diff --git a/electron-builder.yml b/electron-builder.yml index 094c3fb..9a7997e 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -68,4 +68,4 @@ publish: provider: github owner: Wxw-Gu repo: WechatExplorer - releaseType: draft + releaseType: release diff --git a/index.js b/index.js new file mode 100644 index 0000000..4ca69b4 --- /dev/null +++ b/index.js @@ -0,0 +1,139 @@ +"use strict"; +const electron = require("electron"); +const preload = require("@electron-toolkit/preload"); +const api = { + writeAppLog: (entry) => electron.ipcRenderer.invoke("app-log:write", entry), + getAppLogPath: () => electron.ipcRenderer.invoke("app-log:getPath"), + revealAppLog: () => electron.ipcRenderer.invoke("app-log:reveal"), + getAppUpdateState: () => electron.ipcRenderer.invoke("app-update:getState"), + checkAppUpdate: () => electron.ipcRenderer.invoke("app-update:check"), + downloadAppUpdate: () => electron.ipcRenderer.invoke("app-update:download"), + installAppUpdate: () => electron.ipcRenderer.invoke("app-update:install"), + onAppUpdateState: (callback) => { + const listener = (_event, state) => callback(state); + electron.ipcRenderer.on("app-update:state", listener); + return () => electron.ipcRenderer.removeListener("app-update:state", listener); + }, + getCacheSummary: () => electron.ipcRenderer.invoke("cache:getSummary"), + clearCache: (scope) => electron.ipcRenderer.invoke("cache:clear", scope), + initDb: (key) => electron.ipcRenderer.invoke("db:init", key), + getBootstrapCache: () => electron.ipcRenderer.invoke("db:getBootstrapCache"), + getStartupCache: () => electron.ipcRenderer.invoke("db:getStartupCache"), + getContacts: (filter) => electron.ipcRenderer.invoke("db:getContacts", filter), + getContactAvatars: (usernames) => electron.ipcRenderer.invoke("db:getContactAvatars", usernames), + getCachedMessages: (userMd5, startTime, endTime) => electron.ipcRenderer.invoke("db:getCachedMessages", userMd5, startTime, endTime), + getCachedMessagePage: (userMd5, startTime, endTime) => electron.ipcRenderer.invoke("db:getCachedMessagePage", userMd5, startTime, endTime), + getMessages: (userMd5, startTime, endTime, options) => electron.ipcRenderer.invoke("db:getMessages", userMd5, startTime, endTime, options), + getGroupSnapshot: (userMd5) => electron.ipcRenderer.invoke("db:getGroupSnapshot", userMd5), + search: (keyword) => electron.ipcRenderer.invoke("db:search", keyword), + aiChat: (messages, options) => electron.ipcRenderer.invoke("ai:chat", messages, options), + listAIProviders: () => electron.ipcRenderer.invoke("ai:listProviders"), + getAIRuntimeConfig: () => electron.ipcRenderer.invoke("ai:getRuntimeConfig"), + saveAIProvider: (provider) => electron.ipcRenderer.invoke("ai:saveProvider", provider), + deleteAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:deleteProvider", providerId), + setDefaultAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:setDefaultProvider", providerId), + testAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:testProvider", providerId), + testAIVision: (request) => electron.ipcRenderer.invoke("ai:testVision", request), + migrateLegacyAIConfig: (config) => electron.ipcRenderer.invoke("ai:migrateLegacy", config), + copyImage: (base64String) => electron.ipcRenderer.invoke("copy-image", base64String), + getVoiceData: (sessionId, localId, createTime, svrId) => electron.ipcRenderer.invoke("db:getVoiceData", sessionId, localId, createTime, svrId), + parseMessage: (content, messageType) => electron.ipcRenderer.invoke("db:parseMessage", content, messageType), + getImage: (imageMd5, imageDatNameOrThumb, sessionId, options) => electron.ipcRenderer.invoke("db:getImage", imageMd5, imageDatNameOrThumb, sessionId, options), + getVideo: (hashes) => electron.ipcRenderer.invoke("db:getVideo", hashes), + getSticker: (cdnUrl, md5) => electron.ipcRenderer.invoke("db:getSticker", cdnUrl, md5), + startExport: (request) => electron.ipcRenderer.invoke("export:start", request), + cancelExport: (jobId) => electron.ipcRenderer.invoke("export:cancel", jobId), + revealExport: (path) => electron.ipcRenderer.invoke("export:reveal", path), + onExportProgress: (callback) => { + const listener = (_event, progress) => callback(progress); + electron.ipcRenderer.on("export:progress", listener); + return () => electron.ipcRenderer.removeListener("export:progress", listener); + }, + exportGroupReport: (request) => electron.ipcRenderer.invoke("report:export", request), + listGeneratedReports: () => electron.ipcRenderer.invoke("report:listGenerated"), + saveGeneratedReport: (request) => electron.ipcRenderer.invoke("report:saveGenerated", request), + deleteGeneratedReport: (reportId) => electron.ipcRenderer.invoke("report:deleteGenerated", reportId), + revealGroupReport: (filePath) => electron.ipcRenderer.invoke("report:reveal", filePath), + getSavedDbKey: () => electron.ipcRenderer.invoke("key:getSavedDbKey"), + getDatabaseKeyEnvironment: () => electron.ipcRenderer.invoke("key:getEnvironment"), + readDatabaseKeyClipboard: () => electron.ipcRenderer.invoke("key:readClipboardDbKey"), + autoGetDbKey: (options) => electron.ipcRenderer.invoke("key:autoGetDbKey", options), + autoGetImageKey: (options) => electron.ipcRenderer.invoke("key:autoGetImageKey", options), + getImageKeyConfig: () => electron.ipcRenderer.invoke("image:getConfig"), + getImageDecryptionStatus: () => electron.ipcRenderer.invoke("image:getStatus"), + saveImageKeyConfig: (request) => electron.ipcRenderer.invoke("image:saveConfig", request), + testImageDecryption: (request) => electron.ipcRenderer.invoke("image:testConfig", request), + clearImageKeyConfig: () => electron.ipcRenderer.invoke("image:clearConfig"), + pasteAndSaveDbKey: () => electron.ipcRenderer.invoke("key:pasteAndSaveDbKey"), + saveDbKey: (key) => electron.ipcRenderer.invoke("key:saveDbKey", key), + clearSavedDbKey: () => electron.ipcRenderer.invoke("key:clearSavedDbKey"), + onWcdbChange: (callback) => { + const listener = (_event, payload) => callback(payload); + electron.ipcRenderer.on("wcdb-change", listener); + return () => electron.ipcRenderer.removeListener("wcdb-change", listener); + }, + onDbKeyStatus: (callback) => { + const listener = (_event, payload) => callback(payload); + electron.ipcRenderer.on("key:dbKeyStatus", listener); + return () => electron.ipcRenderer.removeListener("key:dbKeyStatus", listener); + }, + onImageKeyStatus: (callback) => { + const listener = (_event, payload) => callback(payload); + electron.ipcRenderer.on("key:imageKeyStatus", listener); + return () => electron.ipcRenderer.removeListener("key:imageKeyStatus", listener); + }, + getSettings: () => electron.ipcRenderer.invoke("settings:get"), + setSettings: (patch) => electron.ipcRenderer.invoke("settings:set", patch), + getSelf: () => electron.ipcRenderer.invoke("settings:getSelf"), + testConnection: (key, accountRoot) => electron.ipcRenderer.invoke("db:testConnection", key, accountRoot), + reopenWithRoot: (accountRoot) => electron.ipcRenderer.invoke("db:reopenWithRoot", accountRoot), + selectDbRoot: () => electron.ipcRenderer.invoke("settings:selectDbRoot"), + openAccountRoot: () => electron.ipcRenderer.invoke("settings:openAccountRoot"), + disconnectDb: (options) => electron.ipcRenderer.invoke("db:disconnect", options), + apiStatus: () => electron.ipcRenderer.invoke("api:getStatus"), + apiStart: (host, port) => electron.ipcRenderer.invoke("api:start", host, port), + apiStop: () => electron.ipcRenderer.invoke("api:stop"), + apiToggle: (enabled) => electron.ipcRenderer.invoke("api:toggle", enabled), + getReaderSkillStatus: () => electron.ipcRenderer.invoke("api:skillStatus"), + readReaderSkill: () => electron.ipcRenderer.invoke("api:readSkill"), + revealReaderSkill: () => electron.ipcRenderer.invoke("api:revealSkill"), + openReaderSkillGithub: () => electron.ipcRenderer.invoke("api:openSkillGithub"), + testLocalApiRequest: (request) => electron.ipcRenderer.invoke("api:testLocalRequest", request), + copyText: (text) => electron.ipcRenderer.invoke("api:copyText", text), + // ============================================================ + // AI 图片理解基础设施(ImageInsightService) + // ============================================================ + imageListCandidates: (query) => electron.ipcRenderer.invoke("image:listCandidates", query), + imageAnalyze: (request) => electron.ipcRenderer.invoke("image:analyze", request), + getImageInsight: (imageHash) => electron.ipcRenderer.invoke("image:getInsight", imageHash), + listImageInsights: (sessionId, limit) => electron.ipcRenderer.invoke("image:listInsights", sessionId, limit), + getAgentHubStatus: () => electron.ipcRenderer.invoke("agent-hub:getStatus"), + getAgentHubLogs: () => electron.ipcRenderer.invoke("agent-hub:getLogs"), + clearAgentHubLogs: () => electron.ipcRenderer.invoke("agent-hub:clearLogs"), + startAgentHubLogin: () => electron.ipcRenderer.invoke("agent-hub:startLogin"), + cancelAgentHubLogin: () => electron.ipcRenderer.invoke("agent-hub:cancelLogin"), + reconnectAgentHub: () => electron.ipcRenderer.invoke("agent-hub:reconnect"), + disconnectAgentHub: () => electron.ipcRenderer.invoke("agent-hub:disconnect"), + selectAgentHubTestImage: () => electron.ipcRenderer.invoke("agent-hub:selectTestImage"), + onAgentHubStatus: (callback) => { + const listener = (_event, status) => callback(status); + electron.ipcRenderer.on("agent-hub:status", listener); + return () => electron.ipcRenderer.removeListener("agent-hub:status", listener); + }, + onAgentHubLog: (callback) => { + const listener = (_event, entry) => callback(entry); + electron.ipcRenderer.on("agent-hub:log", listener); + return () => electron.ipcRenderer.removeListener("agent-hub:log", listener); + } +}; +if (process.contextIsolated) { + try { + electron.contextBridge.exposeInMainWorld("electron", preload.electronAPI); + electron.contextBridge.exposeInMainWorld("api", api); + } catch (error) { + console.error(error); + } +} else { + window.electron = preload.electronAPI; + window.api = api; +} diff --git a/package.json b/package.json index f782315..25b24f1 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@electron-toolkit/utils": "^4.0.0", "@koromix/koffi-win32-x64": "3.1.0", "@tanstack/react-virtual": "^3.14.6", + "electron-updater": "^6.6.2", "fs-extra": "^11.3.2", "fzstd": "^0.1.1", "jsonrepair": "^3.15.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff6b9e7..7bb6bdf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,7 @@ specifiers: '@vitejs/plugin-react': ^5.1.1 electron: ^43.0.0 electron-builder: ^26.0.12 + electron-updater: ^6.6.2 electron-vite: ^5.0.0 eslint: ^9.39.1 eslint-plugin-react: ^7.37.5 @@ -44,6 +45,7 @@ dependencies: '@electron-toolkit/utils': 4.0.0_electron@43.1.0 '@koromix/koffi-win32-x64': 3.1.0 '@tanstack/react-virtual': 3.14.6_bokjwhiew3ov3ffvbmafuwoalq + electron-updater: 6.8.9 fs-extra: 11.3.2 fzstd: 0.1.1 jsonrepair: 3.15.0 @@ -1705,7 +1707,6 @@ packages: /argparse/2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - dev: true /array-buffer-byte-length/1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} @@ -1894,6 +1895,16 @@ packages: - supports-color dev: true + /builder-util-runtime/9.7.0: + resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==} + engines: {node: '>=12.0.0'} + dependencies: + debug: 4.4.3 + sax: 1.4.3 + transitivePeerDependencies: + - supports-color + dev: false + /builder-util/26.0.11: resolution: {integrity: sha512-xNjXfsldUEe153h1DraD0XvDOpqGR0L5eKFkdReB7eFW5HqysDZFfly4rckda6y9dF39N3pkPlOblcfHKGw+uA==} dependencies: @@ -2354,6 +2365,21 @@ packages: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} dev: true + /electron-updater/6.8.9: + resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} + dependencies: + builder-util-runtime: 9.7.0 + fs-extra: 10.1.0 + js-yaml: 4.1.1 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.3 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + dev: false + /electron-vite/5.0.0_vite@7.2.7: resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2874,7 +2900,6 @@ packages: graceful-fs: 4.2.11 jsonfile: 6.2.0 universalify: 2.0.1 - dev: true /fs-extra/11.3.2: resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} @@ -3539,7 +3564,6 @@ packages: hasBin: true dependencies: argparse: 2.0.1 - dev: true /jsesc/3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} @@ -3614,7 +3638,6 @@ packages: /lazy-val/1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} - dev: true /levn/0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} @@ -3631,6 +3654,15 @@ packages: p-locate: 5.0.0 dev: true + /lodash.escaperegexp/4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + dev: false + + /lodash.isequal/4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + dev: false + /lodash.merge/4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true @@ -4419,7 +4451,6 @@ packages: /sax/1.4.3: resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==} - dev: true /scheduler/0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -4767,6 +4798,10 @@ packages: semver: 5.7.2 dev: true + /tiny-typed-emitter/2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + dev: false + /tinyglobby/0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} diff --git a/src/main/app-logger.ts b/src/main/app-logger.ts index b002169..c3d6400 100644 --- a/src/main/app-logger.ts +++ b/src/main/app-logger.ts @@ -2,6 +2,7 @@ import { app, shell } from 'electron' import fs from 'fs-extra' import path from 'path' import type { AppLogEntry } from '../shared/app-log' +import { isPackagedRuntime } from './runtime-mode' const MAX_LOG_BYTES = 5 * 1024 * 1024 const REDACTED_KEY = /(?:api[-_]?key|authorization|token|secret|password|database[-_]?key)/i @@ -52,14 +53,14 @@ export class AppLogger { this.rotateIfNeeded() const record = { timestamp: new Date().toISOString(), - mode: app.isPackaged ? 'packaged' : 'development', + mode: isPackagedRuntime() ? 'packaged' : 'development', level: entry.level, scope: String(entry.scope || 'app').slice(0, 80), message: String(entry.message || '').slice(0, 500), details: sanitize(entry.details || {}) } fs.appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8' }) - if (!app.isPackaged) { + if (!isPackagedRuntime()) { const method = entry.level === 'error' ? console.error diff --git a/src/main/index.ts b/src/main/index.ts index 0df3d2c..96ec4cf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -76,6 +76,9 @@ import { installSafeConsole } from './safe-log' import { agentHubService } from './services/agent-hub-service' import { appLogger } from './app-logger' import type { AppLogEntry } from '../shared/app-log' +import { appUpdateService } from './services/app-update-service' +import { clearCache, getCacheSummary } from './services/cache-service' +import type { CacheClearScope } from './services/cache-service' import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-archive-service' import { VideoAssetService } from './video-asset-service' import { cancelExport, revealExport, runExport } from './export-service' @@ -349,6 +352,17 @@ app.whenReady().then(async () => { ipcMain.handle('app-log:write', (_, entry: AppLogEntry) => appLogger.write(entry)) ipcMain.handle('app-log:getPath', () => appLogger.logPath) ipcMain.handle('app-log:reveal', () => appLogger.reveal()) + ipcMain.handle('app-update:getState', () => appUpdateService.getState()) + ipcMain.handle('app-update:check', () => appUpdateService.check()) + ipcMain.handle('app-update:download', () => appUpdateService.download()) + ipcMain.handle('app-update:install', () => appUpdateService.install()) + ipcMain.handle('cache:getSummary', () => getCacheSummary()) + ipcMain.handle('cache:clear', async (_, scope: CacheClearScope) => { + const allowedScopes: CacheClearScope[] = ['bootstrap', 'electron', 'all'] + if (!allowedScopes.includes(scope)) return getCacheSummary() + imageDecryptService = null + return clearCache(scope) + }) ipcMain.handle('db:init', async (_, key: string) => { if (dbInitInFlight) return dbInitInFlight diff --git a/src/main/runtime-mode.ts b/src/main/runtime-mode.ts new file mode 100644 index 0000000..df787bd --- /dev/null +++ b/src/main/runtime-mode.ts @@ -0,0 +1,12 @@ +import { app } from 'electron' +import { existsSync } from 'fs' +import { join } from 'path' + +export function isPackagedRuntime(): boolean { + if (app.isPackaged) return true + + return ( + existsSync(join(process.resourcesPath, 'app.asar')) && + existsSync(join(process.resourcesPath, 'app-update.yml')) + ) +} diff --git a/src/main/services/agent-hub-service.ts b/src/main/services/agent-hub-service.ts index 99bc48d..886c777 100644 --- a/src/main/services/agent-hub-service.ts +++ b/src/main/services/agent-hub-service.ts @@ -15,6 +15,7 @@ import type { import type { AppSettings } from './settings-store' import { generateAgentGroupReport } from './agent-group-report-service' import { AIProviderService } from './ai-provider-service' +import { isPackagedRuntime } from '../runtime-mode' import { getGroupSnapshot, isReady, @@ -68,7 +69,7 @@ const agentAIProvider = new AIProviderService() function resolveBundledBinary( resourceSegments: string[], executable: string, - packaged = app.isPackaged, + packaged = isPackagedRuntime(), platform = process.platform, arch = process.arch ): string { @@ -80,7 +81,7 @@ function resolveBundledBinary( } export function resolveWechatConnectorBinaryPath( - packaged = app.isPackaged, + packaged = isPackagedRuntime(), platform = process.platform, arch = process.arch ): string { diff --git a/src/main/services/app-update-service.ts b/src/main/services/app-update-service.ts new file mode 100644 index 0000000..d7dfdc0 --- /dev/null +++ b/src/main/services/app-update-service.ts @@ -0,0 +1,120 @@ +import { app, BrowserWindow } from 'electron' +import { autoUpdater, type ProgressInfo } from 'electron-updater' +import type { AppUpdateCheckResult, AppUpdateState } from '../../shared/app-update' +import { isPackagedRuntime } from '../runtime-mode' + +export class AppUpdateService { + private state: AppUpdateState = { + status: 'idle', + currentVersion: app.getVersion() + } + + constructor() { + autoUpdater.autoDownload = false + autoUpdater.autoInstallOnAppQuit = true + autoUpdater.on('checking-for-update', () => this.setState({ status: 'checking' })) + autoUpdater.on('update-available', (info) => + this.setState({ status: 'available', version: info.version, message: '发现新版本' }) + ) + autoUpdater.on('update-not-available', () => + this.setState({ status: 'not-available', message: '当前已是最新版本' }) + ) + autoUpdater.on('download-progress', (progress: ProgressInfo) => + this.setState({ + status: 'downloading', + percent: progress.percent, + transferred: progress.transferred, + total: progress.total, + bytesPerSecond: progress.bytesPerSecond + }) + ) + autoUpdater.on('update-downloaded', (info) => + this.setState({ + status: 'downloaded', + version: info.version, + percent: 100, + message: '更新已下载' + }) + ) + autoUpdater.on('error', (error) => + this.setState({ status: 'error', message: error.message || '更新失败' }) + ) + } + + getState(): AppUpdateState { + return { ...this.state, currentVersion: app.getVersion() } + } + + async check(): Promise { + if (!isPackagedRuntime()) { + const state = this.setState({ + status: 'unsupported', + message: '开发模式不执行安装包更新,请在正式安装包中检查更新' + }) + return { success: false, state } + } + try { + const result = await autoUpdater.checkForUpdates() + if (result?.updateInfo.version) { + this.setState({ + status: 'available', + version: result.updateInfo.version, + message: '发现新版本' + }) + } + return { success: true, state: this.getState() } + } catch (error) { + const state = this.setState({ + status: 'error', + message: error instanceof Error ? error.message : String(error) + }) + return { success: false, state } + } + } + + async download(): Promise { + if (!isPackagedRuntime()) { + const state = this.setState({ status: 'unsupported', message: '开发模式不能下载更新' }) + return { success: false, state } + } + try { + this.setState({ status: 'downloading', percent: 0 }) + await autoUpdater.downloadUpdate() + return { success: true, state: this.getState() } + } catch (error) { + const state = this.setState({ + status: 'error', + message: error instanceof Error ? error.message : String(error) + }) + return { success: false, state } + } + } + + install(): { success: boolean; error?: string } { + if (this.state.status !== 'downloaded') { + return { success: false, error: '更新包尚未下载完成' } + } + autoUpdater.quitAndInstall() + return { success: true } + } + + handleState(callback: (state: AppUpdateState) => void): () => void { + this.listeners.add(callback) + callback(this.getState()) + return () => this.listeners.delete(callback) + } + + private listeners = new Set<(state: AppUpdateState) => void>() + + private setState(patch: Partial): AppUpdateState { + this.state = { ...this.state, ...patch, currentVersion: app.getVersion() } + const state = this.getState() + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) window.webContents.send('app-update:state', state) + } + for (const listener of this.listeners) listener(state) + return state + } +} + +export const appUpdateService = new AppUpdateService() diff --git a/src/main/services/bootstrap-cache.ts b/src/main/services/bootstrap-cache.ts index 9ae74b1..eeb3d66 100644 --- a/src/main/services/bootstrap-cache.ts +++ b/src/main/services/bootstrap-cache.ts @@ -369,6 +369,13 @@ export function flushBootstrapCacheWritesSync(): void { } } +export function clearBootstrapCache(): void { + for (const timer of writeTimers.values()) clearTimeout(timer) + writeTimers.clear() + writeQueues.clear() + memoryCache.clear() +} + export function saveCachedMessages( accountRoot: string, userMd5: string, diff --git a/src/main/services/cache-service.ts b/src/main/services/cache-service.ts new file mode 100644 index 0000000..be2de12 --- /dev/null +++ b/src/main/services/cache-service.ts @@ -0,0 +1,73 @@ +import { app, session } from 'electron' +import fs from 'fs-extra' +import path from 'path' +import { clearBootstrapCache } from './bootstrap-cache' +import type { CacheClearScope, CacheSummary, CacheSummaryItem } from '../../shared/cache' + +export type { CacheClearScope } from '../../shared/cache' + +const BOOTSTRAP_CACHE_DIR = path.join(app.getPath('userData'), 'cache', 'bootstrap') + +function inspectDirectory(directory: string): { sizeBytes: number; fileCount: number } { + if (!fs.existsSync(directory)) return { sizeBytes: 0, fileCount: 0 } + let sizeBytes = 0 + let fileCount = 0 + const visit = (current: string): void => { + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(current, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const target = path.join(current, entry.name) + if (entry.isDirectory()) { + visit(target) + } else if (entry.isFile()) { + try { + sizeBytes += fs.statSync(target).size + fileCount += 1 + } catch { + // A cache file can disappear while it is being inspected. + } + } + } + } + visit(directory) + return { sizeBytes, fileCount } +} + +export function getCacheSummary(): CacheSummary { + const bootstrap = inspectDirectory(BOOTSTRAP_CACHE_DIR) + const electron = inspectDirectory(path.join(app.getPath('userData'), 'Cache')) + const items: CacheSummaryItem[] = [ + { + id: 'bootstrap', + label: '启动与聊天缓存', + description: '联系人、头像、群成员和最近聊天记录的本地副本。', + ...bootstrap + }, + { + id: 'electron', + label: '应用临时缓存', + description: 'Electron 页面资源缓存,清理后会自动重新生成。', + ...electron + } + ] + return { + items, + totalBytes: items.reduce((total, item) => total + item.sizeBytes, 0), + updatedAt: Date.now() + } +} + +export async function clearCache(scope: CacheClearScope): Promise { + if (scope === 'bootstrap' || scope === 'all') { + clearBootstrapCache() + await fs.remove(BOOTSTRAP_CACHE_DIR) + } + if (scope === 'electron' || scope === 'all') { + await session.defaultSession.clearCache() + } + return getCacheSummary() +} diff --git a/src/main/services/settings-store.ts b/src/main/services/settings-store.ts index f2ed04e..bacfc41 100644 --- a/src/main/services/settings-store.ts +++ b/src/main/services/settings-store.ts @@ -32,6 +32,9 @@ export interface AppSettings { debugEnabled: boolean autoLogin: boolean autoLoginPreferenceSet: boolean + appearanceTheme: 'system' | 'light' | 'dark' + compactMode: boolean + showStartupProgress: boolean } function getDefaultDbRoot(): string { @@ -112,7 +115,10 @@ const DEFAULT_SETTINGS: AppSettings = { .trim() .toLowerCase() ), - autoLoginPreferenceSet: false + autoLoginPreferenceSet: false, + appearanceTheme: 'system', + compactMode: false, + showStartupProgress: true } const SETTINGS_FILE = path.join( diff --git a/src/main/services/skill-resource-service.ts b/src/main/services/skill-resource-service.ts index ac670af..2a523c2 100644 --- a/src/main/services/skill-resource-service.ts +++ b/src/main/services/skill-resource-service.ts @@ -1,6 +1,7 @@ import { app, shell } from 'electron' import { existsSync, promises as fs } from 'fs' import { dirname, join } from 'path' +import { isPackagedRuntime } from '../runtime-mode' const SKILL_RELATIVE_PATH = join('skill', 'wechatexplorer-reader', 'SKILL.md') const GITHUB_URL = @@ -23,7 +24,7 @@ function getSkillCandidates(): { path: string; source: 'development' | 'bundled' join(dirname(app.getAppPath()), SKILL_RELATIVE_PATH), join(dirname(process.execPath), 'resources', SKILL_RELATIVE_PATH) ] - return app.isPackaged + return isPackagedRuntime() ? bundledPaths.map((path) => ({ path, source: 'bundled' as const })) : [ { path: developmentPath, source: 'development' as const }, diff --git a/src/main/voice-service.ts b/src/main/voice-service.ts index dc40fda..0d6fb1c 100644 --- a/src/main/voice-service.ts +++ b/src/main/voice-service.ts @@ -2,6 +2,7 @@ import { app } from 'electron' import { join } from 'path' import { existsSync } from 'fs' import { Wcdb4Client } from './wcdb4-client' +import { isPackagedRuntime } from './runtime-mode' export class VoiceService { private wcdb4Client: Wcdb4Client @@ -101,7 +102,7 @@ export class VoiceService { private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise { try { let wasmPath: string - if (app.isPackaged) { + if (isPackagedRuntime()) { wasmPath = join( process.resourcesPath, 'app.asar.unpacked', diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index d7ad952..b9b5f68 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -39,6 +39,8 @@ import type { } from '../shared/image-insight' import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub' import type { AppLogEntry } from '../shared/app-log' +import type { AppUpdateCheckResult, AppUpdateState } from '../shared/app-update' +import type { CacheSummary } from '../shared/cache' import type { ExportRequest, ExportJobProgress, ExportResult } from '../shared/export' export type ParsedContent = @@ -103,6 +105,13 @@ declare global { writeAppLog: (entry: AppLogEntry) => Promise getAppLogPath: () => Promise revealAppLog: () => Promise + getAppUpdateState: () => Promise + checkAppUpdate: () => Promise + downloadAppUpdate: () => Promise + installAppUpdate: () => Promise<{ success: boolean; error?: string }> + onAppUpdateState: (callback: (state: AppUpdateState) => void) => () => void + getCacheSummary: () => Promise + clearCache: (scope: 'bootstrap' | 'electron' | 'all') => Promise initDb: ( key: string ) => Promise @@ -255,6 +264,9 @@ declare global { debugEnabled: boolean autoLogin: boolean autoLoginPreferenceSet: boolean + appearanceTheme: 'system' | 'light' | 'dark' + compactMode: boolean + showStartupProgress: boolean imageXorKey: string imageAesKey: string } @@ -283,6 +295,9 @@ declare global { debugEnabled: boolean autoLogin: boolean autoLoginPreferenceSet: boolean + appearanceTheme: 'system' | 'light' | 'dark' + compactMode: boolean + showStartupProgress: boolean imageXorKey: string imageAesKey: string } @@ -299,6 +314,9 @@ declare global { debugEnabled: boolean autoLogin: boolean autoLoginPreferenceSet: boolean + appearanceTheme: 'system' | 'light' | 'dark' + compactMode: boolean + showStartupProgress: boolean imageXorKey: string imageAesKey: string }> @@ -313,6 +331,9 @@ declare global { debugEnabled: boolean autoLogin: boolean autoLoginPreferenceSet: boolean + appearanceTheme: 'system' | 'light' | 'dark' + compactMode: boolean + showStartupProgress: boolean imageXorKey: string imageAesKey: string } diff --git a/src/preload/index.ts b/src/preload/index.ts index 7d028c3..5181ce2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -17,6 +17,8 @@ import type { } from '../shared/image-insight' import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub' import type { AppLogEntry } from '../shared/app-log' +import type { AppUpdateState } from '../shared/app-update' +import type { CacheSummary } from '../shared/cache' import type { ExportRequest, ExportJobProgress } from '../shared/export' // 渲染器的自定义 API @@ -24,6 +26,19 @@ const api = { writeAppLog: (entry: AppLogEntry) => ipcRenderer.invoke('app-log:write', entry), getAppLogPath: () => ipcRenderer.invoke('app-log:getPath'), revealAppLog: () => ipcRenderer.invoke('app-log:reveal'), + getAppUpdateState: (): Promise => ipcRenderer.invoke('app-update:getState'), + checkAppUpdate: () => ipcRenderer.invoke('app-update:check'), + downloadAppUpdate: () => ipcRenderer.invoke('app-update:download'), + installAppUpdate: () => ipcRenderer.invoke('app-update:install'), + onAppUpdateState: (callback: (state: AppUpdateState) => void) => { + const listener = (_event: Electron.IpcRendererEvent, state: AppUpdateState): void => + callback(state) + ipcRenderer.on('app-update:state', listener) + return () => ipcRenderer.removeListener('app-update:state', listener) + }, + getCacheSummary: (): Promise => ipcRenderer.invoke('cache:getSummary'), + clearCache: (scope: 'bootstrap' | 'electron' | 'all'): Promise => + ipcRenderer.invoke('cache:clear', scope), initDb: (key: string) => ipcRenderer.invoke('db:init', key), getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'), getStartupCache: () => ipcRenderer.invoke('db:getStartupCache'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index fb9610e..0444b8c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -256,6 +256,17 @@ function App(): React.ReactElement { const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading') const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null) const [startupProgress, setStartupProgress] = useState(null) + const [appearanceSettings, setAppearanceSettings] = React.useState<{ + theme: 'system' | 'light' | 'dark' + compactMode: boolean + showStartupProgress: boolean + }>({ theme: 'system', compactMode: false, showStartupProgress: true }) + const handleAppearanceChange = React.useCallback( + (settings: { theme: 'system' | 'light' | 'dark'; compactMode: boolean }) => { + setAppearanceSettings((current) => ({ ...current, ...settings })) + }, + [] + ) const currentGroupSnapshotRef = React.useRef(null) const syntheticGroupMessagesRef = React.useRef>({}) const groupMemberMetaRef = React.useRef>>({}) @@ -268,6 +279,15 @@ function App(): React.ReactElement { const timer = window.setTimeout(() => setReportNotice(''), 3200) return () => window.clearTimeout(timer) }, [reportNotice]) + React.useEffect(() => { + void window.api.getSettings().then((result) => { + setAppearanceSettings({ + theme: result.settings.appearanceTheme, + compactMode: result.settings.compactMode, + showStartupProgress: result.settings.showStartupProgress + }) + }) + }, []) React.useEffect(() => { const loadAIConfig = async (): Promise => { try { @@ -1493,6 +1513,7 @@ function App(): React.ReactElement { onAIRuntimeChange={(config: AIRuntimeModelConfig) => setAiModelConfig(config)} onNotice={setReportNotice} onOpenSettings={openSettings} + onAppearanceChange={handleAppearanceChange} /> ) case 'search': @@ -1579,7 +1600,7 @@ function App(): React.ReactElement { : '使用上次安全保存的密钥' : 'WechatExplorer') return ( -
+
{title}
{subtitle}
@@ -1630,6 +1651,8 @@ function App(): React.ReactElement { dbReady={isDatabaseConnected} onPageChange={handlePageChange} onOpenSettings={openSettings} + appearanceTheme={appearanceSettings.theme} + compactMode={appearanceSettings.compactMode} > {reportNotice &&
{reportNotice}
} {renderCurrentWorkspace()} diff --git a/src/renderer/src/components/layout/AppShell.tsx b/src/renderer/src/components/layout/AppShell.tsx index 96aa04e..8261722 100644 --- a/src/renderer/src/components/layout/AppShell.tsx +++ b/src/renderer/src/components/layout/AppShell.tsx @@ -17,6 +17,8 @@ interface AppShellProps { dbReady: boolean onPageChange: (page: AppPage) => void onOpenSettings: () => void + appearanceTheme?: 'system' | 'light' | 'dark' + compactMode?: boolean children: React.ReactNode } @@ -34,12 +36,14 @@ export function AppShell({ dbReady, onPageChange, onOpenSettings, + appearanceTheme = 'system', + compactMode = false, children }: AppShellProps): React.ReactElement { const activeItem = PRIMARY_NAV_ITEMS.find((item) => item.id === activePage) return ( -
+