This commit is contained in:
@@ -2,8 +2,10 @@ 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;
|
||||
@@ -27,6 +29,29 @@ function createWindow() {
|
||||
|
||||
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 {
|
||||
@@ -45,29 +70,123 @@ function createWindow() {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLoopbackURL(value) {
|
||||
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]";
|
||||
if (!loopback || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
|
||||
throw new Error("本地测试只允许连接 localhost 或 127.0.0.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 = ensureLoopbackURL(input?.apiURL);
|
||||
const apiURL = ensureAllowedAPIURL(input?.apiURL);
|
||||
const deviceID = String(input?.deviceID || "").trim();
|
||||
const deviceSecret = String(input?.deviceSecret || "");
|
||||
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 个字符");
|
||||
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");
|
||||
}
|
||||
@@ -130,6 +249,53 @@ ipcMain.handle("sim:probe", async () => {
|
||||
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())),
|
||||
@@ -180,6 +346,22 @@ ipcMain.handle("sim:heartbeat", async (_event, input) => deviceRequest(
|
||||
},
|
||||
));
|
||||
|
||||
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`,
|
||||
@@ -188,11 +370,62 @@ ipcMain.handle("sim:stop-conversation", async (_event, input) => deviceRequest(
|
||||
device_time: new Date().toISOString(),
|
||||
last_heartbeat_sequence: input.lastHeartbeatSequence || 0,
|
||||
rtc_left: true,
|
||||
recording: { near_segments: 0, far_segments: 0 },
|
||||
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"));
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("deviceSimulator", {
|
||||
getRuntimeDefaults: () => ipcRenderer.invoke("sim:get-runtime-defaults"),
|
||||
configure: (input) => ipcRenderer.invoke("sim:configure", input),
|
||||
probe: () => ipcRenderer.invoke("sim:probe"),
|
||||
probeRTCNetwork: () => ipcRenderer.invoke("sim:probe-rtc-network"),
|
||||
getAutomationAudio: () => ipcRenderer.invoke("sim:get-automation-audio"),
|
||||
createConversation: () => ipcRenderer.invoke("sim:create-conversation"),
|
||||
activateConversation: (input) => ipcRenderer.invoke("sim:activate-conversation", input),
|
||||
getConversation: (conversationID) => ipcRenderer.invoke("sim:get-conversation", conversationID),
|
||||
heartbeat: (input) => ipcRenderer.invoke("sim:heartbeat", input),
|
||||
submitTranscriptBatch: (input) => ipcRenderer.invoke("sim:submit-transcript-batch", input),
|
||||
stopConversation: (input) => ipcRenderer.invoke("sim:stop-conversation", input),
|
||||
uploadConversationRecording: (input) => ipcRenderer.invoke("sim:upload-conversation-recording", input),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user