mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-06-15 09:27:23 +08:00
feat: update macos native integration
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
Reference in New Issue
Block a user