feat: update macos native integration

This commit is contained in:
ILoveBinglu
2026-04-06 21:31:29 +08:00
parent 6aa2a516a2
commit 6f9958c1fb
29 changed files with 921 additions and 266 deletions
+102 -10
View File
@@ -2,19 +2,19 @@ import { basename, join } from 'path'
import { existsSync, readdirSync, statSync } from 'fs'
import { homedir } from 'os'
type PathCandidate = {
path: string
accountCount: number
latestModified: number
score: number
}
export class DbPathService {
async autoDetect(): Promise<{ success: boolean; path?: string; error?: string }> {
try {
for (const candidate of this.getPossibleRoots()) {
if (!existsSync(candidate)) continue
if (this.isAccountDir(candidate)) {
return { success: true, path: candidate }
}
if (this.findAccountDirs(candidate).length > 0) {
return { success: true, path: candidate }
}
const candidates = this.collectCandidates()
if (candidates.length > 0) {
return { success: true, path: candidates[0].path }
}
return { success: false, error: '未能自动检测到微信数据库目录' }
@@ -36,6 +36,9 @@ export class DbPathService {
getDefaultPath(): string {
const home = homedir()
const detected = this.collectCandidates()[0]
if (detected) return detected.path
if (process.platform === 'darwin') {
const appSupportBase = join(
home,
@@ -96,6 +99,95 @@ export class DbPathService {
]
}
private collectCandidates(): PathCandidate[] {
const candidates: PathCandidate[] = []
const seen = new Set<string>()
const pushCandidate = (candidatePath: string) => {
const normalized = String(candidatePath || '').replace(/[\\/]+$/, '')
if (!normalized || seen.has(normalized) || !existsSync(normalized)) return
seen.add(normalized)
if (this.isAccountDir(normalized)) {
const latestModified = this.getAccountModifiedTime(normalized)
candidates.push({
path: normalized,
accountCount: 1,
latestModified,
score: 1_000_000 + latestModified
})
return
}
const accounts = this.findAccountDirs(normalized)
if (accounts.length === 0) return
let latestModified = 0
for (const account of accounts) {
latestModified = Math.max(latestModified, this.getAccountModifiedTime(join(normalized, account)))
}
const rootName = basename(normalized).toLowerCase()
const rootBonus =
process.platform === 'darwin' && this.isMacVersionDir(rootName) ? 50_000 :
rootName === 'xwechat_files' ? 30_000 :
rootName === 'wechat files' ? 20_000 :
0
candidates.push({
path: normalized,
accountCount: accounts.length,
latestModified,
score: rootBonus + accounts.length * 10_000 + latestModified
})
}
for (const candidate of this.getPossibleRoots()) {
pushCandidate(candidate)
}
if (process.platform === 'darwin') {
for (const candidate of this.getMacNestedRoots()) {
pushCandidate(candidate)
}
}
return candidates.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score
return a.path.localeCompare(b.path)
})
}
private getMacNestedRoots(): string[] {
const home = homedir()
const appSupportBase = join(
home,
'Library',
'Containers',
'com.tencent.xinWeChat',
'Data',
'Library',
'Application Support',
'com.tencent.xinWeChat'
)
const nestedRoots: string[] = []
for (const entry of this.safeReadDir(appSupportBase)) {
if (!this.isMacVersionDir(entry)) continue
const versionDir = join(appSupportBase, entry)
nestedRoots.push(versionDir)
for (const child of this.safeReadDir(versionDir)) {
if (!this.isPotentialAccountName(child)) continue
nestedRoots.push(join(versionDir, child))
}
}
return nestedRoots
}
private findAccountDirs(rootPath: string): string[] {
const accounts: string[] = []
@@ -0,0 +1,10 @@
import type { ShortcutService, ShortcutUpdateResult } from './shortcutService'
class DarwinShortcutService implements ShortcutService {
async updateDesktopShortcutIcon(_iconPath: string): Promise<ShortcutUpdateResult> {
// macOS 没有与 Windows .lnk 对应的统一桌面快捷方式图标更新入口,这里保持成功返回即可。
return { success: true }
}
}
export const shortcutService = new DarwinShortcutService()
+19 -77
View File
@@ -1,79 +1,21 @@
import { app } from 'electron'
import { spawn } from 'child_process'
import { join } from 'path'
import { existsSync } from 'fs'
export class ShortcutService {
/**
* 更新桌面快捷方式的图标
* 注意:这需要调用 PowerShell,可能会短暂显示控制台窗口或被杀毒软件拦截
* @param iconPath ICO 图标文件的绝对路径
*/
async updateDesktopShortcutIcon(iconPath: string): Promise<{ success: boolean; error?: string }> {
return new Promise((resolve) => {
try {
if (!existsSync(iconPath)) {
resolve({ success: false, error: '图标文件不存在' })
return
}
const desktopPath = app.getPath('desktop')
const exePath = process.execPath
// PowerShell 脚本:遍历桌面所有 .lnk,如果目标指向当前 exe,则修改图标
// 使用 -WindowStyle Hidden 隐藏窗口
const psScript = `
$WshShell = New-Object -comObject WScript.Shell
$DesktopPath = "${desktopPath}"
$TargetExe = "${exePath}"
$IconPath = "${iconPath}"
Get-ChildItem -Path $DesktopPath -Filter *.lnk | ForEach-Object {
try {
$Shortcut = $WshShell.CreateShortcut($_.FullName)
if ($Shortcut.TargetPath -eq $TargetExe) {
$Shortcut.IconLocation = $IconPath
$Shortcut.Save()
Write-Host "Updated: $($_.Name)"
}
} catch {
Write-Error $_.Exception.Message
}
}
`
const ps = spawn('powershell.exe', [
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-WindowStyle', 'Hidden',
'-Command', psScript
])
let output = ''
let errorOutput = ''
ps.stdout.on('data', (data) => {
output += data.toString()
})
ps.stderr.on('data', (data) => {
errorOutput += data.toString()
})
ps.on('close', (code) => {
if (code === 0) {
resolve({ success: true })
} else {
console.error('[ShortcutService] 更新快捷方式失败', errorOutput)
resolve({ success: false, error: errorOutput || 'Unknown PowerShell error' })
}
})
} catch (e) {
console.error('[ShortcutService] 执行出错', e)
resolve({ success: false, error: String(e) })
}
})
}
export type ShortcutUpdateResult = {
success: boolean
error?: string
}
export const shortcutService = new ShortcutService()
export interface ShortcutService {
updateDesktopShortcutIcon(iconPath: string): Promise<ShortcutUpdateResult>
}
import { shortcutService as windowsShortcutService } from './shortcutService.win32'
import { shortcutService as darwinShortcutService } from './shortcutService.darwin'
import { shortcutService as unsupportedShortcutService } from './shortcutService.unsupported'
const shortcutService: ShortcutService =
process.platform === 'win32'
? windowsShortcutService
: process.platform === 'darwin'
? darwinShortcutService
: unsupportedShortcutService
export { shortcutService }
@@ -0,0 +1,12 @@
import type { ShortcutService, ShortcutUpdateResult } from './shortcutService'
class UnsupportedShortcutService implements ShortcutService {
async updateDesktopShortcutIcon(_iconPath: string): Promise<ShortcutUpdateResult> {
return {
success: false,
error: `Desktop shortcut icon update is not supported on ${process.platform}`
}
}
}
export const shortcutService = new UnsupportedShortcutService()
@@ -0,0 +1,72 @@
import { app } from 'electron'
import { spawn } from 'child_process'
import { existsSync } from 'fs'
import type { ShortcutService, ShortcutUpdateResult } from './shortcutService'
class WindowsShortcutService implements ShortcutService {
async updateDesktopShortcutIcon(iconPath: string): Promise<ShortcutUpdateResult> {
return new Promise((resolve) => {
try {
if (!existsSync(iconPath)) {
resolve({ success: false, error: '图标文件不存在' })
return
}
const desktopPath = app.getPath('desktop')
const exePath = process.execPath
const psScript = `
$WshShell = New-Object -comObject WScript.Shell
$DesktopPath = "${desktopPath}"
$TargetExe = "${exePath}"
$IconPath = "${iconPath}"
Get-ChildItem -Path $DesktopPath -Filter *.lnk | ForEach-Object {
try {
$Shortcut = $WshShell.CreateShortcut($_.FullName)
if ($Shortcut.TargetPath -eq $TargetExe) {
$Shortcut.IconLocation = $IconPath
$Shortcut.Save()
Write-Host "Updated: $($_.Name)"
}
} catch {
Write-Error $_.Exception.Message
}
}
`
const ps = spawn('powershell.exe', [
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-WindowStyle', 'Hidden',
'-Command', psScript
])
let errorOutput = ''
ps.stderr.on('data', (data) => {
errorOutput += data.toString()
})
ps.on('error', (error) => {
console.error('[ShortcutService] PowerShell 启动失败', error)
resolve({ success: false, error: String(error) })
})
ps.on('close', (code) => {
if (code === 0) {
resolve({ success: true })
return
}
console.error('[ShortcutService] 更新快捷方式失败', errorOutput)
resolve({ success: false, error: errorOutput || 'Unknown PowerShell error' })
})
} catch (e) {
console.error('[ShortcutService] 执行出错', e)
resolve({ success: false, error: String(e) })
}
})
}
}
export const shortcutService = new WindowsShortcutService()
+183 -87
View File
@@ -7,6 +7,10 @@ export class WcdbService {
private koffi: any = null
private initialized = false
private handle: number | null = null
private currentPath: string | null = null
private currentKey: string | null = null
private currentWxid: string | null = null
private currentDbStoragePath: string | null = null
private wcdbInit: any = null
private wcdbShutdown: any = null
@@ -37,8 +41,8 @@ export class WcdbService {
return join(baseDir, 'WCDB.dll')
}
private findSessionDb(dir: string, depth = 0): string | null {
if (depth > 5) return null
private findSessionDbs(dir: string, depth = 0, results: string[] = []): string[] {
if (depth > 5) return results
try {
const entries = readdirSync(dir)
@@ -46,8 +50,8 @@ export class WcdbService {
for (const entry of entries) {
if (entry.toLowerCase() === 'session.db') {
const fullPath = join(dir, entry)
if (statSync(fullPath).isFile()) {
return fullPath
if (statSync(fullPath).isFile() && !results.includes(fullPath)) {
results.push(fullPath)
}
}
}
@@ -56,8 +60,7 @@ export class WcdbService {
const fullPath = join(dir, entry)
try {
if (statSync(fullPath).isDirectory()) {
const found = this.findSessionDb(fullPath, depth + 1)
if (found) return found
this.findSessionDbs(fullPath, depth + 1, results)
}
} catch {
// ignore
@@ -67,92 +70,89 @@ export class WcdbService {
console.error('查找 session.db 失败:', e)
}
return null
return results
}
private normalizeWxid(wxid: string): string {
const trimmed = String(wxid || '').trim()
if (!trimmed) return ''
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
return match?.[1] || trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
return suffixMatch ? suffixMatch[1] : trimmed
private scoreSessionDbPath(filePath: string): number {
const normalized = filePath.replace(/\\/g, '/').toLowerCase()
let score = 0
if (normalized.endsWith('/session/session.db')) score += 40
if (normalized.includes('/db_storage/session/')) score += 20
if (normalized.includes('/db_storage/')) score += 10
return score
}
private isAccountDir(dirPath: string): boolean {
return (
existsSync(join(dirPath, 'db_storage')) ||
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
existsSync(join(dirPath, 'FileStorage', 'Image2')) ||
existsSync(join(dirPath, 'msg', 'attach'))
)
private getCandidateSessionDbs(dbStoragePath: string): string[] {
return this.findSessionDbs(dbStoragePath)
.sort((a, b) => this.scoreSessionDbPath(b) - this.scoreSessionDbPath(a) || a.localeCompare(b))
}
private resolveAccountRoot(dbPath: string, wxid: string): string | null {
const normalizedDbPath = dbPath.replace(/[\\/]+$/, '')
const direct = join(normalizedDbPath, wxid)
if (existsSync(direct) && this.isAccountDir(direct)) {
return direct
}
private tryOpenWithCandidates(sessionDbPaths: string[], hexKey: string): { success: boolean; handle?: number; matchedPath?: string; errors: string[] } {
const errors: string[] = []
const normalizedWxid = this.normalizeWxid(wxid)
const directNormalized = join(normalizedDbPath, normalizedWxid)
if (existsSync(directNormalized) && this.isAccountDir(directNormalized)) {
return directNormalized
}
if (this.isAccountDir(normalizedDbPath) && basename(normalizedDbPath) === wxid) {
return normalizedDbPath
}
if (this.isAccountDir(normalizedDbPath) && basename(normalizedDbPath) === normalizedWxid) {
return normalizedDbPath
}
try {
for (const entry of readdirSync(normalizedDbPath, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const entryPath = join(normalizedDbPath, entry.name)
if (!this.isAccountDir(entryPath)) continue
const lowerEntry = entry.name.toLowerCase()
const lowerWxid = wxid.toLowerCase()
const lowerNormalizedWxid = normalizedWxid.toLowerCase()
const cleanedEntry = this.normalizeWxid(entry.name).toLowerCase()
if (
lowerEntry === lowerWxid ||
lowerEntry === lowerNormalizedWxid ||
cleanedEntry === lowerWxid ||
cleanedEntry === lowerNormalizedWxid ||
lowerEntry.startsWith(`${lowerWxid}_`) ||
lowerEntry.startsWith(`${lowerNormalizedWxid}_`)
) {
return entryPath
for (const sessionDbPath of sessionDbPaths) {
const handleOut = [0]
const result = this.wcdbOpenAccount(sessionDbPath, hexKey, handleOut)
if (result === 0 && handleOut[0] > 0) {
return {
success: true,
handle: handleOut[0],
matchedPath: sessionDbPath,
errors
}
}
} catch {
// ignore
errors.push(`${sessionDbPath} => ${this.mapStatusCode(result)}`)
}
return null
return { success: false, errors }
}
private resolveDbStoragePath(dbPath: string, wxid: string): string | null {
if (!dbPath) return null
const normalizedDbPath = dbPath.replace(/[\\/]+$/, '')
if (basename(normalizedDbPath).toLowerCase() === 'db_storage' && existsSync(normalizedDbPath)) {
return normalizedDbPath
}
const accountRoot = this.resolveAccountRoot(normalizedDbPath, wxid)
if (!accountRoot) return null
const direct = join(normalizedDbPath, 'db_storage')
if (existsSync(direct)) {
return direct
}
const dbStoragePath = join(accountRoot, 'db_storage')
return existsSync(dbStoragePath) ? dbStoragePath : null
if (wxid) {
const viaWxid = join(normalizedDbPath, wxid, 'db_storage')
if (existsSync(viaWxid)) {
return viaWxid
}
try {
const lowerWxid = wxid.toLowerCase()
for (const entry of readdirSync(normalizedDbPath)) {
const entryPath = join(normalizedDbPath, entry)
try {
if (!statSync(entryPath).isDirectory()) continue
} catch {
continue
}
const lowerEntry = entry.toLowerCase()
if (lowerEntry !== lowerWxid && !lowerEntry.startsWith(`${lowerWxid}_`)) {
continue
}
const candidate = join(entryPath, 'db_storage')
if (existsSync(candidate)) {
return candidate
}
}
} catch {
// ignore
}
}
return null
}
private async initialize(): Promise<{ success: boolean; error?: string }> {
@@ -201,6 +201,20 @@ export class WcdbService {
async testConnection(dbPath: string, hexKey: string, wxid: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
try {
if (
this.handle !== null &&
this.currentPath === dbPath &&
this.currentKey === hexKey &&
this.currentWxid === wxid
) {
return { success: true, sessionCount: 0 }
}
const hadActiveConnection = this.handle !== null
const prevPath = this.currentPath
const prevKey = this.currentKey
const prevWxid = this.currentWxid
const initRes = await this.initialize()
if (!initRes.success) {
return { success: false, error: initRes.error || 'WCDB 初始化失败' }
@@ -211,24 +225,45 @@ export class WcdbService {
return { success: false, error: `未找到账号目录或 db_storage: ${dbPath}` }
}
const sessionDbPath = this.findSessionDb(dbStoragePath)
if (!sessionDbPath) {
return { success: false, error: '未找到 session.db 文件' }
const sessionDbPaths = this.getCandidateSessionDbs(dbStoragePath)
if (sessionDbPaths.length === 0) {
return { success: false, error: `未找到 session.db 文件: ${dbStoragePath}` }
}
const handleOut = [0]
const result = this.wcdbOpenAccount(sessionDbPath, hexKey, handleOut)
if (result !== 0) {
await this.printLogs()
return { success: false, error: this.mapStatusCode(result) }
const openResult = this.tryOpenWithCandidates(sessionDbPaths, hexKey)
if (!openResult.success || !openResult.handle || !openResult.matchedPath) {
const logs = await this.printLogs()
return {
success: false,
error: `数据库打开失败 | db_storage=${dbStoragePath} | tried=${sessionDbPaths.join(', ')}${openResult.errors.length ? ` | details=${openResult.errors.join(' ; ')}` : ''}${logs ? ` | logs=${logs}` : ''}`
}
}
const handle = handleOut[0]
if (handle <= 0) {
const tempHandle = openResult.handle
if (tempHandle <= 0) {
return { success: false, error: '无效的数据库句柄' }
}
this.handle = handle
try {
this.wcdbShutdown()
this.handle = null
this.currentPath = null
this.currentKey = null
this.currentWxid = null
this.currentDbStoragePath = null
this.initialized = false
} catch (e) {
console.error('关闭测试数据库时出错:', e)
}
if (hadActiveConnection && prevPath && prevKey && prevWxid) {
try {
await this.open(prevPath, prevKey, prevWxid)
} catch {
// ignore restore failure
}
}
return { success: true, sessionCount: 0 }
} catch (e) {
console.error('测试连接异常:', e)
@@ -237,8 +272,63 @@ export class WcdbService {
}
async open(dbPath: string, hexKey: string, wxid: string): Promise<boolean> {
const result = await this.testConnection(dbPath, hexKey, wxid)
return result.success
try {
if (
this.handle !== null &&
this.currentPath === dbPath &&
this.currentKey === hexKey &&
this.currentWxid === wxid
) {
return true
}
const initRes = await this.initialize()
if (!initRes.success) {
return false
}
if (this.handle !== null) {
this.close()
const reinitRes = await this.initialize()
if (!reinitRes.success) {
return false
}
}
const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid)
if (!dbStoragePath) {
console.error('数据库目录不存在:', dbPath)
return false
}
const sessionDbPaths = this.getCandidateSessionDbs(dbStoragePath)
if (sessionDbPaths.length === 0) {
console.error('未找到 session.db 文件:', dbStoragePath)
return false
}
const openResult = this.tryOpenWithCandidates(sessionDbPaths, hexKey)
if (!openResult.success || !openResult.handle) {
await this.printLogs()
return false
}
const handle = openResult.handle
if (handle <= 0) {
return false
}
this.handle = handle
this.currentPath = dbPath
this.currentKey = hexKey
this.currentWxid = wxid
this.currentDbStoragePath = dbStoragePath
this.initialized = true
return true
} catch (e) {
console.error('打开数据库异常:', e)
return false
}
}
close(): void {
@@ -261,6 +351,10 @@ export class WcdbService {
this.handle = null
this.initialized = false
this.lib = null
this.currentPath = null
this.currentKey = null
this.currentWxid = null
this.currentDbStoragePath = null
}
shutdown(): void {
@@ -330,19 +424,21 @@ export class WcdbService {
return encryptedData
}
private async printLogs(): Promise<void> {
private async printLogs(): Promise<string> {
try {
if (!this.wcdbGetLogs) return
if (!this.wcdbGetLogs) return ''
const outPtr = [null as any]
const result = this.wcdbGetLogs(outPtr)
if (result === 0 && outPtr[0]) {
const jsonStr = this.koffi.decode(outPtr[0], 'char', -1)
console.error('WCDB 内部日志:', jsonStr)
this.wcdbFreeString(outPtr[0])
return jsonStr
}
} catch (e) {
console.error('获取 WCDB 日志失败:', e)
}
return ''
}
private mapStatusCode(code: number): string {
+95 -40
View File
@@ -81,16 +81,8 @@ export class WxKeyServiceMac {
}
async initialize(): Promise<boolean> {
if (this.initialized) return true
try {
this.koffi = require('koffi')
const dylibPath = this.getDylibPath()
this.lib = this.koffi.load(dylibPath)
this.GetDbKey = this.lib.func('const char* GetDbKey()')
this.ListWeChatProcesses = this.lib.func('const char* ListWeChatProcesses()')
this.initialized = true
return true
return this.initializeFromRuntime()
} catch (e) {
console.error('[WxKeyServiceMac] 初始化失败:', e)
return false
@@ -127,6 +119,16 @@ export class WxKeyServiceMac {
// ignore
}
try {
if (this.initializeFromRuntime()) {
const raw = this.ListWeChatProcesses?.()
const parsed = this.parseWeChatProcessList(typeof raw === 'string' ? raw : '')
if (parsed.length > 0) return Math.max(...parsed)
}
} catch {
// ignore
}
try {
const output = execSync('/bin/ps -A -o pid,comm,command', { encoding: 'utf8' })
const lines = output.split(/\r?\n/).slice(1)
@@ -156,6 +158,39 @@ export class WxKeyServiceMac {
return null
}
private initializeFromRuntime(): boolean {
if (this.initialized) return true
try {
this.koffi = require('koffi')
const dylibPath = this.getDylibPath()
this.lib = this.koffi.load(dylibPath)
this.GetDbKey = this.lib.func('const char* GetDbKey()')
this.ListWeChatProcesses = this.lib.func('const char* ListWeChatProcesses()')
this.initialized = true
return true
} catch {
return false
}
}
private parseWeChatProcessList(raw: string): number[] {
return String(raw || '')
.split(';')
.map(item => item.trim())
.filter(Boolean)
.map(item => {
const lastColon = item.lastIndexOf(':')
if (lastColon < 0) return null
const name = item.slice(0, lastColon)
const pid = Number(item.slice(lastColon + 1))
if (!Number.isFinite(pid) || pid <= 0) return null
if (name.includes('Helper') || name.includes('crashpad_handler') || name.includes('WeChatAppEx')) return null
return pid
})
.filter((pid): pid is number => pid !== null)
}
killWeChat(): boolean {
try {
execSync('/usr/bin/pkill -x WeChat', { stdio: 'ignore' })
@@ -165,6 +200,16 @@ export class WxKeyServiceMac {
}
}
async waitForWeChatExit(maxWaitSeconds = 15): Promise<boolean> {
for (let i = 0; i < maxWaitSeconds * 2; i++) {
if (!this.isWeChatRunning()) {
return true
}
await new Promise(resolve => setTimeout(resolve, 500))
}
return !this.isWeChatRunning()
}
async launchWeChat(customPath?: string): Promise<boolean> {
try {
if (customPath && existsSync(customPath)) {
@@ -198,18 +243,20 @@ export class WxKeyServiceMac {
if (sipStatus.enabled) {
return {
success: false,
error: 'SIP 已开启,无法抓取 macOS 微信数据库密钥。请关闭 SIP 后重试。'
error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法:\n1. Intel 芯片:重启 Mac 并按住 Command + R 进入恢复模式\n2. Apple 芯片(M 系列):关机后长按开机(指纹)键,选择“设置(选项)”进入恢复模式\n3. 打开终端,输入: csrutil disable\n4. 重启电脑'
}
}
onStatus?.('正在请求管理员授权并启动 helper...', 0)
onStatus?.('正在获取数据库密钥...', 0)
onStatus?.('正在请求管理员授权并执行 helper...', 0)
let parsed: { success: boolean; key?: string; code?: string; detail?: string; raw: string }
try {
const helperResult = await this.getDbKeyByHelperElevated(timeoutMs, onStatus)
parsed = this.parseDbKeyResult(helperResult)
console.log('[WxKeyServiceMac] GetDbKey elevated returned:', parsed.raw)
} catch (e: any) {
const msg = String(e?.message || e)
const msg = `${e?.message || e}`
if (msg.includes('(-128)') || msg.includes('User canceled')) {
return { success: false, error: '已取消管理员授权' }
}
@@ -217,9 +264,11 @@ export class WxKeyServiceMac {
}
if (!parsed.success) {
const errorMsg = this.mapDbKeyErrorMessage(parsed.code, parsed.detail)
onStatus?.(errorMsg, 2)
return {
success: false,
error: this.mapDbKeyErrorMessage(parsed.code, parsed.detail)
error: errorMsg
}
}
@@ -227,8 +276,9 @@ export class WxKeyServiceMac {
return { success: true, key: parsed.key }
} catch (e: any) {
console.error('[WxKeyServiceMac] 获取密钥失败:', e)
console.error('[WxKeyServiceMac] Stack:', e.stack)
onStatus?.(`获取失败: ${e.message}`, 2)
return { success: false, error: e.message || String(e) }
return { success: false, error: e.message }
}
}
@@ -251,7 +301,7 @@ export class WxKeyServiceMac {
`set timeoutSec to ${timeoutSec}`,
'try',
'with timeout of timeoutSec seconds',
'set outText to do shell script (cmd & " 2>&1") with administrator privileges',
'set outText to do shell script cmd with administrator privileges',
'end timeout',
'return "WF_OK::" & outText',
'on error errMsg number errNum partial result pr',
@@ -259,42 +309,47 @@ export class WxKeyServiceMac {
'end try'
]
onStatus?.(`已找到微信进程 PID=${pid},正在请求管理员授权...`, 0)
onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0)
const result = await execFileAsync(
'/usr/bin/osascript',
scriptLines.flatMap(line => ['-e', line]),
{ timeout: waitMs + 20_000 }
)
const lines = String(result.stdout || '').split(/\r?\n/).map(x => x.trim()).filter(Boolean)
if (lines.length === 0) {
throw new Error('helper 返回空输出')
let stdout = ''
try {
const result = await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {
timeout: waitMs + 20_000
})
stdout = result.stdout
} catch (e: any) {
const msg = `${e?.stderr || ''}\n${e?.stdout || ''}\n${e?.message || ''}`.trim()
throw new Error(msg || 'elevated helper execution failed')
}
const lines = String(stdout).split(/\r?\n/).map(x => x.trim()).filter(Boolean)
if (!lines.length) throw new Error('elevated helper returned empty output')
const joined = lines.join('\n')
if (joined.startsWith('WF_ERR::')) {
const parts = joined.split('::')
throw new Error(`elevated helper failed: errNum=${parts[1] || 'unknown'}, errMsg=${parts[2] || 'unknown'}, partial=${parts.slice(3).join('::') || '(empty)'}`)
const errNum = parts[1] || 'unknown'
const errMsg = parts[2] || 'unknown'
const partial = parts.slice(3).join('::')
throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${errMsg}, partial=${partial || '(empty)'}`)
}
const normalizedOutput = joined.startsWith('WF_OK::') ? joined.slice('WF_OK::'.length) : joined
const payloads = normalizedOutput.match(/\{[^{}]*\}/g) ?? []
for (const item of payloads) {
try {
const parsed = JSON.parse(item)
if (parsed?.success === true && typeof parsed?.key === 'string') {
return parsed.key
}
if (typeof parsed?.result === 'string') {
return parsed.result
}
} catch {
// ignore
const extractJsonObjects = (s: string): any[] => {
const results: any[] = []
const re = /\{[^{}]*\}/g
let m: RegExpExecArray | null
while ((m = re.exec(s)) !== null) {
try { results.push(JSON.parse(m[0])) } catch { }
}
return results
}
throw new Error(`elevated helper returned invalid output: ${normalizedOutput}`)
const allJson = extractJsonObjects(normalizedOutput)
const successPayload = allJson.find(p => p?.success === true && typeof p?.key === 'string')
if (successPayload) return successPayload.key
const resultPayload = allJson.find(p => typeof p?.result === 'string')
if (resultPayload) return resultPayload.result
throw new Error('elevated helper returned invalid json: ' + lines[lines.length - 1])
}
private parseDbKeyResult(raw: any): { success: boolean; key?: string; code?: string; detail?: string; raw: string } {