From 1467d07bc4bd1afcb2350be0d65f63f7cb0a95cc Mon Sep 17 00:00:00 2001 From: roberts Date: Sun, 15 Mar 2026 01:05:19 -0500 Subject: [PATCH] Improve window discovery, add app context menu with Start/Stop/Restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window discovery: - Add display-wide search as final fallback (any window on the Xvfb) - Also search by WM_CLASS for Electron apps - Increase timeout to 60s, log progress and early crashes - Add restart endpoint POST /api/display/apps/{id}/restart Panel apps: - Replace play/stop button with ⋮ context menu (Start, Stop, Restart) - LED indicator: green=running, amber pulsing=starting, red=stopped/error - Add status field to app API response Co-Authored-By: Claude Opus 4.6 --- backend/display.py | 58 ++++++++++++--- backend/routers/display.py | 30 ++++++++ frontend/css/panel.css | 70 ++++++++++++++++-- frontend/js/atlus.js | 144 +++++++++++++++++++++++++++---------- 4 files changed, 249 insertions(+), 53 deletions(-) diff --git a/backend/display.py b/backend/display.py index 27d9571..7747cdc 100644 --- a/backend/display.py +++ b/backend/display.py @@ -155,17 +155,30 @@ class ManagedGuiApp: async def _capture_loop(self): """Background: capture window pixmap → JPEG → fan-out.""" - # Wait for window to appear (up to ~30s for heavy apps like Electron) - for attempt in range(30): + # Wait for window to appear (up to ~60s for heavy apps like Electron) + for attempt in range(60): if not self.alive: + log.warning("App %s (%s) exited during window discovery (rc=%s). stderr: %s", + self.app_id, self.command, self.process.returncode, + "; ".join(self._stderr_lines[-5:]) if self._stderr_lines else "(empty)") + # Notify any attached viewers about the crash + for ws in list(self._websockets): + try: + exit_msg = self._build_exit_reason() + await ws.send_json({"type": "closed", "data": exit_msg or "Application crashed during startup"}) + except Exception: + pass return await self._discover_window() if self.window_id: break + if attempt % 10 == 9: + log.info("Still waiting for window for %s (%s) — attempt %d", + self.app_id, self.command, attempt + 1) await asyncio.sleep(1.0) if not self.window_id: - log.warning("No window found for app %s (%s)", self.app_id, self.command) + log.warning("No window found for app %s (%s) after 60s", self.app_id, self.command) # Notify any attached viewers for ws in list(self._websockets): try: @@ -299,14 +312,23 @@ class ManagedGuiApp: except Exception: pass - # Strategy 3: search by window name containing the app command/title - for query in (self.title, self.command): - if query: - wid = await self._xdotool_search("--name", query, env=env) - if wid: - self.window_id = wid - log.debug("Window %d found by name '%s' for %s", wid, query, self.app_id) - return + # Strategy 3: search by window name or WM_CLASS + for flag in ("--name", "--class"): + for query in (self.title, self.command): + if query: + wid = await self._xdotool_search(flag, query, env=env) + if wid: + self.window_id = wid + log.debug("Window %d found by %s '%s' for %s", wid, flag, query, self.app_id) + return + + # Strategy 4: find ANY visible window on this display (last resort) + # Each user gets their own Xvfb, so any window here must be ours + wid = await self._xdotool_search("--onlyvisible", "--name", "", env=env) + if wid: + self.window_id = wid + log.debug("Window %d found by display-wide search for %s", wid, self.app_id) + return @staticmethod def _get_descendant_pids(parent_pid: int) -> list[int]: @@ -450,12 +472,26 @@ class ManagedGuiApp: except Exception: pass + @property + def status(self) -> str: + """Return app status: 'running', 'starting', 'stopped', or 'error'.""" + if not self.alive: + if self.exit_reason: + return "error" if "crash" in (self.exit_reason or "").lower() or ( + self.process and self.process.returncode not in (None, 0) + ) else "stopped" + return "stopped" + if self.window_id: + return "running" + return "starting" + def to_dict(self) -> dict: d = { "app_id": self.app_id, "command": self.command, "title": self.title, "alive": self.alive, + "status": self.status, "pid": self.process.pid if self.process else None, "window_id": self.window_id, "streaming": self._streaming, diff --git a/backend/routers/display.py b/backend/routers/display.py index 8d463a8..880b9c5 100644 --- a/backend/routers/display.py +++ b/backend/routers/display.py @@ -80,6 +80,36 @@ async def close_app(app_id: str, user: str = Depends(get_current_user)): return {"ok": True} +@router.post("/apps/{app_id}/restart") +async def restart_app(app_id: str, user: str = Depends(get_current_user)): + """Restart a running GUI application (stop + relaunch).""" + _require_deps() + app = display_manager.get_app(user, app_id) + if not app: + raise HTTPException(404, "App not found") + + # Save launch params before killing + command = app.command + title = app.title + target_fps = app.target_fps + + # Stop the old instance + display_manager.close_app(user, app_id) + + # Brief pause to let the process fully exit + import asyncio + await asyncio.sleep(1) + + # Relaunch + try: + new_app = await display_manager.launch_app( + user, command, title, target_fps=target_fps, + ) + return new_app.to_dict() + except Exception as e: + raise HTTPException(500, f"Restart failed: {e}") + + @router.get("/status") async def display_status(user: str = Depends(get_current_user)): """Check display system availability.""" diff --git a/frontend/css/panel.css b/frontend/css/panel.css index bcbbeec..4f5a7d3 100644 --- a/frontend/css/panel.css +++ b/frontend/css/panel.css @@ -160,10 +160,25 @@ transition: background var(--transition-fast); } -.panel-app-dot.active { +.panel-app-dot.running { background: var(--status-green); } +.panel-app-dot.starting { + background: var(--status-amber); + animation: pulse-dot 1.2s ease-in-out infinite; +} + +.panel-app-dot.stopped, +.panel-app-dot.error { + background: var(--status-red); +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + .panel-app-icon { font-size: 14px; flex-shrink: 0; @@ -196,7 +211,7 @@ display: flex; align-items: center; justify-content: center; - font-size: 12px; + font-size: 14px; flex-shrink: 0; } @@ -205,15 +220,58 @@ color: var(--accent); } -.panel-app-action.stop:hover { - color: var(--status-red); -} - .panel-app-action:disabled { opacity: 0.5; cursor: not-allowed; } +/* Panel app context menu */ +.panel-app-menu { + position: fixed; + z-index: 500; + background: var(--bg-titlebar); + border: 1px solid var(--border-structural); + border-radius: var(--radius-md); + padding: 4px; + min-width: 140px; + box-shadow: 0 8px 32px rgba(0,0,0,0.4); +} + +.panel-app-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + min-height: 36px; + background: none; + border: none; + width: 100%; + text-align: left; + color: var(--text-primary); + font-family: var(--font-ui); + font-size: 13px; + cursor: pointer; + border-radius: var(--radius-sm); +} + +.panel-app-menu-item:hover { + background: var(--accent-hover); +} + +.panel-app-menu-item.danger { + color: var(--status-red); +} + +.panel-app-menu-item:disabled { + color: var(--text-muted); + cursor: not-allowed; + opacity: 0.5; +} + +.panel-app-menu-item:disabled:hover { + background: none; +} + .panel-app-add { display: flex; align-items: center; diff --git a/frontend/js/atlus.js b/frontend/js/atlus.js index 1804782..4b93621 100644 --- a/frontend/js/atlus.js +++ b/frontend/js/atlus.js @@ -334,6 +334,17 @@ // ===================================================================== // Panel — Applications (docked GUI apps) // ===================================================================== + // Active panel app menu reference (for cleanup) + let _panelAppMenu = null; + + function _closePanelAppMenu() { + if (_panelAppMenu) { + _panelAppMenu.remove(); + _panelAppMenu = null; + } + document.removeEventListener('click', _closePanelAppMenu); + } + async function loadPanelApps() { const container = $('#panelApps'); if (!container) return; @@ -353,7 +364,10 @@ try { const runRes = await Atlus.apiFetch('/api/display/apps'); - if (runRes && runRes.ok) runningApps = await runRes.json(); + if (runRes && runRes.ok) { + const data = await runRes.json(); + runningApps = Array.isArray(data) ? data : (data.apps || []); + } } catch (e) { /* display may be unavailable */ } // Ensure all GUI apps are registered as Atlus app modules @@ -382,21 +396,20 @@ const running = Array.isArray(runningApps) ? runningApps.find(r => r.command === app.command && r.alive) : null; + // Determine LED status + let dotClass = ''; + if (running) { + dotClass = running.status || (running.window_id ? 'running' : 'starting'); + } + const row = document.createElement('div'); row.className = 'panel-app-row'; - const args = Array.isArray(app.args) ? app.args.join(' ') : ''; row.innerHTML = ` -
+
${app.icon || '🖥'} ${app.name || app.command} - + `; // Click name/icon to open in tab @@ -407,33 +420,12 @@ openApp('gui-' + app.id); }); - // Action button (launch/stop) - const actionBtn = row.querySelector('.panel-app-action'); - actionBtn.addEventListener('click', async (e) => { + // Context menu button + const menuBtn = row.querySelector('.panel-app-action'); + menuBtn.addEventListener('click', (e) => { e.stopPropagation(); - const isRunning = actionBtn.classList.contains('stop'); - actionBtn.disabled = true; - - try { - if (isRunning) { - const id = actionBtn.dataset.appId; - if (id) await Atlus.apiFetch(`/api/display/apps/${id}`, { method: 'DELETE' }); - } else { - const a = actionBtn.dataset.args ? actionBtn.dataset.args.split(' ').filter(Boolean) : []; - await Atlus.apiFetch('/api/display/apps', { - method: 'POST', - body: { - command: actionBtn.dataset.command, - title: actionBtn.dataset.title, - args: a, - target_fps: parseInt(actionBtn.dataset.fps) || 10, - }, - }); - } - } catch (err) { - console.warn('Panel app action failed', err); - } - setTimeout(loadPanelApps, 1500); + _closePanelAppMenu(); + _showPanelAppMenu(menuBtn, app, running); }); container.appendChild(row); @@ -457,6 +449,86 @@ container.appendChild(addBtn); } + function _showPanelAppMenu(anchor, appCfg, running) { + const menu = document.createElement('div'); + menu.className = 'panel-app-menu'; + + const isRunning = running && running.alive; + const args = Array.isArray(appCfg.args) ? appCfg.args : []; + + // Start + const startItem = document.createElement('button'); + startItem.className = 'panel-app-menu-item'; + startItem.textContent = '▶ Start'; + startItem.disabled = !!isRunning; + startItem.addEventListener('click', async () => { + _closePanelAppMenu(); + try { + await Atlus.apiFetch('/api/display/apps', { + method: 'POST', + body: { + command: appCfg.command, + title: appCfg.name || appCfg.command, + args: args, + target_fps: appCfg.target_fps || 10, + }, + }); + } catch (err) { + console.warn('Start app failed', err); + } + setTimeout(loadPanelApps, 1500); + }); + menu.appendChild(startItem); + + // Stop + const stopItem = document.createElement('button'); + stopItem.className = 'panel-app-menu-item danger'; + stopItem.textContent = '■ Stop'; + stopItem.disabled = !isRunning; + stopItem.addEventListener('click', async () => { + _closePanelAppMenu(); + try { + if (running.app_id) { + await Atlus.apiFetch(`/api/display/apps/${running.app_id}`, { method: 'DELETE' }); + } + } catch (err) { + console.warn('Stop app failed', err); + } + setTimeout(loadPanelApps, 1500); + }); + menu.appendChild(stopItem); + + // Restart + const restartItem = document.createElement('button'); + restartItem.className = 'panel-app-menu-item'; + restartItem.textContent = '↻ Restart'; + restartItem.disabled = !isRunning; + restartItem.addEventListener('click', async () => { + _closePanelAppMenu(); + try { + if (running.app_id) { + await Atlus.apiFetch(`/api/display/apps/${running.app_id}/restart`, { method: 'POST' }); + } + } catch (err) { + console.warn('Restart app failed', err); + } + setTimeout(loadPanelApps, 2500); + }); + menu.appendChild(restartItem); + + // Position the menu + const rect = anchor.getBoundingClientRect(); + menu.style.top = rect.bottom + 4 + 'px'; + menu.style.right = (window.innerWidth - rect.right) + 'px'; + document.body.appendChild(menu); + _panelAppMenu = menu; + + // Close on outside click (deferred so this click doesn't trigger it) + setTimeout(() => { + document.addEventListener('click', _closePanelAppMenu); + }, 0); + } + // ===================================================================== // Panel — Update checker // =====================================================================