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:
roberts 2026-03-15 00:03:13 -05:00
parent f8fe4e1296
commit 8d48e3eeb8
8 changed files with 457 additions and 44 deletions

View file

@ -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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -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}

View file

@ -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;
}

View file

@ -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;

View file

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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>
`; `;
} }

View file

@ -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() {

View file

@ -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."
} }