From 8d48e3eeb87cbac1ec742f73b3f85bf1ad02fce2 Mon Sep 17 00:00:00 2001 From: roberts Date: Sun, 15 Mar 2026 00:03:13 -0500 Subject: [PATCH] App exit diagnostics, desktop file discovery, WiFi wpa_supplicant fix - Capture stderr from GUI apps and show meaningful exit reasons (signal names, error messages) instead of generic "Application exited" - Add /api/display/discover-apps endpoint that scans .desktop files for installed GUI apps, replacing manual-only app configuration - Settings > Applications now shows discoverable apps with one-click Add, with manual form available as fallback - Fix WiFi: start wpa_supplicant before bringing interface up, handle rfkill blocks, add retry logic and error messages to scan results - Fix WiFi scan frontend bug: response is {networks:[...]} not a bare array - Add iw and wpasupplicant to install.sh dependencies Co-Authored-By: Claude Opus 4.6 --- backend/display.py | 74 +++++++++++++++- backend/routers/display.py | 129 +++++++++++++++++++++++++++ backend/routers/network.py | 91 +++++++++++++++++-- frontend/css/apps/display.css | 14 +++ frontend/css/apps/settings.css | 30 +++++++ frontend/js/apps/display.js | 5 +- frontend/js/apps/settings.js | 156 ++++++++++++++++++++++++++------- install.sh | 2 + 8 files changed, 457 insertions(+), 44 deletions(-) diff --git a/backend/display.py b/backend/display.py index 66612e1..547888d 100644 --- a/backend/display.py +++ b/backend/display.py @@ -90,7 +90,10 @@ class ManagedGuiApp: target_fps: int = 10 created_at: float = field(default_factory=time.time) last_frame: Optional[bytes] = field(default=None, repr=False) + exit_reason: Optional[str] = field(default=None, repr=False) _capture_task: Optional[asyncio.Task] = field(default=None, repr=False) + _stderr_task: Optional[asyncio.Task] = field(default=None, repr=False) + _stderr_lines: list = field(default_factory=list, repr=False) _websockets: list = field(default_factory=list, repr=False) _streaming: bool = field(default=False, repr=False) @@ -128,6 +131,26 @@ class ManagedGuiApp: def start_capture(self): if self._capture_task is None or self._capture_task.done(): self._capture_task = asyncio.create_task(self._capture_loop()) + if self._stderr_task is None or self._stderr_task.done(): + self._stderr_task = asyncio.create_task(self._read_stderr()) + + async def _read_stderr(self): + """Read stderr in background, keep last N lines for exit diagnostics.""" + if not self.process or not self.process.stderr: + return + try: + while True: + line = await self.process.stderr.readline() + if not line: + break + decoded = line.decode(errors="replace").rstrip() + if decoded: + self._stderr_lines.append(decoded) + # Keep only last 20 lines + if len(self._stderr_lines) > 20: + self._stderr_lines = self._stderr_lines[-20:] + except Exception: + pass async def _capture_loop(self): """Background: capture window pixmap → JPEG → fan-out.""" @@ -173,14 +196,56 @@ class ManagedGuiApp: break await asyncio.sleep(1.0 / self.target_fps) - # Process exited — notify viewers + # Process exited — build exit reason from stderr + return code + exit_msg = self._build_exit_reason() + self.exit_reason = exit_msg + log.info("App %s exited: %s", self.app_id, exit_msg) + for ws in list(self._websockets): try: - await ws.send_json({"type": "closed", "data": "Application exited"}) + await ws.send_json({"type": "closed", "data": exit_msg}) except Exception: pass log.info("Capture loop ended for app %s", self.app_id) + def _build_exit_reason(self) -> str: + """Build a human-readable exit reason from stderr output and return code.""" + rc = self.process.returncode if self.process else None + # Filter stderr for meaningful error lines + error_hints = [] + for line in self._stderr_lines: + low = line.lower() + # Skip noisy Qt/GTK debug lines + if any(skip in low for skip in ("qt.qpa.xcb: qxcb", "libgl", "mesa", + "dbind-warning", "g_dbus", "gtk-warning")): + continue + # Keep lines with error-like keywords + if any(kw in low for kw in ("error", "fatal", "failed", "abort", + "cannot", "could not", "not found", + "no such", "missing", "permission denied", + "segfault", "signal", "killed", "crash")): + error_hints.append(line) + + if error_hints: + # Show last few relevant error lines + hint_text = "\n".join(error_hints[-5:]) + if rc is not None and rc < 0: + sig = abs(rc) + return f"Killed by signal {sig}\n{hint_text}" + return hint_text + + # No meaningful stderr — use return code + if rc is None: + return "Application exited unexpectedly" + if rc == 0: + return "Application closed normally" + if rc < 0: + sig = abs(rc) + sig_names = {6: "SIGABRT", 9: "SIGKILL", 11: "SIGSEGV", 15: "SIGTERM"} + sig_name = sig_names.get(sig, f"signal {sig}") + return f"Crashed ({sig_name})" + return f"Exited with code {rc}" + async def _capture_frame(self) -> Optional[bytes]: """Capture window as JPEG using ImageMagick import.""" if not self.window_id or not IMPORT_BIN: @@ -321,7 +386,7 @@ class ManagedGuiApp: pass def to_dict(self) -> dict: - return { + d = { "app_id": self.app_id, "command": self.command, "title": self.title, @@ -332,6 +397,9 @@ class ManagedGuiApp: "viewers": len(self._websockets), "created_at": self.created_at, } + if self.exit_reason: + d["exit_reason"] = self.exit_reason + return d # --------------------------------------------------------------------------- diff --git a/backend/routers/display.py b/backend/routers/display.py index eb2e869..8d463a8 100644 --- a/backend/routers/display.py +++ b/backend/routers/display.py @@ -5,6 +5,10 @@ receives JPEG frames, sends input events. REST endpoints manage app lifecycle. """ import logging +import os +import re +import shutil +from pathlib import Path from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect @@ -85,6 +89,131 @@ async def display_status(user: str = Depends(get_current_user)): } +# --------------------------------------------------------------------------- +# Desktop file discovery — find installed GUI apps +# --------------------------------------------------------------------------- + +_DESKTOP_DIRS = [ + Path("/usr/share/applications"), + Path("/usr/local/share/applications"), + Path(os.path.expanduser("~/.local/share/applications")), + Path("/var/lib/flatpak/exports/share/applications"), + Path(os.path.expanduser("~/.local/share/flatpak/exports/share/applications")), + Path("/snap/applications"), +] + +# Categories that indicate a docked/GUI application +_GUI_CATEGORIES = { + "network", "webbrowser", "email", "office", "graphics", "audio", "video", + "chat", "instantmessaging", "filetransfer", "p2p", "remoteaccess", + "viewer", "player", "game", "utility", "system", "settings", + "monitor", "filesystem", "security", "accessibility", +} + +# Commands to exclude (system utilities, terminals, etc.) +_EXCLUDED_CMDS = { + "bash", "sh", "zsh", "fish", "xterm", "xfce4-terminal", "gnome-terminal", + "lxterminal", "urxvt", "alacritty", "kitty", "foot", + "true", "false", "update-manager", "software-properties-gtk", +} + + +def _parse_desktop_file(path: Path) -> Optional[dict]: + """Parse a .desktop file and return app info, or None if not a GUI app.""" + try: + content = path.read_text(errors="replace") + except Exception: + return None + + entry = {} + in_desktop_entry = False + + for line in content.splitlines(): + line = line.strip() + if line == "[Desktop Entry]": + in_desktop_entry = True + continue + if line.startswith("[") and line.endswith("]"): + if in_desktop_entry: + break # End of [Desktop Entry] section + continue + if not in_desktop_entry or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip() + if key in ("Name", "Exec", "Icon", "Type", "NoDisplay", "Hidden", + "Categories", "Terminal", "Comment"): + entry[key] = value + + # Must be Type=Application + if entry.get("Type") != "Application": + return None + # Skip hidden/no-display + if entry.get("NoDisplay", "").lower() == "true": + return None + if entry.get("Hidden", "").lower() == "true": + return None + # Skip terminal apps + if entry.get("Terminal", "").lower() == "true": + return None + + exec_line = entry.get("Exec", "") + if not exec_line: + return None + + # Extract command: remove field codes (%u, %U, %f, %F, etc.) + exec_clean = re.sub(r"%[a-zA-Z]", "", exec_line).strip() + # Get first token as command, strip path + parts = exec_clean.split() + if not parts: + return None + command = os.path.basename(parts[0]) + + if command in _EXCLUDED_CMDS or not command: + return None + + # Check the command actually exists + if not shutil.which(command): + return None + + # Parse categories + categories = set() + if entry.get("Categories"): + categories = {c.strip().lower() for c in entry["Categories"].split(";")} + + name = entry.get("Name", command) + icon = entry.get("Icon", "🖥") + comment = entry.get("Comment", "") + + return { + "name": name, + "command": command, + "icon": icon, + "comment": comment, + "categories": sorted(categories - {""}), + "desktop_file": path.name, + } + + +@router.get("/discover-apps") +async def discover_apps(_user: str = Depends(get_current_user)): + """Scan .desktop files for installed GUI applications.""" + apps = {} + + for d in _DESKTOP_DIRS: + if not d.is_dir(): + continue + for f in d.glob("*.desktop"): + app = _parse_desktop_file(f) + if app and app["command"] not in apps: + apps[app["command"]] = app + + # Sort alphabetically + result = sorted(apps.values(), key=lambda a: a["name"].lower()) + return result + + # --------------------------------------------------------------------------- # WebSocket — frame streaming + input # --------------------------------------------------------------------------- diff --git a/backend/routers/network.py b/backend/routers/network.py index 1008d85..0562fd1 100644 --- a/backend/routers/network.py +++ b/backend/routers/network.py @@ -744,6 +744,11 @@ async def configure_interface( wifi_password=cfg.wifi_password or "", ) + # For wireless interfaces, handle wpa_supplicant + is_wireless = Path(f"/sys/class/net/{name}/wireless").exists() + if is_wireless and cfg.ssid: + await _start_wpa_supplicant(name) + # Apply: bring interface down then up # Use ifdown/ifup if available, else ip commands ifdown = _find_bin("ifdown") @@ -832,6 +837,50 @@ network={{ log.info("Wrote wpa_supplicant config for %s: ssid=%s", iface, ssid) +async def _start_wpa_supplicant(iface: str): + """Start wpa_supplicant for a wireless interface. + + Kills any existing instance for this interface first. + """ + wpa_bin = _find_bin("wpa_supplicant") + if not wpa_bin: + log.warning("wpa_supplicant not found — WiFi auth will not work") + return + + conf_file = _WPA_SUPPLICANT_DIR / f"{iface}.conf" + if not conf_file.exists(): + log.warning("No wpa_supplicant config for %s", iface) + return + + # Kill any existing wpa_supplicant for this interface + killall = _find_bin("killall") + pkill = _find_bin("pkill") + if pkill: + await _run_cmd(pkill, "-f", f"wpa_supplicant.*{iface}", timeout=5) + elif killall: + await _run_cmd(killall, "wpa_supplicant", timeout=5) + + await asyncio.sleep(0.5) + + # Bring interface up first + await _run_cmd(_IP_BIN, "link", "set", iface, "up") + + # Start wpa_supplicant in background + rc, _, stderr = await _run_cmd( + wpa_bin, "-B", # background/daemonize + "-i", iface, + "-c", str(conf_file), + "-D", "nl80211,wext", # try nl80211 first, fallback to wext + timeout=10, + ) + if rc != 0: + log.warning("wpa_supplicant failed for %s: %s", iface, stderr.strip()) + else: + log.info("wpa_supplicant started for %s", iface) + # Wait a moment for association + await asyncio.sleep(2) + + @router.get("/wifi/scan/{name}") async def scan_wifi(name: str, _user: str = Depends(get_current_user)): """Scan for WiFi networks using iw or iwlist (no NetworkManager required).""" @@ -841,28 +890,56 @@ async def scan_wifi(name: str, _user: str = Depends(get_current_user)): if not Path(f"/sys/class/net/{name}/wireless").exists(): raise HTTPException(400, f"{name} is not a wireless interface") + # Check if interface is rfkill blocked + rfkill = _find_bin("rfkill") + if rfkill: + rc, stdout, _ = await _run_cmd(rfkill, "list", "wifi", timeout=5) + if rc == 0 and "Soft blocked: yes" in stdout: + # Try to unblock + await _run_cmd(rfkill, "unblock", "wifi", timeout=5) + await asyncio.sleep(0.5) + networks = [] + last_error = "" + + # Bring interface up first — scan requires it + await _run_cmd(_IP_BIN, "link", "set", name, "up") + await asyncio.sleep(0.5) # Try iw first if _IW_BIN: - rc, stdout, _ = await _run_cmd(_IW_BIN, "dev", name, "scan", timeout=15) + rc, stdout, stderr = await _run_cmd(_IW_BIN, "dev", name, "scan", timeout=20) if rc == 0: networks = _parse_iw_scan(stdout) - elif rc != 0: - # May need to bring interface up first - await _run_cmd(_IP_BIN, "link", "set", name, "up") - await asyncio.sleep(1) - rc, stdout, _ = await _run_cmd(_IW_BIN, "dev", name, "scan", timeout=15) + else: + last_error = stderr.strip() + log.warning("iw scan failed for %s: %s", name, last_error) + # Retry once after a brief wait (scan can fail if device is busy) + await asyncio.sleep(2) + rc, stdout, stderr = await _run_cmd(_IW_BIN, "dev", name, "scan", timeout=20) if rc == 0: networks = _parse_iw_scan(stdout) + else: + last_error = stderr.strip() + log.warning("iw scan retry failed for %s: %s", name, last_error) # Fallback: iwlist if not networks: iwlist = _find_bin("iwlist") if iwlist: - rc, stdout, _ = await _run_cmd(iwlist, name, "scan", timeout=15) + rc, stdout, stderr = await _run_cmd(iwlist, name, "scan", timeout=15) if rc == 0: networks = _parse_iwlist_scan(stdout) + else: + last_error = stderr.strip() or last_error + log.warning("iwlist scan failed for %s: %s", name, last_error) + + if not networks and not _IW_BIN and not _find_bin("iwlist"): + return {"networks": [], "error": "Neither iw nor iwlist found. Install: apt-get install iw wireless-tools"} + + if not networks and last_error: + # Return empty with error context + return {"networks": [], "error": last_error} return {"networks": networks} diff --git a/frontend/css/apps/display.css b/frontend/css/apps/display.css index 006ea11..42ed64b 100644 --- a/frontend/css/apps/display.css +++ b/frontend/css/apps/display.css @@ -72,3 +72,17 @@ .gui-status.exited .gui-status-icon { opacity: 0.7; } + +.gui-status.exited .gui-status-text, +.gui-status.error .gui-status-text { + max-width: 600px; + font-size: 12px; + line-height: 1.5; + text-align: left; + background: rgba(0,0,0,0.3); + padding: 12px 16px; + border-radius: var(--radius-md); + border: 1px solid var(--border-structural); + word-break: break-word; + white-space: pre-wrap; +} diff --git a/frontend/css/apps/settings.css b/frontend/css/apps/settings.css index 77dacb9..41c8aae 100644 --- a/frontend/css/apps/settings.css +++ b/frontend/css/apps/settings.css @@ -357,6 +357,36 @@ cursor: pointer; } +/* App discovery list */ +.app-discover-list { + max-height: 300px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.app-discover-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid var(--border-structural); +} + +.app-discover-item:last-child { + border-bottom: none; +} + +.app-manual-form { + margin-top: 16px; + padding-top: 16px; + border-top: 1px dashed var(--border-structural); +} + +.app-manual-form.hidden { + display: none; +} + /* WiFi network list */ .wifi-network-list { max-height: 360px; diff --git a/frontend/js/apps/display.js b/frontend/js/apps/display.js index 21cae09..3d969f7 100644 --- a/frontend/js/apps/display.js +++ b/frontend/js/apps/display.js @@ -188,9 +188,12 @@ state.statusEl.className = 'gui-status ' + type; const icons = { connecting: '🖥', error: '⚠', exited: '✖' }; + // Escape HTML and preserve newlines for multiline error messages + const escaped = text.replace(/&/g, '&').replace(//g, '>'); + const formatted = escaped.replace(/\n/g, '
'); state.statusEl.innerHTML = `
${icons[type] || '🖥'}
-
${text}
+
${formatted}
`; } diff --git a/frontend/js/apps/settings.js b/frontend/js/apps/settings.js index 517c389..34a27ba 100644 --- a/frontend/js/apps/settings.js +++ b/frontend/js/apps/settings.js @@ -470,11 +470,14 @@ try { const res = await Atlus.apiFetch(`/api/network/wifi/scan/${name}`); if (!res.ok) { - resultsEl.innerHTML = '
Failed to scan WiFi networks
'; + const err = await res.json().catch(() => ({})); + resultsEl.innerHTML = `
Failed to scan: ${err.detail || 'Unknown error'}
`; } else { - const networks = await res.json(); + const data = await res.json(); + const networks = data.networks || []; if (networks.length === 0) { - resultsEl.innerHTML = '
No networks found
'; + const errMsg = data.error ? `
${data.error}` : ''; + resultsEl.innerHTML = `
No networks found${errMsg}
`; } else { resultsEl.innerHTML = ''; for (const net of networks) { @@ -908,39 +911,47 @@ } html += ''; - // Add app form + // Discover installed apps html += `
ADD APPLICATION
-
-
Name
Display name for the app
- +
+
Loading installed apps…
-
-
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
- -
-
- +
`; @@ -981,7 +992,13 @@ }); }); - // Add app + // Manual form toggle + contentEl.querySelector('#appAddManualToggle').addEventListener('click', () => { + const form = contentEl.querySelector('#appAddManualForm'); + form.classList.toggle('hidden'); + }); + + // Manual add contentEl.querySelector('#appAddSave').addEventListener('click', async () => { const name = contentEl.querySelector('#appAddName').value.trim(); const command = contentEl.querySelector('#appAddCommand').value.trim(); @@ -995,7 +1012,6 @@ return; } - // Check for duplicate command if (guiApps.some(a => a.command === command)) { alert(`An application with command "${command}" already exists.`); return; @@ -1020,6 +1036,80 @@ _refreshPanel(); renderApplications(); }); + + // Load discovered apps + _loadDiscoverableApps(guiApps); + } + + async function _loadDiscoverableApps(existingApps) { + const listEl = contentEl.querySelector('#appDiscoverList'); + if (!listEl) return; + + try { + const res = await Atlus.apiFetch('/api/display/discover-apps'); + if (!res.ok) { + listEl.innerHTML = '
Could not scan for installed apps
'; + return; + } + const discovered = await res.json(); + + // Filter out apps already configured + const existingCmds = new Set(existingApps.map(a => a.command)); + const available = discovered.filter(a => !existingCmds.has(a.command)); + + if (available.length === 0) { + listEl.innerHTML = '
No additional applications found on this system
'; + return; + } + + listEl.innerHTML = ''; + for (const app of available) { + const row = document.createElement('div'); + row.className = 'app-discover-item'; + row.innerHTML = ` +
+ ${_desktopIcon(app.icon)} +
+
${app.name}
+
${app.command}${app.comment ? ' — ' + app.comment : ''}
+
+
+ + `; + row.querySelector('.settings-btn').addEventListener('click', async () => { + const newApp = { + id: _slugify(app.name), + name: app.name, + command: app.command, + icon: _desktopIcon(app.icon), + args: [], + target_fps: 10, + autostart: false, + }; + const apps = [...existingApps, newApp]; + await Atlus.apiFetch('/api/settings', { + method: 'PUT', + body: { gui_apps: apps }, + }); + _refreshPanel(); + renderApplications(); + }); + listEl.appendChild(row); + } + } catch (e) { + listEl.innerHTML = '
Failed to scan for apps
'; + } + } + + function _desktopIcon(icon) { + // If it's already an emoji or short text, use it directly + // If it's a path or icon name (from .desktop files), use a default emoji + if (!icon) return '🖥'; + // Emoji detection: if first char is > U+2000, likely an emoji + if (icon.codePointAt(0) > 0x2000) return icon; + // Short text like "A" or icon names like "nextcloud" — use default + if (icon.length <= 3 && !icon.includes('/')) return icon; + return '🖥'; } async function renderServicesConfig() { diff --git a/install.sh b/install.sh index a5e9c42..234dddc 100755 --- a/install.sh +++ b/install.sh @@ -53,6 +53,8 @@ install_deps() { imagemagick \ x11-utils \ libxcb-cursor0 \ + iw \ + wpasupplicant \ > /dev/null 2>&1 ok "System dependencies installed." }