feat: add Windows real RTC voice device simulator
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user