@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.
- package/CHANGELOG.md +1531 -0
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +75 -0
- package/LICENSE +21 -0
- package/README.md +464 -0
- package/SECURITY.md +130 -0
- package/assets/accuracy-lab.html +2639 -0
- package/assets/api-clis-real.jpg +0 -0
- package/assets/bridge-console-hero.jpg +0 -0
- package/assets/browser-privacy.svg +151 -0
- package/assets/demo-orchestration.svg +74 -0
- package/assets/desktop-select-region.jpg +0 -0
- package/assets/in-page-chat.gif +0 -0
- package/assets/orchestration-hero.svg +126 -0
- package/assets/social-preview.png +0 -0
- package/assets/zara-accent.png +0 -0
- package/build/bootstrap.js +548 -0
- package/build/build.js +680 -0
- package/build/payload-entry.js +649 -0
- package/build/payload-signing-pub.json +7 -0
- package/docs/AGENT_GUIDE.md +259 -0
- package/docs/RELEASE.md +106 -0
- package/docs/SAFETY.md +112 -0
- package/docs/TESTING.md +181 -0
- package/installer/server.js +231 -0
- package/installer/ui/app.js +278 -0
- package/installer/ui/index.html +24 -0
- package/installer/ui/styles.css +146 -0
- package/package.json +95 -0
- package/scripts/bootstrap-e2e.mjs +650 -0
- package/scripts/certify-bridge.mjs +636 -0
- package/scripts/check-companion-surface.mjs +118 -0
- package/scripts/extract-welcome.mjs +64 -0
- package/scripts/gh-route-handler-check.mjs +57 -0
- package/scripts/gh-wire-test.mjs +107 -0
- package/scripts/publish-downloads.mjs +180 -0
- package/scripts/smoke-all-tools.mjs +509 -0
- package/scripts/smoke-live-bridge.mjs +696 -0
- package/scripts/splice-welcome.mjs +63 -0
- package/scripts/welcome-body.txt +2733 -0
- package/src/anthropic-client.ts +192 -0
- package/src/bootstrap-exe.ts +69 -0
- package/src/bridge.ts +2444 -0
- package/src/chat.ts +345 -0
- package/src/cli-runner.ts +239 -0
- package/src/cli.ts +649 -0
- package/src/config.ts +199 -0
- package/src/desktop-overlay.ps1 +121 -0
- package/src/executable-resolver.ts +330 -0
- package/src/handlers/agy-imagegen.ts +179 -0
- package/src/handlers/github-cli.ts +399 -0
- package/src/handlers/higgsfield-cli.ts +783 -0
- package/src/launch.js +337 -0
- package/src/mcp-server.ts +1265 -0
- package/src/pair-claim.ts +218 -0
- package/src/payload-daemon.ts +168 -0
- package/src/server.ts +21036 -0
- package/src/tool-defaults.ts +230 -0
- package/src/update-check.js +136 -0
- package/tray/build.py +76 -0
- package/tray/requirements.txt +2 -0
- 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()
|