@empir3/empir3-bridge 0.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +1531 -0
  2. package/CODE_OF_CONDUCT.md +9 -0
  3. package/CONTRIBUTING.md +75 -0
  4. package/LICENSE +21 -0
  5. package/README.md +464 -0
  6. package/SECURITY.md +130 -0
  7. package/assets/accuracy-lab.html +2639 -0
  8. package/assets/api-clis-real.jpg +0 -0
  9. package/assets/bridge-console-hero.jpg +0 -0
  10. package/assets/browser-privacy.svg +151 -0
  11. package/assets/demo-orchestration.svg +74 -0
  12. package/assets/desktop-select-region.jpg +0 -0
  13. package/assets/in-page-chat.gif +0 -0
  14. package/assets/orchestration-hero.svg +126 -0
  15. package/assets/social-preview.png +0 -0
  16. package/assets/zara-accent.png +0 -0
  17. package/build/bootstrap.js +548 -0
  18. package/build/build.js +680 -0
  19. package/build/payload-entry.js +649 -0
  20. package/build/payload-signing-pub.json +7 -0
  21. package/docs/AGENT_GUIDE.md +259 -0
  22. package/docs/RELEASE.md +106 -0
  23. package/docs/SAFETY.md +112 -0
  24. package/docs/TESTING.md +181 -0
  25. package/installer/server.js +231 -0
  26. package/installer/ui/app.js +278 -0
  27. package/installer/ui/index.html +24 -0
  28. package/installer/ui/styles.css +146 -0
  29. package/package.json +95 -0
  30. package/scripts/bootstrap-e2e.mjs +650 -0
  31. package/scripts/certify-bridge.mjs +636 -0
  32. package/scripts/check-companion-surface.mjs +118 -0
  33. package/scripts/extract-welcome.mjs +64 -0
  34. package/scripts/gh-route-handler-check.mjs +57 -0
  35. package/scripts/gh-wire-test.mjs +107 -0
  36. package/scripts/publish-downloads.mjs +180 -0
  37. package/scripts/smoke-all-tools.mjs +509 -0
  38. package/scripts/smoke-live-bridge.mjs +696 -0
  39. package/scripts/splice-welcome.mjs +63 -0
  40. package/scripts/welcome-body.txt +2733 -0
  41. package/src/anthropic-client.ts +192 -0
  42. package/src/bootstrap-exe.ts +69 -0
  43. package/src/bridge.ts +2444 -0
  44. package/src/chat.ts +345 -0
  45. package/src/cli-runner.ts +239 -0
  46. package/src/cli.ts +649 -0
  47. package/src/config.ts +199 -0
  48. package/src/desktop-overlay.ps1 +121 -0
  49. package/src/executable-resolver.ts +330 -0
  50. package/src/handlers/agy-imagegen.ts +179 -0
  51. package/src/handlers/github-cli.ts +399 -0
  52. package/src/handlers/higgsfield-cli.ts +783 -0
  53. package/src/launch.js +337 -0
  54. package/src/mcp-server.ts +1265 -0
  55. package/src/pair-claim.ts +218 -0
  56. package/src/payload-daemon.ts +168 -0
  57. package/src/server.ts +21036 -0
  58. package/src/tool-defaults.ts +230 -0
  59. package/src/update-check.js +136 -0
  60. package/tray/build.py +76 -0
  61. package/tray/requirements.txt +2 -0
  62. package/tray/tray.py +1843 -0
package/tray/tray.py ADDED
@@ -0,0 +1,1843 @@
1
+ """
2
+ Empir3 Bridge — System Tray wrapper.
3
+
4
+ A thin Python wrapper that gives the headless Node bridge daemon a real
5
+ desktop surface: tray icon, right-click menu, status indicator, log viewer.
6
+
7
+ Architecture:
8
+ - This process owns the tray. The bridge daemon is a child.
9
+ - Spawns `Empir3Setup.exe --daemon-real` (or `node bridge/index.js` in dev)
10
+ as a hidden subprocess. Restarts on crash with exponential backoff.
11
+ - Polls http://127.0.0.1:<port>/api/relay-status every 2s on each of the
12
+ bridge's candidate ports (3006/3106/3206/3306) to find the live daemon
13
+ and read its connection state.
14
+ - Surfaces connection state via icon color (green=connected, red=down)
15
+ and a disabled "● Connected" / "○ Disconnected" menu line.
16
+
17
+ Menu:
18
+ Empir3 — <device name> (header, disabled)
19
+ ● Connected · <user email> (status, disabled)
20
+ ──────────────────────────────
21
+ Open app.empir3.com browser opens https://app.empir3.com
22
+ Open log opens %APPDATA%/Empir3/bridge.log
23
+ ──────────────────────────────
24
+ Reconnect daemon SIGTERM child + respawn
25
+ Sign out delete bridge-auth.json + open installer
26
+ ──────────────────────────────
27
+ Quit Empir3 kill child, exit tray
28
+
29
+ Dev:
30
+ python bridge/tray/tray.py
31
+ → spawns `node <repo>/bridge/index.js` as child, polls local ports.
32
+
33
+ Production (PyInstaller'd as Empir3Tray.exe):
34
+ Empir3Tray.exe
35
+ → spawns `Empir3Setup.exe --daemon-real` as child (same dir as the tray
36
+ exe is the install dir; bootstrapper sits next to us). The bootstrapper
37
+ then runs the unpacked daemon from the cached payload.
38
+ """
39
+
40
+ import atexit
41
+ import json
42
+ import logging
43
+ import os
44
+ import subprocess
45
+ import sys
46
+ import threading
47
+ import time
48
+ import webbrowser
49
+ from pathlib import Path
50
+ from typing import Optional
51
+ from urllib import error as urlerror
52
+ from urllib import request as urlrequest
53
+
54
+ from PIL import Image, ImageDraw, ImageFont
55
+ import pystray
56
+
57
+ # Windows Job Object — assign every child daemon to this so a hard-kill of the
58
+ # tray (taskkill /F, OS shutdown) takes the daemon down with it. Without this
59
+ # the supervisor's children outlive the tray and squat on the bridge ports.
60
+ _JOB_HANDLE = None
61
+ _INSTANCE_MUTEX_HANDLE = None
62
+ if sys.platform == 'win32':
63
+ try:
64
+ import ctypes
65
+ from ctypes import wintypes
66
+
67
+ _kernel32 = ctypes.windll.kernel32
68
+ _JOB_HANDLE = _kernel32.CreateJobObjectW(None, None)
69
+ if _JOB_HANDLE:
70
+ # JOBOBJECT_EXTENDED_LIMIT_INFORMATION (44 + 48 + 24 = 116 bytes on x64).
71
+ # Easiest: use the structured layout from the SDK.
72
+ class _IO_COUNTERS(ctypes.Structure):
73
+ _fields_ = [
74
+ ('ReadOperationCount', ctypes.c_ulonglong),
75
+ ('WriteOperationCount', ctypes.c_ulonglong),
76
+ ('OtherOperationCount', ctypes.c_ulonglong),
77
+ ('ReadTransferCount', ctypes.c_ulonglong),
78
+ ('WriteTransferCount', ctypes.c_ulonglong),
79
+ ('OtherTransferCount', ctypes.c_ulonglong),
80
+ ]
81
+
82
+ class _JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
83
+ _fields_ = [
84
+ ('PerProcessUserTimeLimit', wintypes.LARGE_INTEGER),
85
+ ('PerJobUserTimeLimit', wintypes.LARGE_INTEGER),
86
+ ('LimitFlags', wintypes.DWORD),
87
+ ('MinimumWorkingSetSize', ctypes.c_size_t),
88
+ ('MaximumWorkingSetSize', ctypes.c_size_t),
89
+ ('ActiveProcessLimit', wintypes.DWORD),
90
+ ('Affinity', ctypes.c_size_t),
91
+ ('PriorityClass', wintypes.DWORD),
92
+ ('SchedulingClass', wintypes.DWORD),
93
+ ]
94
+
95
+ class _JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure):
96
+ _fields_ = [
97
+ ('BasicLimitInformation', _JOBOBJECT_BASIC_LIMIT_INFORMATION),
98
+ ('IoInfo', _IO_COUNTERS),
99
+ ('ProcessMemoryLimit', ctypes.c_size_t),
100
+ ('JobMemoryLimit', ctypes.c_size_t),
101
+ ('PeakProcessMemoryUsed', ctypes.c_size_t),
102
+ ('PeakJobMemoryUsed', ctypes.c_size_t),
103
+ ]
104
+
105
+ _info = _JOBOBJECT_EXTENDED_LIMIT_INFORMATION()
106
+ _info.BasicLimitInformation.LimitFlags = 0x00002000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
107
+ _kernel32.SetInformationJobObject(
108
+ _JOB_HANDLE,
109
+ 9, # JobObjectExtendedLimitInformation
110
+ ctypes.byref(_info),
111
+ ctypes.sizeof(_info),
112
+ )
113
+ except Exception as _e:
114
+ _JOB_HANDLE = None
115
+
116
+
117
+ def _attach_to_job(pid: int) -> bool:
118
+ """Add a child PID to our Job Object so it dies with us on hard-kill."""
119
+ if not _JOB_HANDLE or sys.platform != 'win32':
120
+ return False
121
+ try:
122
+ import ctypes
123
+ kernel32 = ctypes.windll.kernel32
124
+ # OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, FALSE, pid)
125
+ h = kernel32.OpenProcess(0x0100 | 0x0001, False, pid)
126
+ if not h:
127
+ return False
128
+ ok = bool(kernel32.AssignProcessToJobObject(_JOB_HANDLE, h))
129
+ kernel32.CloseHandle(h)
130
+ return ok
131
+ except Exception:
132
+ return False
133
+
134
+ # ── Paths + config ─────────────────────────────────────────────────────
135
+
136
+ APPDATA = Path(os.environ.get('APPDATA') or Path.home() / '.empir3') / 'Empir3'
137
+ APPDATA.mkdir(parents=True, exist_ok=True)
138
+ TRAY_LOG = APPDATA / 'tray.log'
139
+ BRIDGE_LOG = APPDATA / 'bridge.log'
140
+ AUTH_FILE = APPDATA / 'bridge-auth.json'
141
+ SETTINGS_FILE = APPDATA / 'bridge-settings.json'
142
+ BOOTSTRAP_POINTER_FILE = APPDATA / 'bridge-bootstrap.json'
143
+ NONCE_FILE = Path.home() / '.empir3-bridge' / 'nonce'
144
+ # focus.json is written by the bridge daemon whenever an agent-focus region
145
+ # is active (via desktop_select_region); deleted on desktop_release_focus
146
+ # or TTL expiry. Tray reads it to decide whether to show "Release focus".
147
+ DESKTOP_FOCUS_FILE = Path.home() / '.empir3-bridge' / 'payload' / 'feedback' / 'desktop' / 'focus.json'
148
+ DESKTOP_POINTER_FILE = Path.home() / '.empir3-bridge' / 'payload' / 'feedback' / 'desktop' / 'pointer.json'
149
+
150
+ # Device-level permission categories. Mirrors bridge/permissions.js — keep
151
+ # the category names + defaults in sync. Writing flips the settings file
152
+ # directly; the daemon re-reads on every tool dispatch so changes take
153
+ # effect immediately, no daemon restart needed.
154
+ def _read_settings() -> dict:
155
+ try:
156
+ return json.loads(SETTINGS_FILE.read_text(encoding='utf-8'))
157
+ except (OSError, json.JSONDecodeError):
158
+ return {}
159
+
160
+
161
+ def _write_settings(state: dict) -> None:
162
+ SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
163
+ SETTINGS_FILE.write_text(json.dumps(state, indent=2), encoding='utf-8')
164
+
165
+
166
+ # ── Auto-update setting ────────────────────────────────────────────────
167
+ #
168
+ # Stored in bridge-settings.json alongside permissions. Default ON: the
169
+ # bridge auto-applies new payload versions in the background. Off: tray
170
+ # notifies but waits for explicit "Check for updates" click.
171
+
172
+ AUTO_UPDATE_DEFAULT = True
173
+
174
+ def get_auto_update() -> bool:
175
+ saved = _read_settings()
176
+ val = saved.get('autoUpdate')
177
+ if val is None:
178
+ return AUTO_UPDATE_DEFAULT
179
+ return bool(val)
180
+
181
+
182
+ def set_auto_update(enabled: bool) -> None:
183
+ saved = _read_settings()
184
+ saved['autoUpdate'] = bool(enabled)
185
+ _write_settings(saved)
186
+ logger.info('auto-update set: %s', enabled)
187
+
188
+
189
+ # ── Handler-family toggles (generic schema) ────────────────────────────
190
+ #
191
+ # settings.handlers.<name>.enabled. The bridge daemon + the MCP shim both
192
+ # read this on every dispatch / startup respectively. Same pattern lets
193
+ # future handlers (Replicate, Runway, Suno) drop in with no schema
194
+ # migration — see TOOL_FAMILY in src/tool-defaults.ts.
195
+
196
+ def get_handler_enabled(name: str) -> bool:
197
+ saved = _read_settings()
198
+ return bool((saved.get('handlers') or {}).get(name, {}).get('enabled'))
199
+
200
+
201
+ def set_handler_enabled(name: str, enabled: bool) -> None:
202
+ saved = _read_settings()
203
+ handlers = dict(saved.get('handlers') or {})
204
+ entry = dict(handlers.get(name) or {})
205
+ entry['enabled'] = bool(enabled)
206
+ handlers[name] = entry
207
+ saved['handlers'] = handlers
208
+ _write_settings(saved)
209
+ logger.info('handler %s set: %s', name, enabled)
210
+
211
+
212
+ def _bootstrap_from_pointer() -> Optional[str]:
213
+ try:
214
+ data = json.loads(BOOTSTRAP_POINTER_FILE.read_text(encoding='utf-8'))
215
+ candidate = str(data.get('bootstrapPath') or '').strip()
216
+ if candidate and Path(candidate).exists():
217
+ return candidate
218
+ except (OSError, json.JSONDecodeError):
219
+ pass
220
+ return None
221
+
222
+
223
+ def _bootstrap_from_autostart() -> Optional[str]:
224
+ if sys.platform != 'win32':
225
+ return None
226
+ try:
227
+ result = subprocess.run(
228
+ ['reg', 'query', r'HKCU\Software\Microsoft\Windows\CurrentVersion\Run', '/v', 'Empir3Bridge'],
229
+ capture_output=True,
230
+ text=True,
231
+ creationflags=CREATE_NO_WINDOW,
232
+ timeout=3,
233
+ )
234
+ if result.returncode != 0:
235
+ return None
236
+ for line in result.stdout.splitlines():
237
+ if 'REG_SZ' not in line:
238
+ continue
239
+ raw = line.split('REG_SZ', 1)[1].strip()
240
+ if raw.startswith('"'):
241
+ candidate = raw.split('"', 2)[1]
242
+ else:
243
+ candidate = raw.split(' --', 1)[0].strip()
244
+ if candidate and Path(candidate).exists():
245
+ return candidate
246
+ except Exception:
247
+ return None
248
+ return None
249
+
250
+
251
+ def resolve_bootstrap_exe() -> Optional[str]:
252
+ """Locate Empir3Setup.exe regardless of where the tray itself runs from.
253
+
254
+ The frozen tray lives inside payload/<version>/Empir3Tray.exe, but the
255
+ bootstrapper exe usually sits in %APPDATA%/Empir3/ — NOT next to the tray.
256
+ So the parent-dir sibling check is only a last resort. Resolution order
257
+ mirrors DaemonSupervisor._spawn_args so the daemon-spawn and uninstall
258
+ paths agree on the same bootstrapper:
259
+
260
+ 1. EMPIR3_BOOTSTRAP_EXE env (set by payload-entry.js when it spawns us)
261
+ 2. bridge-bootstrap.json pointer (written on every daemon launch)
262
+ 3. HKCU autostart "Empir3Bridge" value
263
+ 4. sibling next to a frozen Empir3Tray.exe (legacy / same-dir installs)
264
+
265
+ Returns the absolute path as a string, or None when nothing is found
266
+ (e.g. dev runs where the bridge is launched via `node index.js`).
267
+ """
268
+ from_env = os.environ.get('EMPIR3_BOOTSTRAP_EXE', '').strip()
269
+ if from_env and Path(from_env).exists():
270
+ return from_env
271
+
272
+ from_pointer = _bootstrap_from_pointer()
273
+ if from_pointer:
274
+ return from_pointer
275
+
276
+ from_autostart = _bootstrap_from_autostart()
277
+ if from_autostart:
278
+ return from_autostart
279
+
280
+ if getattr(sys, 'frozen', False):
281
+ sibling = Path(sys.executable).parent / 'Empir3Setup.exe'
282
+ if sibling.exists():
283
+ return str(sibling)
284
+
285
+ return None
286
+
287
+
288
+ # ── Native dialogs ─────────────────────────────────────────────────────
289
+ #
290
+ # The tray runs --windowed (no console), so the only way to talk to the user
291
+ # synchronously is a real Win32 MessageBox. Used to gate the destructive
292
+ # uninstall behind an explicit Yes and to surface failures instead of
293
+ # silently doing nothing.
294
+
295
+ _MB_OK = 0x0
296
+ _MB_YESNO = 0x4
297
+ _MB_ICONERROR = 0x10
298
+ _MB_ICONWARNING = 0x30
299
+ _MB_DEFBUTTON2 = 0x100 # default the focus to the *second* button (No)
300
+ _MB_SETFOREGROUND = 0x10000
301
+ _MB_TOPMOST = 0x40000
302
+ _IDYES = 6
303
+
304
+
305
+ def _message_box(text: str, title: str, flags: int) -> int:
306
+ """Show a modal Win32 MessageBox; return the clicked-button code.
307
+ Returns 0 on non-Windows or on failure (caller decides what 0 means).
308
+ Safe to call from any thread — MessageBoxW pumps its own loop."""
309
+ if sys.platform != 'win32':
310
+ return 0
311
+ try:
312
+ import ctypes
313
+ return int(ctypes.windll.user32.MessageBoxW(None, text, title, flags))
314
+ except Exception as e:
315
+ logger.warning('message box failed: %s', e)
316
+ return 0
317
+
318
+
319
+ def _confirm_uninstall() -> bool:
320
+ """Yes/No gate shown before anything is deleted. Defaults to No. On a
321
+ dialog failure we return False (abort) rather than wipe unacknowledged —
322
+ the whole point of this prompt is that uninstall never happens silently."""
323
+ res = _message_box(
324
+ 'Uninstall Empir3 Bridge?\n\n'
325
+ 'This stops the bridge, removes it from Windows startup, and deletes '
326
+ 'its sign-in, settings, cached data, and browser profile from this '
327
+ 'computer.\n\n'
328
+ 'This cannot be undone.',
329
+ 'Uninstall Empir3',
330
+ _MB_YESNO | _MB_ICONWARNING | _MB_DEFBUTTON2 | _MB_SETFOREGROUND | _MB_TOPMOST,
331
+ )
332
+ return res == _IDYES
333
+
334
+
335
+ # ── Version helpers ────────────────────────────────────────────────────
336
+ #
337
+ # Two distinct versions matter to the user:
338
+ # - DAEMON — what's running right now (read from /api/relay-status)
339
+ # - TRAY — the .exe currently driving the menu (frozen → parent dir
340
+ # name; dev → 'dev'). Only differs from daemon if the user
341
+ # hasn't restarted the tray since the last payload update.
342
+
343
+ def get_running_tray_version() -> str:
344
+ """Version stamp on disk next to THIS tray exe. 'dev' when unfrozen."""
345
+ if not getattr(sys, 'frozen', False):
346
+ return 'dev'
347
+ try:
348
+ # Frozen tray sits inside payload/<version>/Empir3Tray.exe — the
349
+ # bundled .payload-version next to it is the version this binary
350
+ # was built for.
351
+ stamp = Path(sys.executable).parent / '.payload-version'
352
+ if stamp.exists():
353
+ return stamp.read_text(encoding='utf-8').strip()
354
+ # PyInstaller --onefile extracts to a temp dir; the parent there
355
+ # is %TEMP%/_MEIxxxxx, not the install dir. Fall back to the
356
+ # active payload pointer.
357
+ if PAYLOAD_VERSION_FILE.exists():
358
+ return PAYLOAD_VERSION_FILE.read_text(encoding='utf-8').strip()
359
+ except Exception:
360
+ pass
361
+ return 'unknown'
362
+
363
+
364
+ def get_active_payload_version() -> str:
365
+ """The payload .version pointer — what bootstrap thinks is current."""
366
+ active = 'unknown'
367
+ try:
368
+ if PAYLOAD_VERSION_FILE.exists():
369
+ active = PAYLOAD_VERSION_FILE.read_text(encoding='utf-8').strip()
370
+ except Exception:
371
+ pass
372
+
373
+ # If the user is already running a newer tray from payload/<version> but
374
+ # the pointer is stale, repair it. Otherwise update checks can restart the
375
+ # daemon into an older payload while the newer tray keeps the target dir
376
+ # locked.
377
+ tray_version = get_running_tray_version()
378
+ try:
379
+ if is_newer(tray_version, active) and (PAYLOAD_ROOT / tray_version / 'entry.js').exists():
380
+ PAYLOAD_ROOT.mkdir(parents=True, exist_ok=True)
381
+ PAYLOAD_VERSION_FILE.write_text(tray_version, encoding='utf-8')
382
+ logger.info('repaired active payload pointer: %s -> %s', active, tray_version)
383
+ return tray_version
384
+ except Exception as e:
385
+ logger.warning('active payload pointer repair failed: %s', e)
386
+
387
+ return active
388
+
389
+
390
+ def fetch_remote_manifest(timeout=5) -> Optional[dict]:
391
+ """GET the public manifest. Returns None on any failure (silent)."""
392
+ try:
393
+ req = urlrequest.Request(VERSION_MANIFEST_URL,
394
+ headers={'User-Agent': 'empir3-tray/1.0'})
395
+ with urlrequest.urlopen(req, timeout=timeout) as resp:
396
+ return json.loads(resp.read().decode('utf-8'))
397
+ except Exception as e:
398
+ logger.warning('manifest probe failed: %s', e)
399
+ return None
400
+
401
+
402
+ def _ver_tuple(s: str) -> tuple:
403
+ """Crude semver tuple for compare. 'dev'/'unknown' sort lowest."""
404
+ if not s or s in ('dev', 'unknown'):
405
+ return (-1,)
406
+ try:
407
+ return tuple(int(p) for p in s.split('.') if p.isdigit())
408
+ except Exception:
409
+ return (-1,)
410
+
411
+
412
+ def is_newer(remote: str, local: str) -> bool:
413
+ return _ver_tuple(remote) > _ver_tuple(local)
414
+
415
+ CANDIDATE_PORTS = [3006, 3106, 3206, 3306]
416
+ BRIDGE_CONTROL_PORTS = sorted(set(CANDIDATE_PORTS + [9867, 9222]))
417
+ STATUS_POLL_SEC = 4.0
418
+ # Daemon HTTP responses are usually <50ms but can stall for several seconds when
419
+ # the daemon's shared event loop is momentarily busy (CDP/overlay work against an
420
+ # open page, PS spawns, GC, WS reconnects). A slow-but-valid reply still proves the
421
+ # daemon is ALIVE, so the timeout must exceed the worst observed stall — otherwise
422
+ # every poll aborts as a TimeoutError and the tray falsely shows "Daemon not
423
+ # running" while the browser dashboard (which tolerates the slow reply) shows
424
+ # connected. Keep this well above the daemon's stall ceiling.
425
+ STATUS_HTTP_TIMEOUT_SEC = 10.0
426
+ # Require this many consecutive failed polls before we surface the daemon as
427
+ # disconnected. With STATUS_POLL_SEC=4s, FAILS=3 means ~12s of genuine misses
428
+ # before the menu flips — covers the longest real outage without flapping on a
429
+ # transient slow spell.
430
+ STATUS_DISCONNECT_AFTER_FAILS = 3
431
+ RESTART_BACKOFF_SEC = [3, 5, 10, 30, 60] # capped at last value
432
+ SERVER_URL = os.environ.get('EMPIR3_SERVER', 'https://app.empir3.com')
433
+ VERSION_MANIFEST_URL = f'{SERVER_URL}/downloads/bridge-version.json'
434
+ UPDATE_CHECK_INTERVAL_SEC = 30 * 60 # poll the manifest every 30 min
435
+ UPDATE_CHECK_INITIAL_DELAY_SEC = 60 # don't probe immediately on tray start
436
+
437
+ # Where the bootstrapper writes the active payload version. Source of truth
438
+ # for both "what daemon binary is on disk" (via .version pointer) and "what
439
+ # tray binary is currently running" (parent dir of sys.executable when frozen).
440
+ PAYLOAD_ROOT = Path.home() / '.empir3-bridge' / 'payload'
441
+ PAYLOAD_VERSION_FILE = PAYLOAD_ROOT / '.version'
442
+
443
+ # Hide the console window when spawning child processes on Windows.
444
+ CREATE_NO_WINDOW = 0x08000000 if sys.platform == 'win32' else 0
445
+
446
+ logging.basicConfig(
447
+ level=logging.INFO,
448
+ format='%(asctime)s %(levelname)s %(name)s %(message)s',
449
+ handlers=[
450
+ logging.FileHandler(TRAY_LOG, encoding='utf-8'),
451
+ logging.StreamHandler(sys.stdout),
452
+ ],
453
+ )
454
+ logger = logging.getLogger('empir3.tray')
455
+
456
+
457
+ def _json_from_powershell(script: str, timeout: float = 5.0):
458
+ if sys.platform != 'win32':
459
+ return None
460
+ try:
461
+ proc = subprocess.run(
462
+ ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script],
463
+ stdout=subprocess.PIPE,
464
+ stderr=subprocess.PIPE,
465
+ text=True,
466
+ timeout=timeout,
467
+ creationflags=CREATE_NO_WINDOW,
468
+ )
469
+ if proc.returncode != 0:
470
+ err = (proc.stderr or proc.stdout or '').strip()
471
+ if err:
472
+ logger.warning('powershell probe failed: %s', err[:500])
473
+ return None
474
+ text = (proc.stdout or '').strip()
475
+ if not text:
476
+ return None
477
+ return json.loads(text)
478
+ except Exception as e:
479
+ logger.warning('powershell probe threw: %s', e)
480
+ return None
481
+
482
+
483
+ def _as_list(value) -> list:
484
+ if value is None:
485
+ return []
486
+ return value if isinstance(value, list) else [value]
487
+
488
+
489
+ def _process_infos_for_pids(pids: set[int]) -> list[dict]:
490
+ if not pids or sys.platform != 'win32':
491
+ return []
492
+ pid_list = ','.join(str(int(pid)) for pid in sorted(pids) if int(pid) > 0)
493
+ if not pid_list:
494
+ return []
495
+ script = (
496
+ f'$pids=@({pid_list}); '
497
+ 'Get-CimInstance Win32_Process | '
498
+ 'Where-Object { $pids -contains $_.ProcessId } | '
499
+ 'Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine | '
500
+ 'ConvertTo-Json -Compress'
501
+ )
502
+ return [p for p in _as_list(_json_from_powershell(script)) if isinstance(p, dict)]
503
+
504
+
505
+ def _bridge_port_owner_pids() -> set[int]:
506
+ if sys.platform != 'win32':
507
+ return set()
508
+ ports = ','.join(str(p) for p in BRIDGE_CONTROL_PORTS)
509
+ script = (
510
+ f'$ports=@({ports}); '
511
+ 'Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | '
512
+ 'Where-Object { $ports -contains $_.LocalPort } | '
513
+ 'Select-Object -ExpandProperty OwningProcess -Unique | '
514
+ 'ConvertTo-Json -Compress'
515
+ )
516
+ data = _json_from_powershell(script)
517
+ pids = set()
518
+ for raw in _as_list(data):
519
+ try:
520
+ pid = int(raw)
521
+ if pid > 0:
522
+ pids.add(pid)
523
+ except Exception:
524
+ pass
525
+ return pids
526
+
527
+
528
+ def _bridge_source_session_infos() -> list[dict]:
529
+ """Find bridge dev/test sessions that may not yet own a port.
530
+
531
+ This intentionally excludes MCP shims and the tray itself. It targets
532
+ sessions agents commonly leave behind while testing the bridge daemon.
533
+ """
534
+ if sys.platform != 'win32':
535
+ return []
536
+ script = r'''
537
+ $selfPid = $PID
538
+ Get-CimInstance Win32_Process |
539
+ Where-Object {
540
+ $cmd = [string]$_.CommandLine
541
+ $_.ProcessId -ne $selfPid -and
542
+ $_.Name -notmatch '^(powershell|pwsh)\.exe$' -and
543
+ (
544
+ $cmd -match 'empir3-bridge[\\/](src[\\/](bridge|server)\.ts|build[\\/]payload-staging[\\/]bundle-(bridge|server|daemon)\.js)' -or
545
+ $cmd -match '--user-data-dir=.*\.empir3-bridge[\\/]profile'
546
+ )
547
+ } |
548
+ Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine |
549
+ ConvertTo-Json -Compress
550
+ '''
551
+ return [p for p in _as_list(_json_from_powershell(script)) if isinstance(p, dict)]
552
+
553
+
554
+ def _normalized_process_text(info: dict) -> str:
555
+ return ' '.join(str(info.get(k) or '') for k in ('Name', 'ExecutablePath', 'CommandLine')).lower().replace('/', '\\')
556
+
557
+
558
+ def _is_bridge_owned_process(info: dict, *, port_owner: bool) -> bool:
559
+ pid = int(info.get('ProcessId') or 0)
560
+ if pid <= 0 or pid == os.getpid():
561
+ return False
562
+ name = str(info.get('Name') or '').lower()
563
+ if name == 'empir3tray.exe':
564
+ return False
565
+ text = _normalized_process_text(info)
566
+ if '\\.empir3-bridge\\profile' in text:
567
+ return True
568
+ if 'empir3-bridge\\src\\bridge.ts' in text or 'empir3-bridge\\src\\server.ts' in text:
569
+ return True
570
+ if 'bundle-bridge.js' in text or 'bundle-server.js' in text or 'bundle-daemon.js' in text:
571
+ return True
572
+ if port_owner and 'empir3setup.exe' in text:
573
+ return True
574
+ if port_owner and '\\.empir3-bridge\\payload\\' in text and name in ('node.exe', 'empir3setup.exe'):
575
+ return True
576
+ return False
577
+
578
+
579
+ def _kill_process_tree(pid: int, reason: str) -> bool:
580
+ if pid <= 0:
581
+ return False
582
+ try:
583
+ if sys.platform == 'win32':
584
+ proc = subprocess.run(
585
+ ['taskkill', '/PID', str(pid), '/T', '/F'],
586
+ stdout=subprocess.PIPE,
587
+ stderr=subprocess.PIPE,
588
+ text=True,
589
+ timeout=8,
590
+ creationflags=CREATE_NO_WINDOW,
591
+ )
592
+ if proc.returncode == 0:
593
+ logger.info('cleanup: killed bridge-owned process tree pid=%s (%s)', pid, reason)
594
+ return True
595
+ logger.warning('cleanup: taskkill pid=%s failed: %s', pid, (proc.stderr or proc.stdout or '').strip()[:500])
596
+ return False
597
+ os.kill(pid, 9)
598
+ logger.info('cleanup: killed bridge-owned process pid=%s (%s)', pid, reason)
599
+ return True
600
+ except Exception as e:
601
+ logger.warning('cleanup: failed to kill pid=%s: %s', pid, e)
602
+ return False
603
+
604
+
605
+ def cleanup_bridge_owned_processes(reason: str) -> int:
606
+ """Release bridge ports without killing unrelated localhost services."""
607
+ port_pids = _bridge_port_owner_pids()
608
+ port_infos = _process_infos_for_pids(port_pids)
609
+ candidates: dict[int, tuple[dict, bool]] = {}
610
+ for info in port_infos:
611
+ pid = int(info.get('ProcessId') or 0)
612
+ candidates[pid] = (info, True)
613
+ for info in _bridge_source_session_infos():
614
+ pid = int(info.get('ProcessId') or 0)
615
+ candidates.setdefault(pid, (info, False))
616
+
617
+ killed = 0
618
+ for pid, (info, port_owner) in sorted(candidates.items()):
619
+ if _is_bridge_owned_process(info, port_owner=port_owner):
620
+ if _kill_process_tree(pid, reason):
621
+ killed += 1
622
+ elif port_owner:
623
+ logger.warning(
624
+ 'cleanup: leaving non-bridge process on bridge port alone pid=%s name=%s cmd=%s',
625
+ pid,
626
+ info.get('Name'),
627
+ str(info.get('CommandLine') or '')[:240],
628
+ )
629
+ if killed:
630
+ time.sleep(1)
631
+ return killed
632
+
633
+
634
+ def _acquire_single_instance() -> bool:
635
+ """Return False when another tray is already supervising the bridge."""
636
+ global _INSTANCE_MUTEX_HANDLE
637
+ if sys.platform != 'win32':
638
+ return True
639
+ try:
640
+ import ctypes
641
+ from ctypes import wintypes
642
+ kernel32 = ctypes.windll.kernel32
643
+ kernel32.CreateMutexW.argtypes = [wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR]
644
+ kernel32.CreateMutexW.restype = wintypes.HANDLE
645
+ handle = kernel32.CreateMutexW(None, True, 'Local\\Empir3BridgeTray')
646
+ already_exists = kernel32.GetLastError() == 183 # ERROR_ALREADY_EXISTS
647
+ if not handle:
648
+ logger.warning('single-instance mutex could not be created; continuing')
649
+ return True
650
+ if already_exists:
651
+ kernel32.CloseHandle(handle)
652
+ logger.info('another Empir3Tray instance is already running; exiting this copy')
653
+ return False
654
+ _INSTANCE_MUTEX_HANDLE = handle
655
+
656
+ def _release_mutex():
657
+ try:
658
+ kernel32.ReleaseMutex(_INSTANCE_MUTEX_HANDLE)
659
+ kernel32.CloseHandle(_INSTANCE_MUTEX_HANDLE)
660
+ except Exception:
661
+ pass
662
+ atexit.register(_release_mutex)
663
+ return True
664
+ except Exception as e:
665
+ logger.warning('single-instance guard failed; continuing: %s', e)
666
+ return True
667
+
668
+
669
+ # ── Icon rendering ─────────────────────────────────────────────────────
670
+
671
+ def _create_icon_image(connected: bool) -> Image.Image:
672
+ """Solid circle with an 'E' in the middle. Green=connected, red=not."""
673
+ size = 64
674
+ img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
675
+ draw = ImageDraw.Draw(img)
676
+ color = (46, 204, 113) if connected else (231, 76, 60)
677
+ draw.ellipse([4, 4, size - 4, size - 4], fill=color)
678
+ try:
679
+ font = ImageFont.truetype('arial.ttf', 28)
680
+ except (OSError, IOError):
681
+ font = ImageFont.load_default()
682
+ text = 'E'
683
+ bbox = draw.textbbox((0, 0), text, font=font)
684
+ tw = bbox[2] - bbox[0]
685
+ th = bbox[3] - bbox[1]
686
+ draw.text(((size - tw) // 2, (size - th) // 2 - 2), text, fill='white', font=font)
687
+ return img
688
+
689
+
690
+ # ── Bridge daemon supervisor ───────────────────────────────────────────
691
+
692
+ class DaemonSupervisor:
693
+ """
694
+ Owns the lifetime of the bridge child process. Restarts on crash.
695
+
696
+ Spawn target depends on whether we're running PyInstaller-frozen or as a
697
+ plain Python script — see _spawn_args().
698
+ """
699
+
700
+ def __init__(self, on_state_change=None):
701
+ self._proc: Optional[subprocess.Popen] = None
702
+ self._lock = threading.Lock()
703
+ self._stop_requested = False
704
+ self._restart_attempts = 0
705
+ self._on_state_change = on_state_change or (lambda *_: None)
706
+ self._supervise_thread: Optional[threading.Thread] = None
707
+ self._clean_before_next_spawn = True
708
+ self._spawn_count = 0
709
+
710
+ def _spawn_args(self):
711
+ """
712
+ Choose what to spawn.
713
+
714
+ Order of resolution:
715
+ 1. EMPIR3_BOOTSTRAP_EXE env var — set by payload-entry.js when it
716
+ spawns the tray. Most reliable in production.
717
+ 2. Frozen (PyInstaller) sibling: Empir3Setup.exe next to us.
718
+ 3. Dev: `node <repo>/bridge/index.js`.
719
+
720
+ For #1 + #2 we ask the bootstrapper for `--daemon-real` so it skips
721
+ the tray-spawn branch and runs the actual bridge daemon.
722
+ """
723
+ bootstrap = resolve_bootstrap_exe()
724
+ if bootstrap:
725
+ return [bootstrap, '--daemon-real']
726
+
727
+ if getattr(sys, 'frozen', False):
728
+ install_dir = Path(sys.executable).parent
729
+ raise FileNotFoundError(
730
+ f'Empir3Setup.exe not found via EMPIR3_BOOTSTRAP_EXE, bridge-bootstrap.json, autostart, or beside Empir3Tray.exe at {install_dir} '
731
+ '— reinstall the bridge to repair.'
732
+ )
733
+
734
+ # Dev: walk up from bridge/tray/ → bridge/ → repo root
735
+ bridge_dir = Path(__file__).resolve().parent.parent
736
+ index_js = bridge_dir / 'index.js'
737
+ if not index_js.exists():
738
+ raise FileNotFoundError(f'bridge/index.js not found at {index_js}')
739
+ node = os.environ.get('NODE_BIN', 'node')
740
+ return [node, str(index_js)]
741
+
742
+ def start(self):
743
+ """Spawn the daemon and start the supervise loop in a background thread."""
744
+ if self._supervise_thread and self._supervise_thread.is_alive():
745
+ return
746
+ self._stop_requested = False
747
+ self._supervise_thread = threading.Thread(target=self._supervise_loop, daemon=True)
748
+ self._supervise_thread.start()
749
+
750
+ def mark_clean_before_next_spawn(self):
751
+ with self._lock:
752
+ self._clean_before_next_spawn = True
753
+ self._restart_attempts = 0
754
+
755
+ def restart(self, clean_ports: bool = True):
756
+ """Kill the current child; supervise loop will respawn."""
757
+ with self._lock:
758
+ self._restart_attempts = 0
759
+ if clean_ports:
760
+ self._clean_before_next_spawn = True
761
+ if self._proc and self._proc.poll() is None:
762
+ logger.info('restart: terminating current daemon pid=%s', self._proc.pid)
763
+ try:
764
+ self._proc.terminate()
765
+ except Exception as e:
766
+ logger.warning('terminate failed: %s', e)
767
+
768
+ def stop(self, clean_ports: bool = True):
769
+ """Kill the child and stop the supervise loop. Call once on Quit."""
770
+ self._stop_requested = True
771
+ with self._lock:
772
+ if self._proc and self._proc.poll() is None:
773
+ logger.info('stop: terminating daemon pid=%s', self._proc.pid)
774
+ try:
775
+ self._proc.terminate()
776
+ self._proc.wait(timeout=5)
777
+ except subprocess.TimeoutExpired:
778
+ logger.warning('daemon did not exit in 5s, killing')
779
+ try:
780
+ self._proc.kill()
781
+ except Exception:
782
+ pass
783
+ except Exception as e:
784
+ logger.warning('terminate failed: %s', e)
785
+ if clean_ports:
786
+ killed = cleanup_bridge_owned_processes('tray stop')
787
+ if killed:
788
+ logger.info('stop: cleaned %s bridge-owned stale process tree(s)', killed)
789
+
790
+ def _supervise_loop(self):
791
+ while not self._stop_requested:
792
+ do_cleanup = False
793
+ with self._lock:
794
+ if self._clean_before_next_spawn or self._spawn_count == 0:
795
+ do_cleanup = True
796
+ self._clean_before_next_spawn = False
797
+ if do_cleanup:
798
+ killed = cleanup_bridge_owned_processes('before daemon spawn')
799
+ if killed:
800
+ logger.info('pre-spawn cleanup: removed %s bridge-owned stale process tree(s)', killed)
801
+
802
+ try:
803
+ args = self._spawn_args()
804
+ except FileNotFoundError as e:
805
+ logger.error('cannot spawn daemon: %s', e)
806
+ self._on_state_change('error', str(e))
807
+ time.sleep(10)
808
+ continue
809
+
810
+ logger.info('spawning daemon: %s', ' '.join(args))
811
+ self._on_state_change('starting', None)
812
+ log_handle = None
813
+ try:
814
+ BRIDGE_LOG.parent.mkdir(parents=True, exist_ok=True)
815
+ log_handle = open(BRIDGE_LOG, 'ab', buffering=0)
816
+ stamp = time.strftime('%Y-%m-%d %H:%M:%S')
817
+ log_handle.write(f'\n--- daemon spawn {stamp}: {" ".join(args)} ---\n'.encode('utf-8', errors='replace'))
818
+ with self._lock:
819
+ self._proc = subprocess.Popen(
820
+ args,
821
+ stdout=log_handle,
822
+ stderr=subprocess.STDOUT,
823
+ stdin=subprocess.DEVNULL,
824
+ creationflags=CREATE_NO_WINDOW,
825
+ cwd=str(Path(args[0]).parent) if Path(args[0]).is_absolute() else None,
826
+ )
827
+ except Exception as e:
828
+ try:
829
+ if log_handle:
830
+ log_handle.close()
831
+ except Exception:
832
+ pass
833
+ logger.error('spawn failed: %s', e)
834
+ self._on_state_change('error', str(e))
835
+ self._sleep_backoff()
836
+ continue
837
+
838
+ logger.info('daemon spawned pid=%s', self._proc.pid)
839
+ self._spawn_count += 1
840
+ if _attach_to_job(self._proc.pid):
841
+ logger.info('daemon attached to tray job object (will die with tray)')
842
+ self._on_state_change('running', None)
843
+ exit_code = self._proc.wait()
844
+ try:
845
+ if log_handle:
846
+ log_handle.close()
847
+ except Exception:
848
+ pass
849
+ logger.warning('daemon exited code=%s', exit_code)
850
+ self._on_state_change('exited', f'code={exit_code}')
851
+
852
+ if self._stop_requested:
853
+ break
854
+ if exit_code == 0:
855
+ # The daemon exits 0 for intentional self-restarts after
856
+ # account sign-in/sign-out, pairing, and graceful reconnect.
857
+ # Treat that as a fast handoff, not a crash, or account
858
+ # switching snowballs into minute-long waits.
859
+ self._restart_attempts = 0
860
+ logger.info('daemon exited cleanly; restarting in 1s')
861
+ time.sleep(1)
862
+ continue
863
+ self._sleep_backoff()
864
+ self._restart_attempts += 1
865
+
866
+ def _sleep_backoff(self):
867
+ idx = min(self._restart_attempts, len(RESTART_BACKOFF_SEC) - 1)
868
+ delay = RESTART_BACKOFF_SEC[idx]
869
+ logger.info('restart backoff: sleeping %ss (attempt %s)', delay, self._restart_attempts + 1)
870
+ for _ in range(delay):
871
+ if self._stop_requested:
872
+ return
873
+ time.sleep(1)
874
+
875
+ @property
876
+ def child_pid(self) -> Optional[int]:
877
+ with self._lock:
878
+ return self._proc.pid if (self._proc and self._proc.poll() is None) else None
879
+
880
+
881
+ # ── Status poller ──────────────────────────────────────────────────────
882
+
883
+ class StatusPoller:
884
+ """
885
+ Polls the bridge's local /api/relay-status endpoint to discover its port
886
+ and read connection state. Calls on_status(state) on every poll, where
887
+ state is a dict like:
888
+ { 'reachable': bool, 'port': int|None, 'connected': bool,
889
+ 'user_email': str|None, 'device_name': str|None,
890
+ 'channel_id': str|None, 'uptime_ms': int|None }
891
+ """
892
+
893
+ def __init__(self, on_status, on_tray_commands=None):
894
+ self._on_status = on_status
895
+ self._on_tray_commands = on_tray_commands or (lambda _cmds: None)
896
+ self._stop = False
897
+ self._thread: Optional[threading.Thread] = None
898
+ self._last_port: Optional[int] = None # remember which port worked last
899
+ # Sticky-reachability bookkeeping. _consecutive_fails counts how many
900
+ # back-to-back polls timed out; we only emit a "disconnected" state to
901
+ # the menu after STATUS_DISCONNECT_AFTER_FAILS of those. _last_ok keeps
902
+ # the most recent successful poll so we can replay it if we want to
903
+ # avoid menu flap during a transient blip.
904
+ self._consecutive_fails = 0
905
+ self._last_ok: Optional[dict] = None
906
+
907
+ def start(self):
908
+ self._stop = False
909
+ self._thread = threading.Thread(target=self._loop, daemon=True)
910
+ self._thread.start()
911
+
912
+ def stop(self):
913
+ self._stop = True
914
+
915
+ def _loop(self):
916
+ while not self._stop:
917
+ state = self._poll_once()
918
+ try:
919
+ self._on_status(state)
920
+ except Exception as e:
921
+ logger.warning('on_status callback raised: %s', e)
922
+ if state.get('reachable') and state.get('port'):
923
+ commands = self._drain_tray_commands(state['port'])
924
+ if commands:
925
+ try:
926
+ self._on_tray_commands(commands)
927
+ except Exception as e:
928
+ logger.warning('on_tray_commands callback raised: %s', e)
929
+ time.sleep(STATUS_POLL_SEC)
930
+
931
+ def _drain_tray_commands(self, port: int) -> list:
932
+ """Fetch + clear the bridge daemon's tray-command queue. The welcome
933
+ page enqueues lifecycle commands (restart tray, quit, uninstall, check
934
+ updates) and the daemon hands them off to us on this poll. Short
935
+ timeout because the daemon clears the queue server-side regardless."""
936
+ try:
937
+ req = urlrequest.Request(f'http://127.0.0.1:{port}/api/tray/commands')
938
+ with urlrequest.urlopen(req, timeout=STATUS_HTTP_TIMEOUT_SEC) as resp:
939
+ body = json.loads(resp.read().decode('utf-8'))
940
+ if not body.get('ok'):
941
+ return []
942
+ return body.get('commands') or []
943
+ except Exception as e:
944
+ logger.debug('tray command drain failed on port %s: %s', port, e)
945
+ return []
946
+
947
+ def _poll_once(self) -> dict:
948
+ # Try last-known-good port first, then fall back to scanning.
949
+ ports = []
950
+ if self._last_port:
951
+ ports.append(self._last_port)
952
+ for p in CANDIDATE_PORTS:
953
+ if p not in ports:
954
+ ports.append(p)
955
+
956
+ for port in ports:
957
+ try:
958
+ req = urlrequest.Request(f'http://127.0.0.1:{port}/api/relay-status')
959
+ with urlrequest.urlopen(req, timeout=STATUS_HTTP_TIMEOUT_SEC) as resp:
960
+ body = json.loads(resp.read().decode('utf-8'))
961
+ if not body.get('ok'):
962
+ continue
963
+ relay = body.get('relay') or {}
964
+ # /api/relay-status returns the connected user inside relay.user
965
+ # when the relay is up. When in splash mode (no auth), it returns
966
+ # a top-level authUser=null + mode='splash' so the tray can render
967
+ # the right "Sign in" affordance instead of a misleading
968
+ # "Disconnected" status.
969
+ relay_user = relay.get('user') or {}
970
+ auth_user = body.get('authUser') or {}
971
+ email = relay_user.get('email') or auth_user.get('email')
972
+ self._last_port = port
973
+ self._consecutive_fails = 0
974
+ ok_state = {
975
+ 'reachable': True,
976
+ 'port': port,
977
+ 'connected': bool(relay.get('connected')),
978
+ 'user_email': email,
979
+ 'device_name': relay.get('deviceName'),
980
+ 'channel_id': relay.get('channelId'),
981
+ 'uptime_ms': body.get('uptimeMs'),
982
+ 'mode': body.get('mode') or 'paired',
983
+ 'has_auth': bool(body.get('hasAuth')),
984
+ 'standalone': bool(body.get('standalone')),
985
+ 'daemon_version': body.get('version'),
986
+ 'server_url': body.get('serverUrl'),
987
+ 'auth_rejected': bool(relay.get('authRejected')),
988
+ 'relay_close_code': relay.get('lastCloseCode'),
989
+ 'relay_close_reason': relay.get('lastCloseReason'),
990
+ }
991
+ self._last_ok = ok_state
992
+ return ok_state
993
+ except (urlerror.URLError, ConnectionError, OSError, TimeoutError, json.JSONDecodeError):
994
+ continue
995
+ except Exception as e:
996
+ logger.warning('poll error on port %s: %s', port, e)
997
+ continue
998
+
999
+ # Nothing answered this round. If we've succeeded recently and the
1000
+ # failure streak is still short, keep showing the cached good state
1001
+ # — almost certainly a brief daemon stall, not a real outage. Only
1002
+ # surface "disconnected" after STATUS_DISCONNECT_AFTER_FAILS misses.
1003
+ self._consecutive_fails += 1
1004
+ if self._last_ok is not None and self._consecutive_fails < STATUS_DISCONNECT_AFTER_FAILS:
1005
+ return self._last_ok
1006
+ self._last_port = None
1007
+ self._last_ok = None
1008
+ return {
1009
+ 'reachable': False, 'port': None, 'connected': False,
1010
+ 'user_email': None, 'device_name': None,
1011
+ 'channel_id': None, 'uptime_ms': None,
1012
+ 'mode': None, 'has_auth': False, 'standalone': False,
1013
+ 'daemon_version': None, 'server_url': None,
1014
+ 'auth_rejected': False, 'relay_close_code': None, 'relay_close_reason': None,
1015
+ }
1016
+
1017
+
1018
+ # ── Update checker ─────────────────────────────────────────────────────
1019
+ #
1020
+ # Polls the public manifest on a 30-min cadence. When a newer version is
1021
+ # available:
1022
+ # - autoUpdate ON → restart the daemon (bootstrap fetches + extracts)
1023
+ # - autoUpdate OFF → notify the user; "Check for updates" handles install
1024
+ #
1025
+ # Keeps state in memory only (last-known remote version) so the user's
1026
+ # explicit "Check for updates" click can short-circuit the next probe.
1027
+
1028
+ class UpdateChecker:
1029
+ def __init__(self, on_apply, on_notify):
1030
+ self._on_apply = on_apply
1031
+ self._on_notify = on_notify
1032
+ self._stop = False
1033
+ self._thread: Optional[threading.Thread] = None
1034
+ self._last_remote_version: Optional[str] = None
1035
+ self._last_check_at: float = 0.0
1036
+
1037
+ def start(self):
1038
+ self._stop = False
1039
+ self._thread = threading.Thread(target=self._loop, daemon=True)
1040
+ self._thread.start()
1041
+
1042
+ def stop(self):
1043
+ self._stop = True
1044
+
1045
+ def check_now(self) -> tuple:
1046
+ """
1047
+ Force a check + act on the result. Returns (state, local, remote)
1048
+ where state is one of: 'up_to_date', 'newer_available', 'applied',
1049
+ 'probe_failed'. Used by the menu's "Check for updates" item to
1050
+ give immediate feedback.
1051
+ """
1052
+ manifest = fetch_remote_manifest(timeout=5)
1053
+ self._last_check_at = time.time()
1054
+ if not manifest:
1055
+ return ('probe_failed', None, None)
1056
+ remote = manifest.get('version')
1057
+ local = get_active_payload_version()
1058
+ self._last_remote_version = remote
1059
+ if not remote or not is_newer(remote, local):
1060
+ return ('up_to_date', local, remote)
1061
+ # Newer available — apply unconditionally on manual click.
1062
+ try:
1063
+ self._on_apply(remote)
1064
+ return ('applied', local, remote)
1065
+ except Exception as e:
1066
+ logger.error('update apply failed: %s', e)
1067
+ return ('newer_available', local, remote)
1068
+
1069
+ def _loop(self):
1070
+ # Initial delay so we don't compete with first-boot startup.
1071
+ for _ in range(UPDATE_CHECK_INITIAL_DELAY_SEC):
1072
+ if self._stop:
1073
+ return
1074
+ time.sleep(1)
1075
+
1076
+ while not self._stop:
1077
+ try:
1078
+ manifest = fetch_remote_manifest(timeout=5)
1079
+ if manifest:
1080
+ remote = manifest.get('version')
1081
+ local = get_active_payload_version()
1082
+ if remote and is_newer(remote, local):
1083
+ if remote != self._last_remote_version:
1084
+ logger.info('update available: %s → %s', local, remote)
1085
+ self._last_remote_version = remote
1086
+ if get_auto_update():
1087
+ try:
1088
+ self._on_apply(remote)
1089
+ except Exception as e:
1090
+ logger.error('auto-update apply failed: %s', e)
1091
+ else:
1092
+ try:
1093
+ self._on_notify(local, remote)
1094
+ except Exception:
1095
+ pass
1096
+ except Exception as e:
1097
+ logger.warning('update loop error: %s', e)
1098
+
1099
+ for _ in range(UPDATE_CHECK_INTERVAL_SEC):
1100
+ if self._stop:
1101
+ return
1102
+ time.sleep(1)
1103
+
1104
+
1105
+ # ── Tray UI ────────────────────────────────────────────────────────────
1106
+
1107
+ class EmpirTray:
1108
+ def __init__(self):
1109
+ self._icon: Optional[pystray.Icon] = None
1110
+ self._connected = False
1111
+ self._last_status: dict = {
1112
+ 'reachable': False, 'port': None, 'connected': False,
1113
+ 'user_email': None, 'device_name': None,
1114
+ 'mode': None, 'has_auth': False, 'standalone': False,
1115
+ 'daemon_version': None, 'server_url': None,
1116
+ 'auth_rejected': False, 'relay_close_code': None, 'relay_close_reason': None,
1117
+ }
1118
+ self._tray_version = get_running_tray_version()
1119
+ self._supervisor = DaemonSupervisor(on_state_change=self._on_supervisor_state)
1120
+ self._clean_ports_on_final_stop = True
1121
+ self._poller = StatusPoller(
1122
+ on_status=self._on_status,
1123
+ on_tray_commands=self._on_tray_commands,
1124
+ )
1125
+ self._updater = UpdateChecker(
1126
+ on_apply=self._apply_update,
1127
+ on_notify=self._notify_update_available,
1128
+ )
1129
+
1130
+ # ── State callbacks ──
1131
+
1132
+ def _on_supervisor_state(self, state: str, detail: Optional[str]):
1133
+ logger.info('supervisor: %s%s', state, f' ({detail})' if detail else '')
1134
+
1135
+ def _on_status(self, state: dict):
1136
+ prev = self._last_status
1137
+ self._last_status = state
1138
+ connected = state.get('connected', False)
1139
+ connected_changed = connected != self._connected
1140
+ if connected_changed:
1141
+ self._connected = connected
1142
+ logger.info(
1143
+ 'status: connected=%s port=%s user=%s device=%s',
1144
+ connected, state.get('port'),
1145
+ state.get('user_email'), state.get('device_name'),
1146
+ )
1147
+ elif state.get('reachable') != prev.get('reachable'):
1148
+ logger.info('status: reachable=%s port=%s', state.get('reachable'), state.get('port'))
1149
+ # Refresh the menu whenever any user-visible state changes — mode
1150
+ # transitions (paired↔splash), version updates, and standalone
1151
+ # toggles all influence menu item visibility + labels.
1152
+ watched = ('reachable', 'connected', 'mode', 'has_auth', 'standalone',
1153
+ 'daemon_version', 'user_email', 'server_url', 'auth_rejected',
1154
+ 'relay_close_code', 'relay_close_reason')
1155
+ if any(state.get(k) != prev.get(k) for k in watched):
1156
+ self._refresh_icon()
1157
+
1158
+ def _refresh_icon(self):
1159
+ if not self._icon:
1160
+ return
1161
+ try:
1162
+ daemon_online = bool(self._last_status.get('reachable'))
1163
+ self._icon.icon = _create_icon_image(daemon_online)
1164
+ label = self._device_label()
1165
+ self._icon.title = (
1166
+ f'Empir3 — {label} (Running)' if daemon_online
1167
+ else f'Empir3 — {label} (Disconnected)'
1168
+ )
1169
+ self._icon.update_menu()
1170
+ except Exception as e:
1171
+ logger.warning('icon refresh failed: %s', e)
1172
+
1173
+ def _device_label(self) -> str:
1174
+ name = self._last_status.get('device_name')
1175
+ if name:
1176
+ return name
1177
+ try:
1178
+ import socket
1179
+ return socket.gethostname()
1180
+ except Exception:
1181
+ return 'Desktop'
1182
+
1183
+ # ── Tray command queue (driven by the welcome-page command center) ──
1184
+
1185
+ def _on_tray_commands(self, commands: list) -> None:
1186
+ """Dispatch lifecycle commands enqueued by the welcome page. The bridge
1187
+ daemon clears the queue when we drain it, so each command here is
1188
+ delivered exactly once. We log every one so a regression where the
1189
+ welcome page mis-sends a command is debuggable from tray.log alone."""
1190
+ for cmd in commands:
1191
+ try:
1192
+ kind = (cmd or {}).get('type', '')
1193
+ cid = (cmd or {}).get('id', '<no-id>')
1194
+ logger.info('tray command received: %s (id=%s)', kind, cid)
1195
+ if kind == 'tray_check_updates':
1196
+ self._check_for_updates()
1197
+ elif kind == 'tray_apply_update':
1198
+ # Same path as the periodic updater: restart the daemon so
1199
+ # bootstrap fetches the payload, then restart the tray once
1200
+ # the new tray binary exists.
1201
+ params = (cmd or {}).get('params') or {}
1202
+ self._apply_update(str(params.get('version') or 'latest'))
1203
+ elif kind == 'tray_toggle_auto_update':
1204
+ self._toggle_auto_update(self._icon)
1205
+ elif kind == 'tray_open_log':
1206
+ self._open_log()
1207
+ elif kind == 'tray_restart_tray':
1208
+ if self._icon:
1209
+ self._restart_tray(self._icon)
1210
+ elif kind == 'tray_quit':
1211
+ if self._icon:
1212
+ self._quit(self._icon)
1213
+ elif kind == 'tray_uninstall':
1214
+ if self._icon:
1215
+ self._uninstall(self._icon)
1216
+ else:
1217
+ logger.warning('tray command: unknown type %r', kind)
1218
+ except Exception as e:
1219
+ logger.warning('tray command dispatch failed: %s', e)
1220
+
1221
+ # ── Menu actions ──
1222
+
1223
+ def _open_bridge(self, _icon=None, _item=None):
1224
+ # Surface Vincent's browser window — the exact CDP target Vincent
1225
+ # drives — so the user lands on what Vincent is about to navigate,
1226
+ # not a separate tab in their default browser. POSTs
1227
+ # desktop:browse:show to the local daemon, which calls
1228
+ # Page.bringToFront on the attached page (raises tab + window in
1229
+ # one shot). Falls back to opening app.empir3.com in the default
1230
+ # browser if the daemon isn't reachable, so the menu item is never
1231
+ # a dead end.
1232
+ def _do():
1233
+ port = self._last_status.get('port')
1234
+ if not port:
1235
+ self._notify('Bridge daemon not running — try Reconnect daemon first.')
1236
+ try: webbrowser.open(self._last_status.get('server_url') or SERVER_URL)
1237
+ except Exception: pass
1238
+ return
1239
+ nonce = None
1240
+ try:
1241
+ nonce = NONCE_FILE.read_text(encoding='utf-8').strip()
1242
+ except Exception as e:
1243
+ logger.warning('open-bridge: nonce read failed: %s', e)
1244
+ if not nonce:
1245
+ try: webbrowser.open(self._last_status.get('server_url') or SERVER_URL)
1246
+ except Exception: pass
1247
+ return
1248
+ try:
1249
+ req = urlrequest.Request(
1250
+ f'http://127.0.0.1:{port}/api/command',
1251
+ method='POST',
1252
+ data=json.dumps({'action': 'desktop:browse:show', 'params': {}}).encode('utf-8'),
1253
+ headers={
1254
+ 'Content-Type': 'application/json',
1255
+ 'X-Empir3-Nonce': nonce,
1256
+ },
1257
+ )
1258
+ with urlrequest.urlopen(req, timeout=15.0) as resp:
1259
+ if resp.status != 200:
1260
+ raise RuntimeError(f'http {resp.status}')
1261
+ logger.info('open-bridge: surfaced Vincent\'s browser')
1262
+ except Exception as e:
1263
+ logger.warning('open-bridge: command failed (%s); falling back to default browser', e)
1264
+ try: webbrowser.open(self._last_status.get('server_url') or SERVER_URL)
1265
+ except Exception: pass
1266
+ threading.Thread(target=_do, daemon=True).start()
1267
+
1268
+ def _has_focus(self) -> bool:
1269
+ # Bridge writes/deletes focus.json as the authoritative signal; cheap
1270
+ # stat call instead of HTTP polling on every menu open.
1271
+ try: return DESKTOP_FOCUS_FILE.exists()
1272
+ except Exception: return False
1273
+
1274
+ def _has_pointer(self) -> bool:
1275
+ try: return DESKTOP_POINTER_FILE.exists()
1276
+ except Exception: return False
1277
+
1278
+ def _post_command(self, type_: str, payload: dict | None = None, timeout: float = 5.0):
1279
+ port = self._last_status.get('port')
1280
+ if not port:
1281
+ self._notify('Bridge daemon not running — try Reconnect daemon first.')
1282
+ return None
1283
+ nonce = None
1284
+ try:
1285
+ nonce = NONCE_FILE.read_text(encoding='utf-8').strip()
1286
+ except Exception as e:
1287
+ logger.warning('post-command: nonce read failed: %s', e)
1288
+ try:
1289
+ body = {'type': type_}
1290
+ if payload: body.update(payload)
1291
+ req = urlrequest.Request(
1292
+ f'http://127.0.0.1:{port}/api/command',
1293
+ method='POST',
1294
+ data=json.dumps(body).encode('utf-8'),
1295
+ headers={
1296
+ 'Content-Type': 'application/json',
1297
+ **({'X-Empir3-Nonce': nonce} if nonce else {}),
1298
+ },
1299
+ )
1300
+ with urlrequest.urlopen(req, timeout=timeout) as resp:
1301
+ return json.loads(resp.read().decode('utf-8'))
1302
+ except Exception as e:
1303
+ logger.warning('post-command %s failed: %s', type_, e)
1304
+ return None
1305
+
1306
+ def _select_region_for_agent(self, _icon=None, _item=None):
1307
+ # Fires the bridge's region-selector overlay. Long timeout because the
1308
+ # user may take a while to choose; daemon resolves when they finish.
1309
+ def _do():
1310
+ res = self._post_command('desktop_select_region', {'timeoutMs': 120000}, timeout=130.0)
1311
+ if res and res.get('ok') and res.get('result', {}).get('region'):
1312
+ r = res['result']['region']
1313
+ self._notify(f"Agent focus set: {r['width']}x{r['height']} at ({r['x']},{r['y']})")
1314
+ elif res and res.get('result', {}).get('cancelled'):
1315
+ pass # User cancelled, silent
1316
+ else:
1317
+ self._notify('Region selection failed — check bridge log.')
1318
+ threading.Thread(target=_do, daemon=True).start()
1319
+
1320
+ def _release_agent_focus(self, _icon=None, _item=None):
1321
+ def _do():
1322
+ res = self._post_command('desktop_release_focus')
1323
+ if res and res.get('ok'):
1324
+ self._focus_grid_state = False
1325
+ try: self._icon.update_menu()
1326
+ except Exception: pass
1327
+ self._notify('Agent focus and screen artifacts released.')
1328
+ threading.Thread(target=_do, daemon=True).start()
1329
+
1330
+ def _open_desktop_toolbar(self, _icon=None, _item=None):
1331
+ def _do():
1332
+ res = self._post_command('desktop_toolbar', {'action': 'show'}, timeout=5.0)
1333
+ if res and res.get('ok'):
1334
+ self._notify('Desktop toolbar opened.')
1335
+ else:
1336
+ self._notify('Desktop toolbar failed to open - check bridge log.')
1337
+ threading.Thread(target=_do, daemon=True).start()
1338
+
1339
+ def _hide_agent_pointer(self, _icon=None, _item=None):
1340
+ def _do():
1341
+ res = self._post_command('desktop_pointer_hide')
1342
+ if res and res.get('ok'):
1343
+ self._notify('Agent pointer hidden.')
1344
+ threading.Thread(target=_do, daemon=True).start()
1345
+
1346
+ def _focus_grid_running(self) -> bool:
1347
+ # Reflect a local boolean cached after each toggle. We don't poll the
1348
+ # bridge for this — the tray just remembers what it last asked for.
1349
+ return bool(getattr(self, '_focus_grid_state', False))
1350
+
1351
+ def _toggle_focus_grid(self, _icon=None, _item=None):
1352
+ def _do():
1353
+ cur = bool(getattr(self, '_focus_grid_state', False))
1354
+ want = not cur
1355
+ res = self._post_command('desktop_focus_grid', {'action': 'show' if want else 'hide'}, timeout=3.0)
1356
+ if res and res.get('ok'):
1357
+ self._focus_grid_state = bool(res.get('result', {}).get('enabled', want))
1358
+ try: self._icon.update_menu()
1359
+ except Exception: pass
1360
+ else:
1361
+ self._notify('Focus grid toggle failed — check bridge log.')
1362
+ threading.Thread(target=_do, daemon=True).start()
1363
+
1364
+ def _calibrate_pointer(self, _icon=None, _item=None):
1365
+ # Run an interactive click-calibration: bridge spawns a fullscreen
1366
+ # capture overlay + ghost pointer at primary-screen center; user
1367
+ # clicks where they see the cursor; bridge persists the delta. The
1368
+ # bridge call blocks for up to 60s, so do everything in a worker.
1369
+ def _do():
1370
+ self._notify('Click the green ghost cursor at the center of your screen.')
1371
+ res = self._post_command('desktop_calibrate_pointer', timeout=70.0)
1372
+ if not res or not res.get('ok'):
1373
+ if res and res.get('result', {}).get('cancelled'):
1374
+ self._notify('Calibration cancelled.')
1375
+ else:
1376
+ self._notify('Calibration failed — check bridge log.')
1377
+ return
1378
+ cal = (res.get('result') or {}).get('calibration') or {}
1379
+ dx = int(cal.get('offsetX', 0))
1380
+ dy = int(cal.get('offsetY', 0))
1381
+ persisted = (res.get('result') or {}).get('persisted')
1382
+ sign_x = '+' if dx >= 0 else ''
1383
+ sign_y = '+' if dy >= 0 else ''
1384
+ note = 'saved' if persisted else 'NOT saved (see log)'
1385
+ self._notify(f'Calibration {note}: offset ({sign_x}{dx}, {sign_y}{dy}) px.')
1386
+ threading.Thread(target=_do, daemon=True).start()
1387
+
1388
+ def _open_log(self, _icon=None, _item=None):
1389
+ if not BRIDGE_LOG.exists():
1390
+ BRIDGE_LOG.parent.mkdir(parents=True, exist_ok=True)
1391
+ BRIDGE_LOG.touch()
1392
+ if sys.platform == 'win32':
1393
+ os.startfile(str(BRIDGE_LOG)) # noqa: SIM115 — Windows-only API
1394
+ elif sys.platform == 'darwin':
1395
+ subprocess.Popen(['open', str(BRIDGE_LOG)])
1396
+ else:
1397
+ subprocess.Popen(['xdg-open', str(BRIDGE_LOG)])
1398
+
1399
+ def _reconnect(self, _icon=None, _item=None):
1400
+ # Two-step restart: HTTP graceful shutdown first so handlers drain
1401
+ # cleanly, then supervisor.restart() as fallback when the HTTP path
1402
+ # didn't take (daemon non-responsive, port not bound yet, etc.).
1403
+ # Reset _restart_attempts so backoff starts at 3s, not 60s.
1404
+ def _do():
1405
+ self._notify('Reconnecting daemon…')
1406
+ port = self._last_status.get('port')
1407
+ graceful = False
1408
+ self._supervisor.mark_clean_before_next_spawn()
1409
+ if port:
1410
+ try:
1411
+ req = urlrequest.Request(
1412
+ f'http://127.0.0.1:{port}/api/shutdown',
1413
+ method='POST',
1414
+ data=b'{}',
1415
+ headers={'Content-Type': 'application/json'},
1416
+ )
1417
+ with urlrequest.urlopen(req, timeout=2.0) as resp:
1418
+ if resp.status == 200:
1419
+ graceful = True
1420
+ logger.info('reconnect: graceful shutdown OK on port %s', port)
1421
+ except Exception as e:
1422
+ logger.info('reconnect: graceful shutdown failed (%s); falling back to terminate', e)
1423
+ if not graceful:
1424
+ self._supervisor.restart(clean_ports=True)
1425
+ # Either way, supervise loop will see the exit + respawn.
1426
+ # Force-reset attempts so the user-triggered reconnect doesn't
1427
+ # inherit a long backoff from prior crashes.
1428
+ with self._supervisor._lock:
1429
+ self._supervisor._restart_attempts = 0
1430
+ threading.Thread(target=_do, daemon=True).start()
1431
+
1432
+ def _welcome_url(self, port) -> str:
1433
+ # The polished welcome/account flow is served by the wrapper daemon on
1434
+ # `port`. `/api/status.bridgeUrl` points at the CDP bridge (:9867),
1435
+ # whose legacy setup page is still useful internally but is the wrong
1436
+ # place to send tray sign-in/open-welcome actions.
1437
+ return f'http://127.0.0.1:{port}/welcome'
1438
+
1439
+ def _open_welcome(self, _icon=None, _item=None):
1440
+ port = self._last_status.get('port')
1441
+ if not port:
1442
+ self._notify('Bridge daemon not running - try Reconnect daemon first.')
1443
+ return
1444
+ self._open_controlled_url(port, f'http://127.0.0.1:{port}/welcome', 'welcome')
1445
+
1446
+ def _sign_out(self, _icon=None, _item=None):
1447
+ # Delete the auth file and restart the daemon. On restart, the
1448
+ # daemon will detect missing auth and boot into splash mode (which
1449
+ # auto-launches the bridge Chrome at /welcome). User can then click
1450
+ # "Login with Empir3" to re-pair.
1451
+ #
1452
+ # Crucially, do NOT spawn `Empir3Setup.exe` again here — that would
1453
+ # start a SECOND tray + daemon (the no-args path goes through
1454
+ # spawnTrayAndExit). The single supervised daemon respawn handles
1455
+ # everything.
1456
+ def _do():
1457
+ try:
1458
+ if AUTH_FILE.exists():
1459
+ AUTH_FILE.unlink()
1460
+ logger.info('signed out: removed %s', AUTH_FILE)
1461
+ except Exception as e:
1462
+ logger.error('sign-out: failed to remove auth file: %s', e)
1463
+ # Trigger a reconnect through the same graceful-then-terminate
1464
+ # path as the manual Reconnect menu item.
1465
+ self._reconnect()
1466
+ self._notify('Signed out — opening sign-in page…')
1467
+ # Wait for the daemon to come back up without Empir3 auth, then
1468
+ # surface the welcome page in the controlled bridge browser.
1469
+ for _ in range(20): # up to 10s
1470
+ time.sleep(0.5)
1471
+ port = self._last_status.get('port')
1472
+ has_auth = self._last_status.get('has_auth')
1473
+ if port and not has_auth:
1474
+ self._open_controlled_url(port, self._welcome_url(port), 'sign-out')
1475
+ return
1476
+ logger.warning('sign-out: daemon did not return without auth within 10s')
1477
+ threading.Thread(target=_do, daemon=True).start()
1478
+
1479
+ def _sign_in(self, _icon=None, _item=None):
1480
+ # Open the bridge's welcome page in the controlled bridge Chrome
1481
+ # profile, not the user's default browser.
1482
+ port = self._last_status.get('port')
1483
+ if not port:
1484
+ self._notify('Bridge daemon not running — try Reconnect daemon first.')
1485
+ return
1486
+ try:
1487
+ self._open_controlled_url(port, self._welcome_url(port), 'sign-in')
1488
+ except Exception as e:
1489
+ logger.warning('sign-in: controlled browser open failed: %s', e)
1490
+
1491
+ def _open_controlled_url(self, port: int, url: str, reason: str) -> bool:
1492
+ """Navigate and raise the controlled bridge browser to a specific URL."""
1493
+ nonce = ''
1494
+ try:
1495
+ nonce = NONCE_FILE.read_text(encoding='utf-8').strip()
1496
+ except Exception as e:
1497
+ logger.warning('%s: nonce read failed: %s', reason, e)
1498
+ headers = {'Content-Type': 'application/json'}
1499
+ if nonce:
1500
+ headers['X-Empir3-Nonce'] = nonce
1501
+ try:
1502
+ for payload in (
1503
+ {'action': 'navigate', 'url': url},
1504
+ {'action': 'desktop:browse:show', 'params': {}},
1505
+ ):
1506
+ req = urlrequest.Request(
1507
+ f'http://127.0.0.1:{port}/api/command',
1508
+ method='POST',
1509
+ data=json.dumps(payload).encode('utf-8'),
1510
+ headers=headers,
1511
+ )
1512
+ with urlrequest.urlopen(req, timeout=15.0) as resp:
1513
+ if resp.status != 200:
1514
+ raise RuntimeError(f'http {resp.status}')
1515
+ logger.info('%s: opened controlled bridge browser at %s', reason, url)
1516
+ return True
1517
+ except Exception as e:
1518
+ logger.warning('%s: controlled browser open failed: %s', reason, e)
1519
+ self._notify('Could not open the bridge browser - try Reconnect daemon.')
1520
+ return False
1521
+
1522
+ def _check_for_updates(self, _icon=None, _item=None):
1523
+ def _do():
1524
+ self._notify('Checking for updates…')
1525
+ state, local, remote = self._updater.check_now()
1526
+ if state == 'up_to_date':
1527
+ self._notify(f'You\'re on the latest version (v{local}).')
1528
+ elif state == 'applied':
1529
+ self._notify(f'Updating to v{remote} (was v{local})…')
1530
+ elif state == 'newer_available':
1531
+ self._notify(f'Update v{remote} available — apply failed, see log.')
1532
+ else:
1533
+ self._notify('Couldn\'t reach the update server — check your connection.')
1534
+ try:
1535
+ self._icon.update_menu()
1536
+ except Exception:
1537
+ pass
1538
+ threading.Thread(target=_do, daemon=True).start()
1539
+
1540
+ def _toggle_auto_update(self, _icon, _item=None):
1541
+ cur = get_auto_update()
1542
+ set_auto_update(not cur)
1543
+ try:
1544
+ self._icon.update_menu()
1545
+ except Exception:
1546
+ pass
1547
+
1548
+ def _toggle_higgsfield(self, _icon=None, _item=None):
1549
+ # Coarse tray-level gate for the higgsfield_* MCP tool family. The
1550
+ # bridge daemon reads settings.handlers.higgsfield.enabled on every
1551
+ # dispatch, so a flip takes effect immediately. The MCP shim only
1552
+ # advertises higgsfield_* when this is true at MCP startup, which
1553
+ # means clients connected before the toggle should be reconnected
1554
+ # to see the new tools — surface that nuance in the notification.
1555
+ cur = get_handler_enabled('higgsfield')
1556
+ target = not cur
1557
+ set_handler_enabled('higgsfield', target)
1558
+ try:
1559
+ self._icon.update_menu()
1560
+ except Exception:
1561
+ pass
1562
+ if target:
1563
+ self._notify('Higgsfield CLI handler enabled. Restart your MCP client to see higgsfield_* tools.')
1564
+ else:
1565
+ self._notify('Higgsfield CLI handler disabled.')
1566
+
1567
+ def _apply_update(self, new_version: str):
1568
+ """Restart the daemon — bootstrap will fetch + extract the new payload."""
1569
+ logger.info('apply_update: restarting daemon to pull v%s', new_version)
1570
+ # Use the same graceful-restart path as manual Reconnect.
1571
+ threading.Thread(target=self._reconnect, daemon=True).start()
1572
+ threading.Thread(target=self._restart_tray_when_update_ready, args=(new_version,), daemon=True).start()
1573
+
1574
+ def _restart_tray_when_update_ready(self, new_version: str):
1575
+ deadline = time.time() + 90
1576
+ while time.time() < deadline:
1577
+ try:
1578
+ active = get_active_payload_version()
1579
+ new_tray = PAYLOAD_ROOT / active / 'Empir3Tray.exe'
1580
+ if is_newer(active, self._tray_version) and new_tray.exists():
1581
+ logger.info('apply_update: tray payload ready (%s), restarting tray', active)
1582
+ if self._icon:
1583
+ self._restart_tray(self._icon)
1584
+ return
1585
+ except Exception as e:
1586
+ logger.warning('apply_update: tray readiness probe failed: %s', e)
1587
+ time.sleep(2)
1588
+ logger.warning('apply_update: timed out waiting for tray payload v%s', new_version)
1589
+
1590
+ def _notify_update_available(self, local: str, remote: str):
1591
+ self._notify(f'Update v{remote} available — open menu to install (currently v{local}).')
1592
+
1593
+ def _restart_tray(self, icon, _item=None):
1594
+ # Used after a payload update bumps the tray binary on disk. We
1595
+ # spawn the new tray exe (in the just-extracted payload dir) then
1596
+ # exit ourselves. Job Object kill-on-close will tear down our
1597
+ # daemon child too — the new tray respawns it on its own.
1598
+ def _do():
1599
+ new_tray = PAYLOAD_ROOT / get_active_payload_version() / 'Empir3Tray.exe'
1600
+ if not new_tray.exists():
1601
+ logger.warning('restart_tray: new tray exe not found at %s', new_tray)
1602
+ self._notify('No newer tray to restart into.')
1603
+ return
1604
+ logger.info('restart_tray: stopping daemon before spawning new tray %s', new_tray)
1605
+ self._poller.stop()
1606
+ self._supervisor.stop(clean_ports=True)
1607
+ self._clean_ports_on_final_stop = False
1608
+ logger.info('restart_tray: spawning new tray %s', new_tray)
1609
+ try:
1610
+ tray_env = {**os.environ}
1611
+ bootstrap = os.environ.get('EMPIR3_BOOTSTRAP_EXE', '').strip() or _bootstrap_from_pointer() or _bootstrap_from_autostart()
1612
+ if bootstrap:
1613
+ tray_env['EMPIR3_BOOTSTRAP_EXE'] = bootstrap
1614
+ subprocess.Popen(
1615
+ [str(new_tray)],
1616
+ cwd=str(new_tray.parent),
1617
+ creationflags=CREATE_NO_WINDOW,
1618
+ env=tray_env,
1619
+ )
1620
+ except Exception as e:
1621
+ logger.error('restart_tray: spawn failed: %s', e)
1622
+ self._notify(f'Tray restart failed: {e}')
1623
+ return
1624
+ time.sleep(0.5)
1625
+ try:
1626
+ icon.stop()
1627
+ except Exception:
1628
+ pass
1629
+ threading.Thread(target=_do, daemon=True).start()
1630
+
1631
+ def _notify(self, message: str, title: str = 'Empir3 Bridge') -> None:
1632
+ try:
1633
+ if self._icon:
1634
+ self._icon.notify(message, title)
1635
+ except Exception as e:
1636
+ logger.warning('notify failed: %s', e)
1637
+
1638
+ def _uninstall(self, icon, _item=None):
1639
+ # Full wipe: stops daemon + tray, clears auth + settings + logs +
1640
+ # autostart + Start Menu shortcut + cached payloads + Chrome profile.
1641
+ #
1642
+ # Reassurance flow: confirm (Yes/No) → "Uninstalling…" balloon →
1643
+ # spawn the bootstrapper's --uninstall → quit. The bootstrapper kills
1644
+ # this tray as its FIRST cleanup step, so we can't show the
1645
+ # "uninstall complete" message ourselves — by then we're gone. That
1646
+ # final confirmation is shown by the bootstrapper (payload-entry.js)
1647
+ # once the wipe finishes; the balloon bridges the gap until then.
1648
+ def _do():
1649
+ if not _confirm_uninstall():
1650
+ logger.info('uninstall: cancelled at confirmation')
1651
+ return
1652
+
1653
+ # Resolve via the same chain as the daemon-spawn path. The frozen
1654
+ # tray lives in payload/<version>/ but the bootstrapper sits in
1655
+ # %APPDATA%/Empir3/, so the old sibling-only check always missed.
1656
+ bootstrap = resolve_bootstrap_exe()
1657
+ if not bootstrap:
1658
+ logger.warning('uninstall: bootstrapper not found; cannot clean up')
1659
+ _message_box(
1660
+ 'Could not find Empir3Setup.exe to finish the uninstall.\n\n'
1661
+ 'Try reinstalling and uninstalling again, or remove the '
1662
+ 'Empir3 folders manually.',
1663
+ 'Uninstall Empir3',
1664
+ _MB_OK | _MB_ICONERROR | _MB_SETFOREGROUND | _MB_TOPMOST,
1665
+ )
1666
+ return
1667
+
1668
+ bootstrap_path = Path(bootstrap)
1669
+ logger.info('uninstall: spawning %s --uninstall', bootstrap_path)
1670
+ self._notify('Uninstalling Empir3… this takes a few seconds.')
1671
+ try:
1672
+ subprocess.Popen(
1673
+ [str(bootstrap_path), '--uninstall'],
1674
+ cwd=str(bootstrap_path.parent),
1675
+ creationflags=CREATE_NO_WINDOW,
1676
+ )
1677
+ except Exception as e:
1678
+ logger.error('uninstall: spawn failed: %s', e)
1679
+ _message_box(
1680
+ f'The uninstall could not start:\n\n{e}',
1681
+ 'Uninstall Empir3',
1682
+ _MB_OK | _MB_ICONERROR | _MB_SETFOREGROUND | _MB_TOPMOST,
1683
+ )
1684
+ return
1685
+
1686
+ time.sleep(0.8) # give the balloon a moment to render before we go
1687
+ self._poller.stop()
1688
+ self._supervisor.stop(clean_ports=True)
1689
+ self._clean_ports_on_final_stop = False
1690
+ try:
1691
+ icon.stop()
1692
+ except Exception:
1693
+ pass
1694
+ threading.Thread(target=_do, daemon=True).start()
1695
+
1696
+ def _quit(self, icon, _item=None):
1697
+ logger.info('quit requested')
1698
+ def _do():
1699
+ self._poller.stop()
1700
+ self._updater.stop()
1701
+ self._supervisor.stop(clean_ports=True)
1702
+ self._clean_ports_on_final_stop = False
1703
+ try:
1704
+ icon.stop()
1705
+ except Exception as e:
1706
+ logger.warning('icon.stop failed: %s', e)
1707
+ threading.Thread(target=_do, daemon=False).start()
1708
+
1709
+ # ── Menu construction ──
1710
+
1711
+ def _menu(self) -> pystray.Menu:
1712
+ return pystray.Menu(
1713
+ pystray.MenuItem(lambda _: f'Empir3 — {self._device_label()}', None, enabled=False),
1714
+ pystray.MenuItem(self._version_label, None, enabled=False),
1715
+ pystray.MenuItem(self._status_label, None, enabled=False),
1716
+ pystray.Menu.SEPARATOR,
1717
+ # Sign in surfaces any time the daemon has no Empir3 auth. That
1718
+ # includes standalone Claude Code mode, where users may still want
1719
+ # to pair this bridge with Empir3 later.
1720
+ # Lambda visibility is re-evaluated on every menu open.
1721
+ pystray.MenuItem('Sign in', self._sign_in,
1722
+ visible=lambda _: not self._last_status.get('has_auth')),
1723
+ pystray.MenuItem('Switch Empir3 account', self._sign_out,
1724
+ visible=lambda _: self._last_status.get('has_auth')),
1725
+ pystray.MenuItem('Open welcome', self._open_welcome, default=True),
1726
+ pystray.MenuItem('Open bridge', self._open_bridge),
1727
+ pystray.MenuItem('Open log', self._open_log),
1728
+ pystray.Menu.SEPARATOR,
1729
+ pystray.MenuItem('Open desktop toolbar', self._open_desktop_toolbar),
1730
+ pystray.MenuItem('Select region for agent…', self._select_region_for_agent),
1731
+ pystray.MenuItem('Release focus', self._release_agent_focus),
1732
+ pystray.MenuItem('Hide agent pointer', self._hide_agent_pointer,
1733
+ visible=lambda _: self._has_pointer()),
1734
+ pystray.MenuItem('Show focus grid', self._toggle_focus_grid,
1735
+ visible=lambda _: self._has_focus(),
1736
+ checked=lambda _: self._focus_grid_running()),
1737
+ pystray.MenuItem('Calibrate agent clicks…', self._calibrate_pointer),
1738
+ pystray.MenuItem('Updates', self._updates_submenu()),
1739
+ pystray.Menu.SEPARATOR,
1740
+ pystray.MenuItem('Reconnect daemon', self._reconnect),
1741
+ pystray.MenuItem('Sign out', self._sign_out,
1742
+ visible=lambda _: self._last_status.get('has_auth')),
1743
+ # Surfaces only when a newer tray binary is sitting on disk
1744
+ # (after a payload update bumps the tray exe but the running
1745
+ # process is the old one).
1746
+ pystray.MenuItem('Restart tray (apply update)', self._restart_tray,
1747
+ visible=lambda _: self._tray_update_available()),
1748
+ pystray.MenuItem('Uninstall Empir3', self._uninstall),
1749
+ pystray.Menu.SEPARATOR,
1750
+ pystray.MenuItem('Quit Empir3', self._quit),
1751
+ )
1752
+
1753
+ def _updates_submenu(self) -> pystray.Menu:
1754
+ return pystray.Menu(
1755
+ pystray.MenuItem('Check for updates', self._check_for_updates),
1756
+ pystray.Menu.SEPARATOR,
1757
+ pystray.MenuItem('Auto-update',
1758
+ self._toggle_auto_update,
1759
+ checked=lambda _: get_auto_update()),
1760
+ )
1761
+
1762
+ def _version_label(self, _item) -> str:
1763
+ # Show the running daemon version (truth: what's actually serving
1764
+ # tools right now). Fall back to the tray's bundled version when
1765
+ # the daemon hasn't responded yet.
1766
+ s = self._last_status
1767
+ ver = s.get('daemon_version') or self._tray_version
1768
+ if self._tray_update_available():
1769
+ return f'v{ver} (tray restart pending)'
1770
+ return f'v{ver}'
1771
+
1772
+ def _tray_update_available(self) -> bool:
1773
+ """True when the active payload contains a newer tray exe than the
1774
+ one currently running."""
1775
+ return is_newer(get_active_payload_version(), self._tray_version)
1776
+
1777
+ def _status_label(self, _item) -> str:
1778
+ s = self._last_status
1779
+ if not s.get('reachable'):
1780
+ return '○ Daemon not running'
1781
+ # No auth yet → daemon is in splash mode; the misleading
1782
+ # "Disconnected (relay)" label confuses users who think the relay
1783
+ # itself is down.
1784
+ if not s.get('has_auth'):
1785
+ if s.get('standalone'):
1786
+ return '○ Standalone (Claude Code mode)'
1787
+ return '○ Signed out — click "Sign in"'
1788
+ if s.get('connected'):
1789
+ email = s.get('user_email') or 'connected'
1790
+ return f'● Connected · {email}'
1791
+ if s.get('auth_rejected'):
1792
+ return '● Bridge running · sign in needed'
1793
+ email = s.get('user_email') or 'paired'
1794
+ return f'● Bridge running · {email}'
1795
+
1796
+ # ── Run ──
1797
+
1798
+ def run(self):
1799
+ logger.info('empir3 tray starting (frozen=%s, tray_version=%s)',
1800
+ getattr(sys, 'frozen', False), self._tray_version)
1801
+ if not _acquire_single_instance():
1802
+ return
1803
+ self._supervisor.start()
1804
+ self._poller.start()
1805
+ self._updater.start()
1806
+ self._icon = pystray.Icon(
1807
+ 'empir3-bridge',
1808
+ icon=_create_icon_image(False),
1809
+ title='Empir3 — starting',
1810
+ menu=self._menu(),
1811
+ )
1812
+ try:
1813
+ self._icon.run()
1814
+ finally:
1815
+ logger.info('icon.run returned, shutting down')
1816
+ self._poller.stop()
1817
+ self._updater.stop()
1818
+ self._supervisor.stop(clean_ports=self._clean_ports_on_final_stop)
1819
+
1820
+
1821
+ def _cleanup_at_exit():
1822
+ # Job Object closes when this process dies → all assigned children die too.
1823
+ if _JOB_HANDLE and sys.platform == 'win32':
1824
+ try:
1825
+ import ctypes
1826
+ ctypes.windll.kernel32.CloseHandle(_JOB_HANDLE)
1827
+ except Exception:
1828
+ pass
1829
+
1830
+
1831
+ atexit.register(_cleanup_at_exit)
1832
+
1833
+
1834
+ def main():
1835
+ try:
1836
+ EmpirTray().run()
1837
+ except KeyboardInterrupt:
1838
+ logger.info('interrupted')
1839
+ sys.exit(0)
1840
+
1841
+
1842
+ if __name__ == '__main__':
1843
+ main()