const { app, BrowserWindow, ipcMain, session } = require("electron"); const crypto = require("node:crypto"); const fs = require("node:fs"); const path = require("node:path"); const tls = require("node:tls"); app.commandLine.appendSwitch("autoplay-policy", "no-user-gesture-required"); app.disableHardwareAcceleration(); 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) : ""; window.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL) => { console.error("renderer load failed", { errorCode, errorDescription, validatedURL }); }); window.webContents.on("render-process-gone", (_event, details) => { console.error("renderer process exited", details); }); window.webContents.on("console-message", (_event, ...args) => { const details = args[0]; const message = typeof details === "object" && details ? details.message : String(args[1] ?? details ?? ""); console.log(`[renderer] ${message}`); }); window.webContents.once("did-finish-load", async () => { try { const diagnostics = await window.webContents.executeJavaScript(`JSON.stringify({readyState: document.readyState, bodyLength: document.body.innerText.length, appChildren: document.querySelector('#app')?.childElementCount || 0})`); console.log(`[renderer-ready] ${diagnostics}`); } catch (error) { console.error("renderer diagnostics failed", error); } if (process.env.SIMULATOR_AUTO_TEST === "1") { void runAutomatedSmoke(window); } }); 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); }); } } async function runAutomatedSmoke(window) { const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); await delay(800); await window.webContents.executeJavaScript(`document.querySelector('#checkButton')?.click()`); for (let attempt = 0; attempt < 30; attempt += 1) { await delay(500); const state = await window.webContents.executeJavaScript(`JSON.stringify({phase: document.querySelector('#phaseBadge')?.textContent, canStart: !document.querySelector('#startButton')?.disabled, detail: document.querySelector('#apiDetail')?.textContent})`); console.log(`[auto-test] ${state}`); const parsed = JSON.parse(state); if (parsed.canStart) { await window.webContents.executeJavaScript(`document.querySelector('#startButton')?.click()`); console.log("[auto-test] start requested"); await collectAutomatedConversationResult(window); return; } if (parsed.phase === "异常") { return; } } console.error("[auto-test] environment check did not become ready"); } async function collectAutomatedConversationResult(window) { const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); const deadline = Date.now() + 120_000; let snapshot = {}; while (Date.now() < deadline) { await delay(1000); const encoded = await window.webContents.executeJavaScript(`JSON.stringify({ phase: document.querySelector('#phaseBadge')?.textContent || '', conversation_id: document.querySelector('#conversationID')?.textContent || '', subject: [...document.querySelectorAll('.transcript-turn--subject span')].map((item) => item.textContent || ''), agent: [...document.querySelectorAll('.transcript-turn--agent span')].map((item) => item.textContent || ''), turns: [...document.querySelectorAll('.transcript-turn')].map((item) => ({speaker: item.classList.contains('transcript-turn--subject') ? 'subject' : 'agent', text: item.querySelector('span')?.textContent || ''})), events: [...document.querySelectorAll('#eventLog li span')].map((item) => item.textContent || '') })`); snapshot = JSON.parse(encoded); console.log(`[auto-test] conversation ${encoded}`); const subjectIndex = snapshot.turns?.findIndex((turn) => turn.speaker === "subject") ?? -1; const hasAgentReply = subjectIndex >= 0 && snapshot.turns.slice(subjectIndex + 1).some((turn) => turn.speaker === "agent"); if (hasAgentReply) { await window.webContents.executeJavaScript(`document.querySelector('#stopButton')?.click()`); snapshot.result = "PASS"; break; } if (snapshot.phase === "异常") { snapshot.result = "ERROR"; break; } } if (!snapshot.result) snapshot.result = "TIMEOUT"; const resultPath = process.env.SIMULATOR_AUTO_RESULT_PATH; if (resultPath) { fs.writeFileSync(path.resolve(resultPath), JSON.stringify(snapshot, null, 2), "utf8"); } await delay(4000); app.quit(); } function ensureAllowedAPIURL(value) { const parsed = new URL(String(value || "")); const loopback = parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" || parsed.hostname === "[::1]"; const deployedTestServer = parsed.protocol === "https:" && parsed.hostname === "121.41.46.238" && parsed.port === ""; if ((!loopback || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) && !deployedTestServer) { throw new Error("测试设备只允许连接本机平台或已部署的测试服务器"); } return parsed.origin; } function configure(input) { const apiURL = ensureAllowedAPIURL(input?.apiURL); const deviceID = String(input?.deviceID || "").trim(); const deviceSecret = String(input?.deviceSecret || "") || loadDefaultDeviceSecret(); 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 loadDefaultDeviceSecret() { const environmentSecret = String(process.env.SIMULATOR_DEFAULT_DEVICE_SECRET || "").trim(); if (environmentSecret) return environmentSecret; const candidates = []; const localAppData = String(process.env.LOCALAPPDATA || "").trim(); const userProfile = String(process.env.USERPROFILE || "").trim(); const portableDirectory = String(process.env.PORTABLE_EXECUTABLE_DIR || "").trim(); if (localAppData) candidates.push(path.join(localAppData, "DataTerminalVoiceDevice", "server-device-secret")); if (userProfile) candidates.push(path.join(userProfile, "AppData", "Local", "DataTerminalVoiceDevice", "server-device-secret")); try { const home = app.getPath("home"); if (home) candidates.push(path.join(home, "AppData", "Local", "DataTerminalVoiceDevice", "server-device-secret")); } catch { /* app path is unavailable before ready */ } if (portableDirectory) candidates.push(path.join(portableDirectory, ".server-device-secret")); candidates.push(path.join(path.dirname(process.execPath), ".server-device-secret")); for (const secretPath of new Set(candidates)) { try { const stats = fs.statSync(secretPath); if (!stats.isFile() || stats.size < 32 || stats.size > 4096) continue; const value = fs.readFileSync(secretPath, "utf8").trim(); if (value.length >= 32) return value; } catch { /* try the next fixed credential location */ } } return ""; } ipcMain.handle("sim:get-runtime-defaults", () => ({ apiURL: process.env.SIMULATOR_DEFAULT_API_URL || "https://121.41.46.238", deviceID: process.env.SIMULATOR_DEFAULT_DEVICE_ID || "10000000-0000-0000-0000-000000000511", deviceSecret: "", deviceSecretConfigured: loadDefaultDeviceSecret().length >= 32, })); 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 }; }); const rtcWebEndpoints = [ "access.rtc.volcvideo.com", "access2-hl.rtc.volcvideo.com", "common.rtc.volcvideo.com", "rtcg-access.volcvideos.com", "rtcg.volcvideos.com", "web-log-report.rtc.volcvideo.com", "web-log-report.volcvideos.com", ]; function probeTLSEndpoint(hostname) { return new Promise((resolve) => { const socket = tls.connect({ host: hostname, port: 443, servername: hostname, rejectUnauthorized: true }); let finished = false; const finish = (result) => { if (finished) return; finished = true; socket.destroy(); resolve(result); }; socket.setTimeout(5_000, () => finish({ hostname, ok: false, error: "连接超时" })); socket.once("secureConnect", () => finish({ hostname, ok: true })); socket.once("error", (error) => finish({ hostname, ok: false, error: String(error?.code || error?.message || "连接失败") })); }); } ipcMain.handle("sim:probe-rtc-network", async () => { const endpoints = await Promise.all(rtcWebEndpoints.map(probeTLSEndpoint)); return { endpoints }; }); ipcMain.handle("sim:get-automation-audio", async () => { const configuredPath = String(process.env.SIMULATOR_AUTOMATED_AUDIO_PATH || "").trim(); if (!configuredPath) return undefined; const resolvedPath = path.resolve(configuredPath); const stats = fs.statSync(resolvedPath); if (!stats.isFile() || stats.size < 1 || stats.size > 10 * 1024 * 1024) { throw new Error("自动验证音频必须是 1 字节到 10MB 的文件"); } const extension = path.extname(resolvedPath).toLowerCase(); const mime = extension === ".mp3" ? "audio/mpeg" : extension === ".ogg" ? "audio/ogg" : "audio/wav"; return { data_url: `data:${mime};base64,${fs.readFileSync(resolvedPath).toString("base64")}`, source_name: path.basename(resolvedPath), }; }); 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:submit-transcript-batch", async (_event, input) => { const conversationID = String(input?.conversationID || ""); const turns = Array.isArray(input?.turns) ? input.turns : []; if (!conversationID || turns.length === 0) { throw new Error("通话编号和最终话轮不能为空"); } const payload = { turns }; const digest = crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 32); return deviceRequest( "POST", `/device/v2/conversations/${encodeURIComponent(conversationID)}/turns:batch`, payload, `windows-turns-${digest}`, ); }); 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: Number(input.nearSegments || 0), far_segments: Number(input.farSegments || 0) }, }, `windows-stop-${nonce()}`, )); ipcMain.handle("sim:upload-conversation-recording", async (_event, input) => { const conversationID = String(input?.conversationID || ""); const supplied = Array.isArray(input?.segments) ? input.segments : []; if (!conversationID || supplied.length === 0) throw new Error("通话编号和录音分片不能为空"); const segments = supplied.map((segment) => { const bytes = Buffer.from(segment.bytes); if (!bytes.length || bytes.length > 64 * 1024 * 1024) throw new Error("单个录音分片必须在 1 字节到 64MiB 之间"); return { track: segment.track, sequence_no: Number(segment.sequence_no), codec: segment.codec, content_type: segment.content_type, duration_ms: Number(segment.duration_ms), expected_size: bytes.length, expected_digest: crypto.createHash("sha256").update(bytes).digest("hex"), bytes, }; }); const manifest = { segments: segments.map(({ bytes: _bytes, ...segment }) => segment) }; const manifestDigest = crypto.createHash("sha256").update(JSON.stringify(manifest)).digest("hex").slice(0, 40); const batch = await deviceRequest( "POST", `/device/v2/conversations/${encodeURIComponent(conversationID)}/upload-batches`, manifest, `windows-media-${manifestDigest}`, ); for (const local of segments) { const remote = batch.segments?.find((segment) => segment.track === local.track && Number(segment.sequence_no) === local.sequence_no); if (!remote) throw new Error(`平台未返回${local.track}轨道第${local.sequence_no}段录音`); if (remote.status === "VERIFIED" || remote.status === "VERIFYING") continue; if (!remote.upload_url) throw new Error(`平台未签发${local.track}轨道第${local.sequence_no}段上传地址`); const response = await fetch(remote.upload_url, { method: "PUT", headers: { "Content-Type": local.content_type }, body: local.bytes }); if (!response.ok) throw new Error(`录音对象上传失败:HTTP ${response.status}`); } await deviceRequest("POST", `/device/v2/upload-batches/${encodeURIComponent(batch.id)}/complete`, {}); const deadline = Date.now() + 90_000; while (Date.now() < deadline) { const current = await deviceRequest("GET", `/device/v2/upload-batches/${encodeURIComponent(batch.id)}`); if (current.status === "COMPLETED") return current; if (current.status === "FAILED" || current.status === "EXPIRED") { const rejected = current.segments?.find((segment) => segment.status === "REJECTED"); throw new Error(rejected?.last_error_code || current.last_error_code || `录音批次${current.status}`); } await new Promise((resolve) => setTimeout(resolve, 750)); } throw new Error("录音已上传,但等待服务器校验超时"); }); 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(); });