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/linux.py ADDED
@@ -0,0 +1,1623 @@
1
+ """Linux tray app — GTK3 + AyatanaAppIndicator3.
2
+
3
+ Requires: python-gobject, gtk3, libayatana-appindicator (system packages).
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ import subprocess
9
+ import threading
10
+ from pathlib import Path
11
+ from typing import Callable
12
+
13
+ from susops.core.config import PortForward
14
+ from susops.core.ports import is_port_free, validate_port
15
+ from susops.core.types import ProcessState
16
+ from susops.tray.base import AbstractTrayApp, get_icon_path, get_ssh_hosts
17
+
18
+
19
+ def _is_dark_theme() -> bool:
20
+ """Return True when the desktop colour scheme is dark."""
21
+ try:
22
+ out = subprocess.run(
23
+ ["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"],
24
+ capture_output=True, text=True, timeout=2,
25
+ ).stdout.strip()
26
+ if "dark" in out.lower():
27
+ return True
28
+ out = subprocess.run(
29
+ ["gsettings", "get", "org.gnome.desktop.interface", "gtk-theme"],
30
+ capture_output=True, text=True, timeout=2,
31
+ ).stdout.strip()
32
+ return "dark" in out.lower()
33
+ except Exception:
34
+ return False
35
+
36
+
37
+ def _get_icon_path(state: ProcessState, logo_style: str = "colored_glasses") -> str | None:
38
+ """Return icon path for state, picking light/dark variant based on desktop theme."""
39
+ variant = "light" if _is_dark_theme() else "dark"
40
+ return get_icon_path(state, logo_style=logo_style, variant=variant)
41
+
42
+
43
+ def _is_valid_port(value: str, allow_zero: bool = False) -> bool:
44
+ if not value.isdigit():
45
+ return False
46
+ return validate_port(int(value), allow_zero=allow_zero)
47
+
48
+
49
+ def _polish_dialog(Gtk, dlg) -> None:
50
+ import warnings
51
+ with warnings.catch_warnings():
52
+ warnings.simplefilter("ignore", DeprecationWarning)
53
+ try:
54
+ aa = dlg.get_action_area()
55
+ aa.set_margin_start(16)
56
+ aa.set_margin_end(16)
57
+ aa.set_margin_top(8)
58
+ aa.set_margin_bottom(16)
59
+ aa.set_spacing(6)
60
+ aa.set_layout(Gtk.ButtonBoxStyle.EXPAND)
61
+ aa.set_homogeneous(True)
62
+ except Exception:
63
+ pass
64
+
65
+
66
+ def _alert(Gtk, parent, title: str, body: str = "", msg_type=None) -> None:
67
+ if msg_type is None:
68
+ msg_type = Gtk.MessageType.INFO
69
+ dlg = Gtk.MessageDialog(
70
+ transient_for=parent, modal=True,
71
+ message_type=msg_type,
72
+ buttons=Gtk.ButtonsType.CLOSE,
73
+ text=title,
74
+ )
75
+ if body:
76
+ dlg.format_secondary_text(body)
77
+ _polish_dialog(Gtk, dlg)
78
+ dlg.run()
79
+ dlg.destroy()
80
+
81
+
82
+ def _labeled_grid(Gtk, fields: list):
83
+ grid = Gtk.Grid(
84
+ column_spacing=12, row_spacing=8,
85
+ margin_start=16, margin_end=16,
86
+ margin_top=16, margin_bottom=16,
87
+ )
88
+ widgets = {}
89
+ for row, (key, label, widget) in enumerate(fields):
90
+ lbl = Gtk.Label(label=label, xalign=1.0)
91
+ lbl.set_width_chars(22)
92
+ grid.attach(lbl, 0, row, 1, 1)
93
+ widget.set_hexpand(True)
94
+ grid.attach(widget, 1, row, 1, 1)
95
+ widgets[key] = widget
96
+ return grid, widgets
97
+
98
+
99
+ class SusOpsLinuxTray(AbstractTrayApp):
100
+ """GTK3 system tray application."""
101
+
102
+ def __init__(self) -> None:
103
+ super().__init__()
104
+ import gi
105
+ gi.require_version("Gtk", "3.0")
106
+ gi.require_version("AyatanaAppIndicator3", "0.1")
107
+ from gi.repository import AyatanaAppIndicator3, Gtk, GLib
108
+ self._Gtk = Gtk
109
+ self._GLib = GLib
110
+ self._AyatanaAppIndicator3 = AyatanaAppIndicator3
111
+
112
+ # Apply dark-mode preference to dialogs immediately
113
+ self._apply_gtk_theme_preference()
114
+
115
+ self._root = Gtk.Window()
116
+ self._root.set_title("SusOps")
117
+
118
+ self._indicator = AyatanaAppIndicator3.Indicator.new(
119
+ "susops",
120
+ _get_icon_path(ProcessState.STOPPED) or "application-exit",
121
+ AyatanaAppIndicator3.IndicatorCategory.APPLICATION_STATUS,
122
+ )
123
+ self._indicator.set_status(AyatanaAppIndicator3.IndicatorStatus.ACTIVE)
124
+
125
+ self._menu = Gtk.Menu()
126
+ self._build_menu()
127
+ self._indicator.set_menu(self._menu)
128
+
129
+ # ------------------------------------------------------------------ #
130
+ # AbstractTrayApp implementation
131
+ # ------------------------------------------------------------------ #
132
+
133
+ def _apply_gtk_theme_preference(self) -> None:
134
+ """Tell GTK to use dark widgets when the desktop is in dark mode."""
135
+ try:
136
+ settings = self._Gtk.Settings.get_default()
137
+ if settings is not None:
138
+ settings.set_property("gtk-application-prefer-dark-theme", _is_dark_theme())
139
+ except Exception:
140
+ pass
141
+
142
+ def update_icon(self, state: ProcessState) -> None:
143
+ def _update():
144
+ self._apply_gtk_theme_preference()
145
+ logo_style = self.manager.app_config.logo_style.value.lower()
146
+ icon_path = _get_icon_path(state, logo_style)
147
+ if icon_path:
148
+ self._indicator.set_icon_full(icon_path, state.value)
149
+ return False
150
+
151
+ self._GLib.idle_add(_update)
152
+
153
+ def update_menu_sensitivity(self, state: ProcessState) -> None:
154
+ if state == getattr(self, "_last_menu_state", None):
155
+ return # nothing changed — skip idle_add entirely
156
+ self._last_menu_state = state
157
+ running = state == ProcessState.RUNNING
158
+ stopped = state == ProcessState.STOPPED
159
+
160
+ def _update():
161
+ if hasattr(self, "_item_start"):
162
+ self._item_start.set_sensitive(not running)
163
+ if hasattr(self, "_item_stop"):
164
+ self._item_stop.set_sensitive(not stopped)
165
+ if hasattr(self, "_item_restart"):
166
+ self._item_restart.set_sensitive(not stopped)
167
+ if hasattr(self, "_item_test_all"):
168
+ self._item_test_all.set_sensitive(not stopped)
169
+ self._rebuild_status_item(state)
170
+ return False
171
+
172
+ self._GLib.idle_add(_update)
173
+
174
+ def show_alert(self, title: str, msg: str) -> None:
175
+ def _show():
176
+ _alert(self._Gtk, self._root, title, msg)
177
+ return False
178
+
179
+ self._GLib.idle_add(_show)
180
+
181
+ def show_output_dialog(self, title: str, output: str) -> None:
182
+ def _show():
183
+ Gtk = self._Gtk
184
+ dlg = Gtk.Dialog(title=title, transient_for=self._root, modal=False)
185
+ dlg.add_button("Close", Gtk.ResponseType.CLOSE)
186
+ dlg.set_default_size(600, 380)
187
+ dlg.connect("response", lambda d, _r: d.destroy())
188
+ sw = Gtk.ScrolledWindow(
189
+ vexpand=True,
190
+ margin_start=12, margin_end=12,
191
+ margin_top=12, margin_bottom=6,
192
+ )
193
+ tv = Gtk.TextView(
194
+ editable=False,
195
+ monospace=True,
196
+ wrap_mode=Gtk.WrapMode.WORD_CHAR,
197
+ left_margin=4,
198
+ )
199
+ tv.get_buffer().set_text(output)
200
+ sw.add(tv)
201
+ dlg.get_content_area().add(sw)
202
+ _polish_dialog(Gtk, dlg)
203
+ dlg.show_all()
204
+ return False
205
+
206
+ self._GLib.idle_add(_show)
207
+
208
+ def show_live_logs(self, get_text, *, title: str = "Logs",
209
+ interval_ms: int = 1000) -> None:
210
+ """Non-modal, auto-refreshing log window that tails get_text().
211
+
212
+ Polls get_text() every interval_ms; if the text changed, replaces the
213
+ buffer and scrolls to the bottom. The user can keep using the tray
214
+ menu while it's open. Each line is colored via the shared log_style
215
+ rules to match the TUI's Logs tab.
216
+ """
217
+ Gtk = self._Gtk
218
+ GLib = self._GLib
219
+ from susops.core.log_style import style_log_line
220
+
221
+ def _show():
222
+ dlg = Gtk.Dialog(title=title, transient_for=self._root, modal=False)
223
+ dlg.set_default_size(912, 513)
224
+
225
+ # Scroll view fills the dialog edge-to-edge; dismissal is via the
226
+ # titlebar close (X) only — no explicit Close button.
227
+ sw = Gtk.ScrolledWindow(vexpand=True)
228
+ tv = Gtk.TextView(
229
+ editable=False,
230
+ monospace=True,
231
+ wrap_mode=Gtk.WrapMode.NONE,
232
+ left_margin=0,
233
+ )
234
+ buf = tv.get_buffer()
235
+
236
+ # Register color tags. GTK named foreground colors look reasonable
237
+ # against both light and dark themes.
238
+ tag_table = {
239
+ "tag": {"foreground": "#3DB6C9", "weight": 700},
240
+ "ok": {"foreground": "#2EA043"},
241
+ "warn": {"foreground": "#D29922"},
242
+ "err": {"foreground": "#F85149", "weight": 700},
243
+ "dim": {"foreground": "#7D8590"},
244
+ "info": {"foreground": "#58A6FF"},
245
+ }
246
+ tags: dict[str, object] = {}
247
+ for label, props in tag_table.items():
248
+ tags[label] = buf.create_tag(label, **props)
249
+
250
+ sw.add(tv)
251
+ dlg.get_content_area().add(sw)
252
+
253
+ state = {"closed": False, "last": None}
254
+
255
+ def _autoscroll():
256
+ end_iter = buf.get_end_iter()
257
+ tv.scroll_to_iter(end_iter, 0.0, False, 0.0, 1.0)
258
+
259
+ def _apply_colored(text: str) -> None:
260
+ buf.set_text("")
261
+ end = buf.get_end_iter()
262
+ for i, line in enumerate(text.split("\n")):
263
+ for chunk, label in style_log_line(line):
264
+ if not chunk:
265
+ continue
266
+ tag = tags.get(label) if label else None
267
+ if tag is None:
268
+ buf.insert(end, chunk)
269
+ else:
270
+ buf.insert_with_tags(end, chunk, tag)
271
+ if i < len(text.split("\n")) - 1:
272
+ buf.insert(end, "\n")
273
+
274
+ def _refresh():
275
+ if state["closed"]:
276
+ return False
277
+ try:
278
+ text = get_text()
279
+ except Exception as exc:
280
+ text = f"(log fetch failed: {exc})"
281
+ if text != state["last"]:
282
+ state["last"] = text
283
+ _apply_colored(text)
284
+ GLib.idle_add(_autoscroll)
285
+ return True # keep ticking
286
+
287
+ def _on_response(d, _r):
288
+ state["closed"] = True
289
+ d.destroy()
290
+
291
+ dlg.connect("response", _on_response)
292
+ dlg.show_all()
293
+ _refresh() # initial fill
294
+ GLib.timeout_add(interval_ms, _refresh)
295
+ return False
296
+
297
+ GLib.idle_add(_show)
298
+
299
+ def run_in_background(self, fn: Callable, callback: Callable | None = None) -> None:
300
+ def _worker():
301
+ result = fn()
302
+ if callback is not None:
303
+ self._GLib.idle_add(callback, result)
304
+
305
+ threading.Thread(target=_worker, daemon=True).start()
306
+
307
+ # ------------------------------------------------------------------ #
308
+ # Menu building
309
+ # ------------------------------------------------------------------ #
310
+
311
+ def _build_menu(self) -> None:
312
+ Gtk = self._Gtk
313
+ self._active_shares: list = []
314
+
315
+ # ── Status row ────────────────────────────────────────────────────
316
+ self._item_status = Gtk.MenuItem(label="SusOps: checking…")
317
+ self._item_status.set_sensitive(False)
318
+ self._menu.append(self._item_status)
319
+ self._menu.append(Gtk.SeparatorMenuItem())
320
+
321
+ # ── Settings ──────────────────────────────────────────────────────
322
+ i = Gtk.MenuItem(label="Settings…")
323
+ i.connect("activate", lambda _: self._on_settings())
324
+ self._menu.append(i)
325
+ self._menu.append(Gtk.SeparatorMenuItem())
326
+
327
+ # ── Add submenu ───────────────────────────────────────────────────
328
+ add_item = Gtk.MenuItem(label="Add")
329
+ add_sub = Gtk.Menu()
330
+ for label, cb in [
331
+ ("Add Connection", self._on_add_connection),
332
+ ("Add Domain / IP / CIDR", self._on_add_host),
333
+ ("Add Local Forward", self._on_add_local),
334
+ ("Add Remote Forward", self._on_add_remote),
335
+ ]:
336
+ si = Gtk.MenuItem(label=label)
337
+ si.connect("activate", cb)
338
+ add_sub.append(si)
339
+ add_item.set_submenu(add_sub)
340
+ self._menu.append(add_item)
341
+
342
+ # ── Remove submenu ────────────────────────────────────────────────
343
+ rm_item = Gtk.MenuItem(label="Remove")
344
+ rm_sub = Gtk.Menu()
345
+ for label, cb in [
346
+ ("Remove Connection", self._on_rm_connection),
347
+ ("Remove Domain / IP / CIDR", self._on_rm_host),
348
+ ("Remove Local Forward", self._on_rm_local),
349
+ ("Remove Remote Forward", self._on_rm_remote),
350
+ ]:
351
+ si = Gtk.MenuItem(label=label)
352
+ si.connect("activate", cb)
353
+ rm_sub.append(si)
354
+ rm_item.set_submenu(rm_sub)
355
+ self._menu.append(rm_item)
356
+
357
+ # ── Manage submenu ────────────────────────────────────────────────
358
+ manage_item = Gtk.MenuItem(label="Manage")
359
+ manage_sub = Gtk.Menu()
360
+ for label, cb in [
361
+ ("Toggle Connection Enabled…", self._on_toggle_connection),
362
+ ("Toggle Domain Enabled…", self._on_toggle_domain),
363
+ ("Toggle Forward Enabled…", self._on_toggle_forward),
364
+ None, # separator
365
+ ("Start Connection…", self._on_start_connection),
366
+ ("Stop Connection…", self._on_stop_connection),
367
+ ("Restart Connection…", self._on_restart_connection),
368
+ ]:
369
+ if label is None:
370
+ manage_sub.append(Gtk.SeparatorMenuItem())
371
+ else:
372
+ si = Gtk.MenuItem(label=label)
373
+ si.connect("activate", cb)
374
+ manage_sub.append(si)
375
+ manage_item.set_submenu(manage_sub)
376
+ self._menu.append(manage_item)
377
+
378
+ # ── Open Config ───────────────────────────────────────────────────
379
+ i = Gtk.MenuItem(label="Open Config File")
380
+ i.connect("activate", lambda _: self.do_open_config_file())
381
+ self._menu.append(i)
382
+ self._menu.append(Gtk.SeparatorMenuItem())
383
+
384
+ # ── Proxy controls ────────────────────────────────────────────────
385
+ self._item_start = Gtk.MenuItem(label="Start Proxy")
386
+ self._item_start.connect("activate", lambda _: self.do_start())
387
+ self._menu.append(self._item_start)
388
+
389
+ self._item_stop = Gtk.MenuItem(label="Stop Proxy")
390
+ self._item_stop.connect("activate", lambda _: self.do_stop())
391
+ self._menu.append(self._item_stop)
392
+
393
+ self._item_restart = Gtk.MenuItem(label="Restart Proxy")
394
+ self._item_restart.connect("activate", lambda _: self.do_restart())
395
+ self._menu.append(self._item_restart)
396
+ self._menu.append(Gtk.SeparatorMenuItem())
397
+
398
+ # ── Test submenu ──────────────────────────────────────────────────
399
+ test_item = Gtk.MenuItem(label="Test")
400
+ test_sub = Gtk.Menu()
401
+ ti_conn = Gtk.MenuItem(label="Test Connection…")
402
+ ti_conn.connect("activate", self._on_test_connection)
403
+ test_sub.append(ti_conn)
404
+ ti_domain = Gtk.MenuItem(label="Test Domain…")
405
+ ti_domain.connect("activate", self._on_test_domain)
406
+ test_sub.append(ti_domain)
407
+ ti_fwd = Gtk.MenuItem(label="Test Forward…")
408
+ ti_fwd.connect("activate", self._on_test_forward)
409
+ test_sub.append(ti_fwd)
410
+ test_sub.append(Gtk.SeparatorMenuItem())
411
+ self._item_test_all = Gtk.MenuItem(label="Test All PAC Hosts")
412
+ self._item_test_all.connect("activate", lambda _: self.do_test())
413
+ test_sub.append(self._item_test_all)
414
+ test_item.set_submenu(test_sub)
415
+ self._menu.append(test_item)
416
+
417
+ # ── Show status ───────────────────────────────────────────────────
418
+ i = Gtk.MenuItem(label="Show Status")
419
+ i.connect("activate", lambda _: self.do_status())
420
+ self._menu.append(i)
421
+
422
+ # ── Show logs ─────────────────────────────────────────────────────
423
+ i = Gtk.MenuItem(label="Show Logs")
424
+ i.connect("activate", lambda _: self.do_logs())
425
+ self._menu.append(i)
426
+
427
+ # ── Launch Browser ────────────────────────────────────────────────
428
+ self._browser_item = Gtk.MenuItem(label="Launch Browser")
429
+ self._menu.append(self._browser_item)
430
+ self._rebuild_browser_submenu()
431
+ self._menu.append(Gtk.SeparatorMenuItem())
432
+
433
+ # ── File Transfer submenu ─────────────────────────────────────────
434
+ ft_item = Gtk.MenuItem(label="File Transfer")
435
+ self._ft_sub = Gtk.Menu()
436
+ si = Gtk.MenuItem(label="Share File…")
437
+ si.connect("activate", lambda _: self._on_share_file())
438
+ self._ft_sub.append(si)
439
+ si2 = Gtk.MenuItem(label="Fetch File…")
440
+ si2.connect("activate", lambda _: self._on_fetch_file())
441
+ self._ft_sub.append(si2)
442
+ self._ft_sep = Gtk.SeparatorMenuItem()
443
+ self._ft_sub.append(self._ft_sep)
444
+ ft_item.set_submenu(self._ft_sub)
445
+ self._menu.append(ft_item)
446
+ self._menu.append(Gtk.SeparatorMenuItem())
447
+
448
+ # ── Reset All ─────────────────────────────────────────────────────
449
+ i = Gtk.MenuItem(label="Reset All")
450
+ i.connect("activate", lambda _: self._on_reset())
451
+ self._menu.append(i)
452
+ self._menu.append(Gtk.SeparatorMenuItem())
453
+
454
+ # ── About / Quit ──────────────────────────────────────────────────
455
+ i = Gtk.MenuItem(label="About SusOps")
456
+ i.connect("activate", lambda _: self._on_about())
457
+ self._menu.append(i)
458
+
459
+ i = Gtk.MenuItem(label="Quit")
460
+ i.connect("activate", self._on_quit)
461
+ self._menu.append(i)
462
+
463
+ self._menu.show_all()
464
+
465
+ def _rebuild_browser_submenu(self) -> None:
466
+ Gtk = self._Gtk
467
+ browser_sub = Gtk.Menu()
468
+
469
+ # Detection delegated to susops.core.browsers — same table used by
470
+ # the macOS tray and the TUI so a new browser only needs registering
471
+ # in one place.
472
+ from susops.core.browsers import detect_browsers
473
+ found = [(b.name, b.launch_cmd[0], b.is_chromium) for b in detect_browsers()]
474
+
475
+ if not found:
476
+ ni = Gtk.MenuItem(label="No browsers found")
477
+ ni.set_sensitive(False)
478
+ browser_sub.append(ni)
479
+ else:
480
+ for name, exe, chromium in found:
481
+ parent = Gtk.MenuItem(label=name)
482
+ sub = Gtk.Menu()
483
+ li = Gtk.MenuItem(label=f"Launch {name}")
484
+ if chromium:
485
+ li.connect("activate", self._make_chromium_launch(exe))
486
+ else:
487
+ li.connect("activate", self._make_firefox_launch(exe))
488
+ sub.append(li)
489
+ if chromium:
490
+ si = Gtk.MenuItem(label=f"Open {name} Proxy Settings")
491
+ si.connect("activate", self._make_chromium_settings(exe))
492
+ sub.append(si)
493
+ parent.set_submenu(sub)
494
+ browser_sub.append(parent)
495
+
496
+ browser_sub.show_all()
497
+ self._browser_item.set_submenu(browser_sub)
498
+
499
+ def _make_chromium_launch(self, exe: str):
500
+ from susops.core.browsers import Browser, launch_with_pac
501
+ browser = Browser(name=exe, launch_cmd=[exe], is_chromium=True)
502
+
503
+ def handler(_item):
504
+ pac_url = self.manager.get_pac_url()
505
+ if not pac_url:
506
+ self._GLib.idle_add(
507
+ lambda: _alert(self._Gtk, self._root, "Proxy Not Running",
508
+ "Start the proxy first so the PAC port is known.")
509
+ )
510
+ return
511
+ try:
512
+ launch_with_pac(browser, pac_url)
513
+ except Exception as exc:
514
+ self.show_alert("Launch Failed", str(exc))
515
+
516
+ return handler
517
+
518
+ def _make_chromium_settings(self, exe: str):
519
+ from susops.core.browsers import Browser, open_proxy_settings
520
+ browser = Browser(name=exe, launch_cmd=[exe], is_chromium=True)
521
+
522
+ def handler(_item):
523
+ try:
524
+ open_proxy_settings(browser)
525
+ except Exception as exc:
526
+ self.show_alert("Launch Failed", str(exc))
527
+
528
+ return handler
529
+
530
+ def _make_firefox_launch(self, exe: str):
531
+ from susops.core.browsers import Browser, launch_with_pac
532
+ browser = Browser(name=exe, launch_cmd=[exe], is_chromium=False)
533
+
534
+ def handler(_item):
535
+ pac_url = self.manager.get_pac_url()
536
+ if not pac_url:
537
+ self.show_alert("Proxy Not Running", "Start the proxy first.")
538
+ return
539
+ profile_dir = self.manager.workspace / "firefox_profile"
540
+ try:
541
+ launch_with_pac(browser, pac_url, profile_dir=profile_dir)
542
+ except Exception as exc:
543
+ self.show_alert("Launch Failed", str(exc))
544
+
545
+ return handler
546
+
547
+ def _rebuild_status_item(self, state: ProcessState) -> None:
548
+ dot = {
549
+ ProcessState.RUNNING: "🟢",
550
+ ProcessState.STOPPED_PARTIALLY: "🟠",
551
+ ProcessState.STOPPED: "⚫",
552
+ ProcessState.ERROR: "🔴",
553
+ ProcessState.INITIAL: "⚫",
554
+ }.get(state, "⚫")
555
+ self._item_status.set_label(f"{dot} SusOps: {state.value}")
556
+
557
+ def _refresh_share_submenu(self) -> bool:
558
+ """Rebuild the dynamic share items in the File Transfer submenu only when changed."""
559
+ from pathlib import Path as _Path
560
+ Gtk = self._Gtk
561
+ new_shares = self.manager.list_shares()
562
+
563
+ def _share_key(info):
564
+ return (info.port, info.running, info.file_path)
565
+
566
+ old_keys = [_share_key(s) for s in self._active_shares]
567
+ new_keys = [_share_key(s) for s in new_shares]
568
+ if old_keys == new_keys:
569
+ return False # nothing changed — don't touch the menu
570
+
571
+ self._active_shares = new_shares
572
+
573
+ # Remove old dynamic items (everything after the separator)
574
+ sep_reached = False
575
+ for child in list(self._ft_sub.get_children()):
576
+ if sep_reached:
577
+ self._ft_sub.remove(child)
578
+ if child is self._ft_sep:
579
+ sep_reached = True
580
+
581
+ self._ft_sep.set_visible(bool(self._active_shares))
582
+
583
+ for info in self._active_shares:
584
+ name = _Path(info.file_path).name
585
+ dot = "●" if info.running else "○"
586
+ label = f"{dot} {name} ({info.port})"
587
+ item = Gtk.MenuItem(label=label)
588
+ item.connect("activate", self._make_share_info_handler(info))
589
+ self._ft_sub.append(item)
590
+
591
+ self._ft_sub.show_all()
592
+ return False
593
+
594
+ def do_poll(self) -> None:
595
+ super().do_poll()
596
+ self._GLib.idle_add(self._refresh_share_submenu)
597
+
598
+ # ------------------------------------------------------------------ #
599
+ # Dialog handlers
600
+ # ------------------------------------------------------------------ #
601
+
602
+ def _on_settings(self) -> None:
603
+ self._GLib.idle_add(self._show_settings_dialog)
604
+
605
+ def _show_settings_dialog(self) -> bool:
606
+ from susops.core.types import LogoStyle
607
+ Gtk = self._Gtk
608
+ ac = self.manager.app_config
609
+ dlg = Gtk.Dialog(title="Settings", transient_for=self._root, modal=True)
610
+ dlg.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "_Save", Gtk.ResponseType.OK)
611
+ dlg.set_default_response(Gtk.ResponseType.OK)
612
+ dlg.set_default_size(360, -1)
613
+
614
+ grid = Gtk.Grid(column_spacing=12, row_spacing=10,
615
+ margin_start=16, margin_end=16,
616
+ margin_top=16, margin_bottom=16)
617
+ dlg.get_content_area().add(grid)
618
+
619
+ row = 0
620
+
621
+ # Launch at Login
622
+ _AUTOSTART_FILE = Path.home() / ".config" / "autostart" / "org.susops.App.desktop"
623
+ lbl = Gtk.Label(label="Launch at Login:", xalign=1.0)
624
+ lbl.set_width_chars(24)
625
+ grid.attach(lbl, 0, row, 1, 1)
626
+ sw_login = Gtk.Switch(halign=Gtk.Align.START)
627
+ sw_login.set_active(_AUTOSTART_FILE.exists())
628
+ grid.attach(sw_login, 1, row, 1, 1)
629
+ row += 1
630
+
631
+ # Stop Proxy On Quit
632
+ lbl = Gtk.Label(label="Stop Proxy On Quit:", xalign=1.0)
633
+ lbl.set_width_chars(24)
634
+ grid.attach(lbl, 0, row, 1, 1)
635
+ sw_stop = Gtk.Switch(halign=Gtk.Align.START)
636
+ sw_stop.set_active(ac.stop_on_quit)
637
+ grid.attach(sw_stop, 1, row, 1, 1)
638
+ row += 1
639
+
640
+ # Random SSH Ports On Start
641
+ lbl = Gtk.Label(label="Random SSH Ports On Start:", xalign=1.0)
642
+ lbl.set_width_chars(24)
643
+ grid.attach(lbl, 0, row, 1, 1)
644
+ sw_eph = Gtk.Switch(halign=Gtk.Align.START)
645
+ sw_eph.set_active(ac.ephemeral_ports)
646
+ grid.attach(sw_eph, 1, row, 1, 1)
647
+ row += 1
648
+
649
+ # Restore File Shares On Start
650
+ lbl = Gtk.Label(label="Restore Shares On Start:", xalign=1.0)
651
+ lbl.set_width_chars(24)
652
+ grid.attach(lbl, 0, row, 1, 1)
653
+ sw_restore = Gtk.Switch(halign=Gtk.Align.START)
654
+ sw_restore.set_active(ac.restore_shares_on_start)
655
+ grid.attach(sw_restore, 1, row, 1, 1)
656
+ row += 1
657
+
658
+ # Show Bandwidth In Tray
659
+ lbl = Gtk.Label(label="Show Bandwidth In Tray:", xalign=1.0)
660
+ lbl.set_width_chars(24)
661
+ grid.attach(lbl, 0, row, 1, 1)
662
+ sw_bw = Gtk.Switch(halign=Gtk.Align.START)
663
+ sw_bw.set_active(ac.tray_show_bandwidth)
664
+ grid.attach(sw_bw, 1, row, 1, 1)
665
+ row += 1
666
+
667
+ # Logo Style
668
+ lbl = Gtk.Label(label="Logo Style:", xalign=1.0)
669
+ lbl.set_width_chars(24)
670
+ grid.attach(lbl, 0, row, 1, 1)
671
+ combo_logo = Gtk.ComboBoxText(halign=Gtk.Align.START)
672
+ logo_styles = list(LogoStyle)
673
+ for style in logo_styles:
674
+ combo_logo.append(style.value, style.value.replace("_", " ").title())
675
+ combo_logo.set_active_id(ac.logo_style.value)
676
+ combo_logo.connect("changed", lambda cb: self._on_logo_style_preview(cb, logo_styles))
677
+ grid.attach(combo_logo, 1, row, 1, 1)
678
+ row += 1
679
+
680
+ # Server ports — RPC + SSE require daemon restart to apply, PAC is
681
+ # hot-restarted by the facade. Shown before the existing PAC field.
682
+ lbl = Gtk.Label(label="RPC Server Port:", xalign=1.0)
683
+ lbl.set_width_chars(24)
684
+ grid.attach(lbl, 0, row, 1, 1)
685
+ entry_rpc = Gtk.Entry(activates_default=True)
686
+ rpc_val = self.manager.config.rpc_server_port
687
+ entry_rpc.set_text(str(rpc_val) if rpc_val else "")
688
+ entry_rpc.set_placeholder_text("auto (0) — restart daemon to apply")
689
+ grid.attach(entry_rpc, 1, row, 1, 1)
690
+ row += 1
691
+
692
+ lbl = Gtk.Label(label="SSE Server Port:", xalign=1.0)
693
+ lbl.set_width_chars(24)
694
+ grid.attach(lbl, 0, row, 1, 1)
695
+ entry_sse = Gtk.Entry(activates_default=True)
696
+ sse_val = self.manager.config.status_server_port
697
+ entry_sse.set_text(str(sse_val) if sse_val else "")
698
+ entry_sse.set_placeholder_text("auto (0) — restart daemon to apply")
699
+ grid.attach(entry_sse, 1, row, 1, 1)
700
+ row += 1
701
+
702
+ lbl = Gtk.Label(label="PAC Server Port:", xalign=1.0)
703
+ lbl.set_width_chars(24)
704
+ grid.attach(lbl, 0, row, 1, 1)
705
+ entry_pac = Gtk.Entry(activates_default=True)
706
+ pac_val = self.manager.config.pac_server_port
707
+ entry_pac.set_text(str(pac_val) if pac_val else "")
708
+ entry_pac.set_placeholder_text("auto (0)")
709
+ grid.attach(entry_pac, 1, row, 1, 1)
710
+
711
+ _polish_dialog(Gtk, dlg)
712
+ dlg.show_all()
713
+
714
+ _saved_logo = ac.logo_style # track original for cancel revert
715
+
716
+ while True:
717
+ resp = dlg.run()
718
+ if resp != Gtk.ResponseType.OK:
719
+ # Revert live-preview logo change
720
+ self.manager.update_app_config(logo_style=_saved_logo)
721
+ self.update_icon(self.state)
722
+ break
723
+
724
+ # Validate all three ports together — short-circuit on first
725
+ # invalid so the user keeps their other edits in the dialog.
726
+ cfg = self.manager.config
727
+ port_specs = [
728
+ ("RPC", entry_rpc, cfg.rpc_server_port),
729
+ ("SSE", entry_sse, cfg.status_server_port),
730
+ ("PAC", entry_pac, cfg.pac_server_port),
731
+ ]
732
+ port_values: dict[str, int] = {}
733
+ invalid = False
734
+ for label, entry, current in port_specs:
735
+ text = entry.get_text().strip() or "0"
736
+ if text != "0" and not _is_valid_port(text):
737
+ _alert(Gtk, dlg, "Invalid Port",
738
+ f"{label} Server Port must be between 1 and 65535.")
739
+ invalid = True
740
+ break
741
+ n = int(text)
742
+ if n != 0 and n != current and not is_port_free(n):
743
+ _alert(Gtk, dlg, "Port In Use", f"Port {n} is already in use.")
744
+ invalid = True
745
+ break
746
+ port_values[label] = n
747
+ if invalid:
748
+ continue
749
+
750
+ new_logo = logo_styles[combo_logo.get_active()] if combo_logo.get_active() >= 0 else _saved_logo
751
+ self.manager.update_app_config(
752
+ stop_on_quit=sw_stop.get_active(),
753
+ ephemeral_ports=sw_eph.get_active(),
754
+ restore_shares_on_start=sw_restore.get_active(),
755
+ tray_show_bandwidth=sw_bw.get_active(),
756
+ logo_style=new_logo,
757
+ )
758
+ self.refresh_bandwidth_title()
759
+ self.manager.update_config(
760
+ rpc_server_port=port_values["RPC"],
761
+ status_server_port=port_values["SSE"],
762
+ pac_server_port=port_values["PAC"],
763
+ )
764
+ self._apply_autostart(sw_login.get_active())
765
+ self.update_icon(self.state)
766
+ break
767
+
768
+ dlg.destroy()
769
+ return False
770
+
771
+ def _on_logo_style_preview(self, combo, logo_styles: list) -> None:
772
+ """Live-preview the selected logo style — icon swap only, no config write.
773
+
774
+ The icon path is computed directly from the chosen style enum; we don't
775
+ need to mutate manager state for the preview. The actual save happens
776
+ in the Settings handler via update_app_config().
777
+ """
778
+ idx = combo.get_active()
779
+ if not (0 <= idx < len(logo_styles)):
780
+ return
781
+ icon_path = _get_icon_path(self.state, logo_styles[idx].value.lower())
782
+ if icon_path:
783
+ self._indicator.set_icon_full(icon_path, self.state.value)
784
+
785
+ def _apply_autostart(self, enable: bool) -> None:
786
+ autostart_dir = Path.home() / ".config" / "autostart"
787
+ autostart_file = autostart_dir / "org.susops.App.desktop"
788
+ if enable:
789
+ autostart_dir.mkdir(parents=True, exist_ok=True)
790
+ import sys
791
+ exec_path = sys.executable
792
+ autostart_file.write_text(
793
+ "[Desktop Entry]\n"
794
+ "Name=SusOps\n"
795
+ f"Exec={exec_path} -m susops.tray.linux\n"
796
+ "Icon=org.susops.App\n"
797
+ "Type=Application\n"
798
+ "X-GNOME-Autostart-enabled=true\n"
799
+ )
800
+ else:
801
+ autostart_file.unlink(missing_ok=True)
802
+
803
+ def _on_add_connection(self, _) -> None:
804
+ self._GLib.idle_add(self._show_add_connection_dialog)
805
+
806
+ def _show_add_connection_dialog(self) -> bool:
807
+ Gtk = self._Gtk
808
+ dlg = Gtk.Dialog(title="Add Connection", transient_for=self._root, modal=True)
809
+ dlg.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "_Add", Gtk.ResponseType.OK)
810
+ dlg.set_default_response(Gtk.ResponseType.OK)
811
+ dlg.set_default_size(440, -1)
812
+
813
+ tag_entry = Gtk.Entry(activates_default=True)
814
+ host_combo = Gtk.ComboBoxText(has_entry=True)
815
+ for h in get_ssh_hosts():
816
+ host_combo.append_text(h)
817
+ host_combo.get_child().set_placeholder_text("hostname, IP, or SSH alias")
818
+ host_combo.get_child().set_activates_default(True)
819
+ port_entry = Gtk.Entry(placeholder_text="auto if blank", activates_default=True)
820
+
821
+ grid, _ = _labeled_grid(Gtk, [
822
+ ("tag", "Connection Tag *:", tag_entry),
823
+ ("host", "SSH Host *:", host_combo),
824
+ ("port", "SOCKS Proxy Port (optional):", port_entry),
825
+ ])
826
+ dlg.get_content_area().add(grid)
827
+ _polish_dialog(Gtk, dlg)
828
+ dlg.show_all()
829
+
830
+ while True:
831
+ resp = dlg.run()
832
+ if resp != Gtk.ResponseType.OK:
833
+ break
834
+ tag = tag_entry.get_text().strip()
835
+ host = (host_combo.get_active_text() or "").strip()
836
+ port = port_entry.get_text().strip()
837
+
838
+ if not tag:
839
+ _alert(Gtk, dlg, "Missing Field", "Connection Tag must not be empty.", Gtk.MessageType.ERROR)
840
+ continue
841
+ if not host:
842
+ _alert(Gtk, dlg, "Missing Field", "SSH Host must not be empty.", Gtk.MessageType.ERROR)
843
+ continue
844
+ if port and not _is_valid_port(port):
845
+ _alert(Gtk, dlg, "Invalid Port", "SOCKS Proxy Port must be between 1 and 65535.", Gtk.MessageType.ERROR)
846
+ continue
847
+ if port and not is_port_free(int(port)):
848
+ _alert(Gtk, dlg, "Port In Use", f"Port {port} is already in use.", Gtk.MessageType.ERROR)
849
+ continue
850
+
851
+ dlg.destroy()
852
+ port_int = int(port) if port else 0
853
+ self.do_add_connection(tag, host, port_int)
854
+ return False
855
+
856
+ dlg.destroy()
857
+ return False
858
+
859
+ def _on_add_host(self, _) -> None:
860
+ self._GLib.idle_add(self._show_add_host_dialog)
861
+
862
+ def _show_add_host_dialog(self) -> bool:
863
+ Gtk = self._Gtk
864
+ dlg = Gtk.Dialog(title="Add Domain / IP / CIDR", transient_for=self._root, modal=True)
865
+ dlg.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "_Add", Gtk.ResponseType.OK)
866
+ dlg.set_default_response(Gtk.ResponseType.OK)
867
+ dlg.set_default_size(380, -1)
868
+
869
+ conn_combo = Gtk.ComboBoxText()
870
+ tags = [c.tag for c in self.manager.list_config().connections]
871
+ for t in tags:
872
+ conn_combo.append_text(t)
873
+ if tags:
874
+ conn_combo.set_active(0)
875
+ host_entry = Gtk.Entry(activates_default=True)
876
+
877
+ grid, _ = _labeled_grid(Gtk, [
878
+ ("conn", "Connection *:", conn_combo),
879
+ ("host", "Host / IP / CIDR *:", host_entry),
880
+ ])
881
+ dlg.get_content_area().add(grid)
882
+ _polish_dialog(Gtk, dlg)
883
+ dlg.show_all()
884
+
885
+ while True:
886
+ resp = dlg.run()
887
+ if resp != Gtk.ResponseType.OK:
888
+ break
889
+ tag = conn_combo.get_active_text() or ""
890
+ host = host_entry.get_text().strip()
891
+ if not tag:
892
+ _alert(Gtk, dlg, "No Connection", "Add a connection first.", Gtk.MessageType.ERROR)
893
+ continue
894
+ if not host:
895
+ _alert(Gtk, dlg, "Missing Field", "Host must not be empty.", Gtk.MessageType.ERROR)
896
+ continue
897
+ dlg.destroy()
898
+ self.do_add_pac_host(host, conn_tag=tag)
899
+ return False
900
+
901
+ dlg.destroy()
902
+ return False
903
+
904
+ def _on_add_local(self, _) -> None:
905
+ self._GLib.idle_add(self._show_add_local_dialog)
906
+
907
+ def _show_add_local_dialog(self) -> bool:
908
+ Gtk = self._Gtk
909
+ BIND_ADDRESSES = ["localhost", "172.17.0.1", "0.0.0.0"]
910
+ dlg = Gtk.Dialog(title="Add Local Forward", transient_for=self._root, modal=True)
911
+ dlg.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "_Add", Gtk.ResponseType.OK)
912
+ dlg.set_default_response(Gtk.ResponseType.OK)
913
+ dlg.set_default_size(420, -1)
914
+
915
+ conn_combo = Gtk.ComboBoxText()
916
+ tags = [c.tag for c in self.manager.list_config().connections]
917
+ for t in tags:
918
+ conn_combo.append_text(t)
919
+ if tags:
920
+ conn_combo.set_active(0)
921
+ tag_entry = Gtk.Entry(placeholder_text="optional", activates_default=True)
922
+ src_port_entry = Gtk.Entry(placeholder_text="e.g. 8080", activates_default=True)
923
+ dst_port_entry = Gtk.Entry(placeholder_text="e.g. 80", activates_default=True)
924
+ src_addr_combo = Gtk.ComboBoxText(has_entry=True)
925
+ for addr in BIND_ADDRESSES:
926
+ src_addr_combo.append_text(addr)
927
+ src_addr_combo.get_child().set_text("localhost")
928
+ dst_addr_combo = Gtk.ComboBoxText(has_entry=True)
929
+ for addr in BIND_ADDRESSES:
930
+ dst_addr_combo.append_text(addr)
931
+ dst_addr_combo.get_child().set_text("localhost")
932
+ tcp_check = Gtk.CheckButton(label="TCP (SSH -L forward)")
933
+ tcp_check.set_active(True)
934
+ udp_check = Gtk.CheckButton(label="UDP (socat relay)")
935
+ udp_check.set_active(False)
936
+
937
+ grid, _ = _labeled_grid(Gtk, [
938
+ ("conn", "Connection *:", conn_combo),
939
+ ("tag", "Tag (optional):", tag_entry),
940
+ ("src", "Forward Local Port *:", src_port_entry),
941
+ ("dst", "To Remote Port *:", dst_port_entry),
942
+ ("src_addr", "Local Bind (optional):", src_addr_combo),
943
+ ("dst_addr", "Remote Bind (optional):", dst_addr_combo),
944
+ ("tcp", "Protocol:", tcp_check),
945
+ ("udp", "", udp_check),
946
+ ])
947
+ dlg.get_content_area().add(grid)
948
+ _polish_dialog(Gtk, dlg)
949
+ dlg.show_all()
950
+
951
+ while True:
952
+ resp = dlg.run()
953
+ if resp != Gtk.ResponseType.OK:
954
+ break
955
+ conn_tag = conn_combo.get_active_text() or ""
956
+ tag = tag_entry.get_text().strip()
957
+ src = src_port_entry.get_text().strip()
958
+ dst = dst_port_entry.get_text().strip()
959
+ src_addr = src_addr_combo.get_child().get_text().strip() or "localhost"
960
+ dst_addr = dst_addr_combo.get_child().get_text().strip() or "localhost"
961
+ tcp = tcp_check.get_active()
962
+ udp = udp_check.get_active()
963
+
964
+ if not conn_tag:
965
+ _alert(Gtk, dlg, "No Connection", "Add a connection first.", Gtk.MessageType.ERROR)
966
+ continue
967
+ if not tcp and not udp:
968
+ _alert(Gtk, dlg, "Protocol Required", "Select at least one protocol (TCP or UDP).",
969
+ Gtk.MessageType.ERROR)
970
+ continue
971
+ if not _is_valid_port(src):
972
+ _alert(Gtk, dlg, "Invalid Port", "Forward Local Port must be 1–65535.", Gtk.MessageType.ERROR)
973
+ continue
974
+ if not is_port_free(int(src)):
975
+ _alert(Gtk, dlg, "Port In Use", f"Local port {src} is already in use.", Gtk.MessageType.ERROR)
976
+ continue
977
+ if not _is_valid_port(dst):
978
+ _alert(Gtk, dlg, "Invalid Port", "To Remote Port must be 1–65535.", Gtk.MessageType.ERROR)
979
+ continue
980
+
981
+ fw = PortForward(src_addr=src_addr, src_port=int(src), dst_addr=dst_addr, dst_port=int(dst),
982
+ tag=tag or None, tcp=tcp, udp=udp)
983
+ dlg.destroy()
984
+ self.do_add_local_forward(conn_tag, fw)
985
+ return False
986
+
987
+ dlg.destroy()
988
+ return False
989
+
990
+ def _on_add_remote(self, _) -> None:
991
+ self._GLib.idle_add(self._show_add_remote_dialog)
992
+
993
+ def _show_add_remote_dialog(self) -> bool:
994
+ Gtk = self._Gtk
995
+ dlg = Gtk.Dialog(title="Add Remote Forward", transient_for=self._root, modal=True)
996
+ dlg.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "_Add", Gtk.ResponseType.OK)
997
+ dlg.set_default_response(Gtk.ResponseType.OK)
998
+ dlg.set_default_size(420, -1)
999
+
1000
+ BIND_ADDRESSES = ["localhost", "172.17.0.1", "0.0.0.0"]
1001
+ conn_combo = Gtk.ComboBoxText()
1002
+ tags = [c.tag for c in self.manager.list_config().connections]
1003
+ for t in tags:
1004
+ conn_combo.append_text(t)
1005
+ if tags:
1006
+ conn_combo.set_active(0)
1007
+ tag_entry = Gtk.Entry(placeholder_text="optional", activates_default=True)
1008
+ remote_port_entry = Gtk.Entry(placeholder_text="e.g. 8080", activates_default=True)
1009
+ local_port_entry = Gtk.Entry(placeholder_text="e.g. 3000", activates_default=True)
1010
+ src_addr_combo = Gtk.ComboBoxText(has_entry=True)
1011
+ for addr in BIND_ADDRESSES:
1012
+ src_addr_combo.append_text(addr)
1013
+ src_addr_combo.get_child().set_text("localhost")
1014
+ dst_addr_combo = Gtk.ComboBoxText(has_entry=True)
1015
+ for addr in BIND_ADDRESSES:
1016
+ dst_addr_combo.append_text(addr)
1017
+ dst_addr_combo.get_child().set_text("localhost")
1018
+ tcp_check = Gtk.CheckButton(label="TCP (SSH -R forward)")
1019
+ tcp_check.set_active(True)
1020
+ udp_check = Gtk.CheckButton(label="UDP (socat relay)")
1021
+ udp_check.set_active(False)
1022
+
1023
+ grid, _ = _labeled_grid(Gtk, [
1024
+ ("conn", "Connection *:", conn_combo),
1025
+ ("tag", "Tag (optional):", tag_entry),
1026
+ ("rport", "Forward Remote Port *:", remote_port_entry),
1027
+ ("lport", "To Local Port *:", local_port_entry),
1028
+ ("src_addr", "Remote Bind (optional):", src_addr_combo),
1029
+ ("dst_addr", "Local Bind (optional):", dst_addr_combo),
1030
+ ("tcp", "Protocol:", tcp_check),
1031
+ ("udp", "", udp_check),
1032
+ ])
1033
+ dlg.get_content_area().add(grid)
1034
+ _polish_dialog(Gtk, dlg)
1035
+ dlg.show_all()
1036
+
1037
+ while True:
1038
+ resp = dlg.run()
1039
+ if resp != Gtk.ResponseType.OK:
1040
+ break
1041
+ conn_tag = conn_combo.get_active_text() or ""
1042
+ tag = tag_entry.get_text().strip()
1043
+ rport = remote_port_entry.get_text().strip()
1044
+ lport = local_port_entry.get_text().strip()
1045
+ src_addr = src_addr_combo.get_child().get_text().strip() or "localhost"
1046
+ dst_addr = dst_addr_combo.get_child().get_text().strip() or "localhost"
1047
+ tcp = tcp_check.get_active()
1048
+ udp = udp_check.get_active()
1049
+
1050
+ if not conn_tag:
1051
+ _alert(Gtk, dlg, "No Connection", "Add a connection first.", Gtk.MessageType.ERROR)
1052
+ continue
1053
+ if not tcp and not udp:
1054
+ _alert(Gtk, dlg, "Protocol Required", "Select at least one protocol (TCP or UDP).",
1055
+ Gtk.MessageType.ERROR)
1056
+ continue
1057
+ if not _is_valid_port(rport):
1058
+ _alert(Gtk, dlg, "Invalid Port", "Forward Remote Port must be 1–65535.", Gtk.MessageType.ERROR)
1059
+ continue
1060
+ if not _is_valid_port(lport):
1061
+ _alert(Gtk, dlg, "Invalid Port", "To Local Port must be 1–65535.", Gtk.MessageType.ERROR)
1062
+ continue
1063
+ fw = PortForward(src_addr=src_addr, src_port=int(rport), dst_addr=dst_addr, dst_port=int(lport),
1064
+ tag=tag or None, tcp=tcp, udp=udp)
1065
+ dlg.destroy()
1066
+ self.do_add_remote_forward(conn_tag, fw)
1067
+ return False
1068
+
1069
+ dlg.destroy()
1070
+ return False
1071
+
1072
+ def _on_rm_connection(self, _) -> None:
1073
+ self._GLib.idle_add(self._show_rm_connection_dialog)
1074
+
1075
+ def _show_rm_connection_dialog(self) -> bool:
1076
+ tags = [c.tag for c in self.manager.list_config().connections]
1077
+ selected = self._pick_from_list("Remove Connection", "Connection Tag:", tags)
1078
+ if selected:
1079
+ self.do_remove_connection(selected)
1080
+ return False
1081
+
1082
+ def _on_rm_host(self, _) -> None:
1083
+ self._GLib.idle_add(self._show_rm_host_dialog)
1084
+
1085
+ def _show_rm_host_dialog(self) -> bool:
1086
+ config = self.manager.list_config()
1087
+ hosts = [h for c in config.connections for h in c.pac_hosts]
1088
+ selected = self._pick_from_list("Remove Domain / IP / CIDR", "Host:", hosts)
1089
+ if selected:
1090
+ self.do_remove_pac_host(selected)
1091
+ return False
1092
+
1093
+ def _on_rm_local(self, _) -> None:
1094
+ self._GLib.idle_add(self._show_rm_local_dialog)
1095
+
1096
+ def _show_rm_local_dialog(self) -> bool:
1097
+ config = self.manager.list_config()
1098
+ items = []
1099
+ for c in config.connections:
1100
+ for fw in c.forwards.local:
1101
+ items.append(f"[{c.tag}] {fw.src_port}→{fw.dst_addr}:{fw.dst_port}")
1102
+ selected = self._pick_from_list("Remove Local Forward", "Local Forward:", items)
1103
+ if selected:
1104
+ m = re.search(r":(\d+)→", selected)
1105
+ if m:
1106
+ self.do_remove_local_forward(int(m.group(1)))
1107
+ return False
1108
+
1109
+ def _on_rm_remote(self, _) -> None:
1110
+ self._GLib.idle_add(self._show_rm_remote_dialog)
1111
+
1112
+ def _show_rm_remote_dialog(self) -> bool:
1113
+ config = self.manager.list_config()
1114
+ items = []
1115
+ for c in config.connections:
1116
+ for fw in c.forwards.remote:
1117
+ items.append(f"[{c.tag}] {fw.src_port}→{fw.dst_addr}:{fw.dst_port}")
1118
+ selected = self._pick_from_list("Remove Remote Forward", "Remote Forward:", items)
1119
+ if selected:
1120
+ m = re.search(r":(\d+)→", selected)
1121
+ if m:
1122
+ self.do_remove_remote_forward(int(m.group(1)))
1123
+ return False
1124
+
1125
+ def _on_toggle_connection(self, _) -> None:
1126
+ self._GLib.idle_add(self._show_toggle_connection_dialog)
1127
+
1128
+ def _show_toggle_connection_dialog(self) -> bool:
1129
+ cfg = self.manager.list_config()
1130
+ items = [f"[{'✓' if c.enabled else '✗'}] {c.tag}" for c in cfg.connections]
1131
+ selected = self._pick_from_list("Toggle Connection Enabled", "Connection:", items, ok_label="Toggle")
1132
+ if selected:
1133
+ tag = selected.split("] ", 1)[-1]
1134
+ self.do_toggle_connection_enabled(tag)
1135
+ return False
1136
+
1137
+ def _on_toggle_domain(self, _) -> None:
1138
+ self._GLib.idle_add(self._show_toggle_domain_dialog)
1139
+
1140
+ def _show_toggle_domain_dialog(self) -> bool:
1141
+ cfg = self.manager.list_config()
1142
+ items = []
1143
+ for c in cfg.connections:
1144
+ for h in c.pac_hosts:
1145
+ enabled = h not in c.pac_hosts_disabled
1146
+ items.append(f"[{'✓' if enabled else '✗'}] {h}")
1147
+ selected = self._pick_from_list("Toggle Domain Enabled", "Domain:", items, ok_label="Toggle")
1148
+ if selected:
1149
+ host = selected.split("] ", 1)[-1]
1150
+ self.do_toggle_pac_host_enabled(host)
1151
+ return False
1152
+
1153
+ def _on_toggle_forward(self, _) -> None:
1154
+ self._GLib.idle_add(self._show_toggle_forward_dialog)
1155
+
1156
+ def _show_toggle_forward_dialog(self) -> bool:
1157
+ cfg = self.manager.list_config()
1158
+ items = []
1159
+ for c in cfg.connections:
1160
+ for fw in c.forwards.local:
1161
+ state = "✓" if fw.enabled else "✗"
1162
+ items.append(f"[{state}] [{c.tag}] local :{fw.src_port}→{fw.dst_addr}:{fw.dst_port}")
1163
+ for fw in c.forwards.remote:
1164
+ state = "✓" if fw.enabled else "✗"
1165
+ items.append(f"[{state}] [{c.tag}] remote :{fw.src_port}→{fw.dst_addr}:{fw.dst_port}")
1166
+ selected = self._pick_from_list("Toggle Forward Enabled", "Forward:", items, ok_label="Toggle")
1167
+ if selected:
1168
+ m = re.search(r"\[([^\]]+)\] (local|remote) :(\d+)", selected)
1169
+ if m:
1170
+ conn_tag, direction, src_port = m.group(1), m.group(2), int(m.group(3))
1171
+ self.do_toggle_forward_enabled(conn_tag, src_port, direction)
1172
+ return False
1173
+
1174
+ def _on_start_connection(self, _) -> None:
1175
+ self._GLib.idle_add(self._show_start_connection_dialog)
1176
+
1177
+ def _show_start_connection_dialog(self) -> bool:
1178
+ tags = [c.tag for c in self.manager.list_config().connections]
1179
+ selected = self._pick_from_list("Start Connection", "Connection:", tags, ok_label="Start")
1180
+ if selected:
1181
+ self.do_start_connection(selected)
1182
+ return False
1183
+
1184
+ def _on_stop_connection(self, _) -> None:
1185
+ self._GLib.idle_add(self._show_stop_connection_dialog)
1186
+
1187
+ def _show_stop_connection_dialog(self) -> bool:
1188
+ tags = [c.tag for c in self.manager.list_config().connections]
1189
+ selected = self._pick_from_list("Stop Connection", "Connection:", tags, ok_label="Stop")
1190
+ if selected:
1191
+ self.do_stop_connection(selected)
1192
+ return False
1193
+
1194
+ def _on_restart_connection(self, _) -> None:
1195
+ self._GLib.idle_add(self._show_restart_connection_dialog)
1196
+
1197
+ def _show_restart_connection_dialog(self) -> bool:
1198
+ tags = [c.tag for c in self.manager.list_config().connections]
1199
+ selected = self._pick_from_list("Restart Connection", "Connection:", tags, ok_label="Restart")
1200
+ if selected:
1201
+ self.do_restart_connection(selected)
1202
+ return False
1203
+
1204
+ def _on_test_connection(self, _) -> None:
1205
+ self._GLib.idle_add(self._show_test_connection_dialog)
1206
+
1207
+ def _show_test_connection_dialog(self) -> bool:
1208
+ tags = [c.tag for c in self.manager.list_config().connections]
1209
+ selected = self._pick_from_list("Test Connection", "Connection:", tags, ok_label="Test")
1210
+ if selected:
1211
+ self.do_test_connection(selected)
1212
+ return False
1213
+
1214
+ def _on_test_domain(self, _) -> None:
1215
+ self._GLib.idle_add(self._show_test_domain_dialog)
1216
+
1217
+ def _show_test_domain_dialog(self) -> bool:
1218
+ cfg = self.manager.list_config()
1219
+ items = []
1220
+ for c in cfg.connections:
1221
+ for h in c.pac_hosts:
1222
+ items.append(f"[{c.tag}] {h}")
1223
+ selected = self._pick_from_list("Test Domain", "Domain (via connection):", items, ok_label="Test")
1224
+ if selected:
1225
+ m = re.match(r"\[([^\]]+)\] (.+)", selected)
1226
+ if m:
1227
+ self.do_test_domain(m.group(2), m.group(1))
1228
+ return False
1229
+
1230
+ def _on_test_forward(self, _) -> None:
1231
+ self._GLib.idle_add(self._show_test_forward_dialog)
1232
+
1233
+ def _show_test_forward_dialog(self) -> bool:
1234
+ cfg = self.manager.list_config()
1235
+ items = []
1236
+ for c in cfg.connections:
1237
+ for fw in c.forwards.local:
1238
+ items.append(f"[{c.tag}] local :{fw.src_port}→{fw.dst_addr}:{fw.dst_port}")
1239
+ for fw in c.forwards.remote:
1240
+ items.append(f"[{c.tag}] remote :{fw.src_port}→{fw.dst_addr}:{fw.dst_port}")
1241
+ selected = self._pick_from_list("Test Forward", "Forward:", items, ok_label="Test")
1242
+ if selected:
1243
+ m = re.search(r"\[([^\]]+)\] (local|remote) :(\d+)", selected)
1244
+ if m:
1245
+ self.do_test_forward(m.group(1), int(m.group(3)), m.group(2))
1246
+ return False
1247
+
1248
+ def _pick_from_list(self, title: str, label: str, items: list[str], ok_label: str = "Remove") -> str | None:
1249
+ """Show a dialog with a dropdown list. Returns selected item or None."""
1250
+ Gtk = self._Gtk
1251
+ if not items:
1252
+ _alert(Gtk, self._root, "Nothing to Select", "The list is empty.")
1253
+ return None
1254
+
1255
+ dlg = Gtk.Dialog(title=title, transient_for=self._root, modal=True)
1256
+ dlg.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, f"_{ok_label}", Gtk.ResponseType.OK)
1257
+ dlg.set_default_response(Gtk.ResponseType.OK)
1258
+ dlg.set_default_size(340, -1)
1259
+
1260
+ combo = Gtk.ComboBoxText(hexpand=True)
1261
+ for item in items:
1262
+ combo.append_text(item)
1263
+ combo.set_active(0)
1264
+
1265
+ grid, _ = _labeled_grid(Gtk, [(label, label, combo)])
1266
+ dlg.get_content_area().add(grid)
1267
+ _polish_dialog(Gtk, dlg)
1268
+ dlg.show_all()
1269
+
1270
+ resp = dlg.run()
1271
+ selected = combo.get_active_text() if resp == Gtk.ResponseType.OK else None
1272
+ dlg.destroy()
1273
+ return selected
1274
+
1275
+ def _on_share_file(self) -> None:
1276
+ self._GLib.idle_add(self._show_share_file_dialog)
1277
+
1278
+ def _show_share_file_dialog(self) -> bool:
1279
+ Gtk = self._Gtk
1280
+ tags = [c.tag for c in self.manager.list_config().connections]
1281
+ if not tags:
1282
+ _alert(Gtk, self._root, "No Connections", "Add a connection first.")
1283
+ return False
1284
+
1285
+ dlg = Gtk.Dialog(title="Share File", transient_for=self._root, modal=True)
1286
+ dlg.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "_Share", Gtk.ResponseType.OK)
1287
+ dlg.set_default_response(Gtk.ResponseType.OK)
1288
+ dlg.set_default_size(440, -1)
1289
+
1290
+ conn_combo = Gtk.ComboBoxText()
1291
+ for t in tags:
1292
+ conn_combo.append_text(t)
1293
+ conn_combo.set_active(0)
1294
+
1295
+ fc = Gtk.FileChooserButton(title="Select file", action=Gtk.FileChooserAction.OPEN)
1296
+ pw_entry = Gtk.Entry(placeholder_text="auto-generate if blank", activates_default=True)
1297
+ port_entry = Gtk.Entry(placeholder_text="0 (auto)", activates_default=True)
1298
+ port_entry.set_text("0")
1299
+
1300
+ grid, _ = _labeled_grid(Gtk, [
1301
+ ("conn", "Connection *:", conn_combo),
1302
+ ("file", "File *:", fc),
1303
+ ("pw", "Password:", pw_entry),
1304
+ ("port", "Port (0=auto):", port_entry),
1305
+ ])
1306
+ dlg.get_content_area().add(grid)
1307
+ _polish_dialog(Gtk, dlg)
1308
+ dlg.show_all()
1309
+
1310
+ while True:
1311
+ resp = dlg.run()
1312
+ if resp != Gtk.ResponseType.OK:
1313
+ break
1314
+ conn_tag = conn_combo.get_active_text() or ""
1315
+ file_path = fc.get_filename() or ""
1316
+ pw = pw_entry.get_text().strip() or None
1317
+ port_text = port_entry.get_text().strip() or "0"
1318
+ if not conn_tag:
1319
+ _alert(Gtk, dlg, "Missing Field", "Select a connection.", Gtk.MessageType.ERROR)
1320
+ continue
1321
+ if not file_path:
1322
+ _alert(Gtk, dlg, "Missing Field", "Select a file to share.", Gtk.MessageType.ERROR)
1323
+ continue
1324
+ try:
1325
+ port_int = int(port_text)
1326
+ except ValueError:
1327
+ _alert(Gtk, dlg, "Invalid Port", "Port must be a number.", Gtk.MessageType.ERROR)
1328
+ continue
1329
+ dlg.destroy()
1330
+ self.do_share(conn_tag, file_path, password=pw, port=port_int)
1331
+ return False
1332
+
1333
+ dlg.destroy()
1334
+ return False
1335
+
1336
+ def _on_fetch_file(self) -> None:
1337
+ self._GLib.idle_add(self._show_fetch_file_dialog)
1338
+
1339
+ def _show_fetch_file_dialog(self) -> bool:
1340
+ Gtk = self._Gtk
1341
+ tags = [c.tag for c in self.manager.list_config().connections]
1342
+ if not tags:
1343
+ _alert(Gtk, self._root, "No Connections", "Add a connection first.")
1344
+ return False
1345
+
1346
+ dlg = Gtk.Dialog(title="Fetch File", transient_for=self._root, modal=True)
1347
+ dlg.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "_Fetch", Gtk.ResponseType.OK)
1348
+ dlg.set_default_response(Gtk.ResponseType.OK)
1349
+ dlg.set_default_size(440, -1)
1350
+
1351
+ conn_combo = Gtk.ComboBoxText()
1352
+ for t in tags:
1353
+ conn_combo.append_text(t)
1354
+ conn_combo.set_active(0)
1355
+
1356
+ port_entry = Gtk.Entry(placeholder_text="e.g. 52100", activates_default=True)
1357
+ pw_entry = Gtk.Entry(placeholder_text="", activates_default=True)
1358
+ outfile_entry = Gtk.Entry(placeholder_text="blank = ~/Downloads/<filename>", activates_default=True)
1359
+
1360
+ grid, _ = _labeled_grid(Gtk, [
1361
+ ("conn", "Connection *:", conn_combo),
1362
+ ("port", "Port *:", port_entry),
1363
+ ("pw", "Password *:", pw_entry),
1364
+ ("out", "Save to (optional):", outfile_entry),
1365
+ ])
1366
+ dlg.get_content_area().add(grid)
1367
+ _polish_dialog(Gtk, dlg)
1368
+ dlg.show_all()
1369
+
1370
+ while True:
1371
+ resp = dlg.run()
1372
+ if resp != Gtk.ResponseType.OK:
1373
+ break
1374
+ conn_tag = conn_combo.get_active_text() or ""
1375
+ port_text = port_entry.get_text().strip()
1376
+ pw = pw_entry.get_text().strip()
1377
+ outfile = outfile_entry.get_text().strip() or None
1378
+ if not conn_tag:
1379
+ _alert(Gtk, dlg, "Missing Field", "Select a connection.", Gtk.MessageType.ERROR)
1380
+ continue
1381
+ if not port_text or not pw:
1382
+ _alert(Gtk, dlg, "Missing Field", "Port and password are required.", Gtk.MessageType.ERROR)
1383
+ continue
1384
+ try:
1385
+ port_int = int(port_text)
1386
+ except ValueError:
1387
+ _alert(Gtk, dlg, "Invalid Port", "Port must be a number.", Gtk.MessageType.ERROR)
1388
+ continue
1389
+ dlg.destroy()
1390
+ self.do_fetch(conn_tag, port_int, pw, outfile=outfile)
1391
+ return False
1392
+
1393
+ dlg.destroy()
1394
+ return False
1395
+
1396
+ def _make_share_info_handler(self, info):
1397
+ def handler(_item):
1398
+ self._GLib.idle_add(lambda: self._show_share_info_dialog(info))
1399
+
1400
+ return handler
1401
+
1402
+ def _show_share_info_dialog(self, info) -> bool:
1403
+ import pathlib
1404
+ Gtk = self._Gtk
1405
+ name = pathlib.Path(info.file_path).name
1406
+ state = "running" if info.running else "stopped"
1407
+ dlg = Gtk.Dialog(title=f"Share: {name}", transient_for=self._root, modal=True)
1408
+ # Buttons: Stop/Start (context-sensitive), Delete, Close
1409
+ _RESP_TOGGLE = 10 # Stop if running, Start if stopped
1410
+ _RESP_DELETE = 11
1411
+ if info.running:
1412
+ dlg.add_button("_Stop", _RESP_TOGGLE)
1413
+ else:
1414
+ dlg.add_button("_Start", _RESP_TOGGLE)
1415
+ dlg.add_button("_Delete", _RESP_DELETE)
1416
+ dlg.add_button("_Close", Gtk.ResponseType.CLOSE)
1417
+ dlg.set_default_response(Gtk.ResponseType.CLOSE)
1418
+ dlg.set_default_size(380, -1)
1419
+
1420
+ box = dlg.get_content_area()
1421
+ box.set_spacing(4)
1422
+ box.set_margin_start(16)
1423
+ box.set_margin_end(16)
1424
+ box.set_margin_top(12)
1425
+ box.set_margin_bottom(8)
1426
+
1427
+ for lbl, val in [
1428
+ ("File", info.file_path),
1429
+ ("Port", str(info.port)),
1430
+ ("Password", info.password),
1431
+ ("Connection", info.conn_tag or "—"),
1432
+ ("State", state),
1433
+ ]:
1434
+ row_box = Gtk.Box(spacing=8)
1435
+ row_box.pack_start(Gtk.Label(label=f"{lbl}:", xalign=1.0, width_chars=12), False, False, 0)
1436
+ row_box.pack_start(Gtk.Label(label=val, xalign=0.0, selectable=True), True, True, 0)
1437
+ box.add(row_box)
1438
+
1439
+ _polish_dialog(Gtk, dlg)
1440
+ dlg.show_all()
1441
+ resp = dlg.run()
1442
+ dlg.destroy()
1443
+ if resp == _RESP_TOGGLE:
1444
+ if info.running:
1445
+ self.do_stop_share(info.port)
1446
+ else:
1447
+ self.do_share(info.conn_tag or "", info.file_path, info.password, info.port)
1448
+ self._GLib.idle_add(self._refresh_share_submenu)
1449
+ elif resp == _RESP_DELETE:
1450
+ self.do_delete_share(info.port)
1451
+ self._GLib.idle_add(self._refresh_share_submenu)
1452
+ return False
1453
+
1454
+ def _on_reset(self) -> None:
1455
+ def _ask():
1456
+ Gtk = self._Gtk
1457
+ dlg = Gtk.MessageDialog(
1458
+ transient_for=self._root, modal=True,
1459
+ message_type=Gtk.MessageType.WARNING,
1460
+ buttons=Gtk.ButtonsType.NONE,
1461
+ text="Reset All?",
1462
+ )
1463
+ dlg.format_secondary_text(
1464
+ "This will stop all tunnels and delete the workspace. This cannot be undone."
1465
+ )
1466
+ dlg.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "_Reset", Gtk.ResponseType.OK)
1467
+ dlg.set_default_response(Gtk.ResponseType.CANCEL)
1468
+ _polish_dialog(Gtk, dlg)
1469
+ resp = dlg.run()
1470
+ dlg.destroy()
1471
+ if resp == Gtk.ResponseType.OK:
1472
+ self.run_in_background(
1473
+ lambda: self.do_reset(),
1474
+ lambda _: None,
1475
+ )
1476
+ return False
1477
+
1478
+ self._GLib.idle_add(_ask)
1479
+
1480
+ def _on_about(self) -> None:
1481
+ """About dialog. Original layout (name / version / description / link
1482
+ buttons / copyright). Non-modal so the tray menu stays usable, and
1483
+ keep-above so the window stays in the foreground."""
1484
+ from pathlib import Path
1485
+
1486
+ def _show():
1487
+ Gtk = self._Gtk
1488
+ dlg = Gtk.Dialog(title="About SusOps", transient_for=self._root, modal=False)
1489
+ dlg.add_button("_Close", Gtk.ResponseType.CLOSE)
1490
+ dlg.set_default_size(280, -1)
1491
+ try:
1492
+ dlg.set_keep_above(True)
1493
+ except Exception:
1494
+ pass
1495
+ box = dlg.get_content_area()
1496
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6,
1497
+ margin_start=20, margin_end=20,
1498
+ margin_top=16, margin_bottom=12,
1499
+ halign=Gtk.Align.CENTER)
1500
+ box.add(vbox)
1501
+
1502
+ # Static logo at the top, if available.
1503
+ icon_path = Path(__file__).parent.parent.parent.parent / "assets" / "icon.png"
1504
+ if icon_path.exists():
1505
+ try:
1506
+ from gi.repository import GdkPixbuf # type: ignore[import]
1507
+ pix = GdkPixbuf.Pixbuf.new_from_file_at_size(str(icon_path), 64, 64)
1508
+ img = Gtk.Image.new_from_pixbuf(pix)
1509
+ vbox.pack_start(img, False, False, 4)
1510
+ except Exception:
1511
+ pass
1512
+
1513
+ import susops
1514
+ name_lbl = Gtk.Label()
1515
+ name_lbl.set_markup("<b><big>SusOps</big></b>")
1516
+ vbox.pack_start(name_lbl, False, False, 2)
1517
+ ver_lbl = Gtk.Label(label=f"Version {susops.__version__}")
1518
+ ver_lbl.get_style_context().add_class("dim-label")
1519
+ vbox.pack_start(ver_lbl, False, False, 0)
1520
+ desc = Gtk.Label(label="SSH Tunnel & PAC Manager")
1521
+ desc.get_style_context().add_class("dim-label")
1522
+ vbox.pack_start(desc, False, False, 0)
1523
+ for text, url in [
1524
+ ("GitHub", "https://github.com/mashb1t/susops"),
1525
+ ("Report a Bug", "https://github.com/mashb1t/susops/issues/new"),
1526
+ ]:
1527
+ btn = Gtk.LinkButton(uri=url, label=text)
1528
+ vbox.pack_start(btn, False, False, 0)
1529
+ copy_lbl = Gtk.Label(label="Copyright © Manuel Schmid")
1530
+ copy_lbl.get_style_context().add_class("dim-label")
1531
+ vbox.pack_start(copy_lbl, False, False, 4)
1532
+ _polish_dialog(Gtk, dlg)
1533
+ dlg.connect("response", lambda d, _r: d.destroy())
1534
+ dlg.show_all()
1535
+ return False
1536
+
1537
+ self._GLib.idle_add(_show)
1538
+
1539
+ def _on_quit(self, _widget) -> None:
1540
+ self.do_quit()
1541
+ self._Gtk.main_quit()
1542
+
1543
+ # ------------------------------------------------------------------ #
1544
+ # Run
1545
+ # ------------------------------------------------------------------ #
1546
+
1547
+ def _start_sse_listener(self) -> None:
1548
+ """Background thread: connect to SSE /events and update UI on events."""
1549
+ import threading, time
1550
+
1551
+ def _listen():
1552
+ backoff = 1.0
1553
+ while True:
1554
+ status_url = self.manager.get_status_url()
1555
+ if not status_url:
1556
+ time.sleep(2.0)
1557
+ continue
1558
+ try:
1559
+ import os
1560
+ import urllib.request
1561
+ import susops as _susops_pkg
1562
+ req = urllib.request.Request(status_url, headers={
1563
+ "X-Susops-Client": "tray-linux",
1564
+ "X-Susops-Client-Version": _susops_pkg.__version__,
1565
+ "X-Susops-Pid": str(os.getpid()),
1566
+ # Tray only reacts to state + share events; skip the
1567
+ # high-frequency `bandwidth` broadcasts.
1568
+ "X-Susops-Events": "state,share",
1569
+ })
1570
+ with urllib.request.urlopen(req, timeout=60) as resp:
1571
+ backoff = 1.0
1572
+ buf = ""
1573
+ for raw in resp:
1574
+ line = raw.decode("utf-8", errors="replace")
1575
+ buf += line
1576
+ if buf.endswith("\n\n"):
1577
+ if "event: state" in buf:
1578
+ self._GLib.idle_add(self._on_sse_state)
1579
+ if "event: share" in buf:
1580
+ self._GLib.idle_add(self._refresh_share_submenu)
1581
+ buf = ""
1582
+ except Exception:
1583
+ time.sleep(backoff)
1584
+ # Cap at 5s — short enough that the user never sees more
1585
+ # than 5s of staleness, even when the daemon is bouncing.
1586
+ backoff = min(backoff * 2, 5.0)
1587
+
1588
+ threading.Thread(target=_listen, daemon=True, name="susops-sse-linux").start()
1589
+
1590
+ def _on_sse_state(self) -> bool:
1591
+ self.do_poll()
1592
+ return False
1593
+
1594
+ def update_title(self, rx_bps: float | None, tx_bps: float | None) -> None:
1595
+ def _apply():
1596
+ if rx_bps is None or tx_bps is None:
1597
+ self._indicator.set_label("", "")
1598
+ return False
1599
+ up = self._format_rate(tx_bps)
1600
+ down = self._format_rate(rx_bps)
1601
+ self._indicator.set_label(f"↑ {up} ↓ {down}", "")
1602
+ return False
1603
+ self._GLib.idle_add(_apply)
1604
+
1605
+ def _tick_bandwidth(self) -> bool:
1606
+ self.refresh_bandwidth_title()
1607
+ return True
1608
+
1609
+ def run(self) -> None:
1610
+ """Start the GTK main loop."""
1611
+ # Initial state pull on startup; SSE listener drives every refresh
1612
+ # after that. No periodic polling fallback — SSE reconnects within
1613
+ # 5 s on its own.
1614
+ self.do_poll()
1615
+ self.refresh_bandwidth_title()
1616
+ self._start_sse_listener()
1617
+ self._GLib.timeout_add(1000, self._tick_bandwidth)
1618
+ self._Gtk.main()
1619
+
1620
+
1621
+ def main() -> None:
1622
+ app = SusOpsLinuxTray()
1623
+ app.run()