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:
parent
da8f0326f8
commit
1467d07bc4
4 changed files with 249 additions and 53 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =====================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue