diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b4c7459..58b8b58 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,58 @@ permissions: contents: write 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 < release_notes.md + ## 更新日志 + 修复了一些已知问题 + + ## 查看更多日志/获取最新动态 + [点击加入 Telegram 频道](https://t.me/weflow_cc) + EOF + + gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md + release: runs-on: windows-latest diff --git a/electron/main.ts b/electron/main.ts index ccf7c37..bb6576f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -235,6 +235,23 @@ const isYearsLoadCanceled = (taskId: string): boolean => { 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 } = {}) { // 获取图标路径 - 打包后在 resources 目录 const { autoShow = true } = options @@ -256,13 +273,10 @@ function createWindow(options: { autoShow?: boolean } = {}) { webSecurity: false // Allow loading local files (video playback) }, titleBarStyle: 'hidden', - titleBarOverlay: { - color: '#00000000', - symbolColor: '#1a1a1a', - height: 40 - }, + titleBarOverlay: false, show: false }) + setupCustomTitleBarWindow(win) // 窗口准备好后显示 // Splash 模式下不在这里 show,由启动流程统一控制 @@ -710,15 +724,12 @@ function createChatHistoryWindow(sessionId: string, messageId: number) { nodeIntegration: false }, titleBarStyle: 'hidden', - titleBarOverlay: { - color: '#00000000', - symbolColor: isDark ? '#ffffff' : '#1a1a1a', - height: 32 - }, + titleBarOverlay: false, show: false, backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0', autoHideMenuBar: true }) + setupCustomTitleBarWindow(win) win.once('ready-to-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) => { BrowserWindow.fromWebContents(event.sender)?.close() }) diff --git a/electron/preload.ts b/electron/preload.ts index 41039e2..2f2874c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -86,6 +86,12 @@ contextBridge.exposeInMainWorld('electronAPI', { window: { minimize: () => ipcRenderer.send('window:minimize'), 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'), openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'), completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'), diff --git a/electron/services/cloudControlService.ts b/electron/services/cloudControlService.ts index 89edc29..c611bf0 100644 --- a/electron/services/cloudControlService.ts +++ b/electron/services/cloudControlService.ts @@ -67,7 +67,10 @@ class CloudControlService { } 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 @@ -92,4 +95,3 @@ class CloudControlService { export const cloudControlService = new CloudControlService() - diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 2caa66b..66345c2 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -715,6 +715,68 @@ export class KeyService { 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 { + 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( manualDir?: string, onProgress?: (message: string) => void, @@ -750,52 +812,34 @@ export class KeyService { const codes: number[] = accounts[0].keys.map((k: any) => k.code) console.log('[ImageKey] codes:', codes, 'DLL wxids:', accounts.map((a: any) => a.wxid)) - // 优先级: 1. 直接传入的wxidParam 2. 从manualDir提取 3. DLL返回的wxid(可能是unknown) - let targetWxid = '' - - // 方案1: 直接使用传入的wxidParam(最优先) - if (wxidParam && wxidParam.startsWith('wxid_')) { - targetWxid = wxidParam - console.log('[ImageKey] 使用直接传入的 wxid:', targetWxid) + const wxidCandidates = await this.collectWxidCandidates(manualDir, wxidParam) + let verifyCiphertext: Buffer | null = null + if (manualDir && existsSync(manualDir)) { + const template = await this._findTemplateData(manualDir, 32) + verifyCiphertext = template.ciphertext } - - // 方案2: 从 manualDir 提取前端已配置好的正确 wxid - // 格式: "D:\weixin\xwechat_files\wxid_xxx_1234" → "wxid_xxx_1234" - if (!targetWxid && manualDir) { - const dirName = manualDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? '' - if (dirName.startsWith('wxid_')) { - targetWxid = dirName - console.log('[ImageKey] 从 manualDir 提取 wxid:', targetWxid) + + if (verifyCiphertext) { + onProgress?.(`正在校验候选 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 + 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) { - targetWxid = accounts[0].wxid - console.log('[ImageKey] 无法获取 wxid,使用 DLL 发现的:', targetWxid) - } - - // CleanWxid: 截断到第二个下划线,与 xkey 算法一致 - 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 - } + // 无模板密文可验真时回退旧策略 + const fallbackWxid = wxidCandidates[0] || accounts[0].wxid || 'unknown' + const fallbackCode = codes[0] + const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid) + onProgress?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`) + console.log('[ImageKey] 回退计算: wxid=', fallbackWxid, 'code=', fallbackCode) + return { success: true, xorKey, aesKey } } // --- 内存扫描备选方案(融合 Dart+Python 优点)--- diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts index 56280b6..4392c81 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -1,8 +1,10 @@ import { app, shell } from 'electron' -import { join } from 'path' +import { join, basename, dirname } from 'path' import { existsSync, readdirSync, readFileSync, statSync } from 'fs' import { execFile, spawn } from 'child_process' import { promisify } from 'util' +import crypto from 'crypto' +import { homedir } from 'os' type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] } type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string } @@ -14,9 +16,13 @@ export class KeyServiceMac { private initialized = false private GetDbKey: any = null - private ScanMemoryForImageKey: any = null - private FreeString: 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 { const isPackaged = app.isPackaged @@ -81,8 +87,6 @@ export class KeyServiceMac { this.lib = this.koffi.load(dylibPath) 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.initialized = true @@ -97,30 +101,18 @@ export class KeyServiceMac { ): Promise { try { onStatus?.('正在获取数据库密钥...', 0) - let parsed = await this.getDbKeyParsed(timeoutMs, onStatus) - console.log('[KeyServiceMac] GetDbKey returned:', parsed.raw) - - // ATTACH_FAILED 时自动走图形化授权,再重试一次 - if (!parsed.success && parsed.code === 'ATTACH_FAILED') { - onStatus?.('检测到调试权限不足,正在请求系统授权...', 0) - const permissionOk = await this.enableDebugPermissionWithPrompt() - if (permissionOk) { - onStatus?.('授权完成,正在重试获取密钥...', 0) - parsed = await this.getDbKeyParsed(timeoutMs, onStatus) - console.log('[KeyServiceMac] GetDbKey retry returned:', parsed.raw) - } else { - onStatus?.('已取消系统授权', 2) - return { success: false, error: '已取消系统授权' } + onStatus?.('正在请求管理员授权并执行 helper...', 0) + let parsed: { success: boolean; key?: string; code?: string; detail?: string; raw: string } + try { + const elevatedResult = await this.getDbKeyByHelperElevated(timeoutMs, onStatus) + parsed = this.parseDbKeyResult(elevatedResult) + console.log('[KeyServiceMac] GetDbKey elevated returned:', parsed.raw) + } catch (e: any) { + const msg = `${e?.message || e}` + if (msg.includes('(-128)') || msg.includes('User canceled')) { + return { success: false, error: '已取消管理员授权' } } - } - - 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 } + throw e } if (!parsed.success) { @@ -157,45 +149,39 @@ export class KeyServiceMac { timeoutMs: number, onStatus?: (message: string, level: number) => void ): Promise<{ success: boolean; key?: string; code?: string; detail?: string; raw: string }> { - try { - const helperResult = await this.getDbKeyByHelper(timeoutMs, onStatus) - 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()) - } + const helperResult = await this.getDbKeyByHelper(timeoutMs, onStatus) + return this.parseDbKeyResult(helperResult) } private async getWeChatPid(): Promise { 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 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) { const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/) if (!match) continue - - const pid = parseInt(match[1]) - const comm = match[2] + + const pid = parseInt(match[1], 10) const command = match[3] - - const nameMatch = comm === 'WeChat' || comm === '微信' - const pathMatch = command.includes('WeChat.app') || command.includes('/Contents/MacOS/WeChat') - - if (nameMatch && pathMatch) { - candidates.push({ pid, comm, command }) - } + + const pathMatch = command.includes('/Applications/WeChat.app/Contents/MacOS/WeChat') || + command.includes('/Contents/MacOS/WeChat') + if (pathMatch) candidates.push({ pid, command }) } - - if (candidates.length === 0) { - throw new Error('WeChat process not found') - } - + + if (candidates.length === 0) throw new Error('WeChat process not found') + const filtered = candidates.filter(p => { const cmd = p.command return !cmd.includes('WeChatAppEx.app/') && @@ -204,18 +190,11 @@ export class KeyServiceMac { !cmd.includes('crashpad_handler') && !cmd.includes('Helper') }) - - if (filtered.length === 0) { - throw new Error('No valid WeChat main process found') - } - - const preferredMain = filtered.filter(p => - p.command.includes('/Contents/MacOS/WeChat') - ) - + if (filtered.length === 0) 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 selected = selectedPool.reduce((max, p) => p.pid > max.pid ? p : max) - return selected.pid } catch (e: any) { throw new Error('Failed to get WeChat PID: ' + e.message) @@ -229,8 +208,12 @@ export class KeyServiceMac { const helperPath = this.getHelperPath() const waitMs = Math.max(timeoutMs, 30_000) const pid = await this.getWeChatPid() + onStatus?.(`已找到微信进程 PID=${pid},正在定位目标函数...`, 0) + // 最佳努力清理同路径残留 helper(普通权限) + try { await execFileAsync('pkill', ['-f', helperPath], { timeout: 2000 }) } catch { } return await new Promise((resolve, reject) => { + // xkey_helper 参数协议:helper [timeout_ms] const child = spawn(helperPath, [String(pid), String(waitMs)], { stdio: ['ignore', 'pipe', 'pipe'] }) let stdout = '' 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 { + 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 { if (code === 'PROCESS_NOT_FOUND') return '微信进程未运行' if (code === 'ATTACH_FAILED') { @@ -406,18 +434,52 @@ export class KeyServiceMac { onStatus?: (message: string) => void, wxid?: string ): Promise { - onStatus?.('macOS 请使用内存扫描方式') - return { success: false, error: 'macOS 请使用内存扫描方式' } + try { + 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( userDir: string, onProgress?: (message: string) => void ): Promise { - if (!this.initialized) { - await this.initialize() - } - try { // 1. 查找模板文件获取密文和 XOR 密钥 onProgress?.('正在查找模板文件...') @@ -447,7 +509,7 @@ export class KeyServiceMac { while (Date.now() < deadline) { scanCount++ onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`) - const aesKey = await this._scanMemoryForAesKey(pid, ciphertext) + const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress) if (aesKey) { onProgress?.('密钥获取成功') return { success: true, xorKey, aesKey } @@ -516,10 +578,134 @@ export class KeyServiceMac { return { ciphertext, xorKey } } - private async _scanMemoryForAesKey(pid: number, ciphertext: Buffer): Promise { - const ciphertextHex = ciphertext.toString('hex') - const aesKey = this.ScanMemoryForImageKey(pid, ciphertextHex) - return aesKey || null + private ensureMachApis(): boolean { + if (this.machTaskSelf && this.taskForPid && this.machVmRegion && this.machVmReadOverwrite) return true + try { + 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 { + 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 { @@ -536,5 +722,224 @@ export class KeyServiceMac { cleanup(): void { this.lib = null 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() + 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([ + // 与用户实测路径一致: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 } } diff --git a/electron/services/voiceTranscribeService.ts b/electron/services/voiceTranscribeService.ts index 5ff3d84..cc75828 100644 --- a/electron/services/voiceTranscribeService.ts +++ b/electron/services/voiceTranscribeService.ts @@ -48,6 +48,38 @@ export class VoiceTranscribeService { private recognizer: OfflineRecognizer | null = null 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 { const configured = this.configService.get('whisperModelDir') as string | undefined 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 worker = new Worker(workerPath, { - workerData: { - modelPath, - tokensPath, - wavData, - sampleRate: 16000, - languages: supportedLanguages - } + const worker = fork(workerPath, [], { + env: this.buildTranscribeWorkerEnv(), + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + serialization: 'advanced' + }) + worker.send({ + modelPath, + tokensPath, + wavData, + sampleRate: 16000, + languages: supportedLanguages }) let finalTranscript = '' @@ -227,11 +262,13 @@ export class VoiceTranscribeService { } else if (msg.type === 'final') { finalTranscript = msg.text resolve({ success: true, transcript: finalTranscript }) - worker.terminate() + worker.disconnect() + worker.kill() } else if (msg.type === 'error') { console.error('[VoiceTranscribe] Worker 错误:', msg.error) resolve({ success: false, error: msg.error }) - worker.terminate() + worker.disconnect() + worker.kill() } }) diff --git a/electron/transcribeWorker.ts b/electron/transcribeWorker.ts index e5a18d1..847ed06 100644 --- a/electron/transcribeWorker.ts +++ b/electron/transcribeWorker.ts @@ -1,13 +1,56 @@ import { parentPort, workerData } from 'worker_threads' +import { existsSync } from 'fs' +import { join } from 'path' interface WorkerParams { modelPath: string tokensPath: string - wavData: Buffer + wavData: Buffer | Uint8Array | { type: 'Buffer'; data: number[] } sampleRate: number 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 = { 'zh': '<|zh|>', @@ -95,22 +138,60 @@ function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean { } async function run() { - if (!parentPort) { - return; + const isForkProcess = !parentPort + 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 => { + 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 { + prepareSherpaRuntimeEnv() + const params = await readParams() + if (!params) return + // 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等) let sherpa: any; try { sherpa = require('sherpa-onnx-node'); } 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; } - const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = workerData as WorkerParams - const wavData = Buffer.from(rawWavData); + const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = params + const wavData = normalizeBuffer(rawWavData); // 确保有有效的语言列表,默认只允许中文 let allowedLanguages = languages || ['zh'] if (allowedLanguages.length === 0) { @@ -151,16 +232,18 @@ async function run() { if (isLanguageAllowed(result, allowedLanguages)) { const processedText = richTranscribePostProcess(result.text) - parentPort.postMessage({ type: 'final', text: processedText }) + emit({ type: 'final', text: processedText }) + if (isForkProcess) process.exit(0) } else { - parentPort.postMessage({ type: 'final', text: '' }) + emit({ type: 'final', text: '' }) + if (isForkProcess) process.exit(0) } } catch (error) { - parentPort.postMessage({ type: 'error', error: String(error) }) + emit({ type: 'error', error: String(error) }) + if (isForkProcess) process.exit(1) } } run(); - diff --git a/package.json b/package.json index 1666f1e..99b2c94 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,8 @@ "asarUnpack": [ "node_modules/silk-wasm/**/*", "node_modules/sherpa-onnx-node/**/*", + "node_modules/sherpa-onnx-*/*", + "node_modules/sherpa-onnx-*/**/*", "node_modules/ffmpeg-static/**/*" ], "extraFiles": [ diff --git a/src/App.tsx b/src/App.tsx index f50a7b2..e287a68 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -138,10 +138,6 @@ function App() { const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode document.documentElement.setAttribute('data-theme', currentTheme) document.documentElement.setAttribute('data-mode', effectiveMode) - const symbolColor = effectiveMode === 'dark' ? '#ffffff' : '#1a1a1a' - if (!isOnboardingWindow && !isNotificationWindow) { - window.electronAPI.window.setTitleBarOverlay({ symbolColor }) - } } applyMode(themeMode) diff --git a/src/components/TitleBar.scss b/src/components/TitleBar.scss index 139083c..b90b64c 100644 --- a/src/components/TitleBar.scss +++ b/src/components/TitleBar.scss @@ -3,6 +3,7 @@ background: var(--bg-secondary); display: flex; align-items: center; + justify-content: space-between; padding-left: 16px; padding-right: 16px; border-bottom: 1px solid var(--border-color); @@ -57,3 +58,35 @@ 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; + } +} diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index 7b1b4e0..d208eda 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -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' interface TitleBarProps { title?: string sidebarCollapsed?: boolean 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 (
@@ -25,6 +46,37 @@ function TitleBar({ title, sidebarCollapsed = false, onToggleSidebar }: TitleBar ) : null}
+ {showWindowControls ? ( +
+ + + +
+ ) : null}
) } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 72aaa57..64806fc 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -11,6 +11,8 @@ export interface ElectronAPI { window: { minimize: () => void maximize: () => void + isMaximized: () => Promise + onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => () => void close: () => void openAgreementWindow: () => Promise completeOnboarding: () => Promise