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.
@@ -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