Repository navigation
Expand file tree
/
Copy pathtray.py
More file actions
373 lines (311 loc) · 14.1 KB
/
Copy pathtray.py
File metadata and controls
373 lines (311 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
"""macOS tray icon via rumps (NSStatusItem + NSMenu).
Subscribes to state.DaemonState for tone/language/recording changes and
hot-swaps the menu bar icon. UI mutations marshalled to main thread via
PyObjC AppHelper.callAfter — required because state.notify() can fire
from worker or hotkey threads.
The previous pystray implementation produced a daemon-healthy process
with no visible menu bar icon (memory obs 6208); rumps is the sanctioned
replacement (RECON §3 Conflict 1).
"""
from __future__ import annotations
import os
import subprocess
import sys
from enum import Enum
from pathlib import Path
from typing import Callable
import rumps
from state import DaemonState, RecordingState, ToneMode, LanguageMode
from ui.icons import tray_icon_is_template, tray_icon_resolved_path
# Backwards-compat shim — daemon.py still imports Status from this module.
class Status(str, Enum):
IDLE = "idle"
RECORDING = "recording"
PROCESSING = "processing"
_STATUS_FROM_RECORDING = {
RecordingState.IDLE: "idle",
RecordingState.RECORDING: "recording",
RecordingState.PROCESSING: "processing",
}
# Human labels for menu rendering.
_TONE_LABEL = {
ToneMode.RAW: "Raw",
ToneMode.VERBATIM: "Verbatim",
ToneMode.CASUAL: "Casual",
ToneMode.PROFESSIONAL: "Professional",
ToneMode.BULLETS: "Bullet points",
ToneMode.EMAIL: "Email",
ToneMode.SLACK: "Slack",
}
_LANG_LABEL = {
LanguageMode.AUTO: "Auto",
LanguageMode.EN: "English",
LanguageMode.HI: "हिन्दी · Hindi",
LanguageMode.HI_ROMAN: "Hindi (Roman)",
LanguageMode.HINGLISH: "Hinglish",
LanguageMode.HI_TO_EN: "Hindi → English",
LanguageMode.EN_TO_HI: "English → Hindi",
}
def _call_on_main(fn: Callable[[], None]) -> None:
"""Marshal a UI mutation onto the main thread."""
try:
from PyObjCTools import AppHelper # type: ignore
AppHelper.callAfter(fn)
except Exception:
# Fallback: best-effort direct call.
try:
fn()
except Exception as e:
print(f"[tray] main-thread dispatch failed: {e}", flush=True)
class OpenFlowTray(rumps.App):
"""Tray app. Single instance owned by the daemon.
Caller must invoke run_blocking() from the main thread (Cocoa
requirement). All callbacks marshal back via AppHelper.callAfter.
"""
def __init__(self, daemon) -> None:
super().__init__(
"OpenFlow",
icon=str(tray_icon_resolved_path("idle")),
template=tray_icon_is_template("idle"),
quit_button=None,
)
self.daemon = daemon
self.state: DaemonState = daemon.state
self._tone_items: dict[ToneMode, rumps.MenuItem] = {}
self._lang_items: dict[LanguageMode, rumps.MenuItem] = {}
self._build_menu()
self.state.subscribe(self._on_state_change)
_install_reopen_handler()
# Settings › General › Show in Dock: OpenFlow is a Dock app while it
# runs, and quitting it there (or ⌘Q, or the menu) closes everything.
self.set_dock(show_in_dock(getattr(daemon, "cfg", {}) or {}))
rumps.events.before_quit.register(self._before_quit)
def set_dock(self, show: bool) -> None:
_call_on_main(lambda: set_dock_icon(show))
def _before_quit(self) -> None:
"""Any quit (Dock, ⌘Q, menu): stop the daemon and close the widget,
overlay and OpenFlow window with it. Recorded first, so nothing
(the widget, the LaunchAgent guard) brings OpenFlow back."""
try:
import supervisor
supervisor.mark_user_quit()
except Exception as e:
print(f"[tray] could not record the quit: {e}", flush=True)
try:
self.daemon.shutdown()
quit_ui = getattr(self.daemon, "close_ui", None)
if quit_ui is not None:
quit_ui()
except Exception as e:
print(f"[tray] quit cleanup failed: {e}", flush=True)
notify_hub({"quit": True})
# ── menu construction ────────────────────────────────────
def _build_menu(self) -> None:
for tone in ToneMode:
mi = rumps.MenuItem(_TONE_LABEL[tone], callback=self._make_tone_cb(tone))
mi.state = 1 if tone == self.state.tone else 0
self._tone_items[tone] = mi
for lang in LanguageMode:
mi = rumps.MenuItem(_LANG_LABEL[lang], callback=self._make_lang_cb(lang))
mi.state = 1 if lang == self.state.language else 0
self._lang_items[lang] = mi
self.menu = [
rumps.MenuItem("Open OpenFlow", callback=self._open_home),
("Tone", list(self._tone_items.values())),
("Language", list(self._lang_items.values())),
None,
rumps.MenuItem("History…", callback=self._open_history),
rumps.MenuItem("Dictionary…", callback=self._open_dictionary),
rumps.MenuItem("Settings…", callback=self._open_settings, key=","),
None,
rumps.MenuItem("Quit OpenFlow", callback=self._quit, key="q"),
]
def _make_tone_cb(self, tone: ToneMode):
def _cb(_sender):
self.daemon.choose_tone(tone) # applies and saves as the default
return _cb
def _make_lang_cb(self, lang: LanguageMode):
def _cb(_sender):
self.daemon.choose_language(lang)
return _cb
def _quit(self, _sender) -> None:
try:
self.daemon.shutdown()
finally:
rumps.quit_application()
# ── the main window (its own process) ────────────────────
# Each opens a page of the main window: see open_hub.
def _open_home(self, _sender) -> None:
open_hub("home")
def _open_dictionary(self, _sender) -> None:
open_hub("dictionary")
def _open_history(self, _sender) -> None:
open_hub("history")
def _open_settings(self, _sender) -> None:
open_hub("settings")
# ── state subscription ───────────────────────────────────
def _on_state_change(self, state: DaemonState) -> None:
_call_on_main(lambda: self._refresh(state))
def _refresh(self, state: DaemonState) -> None:
status = _STATUS_FROM_RECORDING.get(state.recording, "idle")
try:
self.template = tray_icon_is_template(status) # recording's dot is coloured
self.icon = str(tray_icon_resolved_path(status))
except Exception as e:
print(f"[tray] icon swap failed: {e}", flush=True)
for tone, mi in self._tone_items.items():
mi.state = 1 if tone == state.tone else 0
for lang, mi in self._lang_items.items():
mi.state = 1 if lang == state.language else 0
# ── daemon-facing API (kept for compat with old TrayApp) ─
def set_status(self, status: Status) -> None:
"""Compat shim. Prefer mutating daemon.state.recording + notify()."""
try:
self.template = tray_icon_is_template(status.value)
self.icon = str(tray_icon_resolved_path(status.value))
except Exception:
pass
def run_blocking(self) -> None:
self.run()
def stop(self) -> None:
try:
rumps.quit_application()
except Exception:
pass
# module → bundle CLI args mapping. Keep here so the bundle path stays
# explicit (avoids guessing dotted-name conventions inside PyInstaller).
_BUNDLE_SUBCOMMAND = {
"ui.hub": ["hub"],
}
# Modules that, from source, run through cli.py rather than as a script.
_SOURCE_VIA_CLI = {"ui.hub"}
# From source a hub started with sys.executable shows in the Dock as
# "Python" with Python's icon. When the app is installed, open the hub
# through it instead (see login_item.INSTALLED_APP; OPENFLOW_FROM_SOURCE=1
# keeps the source hub for development).
from login_item import INSTALLED_APP # noqa: E402
def _spawn_ui_subprocess(module: str, *args: str) -> None:
"""Launch a PyQt6 UI surface as an independent process.
Avoids dual-NSApp issues with rumps + PyQt6 in the same process.
Inherits env so the child reads the same config + dictionary file.
`args` follow the subcommand, e.g. ("ui.hub", "history").
"""
env = os.environ.copy()
# Strip LaunchServices per-instance vars — inherited, they make the
# child's windows invisible (registered under the daemon's instance).
for _k in ("__CFBundleIdentifier", "LaunchInstanceID",
"XPC_SERVICE_NAME", "XPC_FLAGS"):
env.pop(_k, None)
frozen = getattr(sys, "frozen", False)
use_installed = (not frozen and module in _BUNDLE_SUBCOMMAND
and not os.environ.get("OPENFLOW_FROM_SOURCE")
and INSTALLED_APP.is_dir())
if frozen or use_installed:
# PyInstaller bundle: route through LaunchServices — a directly
# exec'd child of this daemon never gets its windows onscreen.
sub = _BUNDLE_SUBCOMMAND.get(module)
if not sub:
print(f"[tray] no bundle subcommand for {module}", flush=True)
return
app_path = (str(Path(sys.executable).resolve().parents[2]) if frozen
else str(INSTALLED_APP))
cmd = ["/usr/bin/open", "-n", "-a", app_path, "--args", *sub, *args]
cwd = None
else:
repo_root = Path(__file__).resolve().parent
if module in _SOURCE_VIA_CLI:
cmd = [sys.executable, str(repo_root / "cli.py"), *_BUNDLE_SUBCOMMAND[module], *args]
else:
script = repo_root / (module.replace(".", "/") + ".py")
cmd = [sys.executable, str(script), *args]
cwd = str(repo_root)
try:
subprocess.Popen(cmd, env=env, cwd=cwd)
except Exception as e:
print(f"[tray] spawn {module} failed: {e}", flush=True)
rumps.alert(title="OpenFlow", message=f"Could not open {module}: {e}", ok="OK")
_reopen_installed = False
# ── Dock presence ─────────────────────────────────────────────────────────
HUB_SOCK = Path(os.path.expanduser("~/.openflow")) / "hub.sock"
def show_in_dock(cfg: dict) -> bool:
"""[hub] show_in_dock (default on)."""
return bool((cfg.get("hub") or {}).get("show_in_dock", True))
def set_dock_icon(show: bool, app=None) -> bool:
"""Regular activation policy (Dock icon, ⌘-Tab, Quit from the Dock) or
accessory (menu bar only). The bundle is LSUIElement, so this is the
only thing that puts the daemon in the Dock. Main thread only."""
try:
from AppKit import (NSApplication, NSApplicationActivationPolicyAccessory, # type: ignore
NSApplicationActivationPolicyRegular)
app = app or NSApplication.sharedApplication()
app.setActivationPolicy_(NSApplicationActivationPolicyRegular if show
else NSApplicationActivationPolicyAccessory)
if show:
_install_main_menu(app)
return True
except Exception as e:
print(f"[tray] dock icon {'on' if show else 'off'} failed: {e}", flush=True)
return False
def _install_main_menu(app) -> None:
"""A Dock app shows its menu in the menu bar when active; give it the
app menu with Quit (⌘Q) so the standard quit works."""
menu = app.mainMenu()
if menu is not None and menu.numberOfItems() > 0:
return
from AppKit import NSMenu, NSMenuItem # type: ignore
main = NSMenu.alloc().init()
app_item = NSMenuItem.alloc().init()
main.addItem_(app_item)
app_menu = NSMenu.alloc().initWithTitle_("OpenFlow")
app_menu.addItemWithTitle_action_keyEquivalent_("Quit OpenFlow", "terminate:", "q")
app_item.setSubmenu_(app_menu)
app.setMainMenu_(main)
def notify_hub(msg: dict, sock_path: Path | None = None, timeout: float = 0.5) -> bool:
"""Send one JSON line to a running OpenFlow window (hub.sock):
{"quit": true}, {"raise": true} or {"show": page}. False when no
window process is listening."""
import json
import socket
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.settimeout(timeout)
s.connect(str(sock_path or HUB_SOCK))
s.sendall((json.dumps(msg) + "\n").encode())
return True
except OSError:
return False
def open_hub(page: str) -> None:
"""Show `page` in the main window. A running hub (shown or hidden) gets
{"show": page} over hub.sock, which is instant; only when none answers
is a new one launched (`open -n -a OpenFlow.app --args hub <page>`,
~0.7–1.2 s even when it just hands the page to a running hub)."""
if not notify_hub({"show": page}):
_spawn_ui_subprocess("ui.hub", page)
def _on_reopen() -> None:
"""Finder / Spotlight / Dock opened OpenFlow while it runs: bring the
window forward on its current page, or open it on Home."""
try:
if not notify_hub({"raise": True}):
_spawn_ui_subprocess("ui.hub", "home")
except Exception as e:
print(f"[tray] reopen failed: {e}", flush=True)
def _install_reopen_handler() -> None:
"""Teach rumps' NSApplication delegate `applicationShouldHandleReopen:
hasVisibleWindows:`. rumps doesn't expose it, so add it to its delegate
class (rumps.rumps.NSApp) with a PyObjC category, once per process."""
global _reopen_installed
if _reopen_installed:
return
try:
import objc # type: ignore
from rumps.rumps import NSApp as _RumpsDelegate
class NSApp(objc.Category(_RumpsDelegate)): # noqa: F811 (category name = class name)
@objc.typedSelector(b"Z@:@Z")
def applicationShouldHandleReopen_hasVisibleWindows_(self, _app, _visible):
_on_reopen()
return False # handled; nothing for AppKit to do
_reopen_installed = True
except Exception as e:
print(f"[tray] reopen handler not installed: {e}", flush=True)
# Backwards-compat alias: daemon.py imports TrayApp.
TrayApp = OpenFlowTray