Files
Kuddev bf2ed9af9b feat(mobile): integrate remote terminal, SSH and reader workflows (#343)
Integrate the Android client with native desktop pairing, remote terminal and SSH/SFTP sessions, keyboard controls, file reading and Agent conversations. Preserve authenticated connection ownership and per-device input permissions.

Validation: all ten required GitHub checks passed, including five native test platforms and both macOS release workspace checks.
2026-09-29 20:09:00 +08:00

117 lines
5.5 KiB
JavaScript

import { readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import { WebSocket } from 'ws';
import { MAX_ENVELOPE, MAX_BUFFERED, devicePattern, tokenPattern, ready } from './protocol.mjs';
import { RuntimeLink, discoverEndpoint } from './runtime-link.mjs';
import { pinnedTlsOptions, validateTlsPin } from './tls.mjs';
/** Outbound-only desktop connector; Windows/Linux/macOS need no inbound port. */
export function connectDesktop(config, options = {}) {
const url = new URL(config.url);
const loopback = ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(url.hostname);
if (url.protocol !== 'wss:' && !(options.allowLoopback && loopback && url.protocol === 'ws:')) throw new Error('wss_required');
if (url.username || url.password || url.search || url.hash || !devicePattern.test(config.device) || !tokenPattern.test(config.token)) throw new Error('invalid_connection');
const tlsPin = options.tlsPin ?? config.tlsPin;
if (tlsPin) validateTlsPin(tlsPin);
url.pathname = '/v1/link'; url.searchParams.set('device', config.device); url.searchParams.set('role', 'desktop');
let stopped = false;
let socket;
let runtime;
let link;
let reconnect;
let attempts = 0;
let runtimeUnavailable = false;
const status = options.onStatus ?? (() => {});
const release = () => { runtime?.close(); runtime = null; link = null; };
const send = body => {
if (!link || !socket || socket.readyState !== WebSocket.OPEN || socket.bufferedAmount > MAX_BUFFERED) {
socket?.terminate(); return;
}
const bytes = JSON.stringify({ type: 'relay.data', link, body });
if (Buffer.byteLength(bytes) > MAX_ENVELOPE) { socket.terminate(); return; }
socket.send(bytes);
};
const reportRuntimeUnavailable = () => {
if (runtimeUnavailable) return;
runtimeUnavailable = true;
status('runtime_unavailable');
// Keep this diagnostic deliberately bounded. It is consumed by Android and
// must never carry a filesystem error, token, or other local detail.
send({ type: 'mobile.disconnected', code: 'runtime_unavailable' });
socket.close(1008, 'runtime_unavailable');
};
const start = () => {
if (stopped) return;
runtimeUnavailable = false;
status('connecting');
socket = new WebSocket(url, { headers: { Authorization: `Bearer ${config.token}` },
maxPayload: MAX_ENVELOPE, perMessageDeflate: false, handshakeTimeout: 15_000,
...pinnedTlsOptions({ pin: tlsPin, certificate: options.tlsCert ?? config.tlsCert }) });
let heartbeat;
let lastReceived = Date.now();
socket.on('open', () => {
status('waiting_for_phone');
heartbeat = setInterval(() => {
if (Date.now() - lastReceived > 90_000) socket.terminate();
}, 30_000);
heartbeat.unref();
});
socket.on('ping', () => { lastReceived = Date.now(); });
socket.on('message', bytes => {
lastReceived = Date.now();
try {
const frame = JSON.parse(bytes.toString());
if (frame.type === 'relay.paired') {
release(); link = frame.link; attempts = 0;
let endpoint;
try {
endpoint = options.endpoint ?? discoverEndpoint(config.runtimeFile);
} catch {
reportRuntimeUnavailable();
return;
}
runtime = new RuntimeLink(endpoint, options.allowInput === true, send, () => {
reportRuntimeUnavailable();
release();
});
send(ready(options.allowInput === true)); status('paired');
} else if (frame.type === 'relay.peer_left') { release(); status('waiting_for_phone'); }
else if (frame.type === 'relay.data' && frame.link === link) runtime?.request(frame.body);
else if (frame.type !== 'relay.waiting') throw new Error('invalid_frame');
} catch {
if (!runtimeUnavailable) status('connection_failed');
socket.close(1008, 'protocol_error');
}
});
socket.on('unexpected-response', (_request, response) => {
// Rejected/revoked credentials require user action, never an endless retry storm.
if ([401, 403, 409].includes(response.statusCode)) {
status(response.statusCode === 409 ? 'device_already_connected' : 'authentication_failed');
stopped = true;
} else status('server_unavailable');
response.resume(); socket.terminate();
});
socket.on('error', () => { if (!runtimeUnavailable) status('connection_failed'); });
socket.on('close', () => {
clearInterval(heartbeat); release();
if (!stopped) {
// Leave the actionable Runtime diagnosis visible until the next retry
// starts. Generic network failures retain the existing reconnect state.
if (!runtimeUnavailable) status('reconnecting');
const ceiling = Math.min(30_000, 1000 * 2 ** Math.min(attempts++, 5));
reconnect = setTimeout(start, ceiling / 2 + Math.random() * ceiling / 2);
}
});
};
start();
return { close() { stopped = true; clearTimeout(reconnect); release(); socket?.terminate(); } };
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const index = process.argv.indexOf('--config');
if (index < 0 || !process.argv[index + 1]) throw new Error('Usage: node connector.mjs --config computer.json [--allow-input]');
const config = JSON.parse(readFileSync(process.argv[index + 1], 'utf8'));
const connector = connectDesktop(config, { allowInput: process.argv.includes('--allow-input'), onStatus: value => console.log(value) });
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { connector.close(); });
}