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 <noreply@anthropic.com>
This commit is contained in:
parent
f8fe4e1296
commit
8d48e3eeb8
8 changed files with 457 additions and 44 deletions
|
|
@ -90,7 +90,10 @@ class ManagedGuiApp:
|
||||||
target_fps: int = 10
|
target_fps: int = 10
|
||||||
created_at: float = field(default_factory=time.time)
|
created_at: float = field(default_factory=time.time)
|
||||||
last_frame: Optional[bytes] = field(default=None, repr=False)
|
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)
|
_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)
|
_websockets: list = field(default_factory=list, repr=False)
|
||||||
_streaming: bool = field(default=False, repr=False)
|
_streaming: bool = field(default=False, repr=False)
|
||||||
|
|
||||||
|
|
@ -128,6 +131,26 @@ class ManagedGuiApp:
|
||||||
def start_capture(self):
|
def start_capture(self):
|
||||||
if self._capture_task is None or self._capture_task.done():
|
if self._capture_task is None or self._capture_task.done():
|
||||||
self._capture_task = asyncio.create_task(self._capture_loop())
|
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):
|
async def _capture_loop(self):
|
||||||
"""Background: capture window pixmap → JPEG → fan-out."""
|
"""Background: capture window pixmap → JPEG → fan-out."""
|
||||||
|
|
@ -173,14 +196,56 @@ class ManagedGuiApp:
|
||||||
break
|
break
|
||||||
await asyncio.sleep(1.0 / self.target_fps)
|
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):
|
for ws in list(self._websockets):
|
||||||
try:
|
try:
|
||||||
await ws.send_json({"type": "closed", "data": "Application exited"})
|
await ws.send_json({"type": "closed", "data": exit_msg})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
log.info("Capture loop ended for app %s", self.app_id)
|
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]:
|
async def _capture_frame(self) -> Optional[bytes]:
|
||||||
"""Capture window as JPEG using ImageMagick import."""
|
"""Capture window as JPEG using ImageMagick import."""
|
||||||
if not self.window_id or not IMPORT_BIN:
|
if not self.window_id or not IMPORT_BIN:
|
||||||
|
|
@ -321,7 +386,7 @@ class ManagedGuiApp:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
d = {
|
||||||
"app_id": self.app_id,
|
"app_id": self.app_id,
|
||||||
"command": self.command,
|
"command": self.command,
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
|
|
@ -332,6 +397,9 @@ class ManagedGuiApp:
|
||||||
"viewers": len(self._websockets),
|
"viewers": len(self._websockets),
|
||||||
"created_at": self.created_at,
|
"created_at": self.created_at,
|
||||||
}
|
}
|
||||||
|
if self.exit_reason:
|
||||||
|
d["exit_reason"] = self.exit_reason
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ receives JPEG frames, sends input events. REST endpoints manage app lifecycle.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
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
|
# WebSocket — frame streaming + input
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -744,6 +744,11 @@ async def configure_interface(
|
||||||
wifi_password=cfg.wifi_password or "",
|
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
|
# Apply: bring interface down then up
|
||||||
# Use ifdown/ifup if available, else ip commands
|
# Use ifdown/ifup if available, else ip commands
|
||||||
ifdown = _find_bin("ifdown")
|
ifdown = _find_bin("ifdown")
|
||||||
|
|
@ -832,6 +837,50 @@ network={{
|
||||||
log.info("Wrote wpa_supplicant config for %s: ssid=%s", iface, ssid)
|
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}")
|
@router.get("/wifi/scan/{name}")
|
||||||
async def scan_wifi(name: str, _user: str = Depends(get_current_user)):
|
async def scan_wifi(name: str, _user: str = Depends(get_current_user)):
|
||||||
"""Scan for WiFi networks using iw or iwlist (no NetworkManager required)."""
|
"""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():
|
if not Path(f"/sys/class/net/{name}/wireless").exists():
|
||||||
raise HTTPException(400, f"{name} is not a wireless interface")
|
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 = []
|
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
|
# Try iw first
|
||||||
if _IW_BIN:
|
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:
|
if rc == 0:
|
||||||
networks = _parse_iw_scan(stdout)
|
networks = _parse_iw_scan(stdout)
|
||||||
elif rc != 0:
|
else:
|
||||||
# May need to bring interface up first
|
last_error = stderr.strip()
|
||||||
await _run_cmd(_IP_BIN, "link", "set", name, "up")
|
log.warning("iw scan failed for %s: %s", name, last_error)
|
||||||
await asyncio.sleep(1)
|
# Retry once after a brief wait (scan can fail if device is busy)
|
||||||
rc, stdout, _ = await _run_cmd(_IW_BIN, "dev", name, "scan", timeout=15)
|
await asyncio.sleep(2)
|
||||||
|
rc, stdout, stderr = await _run_cmd(_IW_BIN, "dev", name, "scan", timeout=20)
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
networks = _parse_iw_scan(stdout)
|
networks = _parse_iw_scan(stdout)
|
||||||
|
else:
|
||||||
|
last_error = stderr.strip()
|
||||||
|
log.warning("iw scan retry failed for %s: %s", name, last_error)
|
||||||
|
|
||||||
# Fallback: iwlist
|
# Fallback: iwlist
|
||||||
if not networks:
|
if not networks:
|
||||||
iwlist = _find_bin("iwlist")
|
iwlist = _find_bin("iwlist")
|
||||||
if 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:
|
if rc == 0:
|
||||||
networks = _parse_iwlist_scan(stdout)
|
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}
|
return {"networks": networks}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,3 +72,17 @@
|
||||||
.gui-status.exited .gui-status-icon {
|
.gui-status.exited .gui-status-icon {
|
||||||
opacity: 0.7;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -357,6 +357,36 @@
|
||||||
cursor: pointer;
|
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 */
|
||||||
.wifi-network-list {
|
.wifi-network-list {
|
||||||
max-height: 360px;
|
max-height: 360px;
|
||||||
|
|
|
||||||
|
|
@ -188,9 +188,12 @@
|
||||||
state.statusEl.className = 'gui-status ' + type;
|
state.statusEl.className = 'gui-status ' + type;
|
||||||
|
|
||||||
const icons = { connecting: '🖥', error: '⚠', exited: '✖' };
|
const icons = { connecting: '🖥', error: '⚠', exited: '✖' };
|
||||||
|
// Escape HTML and preserve newlines for multiline error messages
|
||||||
|
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const formatted = escaped.replace(/\n/g, '<br>');
|
||||||
state.statusEl.innerHTML = `
|
state.statusEl.innerHTML = `
|
||||||
<div class="gui-status-icon">${icons[type] || '🖥'}</div>
|
<div class="gui-status-icon">${icons[type] || '🖥'}</div>
|
||||||
<div class="gui-status-text">${text}</div>
|
<div class="gui-status-text">${formatted}</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -470,11 +470,14 @@
|
||||||
try {
|
try {
|
||||||
const res = await Atlus.apiFetch(`/api/network/wifi/scan/${name}`);
|
const res = await Atlus.apiFetch(`/api/network/wifi/scan/${name}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
resultsEl.innerHTML = '<div class="net-error">Failed to scan WiFi networks</div>';
|
const err = await res.json().catch(() => ({}));
|
||||||
|
resultsEl.innerHTML = `<div class="net-error">Failed to scan: ${err.detail || 'Unknown error'}</div>`;
|
||||||
} else {
|
} else {
|
||||||
const networks = await res.json();
|
const data = await res.json();
|
||||||
|
const networks = data.networks || [];
|
||||||
if (networks.length === 0) {
|
if (networks.length === 0) {
|
||||||
resultsEl.innerHTML = '<div style="padding:8px 0;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">No networks found</div>';
|
const errMsg = data.error ? `<br><span style="font-size:11px;">${data.error}</span>` : '';
|
||||||
|
resultsEl.innerHTML = `<div style="padding:8px 0;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">No networks found${errMsg}</div>`;
|
||||||
} else {
|
} else {
|
||||||
resultsEl.innerHTML = '';
|
resultsEl.innerHTML = '';
|
||||||
for (const net of networks) {
|
for (const net of networks) {
|
||||||
|
|
@ -908,10 +911,17 @@
|
||||||
}
|
}
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
// Add app form
|
// Discover installed apps
|
||||||
html += `
|
html += `
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<div class="settings-group-title">ADD APPLICATION</div>
|
<div class="settings-group-title">ADD APPLICATION</div>
|
||||||
|
<div id="appDiscoverList" class="app-discover-list">
|
||||||
|
<div style="padding:8px 0;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">Loading installed apps…</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
<button class="settings-btn secondary" id="appAddManualToggle" style="font-size:12px;">+ Add manually</button>
|
||||||
|
</div>
|
||||||
|
<div id="appAddManualForm" class="app-manual-form hidden">
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<div><div class="settings-row-label">Name</div><div class="settings-row-desc">Display name for the app</div></div>
|
<div><div class="settings-row-label">Name</div><div class="settings-row-desc">Display name for the app</div></div>
|
||||||
<input class="settings-input" id="appAddName" placeholder="e.g. Nextcloud">
|
<input class="settings-input" id="appAddName" placeholder="e.g. Nextcloud">
|
||||||
|
|
@ -921,7 +931,7 @@
|
||||||
<input class="settings-input" id="appAddCommand" placeholder="e.g. nextcloud">
|
<input class="settings-input" id="appAddCommand" placeholder="e.g. nextcloud">
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<div><div class="settings-row-label">Icon</div><div class="settings-row-desc">Emoji icon for dock and panel</div></div>
|
<div><div class="settings-row-label">Icon</div><div class="settings-row-desc">Emoji icon for panel</div></div>
|
||||||
<input class="settings-input" id="appAddIcon" value="🖥" style="width:80px;text-align:center;">
|
<input class="settings-input" id="appAddIcon" value="🖥" style="width:80px;text-align:center;">
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
|
|
@ -943,6 +953,7 @@
|
||||||
<button class="settings-btn" id="appAddSave">Add Application</button>
|
<button class="settings-btn" id="appAddSave">Add Application</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
contentEl.innerHTML = html;
|
contentEl.innerHTML = html;
|
||||||
|
|
@ -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 () => {
|
contentEl.querySelector('#appAddSave').addEventListener('click', async () => {
|
||||||
const name = contentEl.querySelector('#appAddName').value.trim();
|
const name = contentEl.querySelector('#appAddName').value.trim();
|
||||||
const command = contentEl.querySelector('#appAddCommand').value.trim();
|
const command = contentEl.querySelector('#appAddCommand').value.trim();
|
||||||
|
|
@ -995,7 +1012,6 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate command
|
|
||||||
if (guiApps.some(a => a.command === command)) {
|
if (guiApps.some(a => a.command === command)) {
|
||||||
alert(`An application with command "${command}" already exists.`);
|
alert(`An application with command "${command}" already exists.`);
|
||||||
return;
|
return;
|
||||||
|
|
@ -1020,6 +1036,80 @@
|
||||||
_refreshPanel();
|
_refreshPanel();
|
||||||
renderApplications();
|
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 = '<div style="padding:8px 0;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">Could not scan for installed apps</div>';
|
||||||
|
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 = '<div style="padding:8px 0;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">No additional applications found on this system</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
for (const app of available) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'app-discover-item';
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="app-config-info">
|
||||||
|
<span class="app-config-icon">${_desktopIcon(app.icon)}</span>
|
||||||
|
<div class="app-config-details">
|
||||||
|
<div class="app-config-name">${app.name}</div>
|
||||||
|
<div class="app-config-cmd">${app.command}${app.comment ? ' — ' + app.comment : ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="settings-btn" style="height:28px;font-size:11px;padding:0 14px;flex-shrink:0;">Add</button>
|
||||||
|
`;
|
||||||
|
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 = '<div style="padding:8px 0;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">Failed to scan for apps</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
async function renderServicesConfig() {
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ install_deps() {
|
||||||
imagemagick \
|
imagemagick \
|
||||||
x11-utils \
|
x11-utils \
|
||||||
libxcb-cursor0 \
|
libxcb-cursor0 \
|
||||||
|
iw \
|
||||||
|
wpasupplicant \
|
||||||
> /dev/null 2>&1
|
> /dev/null 2>&1
|
||||||
ok "System dependencies installed."
|
ok "System dependencies installed."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue