74 lines
4.4 KiB
JavaScript
74 lines
4.4 KiB
JavaScript
import { spawn } from 'node:child_process';
|
|
import path from 'node:path';
|
|
|
|
/** Wayland input and ScreenCast boundary. The helper owns portal consent, the
|
|
* EIS fd, and the PipeWire remote; Node sends only bounded JSON actions. */
|
|
export class PortalInputBackend {
|
|
constructor({ command = process.env.JARVIS_EI_HELPER || path.resolve(new URL('./py/portal_remote_desktop.py', import.meta.url).pathname), python = 'python3', spawnImpl = spawn, timeoutMs = 120_000 } = {}) {
|
|
this.command = command; this.python = python; this.spawnImpl = spawnImpl; this.timeoutMs = timeoutMs;
|
|
this.process = null; this.available = false; this.screen = false; this._grant = null; this._frameWait = null;
|
|
}
|
|
grant({ persist = false, monitors = 'focused', mode = 'act' } = {}) {
|
|
if (this._grant) return this._grant;
|
|
const child = this.process = this.spawnImpl(this.python, [this.command], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, JARVIS_CU_MODE: mode } });
|
|
this._grant = new Promise((resolve, reject) => {
|
|
let buffer = ''; let settled = false;
|
|
const timer = setTimeout(() => fail(new Error('portal input consent timed out')), this.timeoutMs);
|
|
const fail = (error) => {
|
|
clearTimeout(timer);
|
|
if (this.process === child) { this.available = false; this.screen = false; this.process = null; this._grant = null; }
|
|
this._rejectFrame(error);
|
|
if (!settled) { settled = true; reject(error); }
|
|
child.kill('SIGTERM');
|
|
};
|
|
this._cancelGrant = () => fail(new Error('portal input grant revoked'));
|
|
child.on('error', fail);
|
|
child.once('close', () => {
|
|
clearTimeout(timer);
|
|
if (this.process === child) { this.available = false; this.screen = false; this.process = null; this._grant = null; }
|
|
this._rejectFrame(new Error('portal helper exited'));
|
|
if (!settled) { settled = true; reject(new Error('portal input helper exited before readiness')); }
|
|
});
|
|
child.stdin?.on('error', fail);
|
|
child.stderr?.on('data', () => {});
|
|
child.stdout.on('data', (data) => {
|
|
buffer += String(data);
|
|
if (buffer.length > 64 * 1024) { fail(new Error('portal helper output too large')); return; }
|
|
let index;
|
|
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
|
|
let event;
|
|
try { event = JSON.parse(line); } catch { continue; }
|
|
if (event.type === 'error') {
|
|
if (!settled) { fail(new Error(event.reason || 'portal input unavailable')); return; }
|
|
this._rejectFrame(new Error(event.reason || 'portal helper error'));
|
|
continue;
|
|
}
|
|
if (event.type === 'frame') { this._resolveFrame(event.path || event); continue; }
|
|
if (event.type === 'ready' && this.process === child && !settled) {
|
|
clearTimeout(timer); settled = true; this.available = true; this.screen = Boolean(event.screen);
|
|
resolve({ restore_token_present: Boolean(event.restore_token_present), monitors, backend: event.backend || 'portal-ei', screen: this.screen });
|
|
}
|
|
}
|
|
});
|
|
});
|
|
return this._grant;
|
|
}
|
|
send(action) { if (!this.available || !this.process?.stdin?.writable) throw new Error('portal EIS input backend is unavailable'); this.process.stdin.write(`${JSON.stringify(action)}\n`); }
|
|
captureFrame(output) {
|
|
if (!this.available || !this.process?.stdin?.writable) throw new Error('PipeWire ScreenCast is unavailable until desktop access is granted');
|
|
if (this._frameWait) throw new Error('a PipeWire frame grab is already in flight');
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => this._rejectFrame(new Error('PipeWire frame timed out')), 8000);
|
|
this._frameWait = {
|
|
resolve: (value) => { clearTimeout(timer); resolve(value); },
|
|
reject: (error) => { clearTimeout(timer); reject(error); },
|
|
};
|
|
try { this.send({ type: 'frame', path: output }); } catch (error) { this._rejectFrame(error); }
|
|
});
|
|
}
|
|
_resolveFrame(path) { const wait = this._frameWait; this._frameWait = null; wait?.resolve(path); }
|
|
_rejectFrame(error) { const wait = this._frameWait; this._frameWait = null; wait?.reject(error); }
|
|
revoke() { this._cancelGrant?.(); this._cancelGrant = null; this.available = false; this.screen = false; this.process = null; this._grant = null; }
|
|
}
|