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 = `