263 lines
9.6 KiB
JavaScript
263 lines
9.6 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import process from 'node:process';
|
|
import WebSocket from 'ws';
|
|
|
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
const MIN_TOKEN_LENGTH = 32;
|
|
const MAX_TOKEN_LENGTH = 200;
|
|
|
|
function fail(message) {
|
|
console.error(`tauri-bootstrap-token: ${message}`);
|
|
process.exitCode = 1;
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
port: null,
|
|
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
selfTest: false,
|
|
allowLegacyUi: false,
|
|
};
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const argument = argv[index];
|
|
if (argument === '--self-test') {
|
|
options.selfTest = true;
|
|
continue;
|
|
}
|
|
if (argument === '--allow-legacy-ui') {
|
|
options.allowLegacyUi = true;
|
|
continue;
|
|
}
|
|
if (argument === '--port' || argument === '--timeout-ms') {
|
|
const value = argv[index + 1];
|
|
index += 1;
|
|
if (value === undefined) throw new Error(`${argument} requires a value`);
|
|
if (argument === '--port') options.port = Number(value);
|
|
else options.timeoutMs = Number(value);
|
|
continue;
|
|
}
|
|
if (argument.startsWith('--port=')) options.port = Number(argument.slice(7));
|
|
else if (argument.startsWith('--timeout-ms=')) options.timeoutMs = Number(argument.slice(13));
|
|
else throw new Error(`unknown argument: ${argument}`);
|
|
}
|
|
if (!Number.isInteger(options.port) || options.port < 1024 || options.port > 65535) {
|
|
throw new Error('port must be an integer between 1024 and 65535');
|
|
}
|
|
if (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 1000 || options.timeoutMs > 300_000) {
|
|
throw new Error('timeout-ms must be an integer between 1000 and 300000');
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function isValidEndpoint(value) {
|
|
return value !== null
|
|
&& typeof value === 'object'
|
|
&& Number.isInteger(value.port)
|
|
&& value.port > 0
|
|
&& value.port <= 65535
|
|
&& typeof value.instanceId === 'string'
|
|
&& value.instanceId.trim().length > 0
|
|
&& typeof value.bootstrapToken === 'string'
|
|
&& value.bootstrapToken.length >= MIN_TOKEN_LENGTH
|
|
&& value.bootstrapToken.length <= MAX_TOKEN_LENGTH;
|
|
}
|
|
|
|
function isValidDesktopRuntime(value, allowLegacyUi = false) {
|
|
return isValidEndpoint(value)
|
|
&& value.uiReady === true
|
|
&& value.uiStartupState === (allowLegacyUi ? 'legacy-ready' : 'ready')
|
|
&& typeof value.uiPath === 'string'
|
|
&& value.uiPath.startsWith('/')
|
|
&& Number.isInteger(value.uiTextLength)
|
|
&& value.uiTextLength > 0;
|
|
}
|
|
|
|
async function fetchTargets(port) {
|
|
const response = await fetch(`http://127.0.0.1:${port}/json/list`);
|
|
if (!response.ok) throw new Error(`WebView debug endpoint returned HTTP ${response.status}`);
|
|
const targets = await response.json();
|
|
if (!Array.isArray(targets)) throw new Error('WebView debug endpoint returned an invalid target list');
|
|
return targets.filter((target) => target?.type === 'page' && typeof target.webSocketDebuggerUrl === 'string');
|
|
}
|
|
|
|
async function evaluateEnsureService(webSocketUrl, timeoutMs, allowLegacyUi) {
|
|
const socket = new WebSocket(webSocketUrl);
|
|
let nextId = 1;
|
|
const pending = new Map();
|
|
const timer = setTimeout(() => {
|
|
for (const pendingRequest of pending.values()) pendingRequest.reject(new Error('CDP request timed out'));
|
|
pending.clear();
|
|
socket.close();
|
|
}, timeoutMs);
|
|
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
const openTimer = setTimeout(() => reject(new Error('CDP WebSocket open timed out')), timeoutMs);
|
|
socket.addEventListener('open', () => {
|
|
clearTimeout(openTimer);
|
|
resolve();
|
|
}, { once: true });
|
|
socket.addEventListener('error', () => {
|
|
clearTimeout(openTimer);
|
|
reject(new Error('CDP WebSocket connection failed'));
|
|
}, { once: true });
|
|
});
|
|
|
|
socket.addEventListener('message', (event) => {
|
|
let message;
|
|
try {
|
|
message = JSON.parse(String(event.data));
|
|
} catch {
|
|
return;
|
|
}
|
|
const request = pending.get(message.id);
|
|
if (!request) return;
|
|
pending.delete(message.id);
|
|
if (message.error) request.reject(new Error('Tauri IPC evaluation failed'));
|
|
else request.resolve(message.result);
|
|
});
|
|
|
|
const result = await new Promise((resolve, reject) => {
|
|
const id = nextId;
|
|
nextId += 1;
|
|
pending.set(id, { resolve, reject });
|
|
socket.send(JSON.stringify({
|
|
id,
|
|
method: 'Runtime.evaluate',
|
|
params: {
|
|
awaitPromise: true,
|
|
returnByValue: true,
|
|
expression: `
|
|
(async () => {
|
|
const invoke = globalThis.__TAURI_INTERNALS__?.invoke;
|
|
if (typeof invoke !== 'function') throw new Error('Tauri IPC is unavailable');
|
|
const endpoint = await invoke('ensure_service');
|
|
const deadline = Date.now() + 10_000;
|
|
for (;;) {
|
|
const root = document.getElementById('root');
|
|
const startup = root?.querySelector('[data-waggle-startup]');
|
|
const startupState = startup?.getAttribute('data-waggle-startup') ?? null;
|
|
const readyState = root?.getAttribute('data-waggle-ui-ready') ?? null;
|
|
const uiTextLength = root?.innerText?.trim().length ?? 0;
|
|
const strictUiReady = Boolean(
|
|
root
|
|
&& root.childElementCount > 0
|
|
&& !startup
|
|
&& readyState === 'ready'
|
|
&& uiTextLength > 0
|
|
);
|
|
const legacyUiReady = Boolean(
|
|
root
|
|
&& root.childElementCount > 0
|
|
&& !startup
|
|
&& uiTextLength > 0
|
|
);
|
|
const uiReady = ${allowLegacyUi ? 'legacyUiReady' : 'strictUiReady'};
|
|
if (uiReady || startupState === 'failed' || Date.now() >= deadline) {
|
|
return {
|
|
...endpoint,
|
|
uiPath: globalThis.location?.pathname ?? '',
|
|
uiReady,
|
|
uiStartupState: ${allowLegacyUi ? "uiReady ? 'legacy-ready' : (readyState ?? startupState)" : 'readyState ?? startupState'},
|
|
uiTextLength,
|
|
};
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
})()
|
|
`,
|
|
},
|
|
}));
|
|
});
|
|
const remote = result?.result?.value;
|
|
if (!isValidEndpoint(remote)) throw new Error('Tauri returned an invalid service endpoint');
|
|
if (!isValidDesktopRuntime(remote, allowLegacyUi)) {
|
|
throw new Error('Waggle UI did not render its application shell');
|
|
}
|
|
return {
|
|
bootstrapToken: remote.bootstrapToken,
|
|
uiPath: remote.uiPath,
|
|
uiReady: remote.uiReady,
|
|
uiStartupState: remote.uiStartupState,
|
|
uiTextLength: remote.uiTextLength,
|
|
};
|
|
} finally {
|
|
clearTimeout(timer);
|
|
socket.close();
|
|
}
|
|
}
|
|
|
|
async function resolveToken(port, timeoutMs, allowLegacyUi) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastError = 'WebView target not ready';
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const targets = await fetchTargets(port);
|
|
for (const target of targets) {
|
|
try {
|
|
return await evaluateEnsureService(
|
|
target.webSocketDebuggerUrl,
|
|
Math.min(15_000, deadline - Date.now()),
|
|
allowLegacyUi,
|
|
);
|
|
} catch (error) {
|
|
lastError = error instanceof Error ? error.message : 'CDP evaluation failed';
|
|
}
|
|
}
|
|
} catch (error) {
|
|
lastError = error instanceof Error ? error.message : 'WebView debug endpoint unavailable';
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
}
|
|
throw new Error(lastError);
|
|
}
|
|
|
|
async function main() {
|
|
if (process.argv.includes('--self-test')) {
|
|
if (typeof WebSocket !== 'function') throw new Error('WebSocket client is unavailable');
|
|
const valid = 'a'.repeat(MIN_TOKEN_LENGTH);
|
|
if (!isValidEndpoint({ port: 3333, instanceId: 'test-instance', bootstrapToken: valid })) {
|
|
throw new Error('valid endpoint fixture rejected');
|
|
}
|
|
if (isValidEndpoint({ port: 3333, instanceId: 'test-instance', bootstrapToken: 'short' })) {
|
|
throw new Error('short token fixture accepted');
|
|
}
|
|
const validRuntime = {
|
|
port: 3333,
|
|
instanceId: 'test-instance',
|
|
bootstrapToken: valid,
|
|
uiPath: '/home',
|
|
uiReady: true,
|
|
uiStartupState: 'ready',
|
|
uiTextLength: 10,
|
|
};
|
|
if (!isValidDesktopRuntime(validRuntime)) throw new Error('valid desktop runtime rejected');
|
|
if (isValidDesktopRuntime({ ...validRuntime, uiReady: false })) {
|
|
throw new Error('blank desktop runtime accepted');
|
|
}
|
|
if (isValidDesktopRuntime({ ...validRuntime, uiTextLength: 0 })) {
|
|
throw new Error('empty desktop runtime accepted');
|
|
}
|
|
if (isValidDesktopRuntime({ ...validRuntime, uiStartupState: 'loading' })) {
|
|
throw new Error('loading desktop runtime accepted');
|
|
}
|
|
if (isValidDesktopRuntime({ ...validRuntime, uiStartupState: 'failed' })) {
|
|
throw new Error('failed desktop runtime accepted');
|
|
}
|
|
if (!isValidDesktopRuntime({ ...validRuntime, uiStartupState: 'legacy-ready' }, true)) {
|
|
throw new Error('valid legacy desktop runtime rejected');
|
|
}
|
|
if (isValidDesktopRuntime({ ...validRuntime, uiReady: false, uiStartupState: 'legacy-ready' }, true)) {
|
|
throw new Error('blank legacy desktop runtime accepted');
|
|
}
|
|
console.log(JSON.stringify({ pass: true, cases: 9 }));
|
|
return;
|
|
}
|
|
const options = parseArgs(process.argv.slice(2));
|
|
const runtime = await resolveToken(options.port, options.timeoutMs, options.allowLegacyUi);
|
|
console.log(JSON.stringify(runtime));
|
|
}
|
|
|
|
main().catch((error) => fail(error instanceof Error ? error.message : 'unknown failure'));
|