From f9743bb29a59ecec1931970a708212fb81e07ac7 Mon Sep 17 00:00:00 2001 From: roberts Date: Sat, 14 Mar 2026 16:53:46 -0500 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20Atlus=20web=20de?= =?UTF-8?q?sktop=20environment=20for=20SBCs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-stack implementation: FastAPI backend with PAM auth, WebSocket stats/terminal, and vanilla JS frontend with tiling desktop shell. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 23 ++ ATLUS_CONTEXT.md | 514 ++++++++++++++++++++++++++ LICENSE | 29 ++ atlus.service | 29 ++ backend/__init__.py | 0 backend/auth.py | 131 +++++++ backend/config.py | 103 ++++++ backend/main.py | 137 +++++++ backend/requirements.txt | 8 + backend/routers/__init__.py | 0 backend/routers/files.py | 267 +++++++++++++ backend/routers/plugins/__init__.py | 0 backend/routers/plugins/asi_bridge.py | 201 ++++++++++ backend/routers/processes.py | 89 +++++ backend/routers/services.py | 131 +++++++ backend/routers/settings.py | 116 ++++++ backend/routers/stats.py | 121 ++++++ backend/routers/terminal.py | 116 ++++++ backend/ws/__init__.py | 0 backend/ws/manager.py | 55 +++ frontend/assets/atlus-logo.svg | 5 + frontend/css/apps/files.css | 254 +++++++++++++ frontend/css/apps/services.css | 151 ++++++++ frontend/css/apps/settings.css | 183 +++++++++ frontend/css/apps/tasks.css | 140 +++++++ frontend/css/apps/terminal.css | 93 +++++ frontend/css/dock.css | 115 ++++++ frontend/css/keyboard.css | 138 +++++++ frontend/css/panel.css | 214 +++++++++++ frontend/css/shell.css | 143 +++++++ frontend/css/stage.css | 179 +++++++++ frontend/css/variables.css | 51 +++ frontend/desktop.html | 201 ++++++++++ frontend/index.html | 163 ++++++++ frontend/js/apps/asi_bridge.js | 176 +++++++++ frontend/js/apps/files.js | 353 ++++++++++++++++++ frontend/js/apps/services.js | 179 +++++++++ frontend/js/apps/settings.js | 251 +++++++++++++ frontend/js/apps/tasks.js | 163 ++++++++ frontend/js/apps/terminal.js | 280 ++++++++++++++ frontend/js/atlus.js | 399 ++++++++++++++++++++ frontend/js/auth.js | 53 +++ frontend/js/keyboard.js | 290 +++++++++++++++ install.sh | 135 +++++++ package.sh | 177 +++++++++ 45 files changed, 6556 insertions(+) create mode 100644 .gitignore create mode 100644 ATLUS_CONTEXT.md create mode 100644 LICENSE create mode 100644 atlus.service create mode 100644 backend/__init__.py create mode 100644 backend/auth.py create mode 100644 backend/config.py create mode 100644 backend/main.py create mode 100644 backend/requirements.txt create mode 100644 backend/routers/__init__.py create mode 100644 backend/routers/files.py create mode 100644 backend/routers/plugins/__init__.py create mode 100644 backend/routers/plugins/asi_bridge.py create mode 100644 backend/routers/processes.py create mode 100644 backend/routers/services.py create mode 100644 backend/routers/settings.py create mode 100644 backend/routers/stats.py create mode 100644 backend/routers/terminal.py create mode 100644 backend/ws/__init__.py create mode 100644 backend/ws/manager.py create mode 100644 frontend/assets/atlus-logo.svg create mode 100644 frontend/css/apps/files.css create mode 100644 frontend/css/apps/services.css create mode 100644 frontend/css/apps/settings.css create mode 100644 frontend/css/apps/tasks.css create mode 100644 frontend/css/apps/terminal.css create mode 100644 frontend/css/dock.css create mode 100644 frontend/css/keyboard.css create mode 100644 frontend/css/panel.css create mode 100644 frontend/css/shell.css create mode 100644 frontend/css/stage.css create mode 100644 frontend/css/variables.css create mode 100644 frontend/desktop.html create mode 100644 frontend/index.html create mode 100644 frontend/js/apps/asi_bridge.js create mode 100644 frontend/js/apps/files.js create mode 100644 frontend/js/apps/services.js create mode 100644 frontend/js/apps/settings.js create mode 100644 frontend/js/apps/tasks.js create mode 100644 frontend/js/apps/terminal.js create mode 100644 frontend/js/atlus.js create mode 100644 frontend/js/auth.js create mode 100644 frontend/js/keyboard.js create mode 100755 install.sh create mode 100755 package.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd8e989 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +venv/ +.venv/ + +# Atlus runtime +.atlus_data/ + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ + +# Build artifacts +atlus-*.tar.gz + +# Claude dev config +.claude/ diff --git a/ATLUS_CONTEXT.md b/ATLUS_CONTEXT.md new file mode 100644 index 0000000..4dce3ce --- /dev/null +++ b/ATLUS_CONTEXT.md @@ -0,0 +1,514 @@ +# Atlus — Project Context + +## What Is Atlus? + +Atlus is a lightweight, web-based desktop environment designed for headless single-board computers (SBCs) such as the Orange Pi, Raspberry Pi, and similar ARM-based Linux systems. It provides a browser-accessible interface that replaces the need for SSH or VNC for everyday system management tasks. + +The goal is not to replicate a full desktop environment — it is to provide the *right* tools for managing a headless SBC in a way that feels natural, fast, and accessible from any device on the local network. + +--- + +## Problem Statement + +Headless SBCs are increasingly used as always-on servers, astrophotography rigs, NAS boxes, home automation hubs, and more. Current management options are: + +- **SSH** — powerful but unfriendly for non-developers and tedious for routine tasks +- **VNC / noVNC** — requires a full desktop environment, heavy, slow, not purpose-built +- **Cockpit / Webmin** — generic server admin tools, not tailored to SBC use cases, complex UI + +There is no lightweight, purpose-built web desktop for SBCs that feels like an OS rather than a server admin panel. + +--- + +## Core Philosophy + +- **Web-native first** — no X server required for core functionality +- **Purposefully minimal** — only the tools that matter for headless SBC management +- **Locally hosted** — runs entirely on the SBC, no cloud dependency +- **Accessible from anything** — phone, tablet, laptop — any browser on the local network +- **Tiling over floating** — apps open in the center stage, not as draggable windows +- **Extensible** — apps/widgets can be added as plugins + +--- + +## Target Users + +- Makers and hobbyists running headless SBCs for specific purposes (astrophotography, NAS, IoT, etc.) +- Home lab users who want lightweight management without full desktop overhead +- Anyone who currently lives in SSH and wishes there was something better + +--- + +## ✅ Finalized Layout — Three-Column Shell + +The Atlus UI is a fixed three-column layout inspired by tiling window managers (awesomewm, i3). There are no draggable or floating windows. Everything is tiled, cascaded, or tabbed within the center stage. + +``` +┌────────┬─────────────────────────────┬────────────────┐ +│ │ Stage Tab Bar │ │ +│ Left ├─────────────────────────────┤ Right Panel │ +│ Dock │ │ │ +│ │ Center Stage │ - Hostname │ +│ App │ (active app view) │ - Date/Time │ +│ Icons │ │ - Network │ +│ │ One app at a time, │ - CPU/RAM/ │ +│ (vert) │ or tiled side-by-side │ Disk/Temp │ +│ │ │ - Services │ +│ Gear ├─────────────────────────────┤ (toggles + │ +│ (foot) │ Layout Controls (tab bar) │ open → ) │ +└────────┴─────────────────────────────┴────────────────┘ +``` + +### Left Dock +- Vertical strip of app launcher icons — no labels, tooltip on hover +- Top: Atlus "A" logo — opens a system menu (shutdown, restart, about) +- Core apps listed in order (configurable in settings) +- Separator line between core apps and plugins +- Bottom: Settings gear icon (always pinned) +- Active app highlighted with accent border/background +- Badge dot on plugin icons when they have status to report (e.g. ASI Bridge syncing) + +### Center Stage +- The primary content area — full height, fills remaining width +- One app visible at a time by default +- **Tab bar** at top shows all open apps; click to switch (previous app minimizes) +- **Layout toggle buttons** (top-right of tab bar) switch between: + - Single pane (full width) + - Horizontal split (two panes side-by-side, 50/50 default) + - Vertical split (stacked, stretch goal) +- Each pane has its own macOS-style titlebar (traffic light dots + title) +- When a second app is opened in split mode, it takes the secondary pane + +### Right Panel +- Always visible, fixed width (~220px), never replaced by an app +- **Top section:** Hostname, date, time (live), WiFi name + status, Ethernet status +- **Middle section:** Live system stats — CPU %, Memory used/total, Storage %, CPU temp + - Each stat: compact uppercase label + current value + colored progress bar + - Colors: green (healthy), amber (moderate), red (high) +- **Bottom section:** Services list + - Each service: toggle (on/off), name, ↗ open button + - Toggle maps to `systemctl start/stop` + - ↗ opens that service's detail/control view in the center stage + - Services list is configurable per deployment + +--- + +## Architecture Overview + +### Backend Daemon +- Language: **Python (FastAPI + asyncio)** +- Runs as a systemd service on boot +- Exposes a REST API and WebSocket endpoint +- Responsibilities: + - File system access and management + - Systemd service control (start/stop/restart/status) + - Process/task enumeration + - System stats (CPU, RAM, disk, network, temperature) via psutil + - Notification/event bus (watches logs, mount points, service states) + - X11 app process spawner (Xvfb + x11vnc per app — Phase 4 only) + +### Frontend +- Language: **Vanilla JS + CSS** (no framework — keep it lightweight and portable) +- No build step — served directly as static files by the FastAPI backend +- Communicates via REST for actions, WebSocket for live stat updates +- CSS custom properties for theming +- IBM Plex Mono for system data; Inter for UI chrome + +### X11 App Embedding (Phase 4 — Optional/Advanced) +- For GUI apps that require X (e.g., Nextcloud desktop client) +- Each app spawns its own Xvfb virtual framebuffer +- x11vnc streams that framebuffer over WebSocket via noVNC +- Embedded as a pane in the center stage — not a floating window +- Isolated per-app — no shared display, no full desktop environment needed + +--- + +## Built-in Apps (Core) + +### ⚙️ System Settings +The unified configuration hub for the SBC. Organized into sections: + +- **General** — System name/hostname, date & time (manual or NTP), timezone +- **Users** — User management, password changes, add/remove users +- **Security** — Screen lock timeout, session timeout, auth settings +- **Applications** — Choose which apps appear in the dock; enable/disable on startup +- **Services** — Which systemd services appear in the right panel; autostart configuration +- **Network** — Per-interface settings (WiFi SSID/password, static IP vs DHCP, DNS); shown for all detected interfaces (eth0, wlan0, etc.) +- **Storage / Automount** — USB automount rules, mount point configuration, eject behavior +- **About** — Atlus version, system info, OS details + +--- + +### 📟 Terminal +A fully functional web-based terminal. No restrictions, no simplifications — behaves identically to SSH or a local terminal session. Powered by xterm.js on the frontend and ptyprocess on the backend for a real PTY. + +#### Custom On-Screen Keyboard (Critical) +The system keyboard (iOS/Android) is **never triggered**. Atlus uses its own purpose-built on-screen keyboard for all terminal input. This is non-negotiable — the native keyboard is hostile to terminal use (autocorrect, autocapitalize, no Escape, no function keys, unpredictable behavior). + +**How it works technically:** +- The xterm.js terminal canvas is focused but never triggers a system keyboard (it is not an `` or ` + +
+ +
+ `; + + contentEl.querySelector('#saveServices').addEventListener('click', async () => { + const raw = contentEl.querySelector('#setPanelServices').value; + const units = raw.split(',').map(s => s.trim()).filter(Boolean); + await Atlus.apiFetch('/api/settings', { + method: 'PUT', + body: { panel_services: units }, + }); + alert('Panel services updated.'); + }); + } + + async function renderAbout() { + const res = await Atlus.apiFetch('/api/settings/system'); + const sys = await res.json(); + + contentEl.innerHTML = ` +
About
+
+
+
A
+
ATLUS v0.1.0
+
+
+
Hostname
+ ${sys.hostname} +
+
+
Operating System
+ ${sys.os} +
+
+
Kernel
+ ${sys.kernel} +
+
+
Architecture
+ ${sys.arch} +
+
+
Python
+ ${sys.python} +
+
+
+
+ Licensed under GPL-3.0 +
+
+ `; + } + + Atlus.registerApp('settings', { + title: 'Settings', + + init(el) { + container = el; + container.classList.add('app-settings'); + + // Nav + const nav = document.createElement('div'); + nav.className = 'settings-nav'; + SECTIONS.forEach(s => { + const item = document.createElement('button'); + item.className = 'settings-nav-item' + (s.id === activeSection ? ' active' : ''); + item.dataset.section = s.id; + item.textContent = s.label; + item.addEventListener('click', () => loadSection(s.id)); + nav.appendChild(item); + }); + container.appendChild(nav); + + // Content + contentEl = document.createElement('div'); + contentEl.className = 'settings-content'; + container.appendChild(contentEl); + + loadSection(activeSection); + }, + + destroy() { + container = null; + contentEl = null; + activeSection = 'general'; + }, + }); +})(); diff --git a/frontend/js/apps/tasks.js b/frontend/js/apps/tasks.js new file mode 100644 index 0000000..bf78274 --- /dev/null +++ b/frontend/js/apps/tasks.js @@ -0,0 +1,163 @@ +/* Atlus — Task Manager app */ +(function () { + 'use strict'; + + let container = null; + let listEl = null; + let searchInput = null; + let processes = []; + let sortCol = 'cpu'; + let sortDir = -1; // -1 = desc, 1 = asc + let refreshInterval = null; + + async function loadProcesses() { + try { + const res = await Atlus.apiFetch('/api/processes'); + if (!res.ok) return; + processes = await res.json(); + renderProcesses(); + } catch (e) {} + } + + function renderProcesses() { + if (!listEl) return; + const query = searchInput ? searchInput.value.toLowerCase() : ''; + + let filtered = processes; + if (query) { + filtered = filtered.filter(p => + p.name.toLowerCase().includes(query) || + String(p.pid).includes(query) || + (p.user && p.user.toLowerCase().includes(query)) + ); + } + + // Sort + filtered.sort((a, b) => { + const av = a[sortCol] ?? 0; + const bv = b[sortCol] ?? 0; + if (typeof av === 'string') return av.localeCompare(bv) * sortDir; + return (av - bv) * sortDir; + }); + + // Summary + const totalCpu = processes.reduce((s, p) => s + (p.cpu || 0), 0); + const totalMem = processes.reduce((s, p) => s + (p.mem || 0), 0); + const summaryEl = container.querySelector('.tasks-summary'); + if (summaryEl) { + summaryEl.innerHTML = ` +
+ PROCESSES + ${processes.length} +
+
+ CPU + ${totalCpu.toFixed(1)}% +
+
+ MEM + ${totalMem.toFixed(1)}% +
+ `; + } + + listEl.innerHTML = ''; + + // Header + const header = document.createElement('div'); + header.className = 'tasks-header'; + const cols = [ + { key: 'pid', label: 'PID' }, + { key: 'name', label: 'NAME' }, + { key: 'user', label: 'USER' }, + { key: 'cpu', label: 'CPU %' }, + { key: 'mem', label: 'MEM %' }, + { key: 'status', label: 'STATUS' }, + { key: '', label: '' }, + ]; + cols.forEach(col => { + const span = document.createElement('span'); + span.textContent = col.label; + if (col.key === sortCol) span.classList.add('sorted'); + if (col.key) { + span.addEventListener('click', () => { + if (sortCol === col.key) sortDir *= -1; + else { sortCol = col.key; sortDir = -1; } + renderProcesses(); + }); + } + header.appendChild(span); + }); + listEl.appendChild(header); + + filtered.forEach(proc => { + const row = document.createElement('div'); + row.className = 'proc-row'; + row.innerHTML = ` + ${proc.pid} + ${proc.name} + ${proc.user || '--'} + ${(proc.cpu || 0).toFixed(1)} + ${(proc.mem || 0).toFixed(1)} + ${proc.status} + + `; + + const killBtn = row.querySelector('.proc-kill'); + killBtn.addEventListener('click', async () => { + if (!confirm(`Kill process ${proc.name} (PID ${proc.pid})?`)) return; + await Atlus.apiFetch('/api/processes/signal', { + method: 'POST', + body: { pid: proc.pid, signal: 'SIGTERM' }, + }); + setTimeout(loadProcesses, 500); + }); + + listEl.appendChild(row); + }); + } + + Atlus.registerApp('tasks', { + title: 'Task Manager', + + init(el) { + container = el; + container.classList.add('app-tasks'); + + // Summary + const summary = document.createElement('div'); + summary.className = 'tasks-summary'; + container.appendChild(summary); + + // Toolbar + const toolbar = document.createElement('div'); + toolbar.className = 'tasks-toolbar'; + + searchInput = document.createElement('input'); + searchInput.className = 'tasks-search'; + searchInput.type = 'text'; + searchInput.placeholder = 'Filter processes…'; + searchInput.addEventListener('input', renderProcesses); + toolbar.appendChild(searchInput); + + container.appendChild(toolbar); + + // List + listEl = document.createElement('div'); + listEl.className = 'tasks-list'; + container.appendChild(listEl); + + loadProcesses(); + refreshInterval = setInterval(loadProcesses, 3000); + }, + + destroy() { + if (refreshInterval) clearInterval(refreshInterval); + refreshInterval = null; + container = null; + listEl = null; + searchInput = null; + processes = []; + }, + }); +})(); diff --git a/frontend/js/apps/terminal.js b/frontend/js/apps/terminal.js new file mode 100644 index 0000000..e2e4bec --- /dev/null +++ b/frontend/js/apps/terminal.js @@ -0,0 +1,280 @@ +/* Atlus — Terminal app (xterm.js + PTY WebSocket) */ +(function () { + 'use strict'; + + let sessions = []; // {id, ws, term, fitAddon} + let activeSession = 0; + let sessionCounter = 0; + let container = null; + let termContainer = null; + let tabsContainer = null; + let kbdVisible = true; + + function createSession() { + const id = ++sessionCounter; + const term = new Terminal({ + cursorBlink: true, + fontSize: 14, + fontFamily: "'IBM Plex Mono', monospace", + theme: { + background: '#111318', + foreground: '#c8ccd8', + cursor: '#6ea6f0', + selectionBackground: 'rgba(110, 166, 240, 0.3)', + black: '#0d0f14', + red: '#e05a4a', + green: '#3ab86a', + yellow: '#e09a2a', + blue: '#6ea6f0', + magenta: '#c678dd', + cyan: '#56b6c2', + white: '#c8ccd8', + brightBlack: '#4a5068', + brightRed: '#e05a4a', + brightGreen: '#3ab86a', + brightYellow: '#e09a2a', + brightBlue: '#6ea6f0', + brightMagenta: '#c678dd', + brightCyan: '#56b6c2', + brightWhite: '#ffffff', + }, + allowProposedApi: true, + }); + + const fitAddon = new FitAddon.FitAddon(); + term.loadAddon(fitAddon); + + const ws = new WebSocket(Atlus.wsUrl('/api/terminal/ws')); + + ws.onopen = () => { + // Send initial size + setTimeout(() => { + fitAddon.fit(); + ws.send(JSON.stringify({ + type: 'resize', + cols: term.cols, + rows: term.rows, + })); + }, 100); + }; + + ws.onmessage = (e) => { + const msg = JSON.parse(e.data); + if (msg.type === 'output') { + term.write(msg.data); + } + }; + + ws.onclose = () => { + term.write('\r\n\x1b[31m[Session ended]\x1b[0m\r\n'); + }; + + // Send input to PTY + term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'input', data })); + } + }); + + // Handle resize + term.onResize(({ cols, rows }) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'resize', cols, rows })); + } + }); + + const session = { id, ws, term, fitAddon }; + sessions.push(session); + + // Connect keyboard — only send to PTY, let PTY echo handle display + Atlus.keyboard.setTerminal({ + input: (data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'input', data })); + } + }, + }); + + return session; + } + + function switchSession(id) { + activeSession = id; + renderTabs(); + renderTerminal(); + } + + function closeSession(id) { + const idx = sessions.findIndex(s => s.id === id); + if (idx === -1) return; + + const session = sessions[idx]; + if (session.ws.readyState === WebSocket.OPEN) { + session.ws.close(); + } + session.term.dispose(); + sessions.splice(idx, 1); + + if (sessions.length === 0) { + Atlus.closeApp('terminal'); + return; + } + + if (activeSession === id) { + activeSession = sessions[sessions.length - 1].id; + } + renderTabs(); + renderTerminal(); + } + + function renderTabs() { + if (!tabsContainer) return; + tabsContainer.innerHTML = ''; + sessions.forEach(s => { + const tab = document.createElement('button'); + tab.className = 'terminal-tab' + (s.id === activeSession ? ' active' : ''); + tab.innerHTML = `Shell ${s.id}`; + if (sessions.length > 1) { + tab.innerHTML += `×`; + } + tab.addEventListener('click', (e) => { + if (e.target.classList.contains('terminal-tab-close')) { + closeSession(s.id); + } else { + switchSession(s.id); + } + }); + tabsContainer.appendChild(tab); + }); + } + + function renderTerminal() { + if (!termContainer) return; + // Clear + termContainer.innerHTML = ''; + + const session = sessions.find(s => s.id === activeSession); + if (!session) return; + + const wrap = document.createElement('div'); + wrap.className = 'terminal-container'; + termContainer.appendChild(wrap); + session.term.open(wrap); + + // Fit after render + requestAnimationFrame(() => { + session.fitAddon.fit(); + }); + + // Update keyboard terminal ref — only send to PTY + Atlus.keyboard.setTerminal({ + input: (data) => { + if (session.ws.readyState === WebSocket.OPEN) { + session.ws.send(JSON.stringify({ type: 'input', data })); + } + }, + }); + } + + // Resize observer + let resizeObserver = null; + + Atlus.registerApp('terminal', { + title: 'Terminal', + + init(el) { + container = el; + container.classList.add('app-terminal'); + + // Toolbar + const toolbar = document.createElement('div'); + toolbar.className = 'terminal-toolbar'; + + tabsContainer = document.createElement('div'); + tabsContainer.className = 'terminal-tabs'; + toolbar.appendChild(tabsContainer); + + const newTabBtn = document.createElement('button'); + newTabBtn.className = 'terminal-new-tab'; + newTabBtn.textContent = '+'; + newTabBtn.title = 'New tab'; + newTabBtn.addEventListener('click', () => { + const s = createSession(); + switchSession(s.id); + }); + toolbar.appendChild(newTabBtn); + + const kbdBtn = document.createElement('button'); + kbdBtn.className = 'terminal-kbd-toggle' + (kbdVisible ? ' active' : ''); + kbdBtn.textContent = '⌨'; + kbdBtn.title = 'Toggle keyboard'; + kbdBtn.addEventListener('click', () => { + kbdVisible = !kbdVisible; + kbdBtn.classList.toggle('active', kbdVisible); + Atlus.keyboard.toggle(); + // Refit terminal + const session = sessions.find(s => s.id === activeSession); + if (session) { + requestAnimationFrame(() => session.fitAddon.fit()); + } + }); + toolbar.appendChild(kbdBtn); + + container.appendChild(toolbar); + + // Terminal area + termContainer = document.createElement('div'); + termContainer.style.flex = '1'; + termContainer.style.overflow = 'hidden'; + termContainer.style.display = 'flex'; + termContainer.style.flexDirection = 'column'; + container.appendChild(termContainer); + + // On-screen keyboard + const kbdEl = Atlus.keyboard.create(); + if (!kbdVisible) kbdEl.classList.add('hidden'); + container.appendChild(kbdEl); + + // Create first session + const s = createSession(); + activeSession = s.id; + renderTabs(); + renderTerminal(); + + // Resize observer + resizeObserver = new ResizeObserver(() => { + const session = sessions.find(s => s.id === activeSession); + if (session) { + try { session.fitAddon.fit(); } catch (e) {} + } + }); + resizeObserver.observe(termContainer); + }, + + destroy() { + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = null; + } + sessions.forEach(s => { + if (s.ws.readyState === WebSocket.OPEN) s.ws.close(); + s.term.dispose(); + }); + sessions = []; + sessionCounter = 0; + container = null; + termContainer = null; + tabsContainer = null; + }, + + onFocus() { + const session = sessions.find(s => s.id === activeSession); + if (session) { + requestAnimationFrame(() => { + session.fitAddon.fit(); + session.term.focus(); + }); + } + }, + }); +})(); diff --git a/frontend/js/atlus.js b/frontend/js/atlus.js new file mode 100644 index 0000000..9f46587 --- /dev/null +++ b/frontend/js/atlus.js @@ -0,0 +1,399 @@ +/* Atlus — Core shell: app switching, WebSocket stats, panel updates */ +(function () { + 'use strict'; + + // ===================================================================== + // Auth guard + // ===================================================================== + const TOKEN = sessionStorage.getItem('atlus_token'); + const USER = sessionStorage.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) { + sessionStorage.clear(); + 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); + } + + 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(); + } + + 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'; + } + } + } + + 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' }); + sessionStorage.clear(); + 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 */ } + } + + // ===================================================================== + // Init + // ===================================================================== + loadHostname(); + loadPanelServices(); + connectStats(); + + // Refresh services panel periodically + setInterval(loadPanelServices, 30000); + + // Expose for app modules + window.Atlus.openApp = openApp; + window.Atlus.closeApp = closeApp; + window.Atlus.focusApp = focusApp; +})(); diff --git a/frontend/js/auth.js b/frontend/js/auth.js new file mode 100644 index 0000000..5b6f31f --- /dev/null +++ b/frontend/js/auth.js @@ -0,0 +1,53 @@ +/* Atlus — Login screen logic */ +(function () { + 'use strict'; + + // Redirect to desktop if already authenticated + const token = sessionStorage.getItem('atlus_token'); + if (token) { + window.location.href = '/desktop'; + return; + } + + const form = document.getElementById('loginForm'); + const usernameInput = document.getElementById('username'); + const passwordInput = document.getElementById('password'); + const loginBtn = document.getElementById('loginBtn'); + const errorEl = document.getElementById('loginError'); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + errorEl.textContent = ''; + loginBtn.disabled = true; + loginBtn.textContent = 'Signing in…'; + + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: usernameInput.value.trim(), + password: passwordInput.value, + }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || 'Invalid credentials'); + } + + const data = await res.json(); + sessionStorage.setItem('atlus_token', data.token); + sessionStorage.setItem('atlus_user', data.username); + window.location.href = '/desktop'; + } catch (err) { + errorEl.textContent = err.message; + loginBtn.disabled = false; + loginBtn.textContent = 'Sign In'; + passwordInput.value = ''; + passwordInput.focus(); + } + }); + + usernameInput.focus(); +})(); diff --git a/frontend/js/keyboard.js b/frontend/js/keyboard.js new file mode 100644 index 0000000..fd4f11a --- /dev/null +++ b/frontend/js/keyboard.js @@ -0,0 +1,290 @@ +/* Atlus — Custom on-screen keyboard for terminal */ +(function () { + 'use strict'; + + const MODES = { + keys: 'Keys', + fn: 'Fn', + nav: 'Nav', + sym: 'Sym', + }; + + const QUICK_KEYS = ['$', '#', '>', '-', '_', '.', '/', '|', '~']; + + const QWERTY_ROWS = [ + ['1','2','3','4','5','6','7','8','9','0'], + ['q','w','e','r','t','y','u','i','o','p'], + ['a','s','d','f','g','h','j','k','l'], + ['z','x','c','v','b','n','m'], + ]; + + const SYM_ROWS = [ + ['!','@','#','$','%','^','&','*','(',')'], + ['-','_','=','+','[',']','{','}','\\','|'], + [';',':','\'','"',',','.','/','?','`','~'], + ['<','>','©','®','…','§','±','×'], + ]; + + const FN_KEYS = ['F1','F2','F3','F4','F5','F6','F7','F8','F9','F10','F11','F12']; + + const NAV_KEYS = [ + {label: '↑', seq: '\x1b[A'}, + {label: '↓', seq: '\x1b[B'}, + {label: '←', seq: '\x1b[D'}, + {label: '→', seq: '\x1b[C'}, + {label: 'Home', seq: '\x1b[H'}, + {label: 'End', seq: '\x1b[F'}, + {label: 'PgUp', seq: '\x1b[5~'}, + {label: 'PgDn', seq: '\x1b[6~'}, + {label: 'Del', seq: '\x1b[3~'}, + {label: 'Ins', seq: '\x1b[2~'}, + ]; + + class OnScreenKeyboard { + constructor() { + this.el = null; + this.terminal = null; // set by terminal app + this.mode = 'keys'; + this.shift = false; + this.ctrl = false; + this.alt = false; + // States: null=off, 'armed'=one-shot, 'locked'=persistent + this.ctrlState = null; + this.altState = null; + this.shiftState = null; + this.visible = true; + } + + create() { + this.el = document.createElement('div'); + this.el.className = 'osk'; + this._render(); + return this.el; + } + + toggle() { + this.visible = !this.visible; + if (this.el) this.el.classList.toggle('hidden', !this.visible); + } + + setTerminal(term) { + this.terminal = term; + } + + _send(data) { + if (!this.terminal) return; + + let out = data; + + // Apply modifiers + if (this.ctrlState && data.length === 1) { + const code = data.toLowerCase().charCodeAt(0); + if (code >= 97 && code <= 122) { + out = String.fromCharCode(code - 96); + } + } else if (this.altState && data.length === 1) { + out = '\x1b' + data; + } + + if (this.shiftState && data.length === 1 && data >= 'a' && data <= 'z') { + out = data.toUpperCase(); + } + + this.terminal.input(out); + + // Clear armed (one-shot) modifiers + if (this.ctrlState === 'armed') { this.ctrlState = null; } + if (this.altState === 'armed') { this.altState = null; } + if (this.shiftState === 'armed') { this.shiftState = null; } + this._updateModifiers(); + } + + _toggleMod(mod) { + const key = mod + 'State'; + if (this[key] === null) { + this[key] = 'armed'; + } else if (this[key] === 'armed') { + this[key] = 'locked'; + } else { + this[key] = null; + } + this._updateModifiers(); + } + + _updateModifiers() { + if (!this.el) return; + this.el.querySelectorAll('.osk-key.mod').forEach(key => { + const mod = key.dataset.mod; + if (!mod) return; + const state = this[mod + 'State']; + key.classList.toggle('armed', state === 'armed'); + key.classList.toggle('locked', state === 'locked'); + }); + } + + _render() { + this.el.innerHTML = ''; + + // Quick row + const quick = document.createElement('div'); + quick.className = 'osk-quick'; + QUICK_KEYS.forEach(k => { + const btn = this._makeKey(k, () => this._send(k)); + quick.appendChild(btn); + }); + this.el.appendChild(quick); + + // Mode tabs + const modes = document.createElement('div'); + modes.className = 'osk-modes'; + for (const [id, label] of Object.entries(MODES)) { + const tab = document.createElement('button'); + tab.className = 'osk-mode-tab' + (id === this.mode ? ' active' : ''); + tab.textContent = label; + tab.addEventListener('click', () => { + this.mode = id; + this._render(); + }); + modes.appendChild(tab); + } + this.el.appendChild(modes); + + // Mode content + if (this.mode === 'keys') this._renderQwerty(); + else if (this.mode === 'fn') this._renderFn(); + else if (this.mode === 'nav') this._renderNav(); + else if (this.mode === 'sym') this._renderSym(); + } + + _makeKey(label, handler, className = '') { + const btn = document.createElement('button'); + btn.className = 'osk-key ' + className; + btn.textContent = label; + btn.addEventListener('touchstart', (e) => { + e.preventDefault(); + handler(); + }); + btn.addEventListener('click', (e) => { + e.preventDefault(); + handler(); + }); + return btn; + } + + _renderQwerty() { + // Modifier row + const modRow = document.createElement('div'); + modRow.className = 'osk-row'; + modRow.appendChild(this._makeKey('Esc', () => this._send('\x1b'), 'mod')); + modRow.appendChild(this._makeKey('Tab', () => this._send('\t'), 'mod')); + + const ctrlKey = this._makeKey('Ctrl', () => this._toggleMod('ctrl'), 'mod'); + ctrlKey.dataset.mod = 'ctrl'; + modRow.appendChild(ctrlKey); + + const altKey = this._makeKey('Alt', () => this._toggleMod('alt'), 'mod'); + altKey.dataset.mod = 'alt'; + modRow.appendChild(altKey); + + this.el.appendChild(modRow); + + // QWERTY rows + QWERTY_ROWS.forEach((row, i) => { + const rowEl = document.createElement('div'); + rowEl.className = 'osk-row'; + + if (i === 3) { + // Shift key + const shiftKey = this._makeKey('Shift', () => this._toggleMod('shift'), 'mod wide'); + shiftKey.dataset.mod = 'shift'; + rowEl.appendChild(shiftKey); + } + + row.forEach(k => { + rowEl.appendChild(this._makeKey(k, () => this._send(k))); + }); + + if (i === 3) { + rowEl.appendChild(this._makeKey('⌫', () => this._send('\x7f'), 'wide')); + } + + this.el.appendChild(rowEl); + }); + + // Space row + const spaceRow = document.createElement('div'); + spaceRow.className = 'osk-row'; + spaceRow.appendChild(this._makeKey('Paste', () => this._paste(), 'mod')); + spaceRow.appendChild(this._makeKey('Space', () => this._send(' '), 'space')); + spaceRow.appendChild(this._makeKey('Enter', () => this._send('\r'), 'wide')); + this.el.appendChild(spaceRow); + + this._updateModifiers(); + } + + _renderSym() { + SYM_ROWS.forEach(row => { + const rowEl = document.createElement('div'); + rowEl.className = 'osk-row'; + row.forEach(k => { + rowEl.appendChild(this._makeKey(k, () => this._send(k))); + }); + this.el.appendChild(rowEl); + }); + } + + _renderFn() { + const scroll = document.createElement('div'); + scroll.className = 'osk-fn-scroll'; + FN_KEYS.forEach((k, i) => { + const seq = `\x1bO${String.fromCharCode(80 + i)}`; + // F1-F4 use \x1bOP-S, F5+ use \x1b[15~, etc. + const seqs = [ + '\x1bOP','\x1bOQ','\x1bOR','\x1bOS', + '\x1b[15~','\x1b[17~','\x1b[18~','\x1b[19~', + '\x1b[20~','\x1b[21~','\x1b[23~','\x1b[24~', + ]; + scroll.appendChild(this._makeKey(k, () => this._send(seqs[i]))); + }); + this.el.appendChild(scroll); + + // Common ctrl combos + const combos = document.createElement('div'); + combos.className = 'osk-row'; + const ctrlKeys = [ + {label: 'Ctrl+C', seq: '\x03'}, + {label: 'Ctrl+D', seq: '\x04'}, + {label: 'Ctrl+Z', seq: '\x1a'}, + {label: 'Ctrl+A', seq: '\x01'}, + {label: 'Ctrl+L', seq: '\x0c'}, + {label: 'Ctrl+R', seq: '\x12'}, + ]; + ctrlKeys.forEach(k => { + combos.appendChild(this._makeKey(k.label, () => this._send(k.seq), 'mod')); + }); + this.el.appendChild(combos); + } + + _renderNav() { + const nav = document.createElement('div'); + nav.className = 'osk-nav'; + NAV_KEYS.forEach(k => { + nav.appendChild(this._makeKey(k.label, () => this._send(k.seq))); + }); + this.el.appendChild(nav); + } + + async _paste() { + try { + const text = await navigator.clipboard.readText(); + if (text && this.terminal) { + this.terminal.input(text); + } + } catch (e) { + // Clipboard access denied + } + } + } + + window.Atlus.keyboard = new OnScreenKeyboard(); +})(); diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..e1d3f73 --- /dev/null +++ b/install.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# Atlus installer — targets DietPi, Armbian, Debian, Ubuntu +# Usage: curl -sSL https://raw.githubusercontent.com/YOUR_REPO/atlus/main/install.sh | bash +set -euo pipefail + +INSTALL_DIR="/opt/atlus" +CONFIG_DIR="/etc/atlus" +DATA_DIR="/var/lib/atlus" +SERVICE_FILE="/etc/systemd/system/atlus.service" +REPO_URL="https://github.com/YOUR_REPO/atlus.git" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +info() { echo -e "\033[1;34m[atlus]\033[0m $*"; } +ok() { echo -e "\033[1;32m[atlus]\033[0m $*"; } +err() { echo -e "\033[1;31m[atlus]\033[0m $*" >&2; } + +require_root() { + if [[ $EUID -ne 0 ]]; then + err "This installer must be run as root." + exit 1 + fi +} + +detect_os() { + if [ -f /etc/os-release ]; then + . /etc/os-release + OS_ID="${ID:-unknown}" + OS_NAME="${PRETTY_NAME:-Linux}" + else + OS_ID="unknown" + OS_NAME="Linux" + fi + info "Detected OS: $OS_NAME" +} + +# --------------------------------------------------------------------------- +# Install +# --------------------------------------------------------------------------- + +install_deps() { + info "Installing system dependencies..." + apt-get update -qq + apt-get install -y -qq \ + python3 python3-venv python3-dev python3-pip \ + libpam0g-dev \ + git \ + cifs-utils \ + > /dev/null 2>&1 + ok "System dependencies installed." +} + +install_atlus() { + info "Installing Atlus to $INSTALL_DIR..." + + if [ -d "$INSTALL_DIR/.git" ]; then + info "Existing installation found — pulling latest..." + cd "$INSTALL_DIR" + git pull --ff-only + else + rm -rf "$INSTALL_DIR" + git clone "$REPO_URL" "$INSTALL_DIR" + cd "$INSTALL_DIR" + fi + + # Python virtual environment + info "Setting up Python venv..." + python3 -m venv venv + venv/bin/pip install --upgrade pip -q + venv/bin/pip install -r backend/requirements.txt -q + ok "Python dependencies installed." +} + +setup_dirs() { + mkdir -p "$CONFIG_DIR" "$DATA_DIR" + chmod 700 "$DATA_DIR" +} + +install_service() { + info "Installing systemd service..." + cp "$INSTALL_DIR/atlus.service" "$SERVICE_FILE" + systemctl daemon-reload + systemctl enable atlus.service + systemctl restart atlus.service + ok "Atlus service installed and started." +} + +show_status() { + echo "" + ok "=============================" + ok " Atlus installed!" + ok "=============================" + echo "" + # Get primary IP + IP=$(hostname -I 2>/dev/null | awk '{print $1}') + if [ -n "$IP" ]; then + info "Access Atlus at: http://$IP:7779" + else + info "Access Atlus at: http://$(hostname):7779" + fi + info "Log in with any system user (PAM authentication)." + echo "" + info "Manage the service:" + info " systemctl status atlus" + info " systemctl restart atlus" + info " journalctl -u atlus -f" + echo "" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +main() { + echo "" + echo " _ _ _" + echo " /_\ | |_| |_ _ ___" + echo " / _ \| _| | | | (_-<" + echo " /_/ \_\\\\__|_| \\_,_/__/" + echo "" + echo " Web Desktop Environment for SBCs" + echo "" + + require_root + detect_os + install_deps + install_atlus + setup_dirs + install_service + show_status +} + +main "$@" diff --git a/package.sh b/package.sh new file mode 100755 index 0000000..2bb9211 --- /dev/null +++ b/package.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# Build a self-contained Atlus tarball for deployment to SBCs. +# Usage: ./package.sh +# Output: atlus-.tar.gz (scp it to the target, then run the bundled install) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +VERSION=$(date +%Y%m%d-%H%M%S) +STAGING="/tmp/atlus-package-$$" +OUT="${SCRIPT_DIR}/atlus-${VERSION}.tar.gz" + +info() { echo -e "\033[1;34m[package]\033[0m $*"; } +ok() { echo -e "\033[1;32m[package]\033[0m $*"; } + +cleanup() { rm -rf "$STAGING"; } +trap cleanup EXIT + +info "Packaging Atlus..." + +# Create staging directory +mkdir -p "$STAGING/atlus" + +# Copy application files (exclude dev artifacts) +rsync -a --exclude='.git' \ + --exclude='venv' \ + --exclude='.claude' \ + --exclude='.atlus_data' \ + --exclude='.DS_Store' \ + --exclude='__pycache__' \ + --exclude='*.pyc' \ + --exclude='atlus-*.tar.gz' \ + --exclude='package.sh' \ + "$SCRIPT_DIR/" "$STAGING/atlus/" + +# Create the deploy script that lives inside the tarball +cat > "$STAGING/deploy.sh" << 'DEPLOY_EOF' +#!/usr/bin/env bash +# Atlus deployment script — run on the target SBC as root +# Usage: sudo ./deploy.sh +set -euo pipefail + +INSTALL_DIR="/opt/atlus" +CONFIG_DIR="/etc/atlus" +DATA_DIR="/var/lib/atlus" +SERVICE_FILE="/etc/systemd/system/atlus.service" + +info() { echo -e "\033[1;34m[atlus]\033[0m $*"; } +ok() { echo -e "\033[1;32m[atlus]\033[0m $*"; } +err() { echo -e "\033[1;31m[atlus]\033[0m $*" >&2; } + +# --------------------------------------------------------------------------- +# Pre-flight +# --------------------------------------------------------------------------- + +if [[ $EUID -ne 0 ]]; then + err "This installer must be run as root (use sudo)." + exit 1 +fi + +echo "" +echo " _ _ _" +echo " /_\ | |_| |_ _ ___" +echo " / _ \| _| | | | (_-<" +echo " /_/ \_\\\\__|_| \\_,_/__/" +echo "" +echo " Web Desktop Environment for SBCs" +echo "" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +if [ -f /etc/os-release ]; then + . /etc/os-release + info "Detected OS: ${PRETTY_NAME:-Linux}" +else + info "Detected OS: Linux" +fi + +# --------------------------------------------------------------------------- +# System dependencies +# --------------------------------------------------------------------------- + +info "Installing system dependencies..." +apt-get update -qq +apt-get install -y -qq \ + python3 python3-venv python3-dev python3-pip \ + libpam0g-dev \ + cifs-utils \ + > /dev/null 2>&1 +ok "System dependencies installed." + +# --------------------------------------------------------------------------- +# Install application files +# --------------------------------------------------------------------------- + +info "Installing Atlus to $INSTALL_DIR..." + +# Back up existing config if upgrading +if [ -d "$INSTALL_DIR" ] && [ -d "$CONFIG_DIR" ]; then + info "Existing installation found — upgrading..." +fi + +# Copy application files +mkdir -p "$INSTALL_DIR" +rsync -a --delete \ + --exclude='venv' \ + "$SCRIPT_DIR/atlus/" "$INSTALL_DIR/" + +# --------------------------------------------------------------------------- +# Python virtual environment +# --------------------------------------------------------------------------- + +info "Setting up Python virtual environment..." +if [ ! -d "$INSTALL_DIR/venv" ]; then + python3 -m venv "$INSTALL_DIR/venv" +fi +"$INSTALL_DIR/venv/bin/pip" install --upgrade pip -q +"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/backend/requirements.txt" -q +ok "Python dependencies installed." + +# --------------------------------------------------------------------------- +# Directories and permissions +# --------------------------------------------------------------------------- + +mkdir -p "$CONFIG_DIR" "$DATA_DIR" +chmod 700 "$DATA_DIR" + +# --------------------------------------------------------------------------- +# systemd service +# --------------------------------------------------------------------------- + +info "Installing systemd service..." +cp "$INSTALL_DIR/atlus.service" "$SERVICE_FILE" +systemctl daemon-reload +systemctl enable atlus.service +systemctl restart atlus.service +ok "Atlus service started." + +# --------------------------------------------------------------------------- +# Done +# --------------------------------------------------------------------------- + +echo "" +ok "=============================" +ok " Atlus installed!" +ok "=============================" +echo "" +IP=$(hostname -I 2>/dev/null | awk '{print $1}') +if [ -n "$IP" ]; then + info "Access Atlus at: http://$IP:7779" +else + info "Access Atlus at: http://$(hostname):7779" +fi +info "Log in with any system user (PAM authentication)." +echo "" +info "Manage the service:" +info " systemctl status atlus" +info " systemctl restart atlus" +info " journalctl -u atlus -f" +echo "" +DEPLOY_EOF + +chmod +x "$STAGING/deploy.sh" + +# Build tarball +info "Creating tarball..." +tar -czf "$OUT" -C "$STAGING" deploy.sh atlus/ + +ok "Package built: $OUT" +FILESIZE=$(du -h "$OUT" | cut -f1) +ok "Size: $FILESIZE" +echo "" +info "Deploy to your SBC:" +info " scp $OUT user@your-sbc:~/" +info " ssh user@your-sbc" +info " tar xzf $(basename "$OUT")" +info " sudo ./deploy.sh" +echo ""