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
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, '<').replace(/>/g, '>');
|
||||
const formatted = escaped.replace(/\n/g, '<br>');
|
||||
state.statusEl.innerHTML = `
|
||||
<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 {
|
||||
const res = await Atlus.apiFetch(`/api/network/wifi/scan/${name}`);
|
||||
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 {
|
||||
const networks = await res.json();
|
||||
const data = await res.json();
|
||||
const networks = data.networks || [];
|
||||
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 {
|
||||
resultsEl.innerHTML = '';
|
||||
for (const net of networks) {
|
||||
|
|
@ -908,10 +911,17 @@
|
|||
}
|
||||
html += '</div>';
|
||||
|
||||
// Add app form
|
||||
// Discover installed apps
|
||||
html += `
|
||||
<div class="settings-group">
|
||||
<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><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">
|
||||
|
|
@ -921,7 +931,7 @@
|
|||
<input class="settings-input" id="appAddCommand" placeholder="e.g. nextcloud">
|
||||
</div>
|
||||
<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;">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
|
|
@ -943,6 +953,7 @@
|
|||
<button class="settings-btn" id="appAddSave">Add Application</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 () => {
|
||||
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 = '<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() {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ install_deps() {
|
|||
imagemagick \
|
||||
x11-utils \
|
||||
libxcb-cursor0 \
|
||||
iw \
|
||||
wpasupplicant \
|
||||
> /dev/null 2>&1
|
||||
ok "System dependencies installed."
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue