diff --git a/apps/desktop/src/main/desktop-update-flow.test.mjs b/apps/desktop/src/main/desktop-update-flow.test.mjs index c7fe412f..1b4718c5 100644 --- a/apps/desktop/src/main/desktop-update-flow.test.mjs +++ b/apps/desktop/src/main/desktop-update-flow.test.mjs @@ -2,14 +2,35 @@ import { readFileSync } from "node:fs"; import { describe, expect, test } from "bun:test"; const mainSource = readFileSync(new URL("./index.mjs", import.meta.url), "utf8"); +const preloadSource = readFileSync(new URL("../preload/index.cjs", import.meta.url), "utf8"); const noticeSource = readFileSync(new URL("../../../web/src/components/DesktopUpdateNotice.tsx", import.meta.url), "utf8"); +const systemInfoSource = readFileSync(new URL("../../../web/src/components/settings/SystemInfoPanel.tsx", import.meta.url), "utf8"); describe("desktop update flow", () => { test("downloads updates in the background and relaunches after installation", () => { expect(mainSource).toContain("autoUpdater.autoDownload = true"); expect(mainSource).toContain("autoUpdater.autoRunAppAfterInstall = true"); expect(mainSource).toContain("isQuitting = true;\n autoUpdater.quitAndInstall(false, true)"); - expect(mainSource).toContain("result?.downloadPromise?.catch"); + expect(mainSource).toContain("result?.downloadPromise"); + }); + + test("offers a manual update check in desktop system settings", () => { + expect(mainSource).toContain('ipcMain.handle("desktop:check-update"'); + expect(mainSource).toContain('checkForDesktopUpdate("manual", { force: true, throwOnError: true })'); + expect(preloadSource).toContain('checkUpdate: () => ipcRenderer.invoke("desktop:check-update")'); + expect(systemInfoSource).toContain('t("systemInfo.desktopCheckForUpdates")'); + expect(systemInfoSource).toContain("desktopBridge!.checkUpdate()"); + expect(systemInfoSource).toContain('t("systemInfo.desktopUpdateCurrent")'); + }); + + test("rechecks for updates while a packaged app remains open", () => { + expect(mainSource).toContain('checkForDesktopUpdate("startup", { force: true })'); + expect(mainSource).toContain('checkForDesktopUpdate("interval", { force: true })'); + expect(mainSource).toContain('powerMonitor.on("resume"'); + expect(mainSource).toContain('checkForDesktopUpdate("activate")'); + expect(mainSource).toContain("if (updateCheckInFlight) {"); + expect(mainSource).toContain("return throwOnError ? updateCheckInFlight : updateCheckInFlight.catch(() => null)"); + expect(mainSource).toContain("clearInterval(updateCheckTimer)"); }); test("only offers the restart action after the update is downloaded", () => { diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index a22c2c0f..630cba38 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -1,4 +1,4 @@ -import { app, BrowserWindow, Menu, Tray, nativeImage, ipcMain, session, net, protocol, shell, dialog, safeStorage, clipboard } from "electron"; +import { app, BrowserWindow, Menu, Tray, nativeImage, ipcMain, session, net, protocol, shell, dialog, safeStorage, clipboard, powerMonitor } from "electron"; import { existsSync } from "node:fs"; import { appendFile, mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; import { basename, join } from "node:path"; @@ -76,6 +76,10 @@ let sidecar; let tray; let isQuitting = false; let updateState = "idle"; +let updateCheckInFlight = null; +let updateDownloadInFlight = null; +let updateCheckTimer = null; +let lastUpdateCheckAt = 0; let sidecarScopeKey = "anonymous"; let activeAccountId = null; let shutdownCleanupStarted = false; @@ -84,6 +88,8 @@ let sidecarRestartAttempts = 0; let sidecarRestartInFlight = false; let localDataResetScheduled = false; let rendererCrashDialogOpen = false; +const updateCheckIntervalMs = 60 * 60 * 1_000; +const updateCheckFocusThrottleMs = 15 * 60 * 1_000; const hasSingleInstanceLock = app.requestSingleInstanceLock(); const windowStatePath = () => join(app.getPath("userData"), "window-state.json"); const instanceUrlPath = () => join(app.getPath("userData"), "instance-url"); @@ -508,18 +514,61 @@ const installDownloadedUpdate = () => { return { started: true }; }; +const checkForDesktopUpdate = (reason, { force = false, throwOnError = false } = {}) => { + if (!app.isPackaged || process.env.EDGE_EVER_DISABLE_AUTO_UPDATE === "1" || updateState === "downloaded") { + return Promise.resolve(null); + } + if (updateCheckInFlight) { + return throwOnError ? updateCheckInFlight : updateCheckInFlight.catch(() => null); + } + if (updateDownloadInFlight) return Promise.resolve(null); + const now = Date.now(); + if (!force && now - lastUpdateCheckAt < updateCheckFocusThrottleMs) return Promise.resolve(null); + lastUpdateCheckAt = now; + void writeDiagnostic("update.check-started", { reason }); + updateCheckInFlight = autoUpdater.checkForUpdates() + .then((result) => { + if (result?.downloadPromise) { + updateDownloadInFlight = result.downloadPromise + .catch(async (error) => { + updateState = "idle"; + refreshTrayMenu(); + await writeDiagnostic("update.download-failed", { reason, message: error.message }); + }) + .finally(() => { updateDownloadInFlight = null; }); + } + return result; + }) + .catch(async (error) => { + await writeDiagnostic("update.check-failed", { reason, message: error.message }); + throw error; + }) + .finally(() => { updateCheckInFlight = null; }); + return throwOnError ? updateCheckInFlight : updateCheckInFlight.catch(() => null); +}; + const configureAutoUpdater = () => { if (!app.isPackaged || process.env.EDGE_EVER_DISABLE_AUTO_UPDATE === "1") return; autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = true; autoUpdater.autoRunAppAfterInstall = true; autoUpdater.on("update-available", () => { updateState = "available"; refreshTrayMenu(); void writeDiagnostic("update.available"); }); + autoUpdater.on("update-not-available", () => { updateState = "idle"; refreshTrayMenu(); void writeDiagnostic("update.not-available"); }); autoUpdater.on("download-progress", (progress) => { void writeDiagnostic("update.download-progress", { percent: progress.percent }); }); autoUpdater.on("update-downloaded", () => { updateState = "downloaded"; refreshTrayMenu(); void writeDiagnostic("update.downloaded"); }); - autoUpdater.on("error", (error) => { isQuitting = false; void writeDiagnostic("update.error", { message: error.message }); }); - void autoUpdater.checkForUpdates() - .then((result) => result?.downloadPromise?.catch((error) => writeDiagnostic("update.download-failed", { message: error.message }))) - .catch((error) => writeDiagnostic("update.check-failed", { message: error.message })); + autoUpdater.on("error", (error) => { + isQuitting = false; + if (updateState !== "downloaded") updateState = "idle"; + refreshTrayMenu(); + void writeDiagnostic("update.error", { message: error.message }); + }); + void checkForDesktopUpdate("startup", { force: true }); + updateCheckTimer = setInterval(() => { + void checkForDesktopUpdate("interval", { force: true }); + }, updateCheckIntervalMs); + powerMonitor.on("resume", () => { + void checkForDesktopUpdate("resume", { force: true }); + }); }; const startSidecar = async (accountId = null) => { @@ -907,6 +956,10 @@ app.whenReady().then(async () => { return configuredApiBaseUrl; }); ipcMain.handle("desktop:update-status", () => ({ state: updateState })); + ipcMain.handle("desktop:check-update", async () => { + await checkForDesktopUpdate("manual", { force: true, throwOnError: true }); + return { state: updateState }; + }); ipcMain.handle("desktop:download-update", () => autoUpdater.downloadUpdate()); ipcMain.handle("desktop:install-update", () => installDownloadedUpdate()); ipcMain.handle("desktop:stage-resource", async (_event, input) => { @@ -979,6 +1032,7 @@ app.whenReady().then(async () => { handleOpenTarget(process.argv); app.on("activate", () => { if (!showWindow(mainWindow)) void createWindow(); + void checkForDesktopUpdate("activate"); }); }); @@ -1005,6 +1059,10 @@ app.on("before-quit", (event) => { clearTimeout(sidecarRestartTimer); sidecarRestartTimer = null; } + if (updateCheckTimer) { + clearInterval(updateCheckTimer); + updateCheckTimer = null; + } tray?.destroy(); void (async () => { await stopSidecar(); diff --git a/apps/desktop/src/preload/index.cjs b/apps/desktop/src/preload/index.cjs index 7947afe7..ce6fe8d4 100644 --- a/apps/desktop/src/preload/index.cjs +++ b/apps/desktop/src/preload/index.cjs @@ -16,6 +16,7 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({ recordRendererError: (details) => ipcRenderer.invoke("desktop:record-renderer-error", details), openRendererIssue: (details) => ipcRenderer.invoke("desktop:open-renderer-issue", details), updateStatus: () => ipcRenderer.invoke("desktop:update-status"), + checkUpdate: () => ipcRenderer.invoke("desktop:check-update"), downloadUpdate: () => ipcRenderer.invoke("desktop:download-update"), installUpdate: () => ipcRenderer.invoke("desktop:install-update"), sidecarRequest: (method, params = {}) => ipcRenderer.invoke("desktop:sidecar-request", method, params), diff --git a/apps/web/src/components/PublicSharePage.tsx b/apps/web/src/components/PublicSharePage.tsx index a0b41487..c10fe9a0 100644 --- a/apps/web/src/components/PublicSharePage.tsx +++ b/apps/web/src/components/PublicSharePage.tsx @@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next"; import { useParams } from "react-router"; import { api } from "@/lib/api"; import { EdgeEverCodeBlock, codeBlockLowlight } from "@/lib/code-block"; +import { withEnvironmentTitlePrefix } from "@/lib/environment-title"; import { parseImageWidth, getImageReferrerPolicy, @@ -119,7 +120,12 @@ export const PublicSharePage = () => { robots.name = "robots"; robots.content = "noindex,nofollow,noarchive"; document.head.appendChild(robots); - if (share) document.title = `${share.title?.trim() || t("common.untitledMemo")} · EdgeEver`; + if (share) { + document.title = withEnvironmentTitlePrefix( + `${share.title?.trim() || t("common.untitledMemo")} · EdgeEver`, + { development: import.meta.env.DEV, profile: __EDGEEVER_DEVELOPMENT_PROFILE__ }, + ); + } return () => { document.title = previousTitle; robots.remove(); diff --git a/apps/web/src/components/settings/SystemInfoPanel.tsx b/apps/web/src/components/settings/SystemInfoPanel.tsx index e7f3c8fd..8bd2b17a 100644 --- a/apps/web/src/components/settings/SystemInfoPanel.tsx +++ b/apps/web/src/components/settings/SystemInfoPanel.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { CircleCheck, Copy, ExternalLink } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CircleCheck, Copy, ExternalLink, LoaderCircle, RefreshCw, RotateCcw } from "lucide-react"; import { Trans, useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { useDeployedUpdateNotice } from "@/hooks/useDeployedUpdateNotice"; @@ -84,8 +84,12 @@ export const getWebSystemInfoItems = ( export const SystemInfoPanel = ({ active = true }: { active?: boolean }) => { const { t, i18n } = useTranslation(); const [copied, setCopied] = useState(false); + const [desktopUpdateChecked, setDesktopUpdateChecked] = useState(false); + const queryClient = useQueryClient(); const { release } = useDeployedUpdateNotice(); - const instanceUrl = window.edgeeverDesktop?.isAvailable === true ? getConfiguredDesktopApiBaseUrl() : window.location.origin; + const desktopBridge = window.edgeeverDesktop; + const desktopAvailable = desktopBridge?.isAvailable === true; + const instanceUrl = desktopAvailable ? getConfiguredDesktopApiBaseUrl() : window.location.origin; const healthQuery = useQuery({ queryKey: ["instance-health", instanceUrl], queryFn: () => api.getInstanceHealth(), @@ -93,6 +97,23 @@ export const SystemInfoPanel = ({ active = true }: { active?: boolean }) => { staleTime: 5 * 60 * 1000, retry: 1, }); + const desktopUpdateStatusQuery = useQuery({ + queryKey: ["desktop-update-status"], + queryFn: () => desktopBridge!.updateStatus(), + enabled: active && desktopAvailable, + refetchInterval: (query) => query.state.data?.state === "available" ? 1_000 : false, + retry: 1, + }); + const desktopUpdateCheckMutation = useMutation({ + mutationFn: () => desktopBridge!.checkUpdate(), + onSuccess: (status) => { + queryClient.setQueryData(["desktop-update-status"], status); + setDesktopUpdateChecked(true); + }, + }); + const desktopUpdateInstallMutation = useMutation({ + mutationFn: () => desktopBridge!.installUpdate(), + }); const infoItems = useMemo( () => getWebSystemInfoItems(t, i18n.language, healthQuery.data?.runtime), [healthQuery.data?.runtime, i18n.language, t], @@ -112,14 +133,72 @@ export const SystemInfoPanel = ({ active = true }: { active?: boolean }) => { window.setTimeout(() => setCopied(false), 1600); }; + const desktopUpdateState = desktopUpdateStatusQuery.data?.state ?? "idle"; + const desktopUpdateBusy = desktopUpdateCheckMutation.isPending || desktopUpdateInstallMutation.isPending; + const desktopUpdateStatus = desktopUpdateInstallMutation.isError || desktopUpdateCheckMutation.isError || desktopUpdateStatusQuery.isError + ? t("systemInfo.desktopUpdateFailed") + : desktopUpdateInstallMutation.isPending + ? t("systemInfo.desktopUpdateInstalling") + : desktopUpdateCheckMutation.isPending + ? t("systemInfo.desktopUpdateChecking") + : desktopUpdateState === "available" + ? t("systemInfo.desktopUpdateDownloading") + : desktopUpdateState === "downloaded" + ? t("systemInfo.desktopUpdateReady") + : desktopUpdateChecked + ? t("systemInfo.desktopUpdateCurrent") + : null; + + const handleDesktopUpdate = () => { + if (desktopUpdateState === "downloaded") desktopUpdateInstallMutation.mutate(); + else desktopUpdateCheckMutation.mutate(); + }; + return (
-
+
+ {desktopAvailable ? ( + + ) : null}
+ {desktopAvailable && desktopUpdateStatus ? ( +

+ {desktopUpdateStatus} +

+ ) : null} {active && release ? (
diff --git a/apps/web/src/lib/environment-title.test.mjs b/apps/web/src/lib/environment-title.test.mjs new file mode 100644 index 00000000..8a2be459 --- /dev/null +++ b/apps/web/src/lib/environment-title.test.mjs @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { withEnvironmentTitlePrefix } from "./environment-title.ts"; + +describe("environment tab title", () => { + test("marks local development tabs as DEV", () => { + expect(withEnvironmentTitlePrefix("EdgeEver", { development: true, profile: "local" })) + .toBe("[DEV] EdgeEver"); + expect(withEnvironmentTitlePrefix("EdgeEver", { development: true, profile: "" })) + .toBe("[DEV] EdgeEver"); + }); + + test("marks the local demo profile as DEMO", () => { + expect(withEnvironmentTitlePrefix("EdgeEver", { development: true, profile: "demo" })) + .toBe("[DEMO] EdgeEver"); + }); + + test("is idempotent during hot reloads and leaves production titles unchanged", () => { + expect(withEnvironmentTitlePrefix("[LOCAL DEMO] EdgeEver", { development: true, profile: "demo" })) + .toBe("[DEMO] EdgeEver"); + expect(withEnvironmentTitlePrefix("EdgeEver", { development: false, profile: "demo" })) + .toBe("EdgeEver"); + }); +}); diff --git a/apps/web/src/lib/environment-title.ts b/apps/web/src/lib/environment-title.ts new file mode 100644 index 00000000..a4942314 --- /dev/null +++ b/apps/web/src/lib/environment-title.ts @@ -0,0 +1,12 @@ +type DevelopmentProfile = "" | "local" | "demo"; + +const existingEnvironmentPrefix = /^\[(?:DEV|DEMO|LOCAL|LOCAL DEMO)\]\s*/; + +export const withEnvironmentTitlePrefix = ( + title: string, + options: { development: boolean; profile: DevelopmentProfile }, +) => { + if (!options.development) return title; + const label = options.profile === "demo" ? "DEMO" : "DEV"; + return `[${label}] ${title.replace(existingEnvironmentPrefix, "")}`; +}; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 0f920e8b..86c210e1 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -6,6 +6,7 @@ import { registerSW } from "virtual:pwa-register"; import { App } from "./app/App"; import "./i18n"; import { emitPwaUpdateNotice } from "./lib/pwa-update-notice"; +import { withEnvironmentTitlePrefix } from "./lib/environment-title"; import { initializeTheme, ThemeProvider } from "./components/ThemeProvider"; import { DesktopRendererErrorBoundary } from "./components/DesktopRendererErrorBoundary"; import "./styles/globals.css"; @@ -13,10 +14,14 @@ import "./styles/globals.css"; const PWA_UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1_000; const DEVELOPMENT_PWA_RELOAD_KEY = "edgeever.dev-pwa-reset"; -if (import.meta.env.DEV && __EDGEEVER_DEVELOPMENT_PROFILE__) { - document.documentElement.dataset.edgeeverEnvironment = __EDGEEVER_DEVELOPMENT_PROFILE__; - const environmentLabel = __EDGEEVER_DEVELOPMENT_PROFILE__ === "demo" ? "LOCAL DEMO" : "LOCAL"; - document.title = `[${environmentLabel}] ${document.title}`; +if (import.meta.env.DEV) { + if (__EDGEEVER_DEVELOPMENT_PROFILE__) { + document.documentElement.dataset.edgeeverEnvironment = __EDGEEVER_DEVELOPMENT_PROFILE__; + } + document.title = withEnvironmentTitlePrefix(document.title, { + development: true, + profile: __EDGEEVER_DEVELOPMENT_PROFILE__, + }); } const clearDevelopmentPwaState = async () => { diff --git a/apps/web/src/note-print.ts b/apps/web/src/note-print.ts index 8c16b9d4..4fdc3c2e 100644 --- a/apps/web/src/note-print.ts +++ b/apps/web/src/note-print.ts @@ -8,6 +8,7 @@ import { type NotePrintPayload, } from "@/lib/note-print"; import { getMessageTargetOrigin } from "@/lib/app-page-path"; +import { withEnvironmentTitlePrefix } from "@/lib/environment-title"; import katexStyles from "katex/dist/katex.min.css?inline"; import printStyles from "@/styles/note-print.css?inline"; import "@fontsource-variable/noto-sans-sc/wght.css"; @@ -110,7 +111,10 @@ const buildSource = (payload: NotePrintPayload) => { const renderPreview = async (payload: NotePrintPayload) => { document.documentElement.lang = payload.language; - document.title = `${payload.title} · EdgeEver`; + document.title = withEnvironmentTitlePrefix(`${payload.title} · EdgeEver`, { + development: import.meta.env.DEV, + profile: __EDGEEVER_DEVELOPMENT_PROFILE__, + }); setText(status, payload.labels.preparing); setText(hint, ""); setText(closeButton, payload.labels.close); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index a2c48cc1..bd90158c 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -32,6 +32,7 @@ interface EdgeEverDesktopBridge { sidecarStatus(): Promise<{ available: boolean; path: string; scope: string }>; setAccountScope(accountId: string | null): Promise<{ ready: true; scope: string }>; updateStatus(): Promise<{ state: "idle" | "available" | "downloaded" }>; + checkUpdate(): Promise<{ state: "idle" | "available" | "downloaded" }>; downloadUpdate(): Promise; installUpdate(): Promise; sidecarRequest(method: string, params?: Record): Promise; diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 879b351d..3cedba71 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -609,10 +609,14 @@ export const enUS = { releaseNotesUnavailable: "This build does not include an update summary. You can view the full Release notes.", clientUpdatesNote: "Download desktop updates from Releases, or update mobile apps through the corresponding app store.", viewReleaseNotes: "Full notes", + desktopCheckForUpdates: "Check for updates", + desktopUpdateChecking: "Checking for updates…", + desktopUpdateCurrent: "You're up to date.", desktopUpdateDownloading: "Downloading update…", desktopUpdateReady: "Update downloaded", desktopUpdateDownload: "Download update", desktopUpdateRestart: "Restart to update", + desktopUpdateInstalling: "Restarting to install update…", desktopUpdateFailed: "Update failed. Please try again later.", }, feedback: { diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 62fca6a5..70671f36 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -609,10 +609,14 @@ export const zhCN = { releaseNotesUnavailable: "本次构建未包含更新摘要,可查看完整 Release 说明。", clientUpdatesNote: "桌面端可前往 Releases 下载更新,移动端请通过对应的应用商店更新。", viewReleaseNotes: "完整说明", + desktopCheckForUpdates: "检查更新", + desktopUpdateChecking: "正在检查更新…", + desktopUpdateCurrent: "当前已是最新版本。", desktopUpdateDownloading: "正在下载更新…", desktopUpdateReady: "更新已下载完成", desktopUpdateDownload: "下载更新", desktopUpdateRestart: "重启并更新", + desktopUpdateInstalling: "正在重启并安装更新…", desktopUpdateFailed: "更新失败,请稍后重试。", }, feedback: {