Files
story/device-simulator-windows/electron/main.cjs
T

209 lines
7.3 KiB
JavaScript
Raw Normal View History

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();
});