susops 3.0.0rc3.dev1__py3-none-any.whl
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.
- susops/__init__.py +4 -0
- susops/client.py +230 -0
- susops/core/__init__.py +0 -0
- susops/core/browsers.py +330 -0
- susops/core/config.py +253 -0
- susops/core/log_style.py +92 -0
- susops/core/pac.py +185 -0
- susops/core/ports.py +57 -0
- susops/core/process.py +167 -0
- susops/core/rpc_protocol.py +186 -0
- susops/core/rpc_server.py +131 -0
- susops/core/services_daemon.py +312 -0
- susops/core/share.py +323 -0
- susops/core/socat.py +200 -0
- susops/core/ssh.py +330 -0
- susops/core/ssh_config.py +40 -0
- susops/core/status.py +245 -0
- susops/core/types.py +171 -0
- susops/facade.py +2237 -0
- susops/tray/__init__.py +20 -0
- susops/tray/base.py +650 -0
- susops/tray/linux.py +1623 -0
- susops/tray/mac.py +3105 -0
- susops/tui/__init__.py +0 -0
- susops/tui/__main__.py +44 -0
- susops/tui/app.py +191 -0
- susops/tui/cli.py +665 -0
- susops/tui/screens/__init__.py +114 -0
- susops/tui/screens/connections.py +871 -0
- susops/tui/screens/dashboard.py +935 -0
- susops/tui/screens/shares.py +357 -0
- susops/tui/widgets/__init__.py +0 -0
- susops/tui/widgets/connection_card.py +137 -0
- susops/version.py +12 -0
- susops-3.0.0rc3.dev1.dist-info/METADATA +977 -0
- susops-3.0.0rc3.dev1.dist-info/RECORD +40 -0
- susops-3.0.0rc3.dev1.dist-info/WHEEL +5 -0
- susops-3.0.0rc3.dev1.dist-info/entry_points.txt +7 -0
- susops-3.0.0rc3.dev1.dist-info/licenses/LICENSE +674 -0
- susops-3.0.0rc3.dev1.dist-info/top_level.txt +1 -0
susops/tray/mac.py
ADDED
|
@@ -0,0 +1,3105 @@
|
|
|
1
|
+
"""macOS tray app — rumps + PyObjC.
|
|
2
|
+
|
|
3
|
+
Requires: pip install 'susops[tray-mac]' (rumps>=0.4)
|
|
4
|
+
|
|
5
|
+
Matches the Linux tray feature-set: single multi-field dialogs (NSAlert +
|
|
6
|
+
accessoryView), logo-style picker with live preview, launch-at-login,
|
|
7
|
+
auto-discovered browser submenu, NSPopUpButton-based pickers, native
|
|
8
|
+
file-open panel for share, and a custom About panel.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import threading
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Callable
|
|
18
|
+
|
|
19
|
+
from susops.core.config import PortForward
|
|
20
|
+
from susops.core.ports import is_port_free, validate_port
|
|
21
|
+
from susops.core.types import LogoStyle, ProcessState
|
|
22
|
+
from susops.tray.base import AbstractTrayApp, get_icon_path, get_ssh_hosts
|
|
23
|
+
|
|
24
|
+
BIND_ADDRESSES = ["localhost", "172.17.0.1", "0.0.0.0"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Appearance + icon helpers
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_dark_theme() -> bool:
|
|
33
|
+
"""Return True when macOS is using Dark Mode."""
|
|
34
|
+
try:
|
|
35
|
+
from AppKit import NSApplication # type: ignore[import]
|
|
36
|
+
appearance = NSApplication.sharedApplication().effectiveAppearance().name()
|
|
37
|
+
return "dark" in appearance.lower()
|
|
38
|
+
except Exception:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _get_icon_path(state: ProcessState, logo_style: str = "colored_glasses") -> str | None:
|
|
43
|
+
"""Return icon path for state, respecting macOS light/dark appearance.
|
|
44
|
+
|
|
45
|
+
Appearance is inverted: dark menu bar → light icons (and vice versa) so
|
|
46
|
+
the asset is visible against the bar background.
|
|
47
|
+
"""
|
|
48
|
+
variant = "light" if _is_dark_theme() else "dark"
|
|
49
|
+
return get_icon_path(state, logo_style=logo_style, variant=variant, prefer_png=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_STATUS_ICONS_DIR = Path(__file__).parent.parent.parent.parent / "assets" / "icons" / "status"
|
|
53
|
+
|
|
54
|
+
_STATUS_ICON_NAMES = {
|
|
55
|
+
ProcessState.RUNNING: "running",
|
|
56
|
+
ProcessState.STOPPED_PARTIALLY: "stopped_partially",
|
|
57
|
+
ProcessState.STOPPED: "stopped",
|
|
58
|
+
ProcessState.ERROR: "error",
|
|
59
|
+
ProcessState.INITIAL: "stopped",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _get_status_icon_path(state: ProcessState) -> str | None:
|
|
64
|
+
"""Return path to the colored-circle SVG used as the status menu item's icon."""
|
|
65
|
+
name = _STATUS_ICON_NAMES.get(state, "stopped")
|
|
66
|
+
p = _STATUS_ICONS_DIR / f"{name}.svg"
|
|
67
|
+
return str(p) if p.exists() else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Browser discovery (macOS)
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _find_installed_browsers() -> list[tuple[str, str, bool]]:
|
|
76
|
+
"""Return list of (app_bundle, display_name, is_chromium) for installed browsers.
|
|
77
|
+
|
|
78
|
+
Thin adapter over susops.core.browsers.detect_browsers() — the menu
|
|
79
|
+
construction code below was written before the shared module existed
|
|
80
|
+
and expects this 3-tuple shape. Kept stable for that reason.
|
|
81
|
+
"""
|
|
82
|
+
from susops.core.browsers import detect_browsers
|
|
83
|
+
return [(b.bundle, b.name, b.is_chromium) for b in detect_browsers() if b.bundle]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Module-level NSObject helper for live segment preview
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
_segmented_handler_cls = None
|
|
91
|
+
_switch_handler_cls = None
|
|
92
|
+
_main_dispatcher_cls = None
|
|
93
|
+
_button_handler_cls = None
|
|
94
|
+
_tagged_button_handler_cls = None
|
|
95
|
+
_window_close_delegate_cls = None
|
|
96
|
+
_url_handler_cls = None
|
|
97
|
+
_modal_panel_cls = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _get_modal_panel_cls():
|
|
101
|
+
"""Lazily build an NSPanel subclass that always becomes key/main.
|
|
102
|
+
|
|
103
|
+
NSPanel's default behavior is `becomesKeyOnlyIfNeeded == YES` and
|
|
104
|
+
`canBecomeKeyWindow` returns NO for some configurations. That's why text
|
|
105
|
+
fields can look focused (border highlighted) yet silently reject keystrokes:
|
|
106
|
+
the panel itself never becomes key, so the first responder never receives
|
|
107
|
+
keyDown events. Subclassing and forcing both predicates to True fixes it.
|
|
108
|
+
"""
|
|
109
|
+
global _modal_panel_cls
|
|
110
|
+
if _modal_panel_cls is not None:
|
|
111
|
+
return _modal_panel_cls
|
|
112
|
+
|
|
113
|
+
import objc # type: ignore[import]
|
|
114
|
+
from AppKit import NSPanel # type: ignore[import]
|
|
115
|
+
|
|
116
|
+
class _SusOpsModalPanel(NSPanel):
|
|
117
|
+
def canBecomeKeyWindow(self):
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
def canBecomeMainWindow(self):
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
_modal_panel_cls = _SusOpsModalPanel
|
|
124
|
+
return _SusOpsModalPanel
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _get_url_handler_cls():
|
|
128
|
+
"""Lazily build the NSObject subclass that opens a URL when its button is clicked."""
|
|
129
|
+
global _url_handler_cls
|
|
130
|
+
if _url_handler_cls is not None:
|
|
131
|
+
return _url_handler_cls
|
|
132
|
+
|
|
133
|
+
import objc # type: ignore[import]
|
|
134
|
+
from Cocoa import NSObject # type: ignore[import]
|
|
135
|
+
|
|
136
|
+
class _URLHandler(NSObject):
|
|
137
|
+
def initWithURL_(self, url):
|
|
138
|
+
self = objc.super(_URLHandler, self).init()
|
|
139
|
+
if self is None:
|
|
140
|
+
return None
|
|
141
|
+
self._url = url
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def openURL_(self, _):
|
|
145
|
+
try:
|
|
146
|
+
from AppKit import NSWorkspace # type: ignore[import]
|
|
147
|
+
from Foundation import NSURL # type: ignore[import]
|
|
148
|
+
ns_url = NSURL.URLWithString_(self._url)
|
|
149
|
+
if ns_url is not None:
|
|
150
|
+
NSWorkspace.sharedWorkspace().openURL_(ns_url)
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
_url_handler_cls = _URLHandler
|
|
155
|
+
return _URLHandler
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _get_button_handler_cls():
|
|
159
|
+
"""Lazily build the NSObject subclass used to handle OK/Cancel buttons on form panels.
|
|
160
|
+
|
|
161
|
+
The handler stops the NSApplication modal session with a response code:
|
|
162
|
+
1 = OK, 0 = Cancel.
|
|
163
|
+
"""
|
|
164
|
+
global _button_handler_cls
|
|
165
|
+
if _button_handler_cls is not None:
|
|
166
|
+
return _button_handler_cls
|
|
167
|
+
|
|
168
|
+
import objc # type: ignore[import]
|
|
169
|
+
from Cocoa import NSObject # type: ignore[import]
|
|
170
|
+
|
|
171
|
+
class _ButtonHandler(NSObject):
|
|
172
|
+
def okClicked_(self, _):
|
|
173
|
+
try:
|
|
174
|
+
from AppKit import NSApplication # type: ignore[import]
|
|
175
|
+
NSApplication.sharedApplication().stopModalWithCode_(1)
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
def cancelClicked_(self, _):
|
|
180
|
+
try:
|
|
181
|
+
from AppKit import NSApplication # type: ignore[import]
|
|
182
|
+
NSApplication.sharedApplication().stopModalWithCode_(0)
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
_button_handler_cls = _ButtonHandler
|
|
187
|
+
return _ButtonHandler
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _get_tagged_button_handler_cls():
|
|
191
|
+
"""Lazily build the NSObject subclass that stops the modal with the sender's tag."""
|
|
192
|
+
global _tagged_button_handler_cls
|
|
193
|
+
if _tagged_button_handler_cls is not None:
|
|
194
|
+
return _tagged_button_handler_cls
|
|
195
|
+
|
|
196
|
+
import objc # type: ignore[import]
|
|
197
|
+
from Cocoa import NSObject # type: ignore[import]
|
|
198
|
+
|
|
199
|
+
class _TaggedButtonHandler(NSObject):
|
|
200
|
+
def buttonClicked_(self, sender):
|
|
201
|
+
try:
|
|
202
|
+
from AppKit import NSApplication # type: ignore[import]
|
|
203
|
+
NSApplication.sharedApplication().stopModalWithCode_(int(sender.tag()))
|
|
204
|
+
except Exception:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
_tagged_button_handler_cls = _TaggedButtonHandler
|
|
208
|
+
return _TaggedButtonHandler
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _get_window_close_delegate_cls():
|
|
212
|
+
"""Lazily build the NSObject subclass used as window delegate to handle the X button.
|
|
213
|
+
|
|
214
|
+
When the user closes the panel via the close button, the modal is stopped
|
|
215
|
+
with response code 0 (treated as cancel).
|
|
216
|
+
"""
|
|
217
|
+
global _window_close_delegate_cls
|
|
218
|
+
if _window_close_delegate_cls is not None:
|
|
219
|
+
return _window_close_delegate_cls
|
|
220
|
+
|
|
221
|
+
import objc # type: ignore[import]
|
|
222
|
+
from Cocoa import NSObject # type: ignore[import]
|
|
223
|
+
|
|
224
|
+
class _WindowCloseDelegate(NSObject):
|
|
225
|
+
def windowShouldClose_(self, _sender):
|
|
226
|
+
try:
|
|
227
|
+
from AppKit import NSApplication # type: ignore[import]
|
|
228
|
+
NSApplication.sharedApplication().stopModalWithCode_(0)
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
return True
|
|
232
|
+
|
|
233
|
+
_window_close_delegate_cls = _WindowCloseDelegate
|
|
234
|
+
return _WindowCloseDelegate
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _get_segmented_handler_cls():
|
|
238
|
+
"""Lazily build the NSObject subclass used as target for segmented controls."""
|
|
239
|
+
global _segmented_handler_cls
|
|
240
|
+
if _segmented_handler_cls is not None:
|
|
241
|
+
return _segmented_handler_cls
|
|
242
|
+
|
|
243
|
+
import objc # type: ignore[import]
|
|
244
|
+
from Cocoa import NSObject # type: ignore[import]
|
|
245
|
+
|
|
246
|
+
class _SegmentedHandler(NSObject):
|
|
247
|
+
def initWithCallback_(self, callback):
|
|
248
|
+
self = objc.super(_SegmentedHandler, self).init()
|
|
249
|
+
if self is None:
|
|
250
|
+
return None
|
|
251
|
+
self._cb = callback
|
|
252
|
+
return self
|
|
253
|
+
|
|
254
|
+
def segmentChanged_(self, sender):
|
|
255
|
+
try:
|
|
256
|
+
self._cb(sender.selectedSegment())
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
_segmented_handler_cls = _SegmentedHandler
|
|
261
|
+
return _SegmentedHandler
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _get_switch_handler_cls():
|
|
265
|
+
"""Lazily build the NSObject subclass used as target for NSSwitch buttons."""
|
|
266
|
+
global _switch_handler_cls
|
|
267
|
+
if _switch_handler_cls is not None:
|
|
268
|
+
return _switch_handler_cls
|
|
269
|
+
|
|
270
|
+
import objc # type: ignore[import]
|
|
271
|
+
from Cocoa import NSObject # type: ignore[import]
|
|
272
|
+
|
|
273
|
+
class _SwitchHandler(NSObject):
|
|
274
|
+
def initWithCallback_(self, callback):
|
|
275
|
+
self = objc.super(_SwitchHandler, self).init()
|
|
276
|
+
if self is None:
|
|
277
|
+
return None
|
|
278
|
+
self._cb = callback
|
|
279
|
+
return self
|
|
280
|
+
|
|
281
|
+
def switchChanged_(self, sender):
|
|
282
|
+
try:
|
|
283
|
+
self._cb(bool(sender.state()))
|
|
284
|
+
except Exception:
|
|
285
|
+
pass
|
|
286
|
+
|
|
287
|
+
_switch_handler_cls = _SwitchHandler
|
|
288
|
+
return _SwitchHandler
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _get_main_dispatcher_cls():
|
|
292
|
+
"""Lazily build the NSObject subclass used to dispatch callables onto the main thread."""
|
|
293
|
+
global _main_dispatcher_cls
|
|
294
|
+
if _main_dispatcher_cls is not None:
|
|
295
|
+
return _main_dispatcher_cls
|
|
296
|
+
|
|
297
|
+
import objc # type: ignore[import]
|
|
298
|
+
from Cocoa import NSObject # type: ignore[import]
|
|
299
|
+
|
|
300
|
+
class _MainDispatcher(NSObject):
|
|
301
|
+
def initWithCallable_(self, callable_):
|
|
302
|
+
self = objc.super(_MainDispatcher, self).init()
|
|
303
|
+
if self is None:
|
|
304
|
+
return None
|
|
305
|
+
self._callable = callable_
|
|
306
|
+
return self
|
|
307
|
+
|
|
308
|
+
def fire_(self, _):
|
|
309
|
+
try:
|
|
310
|
+
self._callable()
|
|
311
|
+
except Exception:
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
_main_dispatcher_cls = _MainDispatcher
|
|
315
|
+
return _MainDispatcher
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _on_main(callable_) -> None:
|
|
319
|
+
"""Schedule `callable_` to run on the main (NSApplication) thread.
|
|
320
|
+
|
|
321
|
+
Use this to marshal UI updates triggered from background threads
|
|
322
|
+
(e.g. the SSE listener) onto the Cocoa main runloop.
|
|
323
|
+
"""
|
|
324
|
+
try:
|
|
325
|
+
cls = _get_main_dispatcher_cls()
|
|
326
|
+
disp = cls.alloc().initWithCallable_(callable_)
|
|
327
|
+
disp.performSelectorOnMainThread_withObject_waitUntilDone_("fire:", None, False)
|
|
328
|
+
except Exception:
|
|
329
|
+
# Last-resort fallback: run inline (matches pre-existing behavior).
|
|
330
|
+
try:
|
|
331
|
+
callable_()
|
|
332
|
+
except Exception:
|
|
333
|
+
pass
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
# Dialog helpers
|
|
338
|
+
# ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _activate_app() -> None:
|
|
342
|
+
"""Bring the SusOps app to front so dialogs receive focus."""
|
|
343
|
+
try:
|
|
344
|
+
from AppKit import NSApplication # type: ignore[import]
|
|
345
|
+
NSApplication.sharedApplication().activateIgnoringOtherApps_(True)
|
|
346
|
+
except Exception:
|
|
347
|
+
pass
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# Depth-counted activation-policy management — only switches policy when the
|
|
351
|
+
# app is currently in .accessory mode (the bundled .app sets LSUIElement=YES).
|
|
352
|
+
# When running `.venv/bin/susops-tray` directly (.regular policy), this is a
|
|
353
|
+
# no-op: no Dock-icon flash, no behavioural change.
|
|
354
|
+
#
|
|
355
|
+
# Why this exists at all: `activateIgnoringOtherApps:` does NOT reliably
|
|
356
|
+
# activate accessory-mode apps on macOS, so dialogs from the bundled tray
|
|
357
|
+
# fail to receive key-window status on second/subsequent open. The standard
|
|
358
|
+
# workaround is to switch to .regular for the duration of any modal session,
|
|
359
|
+
# then restore the previous policy.
|
|
360
|
+
#
|
|
361
|
+
# Restoration is delayed via NSTimer so a quickly-following chained dialog
|
|
362
|
+
# (e.g. a success alert opened from the previous dialog's OK handler) shares
|
|
363
|
+
# the same policy transition — otherwise the chained alert opens behind the
|
|
364
|
+
# foreground app and looks like a freeze.
|
|
365
|
+
|
|
366
|
+
_policy_depth = 0
|
|
367
|
+
_policy_prev: int | None = None
|
|
368
|
+
_policy_restore_timer = None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _cancel_policy_restore_timer() -> None:
|
|
372
|
+
global _policy_restore_timer
|
|
373
|
+
if _policy_restore_timer is not None:
|
|
374
|
+
try:
|
|
375
|
+
_policy_restore_timer.invalidate()
|
|
376
|
+
except Exception:
|
|
377
|
+
pass
|
|
378
|
+
_policy_restore_timer = None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _try_restore_policy(_timer=None) -> None:
|
|
382
|
+
global _policy_depth, _policy_prev, _policy_restore_timer
|
|
383
|
+
_policy_restore_timer = None
|
|
384
|
+
if _policy_depth > 0:
|
|
385
|
+
return # another modal opened; keep .regular
|
|
386
|
+
if _policy_prev is None:
|
|
387
|
+
return
|
|
388
|
+
try:
|
|
389
|
+
from AppKit import NSApplication # type: ignore[import]
|
|
390
|
+
NSApplication.sharedApplication().setActivationPolicy_(_policy_prev)
|
|
391
|
+
except Exception:
|
|
392
|
+
pass
|
|
393
|
+
_policy_prev = None
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
_edit_menu_installed = False
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _ensure_edit_menu() -> None:
|
|
400
|
+
"""Install a minimal app-level Edit menu so Cmd+C/V/X/A/Z work in dialogs.
|
|
401
|
+
|
|
402
|
+
LSUIElement-style apps (no Dock icon, no menubar by default) get NO app
|
|
403
|
+
menu from AppKit. Without an Edit menu, ``performKeyEquivalent:`` walks
|
|
404
|
+
the menu hierarchy looking for an item whose key equivalent matches the
|
|
405
|
+
pressed combo + whose action selector exists somewhere on the responder
|
|
406
|
+
chain. With no menu, the lookup fails and Cmd+C / Cmd+V / Cmd+X /
|
|
407
|
+
Cmd+A / Cmd+Z fall through — text fields appear to "swallow" the
|
|
408
|
+
shortcut.
|
|
409
|
+
|
|
410
|
+
We install an Edit menu with nil-targeted items so AppKit routes the
|
|
411
|
+
selectors (``copy:``, ``paste:``, ``cut:``, ``selectAll:``, ``undo:``,
|
|
412
|
+
``redo:``) up the responder chain to the field editor (NSText), which
|
|
413
|
+
has built-in implementations.
|
|
414
|
+
|
|
415
|
+
Idempotent — runs at most once per process.
|
|
416
|
+
"""
|
|
417
|
+
global _edit_menu_installed
|
|
418
|
+
if _edit_menu_installed:
|
|
419
|
+
return
|
|
420
|
+
try:
|
|
421
|
+
from AppKit import NSApplication, NSMenu, NSMenuItem # type: ignore[import]
|
|
422
|
+
|
|
423
|
+
app = NSApplication.sharedApplication()
|
|
424
|
+
main_menu = app.mainMenu()
|
|
425
|
+
if main_menu is None:
|
|
426
|
+
main_menu = NSMenu.alloc().init()
|
|
427
|
+
app.setMainMenu_(main_menu)
|
|
428
|
+
|
|
429
|
+
# AppKit conventions: the first top-level item is the "app menu"
|
|
430
|
+
# (whose label gets ignored — macOS uses the app name). Even though
|
|
431
|
+
# we don't populate it, having it present makes the menubar render
|
|
432
|
+
# correctly during the activation-policy regular scope.
|
|
433
|
+
if main_menu.numberOfItems() == 0:
|
|
434
|
+
app_item = NSMenuItem.alloc().init()
|
|
435
|
+
app_item.setSubmenu_(NSMenu.alloc().initWithTitle_("SusOps"))
|
|
436
|
+
main_menu.addItem_(app_item)
|
|
437
|
+
|
|
438
|
+
edit_item = NSMenuItem.alloc().init()
|
|
439
|
+
edit_item.setTitle_("Edit")
|
|
440
|
+
edit_submenu = NSMenu.alloc().initWithTitle_("Edit")
|
|
441
|
+
|
|
442
|
+
# (label, selector, key-equivalent). Nil target → walks responder
|
|
443
|
+
# chain → reaches the text field's editor, which implements these.
|
|
444
|
+
spec = [
|
|
445
|
+
("Undo", "undo:", "z"),
|
|
446
|
+
("Redo", "redo:", "Z"), # Cmd+Shift+Z
|
|
447
|
+
(None, None, None), # separator
|
|
448
|
+
("Cut", "cut:", "x"),
|
|
449
|
+
("Copy", "copy:", "c"),
|
|
450
|
+
("Paste", "paste:", "v"),
|
|
451
|
+
(None, None, None),
|
|
452
|
+
("Select All", "selectAll:", "a"),
|
|
453
|
+
]
|
|
454
|
+
for label, selector, key in spec:
|
|
455
|
+
if label is None:
|
|
456
|
+
edit_submenu.addItem_(NSMenuItem.separatorItem())
|
|
457
|
+
continue
|
|
458
|
+
item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
|
|
459
|
+
label, selector, key,
|
|
460
|
+
)
|
|
461
|
+
edit_submenu.addItem_(item)
|
|
462
|
+
|
|
463
|
+
edit_item.setSubmenu_(edit_submenu)
|
|
464
|
+
main_menu.addItem_(edit_item)
|
|
465
|
+
_edit_menu_installed = True
|
|
466
|
+
except Exception:
|
|
467
|
+
pass
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class _RegularPolicyScope:
|
|
471
|
+
"""Context manager: ensure app is in .regular activation policy while open.
|
|
472
|
+
|
|
473
|
+
No-op when the app is already .regular (e.g. running from `.venv/bin/`).
|
|
474
|
+
Switches accessory → regular for bundled .app (LSUIElement=YES) so modal
|
|
475
|
+
dialogs reliably receive key-window focus. Stacks safely: chained scopes
|
|
476
|
+
share one policy transition; restoration is delayed by 0.3 s so a chained
|
|
477
|
+
dialog cancels the pending restore.
|
|
478
|
+
|
|
479
|
+
Also lazily installs the app-level Edit menu on first entry so Cmd+C /
|
|
480
|
+
Cmd+V / etc. work in text fields inside dialogs — see _ensure_edit_menu.
|
|
481
|
+
"""
|
|
482
|
+
|
|
483
|
+
def __enter__(self):
|
|
484
|
+
global _policy_depth, _policy_prev
|
|
485
|
+
try:
|
|
486
|
+
from AppKit import ( # type: ignore[import]
|
|
487
|
+
NSApplication,
|
|
488
|
+
NSApplicationActivationPolicyRegular,
|
|
489
|
+
)
|
|
490
|
+
_ensure_edit_menu()
|
|
491
|
+
_cancel_policy_restore_timer()
|
|
492
|
+
app = NSApplication.sharedApplication()
|
|
493
|
+
current = app.activationPolicy()
|
|
494
|
+
if _policy_depth == 0:
|
|
495
|
+
_policy_prev = current
|
|
496
|
+
if current != NSApplicationActivationPolicyRegular:
|
|
497
|
+
app.setActivationPolicy_(NSApplicationActivationPolicyRegular)
|
|
498
|
+
app.activateIgnoringOtherApps_(True)
|
|
499
|
+
_policy_depth += 1
|
|
500
|
+
except Exception:
|
|
501
|
+
pass
|
|
502
|
+
return self
|
|
503
|
+
|
|
504
|
+
def __exit__(self, *_):
|
|
505
|
+
global _policy_depth, _policy_restore_timer
|
|
506
|
+
if _policy_depth > 0:
|
|
507
|
+
_policy_depth -= 1
|
|
508
|
+
if _policy_depth == 0:
|
|
509
|
+
try:
|
|
510
|
+
from Foundation import NSTimer # type: ignore[import]
|
|
511
|
+
_policy_restore_timer = NSTimer.scheduledTimerWithTimeInterval_repeats_block_(
|
|
512
|
+
0.3, False, _try_restore_policy
|
|
513
|
+
)
|
|
514
|
+
except Exception:
|
|
515
|
+
_try_restore_policy()
|
|
516
|
+
return False
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _make_label(text: str, x: float, y: float, w: float, h: float, *, right: bool = True):
|
|
520
|
+
from Cocoa import NSTextField, NSMakeRect # type: ignore[import]
|
|
521
|
+
lbl = NSTextField.alloc().initWithFrame_(NSMakeRect(x, y, w, h))
|
|
522
|
+
lbl.setStringValue_(text)
|
|
523
|
+
lbl.setBezeled_(False)
|
|
524
|
+
lbl.setDrawsBackground_(False)
|
|
525
|
+
lbl.setEditable_(False)
|
|
526
|
+
lbl.setSelectable_(False)
|
|
527
|
+
lbl.setAlignment_(2 if right else 0) # 2 = right, 0 = left
|
|
528
|
+
return lbl
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _show_form_dialog(
|
|
532
|
+
title: str,
|
|
533
|
+
fields: list[dict],
|
|
534
|
+
*,
|
|
535
|
+
ok_title: str = "OK",
|
|
536
|
+
cancel_title: str = "Cancel",
|
|
537
|
+
informative: str | None = None,
|
|
538
|
+
icon_path: str | None = None,
|
|
539
|
+
) -> dict | None:
|
|
540
|
+
"""Show a multi-field modal NSPanel form.
|
|
541
|
+
|
|
542
|
+
Built as a floating NSPanel + NSApplication.runModalForWindow_ rather than
|
|
543
|
+
NSAlert+accessoryView — the latter has focus/window-order quirks in
|
|
544
|
+
menu-extra (LSUIElement) rumps apps that can leave the dialog invisible
|
|
545
|
+
while the main thread is blocked in runModal.
|
|
546
|
+
|
|
547
|
+
fields: list of dicts with keys:
|
|
548
|
+
- key: str (result key)
|
|
549
|
+
- label: str (display label; trailing colon recommended)
|
|
550
|
+
- kind: "text" | "secure" | "popup" | "combo" | "switch" | "segmented"
|
|
551
|
+
- default: default value (str for text/secure, str for popup/combo selection,
|
|
552
|
+
bool for switch, int index for segmented)
|
|
553
|
+
- options: list — required for popup/combo/segmented
|
|
554
|
+
- hint: optional placeholder text (text/secure/combo)
|
|
555
|
+
- on_change: optional callback(index) for live preview (segmented)
|
|
556
|
+
|
|
557
|
+
Returns dict {key: value} or None if cancelled.
|
|
558
|
+
"""
|
|
559
|
+
from AppKit import ( # type: ignore[import]
|
|
560
|
+
NSApplication,
|
|
561
|
+
NSBackingStoreBuffered,
|
|
562
|
+
NSFloatingWindowLevel,
|
|
563
|
+
NSImage,
|
|
564
|
+
NSImageScaleProportionallyDown,
|
|
565
|
+
NSOffState,
|
|
566
|
+
NSOnState,
|
|
567
|
+
NSPanel,
|
|
568
|
+
NSRegularControlSize,
|
|
569
|
+
NSSegmentSwitchTrackingSelectOne,
|
|
570
|
+
NSSwitchButton,
|
|
571
|
+
NSWindowStyleMaskClosable,
|
|
572
|
+
NSWindowStyleMaskTitled,
|
|
573
|
+
)
|
|
574
|
+
from Cocoa import ( # type: ignore[import]
|
|
575
|
+
NSButton,
|
|
576
|
+
NSComboBox,
|
|
577
|
+
NSMakeRect,
|
|
578
|
+
NSPopUpButton,
|
|
579
|
+
NSSecureTextField,
|
|
580
|
+
NSSegmentedControl,
|
|
581
|
+
NSTextField,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
# Layout constants tuned to match susops-mac's GenericFieldPanel:
|
|
585
|
+
# - 40-px row pitch (24-px field + 16-px gap)
|
|
586
|
+
# - 80x30 buttons positioned at the edges of the input column
|
|
587
|
+
# - Cancel on the left (just outside the input column), OK on the right
|
|
588
|
+
LABEL_W = 160
|
|
589
|
+
LABEL_GAP = 10
|
|
590
|
+
INPUT_W = 200
|
|
591
|
+
ROW_H = 24
|
|
592
|
+
ROW_GAP = 16
|
|
593
|
+
PAD_X = 16
|
|
594
|
+
PAD_TOP = 16
|
|
595
|
+
BUTTON_H = 30
|
|
596
|
+
BUTTON_W = 80
|
|
597
|
+
BUTTON_BOTTOM = 16
|
|
598
|
+
GAP_BEFORE_BUTTONS = 16
|
|
599
|
+
BUTTON_AREA = GAP_BEFORE_BUTTONS + BUTTON_H + BUTTON_BOTTOM
|
|
600
|
+
|
|
601
|
+
rows = len(fields)
|
|
602
|
+
# Info rows can be multi-line; estimate their extra height so the panel
|
|
603
|
+
# is tall enough.
|
|
604
|
+
info_extra_h = 0
|
|
605
|
+
for f in fields:
|
|
606
|
+
if f.get("kind") == "info":
|
|
607
|
+
text_value = str(f.get("default", "") or "")
|
|
608
|
+
line_count = text_value.count("\n") + 1
|
|
609
|
+
info_h = max(ROW_H, line_count * 16 + 4)
|
|
610
|
+
info_extra_h += max(0, info_h - ROW_H)
|
|
611
|
+
fields_h = rows * ROW_H + max(0, rows - 1) * ROW_GAP + info_extra_h
|
|
612
|
+
content_w = PAD_X + LABEL_W + LABEL_GAP + INPUT_W + PAD_X
|
|
613
|
+
content_h = PAD_TOP + fields_h + BUTTON_AREA
|
|
614
|
+
|
|
615
|
+
style = NSWindowStyleMaskTitled | NSWindowStyleMaskClosable
|
|
616
|
+
panel = NSPanel.alloc().initWithContentRect_styleMask_backing_defer_(
|
|
617
|
+
NSMakeRect(0, 0, content_w, content_h),
|
|
618
|
+
style,
|
|
619
|
+
NSBackingStoreBuffered,
|
|
620
|
+
False,
|
|
621
|
+
)
|
|
622
|
+
panel.setTitle_(title)
|
|
623
|
+
panel.setReleasedWhenClosed_(False)
|
|
624
|
+
panel.setHidesOnDeactivate_(False)
|
|
625
|
+
panel.setLevel_(NSFloatingWindowLevel)
|
|
626
|
+
# NSPanel default is becomesKeyOnlyIfNeeded=YES which can prevent the
|
|
627
|
+
# panel from becoming key for our modal — explicitly disable it so
|
|
628
|
+
# text fields receive keystrokes.
|
|
629
|
+
try:
|
|
630
|
+
panel.setBecomesKeyOnlyIfNeeded_(False)
|
|
631
|
+
except Exception:
|
|
632
|
+
pass
|
|
633
|
+
|
|
634
|
+
content = panel.contentView()
|
|
635
|
+
widgets: dict[str, object] = {}
|
|
636
|
+
handlers: list = []
|
|
637
|
+
|
|
638
|
+
label_x = PAD_X
|
|
639
|
+
input_x = PAD_X + LABEL_W + LABEL_GAP
|
|
640
|
+
|
|
641
|
+
# Layout top-to-bottom (content view uses bottom-left origin).
|
|
642
|
+
y = content_h - PAD_TOP - ROW_H
|
|
643
|
+
for f in fields:
|
|
644
|
+
key = f["key"]
|
|
645
|
+
kind = f.get("kind", "text")
|
|
646
|
+
default = f.get("default", "")
|
|
647
|
+
options = f.get("options") or []
|
|
648
|
+
hint = f.get("hint")
|
|
649
|
+
on_change = f.get("on_change")
|
|
650
|
+
|
|
651
|
+
lbl = _make_label(f.get("label", ""), label_x, y, LABEL_W, ROW_H, right=True)
|
|
652
|
+
content.addSubview_(lbl)
|
|
653
|
+
|
|
654
|
+
if kind == "text":
|
|
655
|
+
# Use full ROW_H height — text fields shorter than ~22 px don't
|
|
656
|
+
# receive keyboard input correctly on macOS (cursor can't fit).
|
|
657
|
+
field = NSTextField.alloc().initWithFrame_(NSMakeRect(input_x, y, INPUT_W, ROW_H))
|
|
658
|
+
field.setStringValue_(str(default) if default is not None else "")
|
|
659
|
+
field.setEditable_(True)
|
|
660
|
+
field.setSelectable_(True)
|
|
661
|
+
field.setEnabled_(True)
|
|
662
|
+
field.setBezeled_(True)
|
|
663
|
+
if hint:
|
|
664
|
+
try:
|
|
665
|
+
field.cell().setPlaceholderString_(hint)
|
|
666
|
+
except Exception:
|
|
667
|
+
pass
|
|
668
|
+
content.addSubview_(field)
|
|
669
|
+
widgets[key] = field
|
|
670
|
+
|
|
671
|
+
elif kind == "secure":
|
|
672
|
+
field = NSSecureTextField.alloc().initWithFrame_(NSMakeRect(input_x, y, INPUT_W, ROW_H))
|
|
673
|
+
field.setStringValue_(str(default) if default is not None else "")
|
|
674
|
+
field.setEditable_(True)
|
|
675
|
+
field.setSelectable_(True)
|
|
676
|
+
field.setEnabled_(True)
|
|
677
|
+
field.setBezeled_(True)
|
|
678
|
+
if hint:
|
|
679
|
+
try:
|
|
680
|
+
field.cell().setPlaceholderString_(hint)
|
|
681
|
+
except Exception:
|
|
682
|
+
pass
|
|
683
|
+
content.addSubview_(field)
|
|
684
|
+
widgets[key] = field
|
|
685
|
+
|
|
686
|
+
elif kind == "popup":
|
|
687
|
+
popup = NSPopUpButton.alloc().initWithFrame_(NSMakeRect(input_x, y, INPUT_W, ROW_H))
|
|
688
|
+
popup.setPullsDown_(False)
|
|
689
|
+
titles = [str(o) for o in options]
|
|
690
|
+
popup.addItemsWithTitles_(titles)
|
|
691
|
+
if default is not None and str(default) in titles:
|
|
692
|
+
popup.selectItemWithTitle_(str(default))
|
|
693
|
+
elif titles:
|
|
694
|
+
popup.selectItemAtIndex_(0)
|
|
695
|
+
content.addSubview_(popup)
|
|
696
|
+
widgets[key] = popup
|
|
697
|
+
|
|
698
|
+
elif kind == "combo":
|
|
699
|
+
combo = NSComboBox.alloc().initWithFrame_(NSMakeRect(input_x, y, INPUT_W, ROW_H))
|
|
700
|
+
combo.addItemsWithObjectValues_([str(o) for o in options])
|
|
701
|
+
# Enable inline autocomplete against the list (so typing "myh"
|
|
702
|
+
# completes to "myhost" from ~/.ssh/config et al).
|
|
703
|
+
try:
|
|
704
|
+
combo.setCompletes_(True)
|
|
705
|
+
except Exception:
|
|
706
|
+
pass
|
|
707
|
+
if default:
|
|
708
|
+
combo.setStringValue_(str(default))
|
|
709
|
+
# Leave blank if no explicit default so the placeholder shows and
|
|
710
|
+
# the user can immediately type/autocomplete.
|
|
711
|
+
if hint:
|
|
712
|
+
try:
|
|
713
|
+
combo.cell().setPlaceholderString_(hint)
|
|
714
|
+
except Exception:
|
|
715
|
+
pass
|
|
716
|
+
content.addSubview_(combo)
|
|
717
|
+
widgets[key] = combo
|
|
718
|
+
|
|
719
|
+
elif kind == "switch":
|
|
720
|
+
btn = NSButton.alloc().initWithFrame_(NSMakeRect(input_x, y, INPUT_W, ROW_H))
|
|
721
|
+
btn.setButtonType_(NSSwitchButton)
|
|
722
|
+
btn.setTitle_("")
|
|
723
|
+
btn.setState_(NSOnState if default else NSOffState)
|
|
724
|
+
if on_change is not None:
|
|
725
|
+
cls = _get_switch_handler_cls()
|
|
726
|
+
handler = cls.alloc().initWithCallback_(on_change)
|
|
727
|
+
handlers.append(handler)
|
|
728
|
+
btn.setTarget_(handler)
|
|
729
|
+
btn.setAction_("switchChanged:")
|
|
730
|
+
content.addSubview_(btn)
|
|
731
|
+
widgets[key] = btn
|
|
732
|
+
|
|
733
|
+
elif kind == "segmented":
|
|
734
|
+
seg = NSSegmentedControl.alloc().initWithFrame_(NSMakeRect(input_x, y, INPUT_W, ROW_H))
|
|
735
|
+
seg.setSegmentCount_(len(options))
|
|
736
|
+
seg.setTrackingMode_(NSSegmentSwitchTrackingSelectOne)
|
|
737
|
+
seg.setControlSize_(NSRegularControlSize)
|
|
738
|
+
for idx, opt in enumerate(options):
|
|
739
|
+
if isinstance(opt, tuple):
|
|
740
|
+
label_text, image_path = opt
|
|
741
|
+
else:
|
|
742
|
+
label_text, image_path = str(opt), None
|
|
743
|
+
if image_path and os.path.exists(image_path):
|
|
744
|
+
try:
|
|
745
|
+
img = NSImage.alloc().initWithContentsOfFile_(image_path)
|
|
746
|
+
if img is not None:
|
|
747
|
+
img.setSize_((24, 24))
|
|
748
|
+
seg.setImage_forSegment_(img, idx)
|
|
749
|
+
try:
|
|
750
|
+
seg.cell().setImageScaling_forSegment_(NSImageScaleProportionallyDown, idx)
|
|
751
|
+
except Exception:
|
|
752
|
+
pass
|
|
753
|
+
except Exception:
|
|
754
|
+
pass
|
|
755
|
+
try:
|
|
756
|
+
seg.setLabel_forSegment_(label_text, idx)
|
|
757
|
+
except Exception:
|
|
758
|
+
pass
|
|
759
|
+
try:
|
|
760
|
+
seg.setSelectedSegment_(int(default) if default is not None else 0)
|
|
761
|
+
except Exception:
|
|
762
|
+
pass
|
|
763
|
+
if on_change is not None:
|
|
764
|
+
cls = _get_segmented_handler_cls()
|
|
765
|
+
handler = cls.alloc().initWithCallback_(on_change)
|
|
766
|
+
handlers.append(handler)
|
|
767
|
+
seg.setTarget_(handler)
|
|
768
|
+
seg.setAction_("segmentChanged:")
|
|
769
|
+
content.addSubview_(seg)
|
|
770
|
+
widgets[key] = seg
|
|
771
|
+
|
|
772
|
+
elif kind == "info":
|
|
773
|
+
# Multi-line static note spanning both columns. Used for the
|
|
774
|
+
# "Host can be: domain / IP / CIDR" hint in susops-mac's
|
|
775
|
+
# AddHostPanel. The label column is left empty for this kind.
|
|
776
|
+
text_value = str(default) if default is not None else ""
|
|
777
|
+
line_count = text_value.count("\n") + 1
|
|
778
|
+
info_h = max(ROW_H, line_count * 16 + 4)
|
|
779
|
+
# Remove the empty right-aligned label that the outer loop already
|
|
780
|
+
# placed for this row (we want the info text to span full width).
|
|
781
|
+
try:
|
|
782
|
+
content.subviews()[-1].removeFromSuperview()
|
|
783
|
+
except Exception:
|
|
784
|
+
pass
|
|
785
|
+
info_y = y + (ROW_H - info_h)
|
|
786
|
+
info = NSTextField.alloc().initWithFrame_(
|
|
787
|
+
NSMakeRect(label_x, info_y, content_w - 2 * PAD_X, info_h)
|
|
788
|
+
)
|
|
789
|
+
info.setStringValue_(text_value)
|
|
790
|
+
info.setBezeled_(False)
|
|
791
|
+
info.setDrawsBackground_(False)
|
|
792
|
+
info.setEditable_(False)
|
|
793
|
+
info.setSelectable_(False)
|
|
794
|
+
info.setAlignment_(0) # left
|
|
795
|
+
try:
|
|
796
|
+
cell = info.cell()
|
|
797
|
+
cell.setWraps_(True)
|
|
798
|
+
cell.setLineBreakMode_(0)
|
|
799
|
+
except Exception:
|
|
800
|
+
pass
|
|
801
|
+
content.addSubview_(info)
|
|
802
|
+
widgets[key] = info
|
|
803
|
+
# Bump the row stride if this info row is taller than ROW_H.
|
|
804
|
+
extra = max(0, info_h - ROW_H)
|
|
805
|
+
y -= extra
|
|
806
|
+
|
|
807
|
+
else:
|
|
808
|
+
field = NSTextField.alloc().initWithFrame_(NSMakeRect(input_x, y, INPUT_W, ROW_H))
|
|
809
|
+
content.addSubview_(field)
|
|
810
|
+
widgets[key] = field
|
|
811
|
+
|
|
812
|
+
y -= ROW_H + ROW_GAP
|
|
813
|
+
|
|
814
|
+
# OK + Cancel buttons in the bottom-right corner.
|
|
815
|
+
button_handler = _get_button_handler_cls().alloc().init()
|
|
816
|
+
handlers.append(button_handler)
|
|
817
|
+
|
|
818
|
+
# susops-mac places Cancel at the left edge of the input column and OK at
|
|
819
|
+
# the right edge — visually anchoring both buttons within the same vertical
|
|
820
|
+
# band as the input fields.
|
|
821
|
+
cancel_btn = NSButton.alloc().initWithFrame_(NSMakeRect(
|
|
822
|
+
input_x, BUTTON_BOTTOM, BUTTON_W, BUTTON_H,
|
|
823
|
+
))
|
|
824
|
+
cancel_btn.setTitle_(cancel_title)
|
|
825
|
+
cancel_btn.setBezelStyle_(1)
|
|
826
|
+
cancel_btn.setKeyEquivalent_("\x1b") # Esc
|
|
827
|
+
cancel_btn.setTarget_(button_handler)
|
|
828
|
+
cancel_btn.setAction_("cancelClicked:")
|
|
829
|
+
content.addSubview_(cancel_btn)
|
|
830
|
+
|
|
831
|
+
ok_btn = NSButton.alloc().initWithFrame_(NSMakeRect(
|
|
832
|
+
input_x + INPUT_W - BUTTON_W, BUTTON_BOTTOM, BUTTON_W, BUTTON_H,
|
|
833
|
+
))
|
|
834
|
+
ok_btn.setTitle_(ok_title)
|
|
835
|
+
ok_btn.setBezelStyle_(1)
|
|
836
|
+
ok_btn.setKeyEquivalent_("\r") # Enter — default button
|
|
837
|
+
ok_btn.setTarget_(button_handler)
|
|
838
|
+
ok_btn.setAction_("okClicked:")
|
|
839
|
+
content.addSubview_(ok_btn)
|
|
840
|
+
|
|
841
|
+
# Initial first responder + Tab navigation across all interactive widgets.
|
|
842
|
+
# NSPanel needs setInitialFirstResponder_ so the first field is focused
|
|
843
|
+
# when the panel becomes key — otherwise text fields appear "inactive" and
|
|
844
|
+
# the user has to click into them before typing.
|
|
845
|
+
keyable = [
|
|
846
|
+
widgets.get(f["key"])
|
|
847
|
+
for f in fields
|
|
848
|
+
if widgets.get(f["key"]) is not None
|
|
849
|
+
and f.get("kind", "text") in ("text", "secure", "combo", "popup", "switch", "segmented")
|
|
850
|
+
]
|
|
851
|
+
for i in range(len(keyable) - 1):
|
|
852
|
+
try:
|
|
853
|
+
keyable[i].setNextKeyView_(keyable[i + 1])
|
|
854
|
+
except Exception:
|
|
855
|
+
pass
|
|
856
|
+
if keyable:
|
|
857
|
+
try:
|
|
858
|
+
panel.setInitialFirstResponder_(keyable[0])
|
|
859
|
+
keyable[-1].setNextKeyView_(keyable[0]) # cycle Tab
|
|
860
|
+
except Exception:
|
|
861
|
+
pass
|
|
862
|
+
|
|
863
|
+
# Title-bar X / Cmd-W → treat as cancel. Without this delegate, closing
|
|
864
|
+
# the panel via X just hides the window without stopping the modal
|
|
865
|
+
# session, so the app stays in modal mode forever (menu greyed out,
|
|
866
|
+
# no further dialogs can open).
|
|
867
|
+
close_delegate = _get_window_close_delegate_cls().alloc().init()
|
|
868
|
+
handlers.append(close_delegate)
|
|
869
|
+
panel.setDelegate_(close_delegate)
|
|
870
|
+
|
|
871
|
+
# Show + run modal inside _RegularPolicyScope — no-op when running from
|
|
872
|
+
# `.venv/bin/` (already .regular), switches accessory→regular for the
|
|
873
|
+
# bundled .app so the panel reliably receives key-window focus.
|
|
874
|
+
with _RegularPolicyScope():
|
|
875
|
+
panel.center()
|
|
876
|
+
panel.makeKeyAndOrderFront_(None)
|
|
877
|
+
if keyable:
|
|
878
|
+
try:
|
|
879
|
+
panel.makeFirstResponder_(keyable[0])
|
|
880
|
+
except Exception:
|
|
881
|
+
pass
|
|
882
|
+
try:
|
|
883
|
+
response = NSApplication.sharedApplication().runModalForWindow_(panel)
|
|
884
|
+
finally:
|
|
885
|
+
try:
|
|
886
|
+
panel.setDelegate_(None)
|
|
887
|
+
except Exception:
|
|
888
|
+
pass
|
|
889
|
+
try:
|
|
890
|
+
panel.orderOut_(None)
|
|
891
|
+
except Exception:
|
|
892
|
+
pass
|
|
893
|
+
try:
|
|
894
|
+
panel.close()
|
|
895
|
+
except Exception:
|
|
896
|
+
pass
|
|
897
|
+
|
|
898
|
+
if response != 1:
|
|
899
|
+
handlers.clear()
|
|
900
|
+
return None
|
|
901
|
+
|
|
902
|
+
result: dict = {}
|
|
903
|
+
for f in fields:
|
|
904
|
+
key = f["key"]
|
|
905
|
+
kind = f.get("kind", "text")
|
|
906
|
+
w = widgets.get(key)
|
|
907
|
+
if w is None:
|
|
908
|
+
continue
|
|
909
|
+
if kind in ("text", "secure"):
|
|
910
|
+
result[key] = str(w.stringValue()).strip()
|
|
911
|
+
elif kind == "combo":
|
|
912
|
+
result[key] = str(w.stringValue()).strip()
|
|
913
|
+
elif kind == "popup":
|
|
914
|
+
item = w.titleOfSelectedItem()
|
|
915
|
+
result[key] = str(item) if item is not None else ""
|
|
916
|
+
elif kind == "switch":
|
|
917
|
+
result[key] = bool(w.state())
|
|
918
|
+
elif kind == "segmented":
|
|
919
|
+
result[key] = int(w.selectedSegment())
|
|
920
|
+
else:
|
|
921
|
+
result[key] = ""
|
|
922
|
+
|
|
923
|
+
handlers.clear()
|
|
924
|
+
return result
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _show_pick_dialog(
|
|
928
|
+
title: str,
|
|
929
|
+
label: str,
|
|
930
|
+
items: list[str],
|
|
931
|
+
*,
|
|
932
|
+
ok_title: str = "Select",
|
|
933
|
+
cancel_title: str = "Cancel",
|
|
934
|
+
) -> str | None:
|
|
935
|
+
"""Show an NSAlert with a single NSPopUpButton; returns selected item or None."""
|
|
936
|
+
if not items:
|
|
937
|
+
_show_message("Nothing to Select", "The list is empty.")
|
|
938
|
+
return None
|
|
939
|
+
result = _show_form_dialog(
|
|
940
|
+
title,
|
|
941
|
+
[{"key": "value", "label": label, "kind": "popup", "options": items, "default": items[0]}],
|
|
942
|
+
ok_title=ok_title,
|
|
943
|
+
cancel_title=cancel_title,
|
|
944
|
+
)
|
|
945
|
+
if result is None:
|
|
946
|
+
return None
|
|
947
|
+
return result.get("value") or None
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _show_message_panel(
|
|
951
|
+
title: str,
|
|
952
|
+
message: str,
|
|
953
|
+
buttons: list[tuple[str, int]],
|
|
954
|
+
*,
|
|
955
|
+
default_index: int = 0,
|
|
956
|
+
cancel_index: int | None = None,
|
|
957
|
+
) -> int:
|
|
958
|
+
"""Show a modal message NSPanel with a multiline body and 1–3 buttons.
|
|
959
|
+
|
|
960
|
+
NSAlert.runModal from rumps menu callbacks reliably hangs on macOS Tahoe
|
|
961
|
+
(the alert window comes up in an unfocused state and is invisible to the
|
|
962
|
+
user, leaving the main thread blocked in a modal that can't be dismissed —
|
|
963
|
+
Ctrl+C in the launching terminal won't even kill the Python process).
|
|
964
|
+
We sidestep NSAlert entirely and build the same kind of NSPanel that
|
|
965
|
+
_show_form_dialog uses (it's the proven path).
|
|
966
|
+
|
|
967
|
+
buttons: list of (label, response_code) — leftmost button is buttons[0].
|
|
968
|
+
Idiomatic order on macOS is: rightmost = default (primary),
|
|
969
|
+
leftmost = cancel. The helper places button[0] rightmost.
|
|
970
|
+
default_index: which button is the default (Enter triggers it).
|
|
971
|
+
cancel_index: which button is Cancel (Esc + window-X triggers it).
|
|
972
|
+
Defaults to the last entry.
|
|
973
|
+
|
|
974
|
+
Returns the response_code of the clicked button.
|
|
975
|
+
"""
|
|
976
|
+
from AppKit import ( # type: ignore[import]
|
|
977
|
+
NSApplication,
|
|
978
|
+
NSBackingStoreBuffered,
|
|
979
|
+
NSFloatingWindowLevel,
|
|
980
|
+
NSPanel,
|
|
981
|
+
NSWindowStyleMaskClosable,
|
|
982
|
+
NSWindowStyleMaskTitled,
|
|
983
|
+
)
|
|
984
|
+
from Cocoa import ( # type: ignore[import]
|
|
985
|
+
NSButton,
|
|
986
|
+
NSMakeRect,
|
|
987
|
+
NSTextField,
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
# Match susops-mac proportions: 30-px buttons, 16-px paddings.
|
|
991
|
+
PAD_X = 16
|
|
992
|
+
PAD_TOP = 18
|
|
993
|
+
PAD_BOTTOM = 16
|
|
994
|
+
GAP_BETWEEN = 18
|
|
995
|
+
BUTTON_H = 30
|
|
996
|
+
BUTTON_MIN_W = 80
|
|
997
|
+
BUTTON_GAP = 10
|
|
998
|
+
MSG_W = 360
|
|
999
|
+
# Hard cap on the body height — beyond this we wrap the content in an
|
|
1000
|
+
# NSScrollView so the panel stays usable even for log dumps with hundreds
|
|
1001
|
+
# of lines.
|
|
1002
|
+
MSG_H_MAX = 460
|
|
1003
|
+
# Widen the panel for content with long lines (e.g. log entries) so the
|
|
1004
|
+
# text doesn't have to wrap as aggressively.
|
|
1005
|
+
MSG_W_WIDE = 640
|
|
1006
|
+
|
|
1007
|
+
# Rough message height estimate (good enough — NSTextField will wrap).
|
|
1008
|
+
line_count = message.count("\n") + 1
|
|
1009
|
+
longest = max((len(line) for line in message.split("\n")), default=0)
|
|
1010
|
+
use_wide = longest > 56
|
|
1011
|
+
msg_w_target = MSG_W_WIDE if use_wide else MSG_W
|
|
1012
|
+
wraps_per_long_line = max(0, longest // (96 if use_wide else 56))
|
|
1013
|
+
msg_h_natural = max(36, (line_count + wraps_per_long_line) * 18 + 10)
|
|
1014
|
+
scrollable = msg_h_natural > MSG_H_MAX
|
|
1015
|
+
msg_h = min(msg_h_natural, MSG_H_MAX)
|
|
1016
|
+
|
|
1017
|
+
# Compute button widths based on label lengths so long labels fit.
|
|
1018
|
+
button_widths = [max(BUTTON_MIN_W, 16 + 7 * len(label)) for label, _ in buttons]
|
|
1019
|
+
buttons_total_w = sum(button_widths) + BUTTON_GAP * max(0, len(buttons) - 1)
|
|
1020
|
+
|
|
1021
|
+
content_w = max(msg_w_target + PAD_X * 2, buttons_total_w + PAD_X * 2)
|
|
1022
|
+
content_h = PAD_TOP + msg_h + GAP_BETWEEN + BUTTON_H + PAD_BOTTOM
|
|
1023
|
+
|
|
1024
|
+
if cancel_index is None:
|
|
1025
|
+
cancel_index = len(buttons) - 1
|
|
1026
|
+
|
|
1027
|
+
style = NSWindowStyleMaskTitled | NSWindowStyleMaskClosable
|
|
1028
|
+
panel = NSPanel.alloc().initWithContentRect_styleMask_backing_defer_(
|
|
1029
|
+
NSMakeRect(0, 0, content_w, content_h),
|
|
1030
|
+
style,
|
|
1031
|
+
NSBackingStoreBuffered,
|
|
1032
|
+
False,
|
|
1033
|
+
)
|
|
1034
|
+
panel.setTitle_(title or "")
|
|
1035
|
+
panel.setReleasedWhenClosed_(False)
|
|
1036
|
+
panel.setHidesOnDeactivate_(False)
|
|
1037
|
+
panel.setLevel_(NSFloatingWindowLevel)
|
|
1038
|
+
# NSPanel default is becomesKeyOnlyIfNeeded=YES which can prevent the
|
|
1039
|
+
# panel from becoming key for our modal — explicitly disable it so
|
|
1040
|
+
# text fields receive keystrokes.
|
|
1041
|
+
try:
|
|
1042
|
+
panel.setBecomesKeyOnlyIfNeeded_(False)
|
|
1043
|
+
except Exception:
|
|
1044
|
+
pass
|
|
1045
|
+
|
|
1046
|
+
content = panel.contentView()
|
|
1047
|
+
handlers: list = []
|
|
1048
|
+
|
|
1049
|
+
# Message body. Multi-line messages need monospaced text so column-
|
|
1050
|
+
# aligned output (status tables, log excerpts) renders correctly; for
|
|
1051
|
+
# single-line alerts the difference is barely noticeable. When the
|
|
1052
|
+
# natural content height exceeds MSG_H_MAX we wrap it in an NSScrollView
|
|
1053
|
+
# so log dumps stay browsable instead of producing a panel taller than
|
|
1054
|
+
# the screen.
|
|
1055
|
+
msg_y = content_h - PAD_TOP - msg_h
|
|
1056
|
+
use_mono = "\n" in (message or "")
|
|
1057
|
+
if scrollable:
|
|
1058
|
+
from AppKit import ( # type: ignore[import]
|
|
1059
|
+
NSScrollView,
|
|
1060
|
+
NSTextView,
|
|
1061
|
+
NSBezelBorder,
|
|
1062
|
+
)
|
|
1063
|
+
from Cocoa import NSMakeSize # type: ignore[import]
|
|
1064
|
+
|
|
1065
|
+
scroll = NSScrollView.alloc().initWithFrame_(
|
|
1066
|
+
NSMakeRect(PAD_X, msg_y, content_w - 2 * PAD_X, msg_h)
|
|
1067
|
+
)
|
|
1068
|
+
scroll.setHasVerticalScroller_(True)
|
|
1069
|
+
scroll.setHasHorizontalScroller_(False)
|
|
1070
|
+
scroll.setAutohidesScrollers_(True)
|
|
1071
|
+
scroll.setBorderType_(NSBezelBorder)
|
|
1072
|
+
tv = NSTextView.alloc().initWithFrame_(
|
|
1073
|
+
NSMakeRect(0, 0, content_w - 2 * PAD_X, msg_h)
|
|
1074
|
+
)
|
|
1075
|
+
tv.setEditable_(False)
|
|
1076
|
+
tv.setSelectable_(True)
|
|
1077
|
+
tv.setRichText_(False)
|
|
1078
|
+
tv.setVerticallyResizable_(True)
|
|
1079
|
+
tv.setHorizontallyResizable_(False)
|
|
1080
|
+
tv.setAutoresizingMask_(2) # NSViewWidthSizable
|
|
1081
|
+
if use_mono:
|
|
1082
|
+
try:
|
|
1083
|
+
from AppKit import NSFont, NSFontWeightRegular # type: ignore[import]
|
|
1084
|
+
tv.setFont_(NSFont.monospacedSystemFontOfSize_weight_(12, NSFontWeightRegular))
|
|
1085
|
+
except Exception:
|
|
1086
|
+
pass
|
|
1087
|
+
tv.setString_(message or "")
|
|
1088
|
+
try:
|
|
1089
|
+
tv.textContainer().setContainerSize_(
|
|
1090
|
+
NSMakeSize(content_w - 2 * PAD_X, 1.0e7)
|
|
1091
|
+
)
|
|
1092
|
+
tv.textContainer().setWidthTracksTextView_(True)
|
|
1093
|
+
except Exception:
|
|
1094
|
+
pass
|
|
1095
|
+
scroll.setDocumentView_(tv)
|
|
1096
|
+
content.addSubview_(scroll)
|
|
1097
|
+
else:
|
|
1098
|
+
msg_lbl = NSTextField.alloc().initWithFrame_(NSMakeRect(PAD_X, msg_y, content_w - 2 * PAD_X, msg_h))
|
|
1099
|
+
msg_lbl.setStringValue_(message or "")
|
|
1100
|
+
msg_lbl.setBezeled_(False)
|
|
1101
|
+
msg_lbl.setDrawsBackground_(False)
|
|
1102
|
+
msg_lbl.setEditable_(False)
|
|
1103
|
+
msg_lbl.setSelectable_(True)
|
|
1104
|
+
if use_mono:
|
|
1105
|
+
try:
|
|
1106
|
+
from AppKit import NSFont, NSFontWeightRegular # type: ignore[import]
|
|
1107
|
+
msg_lbl.setFont_(NSFont.monospacedSystemFontOfSize_weight_(12, NSFontWeightRegular))
|
|
1108
|
+
except Exception:
|
|
1109
|
+
pass
|
|
1110
|
+
try:
|
|
1111
|
+
cell = msg_lbl.cell()
|
|
1112
|
+
cell.setWraps_(True)
|
|
1113
|
+
cell.setLineBreakMode_(0) # NSLineBreakByWordWrapping
|
|
1114
|
+
except Exception:
|
|
1115
|
+
pass
|
|
1116
|
+
content.addSubview_(msg_lbl)
|
|
1117
|
+
|
|
1118
|
+
# Buttons (button[0] = rightmost)
|
|
1119
|
+
button_handler = _get_tagged_button_handler_cls().alloc().init()
|
|
1120
|
+
handlers.append(button_handler)
|
|
1121
|
+
|
|
1122
|
+
x_right = content_w - PAD_X
|
|
1123
|
+
for idx, (label, code) in enumerate(buttons):
|
|
1124
|
+
w = button_widths[idx]
|
|
1125
|
+
x = x_right - w
|
|
1126
|
+
btn = NSButton.alloc().initWithFrame_(NSMakeRect(x, PAD_BOTTOM, w, BUTTON_H))
|
|
1127
|
+
btn.setTitle_(label)
|
|
1128
|
+
btn.setBezelStyle_(1)
|
|
1129
|
+
btn.setTag_(int(code))
|
|
1130
|
+
btn.setTarget_(button_handler)
|
|
1131
|
+
btn.setAction_("buttonClicked:")
|
|
1132
|
+
if idx == default_index:
|
|
1133
|
+
btn.setKeyEquivalent_("\r") # Enter
|
|
1134
|
+
if idx == cancel_index and idx != default_index:
|
|
1135
|
+
btn.setKeyEquivalent_("\x1b") # Esc
|
|
1136
|
+
content.addSubview_(btn)
|
|
1137
|
+
x_right = x - BUTTON_GAP
|
|
1138
|
+
|
|
1139
|
+
# Close-button (X) → treat as cancel
|
|
1140
|
+
cancel_code = buttons[cancel_index][1] if 0 <= cancel_index < len(buttons) else 0
|
|
1141
|
+
delegate = _get_window_close_delegate_cls().alloc().init()
|
|
1142
|
+
handlers.append(delegate)
|
|
1143
|
+
panel.setDelegate_(delegate)
|
|
1144
|
+
|
|
1145
|
+
# Show + run modal inside the activation-policy scope so the panel
|
|
1146
|
+
# receives key-window focus regardless of bundled vs dev-run.
|
|
1147
|
+
with _RegularPolicyScope():
|
|
1148
|
+
panel.center()
|
|
1149
|
+
panel.makeKeyAndOrderFront_(None)
|
|
1150
|
+
try:
|
|
1151
|
+
response = NSApplication.sharedApplication().runModalForWindow_(panel)
|
|
1152
|
+
finally:
|
|
1153
|
+
try:
|
|
1154
|
+
panel.setDelegate_(None)
|
|
1155
|
+
except Exception:
|
|
1156
|
+
pass
|
|
1157
|
+
try:
|
|
1158
|
+
panel.orderOut_(None)
|
|
1159
|
+
except Exception:
|
|
1160
|
+
pass
|
|
1161
|
+
try:
|
|
1162
|
+
panel.close()
|
|
1163
|
+
except Exception:
|
|
1164
|
+
pass
|
|
1165
|
+
|
|
1166
|
+
handlers.clear()
|
|
1167
|
+
# If the delegate fired (X / Cmd-W), it stopped the modal with 0 — translate
|
|
1168
|
+
# to the actual cancel button's response code so callers get a consistent value.
|
|
1169
|
+
if response == 0 and cancel_code != 0:
|
|
1170
|
+
return cancel_code
|
|
1171
|
+
return int(response)
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def _show_message(title: str, message: str, *, ok: str = "OK") -> None:
|
|
1175
|
+
"""Show a simple modal info panel with a single OK/Close button."""
|
|
1176
|
+
_show_message_panel(title, message, [(ok, 1)])
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
# Strong refs to keep live windows + their timers/delegates alive after the
|
|
1180
|
+
# Python caller returns. Indexed by id(panel) to allow multiple windows.
|
|
1181
|
+
_LIVE_WINDOWS: dict[int, dict] = {}
|
|
1182
|
+
|
|
1183
|
+
# NSObject subclasses are registered globally by name in the Objective-C
|
|
1184
|
+
# runtime. Defining them inside a function means the SECOND call re-registers
|
|
1185
|
+
# the same class name and PyObjC returns a stale class whose selectors no
|
|
1186
|
+
# longer dispatch to fresh Python state — that's why the logs panel only
|
|
1187
|
+
# worked on its first invocation. Build these lazily once and reuse.
|
|
1188
|
+
_live_tick_target_cls = None
|
|
1189
|
+
_live_close_handler_cls = None
|
|
1190
|
+
_live_window_delegate_cls = None
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def _get_live_window_classes():
|
|
1194
|
+
"""Lazily build the timer-target / close-button / window-delegate NSObject
|
|
1195
|
+
subclasses used by _open_live_text_window. Cached at module scope so
|
|
1196
|
+
repeated opens reuse the same registered ObjC classes."""
|
|
1197
|
+
global _live_tick_target_cls, _live_close_handler_cls, _live_window_delegate_cls
|
|
1198
|
+
if _live_tick_target_cls is not None:
|
|
1199
|
+
return (
|
|
1200
|
+
_live_tick_target_cls,
|
|
1201
|
+
_live_close_handler_cls,
|
|
1202
|
+
_live_window_delegate_cls,
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
import objc # type: ignore[import]
|
|
1206
|
+
from Foundation import NSObject # type: ignore[import]
|
|
1207
|
+
|
|
1208
|
+
class _LiveTickTarget(NSObject):
|
|
1209
|
+
def initWithCallback_(self, cb): # noqa: N802
|
|
1210
|
+
self = objc.super(_LiveTickTarget, self).init()
|
|
1211
|
+
if self is None:
|
|
1212
|
+
return None
|
|
1213
|
+
self._cb = cb
|
|
1214
|
+
return self
|
|
1215
|
+
|
|
1216
|
+
def tick_(self, _timer): # noqa: N802
|
|
1217
|
+
try:
|
|
1218
|
+
self._cb()
|
|
1219
|
+
except Exception:
|
|
1220
|
+
pass
|
|
1221
|
+
|
|
1222
|
+
class _LiveCloseHandler(NSObject):
|
|
1223
|
+
def initWithCallback_(self, cb): # noqa: N802
|
|
1224
|
+
self = objc.super(_LiveCloseHandler, self).init()
|
|
1225
|
+
if self is None:
|
|
1226
|
+
return None
|
|
1227
|
+
self._cb = cb
|
|
1228
|
+
return self
|
|
1229
|
+
|
|
1230
|
+
def click_(self, _sender): # noqa: N802
|
|
1231
|
+
try:
|
|
1232
|
+
self._cb()
|
|
1233
|
+
except Exception:
|
|
1234
|
+
pass
|
|
1235
|
+
|
|
1236
|
+
class _LiveWindowDelegate(NSObject):
|
|
1237
|
+
def initWithCallback_(self, cb): # noqa: N802
|
|
1238
|
+
self = objc.super(_LiveWindowDelegate, self).init()
|
|
1239
|
+
if self is None:
|
|
1240
|
+
return None
|
|
1241
|
+
self._cb = cb
|
|
1242
|
+
return self
|
|
1243
|
+
|
|
1244
|
+
def windowShouldClose_(self, _sender): # noqa: N802
|
|
1245
|
+
try:
|
|
1246
|
+
self._cb()
|
|
1247
|
+
except Exception:
|
|
1248
|
+
pass
|
|
1249
|
+
return True
|
|
1250
|
+
|
|
1251
|
+
_live_tick_target_cls = _LiveTickTarget
|
|
1252
|
+
_live_close_handler_cls = _LiveCloseHandler
|
|
1253
|
+
_live_window_delegate_cls = _LiveWindowDelegate
|
|
1254
|
+
return _LiveTickTarget, _LiveCloseHandler, _LiveWindowDelegate
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def _open_live_text_window(title: str, get_text: Callable[[], str],
|
|
1258
|
+
interval_ms: int = 1000) -> None:
|
|
1259
|
+
"""Open a non-modal panel that periodically refreshes its text body and
|
|
1260
|
+
auto-scrolls to the bottom. The tray menu stays responsive while it's open.
|
|
1261
|
+
|
|
1262
|
+
`get_text` is called on the main thread by an NSTimer every interval_ms;
|
|
1263
|
+
if the result changed since the last tick the NSTextView is updated and
|
|
1264
|
+
scrolled to the bottom.
|
|
1265
|
+
"""
|
|
1266
|
+
from AppKit import ( # type: ignore[import]
|
|
1267
|
+
NSApplication,
|
|
1268
|
+
NSBackingStoreBuffered,
|
|
1269
|
+
NSBezelBorder,
|
|
1270
|
+
NSFont,
|
|
1271
|
+
NSFontWeightRegular,
|
|
1272
|
+
NSPanel,
|
|
1273
|
+
NSScrollView,
|
|
1274
|
+
NSStatusWindowLevel,
|
|
1275
|
+
NSTextView,
|
|
1276
|
+
NSTimer,
|
|
1277
|
+
NSWindowCollectionBehaviorCanJoinAllSpaces,
|
|
1278
|
+
NSWindowCollectionBehaviorFullScreenAuxiliary,
|
|
1279
|
+
NSWindowStyleMaskClosable,
|
|
1280
|
+
NSWindowStyleMaskNonactivatingPanel,
|
|
1281
|
+
NSWindowStyleMaskResizable,
|
|
1282
|
+
NSWindowStyleMaskTitled,
|
|
1283
|
+
)
|
|
1284
|
+
from Cocoa import ( # type: ignore[import]
|
|
1285
|
+
NSMakeRect,
|
|
1286
|
+
NSMakeSize,
|
|
1287
|
+
)
|
|
1288
|
+
from PyObjCTools import AppHelper # noqa: F401 (ensures runloop available)
|
|
1289
|
+
|
|
1290
|
+
content_w = 912
|
|
1291
|
+
content_h = 513
|
|
1292
|
+
|
|
1293
|
+
style = (
|
|
1294
|
+
NSWindowStyleMaskTitled
|
|
1295
|
+
| NSWindowStyleMaskClosable
|
|
1296
|
+
| NSWindowStyleMaskResizable
|
|
1297
|
+
| NSWindowStyleMaskNonactivatingPanel
|
|
1298
|
+
)
|
|
1299
|
+
panel = NSPanel.alloc().initWithContentRect_styleMask_backing_defer_(
|
|
1300
|
+
NSMakeRect(0, 0, content_w, content_h),
|
|
1301
|
+
style,
|
|
1302
|
+
NSBackingStoreBuffered,
|
|
1303
|
+
False,
|
|
1304
|
+
)
|
|
1305
|
+
panel.setTitle_(title or "Logs")
|
|
1306
|
+
panel.setReleasedWhenClosed_(False)
|
|
1307
|
+
panel.setHidesOnDeactivate_(False)
|
|
1308
|
+
# Status-window level keeps the panel above other app windows; the panel
|
|
1309
|
+
# also joins every Space + survives full-screen so it really does stay on
|
|
1310
|
+
# top regardless of where the user is.
|
|
1311
|
+
panel.setLevel_(NSStatusWindowLevel)
|
|
1312
|
+
try:
|
|
1313
|
+
panel.setCollectionBehavior_(
|
|
1314
|
+
NSWindowCollectionBehaviorCanJoinAllSpaces
|
|
1315
|
+
| NSWindowCollectionBehaviorFullScreenAuxiliary
|
|
1316
|
+
)
|
|
1317
|
+
except Exception:
|
|
1318
|
+
pass
|
|
1319
|
+
try:
|
|
1320
|
+
panel.setBecomesKeyOnlyIfNeeded_(False)
|
|
1321
|
+
except Exception:
|
|
1322
|
+
pass
|
|
1323
|
+
try:
|
|
1324
|
+
panel.setFloatingPanel_(True)
|
|
1325
|
+
except Exception:
|
|
1326
|
+
pass
|
|
1327
|
+
|
|
1328
|
+
content = panel.contentView()
|
|
1329
|
+
|
|
1330
|
+
# Scroll view fills the entire panel edge-to-edge — no in-panel close
|
|
1331
|
+
# button; the titlebar X handles dismissal via the window delegate below.
|
|
1332
|
+
scroll = NSScrollView.alloc().initWithFrame_(
|
|
1333
|
+
NSMakeRect(0, 0, content_w, content_h)
|
|
1334
|
+
)
|
|
1335
|
+
scroll.setHasVerticalScroller_(True)
|
|
1336
|
+
scroll.setHasHorizontalScroller_(False)
|
|
1337
|
+
scroll.setAutohidesScrollers_(True)
|
|
1338
|
+
scroll.setBorderType_(NSBezelBorder)
|
|
1339
|
+
scroll.setAutoresizingMask_(2 | 16) # WidthSizable | HeightSizable
|
|
1340
|
+
|
|
1341
|
+
tv = NSTextView.alloc().initWithFrame_(
|
|
1342
|
+
NSMakeRect(0, 0, content_w, content_h)
|
|
1343
|
+
)
|
|
1344
|
+
tv.setEditable_(False)
|
|
1345
|
+
tv.setSelectable_(True)
|
|
1346
|
+
tv.setRichText_(False)
|
|
1347
|
+
tv.setVerticallyResizable_(True)
|
|
1348
|
+
tv.setHorizontallyResizable_(False)
|
|
1349
|
+
tv.setAutoresizingMask_(2) # NSViewWidthSizable
|
|
1350
|
+
try:
|
|
1351
|
+
tv.setFont_(NSFont.monospacedSystemFontOfSize_weight_(12, NSFontWeightRegular))
|
|
1352
|
+
except Exception:
|
|
1353
|
+
pass
|
|
1354
|
+
try:
|
|
1355
|
+
tv.textContainer().setContainerSize_(NSMakeSize(content_w, 1.0e7))
|
|
1356
|
+
tv.textContainer().setWidthTracksTextView_(True)
|
|
1357
|
+
except Exception:
|
|
1358
|
+
pass
|
|
1359
|
+
scroll.setDocumentView_(tv)
|
|
1360
|
+
content.addSubview_(scroll)
|
|
1361
|
+
|
|
1362
|
+
state: dict = {"last": None, "closed": False}
|
|
1363
|
+
|
|
1364
|
+
def _build_attributed(text: str):
|
|
1365
|
+
"""Build an NSAttributedString with colored segments per the shared
|
|
1366
|
+
log_style rules. Each line is parsed independently."""
|
|
1367
|
+
from AppKit import ( # type: ignore[import]
|
|
1368
|
+
NSAttributedString,
|
|
1369
|
+
NSColor,
|
|
1370
|
+
NSFont,
|
|
1371
|
+
NSFontWeightBold,
|
|
1372
|
+
NSFontWeightRegular,
|
|
1373
|
+
NSMutableAttributedString,
|
|
1374
|
+
NSForegroundColorAttributeName,
|
|
1375
|
+
NSFontAttributeName,
|
|
1376
|
+
)
|
|
1377
|
+
from susops.core.log_style import style_log_line
|
|
1378
|
+
|
|
1379
|
+
base_font = NSFont.monospacedSystemFontOfSize_weight_(12, NSFontWeightRegular)
|
|
1380
|
+
bold_font = NSFont.monospacedSystemFontOfSize_weight_(12, NSFontWeightBold)
|
|
1381
|
+
|
|
1382
|
+
# Pre-resolve colors for each label.
|
|
1383
|
+
palette = {
|
|
1384
|
+
"tag": (NSColor.systemTealColor(), True),
|
|
1385
|
+
"ok": (NSColor.systemGreenColor(), False),
|
|
1386
|
+
"warn": (NSColor.systemYellowColor(), False),
|
|
1387
|
+
"err": (NSColor.systemRedColor(), True),
|
|
1388
|
+
"dim": (NSColor.tertiaryLabelColor(), False),
|
|
1389
|
+
"info": (NSColor.systemBlueColor(), False),
|
|
1390
|
+
}
|
|
1391
|
+
default_color = NSColor.labelColor()
|
|
1392
|
+
|
|
1393
|
+
result = NSMutableAttributedString.alloc().init()
|
|
1394
|
+
lines = text.split("\n")
|
|
1395
|
+
for i, line in enumerate(lines):
|
|
1396
|
+
for chunk, label in style_log_line(line):
|
|
1397
|
+
if not chunk:
|
|
1398
|
+
continue
|
|
1399
|
+
color, bold = palette.get(label, (default_color, False))
|
|
1400
|
+
attrs = {
|
|
1401
|
+
NSForegroundColorAttributeName: color,
|
|
1402
|
+
NSFontAttributeName: bold_font if bold else base_font,
|
|
1403
|
+
}
|
|
1404
|
+
seg = NSAttributedString.alloc().initWithString_attributes_(chunk, attrs)
|
|
1405
|
+
result.appendAttributedString_(seg)
|
|
1406
|
+
if i < len(lines) - 1:
|
|
1407
|
+
nl = NSAttributedString.alloc().initWithString_attributes_(
|
|
1408
|
+
"\n",
|
|
1409
|
+
{NSFontAttributeName: base_font, NSForegroundColorAttributeName: default_color},
|
|
1410
|
+
)
|
|
1411
|
+
result.appendAttributedString_(nl)
|
|
1412
|
+
return result
|
|
1413
|
+
|
|
1414
|
+
def _refresh_now() -> None:
|
|
1415
|
+
if state["closed"]:
|
|
1416
|
+
return
|
|
1417
|
+
try:
|
|
1418
|
+
text = get_text()
|
|
1419
|
+
except Exception as exc: # never let a timer crash the runloop
|
|
1420
|
+
text = f"(log fetch failed: {exc})"
|
|
1421
|
+
if text == state["last"]:
|
|
1422
|
+
return
|
|
1423
|
+
state["last"] = text
|
|
1424
|
+
try:
|
|
1425
|
+
attr = _build_attributed(text)
|
|
1426
|
+
tv.textStorage().setAttributedString_(attr)
|
|
1427
|
+
except Exception:
|
|
1428
|
+
tv.setString_(text)
|
|
1429
|
+
# Scroll to bottom.
|
|
1430
|
+
try:
|
|
1431
|
+
length = tv.string().length() if tv.string() else 0
|
|
1432
|
+
tv.scrollRangeToVisible_((length, 0))
|
|
1433
|
+
except Exception:
|
|
1434
|
+
pass
|
|
1435
|
+
|
|
1436
|
+
# Resolve cached NSObject classes (see _get_live_window_classes for why
|
|
1437
|
+
# they must NOT be defined inline inside this function).
|
|
1438
|
+
TickTargetCls, _CloseHandlerCls_unused, WindowDelegateCls = _get_live_window_classes()
|
|
1439
|
+
|
|
1440
|
+
# Forward-declared so the delegate closure can call it. Real implementation
|
|
1441
|
+
# is assigned below once `policy_scope` and `state` exist.
|
|
1442
|
+
teardown_box: dict = {"fn": None}
|
|
1443
|
+
|
|
1444
|
+
def _teardown_proxy():
|
|
1445
|
+
fn = teardown_box["fn"]
|
|
1446
|
+
if fn is not None:
|
|
1447
|
+
fn()
|
|
1448
|
+
|
|
1449
|
+
tick_target = TickTargetCls.alloc().initWithCallback_(_refresh_now)
|
|
1450
|
+
timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
|
|
1451
|
+
interval_ms / 1000.0, tick_target, "tick:", None, True,
|
|
1452
|
+
)
|
|
1453
|
+
|
|
1454
|
+
# Titlebar close (X) → tear down via the window delegate. No in-panel
|
|
1455
|
+
# close button.
|
|
1456
|
+
delegate = WindowDelegateCls.alloc().initWithCallback_(_teardown_proxy)
|
|
1457
|
+
panel.setDelegate_(delegate)
|
|
1458
|
+
|
|
1459
|
+
# Hold the activation-policy scope open across the window's lifetime —
|
|
1460
|
+
# using `with _RegularPolicyScope()` here would exit immediately after
|
|
1461
|
+
# ordering the window front, and 0.3 s later the app flips back to
|
|
1462
|
+
# accessory mode which hides regular windows. Enter the scope manually
|
|
1463
|
+
# on open and exit in _teardown so the panel stays visible until closed.
|
|
1464
|
+
policy_scope = _RegularPolicyScope()
|
|
1465
|
+
policy_scope.__enter__()
|
|
1466
|
+
|
|
1467
|
+
def _teardown():
|
|
1468
|
+
if state["closed"]:
|
|
1469
|
+
return
|
|
1470
|
+
state["closed"] = True
|
|
1471
|
+
try:
|
|
1472
|
+
timer.invalidate()
|
|
1473
|
+
except Exception:
|
|
1474
|
+
pass
|
|
1475
|
+
try:
|
|
1476
|
+
panel.orderOut_(None)
|
|
1477
|
+
except Exception:
|
|
1478
|
+
pass
|
|
1479
|
+
try:
|
|
1480
|
+
policy_scope.__exit__(None, None, None)
|
|
1481
|
+
except Exception:
|
|
1482
|
+
pass
|
|
1483
|
+
_LIVE_WINDOWS.pop(id(panel), None)
|
|
1484
|
+
|
|
1485
|
+
teardown_box["fn"] = _teardown
|
|
1486
|
+
|
|
1487
|
+
_LIVE_WINDOWS[id(panel)] = {
|
|
1488
|
+
"panel": panel,
|
|
1489
|
+
"timer": timer,
|
|
1490
|
+
"tick_target": tick_target,
|
|
1491
|
+
"delegate": delegate,
|
|
1492
|
+
"tv": tv,
|
|
1493
|
+
"policy_scope": policy_scope,
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
# Initial fill before the first timer tick.
|
|
1497
|
+
_refresh_now()
|
|
1498
|
+
|
|
1499
|
+
panel.center()
|
|
1500
|
+
panel.makeKeyAndOrderFront_(None)
|
|
1501
|
+
# orderFrontRegardless makes the panel show even if the app would
|
|
1502
|
+
# otherwise lose focus during the activation-policy transition.
|
|
1503
|
+
try:
|
|
1504
|
+
panel.orderFrontRegardless()
|
|
1505
|
+
except Exception:
|
|
1506
|
+
pass
|
|
1507
|
+
try:
|
|
1508
|
+
NSApplication.sharedApplication().activateIgnoringOtherApps_(True)
|
|
1509
|
+
except Exception:
|
|
1510
|
+
pass
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
def _show_confirm(title: str, message: str, *, ok: str = "OK", cancel: str = "Cancel") -> bool:
|
|
1514
|
+
"""Show a confirmation panel; return True if OK clicked."""
|
|
1515
|
+
r = _show_message_panel(
|
|
1516
|
+
title, message, [(ok, 1), (cancel, 0)], default_index=0, cancel_index=1,
|
|
1517
|
+
)
|
|
1518
|
+
return r == 1
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
def _show_three_way(title: str, message: str, primary: str, secondary: str, cancel: str = "Cancel") -> int:
|
|
1522
|
+
"""Three-button panel. Returns 1 if primary, 2 if secondary, 0 if cancel."""
|
|
1523
|
+
r = _show_message_panel(
|
|
1524
|
+
title, message,
|
|
1525
|
+
[(primary, 1), (secondary, 2), (cancel, 0)],
|
|
1526
|
+
default_index=0,
|
|
1527
|
+
cancel_index=2,
|
|
1528
|
+
)
|
|
1529
|
+
if r in (0, 1, 2):
|
|
1530
|
+
return r
|
|
1531
|
+
return 0
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
def _pick_file() -> str | None:
|
|
1535
|
+
"""Show NSOpenPanel and return the chosen file path or None.
|
|
1536
|
+
|
|
1537
|
+
Wrapped in _RegularPolicyScope so the app is in .regular activation
|
|
1538
|
+
policy for the duration — menu-extra apps on macOS Tahoe otherwise have
|
|
1539
|
+
NSOpenPanel come up behind/unfocused. Also forces floating window
|
|
1540
|
+
level + center + makeKeyAndOrderFront for extra safety.
|
|
1541
|
+
"""
|
|
1542
|
+
from AppKit import NSFloatingWindowLevel, NSModalResponseOK # type: ignore[import]
|
|
1543
|
+
from Cocoa import NSOpenPanel # type: ignore[import]
|
|
1544
|
+
panel = NSOpenPanel.openPanel()
|
|
1545
|
+
panel.setCanChooseFiles_(True)
|
|
1546
|
+
panel.setCanChooseDirectories_(False)
|
|
1547
|
+
panel.setAllowsMultipleSelection_(False)
|
|
1548
|
+
try:
|
|
1549
|
+
panel.setLevel_(NSFloatingWindowLevel)
|
|
1550
|
+
except Exception:
|
|
1551
|
+
pass
|
|
1552
|
+
with _RegularPolicyScope():
|
|
1553
|
+
try:
|
|
1554
|
+
panel.center()
|
|
1555
|
+
except Exception:
|
|
1556
|
+
pass
|
|
1557
|
+
try:
|
|
1558
|
+
panel.makeKeyAndOrderFront_(None)
|
|
1559
|
+
except Exception:
|
|
1560
|
+
pass
|
|
1561
|
+
result = panel.runModal()
|
|
1562
|
+
if result != NSModalResponseOK:
|
|
1563
|
+
return None
|
|
1564
|
+
urls = panel.URLs()
|
|
1565
|
+
if not urls:
|
|
1566
|
+
return None
|
|
1567
|
+
return str(urls[0].path())
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
_ABOUT_WINDOWS: dict[int, dict] = {}
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
def _show_about_panel(version: str = "", *, icon_state=None) -> None:
|
|
1574
|
+
"""Show the About panel — non-modal, always-on-top, titlebar-X-to-close.
|
|
1575
|
+
|
|
1576
|
+
Behavioural changes vs the original:
|
|
1577
|
+
* Non-modal — no ``runModalForWindow_``. The tray menu stays usable.
|
|
1578
|
+
* Always on top — ``NSStatusWindowLevel`` + canJoinAllSpaces +
|
|
1579
|
+
fullScreenAuxiliary, plus a held-open ``_RegularPolicyScope`` so the
|
|
1580
|
+
panel doesn't vanish when accessory mode reasserts.
|
|
1581
|
+
* Static icon — ``assets/icon.png`` instead of the state-dependent
|
|
1582
|
+
per-style status icon.
|
|
1583
|
+
|
|
1584
|
+
``icon_state`` is accepted and ignored to keep the old call sites working.
|
|
1585
|
+
"""
|
|
1586
|
+
if version == "":
|
|
1587
|
+
try:
|
|
1588
|
+
import susops
|
|
1589
|
+
version = getattr(susops, "__version__", "")
|
|
1590
|
+
except Exception:
|
|
1591
|
+
version = ""
|
|
1592
|
+
|
|
1593
|
+
from AppKit import ( # type: ignore[import]
|
|
1594
|
+
NSApplication,
|
|
1595
|
+
NSBackingStoreBuffered,
|
|
1596
|
+
NSButton,
|
|
1597
|
+
NSImage,
|
|
1598
|
+
NSImageView,
|
|
1599
|
+
NSPanel,
|
|
1600
|
+
NSStatusWindowLevel,
|
|
1601
|
+
NSWindowCollectionBehaviorCanJoinAllSpaces,
|
|
1602
|
+
NSWindowCollectionBehaviorFullScreenAuxiliary,
|
|
1603
|
+
NSWindowStyleMaskClosable,
|
|
1604
|
+
NSWindowStyleMaskNonactivatingPanel,
|
|
1605
|
+
NSWindowStyleMaskTitled,
|
|
1606
|
+
)
|
|
1607
|
+
from Cocoa import ( # type: ignore[import]
|
|
1608
|
+
NSAttributedString,
|
|
1609
|
+
NSColor,
|
|
1610
|
+
NSFont,
|
|
1611
|
+
NSFontAttributeName,
|
|
1612
|
+
NSForegroundColorAttributeName,
|
|
1613
|
+
NSMakeRect,
|
|
1614
|
+
NSTextField,
|
|
1615
|
+
)
|
|
1616
|
+
|
|
1617
|
+
win_w = 250
|
|
1618
|
+
win_h = 170
|
|
1619
|
+
icon_size = 64
|
|
1620
|
+
|
|
1621
|
+
style = (
|
|
1622
|
+
NSWindowStyleMaskTitled
|
|
1623
|
+
| NSWindowStyleMaskClosable
|
|
1624
|
+
| NSWindowStyleMaskNonactivatingPanel
|
|
1625
|
+
)
|
|
1626
|
+
panel = NSPanel.alloc().initWithContentRect_styleMask_backing_defer_(
|
|
1627
|
+
NSMakeRect(0, 0, win_w, win_h),
|
|
1628
|
+
style,
|
|
1629
|
+
NSBackingStoreBuffered,
|
|
1630
|
+
False,
|
|
1631
|
+
)
|
|
1632
|
+
panel.setTitle_("")
|
|
1633
|
+
panel.setReleasedWhenClosed_(False)
|
|
1634
|
+
panel.setHidesOnDeactivate_(False)
|
|
1635
|
+
panel.setLevel_(NSStatusWindowLevel)
|
|
1636
|
+
try:
|
|
1637
|
+
panel.setCollectionBehavior_(
|
|
1638
|
+
NSWindowCollectionBehaviorCanJoinAllSpaces
|
|
1639
|
+
| NSWindowCollectionBehaviorFullScreenAuxiliary
|
|
1640
|
+
)
|
|
1641
|
+
except Exception:
|
|
1642
|
+
pass
|
|
1643
|
+
try:
|
|
1644
|
+
panel.setFloatingPanel_(True)
|
|
1645
|
+
except Exception:
|
|
1646
|
+
pass
|
|
1647
|
+
try:
|
|
1648
|
+
panel.setBecomesKeyOnlyIfNeeded_(False)
|
|
1649
|
+
except Exception:
|
|
1650
|
+
pass
|
|
1651
|
+
|
|
1652
|
+
content = panel.contentView()
|
|
1653
|
+
url_handlers: list = []
|
|
1654
|
+
|
|
1655
|
+
# ── App icon (centered at top) — static assets/icon.png ──
|
|
1656
|
+
icon_path = Path(__file__).parent.parent.parent.parent / "assets" / "icon.png"
|
|
1657
|
+
y_icon = win_h - icon_size - 14
|
|
1658
|
+
if icon_path.exists():
|
|
1659
|
+
try:
|
|
1660
|
+
img = NSImage.alloc().initWithContentsOfFile_(str(icon_path))
|
|
1661
|
+
if img is not None:
|
|
1662
|
+
img.setSize_((icon_size, icon_size))
|
|
1663
|
+
x_icon = (win_w - icon_size) / 2
|
|
1664
|
+
image_view = NSImageView.alloc().initWithFrame_(
|
|
1665
|
+
NSMakeRect(x_icon, y_icon, icon_size, icon_size)
|
|
1666
|
+
)
|
|
1667
|
+
image_view.setImage_(img)
|
|
1668
|
+
content.addSubview_(image_view)
|
|
1669
|
+
except Exception:
|
|
1670
|
+
pass
|
|
1671
|
+
|
|
1672
|
+
def _add_centered_label(text: str, y: float, h: float, font, color=None) -> None:
|
|
1673
|
+
lbl = NSTextField.alloc().initWithFrame_(NSMakeRect(0, y, win_w, h))
|
|
1674
|
+
lbl.setStringValue_(text)
|
|
1675
|
+
lbl.setAlignment_(1) # center
|
|
1676
|
+
lbl.setBezeled_(False)
|
|
1677
|
+
lbl.setDrawsBackground_(False)
|
|
1678
|
+
lbl.setEditable_(False)
|
|
1679
|
+
lbl.setSelectable_(False)
|
|
1680
|
+
lbl.setFont_(font)
|
|
1681
|
+
if color is not None:
|
|
1682
|
+
try:
|
|
1683
|
+
lbl.setTextColor_(color)
|
|
1684
|
+
except Exception:
|
|
1685
|
+
pass
|
|
1686
|
+
content.addSubview_(lbl)
|
|
1687
|
+
|
|
1688
|
+
# ── "SusOps" bold name ──
|
|
1689
|
+
name_y = y_icon - 30
|
|
1690
|
+
_add_centered_label("SusOps", name_y, 20, NSFont.boldSystemFontOfSize_(14))
|
|
1691
|
+
|
|
1692
|
+
# ── Version subtitle (dimmed) ──
|
|
1693
|
+
ver_y = name_y - 18
|
|
1694
|
+
try:
|
|
1695
|
+
secondary = NSColor.secondaryLabelColor()
|
|
1696
|
+
except Exception:
|
|
1697
|
+
secondary = None
|
|
1698
|
+
_add_centered_label(f"Version {version}", ver_y, 14, NSFont.systemFontOfSize_(11), secondary)
|
|
1699
|
+
|
|
1700
|
+
# ── Link row: 4 borderless NSButtons separated by " | " labels ──
|
|
1701
|
+
links: list[tuple[str, str]] = [
|
|
1702
|
+
("GitHub", "https://github.com/mashb1t/susops"),
|
|
1703
|
+
("Sponsor", "https://github.com/sponsors/mashb1t"),
|
|
1704
|
+
("Report a Bug", "https://github.com/mashb1t/susops/issues/new"),
|
|
1705
|
+
]
|
|
1706
|
+
link_font = NSFont.systemFontOfSize_(12)
|
|
1707
|
+
try:
|
|
1708
|
+
link_color = NSColor.linkColor()
|
|
1709
|
+
except Exception:
|
|
1710
|
+
link_color = NSColor.blueColor()
|
|
1711
|
+
|
|
1712
|
+
sep_attr = NSAttributedString.alloc().initWithString_attributes_(
|
|
1713
|
+
" | ", {NSFontAttributeName: link_font}
|
|
1714
|
+
)
|
|
1715
|
+
sep_w = float(sep_attr.size().width) + 2
|
|
1716
|
+
link_widths: list[float] = []
|
|
1717
|
+
for label, _ in links:
|
|
1718
|
+
attr = NSAttributedString.alloc().initWithString_attributes_(
|
|
1719
|
+
label, {NSFontAttributeName: link_font, NSForegroundColorAttributeName: link_color}
|
|
1720
|
+
)
|
|
1721
|
+
link_widths.append(float(attr.size().width) + 4)
|
|
1722
|
+
total_w = sum(link_widths) + sep_w * (len(links) - 1)
|
|
1723
|
+
|
|
1724
|
+
link_y = ver_y - 26
|
|
1725
|
+
link_h = 18
|
|
1726
|
+
x = (win_w - total_w) / 2
|
|
1727
|
+
for idx, (label, url) in enumerate(links):
|
|
1728
|
+
w = link_widths[idx]
|
|
1729
|
+
btn = NSButton.alloc().initWithFrame_(NSMakeRect(x, link_y, w, link_h))
|
|
1730
|
+
btn.setBordered_(False)
|
|
1731
|
+
btn.setButtonType_(7) # NSButtonTypeMomentaryChange — visual feedback only
|
|
1732
|
+
attr_title = NSAttributedString.alloc().initWithString_attributes_(
|
|
1733
|
+
label,
|
|
1734
|
+
{
|
|
1735
|
+
NSFontAttributeName: link_font,
|
|
1736
|
+
NSForegroundColorAttributeName: link_color,
|
|
1737
|
+
},
|
|
1738
|
+
)
|
|
1739
|
+
btn.setAttributedTitle_(attr_title)
|
|
1740
|
+
handler = _get_url_handler_cls().alloc().initWithURL_(url)
|
|
1741
|
+
url_handlers.append(handler)
|
|
1742
|
+
btn.setTarget_(handler)
|
|
1743
|
+
btn.setAction_("openURL:")
|
|
1744
|
+
content.addSubview_(btn)
|
|
1745
|
+
x += w
|
|
1746
|
+
|
|
1747
|
+
if idx != len(links) - 1:
|
|
1748
|
+
sep = NSTextField.alloc().initWithFrame_(NSMakeRect(x, link_y, sep_w, link_h))
|
|
1749
|
+
sep.setStringValue_("|")
|
|
1750
|
+
sep.setAlignment_(1)
|
|
1751
|
+
sep.setBezeled_(False)
|
|
1752
|
+
sep.setDrawsBackground_(False)
|
|
1753
|
+
sep.setEditable_(False)
|
|
1754
|
+
sep.setSelectable_(False)
|
|
1755
|
+
sep.setFont_(link_font)
|
|
1756
|
+
try:
|
|
1757
|
+
sep.setTextColor_(NSColor.tertiaryLabelColor())
|
|
1758
|
+
except Exception:
|
|
1759
|
+
pass
|
|
1760
|
+
content.addSubview_(sep)
|
|
1761
|
+
x += sep_w
|
|
1762
|
+
|
|
1763
|
+
# ── Copyright (dimmed footer) ──
|
|
1764
|
+
# copy_y = link_y - 28
|
|
1765
|
+
# _add_centered_label("GPLv3, by mashb1t", copy_y, 14, NSFont.systemFontOfSize_(11), secondary)
|
|
1766
|
+
|
|
1767
|
+
# Reuse the WindowDelegateCls from the live-logs helper — same close-via-X
|
|
1768
|
+
# semantics, callback-based, registered once at module scope (avoids the
|
|
1769
|
+
# second-open-is-invisible PyObjC re-registration bug).
|
|
1770
|
+
_TickTargetCls, _CloseHandlerCls, WindowDelegateCls = _get_live_window_classes()
|
|
1771
|
+
|
|
1772
|
+
teardown_box: dict = {"fn": None}
|
|
1773
|
+
|
|
1774
|
+
def _teardown_proxy():
|
|
1775
|
+
fn = teardown_box["fn"]
|
|
1776
|
+
if fn is not None:
|
|
1777
|
+
fn()
|
|
1778
|
+
|
|
1779
|
+
delegate = WindowDelegateCls.alloc().initWithCallback_(_teardown_proxy)
|
|
1780
|
+
panel.setDelegate_(delegate)
|
|
1781
|
+
|
|
1782
|
+
# Hold the activation-policy scope open across the window's lifetime —
|
|
1783
|
+
# exit immediately would let accessory mode reassert 0.3 s later and hide
|
|
1784
|
+
# the panel. See _open_live_text_window for the full rationale.
|
|
1785
|
+
policy_scope = _RegularPolicyScope()
|
|
1786
|
+
policy_scope.__enter__()
|
|
1787
|
+
|
|
1788
|
+
state = {"closed": False}
|
|
1789
|
+
|
|
1790
|
+
def _teardown():
|
|
1791
|
+
if state["closed"]:
|
|
1792
|
+
return
|
|
1793
|
+
state["closed"] = True
|
|
1794
|
+
try:
|
|
1795
|
+
panel.orderOut_(None)
|
|
1796
|
+
except Exception:
|
|
1797
|
+
pass
|
|
1798
|
+
try:
|
|
1799
|
+
policy_scope.__exit__(None, None, None)
|
|
1800
|
+
except Exception:
|
|
1801
|
+
pass
|
|
1802
|
+
_ABOUT_WINDOWS.pop(id(panel), None)
|
|
1803
|
+
|
|
1804
|
+
teardown_box["fn"] = _teardown
|
|
1805
|
+
|
|
1806
|
+
_ABOUT_WINDOWS[id(panel)] = {
|
|
1807
|
+
"panel": panel,
|
|
1808
|
+
"delegate": delegate,
|
|
1809
|
+
"url_handlers": url_handlers,
|
|
1810
|
+
"policy_scope": policy_scope,
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
panel.center()
|
|
1814
|
+
panel.makeKeyAndOrderFront_(None)
|
|
1815
|
+
try:
|
|
1816
|
+
panel.orderFrontRegardless()
|
|
1817
|
+
except Exception:
|
|
1818
|
+
pass
|
|
1819
|
+
try:
|
|
1820
|
+
NSApplication.sharedApplication().activateIgnoringOtherApps_(True)
|
|
1821
|
+
except Exception:
|
|
1822
|
+
pass
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
# ---------------------------------------------------------------------------
|
|
1826
|
+
# Launch at Login (AppleScript-based)
|
|
1827
|
+
# ---------------------------------------------------------------------------
|
|
1828
|
+
|
|
1829
|
+
|
|
1830
|
+
def _login_item_path() -> str:
|
|
1831
|
+
"""Return the path that should be registered as the login item.
|
|
1832
|
+
|
|
1833
|
+
When running from a frozen .app bundle, returns the bundle path. Otherwise
|
|
1834
|
+
returns the Python interpreter — best effort.
|
|
1835
|
+
"""
|
|
1836
|
+
try:
|
|
1837
|
+
from Foundation import NSBundle # type: ignore[import]
|
|
1838
|
+
bundle_path = NSBundle.mainBundle().bundlePath()
|
|
1839
|
+
if bundle_path and bundle_path.endswith(".app"):
|
|
1840
|
+
return str(bundle_path)
|
|
1841
|
+
except Exception:
|
|
1842
|
+
pass
|
|
1843
|
+
import sys
|
|
1844
|
+
return sys.executable
|
|
1845
|
+
|
|
1846
|
+
|
|
1847
|
+
def _is_launch_at_login_enabled() -> bool:
|
|
1848
|
+
try:
|
|
1849
|
+
path = _login_item_path()
|
|
1850
|
+
name = Path(path).stem
|
|
1851
|
+
out = subprocess.check_output(
|
|
1852
|
+
["osascript", "-e", 'tell application "System Events" to get name of every login item'],
|
|
1853
|
+
stderr=subprocess.DEVNULL,
|
|
1854
|
+
timeout=3,
|
|
1855
|
+
).decode("utf-8", errors="ignore")
|
|
1856
|
+
return name in out
|
|
1857
|
+
except Exception:
|
|
1858
|
+
return False
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
def _set_launch_at_login(enable: bool) -> None:
|
|
1862
|
+
path = _login_item_path()
|
|
1863
|
+
name = Path(path).stem
|
|
1864
|
+
if enable:
|
|
1865
|
+
script = (
|
|
1866
|
+
'tell application "System Events" to make login item '
|
|
1867
|
+
f'at end with properties {{path:"{path}", hidden:false}}'
|
|
1868
|
+
)
|
|
1869
|
+
else:
|
|
1870
|
+
script = f'tell application "System Events" to delete login item "{name}"'
|
|
1871
|
+
try:
|
|
1872
|
+
subprocess.run(["osascript", "-e", script], check=False, timeout=3)
|
|
1873
|
+
except Exception:
|
|
1874
|
+
pass
|
|
1875
|
+
|
|
1876
|
+
|
|
1877
|
+
# ---------------------------------------------------------------------------
|
|
1878
|
+
# Tray app
|
|
1879
|
+
# ---------------------------------------------------------------------------
|
|
1880
|
+
|
|
1881
|
+
|
|
1882
|
+
class SusOpsMacTray(AbstractTrayApp):
|
|
1883
|
+
"""macOS system tray application using rumps + native PyObjC dialogs."""
|
|
1884
|
+
|
|
1885
|
+
def __init__(self) -> None:
|
|
1886
|
+
super().__init__()
|
|
1887
|
+
import rumps # type: ignore[import]
|
|
1888
|
+
self._rumps = rumps
|
|
1889
|
+
|
|
1890
|
+
icon_path = _get_icon_path(ProcessState.STOPPED, self.manager.app_config.logo_style.value.lower())
|
|
1891
|
+
self._app = rumps.App(
|
|
1892
|
+
"SusOps",
|
|
1893
|
+
icon=icon_path,
|
|
1894
|
+
template=False,
|
|
1895
|
+
quit_button=None,
|
|
1896
|
+
)
|
|
1897
|
+
self._active_shares: list = []
|
|
1898
|
+
# Tri-state cache for launch-at-login: None until the background probe finishes.
|
|
1899
|
+
# Synchronous osascript would block the main thread (and can trigger a TCC prompt
|
|
1900
|
+
# the first time), so we never query it from the menu callback.
|
|
1901
|
+
self._launch_at_login_cached: bool | None = None
|
|
1902
|
+
self._refresh_launch_at_login_async()
|
|
1903
|
+
self._build_menu()
|
|
1904
|
+
self._register_appearance_observer()
|
|
1905
|
+
|
|
1906
|
+
def _refresh_launch_at_login_async(self) -> None:
|
|
1907
|
+
def _probe():
|
|
1908
|
+
try:
|
|
1909
|
+
self._launch_at_login_cached = _is_launch_at_login_enabled()
|
|
1910
|
+
except Exception:
|
|
1911
|
+
self._launch_at_login_cached = False
|
|
1912
|
+
|
|
1913
|
+
threading.Thread(target=_probe, daemon=True, name="susops-loginitem-probe").start()
|
|
1914
|
+
|
|
1915
|
+
# ------------------------------------------------------------------ #
|
|
1916
|
+
# Appearance observer
|
|
1917
|
+
# ------------------------------------------------------------------ #
|
|
1918
|
+
|
|
1919
|
+
def _register_appearance_observer(self) -> None:
|
|
1920
|
+
try:
|
|
1921
|
+
from Foundation import NSDistributedNotificationCenter # type: ignore[import]
|
|
1922
|
+
|
|
1923
|
+
def _on_appearance_changed(_notification):
|
|
1924
|
+
_on_main(lambda: self.update_icon(self.state))
|
|
1925
|
+
|
|
1926
|
+
self._appearance_observer = _on_appearance_changed
|
|
1927
|
+
center = NSDistributedNotificationCenter.defaultCenter()
|
|
1928
|
+
center.addObserverForName_object_queue_usingBlock_(
|
|
1929
|
+
"AppleInterfaceThemeChangedNotification",
|
|
1930
|
+
None,
|
|
1931
|
+
None,
|
|
1932
|
+
_on_appearance_changed,
|
|
1933
|
+
)
|
|
1934
|
+
except Exception:
|
|
1935
|
+
pass
|
|
1936
|
+
|
|
1937
|
+
# ------------------------------------------------------------------ #
|
|
1938
|
+
# AbstractTrayApp implementation
|
|
1939
|
+
# ------------------------------------------------------------------ #
|
|
1940
|
+
|
|
1941
|
+
def _apply_icon_path(self, icon_path: str) -> None:
|
|
1942
|
+
"""Route an icon path through the bandwidth subview when active, else
|
|
1943
|
+
fall back to rumps's normal app.icon setter."""
|
|
1944
|
+
if icon_path:
|
|
1945
|
+
self._last_icon_path = icon_path
|
|
1946
|
+
iv = getattr(self, "_bw_icon_view", None)
|
|
1947
|
+
if iv is None or not icon_path:
|
|
1948
|
+
if icon_path:
|
|
1949
|
+
self._app.icon = icon_path
|
|
1950
|
+
return
|
|
1951
|
+
from AppKit import NSImage # type: ignore[import]
|
|
1952
|
+
img = NSImage.alloc().initByReferencingFile_(icon_path)
|
|
1953
|
+
if img is not None:
|
|
1954
|
+
iv.setImage_(img)
|
|
1955
|
+
try:
|
|
1956
|
+
button = self._app._nsapp.nsstatusitem.button()
|
|
1957
|
+
if button is not None:
|
|
1958
|
+
button.setImage_(None)
|
|
1959
|
+
except Exception:
|
|
1960
|
+
pass
|
|
1961
|
+
|
|
1962
|
+
def _current_icon_path(self) -> str | None:
|
|
1963
|
+
"""Most recently applied icon path. Falls back to the saved logo style
|
|
1964
|
+
so the first read after launch (before any _apply_icon_path) works."""
|
|
1965
|
+
cached = getattr(self, "_last_icon_path", None)
|
|
1966
|
+
if cached:
|
|
1967
|
+
return cached
|
|
1968
|
+
logo_style = self.manager.app_config.logo_style.value.lower()
|
|
1969
|
+
return _get_icon_path(self.state, logo_style)
|
|
1970
|
+
|
|
1971
|
+
def update_icon(self, state: ProcessState) -> None:
|
|
1972
|
+
logo_style = self.manager.app_config.logo_style.value.lower()
|
|
1973
|
+
icon_path = _get_icon_path(state, logo_style)
|
|
1974
|
+
if icon_path:
|
|
1975
|
+
self._apply_icon_path(icon_path)
|
|
1976
|
+
|
|
1977
|
+
def update_menu_sensitivity(self, state: ProcessState) -> None:
|
|
1978
|
+
# Match susops-mac's per-state enablement table:
|
|
1979
|
+
# RUNNING → Start off, Stop on, Restart on, TestAll on
|
|
1980
|
+
# STOPPED_PARTIALLY → Start on, Stop on, Restart on, TestAll on
|
|
1981
|
+
# STOPPED / INITIAL → Start on, Stop off, Restart off, TestAll off
|
|
1982
|
+
# ERROR → all off (recovery is via Reset)
|
|
1983
|
+
running_like = state in (ProcessState.RUNNING, ProcessState.STOPPED_PARTIALLY)
|
|
1984
|
+
start_on = state in (ProcessState.STOPPED, ProcessState.STOPPED_PARTIALLY, ProcessState.INITIAL)
|
|
1985
|
+
action_on = running_like # Stop / Restart / Test All
|
|
1986
|
+
|
|
1987
|
+
if hasattr(self, "_item_start"):
|
|
1988
|
+
self._item_start._menuitem.setEnabled_(start_on) # type: ignore[attr-defined]
|
|
1989
|
+
if hasattr(self, "_item_stop"):
|
|
1990
|
+
self._item_stop._menuitem.setEnabled_(action_on) # type: ignore[attr-defined]
|
|
1991
|
+
if hasattr(self, "_item_restart"):
|
|
1992
|
+
self._item_restart._menuitem.setEnabled_(action_on) # type: ignore[attr-defined]
|
|
1993
|
+
if hasattr(self, "_item_test_all"):
|
|
1994
|
+
self._item_test_all._menuitem.setEnabled_(action_on) # type: ignore[attr-defined]
|
|
1995
|
+
if hasattr(self, "_item_status"):
|
|
1996
|
+
# SVG status indicator (green / orange / grey / red) shown as the
|
|
1997
|
+
# menu item's icon — matches susops-mac's status-icon pattern.
|
|
1998
|
+
label = state.value.lower().replace("_", " ")
|
|
1999
|
+
self._item_status.title = f"SusOps: {label}"
|
|
2000
|
+
icon_path = _get_status_icon_path(state)
|
|
2001
|
+
if icon_path:
|
|
2002
|
+
try:
|
|
2003
|
+
self._item_status.icon = icon_path
|
|
2004
|
+
except Exception:
|
|
2005
|
+
pass
|
|
2006
|
+
|
|
2007
|
+
def show_alert(self, title: str, msg: str) -> None:
|
|
2008
|
+
_show_message(title, msg)
|
|
2009
|
+
|
|
2010
|
+
def show_output_dialog(self, title: str, output: str) -> None:
|
|
2011
|
+
_show_message(title, output, ok="Close")
|
|
2012
|
+
|
|
2013
|
+
def show_live_logs(self, get_text: Callable[[], str], *, title: str = "Logs",
|
|
2014
|
+
interval_ms: int = 1000) -> None:
|
|
2015
|
+
"""Open a non-modal, auto-refreshing logs panel on the main thread."""
|
|
2016
|
+
_on_main(lambda: _open_live_text_window(title, get_text, interval_ms))
|
|
2017
|
+
|
|
2018
|
+
def run_in_background(self, fn: Callable, callback: Callable | None = None) -> None:
|
|
2019
|
+
"""Run fn on a worker thread; marshal the callback back to the main thread.
|
|
2020
|
+
|
|
2021
|
+
AppKit (NSWindow, NSAlert, …) MUST be touched only from the main thread.
|
|
2022
|
+
Background-task callbacks frequently end up calling show_alert / show_output_dialog,
|
|
2023
|
+
so we dispatch them via _on_main rather than running them on the worker. Without
|
|
2024
|
+
this, AppKit raises NSInternalInconsistencyException and the app gets stuck.
|
|
2025
|
+
"""
|
|
2026
|
+
|
|
2027
|
+
def _worker():
|
|
2028
|
+
result = fn()
|
|
2029
|
+
if callback is not None:
|
|
2030
|
+
_on_main(lambda: callback(result))
|
|
2031
|
+
|
|
2032
|
+
threading.Thread(target=_worker, daemon=True).start()
|
|
2033
|
+
|
|
2034
|
+
# ------------------------------------------------------------------ #
|
|
2035
|
+
# Browser launch overrides (macOS)
|
|
2036
|
+
# ------------------------------------------------------------------ #
|
|
2037
|
+
|
|
2038
|
+
def _launch_chromium_app(self, bundle_name: str) -> None:
|
|
2039
|
+
pac_url = self.manager.get_pac_url()
|
|
2040
|
+
if not pac_url:
|
|
2041
|
+
self.show_alert("Proxy Not Running", "Start the proxy first so the PAC port is known.")
|
|
2042
|
+
return
|
|
2043
|
+
from susops.core.browsers import Browser, launch_with_pac
|
|
2044
|
+
browser = Browser(
|
|
2045
|
+
name=bundle_name,
|
|
2046
|
+
launch_cmd=["open", "-a", bundle_name],
|
|
2047
|
+
is_chromium=True,
|
|
2048
|
+
bundle=bundle_name,
|
|
2049
|
+
)
|
|
2050
|
+
|
|
2051
|
+
# Spawn in a background thread — `open -na` can block briefly on macOS
|
|
2052
|
+
# while LaunchServices coordinates with the new app instance, and we
|
|
2053
|
+
# don't want to freeze the menu bar app while that happens.
|
|
2054
|
+
def _spawn():
|
|
2055
|
+
try:
|
|
2056
|
+
launch_with_pac(browser, pac_url)
|
|
2057
|
+
except Exception as exc:
|
|
2058
|
+
_on_main(lambda: self.show_alert("Launch Failed", str(exc)))
|
|
2059
|
+
|
|
2060
|
+
threading.Thread(target=_spawn, daemon=True, name="susops-launch-chrome").start()
|
|
2061
|
+
|
|
2062
|
+
def _open_chromium_proxy_settings(self, bundle_name: str) -> None:
|
|
2063
|
+
"""Open Chrome/Edge/Brave/... directly on chrome://net-internals/#proxy."""
|
|
2064
|
+
from susops.core.browsers import Browser, open_proxy_settings
|
|
2065
|
+
browser = Browser(
|
|
2066
|
+
name=bundle_name,
|
|
2067
|
+
launch_cmd=["open", "-a", bundle_name],
|
|
2068
|
+
is_chromium=True,
|
|
2069
|
+
bundle=bundle_name,
|
|
2070
|
+
)
|
|
2071
|
+
|
|
2072
|
+
def _spawn():
|
|
2073
|
+
try:
|
|
2074
|
+
open_proxy_settings(browser)
|
|
2075
|
+
except Exception as exc:
|
|
2076
|
+
_on_main(lambda: self.show_alert("Launch Failed", str(exc)))
|
|
2077
|
+
|
|
2078
|
+
threading.Thread(target=_spawn, daemon=True, name="susops-open-browser").start()
|
|
2079
|
+
|
|
2080
|
+
def _launch_firefox_app(self, bundle_name: str = "Firefox") -> None:
|
|
2081
|
+
pac_url = self.manager.get_pac_url()
|
|
2082
|
+
if not pac_url:
|
|
2083
|
+
self.show_alert("Proxy Not Running", "Start the proxy first.")
|
|
2084
|
+
return
|
|
2085
|
+
from susops.core.browsers import Browser, launch_with_pac
|
|
2086
|
+
browser = Browser(
|
|
2087
|
+
name=bundle_name,
|
|
2088
|
+
launch_cmd=["open", "-a", bundle_name],
|
|
2089
|
+
is_chromium=False,
|
|
2090
|
+
bundle=bundle_name,
|
|
2091
|
+
)
|
|
2092
|
+
profile_dir = self.manager.workspace / "firefox_profile"
|
|
2093
|
+
|
|
2094
|
+
def _spawn():
|
|
2095
|
+
try:
|
|
2096
|
+
launch_with_pac(browser, pac_url, profile_dir=profile_dir)
|
|
2097
|
+
except Exception as exc:
|
|
2098
|
+
_on_main(lambda: self.show_alert("Launch Failed", str(exc)))
|
|
2099
|
+
|
|
2100
|
+
threading.Thread(target=_spawn, daemon=True, name="susops-launch-firefox").start()
|
|
2101
|
+
|
|
2102
|
+
def do_launch_chrome(self) -> None:
|
|
2103
|
+
self._launch_chromium_app("Google Chrome")
|
|
2104
|
+
|
|
2105
|
+
def do_launch_firefox(self) -> None:
|
|
2106
|
+
self._launch_firefox_app("Firefox")
|
|
2107
|
+
|
|
2108
|
+
# ------------------------------------------------------------------ #
|
|
2109
|
+
# Menu building
|
|
2110
|
+
# ------------------------------------------------------------------ #
|
|
2111
|
+
|
|
2112
|
+
def _build_menu(self) -> None:
|
|
2113
|
+
rumps = self._rumps
|
|
2114
|
+
|
|
2115
|
+
self._item_status = rumps.MenuItem("SusOps: …")
|
|
2116
|
+
# Seed the SVG icon at startup so the menu has the right circle before
|
|
2117
|
+
# the first poll fires.
|
|
2118
|
+
initial_icon = _get_status_icon_path(ProcessState.INITIAL)
|
|
2119
|
+
if initial_icon:
|
|
2120
|
+
try:
|
|
2121
|
+
self._item_status.icon = initial_icon
|
|
2122
|
+
except Exception:
|
|
2123
|
+
pass
|
|
2124
|
+
self._item_status._menuitem.setEnabled_(False) # type: ignore[attr-defined]
|
|
2125
|
+
|
|
2126
|
+
self._item_start = rumps.MenuItem("Start Proxy", callback=lambda _: self.do_start())
|
|
2127
|
+
self._item_stop = rumps.MenuItem("Stop Proxy", callback=lambda _: self.do_stop())
|
|
2128
|
+
self._item_restart = rumps.MenuItem("Restart Proxy", callback=lambda _: self.do_restart(), key="r")
|
|
2129
|
+
|
|
2130
|
+
# Add submenu
|
|
2131
|
+
add_menu = rumps.MenuItem("Add")
|
|
2132
|
+
add_menu["Add Connection"] = rumps.MenuItem(
|
|
2133
|
+
"Add Connection", callback=lambda _: self._show_add_connection_dialog()
|
|
2134
|
+
)
|
|
2135
|
+
add_menu["Add Domain / IP / CIDR"] = rumps.MenuItem(
|
|
2136
|
+
"Add Domain / IP / CIDR", callback=lambda _: self._show_add_host_dialog()
|
|
2137
|
+
)
|
|
2138
|
+
add_menu["Add Local Forward"] = rumps.MenuItem(
|
|
2139
|
+
"Add Local Forward", callback=lambda _: self._show_add_forward_dialog(remote=False)
|
|
2140
|
+
)
|
|
2141
|
+
add_menu["Add Remote Forward"] = rumps.MenuItem(
|
|
2142
|
+
"Add Remote Forward", callback=lambda _: self._show_add_forward_dialog(remote=True)
|
|
2143
|
+
)
|
|
2144
|
+
|
|
2145
|
+
# Remove submenu
|
|
2146
|
+
rm_menu = rumps.MenuItem("Remove")
|
|
2147
|
+
rm_menu["Remove Connection"] = rumps.MenuItem(
|
|
2148
|
+
"Remove Connection", callback=lambda _: self._show_rm_connection_dialog()
|
|
2149
|
+
)
|
|
2150
|
+
rm_menu["Remove Domain / IP / CIDR"] = rumps.MenuItem(
|
|
2151
|
+
"Remove Domain / IP / CIDR", callback=lambda _: self._show_rm_host_dialog()
|
|
2152
|
+
)
|
|
2153
|
+
rm_menu["Remove Local Forward"] = rumps.MenuItem(
|
|
2154
|
+
"Remove Local Forward", callback=lambda _: self._show_rm_local_dialog()
|
|
2155
|
+
)
|
|
2156
|
+
rm_menu["Remove Remote Forward"] = rumps.MenuItem(
|
|
2157
|
+
"Remove Remote Forward", callback=lambda _: self._show_rm_remote_dialog()
|
|
2158
|
+
)
|
|
2159
|
+
|
|
2160
|
+
# Manage submenu
|
|
2161
|
+
manage_menu = rumps.MenuItem("Manage")
|
|
2162
|
+
manage_menu["Toggle Connection Enabled…"] = rumps.MenuItem(
|
|
2163
|
+
"Toggle Connection Enabled…", callback=lambda _: self._show_toggle_connection_dialog()
|
|
2164
|
+
)
|
|
2165
|
+
manage_menu["Toggle Domain Enabled…"] = rumps.MenuItem(
|
|
2166
|
+
"Toggle Domain Enabled…", callback=lambda _: self._show_toggle_domain_dialog()
|
|
2167
|
+
)
|
|
2168
|
+
manage_menu["Toggle Forward Enabled…"] = rumps.MenuItem(
|
|
2169
|
+
"Toggle Forward Enabled…", callback=lambda _: self._show_toggle_forward_dialog()
|
|
2170
|
+
)
|
|
2171
|
+
manage_menu["---1"] = None
|
|
2172
|
+
manage_menu["Start Connection…"] = rumps.MenuItem(
|
|
2173
|
+
"Start Connection…", callback=lambda _: self._show_start_connection_dialog()
|
|
2174
|
+
)
|
|
2175
|
+
manage_menu["Stop Connection…"] = rumps.MenuItem(
|
|
2176
|
+
"Stop Connection…", callback=lambda _: self._show_stop_connection_dialog()
|
|
2177
|
+
)
|
|
2178
|
+
manage_menu["Restart Connection…"] = rumps.MenuItem(
|
|
2179
|
+
"Restart Connection…", callback=lambda _: self._show_restart_connection_dialog()
|
|
2180
|
+
)
|
|
2181
|
+
|
|
2182
|
+
# Test submenu
|
|
2183
|
+
self._item_test_all = rumps.MenuItem(
|
|
2184
|
+
"Test All PAC Hosts", callback=lambda _: self.do_test()
|
|
2185
|
+
)
|
|
2186
|
+
test_menu = rumps.MenuItem("Test")
|
|
2187
|
+
test_menu["Test Connection…"] = rumps.MenuItem(
|
|
2188
|
+
"Test Connection…", callback=lambda _: self._show_test_connection_dialog()
|
|
2189
|
+
)
|
|
2190
|
+
test_menu["Test Domain…"] = rumps.MenuItem(
|
|
2191
|
+
"Test Domain…", callback=lambda _: self._show_test_domain_dialog()
|
|
2192
|
+
)
|
|
2193
|
+
test_menu["Test Forward…"] = rumps.MenuItem(
|
|
2194
|
+
"Test Forward…", callback=lambda _: self._show_test_forward_dialog()
|
|
2195
|
+
)
|
|
2196
|
+
test_menu["---"] = None
|
|
2197
|
+
test_menu["Test All PAC Hosts"] = self._item_test_all
|
|
2198
|
+
|
|
2199
|
+
# Launch Browser submenu — built dynamically from installed apps
|
|
2200
|
+
self._browser_menu = rumps.MenuItem("Launch Browser")
|
|
2201
|
+
self._rebuild_browser_submenu()
|
|
2202
|
+
|
|
2203
|
+
# File Transfer submenu
|
|
2204
|
+
self._ft_menu = rumps.MenuItem("File Transfer")
|
|
2205
|
+
self._ft_menu["Share File…"] = rumps.MenuItem(
|
|
2206
|
+
"Share File…", callback=lambda _: self._show_share_file_dialog()
|
|
2207
|
+
)
|
|
2208
|
+
self._ft_menu["Fetch File…"] = rumps.MenuItem(
|
|
2209
|
+
"Fetch File…", callback=lambda _: self._show_fetch_file_dialog()
|
|
2210
|
+
)
|
|
2211
|
+
|
|
2212
|
+
self._app.menu = [
|
|
2213
|
+
self._item_status,
|
|
2214
|
+
None,
|
|
2215
|
+
rumps.MenuItem("Settings…", callback=lambda _: self._show_settings_dialog(), key=","),
|
|
2216
|
+
None,
|
|
2217
|
+
add_menu,
|
|
2218
|
+
rm_menu,
|
|
2219
|
+
manage_menu,
|
|
2220
|
+
rumps.MenuItem("Open Config File", callback=lambda _: self.do_open_config_file()),
|
|
2221
|
+
None,
|
|
2222
|
+
self._item_start,
|
|
2223
|
+
self._item_stop,
|
|
2224
|
+
self._item_restart,
|
|
2225
|
+
None,
|
|
2226
|
+
test_menu,
|
|
2227
|
+
rumps.MenuItem("Show Status", callback=lambda _: self.do_status()),
|
|
2228
|
+
rumps.MenuItem("Show Logs", callback=lambda _: self.do_logs()),
|
|
2229
|
+
self._browser_menu,
|
|
2230
|
+
self._ft_menu,
|
|
2231
|
+
None,
|
|
2232
|
+
rumps.MenuItem("Reset All", callback=lambda _: self._confirm_reset()),
|
|
2233
|
+
None,
|
|
2234
|
+
rumps.MenuItem("About SusOps", callback=lambda _: self._show_about_dialog()),
|
|
2235
|
+
rumps.MenuItem("Quit", callback=self._on_quit, key="q"),
|
|
2236
|
+
]
|
|
2237
|
+
|
|
2238
|
+
def _rebuild_browser_submenu(self) -> None:
|
|
2239
|
+
rumps = self._rumps
|
|
2240
|
+
# Clear existing items
|
|
2241
|
+
for k in list(self._browser_menu.keys()):
|
|
2242
|
+
del self._browser_menu[k]
|
|
2243
|
+
|
|
2244
|
+
installed = _find_installed_browsers()
|
|
2245
|
+
if not installed:
|
|
2246
|
+
none_item = rumps.MenuItem("No browsers found")
|
|
2247
|
+
none_item._menuitem.setEnabled_(False) # type: ignore[attr-defined]
|
|
2248
|
+
self._browser_menu["No browsers found"] = none_item
|
|
2249
|
+
return
|
|
2250
|
+
|
|
2251
|
+
for bundle, name, chromium in installed:
|
|
2252
|
+
sub = rumps.MenuItem(name)
|
|
2253
|
+
sub[f"Launch {name}"] = rumps.MenuItem(
|
|
2254
|
+
f"Launch {name}",
|
|
2255
|
+
callback=self._make_browser_launch(bundle, chromium),
|
|
2256
|
+
)
|
|
2257
|
+
if chromium:
|
|
2258
|
+
sub[f"Open {name} Proxy Settings"] = rumps.MenuItem(
|
|
2259
|
+
f"Open {name} Proxy Settings",
|
|
2260
|
+
callback=self._make_browser_settings(bundle),
|
|
2261
|
+
)
|
|
2262
|
+
self._browser_menu[name] = sub
|
|
2263
|
+
|
|
2264
|
+
def _make_browser_launch(self, bundle: str, chromium: bool):
|
|
2265
|
+
def handler(_sender):
|
|
2266
|
+
if chromium:
|
|
2267
|
+
self._launch_chromium_app(bundle)
|
|
2268
|
+
else:
|
|
2269
|
+
self._launch_firefox_app(bundle)
|
|
2270
|
+
|
|
2271
|
+
return handler
|
|
2272
|
+
|
|
2273
|
+
def _make_browser_settings(self, bundle: str):
|
|
2274
|
+
def handler(_sender):
|
|
2275
|
+
self._open_chromium_proxy_settings(bundle)
|
|
2276
|
+
|
|
2277
|
+
return handler
|
|
2278
|
+
|
|
2279
|
+
# ------------------------------------------------------------------ #
|
|
2280
|
+
# File-transfer share submenu refresh (optimized — only on change)
|
|
2281
|
+
# ------------------------------------------------------------------ #
|
|
2282
|
+
|
|
2283
|
+
def _refresh_share_submenu(self) -> None:
|
|
2284
|
+
import pathlib
|
|
2285
|
+
rumps = self._rumps
|
|
2286
|
+
new_shares = self.manager.list_shares()
|
|
2287
|
+
|
|
2288
|
+
def _share_key(info):
|
|
2289
|
+
return (info.port, info.running, info.file_path)
|
|
2290
|
+
|
|
2291
|
+
old_keys = [_share_key(s) for s in self._active_shares]
|
|
2292
|
+
new_keys = [_share_key(s) for s in new_shares]
|
|
2293
|
+
if old_keys == new_keys:
|
|
2294
|
+
return
|
|
2295
|
+
|
|
2296
|
+
self._active_shares = new_shares
|
|
2297
|
+
|
|
2298
|
+
# Remove old dynamic entries (everything except Share File… and Fetch File…)
|
|
2299
|
+
for key in list(self._ft_menu.keys()):
|
|
2300
|
+
if key not in ("Share File…", "Fetch File…"):
|
|
2301
|
+
del self._ft_menu[key]
|
|
2302
|
+
|
|
2303
|
+
if not self._active_shares:
|
|
2304
|
+
return
|
|
2305
|
+
|
|
2306
|
+
self._ft_menu["---"] = None
|
|
2307
|
+
for info in self._active_shares:
|
|
2308
|
+
name = pathlib.Path(info.file_path).name
|
|
2309
|
+
dot = "●" if info.running else "○"
|
|
2310
|
+
label = f"{dot} {name} ({info.port})"
|
|
2311
|
+
self._ft_menu[label] = rumps.MenuItem(
|
|
2312
|
+
label,
|
|
2313
|
+
callback=self._make_share_info_handler(info),
|
|
2314
|
+
)
|
|
2315
|
+
|
|
2316
|
+
def do_poll(self) -> None:
|
|
2317
|
+
super().do_poll()
|
|
2318
|
+
self._refresh_share_submenu()
|
|
2319
|
+
|
|
2320
|
+
# ------------------------------------------------------------------ #
|
|
2321
|
+
# Settings dialog (with live logo preview)
|
|
2322
|
+
# ------------------------------------------------------------------ #
|
|
2323
|
+
|
|
2324
|
+
def _show_settings_dialog(self) -> None:
|
|
2325
|
+
ac = self.manager.app_config
|
|
2326
|
+
cfg = self.manager.config
|
|
2327
|
+
pac_port = cfg.pac_server_port
|
|
2328
|
+
rpc_port = cfg.rpc_server_port
|
|
2329
|
+
sse_port = cfg.status_server_port
|
|
2330
|
+
saved_logo = ac.logo_style
|
|
2331
|
+
logo_styles = list(LogoStyle)
|
|
2332
|
+
|
|
2333
|
+
# Build segment images for logo styles (current state, current appearance)
|
|
2334
|
+
seg_options: list[tuple[str, str | None]] = []
|
|
2335
|
+
for style in logo_styles:
|
|
2336
|
+
img_path = _get_icon_path(self.state, style.value.lower())
|
|
2337
|
+
label_text = style.value.replace("_", " ").title()
|
|
2338
|
+
seg_options.append(("", img_path))
|
|
2339
|
+
|
|
2340
|
+
def _preview(idx: int) -> None:
|
|
2341
|
+
if 0 <= idx < len(logo_styles):
|
|
2342
|
+
style = logo_styles[idx]
|
|
2343
|
+
icon_path = _get_icon_path(self.state, style.value.lower())
|
|
2344
|
+
if icon_path:
|
|
2345
|
+
self._apply_icon_path(icon_path)
|
|
2346
|
+
|
|
2347
|
+
# Initial defaults — updated after each invalid attempt so the user keeps state.
|
|
2348
|
+
# Launch-at-login state is read from a background-populated cache to avoid
|
|
2349
|
+
# blocking the main thread on osascript / TCC prompts when opening Settings.
|
|
2350
|
+
defaults = {
|
|
2351
|
+
"launch_at_login": bool(self._launch_at_login_cached),
|
|
2352
|
+
"stop_on_quit": ac.stop_on_quit,
|
|
2353
|
+
"ephemeral_ports": ac.ephemeral_ports,
|
|
2354
|
+
"restore_shares": ac.restore_shares_on_start,
|
|
2355
|
+
"show_bandwidth": ac.tray_show_bandwidth,
|
|
2356
|
+
"logo_style": logo_styles.index(saved_logo),
|
|
2357
|
+
"rpc_port": str(rpc_port) if rpc_port else "",
|
|
2358
|
+
"sse_port": str(sse_port) if sse_port else "",
|
|
2359
|
+
"pac_port": str(pac_port) if pac_port else "",
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
while True:
|
|
2363
|
+
fields = [
|
|
2364
|
+
{"key": "launch_at_login", "label": "Launch at Login:", "kind": "switch",
|
|
2365
|
+
"default": defaults["launch_at_login"]},
|
|
2366
|
+
{"key": "stop_on_quit", "label": "Stop Proxy On Quit:", "kind": "switch",
|
|
2367
|
+
"default": defaults["stop_on_quit"]},
|
|
2368
|
+
{"key": "ephemeral_ports", "label": "Random SSH Ports On Start:", "kind": "switch",
|
|
2369
|
+
"default": defaults["ephemeral_ports"]},
|
|
2370
|
+
{"key": "restore_shares", "label": "Restore Shares On Start:", "kind": "switch",
|
|
2371
|
+
"default": defaults["restore_shares"]},
|
|
2372
|
+
{"key": "show_bandwidth", "label": "Show Bandwidth In Menu Bar:", "kind": "switch",
|
|
2373
|
+
"default": defaults["show_bandwidth"],
|
|
2374
|
+
"on_change": self._preview_bandwidth_visibility},
|
|
2375
|
+
{"key": "logo_style", "label": "Logo Style:", "kind": "segmented",
|
|
2376
|
+
"options": seg_options, "default": defaults["logo_style"],
|
|
2377
|
+
"on_change": _preview},
|
|
2378
|
+
# Server ports — RPC + SSE require a daemon restart to take
|
|
2379
|
+
# effect; PAC is hot-restarted by the facade.
|
|
2380
|
+
{"key": "rpc_port", "label": "RPC Server Port:", "kind": "text",
|
|
2381
|
+
"default": defaults["rpc_port"],
|
|
2382
|
+
"hint": "auto (0) — restart daemon to apply"},
|
|
2383
|
+
{"key": "sse_port", "label": "SSE Server Port:", "kind": "text",
|
|
2384
|
+
"default": defaults["sse_port"],
|
|
2385
|
+
"hint": "auto (0) — restart daemon to apply"},
|
|
2386
|
+
{"key": "pac_port", "label": "PAC Server Port:", "kind": "text",
|
|
2387
|
+
"default": defaults["pac_port"],
|
|
2388
|
+
"hint": "auto (0)"},
|
|
2389
|
+
]
|
|
2390
|
+
|
|
2391
|
+
result = _show_form_dialog("Settings", fields, ok_title="Save", cancel_title="Cancel")
|
|
2392
|
+
if result is None:
|
|
2393
|
+
# Revert any preview
|
|
2394
|
+
self.update_icon(self.state)
|
|
2395
|
+
self.refresh_bandwidth_title()
|
|
2396
|
+
return
|
|
2397
|
+
|
|
2398
|
+
# Refresh defaults so a re-show on validation failure keeps user edits.
|
|
2399
|
+
defaults.update(result)
|
|
2400
|
+
|
|
2401
|
+
# Validate all three port fields; the helper short-circuits to
|
|
2402
|
+
# the inner loop on failure so the user re-edits the offending
|
|
2403
|
+
# value without losing their other edits.
|
|
2404
|
+
port_ints: dict[str, int] = {}
|
|
2405
|
+
port_specs = [
|
|
2406
|
+
("rpc_port", "RPC", rpc_port),
|
|
2407
|
+
("sse_port", "SSE", sse_port),
|
|
2408
|
+
("pac_port", "PAC", pac_port),
|
|
2409
|
+
]
|
|
2410
|
+
invalid = False
|
|
2411
|
+
for key, label, current in port_specs:
|
|
2412
|
+
raw = (result.get(key) or "").strip() or "0"
|
|
2413
|
+
try:
|
|
2414
|
+
n = int(raw)
|
|
2415
|
+
except ValueError:
|
|
2416
|
+
_show_message("Invalid Port", f"'{raw}' is not a valid {label} port.")
|
|
2417
|
+
invalid = True
|
|
2418
|
+
break
|
|
2419
|
+
if not validate_port(n, allow_zero=True):
|
|
2420
|
+
_show_message("Invalid Port",
|
|
2421
|
+
f"{label} port must be 0 (auto) or between 1 and 65535.")
|
|
2422
|
+
invalid = True
|
|
2423
|
+
break
|
|
2424
|
+
if n != 0 and n != current and not is_port_free(n):
|
|
2425
|
+
_show_message("Port In Use", f"Port {n} is already in use.")
|
|
2426
|
+
invalid = True
|
|
2427
|
+
break
|
|
2428
|
+
port_ints[key] = n
|
|
2429
|
+
if invalid:
|
|
2430
|
+
continue
|
|
2431
|
+
|
|
2432
|
+
new_logo = logo_styles[result["logo_style"]] if 0 <= result["logo_style"] < len(logo_styles) else saved_logo
|
|
2433
|
+
|
|
2434
|
+
self.manager.update_app_config(
|
|
2435
|
+
stop_on_quit=result["stop_on_quit"],
|
|
2436
|
+
ephemeral_ports=result["ephemeral_ports"],
|
|
2437
|
+
restore_shares_on_start=result["restore_shares"],
|
|
2438
|
+
tray_show_bandwidth=result["show_bandwidth"],
|
|
2439
|
+
logo_style=new_logo,
|
|
2440
|
+
)
|
|
2441
|
+
# No refresh_bandwidth_title() here: preview already left the
|
|
2442
|
+
# menu bar in its final state. Re-running update_title rebuilds
|
|
2443
|
+
# the subview frame and momentarily nudges the icon size.
|
|
2444
|
+
self.manager.update_config(
|
|
2445
|
+
rpc_server_port=port_ints["rpc_port"],
|
|
2446
|
+
status_server_port=port_ints["sse_port"],
|
|
2447
|
+
pac_server_port=port_ints["pac_port"],
|
|
2448
|
+
)
|
|
2449
|
+
# Apply Launch at Login off the main thread — osascript may block
|
|
2450
|
+
# for several seconds the first time (TCC prompt).
|
|
2451
|
+
desired_login = bool(result["launch_at_login"])
|
|
2452
|
+
self._launch_at_login_cached = desired_login # optimistic update
|
|
2453
|
+
threading.Thread(
|
|
2454
|
+
target=lambda: _set_launch_at_login(desired_login),
|
|
2455
|
+
daemon=True,
|
|
2456
|
+
name="susops-loginitem-apply",
|
|
2457
|
+
).start()
|
|
2458
|
+
self.update_icon(self.state)
|
|
2459
|
+
return
|
|
2460
|
+
|
|
2461
|
+
# ------------------------------------------------------------------ #
|
|
2462
|
+
# Add dialogs
|
|
2463
|
+
# ------------------------------------------------------------------ #
|
|
2464
|
+
|
|
2465
|
+
def _show_add_connection_dialog(self) -> None:
|
|
2466
|
+
try:
|
|
2467
|
+
ssh_hosts = get_ssh_hosts()
|
|
2468
|
+
except Exception:
|
|
2469
|
+
ssh_hosts = []
|
|
2470
|
+
|
|
2471
|
+
fields = [
|
|
2472
|
+
{"key": "tag", "label": "Connection Tag *:", "kind": "text",
|
|
2473
|
+
"hint": "e.g. work"},
|
|
2474
|
+
{"key": "host", "label": "SSH Host *:", "kind": "combo",
|
|
2475
|
+
"options": ssh_hosts, "hint": "hostname, IP, or SSH alias"},
|
|
2476
|
+
{"key": "port", "label": "SOCKS Proxy Port (optional):", "kind": "text",
|
|
2477
|
+
"hint": "auto if blank"},
|
|
2478
|
+
]
|
|
2479
|
+
while True:
|
|
2480
|
+
result = _show_form_dialog("Add Connection", fields, ok_title="Add", cancel_title="Cancel")
|
|
2481
|
+
if result is None:
|
|
2482
|
+
return
|
|
2483
|
+
tag = result["tag"]
|
|
2484
|
+
host = result["host"]
|
|
2485
|
+
port_text = result["port"]
|
|
2486
|
+
if not tag:
|
|
2487
|
+
_show_message("Missing Field", "Connection Tag must not be empty.")
|
|
2488
|
+
continue
|
|
2489
|
+
if not host:
|
|
2490
|
+
_show_message("Missing Field", "SSH Host must not be empty.")
|
|
2491
|
+
continue
|
|
2492
|
+
port_int = 0
|
|
2493
|
+
if port_text:
|
|
2494
|
+
if not port_text.isdigit() or not validate_port(int(port_text)):
|
|
2495
|
+
_show_message("Invalid Port", "SOCKS Proxy Port must be between 1 and 65535.")
|
|
2496
|
+
continue
|
|
2497
|
+
port_int = int(port_text)
|
|
2498
|
+
if not is_port_free(port_int):
|
|
2499
|
+
_show_message("Port In Use", f"Port {port_int} is already in use.")
|
|
2500
|
+
continue
|
|
2501
|
+
self.do_add_connection(tag, host, port_int)
|
|
2502
|
+
return
|
|
2503
|
+
|
|
2504
|
+
def _show_add_host_dialog(self) -> None:
|
|
2505
|
+
cfg = self.manager.list_config()
|
|
2506
|
+
tags = [c.tag for c in cfg.connections]
|
|
2507
|
+
if not tags:
|
|
2508
|
+
_show_message("No Connections", "Add a connection first.")
|
|
2509
|
+
return
|
|
2510
|
+
fields = [
|
|
2511
|
+
{"key": "conn", "label": "Connection *:", "kind": "popup", "options": tags, "default": tags[0]},
|
|
2512
|
+
{"key": "host", "label": "Host / IP / CIDR *:", "kind": "text",
|
|
2513
|
+
"hint": "domain, IP address, or CIDR"},
|
|
2514
|
+
{"key": "_info", "label": "", "kind": "info",
|
|
2515
|
+
"default": "Host can be:\n • Domain (subdomains & wildcards supported)\n • IP address (CIDR notation supported)"},
|
|
2516
|
+
]
|
|
2517
|
+
while True:
|
|
2518
|
+
result = _show_form_dialog("Add Domain / IP / CIDR", fields, ok_title="Add", cancel_title="Cancel")
|
|
2519
|
+
if result is None:
|
|
2520
|
+
return
|
|
2521
|
+
host = result["host"]
|
|
2522
|
+
conn_tag = result["conn"]
|
|
2523
|
+
if not host:
|
|
2524
|
+
_show_message("Missing Field", "Host must not be empty.")
|
|
2525
|
+
continue
|
|
2526
|
+
if not conn_tag:
|
|
2527
|
+
_show_message("Missing Field", "Select a connection.")
|
|
2528
|
+
continue
|
|
2529
|
+
self.do_add_pac_host(host, conn_tag=conn_tag)
|
|
2530
|
+
return
|
|
2531
|
+
|
|
2532
|
+
def _show_add_forward_dialog(self, *, remote: bool) -> None:
|
|
2533
|
+
cfg = self.manager.list_config()
|
|
2534
|
+
tags = [c.tag for c in cfg.connections]
|
|
2535
|
+
if not tags:
|
|
2536
|
+
_show_message("No Connections", "Add a connection first.")
|
|
2537
|
+
return
|
|
2538
|
+
|
|
2539
|
+
title = "Add Remote Forward" if remote else "Add Local Forward"
|
|
2540
|
+
src_label = "Forward Remote Port *:" if remote else "Forward Local Port *:"
|
|
2541
|
+
dst_label = "To Local Port *:" if remote else "To Remote Port *:"
|
|
2542
|
+
src_bind_label = "Remote Bind (optional):" if remote else "Local Bind (optional):"
|
|
2543
|
+
dst_bind_label = "Local Bind (optional):" if remote else "Remote Bind (optional):"
|
|
2544
|
+
|
|
2545
|
+
fields = [
|
|
2546
|
+
{"key": "conn", "label": "Connection *:", "kind": "popup", "options": tags, "default": tags[0]},
|
|
2547
|
+
{"key": "tag", "label": "Tag (optional):", "kind": "text", "hint": "optional label"},
|
|
2548
|
+
{"key": "src", "label": src_label, "kind": "text", "hint": "e.g. 8080"},
|
|
2549
|
+
{"key": "dst", "label": dst_label, "kind": "text", "hint": "e.g. 80"},
|
|
2550
|
+
{"key": "src_addr", "label": src_bind_label, "kind": "combo",
|
|
2551
|
+
"options": BIND_ADDRESSES, "default": "localhost"},
|
|
2552
|
+
{"key": "dst_addr", "label": dst_bind_label, "kind": "combo",
|
|
2553
|
+
"options": BIND_ADDRESSES, "default": "localhost"},
|
|
2554
|
+
{"key": "tcp", "label": "TCP:", "kind": "switch", "default": True},
|
|
2555
|
+
{"key": "udp", "label": "UDP:", "kind": "switch", "default": False},
|
|
2556
|
+
]
|
|
2557
|
+
|
|
2558
|
+
while True:
|
|
2559
|
+
result = _show_form_dialog(title, fields, ok_title="Add", cancel_title="Cancel")
|
|
2560
|
+
if result is None:
|
|
2561
|
+
return
|
|
2562
|
+
|
|
2563
|
+
conn_tag = result["conn"]
|
|
2564
|
+
tag = result["tag"]
|
|
2565
|
+
src = result["src"]
|
|
2566
|
+
dst = result["dst"]
|
|
2567
|
+
src_addr = result["src_addr"] or "localhost"
|
|
2568
|
+
dst_addr = result["dst_addr"] or "localhost"
|
|
2569
|
+
tcp = result["tcp"]
|
|
2570
|
+
udp = result["udp"]
|
|
2571
|
+
|
|
2572
|
+
if not conn_tag:
|
|
2573
|
+
_show_message("No Connection", "Add a connection first.")
|
|
2574
|
+
continue
|
|
2575
|
+
if not tcp and not udp:
|
|
2576
|
+
_show_message("Protocol Required", "Select at least one protocol (TCP or UDP).")
|
|
2577
|
+
continue
|
|
2578
|
+
if not src.isdigit() or not validate_port(int(src)):
|
|
2579
|
+
_show_message("Invalid Port", f"{src_label.rstrip(':*').strip()} must be 1–65535.")
|
|
2580
|
+
continue
|
|
2581
|
+
if not dst.isdigit() or not validate_port(int(dst)):
|
|
2582
|
+
_show_message("Invalid Port", f"{dst_label.rstrip(':*').strip()} must be 1–65535.")
|
|
2583
|
+
continue
|
|
2584
|
+
if not remote and not is_port_free(int(src)):
|
|
2585
|
+
_show_message("Port In Use", f"Local port {src} is already in use.")
|
|
2586
|
+
continue
|
|
2587
|
+
|
|
2588
|
+
fw = PortForward(
|
|
2589
|
+
src_addr=src_addr,
|
|
2590
|
+
src_port=int(src),
|
|
2591
|
+
dst_addr=dst_addr,
|
|
2592
|
+
dst_port=int(dst),
|
|
2593
|
+
tag=tag or None,
|
|
2594
|
+
tcp=tcp,
|
|
2595
|
+
udp=udp,
|
|
2596
|
+
)
|
|
2597
|
+
if remote:
|
|
2598
|
+
self.do_add_remote_forward(conn_tag, fw)
|
|
2599
|
+
else:
|
|
2600
|
+
self.do_add_local_forward(conn_tag, fw)
|
|
2601
|
+
return
|
|
2602
|
+
|
|
2603
|
+
# ------------------------------------------------------------------ #
|
|
2604
|
+
# Remove dialogs
|
|
2605
|
+
# ------------------------------------------------------------------ #
|
|
2606
|
+
|
|
2607
|
+
def _show_rm_connection_dialog(self) -> None:
|
|
2608
|
+
tags = [c.tag for c in self.manager.list_config().connections]
|
|
2609
|
+
selected = _show_pick_dialog("Remove Connection", "Connection:", tags, ok_title="Remove")
|
|
2610
|
+
if selected:
|
|
2611
|
+
self.do_remove_connection(selected)
|
|
2612
|
+
|
|
2613
|
+
def _show_rm_host_dialog(self) -> None:
|
|
2614
|
+
cfg = self.manager.list_config()
|
|
2615
|
+
hosts = [h for c in cfg.connections for h in c.pac_hosts]
|
|
2616
|
+
selected = _show_pick_dialog("Remove Domain / IP / CIDR", "Host:", hosts, ok_title="Remove")
|
|
2617
|
+
if selected:
|
|
2618
|
+
self.do_remove_pac_host(selected)
|
|
2619
|
+
|
|
2620
|
+
def _show_rm_local_dialog(self) -> None:
|
|
2621
|
+
cfg = self.manager.list_config()
|
|
2622
|
+
items: list[str] = []
|
|
2623
|
+
port_map: dict[str, int] = {}
|
|
2624
|
+
for c in cfg.connections:
|
|
2625
|
+
for fw in c.forwards.local:
|
|
2626
|
+
label = f"[{c.tag}] {fw.src_port}→{fw.dst_addr}:{fw.dst_port}"
|
|
2627
|
+
items.append(label)
|
|
2628
|
+
port_map[label] = fw.src_port
|
|
2629
|
+
selected = _show_pick_dialog("Remove Local Forward", "Local Forward:", items, ok_title="Remove")
|
|
2630
|
+
if selected and selected in port_map:
|
|
2631
|
+
self.do_remove_local_forward(port_map[selected])
|
|
2632
|
+
|
|
2633
|
+
def _show_rm_remote_dialog(self) -> None:
|
|
2634
|
+
cfg = self.manager.list_config()
|
|
2635
|
+
items: list[str] = []
|
|
2636
|
+
port_map: dict[str, int] = {}
|
|
2637
|
+
for c in cfg.connections:
|
|
2638
|
+
for fw in c.forwards.remote:
|
|
2639
|
+
label = f"[{c.tag}] {fw.src_port}→{fw.dst_addr}:{fw.dst_port}"
|
|
2640
|
+
items.append(label)
|
|
2641
|
+
port_map[label] = fw.src_port
|
|
2642
|
+
selected = _show_pick_dialog("Remove Remote Forward", "Remote Forward:", items, ok_title="Remove")
|
|
2643
|
+
if selected and selected in port_map:
|
|
2644
|
+
self.do_remove_remote_forward(port_map[selected])
|
|
2645
|
+
|
|
2646
|
+
# ------------------------------------------------------------------ #
|
|
2647
|
+
# Manage / toggle dialogs
|
|
2648
|
+
# ------------------------------------------------------------------ #
|
|
2649
|
+
|
|
2650
|
+
def _show_toggle_connection_dialog(self) -> None:
|
|
2651
|
+
cfg = self.manager.list_config()
|
|
2652
|
+
items = [f"[{'✓' if c.enabled else '✗'}] {c.tag}" for c in cfg.connections]
|
|
2653
|
+
selected = _show_pick_dialog("Toggle Connection Enabled", "Connection:", items, ok_title="Toggle")
|
|
2654
|
+
if selected:
|
|
2655
|
+
tag = selected.split("] ", 1)[-1]
|
|
2656
|
+
self.do_toggle_connection_enabled(tag)
|
|
2657
|
+
|
|
2658
|
+
def _show_toggle_domain_dialog(self) -> None:
|
|
2659
|
+
cfg = self.manager.list_config()
|
|
2660
|
+
items: list[str] = []
|
|
2661
|
+
for c in cfg.connections:
|
|
2662
|
+
for h in c.pac_hosts:
|
|
2663
|
+
enabled = h not in c.pac_hosts_disabled
|
|
2664
|
+
items.append(f"[{'✓' if enabled else '✗'}] {h}")
|
|
2665
|
+
selected = _show_pick_dialog("Toggle Domain Enabled", "Domain:", items, ok_title="Toggle")
|
|
2666
|
+
if selected:
|
|
2667
|
+
host = selected.split("] ", 1)[-1]
|
|
2668
|
+
self.do_toggle_pac_host_enabled(host)
|
|
2669
|
+
|
|
2670
|
+
def _show_toggle_forward_dialog(self) -> None:
|
|
2671
|
+
cfg = self.manager.list_config()
|
|
2672
|
+
items: list[str] = []
|
|
2673
|
+
for c in cfg.connections:
|
|
2674
|
+
for fw in c.forwards.local:
|
|
2675
|
+
state = "✓" if fw.enabled else "✗"
|
|
2676
|
+
items.append(f"[{state}] [{c.tag}] local :{fw.src_port}→{fw.dst_addr}:{fw.dst_port}")
|
|
2677
|
+
for fw in c.forwards.remote:
|
|
2678
|
+
state = "✓" if fw.enabled else "✗"
|
|
2679
|
+
items.append(f"[{state}] [{c.tag}] remote :{fw.src_port}→{fw.dst_addr}:{fw.dst_port}")
|
|
2680
|
+
selected = _show_pick_dialog("Toggle Forward Enabled", "Forward:", items, ok_title="Toggle")
|
|
2681
|
+
if selected:
|
|
2682
|
+
m = re.search(r"\[([^\]]+)\] (local|remote) :(\d+)", selected)
|
|
2683
|
+
if m:
|
|
2684
|
+
conn_tag, direction, src_port = m.group(1), m.group(2), int(m.group(3))
|
|
2685
|
+
self.do_toggle_forward_enabled(conn_tag, src_port, direction)
|
|
2686
|
+
|
|
2687
|
+
def _show_start_connection_dialog(self) -> None:
|
|
2688
|
+
tags = [c.tag for c in self.manager.list_config().connections]
|
|
2689
|
+
selected = _show_pick_dialog("Start Connection", "Connection:", tags, ok_title="Start")
|
|
2690
|
+
if selected:
|
|
2691
|
+
self.do_start_connection(selected)
|
|
2692
|
+
|
|
2693
|
+
def _show_stop_connection_dialog(self) -> None:
|
|
2694
|
+
tags = [c.tag for c in self.manager.list_config().connections]
|
|
2695
|
+
selected = _show_pick_dialog("Stop Connection", "Connection:", tags, ok_title="Stop")
|
|
2696
|
+
if selected:
|
|
2697
|
+
self.do_stop_connection(selected)
|
|
2698
|
+
|
|
2699
|
+
def _show_restart_connection_dialog(self) -> None:
|
|
2700
|
+
tags = [c.tag for c in self.manager.list_config().connections]
|
|
2701
|
+
selected = _show_pick_dialog("Restart Connection", "Connection:", tags, ok_title="Restart")
|
|
2702
|
+
if selected:
|
|
2703
|
+
self.do_restart_connection(selected)
|
|
2704
|
+
|
|
2705
|
+
# ------------------------------------------------------------------ #
|
|
2706
|
+
# Test dialogs
|
|
2707
|
+
# ------------------------------------------------------------------ #
|
|
2708
|
+
|
|
2709
|
+
def _show_test_connection_dialog(self) -> None:
|
|
2710
|
+
tags = [c.tag for c in self.manager.list_config().connections]
|
|
2711
|
+
selected = _show_pick_dialog("Test Connection", "Connection:", tags, ok_title="Test")
|
|
2712
|
+
if selected:
|
|
2713
|
+
self.do_test_connection(selected)
|
|
2714
|
+
|
|
2715
|
+
def _show_test_domain_dialog(self) -> None:
|
|
2716
|
+
cfg = self.manager.list_config()
|
|
2717
|
+
items: list[str] = []
|
|
2718
|
+
for c in cfg.connections:
|
|
2719
|
+
for h in c.pac_hosts:
|
|
2720
|
+
items.append(f"[{c.tag}] {h}")
|
|
2721
|
+
selected = _show_pick_dialog("Test Domain", "Domain (via connection):", items, ok_title="Test")
|
|
2722
|
+
if selected:
|
|
2723
|
+
m = re.match(r"\[([^\]]+)\] (.+)", selected)
|
|
2724
|
+
if m:
|
|
2725
|
+
self.do_test_domain(m.group(2), m.group(1))
|
|
2726
|
+
|
|
2727
|
+
def _show_test_forward_dialog(self) -> None:
|
|
2728
|
+
cfg = self.manager.list_config()
|
|
2729
|
+
items: list[str] = []
|
|
2730
|
+
for c in cfg.connections:
|
|
2731
|
+
for fw in c.forwards.local:
|
|
2732
|
+
items.append(f"[{c.tag}] local :{fw.src_port}→{fw.dst_addr}:{fw.dst_port}")
|
|
2733
|
+
for fw in c.forwards.remote:
|
|
2734
|
+
items.append(f"[{c.tag}] remote :{fw.src_port}→{fw.dst_addr}:{fw.dst_port}")
|
|
2735
|
+
selected = _show_pick_dialog("Test Forward", "Forward:", items, ok_title="Test")
|
|
2736
|
+
if selected:
|
|
2737
|
+
m = re.search(r"\[([^\]]+)\] (local|remote) :(\d+)", selected)
|
|
2738
|
+
if m:
|
|
2739
|
+
self.do_test_forward(m.group(1), int(m.group(3)), m.group(2))
|
|
2740
|
+
|
|
2741
|
+
# ------------------------------------------------------------------ #
|
|
2742
|
+
# File transfer dialogs
|
|
2743
|
+
# ------------------------------------------------------------------ #
|
|
2744
|
+
|
|
2745
|
+
def _show_share_file_dialog(self) -> None:
|
|
2746
|
+
tags = [c.tag for c in self.manager.list_config().connections]
|
|
2747
|
+
if not tags:
|
|
2748
|
+
_show_message("No Connections", "Add a connection first.")
|
|
2749
|
+
return
|
|
2750
|
+
|
|
2751
|
+
file_path = _pick_file()
|
|
2752
|
+
if not file_path:
|
|
2753
|
+
return
|
|
2754
|
+
|
|
2755
|
+
fields = [
|
|
2756
|
+
{"key": "conn", "label": "Connection *:", "kind": "popup", "options": tags, "default": tags[0]},
|
|
2757
|
+
{"key": "file", "label": "File:", "kind": "text", "default": file_path},
|
|
2758
|
+
{"key": "pw", "label": "Password (optional):", "kind": "text", "hint": "auto-generate if blank"},
|
|
2759
|
+
{"key": "port", "label": "Port:", "kind": "text", "default": "0", "hint": "0 = auto"},
|
|
2760
|
+
]
|
|
2761
|
+
while True:
|
|
2762
|
+
result = _show_form_dialog(
|
|
2763
|
+
"Share File",
|
|
2764
|
+
fields,
|
|
2765
|
+
ok_title="Share",
|
|
2766
|
+
cancel_title="Cancel",
|
|
2767
|
+
informative=f"Sharing: {Path(file_path).name}",
|
|
2768
|
+
)
|
|
2769
|
+
if result is None:
|
|
2770
|
+
return
|
|
2771
|
+
conn_tag = result["conn"]
|
|
2772
|
+
fp = result["file"] or file_path
|
|
2773
|
+
pw = (result["pw"] or "").strip() or None
|
|
2774
|
+
port_text = (result["port"] or "0").strip() or "0"
|
|
2775
|
+
if not conn_tag:
|
|
2776
|
+
_show_message("Missing Field", "Select a connection.")
|
|
2777
|
+
continue
|
|
2778
|
+
if not fp or not Path(fp).exists():
|
|
2779
|
+
_show_message("File Not Found", f"'{fp}' does not exist.")
|
|
2780
|
+
continue
|
|
2781
|
+
try:
|
|
2782
|
+
port_int = int(port_text)
|
|
2783
|
+
except ValueError:
|
|
2784
|
+
_show_message("Invalid Port", f"'{port_text}' is not a valid port number.")
|
|
2785
|
+
continue
|
|
2786
|
+
self.do_share(conn_tag, fp, password=pw, port=port_int)
|
|
2787
|
+
return
|
|
2788
|
+
|
|
2789
|
+
def _show_fetch_file_dialog(self) -> None:
|
|
2790
|
+
tags = [c.tag for c in self.manager.list_config().connections]
|
|
2791
|
+
if not tags:
|
|
2792
|
+
_show_message("No Connections", "Add a connection first.")
|
|
2793
|
+
return
|
|
2794
|
+
|
|
2795
|
+
fields = [
|
|
2796
|
+
{"key": "conn", "label": "Connection *:", "kind": "popup", "options": tags, "default": tags[0]},
|
|
2797
|
+
{"key": "port", "label": "Port *:", "kind": "text", "hint": "e.g. 52100"},
|
|
2798
|
+
{"key": "pw", "label": "Password *:", "kind": "text"},
|
|
2799
|
+
{"key": "out", "label": "Save to (optional):", "kind": "text",
|
|
2800
|
+
"hint": "blank = ~/Downloads/<filename>"},
|
|
2801
|
+
]
|
|
2802
|
+
while True:
|
|
2803
|
+
result = _show_form_dialog("Fetch File", fields, ok_title="Fetch", cancel_title="Cancel")
|
|
2804
|
+
if result is None:
|
|
2805
|
+
return
|
|
2806
|
+
conn_tag = result["conn"]
|
|
2807
|
+
port_text = (result["port"] or "").strip()
|
|
2808
|
+
pw = (result["pw"] or "").strip()
|
|
2809
|
+
outfile = (result["out"] or "").strip() or None
|
|
2810
|
+
if not conn_tag:
|
|
2811
|
+
_show_message("Missing Field", "Select a connection.")
|
|
2812
|
+
continue
|
|
2813
|
+
if not port_text or not pw:
|
|
2814
|
+
_show_message("Missing Field", "Port and password are required.")
|
|
2815
|
+
continue
|
|
2816
|
+
try:
|
|
2817
|
+
port_int = int(port_text)
|
|
2818
|
+
except ValueError:
|
|
2819
|
+
_show_message("Invalid Port", f"'{port_text}' is not a valid port number.")
|
|
2820
|
+
continue
|
|
2821
|
+
self.do_fetch(conn_tag, port_int, pw, outfile=outfile)
|
|
2822
|
+
return
|
|
2823
|
+
|
|
2824
|
+
def _make_share_info_handler(self, info):
|
|
2825
|
+
def handler(_sender):
|
|
2826
|
+
self._show_share_info_dialog(info)
|
|
2827
|
+
|
|
2828
|
+
return handler
|
|
2829
|
+
|
|
2830
|
+
def _show_share_info_dialog(self, info) -> None:
|
|
2831
|
+
name = Path(info.file_path).name
|
|
2832
|
+
state = "running" if info.running else "stopped"
|
|
2833
|
+
toggle_label = "Stop" if info.running else "Start"
|
|
2834
|
+
message = (
|
|
2835
|
+
f"File: {info.file_path}\n"
|
|
2836
|
+
f"Port: {info.port}\n"
|
|
2837
|
+
f"Password: {info.password}\n"
|
|
2838
|
+
f"Connection: {info.conn_tag or '—'}\n"
|
|
2839
|
+
f"State: {state}"
|
|
2840
|
+
)
|
|
2841
|
+
choice = _show_three_way(
|
|
2842
|
+
f"Share: {name}",
|
|
2843
|
+
message,
|
|
2844
|
+
primary=toggle_label,
|
|
2845
|
+
secondary="Delete",
|
|
2846
|
+
cancel="Close",
|
|
2847
|
+
)
|
|
2848
|
+
if choice == 1:
|
|
2849
|
+
if info.running:
|
|
2850
|
+
self.do_stop_share(info.port)
|
|
2851
|
+
else:
|
|
2852
|
+
self.do_share(info.conn_tag or "", info.file_path, info.password, info.port)
|
|
2853
|
+
self._refresh_share_submenu()
|
|
2854
|
+
elif choice == 2:
|
|
2855
|
+
self.do_delete_share(info.port)
|
|
2856
|
+
self._refresh_share_submenu()
|
|
2857
|
+
|
|
2858
|
+
# ------------------------------------------------------------------ #
|
|
2859
|
+
# Reset / About / Quit
|
|
2860
|
+
# ------------------------------------------------------------------ #
|
|
2861
|
+
|
|
2862
|
+
def _confirm_reset(self) -> None:
|
|
2863
|
+
if not _show_confirm(
|
|
2864
|
+
"Reset All?",
|
|
2865
|
+
"This will stop all tunnels and delete the workspace. This cannot be undone.",
|
|
2866
|
+
ok="Reset",
|
|
2867
|
+
cancel="Cancel",
|
|
2868
|
+
):
|
|
2869
|
+
return
|
|
2870
|
+
self.run_in_background(lambda: self.do_reset(), lambda _: None)
|
|
2871
|
+
|
|
2872
|
+
def _show_about_dialog(self) -> None:
|
|
2873
|
+
import susops
|
|
2874
|
+
_show_about_panel(susops.__version__)
|
|
2875
|
+
|
|
2876
|
+
def _on_quit(self, _sender) -> None:
|
|
2877
|
+
self.do_quit()
|
|
2878
|
+
self._rumps.quit_application()
|
|
2879
|
+
|
|
2880
|
+
# ------------------------------------------------------------------ #
|
|
2881
|
+
# SSE listener
|
|
2882
|
+
# ------------------------------------------------------------------ #
|
|
2883
|
+
|
|
2884
|
+
def _start_sse_listener(self) -> None:
|
|
2885
|
+
import time
|
|
2886
|
+
|
|
2887
|
+
def _listen():
|
|
2888
|
+
backoff = 1.0
|
|
2889
|
+
while True:
|
|
2890
|
+
status_url = self.manager.get_status_url()
|
|
2891
|
+
if not status_url:
|
|
2892
|
+
time.sleep(2.0)
|
|
2893
|
+
continue
|
|
2894
|
+
try:
|
|
2895
|
+
import urllib.request
|
|
2896
|
+
import susops as _susops_pkg
|
|
2897
|
+
req = urllib.request.Request(status_url, headers={
|
|
2898
|
+
"X-Susops-Client": "tray-mac",
|
|
2899
|
+
"X-Susops-Client-Version": _susops_pkg.__version__,
|
|
2900
|
+
"X-Susops-Pid": str(os.getpid()),
|
|
2901
|
+
# Tray only reacts to state + share events. Filtering
|
|
2902
|
+
# out `bandwidth` (high-frequency) and `forward`
|
|
2903
|
+
# spares the daemon some serialisation work and us
|
|
2904
|
+
# some wakeups for events we never act on.
|
|
2905
|
+
"X-Susops-Events": "state,share",
|
|
2906
|
+
})
|
|
2907
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
2908
|
+
backoff = 1.0
|
|
2909
|
+
buf = ""
|
|
2910
|
+
for raw in resp:
|
|
2911
|
+
line = raw.decode("utf-8", errors="replace")
|
|
2912
|
+
buf += line
|
|
2913
|
+
if buf.endswith("\n\n"):
|
|
2914
|
+
if "event: state" in buf:
|
|
2915
|
+
_on_main(self.do_poll)
|
|
2916
|
+
if "event: share" in buf:
|
|
2917
|
+
_on_main(self._refresh_share_submenu)
|
|
2918
|
+
buf = ""
|
|
2919
|
+
except Exception:
|
|
2920
|
+
time.sleep(backoff)
|
|
2921
|
+
# Cap at 5s — short enough that the user never sees more
|
|
2922
|
+
# than 5s of staleness, even when the daemon is bouncing.
|
|
2923
|
+
backoff = min(backoff * 2, 5.0)
|
|
2924
|
+
|
|
2925
|
+
threading.Thread(target=_listen, daemon=True, name="susops-sse-mac").start()
|
|
2926
|
+
|
|
2927
|
+
# ------------------------------------------------------------------ #
|
|
2928
|
+
# Run
|
|
2929
|
+
# ------------------------------------------------------------------ #
|
|
2930
|
+
|
|
2931
|
+
_BW_RATE_COL_WIDTH = 9 # widest expected rate, e.g. "99.9 MB/s"
|
|
2932
|
+
|
|
2933
|
+
def _clear_bw_views(self, button) -> None:
|
|
2934
|
+
iv = getattr(self, "_bw_icon_view", None)
|
|
2935
|
+
if iv is not None:
|
|
2936
|
+
iv.removeFromSuperview()
|
|
2937
|
+
self._bw_icon_view = None
|
|
2938
|
+
tf = getattr(self, "_bw_text_view", None)
|
|
2939
|
+
if tf is not None:
|
|
2940
|
+
tf.removeFromSuperview()
|
|
2941
|
+
self._bw_text_view = None
|
|
2942
|
+
try:
|
|
2943
|
+
self._app._nsapp.nsstatusitem.setLength_(-1.0) # NSVariableStatusItemLength
|
|
2944
|
+
except Exception:
|
|
2945
|
+
pass
|
|
2946
|
+
# Restore the button's icon via rumps's own path so it picks up the
|
|
2947
|
+
# exact NSImage sizing rumps applies at launch. Poke the internal
|
|
2948
|
+
# path cache first so the setter doesn't short-circuit on a value
|
|
2949
|
+
# that matches the last one we routed through it.
|
|
2950
|
+
icon_path = self._current_icon_path()
|
|
2951
|
+
if icon_path:
|
|
2952
|
+
try:
|
|
2953
|
+
self._app._icon = None
|
|
2954
|
+
except Exception:
|
|
2955
|
+
pass
|
|
2956
|
+
self._app.icon = icon_path
|
|
2957
|
+
|
|
2958
|
+
def update_title(self, rx_bps: float | None, tx_bps: float | None) -> None:
|
|
2959
|
+
try:
|
|
2960
|
+
status_item = self._app._nsapp.nsstatusitem
|
|
2961
|
+
button = status_item.button()
|
|
2962
|
+
except Exception:
|
|
2963
|
+
status_item, button = None, None
|
|
2964
|
+
|
|
2965
|
+
if rx_bps is None or tx_bps is None:
|
|
2966
|
+
self._clear_bw_views(button)
|
|
2967
|
+
self._app.title = ""
|
|
2968
|
+
return
|
|
2969
|
+
|
|
2970
|
+
up = self._format_rate(tx_bps).rjust(self._BW_RATE_COL_WIDTH)
|
|
2971
|
+
down = self._format_rate(rx_bps).rjust(self._BW_RATE_COL_WIDTH)
|
|
2972
|
+
text = f"{up}\n{down}"
|
|
2973
|
+
if button is None:
|
|
2974
|
+
self._app.title = text
|
|
2975
|
+
return
|
|
2976
|
+
|
|
2977
|
+
from AppKit import ( # type: ignore[import]
|
|
2978
|
+
NSAttributedString,
|
|
2979
|
+
NSColor,
|
|
2980
|
+
NSFont,
|
|
2981
|
+
NSFontAttributeName,
|
|
2982
|
+
NSFontWeightRegular,
|
|
2983
|
+
NSForegroundColorAttributeName,
|
|
2984
|
+
NSImageView,
|
|
2985
|
+
NSLineBreakByClipping,
|
|
2986
|
+
NSMutableParagraphStyle,
|
|
2987
|
+
NSParagraphStyleAttributeName,
|
|
2988
|
+
NSTextAlignmentRight,
|
|
2989
|
+
NSTextField,
|
|
2990
|
+
)
|
|
2991
|
+
from Foundation import NSMakeRect # type: ignore[import]
|
|
2992
|
+
|
|
2993
|
+
font = NSFont.systemFontOfSize_weight_(8, NSFontWeightRegular)
|
|
2994
|
+
para = NSMutableParagraphStyle.alloc().init()
|
|
2995
|
+
para.setLineSpacing_(0)
|
|
2996
|
+
para.setAlignment_(NSTextAlignmentRight)
|
|
2997
|
+
attrs = {
|
|
2998
|
+
NSFontAttributeName: font,
|
|
2999
|
+
NSParagraphStyleAttributeName: para,
|
|
3000
|
+
NSForegroundColorAttributeName: NSColor.labelColor(),
|
|
3001
|
+
}
|
|
3002
|
+
attr = NSAttributedString.alloc().initWithString_attributes_(text, attrs)
|
|
3003
|
+
# Lock text-view dimensions to the widest possible two-line block
|
|
3004
|
+
# so the frame stays the same even as digits / units fluctuate.
|
|
3005
|
+
max_text = (
|
|
3006
|
+
f"↑ {'99.9 MB/s'.rjust(self._BW_RATE_COL_WIDTH)}\n"
|
|
3007
|
+
f"↓ {'99.9 MB/s'.rjust(self._BW_RATE_COL_WIDTH)}"
|
|
3008
|
+
)
|
|
3009
|
+
max_attr = NSAttributedString.alloc().initWithString_attributes_(max_text, attrs)
|
|
3010
|
+
max_size = max_attr.size()
|
|
3011
|
+
fixed_w = max_size.width
|
|
3012
|
+
fixed_h = max_size.height
|
|
3013
|
+
|
|
3014
|
+
iv = getattr(self, "_bw_icon_view", None)
|
|
3015
|
+
if iv is None:
|
|
3016
|
+
iv = NSImageView.alloc().init()
|
|
3017
|
+
iv.setImageScaling_(0) # NSImageScaleProportionallyDown
|
|
3018
|
+
button.addSubview_(iv)
|
|
3019
|
+
self._bw_icon_view = iv
|
|
3020
|
+
|
|
3021
|
+
tf = getattr(self, "_bw_text_view", None)
|
|
3022
|
+
if tf is None:
|
|
3023
|
+
tf = NSTextField.alloc().init()
|
|
3024
|
+
tf.setBezeled_(False)
|
|
3025
|
+
tf.setDrawsBackground_(False)
|
|
3026
|
+
tf.setEditable_(False)
|
|
3027
|
+
tf.setSelectable_(False)
|
|
3028
|
+
tf.setBordered_(False)
|
|
3029
|
+
tf.setAlignment_(NSTextAlignmentRight)
|
|
3030
|
+
tf.cell().setLineBreakMode_(NSLineBreakByClipping)
|
|
3031
|
+
button.addSubview_(tf)
|
|
3032
|
+
self._bw_text_view = tf
|
|
3033
|
+
|
|
3034
|
+
# Load the current icon directly into NSImageView so we don't depend
|
|
3035
|
+
# on button.image() (which can be None when rumps short-circuits on
|
|
3036
|
+
# an unchanged path). NSImageView scales the native asset to fit
|
|
3037
|
+
# the view frame. Honour the last-previewed logo, not just the
|
|
3038
|
+
# saved one, so the Settings preview survives toggling Bandwidth.
|
|
3039
|
+
btn_h = button.frame().size.height
|
|
3040
|
+
# macOS draws menu-bar item icons at 20pt with a small inset on a
|
|
3041
|
+
# 22pt-tall button. Match that so the subview doesn't render the
|
|
3042
|
+
# icon noticeably larger than rumps's bare-button path.
|
|
3043
|
+
icon_box = 20.0
|
|
3044
|
+
icon_path = self._current_icon_path()
|
|
3045
|
+
if icon_path:
|
|
3046
|
+
from AppKit import NSImage as _NSImage # type: ignore[import]
|
|
3047
|
+
img = _NSImage.alloc().initByReferencingFile_(icon_path)
|
|
3048
|
+
if img is not None:
|
|
3049
|
+
iv.setImage_(img)
|
|
3050
|
+
button.setImage_(None)
|
|
3051
|
+
button.setAttributedTitle_(NSAttributedString.alloc().initWithString_(""))
|
|
3052
|
+
tf.setAttributedStringValue_(attr)
|
|
3053
|
+
|
|
3054
|
+
icon_w = icon_box
|
|
3055
|
+
icon_h = icon_box
|
|
3056
|
+
gap = 6.0
|
|
3057
|
+
right_pad = 4.0
|
|
3058
|
+
total_w = icon_w + gap + fixed_w + right_pad
|
|
3059
|
+
|
|
3060
|
+
if status_item is not None:
|
|
3061
|
+
status_item.setLength_(total_w)
|
|
3062
|
+
iv.setFrame_(NSMakeRect(0, (btn_h - icon_h) / 2.0, icon_w, icon_h))
|
|
3063
|
+
y = (btn_h - fixed_h) / 2.0
|
|
3064
|
+
x = icon_w + gap
|
|
3065
|
+
tf.setFrame_(NSMakeRect(x, y, fixed_w, fixed_h))
|
|
3066
|
+
|
|
3067
|
+
def _tick_bandwidth(self, _timer=None) -> None:
|
|
3068
|
+
self.refresh_bandwidth_title()
|
|
3069
|
+
|
|
3070
|
+
def _preview_bandwidth_visibility(self, checked: bool) -> None:
|
|
3071
|
+
"""Live-toggle the menu-bar bandwidth title from the Settings switch.
|
|
3072
|
+
Config isn't persisted until the user clicks Save; Cancel reverts via
|
|
3073
|
+
refresh_bandwidth_title() reading the unchanged saved value."""
|
|
3074
|
+
if checked:
|
|
3075
|
+
try:
|
|
3076
|
+
rx, tx = self.manager.get_bandwidth_global()
|
|
3077
|
+
except Exception:
|
|
3078
|
+
rx, tx = 0.0, 0.0
|
|
3079
|
+
self.update_title(rx, tx)
|
|
3080
|
+
else:
|
|
3081
|
+
self.update_title(None, None)
|
|
3082
|
+
|
|
3083
|
+
def run(self) -> None:
|
|
3084
|
+
# Initial state pull on startup; from then on the SSE listener drives
|
|
3085
|
+
# every refresh. No periodic polling fallback — SSE reconnects with
|
|
3086
|
+
# a small backoff cap on its own.
|
|
3087
|
+
self.do_poll()
|
|
3088
|
+
# rumps creates the NSStatusItem inside applicationDidFinishLaunching,
|
|
3089
|
+
# which only fires once _app.run() starts the runloop. Schedule the
|
|
3090
|
+
# bandwidth subview population for the first runloop tick so the
|
|
3091
|
+
# status item never paints in its "icon-only, default width" state
|
|
3092
|
+
# before we widen it.
|
|
3093
|
+
from Foundation import NSTimer # type: ignore[import]
|
|
3094
|
+
NSTimer.scheduledTimerWithTimeInterval_repeats_block_(
|
|
3095
|
+
0.0, False, lambda _t: self.refresh_bandwidth_title()
|
|
3096
|
+
)
|
|
3097
|
+
self._start_sse_listener()
|
|
3098
|
+
self._bw_timer = self._rumps.Timer(self._tick_bandwidth, 1)
|
|
3099
|
+
self._bw_timer.start()
|
|
3100
|
+
self._app.run()
|
|
3101
|
+
|
|
3102
|
+
|
|
3103
|
+
def main() -> None:
|
|
3104
|
+
app = SusOpsMacTray()
|
|
3105
|
+
app.run()
|