diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 0957bd27b..4e097c67c 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -1,4 +1,5 @@ import type { GatewayBrowserClient } from "../gateway"; +import { generateUUID } from "../uuid"; export type ChatState = { client: GatewayBrowserClient | null; @@ -48,7 +49,7 @@ export async function sendChat(state: ChatState) { state.chatSending = true; state.chatMessage = ""; state.lastError = null; - const runId = crypto.randomUUID(); + const runId = generateUUID(); state.chatRunId = runId; state.chatStream = ""; try { diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 7fd488498..7dd7722b3 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -1,3 +1,5 @@ +import { generateUUID } from "./uuid"; + export type GatewayEventFrame = { type: "event"; event: string; @@ -167,7 +169,7 @@ export class GatewayBrowserClient { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return Promise.reject(new Error("gateway not connected")); } - const id = crypto.randomUUID(); + const id = generateUUID(); const frame = { type: "req", id, method, params }; const p = new Promise((resolve, reject) => { this.pending.set(id, { resolve: (v) => resolve(v as T), reject }); diff --git a/ui/src/ui/uuid.test.ts b/ui/src/ui/uuid.test.ts new file mode 100644 index 000000000..62856f1b6 --- /dev/null +++ b/ui/src/ui/uuid.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { generateUUID } from "./uuid"; + +describe("generateUUID", () => { + it("uses crypto.randomUUID when available", () => { + const id = generateUUID({ + randomUUID: () => "randomuuid", + getRandomValues: () => { + throw new Error("should not be called"); + }, + }); + + expect(id).toBe("randomuuid"); + }); + + it("falls back to crypto.getRandomValues", () => { + const id = generateUUID({ + getRandomValues: (bytes) => { + for (let i = 0; i < bytes.length; i++) bytes[i] = i; + return bytes; + }, + }); + + expect(id).toBe("00010203-0405-4607-8809-0a0b0c0d0e0f"); + }); + + it("still returns a v4 UUID when crypto is missing", () => { + const id = generateUUID(null); + expect(id).toMatch(/^[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/ui/src/ui/uuid.ts b/ui/src/ui/uuid.ts new file mode 100644 index 000000000..7124dbb8f --- /dev/null +++ b/ui/src/ui/uuid.ts @@ -0,0 +1,43 @@ +export type CryptoLike = { + randomUUID?: (() => string) | undefined; + getRandomValues?: ((array: Uint8Array) => Uint8Array) | undefined; +}; + +function uuidFromBytes(bytes: Uint8Array): string { + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 + + let hex = ""; + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i]!.toString(16).padStart(2, "0"); + } + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice( + 16, + 20, + )}-${hex.slice(20)}`; +} + +function weakRandomBytes(): Uint8Array { + const bytes = new Uint8Array(16); + const now = Date.now(); + for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); + bytes[0] ^= now & 0xff; + bytes[1] ^= (now >>> 8) & 0xff; + bytes[2] ^= (now >>> 16) & 0xff; + bytes[3] ^= (now >>> 24) & 0xff; + return bytes; +} + +export function generateUUID(cryptoLike: CryptoLike | null = globalThis.crypto): string { + if (cryptoLike && typeof cryptoLike.randomUUID === "function") return cryptoLike.randomUUID(); + + if (cryptoLike && typeof cryptoLike.getRandomValues === "function") { + const bytes = new Uint8Array(16); + cryptoLike.getRandomValues(bytes); + return uuidFromBytes(bytes); + } + + return uuidFromBytes(weakRandomBytes()); +} +