susops 3.0.0rc3.dev8__tar.gz → 3.0.0rc3.dev9__tar.gz

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.
Files changed (96) hide show
  1. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/PKG-INFO +1 -1
  2. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/pyproject.toml +1 -1
  3. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/client.py +18 -0
  4. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/core/rpc_server.py +2 -0
  5. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/facade.py +50 -13
  6. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/tray/base.py +9 -1
  7. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/tui/app.py +10 -3
  8. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops.egg-info/PKG-INFO +1 -1
  9. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/LICENSE +0 -0
  10. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/README.md +0 -0
  11. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/setup.cfg +0 -0
  12. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/__init__.py +0 -0
  13. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icon.png +0 -0
  14. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_glasses/dark/error.svg +0 -0
  15. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_glasses/dark/running.svg +0 -0
  16. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_glasses/dark/stopped.svg +0 -0
  17. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_glasses/dark/stopped_partially.svg +0 -0
  18. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_glasses/light/error.svg +0 -0
  19. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_glasses/light/running.svg +0 -0
  20. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_glasses/light/stopped.svg +0 -0
  21. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_glasses/light/stopped_partially.svg +0 -0
  22. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_s/dark/error.svg +0 -0
  23. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_s/dark/running.svg +0 -0
  24. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_s/dark/stopped.svg +0 -0
  25. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_s/dark/stopped_partially.svg +0 -0
  26. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_s/light/error.svg +0 -0
  27. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_s/light/running.svg +0 -0
  28. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_s/light/stopped.svg +0 -0
  29. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_s/light/stopped_partially.svg +0 -0
  30. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/gear/dark/error.svg +0 -0
  31. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/gear/dark/running.svg +0 -0
  32. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/gear/dark/stopped.svg +0 -0
  33. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/gear/dark/stopped_partially.svg +0 -0
  34. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/gear/light/error.svg +0 -0
  35. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/gear/light/running.svg +0 -0
  36. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/gear/light/stopped.svg +0 -0
  37. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/gear/light/stopped_partially.svg +0 -0
  38. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/status/error.svg +0 -0
  39. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/status/running.svg +0 -0
  40. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/status/stopped.svg +0 -0
  41. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/status/stopped_partially.svg +0 -0
  42. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/core/__init__.py +0 -0
  43. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/core/browsers.py +0 -0
  44. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/core/config.py +0 -0
  45. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/core/log_style.py +0 -0
  46. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/core/pac.py +0 -0
  47. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/core/ports.py +0 -0
  48. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/core/process.py +0 -0
  49. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/core/rpc_protocol.py +0 -0
  50. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/core/services_daemon.py +0 -0
  51. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/core/share.py +0 -0
  52. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/core/socat.py +0 -0
  53. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/core/ssh.py +0 -0
  54. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/core/ssh_config.py +0 -0
  55. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/core/status.py +0 -0
  56. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/core/types.py +0 -0
  57. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/tray/__init__.py +0 -0
  58. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/tray/linux.py +0 -0
  59. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/tray/mac.py +0 -0
  60. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/tui/__init__.py +0 -0
  61. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/tui/__main__.py +0 -0
  62. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/tui/app.tcss +0 -0
  63. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/tui/cli.py +0 -0
  64. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/tui/screens/__init__.py +0 -0
  65. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/tui/screens/connections.py +0 -0
  66. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/tui/screens/dashboard.py +0 -0
  67. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/tui/screens/shares.py +0 -0
  68. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/tui/widgets/__init__.py +0 -0
  69. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/tui/widgets/connection_card.py +0 -0
  70. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops/version.py +0 -0
  71. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops.egg-info/SOURCES.txt +0 -0
  72. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops.egg-info/dependency_links.txt +0 -0
  73. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops.egg-info/entry_points.txt +0 -0
  74. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops.egg-info/requires.txt +0 -0
  75. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/src/susops.egg-info/top_level.txt +0 -0
  76. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_bw_totals.py +0 -0
  77. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_cli.py +0 -0
  78. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_client.py +0 -0
  79. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_config.py +0 -0
  80. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_conftest_smoke.py +0 -0
  81. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_facade.py +0 -0
  82. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_openapi.py +0 -0
  83. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_pac.py +0 -0
  84. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_packaging.py +0 -0
  85. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_process.py +0 -0
  86. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_rpc_protocol.py +0 -0
  87. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_rpc_server.py +0 -0
  88. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_scripts.py +0 -0
  89. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_services_daemon.py +0 -0
  90. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_share.py +0 -0
  91. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_share_aiohttp.py +0 -0
  92. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_socat.py +0 -0
  93. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_ssh.py +0 -0
  94. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_status.py +0 -0
  95. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_update_homebrew_sha.py +0 -0
  96. {susops-3.0.0rc3.dev8 → susops-3.0.0rc3.dev9}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: susops
3
- Version: 3.0.0rc3.dev8
3
+ Version: 3.0.0rc3.dev9
4
4
  Summary: SusOps — SSH SOCKS5 proxy manager with PAC server, Textual TUI, and system tray apps
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "susops"
7
- version = "3.0.0-rc3.dev8"
7
+ version = "3.0.0-rc3.dev9"
8
8
  description = "SusOps — SSH SOCKS5 proxy manager with PAC server, Textual TUI, and system tray apps"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -69,6 +69,24 @@ def ensure_daemon_running(workspace: Path = _WORKSPACE_DEFAULT) -> int:
69
69
  port = _read_port(workspace)
70
70
  if port:
71
71
  return port
72
+ # Daemon claimed its PID file but hasn't published the port yet.
73
+ # Wait for it instead of spawning a competitor that would race the
74
+ # O_EXCL claim and exit rc=2 ("another daemon is already running").
75
+ deadline = time.monotonic() + _DAEMON_SPAWN_TIMEOUT
76
+ while time.monotonic() < deadline:
77
+ if not _is_daemon_alive(workspace):
78
+ break # died mid-startup, fall through to spawn a new one
79
+ port = _read_port(workspace)
80
+ if port:
81
+ return port
82
+ time.sleep(0.1)
83
+ else:
84
+ raise DaemonUnavailableError(
85
+ "An existing susops-services daemon is alive but never "
86
+ "published an RPC port. Try `kill " +
87
+ (_pid_path(workspace).read_text().strip() or "<pid>") +
88
+ "` and start again."
89
+ )
72
90
 
73
91
  proc = subprocess.Popen(
74
92
  [sys.executable, "-m", "susops.core.services_daemon",
@@ -46,6 +46,8 @@ _ALLOWED_METHODS: set[str] = {
46
46
  # Bandwidth
47
47
  "get_bandwidth", "get_bandwidth_totals", "get_bandwidth_global",
48
48
  "get_bandwidth_history",
49
+ # Frontend coordination
50
+ "sse_client_count",
49
51
  # Reconnect introspection
50
52
  "reconnect_monitor_info",
51
53
  # Process introspection
@@ -625,25 +625,53 @@ class SusOpsManager:
625
625
  print(full, file=sys.stderr)
626
626
 
627
627
  def _notify(self, title: str, body: str) -> None:
628
- """Send a desktop notification. Best-effort — fails silently."""
628
+ """Send a desktop notification with the SusOps icon. Best-effort."""
629
629
  import platform
630
- import subprocess
630
+ from pathlib import Path
631
+ icon = Path(__file__).parent / "assets" / "icon.png"
631
632
  try:
632
633
  if platform.system() == "Darwin":
633
- subprocess.Popen(
634
- ["osascript", "-e",
635
- f'display notification "{body}" with title "{title}"'],
636
- stdout=subprocess.DEVNULL,
637
- stderr=subprocess.DEVNULL,
638
- )
634
+ self._notify_macos(title, body, icon)
639
635
  elif platform.system() == "Linux":
640
- subprocess.Popen(
641
- ["notify-send", title, body],
642
- stdout=subprocess.DEVNULL,
643
- stderr=subprocess.DEVNULL,
644
- )
636
+ self._notify_linux(title, body, icon)
637
+ except Exception:
638
+ pass
639
+
640
+ @staticmethod
641
+ def _notify_macos(title: str, body: str, icon: "Path") -> None:
642
+ # NSUserNotification's setContentImage_ puts the SusOps icon on the
643
+ # right side of the banner. The source-app slot (left side) reflects
644
+ # the running process — bundled .app shows SusOps, raw pip install
645
+ # shows Python. osascript can't set either, so it loses both.
646
+ try:
647
+ from Foundation import NSUserNotification, NSUserNotificationCenter # type: ignore
648
+ from AppKit import NSImage # type: ignore
649
+ n = NSUserNotification.alloc().init()
650
+ n.setTitle_(title)
651
+ n.setInformativeText_(body)
652
+ if icon.exists():
653
+ img = NSImage.alloc().initWithContentsOfFile_(str(icon))
654
+ if img is not None:
655
+ n.setContentImage_(img)
656
+ NSUserNotificationCenter.defaultUserNotificationCenter().deliverNotification_(n)
657
+ return
645
658
  except Exception:
646
659
  pass
660
+ # Fallback for non-PyObjC environments
661
+ import subprocess
662
+ subprocess.Popen(
663
+ ["osascript", "-e", f'display notification "{body}" with title "{title}"'],
664
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
665
+ )
666
+
667
+ @staticmethod
668
+ def _notify_linux(title: str, body: str, icon: "Path") -> None:
669
+ import subprocess
670
+ cmd = ["notify-send"]
671
+ if icon.exists():
672
+ cmd += ["-i", str(icon)]
673
+ cmd += [title, body]
674
+ subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
647
675
 
648
676
  def _emit(self, event: str, data: dict) -> None:
649
677
  """Emit an SSE event and log it when verbose (bandwidth excluded — too noisy)."""
@@ -2152,6 +2180,15 @@ class SusOpsManager:
2152
2180
  """Return persisted [rx_bps, tx_bps] samples for *tag* (oldest → newest)."""
2153
2181
  return self._bw_sampler.get_history(tag)
2154
2182
 
2183
+ def sse_client_count(self) -> int:
2184
+ """Live SSE subscriber count. Used by frontends to decide whether
2185
+ a stop-on-quit should actually stop, or skip because another
2186
+ frontend is still attached to the same daemon."""
2187
+ try:
2188
+ return int(self._status_server.client_count())
2189
+ except Exception:
2190
+ return 0
2191
+
2155
2192
  def get_bandwidth_global(self) -> tuple[float, float]:
2156
2193
  """Return (rx_bps, tx_bps) summed across every connection."""
2157
2194
  rx_total = 0.0
@@ -641,7 +641,15 @@ class AbstractTrayApp(ABC):
641
641
 
642
642
  def do_quit(self) -> None:
643
643
  if self.manager.app_config.stop_on_quit:
644
- self.manager.stop()
644
+ # Skip the destructive stop when another frontend (e.g. TUI)
645
+ # is still attached, otherwise it ends up with its SSH
646
+ # masters / PAC / reconnect torn out from under it.
647
+ try:
648
+ other_clients = int(self.manager.sse_client_count()) - 1
649
+ except Exception:
650
+ other_clients = 0
651
+ if other_clients <= 0:
652
+ self.manager.stop()
645
653
  # else: the daemon is a separate process — it keeps running with
646
654
  # PAC server, status SSE, and reconnect monitor independent of the
647
655
  # tray's lifetime. No detach calls needed.
@@ -121,9 +121,16 @@ class SusOpsTuiApp(App):
121
121
 
122
122
  def action_quit(self) -> None:
123
123
  if self.manager.app_config.stop_on_quit:
124
- # Stops SSH tunnels + share servers in the daemon; PAC stays up
125
- # via stopped-marker tombstones unless the user reset.
126
- self.manager.stop_quick()
124
+ # Skip the destructive stop when another frontend (e.g. tray)
125
+ # is still attached. Otherwise the still-running frontend
126
+ # finds its SSH masters / PAC / reconnect monitor torn out
127
+ # from under it and looks broken until it's restarted.
128
+ try:
129
+ other_clients = int(self.manager.sse_client_count()) - 1
130
+ except Exception:
131
+ other_clients = 0
132
+ if other_clients <= 0:
133
+ self.manager.stop_quick()
127
134
  # No detach calls — the daemon is already a separate process and
128
135
  # outlives the TUI.
129
136
  self.exit()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: susops
3
- Version: 3.0.0rc3.dev8
3
+ Version: 3.0.0rc3.dev9
4
4
  Summary: SusOps — SSH SOCKS5 proxy manager with PAC server, Textual TUI, and system tray apps
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
File without changes
File without changes
File without changes