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

258 lines
14 KiB
JavaScript

import http from 'node:http';
import { randomBytes } from 'node:crypto';
import { spawn } from 'node:child_process';
import QRCode from 'qrcode';
const LOOPBACK = new Set(['127.0.0.1', '::1']);
const SAFE_STATUSES = new Set([
'connecting', 'waiting_for_phone', 'paired', 'reconnecting',
'connection_failed', 'authentication_failed', 'server_unavailable',
'device_already_connected', 'runtime_unavailable', 'network_changed',
'network_change_failed', 'helper_error',
]);
const ERROR_STATUSES = new Set([
'connection_failed', 'authentication_failed', 'server_unavailable',
'device_already_connected', 'runtime_unavailable', 'network_change_failed', 'helper_error',
]);
function isLoopback(socket) {
const address = socket.remoteAddress?.replace(/^::ffff:/, '');
return LOOPBACK.has(address);
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, character => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[character]));
}
function scriptText(value) {
return JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
}
function advertisedEndpoint(invitation) {
try { return new URL(invitation.url).host; }
catch { return 'unavailable'; }
}
function normalizeNetworkOptions(value) {
if (!Array.isArray(value)) return [];
return value.slice(0, 32).map(item => {
const address = typeof item?.address === 'string' ? item.address.trim() : '';
const interfaceName = typeof item?.interfaceName === 'string'
? item.interfaceName.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 64) : '';
if (!address || address.length > 253 || /[\u0000-\u001f\u007f]/.test(address)) return null;
return { address, interfaceName, defaultRoute: item?.defaultRoute === true };
}).filter(Boolean).filter((item, index, values) => values.findIndex(other => other.address === item.address) === index);
}
function readBoundedBody(request, limit = 1024) {
return new Promise((resolve, reject) => {
const chunks = [];
let length = 0;
let settled = false;
request.on('data', chunk => {
if (settled) return;
length += chunk.length;
if (length > limit) {
settled = true;
request.resume();
reject(new Error('request_too_large'));
return;
}
chunks.push(chunk);
});
request.on('end', () => {
if (!settled) { settled = true; resolve(Buffer.concat(chunks).toString('utf8')); }
});
request.on('error', error => { if (!settled) { settled = true; reject(error); } });
});
}
function pageMarkup(invitation, svg, route, addresses, currentAddress) {
const raw = JSON.stringify(invitation);
const title = escapeHtml(invitation.name);
const endpoint = escapeHtml(advertisedEndpoint(invitation));
const options = normalizeNetworkOptions(addresses);
const picker = options.length > 1 ? `<form id="network-form" class="network-form">
<label for="network">Network interface / 网络接口</label><select id="network">${options.map(item =>
`<option value="${escapeHtml(item.address)}"${item.address === currentAddress ? ' selected' : ''}>${escapeHtml(item.interfaceName ? `${item.interfaceName} · ${item.address}` : item.address)}${item.defaultRoute ? ' · default / 默认' : ''}</option>`).join('')}</select>
<button id="network-submit" type="submit">Use this address / 使用此地址</button><small>Changing the network disconnects the phone. Scan the new QR to reconnect. / 切换网络会断开手机连接,请扫描新二维码重新连接。</small></form>` : '';
const encoded = scriptText(raw);
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'self';">
<title>Pebrel · Pair computer</title>
<style>
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #10111a; color: #f5f2ff; }
* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 28px; }
main { width: min(560px, 100%); border: 1px solid #302d4b; border-radius: 22px; background: #171827; padding: 30px; box-shadow: 0 20px 70px #08081099; text-align: center; }
.brand { color: #b9a8ff; letter-spacing: .12em; text-transform: uppercase; font-size: 12px; font-weight: 700; }
h1 { margin: 10px 0 6px; font-size: clamp(24px, 5vw, 34px); }
.name { color: #cbc6da; margin: 0 0 22px; }
.endpoint { color: #b8b1cc; margin: -12px 0 22px; font-size: 13px; }
.endpoint code { color: #f5f2ff; font: inherit; }
.qr { display: inline-grid; place-items: center; padding: 18px; border-radius: 18px; background: #f8f7ff; }
.qr svg { width: min(360px, 70vw); height: auto; display: block; }
.status { min-height: 24px; margin: 22px 0 14px; color: #d5cff1; }
.status[data-state="paired"] { color: #8de0bb; }
.status[data-state="error"] { color: #ff9b9b; }
button { border: 1px solid #4f477a; border-radius: 10px; background: #282344; color: #f5f2ff; font: inherit; padding: 10px 15px; cursor: pointer; }
button:hover { background: #352d5a; }
button:focus-visible, select:focus-visible { outline: 3px solid #b9a8ff; outline-offset: 3px; }
button:disabled { opacity: .55; cursor: wait; }
.network-form { margin: 18px 0; display: grid; gap: 10px; text-align: left; }
.network-form label { font-size: 13px; color: #cbc6da; }
select { width: 100%; min-height: 44px; padding: 10px; border: 1px solid #4f477a; border-radius: 10px; background: #202034; color: #f5f2ff; font: inherit; }
.network-form button { min-height: 44px; }
.network-form small { color: #aaa3bb; line-height: 1.5; }
.hint { color: #9791aa; font-size: 13px; line-height: 1.5; margin: 18px auto 0; max-width: 42ch; }
.copied { min-height: 18px; margin: 8px 0 0; color: #8de0bb; font-size: 13px; }
</style></head><body><main>
<div class="brand">Pebrel</div><h1>连接手机 · Connect phone</h1><p class="name">${title}</p>
<p class="endpoint">Phone address / 手机连接地址: <code>${endpoint}</code></p>
<div class="qr" role="img" aria-label="Pebrel phone pairing QR code">${svg}</div>
<div id="status" class="status" role="status" aria-live="polite">Waiting for the phone…</div>
${picker}
<button id="copy" type="button">复制邀请 · Copy invitation</button><div id="copied" class="copied" aria-live="polite"></div>
<p class="hint">在手机 Pebrel 扫码即可连接。两台设备须位于同一受信任网络。 / Scan in Pebrel on your phone using the same trusted network.</p>
</main><script>
const invite = ${encoded};
const status = document.getElementById('status');
const copied = document.getElementById('copied');
const safeStatuses = ${scriptText([...SAFE_STATUSES])};
const errorStatuses = ${scriptText([...ERROR_STATUSES])};
const labels = {
connecting: 'Connecting to the local relay… · 正在连接本机中转…',
waiting_for_phone: 'Waiting for the phone… · 等待手机连接…',
paired: 'Phone connected · 手机已连接',
reconnecting: 'Reconnecting… · 正在重连…',
connection_failed: 'Connection failed; check the network or firewall. · 连接失败,请检查网络或防火墙。',
authentication_failed: 'Credentials rejected; import a fresh invitation. · 凭据被拒绝,请重新导入配对邀请。',
server_unavailable: 'Relay unavailable; check the server. · 中转服务不可用,请检查服务器。',
device_already_connected: 'Another phone is already connected. · 另一部手机已连接。',
runtime_unavailable: 'Cannot read Pebrel sessions. Reopen Connect phone while Pebrel is running. · 无法读取 Pebrel 会话,请保持 Pebrel 运行后重新打开连接手机。',
network_changed: 'Network changed. Scan this new QR code. · 已切换网络,请重新扫描此二维码。',
network_change_failed: 'Could not switch the network. Check the interface and retry. · 无法切换网络,请检查所选接口后重试。',
helper_error: 'Pairing helper reported an unexpected state. · 配对工具报告了未知状态。',
};
document.getElementById('copy').addEventListener('click', async () => { try { await navigator.clipboard.writeText(invite); copied.textContent = '已复制 · Copied'; } catch { copied.textContent = '复制失败,请重试 · Could not copy'; } });
let switching = false;
document.getElementById('network-form')?.addEventListener('submit', async event => {
event.preventDefault();
if (switching) return;
switching = true;
const button = document.getElementById('network-submit');
button.disabled = true;
status.textContent = '正在切换网络… · Switching network…';
try {
const response = await fetch('${route}/network', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: document.getElementById('network').value }) });
if (!response.ok) throw new Error('network_change_failed');
location.reload();
} catch {
switching = false; button.disabled = false;
status.textContent = labels.network_change_failed; status.dataset.state = 'error';
}
});
async function refresh() { if (switching) return; try { const response = await fetch('${route}/status', { cache: 'no-store' }); if (!response.ok) return; const value = await response.json(); const key = safeStatuses.includes(value.status) ? value.status : 'helper_error'; status.textContent = labels[key]; status.dataset.state = key === 'paired' ? 'paired' : errorStatuses.includes(key) ? 'error' : ''; } catch { /* the helper may be closing */ } }
refresh(); setInterval(refresh, 1000);
</script></body></html>`;
}
/** Serve the QR page on IPv4 loopback only; no LAN interface can read its secret. */
export async function startQrPage(invitation, {
host = '127.0.0.1', port = 0, addresses = [], currentAddress = '', onAddressChange,
} = {}) {
if (host !== '127.0.0.1') throw new Error('qr_page_loopback_only');
const route = `/pair/${randomBytes(18).toString('base64url')}`;
const makeSvg = value => QRCode.toString(JSON.stringify(value), {
type: 'svg', margin: 2, width: 360, errorCorrectionLevel: 'M',
color: { dark: '#171827', light: '#f8f7ff' },
});
let svg = await makeSvg(invitation);
let currentStatus = 'connecting';
let options = normalizeNetworkOptions(addresses);
let changing = false;
const server = http.createServer(async (request, response) => {
const listener = server.address();
if (!listener) { response.writeHead(503); response.end(); return; }
const origin = `http://127.0.0.1:${listener.port}`;
response.setHeader('cache-control', 'no-store');
response.setHeader('x-content-type-options', 'nosniff');
if (!isLoopback(request.socket) || request.headers.host !== new URL(origin).host) {
response.writeHead(403); response.end(); return;
}
let url;
try { url = new URL(request.url ?? '/', origin); }
catch { response.writeHead(400); response.end(); return; }
if (request.method === 'POST' && url.pathname === `${route}/network`) {
if (typeof onAddressChange !== 'function' || request.headers.origin !== origin ||
request.headers['content-type']?.split(';')[0].trim() !== 'application/json') {
response.writeHead(403); response.end(); return;
}
if (changing) { response.writeHead(409); response.end(); return; }
changing = true;
try {
const body = JSON.parse(await readBoundedBody(request));
if (!options.some(item => item.address === body.address)) throw new Error('invalid_network');
const result = await onAddressChange(body.address);
const nextSvg = await makeSvg(result.invitation);
invitation = result.invitation;
svg = nextSvg;
currentAddress = result.address;
options = normalizeNetworkOptions(result.addresses);
currentStatus = 'network_changed';
response.writeHead(204); response.end();
} catch {
currentStatus = 'network_change_failed';
response.writeHead(400); response.end();
} finally { changing = false; }
return;
}
if (request.method !== 'GET' || (url.pathname !== route && url.pathname !== `${route}/status`)) {
response.writeHead(404); response.end(); return;
}
if (url.pathname === `${route}/status`) {
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
response.end(JSON.stringify({ status: currentStatus })); return;
}
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
response.end(pageMarkup(invitation, svg, route, options, currentAddress));
});
server.maxConnections = 8;
server.requestTimeout = 5_000;
server.headersTimeout = 5_000;
await new Promise((resolve, reject) => {
const fail = error => { server.off('listening', resolve); reject(error); };
server.once('error', fail);
server.listen(port, host, () => { server.off('error', fail); resolve(); });
});
const actualPort = server.address().port;
return {
server, route, url: `http://127.0.0.1:${actualPort}${route}`,
setStatus(value) {
const next = SAFE_STATUSES.has(value) ? value : 'helper_error';
if (changing) return;
if (['reconnecting', 'connecting', 'waiting_for_phone'].includes(next) && ERROR_STATUSES.has(currentStatus)) return;
if (currentStatus === 'network_changed' && ['connecting', 'waiting_for_phone'].includes(next)) return;
currentStatus = next;
},
close: async () => { await new Promise(resolve => server.close(() => resolve())); },
};
}
/** Open the local page without invoking a shell or interpolating user input. */
export function openBrowser(url) {
let command;
let args;
if (process.platform === 'win32') { command = 'cmd.exe'; args = ['/c', 'start', '', url]; }
else if (process.platform === 'darwin') { command = 'open'; args = [url]; }
else { command = 'xdg-open'; args = [url]; }
const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
child.on('error', () => {});
child.unref();
return child;
}