/* Atlus — Core shell: app switching, WebSocket stats, panel updates */ (function () { 'use strict'; // ===================================================================== // Auth guard // ===================================================================== const TOKEN = localStorage.getItem('atlus_token'); const USER = localStorage.getItem('atlus_user'); if (!TOKEN) { window.location.href = '/'; return; } // ===================================================================== // Globals // ===================================================================== window.Atlus = { token: TOKEN, user: USER, apps: {}, // registered app modules { id: { init, destroy, title } } openApps: [], // ordered list of open app ids activeApp: null, // currently focused app id layout: 'single', // 'single' | 'split' secondaryApp: null, /** Authenticated fetch wrapper */ async apiFetch(url, opts = {}) { opts.headers = Object.assign({ 'Authorization': `Bearer ${TOKEN}` }, opts.headers || {}); if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(opts.body); } const res = await fetch(url, opts); if (res.status === 401) { localStorage.removeItem('atlus_token'); localStorage.removeItem('atlus_user'); window.location.href = '/'; return; } return res; }, /** Create an authenticated WebSocket URL */ wsUrl(path) { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const sep = path.includes('?') ? '&' : '?'; return `${proto}//${location.host}${path}${sep}token=${TOKEN}`; }, /** Register an app module */ registerApp(id, module) { this.apps[id] = module; }, /** Format bytes to human readable */ formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; }, }; // ===================================================================== // DOM refs // ===================================================================== const $ = (sel) => document.querySelector(sel); const $$ = (sel) => document.querySelectorAll(sel); const stageTabs = $('#stageTabs'); const paneA = $('#paneContentA'); const paneB = $('#paneContentB'); const paneTitleA = $('#paneTitleA'); const paneTitleB = $('#paneTitleB'); const paneBEl = $('#paneB'); const welcomeScreen = $('#welcomeScreen'); // ===================================================================== // App switching // ===================================================================== function openApp(appId) { const app = Atlus.apps[appId]; if (!app) return; // Already open — just focus if (Atlus.openApps.includes(appId)) { focusApp(appId); return; } Atlus.openApps.push(appId); addTab(appId, app.title || appId); // Create app container const container = document.createElement('div'); container.className = 'app-view'; container.id = `app-${appId}`; container.style.display = 'none'; paneA.appendChild(container); // Initialize app if (app.init) app.init(container); focusApp(appId); saveDesktopState(); } function focusApp(appId) { if (welcomeScreen) welcomeScreen.style.display = 'none'; // Hide all apps in pane A paneA.querySelectorAll('.app-view').forEach(el => el.style.display = 'none'); // Show target const target = $(`#app-${appId}`); if (target) target.style.display = 'flex'; // Update tabs stageTabs.querySelectorAll('.stage-tab').forEach(tab => { tab.classList.toggle('active', tab.dataset.app === appId); }); // Update dock $$('.dock-item[data-app]').forEach(item => { item.classList.toggle('active', item.dataset.app === appId); }); // Update titlebar const app = Atlus.apps[appId]; paneTitleA.textContent = app ? app.title : appId; Atlus.activeApp = appId; // Notify app it got focus if (app && app.onFocus) app.onFocus(); saveDesktopState(); } function closeApp(appId) { const app = Atlus.apps[appId]; if (app && app.destroy) app.destroy(); // Remove DOM const container = $(`#app-${appId}`); if (container) container.remove(); // Remove from open list Atlus.openApps = Atlus.openApps.filter(id => id !== appId); // Remove tab const tab = stageTabs.querySelector(`.stage-tab[data-app="${appId}"]`); if (tab) tab.remove(); // Update dock const dockItem = $(`.dock-item[data-app="${appId}"]`); if (dockItem) dockItem.classList.remove('active'); // Focus next app or show welcome if (Atlus.activeApp === appId) { if (Atlus.openApps.length > 0) { focusApp(Atlus.openApps[Atlus.openApps.length - 1]); } else { Atlus.activeApp = null; paneTitleA.textContent = ''; if (welcomeScreen) welcomeScreen.style.display = 'flex'; } } saveDesktopState(); } function addTab(appId, title) { const tab = document.createElement('button'); tab.className = 'stage-tab'; tab.dataset.app = appId; tab.innerHTML = `${title}×`; tab.addEventListener('click', (e) => { if (e.target.classList.contains('tab-close')) { closeApp(appId); } else { focusApp(appId); } }); stageTabs.appendChild(tab); } // ===================================================================== // Dock clicks // ===================================================================== $$('.dock-item[data-app]').forEach(item => { item.addEventListener('click', () => openApp(item.dataset.app)); }); // Layout toggle $$('.layout-btn').forEach(btn => { btn.addEventListener('click', () => { const layout = btn.dataset.layout; Atlus.layout = layout; $$('.layout-btn').forEach(b => b.classList.toggle('active', b.dataset.layout === layout)); paneBEl.classList.toggle('hidden', layout === 'single'); }); }); // ===================================================================== // System menu // ===================================================================== const systemMenu = $('#systemMenu'); const logoBtn = $('.dock-logo'); logoBtn.addEventListener('click', () => { systemMenu.classList.toggle('hidden'); }); systemMenu.addEventListener('click', async (e) => { const action = e.target.dataset.action; if (!action) { // Clicked backdrop if (e.target === systemMenu) systemMenu.classList.add('hidden'); return; } systemMenu.classList.add('hidden'); if (action === 'logout') { await Atlus.apiFetch('/api/auth/logout', { method: 'POST' }); localStorage.removeItem('atlus_token'); localStorage.removeItem('atlus_user'); window.location.href = '/'; } }); // ===================================================================== // Panel — Clock // ===================================================================== function updateClock() { const now = new Date(); $('#panelDate').textContent = now.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); $('#panelTime').textContent = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); } updateClock(); setInterval(updateClock, 1000); // ===================================================================== // Panel — WebSocket stats // ===================================================================== let statsWs = null; function connectStats() { statsWs = new WebSocket(Atlus.wsUrl('/api/stats/ws')); statsWs.onmessage = (e) => { const data = JSON.parse(e.data); updatePanel(data); }; statsWs.onclose = () => { setTimeout(connectStats, 3000); }; statsWs.onerror = () => { statsWs.close(); }; } function updatePanel(data) { // CPU const cpuPct = Math.round(data.cpu_percent); $('#statCpu').textContent = cpuPct + '%'; updateBar($('#statCpuBar'), cpuPct); // Memory const memPct = Math.round(data.memory.percent); const memUsed = Atlus.formatBytes(data.memory.used); const memTotal = Atlus.formatBytes(data.memory.total); $('#statMem').textContent = `${memUsed} / ${memTotal}`; updateBar($('#statMemBar'), memPct); // Disk const diskPct = Math.round(data.disk.percent); $('#statDisk').textContent = diskPct + '%'; updateBar($('#statDiskBar'), diskPct); // Temp if (data.cpu_temp !== null) { const temp = Math.round(data.cpu_temp); $('#statTemp').textContent = temp + '\u00B0C'; // Temp bar: 0-85°C range const tempPct = Math.min(100, Math.round((temp / 85) * 100)); updateBar($('#statTempBar'), tempPct); } // Network const netContainer = $('#panelNetwork'); netContainer.innerHTML = ''; const ifaces = data.network.interfaces; for (const [name, info] of Object.entries(ifaces)) { const item = document.createElement('div'); item.className = 'panel-net-item'; item.innerHTML = ` ${name} ${info.ipv4 || '--'} `; netContainer.appendChild(item); } } function updateBar(barEl, percent) { barEl.style.width = percent + '%'; barEl.className = 'stat-bar-fill'; if (percent >= 90) barEl.classList.add('crit'); else if (percent >= 70) barEl.classList.add('warn'); } // ===================================================================== // Panel — Hostname // ===================================================================== async function loadHostname() { try { const res = await Atlus.apiFetch('/api/settings/system'); if (res.ok) { const data = await res.json(); $('#panelHostname').textContent = data.hostname; } } catch (e) { /* ignore */ } } // ===================================================================== // Panel — Services // ===================================================================== async function loadPanelServices() { try { const cfgRes = await Atlus.apiFetch('/api/settings'); if (!cfgRes.ok) return; const cfg = await cfgRes.json(); const panelUnits = cfg.panel_services || []; if (panelUnits.length === 0) { $('#panelServices').innerHTML = '
No services pinned
'; return; } const container = $('#panelServices'); container.innerHTML = ''; for (const unit of panelUnits) { const res = await Atlus.apiFetch(`/api/services/${unit}`); if (!res.ok) continue; const svc = await res.json(); const isActive = svc.active === 'active'; const name = svc.name || unit.replace('.service', ''); const row = document.createElement('div'); row.className = 'panel-service-row'; row.innerHTML = ` ${name} `; container.appendChild(row); } // Toggle handlers container.querySelectorAll('.service-toggle').forEach(btn => { btn.addEventListener('click', async () => { const unit = btn.dataset.unit; const action = btn.classList.contains('on') ? 'stop' : 'start'; await Atlus.apiFetch('/api/services/action', { method: 'POST', body: { unit, action }, }); loadPanelServices(); }); }); // Open handlers container.querySelectorAll('.service-open').forEach(btn => { btn.addEventListener('click', () => { openApp('services'); }); }); } catch (e) { /* ignore */ } } // ===================================================================== // Panel — Update checker // ===================================================================== async function checkForUpdates() { const panel = $('#panelUpdates'); if (!panel) return; try { const res = await Atlus.apiFetch('/api/updates/check'); if (!res || !res.ok) { // Show error state if endpoint fails (503 = git not found, etc.) panel.classList.remove('hidden'); const status = res ? res.status : 0; const detail = res ? await res.json().catch(() => ({})) : {}; panel.innerHTML = `
Updates unavailable
${detail.detail || 'Could not check'}
`; return; } const data = await res.json(); panel.classList.remove('hidden'); if (data.error) { showUpdateError(panel, data); } else if (data.available && data.behind_count > 0) { showUpdateAvailable(panel, data); } else { showUpToDate(panel, data); } } catch (e) { console.warn('Update check failed:', e); } } function showUpToDate(panel, data) { panel.innerHTML = `
Up to date
${data.local_hash}
`; } function showUpdateError(panel, data) { panel.innerHTML = `
Check failed
${data.error}
`; } function showUpdateAvailable(panel, data) { panel.innerHTML = `
Update available
${data.behind_count} commit${data.behind_count !== 1 ? 's' : ''} behind (${data.remote_hash})
`; panel.querySelector('.update-install-btn').addEventListener('click', async (e) => { const btn = e.target; btn.disabled = true; btn.textContent = 'Updating…'; try { const res = await Atlus.apiFetch('/api/updates/apply', { method: 'POST' }); if (res.ok) { btn.textContent = 'Restarting…'; setTimeout(() => attemptReload(0), 4000); } else { btn.textContent = 'Failed'; btn.disabled = false; } } catch (e) { // Connection lost = server restarting btn.textContent = 'Restarting…'; setTimeout(() => attemptReload(0), 4000); } }); } function attemptReload(attempt) { if (attempt > 10) return; // Give up after ~30s fetch('/desktop', { method: 'HEAD' }) .then(() => window.location.reload()) .catch(() => setTimeout(() => attemptReload(attempt + 1), 3000)); } // ===================================================================== // Panel — Desktop Apps (X11 tray) // ===================================================================== async function pollDesktopApps() { const trayPanel = $('#panelTray'); const trayList = $('#panelTrayApps'); if (!trayPanel || !trayList) return; try { const res = await Atlus.apiFetch('/api/display/apps'); if (!res || !res.ok) { trayPanel.classList.add('hidden'); return; } const data = await res.json(); const apps = data.apps || []; if (apps.length === 0) { trayPanel.classList.add('hidden'); return; } trayPanel.classList.remove('hidden'); trayList.innerHTML = ''; apps.forEach(app => { const row = document.createElement('div'); row.className = 'panel-tray-item'; row.innerHTML = ` ${app.name} `; // Click app name or open button → switch to display app row.querySelector('.panel-tray-open').addEventListener('click', () => { openApp('xdisplay'); }); row.querySelector('.panel-tray-name').addEventListener('click', () => { openApp('xdisplay'); }); trayList.appendChild(row); }); } catch (e) { // Display API not available (no X11 deps) — hide tray trayPanel.classList.add('hidden'); } } // ===================================================================== // Session persistence — save/restore desktop state // ===================================================================== let _saveTimer = null; function saveDesktopState() { // Debounce — save at most once per second if (_saveTimer) clearTimeout(_saveTimer); _saveTimer = setTimeout(_doSaveState, 1000); } function _doSaveState() { const termApp = Atlus.apps.terminal; const terminalTabs = (termApp && termApp.getTerminalIds) ? termApp.getTerminalIds() : []; const state = { open_apps: Atlus.openApps.slice(), active_app: Atlus.activeApp, terminal_tabs: terminalTabs, }; Atlus.apiFetch('/api/session/state', { method: 'PUT', body: state, }).catch(() => {}); // best-effort } async function restoreSession() { try { const res = await Atlus.apiFetch('/api/session'); if (!res || !res.ok) return; const session = await res.json(); const state = session.desktop_state || {}; const openApps = state.open_apps || []; const activeApp = state.active_app; const serverTerminals = session.terminals || {}; // Store terminal info for the terminal app to use when it inits Atlus._restoredTerminals = Object.values(serverTerminals); Atlus._restoredTerminalTabs = state.terminal_tabs || []; // Re-open apps in saved order for (const appId of openApps) { if (Atlus.apps[appId]) { openApp(appId); } } // Focus the previously active app if (activeApp && Atlus.openApps.includes(activeApp)) { focusApp(activeApp); } } catch (e) { // First visit or server error — no session to restore } } // Save state on visibility change (tab switch / minimize) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { _doSaveState(); // immediate save on hide } }); // Save state before unload (use sendBeacon for reliability) window.addEventListener('beforeunload', () => { const termApp = Atlus.apps.terminal; const terminalTabs = (termApp && termApp.getTerminalIds) ? termApp.getTerminalIds() : []; const state = { open_apps: Atlus.openApps.slice(), active_app: Atlus.activeApp, terminal_tabs: terminalTabs, }; const blob = new Blob([JSON.stringify(state)], { type: 'application/json' }); navigator.sendBeacon('/api/session/state?token=' + TOKEN, blob); }); // ===================================================================== // Init // ===================================================================== loadHostname(); loadPanelServices(); connectStats(); checkForUpdates(); pollDesktopApps(); // Refresh services panel periodically setInterval(loadPanelServices, 30000); // Check for updates every 60 seconds setInterval(checkForUpdates, 60 * 1000); // Poll desktop apps every 5 seconds setInterval(pollDesktopApps, 5000); // Expose for app modules window.Atlus.openApp = openApp; window.Atlus.closeApp = closeApp; window.Atlus.focusApp = focusApp; window.Atlus.saveDesktopState = saveDesktopState; // Restore previous session (after a brief delay to let app modules register) setTimeout(restoreSession, 100); })();