mirror of
https://mirror.skon.top/github.com/ILoveBingLu/CipherTalk
synced 2026-04-30 22:01:03 +08:00
feat: update macos native integration
This commit is contained in:
2
WeFlow
2
WeFlow
Submodule WeFlow updated: 4da9f1e6cf...57fad47f27
188
electron/main.ts
188
electron/main.ts
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow, ipcMain, nativeTheme, protocol, net, Tray, Menu } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain, nativeImage, nativeTheme, protocol, net, Tray, Menu } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { readFileSync, existsSync, mkdirSync } from 'fs'
|
||||
@@ -183,15 +183,54 @@ function getAppIconPath(): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getDockIconPath(): string {
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconName = configService?.get('appIcon') || 'default'
|
||||
|
||||
if (iconName === 'xinnian') {
|
||||
const devPaddedPath = join(__dirname, '../public/xinnian-dock.png')
|
||||
const devFallbackPath = join(__dirname, '../public/xinnian.png')
|
||||
return isDev
|
||||
? (existsSync(devPaddedPath) ? devPaddedPath : devFallbackPath)
|
||||
: join(process.resourcesPath, 'icon.png')
|
||||
}
|
||||
|
||||
const devPaddedPath = join(__dirname, '../public/icon-dock.png')
|
||||
const devFallbackPath = join(__dirname, '../public/logo.png')
|
||||
return isDev
|
||||
? (existsSync(devPaddedPath) ? devPaddedPath : devFallbackPath)
|
||||
: join(process.resourcesPath, 'icon.png')
|
||||
}
|
||||
|
||||
function getTrayIconPath(): string {
|
||||
if (process.platform === 'darwin') {
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconName = configService?.get('appIcon') || 'default'
|
||||
const devTrayPath = iconName === 'xinnian'
|
||||
? join(__dirname, '../public/xinnian-tray.png')
|
||||
: join(__dirname, '../public/tray-mac.png')
|
||||
|
||||
if (isDev && existsSync(devTrayPath)) {
|
||||
return devTrayPath
|
||||
}
|
||||
}
|
||||
|
||||
return getAppIconPath()
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建系统托盘
|
||||
*/
|
||||
function createTray() {
|
||||
if (tray) return tray
|
||||
|
||||
const iconPath = getAppIconPath()
|
||||
const iconPath = getTrayIconPath()
|
||||
tray = new Tray(iconPath)
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
tray.setIgnoreDoubleClickEvents(true)
|
||||
}
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: '显示主窗口',
|
||||
@@ -1406,14 +1445,20 @@ function registerIpcHandlers() {
|
||||
|
||||
ipcMain.handle('app:setAppIcon', async (_, iconName: string) => {
|
||||
try {
|
||||
const iconPath = getAppIconPath()
|
||||
const iconPath = process.platform === 'darwin' ? getDockIconPath() : getAppIconPath()
|
||||
|
||||
if (existsSync(iconPath)) {
|
||||
const { nativeImage } = require('electron')
|
||||
const image = nativeImage.createFromPath(iconPath)
|
||||
BrowserWindow.getAllWindows().forEach(win => {
|
||||
win.setIcon(image)
|
||||
})
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
if (!image.isEmpty()) {
|
||||
app.dock.setIcon(image)
|
||||
}
|
||||
} else {
|
||||
BrowserWindow.getAllWindows().forEach(win => {
|
||||
win.setIcon(image)
|
||||
})
|
||||
}
|
||||
|
||||
// 尝试更新桌面快捷方式图标 (不阻塞主线程)
|
||||
shortcutService.updateDesktopShortcutIcon(iconPath).catch(err => {
|
||||
@@ -1713,20 +1758,111 @@ function registerIpcHandlers() {
|
||||
return wxKeyService.waitForWeChatWindow(maxWaitSeconds)
|
||||
})
|
||||
|
||||
ipcMain.handle('wxkey:startGetKey', async (event, customWechatPath?: string) => {
|
||||
ipcMain.handle('wxkey:startGetKey', async (event, customWechatPath?: string, dbPath?: string) => {
|
||||
logService?.info('WxKey', '开始获取微信密钥', { customWechatPath })
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
const isRunning = wxKeyServiceMac.isWeChatRunning()
|
||||
if (isRunning) {
|
||||
event.sender.send('wxkey:status', { status: '检测到微信正在运行,正在关闭微信...', level: 0 })
|
||||
wxKeyServiceMac.killWeChat()
|
||||
|
||||
const exited = await wxKeyServiceMac.waitForWeChatExit(20)
|
||||
if (!exited) {
|
||||
return { success: false, error: '未能自动关闭微信,请先手动退出微信后重试' }
|
||||
}
|
||||
|
||||
event.sender.send('wxkey:status', { status: '微信已关闭,正在重新启动微信...', level: 0 })
|
||||
const relaunched = await wxKeyServiceMac.launchWeChat(customWechatPath)
|
||||
if (!relaunched) {
|
||||
return { success: false, error: '微信关闭后自动重启失败' }
|
||||
}
|
||||
|
||||
event.sender.send('wxkey:status', { status: '微信已重新启动,等待主进程就绪...', level: 0 })
|
||||
const ready = await wxKeyServiceMac.waitForWeChatWindow(20)
|
||||
if (!ready) {
|
||||
return { success: false, error: '微信已重新启动,但未检测到可用主进程,请确认微信已完成启动并显示主窗口' }
|
||||
}
|
||||
} else {
|
||||
event.sender.send('wxkey:status', { status: '未检测到微信主进程,正在尝试启动微信...', level: 0 })
|
||||
|
||||
const launched = await wxKeyServiceMac.launchWeChat(customWechatPath)
|
||||
if (!launched) {
|
||||
return { success: false, error: '未找到微信主进程,且自动启动微信失败' }
|
||||
}
|
||||
|
||||
event.sender.send('wxkey:status', { status: '微信已启动,等待主进程就绪...', level: 0 })
|
||||
const ready = await wxKeyServiceMac.waitForWeChatWindow(20)
|
||||
if (!ready) {
|
||||
return { success: false, error: '微信已启动,但未检测到可用主进程,请确认微信已完成启动并显示主窗口' }
|
||||
}
|
||||
}
|
||||
|
||||
const result = await wxKeyServiceMac.autoGetDbKey(180_000, (status, level) => {
|
||||
event.sender.send('wxkey:status', { status, level })
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
logService?.info('WxKey', 'macOS 数据库密钥获取成功', { keyLength: result.key?.length || 0 })
|
||||
} else {
|
||||
if (!result.success) {
|
||||
logService?.warn('WxKey', 'macOS 数据库密钥获取失败', { error: result.error })
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.key && dbPath) {
|
||||
event.sender.send('wxkey:status', { status: '已获取候选密钥,正在验证数据库...', level: 0 })
|
||||
|
||||
const wxidCandidates: string[] = []
|
||||
const pushWxid = (value?: string | null) => {
|
||||
const wxid = String(value || '').trim()
|
||||
if (!wxid || wxidCandidates.includes(wxid)) return
|
||||
wxidCandidates.push(wxid)
|
||||
}
|
||||
|
||||
let currentAccount = wxKeyServiceMac.detectCurrentAccount(dbPath, 10)
|
||||
if (!currentAccount) {
|
||||
currentAccount = wxKeyServiceMac.detectCurrentAccount(dbPath, 60)
|
||||
}
|
||||
pushWxid(currentAccount?.wxid)
|
||||
|
||||
try {
|
||||
const scannedWxids = dbPathService.scanWxids(dbPath)
|
||||
for (const wxid of scannedWxids) {
|
||||
pushWxid(wxid)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
let validatedWxid = ''
|
||||
let lastError = ''
|
||||
for (const wxid of wxidCandidates) {
|
||||
event.sender.send('wxkey:status', { status: `正在验证账号目录: ${wxid}`, level: 0 })
|
||||
const testResult = await wcdbService.testConnection(dbPath, result.key, wxid)
|
||||
if (testResult.success) {
|
||||
validatedWxid = wxid
|
||||
break
|
||||
}
|
||||
lastError = testResult.error || ''
|
||||
}
|
||||
|
||||
if (!validatedWxid) {
|
||||
logService?.warn('WxKey', 'macOS 候选密钥未通过数据库验证', {
|
||||
dbPath,
|
||||
candidateCount: wxidCandidates.length
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: lastError || '已捕获到候选密钥,但未通过数据库验证。请在微信完成登录后进入任意聊天,让数据库访问真正触发,再重试。'
|
||||
}
|
||||
}
|
||||
|
||||
logService?.info('WxKey', 'macOS 候选密钥已通过数据库验证', { dbPath, wxid: validatedWxid })
|
||||
return {
|
||||
...result,
|
||||
validatedWxid
|
||||
}
|
||||
}
|
||||
|
||||
logService?.info('WxKey', 'macOS 数据库密钥获取成功', { keyLength: result.key?.length || 0 })
|
||||
return result
|
||||
} catch (e) {
|
||||
wxKeyServiceMac.dispose()
|
||||
@@ -1889,6 +2025,26 @@ function registerIpcHandlers() {
|
||||
return result
|
||||
})
|
||||
|
||||
ipcMain.handle('wcdb:resolveValidWxid', async (_, dbPath: string, hexKey: string) => {
|
||||
try {
|
||||
const wxids = dbPathService.scanWxids(dbPath)
|
||||
if (wxids.length === 0) {
|
||||
return { success: false, error: '未检测到账号目录' }
|
||||
}
|
||||
|
||||
for (const wxid of wxids) {
|
||||
const result = await wcdbService.testConnection(dbPath, hexKey, wxid, true)
|
||||
if (result.success) {
|
||||
return { success: true, wxid }
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: '未找到可通过当前密钥验证的账号目录' }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('wcdb:open', async (_, dbPath: string, hexKey: string, wxid: string) => {
|
||||
return wcdbService.open(dbPath, hexKey, wxid)
|
||||
})
|
||||
@@ -3963,6 +4119,16 @@ app.whenReady().then(async () => {
|
||||
configService = new ConfigService()
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const dockIconPath = getDockIconPath()
|
||||
if (existsSync(dockIconPath)) {
|
||||
const dockIcon = nativeImage.createFromPath(dockIconPath)
|
||||
if (!dockIcon.isEmpty()) {
|
||||
app.dock.setIcon(dockIcon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!configService.get('mcpProxyToken')) {
|
||||
configService.set('mcpProxyToken', randomBytes(24).toString('hex'))
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
killWeChat: () => ipcRenderer.invoke('wxkey:killWeChat'),
|
||||
launchWeChat: () => ipcRenderer.invoke('wxkey:launchWeChat'),
|
||||
waitForWindow: (maxWaitSeconds?: number) => ipcRenderer.invoke('wxkey:waitForWindow', maxWaitSeconds),
|
||||
startGetKey: (customWechatPath?: string) => ipcRenderer.invoke('wxkey:startGetKey', customWechatPath),
|
||||
startGetKey: (customWechatPath?: string, dbPath?: string) => ipcRenderer.invoke('wxkey:startGetKey', customWechatPath, dbPath),
|
||||
cancel: () => ipcRenderer.invoke('wxkey:cancel'),
|
||||
detectCurrentAccount: (dbPath?: string, maxTimeDiffMinutes?: number) => ipcRenderer.invoke('wxkey:detectCurrentAccount', dbPath, maxTimeDiffMinutes),
|
||||
onStatus: (callback: (data: { status: string; level: number }) => void) => {
|
||||
@@ -197,6 +197,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
wcdb: {
|
||||
testConnection: (dbPath: string, hexKey: string, wxid: string, isAutoConnect?: boolean) =>
|
||||
ipcRenderer.invoke('wcdb:testConnection', dbPath, hexKey, wxid, isAutoConnect),
|
||||
resolveValidWxid: (dbPath: string, hexKey: string) =>
|
||||
ipcRenderer.invoke('wcdb:resolveValidWxid', dbPath, hexKey),
|
||||
open: (dbPath: string, hexKey: string, wxid: string) =>
|
||||
ipcRenderer.invoke('wcdb:open', dbPath, hexKey, wxid),
|
||||
close: () => ipcRenderer.invoke('wcdb:close'),
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
|
||||
10
electron/services/shortcutService.darwin.ts
Normal file
10
electron/services/shortcutService.darwin.ts
Normal file
@@ -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 }
|
||||
|
||||
12
electron/services/shortcutService.unsupported.ts
Normal file
12
electron/services/shortcutService.unsupported.ts
Normal file
@@ -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()
|
||||
72
electron/services/shortcutService.win32.ts
Normal file
72
electron/services/shortcutService.win32.ts
Normal file
@@ -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 } {
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -57,7 +57,7 @@
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"adm-zip": "^0.5.16",
|
||||
"electron": "^39.2.7",
|
||||
"electron": "^39.6.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"sass": "^1.83.0",
|
||||
"sharp": "^0.34.5",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"main": "dist-electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"icon:mac": "bash scripts/build-macos-icon.sh",
|
||||
"prebuild": "node scripts/update-readme-version.js && node scripts/prepare-release-announcement.js",
|
||||
"build": "tsc && vite build && electron-builder && node scripts/add-size-to-yml.js",
|
||||
"build:ci": "node scripts/prepare-release-announcement.js && tsc && vite build && electron-builder --publish never && node scripts/add-size-to-yml.js",
|
||||
@@ -24,9 +25,9 @@
|
||||
"postinstall": "electron-rebuild"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@mui/material": "^7.3.9",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/marked": "^5.0.2",
|
||||
@@ -71,7 +72,7 @@
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"adm-zip": "^0.5.16",
|
||||
"electron": "^39.2.7",
|
||||
"electron": "^39.6.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"sass": "^1.83.0",
|
||||
"sharp": "^0.34.5",
|
||||
@@ -101,6 +102,7 @@
|
||||
"requestedExecutionLevel": "asInvoker"
|
||||
},
|
||||
"mac": {
|
||||
"icon": "public/icon.icns",
|
||||
"category": "public.app-category.utilities",
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false,
|
||||
|
||||
BIN
public/icon-dock.png
Normal file
BIN
public/icon-dock.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/icon.icns
Normal file
BIN
public/icon.icns
Normal file
Binary file not shown.
BIN
public/tray-mac.png
Normal file
BIN
public/tray-mac.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/xinnian-dock.png
Normal file
BIN
public/xinnian-dock.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/xinnian-tray.png
Normal file
BIN
public/xinnian-tray.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
BIN
public/xinnian.icns
Normal file
BIN
public/xinnian.icns
Normal file
Binary file not shown.
BIN
resources/macos/image_scan_helper
Executable file
BIN
resources/macos/image_scan_helper
Executable file
Binary file not shown.
BIN
resources/macos/libdobby.dylib
Executable file
BIN
resources/macos/libdobby.dylib
Executable file
Binary file not shown.
BIN
resources/macos/libwcdb_api.dylib
Executable file
BIN
resources/macos/libwcdb_api.dylib
Executable file
Binary file not shown.
BIN
resources/macos/libwcdb_decrypt.dylib
Executable file
BIN
resources/macos/libwcdb_decrypt.dylib
Executable file
Binary file not shown.
BIN
resources/macos/libwx_key.dylib
Executable file
BIN
resources/macos/libwx_key.dylib
Executable file
Binary file not shown.
BIN
resources/macos/xkey_helper
Executable file
BIN
resources/macos/xkey_helper
Executable file
Binary file not shown.
46
scripts/build-macos-icon.sh
Normal file
46
scripts/build-macos-icon.sh
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SRC_PNG="${1:-${ROOT_DIR}/public/logo.png}"
|
||||
OUT_ICNS="${2:-${ROOT_DIR}/public/icon.icns}"
|
||||
OUT_DOCK_PNG="${3:-${ROOT_DIR}/public/icon-dock.png}"
|
||||
OUT_TRAY_PNG="${4:-${ROOT_DIR}/public/tray-mac.png}"
|
||||
ICONSET_DIR="${ROOT_DIR}/public/icon.iconset"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
PADDED_MASTER="${TMP_DIR}/padded-master.png"
|
||||
INNER_SIZE="${INNER_SIZE:-824}"
|
||||
TRAY_INNER_SIZE="${TRAY_INNER_SIZE:-44}"
|
||||
|
||||
if [[ ! -f "${SRC_PNG}" ]]; then
|
||||
echo "source png not found: ${SRC_PNG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
rm -rf "${ICONSET_DIR}" "${TMP_DIR}"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
rm -rf "${ICONSET_DIR}"
|
||||
mkdir -p "${ICONSET_DIR}"
|
||||
|
||||
sips -z "${INNER_SIZE}" "${INNER_SIZE}" "${SRC_PNG}" --out "${PADDED_MASTER}" >/dev/null
|
||||
sips -p 1024 1024 "${PADDED_MASTER}" --out "${OUT_DOCK_PNG}" >/dev/null
|
||||
|
||||
TRAY_MASTER="${TMP_DIR}/tray-master.png"
|
||||
sips -z "${TRAY_INNER_SIZE}" "${TRAY_INNER_SIZE}" "${SRC_PNG}" --out "${TRAY_MASTER}" >/dev/null
|
||||
sips -p 64 64 "${TRAY_MASTER}" --out "${OUT_TRAY_PNG}" >/dev/null
|
||||
|
||||
for size in 16 32 128 256 512; do
|
||||
sips -z "${size}" "${size}" "${OUT_DOCK_PNG}" --out "${ICONSET_DIR}/icon_${size}x${size}.png" >/dev/null
|
||||
retina_size=$((size * 2))
|
||||
sips -z "${retina_size}" "${retina_size}" "${OUT_DOCK_PNG}" --out "${ICONSET_DIR}/icon_${size}x${size}@2x.png" >/dev/null
|
||||
done
|
||||
|
||||
iconutil -c icns "${ICONSET_DIR}" -o "${OUT_ICNS}"
|
||||
|
||||
echo "generated ${OUT_ICNS}"
|
||||
echo "generated ${OUT_DOCK_PNG}"
|
||||
echo "generated ${OUT_TRAY_PNG}"
|
||||
@@ -691,13 +691,34 @@ function SettingsPage() {
|
||||
setKeyStatus(status)
|
||||
})
|
||||
|
||||
const result = await window.electronAPI.wxKey.startGetKey()
|
||||
const result = await window.electronAPI.wxKey.startGetKey(undefined, dbPath || undefined)
|
||||
removeListener()
|
||||
|
||||
if (result.success && result.key) {
|
||||
setDecryptKey(result.key)
|
||||
await configService.setDecryptKey(result.key)
|
||||
|
||||
if (dbPath) {
|
||||
const resolved = await window.electronAPI.wcdb.resolveValidWxid(dbPath, result.key)
|
||||
if (resolved.success && resolved.wxid) {
|
||||
setWxid(resolved.wxid)
|
||||
setIsAccountVerified(true)
|
||||
await configService.setMyWxid(resolved.wxid)
|
||||
showMessage(`密钥获取成功!已验证账号: ${resolved.wxid}`, true)
|
||||
setKeyStatus('')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (result.validatedWxid) {
|
||||
setWxid(result.validatedWxid)
|
||||
setIsAccountVerified(true)
|
||||
await configService.setMyWxid(result.validatedWxid)
|
||||
showMessage(`密钥获取成功!已验证账号: ${result.validatedWxid}`, true)
|
||||
setKeyStatus('')
|
||||
return
|
||||
}
|
||||
|
||||
setKeyStatus('正在检测当前登录账号...')
|
||||
|
||||
let accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 10)
|
||||
@@ -771,7 +792,7 @@ function SettingsPage() {
|
||||
})
|
||||
|
||||
setKeyStatus('Hook 已安装,请登录微信...')
|
||||
const result = await window.electronAPI.wxKey.startGetKey()
|
||||
const result = await window.electronAPI.wxKey.startGetKey(undefined, dbPath || undefined)
|
||||
removeListener()
|
||||
|
||||
if (result.success && result.key) {
|
||||
@@ -893,10 +914,36 @@ function SettingsPage() {
|
||||
setWxidOptions([])
|
||||
setShowWxidDropdown(false)
|
||||
} else {
|
||||
// 多个账号,显示选择下拉框
|
||||
let selectedWxid = ''
|
||||
|
||||
if (decryptKey.length === 64) {
|
||||
const resolved = await window.electronAPI.wcdb.resolveValidWxid(dbPath, decryptKey)
|
||||
if (resolved.success && resolved.wxid && wxids.includes(resolved.wxid)) {
|
||||
selectedWxid = resolved.wxid
|
||||
setWxid(selectedWxid)
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedWxid) {
|
||||
let accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 10)
|
||||
if (!accountInfo) {
|
||||
accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 60)
|
||||
}
|
||||
|
||||
if (accountInfo && wxids.includes(accountInfo.wxid)) {
|
||||
selectedWxid = accountInfo.wxid
|
||||
setWxid(selectedWxid)
|
||||
}
|
||||
}
|
||||
|
||||
setWxidOptions(wxids)
|
||||
setShowWxidDropdown(true)
|
||||
showMessage(`检测到 ${wxids.length} 个候选账号目录,请选择后验证`, true)
|
||||
showMessage(
|
||||
selectedWxid
|
||||
? `检测到 ${wxids.length} 个候选账号目录,已按最新活动优先选择:${selectedWxid}`
|
||||
: `检测到 ${wxids.length} 个候选账号目录,请选择后验证`,
|
||||
true
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`扫描失败: ${e}`, false)
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
.welcome-page.is-standalone .window-controls {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 18px;
|
||||
left: 18px;
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
@@ -306,6 +306,7 @@
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
/* 自定义滚动条 */
|
||||
&::-webkit-scrollbar {
|
||||
@@ -398,6 +399,7 @@
|
||||
|
||||
.info-content {
|
||||
margin-top: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info-content h3 {
|
||||
@@ -417,6 +419,7 @@
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
list-style: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info-list li {
|
||||
@@ -425,6 +428,9 @@
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
padding-left: 8px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.info-list li::before {
|
||||
@@ -435,12 +441,16 @@
|
||||
}
|
||||
|
||||
.info-list code {
|
||||
display: inline;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(139, 115, 85, 0.1);
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
font-family: 'Consolas', monospace;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.info-tips {
|
||||
|
||||
@@ -44,6 +44,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [isScanningWxid, setIsScanningWxid] = useState(false)
|
||||
const [isDetectingPath, setIsDetectingPath] = useState(false)
|
||||
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
||||
const [isFetchingImageKey, setIsFetchingImageKey] = useState(false)
|
||||
const [showDecryptKey, setShowDecryptKey] = useState(false)
|
||||
@@ -199,6 +200,14 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const rootClassName = `welcome-page${isClosing ? ' is-closing' : ''}${standalone ? ' is-standalone' : ''}`
|
||||
const showWindowControls = standalone
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep.id !== 'db') return
|
||||
if (dbPath) return
|
||||
if (isDetectingPath) return
|
||||
|
||||
void handleAutoDetectPath(true)
|
||||
}, [currentStep.id, dbPath, isDetectingPath])
|
||||
|
||||
const handleMinimize = () => {
|
||||
window.electronAPI.window.minimize()
|
||||
}
|
||||
@@ -234,6 +243,48 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoDetectPath = async (silent = false) => {
|
||||
if (isDetectingPath) return
|
||||
|
||||
setIsDetectingPath(true)
|
||||
if (!silent) setError('')
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.dbPath.autoDetect()
|
||||
if (result.success && result.path) {
|
||||
setDbPath(result.path)
|
||||
setError('')
|
||||
return
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
setError(result.error || '未能自动检测到微信数据库目录')
|
||||
}
|
||||
} catch (e) {
|
||||
if (!silent) {
|
||||
setError(`自动检测失败: ${e}`)
|
||||
}
|
||||
} finally {
|
||||
setIsDetectingPath(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenDetectedPath = async () => {
|
||||
if (!dbPath) {
|
||||
setError('当前没有可打开的数据库目录')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.shell.openPath(dbPath)
|
||||
if (result) {
|
||||
setError(result)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`打开目录失败: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const handleSelectCachePath = async () => {
|
||||
@@ -265,11 +316,38 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
setWxidOptions(wxids)
|
||||
setIsAccountVerified(false)
|
||||
if (wxids.length > 0) {
|
||||
// 密钥前仅做候选识别,默认优先 wxid_ 前缀目录
|
||||
const wxidAccount = wxids.find(id => id.startsWith('wxid_'))
|
||||
const selectedWxid = wxidAccount || wxids[0]
|
||||
setWxid(selectedWxid)
|
||||
if (!silent) setError('')
|
||||
let selectedWxid = ''
|
||||
|
||||
if (decryptKey.length === 64) {
|
||||
const resolved = await window.electronAPI.wcdb.resolveValidWxid(dbPath, decryptKey)
|
||||
if (resolved.success && resolved.wxid && wxids.includes(resolved.wxid)) {
|
||||
selectedWxid = resolved.wxid
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedWxid) {
|
||||
let accountInfo: { wxid: string; dbPath: string } | null = null
|
||||
accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 10)
|
||||
if (!accountInfo) {
|
||||
accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 60)
|
||||
}
|
||||
|
||||
if (accountInfo && wxids.includes(accountInfo.wxid)) {
|
||||
selectedWxid = accountInfo.wxid
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedWxid) {
|
||||
const wxidAccount = wxids.find(id => id.startsWith('wxid_'))
|
||||
selectedWxid = wxidAccount || wxids[0]
|
||||
}
|
||||
|
||||
if (selectedWxid) {
|
||||
setWxid(selectedWxid)
|
||||
if (!silent) setError('')
|
||||
} else {
|
||||
if (!silent) setError('未能自动确定正确账号目录,请手动选择')
|
||||
}
|
||||
} else {
|
||||
if (!silent) setError('未检测到账号目录,请检查路径')
|
||||
}
|
||||
@@ -288,13 +366,29 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
setError('')
|
||||
setDbKeyStatus('正在准备获取密钥...')
|
||||
try {
|
||||
const result = await window.electronAPI.wxKey.startGetKey(wechatPath)
|
||||
const result = await window.electronAPI.wxKey.startGetKey(wechatPath, dbPath || undefined)
|
||||
if (result.success && result.key) {
|
||||
setDecryptKey(result.key)
|
||||
setDbKeyStatus('密钥获取成功,正在验证账号目录...')
|
||||
setError('')
|
||||
setShowWechatPathPrompt(false)
|
||||
|
||||
if (dbPath) {
|
||||
const resolved = await window.electronAPI.wcdb.resolveValidWxid(dbPath, result.key)
|
||||
if (resolved.success && resolved.wxid) {
|
||||
setWxid(resolved.wxid)
|
||||
setIsAccountVerified(true)
|
||||
setDbKeyStatus(`密钥获取成功,已验证账号目录: ${resolved.wxid}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (result.validatedWxid) {
|
||||
setWxid(result.validatedWxid)
|
||||
setDbKeyStatus(`密钥获取成功,已验证账号目录: ${result.validatedWxid}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 先尝试当前登录账号检测(强信号)
|
||||
let accountInfo: { wxid: string; dbPath: string } | null = null
|
||||
if (dbPath) {
|
||||
@@ -593,12 +687,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
<div className={rootClassName}>
|
||||
{showWindowControls && (
|
||||
<div className="window-controls">
|
||||
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<button type="button" className="window-btn is-close" onClick={handleCloseWindow} aria-label="关闭">
|
||||
<X size={14} />
|
||||
</button>
|
||||
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -678,20 +772,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
|
||||
{currentStep.id === 'db' && (
|
||||
<div className="info-content">
|
||||
<h3>数据库目录说明</h3>
|
||||
<p>这是微信存储聊天记录的根目录,通常位于:</p>
|
||||
<h3>自动获取数据库目录</h3>
|
||||
<p>系统会优先自动识别当前设备上的微信数据存储目录。</p>
|
||||
<ul className="info-list">
|
||||
{isMac ? (
|
||||
<li><code>~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/<version></code> 或旧版 <code>xwechat_files</code></li>
|
||||
) : (
|
||||
<li>微信 → 设置 → 账号与存储 → 存储位置</li>
|
||||
)}
|
||||
{isMac ? (
|
||||
<li>支持 4.0.5+ 新路径和旧版 <code>xwechat_files</code> 路径</li>
|
||||
) : (
|
||||
<li>按照上面的路径找到 <code>xwechat_files</code> 目录</li>
|
||||
)}
|
||||
<li>{isMac ? '建议优先选择版本目录或账号根目录' : '路径中不能包含中文字符'}</li>
|
||||
<li>进入本步骤后会先尝试自动检测</li>
|
||||
<li>检测到结果后可直接打开文件夹确认</li>
|
||||
<li>{isMac ? '若未命中,再手动选择版本目录或账号目录' : '若未命中,再按微信存储位置手动选择'}</li>
|
||||
</ul>
|
||||
{!isMac && (
|
||||
<div className="info-warning">
|
||||
@@ -718,15 +804,15 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
{currentStep.id === 'key' && (
|
||||
<div className="info-content">
|
||||
<h3>解密密钥说明</h3>
|
||||
<p>用于解密微信数据库的64位十六进制密钥。</p>
|
||||
<p>此步骤会在本机完成密钥识别与账号校验。</p>
|
||||
<ul className="info-list">
|
||||
<li>{isMac ? 'macOS 通过 helper + 断点捕获获取 DbKey' : '点击"自动获取"会自动启动微信'}</li>
|
||||
<li>{isMac ? '此流程要求先关闭 SIP,并允许管理员提权' : '等待提示"hook安装成功"后登录'}</li>
|
||||
<li>{isMac ? '成功后会自动回填 64 位 DbKey 并尝试识别账号' : '登录后会自动识别账号'}</li>
|
||||
<li>{isMac ? '建议先启动微信,并按界面提示完成授权' : '点击“自动获取”后按提示操作'}</li>
|
||||
<li>{isMac ? '识别完成后会自动尝试匹配账号目录' : '完成后会自动识别账号目录'}</li>
|
||||
<li>密钥仅保存在本地配置中</li>
|
||||
</ul>
|
||||
<div className="info-warning">
|
||||
<ShieldCheck size={16} />
|
||||
<span>{isMac ? '若 SIP 未关闭,自动获取会直接失败并给出提示' : '密钥仅保存在本地,不会上传'}</span>
|
||||
<span>{isMac ? '若系统环境不满足要求,界面会直接给出提示' : '密钥不会上传到服务器'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -822,9 +908,25 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
value={dbPath}
|
||||
onChange={(e) => setDbPath(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-primary btn-full" onClick={handleSelectPath}>
|
||||
<FolderOpen size={16} /> 浏览选择目录
|
||||
</button>
|
||||
<div className="button-row">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => handleAutoDetectPath()}
|
||||
disabled={isDetectingPath}
|
||||
>
|
||||
<Wand2 size={16} /> {isDetectingPath ? '自动检测中...' : '自动检测'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={handleSelectPath}>
|
||||
<FolderOpen size={16} /> 浏览选择目录
|
||||
</button>
|
||||
</div>
|
||||
{dbPath && (
|
||||
<div className="button-row">
|
||||
<button className="btn btn-secondary" onClick={handleOpenDetectedPath}>
|
||||
<FolderOpen size={16} /> 打开此文件夹
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="field-hint">{isMac ? '请选择微信版本目录或账号根目录' : '请选择微信-设置-存储位置对应的目录'}</div>
|
||||
{!isMac && (
|
||||
<div className="field-hint" style={{ color: '#ff6b6b', marginTop: '4px' }}>⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录</div>
|
||||
|
||||
3
src/types/electron.d.ts
vendored
3
src/types/electron.d.ts
vendored
@@ -294,7 +294,7 @@ export interface ElectronAPI {
|
||||
killWeChat: () => Promise<boolean>
|
||||
launchWeChat: () => Promise<boolean>
|
||||
waitForWindow: (maxWaitSeconds?: number) => Promise<boolean>
|
||||
startGetKey: (customWechatPath?: string) => Promise<{ success: boolean; key?: string; error?: string; needManualPath?: boolean }>
|
||||
startGetKey: (customWechatPath?: string, dbPath?: string) => Promise<{ success: boolean; key?: string; error?: string; needManualPath?: boolean; validatedWxid?: string }>
|
||||
cancel: () => Promise<boolean>
|
||||
detectCurrentAccount: (dbPath?: string, maxTimeDiffMinutes?: number) => Promise<{ wxid: string; dbPath: string } | null>
|
||||
onStatus: (callback: (data: { status: string; level: number }) => void) => () => void
|
||||
@@ -307,6 +307,7 @@ export interface ElectronAPI {
|
||||
}
|
||||
wcdb: {
|
||||
testConnection: (dbPath: string, hexKey: string, wxid: string, isAutoConnect?: boolean) => Promise<{ success: boolean; error?: string; sessionCount?: number }>
|
||||
resolveValidWxid: (dbPath: string, hexKey: string) => Promise<{ success: boolean; wxid?: string; error?: string }>
|
||||
open: (dbPath: string, hexKey: string, wxid: string) => Promise<boolean>
|
||||
close: () => Promise<boolean>
|
||||
decryptDatabase: (dbPath: string, hexKey: string, wxid: string) => Promise<{ success: boolean; error?: string; totalFiles?: number; successCount?: number; failCount?: number }>
|
||||
|
||||
Reference in New Issue
Block a user