diff --git a/backend/config.py b/backend/config.py
index 2517978..23e679b 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -72,6 +72,8 @@ _DEFAULT_CONFIG: dict = {
},
"session_timeout_minutes": 1440, # 24 h
"stats_interval_seconds": 2,
+ "update_check_enabled": True,
+ "update_check_interval": 60, # seconds between update checks
}
diff --git a/backend/display.py b/backend/display.py
index 8c59a96..66612e1 100644
--- a/backend/display.py
+++ b/backend/display.py
@@ -6,6 +6,7 @@ Each GUI app window is captured independently and streamed as JPEG frames.
"""
import asyncio
+import getpass
import logging
import os
import re
@@ -473,6 +474,39 @@ class DisplayManager:
app.kill()
return [a.to_dict() for a in user_apps.values()]
+ async def autostart_apps(self, gui_apps: list[dict]):
+ """Launch all apps with autostart=True. Called on service startup."""
+ if not HAS_DISPLAY_DEPS:
+ log.info("Display deps not available, skipping autostart")
+ return
+
+ username = getpass.getuser()
+ started = 0
+ for app_cfg in gui_apps:
+ if not app_cfg.get("autostart"):
+ continue
+ command = app_cfg.get("command", "")
+ if not command:
+ continue
+ # Skip if already running
+ if self.get_app_by_command(username, command):
+ log.debug("Autostart skip (already running): %s", command)
+ continue
+ try:
+ await self.launch_app(
+ username=username,
+ command=command,
+ title=app_cfg.get("name", command),
+ args=app_cfg.get("args", []),
+ target_fps=app_cfg.get("target_fps", 10),
+ )
+ started += 1
+ log.info("Autostarted: %s", command)
+ except Exception as e:
+ log.warning("Failed to autostart %s: %s", command, e)
+ if started:
+ log.info("Autostarted %d application(s)", started)
+
async def shutdown_all(self):
"""Kill all apps and Xvfb displays."""
for username, apps in self._apps.items():
diff --git a/backend/main.py b/backend/main.py
index 6f40a8d..2d2ce77 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -10,7 +10,7 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from backend.auth import authenticate_user, create_token, logout
-from backend.config import FRONTEND_DIR, HOST, PORT
+from backend.config import FRONTEND_DIR, HOST, PORT, load_config
from backend.routers import stats, terminal, files, services, processes, settings, network, packages, updates, session, display
from backend.sessions import manager as session_manager
from backend.display import display_manager
@@ -30,6 +30,16 @@ log = logging.getLogger("atlus")
@asynccontextmanager
async def lifespan(app: FastAPI):
log.info("Atlus starting on %s:%d", HOST, PORT)
+
+ # Autostart configured GUI apps (always-on desktop session)
+ cfg = load_config()
+ gui_apps = cfg.get("gui_apps", [])
+ if gui_apps:
+ try:
+ await display_manager.autostart_apps(gui_apps)
+ except Exception as e:
+ log.warning("Autostart failed: %s", e)
+
# Start stats broadcaster
broadcaster = asyncio.create_task(stats.stats_broadcaster())
diff --git a/backend/routers/settings.py b/backend/routers/settings.py
index f73852c..7d3d16a 100644
--- a/backend/routers/settings.py
+++ b/backend/routers/settings.py
@@ -24,6 +24,8 @@ class ConfigUpdate(BaseModel):
gui_apps: Optional[list[dict]] = None
session_timeout_minutes: Optional[int] = None
stats_interval_seconds: Optional[int] = None
+ update_check_enabled: Optional[bool] = None
+ update_check_interval: Optional[int] = None
class HostnameRequest(BaseModel):
diff --git a/frontend/css/apps/settings.css b/frontend/css/apps/settings.css
index ec87858..77dacb9 100644
--- a/frontend/css/apps/settings.css
+++ b/frontend/css/apps/settings.css
@@ -289,6 +289,74 @@
white-space: nowrap;
}
+/* Applications management */
+.app-config-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 10px 0;
+ border-bottom: 1px solid var(--border-structural);
+}
+
+.app-config-info {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex: 1;
+ min-width: 0;
+}
+
+.app-config-icon {
+ font-size: 20px;
+ flex-shrink: 0;
+}
+
+.app-config-details {
+ min-width: 0;
+}
+
+.app-config-name {
+ font-family: var(--font-ui);
+ font-size: 14px;
+ color: var(--text-primary);
+}
+
+.app-config-cmd {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--text-muted);
+ margin-top: 2px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.app-config-controls {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-shrink: 0;
+}
+
+.app-autostart-label {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ cursor: pointer;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--text-secondary);
+ user-select: none;
+}
+
+.app-autostart-label input[type="checkbox"] {
+ width: 14px;
+ height: 14px;
+ accent-color: var(--accent);
+ cursor: pointer;
+}
+
/* WiFi network list */
.wifi-network-list {
max-height: 360px;
diff --git a/frontend/css/panel.css b/frontend/css/panel.css
index 87ea6d3..ef875e2 100644
--- a/frontend/css/panel.css
+++ b/frontend/css/panel.css
@@ -136,54 +136,41 @@
.stat-bar-fill.warn { background: var(--status-amber); }
.stat-bar-fill.crit { background: var(--status-red); }
-/* Services */
-.panel-service-list {
+/* Applications (docked GUI apps) */
+.panel-app-list {
display: flex;
flex-direction: column;
gap: 2px;
}
-.panel-service-row {
+.panel-app-row {
display: flex;
align-items: center;
gap: 8px;
- min-height: 44px;
- padding: 6px 0;
+ min-height: 38px;
+ padding: 4px 0;
}
-.service-toggle {
- position: relative;
- width: 36px;
- height: 20px;
+.panel-app-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
background: var(--border-component);
- border-radius: 10px;
- border: none;
- cursor: pointer;
- transition: background var(--transition-fast);
flex-shrink: 0;
+ transition: background var(--transition-fast);
}
-.service-toggle.on {
+.panel-app-dot.active {
background: var(--status-green);
}
-.service-toggle::after {
- content: '';
- position: absolute;
- top: 2px;
- left: 2px;
- width: 16px;
- height: 16px;
- border-radius: 50%;
- background: #fff;
- transition: transform var(--transition-fast);
+.panel-app-icon {
+ font-size: 14px;
+ flex-shrink: 0;
+ cursor: pointer;
}
-.service-toggle.on::after {
- transform: translateX(16px);
-}
-
-.service-name {
+.panel-app-name {
flex: 1;
font-family: var(--font-mono);
font-size: 12px;
@@ -191,9 +178,14 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+ cursor: pointer;
}
-.service-open {
+.panel-app-name:hover {
+ color: var(--accent);
+}
+
+.panel-app-action {
width: 28px;
height: 28px;
background: none;
@@ -204,15 +196,47 @@
display: flex;
align-items: center;
justify-content: center;
- font-size: 14px;
+ font-size: 12px;
flex-shrink: 0;
}
-.service-open:hover {
+.panel-app-action:hover {
background: var(--accent-hover);
color: var(--accent);
}
+.panel-app-action.stop:hover {
+ color: var(--status-red);
+}
+
+.panel-app-action:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.panel-app-add {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 32px;
+ margin-top: 8px;
+ background: none;
+ border: 1px dashed var(--border-structural);
+ border-radius: var(--radius-sm);
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.panel-app-add:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+ background: var(--accent-hover);
+}
+
/* Update status */
.panel-updates {
transition: all var(--transition-base);
diff --git a/frontend/desktop.html b/frontend/desktop.html
index d15bec5..5ae7dc6 100644
--- a/frontend/desktop.html
+++ b/frontend/desktop.html
@@ -172,10 +172,10 @@
-
-
-
SERVICES
-
+
+
diff --git a/frontend/js/apps/settings.js b/frontend/js/apps/settings.js
index 656dfb6..bfbd14d 100644
--- a/frontend/js/apps/settings.js
+++ b/frontend/js/apps/settings.js
@@ -8,6 +8,7 @@
const SECTIONS = [
{ id: 'general', label: 'General' },
+ { id: 'applications', label: 'Applications' },
{ id: 'network', label: 'Network' },
{ id: 'services', label: 'Services' },
{ id: 'about', label: 'About' },
@@ -21,6 +22,7 @@
});
if (section === 'general') await renderGeneral();
+ else if (section === 'applications') await renderApplications();
else if (section === 'network') await renderNetwork();
else if (section === 'services') await renderServicesConfig();
else if (section === 'about') await renderAbout();
@@ -69,6 +71,19 @@
+
+
+
Update scanner
+
Seconds between update checks
+
+
+
+
+
+
@@ -76,11 +91,20 @@
`;
+ // Update scanner toggle — enable/disable the interval input
+ const updateEnabledCb = contentEl.querySelector('#setUpdateEnabled');
+ const updateIntervalInput = contentEl.querySelector('#setUpdateInterval');
+ updateEnabledCb.addEventListener('change', () => {
+ updateIntervalInput.disabled = !updateEnabledCb.checked;
+ });
+
contentEl.querySelector('#saveGeneral').addEventListener('click', async () => {
const hostname = contentEl.querySelector('#setHostname').value.trim();
const timezone = contentEl.querySelector('#setTimezone').value.trim();
const interval = parseInt(contentEl.querySelector('#setStatsInterval').value);
const timeout = parseInt(contentEl.querySelector('#setSessionTimeout').value);
+ const updateEnabled = updateEnabledCb.checked;
+ const updateInterval = parseInt(updateIntervalInput.value) || 60;
// Update hostname if changed
if (hostname && hostname !== sys.hostname) {
@@ -105,10 +129,12 @@
stats_interval_seconds: interval,
session_timeout_minutes: timeout,
timezone: timezone === 'System default' ? null : timezone,
+ update_check_enabled: updateEnabled,
+ update_check_interval: updateInterval,
},
});
- alert('Settings saved.');
+ alert('Settings saved. Update scanner changes take effect on page reload.');
});
}
@@ -804,17 +830,201 @@
}
}
+ // ---------------------------------------------------------------
+ // Applications section — manage GUI apps + autostart
+ // ---------------------------------------------------------------
+
+ function _slugify(str) {
+ return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+ }
+
+ async function renderApplications() {
+ const cfgRes = await Atlus.apiFetch('/api/settings');
+ const cfg = await cfgRes.json();
+ const guiApps = cfg.gui_apps || [];
+
+ // Check display availability
+ let runningApps = [];
+ let displayAvailable = false;
+ try {
+ const statusRes = await Atlus.apiFetch('/api/display/status');
+ if (statusRes.ok) {
+ const status = await statusRes.json();
+ displayAvailable = status.available;
+ runningApps = status.apps || [];
+ }
+ } catch (e) { /* ignore */ }
+
+ let html = 'Applications
';
+
+ // Display deps status
+ if (!displayAvailable) {
+ html += `
+
+ Display dependencies not available. Install xvfb, xdotool, and imagemagick to enable GUI app support.
+
+
`;
+ }
+
+ // Configured apps list
+ html += 'CONFIGURED APPLICATIONS
';
+
+ if (guiApps.length === 0) {
+ html += '
No applications configured. Add one below.
';
+ } else {
+ for (let i = 0; i < guiApps.length; i++) {
+ const app = guiApps[i];
+ const running = runningApps.find(r => r.command === app.command && r.alive);
+ const statusColor = running ? 'var(--status-green)' : 'var(--text-muted)';
+ const statusText = running ? 'Running' : 'Stopped';
+
+ html += `
+
+
+
${app.icon || '🖥'}
+
+
${app.name || app.command}
+
${app.command}${app.args && app.args.length ? ' ' + app.args.join(' ') : ''}
+
+
+
+ ${statusText}
+
+
+
+
+ `;
+ }
+ }
+ html += '
';
+
+ // Add app form
+ html += `
+
+
ADD APPLICATION
+
+
Name
Display name for the app
+
+
+
+
Command
Executable name (must be in PATH)
+
+
+
+
Icon
Emoji icon for dock and panel
+
+
+
+
Arguments
Optional, space-separated
+
+
+
+
Target FPS
Frame rate for streaming
+
+
+
+
Autostart
Launch automatically when Atlus starts
+
+
+
+
+
+
+ `;
+
+ contentEl.innerHTML = html;
+
+ // ---- Event handlers ----
+
+ // Autostart toggle
+ contentEl.querySelectorAll('.app-autostart-cb').forEach(cb => {
+ cb.addEventListener('change', async () => {
+ const idx = parseInt(cb.dataset.appIdx);
+ const apps = [...guiApps];
+ if (apps[idx]) {
+ apps[idx] = { ...apps[idx], autostart: cb.checked };
+ await Atlus.apiFetch('/api/settings', {
+ method: 'PUT',
+ body: { gui_apps: apps },
+ });
+ }
+ });
+ });
+
+ // Remove app
+ contentEl.querySelectorAll('[data-remove-app]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const idx = parseInt(btn.dataset.removeApp);
+ const app = guiApps[idx];
+ if (!confirm(`Remove ${app.name || app.command} from applications?`)) return;
+
+ const apps = guiApps.filter((_, i) => i !== idx);
+ await Atlus.apiFetch('/api/settings', {
+ method: 'PUT',
+ body: { gui_apps: apps },
+ });
+ renderApplications();
+ });
+ });
+
+ // Add app
+ contentEl.querySelector('#appAddSave').addEventListener('click', async () => {
+ const name = contentEl.querySelector('#appAddName').value.trim();
+ const command = contentEl.querySelector('#appAddCommand').value.trim();
+ const icon = contentEl.querySelector('#appAddIcon').value.trim() || '🖥';
+ const argsRaw = contentEl.querySelector('#appAddArgs').value.trim();
+ const fps = parseInt(contentEl.querySelector('#appAddFps').value) || 10;
+ const autostart = contentEl.querySelector('#appAddAutostart').checked;
+
+ if (!name || !command) {
+ alert('Name and command are required.');
+ return;
+ }
+
+ // Check for duplicate command
+ if (guiApps.some(a => a.command === command)) {
+ alert(`An application with command "${command}" already exists.`);
+ return;
+ }
+
+ const newApp = {
+ id: _slugify(name),
+ name: name,
+ command: command,
+ icon: icon,
+ args: argsRaw ? argsRaw.split(' ').filter(Boolean) : [],
+ target_fps: fps,
+ autostart: autostart,
+ };
+
+ const apps = [...guiApps, newApp];
+ await Atlus.apiFetch('/api/settings', {
+ method: 'PUT',
+ body: { gui_apps: apps },
+ });
+
+ alert(`${name} added. It will appear in the dock and panel.`);
+ renderApplications();
+ });
+ }
+
async function renderServicesConfig() {
const cfgRes = await Atlus.apiFetch('/api/settings');
const cfg = await cfgRes.json();
const panelServices = cfg.panel_services || [];
contentEl.innerHTML = `
- Panel Services
+ Services
-
PINNED TO RIGHT PANEL
+
MONITORED SERVICES
- Comma-separated list of systemd unit names to show in the right panel.
+ Comma-separated list of systemd unit names to monitor via the Services app.
';
- return;
- }
+ // Get running apps (may 503 if display deps unavailable)
+ let runningApps = [];
+ try {
+ const runRes = await Atlus.apiFetch('/api/display/apps');
+ if (runRes.ok) runningApps = await runRes.json();
+ } catch (e) { /* ignore */ }
- 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', '');
+ if (guiApps.length === 0) {
+ container.innerHTML = 'No apps configured
';
+ } else {
+ for (const app of guiApps) {
+ const running = runningApps.find(r => r.command === app.command && r.alive);
+ const row = document.createElement('div');
+ row.className = 'panel-app-row';
+ row.innerHTML = `
+
+ ${app.icon || '🖥'}
+ ${app.name || app.command}
+
+ `;
- const row = document.createElement('div');
- row.className = 'panel-service-row';
- row.innerHTML = `
-
- ${name}
-
- `;
- container.appendChild(row);
+ // Click name to open in tab
+ row.querySelector('.panel-app-name').addEventListener('click', () => {
+ openApp('gui-' + app.id);
+ });
+ row.querySelector('.panel-app-icon').addEventListener('click', () => {
+ openApp('gui-' + app.id);
+ });
+
+ 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();
- });
+ // Add App button
+ const addBtn = document.createElement('button');
+ addBtn.className = 'panel-app-add';
+ addBtn.textContent = '+ Add';
+ addBtn.addEventListener('click', () => {
+ openApp('settings');
+ // Navigate to Applications section after a brief delay
+ setTimeout(() => {
+ const navItem = document.querySelector('.settings-nav-item[data-section="applications"]');
+ if (navItem) navItem.click();
+ }, 200);
});
+ container.appendChild(addBtn);
- // Open handlers
- container.querySelectorAll('.service-open').forEach(btn => {
- btn.addEventListener('click', () => {
- openApp('services');
+ // Action button handlers (launch/stop)
+ container.querySelectorAll('.panel-app-action').forEach(btn => {
+ btn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ const isRunning = btn.classList.contains('stop');
+ btn.disabled = true;
+
+ if (isRunning) {
+ // Stop the app
+ const appId = btn.dataset.appId;
+ if (appId) {
+ await Atlus.apiFetch(`/api/display/apps/${appId}`, { method: 'DELETE' });
+ }
+ } else {
+ // Launch the app
+ const args = btn.dataset.args ? btn.dataset.args.split(' ').filter(Boolean) : [];
+ await Atlus.apiFetch('/api/display/apps', {
+ method: 'POST',
+ body: {
+ command: btn.dataset.command,
+ title: btn.dataset.title,
+ args: args,
+ target_fps: parseInt(btn.dataset.fps) || 10,
+ },
+ });
+ }
+ setTimeout(loadPanelApps, 1500);
});
});
} catch (e) { /* ignore */ }
@@ -578,15 +621,34 @@
// Init
// =====================================================================
loadHostname();
- loadPanelServices();
+ loadPanelApps();
connectStats();
- checkForUpdates();
- // Refresh services panel periodically
- setInterval(loadPanelServices, 30000);
+ // Refresh applications panel periodically
+ setInterval(loadPanelApps, 30000);
- // Check for updates every 60 seconds
- setInterval(checkForUpdates, 60 * 1000);
+ // Update scanner — read interval and enabled state from config
+ (async function initUpdateChecker() {
+ try {
+ const cfgRes = await Atlus.apiFetch('/api/settings');
+ if (cfgRes.ok) {
+ const cfg = await cfgRes.json();
+ const enabled = cfg.update_check_enabled !== false;
+ const intervalSec = cfg.update_check_interval || 60;
+ if (enabled) {
+ checkForUpdates();
+ setInterval(checkForUpdates, intervalSec * 1000);
+ }
+ } else {
+ // Fallback: check every 60s
+ checkForUpdates();
+ setInterval(checkForUpdates, 60 * 1000);
+ }
+ } catch (e) {
+ checkForUpdates();
+ setInterval(checkForUpdates, 60 * 1000);
+ }
+ })();
// Expose for app modules
window.Atlus.openApp = openApp;