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/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()