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):
|
async def _capture_loop(self):
|
||||||
"""Background: capture window pixmap → JPEG → fan-out."""
|
"""Background: capture window pixmap → JPEG → fan-out."""
|
||||||
# Wait for window to appear (up to ~30s for heavy apps like Electron)
|
# Wait for window to appear (up to ~60s for heavy apps like Electron)
|
||||||
for attempt in range(30):
|
for attempt in range(60):
|
||||||
if not self.alive:
|
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
|
return
|
||||||
await self._discover_window()
|
await self._discover_window()
|
||||||
if self.window_id:
|
if self.window_id:
|
||||||
break
|
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)
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
if not self.window_id:
|
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
|
# Notify any attached viewers
|
||||||
for ws in list(self._websockets):
|
for ws in list(self._websockets):
|
||||||
try:
|
try:
|
||||||
|
|
@ -299,14 +312,23 @@ class ManagedGuiApp:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Strategy 3: search by window name containing the app command/title
|
# Strategy 3: search by window name or WM_CLASS
|
||||||
for query in (self.title, self.command):
|
for flag in ("--name", "--class"):
|
||||||
if query:
|
for query in (self.title, self.command):
|
||||||
wid = await self._xdotool_search("--name", query, env=env)
|
if query:
|
||||||
if wid:
|
wid = await self._xdotool_search(flag, query, env=env)
|
||||||
self.window_id = wid
|
if wid:
|
||||||
log.debug("Window %d found by name '%s' for %s", wid, query, self.app_id)
|
self.window_id = wid
|
||||||
return
|
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
|
@staticmethod
|
||||||
def _get_descendant_pids(parent_pid: int) -> list[int]:
|
def _get_descendant_pids(parent_pid: int) -> list[int]:
|
||||||
|
|
@ -450,12 +472,26 @@ class ManagedGuiApp:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
def to_dict(self) -> dict:
|
||||||
d = {
|
d = {
|
||||||
"app_id": self.app_id,
|
"app_id": self.app_id,
|
||||||
"command": self.command,
|
"command": self.command,
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
"alive": self.alive,
|
"alive": self.alive,
|
||||||
|
"status": self.status,
|
||||||
"pid": self.process.pid if self.process else None,
|
"pid": self.process.pid if self.process else None,
|
||||||
"window_id": self.window_id,
|
"window_id": self.window_id,
|
||||||
"streaming": self._streaming,
|
"streaming": self._streaming,
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,36 @@ async def close_app(app_id: str, user: str = Depends(get_current_user)):
|
||||||
return {"ok": True}
|
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")
|
@router.get("/status")
|
||||||
async def display_status(user: str = Depends(get_current_user)):
|
async def display_status(user: str = Depends(get_current_user)):
|
||||||
"""Check display system availability."""
|
"""Check display system availability."""
|
||||||
|
|
|
||||||
|
|
@ -160,10 +160,25 @@
|
||||||
transition: background var(--transition-fast);
|
transition: background var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-app-dot.active {
|
.panel-app-dot.running {
|
||||||
background: var(--status-green);
|
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 {
|
.panel-app-icon {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
@ -196,7 +211,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -205,15 +220,58 @@
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-app-action.stop:hover {
|
|
||||||
color: var(--status-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-app-action:disabled {
|
.panel-app-action:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
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 {
|
.panel-app-add {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -334,6 +334,17 @@
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Panel — Applications (docked GUI apps)
|
// 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() {
|
async function loadPanelApps() {
|
||||||
const container = $('#panelApps');
|
const container = $('#panelApps');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
@ -353,7 +364,10 @@
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const runRes = await Atlus.apiFetch('/api/display/apps');
|
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 */ }
|
} catch (e) { /* display may be unavailable */ }
|
||||||
|
|
||||||
// Ensure all GUI apps are registered as Atlus app modules
|
// Ensure all GUI apps are registered as Atlus app modules
|
||||||
|
|
@ -382,21 +396,20 @@
|
||||||
const running = Array.isArray(runningApps)
|
const running = Array.isArray(runningApps)
|
||||||
? runningApps.find(r => r.command === app.command && r.alive)
|
? runningApps.find(r => r.command === app.command && r.alive)
|
||||||
: null;
|
: null;
|
||||||
|
// Determine LED status
|
||||||
|
let dotClass = '';
|
||||||
|
if (running) {
|
||||||
|
dotClass = running.status || (running.window_id ? 'running' : 'starting');
|
||||||
|
}
|
||||||
|
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'panel-app-row';
|
row.className = 'panel-app-row';
|
||||||
|
|
||||||
const args = Array.isArray(app.args) ? app.args.join(' ') : '';
|
|
||||||
row.innerHTML = `
|
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-icon">${app.icon || '🖥'}</span>
|
||||||
<span class="panel-app-name">${app.name || app.command}</span>
|
<span class="panel-app-name">${app.name || app.command}</span>
|
||||||
<button class="panel-app-action ${running ? 'stop' : 'start'}"
|
<button class="panel-app-action" title="Actions">⋮</button>
|
||||||
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>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Click name/icon to open in tab
|
// Click name/icon to open in tab
|
||||||
|
|
@ -407,33 +420,12 @@
|
||||||
openApp('gui-' + app.id);
|
openApp('gui-' + app.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Action button (launch/stop)
|
// Context menu button
|
||||||
const actionBtn = row.querySelector('.panel-app-action');
|
const menuBtn = row.querySelector('.panel-app-action');
|
||||||
actionBtn.addEventListener('click', async (e) => {
|
menuBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const isRunning = actionBtn.classList.contains('stop');
|
_closePanelAppMenu();
|
||||||
actionBtn.disabled = true;
|
_showPanelAppMenu(menuBtn, app, running);
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
|
|
@ -457,6 +449,86 @@
|
||||||
container.appendChild(addBtn);
|
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
|
// Panel — Update checker
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue