atlus/frontend/js/apps/xdisplay.js
2026-03-14 22:41:00 -05:00

301 lines
10 KiB
JavaScript

/* Atlus — X11 Display app (noVNC viewer for virtual desktop) */
(function () {
'use strict';
let container = null;
let iframeEl = null;
let statusEl = null;
let toolbarEl = null;
let wsPort = null;
let displayStarted = false;
// ---- Toolbar buttons state ----
let appListEl = null;
let refreshTimer = null;
// ---- Known launchable apps ----
const KNOWN_APPS = [
{ command: 'nextcloud', name: 'Nextcloud', has_tray_icon: true },
];
// ---- Display management ----
async function startDisplay() {
setStatus('Starting display…', 'info');
try {
const res = await Atlus.apiFetch('/api/display/start', { method: 'POST' });
if (!res || !res.ok) {
const err = res ? await res.json().catch(() => ({ detail: 'Unknown error' })) : { detail: 'No response' };
setStatus(err.detail || 'Failed to start display', 'error');
return false;
}
const data = await res.json();
wsPort = data.ws_port;
displayStarted = true;
return true;
} catch (e) {
setStatus('Failed to start display: ' + e.message, 'error');
return false;
}
}
async function connectViewer() {
if (!displayStarted) {
const ok = await startDisplay();
if (!ok) return;
}
setStatus('Connecting to display…', 'info');
// Use noVNC served by websockify
// The websockify --web flag serves noVNC files
// Try direct websockify noVNC endpoint first
const host = location.hostname;
const novncUrl = `http://${host}:${wsPort}/vnc.html?host=${host}&port=${wsPort}&autoconnect=true&resize=scale&reconnect=true&reconnect_delay=1000&view_only=false`;
if (iframeEl) {
iframeEl.remove();
}
iframeEl = document.createElement('iframe');
iframeEl.className = 'xdisplay-iframe';
iframeEl.src = novncUrl;
iframeEl.setAttribute('allowfullscreen', 'true');
iframeEl.onload = () => {
statusEl.classList.add('hidden');
};
iframeEl.onerror = () => {
// If noVNC files aren't served by websockify, fallback to raw WebSocket viewer
setStatus('noVNC not available — install novnc package', 'error');
};
const viewerArea = container.querySelector('.xdisplay-viewer');
viewerArea.appendChild(iframeEl);
}
// ---- App launcher ----
async function launchApp(command, args, name, hasTrayIcon) {
try {
const res = await Atlus.apiFetch('/api/display/apps/launch', {
method: 'POST',
body: { command, args: args || [], name: name || command, has_tray_icon: hasTrayIcon || false },
});
if (!res || !res.ok) {
const err = res ? await res.json().catch(() => ({})) : {};
console.error('Failed to launch app:', err.detail || 'unknown');
return null;
}
const data = await res.json();
refreshAppList();
return data;
} catch (e) {
console.error('Failed to launch app:', e);
return null;
}
}
async function stopApp(appId) {
try {
await Atlus.apiFetch(`/api/display/apps/${appId}`, { method: 'DELETE' });
} catch (e) { /* best effort */ }
refreshAppList();
}
async function refreshAppList() {
if (!appListEl) return;
try {
const res = await Atlus.apiFetch('/api/display/apps');
if (!res || !res.ok) return;
const data = await res.json();
renderAppList(data.apps || []);
} catch (e) { /* ignore */ }
}
function renderAppList(apps) {
if (!appListEl) return;
appListEl.innerHTML = '';
if (apps.length === 0) {
appListEl.innerHTML = '<span class="xdisplay-no-apps">No apps running</span>';
return;
}
apps.forEach(app => {
const item = document.createElement('div');
item.className = 'xdisplay-app-item';
item.innerHTML = `
<span class="xdisplay-app-name">${app.name}</span>
<button class="xdisplay-app-stop" data-id="${app.app_id}" title="Stop">&times;</button>
`;
item.querySelector('.xdisplay-app-stop').addEventListener('click', (e) => {
e.stopPropagation();
stopApp(app.app_id);
});
appListEl.appendChild(item);
});
}
// ---- Status ----
function setStatus(message, type) {
if (!statusEl) return;
statusEl.textContent = message;
statusEl.className = 'xdisplay-status';
if (type) statusEl.classList.add(type);
statusEl.classList.remove('hidden');
}
// ---- App registration ----
Atlus.registerApp('xdisplay', {
title: 'Display',
init(el) {
container = el;
container.classList.add('app-xdisplay');
// Toolbar
toolbarEl = document.createElement('div');
toolbarEl.className = 'xdisplay-toolbar';
// Launch dropdown
const launchGroup = document.createElement('div');
launchGroup.className = 'xdisplay-launch-group';
const launchBtn = document.createElement('button');
launchBtn.className = 'xdisplay-btn xdisplay-launch-btn';
launchBtn.textContent = '+ Launch App';
const launchMenu = document.createElement('div');
launchMenu.className = 'xdisplay-launch-menu hidden';
KNOWN_APPS.forEach(app => {
const item = document.createElement('button');
item.className = 'xdisplay-launch-menu-item';
item.textContent = app.name;
item.addEventListener('click', () => {
launchMenu.classList.add('hidden');
launchApp(app.command, [], app.name, app.has_tray_icon);
});
launchMenu.appendChild(item);
});
// Custom app option
const customItem = document.createElement('button');
customItem.className = 'xdisplay-launch-menu-item';
customItem.textContent = 'Custom…';
customItem.addEventListener('click', () => {
launchMenu.classList.add('hidden');
const cmd = prompt('Enter command to launch:');
if (cmd && cmd.trim()) {
const parts = cmd.trim().split(/\s+/);
launchApp(parts[0], parts.slice(1), parts[0], false);
}
});
launchMenu.appendChild(customItem);
launchBtn.addEventListener('click', () => {
launchMenu.classList.toggle('hidden');
});
// Close menu on outside click
document.addEventListener('click', (e) => {
if (!launchGroup.contains(e.target)) {
launchMenu.classList.add('hidden');
}
});
launchGroup.appendChild(launchBtn);
launchGroup.appendChild(launchMenu);
toolbarEl.appendChild(launchGroup);
// Running apps list
appListEl = document.createElement('div');
appListEl.className = 'xdisplay-app-list';
toolbarEl.appendChild(appListEl);
// Connection button (right side)
const connectBtn = document.createElement('button');
connectBtn.className = 'xdisplay-btn xdisplay-connect-btn';
connectBtn.textContent = 'Connect';
connectBtn.addEventListener('click', () => connectViewer());
toolbarEl.appendChild(connectBtn);
container.appendChild(toolbarEl);
// Status
statusEl = document.createElement('div');
statusEl.className = 'xdisplay-status';
statusEl.textContent = 'Click Connect to start the virtual display';
// Viewer area
const viewerArea = document.createElement('div');
viewerArea.className = 'xdisplay-viewer';
viewerArea.appendChild(statusEl);
container.appendChild(viewerArea);
// Check if display is already running
checkExistingDisplay();
// Poll running apps every 5 seconds
refreshTimer = setInterval(refreshAppList, 5000);
},
destroy() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
if (iframeEl) {
iframeEl.remove();
iframeEl = null;
}
container = null;
toolbarEl = null;
statusEl = null;
appListEl = null;
},
onFocus() {
refreshAppList();
},
/** Public API: launch an app from outside (e.g., panel tray click) */
launchApp(command, args, name, hasTrayIcon) {
return launchApp(command, args, name, hasTrayIcon);
},
/** Public API: connect/show the viewer */
showViewer() {
if (!displayStarted) {
connectViewer();
}
},
});
async function checkExistingDisplay() {
try {
const res = await Atlus.apiFetch('/api/display/status');
if (!res || !res.ok) return;
const data = await res.json();
if (data.missing && data.missing.length > 0) {
setStatus(
`Missing packages: ${data.missing.join(', ')}. Install with: sudo apt install ${data.missing.map(n => n.toLowerCase()).join(' ')}`,
'error'
);
return;
}
if (data.session && data.session.started) {
wsPort = data.session.ws_port;
displayStarted = true;
connectViewer();
refreshAppList();
}
} catch (e) { /* ignore */ }
}
})();