兼容缺少 randomUUID 的 WebView

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Ubuntu 2026-08-03 20:25:17 +00:00
parent d7e49d54f3
commit 82376fea9d
3 changed files with 24 additions and 6 deletions

View File

@ -1,7 +1,7 @@
import { ChangeEvent, ReactNode, useEffect, useMemo, useState } from "react";
import { Button } from "../../ui";
import { ConnectionAdapter } from "./mockAdapter";
import { blankConnection, ConnectionConfig, ConnectionRecord, connectionStates, DiagnosticCategory, DiagnosticResult, maskedSecret, parseImportPreview, toSafeExport, validateConnection } from "./model";
import { blankConnection, ConnectionConfig, ConnectionRecord, connectionStates, createConnectionId, DiagnosticCategory, DiagnosticResult, maskedSecret, parseImportPreview, toSafeExport, validateConnection } from "./model";
const field = (config: ConnectionConfig, onChange: (next: ConnectionConfig) => void) =>
<K extends keyof ConnectionConfig>(key: K, value: ConnectionConfig[K]) => onChange({ ...config, [key]: value });
@ -103,7 +103,7 @@ export function ConnectionCenter({ adapter, developmentMode = false }: { adapter
const importPreview = () => { try { setEditing(parseImportPreview(importText)); setNotice("导入预览已加载;秘密字段已清空,保存前请确认。"); } catch (error) { setNotice(error instanceof Error ? error.message : "导入失败"); } };
const exportRecord = (record: ConnectionRecord) => { const safe = JSON.stringify(toSafeExport(record.config), null, 2); setNotice(`安全导出预览(不含 Token、密码和客户端私钥\n${safe}`); };
if (editing) return <ConnectionEditor initial={editing} developmentMode={developmentMode} onCancel={() => setEditing(undefined)} onSave={async (config) => { await adapter.save(config); await refresh(); setSelected(config.id); setEditing(undefined); setNotice("配置已保存到内存 mock adapter尚未建立网络连接。"); }} />;
return <div className="connection-center"><ConnectionList records={records} selectedId={selected} onSelect={setSelected} onCreate={() => setEditing(blankConnection())} onCopy={(record) => setEditing({ ...structuredClone(record.config), id: crypto.randomUUID(), name: `${record.config.name} 副本`, auth: { ...record.config.auth, secret: "" }, proxy: { ...record.config.proxy, password: "" }, tls: { ...record.config.tls, clientKey: "" } })} onExport={exportRecord} onDelete={setDeleting} />
return <div className="connection-center"><ConnectionList records={records} selectedId={selected} onSelect={setSelected} onCreate={() => setEditing(blankConnection())} onCopy={(record) => setEditing({ ...structuredClone(record.config), id: createConnectionId(), name: `${record.config.name} 副本`, auth: { ...record.config.auth, secret: "" }, proxy: { ...record.config.proxy, password: "" }, tls: { ...record.config.tls, clientKey: "" } })} onExport={exportRecord} onDelete={setDeleting} />
<section className="import-panel"><h3></h3><textarea aria-label="导入 JSON" value={importText} onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setImportText(e.target.value)} placeholder="粘贴由本客户端安全导出的 JSON" /><button className="secondary" onClick={importPreview}></button></section>
{selectedRecord && <ConnectionDiagnostics record={selectedRecord} adapter={adapter} />}
{notice && <pre className="connection-notice" aria-live="polite">{notice}</pre>}

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { blankConnection, connectionStates, maskedSecret, parseImportPreview, toSafeExport, validateConnection } from "./model";
import { describe, expect, it, vi } from "vitest";
import { blankConnection, connectionStates, createConnectionId, maskedSecret, parseImportPreview, toSafeExport, validateConnection } from "./model";
function validConfig() {
const config = blankConnection();
@ -9,6 +9,11 @@ function validConfig() {
}
describe("连接配置安全边界", () => {
it("缺少 crypto.randomUUID 时仍可生成 RFC 4122 格式 ID", () => {
vi.stubGlobal("crypto", { getRandomValues: (bytes: Uint8Array) => { bytes.fill(7); return bytes; } });
expect(createConnectionId()).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
vi.unstubAllGlobals();
});
it("验证地址、URL 凭据及开发模式 TLS 约束", () => {
const config = validConfig();
config.address = "https://user:password@atlas.example";

View File

@ -33,8 +33,21 @@ export interface ConnectionRecord { config: ConnectionConfig; state: ConnectionS
export type DiagnosticCategory = "network" | "tls" | "authentication" | "version" | "permission" | "rate_limit" | "timeout" | "message_format" | "server";
export interface DiagnosticResult { category: DiagnosticCategory; ok: boolean; summary: string; }
/** 连接配置 ID 不承载安全语义;兼容缺少 randomUUID 的旧 WebView 与非安全 HTTP 上下文。 */
export function createConnectionId(): string {
const webCrypto = globalThis.crypto;
if (typeof webCrypto?.randomUUID === "function") return webCrypto.randomUUID();
const bytes = new Uint8Array(16);
if (typeof webCrypto?.getRandomValues === "function") webCrypto.getRandomValues(bytes);
else for (let index = 0; index < bytes.length; index += 1) bytes[index] = Math.floor(Math.random() * 256);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const value = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`;
}
export const blankConnection = (): ConnectionConfig => ({
id: crypto.randomUUID(), name: "", address: "", transport: "grpc",
id: createConnectionId(), name: "", address: "", transport: "grpc",
tls: { enabled: true, customCa: "", clientCertificate: "", clientKey: "", fingerprint: "", sni: "", skipVerification: false },
auth: { method: "token", username: "", secret: "" },
proxy: { enabled: false, url: "", username: "", password: "" },
@ -87,7 +100,7 @@ export function parseImportPreview(raw: string): ConnectionConfig {
if (input.exportVersion !== 1) throw new Error("不支持的导出版本");
const base = blankConnection();
const imported: ConnectionConfig = {
...base, ...input, id: crypto.randomUUID(),
...base, ...input, id: createConnectionId(),
tls: { ...base.tls, ...input.tls, clientKey: "" },
auth: { ...base.auth, ...input.auth, secret: "" },
proxy: { ...base.proxy, ...input.proxy, password: "" },