/* 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 = `
πŸ–₯
Launching ${guiConfig.name || guiConfig.command}…
`; 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: 'βœ–' }; // Escape HTML and preserve newlines for multiline error messages const escaped = text.replace(/&/g, '&').replace(//g, '>'); const formatted = escaped.replace(/\n/g, '
'); state.statusEl.innerHTML = `
${icons[type] || 'πŸ–₯'}
${formatted}
`; } // ---- 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), }); }); } // ---- App registration (no left dock β€” apps live in the right panel) ---- 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; guiApps.forEach(app => { if (!app.id || !app.command) return; // Register the app module so openApp('gui-xxx') works from the right panel createGuiApp(app); }); } catch (e) { // Display not available β€” silently skip } } // Expose for dynamic re-registration when apps are added via Settings window._atlusRegisterGuiApp = createGuiApp; // Initialize after a brief delay to ensure Atlus core is ready setTimeout(initGuiApps, 200); })();