diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 0f778b518140..232b7557df61 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -2,6 +2,7 @@ import { onCleanup, onMount } from "solid-js" import { showToast } from "@opencode-ai/ui/toast" import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt" import { useLanguage } from "@/context/language" +import { uuid } from "@/utils/uuid" import { getCursorPosition } from "./editor-dom" export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] @@ -31,7 +32,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { const dataUrl = reader.result as string const attachment: ImageAttachmentPart = { type: "image", - id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2), + id: uuid(), filename: file.name, mime: file.type, dataUrl, diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index b91f029bc812..a88ea0d86d96 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -4,6 +4,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { useParams } from "@solidjs/router" import { Persist, persisted } from "@/utils/persist" import { createScopedCache } from "@/utils/scoped-cache" +import { uuid } from "@/utils/uuid" import type { SelectedLineRange } from "@/context/file" export type LineComment = { @@ -53,7 +54,7 @@ function createCommentSessionState(store: Store, setStore: SetStor const add = (input: Omit) => { const next: LineComment = { - id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2), + id: uuid(), time: Date.now(), ...input, } diff --git a/packages/app/src/utils/perf.ts b/packages/app/src/utils/perf.ts index 0ecbc33ffc1e..105d02a827fe 100644 --- a/packages/app/src/utils/perf.ts +++ b/packages/app/src/utils/perf.ts @@ -1,3 +1,5 @@ +import { uuid } from "@/utils/uuid" + type Nav = { id: string dir?: string @@ -16,8 +18,6 @@ const key = (dir: string | undefined, to: string) => `${dir ?? ""}:${to}` const now = () => performance.now() -const uid = () => crypto.randomUUID?.() ?? Math.random().toString(16).slice(2) - const navs = new Map() const pending = new Map() const active = new Map() @@ -94,7 +94,7 @@ function ensure(id: string, data: Omit) { export function navStart(input: { dir?: string; from?: string; to: string; trigger?: string }) { if (!dev) return - const id = uid() + const id = uuid() const start = now() const nav = ensure(id, { ...input, id, start }) nav.marks["navigate:start"] = start @@ -109,7 +109,7 @@ export function navParams(input: { dir?: string; from?: string; to: string }) { const k = key(input.dir, input.to) const pendingId = pending.get(k) if (pendingId) pending.delete(k) - const id = pendingId ?? uid() + const id = pendingId ?? uuid() const start = now() const nav = ensure(id, { ...input, id, start, trigger: pendingId ? "key" : "route" }) diff --git a/packages/app/src/utils/uuid.test.ts b/packages/app/src/utils/uuid.test.ts new file mode 100644 index 000000000000..e6b4e2824099 --- /dev/null +++ b/packages/app/src/utils/uuid.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { uuid } from "./uuid" + +const cryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto") +const secureDescriptor = Object.getOwnPropertyDescriptor(globalThis, "isSecureContext") +const randomDescriptor = Object.getOwnPropertyDescriptor(Math, "random") + +const setCrypto = (value: Partial) => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: value as Crypto, + }) +} + +const setSecure = (value: boolean) => { + Object.defineProperty(globalThis, "isSecureContext", { + configurable: true, + value, + }) +} + +const setRandom = (value: () => number) => { + Object.defineProperty(Math, "random", { + configurable: true, + value, + }) +} + +afterEach(() => { + if (cryptoDescriptor) { + Object.defineProperty(globalThis, "crypto", cryptoDescriptor) + } + + if (secureDescriptor) { + Object.defineProperty(globalThis, "isSecureContext", secureDescriptor) + } + + if (!secureDescriptor) { + delete (globalThis as { isSecureContext?: boolean }).isSecureContext + } + + if (randomDescriptor) { + Object.defineProperty(Math, "random", randomDescriptor) + } +}) + +describe("uuid", () => { + test("uses randomUUID in secure contexts", () => { + setCrypto({ randomUUID: () => "00000000-0000-0000-0000-000000000000" }) + setSecure(true) + expect(uuid()).toBe("00000000-0000-0000-0000-000000000000") + }) + + test("falls back in insecure contexts", () => { + setCrypto({ randomUUID: () => "00000000-0000-0000-0000-000000000000" }) + setSecure(false) + setRandom(() => 0.5) + expect(uuid()).toBe("8") + }) + + test("falls back when randomUUID throws", () => { + setCrypto({ + randomUUID: () => { + throw new DOMException("Failed", "OperationError") + }, + }) + setSecure(true) + setRandom(() => 0.5) + expect(uuid()).toBe("8") + }) + + test("falls back when randomUUID is unavailable", () => { + setCrypto({}) + setSecure(true) + setRandom(() => 0.5) + expect(uuid()).toBe("8") + }) +}) diff --git a/packages/app/src/utils/uuid.ts b/packages/app/src/utils/uuid.ts new file mode 100644 index 000000000000..7b964068c86f --- /dev/null +++ b/packages/app/src/utils/uuid.ts @@ -0,0 +1,12 @@ +const fallback = () => Math.random().toString(16).slice(2) + +export function uuid() { + const c = globalThis.crypto + if (!c || typeof c.randomUUID !== "function") return fallback() + if (typeof globalThis.isSecureContext === "boolean" && !globalThis.isSecureContext) return fallback() + try { + return c.randomUUID() + } catch { + return fallback() + } +}