mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev
This commit is contained in:
52
.github/workflows/release.yml
vendored
52
.github/workflows/release.yml
vendored
@@ -9,6 +9,58 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
release-mac-arm64:
|
||||||
|
runs-on: macos-14
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22.12
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Sync version with tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
|
echo "Syncing package.json version to $VERSION"
|
||||||
|
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package and Publish macOS arm64 (unsigned DMG)
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
run: |
|
||||||
|
npx electron-builder --mac dmg --arm64 --publish always
|
||||||
|
|
||||||
|
- name: Update Release Notes
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cat <<EOF > release_notes.md
|
||||||
|
## 更新日志
|
||||||
|
修复了一些已知问题
|
||||||
|
|
||||||
|
## 查看更多日志/获取最新动态
|
||||||
|
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
|||||||
@@ -235,6 +235,23 @@ const isYearsLoadCanceled = (taskId: string): boolean => {
|
|||||||
return task?.canceled === true
|
return task?.canceled === true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setupCustomTitleBarWindow = (win: BrowserWindow): void => {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
win.setWindowButtonVisibility(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emitMaximizeState = () => {
|
||||||
|
if (win.isDestroyed()) return
|
||||||
|
win.webContents.send('window:maximizeStateChanged', win.isMaximized() || win.isFullScreen())
|
||||||
|
}
|
||||||
|
|
||||||
|
win.on('maximize', emitMaximizeState)
|
||||||
|
win.on('unmaximize', emitMaximizeState)
|
||||||
|
win.on('enter-full-screen', emitMaximizeState)
|
||||||
|
win.on('leave-full-screen', emitMaximizeState)
|
||||||
|
win.webContents.on('did-finish-load', emitMaximizeState)
|
||||||
|
}
|
||||||
|
|
||||||
function createWindow(options: { autoShow?: boolean } = {}) {
|
function createWindow(options: { autoShow?: boolean } = {}) {
|
||||||
// 获取图标路径 - 打包后在 resources 目录
|
// 获取图标路径 - 打包后在 resources 目录
|
||||||
const { autoShow = true } = options
|
const { autoShow = true } = options
|
||||||
@@ -256,13 +273,10 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
|||||||
webSecurity: false // Allow loading local files (video playback)
|
webSecurity: false // Allow loading local files (video playback)
|
||||||
},
|
},
|
||||||
titleBarStyle: 'hidden',
|
titleBarStyle: 'hidden',
|
||||||
titleBarOverlay: {
|
titleBarOverlay: false,
|
||||||
color: '#00000000',
|
|
||||||
symbolColor: '#1a1a1a',
|
|
||||||
height: 40
|
|
||||||
},
|
|
||||||
show: false
|
show: false
|
||||||
})
|
})
|
||||||
|
setupCustomTitleBarWindow(win)
|
||||||
|
|
||||||
// 窗口准备好后显示
|
// 窗口准备好后显示
|
||||||
// Splash 模式下不在这里 show,由启动流程统一控制
|
// Splash 模式下不在这里 show,由启动流程统一控制
|
||||||
@@ -710,15 +724,12 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
|
|||||||
nodeIntegration: false
|
nodeIntegration: false
|
||||||
},
|
},
|
||||||
titleBarStyle: 'hidden',
|
titleBarStyle: 'hidden',
|
||||||
titleBarOverlay: {
|
titleBarOverlay: false,
|
||||||
color: '#00000000',
|
|
||||||
symbolColor: isDark ? '#ffffff' : '#1a1a1a',
|
|
||||||
height: 32
|
|
||||||
},
|
|
||||||
show: false,
|
show: false,
|
||||||
backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0',
|
backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0',
|
||||||
autoHideMenuBar: true
|
autoHideMenuBar: true
|
||||||
})
|
})
|
||||||
|
setupCustomTitleBarWindow(win)
|
||||||
|
|
||||||
win.once('ready-to-show', () => {
|
win.once('ready-to-show', () => {
|
||||||
win.show()
|
win.show()
|
||||||
@@ -1103,6 +1114,11 @@ function registerIpcHandlers() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('window:isMaximized', (event) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
return Boolean(win?.isMaximized() || win?.isFullScreen())
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.on('window:close', (event) => {
|
ipcMain.on('window:close', (event) => {
|
||||||
BrowserWindow.fromWebContents(event.sender)?.close()
|
BrowserWindow.fromWebContents(event.sender)?.close()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
window: {
|
window: {
|
||||||
minimize: () => ipcRenderer.send('window:minimize'),
|
minimize: () => ipcRenderer.send('window:minimize'),
|
||||||
maximize: () => ipcRenderer.send('window:maximize'),
|
maximize: () => ipcRenderer.send('window:maximize'),
|
||||||
|
isMaximized: () => ipcRenderer.invoke('window:isMaximized'),
|
||||||
|
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => {
|
||||||
|
const listener = (_: unknown, isMaximized: boolean) => callback(isMaximized)
|
||||||
|
ipcRenderer.on('window:maximizeStateChanged', listener)
|
||||||
|
return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener)
|
||||||
|
},
|
||||||
close: () => ipcRenderer.send('window:close'),
|
close: () => ipcRenderer.send('window:close'),
|
||||||
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
||||||
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ class CloudControlService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (platform === 'darwin') {
|
if (platform === 'darwin') {
|
||||||
return `macOS ${os.release()}`
|
// `os.release()` returns Darwin kernel version (e.g. 25.3.0),
|
||||||
|
// while cloud reporting expects the macOS product version (e.g. 26.3).
|
||||||
|
const macVersion = typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : os.release()
|
||||||
|
return `macOS ${macVersion}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return platform
|
return platform
|
||||||
@@ -92,4 +95,3 @@ class CloudControlService {
|
|||||||
|
|
||||||
export const cloudControlService = new CloudControlService()
|
export const cloudControlService = new CloudControlService()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -715,6 +715,68 @@ export class KeyService {
|
|||||||
return wxid.substring(0, second)
|
return wxid.substring(0, second)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
|
||||||
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
const xorKey = code & 0xFF
|
||||||
|
const dataToHash = code.toString() + cleanedWxid
|
||||||
|
const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex')
|
||||||
|
const aesKey = md5Full.substring(0, 16)
|
||||||
|
return { xorKey, aesKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
|
||||||
|
try {
|
||||||
|
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
|
||||||
|
const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null)
|
||||||
|
decipher.setAutoPadding(false)
|
||||||
|
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||||
|
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
|
||||||
|
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
|
||||||
|
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
|
||||||
|
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
|
||||||
|
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async collectWxidCandidates(manualDir?: string, wxidParam?: string): Promise<string[]> {
|
||||||
|
const candidates: string[] = []
|
||||||
|
const pushUnique = (value: string) => {
|
||||||
|
const v = String(value || '').trim()
|
||||||
|
if (!v || candidates.includes(v)) return
|
||||||
|
candidates.push(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam)
|
||||||
|
|
||||||
|
if (manualDir) {
|
||||||
|
const normalized = manualDir.replace(/[\\/]+$/, '')
|
||||||
|
const dirName = normalized.split(/[\\/]/).pop() ?? ''
|
||||||
|
if (dirName.startsWith('wxid_')) pushUnique(dirName)
|
||||||
|
|
||||||
|
const marker = normalized.match(/[\\/]xwechat_files/i) || normalized.match(/[\\/]WeChat Files/i)
|
||||||
|
if (marker) {
|
||||||
|
const root = normalized.slice(0, marker.index! + marker[0].length)
|
||||||
|
try {
|
||||||
|
const { readdirSync, statSync } = await import('fs')
|
||||||
|
const { join } = await import('path')
|
||||||
|
for (const entry of readdirSync(root)) {
|
||||||
|
if (!entry.startsWith('wxid_')) continue
|
||||||
|
const full = join(root, entry)
|
||||||
|
try {
|
||||||
|
if (statSync(full).isDirectory()) pushUnique(entry)
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushUnique('unknown')
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
async autoGetImageKey(
|
async autoGetImageKey(
|
||||||
manualDir?: string,
|
manualDir?: string,
|
||||||
onProgress?: (message: string) => void,
|
onProgress?: (message: string) => void,
|
||||||
@@ -750,52 +812,34 @@ export class KeyService {
|
|||||||
const codes: number[] = accounts[0].keys.map((k: any) => k.code)
|
const codes: number[] = accounts[0].keys.map((k: any) => k.code)
|
||||||
console.log('[ImageKey] codes:', codes, 'DLL wxids:', accounts.map((a: any) => a.wxid))
|
console.log('[ImageKey] codes:', codes, 'DLL wxids:', accounts.map((a: any) => a.wxid))
|
||||||
|
|
||||||
// 优先级: 1. 直接传入的wxidParam 2. 从manualDir提取 3. DLL返回的wxid(可能是unknown)
|
const wxidCandidates = await this.collectWxidCandidates(manualDir, wxidParam)
|
||||||
let targetWxid = ''
|
let verifyCiphertext: Buffer | null = null
|
||||||
|
if (manualDir && existsSync(manualDir)) {
|
||||||
// 方案1: 直接使用传入的wxidParam(最优先)
|
const template = await this._findTemplateData(manualDir, 32)
|
||||||
if (wxidParam && wxidParam.startsWith('wxid_')) {
|
verifyCiphertext = template.ciphertext
|
||||||
targetWxid = wxidParam
|
|
||||||
console.log('[ImageKey] 使用直接传入的 wxid:', targetWxid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 方案2: 从 manualDir 提取前端已配置好的正确 wxid
|
if (verifyCiphertext) {
|
||||||
// 格式: "D:\weixin\xwechat_files\wxid_xxx_1234" → "wxid_xxx_1234"
|
onProgress?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`)
|
||||||
if (!targetWxid && manualDir) {
|
for (const candidateWxid of wxidCandidates) {
|
||||||
const dirName = manualDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
|
for (const code of codes) {
|
||||||
if (dirName.startsWith('wxid_')) {
|
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
|
||||||
targetWxid = dirName
|
if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue
|
||||||
console.log('[ImageKey] 从 manualDir 提取 wxid:', targetWxid)
|
onProgress?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
||||||
|
console.log('[ImageKey] 校验命中: wxid=', candidateWxid, 'code=', code)
|
||||||
|
return { success: true, xorKey, aesKey }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 方案3: 回退到 DLL 发现的第一个(可能是 unknown)
|
// 无模板密文可验真时回退旧策略
|
||||||
if (!targetWxid) {
|
const fallbackWxid = wxidCandidates[0] || accounts[0].wxid || 'unknown'
|
||||||
targetWxid = accounts[0].wxid
|
const fallbackCode = codes[0]
|
||||||
console.log('[ImageKey] 无法获取 wxid,使用 DLL 发现的:', targetWxid)
|
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
|
||||||
}
|
onProgress?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
|
||||||
|
console.log('[ImageKey] 回退计算: wxid=', fallbackWxid, 'code=', fallbackCode)
|
||||||
// CleanWxid: 截断到第二个下划线,与 xkey 算法一致
|
return { success: true, xorKey, aesKey }
|
||||||
const cleanedWxid = this.cleanWxid(targetWxid)
|
|
||||||
console.log('[ImageKey] wxid:', targetWxid, '→ cleaned:', cleanedWxid)
|
|
||||||
|
|
||||||
// 用 cleanedWxid + code 本地计算密钥
|
|
||||||
// xorKey = code & 0xFF
|
|
||||||
// aesKey = MD5(code.toString() + cleanedWxid).substring(0, 16)
|
|
||||||
const code = codes[0]
|
|
||||||
const xorKey = code & 0xFF
|
|
||||||
const dataToHash = code.toString() + cleanedWxid
|
|
||||||
const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex')
|
|
||||||
const aesKey = md5Full.substring(0, 16)
|
|
||||||
|
|
||||||
onProgress?.(`密钥获取成功 (wxid: ${targetWxid}, code: ${code})`)
|
|
||||||
console.log('[ImageKey] 计算结果: xorKey=', xorKey, 'aesKey=', aesKey)
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
xorKey,
|
|
||||||
aesKey
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 内存扫描备选方案(融合 Dart+Python 优点)---
|
// --- 内存扫描备选方案(融合 Dart+Python 优点)---
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { app, shell } from 'electron'
|
import { app, shell } from 'electron'
|
||||||
import { join } from 'path'
|
import { join, basename, dirname } from 'path'
|
||||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
||||||
import { execFile, spawn } from 'child_process'
|
import { execFile, spawn } from 'child_process'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { homedir } from 'os'
|
||||||
|
|
||||||
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
||||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
||||||
@@ -14,9 +16,13 @@ export class KeyServiceMac {
|
|||||||
private initialized = false
|
private initialized = false
|
||||||
|
|
||||||
private GetDbKey: any = null
|
private GetDbKey: any = null
|
||||||
private ScanMemoryForImageKey: any = null
|
|
||||||
private FreeString: any = null
|
|
||||||
private ListWeChatProcesses: any = null
|
private ListWeChatProcesses: any = null
|
||||||
|
private libSystem: any = null
|
||||||
|
private machTaskSelf: any = null
|
||||||
|
private taskForPid: any = null
|
||||||
|
private machVmRegion: any = null
|
||||||
|
private machVmReadOverwrite: any = null
|
||||||
|
private machPortDeallocate: any = null
|
||||||
|
|
||||||
private getHelperPath(): string {
|
private getHelperPath(): string {
|
||||||
const isPackaged = app.isPackaged
|
const isPackaged = app.isPackaged
|
||||||
@@ -81,8 +87,6 @@ export class KeyServiceMac {
|
|||||||
this.lib = this.koffi.load(dylibPath)
|
this.lib = this.koffi.load(dylibPath)
|
||||||
|
|
||||||
this.GetDbKey = this.lib.func('const char* GetDbKey()')
|
this.GetDbKey = this.lib.func('const char* GetDbKey()')
|
||||||
this.ScanMemoryForImageKey = this.lib.func('const char* ScanMemoryForImageKey(int pid, const char* ciphertext)')
|
|
||||||
this.FreeString = this.lib.func('void FreeString(const char* str)')
|
|
||||||
this.ListWeChatProcesses = this.lib.func('const char* ListWeChatProcesses()')
|
this.ListWeChatProcesses = this.lib.func('const char* ListWeChatProcesses()')
|
||||||
|
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
@@ -97,30 +101,18 @@ export class KeyServiceMac {
|
|||||||
): Promise<DbKeyResult> {
|
): Promise<DbKeyResult> {
|
||||||
try {
|
try {
|
||||||
onStatus?.('正在获取数据库密钥...', 0)
|
onStatus?.('正在获取数据库密钥...', 0)
|
||||||
let parsed = await this.getDbKeyParsed(timeoutMs, onStatus)
|
onStatus?.('正在请求管理员授权并执行 helper...', 0)
|
||||||
console.log('[KeyServiceMac] GetDbKey returned:', parsed.raw)
|
let parsed: { success: boolean; key?: string; code?: string; detail?: string; raw: string }
|
||||||
|
try {
|
||||||
// ATTACH_FAILED 时自动走图形化授权,再重试一次
|
const elevatedResult = await this.getDbKeyByHelperElevated(timeoutMs, onStatus)
|
||||||
if (!parsed.success && parsed.code === 'ATTACH_FAILED') {
|
parsed = this.parseDbKeyResult(elevatedResult)
|
||||||
onStatus?.('检测到调试权限不足,正在请求系统授权...', 0)
|
console.log('[KeyServiceMac] GetDbKey elevated returned:', parsed.raw)
|
||||||
const permissionOk = await this.enableDebugPermissionWithPrompt()
|
} catch (e: any) {
|
||||||
if (permissionOk) {
|
const msg = `${e?.message || e}`
|
||||||
onStatus?.('授权完成,正在重试获取密钥...', 0)
|
if (msg.includes('(-128)') || msg.includes('User canceled')) {
|
||||||
parsed = await this.getDbKeyParsed(timeoutMs, onStatus)
|
return { success: false, error: '已取消管理员授权' }
|
||||||
console.log('[KeyServiceMac] GetDbKey retry returned:', parsed.raw)
|
|
||||||
} else {
|
|
||||||
onStatus?.('已取消系统授权', 2)
|
|
||||||
return { success: false, error: '已取消系统授权' }
|
|
||||||
}
|
}
|
||||||
}
|
throw e
|
||||||
|
|
||||||
if (!parsed.success && parsed.code === 'ATTACH_FAILED') {
|
|
||||||
// DevToolsSecurity 仍不足时,自动拉起开发者工具权限页面
|
|
||||||
await this.openDeveloperToolsPrivacySettings()
|
|
||||||
await this.revealCurrentExecutableInFinder()
|
|
||||||
const msg = `无法附加到微信进程。已打开“开发者工具”设置,并在访达中定位当前运行程序。\n请在“隐私与安全性 -> 开发者工具”点击“+”添加并允许:${process.execPath}`
|
|
||||||
onStatus?.(msg, 2)
|
|
||||||
return { success: false, error: msg }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -157,44 +149,38 @@ export class KeyServiceMac {
|
|||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
onStatus?: (message: string, level: number) => void
|
onStatus?: (message: string, level: number) => void
|
||||||
): Promise<{ success: boolean; key?: string; code?: string; detail?: string; raw: string }> {
|
): Promise<{ success: boolean; key?: string; code?: string; detail?: string; raw: string }> {
|
||||||
try {
|
const helperResult = await this.getDbKeyByHelper(timeoutMs, onStatus)
|
||||||
const helperResult = await this.getDbKeyByHelper(timeoutMs, onStatus)
|
return this.parseDbKeyResult(helperResult)
|
||||||
return this.parseDbKeyResult(helperResult)
|
|
||||||
} catch (e: any) {
|
|
||||||
console.warn('[KeyServiceMac] helper unavailable, fallback to dylib:', e?.message || e)
|
|
||||||
if (!this.initialized) {
|
|
||||||
await this.initialize()
|
|
||||||
}
|
|
||||||
return this.parseDbKeyResult(this.GetDbKey())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getWeChatPid(): Promise<number> {
|
private async getWeChatPid(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
|
// 优先使用 pgrep,避免 ps 的 comm 列被截断导致识别失败
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('pgrep', ['-x', 'WeChat'])
|
||||||
|
const ids = stdout.split(/\r?\n/).map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n) && n > 0)
|
||||||
|
if (ids.length > 0) return Math.max(...ids)
|
||||||
|
} catch {
|
||||||
|
// ignore and fallback to ps
|
||||||
|
}
|
||||||
|
|
||||||
const { stdout } = await execFileAsync('ps', ['-A', '-o', 'pid,comm,command'])
|
const { stdout } = await execFileAsync('ps', ['-A', '-o', 'pid,comm,command'])
|
||||||
const lines = stdout.split('\n').slice(1)
|
const lines = stdout.split('\n').slice(1)
|
||||||
|
|
||||||
const candidates: Array<{ pid: number; comm: string; command: string }> = []
|
const candidates: Array<{ pid: number; command: string }> = []
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/)
|
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/)
|
||||||
if (!match) continue
|
if (!match) continue
|
||||||
|
|
||||||
const pid = parseInt(match[1])
|
const pid = parseInt(match[1], 10)
|
||||||
const comm = match[2]
|
|
||||||
const command = match[3]
|
const command = match[3]
|
||||||
|
|
||||||
const nameMatch = comm === 'WeChat' || comm === '微信'
|
const pathMatch = command.includes('/Applications/WeChat.app/Contents/MacOS/WeChat') ||
|
||||||
const pathMatch = command.includes('WeChat.app') || command.includes('/Contents/MacOS/WeChat')
|
command.includes('/Contents/MacOS/WeChat')
|
||||||
|
if (pathMatch) candidates.push({ pid, command })
|
||||||
if (nameMatch && pathMatch) {
|
|
||||||
candidates.push({ pid, comm, command })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) throw new Error('WeChat process not found')
|
||||||
throw new Error('WeChat process not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtered = candidates.filter(p => {
|
const filtered = candidates.filter(p => {
|
||||||
const cmd = p.command
|
const cmd = p.command
|
||||||
@@ -204,18 +190,11 @@ export class KeyServiceMac {
|
|||||||
!cmd.includes('crashpad_handler') &&
|
!cmd.includes('crashpad_handler') &&
|
||||||
!cmd.includes('Helper')
|
!cmd.includes('Helper')
|
||||||
})
|
})
|
||||||
|
if (filtered.length === 0) throw new Error('No valid WeChat main process found')
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
const preferredMain = filtered.filter(p => p.command.includes('/Contents/MacOS/WeChat'))
|
||||||
throw new Error('No valid WeChat main process found')
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredMain = filtered.filter(p =>
|
|
||||||
p.command.includes('/Contents/MacOS/WeChat')
|
|
||||||
)
|
|
||||||
|
|
||||||
const selectedPool = preferredMain.length > 0 ? preferredMain : filtered
|
const selectedPool = preferredMain.length > 0 ? preferredMain : filtered
|
||||||
const selected = selectedPool.reduce((max, p) => p.pid > max.pid ? p : max)
|
const selected = selectedPool.reduce((max, p) => p.pid > max.pid ? p : max)
|
||||||
|
|
||||||
return selected.pid
|
return selected.pid
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
throw new Error('Failed to get WeChat PID: ' + e.message)
|
throw new Error('Failed to get WeChat PID: ' + e.message)
|
||||||
@@ -229,8 +208,12 @@ export class KeyServiceMac {
|
|||||||
const helperPath = this.getHelperPath()
|
const helperPath = this.getHelperPath()
|
||||||
const waitMs = Math.max(timeoutMs, 30_000)
|
const waitMs = Math.max(timeoutMs, 30_000)
|
||||||
const pid = await this.getWeChatPid()
|
const pid = await this.getWeChatPid()
|
||||||
|
onStatus?.(`已找到微信进程 PID=${pid},正在定位目标函数...`, 0)
|
||||||
|
// 最佳努力清理同路径残留 helper(普通权限)
|
||||||
|
try { await execFileAsync('pkill', ['-f', helperPath], { timeout: 2000 }) } catch { }
|
||||||
|
|
||||||
return await new Promise<string>((resolve, reject) => {
|
return await new Promise<string>((resolve, reject) => {
|
||||||
|
// xkey_helper 参数协议:helper <pid> [timeout_ms]
|
||||||
const child = spawn(helperPath, [String(pid), String(waitMs)], { stdio: ['ignore', 'pipe', 'pipe'] })
|
const child = spawn(helperPath, [String(pid), String(waitMs)], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
let stdout = ''
|
let stdout = ''
|
||||||
let stderr = ''
|
let stderr = ''
|
||||||
@@ -330,6 +313,51 @@ export class KeyServiceMac {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shellSingleQuote(text: string): string {
|
||||||
|
return `'${String(text).replace(/'/g, `'\\''`)}'`
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDbKeyByHelperElevated(
|
||||||
|
timeoutMs: number,
|
||||||
|
onStatus?: (message: string, level: number) => void
|
||||||
|
): Promise<string> {
|
||||||
|
const helperPath = this.getHelperPath()
|
||||||
|
const waitMs = Math.max(timeoutMs, 30_000)
|
||||||
|
const pid = await this.getWeChatPid()
|
||||||
|
// 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败
|
||||||
|
const scriptLines = [
|
||||||
|
`set helperPath to ${JSON.stringify(helperPath)}`,
|
||||||
|
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
|
||||||
|
'do shell script cmd with administrator privileges'
|
||||||
|
]
|
||||||
|
onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0)
|
||||||
|
|
||||||
|
let stdout = ''
|
||||||
|
try {
|
||||||
|
const result = await execFileAsync('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)
|
||||||
|
const last = lines[lines.length - 1]
|
||||||
|
if (!last) throw new Error('elevated helper returned empty output')
|
||||||
|
|
||||||
|
let payload: any
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(last)
|
||||||
|
} catch {
|
||||||
|
throw new Error('elevated helper returned invalid json: ' + last)
|
||||||
|
}
|
||||||
|
if (payload?.success === true && typeof payload?.key === 'string') return payload.key
|
||||||
|
if (typeof payload?.result === 'string') return payload.result
|
||||||
|
throw new Error('elevated helper json missing key/result')
|
||||||
|
}
|
||||||
|
|
||||||
private mapDbKeyErrorMessage(code?: string, detail?: string): string {
|
private mapDbKeyErrorMessage(code?: string, detail?: string): string {
|
||||||
if (code === 'PROCESS_NOT_FOUND') return '微信进程未运行'
|
if (code === 'PROCESS_NOT_FOUND') return '微信进程未运行'
|
||||||
if (code === 'ATTACH_FAILED') {
|
if (code === 'ATTACH_FAILED') {
|
||||||
@@ -406,18 +434,52 @@ export class KeyServiceMac {
|
|||||||
onStatus?: (message: string) => void,
|
onStatus?: (message: string) => void,
|
||||||
wxid?: string
|
wxid?: string
|
||||||
): Promise<ImageKeyResult> {
|
): Promise<ImageKeyResult> {
|
||||||
onStatus?.('macOS 请使用内存扫描方式')
|
try {
|
||||||
return { success: false, error: 'macOS 请使用内存扫描方式' }
|
onStatus?.('正在从缓存目录扫描图片密钥...')
|
||||||
|
const codes = this.collectKvcommCodes(accountPath)
|
||||||
|
if (codes.length === 0) {
|
||||||
|
return { success: false, error: '未找到有效的密钥码(kvcomm 缓存为空)' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const wxidCandidates = this.collectWxidCandidates(accountPath, wxid)
|
||||||
|
if (wxidCandidates.length === 0) {
|
||||||
|
return { success: false, error: '未找到可用的 wxid 候选,请先选择正确的账号目录' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用模板密文做验真,避免 wxid 不匹配导致快速方案算错
|
||||||
|
let verifyCiphertext: Buffer | null = null
|
||||||
|
if (accountPath && existsSync(accountPath)) {
|
||||||
|
const template = await this._findTemplateData(accountPath, 32)
|
||||||
|
verifyCiphertext = template.ciphertext
|
||||||
|
}
|
||||||
|
if (verifyCiphertext) {
|
||||||
|
onStatus?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`)
|
||||||
|
for (const candidateWxid of wxidCandidates) {
|
||||||
|
for (const code of codes) {
|
||||||
|
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
|
||||||
|
if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue
|
||||||
|
onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
||||||
|
return { success: true, xorKey, aesKey }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无法获取模板密文时,回退为历史策略(优先级最高候选 + 第一条 code)
|
||||||
|
const fallbackWxid = wxidCandidates[0]
|
||||||
|
const fallbackCode = codes[0]
|
||||||
|
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
|
||||||
|
onStatus?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
|
||||||
|
return { success: true, xorKey, aesKey }
|
||||||
|
} catch (e: any) {
|
||||||
|
return { success: false, error: `自动获取图片密钥失败: ${e.message}` }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async autoGetImageKeyByMemoryScan(
|
async autoGetImageKeyByMemoryScan(
|
||||||
userDir: string,
|
userDir: string,
|
||||||
onProgress?: (message: string) => void
|
onProgress?: (message: string) => void
|
||||||
): Promise<ImageKeyResult> {
|
): Promise<ImageKeyResult> {
|
||||||
if (!this.initialized) {
|
|
||||||
await this.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 查找模板文件获取密文和 XOR 密钥
|
// 1. 查找模板文件获取密文和 XOR 密钥
|
||||||
onProgress?.('正在查找模板文件...')
|
onProgress?.('正在查找模板文件...')
|
||||||
@@ -447,7 +509,7 @@ export class KeyServiceMac {
|
|||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
scanCount++
|
scanCount++
|
||||||
onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`)
|
onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`)
|
||||||
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext)
|
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress)
|
||||||
if (aesKey) {
|
if (aesKey) {
|
||||||
onProgress?.('密钥获取成功')
|
onProgress?.('密钥获取成功')
|
||||||
return { success: true, xorKey, aesKey }
|
return { success: true, xorKey, aesKey }
|
||||||
@@ -516,10 +578,134 @@ export class KeyServiceMac {
|
|||||||
return { ciphertext, xorKey }
|
return { ciphertext, xorKey }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _scanMemoryForAesKey(pid: number, ciphertext: Buffer): Promise<string | null> {
|
private ensureMachApis(): boolean {
|
||||||
const ciphertextHex = ciphertext.toString('hex')
|
if (this.machTaskSelf && this.taskForPid && this.machVmRegion && this.machVmReadOverwrite) return true
|
||||||
const aesKey = this.ScanMemoryForImageKey(pid, ciphertextHex)
|
try {
|
||||||
return aesKey || null
|
if (!this.koffi) this.koffi = require('koffi')
|
||||||
|
this.libSystem = this.koffi.load('/usr/lib/libSystem.B.dylib')
|
||||||
|
this.machTaskSelf = this.libSystem.func('mach_task_self', 'uint32', [])
|
||||||
|
this.taskForPid = this.libSystem.func('task_for_pid', 'int', ['uint32', 'int', this.koffi.out('uint32*')])
|
||||||
|
this.machVmRegion = this.libSystem.func('mach_vm_region', 'int', [
|
||||||
|
'uint32',
|
||||||
|
this.koffi.out('uint64*'),
|
||||||
|
this.koffi.out('uint64*'),
|
||||||
|
'int',
|
||||||
|
'void*',
|
||||||
|
this.koffi.out('uint32*'),
|
||||||
|
this.koffi.out('uint32*')
|
||||||
|
])
|
||||||
|
this.machVmReadOverwrite = this.libSystem.func('mach_vm_read_overwrite', 'int', [
|
||||||
|
'uint32',
|
||||||
|
'uint64',
|
||||||
|
'uint64',
|
||||||
|
'void*',
|
||||||
|
this.koffi.out('uint64*')
|
||||||
|
])
|
||||||
|
this.machPortDeallocate = this.libSystem.func('mach_port_deallocate', 'int', ['uint32', 'uint32'])
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[KeyServiceMac] 初始化 Mach API 失败:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _scanMemoryForAesKey(
|
||||||
|
pid: number,
|
||||||
|
ciphertext: Buffer,
|
||||||
|
onProgress?: (message: string) => void
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!this.ensureMachApis()) return null
|
||||||
|
|
||||||
|
const VM_PROT_READ = 0x1
|
||||||
|
const VM_PROT_WRITE = 0x2
|
||||||
|
const VM_REGION_BASIC_INFO_64 = 9
|
||||||
|
const VM_REGION_BASIC_INFO_COUNT_64 = 9
|
||||||
|
const KERN_SUCCESS = 0
|
||||||
|
const MAX_REGION_SIZE = 50 * 1024 * 1024
|
||||||
|
const CHUNK = 4 * 1024 * 1024
|
||||||
|
const OVERLAP = 65
|
||||||
|
|
||||||
|
const selfTask = this.machTaskSelf()
|
||||||
|
const taskBuf = Buffer.alloc(4)
|
||||||
|
const attachKr = this.taskForPid(selfTask, pid, taskBuf)
|
||||||
|
const task = taskBuf.readUInt32LE(0)
|
||||||
|
if (attachKr !== KERN_SUCCESS || !task) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const regions: Array<[number, number]> = []
|
||||||
|
let address = 0
|
||||||
|
|
||||||
|
while (address < 0x7FFFFFFFFFFF) {
|
||||||
|
const addrBuf = Buffer.alloc(8)
|
||||||
|
addrBuf.writeBigUInt64LE(BigInt(address), 0)
|
||||||
|
const sizeBuf = Buffer.alloc(8)
|
||||||
|
const infoBuf = Buffer.alloc(64)
|
||||||
|
const countBuf = Buffer.alloc(4)
|
||||||
|
countBuf.writeUInt32LE(VM_REGION_BASIC_INFO_COUNT_64, 0)
|
||||||
|
const objectBuf = Buffer.alloc(4)
|
||||||
|
|
||||||
|
const kr = this.machVmRegion(task, addrBuf, sizeBuf, VM_REGION_BASIC_INFO_64, infoBuf, countBuf, objectBuf)
|
||||||
|
if (kr !== KERN_SUCCESS) break
|
||||||
|
|
||||||
|
const base = Number(addrBuf.readBigUInt64LE(0))
|
||||||
|
const size = Number(sizeBuf.readBigUInt64LE(0))
|
||||||
|
const protection = infoBuf.readInt32LE(0)
|
||||||
|
const objectName = objectBuf.readUInt32LE(0)
|
||||||
|
if (objectName) {
|
||||||
|
try { this.machPortDeallocate(selfTask, objectName) } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((protection & VM_PROT_READ) !== 0 &&
|
||||||
|
(protection & VM_PROT_WRITE) !== 0 &&
|
||||||
|
size > 0 &&
|
||||||
|
size <= MAX_REGION_SIZE) {
|
||||||
|
regions.push([base, size])
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = base + size
|
||||||
|
if (next <= address) break
|
||||||
|
address = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMB = regions.reduce((sum, [, size]) => sum + size, 0) / 1024 / 1024
|
||||||
|
onProgress?.(`扫描 ${regions.length} 个 RW 区域 (${totalMB.toFixed(0)} MB)...`)
|
||||||
|
|
||||||
|
for (let ri = 0; ri < regions.length; ri++) {
|
||||||
|
const [base, size] = regions[ri]
|
||||||
|
if (ri % 20 === 0) {
|
||||||
|
onProgress?.(`扫描进度 ${ri}/${regions.length}...`)
|
||||||
|
await new Promise(r => setTimeout(r, 1))
|
||||||
|
}
|
||||||
|
let offset = 0
|
||||||
|
let trailing: Buffer | null = null
|
||||||
|
|
||||||
|
while (offset < size) {
|
||||||
|
const chunkSize = Math.min(CHUNK, size - offset)
|
||||||
|
const chunk = Buffer.alloc(chunkSize)
|
||||||
|
const outSizeBuf = Buffer.alloc(8)
|
||||||
|
const kr = this.machVmReadOverwrite(task, base + offset, chunkSize, chunk, outSizeBuf)
|
||||||
|
const bytesRead = Number(outSizeBuf.readBigUInt64LE(0))
|
||||||
|
offset += chunkSize
|
||||||
|
|
||||||
|
if (kr !== KERN_SUCCESS || bytesRead <= 0) {
|
||||||
|
trailing = null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = chunk.subarray(0, bytesRead)
|
||||||
|
const data = trailing ? Buffer.concat([trailing, current]) : current
|
||||||
|
const key = this._searchAsciiKey(data, ciphertext) || this._searchUtf16Key(data, ciphertext)
|
||||||
|
if (key) return key
|
||||||
|
// 兜底:兼容旧 C++ 的滑窗 16-byte 扫描(严格规则 miss 时仍可命中)
|
||||||
|
const fallbackKey = this._searchAny16Key(data, ciphertext)
|
||||||
|
if (fallbackKey) return fallbackKey
|
||||||
|
trailing = data.subarray(Math.max(0, data.length - OVERLAP))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
try { this.machPortDeallocate(selfTask, task) } catch { }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findWeChatPid(): Promise<number | null> {
|
private async findWeChatPid(): Promise<number | null> {
|
||||||
@@ -536,5 +722,224 @@ export class KeyServiceMac {
|
|||||||
cleanup(): void {
|
cleanup(): void {
|
||||||
this.lib = null
|
this.lib = null
|
||||||
this.initialized = false
|
this.initialized = false
|
||||||
|
this.libSystem = null
|
||||||
|
this.machTaskSelf = null
|
||||||
|
this.taskForPid = null
|
||||||
|
this.machVmRegion = null
|
||||||
|
this.machVmReadOverwrite = null
|
||||||
|
this.machPortDeallocate = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanWxid(wxid: string): string {
|
||||||
|
const first = wxid.indexOf('_')
|
||||||
|
if (first === -1) return wxid
|
||||||
|
const second = wxid.indexOf('_', first + 1)
|
||||||
|
if (second === -1) return wxid
|
||||||
|
return wxid.substring(0, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
|
||||||
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
const xorKey = code & 0xFF
|
||||||
|
const dataToHash = code.toString() + cleanedWxid
|
||||||
|
const aesKey = crypto.createHash('md5').update(dataToHash).digest('hex').substring(0, 16)
|
||||||
|
return { xorKey, aesKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] {
|
||||||
|
const candidates: string[] = []
|
||||||
|
const pushUnique = (value: string) => {
|
||||||
|
const v = String(value || '').trim()
|
||||||
|
if (!v || candidates.includes(v)) return
|
||||||
|
candidates.push(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 显式传参优先
|
||||||
|
if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam)
|
||||||
|
|
||||||
|
if (accountPath) {
|
||||||
|
const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||||
|
const dirName = basename(normalized)
|
||||||
|
// 2) 当前目录名为 wxid_*
|
||||||
|
if (dirName.startsWith('wxid_')) pushUnique(dirName)
|
||||||
|
|
||||||
|
// 3) 从 xwechat_files 根目录枚举全部 wxid_* 目录
|
||||||
|
const marker = '/xwechat_files'
|
||||||
|
const markerIdx = normalized.indexOf(marker)
|
||||||
|
if (markerIdx >= 0) {
|
||||||
|
const root = normalized.slice(0, markerIdx + marker.length)
|
||||||
|
if (existsSync(root)) {
|
||||||
|
try {
|
||||||
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue
|
||||||
|
if (!entry.name.startsWith('wxid_')) continue
|
||||||
|
pushUnique(entry.name)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushUnique('unknown')
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
|
||||||
|
try {
|
||||||
|
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
|
||||||
|
const keyBytes = Buffer.from(aesKey, 'ascii').subarray(0, 16)
|
||||||
|
const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes, null)
|
||||||
|
decipher.setAutoPadding(false)
|
||||||
|
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||||
|
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
|
||||||
|
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
|
||||||
|
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
|
||||||
|
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
|
||||||
|
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectKvcommCodes(accountPath?: string): number[] {
|
||||||
|
const codeSet = new Set<number>()
|
||||||
|
const pattern = /^key_(\d+)_.+\.statistic$/i
|
||||||
|
|
||||||
|
for (const kvcommDir of this.getKvcommCandidates(accountPath)) {
|
||||||
|
if (!existsSync(kvcommDir)) continue
|
||||||
|
try {
|
||||||
|
const files = readdirSync(kvcommDir)
|
||||||
|
for (const file of files) {
|
||||||
|
const match = file.match(pattern)
|
||||||
|
if (!match) continue
|
||||||
|
const code = Number(match[1])
|
||||||
|
if (!Number.isFinite(code) || code <= 0 || code > 0xFFFFFFFF) continue
|
||||||
|
codeSet.add(code)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略不可读目录,继续尝试其他候选路径
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(codeSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getKvcommCandidates(accountPath?: string): string[] {
|
||||||
|
const home = homedir()
|
||||||
|
const candidates = new Set<string>([
|
||||||
|
// 与用户实测路径一致:Documents/xwechat_files -> Documents/app_data/net/kvcomm
|
||||||
|
join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'app_data', 'net', 'kvcomm'),
|
||||||
|
join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat', 'xwechat', 'net', 'kvcomm'),
|
||||||
|
join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat', 'net', 'kvcomm'),
|
||||||
|
join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat', 'net', 'kvcomm')
|
||||||
|
])
|
||||||
|
|
||||||
|
if (accountPath) {
|
||||||
|
// 规则:把路径中的 xwechat_files 替换为 app_data,然后拼 net/kvcomm
|
||||||
|
const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||||
|
const marker = '/xwechat_files'
|
||||||
|
const idx = normalized.indexOf(marker)
|
||||||
|
if (idx >= 0) {
|
||||||
|
const base = normalized.slice(0, idx)
|
||||||
|
candidates.add(`${base}/app_data/net/kvcomm`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = accountPath
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
candidates.add(join(cursor, 'net', 'kvcomm'))
|
||||||
|
const next = dirname(cursor)
|
||||||
|
if (next === cursor) break
|
||||||
|
cursor = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
private _searchAsciiKey(data: Buffer, ciphertext: Buffer): string | null {
|
||||||
|
for (let i = 0; i < data.length - 34; i++) {
|
||||||
|
if (this._isAlphaNum(data[i])) continue
|
||||||
|
let valid = true
|
||||||
|
for (let j = 1; j <= 32; j++) {
|
||||||
|
if (!this._isAlphaNum(data[i + j])) { valid = false; break }
|
||||||
|
}
|
||||||
|
if (!valid) continue
|
||||||
|
if (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue
|
||||||
|
const keyBytes = data.subarray(i + 1, i + 33)
|
||||||
|
if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private _searchUtf16Key(data: Buffer, ciphertext: Buffer): string | null {
|
||||||
|
for (let i = 0; i < data.length - 65; i++) {
|
||||||
|
let valid = true
|
||||||
|
for (let j = 0; j < 32; j++) {
|
||||||
|
if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) { valid = false; break }
|
||||||
|
}
|
||||||
|
if (!valid) continue
|
||||||
|
const keyBytes = Buffer.alloc(32)
|
||||||
|
for (let j = 0; j < 32; j++) keyBytes[j] = data[i + j * 2]
|
||||||
|
if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private _isAlphaNum(b: number): boolean {
|
||||||
|
return (b >= 0x61 && b <= 0x7A) || (b >= 0x41 && b <= 0x5A) || (b >= 0x30 && b <= 0x39)
|
||||||
|
}
|
||||||
|
|
||||||
|
private _verifyAesKey(keyBytes: Buffer, ciphertext: Buffer): boolean {
|
||||||
|
try {
|
||||||
|
const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes.subarray(0, 16), null)
|
||||||
|
decipher.setAutoPadding(false)
|
||||||
|
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||||
|
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
|
||||||
|
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
|
||||||
|
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
|
||||||
|
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
|
||||||
|
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底策略:遍历任意 16-byte 候选,提升 macOS 内存布局差异下的命中率
|
||||||
|
private _searchAny16Key(data: Buffer, ciphertext: Buffer): string | null {
|
||||||
|
for (let i = 0; i + 16 <= data.length; i++) {
|
||||||
|
const keyBytes = data.subarray(i, i + 16)
|
||||||
|
if (!this._verifyAesKey16Raw(keyBytes, ciphertext)) continue
|
||||||
|
if (!this._isMostlyPrintableAscii(keyBytes)) continue
|
||||||
|
return keyBytes.toString('ascii')
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private _verifyAesKey16Raw(keyBytes16: Buffer, ciphertext: Buffer): boolean {
|
||||||
|
try {
|
||||||
|
const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes16, null)
|
||||||
|
decipher.setAutoPadding(false)
|
||||||
|
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||||
|
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
|
||||||
|
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
|
||||||
|
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
|
||||||
|
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
|
||||||
|
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _isMostlyPrintableAscii(keyBytes16: Buffer): boolean {
|
||||||
|
let printable = 0
|
||||||
|
for (const b of keyBytes16) {
|
||||||
|
if (b >= 0x20 && b <= 0x7E) printable++
|
||||||
|
}
|
||||||
|
return printable >= 14
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,38 @@ export class VoiceTranscribeService {
|
|||||||
private recognizer: OfflineRecognizer | null = null
|
private recognizer: OfflineRecognizer | null = null
|
||||||
private isInitializing = false
|
private isInitializing = false
|
||||||
|
|
||||||
|
private buildTranscribeWorkerEnv(): NodeJS.ProcessEnv {
|
||||||
|
const env: NodeJS.ProcessEnv = { ...process.env }
|
||||||
|
const platform = process.platform === 'win32' ? 'win' : process.platform
|
||||||
|
const platformPkg = `sherpa-onnx-${platform}-${process.arch}`
|
||||||
|
const candidates = [
|
||||||
|
join(__dirname, '..', 'node_modules', platformPkg),
|
||||||
|
join(__dirname, 'node_modules', platformPkg),
|
||||||
|
join(process.cwd(), 'node_modules', platformPkg),
|
||||||
|
process.resourcesPath ? join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : ''
|
||||||
|
].filter((item): item is string => Boolean(item) && existsSync(item))
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const key = 'DYLD_LIBRARY_PATH'
|
||||||
|
const existing = env[key] || ''
|
||||||
|
const merged = [...candidates, ...existing.split(':').filter(Boolean)]
|
||||||
|
env[key] = Array.from(new Set(merged)).join(':')
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||||
|
}
|
||||||
|
} else if (process.platform === 'linux') {
|
||||||
|
const key = 'LD_LIBRARY_PATH'
|
||||||
|
const existing = env[key] || ''
|
||||||
|
const merged = [...candidates, ...existing.split(':').filter(Boolean)]
|
||||||
|
env[key] = Array.from(new Set(merged)).join(':')
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
private resolveModelDir(): string {
|
private resolveModelDir(): string {
|
||||||
const configured = this.configService.get('whisperModelDir') as string | undefined
|
const configured = this.configService.get('whisperModelDir') as string | undefined
|
||||||
if (configured) return configured
|
if (configured) return configured
|
||||||
@@ -206,17 +238,20 @@ export class VoiceTranscribeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { Worker } = require('worker_threads')
|
const { fork } = require('child_process')
|
||||||
const workerPath = join(__dirname, 'transcribeWorker.js')
|
const workerPath = join(__dirname, 'transcribeWorker.js')
|
||||||
|
|
||||||
const worker = new Worker(workerPath, {
|
const worker = fork(workerPath, [], {
|
||||||
workerData: {
|
env: this.buildTranscribeWorkerEnv(),
|
||||||
modelPath,
|
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
||||||
tokensPath,
|
serialization: 'advanced'
|
||||||
wavData,
|
})
|
||||||
sampleRate: 16000,
|
worker.send({
|
||||||
languages: supportedLanguages
|
modelPath,
|
||||||
}
|
tokensPath,
|
||||||
|
wavData,
|
||||||
|
sampleRate: 16000,
|
||||||
|
languages: supportedLanguages
|
||||||
})
|
})
|
||||||
|
|
||||||
let finalTranscript = ''
|
let finalTranscript = ''
|
||||||
@@ -227,11 +262,13 @@ export class VoiceTranscribeService {
|
|||||||
} else if (msg.type === 'final') {
|
} else if (msg.type === 'final') {
|
||||||
finalTranscript = msg.text
|
finalTranscript = msg.text
|
||||||
resolve({ success: true, transcript: finalTranscript })
|
resolve({ success: true, transcript: finalTranscript })
|
||||||
worker.terminate()
|
worker.disconnect()
|
||||||
|
worker.kill()
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
|
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
|
||||||
resolve({ success: false, error: msg.error })
|
resolve({ success: false, error: msg.error })
|
||||||
worker.terminate()
|
worker.disconnect()
|
||||||
|
worker.kill()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,56 @@
|
|||||||
import { parentPort, workerData } from 'worker_threads'
|
import { parentPort, workerData } from 'worker_threads'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
interface WorkerParams {
|
interface WorkerParams {
|
||||||
modelPath: string
|
modelPath: string
|
||||||
tokensPath: string
|
tokensPath: string
|
||||||
wavData: Buffer
|
wavData: Buffer | Uint8Array | { type: 'Buffer'; data: number[] }
|
||||||
sampleRate: number
|
sampleRate: number
|
||||||
languages?: string[]
|
languages?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendLibrarySearchPath(libDir: string): void {
|
||||||
|
if (!existsSync(libDir)) return
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const current = process.env.DYLD_LIBRARY_PATH || ''
|
||||||
|
const paths = current.split(':').filter(Boolean)
|
||||||
|
if (!paths.includes(libDir)) {
|
||||||
|
process.env.DYLD_LIBRARY_PATH = [libDir, ...paths].join(':')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
const current = process.env.LD_LIBRARY_PATH || ''
|
||||||
|
const paths = current.split(':').filter(Boolean)
|
||||||
|
if (!paths.includes(libDir)) {
|
||||||
|
process.env.LD_LIBRARY_PATH = [libDir, ...paths].join(':')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareSherpaRuntimeEnv(): void {
|
||||||
|
const platform = process.platform === 'win32' ? 'win' : process.platform
|
||||||
|
const platformPkg = `sherpa-onnx-${platform}-${process.arch}`
|
||||||
|
const resourcesPath = (process as any).resourcesPath as string | undefined
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
// Dev: /project/dist-electron -> /project/node_modules/...
|
||||||
|
join(__dirname, '..', 'node_modules', platformPkg),
|
||||||
|
// Fallback for alternate layouts
|
||||||
|
join(__dirname, 'node_modules', platformPkg),
|
||||||
|
join(process.cwd(), 'node_modules', platformPkg),
|
||||||
|
// Packaged app: Resources/app.asar.unpacked/node_modules/...
|
||||||
|
resourcesPath ? join(resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : ''
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
for (const dir of candidates) {
|
||||||
|
appendLibrarySearchPath(dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 语言标记映射
|
// 语言标记映射
|
||||||
const LANGUAGE_TAGS: Record<string, string> = {
|
const LANGUAGE_TAGS: Record<string, string> = {
|
||||||
'zh': '<|zh|>',
|
'zh': '<|zh|>',
|
||||||
@@ -95,22 +138,60 @@ function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
if (!parentPort) {
|
const isForkProcess = !parentPort
|
||||||
return;
|
const emit = (msg: any) => {
|
||||||
|
if (parentPort) {
|
||||||
|
parentPort.postMessage(msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof process.send === 'function') {
|
||||||
|
process.send(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeBuffer = (data: WorkerParams['wavData']): Buffer => {
|
||||||
|
if (Buffer.isBuffer(data)) return data
|
||||||
|
if (data instanceof Uint8Array) return Buffer.from(data)
|
||||||
|
if (data && typeof data === 'object' && (data as any).type === 'Buffer' && Array.isArray((data as any).data)) {
|
||||||
|
return Buffer.from((data as any).data)
|
||||||
|
}
|
||||||
|
return Buffer.alloc(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const readParams = async (): Promise<WorkerParams | null> => {
|
||||||
|
if (parentPort) {
|
||||||
|
return workerData as WorkerParams
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false
|
||||||
|
const finish = (value: WorkerParams | null) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
resolve(value)
|
||||||
|
}
|
||||||
|
process.once('message', (msg) => finish(msg as WorkerParams))
|
||||||
|
process.once('disconnect', () => finish(null))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
prepareSherpaRuntimeEnv()
|
||||||
|
const params = await readParams()
|
||||||
|
if (!params) return
|
||||||
|
|
||||||
// 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等)
|
// 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等)
|
||||||
let sherpa: any;
|
let sherpa: any;
|
||||||
try {
|
try {
|
||||||
sherpa = require('sherpa-onnx-node');
|
sherpa = require('sherpa-onnx-node');
|
||||||
} catch (requireError) {
|
} catch (requireError) {
|
||||||
parentPort.postMessage({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
|
emit({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
|
||||||
|
if (isForkProcess) process.exit(1)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = workerData as WorkerParams
|
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = params
|
||||||
const wavData = Buffer.from(rawWavData);
|
const wavData = normalizeBuffer(rawWavData);
|
||||||
// 确保有有效的语言列表,默认只允许中文
|
// 确保有有效的语言列表,默认只允许中文
|
||||||
let allowedLanguages = languages || ['zh']
|
let allowedLanguages = languages || ['zh']
|
||||||
if (allowedLanguages.length === 0) {
|
if (allowedLanguages.length === 0) {
|
||||||
@@ -151,16 +232,18 @@ async function run() {
|
|||||||
if (isLanguageAllowed(result, allowedLanguages)) {
|
if (isLanguageAllowed(result, allowedLanguages)) {
|
||||||
const processedText = richTranscribePostProcess(result.text)
|
const processedText = richTranscribePostProcess(result.text)
|
||||||
|
|
||||||
parentPort.postMessage({ type: 'final', text: processedText })
|
emit({ type: 'final', text: processedText })
|
||||||
|
if (isForkProcess) process.exit(0)
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
parentPort.postMessage({ type: 'final', text: '' })
|
emit({ type: 'final', text: '' })
|
||||||
|
if (isForkProcess) process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
parentPort.postMessage({ type: 'error', error: String(error) })
|
emit({ type: 'error', error: String(error) })
|
||||||
|
if (isForkProcess) process.exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
run();
|
run();
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,8 @@
|
|||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
"node_modules/silk-wasm/**/*",
|
"node_modules/silk-wasm/**/*",
|
||||||
"node_modules/sherpa-onnx-node/**/*",
|
"node_modules/sherpa-onnx-node/**/*",
|
||||||
|
"node_modules/sherpa-onnx-*/*",
|
||||||
|
"node_modules/sherpa-onnx-*/**/*",
|
||||||
"node_modules/ffmpeg-static/**/*"
|
"node_modules/ffmpeg-static/**/*"
|
||||||
],
|
],
|
||||||
"extraFiles": [
|
"extraFiles": [
|
||||||
|
|||||||
@@ -138,10 +138,6 @@ function App() {
|
|||||||
const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode
|
const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode
|
||||||
document.documentElement.setAttribute('data-theme', currentTheme)
|
document.documentElement.setAttribute('data-theme', currentTheme)
|
||||||
document.documentElement.setAttribute('data-mode', effectiveMode)
|
document.documentElement.setAttribute('data-mode', effectiveMode)
|
||||||
const symbolColor = effectiveMode === 'dark' ? '#ffffff' : '#1a1a1a'
|
|
||||||
if (!isOnboardingWindow && !isNotificationWindow) {
|
|
||||||
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applyMode(themeMode)
|
applyMode(themeMode)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
padding-right: 16px;
|
padding-right: 16px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
@@ -57,3 +58,35 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title-window-controls {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-window-control-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-close:hover {
|
||||||
|
background: #e5484d;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,34 @@
|
|||||||
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Copy, Minus, PanelLeftClose, PanelLeftOpen, Square, X } from 'lucide-react'
|
||||||
import './TitleBar.scss'
|
import './TitleBar.scss'
|
||||||
|
|
||||||
interface TitleBarProps {
|
interface TitleBarProps {
|
||||||
title?: string
|
title?: string
|
||||||
sidebarCollapsed?: boolean
|
sidebarCollapsed?: boolean
|
||||||
onToggleSidebar?: () => void
|
onToggleSidebar?: () => void
|
||||||
|
showWindowControls?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function TitleBar({ title, sidebarCollapsed = false, onToggleSidebar }: TitleBarProps = {}) {
|
function TitleBar({
|
||||||
|
title,
|
||||||
|
sidebarCollapsed = false,
|
||||||
|
onToggleSidebar,
|
||||||
|
showWindowControls = true
|
||||||
|
}: TitleBarProps = {}) {
|
||||||
|
const [isMaximized, setIsMaximized] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showWindowControls) return
|
||||||
|
|
||||||
|
void window.electronAPI.window.isMaximized().then(setIsMaximized).catch(() => {
|
||||||
|
setIsMaximized(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return window.electronAPI.window.onMaximizeStateChanged((maximized) => {
|
||||||
|
setIsMaximized(maximized)
|
||||||
|
})
|
||||||
|
}, [showWindowControls])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="title-bar">
|
<div className="title-bar">
|
||||||
<div className="title-brand">
|
<div className="title-brand">
|
||||||
@@ -25,6 +46,37 @@ function TitleBar({ title, sidebarCollapsed = false, onToggleSidebar }: TitleBar
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{showWindowControls ? (
|
||||||
|
<div className="title-window-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="title-window-control-btn"
|
||||||
|
aria-label="最小化"
|
||||||
|
title="最小化"
|
||||||
|
onClick={() => window.electronAPI.window.minimize()}
|
||||||
|
>
|
||||||
|
<Minus size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="title-window-control-btn"
|
||||||
|
aria-label={isMaximized ? '还原' : '最大化'}
|
||||||
|
title={isMaximized ? '还原' : '最大化'}
|
||||||
|
onClick={() => window.electronAPI.window.maximize()}
|
||||||
|
>
|
||||||
|
{isMaximized ? <Copy size={12} /> : <Square size={12} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="title-window-control-btn is-close"
|
||||||
|
aria-label="关闭"
|
||||||
|
title="关闭"
|
||||||
|
onClick={() => window.electronAPI.window.close()}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
2
src/types/electron.d.ts
vendored
2
src/types/electron.d.ts
vendored
@@ -11,6 +11,8 @@ export interface ElectronAPI {
|
|||||||
window: {
|
window: {
|
||||||
minimize: () => void
|
minimize: () => void
|
||||||
maximize: () => void
|
maximize: () => void
|
||||||
|
isMaximized: () => Promise<boolean>
|
||||||
|
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => () => void
|
||||||
close: () => void
|
close: () => void
|
||||||
openAgreementWindow: () => Promise<boolean>
|
openAgreementWindow: () => Promise<boolean>
|
||||||
completeOnboarding: () => Promise<boolean>
|
completeOnboarding: () => Promise<boolean>
|
||||||
|
|||||||
Reference in New Issue
Block a user