diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index fde31e28..b31ea840 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -90,6 +90,7 @@ let sidecarRestartAttempts = 0; let sidecarRestartInFlight = false; let localDataResetScheduled = false; let rendererCrashDialogOpen = false; +let recoveredAfterAbnormalExit = false; const updateCheckIntervalMs = 60 * 60 * 1_000; const updateCheckFocusThrottleMs = 15 * 60 * 1_000; const hasSingleInstanceLock = app.requestSingleInstanceLock(); @@ -886,8 +887,8 @@ app.whenReady().then(async () => { await loadConfiguredApiBaseUrl(); await loadDesktopSessionToken(); app.setAsDefaultProtocolClient("edgeever"); - const previousSessionWasActive = existsSync(crashMarkerPath()); - void writeDiagnostic(previousSessionWasActive ? "session.recovered-after-abnormal-exit" : "session.started"); + recoveredAfterAbnormalExit = existsSync(crashMarkerPath()); + void writeDiagnostic(recoveredAfterAbnormalExit ? "session.recovered-after-abnormal-exit" : "session.started"); await writeFile(crashMarkerPath(), new Date().toISOString()); session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); registerResourceProtocol(); @@ -931,6 +932,7 @@ app.whenReady().then(async () => { }); ipcMain.on("desktop:api-base-url-sync", (event) => { event.returnValue = configuredApiBaseUrl; }); ipcMain.on("desktop:session-token-sync", (event) => { event.returnValue = desktopSessionToken; }); + ipcMain.on("desktop:recovered-after-abnormal-exit-sync", (event) => { event.returnValue = recoveredAfterAbnormalExit; }); ipcMain.handle("desktop:copy-text", (_event, value) => { if (typeof value !== "string") throw new Error("Clipboard value must be a string"); clipboard.writeText(value); diff --git a/apps/desktop/src/main/renderer-recovery.test.mjs b/apps/desktop/src/main/renderer-recovery.test.mjs new file mode 100644 index 00000000..352d2f94 --- /dev/null +++ b/apps/desktop/src/main/renderer-recovery.test.mjs @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +describe("desktop abnormal-exit recovery", () => { + test("exposes the previous abnormal session to the renderer before workspace startup", () => { + const mainSource = readFileSync(new URL("./index.mjs", import.meta.url), "utf8"); + const preloadSource = readFileSync(new URL("../preload/index.cjs", import.meta.url), "utf8"); + + expect(mainSource).toContain("recoveredAfterAbnormalExit = existsSync(crashMarkerPath())"); + expect(mainSource).toContain('ipcMain.on("desktop:recovered-after-abnormal-exit-sync"'); + expect(preloadSource).toContain('recoveredAfterAbnormalExit: ipcRenderer.sendSync("desktop:recovered-after-abnormal-exit-sync")'); + }); +}); diff --git a/apps/desktop/src/preload/index.cjs b/apps/desktop/src/preload/index.cjs index 18a9f989..9a60ec9e 100644 --- a/apps/desktop/src/preload/index.cjs +++ b/apps/desktop/src/preload/index.cjs @@ -3,6 +3,7 @@ const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({ isAvailable: true, canClearLocalData: ipcRenderer.sendSync("desktop:local-data-reset-available-sync"), + recoveredAfterAbnormalExit: ipcRenderer.sendSync("desktop:recovered-after-abnormal-exit-sync"), sidecarStatus: () => ipcRenderer.invoke("desktop:sidecar-status"), setAccountScope: (accountId) => ipcRenderer.invoke("desktop:set-account-scope", accountId), apiBaseUrl: ipcRenderer.sendSync("desktop:api-base-url-sync"), diff --git a/apps/web/src/components/DesktopRendererErrorBoundary.tsx b/apps/web/src/components/DesktopRendererErrorBoundary.tsx index a386be0c..87c77339 100644 --- a/apps/web/src/components/DesktopRendererErrorBoundary.tsx +++ b/apps/web/src/components/DesktopRendererErrorBoundary.tsx @@ -1,4 +1,5 @@ import React, { type ErrorInfo, type ReactNode } from "react"; +import { markRendererRecoveryRequired } from "@/lib/renderer-recovery"; type RendererErrorDetails = { kind: "react-error"; @@ -30,6 +31,7 @@ export class DesktopRendererErrorBoundary extends React.Component<{ children: Re } componentDidCatch(error: Error, info: ErrorInfo) { + markRendererRecoveryRequired(); const details: RendererErrorDetails = { kind: "react-error", message: error.message, @@ -65,11 +67,11 @@ export class DesktopRendererErrorBoundary extends React.Component<{ children: Re

{desktop ? (zh - ? "问题已经记录到这台设备。你可以重新加载继续使用,并在提交前检查脱敏后的 GitHub Issue。" - : "The problem was recorded on this device. You can reload to continue and review the redacted GitHub Issue before submitting it.") + ? "问题已经记录到这台设备。重新加载后会进入安全启动模式,不再自动打开刚才的笔记。" + : "The problem was recorded on this device. Reloading will enter safe startup mode instead of reopening the previous note.") : (zh - ? "你可以重新加载继续使用。如果问题仍然出现,请检查浏览器控制台,并在提交 GitHub Issue 前移除敏感信息。" - : "Reload to continue. If the problem persists, check the browser console and remove sensitive information before submitting a GitHub Issue.")} + ? "安全重新加载后不会自动打开刚才的笔记。如果问题仍然出现,请检查浏览器控制台。" + : "A safe reload will avoid reopening the previous note. If the problem persists, check the browser console.")}

{desktop ? ( + +
+ + + ); + } +} + +export const EditorRecoveryPane = () => { + const zh = isChineseInterface(); + return ( +
+
+
✓
+

{zh ? "已进入安全启动模式" : "Safe startup mode is active"}

+

+ {zh + ? "EdgeEver 没有重新打开上次出错的笔记。请从左侧列表选择其他笔记继续使用;选择后会自动退出安全模式。" + : "EdgeEver did not reopen the note that failed last time. Choose another note from the list to continue; safe mode will then turn off automatically."} +

+
+
+ ); +}; diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index a8372544..0e2c6e87 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -94,6 +94,8 @@ import { useWorkspacePreferences } from "@/hooks/useWorkspacePreferences"; import { useWorkspaceSelection } from "@/hooks/useWorkspaceSelection"; import { useWorkspaceQueuedSync } from "@/hooks/useWorkspaceQueuedSync"; import { EdgeEverPluginHost } from "@/lib/plugins/plugin-host"; +import { clearRendererRecoveryRequired, isRendererRecoveryRequired } from "@/lib/renderer-recovery"; +import { EditorPaneErrorBoundary, EditorRecoveryPane } from "./EditorPaneErrorBoundary"; const isDesktopViewport = () => window.matchMedia("(min-width: 1024px)").matches; const PULL_TO_REFRESH_TRIGGER_PX = 72; @@ -685,6 +687,9 @@ export const WorkspaceApp = ({ const isInitialAiPromptsRoute = route.isAiPrompts; const isInitialMobileEditorReturn = Boolean(route.mobileEditorReturnMemoId); const isTrashRoute = route.isTrash; + const [rendererRecoveryMode, setRendererRecoveryMode] = useState(() => + Boolean(window.edgeeverDesktop?.recoveredAfterAbnormalExit) || isRendererRecoveryRequired() + ); const [activePane, setActivePane] = useState(() => ((isInitialSettingsRoute || isInitialPluginsRoute || isInitialTemplatesRoute || isInitialAiPromptsRoute) && !isInitialMobileEditorReturn ? "editor" : "memos")); const [memoView, setMemoView] = useState(() => (isTrashRoute ? "trash" : "notebook")); const { @@ -1371,11 +1376,15 @@ export const WorkspaceApp = ({ const previousMemoId = selectedMemoIndex > 0 ? memos[selectedMemoIndex - 1]?.id : null; const nextMemoId = selectedMemoIndex >= 0 && selectedMemoIndex < memos.length - 1 ? memos[selectedMemoIndex + 1]?.id : null; - const detailMemoId = selectedMemoId ?? memos[0]?.id ?? null; + const detailMemoId = rendererRecoveryMode ? null : selectedMemoId ?? memos[0]?.id ?? null; useEffect(() => { const selectedMemoInList = selectedMemoId ? memos.some((memo) => memo.id === selectedMemoId) : false; + if (rendererRecoveryMode) { + return; + } + if (creatingMemoSelectionRef.current || pendingCreatedMemoIdRef.current) { return; } @@ -1402,7 +1411,13 @@ export const WorkspaceApp = ({ if (!selectedMemoId || !selectedMemoInList) { setSelectedMemoId(memos[0].id); } - }, [createdMemoEditId, memos, selectedMemoId]); + }, [createdMemoEditId, memos, rendererRecoveryMode, selectedMemoId]); + + useEffect(() => { + if (!rendererRecoveryMode || !selectedMemoId) return; + clearRendererRecoveryRequired(); + setRendererRecoveryMode(false); + }, [rendererRecoveryMode, selectedMemoId]); const memoQuery = useQuery({ queryKey: detailMemoId ? memoDetailQueryKey(detailMemoId, memoView) : ["memo", detailMemoId, memoView], @@ -3128,8 +3143,18 @@ export const WorkspaceApp = ({ ) : rightView === "evernote-migration" ? ( setRightView("settings")} /> + ) : rendererRecoveryMode ? ( + ) : ( - { + setRendererRecoveryMode(true); + setSelectedMemoId(null); + setActivePane("memos"); + }} + > + + )} diff --git a/apps/web/src/lib/renderer-recovery.test.mjs b/apps/web/src/lib/renderer-recovery.test.mjs new file mode 100644 index 00000000..701f3490 --- /dev/null +++ b/apps/web/src/lib/renderer-recovery.test.mjs @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { + RENDERER_RECOVERY_STORAGE_KEY, + clearRendererRecoveryRequired, + isRendererRecoveryRequired, + markRendererRecoveryRequired, +} from "./renderer-recovery.ts"; + +const createStorage = () => { + const values = new Map(); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + removeItem: (key) => values.delete(key), + }; +}; + +describe("renderer recovery marker", () => { + test("persists recovery until the user leaves safe mode", () => { + const storage = createStorage(); + + expect(isRendererRecoveryRequired(storage)).toBe(false); + markRendererRecoveryRequired(storage); + expect(storage.getItem(RENDERER_RECOVERY_STORAGE_KEY)).toBe("1"); + expect(isRendererRecoveryRequired(storage)).toBe(true); + + clearRendererRecoveryRequired(storage); + expect(isRendererRecoveryRequired(storage)).toBe(false); + }); + + test("never throws when browser storage is unavailable", () => { + const unavailableStorage = { + getItem() { throw new Error("blocked"); }, + setItem() { throw new Error("blocked"); }, + removeItem() { throw new Error("blocked"); }, + }; + + expect(isRendererRecoveryRequired(unavailableStorage)).toBe(false); + expect(() => markRendererRecoveryRequired(unavailableStorage)).not.toThrow(); + expect(() => clearRendererRecoveryRequired(unavailableStorage)).not.toThrow(); + }); +}); diff --git a/apps/web/src/lib/renderer-recovery.ts b/apps/web/src/lib/renderer-recovery.ts new file mode 100644 index 00000000..cd57581c --- /dev/null +++ b/apps/web/src/lib/renderer-recovery.ts @@ -0,0 +1,35 @@ +export const RENDERER_RECOVERY_STORAGE_KEY = "edgeever.renderer-recovery-required"; + +type RecoveryStorage = Pick; + +const getBrowserStorage = (): RecoveryStorage | null => { + try { + return typeof window === "undefined" ? null : window.localStorage; + } catch { + return null; + } +}; + +export const isRendererRecoveryRequired = (storage: RecoveryStorage | null = getBrowserStorage()) => { + try { + return storage?.getItem(RENDERER_RECOVERY_STORAGE_KEY) === "1"; + } catch { + return false; + } +}; + +export const markRendererRecoveryRequired = (storage: RecoveryStorage | null = getBrowserStorage()) => { + try { + storage?.setItem(RENDERER_RECOVERY_STORAGE_KEY, "1"); + } catch { + // Recovery must never turn a renderer failure into another failure. + } +}; + +export const clearRendererRecoveryRequired = (storage: RecoveryStorage | null = getBrowserStorage()) => { + try { + storage?.removeItem(RENDERER_RECOVERY_STORAGE_KEY); + } catch { + // Restricted storage should not prevent the user from opening another note. + } +}; diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 5327007f..d1243f50 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -16,6 +16,7 @@ declare const __EDGEEVER_DEVELOPMENT_PROFILE__: "" | "local" | "demo"; interface EdgeEverDesktopBridge { isAvailable: boolean; canClearLocalData: boolean; + recoveredAfterAbnormalExit: boolean; apiBaseUrl: string; setApiBaseUrl(value: string): Promise; getSessionToken(): string; diff --git a/package.json b/package.json index aa43f781..fa62e32e 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "release": "bun scripts/release.mjs", "publish:stores": "bun scripts/store-delivery.mjs", "test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs", - "test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar", + "test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar", "verify:desktop-renderer": "bun scripts/verify-desktop-renderer.mjs", "verify:desktop-package": "bun scripts/verify-desktop-package.mjs", "build:worker": "bun scripts/build-cloudflare-worker.mjs", diff --git a/tests/e2e/renderer-recovery.spec.ts b/tests/e2e/renderer-recovery.spec.ts new file mode 100644 index 00000000..5022d477 --- /dev/null +++ b/tests/e2e/renderer-recovery.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from "@playwright/test"; + +test("starts at the note list after a renderer failure instead of reopening the previous note", async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.setItem("edgeever.renderer-recovery-required", "1"); + }); + await page.goto("/"); + + await expect(page.getByRole("status").getByText("已进入安全启动模式")).toBeVisible(); + const firstMemo = page.locator("[data-memo-id]").first(); + await expect(firstMemo).toBeVisible(); + await firstMemo.click(); + + await expect(page.getByText("已进入安全启动模式")).toHaveCount(0); + await expect.poll(() => page.evaluate(() => window.localStorage.getItem("edgeever.renderer-recovery-required"))).toBeNull(); +});