susops 3.0.0rc4.dev2__tar.gz → 3.0.0rc6.dev1__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 (100) hide show
  1. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/PKG-INFO +74 -7
  2. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/README.md +73 -6
  3. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/pyproject.toml +1 -1
  4. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/client.py +24 -2
  5. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/config.py +12 -18
  6. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/pac.py +12 -2
  7. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/rpc_server.py +1 -0
  8. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/services_daemon.py +8 -4
  9. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/ssh.py +35 -0
  10. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/facade.py +388 -228
  11. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tray/base.py +36 -11
  12. susops-3.0.0rc6.dev1/src/susops/tray/config_window_model.py +533 -0
  13. susops-3.0.0rc6.dev1/src/susops/tray/debug_server.py +72 -0
  14. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tray/linux.py +10 -0
  15. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tray/mac.py +1286 -1204
  16. susops-3.0.0rc6.dev1/src/susops/tray/mac_config_window.py +3208 -0
  17. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/cli.py +9 -7
  18. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/screens/dashboard.py +18 -2
  19. susops-3.0.0rc6.dev1/src/susops/version.py +15 -0
  20. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops.egg-info/PKG-INFO +74 -7
  21. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops.egg-info/SOURCES.txt +3 -0
  22. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_client.py +26 -6
  23. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_config.py +37 -0
  24. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_facade.py +264 -0
  25. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_pac.py +24 -0
  26. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_services_daemon.py +50 -0
  27. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_update_homebrew_sha.py +2 -2
  28. susops-3.0.0rc4.dev2/src/susops/version.py +0 -12
  29. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/LICENSE +0 -0
  30. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/setup.cfg +0 -0
  31. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/__init__.py +0 -0
  32. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icon.png +0 -0
  33. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/dark/error.svg +0 -0
  34. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/dark/running.svg +0 -0
  35. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/dark/stopped.svg +0 -0
  36. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/dark/stopped_partially.svg +0 -0
  37. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/light/error.svg +0 -0
  38. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/light/running.svg +0 -0
  39. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/light/stopped.svg +0 -0
  40. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/light/stopped_partially.svg +0 -0
  41. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/dark/error.svg +0 -0
  42. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/dark/running.svg +0 -0
  43. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/dark/stopped.svg +0 -0
  44. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/dark/stopped_partially.svg +0 -0
  45. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/light/error.svg +0 -0
  46. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/light/running.svg +0 -0
  47. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/light/stopped.svg +0 -0
  48. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/light/stopped_partially.svg +0 -0
  49. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/dark/error.svg +0 -0
  50. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/dark/running.svg +0 -0
  51. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/dark/stopped.svg +0 -0
  52. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/dark/stopped_partially.svg +0 -0
  53. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/light/error.svg +0 -0
  54. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/light/running.svg +0 -0
  55. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/light/stopped.svg +0 -0
  56. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/light/stopped_partially.svg +0 -0
  57. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/status/error.svg +0 -0
  58. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/status/running.svg +0 -0
  59. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/status/stopped.svg +0 -0
  60. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/status/stopped_partially.svg +0 -0
  61. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/__init__.py +0 -0
  62. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/browsers.py +0 -0
  63. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/log_style.py +0 -0
  64. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/ports.py +0 -0
  65. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/process.py +0 -0
  66. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/rpc_protocol.py +0 -0
  67. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/share.py +0 -0
  68. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/socat.py +0 -0
  69. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/ssh_config.py +0 -0
  70. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/status.py +0 -0
  71. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/types.py +0 -0
  72. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tray/__init__.py +0 -0
  73. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/__init__.py +0 -0
  74. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/__main__.py +0 -0
  75. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/app.py +0 -0
  76. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/app.tcss +0 -0
  77. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/screens/__init__.py +0 -0
  78. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/screens/connections.py +0 -0
  79. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/screens/shares.py +0 -0
  80. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/widgets/__init__.py +0 -0
  81. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/widgets/connection_card.py +0 -0
  82. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops.egg-info/dependency_links.txt +0 -0
  83. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops.egg-info/entry_points.txt +0 -0
  84. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops.egg-info/requires.txt +0 -0
  85. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops.egg-info/top_level.txt +0 -0
  86. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_bw_totals.py +0 -0
  87. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_cli.py +0 -0
  88. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_conftest_smoke.py +0 -0
  89. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_openapi.py +0 -0
  90. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_packaging.py +0 -0
  91. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_process.py +0 -0
  92. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_rpc_protocol.py +0 -0
  93. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_rpc_server.py +0 -0
  94. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_scripts.py +0 -0
  95. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_share.py +0 -0
  96. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_share_aiohttp.py +0 -0
  97. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_socat.py +0 -0
  98. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_ssh.py +0 -0
  99. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_status.py +0 -0
  100. {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: susops
3
- Version: 3.0.0rc4.dev2
3
+ Version: 3.0.0rc6.dev1
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
@@ -315,7 +315,7 @@ susops
315
315
 
316
316
  ### Screens
317
317
 
318
- **Dashboard** (default) — split-pane view. Left sidebar shows all connections (status dot, SOCKS port, live throughput), PAC server status, and active file shares. Right panel is tabbed:
318
+ **Dashboard** (default) — split-pane view. Left sidebar shows all connections (status dot — `●` green running, `◐` orange pending, `○` dim stopped — SOCKS port, live throughput), PAC server status, and active file shares. Right panel is tabbed:
319
319
  - **Stats** — CPU%, memory, active connections, PID for the selected connection
320
320
  - **Bandwidth** — live RX and TX line charts (PlotextPlot, 60-sample rolling window, auto-scaled units)
321
321
  - **Forwards** — DataTable of all port forwards (direction, local port, local bind, remote port, remote bind, label)
@@ -436,9 +436,11 @@ susops reset [--force] # Kill all processes, wipe ~/.susops workspace
436
436
  |------|---------|
437
437
  | `0` | Success / all running |
438
438
  | `1` | Error |
439
- | `2` | Partial (some services stopped) |
439
+ | `2` | Partial (some services stopped, or any connection pending) |
440
440
  | `3` | All stopped |
441
441
 
442
+ A connection is **pending** when its SSH master is alive but the ControlMaster socket isn't up yet — typically while ssh-agent is waiting on a key unlock, or between reconnect attempts. `start` returns as soon as the master spawns; auth then completes asynchronously up to 60 s later. Slow agent prompts (Vaultwarden, 1Password, hardware keys) no longer freeze the UI.
443
+
442
444
  ---
443
445
 
444
446
  ## System Tray
@@ -461,7 +463,35 @@ susops-tray
461
463
 
462
464
  Requires `rumps`: `pip install "susops[tray-mac]"`
463
465
 
464
- The tray icon reflects the current state (running/partial/stopped). State changes arrive over the daemon's SSE `/events` stream. If the stream drops, the listener reconnects within at most 5 seconds. Both tray implementations support the same feature set via native dialogs:
466
+ The tray icon reflects the current state (running / partial-or-pending / stopped). State changes arrive over the daemon's SSE `/events` stream. If the stream drops, the listener reconnects within at most 5 seconds. When TUI + tray are both attached to the same daemon, quitting one keeps the other running — `stop_on_quit` is skipped if any other frontend is still connected.
467
+
468
+ **Note: the macOS and Linux tray apps diverge in their UI structure.** The 3-column config window described below is macOS-only for now; the Linux tray keeps the classic submenu structure.
469
+
470
+ ### macOS tray
471
+
472
+ The macOS tray has a slim menu with a unified **config window** (open with Settings… ⌘, or from the tray menu):
473
+
474
+ Slim menu items:
475
+ - **Status line** — shows current connection state
476
+ - **Settings… ⌘,** — opens the unified config window
477
+ - **Start / Stop / Restart Proxy** — bulk lifecycle for all connections
478
+ - **Show Status** — print current state to stdout
479
+ - **Show Logs** — open the log directory
480
+ - **Launch Browser ▸** — submenu: Chrome or Firefox with the PAC URL pre-configured
481
+ - **Reset All** — kill all processes and wipe the workspace
482
+ - **About SusOps**
483
+ - **Quit ⌘Q**
484
+
485
+ The **config window** is a 3-column Tailscale-style editor (nav / list / detail) with a dark skin and instant-apply settings:
486
+
487
+ - **Column 1 — nav.** Categories with live counts: **Connections / Domains / Forwards / Shares**, plus **Settings** pinned at the bottom. Selecting a category drives the list.
488
+ - **Column 2 — list.** A search field on top filters the rows for the selected category. Forwards split into **Local** and **Remote** sections (each with a one-line explainer); shares show download counts. Each row carries a colored run-state dot (green active, amber pending, gray stopped/inactive, red error/connection-down) and a connection badge; disabled rows render dimmed. A context-aware add button sits at the bottom (`+ Add Connection`, `+ Add Domain / IP / CIDR`, `+ Add Forward`, or `Share File…` / `Fetch…`).
489
+ - **Column 3 — detail / editor.** A header with the title, a colored status line, and an **Enabled** toggle in the top-right (applies instantly). The body is a card holding the form: forwards, domains, and shares are **edited inline** (change a field and **Save**) — no remove-and-re-enter. The same `+` buttons open inline **create forms** in this column (file/folder pickers still use the native chooser). Shares expose a copyable `http://localhost:PORT` URL and password. Per-item actions live in the action row (**Delete…** on the left, **Test** / **Save** on the right).
490
+ - **Settings** spans columns 2–3: grouped toggles (launch at login, stop on quit, random SSH ports, restore shares, show bandwidth, notifications, logo style) that **apply instantly**, the RPC / SSE / PAC server ports behind a single **Apply** button, and an **Open Config File…** button.
491
+
492
+ ### Linux tray
493
+
494
+ The Linux tray keeps the classic submenu menu structure:
465
495
 
466
496
  - **Manage** — toggle connection/PAC host/forward enabled state; start, stop, or restart a specific connection
467
497
  - **Start / Stop / Restart All** — bulk lifecycle operations across all connections
@@ -552,7 +582,11 @@ Both can be `true` simultaneously — susops will start an SSH slave for TCP and
552
582
 
553
583
  ### Port assignment
554
584
 
555
- Ports default to `0` (auto-assign). SusOps picks a random free port from the ephemeral range (49152–65535) at start time and saves it back to `config.yaml`. Pass a specific port to `add-connection` or set it in the config to pin it.
585
+ Ports default to `0` (auto-assign). SusOps picks a random free port from the ephemeral range (49152–65535) at start time and saves it back to `config.yaml`. Pass a specific port to `add-connection` or set it in the config to pin it. Ports outside `1–65535` are rejected on `add`.
586
+
587
+ ### Tag format
588
+
589
+ Connection tags must match `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` (no slashes, no `..`, no leading punctuation). Tags appear in PID-file names, socket paths, log lines, and PAC keys, so the format is intentionally strict.
556
590
 
557
591
  ### Workspace
558
592
 
@@ -668,7 +702,7 @@ sudo apt install socat # Ubuntu / Debian
668
702
 
669
703
  ### Enabling UDP on a forward
670
704
 
671
- Set `udp: true` in the forward config, or check "UDP" in the TUI/tray add-forward dialog:
705
+ Set `udp: true` in the forward config, or check "UDP" in the TUI/tray forward form:
672
706
 
673
707
  ```yaml
674
708
  forwards:
@@ -860,8 +894,41 @@ susops
860
894
  # Run the CLI
861
895
  susops ps
862
896
  susops ls
897
+
898
+ # Build local pypi + brew artifacts (mirrors CI, no upload)
899
+ scripts/build-local.sh pypi # wheel + sdist
900
+ scripts/build-local.sh brew # SusOps.app + .dmg (macOS only)
901
+ scripts/build-local.sh install-pypi # install wheel into a throwaway venv
902
+ scripts/build-local.sh install-brew # copy SusOps.app to /Applications
863
903
  ```
864
904
 
905
+ ### Tray development (macOS)
906
+
907
+ Run a dev tray instance against an isolated workspace without touching your live `~/.susops` setup:
908
+
909
+ ```bash
910
+ # Isolated workspace (avoids any conflict with a running user tray)
911
+ WS=$(mktemp -d /tmp/susops-dev.XXXX)
912
+ SUSOPS_TRAY_WORKSPACE=$WS SUSOPS_TRAY_DEBUG_PORT=7799 .venv/bin/susops-tray &
913
+
914
+ # Drive the tray via the debug command server
915
+ .venv/bin/python tools/tray_debug.py 7799 ping
916
+ .venv/bin/python tools/tray_debug.py 7799 dump-menu # full menu JSON
917
+ .venv/bin/python tools/tray_debug.py 7799 open-config # open the Settings window
918
+ .venv/bin/python tools/tray_debug.py 7799 screenshot /tmp/tray.png
919
+ .venv/bin/python tools/tray_debug.py 7799 quit
920
+ ```
921
+
922
+ `SUSOPS_TRAY_WORKSPACE` points the tray at a different workspace directory (separate config, PID files, sockets) so it never touches `~/.susops`. `SUSOPS_TRAY_DEBUG_PORT` starts a localhost command server on that port; `tools/tray_debug.py` is the client. Supported commands: `ping`, `dump-menu`, `open-config [gear]`, `select <conn> <section> <index>`, `dump-window`, `screenshot <path>`, `action <name>`, `open-about`, `quit`.
923
+
924
+ ### GUI smoke tests (macOS)
925
+
926
+ ```bash
927
+ SUSOPS_RUN_GUI_TESTS=1 .venv/bin/pytest -m gui -v
928
+ ```
929
+
930
+ Seven smoke tests launch a real tray instance (using `SUSOPS_TRAY_WORKSPACE` + `SUSOPS_TRAY_DEBUG_PORT` internally), drive it through the debug server, and assert menu structure and window content. Skipped automatically on Linux and when `SUSOPS_RUN_GUI_TESTS` is unset.
931
+
865
932
  ### Project layout
866
933
 
867
934
  ```
@@ -937,7 +1004,7 @@ brew tap mashb1t/susops
937
1004
  brew install susops
938
1005
  ```
939
1006
 
940
- The formula at `packaging/homebrew/Formula/susops.rb` uses `virtualenv_install_with_resources` to create an isolated Python environment with all dependencies. The cask at `packaging/homebrew/Casks/susops.rb` is for a future `.dmg` distribution of `SusOps.app`.
1007
+ The formula at `packaging/homebrew/Formula/susops.rb` uses `virtualenv_install_with_resources` to create an isolated Python environment for the CLI + TUI. The cask at `packaging/homebrew/Casks/susops.rb` installs `SusOps.app` (PyInstaller bundle) for the macOS tray — `brew install --cask susops`.
941
1008
 
942
1009
  **Note:** Resource sha256 checksums in the formula must be updated for each release. Generate them with:
943
1010
 
@@ -291,7 +291,7 @@ susops
291
291
 
292
292
  ### Screens
293
293
 
294
- **Dashboard** (default) — split-pane view. Left sidebar shows all connections (status dot, SOCKS port, live throughput), PAC server status, and active file shares. Right panel is tabbed:
294
+ **Dashboard** (default) — split-pane view. Left sidebar shows all connections (status dot — `●` green running, `◐` orange pending, `○` dim stopped — SOCKS port, live throughput), PAC server status, and active file shares. Right panel is tabbed:
295
295
  - **Stats** — CPU%, memory, active connections, PID for the selected connection
296
296
  - **Bandwidth** — live RX and TX line charts (PlotextPlot, 60-sample rolling window, auto-scaled units)
297
297
  - **Forwards** — DataTable of all port forwards (direction, local port, local bind, remote port, remote bind, label)
@@ -412,9 +412,11 @@ susops reset [--force] # Kill all processes, wipe ~/.susops workspace
412
412
  |------|---------|
413
413
  | `0` | Success / all running |
414
414
  | `1` | Error |
415
- | `2` | Partial (some services stopped) |
415
+ | `2` | Partial (some services stopped, or any connection pending) |
416
416
  | `3` | All stopped |
417
417
 
418
+ A connection is **pending** when its SSH master is alive but the ControlMaster socket isn't up yet — typically while ssh-agent is waiting on a key unlock, or between reconnect attempts. `start` returns as soon as the master spawns; auth then completes asynchronously up to 60 s later. Slow agent prompts (Vaultwarden, 1Password, hardware keys) no longer freeze the UI.
419
+
418
420
  ---
419
421
 
420
422
  ## System Tray
@@ -437,7 +439,35 @@ susops-tray
437
439
 
438
440
  Requires `rumps`: `pip install "susops[tray-mac]"`
439
441
 
440
- The tray icon reflects the current state (running/partial/stopped). State changes arrive over the daemon's SSE `/events` stream. If the stream drops, the listener reconnects within at most 5 seconds. Both tray implementations support the same feature set via native dialogs:
442
+ The tray icon reflects the current state (running / partial-or-pending / stopped). State changes arrive over the daemon's SSE `/events` stream. If the stream drops, the listener reconnects within at most 5 seconds. When TUI + tray are both attached to the same daemon, quitting one keeps the other running — `stop_on_quit` is skipped if any other frontend is still connected.
443
+
444
+ **Note: the macOS and Linux tray apps diverge in their UI structure.** The 3-column config window described below is macOS-only for now; the Linux tray keeps the classic submenu structure.
445
+
446
+ ### macOS tray
447
+
448
+ The macOS tray has a slim menu with a unified **config window** (open with Settings… ⌘, or from the tray menu):
449
+
450
+ Slim menu items:
451
+ - **Status line** — shows current connection state
452
+ - **Settings… ⌘,** — opens the unified config window
453
+ - **Start / Stop / Restart Proxy** — bulk lifecycle for all connections
454
+ - **Show Status** — print current state to stdout
455
+ - **Show Logs** — open the log directory
456
+ - **Launch Browser ▸** — submenu: Chrome or Firefox with the PAC URL pre-configured
457
+ - **Reset All** — kill all processes and wipe the workspace
458
+ - **About SusOps**
459
+ - **Quit ⌘Q**
460
+
461
+ The **config window** is a 3-column Tailscale-style editor (nav / list / detail) with a dark skin and instant-apply settings:
462
+
463
+ - **Column 1 — nav.** Categories with live counts: **Connections / Domains / Forwards / Shares**, plus **Settings** pinned at the bottom. Selecting a category drives the list.
464
+ - **Column 2 — list.** A search field on top filters the rows for the selected category. Forwards split into **Local** and **Remote** sections (each with a one-line explainer); shares show download counts. Each row carries a colored run-state dot (green active, amber pending, gray stopped/inactive, red error/connection-down) and a connection badge; disabled rows render dimmed. A context-aware add button sits at the bottom (`+ Add Connection`, `+ Add Domain / IP / CIDR`, `+ Add Forward`, or `Share File…` / `Fetch…`).
465
+ - **Column 3 — detail / editor.** A header with the title, a colored status line, and an **Enabled** toggle in the top-right (applies instantly). The body is a card holding the form: forwards, domains, and shares are **edited inline** (change a field and **Save**) — no remove-and-re-enter. The same `+` buttons open inline **create forms** in this column (file/folder pickers still use the native chooser). Shares expose a copyable `http://localhost:PORT` URL and password. Per-item actions live in the action row (**Delete…** on the left, **Test** / **Save** on the right).
466
+ - **Settings** spans columns 2–3: grouped toggles (launch at login, stop on quit, random SSH ports, restore shares, show bandwidth, notifications, logo style) that **apply instantly**, the RPC / SSE / PAC server ports behind a single **Apply** button, and an **Open Config File…** button.
467
+
468
+ ### Linux tray
469
+
470
+ The Linux tray keeps the classic submenu menu structure:
441
471
 
442
472
  - **Manage** — toggle connection/PAC host/forward enabled state; start, stop, or restart a specific connection
443
473
  - **Start / Stop / Restart All** — bulk lifecycle operations across all connections
@@ -528,7 +558,11 @@ Both can be `true` simultaneously — susops will start an SSH slave for TCP and
528
558
 
529
559
  ### Port assignment
530
560
 
531
- Ports default to `0` (auto-assign). SusOps picks a random free port from the ephemeral range (49152–65535) at start time and saves it back to `config.yaml`. Pass a specific port to `add-connection` or set it in the config to pin it.
561
+ Ports default to `0` (auto-assign). SusOps picks a random free port from the ephemeral range (49152–65535) at start time and saves it back to `config.yaml`. Pass a specific port to `add-connection` or set it in the config to pin it. Ports outside `1–65535` are rejected on `add`.
562
+
563
+ ### Tag format
564
+
565
+ Connection tags must match `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` (no slashes, no `..`, no leading punctuation). Tags appear in PID-file names, socket paths, log lines, and PAC keys, so the format is intentionally strict.
532
566
 
533
567
  ### Workspace
534
568
 
@@ -644,7 +678,7 @@ sudo apt install socat # Ubuntu / Debian
644
678
 
645
679
  ### Enabling UDP on a forward
646
680
 
647
- Set `udp: true` in the forward config, or check "UDP" in the TUI/tray add-forward dialog:
681
+ Set `udp: true` in the forward config, or check "UDP" in the TUI/tray forward form:
648
682
 
649
683
  ```yaml
650
684
  forwards:
@@ -836,8 +870,41 @@ susops
836
870
  # Run the CLI
837
871
  susops ps
838
872
  susops ls
873
+
874
+ # Build local pypi + brew artifacts (mirrors CI, no upload)
875
+ scripts/build-local.sh pypi # wheel + sdist
876
+ scripts/build-local.sh brew # SusOps.app + .dmg (macOS only)
877
+ scripts/build-local.sh install-pypi # install wheel into a throwaway venv
878
+ scripts/build-local.sh install-brew # copy SusOps.app to /Applications
839
879
  ```
840
880
 
881
+ ### Tray development (macOS)
882
+
883
+ Run a dev tray instance against an isolated workspace without touching your live `~/.susops` setup:
884
+
885
+ ```bash
886
+ # Isolated workspace (avoids any conflict with a running user tray)
887
+ WS=$(mktemp -d /tmp/susops-dev.XXXX)
888
+ SUSOPS_TRAY_WORKSPACE=$WS SUSOPS_TRAY_DEBUG_PORT=7799 .venv/bin/susops-tray &
889
+
890
+ # Drive the tray via the debug command server
891
+ .venv/bin/python tools/tray_debug.py 7799 ping
892
+ .venv/bin/python tools/tray_debug.py 7799 dump-menu # full menu JSON
893
+ .venv/bin/python tools/tray_debug.py 7799 open-config # open the Settings window
894
+ .venv/bin/python tools/tray_debug.py 7799 screenshot /tmp/tray.png
895
+ .venv/bin/python tools/tray_debug.py 7799 quit
896
+ ```
897
+
898
+ `SUSOPS_TRAY_WORKSPACE` points the tray at a different workspace directory (separate config, PID files, sockets) so it never touches `~/.susops`. `SUSOPS_TRAY_DEBUG_PORT` starts a localhost command server on that port; `tools/tray_debug.py` is the client. Supported commands: `ping`, `dump-menu`, `open-config [gear]`, `select <conn> <section> <index>`, `dump-window`, `screenshot <path>`, `action <name>`, `open-about`, `quit`.
899
+
900
+ ### GUI smoke tests (macOS)
901
+
902
+ ```bash
903
+ SUSOPS_RUN_GUI_TESTS=1 .venv/bin/pytest -m gui -v
904
+ ```
905
+
906
+ Seven smoke tests launch a real tray instance (using `SUSOPS_TRAY_WORKSPACE` + `SUSOPS_TRAY_DEBUG_PORT` internally), drive it through the debug server, and assert menu structure and window content. Skipped automatically on Linux and when `SUSOPS_RUN_GUI_TESTS` is unset.
907
+
841
908
  ### Project layout
842
909
 
843
910
  ```
@@ -913,7 +980,7 @@ brew tap mashb1t/susops
913
980
  brew install susops
914
981
  ```
915
982
 
916
- The formula at `packaging/homebrew/Formula/susops.rb` uses `virtualenv_install_with_resources` to create an isolated Python environment with all dependencies. The cask at `packaging/homebrew/Casks/susops.rb` is for a future `.dmg` distribution of `SusOps.app`.
983
+ The formula at `packaging/homebrew/Formula/susops.rb` uses `virtualenv_install_with_resources` to create an isolated Python environment for the CLI + TUI. The cask at `packaging/homebrew/Casks/susops.rb` installs `SusOps.app` (PyInstaller bundle) for the macOS tray — `brew install --cask susops`.
917
984
 
918
985
  **Note:** Resource sha256 checksums in the formula must be updated for each release. Generate them with:
919
986
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "susops"
7
- version = "3.0.0-rc4.dev2"
7
+ version = "3.0.0-rc6.dev1"
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" }
@@ -60,16 +60,38 @@ def _read_port(workspace: Path) -> int | None:
60
60
  return None
61
61
 
62
62
 
63
+ def _pid_is_susops_daemon(pid: int) -> bool:
64
+ """True only if PID belongs to a live process whose cmdline matches a
65
+ services_daemon invocation. Defends against PID reuse: SIGKILLing the
66
+ daemon under load can let an ssh fork (or any other short-lived child)
67
+ inherit the freed PID, and a bare `os.kill(pid, 0)` liveness probe
68
+ would then falsely report the daemon as up.
69
+ """
70
+ try:
71
+ import psutil
72
+ except ImportError:
73
+ try:
74
+ os.kill(pid, 0)
75
+ return True
76
+ except (OSError, ValueError):
77
+ return False
78
+ try:
79
+ proc = psutil.Process(pid)
80
+ cmdline = " ".join(proc.cmdline())
81
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
82
+ return False
83
+ return "susops.core.services_daemon" in cmdline or "susops-services" in cmdline
84
+
85
+
63
86
  def _is_daemon_alive(workspace: Path) -> bool:
64
87
  pid_file = _pid_path(workspace)
65
88
  if not pid_file.exists():
66
89
  return False
67
90
  try:
68
91
  pid = int(pid_file.read_text().strip())
69
- os.kill(pid, 0) # signal 0 = liveness probe
70
- return True
71
92
  except (OSError, ValueError):
72
93
  return False
94
+ return _pid_is_susops_daemon(pid)
73
95
 
74
96
 
75
97
  def ensure_daemon_running(workspace: Path = _WORKSPACE_DEFAULT) -> int:
@@ -112,12 +112,14 @@ class AppConfig(BaseModel):
112
112
  logo_style: LogoStyle = LogoStyle.COLORED_GLASSES
113
113
  restore_shares_on_start: bool = True
114
114
  tray_show_bandwidth: bool = False
115
+ notifications_enabled: bool = True
115
116
 
116
117
  @field_validator(
117
118
  "stop_on_quit",
118
119
  "ephemeral_ports",
119
120
  "restore_shares_on_start",
120
121
  "tray_show_bandwidth",
122
+ "notifications_enabled",
121
123
  mode="before",
122
124
  )
123
125
  @classmethod
@@ -193,39 +195,32 @@ def save_config(config: SusOpsConfig, workspace: Path = WORKSPACE_DEFAULT) -> No
193
195
  clicks.
194
196
  """
195
197
  import os
198
+ import tempfile
196
199
  path = get_config_path(workspace)
197
200
  path.parent.mkdir(parents=True, exist_ok=True)
198
201
  yaml = YAML()
199
202
  yaml.default_flow_style = False
200
203
  yaml.indent(mapping=2, sequence=4, offset=2)
201
- # Convert to plain dict via model_dump, then save
202
204
  data = config.model_dump(mode='python')
203
- # Convert enums to their values for serialization
204
205
  data['susops_app']['logo_style'] = config.susops_app.logo_style.value
205
- # Compute the target mode BEFORE writing: preserve any user-set restrictive
206
- # permissions (e.g. chmod 600 on a hardened install) since Path.replace()
207
- # would otherwise clobber them with the temp file's umask-derived mode.
208
- # New configs default to 0o600 — the file holds share passwords.
209
206
  try:
210
207
  target_mode = path.stat().st_mode & 0o777
211
208
  except OSError:
212
209
  target_mode = 0o600
213
- # Temp file in the same directory so the rename stays on one filesystem.
214
- # Open with O_EXCL so we never overwrite a leftover temp file from a
215
- # crashed earlier save (would otherwise inherit its mode).
216
- tmp_path = path.with_name(path.name + ".tmp")
210
+ # Unique tmp file per call so concurrent saves (rapid TUI start/stop runs
211
+ # each handler in its own executor thread) don't fight over the same name
212
+ # and crash on tmp.replace(path) after a peer already renamed it away.
213
+ fd, tmp_name = tempfile.mkstemp(
214
+ prefix=path.name + ".",
215
+ suffix=".tmp",
216
+ dir=str(path.parent),
217
+ )
218
+ tmp_path = Path(tmp_name)
217
219
  try:
218
- fd = os.open(
219
- str(tmp_path),
220
- os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
221
- target_mode,
222
- )
223
220
  try:
224
221
  with os.fdopen(fd, 'w') as f:
225
222
  yaml.dump(data, f)
226
223
  except Exception:
227
- # fdopen owns fd on success; on failure we may need to close it
228
- # if fdopen never took ownership. Best-effort.
229
224
  try:
230
225
  os.close(fd)
231
226
  except OSError:
@@ -235,7 +230,6 @@ def save_config(config: SusOpsConfig, workspace: Path = WORKSPACE_DEFAULT) -> No
235
230
  os.chmod(tmp_path, target_mode)
236
231
  tmp_path.replace(path)
237
232
  except Exception:
238
- # Best-effort cleanup of the temp file if writing failed.
239
233
  try:
240
234
  tmp_path.unlink(missing_ok=True)
241
235
  except Exception:
@@ -134,10 +134,20 @@ class PacServer:
134
134
  def start(self, port: int, pac_path: Path) -> None:
135
135
  """Start the PAC HTTP server on the given port.
136
136
 
137
- Raises RuntimeError if already running or if port is in use.
137
+ No-op if already running on the same port (parallel start() callers
138
+ race here — facade's check-then-act on is_running() can let multiple
139
+ threads through). Raises RuntimeError if the port is in use by
140
+ something else.
138
141
  """
139
142
  if self._server is not None:
140
- raise RuntimeError("PAC server is already running")
143
+ if self._port == port:
144
+ # Same port + already up: caller's intent is already satisfied.
145
+ self._pac_path = pac_path
146
+ return
147
+ raise RuntimeError(
148
+ f"PAC server already running on port {self._port}, "
149
+ f"refusing to also bind {port}"
150
+ )
141
151
 
142
152
  self._pac_path = pac_path
143
153
 
@@ -27,6 +27,7 @@ _ALLOWED_METHODS: set[str] = {
27
27
  "list_config",
28
28
  # Connection CRUD
29
29
  "add_connection", "remove_connection", "set_connection_enabled",
30
+ "update_connection",
30
31
  # PAC hosts
31
32
  "add_pac_host", "remove_pac_host", "set_pac_host_enabled",
32
33
  # Forwards
@@ -86,17 +86,21 @@ def _preflight(workspace: Path, log: "logging.Logger") -> None:
86
86
  # Won the race uncontested.
87
87
  pass
88
88
  else:
89
- # File exists. Is the holder alive?
89
+ # File exists. Is the holder alive AND actually our daemon?
90
+ # PID reuse (an ssh fork inheriting the freed PID after we kill -9
91
+ # the daemon) would otherwise wedge us in a refusal loop until the
92
+ # impostor exits. Reuse the same identity check the client does.
90
93
  pid_file = _pid_path(workspace)
91
94
  try:
92
95
  existing_pid = int(pid_file.read_text().strip())
93
- os.kill(existing_pid, 0) # liveness probe
94
96
  except (OSError, ValueError):
95
- # Stale PID file. Remove + retry the atomic claim once.
97
+ existing_pid = None
98
+ from susops.client import _pid_is_susops_daemon
99
+ if existing_pid is None or not _pid_is_susops_daemon(existing_pid):
100
+ # Stale (or impostor) PID file. Remove + retry the atomic claim.
96
101
  pid_file.unlink(missing_ok=True)
97
102
  _port_path(workspace).unlink(missing_ok=True)
98
103
  if not _claim_pid_file(workspace):
99
- # Some other daemon claimed it between our cleanup and retry.
100
104
  log.error(
101
105
  "another susops-services daemon raced us to start. "
102
106
  "Try again — or run "
@@ -8,6 +8,7 @@ Architecture:
8
8
  """
9
9
  from __future__ import annotations
10
10
 
11
+ import os
11
12
  import subprocess
12
13
  import time
13
14
  from pathlib import Path
@@ -249,9 +250,43 @@ def stop_tunnel(
249
250
  except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
250
251
  pass
251
252
 
253
+ # Last-resort sweep for orphan masters bound to our socket path.
254
+ # Under parallel start/stop the PID file can race (start writes PID Y while
255
+ # stop reads stale PID X, or the reconnect monitor spawns M2 between the
256
+ # read and the kill), leaving an ssh -N -T -D ... ControlPath=...sock
257
+ # process alive until its own keepalive timeout fires ~60s later.
258
+ if workspace is not None:
259
+ _sweep_orphan_masters(tag, workspace)
260
+
252
261
  return stopped
253
262
 
254
263
 
264
+ def _sweep_orphan_masters(tag: str, workspace: Path) -> None:
265
+ """Kill any ssh process whose argv references our ControlPath socket but
266
+ that ProcessManager isn't tracking. No-op if psutil is unavailable.
267
+ """
268
+ try:
269
+ import psutil
270
+ except ImportError:
271
+ return
272
+ sock_str = str(socket_path(tag, workspace))
273
+ try:
274
+ own_pid = os.getpid()
275
+ except Exception:
276
+ own_pid = -1
277
+ for proc in psutil.process_iter(["pid", "name", "cmdline"]):
278
+ try:
279
+ if proc.info["pid"] == own_pid:
280
+ continue
281
+ if proc.info.get("name") != "ssh":
282
+ continue
283
+ cmdline = proc.info.get("cmdline") or []
284
+ if any(sock_str in arg for arg in cmdline):
285
+ proc.kill()
286
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
287
+ continue
288
+
289
+
255
290
  def is_tunnel_running(tag: str, process_mgr: ProcessManager) -> bool:
256
291
  """Return True if the ControlMaster SSH process for tag is currently running."""
257
292
  return process_mgr.is_running(_master_name(tag))