atlus/frontend/js/apps/display.js
roberts a73b515258 Add native GUI app support via per-window frame streaming
Each configured GUI app (e.g. Nextcloud) gets its own dock icon and
opens as a regular Atlus tab. Under the hood: Xvfb virtual display,
ImageMagick captures individual window pixmaps as JPEG, streams over
WebSocket to a canvas element, with xdotool forwarding mouse/keyboard
input back to the X11 window. Apps persist in background when tab is
closed, and streaming pauses when no viewers are attached.

New files: backend/display.py (DisplayManager + ManagedGuiApp),
backend/routers/display.py (WebSocket + REST), frontend display.js/css.
Config: gui_apps array in settings for registered applications.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 23:09:15 -05:00

329 lines
11 KiB
JavaScript

/* Atlus — GUI App Display module.
*
* Provides per-window frame streaming for native GUI applications.
* Each configured gui_app registers as its own Atlus app (like terminal, files).
* Canvas receives JPEG frames via WebSocket, input forwarded back.
*/
(function () {
'use strict';
// Per-app state: keyed by gui app id (e.g. "nextcloud")
const appState = {};
// ---- Shared canvas/WS infrastructure ----
function createGuiApp(guiConfig) {
const appId = 'gui-' + guiConfig.id;
Atlus.registerApp(appId, {
title: guiConfig.name || guiConfig.command,
init(container) {
container.classList.add('app-gui-display');
const state = {
config: guiConfig,
container: container,
canvas: null,
ctx: null,
ws: null,
serverAppId: null, // backend app_id
status: 'connecting',
};
appState[guiConfig.id] = state;
// Status overlay
const statusEl = document.createElement('div');
statusEl.className = 'gui-status connecting';
statusEl.innerHTML = `
<div class="gui-status-icon">🖥</div>
<div class="gui-status-text">Launching ${guiConfig.name || guiConfig.command}…</div>
`;
container.appendChild(statusEl);
state.statusEl = statusEl;
// Canvas wrapper
const wrap = document.createElement('div');
wrap.className = 'gui-canvas-wrap';
wrap.style.display = 'none';
const canvas = document.createElement('canvas');
canvas.className = 'gui-canvas';
canvas.width = 800;
canvas.height = 600;
canvas.tabIndex = 0;
wrap.appendChild(canvas);
container.appendChild(wrap);
state.wrap = wrap;
state.canvas = canvas;
state.ctx = canvas.getContext('2d');
// Bind input handlers
bindInput(state);
// Launch the app on the server, then connect WS
launchAndConnect(state);
},
destroy() {
const state = appState[guiConfig.id];
if (!state) return;
if (state.ws) {
try { state.ws.close(); } catch (e) {}
state.ws = null;
}
// Don't kill the backend app — it runs in background
delete appState[guiConfig.id];
},
onFocus() {
const state = appState[guiConfig.id];
if (state && state.canvas) {
state.canvas.focus();
}
},
});
}
async function launchAndConnect(state) {
try {
// Launch or get existing app
const res = await Atlus.apiFetch('/api/display/apps', {
method: 'POST',
body: {
command: state.config.command,
title: state.config.name || state.config.command,
args: state.config.args || [],
target_fps: state.config.target_fps || 10,
},
});
if (!res || !res.ok) {
const err = res ? await res.json().catch(() => ({})) : {};
showStatus(state, 'error', err.detail || 'Failed to launch application');
return;
}
const data = await res.json();
state.serverAppId = data.app_id;
// Connect WebSocket
connectWs(state);
} catch (e) {
showStatus(state, 'error', 'Failed to launch: ' + e.message);
}
}
function connectWs(state) {
if (!state.serverAppId) return;
const url = Atlus.wsUrl(`/api/display/ws?app_id=${state.serverAppId}`);
const ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
state.ws = ws;
ws.onopen = () => {
showStatus(state, 'connecting', 'Waiting for window…');
};
ws.onmessage = (e) => {
if (e.data instanceof ArrayBuffer) {
// Binary = JPEG frame
renderFrame(state, e.data);
} else {
// JSON message
try {
const msg = JSON.parse(e.data);
handleMessage(state, msg);
} catch (err) {}
}
};
ws.onclose = () => {
// Don't auto-reconnect — the app tab will re-init if reopened
};
ws.onerror = () => {
showStatus(state, 'error', 'Connection lost');
};
}
function renderFrame(state, buffer) {
// Show canvas, hide status
if (state.status !== 'streaming') {
state.status = 'streaming';
state.statusEl.style.display = 'none';
state.wrap.style.display = 'flex';
state.canvas.focus();
}
const blob = new Blob([buffer], { type: 'image/jpeg' });
const img = new Image();
img.onload = () => {
if (state.canvas.width !== img.width || state.canvas.height !== img.height) {
state.canvas.width = img.width;
state.canvas.height = img.height;
}
state.ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(blob);
}
function handleMessage(state, msg) {
if (msg.type === 'closed') {
showStatus(state, 'exited', msg.data || 'Application exited');
} else if (msg.type === 'error') {
showStatus(state, 'error', msg.data || 'Error');
} else if (msg.type === 'meta') {
// Could update title etc.
}
}
function showStatus(state, type, text) {
state.status = type;
state.wrap.style.display = 'none';
state.statusEl.style.display = 'flex';
state.statusEl.className = 'gui-status ' + type;
const icons = { connecting: '🖥', error: '⚠', exited: '✖' };
state.statusEl.innerHTML = `
<div class="gui-status-icon">${icons[type] || '🖥'}</div>
<div class="gui-status-text">${text}</div>
`;
}
// ---- Input forwarding ----
function bindInput(state) {
const canvas = state.canvas;
function scaleCoords(e) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: Math.round((e.clientX - rect.left) * scaleX),
y: Math.round((e.clientY - rect.top) * scaleY),
};
}
function send(msg) {
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
state.ws.send(JSON.stringify(msg));
}
}
function getModifiers(e) {
const m = [];
if (e.ctrlKey) m.push('Control');
if (e.shiftKey) m.push('Shift');
if (e.altKey) m.push('Alt');
if (e.metaKey) m.push('Meta');
return m;
}
// Mouse events
canvas.addEventListener('mousedown', (e) => {
canvas.focus();
const coords = scaleCoords(e);
send({ type: 'mouse', action: 'click', ...coords, button: e.button + 1 });
});
canvas.addEventListener('dblclick', (e) => {
const coords = scaleCoords(e);
send({ type: 'mouse', action: 'dblclick', ...coords, button: e.button + 1 });
});
canvas.addEventListener('mousemove', (e) => {
// Throttle mousemove to ~30fps
if (state._lastMove && Date.now() - state._lastMove < 33) return;
state._lastMove = Date.now();
const coords = scaleCoords(e);
send({ type: 'mouse', action: 'move', ...coords });
});
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const coords = scaleCoords(e);
send({ type: 'mouse', action: 'scroll', ...coords, delta: e.deltaY > 0 ? 1 : -1 });
}, { passive: false });
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// Keyboard events
canvas.addEventListener('keydown', (e) => {
e.preventDefault();
e.stopPropagation();
send({
type: 'key', action: 'press',
key: e.key, code: e.code,
modifiers: getModifiers(e),
});
});
canvas.addEventListener('keyup', (e) => {
e.preventDefault();
e.stopPropagation();
send({
type: 'key', action: 'release',
key: e.key, code: e.code,
modifiers: getModifiers(e),
});
});
}
// ---- Dynamic dock + app registration ----
async function initGuiApps() {
try {
const res = await Atlus.apiFetch('/api/settings');
if (!res || !res.ok) return;
const cfg = await res.json();
const guiApps = cfg.gui_apps || [];
if (guiApps.length === 0) return;
// Check if display system is available
const statusRes = await Atlus.apiFetch('/api/display/status');
if (!statusRes || !statusRes.ok) return;
const status = await statusRes.json();
if (!status.available) return;
const dockApps = document.querySelector('.dock-apps');
if (!dockApps) return;
// Find the separator to insert before it
const separator = dockApps.querySelector('.dock-separator');
guiApps.forEach(app => {
if (!app.id || !app.command) return;
// Register the app module
createGuiApp(app);
// Create dock button
const btn = document.createElement('button');
btn.className = 'dock-item';
btn.dataset.app = 'gui-' + app.id;
btn.innerHTML = `
<span class="dock-icon">${app.icon || '🖥'}</span>
<span class="dock-label">${app.name || app.command}</span>
`;
btn.addEventListener('click', () => Atlus.openApp('gui-' + app.id));
if (separator) {
dockApps.insertBefore(btn, separator);
} else {
dockApps.appendChild(btn);
}
});
} catch (e) {
// Display not available — silently skip
}
}
// Initialize after a brief delay to ensure Atlus core is ready
setTimeout(initGuiApps, 200);
})();