diff --git a/backend/display.py b/backend/display.py index 913d750..27d9571 100644 --- a/backend/display.py +++ b/backend/display.py @@ -155,14 +155,14 @@ class ManagedGuiApp: async def _capture_loop(self): """Background: capture window pixmap → JPEG → fan-out.""" - # Wait for window to appear - for attempt in range(20): + # Wait for window to appear (up to ~30s for heavy apps like Electron) + for attempt in range(30): if not self.alive: return await self._discover_window() if self.window_id: break - await asyncio.sleep(0.5 * (1 + attempt * 0.2)) + await asyncio.sleep(1.0) if not self.window_id: log.warning("No window found for app %s (%s)", self.app_id, self.command) @@ -269,24 +269,88 @@ class ManagedGuiApp: return None async def _discover_window(self): - """Find X11 window ID for this app's process.""" + """Find X11 window ID for this app's process. + + Tries multiple strategies: + 1. Direct PID match (works for simple apps) + 2. Child PID match (walks /proc for descendants — Electron apps fork) + 3. Any visible window on the display (last resort) + """ if not XDOTOOL_BIN or not self.process: return + env = self._display_env() + + # Strategy 1: direct PID match + wid = await self._xdotool_search("--pid", str(self.process.pid), env=env) + if wid: + self.window_id = wid + log.debug("Window %d found by PID %d for %s", wid, self.process.pid, self.app_id) + return + + # Strategy 2: search child PIDs (Electron/forking apps) + try: + child_pids = self._get_descendant_pids(self.process.pid) + for cpid in child_pids: + wid = await self._xdotool_search("--pid", str(cpid), env=env) + if wid: + self.window_id = wid + log.debug("Window %d found by child PID %d for %s", wid, cpid, self.app_id) + return + 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 + + @staticmethod + def _get_descendant_pids(parent_pid: int) -> list[int]: + """Walk /proc to find all descendant PIDs of a process.""" + children = [] + try: + for entry in os.listdir("/proc"): + if not entry.isdigit(): + continue + try: + with open(f"/proc/{entry}/stat") as f: + stat = f.read() + # Field 4 is PPID + parts = stat.split() + ppid = int(parts[3]) + pid = int(entry) + if ppid == parent_pid: + children.append(pid) + except (OSError, IndexError, ValueError): + continue + except OSError: + pass + # Recurse into children + grandchildren = [] + for cpid in children: + grandchildren.extend(ManagedGuiApp._get_descendant_pids(cpid)) + return children + grandchildren + + async def _xdotool_search(self, *args, env=None) -> Optional[int]: + """Run xdotool search and return first window ID, or None.""" try: proc = await asyncio.create_subprocess_exec( - XDOTOOL_BIN, "search", "--pid", str(self.process.pid), + XDOTOOL_BIN, "search", *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - env=self._display_env(), + env=env, ) stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5) lines = stdout.decode().strip().splitlines() if lines: - self.window_id = int(lines[0]) - log.debug("Discovered window %d for app %s (pid %d)", - self.window_id, self.app_id, self.process.pid) + return int(lines[0]) except Exception: pass + return None # ---- Input forwarding ----