const { app, BrowserWindow, ipcMain, session } = require("electron"); const crypto = require("node:crypto"); const fs = require("node:fs"); const path = require("node:path"); app.commandLine.appendSwitch("autoplay-policy", "no-user-gesture-required"); let deviceConfig = null; let desiredConfigRevision = 1; function createWindow() { const window = new BrowserWindow({ width: 1220, height: 820, minWidth: 980, minHeight: 700, backgroundColor: "#eef3f0", autoHideMenuBar: true, title: "语音终端模拟器", webPreferences: { preload: path.join(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, sandbox: true, }, }); const devArgument = process.argv.find((argument) => argument.startsWith("--dev-server-url=")); const devServerURL = devArgument ? devArgument.slice("--dev-server-url=".length) : ""; if (devServerURL) { window.loadURL(devServerURL); } else { window.loadFile(path.join(__dirname, "..", "dist", "index.html")); } const screenshotPath = process.env.SIMULATOR_SCREENSHOT_PATH; if (screenshotPath) { window.webContents.once("did-finish-load", () => { setTimeout(async () => { const image = await window.webContents.capturePage(); fs.writeFileSync(screenshotPath, image.toPNG()); app.quit(); }, 1200); }); } } function ensureLoopbackURL(value) { const parsed = new URL(String(value || "")); const loopback = parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" || parsed.hostname === "[::1]"; if (!loopback || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) { throw new Error("本地测试只允许连接 localhost 或 127.0.0.1"); } return parsed.origin; } function configure(input) { const apiURL = ensureLoopbackURL(input?.apiURL); const deviceID = String(input?.deviceID || "").trim(); const deviceSecret = String(input?.deviceSecret || ""); if (!/^[0-9a-f-]{36}$/i.test(deviceID)) { throw new Error("设备 ID 必须是 UUID"); } if (deviceSecret.length < 32) { throw new Error("设备密钥至少需要 32 个字符"); } deviceConfig = { apiURL, deviceID, deviceSecret }; return { apiURL, deviceID }; } function nonce() { return crypto.randomBytes(16).toString("hex"); } function signedHeaders(method, requestURI, body) { if (!deviceConfig) throw new Error("请先连接本地平台"); const timestamp = new Date().toISOString(); const requestNonce = nonce(); const digest = crypto.createHash("sha256").update(body).digest("hex"); const canonical = [method.toUpperCase(), requestURI, timestamp, requestNonce, digest].join("\n"); const signature = crypto.createHmac("sha256", deviceConfig.deviceSecret).update(canonical).digest("base64url"); return { "X-Device-Id": deviceConfig.deviceID, "X-Device-Timestamp": timestamp, "X-Device-Nonce": requestNonce, "X-Device-Content-SHA256": digest, "X-Device-Signature": signature, }; } async function deviceRequest(method, requestPath, payload, idempotencyKey) { if (!deviceConfig) throw new Error("请先连接本地平台"); const target = new URL(requestPath, `${deviceConfig.apiURL}/`); const body = payload === undefined ? "" : JSON.stringify(payload); const headers = signedHeaders(method, `${target.pathname}${target.search}`, body); headers.Accept = "application/json"; if (body) headers["Content-Type"] = "application/json"; if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey; const response = await fetch(target, { method, headers, body: body || undefined }); const text = await response.text(); let parsed; try { parsed = text ? JSON.parse(text) : {}; } catch { parsed = { message: text }; } if (!response.ok) { const code = parsed?.error?.code || parsed?.code || `HTTP_${response.status}`; const message = parsed?.error?.message || parsed?.message || "本地平台请求失败"; throw new Error(`${code}: ${message}`); } return parsed?.data ?? parsed; } ipcMain.handle("sim:configure", (_event, input) => configure(input)); ipcMain.handle("sim:probe", async () => { const query = new URLSearchParams({ firmware_version: "0.3.0-windows-rtc", board_rev: "windows-x64", rtc_sdk_version: "web-4.69.0-electron", codec: "opus", sample_rate: "48000", network: "windows-local", device_time: new Date().toISOString(), }); const bootstrap = await deviceRequest("GET", `/device/v2/bootstrap?${query.toString()}`); const configuration = await deviceRequest("GET", "/device/v2/config"); desiredConfigRevision = Number(configuration.revision || bootstrap?.device?.config_revision || 1); return { bootstrap, configuration, desiredConfigRevision }; }); ipcMain.handle("sim:create-conversation", async () => deviceRequest("POST", "/device/v2/conversations", { device_time: new Date().toISOString(), monotonic_ms: Math.max(0, Math.round(performance.now())), capabilities: { rtc: true, audio_tracks: ["near", "far"], upload_resume: true, rtc_credential_renewal: true, }, network: { type: "windows-local" }, requested_goal: "open_conversation", }, `windows-create-${nonce()}`)); ipcMain.handle("sim:activate-conversation", async (_event, input) => deviceRequest( "POST", `/device/v2/conversations/${encodeURIComponent(input.conversationID)}/activate`, { joined_at: new Date().toISOString(), room_id: input.roomID, rtc_user_id: input.userID, rtc_join_result: "success", config_revision: desiredConfigRevision, connection_reference: "windows-electron-web-rtc", }, `windows-activate-${nonce()}`, )); ipcMain.handle("sim:get-conversation", async (_event, conversationID) => deviceRequest( "GET", `/device/v2/conversations/${encodeURIComponent(conversationID)}`, )); ipcMain.handle("sim:heartbeat", async (_event, input) => deviceRequest( "POST", `/device/v2/conversations/${encodeURIComponent(input.conversationID)}/heartbeat`, { sequence_no: input.sequenceNo, device_time: new Date().toISOString(), monotonic_ms: Math.max(0, Math.round(performance.now())), conversation_status: "ACTIVE", rtc: { joined: true, uplink: true, downlink: true }, network: { type: "windows-local" }, firmware_version: "0.3.0-windows-rtc", reported_config_revision: desiredConfigRevision, rtc_credential_expires_at: input.credentialExpiresAt, recording_queue: { pending_segments: 0, failed_segments: 0 }, command_results: [], }, )); ipcMain.handle("sim:stop-conversation", async (_event, input) => deviceRequest( "POST", `/device/v2/conversations/${encodeURIComponent(input.conversationID)}/stop`, { reason: input.reason || "windows_user_hangup", device_time: new Date().toISOString(), last_heartbeat_sequence: input.lastHeartbeatSequence || 0, rtc_left: true, recording: { near_segments: 0, far_segments: 0 }, }, `windows-stop-${nonce()}`, )); app.whenReady().then(() => { session.defaultSession.setPermissionCheckHandler((_webContents, permission) => permission === "media"); session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => callback(permission === "media")); createWindow(); }); app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit(); }); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });