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 (
+ {desktopUpdateStatus} +
+ ) : null} {active && release ? (