From a14f8de6d08d29541e0111626ed819259414455a Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Wed, 18 Feb 2026 16:53:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(app-shell):=20=E6=A1=8C=E9=9D=A2=E7=AB=AF?= =?UTF-8?q?=E9=9B=86=E6=88=90=E8=87=AA=E5=8A=A8=E6=9B=B4=E6=96=B0=EF=BC=88?= =?UTF-8?q?electron-updater=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 集成 electron-updater:检查更新/下载/安装/忽略此版本,并推送下载进度到前端 - 打包版启动后自动检查更新;托盘菜单支持手动检查 - preload 暴露 updater IPC + __brand 标记;前端新增更新弹窗与设置页版本/检查更新入口 - 补全发布配置:artifactName/publish;release workflow 增加上传 latest.yml --- .github/workflows/release.yml | 1 + desktop/package-lock.json | 93 ++++- desktop/package.json | 12 +- desktop/src/main.cjs | 440 ++++++++++++++++++-- desktop/src/preload.cjs | 29 ++ frontend/app.vue | 32 +- frontend/components/DesktopUpdateDialog.vue | 164 ++++++++ frontend/composables/useDesktopUpdate.js | 236 +++++++++++ frontend/pages/settings.vue | 42 +- 9 files changed, 1010 insertions(+), 39 deletions(-) create mode 100644 frontend/components/DesktopUpdateDialog.vue create mode 100644 frontend/composables/useDesktopUpdate.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a302de8..ef06b1e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,3 +75,4 @@ jobs: files: | desktop/dist/*Setup*.exe desktop/dist/*Setup*.exe.blockmap + desktop/dist/latest.yml diff --git a/desktop/package-lock.json b/desktop/package-lock.json index dcdb276..11a8eb8 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "wechat-data-analysis-desktop", "version": "1.3.0", + "dependencies": { + "electron-updater": "^6.7.3" + }, "devDependencies": { "concurrently": "^9.2.1", "cross-env": "^10.1.0", @@ -1105,7 +1108,6 @@ "version": "2.0.1", "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/assert-plus": { @@ -1295,7 +1297,6 @@ "version": "9.5.1", "resolved": "https://registry.npmmirror.com/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -1796,7 +1797,6 @@ "version": "4.4.3", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2252,6 +2252,69 @@ "node": ">= 10.0.0" } }, + "node_modules/electron-updater": { + "version": "6.7.3", + "resolved": "https://registry.npmmirror.com/electron-updater/-/electron-updater-6.7.3.tgz", + "integrity": "sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.5.1", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "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" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/electron-winstaller": { "version": "5.4.0", "resolved": "https://registry.npmmirror.com/electron-winstaller/-/electron-winstaller-5.4.0.tgz", @@ -2816,7 +2879,6 @@ "version": "4.2.11", "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -3139,7 +3201,6 @@ "version": "4.1.1", "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -3207,7 +3268,6 @@ "version": "1.0.5", "resolved": "https://registry.npmmirror.com/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/lodash": { @@ -3217,6 +3277,19 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz", @@ -3535,7 +3608,6 @@ "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/negotiator": { @@ -4272,7 +4344,6 @@ "version": "1.4.4", "resolved": "https://registry.npmmirror.com/sax/-/sax-1.4.4.tgz", "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -4767,6 +4838,12 @@ "semver": "bin/semver" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/desktop/package.json b/desktop/package.json index c25d092..118a7b0 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -9,11 +9,15 @@ "build:ui": "pushd ..\\\\frontend && npm run generate && popd && node scripts\\\\copy-ui.cjs", "build:backend": "uv sync --extra build && node scripts/build-backend.cjs", "build:icon": "node scripts/build-icon.cjs", - "dist": "npm run build:ui && npm run build:backend && npm run build:icon && electron-builder --win --x64" + "dist": "npm run build:ui && npm run build:backend && npm run build:icon && electron-builder --win --x64 --publish never" + }, + "dependencies": { + "electron-updater": "^6.7.3" }, "build": { "appId": "com.lifearchive.wechatdataanalysis", "productName": "WeChatDataAnalysis", + "artifactName": "${productName}-${version}-Setup.${ext}", "icon": "build/icon.ico", "asar": true, "directories": { @@ -39,6 +43,12 @@ "nsis" ] }, + "publish": { + "provider": "github", + "owner": "LifeArchiveProject", + "repo": "WeChatDataAnalysis", + "releaseType": "release" + }, "nsis": { "oneClick": false, "allowToChangeInstallationDirectory": true, diff --git a/desktop/src/main.cjs b/desktop/src/main.cjs index 25524c6..2f2dace 100644 --- a/desktop/src/main.cjs +++ b/desktop/src/main.cjs @@ -8,7 +8,8 @@ const { dialog, shell, } = require("electron"); -const { spawn } = require("child_process"); +const { autoUpdater } = require("electron-updater"); +const { spawn, spawnSync } = require("child_process"); const fs = require("fs"); const http = require("http"); const path = require("path"); @@ -25,6 +26,22 @@ let tray = null; let isQuitting = false; let desktopSettings = null; +const gotSingleInstanceLock = app.requestSingleInstanceLock(); +if (!gotSingleInstanceLock) { + // If we allow a second instance to boot it will try to spawn another backend on the same port. + // Quit early to avoid leaving orphan backend processes around. + try { + app.quit(); + } catch {} +} else { + app.on("second-instance", () => { + try { + if (app.isReady()) showMainWindow(); + else app.whenReady().then(() => showMainWindow()); + } catch {} + }); +} + function nowIso() { return new Date().toISOString(); } @@ -127,6 +144,8 @@ function loadDesktopSettings() { // 'tray' (default): closing the window hides it to the system tray. // 'exit': closing the window quits the app. closeBehavior: "tray", + // When set, suppress the auto-update prompt for this exact version. + ignoredUpdateVersion: "", }; const p = getDesktopSettingsPath(); @@ -177,6 +196,229 @@ function setCloseBehavior(next) { return desktopSettings.closeBehavior; } +function getIgnoredUpdateVersion() { + const v = String(loadDesktopSettings()?.ignoredUpdateVersion || "").trim(); + return v || ""; +} + +function setIgnoredUpdateVersion(version) { + loadDesktopSettings(); + desktopSettings.ignoredUpdateVersion = String(version || "").trim(); + persistDesktopSettings(); + return desktopSettings.ignoredUpdateVersion; +} + +function parseEnvBool(value) { + if (value == null) return null; + const v = String(value).trim().toLowerCase(); + if (!v) return null; + if (v === "1" || v === "true" || v === "yes" || v === "y" || v === "on") return true; + if (v === "0" || v === "false" || v === "no" || v === "n" || v === "off") return false; + return null; +} + +let autoUpdateEnabledCache = null; +function isAutoUpdateEnabled() { + if (autoUpdateEnabledCache != null) return !!autoUpdateEnabledCache; + + const forced = parseEnvBool(process.env.AUTO_UPDATE_ENABLED); + let enabled = forced != null ? forced : !!app.isPackaged; + + // In packaged builds electron-updater reads update config from app-update.yml. + // If missing, treat auto-update as disabled to avoid noisy errors. + if (enabled && app.isPackaged) { + try { + const updateConfigPath = path.join(process.resourcesPath, "app-update.yml"); + if (!fs.existsSync(updateConfigPath)) { + enabled = false; + logMain(`[main] auto-update disabled: missing ${updateConfigPath}`); + } + } catch (err) { + enabled = false; + logMain(`[main] auto-update disabled: failed to check app-update.yml: ${err?.message || err}`); + } + } + + autoUpdateEnabledCache = enabled; + return enabled; +} + +let autoUpdaterInitialized = false; +let updateDownloadInProgress = false; +let installOnDownload = false; +let updateDownloaded = false; +let lastUpdateInfo = null; + +function sendToRenderer(channel, payload) { + try { + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.webContents.send(channel, payload); + } catch (err) { + logMain(`[main] failed to send ${channel}: ${err?.message || err}`); + } +} + +function setWindowProgressBar(value) { + try { + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.setProgressBar(value); + } catch {} +} + +function normalizeReleaseNotes(releaseNotes) { + if (!releaseNotes) return ""; + if (typeof releaseNotes === "string") return releaseNotes; + if (Array.isArray(releaseNotes)) { + const parts = []; + for (const item of releaseNotes) { + const version = item?.version ? String(item.version) : ""; + const note = item?.note; + const noteText = + typeof note === "string" ? note : note != null ? JSON.stringify(note, null, 2) : ""; + const block = [version ? `v${version}` : "", noteText].filter(Boolean).join("\n"); + if (block) parts.push(block); + } + return parts.join("\n\n"); + } + try { + return JSON.stringify(releaseNotes, null, 2); + } catch { + return String(releaseNotes); + } +} + +function initAutoUpdater() { + if (autoUpdaterInitialized) return; + autoUpdaterInitialized = true; + + // Configure auto-updater (align with WeFlow). + autoUpdater.autoDownload = false; + // Don't install automatically on quit; let the user choose when to restart/install. + autoUpdater.autoInstallOnAppQuit = false; + autoUpdater.disableDifferentialDownload = true; + + autoUpdater.on("download-progress", (progress) => { + sendToRenderer("app:downloadProgress", progress); + const percent = Number(progress?.percent || 0); + if (Number.isFinite(percent) && percent > 0) { + setWindowProgressBar(Math.max(0, Math.min(1, percent / 100))); + } + }); + + autoUpdater.on("update-downloaded", () => { + updateDownloadInProgress = false; + updateDownloaded = true; + installOnDownload = false; + setWindowProgressBar(-1); + + const payload = { + version: lastUpdateInfo?.version ? String(lastUpdateInfo.version) : "", + releaseNotes: normalizeReleaseNotes(lastUpdateInfo?.releaseNotes), + }; + sendToRenderer("app:updateDownloaded", payload); + + try { + // If the window is hidden to tray, show a lightweight hint instead of forcing UI focus. + tray?.displayBalloon?.({ + title: "更新已下载完成", + content: "可在弹窗中选择“立即重启安装”,或稍后再安装。", + }); + } catch {} + }); + + autoUpdater.on("error", (err) => { + updateDownloadInProgress = false; + installOnDownload = false; + updateDownloaded = false; + setWindowProgressBar(-1); + const message = err?.message || String(err); + logMain(`[main] autoUpdater error: ${message}`); + sendToRenderer("app:updateError", { message }); + }); +} + +async function checkForUpdatesInternal() { + const enabled = isAutoUpdateEnabled(); + if (!enabled) return { hasUpdate: false, enabled: false }; + + initAutoUpdater(); + + try { + const result = await autoUpdater.checkForUpdates(); + const updateInfo = result?.updateInfo; + lastUpdateInfo = updateInfo || null; + const latestVersion = updateInfo?.version ? String(updateInfo.version) : ""; + const currentVersion = (() => { + try { + return app.getVersion(); + } catch { + return ""; + } + })(); + + if (latestVersion && currentVersion && latestVersion !== currentVersion) { + return { + hasUpdate: true, + enabled: true, + version: latestVersion, + releaseNotes: normalizeReleaseNotes(updateInfo?.releaseNotes), + }; + } + + return { hasUpdate: false, enabled: true }; + } catch (err) { + const message = err?.message || String(err); + logMain(`[main] checkForUpdates failed: ${message}`); + return { hasUpdate: false, enabled: true, error: message }; + } +} + +async function downloadAndInstallInternal() { + if (!isAutoUpdateEnabled()) { + throw new Error("自动更新已禁用"); + } + initAutoUpdater(); + + if (updateDownloadInProgress) { + throw new Error("正在下载更新中,请稍候…"); + } + + updateDownloadInProgress = true; + installOnDownload = true; + updateDownloaded = false; + setWindowProgressBar(0); + + try { + // Ensure update info is up-to-date (downloadUpdate relies on the last check). + await autoUpdater.checkForUpdates(); + await autoUpdater.downloadUpdate(); + return { success: true }; + } catch (err) { + updateDownloadInProgress = false; + installOnDownload = false; + setWindowProgressBar(-1); + throw err; + } +} + +function checkForUpdatesOnStartup() { + if (!isAutoUpdateEnabled()) return; + if (!app.isPackaged) return; // keep dev noise-free by default + + setTimeout(async () => { + const result = await checkForUpdatesInternal(); + if (!result?.hasUpdate) return; + + const ignored = getIgnoredUpdateVersion(); + if (ignored && ignored === result.version) return; + + sendToRenderer("app:updateAvailable", { + version: result.version, + releaseNotes: result.releaseNotes || "", + }); + }, 3000); +} + function getTrayIconPath() { // Prefer an icon shipped in `src/` so it works both in dev and packaged (asar) builds. const shipped = path.join(__dirname, "icon.ico"); @@ -238,6 +480,91 @@ function createTray() { label: "显示", click: () => showMainWindow(), }, + { + label: "检查更新...", + click: async () => { + try { + if (!isAutoUpdateEnabled()) { + await dialog.showMessageBox({ + type: "info", + title: "检查更新", + message: "自动更新已禁用(仅打包版本可用)。", + buttons: ["确定"], + noLink: true, + }); + return; + } + + const result = await checkForUpdatesInternal(); + if (result?.error) { + await dialog.showMessageBox({ + type: "error", + title: "检查更新失败", + message: result.error, + buttons: ["确定"], + noLink: true, + }); + return; + } + + if (result?.hasUpdate && result?.version) { + const { response } = await dialog.showMessageBox({ + type: "info", + title: "发现新版本", + message: `发现新版本 ${result.version},是否立即更新?`, + detail: result.releaseNotes ? `更新内容:\n${result.releaseNotes}` : undefined, + buttons: ["立即更新", "稍后", "忽略此版本"], + defaultId: 0, + cancelId: 1, + noLink: true, + }); + + if (response === 0) { + try { + await downloadAndInstallInternal(); + } catch (err) { + const message = err?.message || String(err); + logMain(`[main] downloadAndInstall failed (tray): ${message}`); + await dialog.showMessageBox({ + type: "error", + title: "更新失败", + message, + buttons: ["确定"], + noLink: true, + }); + } + } else if (response === 2) { + try { + setIgnoredUpdateVersion(result.version); + } catch {} + } + + return; + } + + await dialog.showMessageBox({ + type: "info", + title: "检查更新", + message: "当前已是最新版本。", + buttons: ["确定"], + noLink: true, + }); + } catch (err) { + const message = err?.message || String(err); + logMain(`[main] tray check updates failed: ${message}`); + await dialog.showMessageBox({ + type: "error", + title: "检查更新失败", + message, + buttons: ["确定"], + noLink: true, + }); + } + }, + }, + { + type: "separator", + }, { label: "退出", click: () => { @@ -380,17 +707,28 @@ function startBackend() { function stopBackend() { if (!backendProc) return; - try { - if (process.platform === "win32" && backendProc.pid) { - // Ensure child tree is killed on Windows. - spawn("taskkill", ["/pid", String(backendProc.pid), "/T", "/F"], { - stdio: "ignore", - windowsHide: true, - }); - return; - } - } catch {} + const pid = backendProc.pid; + logMain(`[main] stopBackend pid=${pid || "?"}`); + // Best-effort: ensure process tree is gone on Windows. Use spawnSync so the kill + // isn't aborted by the app quitting immediately after "before-quit". + if (process.platform === "win32" && pid) { + const systemRoot = process.env.SystemRoot || process.env.WINDIR || "C:\\Windows"; + const taskkillExe = path.join(systemRoot, "System32", "taskkill.exe"); + const args = ["/pid", String(pid), "/T", "/F"]; + + try { + const exe = fs.existsSync(taskkillExe) ? taskkillExe : "taskkill"; + const r = spawnSync(exe, args, { stdio: "ignore", windowsHide: true, timeout: 5000 }); + if (r?.error) logMain(`[main] taskkill failed: ${r.error?.message || r.error}`); + else if (typeof r?.status === "number" && r.status !== 0) + logMain(`[main] taskkill exit code=${r.status}`); + } catch (err) { + logMain(`[main] taskkill exception: ${err?.message || err}`); + } + } + + // Fallback: kill the direct process (taskkill might be missing from PATH in some envs). try { backendProc.kill(); } catch {} @@ -612,6 +950,47 @@ function registerWindowIpc() { } }); + ipcMain.handle("app:getVersion", () => { + try { + return app.getVersion(); + } catch (err) { + logMain(`[main] getVersion failed: ${err?.message || err}`); + return ""; + } + }); + + ipcMain.handle("app:checkForUpdates", async () => { + return await checkForUpdatesInternal(); + }); + + ipcMain.handle("app:downloadAndInstall", async () => { + return await downloadAndInstallInternal(); + }); + + ipcMain.handle("app:installUpdate", async () => { + if (!isAutoUpdateEnabled()) { + throw new Error("自动更新已禁用"); + } + initAutoUpdater(); + if (!updateDownloaded) { + throw new Error("更新尚未下载完成"); + } + + try { + autoUpdater.quitAndInstall(false, true); + return { success: true }; + } catch (err) { + const message = err?.message || String(err); + logMain(`[main] installUpdate failed: ${message}`); + throw new Error(message); + } + }); + + ipcMain.handle("app:ignoreUpdate", async (_event, version) => { + setIgnoredUpdateVersion(version); + return { success: true }; + }); + ipcMain.handle("dialog:chooseDirectory", async (_event, options) => { try { const result = await dialog.showOpenDialog({ @@ -658,6 +1037,9 @@ async function main() { await loadWithRetry(win, startUrl); + // Auto-check updates after the UI has loaded (packaged builds only). + checkForUpdatesOnStartup(); + // If debug mode is enabled, auto-open DevTools so the user doesn't need menu/shortcuts. if (debugEnabled()) { try { @@ -683,20 +1065,22 @@ app.on("before-quit", () => { stopBackend(); }); -main().catch((err) => { - // eslint-disable-next-line no-console - console.error(err); - logMain(`[main] fatal: ${err?.stack || String(err)}`); - stopBackend(); - try { - const dir = getUserDataDir(); - if (dir) { - dialog.showErrorBox( - "WeChatDataAnalysis 启动失败", - `启动失败:${err?.message || err}\n\n请查看日志目录:\n${dir}\n\n文件:desktop-main.log / backend-stdio.log / output\\\\logs\\\\...` - ); - shell.openPath(dir); - } - } catch {} - app.quit(); -}); +if (gotSingleInstanceLock) { + main().catch((err) => { + // eslint-disable-next-line no-console + console.error(err); + logMain(`[main] fatal: ${err?.stack || String(err)}`); + stopBackend(); + try { + const dir = getUserDataDir(); + if (dir) { + dialog.showErrorBox( + "WeChatDataAnalysis 启动失败", + `启动失败:${err?.message || err}\n\n请查看日志目录:\n${dir}\n\n文件:desktop-main.log / backend-stdio.log / output\\\\logs\\\\...` + ); + shell.openPath(dir); + } + } catch {} + app.quit(); + }); +} diff --git a/desktop/src/preload.cjs b/desktop/src/preload.cjs index f40b33d..77bf4d0 100644 --- a/desktop/src/preload.cjs +++ b/desktop/src/preload.cjs @@ -1,6 +1,8 @@ const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("wechatDesktop", { + // Marker used by the frontend to distinguish the Electron desktop shell from the pure web build. + __brand: "WeChatDataAnalysisDesktop", minimize: () => ipcRenderer.invoke("window:minimize"), toggleMaximize: () => ipcRenderer.invoke("window:toggleMaximize"), close: () => ipcRenderer.invoke("window:close"), @@ -13,4 +15,31 @@ contextBridge.exposeInMainWorld("wechatDesktop", { setCloseBehavior: (behavior) => ipcRenderer.invoke("app:setCloseBehavior", String(behavior || "")), chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options), + + // Auto update + getVersion: () => ipcRenderer.invoke("app:getVersion"), + checkForUpdates: () => ipcRenderer.invoke("app:checkForUpdates"), + downloadAndInstall: () => ipcRenderer.invoke("app:downloadAndInstall"), + installUpdate: () => ipcRenderer.invoke("app:installUpdate"), + ignoreUpdate: (version) => ipcRenderer.invoke("app:ignoreUpdate", String(version || "")), + onDownloadProgress: (callback) => { + const handler = (_event, progress) => callback(progress); + ipcRenderer.on("app:downloadProgress", handler); + return () => ipcRenderer.removeListener("app:downloadProgress", handler); + }, + onUpdateAvailable: (callback) => { + const handler = (_event, info) => callback(info); + ipcRenderer.on("app:updateAvailable", handler); + return () => ipcRenderer.removeListener("app:updateAvailable", handler); + }, + onUpdateDownloaded: (callback) => { + const handler = (_event, info) => callback(info); + ipcRenderer.on("app:updateDownloaded", handler); + return () => ipcRenderer.removeListener("app:updateDownloaded", handler); + }, + onUpdateError: (callback) => { + const handler = (_event, payload) => callback(payload); + ipcRenderer.on("app:updateError", handler); + return () => ipcRenderer.removeListener("app:updateError", handler); + }, }); diff --git a/frontend/app.vue b/frontend/app.vue index be1176f..c39c46f 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -8,6 +8,22 @@ + + + + @@ -16,12 +32,14 @@ import { useChatAccountsStore } from '~/stores/chatAccounts' import { usePrivacyStore } from '~/stores/privacy' const route = useRoute() +const desktopUpdate = useDesktopUpdate() // In Electron the server/pre-render doesn't know about `window.wechatDesktop`. // If we render different DOM on server vs client, Vue hydration will keep the // server HTML (no patch) and the layout/CSS fixes won't apply reliably. // So we detect desktop onMounted and update reactively. const isDesktop = ref(false) +const isDesktopUpdater = ref(false) const updateDprVar = () => { const dpr = window.devicePixelRatio || 1 @@ -29,10 +47,22 @@ const updateDprVar = () => { } onMounted(() => { - isDesktop.value = !!window?.wechatDesktop + const isElectron = /electron/i.test(String(navigator.userAgent || '')) + const api = window?.wechatDesktop + isDesktop.value = isElectron && !!api + const brandOk = !api?.__brand || api.__brand === 'WeChatDataAnalysisDesktop' + isDesktopUpdater.value = + isDesktop.value && + brandOk && + typeof api?.checkForUpdates === 'function' && + typeof api?.downloadAndInstall === 'function' updateDprVar() window.addEventListener('resize', updateDprVar) + if (isDesktopUpdater.value) { + void desktopUpdate.initListeners() + } + // Init global UI state. const chatAccounts = useChatAccountsStore() const privacy = usePrivacyStore() diff --git a/frontend/components/DesktopUpdateDialog.vue b/frontend/components/DesktopUpdateDialog.vue new file mode 100644 index 0000000..a30d512 --- /dev/null +++ b/frontend/components/DesktopUpdateDialog.vue @@ -0,0 +1,164 @@ + + + diff --git a/frontend/composables/useDesktopUpdate.js b/frontend/composables/useDesktopUpdate.js new file mode 100644 index 0000000..676682e --- /dev/null +++ b/frontend/composables/useDesktopUpdate.js @@ -0,0 +1,236 @@ +let listenersInitialized = false; +let removeListeners = []; + +const getDesktopApi = () => { + if (!process.client) return null; + if (typeof window === "undefined") return null; + return window?.wechatDesktop || null; +}; + +const isDesktopShell = () => !!getDesktopApi(); + +const isUpdaterSupported = () => { + const api = getDesktopApi(); + if (!api) return false; + + // If the bridge exposes a brand marker, ensure it's our Electron shell. + if (api.__brand && api.__brand !== "WeChatDataAnalysisDesktop") return false; + + // Require updater IPC to avoid showing update UI in the pure web build. + return ( + typeof api.getVersion === "function" && + typeof api.checkForUpdates === "function" && + typeof api.downloadAndInstall === "function" + ); +}; + +export const useDesktopUpdate = () => { + const info = useState("desktopUpdate.info", () => null); + const open = useState("desktopUpdate.open", () => false); + const isDownloading = useState("desktopUpdate.isDownloading", () => false); + const readyToInstall = useState("desktopUpdate.readyToInstall", () => false); + const progress = useState("desktopUpdate.progress", () => ({ percent: 0 })); + const error = useState("desktopUpdate.error", () => ""); + const currentVersion = useState("desktopUpdate.currentVersion", () => ""); + + const manualCheckLoading = useState("desktopUpdate.manualCheckLoading", () => false); + const lastCheckMessage = useState("desktopUpdate.lastCheckMessage", () => ""); + const lastCheckAt = useState("desktopUpdate.lastCheckAt", () => 0); + + const setUpdateInfo = (payload) => { + if (!payload) return; + const version = String(payload?.version || "").trim(); + const releaseNotes = String(payload?.releaseNotes || ""); + if (!version) return; + info.value = { version, releaseNotes }; + readyToInstall.value = false; + }; + + const dismiss = () => { + open.value = false; + }; + + const refreshVersion = async () => { + if (!isUpdaterSupported()) return ""; + try { + const v = await getDesktopApi()?.getVersion?.(); + currentVersion.value = String(v || ""); + return currentVersion.value; + } catch { + return currentVersion.value || ""; + } + }; + + const initListeners = async () => { + if (!isUpdaterSupported()) return; + if (listenersInitialized) return; + listenersInitialized = true; + + await refreshVersion(); + + const unsubs = []; + + const unUpdate = window.wechatDesktop?.onUpdateAvailable?.((payload) => { + error.value = ""; + isDownloading.value = false; + readyToInstall.value = false; + progress.value = { percent: 0 }; + setUpdateInfo(payload); + open.value = true; + }); + if (typeof unUpdate === "function") unsubs.push(unUpdate); + + const unProgress = window.wechatDesktop?.onDownloadProgress?.((p) => { + progress.value = p || { percent: 0 }; + const percent = Number(progress.value?.percent || 0); + if (Number.isFinite(percent) && percent > 0) { + isDownloading.value = true; + } + }); + if (typeof unProgress === "function") unsubs.push(unProgress); + + const unDownloaded = window.wechatDesktop?.onUpdateDownloaded?.((payload) => { + // Download finished. Keep the dialog open and let the user decide when to install. + setUpdateInfo(payload || info.value || {}); + isDownloading.value = false; + readyToInstall.value = true; + progress.value = { ...(progress.value || {}), percent: 100 }; + open.value = true; + }); + if (typeof unDownloaded === "function") unsubs.push(unDownloaded); + + const unError = window.wechatDesktop?.onUpdateError?.((payload) => { + const msg = String(payload?.message || ""); + if (msg) error.value = msg; + isDownloading.value = false; + readyToInstall.value = false; + }); + if (typeof unError === "function") unsubs.push(unError); + + removeListeners = unsubs; + }; + + const startUpdate = async () => { + if (!isUpdaterSupported()) return; + + error.value = ""; + isDownloading.value = true; + readyToInstall.value = false; + progress.value = { percent: 0 }; + + try { + await getDesktopApi()?.downloadAndInstall?.(); + } catch (e) { + const msg = e?.message || String(e); + error.value = msg; + isDownloading.value = false; + } + }; + + const installUpdate = async () => { + if (!isUpdaterSupported()) return; + if (!getDesktopApi()?.installUpdate) return; + + error.value = ""; + try { + await getDesktopApi()?.installUpdate?.(); + } catch (e) { + const msg = e?.message || String(e); + error.value = msg; + } + }; + + const ignore = async () => { + if (!isUpdaterSupported()) return; + const version = String(info.value?.version || "").trim(); + if (!version) return; + + try { + await getDesktopApi()?.ignoreUpdate?.(version); + } catch (e) { + const msg = e?.message || String(e); + error.value = msg; + } finally { + // Hide the dialog locally; startup auto-check will also respect the ignore. + open.value = false; + info.value = null; + } + }; + + const manualCheck = async () => { + if (!isDesktopShell()) { + lastCheckMessage.value = "仅桌面端可用。"; + return { hasUpdate: false }; + } + if (!isUpdaterSupported()) { + lastCheckMessage.value = "当前桌面端版本不支持自动更新。"; + return { hasUpdate: false }; + } + + manualCheckLoading.value = true; + error.value = ""; + lastCheckMessage.value = ""; + + try { + await refreshVersion(); + + const res = await getDesktopApi()?.checkForUpdates?.(); + lastCheckAt.value = Date.now(); + + if (res?.enabled === false) { + lastCheckMessage.value = "自动更新已禁用(仅打包版本可用)。"; + return res; + } + + if (res?.error) { + lastCheckMessage.value = `检查更新失败:${String(res.error)}`; + return res; + } + + if (res?.hasUpdate && res?.version) { + setUpdateInfo({ version: res.version, releaseNotes: res.releaseNotes || "" }); + open.value = true; + lastCheckMessage.value = `发现新版本:${String(res.version)}`; + return res; + } + + lastCheckMessage.value = "当前已是最新版本。"; + return res; + } catch (e) { + const msg = e?.message || String(e); + lastCheckMessage.value = `检查更新失败:${msg}`; + return { hasUpdate: false, error: msg }; + } finally { + manualCheckLoading.value = false; + } + }; + + const cleanup = () => { + try { + for (const fn of removeListeners) fn?.(); + } catch {} + removeListeners = []; + listenersInitialized = false; + }; + + return { + info, + open, + isDownloading, + readyToInstall, + progress, + error, + currentVersion, + manualCheckLoading, + lastCheckMessage, + lastCheckAt, + initListeners, + refreshVersion, + manualCheck, + startUpdate, + installUpdate, + ignore, + dismiss, + cleanup, + }; +}; diff --git a/frontend/pages/settings.vue b/frontend/pages/settings.vue index 9136a9b..5ca9e0f 100644 --- a/frontend/pages/settings.vue +++ b/frontend/pages/settings.vue @@ -90,6 +90,33 @@ +
+
+
更新
+
+
+
+
+
当前版本
+
+ {{ desktopVersionText }} +
+
+ +
+
+ {{ desktopUpdate.lastCheckMessage }} +
+
+
+
朋友圈
@@ -123,6 +150,13 @@ import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, useHead({ title: '设置 - 微信数据分析助手' }) const isDesktopEnv = ref(false) +const desktopUpdate = useDesktopUpdate() + +const desktopVersionText = computed(() => { + if (!isDesktopEnv.value) return '仅桌面端可用' + const v = String(desktopUpdate.currentVersion.value || '').trim() + return v || '—' +}) const desktopAutoRealtime = ref(false) const desktopDefaultToChatWhenData = ref(false) @@ -225,9 +259,14 @@ const onSnsUseCacheToggle = (ev) => { writeLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, checked) } +const onDesktopCheckUpdates = async () => { + await desktopUpdate.manualCheck() +} + onMounted(async () => { if (process.client && typeof window !== 'undefined') { - isDesktopEnv.value = !!window.wechatDesktop + const isElectron = /electron/i.test(String(navigator.userAgent || '')) + isDesktopEnv.value = isElectron && !!window.wechatDesktop } desktopAutoRealtime.value = readLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, false) @@ -235,6 +274,7 @@ onMounted(async () => { snsUseCache.value = readLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, true) if (isDesktopEnv.value) { + void desktopUpdate.initListeners() await refreshDesktopAutoLaunch() await refreshDesktopCloseBehavior() }