susops 3.0.0rc5.dev1__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.
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/PKG-INFO +58 -3
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/README.md +57 -2
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/pyproject.toml +1 -1
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/core/config.py +2 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/core/rpc_server.py +1 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/facade.py +151 -59
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/tray/base.py +36 -11
- susops-3.0.0rc6.dev1/src/susops/tray/config_window_model.py +533 -0
- susops-3.0.0rc6.dev1/src/susops/tray/debug_server.py +72 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/tray/linux.py +10 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/tray/mac.py +1286 -1204
- susops-3.0.0rc6.dev1/src/susops/tray/mac_config_window.py +3208 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/tui/cli.py +9 -7
- susops-3.0.0rc6.dev1/src/susops/version.py +15 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops.egg-info/PKG-INFO +58 -3
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops.egg-info/SOURCES.txt +3 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_facade.py +114 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_update_homebrew_sha.py +2 -2
- susops-3.0.0rc5.dev1/src/susops/version.py +0 -12
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/LICENSE +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/setup.cfg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/__init__.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icon.png +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/dark/error.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/dark/running.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/dark/stopped.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/dark/stopped_partially.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/light/error.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/light/running.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/light/stopped.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/light/stopped_partially.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/dark/error.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/dark/running.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/dark/stopped.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/dark/stopped_partially.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/light/error.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/light/running.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/light/stopped.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/light/stopped_partially.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/dark/error.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/dark/running.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/dark/stopped.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/dark/stopped_partially.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/light/error.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/light/running.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/light/stopped.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/light/stopped_partially.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/status/error.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/status/running.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/status/stopped.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/status/stopped_partially.svg +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/client.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/core/__init__.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/core/browsers.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/core/log_style.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/core/pac.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/core/ports.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/core/process.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/core/rpc_protocol.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/core/services_daemon.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/core/share.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/core/socat.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/core/ssh.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/core/ssh_config.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/core/status.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/core/types.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/tray/__init__.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/tui/__init__.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/tui/__main__.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/tui/app.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/tui/app.tcss +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/tui/screens/__init__.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/tui/screens/connections.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/tui/screens/dashboard.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/tui/screens/shares.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/tui/widgets/__init__.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops/tui/widgets/connection_card.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops.egg-info/dependency_links.txt +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops.egg-info/entry_points.txt +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops.egg-info/requires.txt +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/src/susops.egg-info/top_level.txt +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_bw_totals.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_cli.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_client.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_config.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_conftest_smoke.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_openapi.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_pac.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_packaging.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_process.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_rpc_protocol.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_rpc_server.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_scripts.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_services_daemon.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_share.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_share_aiohttp.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_socat.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_ssh.py +0 -0
- {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev1}/tests/test_status.py +0 -0
- {susops-3.0.0rc5.dev1 → 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.
|
|
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
|
|
@@ -463,7 +463,35 @@ susops-tray
|
|
|
463
463
|
|
|
464
464
|
Requires `rumps`: `pip install "susops[tray-mac]"`
|
|
465
465
|
|
|
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.
|
|
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:
|
|
467
495
|
|
|
468
496
|
- **Manage** — toggle connection/PAC host/forward enabled state; start, stop, or restart a specific connection
|
|
469
497
|
- **Start / Stop / Restart All** — bulk lifecycle operations across all connections
|
|
@@ -674,7 +702,7 @@ sudo apt install socat # Ubuntu / Debian
|
|
|
674
702
|
|
|
675
703
|
### Enabling UDP on a forward
|
|
676
704
|
|
|
677
|
-
Set `udp: true` in the forward config, or check "UDP" in the TUI/tray
|
|
705
|
+
Set `udp: true` in the forward config, or check "UDP" in the TUI/tray forward form:
|
|
678
706
|
|
|
679
707
|
```yaml
|
|
680
708
|
forwards:
|
|
@@ -874,6 +902,33 @@ scripts/build-local.sh install-pypi # install wheel into a throwaway venv
|
|
|
874
902
|
scripts/build-local.sh install-brew # copy SusOps.app to /Applications
|
|
875
903
|
```
|
|
876
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
|
+
|
|
877
932
|
### Project layout
|
|
878
933
|
|
|
879
934
|
```
|
|
@@ -439,7 +439,35 @@ susops-tray
|
|
|
439
439
|
|
|
440
440
|
Requires `rumps`: `pip install "susops[tray-mac]"`
|
|
441
441
|
|
|
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.
|
|
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:
|
|
443
471
|
|
|
444
472
|
- **Manage** — toggle connection/PAC host/forward enabled state; start, stop, or restart a specific connection
|
|
445
473
|
- **Start / Stop / Restart All** — bulk lifecycle operations across all connections
|
|
@@ -650,7 +678,7 @@ sudo apt install socat # Ubuntu / Debian
|
|
|
650
678
|
|
|
651
679
|
### Enabling UDP on a forward
|
|
652
680
|
|
|
653
|
-
Set `udp: true` in the forward config, or check "UDP" in the TUI/tray
|
|
681
|
+
Set `udp: true` in the forward config, or check "UDP" in the TUI/tray forward form:
|
|
654
682
|
|
|
655
683
|
```yaml
|
|
656
684
|
forwards:
|
|
@@ -850,6 +878,33 @@ scripts/build-local.sh install-pypi # install wheel into a throwaway venv
|
|
|
850
878
|
scripts/build-local.sh install-brew # copy SusOps.app to /Applications
|
|
851
879
|
```
|
|
852
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
|
+
|
|
853
908
|
### Project layout
|
|
854
909
|
|
|
855
910
|
```
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "susops"
|
|
7
|
-
version = "3.0.0-
|
|
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" }
|
|
@@ -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
|
|
@@ -659,7 +659,13 @@ class SusOpsManager:
|
|
|
659
659
|
print(full, file=sys.stderr)
|
|
660
660
|
|
|
661
661
|
def _notify(self, title: str, body: str) -> None:
|
|
662
|
-
"""Send a desktop notification with the SusOps icon. Best-effort.
|
|
662
|
+
"""Send a desktop notification with the SusOps icon. Best-effort.
|
|
663
|
+
|
|
664
|
+
Suppressed entirely when susops_app.notifications_enabled is False
|
|
665
|
+
(tray Settings → Notifications toggle).
|
|
666
|
+
"""
|
|
667
|
+
if not self.config.susops_app.notifications_enabled:
|
|
668
|
+
return
|
|
663
669
|
import platform
|
|
664
670
|
from pathlib import Path
|
|
665
671
|
icon = Path(__file__).parent / "assets" / "icon.png"
|
|
@@ -1118,46 +1124,58 @@ class SusOpsManager:
|
|
|
1118
1124
|
def _add_file_share_to_config(
|
|
1119
1125
|
self, conn_tag: str, file_path: str, password: str, port: int
|
|
1120
1126
|
) -> None:
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1127
|
+
with self._config_lock:
|
|
1128
|
+
self._reload_config()
|
|
1129
|
+
conn = get_connection(self.config, conn_tag)
|
|
1130
|
+
if conn is None:
|
|
1131
|
+
return
|
|
1132
|
+
# Update existing entry (clear stopped flag on re-share) or append new
|
|
1133
|
+
existing = [f for f in conn.file_shares if f.file_path == file_path]
|
|
1134
|
+
if existing:
|
|
1135
|
+
new_shares = [
|
|
1136
|
+
fs.model_copy(update={"password": password, "port": port, "stopped": False})
|
|
1137
|
+
if fs.file_path == file_path else fs
|
|
1138
|
+
for fs in conn.file_shares
|
|
1139
|
+
]
|
|
1140
|
+
else:
|
|
1141
|
+
new_shares = list(conn.file_shares) + [
|
|
1142
|
+
FileShare(file_path=file_path, password=password, port=port)
|
|
1143
|
+
]
|
|
1144
|
+
self._replace_connection(conn.model_copy(update={"file_shares": new_shares}))
|
|
1145
|
+
self._save()
|
|
1138
1146
|
|
|
1139
1147
|
def _remove_file_share_from_config(self, port: int) -> None:
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1148
|
+
with self._config_lock:
|
|
1149
|
+
self._reload_config()
|
|
1150
|
+
new_conns = []
|
|
1151
|
+
for conn in self.config.connections:
|
|
1152
|
+
updated_shares = [f for f in conn.file_shares if f.port != port]
|
|
1153
|
+
if len(updated_shares) != len(conn.file_shares):
|
|
1154
|
+
new_conns.append(conn.model_copy(update={"file_shares": updated_shares}))
|
|
1155
|
+
else:
|
|
1156
|
+
new_conns.append(conn)
|
|
1157
|
+
self.config = self.config.model_copy(update={"connections": new_conns})
|
|
1158
|
+
self._save()
|
|
1149
1159
|
|
|
1150
1160
|
def _set_file_share_stopped(self, port: int, stopped: bool) -> None:
|
|
1151
|
-
"""Update the stopped flag on a persisted FileShare entry.
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
+
"""Update the stopped flag on a persisted FileShare entry.
|
|
1162
|
+
|
|
1163
|
+
Reads fresh under _config_lock so a concurrent config mutation (the
|
|
1164
|
+
tray poll's list_shares, another frontend) cannot be clobbered by a
|
|
1165
|
+
stale in-memory write. Matches the read-modify-write pattern used by
|
|
1166
|
+
the other config mutators.
|
|
1167
|
+
"""
|
|
1168
|
+
with self._config_lock:
|
|
1169
|
+
self._reload_config()
|
|
1170
|
+
new_conns = []
|
|
1171
|
+
for conn in self.config.connections:
|
|
1172
|
+
updated = [
|
|
1173
|
+
fs.model_copy(update={"stopped": stopped}) if fs.port == port else fs
|
|
1174
|
+
for fs in conn.file_shares
|
|
1175
|
+
]
|
|
1176
|
+
new_conns.append(conn.model_copy(update={"file_shares": updated}))
|
|
1177
|
+
self.config = self.config.model_copy(update={"connections": new_conns})
|
|
1178
|
+
self._save()
|
|
1161
1179
|
|
|
1162
1180
|
# ------------------------------------------------------------------ #
|
|
1163
1181
|
# Lifecycle
|
|
@@ -1727,6 +1745,75 @@ class SusOpsManager:
|
|
|
1727
1745
|
pid = self._process_mgr.get_pid(f"{SSH_PROCESS_PREFIX}-{tag}") if running else None
|
|
1728
1746
|
self._emit("state", {"tag": tag, "running": running, "pid": pid})
|
|
1729
1747
|
|
|
1748
|
+
def update_connection(
|
|
1749
|
+
self,
|
|
1750
|
+
tag: str,
|
|
1751
|
+
*,
|
|
1752
|
+
new_tag: str | None = None,
|
|
1753
|
+
ssh_host: str | None = None,
|
|
1754
|
+
socks_proxy_port: int | None = None,
|
|
1755
|
+
restart: bool = True,
|
|
1756
|
+
) -> Connection:
|
|
1757
|
+
"""Edit a connection's tag / ssh_host / socks_proxy_port in place.
|
|
1758
|
+
|
|
1759
|
+
Unlike remove + re-add, this preserves the connection's children
|
|
1760
|
+
(forwards, pac_hosts, pac_hosts_disabled, file_shares, enabled) via
|
|
1761
|
+
model_copy — remove_connection cascades and would drop them.
|
|
1762
|
+
|
|
1763
|
+
If the connection was running and restart=True, the tunnel is torn
|
|
1764
|
+
down under the OLD tag and brought back up under the NEW config so a
|
|
1765
|
+
renamed/re-pointed connection picks up the change immediately.
|
|
1766
|
+
"""
|
|
1767
|
+
with self._config_lock:
|
|
1768
|
+
self._reload_config()
|
|
1769
|
+
conn = get_connection(self.config, tag)
|
|
1770
|
+
if conn is None:
|
|
1771
|
+
raise ValueError(f"Connection '{tag}' not found")
|
|
1772
|
+
|
|
1773
|
+
target_tag = (new_tag if new_tag is not None else tag).strip()
|
|
1774
|
+
if not target_tag:
|
|
1775
|
+
raise ValueError("Tag must be a non-empty string")
|
|
1776
|
+
if target_tag != tag:
|
|
1777
|
+
_validate_tag(target_tag)
|
|
1778
|
+
if get_connection(self.config, target_tag) is not None:
|
|
1779
|
+
raise ValueError(f"Connection '{target_tag}' already exists")
|
|
1780
|
+
|
|
1781
|
+
new_host = (ssh_host if ssh_host is not None else conn.ssh_host).strip()
|
|
1782
|
+
if not new_host:
|
|
1783
|
+
raise ValueError("ssh_host must be a non-empty string")
|
|
1784
|
+
|
|
1785
|
+
new_port = conn.socks_proxy_port if socks_proxy_port is None else int(socks_proxy_port)
|
|
1786
|
+
if not validate_port(new_port, allow_zero=True):
|
|
1787
|
+
raise ValueError(f"Invalid socks_proxy_port {new_port}: must be 0 or 1-65535")
|
|
1788
|
+
if new_port != 0 and new_port != conn.socks_proxy_port and not is_port_free(new_port):
|
|
1789
|
+
raise ValueError(f"SOCKS port {new_port} is already in use")
|
|
1790
|
+
|
|
1791
|
+
was_running = (
|
|
1792
|
+
is_tunnel_running(tag, self._process_mgr)
|
|
1793
|
+
or is_socket_alive(tag, self.workspace)
|
|
1794
|
+
)
|
|
1795
|
+
|
|
1796
|
+
updated = conn.model_copy(update={
|
|
1797
|
+
"tag": target_tag,
|
|
1798
|
+
"ssh_host": new_host,
|
|
1799
|
+
"socks_proxy_port": new_port,
|
|
1800
|
+
})
|
|
1801
|
+
self.config = self.config.model_copy(update={
|
|
1802
|
+
"connections": [updated if c.tag == tag else c for c in self.config.connections]
|
|
1803
|
+
})
|
|
1804
|
+
self._save()
|
|
1805
|
+
|
|
1806
|
+
self._update_pac()
|
|
1807
|
+
if was_running and restart:
|
|
1808
|
+
# Tear down under the OLD tag (its PID/socket/shares/forwards), then
|
|
1809
|
+
# bring it back up under the NEW config. Outside the config lock —
|
|
1810
|
+
# stop/start acquire their own locks.
|
|
1811
|
+
self.stop(tag=tag)
|
|
1812
|
+
self.start(tag=target_tag)
|
|
1813
|
+
self._log(f"[{tag}] Updated → tag={target_tag} host={new_host} socks={new_port}")
|
|
1814
|
+
self._emit_state(self._compute_state())
|
|
1815
|
+
return updated
|
|
1816
|
+
|
|
1730
1817
|
def _update_pac(self) -> None:
|
|
1731
1818
|
"""Write the PAC file and reload the in-process server if running."""
|
|
1732
1819
|
pac_path = write_pac_file(self.config, self.workspace, active_tags=self._active_tags())
|
|
@@ -2123,20 +2210,23 @@ class SusOpsManager:
|
|
|
2123
2210
|
failed_count=server.failed_count,
|
|
2124
2211
|
))
|
|
2125
2212
|
|
|
2126
|
-
# Config-only stopped shares (persisted but server not running in this
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2213
|
+
# Config-only stopped shares (persisted but server not running in this
|
|
2214
|
+
# process). Reload under the lock so a concurrent share/stop_share
|
|
2215
|
+
# config write is not interleaved or clobbered.
|
|
2216
|
+
with self._config_lock:
|
|
2217
|
+
self._reload_config()
|
|
2218
|
+
for conn in self.config.connections:
|
|
2219
|
+
for fs in conn.file_shares:
|
|
2220
|
+
if fs.port not in running_ports:
|
|
2221
|
+
result.append(ShareInfo(
|
|
2222
|
+
file_path=fs.file_path,
|
|
2223
|
+
port=fs.port,
|
|
2224
|
+
password=fs.password,
|
|
2225
|
+
url=f"http://localhost:{fs.port}",
|
|
2226
|
+
conn_tag=conn.tag,
|
|
2227
|
+
running=False,
|
|
2228
|
+
stopped=fs.stopped,
|
|
2229
|
+
))
|
|
2140
2230
|
|
|
2141
2231
|
return result
|
|
2142
2232
|
|
|
@@ -2428,11 +2518,12 @@ class SusOpsManager:
|
|
|
2428
2518
|
return self.config.susops_app
|
|
2429
2519
|
|
|
2430
2520
|
def update_app_config(self, **kwargs) -> None:
|
|
2431
|
-
self.
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2521
|
+
with self._config_lock:
|
|
2522
|
+
self._reload_config()
|
|
2523
|
+
self.config = self.config.model_copy(
|
|
2524
|
+
update={"susops_app": self.config.susops_app.model_copy(update=kwargs)}
|
|
2525
|
+
)
|
|
2526
|
+
self._save()
|
|
2436
2527
|
|
|
2437
2528
|
def update_config(self, **kwargs) -> None:
|
|
2438
2529
|
"""Update top-level SusOpsConfig fields (e.g. pac_server_port).
|
|
@@ -2444,6 +2535,7 @@ class SusOpsManager:
|
|
|
2444
2535
|
— needed by RPC clients that can't touch private methods or rebind
|
|
2445
2536
|
the config attribute directly.
|
|
2446
2537
|
"""
|
|
2447
|
-
self.
|
|
2448
|
-
|
|
2449
|
-
|
|
2538
|
+
with self._config_lock:
|
|
2539
|
+
self._reload_config()
|
|
2540
|
+
self.config = self.config.model_copy(update=kwargs)
|
|
2541
|
+
self._save()
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""AbstractTrayApp — shared tray app logic for Linux and macOS."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import os
|
|
4
5
|
import re
|
|
5
6
|
from abc import ABC, abstractmethod
|
|
6
7
|
from pathlib import Path
|
|
@@ -66,6 +67,15 @@ from susops.client import SusOpsClient
|
|
|
66
67
|
from susops.core.config import PortForward
|
|
67
68
|
|
|
68
69
|
|
|
70
|
+
def _resolve_workspace() -> Path:
|
|
71
|
+
"""Workspace dir for the tray. SUSOPS_TRAY_WORKSPACE overrides ~/.susops
|
|
72
|
+
so a dev/test instance can run alongside the user's real tray."""
|
|
73
|
+
env = os.environ.get("SUSOPS_TRAY_WORKSPACE")
|
|
74
|
+
if env:
|
|
75
|
+
return Path(env).expanduser()
|
|
76
|
+
return Path.home() / ".susops"
|
|
77
|
+
|
|
78
|
+
|
|
69
79
|
class AbstractTrayApp(ABC):
|
|
70
80
|
"""Base class for tray apps. Subclasses implement the platform-specific UI layer.
|
|
71
81
|
|
|
@@ -78,7 +88,7 @@ class AbstractTrayApp(ABC):
|
|
|
78
88
|
"""
|
|
79
89
|
|
|
80
90
|
def __init__(self) -> None:
|
|
81
|
-
workspace =
|
|
91
|
+
workspace = _resolve_workspace()
|
|
82
92
|
self.manager = SusOpsClient(workspace=workspace, process_name="susops-tray")
|
|
83
93
|
self.state = ProcessState.INITIAL
|
|
84
94
|
|
|
@@ -391,14 +401,19 @@ class AbstractTrayApp(ABC):
|
|
|
391
401
|
cfg = self.manager.list_config()
|
|
392
402
|
conn = next((c for c in cfg.connections if c.tag == tag), None)
|
|
393
403
|
if conn is None:
|
|
394
|
-
return f"Connection '{tag}' not found."
|
|
404
|
+
return f"Connection '{tag}' not found.", False
|
|
395
405
|
new_state = not conn.enabled
|
|
396
406
|
self.manager.set_connection_enabled(tag, new_state)
|
|
397
|
-
return
|
|
407
|
+
return None, True
|
|
398
408
|
except Exception as e:
|
|
399
|
-
return f"Error: {e}"
|
|
409
|
+
return f"Error: {e}", False
|
|
410
|
+
|
|
411
|
+
def _done(r):
|
|
412
|
+
msg, ok = r
|
|
413
|
+
if not ok:
|
|
414
|
+
self.show_alert("Toggle Connection", msg)
|
|
400
415
|
|
|
401
|
-
self.run_in_background(_run,
|
|
416
|
+
self.run_in_background(_run, _done)
|
|
402
417
|
|
|
403
418
|
def do_start_connection(self, tag: str) -> None:
|
|
404
419
|
def _run():
|
|
@@ -443,21 +458,31 @@ class AbstractTrayApp(ABC):
|
|
|
443
458
|
all_disabled = [h for c in cfg.connections for h in c.pac_hosts_disabled]
|
|
444
459
|
currently_disabled = host in all_disabled
|
|
445
460
|
self.manager.set_pac_host_enabled(host, currently_disabled) # flip
|
|
446
|
-
return
|
|
461
|
+
return None, True
|
|
447
462
|
except Exception as e:
|
|
448
|
-
return f"Error: {e}"
|
|
463
|
+
return f"Error: {e}", False
|
|
464
|
+
|
|
465
|
+
def _done(r):
|
|
466
|
+
msg, ok = r
|
|
467
|
+
if not ok:
|
|
468
|
+
self.show_alert("Toggle Domain", msg)
|
|
449
469
|
|
|
450
|
-
self.run_in_background(_run,
|
|
470
|
+
self.run_in_background(_run, _done)
|
|
451
471
|
|
|
452
472
|
def do_toggle_forward_enabled(self, conn_tag: str, src_port: int, direction: str) -> None:
|
|
453
473
|
def _run():
|
|
454
474
|
try:
|
|
455
475
|
self.manager.toggle_forward_enabled(conn_tag, src_port, direction)
|
|
456
|
-
return
|
|
476
|
+
return None, True
|
|
457
477
|
except Exception as e:
|
|
458
|
-
return f"Error: {e}"
|
|
478
|
+
return f"Error: {e}", False
|
|
459
479
|
|
|
460
|
-
|
|
480
|
+
def _done(r):
|
|
481
|
+
msg, ok = r
|
|
482
|
+
if not ok:
|
|
483
|
+
self.show_alert("Toggle Forward", msg)
|
|
484
|
+
|
|
485
|
+
self.run_in_background(_run, _done)
|
|
461
486
|
|
|
462
487
|
def do_test_connection(self, conn_tag: str) -> None:
|
|
463
488
|
def _run():
|