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.")}
window.location.reload()}
>
- {zh ? "重新加载" : "Reload"}
+ {zh ? "安全重新加载" : "Reload safely"}
{desktop ? (
{
+ test("isolates editor failures below the workspace shell", () => {
+ const workspaceSource = readFileSync(new URL("./WorkspaceApp.tsx", import.meta.url), "utf8");
+
+ expect(workspaceSource).toContain(" {
+ const workspaceSource = readFileSync(new URL("./WorkspaceApp.tsx", import.meta.url), "utf8");
+ const rootBoundarySource = readFileSync(new URL("./DesktopRendererErrorBoundary.tsx", import.meta.url), "utf8");
+
+ expect(rootBoundarySource).toContain("markRendererRecoveryRequired()");
+ expect(workspaceSource).toContain("rendererRecoveryMode ? null : selectedMemoId ?? memos[0]?.id ?? null");
+ expect(workspaceSource).toContain("if (rendererRecoveryMode) {");
+ expect(workspaceSource).toContain(" ");
+ });
+});
diff --git a/apps/web/src/components/EditorPaneErrorBoundary.tsx b/apps/web/src/components/EditorPaneErrorBoundary.tsx
new file mode 100644
index 00000000..a2e9c262
--- /dev/null
+++ b/apps/web/src/components/EditorPaneErrorBoundary.tsx
@@ -0,0 +1,98 @@
+import React, { type ErrorInfo, type ReactNode } from "react";
+import { clearRendererRecoveryRequired, markRendererRecoveryRequired } from "@/lib/renderer-recovery";
+
+type Props = {
+ children: ReactNode;
+ resetKey: string | null;
+ onBackToList: () => void;
+};
+
+type State = {
+ error: Error | null;
+};
+
+const isChineseInterface = () => navigator.language.toLowerCase().startsWith("zh");
+
+export class EditorPaneErrorBoundary extends React.Component {
+ state: State = { error: null };
+
+ static getDerivedStateFromError(error: unknown): State {
+ return { error: error instanceof Error ? error : new Error(String(error)) };
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo) {
+ markRendererRecoveryRequired();
+ const details = {
+ kind: "react-error" as const,
+ message: error.message,
+ stack: error.stack ?? "",
+ componentStack: info.componentStack ?? "",
+ };
+ console.error("Caught note editor error", error, info.componentStack);
+ void window.edgeeverDesktop?.recordRendererError(details).catch(() => undefined);
+ }
+
+ componentDidUpdate(previousProps: Props) {
+ if (this.state.error && previousProps.resetKey !== this.props.resetKey) {
+ clearRendererRecoveryRequired();
+ this.setState({ error: null });
+ }
+ }
+
+ private retry = () => {
+ clearRendererRecoveryRequired();
+ this.setState({ error: null });
+ };
+
+ render() {
+ if (!this.state.error) return this.props.children;
+ const zh = isChineseInterface();
+
+ return (
+
+
+ !
+ {zh ? "这条笔记暂时无法显示" : "This note cannot be displayed"}
+
+ {zh
+ ? "异常已被限制在当前笔记,笔记列表和应用其他功能仍可继续使用。请选择另一条笔记,或重试当前笔记。"
+ : "The failure was isolated to this note. The note list and the rest of the app remain available. Choose another note or retry this one."}
+
+
+
+ {zh ? "返回笔记列表" : "Back to note list"}
+
+
+ {zh ? "重试当前笔记" : "Retry this note"}
+
+
+
+
+ );
+ }
+}
+
+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();
+});