susops 3.0.0rc3.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- susops/__init__.py +4 -0
- susops/client.py +230 -0
- susops/core/__init__.py +0 -0
- susops/core/browsers.py +330 -0
- susops/core/config.py +253 -0
- susops/core/log_style.py +92 -0
- susops/core/pac.py +185 -0
- susops/core/ports.py +57 -0
- susops/core/process.py +167 -0
- susops/core/rpc_protocol.py +186 -0
- susops/core/rpc_server.py +131 -0
- susops/core/services_daemon.py +312 -0
- susops/core/share.py +323 -0
- susops/core/socat.py +200 -0
- susops/core/ssh.py +330 -0
- susops/core/ssh_config.py +40 -0
- susops/core/status.py +245 -0
- susops/core/types.py +171 -0
- susops/facade.py +2237 -0
- susops/tray/__init__.py +20 -0
- susops/tray/base.py +650 -0
- susops/tray/linux.py +1623 -0
- susops/tray/mac.py +3105 -0
- susops/tui/__init__.py +0 -0
- susops/tui/__main__.py +44 -0
- susops/tui/app.py +191 -0
- susops/tui/cli.py +665 -0
- susops/tui/screens/__init__.py +114 -0
- susops/tui/screens/connections.py +871 -0
- susops/tui/screens/dashboard.py +935 -0
- susops/tui/screens/shares.py +357 -0
- susops/tui/widgets/__init__.py +0 -0
- susops/tui/widgets/connection_card.py +137 -0
- susops/version.py +12 -0
- susops-3.0.0rc3.dev1.dist-info/METADATA +977 -0
- susops-3.0.0rc3.dev1.dist-info/RECORD +40 -0
- susops-3.0.0rc3.dev1.dist-info/WHEEL +5 -0
- susops-3.0.0rc3.dev1.dist-info/entry_points.txt +7 -0
- susops-3.0.0rc3.dev1.dist-info/licenses/LICENSE +674 -0
- susops-3.0.0rc3.dev1.dist-info/top_level.txt +1 -0
susops/tray/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()
|