Improve window discovery, add app context menu with Start/Stop/Restart

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 <noreply@anthropic.com>
This commit is contained in:
roberts 2026-03-15 01:05:19 -05:00
parent da8f0326f8
commit 1467d07bc4
4 changed files with 249 additions and 53 deletions

View file

@ -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,13 +312,22 @@ class ManagedGuiApp:
except Exception:
pass
# Strategy 3: search by window name containing the app command/title
# 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("--name", query, env=env)
wid = await self._xdotool_search(flag, query, env=env)
if wid:
self.window_id = wid
log.debug("Window %d found by name '%s' for %s", wid, query, self.app_id)
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
@ -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,

View file

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

View file

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

View file

@ -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 = `
<div class="panel-app-dot ${running ? 'active' : ''}"></div>
<div class="panel-app-dot ${dotClass}"></div>
<span class="panel-app-icon">${app.icon || '🖥'}</span>
<span class="panel-app-name">${app.name || app.command}</span>
<button class="panel-app-action ${running ? 'stop' : 'start'}"
data-command="${app.command}" data-app-id="${running ? running.app_id : ''}"
data-gui-id="${app.id}" data-title="${app.name || app.command}"
data-args="${args}" data-fps="${app.target_fps || 10}"
title="${running ? 'Stop' : 'Launch'}">
${running ? '■' : '▶'}
</button>
<button class="panel-app-action" title="Actions"></button>
`;
// 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
// =====================================================================