Fix window discovery for Electron/forking apps like Nextcloud

xdotool search --pid only matches the exact launched PID, but
Electron apps fork into child processes that own the actual window.
Now tries: direct PID → descendant PIDs via /proc → window name match.
Also increases retry timeout to 30s for heavy apps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
roberts 2026-03-15 00:58:28 -05:00
parent 9fee17af17
commit da8f0326f8

View file

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