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/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _ensure_system_site_packages() -> None:
|
|
5
|
+
"""Add system site-packages to sys.path so GTK/gi are reachable from a venv."""
|
|
6
|
+
import sysconfig
|
|
7
|
+
system_sp = sysconfig.get_path("purelib", vars={"base": "/usr", "platbase": "/usr"})
|
|
8
|
+
if system_sp and system_sp not in sys.path:
|
|
9
|
+
sys.path.insert(0, system_sp)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main():
|
|
13
|
+
"""Auto-detect platform and launch the appropriate tray app."""
|
|
14
|
+
if sys.platform == "darwin":
|
|
15
|
+
from susops.tray.mac import SusOpsMacTray
|
|
16
|
+
SusOpsMacTray().run()
|
|
17
|
+
else:
|
|
18
|
+
_ensure_system_site_packages()
|
|
19
|
+
from susops.tray.linux import SusOpsLinuxTray
|
|
20
|
+
SusOpsLinuxTray().run()
|
susops/tray/base.py
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
"""AbstractTrayApp — shared tray app logic for Linux and macOS."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from susops.core.types import ProcessState
|
|
10
|
+
|
|
11
|
+
_ASSETS_DIR = Path(__file__).parent.parent.parent.parent / "assets" / "icons"
|
|
12
|
+
|
|
13
|
+
_STATE_FILENAMES = {
|
|
14
|
+
ProcessState.RUNNING: "running",
|
|
15
|
+
ProcessState.STOPPED_PARTIALLY: "stopped_partially",
|
|
16
|
+
ProcessState.STOPPED: "stopped",
|
|
17
|
+
ProcessState.ERROR: "error",
|
|
18
|
+
ProcessState.INITIAL: "stopped",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_icon_path(
|
|
23
|
+
state: ProcessState,
|
|
24
|
+
logo_style: str = "colored_glasses",
|
|
25
|
+
variant: str = "dark",
|
|
26
|
+
prefer_png: bool = False,
|
|
27
|
+
) -> str | None:
|
|
28
|
+
"""Return the icon path for a given state, style, and variant.
|
|
29
|
+
|
|
30
|
+
Tries the requested variant first, then falls back to the other.
|
|
31
|
+
If prefer_png is True, checks .png before .svg; otherwise SVG first.
|
|
32
|
+
"""
|
|
33
|
+
name = _STATE_FILENAMES.get(state, "stopped")
|
|
34
|
+
exts = ("png", "svg") if prefer_png else ("svg", "png")
|
|
35
|
+
other_variant = "dark" if variant == "light" else "light"
|
|
36
|
+
|
|
37
|
+
for v in (variant, other_variant):
|
|
38
|
+
base = _ASSETS_DIR / logo_style.lower() / v / name
|
|
39
|
+
for ext in exts:
|
|
40
|
+
p = base.with_suffix(f".{ext}")
|
|
41
|
+
if p.exists():
|
|
42
|
+
return str(p)
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_ssh_hosts() -> list[str]:
|
|
47
|
+
"""Return non-wildcard Host entries from ~/.ssh/config."""
|
|
48
|
+
cfg = Path.home() / ".ssh" / "config"
|
|
49
|
+
if not cfg.exists():
|
|
50
|
+
return []
|
|
51
|
+
hosts = []
|
|
52
|
+
pattern = re.compile(r"^\s*Host\s+(.*)$", re.IGNORECASE)
|
|
53
|
+
for line in cfg.read_text().splitlines():
|
|
54
|
+
line = line.strip()
|
|
55
|
+
if not line or line.startswith("#"):
|
|
56
|
+
continue
|
|
57
|
+
m = pattern.match(line)
|
|
58
|
+
if m:
|
|
59
|
+
for h in m.group(1).split():
|
|
60
|
+
if "*" not in h and "?" not in h:
|
|
61
|
+
hosts.append(h)
|
|
62
|
+
return hosts
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
from susops.client import SusOpsClient
|
|
66
|
+
from susops.core.config import PortForward
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AbstractTrayApp(ABC):
|
|
70
|
+
"""Base class for tray apps. Subclasses implement the platform-specific UI layer.
|
|
71
|
+
|
|
72
|
+
All business logic lives here; linux.py and mac.py each provide:
|
|
73
|
+
- update_icon(state)
|
|
74
|
+
- update_menu_sensitivity(state)
|
|
75
|
+
- show_alert(title, msg)
|
|
76
|
+
- show_output_dialog(title, output)
|
|
77
|
+
- run_in_background(fn, callback)
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self) -> None:
|
|
81
|
+
workspace = Path.home() / ".susops"
|
|
82
|
+
self.manager = SusOpsClient(workspace=workspace, process_name="susops-tray")
|
|
83
|
+
self.state = ProcessState.INITIAL
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------ #
|
|
86
|
+
# Platform abstractions (must be implemented by subclass)
|
|
87
|
+
# ------------------------------------------------------------------ #
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
def update_icon(self, state: ProcessState) -> None:
|
|
91
|
+
...
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def update_menu_sensitivity(self, state: ProcessState) -> None:
|
|
95
|
+
...
|
|
96
|
+
|
|
97
|
+
@abstractmethod
|
|
98
|
+
def show_alert(self, title: str, msg: str) -> None:
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
def show_output_dialog(self, title: str, output: str) -> None:
|
|
103
|
+
...
|
|
104
|
+
|
|
105
|
+
@abstractmethod
|
|
106
|
+
def run_in_background(self, fn: Callable, callback: Callable | None = None) -> None:
|
|
107
|
+
...
|
|
108
|
+
|
|
109
|
+
def update_title(self, rx_bps: float | None, tx_bps: float | None) -> None:
|
|
110
|
+
"""Render aggregated up/down bandwidth in the tray title/label.
|
|
111
|
+
|
|
112
|
+
``None`` for both rates means clear the title. Default is a no-op so
|
|
113
|
+
subclasses that haven't wired this up still work.
|
|
114
|
+
"""
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _format_rate(bps: float) -> str:
|
|
119
|
+
# Tray rule: always render in KB/s or larger. Idle stays at 0 KB/s,
|
|
120
|
+
# anything below 1 KB/s rounds up to 1 KB/s so the unit doesn't
|
|
121
|
+
# flicker on SSH keepalive noise.
|
|
122
|
+
if bps <= 0:
|
|
123
|
+
return "0 KB/s"
|
|
124
|
+
if bps < 1024:
|
|
125
|
+
return "1 KB/s"
|
|
126
|
+
if bps < 1024 ** 2:
|
|
127
|
+
return f"{bps / 1024:.0f} KB/s"
|
|
128
|
+
if bps < 1024 ** 3:
|
|
129
|
+
return f"{bps / (1024 ** 2):.1f} MB/s"
|
|
130
|
+
return f"{bps / (1024 ** 3):.2f} GB/s"
|
|
131
|
+
|
|
132
|
+
def refresh_bandwidth_title(self) -> None:
|
|
133
|
+
"""Refresh the tray title with current global bandwidth, or clear it."""
|
|
134
|
+
try:
|
|
135
|
+
enabled = bool(self.manager.app_config.tray_show_bandwidth)
|
|
136
|
+
except Exception:
|
|
137
|
+
enabled = False
|
|
138
|
+
if not enabled:
|
|
139
|
+
self.update_title(None, None)
|
|
140
|
+
return
|
|
141
|
+
try:
|
|
142
|
+
rx, tx = self.manager.get_bandwidth_global()
|
|
143
|
+
except Exception:
|
|
144
|
+
rx, tx = 0.0, 0.0
|
|
145
|
+
self.update_title(rx, tx)
|
|
146
|
+
|
|
147
|
+
def show_live_logs(self, get_text: Callable[[], str], *, title: str = "Logs",
|
|
148
|
+
interval_ms: int = 1000) -> None:
|
|
149
|
+
"""Show a non-modal, auto-refreshing log window.
|
|
150
|
+
|
|
151
|
+
Default implementation falls back to a one-shot snapshot via
|
|
152
|
+
show_output_dialog so AbstractTrayApp keeps working on platforms that
|
|
153
|
+
haven't built a streaming window yet. Real implementations override
|
|
154
|
+
this with a non-modal window that polls `get_text()` every
|
|
155
|
+
``interval_ms`` and auto-scrolls to the bottom.
|
|
156
|
+
"""
|
|
157
|
+
self.show_output_dialog(title, get_text())
|
|
158
|
+
|
|
159
|
+
# ------------------------------------------------------------------ #
|
|
160
|
+
# State management
|
|
161
|
+
# ------------------------------------------------------------------ #
|
|
162
|
+
|
|
163
|
+
def _on_state_change_safe(self, state: ProcessState) -> None:
|
|
164
|
+
"""Called from background threads; subclass must thread-marshal if needed."""
|
|
165
|
+
self.state = state
|
|
166
|
+
self.update_icon(state)
|
|
167
|
+
self.update_menu_sensitivity(state)
|
|
168
|
+
|
|
169
|
+
def do_poll(self) -> None:
|
|
170
|
+
"""Poll SusOpsClient status and update UI. Called on a timer."""
|
|
171
|
+
result = self.manager.status()
|
|
172
|
+
self._on_state_change_safe(result.state)
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------ #
|
|
175
|
+
# Shared actions
|
|
176
|
+
# ------------------------------------------------------------------ #
|
|
177
|
+
|
|
178
|
+
def do_start(self) -> None:
|
|
179
|
+
def _run():
|
|
180
|
+
result = self.manager.start()
|
|
181
|
+
return result.message, result.success
|
|
182
|
+
|
|
183
|
+
def _done(result):
|
|
184
|
+
msg, ok = result
|
|
185
|
+
if not ok:
|
|
186
|
+
self.show_alert("Start failed", msg)
|
|
187
|
+
|
|
188
|
+
self.run_in_background(_run, _done)
|
|
189
|
+
|
|
190
|
+
def do_stop(self) -> None:
|
|
191
|
+
def _run():
|
|
192
|
+
result = self.manager.stop()
|
|
193
|
+
return result.message, result.success
|
|
194
|
+
|
|
195
|
+
def _done(result):
|
|
196
|
+
msg, ok = result
|
|
197
|
+
if not ok:
|
|
198
|
+
self.show_alert("Stop failed", msg)
|
|
199
|
+
|
|
200
|
+
self.run_in_background(_run, _done)
|
|
201
|
+
|
|
202
|
+
def do_restart(self) -> None:
|
|
203
|
+
def _run():
|
|
204
|
+
result = self.manager.restart()
|
|
205
|
+
return result.message, result.success
|
|
206
|
+
|
|
207
|
+
def _done(result):
|
|
208
|
+
msg, ok = result
|
|
209
|
+
if not ok:
|
|
210
|
+
self.show_alert("Restart failed", msg)
|
|
211
|
+
|
|
212
|
+
self.run_in_background(_run, _done)
|
|
213
|
+
|
|
214
|
+
def do_test(self, target: str = "") -> None:
|
|
215
|
+
if target:
|
|
216
|
+
def _run():
|
|
217
|
+
r = self.manager.test(target)
|
|
218
|
+
icon = "✓" if r.success else "✗"
|
|
219
|
+
lat = f" ({r.latency_ms:.0f}ms)" if r.latency_ms else ""
|
|
220
|
+
return f"{icon} {r.target}{lat}: {r.message}"
|
|
221
|
+
|
|
222
|
+
self.run_in_background(_run, lambda msg: self.show_output_dialog("Test result", msg))
|
|
223
|
+
else:
|
|
224
|
+
def _run():
|
|
225
|
+
results = self.manager.test_all()
|
|
226
|
+
lines = []
|
|
227
|
+
for r in results:
|
|
228
|
+
icon = "✓" if r.success else "✗"
|
|
229
|
+
lat = f" ({r.latency_ms:.0f}ms)" if r.latency_ms else ""
|
|
230
|
+
lines.append(f"{icon} {r.target}{lat}: {r.message}")
|
|
231
|
+
return "\n".join(lines) or "No PAC hosts configured."
|
|
232
|
+
|
|
233
|
+
self.run_in_background(_run, lambda msg: self.show_output_dialog("Test all results", msg))
|
|
234
|
+
|
|
235
|
+
def do_status(self) -> None:
|
|
236
|
+
def _run():
|
|
237
|
+
result = self.manager.status()
|
|
238
|
+
statuses = list(result.connection_statuses)
|
|
239
|
+
enabled = [s for s in statuses if s.enabled]
|
|
240
|
+
running_n = sum(1 for s in enabled if s.running)
|
|
241
|
+
disabled_n = sum(1 for s in statuses if not s.enabled)
|
|
242
|
+
|
|
243
|
+
state_dot = {
|
|
244
|
+
ProcessState.RUNNING: "●",
|
|
245
|
+
ProcessState.STOPPED_PARTIALLY: "◐",
|
|
246
|
+
ProcessState.STOPPED: "○",
|
|
247
|
+
ProcessState.ERROR: "✕",
|
|
248
|
+
ProcessState.INITIAL: "○",
|
|
249
|
+
}.get(result.state, "○")
|
|
250
|
+
|
|
251
|
+
summary = f"{running_n} of {len(enabled)} running"
|
|
252
|
+
if disabled_n:
|
|
253
|
+
summary += f" · {disabled_n} disabled"
|
|
254
|
+
|
|
255
|
+
tag_w = max((len(s.tag) for s in statuses), default=8)
|
|
256
|
+
tag_w = max(tag_w, 8)
|
|
257
|
+
|
|
258
|
+
lines = [
|
|
259
|
+
f"{state_dot} SusOps {result.state.value}",
|
|
260
|
+
f" {summary}",
|
|
261
|
+
"",
|
|
262
|
+
"CONNECTIONS",
|
|
263
|
+
]
|
|
264
|
+
if not statuses:
|
|
265
|
+
lines.append(" (no connections configured)")
|
|
266
|
+
else:
|
|
267
|
+
for cs in statuses:
|
|
268
|
+
if not cs.enabled:
|
|
269
|
+
dot = "─"
|
|
270
|
+
detail = "disabled"
|
|
271
|
+
elif cs.running:
|
|
272
|
+
dot = "●"
|
|
273
|
+
bits = []
|
|
274
|
+
if cs.socks_port:
|
|
275
|
+
bits.append(f"SOCKS {cs.socks_port}")
|
|
276
|
+
if cs.pid:
|
|
277
|
+
bits.append(f"pid {cs.pid}")
|
|
278
|
+
detail = " ".join(bits) if bits else "running"
|
|
279
|
+
else:
|
|
280
|
+
dot = "○"
|
|
281
|
+
detail = "stopped"
|
|
282
|
+
lines.append(f" {dot} {cs.tag:<{tag_w}} {detail}")
|
|
283
|
+
|
|
284
|
+
lines.append("")
|
|
285
|
+
lines.append("PAC SERVER")
|
|
286
|
+
if result.pac_running and result.pac_port:
|
|
287
|
+
lines.append(f" ● http://localhost:{result.pac_port}/susops.pac")
|
|
288
|
+
else:
|
|
289
|
+
lines.append(" ○ stopped")
|
|
290
|
+
|
|
291
|
+
# Daemon metadata — RPC port, PID, SSE port, workspace.
|
|
292
|
+
# These come from the local port/pid files written by
|
|
293
|
+
# services_daemon.py, not from the facade (the facade doesn't
|
|
294
|
+
# know which port its own RPC server is bound to).
|
|
295
|
+
lines.append("")
|
|
296
|
+
lines.append("DAEMON")
|
|
297
|
+
workspace = self.manager.workspace
|
|
298
|
+
try:
|
|
299
|
+
rpc_port = int((workspace / "pids" / "susops-services.port")
|
|
300
|
+
.read_text().strip())
|
|
301
|
+
lines.append(f" ● RPC http://localhost:{rpc_port}/rpc")
|
|
302
|
+
except (OSError, ValueError):
|
|
303
|
+
lines.append(" ○ RPC (port file unavailable)")
|
|
304
|
+
try:
|
|
305
|
+
pid = int((workspace / "pids" / "susops-services.pid")
|
|
306
|
+
.read_text().strip())
|
|
307
|
+
lines.append(f" PID {pid}")
|
|
308
|
+
except (OSError, ValueError):
|
|
309
|
+
pass
|
|
310
|
+
# Parse the SSE URL the facade exposes — same daemon process,
|
|
311
|
+
# different port (status_server_port from config).
|
|
312
|
+
try:
|
|
313
|
+
sse_url = self.manager.get_status_url() or ""
|
|
314
|
+
except Exception:
|
|
315
|
+
sse_url = ""
|
|
316
|
+
if sse_url:
|
|
317
|
+
lines.append(f" SSE {sse_url}")
|
|
318
|
+
lines.append(f" Workspace {workspace}")
|
|
319
|
+
return "\n".join(lines)
|
|
320
|
+
|
|
321
|
+
self.run_in_background(_run, lambda msg: self.show_output_dialog("Status", msg))
|
|
322
|
+
|
|
323
|
+
def do_logs(self, n: int = 500) -> None:
|
|
324
|
+
"""Show the in-memory log buffer (same source as the TUI 'Logs' tab).
|
|
325
|
+
|
|
326
|
+
Opens as a non-modal, live-updating window so the user can keep
|
|
327
|
+
interacting with the tray (and the logs scroll automatically as new
|
|
328
|
+
entries arrive).
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
def _get_text() -> str:
|
|
332
|
+
lines = self.manager.get_logs(n)
|
|
333
|
+
return "\n".join(lines) if lines else "(no log entries yet)"
|
|
334
|
+
|
|
335
|
+
self.show_live_logs(_get_text, title="Logs")
|
|
336
|
+
|
|
337
|
+
def do_add_connection(self, tag: str, host: str, port: int = 0) -> None:
|
|
338
|
+
try:
|
|
339
|
+
conn = self.manager.add_connection(tag, host, port)
|
|
340
|
+
except ValueError as e:
|
|
341
|
+
self.show_alert("Error", str(e))
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
# If the proxy is up, bring just the new connection online — don't
|
|
345
|
+
# restart everything (that would tear down every other working
|
|
346
|
+
# tunnel and reconnect them needlessly).
|
|
347
|
+
if self.state == ProcessState.RUNNING:
|
|
348
|
+
def _run():
|
|
349
|
+
result = self.manager.start(tag=conn.tag)
|
|
350
|
+
return result.message, result.success
|
|
351
|
+
|
|
352
|
+
def _done(result):
|
|
353
|
+
msg, ok = result
|
|
354
|
+
if not ok:
|
|
355
|
+
self.show_alert("Start failed", msg)
|
|
356
|
+
else:
|
|
357
|
+
self.show_alert("Added", f"Connection '{conn.tag}' → {conn.ssh_host}")
|
|
358
|
+
|
|
359
|
+
self.run_in_background(_run, _done)
|
|
360
|
+
else:
|
|
361
|
+
self.show_alert("Added", f"Connection '{conn.tag}' → {conn.ssh_host}")
|
|
362
|
+
|
|
363
|
+
def do_remove_connection(self, tag: str) -> None:
|
|
364
|
+
# facade.remove_connection() already stops the tunnel for this tag
|
|
365
|
+
# and regenerates the PAC; no global restart is needed (and the
|
|
366
|
+
# restart would tear down every other working tunnel).
|
|
367
|
+
try:
|
|
368
|
+
self.manager.remove_connection(tag)
|
|
369
|
+
except ValueError as e:
|
|
370
|
+
self.show_alert("Error", str(e))
|
|
371
|
+
|
|
372
|
+
def do_add_pac_host(self, host: str, conn_tag: str | None = None) -> None:
|
|
373
|
+
try:
|
|
374
|
+
self.manager.add_pac_host(host, conn_tag=conn_tag)
|
|
375
|
+
except ValueError as e:
|
|
376
|
+
self.show_alert("Error", str(e))
|
|
377
|
+
|
|
378
|
+
def do_remove_pac_host(self, host: str) -> None:
|
|
379
|
+
try:
|
|
380
|
+
self.manager.remove_pac_host(host)
|
|
381
|
+
except ValueError as e:
|
|
382
|
+
self.show_alert("Error", str(e))
|
|
383
|
+
|
|
384
|
+
def do_toggle_connection_enabled(self, tag: str) -> None:
|
|
385
|
+
def _run():
|
|
386
|
+
try:
|
|
387
|
+
cfg = self.manager.list_config()
|
|
388
|
+
conn = next((c for c in cfg.connections if c.tag == tag), None)
|
|
389
|
+
if conn is None:
|
|
390
|
+
return f"Connection '{tag}' not found."
|
|
391
|
+
new_state = not conn.enabled
|
|
392
|
+
self.manager.set_connection_enabled(tag, new_state)
|
|
393
|
+
return f"Connection '{tag}' {'enabled' if new_state else 'disabled'}."
|
|
394
|
+
except Exception as e:
|
|
395
|
+
return f"Error: {e}"
|
|
396
|
+
|
|
397
|
+
self.run_in_background(_run, lambda msg: self.show_alert("Toggle Connection", msg))
|
|
398
|
+
|
|
399
|
+
def do_start_connection(self, tag: str) -> None:
|
|
400
|
+
def _run():
|
|
401
|
+
result = self.manager.start(tag=tag)
|
|
402
|
+
return result.message, result.success
|
|
403
|
+
|
|
404
|
+
def _done(r):
|
|
405
|
+
msg, ok = r
|
|
406
|
+
if not ok:
|
|
407
|
+
self.show_alert("Start failed", msg)
|
|
408
|
+
|
|
409
|
+
self.run_in_background(_run, _done)
|
|
410
|
+
|
|
411
|
+
def do_stop_connection(self, tag: str) -> None:
|
|
412
|
+
def _run():
|
|
413
|
+
result = self.manager.stop(tag=tag)
|
|
414
|
+
return result.message, result.success
|
|
415
|
+
|
|
416
|
+
def _done(r):
|
|
417
|
+
msg, ok = r
|
|
418
|
+
if not ok:
|
|
419
|
+
self.show_alert("Stop failed", msg)
|
|
420
|
+
|
|
421
|
+
self.run_in_background(_run, _done)
|
|
422
|
+
|
|
423
|
+
def do_restart_connection(self, tag: str) -> None:
|
|
424
|
+
def _run():
|
|
425
|
+
result = self.manager.restart(tag=tag)
|
|
426
|
+
return result.message, result.success
|
|
427
|
+
|
|
428
|
+
def _done(r):
|
|
429
|
+
msg, ok = r
|
|
430
|
+
if not ok:
|
|
431
|
+
self.show_alert("Restart failed", msg)
|
|
432
|
+
|
|
433
|
+
self.run_in_background(_run, _done)
|
|
434
|
+
|
|
435
|
+
def do_toggle_pac_host_enabled(self, host: str) -> None:
|
|
436
|
+
def _run():
|
|
437
|
+
try:
|
|
438
|
+
cfg = self.manager.list_config()
|
|
439
|
+
all_disabled = [h for c in cfg.connections for h in c.pac_hosts_disabled]
|
|
440
|
+
currently_disabled = host in all_disabled
|
|
441
|
+
self.manager.set_pac_host_enabled(host, currently_disabled) # flip
|
|
442
|
+
return f"Domain '{host}' {'enabled' if currently_disabled else 'disabled'}."
|
|
443
|
+
except Exception as e:
|
|
444
|
+
return f"Error: {e}"
|
|
445
|
+
|
|
446
|
+
self.run_in_background(_run, lambda msg: self.show_alert("Toggle Domain", msg))
|
|
447
|
+
|
|
448
|
+
def do_toggle_forward_enabled(self, conn_tag: str, src_port: int, direction: str) -> None:
|
|
449
|
+
def _run():
|
|
450
|
+
try:
|
|
451
|
+
self.manager.toggle_forward_enabled(conn_tag, src_port, direction)
|
|
452
|
+
return f"Forward :{src_port} toggled."
|
|
453
|
+
except Exception as e:
|
|
454
|
+
return f"Error: {e}"
|
|
455
|
+
|
|
456
|
+
self.run_in_background(_run, lambda msg: self.show_alert("Toggle Forward", msg))
|
|
457
|
+
|
|
458
|
+
def do_test_connection(self, conn_tag: str) -> None:
|
|
459
|
+
def _run():
|
|
460
|
+
result = self.manager.test_connection(conn_tag)
|
|
461
|
+
icon = "✓" if result.success else "✗"
|
|
462
|
+
lat = f" ({result.latency_ms:.0f} ms)" if result.latency_ms else ""
|
|
463
|
+
return f"{icon} {conn_tag}{lat}: {result.message}"
|
|
464
|
+
|
|
465
|
+
self.run_in_background(_run, lambda msg: self.show_output_dialog(f"Test: {conn_tag}", msg))
|
|
466
|
+
|
|
467
|
+
def do_test_domain(self, host: str, conn_tag: str) -> None:
|
|
468
|
+
def _run():
|
|
469
|
+
result = self.manager.test_domain(host, conn_tag)
|
|
470
|
+
icon = "✓" if result.success else "✗"
|
|
471
|
+
lat = f" ({result.latency_ms:.0f} ms)" if result.latency_ms else ""
|
|
472
|
+
return f"{icon} [{conn_tag}] {host}{lat}: {result.message}"
|
|
473
|
+
|
|
474
|
+
self.run_in_background(_run, lambda msg: self.show_output_dialog(f"Test: {host}", msg))
|
|
475
|
+
|
|
476
|
+
def do_test_forward(self, conn_tag: str, src_port: int, direction: str) -> None:
|
|
477
|
+
def _run():
|
|
478
|
+
try:
|
|
479
|
+
results = self.manager.test_forward(conn_tag, src_port, direction)
|
|
480
|
+
lines = []
|
|
481
|
+
for proto, ok in results.items():
|
|
482
|
+
icon = "✓" if ok else "✗"
|
|
483
|
+
if proto == "tcp":
|
|
484
|
+
detail = "port bound" if ok else "port not bound"
|
|
485
|
+
if direction == "remote":
|
|
486
|
+
detail = "master socket alive" if ok else "master socket dead"
|
|
487
|
+
else:
|
|
488
|
+
detail = "socat running" if ok else "socat not running"
|
|
489
|
+
lines.append(f"{icon} {proto.upper()}: {detail}")
|
|
490
|
+
return "\n".join(lines) or "No results."
|
|
491
|
+
except Exception as e:
|
|
492
|
+
return f"Error: {e}"
|
|
493
|
+
|
|
494
|
+
self.run_in_background(_run, lambda msg: self.show_output_dialog(f"Test: {direction} :{src_port}", msg))
|
|
495
|
+
|
|
496
|
+
def do_add_local_forward(self, conn_tag: str, fw: PortForward) -> None:
|
|
497
|
+
try:
|
|
498
|
+
# Facade starts the slave immediately if ControlMaster is running — no restart needed.
|
|
499
|
+
self.manager.add_local_forward(conn_tag, fw)
|
|
500
|
+
except ValueError as e:
|
|
501
|
+
self.show_alert("Error", str(e))
|
|
502
|
+
|
|
503
|
+
def do_add_remote_forward(self, conn_tag: str, fw: PortForward) -> None:
|
|
504
|
+
try:
|
|
505
|
+
self.manager.add_remote_forward(conn_tag, fw)
|
|
506
|
+
except ValueError as e:
|
|
507
|
+
self.show_alert("Error", str(e))
|
|
508
|
+
|
|
509
|
+
def do_remove_local_forward(self, port: int) -> None:
|
|
510
|
+
try:
|
|
511
|
+
# Facade kills the slave immediately — no restart needed.
|
|
512
|
+
self.manager.remove_local_forward(port)
|
|
513
|
+
except ValueError as e:
|
|
514
|
+
self.show_alert("Error", str(e))
|
|
515
|
+
|
|
516
|
+
def do_remove_remote_forward(self, port: int) -> None:
|
|
517
|
+
try:
|
|
518
|
+
self.manager.remove_remote_forward(port)
|
|
519
|
+
except ValueError as e:
|
|
520
|
+
self.show_alert("Error", str(e))
|
|
521
|
+
|
|
522
|
+
def do_launch_chrome(self) -> None:
|
|
523
|
+
import shutil, subprocess
|
|
524
|
+
pac_url = self.manager.get_pac_url()
|
|
525
|
+
if not pac_url:
|
|
526
|
+
self.show_alert("Error", "PAC server is not running")
|
|
527
|
+
return
|
|
528
|
+
for browser in ("google-chrome-stable", "google-chrome", "chromium", "chromium-browser", "brave-browser"):
|
|
529
|
+
if shutil.which(browser):
|
|
530
|
+
subprocess.Popen([browser, f"--proxy-pac-url={pac_url}"])
|
|
531
|
+
return
|
|
532
|
+
self.show_alert("Error", "No Chrome/Chromium browser found")
|
|
533
|
+
|
|
534
|
+
def do_launch_firefox(self) -> None:
|
|
535
|
+
import shutil, subprocess
|
|
536
|
+
pac_url = self.manager.get_pac_url()
|
|
537
|
+
if not pac_url:
|
|
538
|
+
self.show_alert("Error", "PAC server is not running")
|
|
539
|
+
return
|
|
540
|
+
profile_dir = self.manager.workspace / "firefox_profile"
|
|
541
|
+
profile_dir.mkdir(exist_ok=True)
|
|
542
|
+
(profile_dir / "user.js").write_text(
|
|
543
|
+
f'user_pref("network.proxy.type", 2);\n'
|
|
544
|
+
f'user_pref("network.proxy.autoconfig_url", "{pac_url}");\n'
|
|
545
|
+
f'user_pref("network.proxy.no_proxies_on", "localhost, 127.0.0.1");\n'
|
|
546
|
+
)
|
|
547
|
+
if shutil.which("firefox"):
|
|
548
|
+
subprocess.Popen(["firefox", "-profile", str(profile_dir), "-no-remote"])
|
|
549
|
+
else:
|
|
550
|
+
self.show_alert("Error", "Firefox not found")
|
|
551
|
+
|
|
552
|
+
def do_open_config_file(self) -> None:
|
|
553
|
+
import subprocess, shutil
|
|
554
|
+
config_path = self.manager.workspace / "config.yaml"
|
|
555
|
+
for opener in ("xdg-open", "open", "notepad"):
|
|
556
|
+
if shutil.which(opener):
|
|
557
|
+
subprocess.Popen([opener, str(config_path)])
|
|
558
|
+
return
|
|
559
|
+
|
|
560
|
+
# ------------------------------------------------------------------ #
|
|
561
|
+
# File sharing
|
|
562
|
+
# ------------------------------------------------------------------ #
|
|
563
|
+
|
|
564
|
+
def do_share(
|
|
565
|
+
self,
|
|
566
|
+
conn_tag: str,
|
|
567
|
+
file_path: str,
|
|
568
|
+
password: str | None = None,
|
|
569
|
+
port: int = 0,
|
|
570
|
+
) -> None:
|
|
571
|
+
def _run():
|
|
572
|
+
try:
|
|
573
|
+
info = self.manager.share(
|
|
574
|
+
__import__("pathlib").Path(file_path),
|
|
575
|
+
conn_tag,
|
|
576
|
+
password=password or None,
|
|
577
|
+
port=port or None,
|
|
578
|
+
)
|
|
579
|
+
return info, None
|
|
580
|
+
except Exception as exc:
|
|
581
|
+
return None, str(exc)
|
|
582
|
+
|
|
583
|
+
def _done(result):
|
|
584
|
+
info, err = result
|
|
585
|
+
if err:
|
|
586
|
+
self.show_alert("Share Failed", err)
|
|
587
|
+
elif info:
|
|
588
|
+
self.show_alert(
|
|
589
|
+
"Share Started",
|
|
590
|
+
f"Sharing {__import__('pathlib').Path(info.file_path).name}\n"
|
|
591
|
+
f"Port: {info.port}\n"
|
|
592
|
+
f"Password: {info.password}",
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
self.run_in_background(_run, _done)
|
|
596
|
+
|
|
597
|
+
def do_stop_share(self, port: int | None = None) -> None:
|
|
598
|
+
def _run():
|
|
599
|
+
self.manager.stop_share(port)
|
|
600
|
+
|
|
601
|
+
self.run_in_background(_run)
|
|
602
|
+
|
|
603
|
+
def do_delete_share(self, port: int) -> None:
|
|
604
|
+
def _run():
|
|
605
|
+
self.manager.delete_share(port)
|
|
606
|
+
|
|
607
|
+
self.run_in_background(_run)
|
|
608
|
+
|
|
609
|
+
def do_fetch(
|
|
610
|
+
self,
|
|
611
|
+
conn_tag: str,
|
|
612
|
+
port: int,
|
|
613
|
+
password: str,
|
|
614
|
+
outfile: str | None = None,
|
|
615
|
+
) -> None:
|
|
616
|
+
def _run():
|
|
617
|
+
try:
|
|
618
|
+
out = __import__("pathlib").Path(outfile) if outfile else None
|
|
619
|
+
result = self.manager.fetch(
|
|
620
|
+
port=port, password=password, conn_tag=conn_tag, outfile=out
|
|
621
|
+
)
|
|
622
|
+
return str(result), None
|
|
623
|
+
except Exception as exc:
|
|
624
|
+
return None, str(exc)
|
|
625
|
+
|
|
626
|
+
def _done(result):
|
|
627
|
+
path, err = result
|
|
628
|
+
if err:
|
|
629
|
+
self.show_alert("Fetch Failed", err)
|
|
630
|
+
else:
|
|
631
|
+
self.show_alert("Download Complete", f"Saved to:\n{path}")
|
|
632
|
+
|
|
633
|
+
self.run_in_background(_run, _done)
|
|
634
|
+
|
|
635
|
+
def do_list_shares(self) -> list:
|
|
636
|
+
return self.manager.list_shares()
|
|
637
|
+
|
|
638
|
+
def do_reset(self, force: bool = False) -> None:
|
|
639
|
+
self.manager.reset()
|
|
640
|
+
self.show_alert("Reset", "Workspace has been reset.")
|
|
641
|
+
|
|
642
|
+
def do_quit(self) -> None:
|
|
643
|
+
if self.manager.app_config.stop_on_quit:
|
|
644
|
+
self.manager.stop()
|
|
645
|
+
# else: the daemon is a separate process — it keeps running with
|
|
646
|
+
# PAC server, status SSE, and reconnect monitor independent of the
|
|
647
|
+
# tray's lifetime. No detach calls needed.
|
|
648
|
+
|
|
649
|
+
def _should_restart_after_change(self) -> bool:
|
|
650
|
+
return self.state == ProcessState.RUNNING
|