diff --git a/apps/web/src/components/DesktopRendererErrorBoundary.tsx b/apps/web/src/components/DesktopRendererErrorBoundary.tsx
index dd174fd8..a386be0c 100644
--- a/apps/web/src/components/DesktopRendererErrorBoundary.tsx
+++ b/apps/web/src/components/DesktopRendererErrorBoundary.tsx
@@ -63,9 +63,13 @@ export class DesktopRendererErrorBoundary extends React.Component<{ children: Re
!
{zh ? "EdgeEver 页面出现异常" : "EdgeEver encountered a page error"}
- {zh
- ? "问题已经记录到这台设备。你可以重新加载继续使用,并在提交前检查脱敏后的 GitHub Issue。"
- : "The problem was recorded on this device. You can reload to continue and review the redacted GitHub Issue before submitting it."}
+ {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.")
+ : (zh
+ ? "你可以重新加载继续使用。如果问题仍然出现,请检查浏览器控制台,并在提交 GitHub Issue 前移除敏感信息。"
+ : "Reload to continue. If the problem persists, check the browser console and remove sensitive information before submitting a GitHub Issue.")}
{
refreshWorkspaceRef.current = refreshWorkspace;
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index c74e97a5..737ee1b9 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -39,6 +39,7 @@ import type {
import { resolveInstanceUrlInput } from "@edgeever/shared";
import type { MemoFilterMode, MemoSortMode } from "./app-helpers";
import { readAiStreamingPreference } from "./ai-generation-preference";
+import { createClientUuid } from "./client-id";
type ListNotebooksResponse = {
notebooks: Notebook[];
@@ -227,10 +228,7 @@ export const saveDesktopApiBaseUrl = async (value: string) => {
};
const createWebDeviceId = () => {
- const uuid = globalThis.crypto?.randomUUID?.();
- return uuid
- ? `web-${uuid}`
- : `web-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
+ return `web-${createClientUuid()}`;
};
const getOrCreateWebDeviceId = () => {
diff --git a/apps/web/src/lib/client-id.test.mjs b/apps/web/src/lib/client-id.test.mjs
new file mode 100644
index 00000000..0f53bc84
--- /dev/null
+++ b/apps/web/src/lib/client-id.test.mjs
@@ -0,0 +1,32 @@
+import { strict as assert } from "node:assert";
+import { describe, it } from "node:test";
+import { createClientUuid } from "./client-id.ts";
+
+describe("createClientUuid", () => {
+ it("prefers the browser randomUUID implementation", () => {
+ const expected = "12345678-1234-4123-8123-123456789abc";
+ const cryptoApi = {
+ randomUUID: () => expected,
+ getRandomValues: () => {
+ throw new Error("getRandomValues should not be called");
+ },
+ };
+
+ assert.equal(createClientUuid(cryptoApi), expected);
+ });
+
+ it("creates an RFC 4122 v4 UUID when randomUUID is unavailable on HTTP", () => {
+ const cryptoApi = {
+ getRandomValues: (bytes) => {
+ bytes.fill(0xab);
+ return bytes;
+ },
+ };
+
+ assert.equal(createClientUuid(cryptoApi), "abababab-abab-4bab-abab-abababababab");
+ });
+
+ it("still returns a UUID when the Crypto API is unavailable", () => {
+ assert.match(createClientUuid(null), /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
+ });
+});
diff --git a/apps/web/src/lib/client-id.ts b/apps/web/src/lib/client-id.ts
new file mode 100644
index 00000000..b3899441
--- /dev/null
+++ b/apps/web/src/lib/client-id.ts
@@ -0,0 +1,27 @@
+type ClientCrypto = Pick & Partial>;
+
+const fillWithPseudoRandomBytes = (bytes: Uint8Array) => {
+ for (let index = 0; index < bytes.length; index += 1) {
+ bytes[index] = Math.floor(Math.random() * 256);
+ }
+};
+
+export const createClientUuid = (cryptoApi: ClientCrypto | null | undefined = globalThis.crypto) => {
+ if (typeof cryptoApi?.randomUUID === "function") {
+ return cryptoApi.randomUUID();
+ }
+
+ const bytes = new Uint8Array(16);
+ if (typeof cryptoApi?.getRandomValues === "function") {
+ cryptoApi.getRandomValues(bytes);
+ } else {
+ fillWithPseudoRandomBytes(bytes);
+ }
+
+ // RFC 4122 version 4 and variant bits. getRandomValues remains available in
+ // browsers on plain-HTTP LAN origins where randomUUID is intentionally hidden.
+ bytes[6] = (bytes[6]! & 0x0f) | 0x40;
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80;
+ const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0"));
+ return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
+};
diff --git a/apps/web/src/lib/local-mirror.ts b/apps/web/src/lib/local-mirror.ts
index aff7b08c..472575e1 100644
--- a/apps/web/src/lib/local-mirror.ts
+++ b/apps/web/src/lib/local-mirror.ts
@@ -5,6 +5,7 @@ import { localDb, selectNewestLocalDraft, type LocalDraft, type LocalMemo, type
import { cacheLocalResourceBytes, localResourceUrl, removeCachedLocalResourceBytes } from "@/lib/local-resource-cache";
import { isBrowserOffline } from "@/lib/network-status";
import { parseTagsText } from "@/lib/utils";
+import { createClientUuid } from "@/lib/client-id";
export type LocalMemoListParams = {
notebookId?: string | null;
@@ -275,7 +276,7 @@ export const createLocalMemo = async (
const contentMarkdown = input.contentMarkdown ?? docToMarkdown(contentJson);
const contentText = docToText(contentJson);
const memo: MemoDetail = {
- id: `local_${crypto.randomUUID()}`,
+ id: `local_${createClientUuid()}`,
notebookId: input.notebookId,
title: input.title?.trim() || null,
excerpt: createExcerpt(contentText),
@@ -460,7 +461,7 @@ export const deleteLocalResource = async (scope: string, resourceId: string) =>
export const createLocalResource = async (scope: string, memoId: string, file: File) => {
const now = new Date().toISOString();
- const id = `local_resource_${crypto.randomUUID()}`;
+ const id = `local_resource_${createClientUuid()}`;
const url = localResourceUrl(id);
await cacheLocalResourceBytes(url, file);
const resource: ResourceListItem = {
@@ -517,7 +518,7 @@ export const deleteLocalNotebook = async (scope: string, notebookId: string) =>
export const createLocalNotebook = async (scope: string, input: { name: string; parentId?: string | null }) => {
const now = new Date().toISOString();
const notebook: Notebook = {
- id: `local_${crypto.randomUUID()}`,
+ id: `local_${createClientUuid()}`,
parentId: input.parentId ?? null,
name: input.name.trim(),
slug: null,
@@ -537,7 +538,7 @@ export const createLocalTemplate = async (scope: string, input: { name: string;
const now = new Date().toISOString();
const contentMarkdown = input.contentMarkdown ?? "";
const template: MemoTemplate = {
- id: `local_${crypto.randomUUID()}`,
+ id: `local_${createClientUuid()}`,
name: input.name.trim(),
description: input.description ?? null,
title: input.title ?? null,
diff --git a/apps/web/src/lib/note-print.ts b/apps/web/src/lib/note-print.ts
index 1602b156..5870c1ec 100644
--- a/apps/web/src/lib/note-print.ts
+++ b/apps/web/src/lib/note-print.ts
@@ -2,6 +2,7 @@ import { DOMSerializer } from "@tiptap/pm/model";
import type { Editor } from "@tiptap/react";
import type { TiptapDoc } from "@edgeever/shared";
import { getAppPagePath, getMessageTargetOrigin } from "@/lib/app-page-path";
+import { createClientUuid } from "@/lib/client-id";
export const NOTE_PRINT_MESSAGE = "edgeever:note-print";
export const NOTE_PRINT_READY_MESSAGE = "edgeever:note-print-ready";
@@ -44,7 +45,7 @@ export const openNotePrintPreview = (
payload: Omit,
preopenedWindow?: Window,
) => {
- const token = crypto.randomUUID();
+ const token = createClientUuid();
let printWindow: Window | null = preopenedWindow ?? null;
const handleMessage = (event: MessageEvent) => {
diff --git a/apps/web/src/lib/sync-queue.ts b/apps/web/src/lib/sync-queue.ts
index c8f09fca..c87f64c0 100644
--- a/apps/web/src/lib/sync-queue.ts
+++ b/apps/web/src/lib/sync-queue.ts
@@ -31,6 +31,7 @@ import { getMemoSaveConflictInfo, parseMemoSaveConflictDetails } from "@/lib/mem
import { getCachedLocalResourceBytes, removeCachedLocalResourceBytes } from "@/lib/local-resource-cache";
import { isBrowserOffline } from "@/lib/network-status";
import { parseTagsText } from "@/lib/utils";
+import { createClientUuid } from "@/lib/client-id";
export type { SyncQueueSummary, SyncRunResult } from "@edgeever/shared";
export type SyncQueueResult = MemoDetail | Notebook | MemoTemplate | Resource | null;
@@ -458,7 +459,7 @@ const claimQueueItem = (id: string) =>
return null;
}
- const claimId = crypto.randomUUID();
+ const claimId = createClientUuid();
const claimedItem: SyncQueueItem = {
...item,
status: "syncing",