301 lines
10 KiB
JavaScript
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">×</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 */ }
|
|
}
|
|
})();
|