susops 3.0.0rc5.dev1__tar.gz → 3.0.0rc6.dev2__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.0rc5.dev1 → susops-3.0.0rc6.dev2}/PKG-INFO +58 -3
  2. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/README.md +57 -2
  3. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/pyproject.toml +1 -1
  4. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/config.py +2 -0
  5. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/rpc_server.py +1 -0
  6. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/facade.py +151 -59
  7. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/tray/base.py +36 -11
  8. susops-3.0.0rc6.dev2/src/susops/tray/config_window_model.py +534 -0
  9. susops-3.0.0rc6.dev2/src/susops/tray/debug_server.py +72 -0
  10. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/tray/linux.py +10 -0
  11. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/tray/mac.py +1312 -1205
  12. susops-3.0.0rc6.dev2/src/susops/tray/mac_config_window.py +3427 -0
  13. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/cli.py +9 -7
  14. susops-3.0.0rc6.dev2/src/susops/version.py +15 -0
  15. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops.egg-info/PKG-INFO +58 -3
  16. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops.egg-info/SOURCES.txt +3 -0
  17. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_facade.py +114 -0
  18. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_update_homebrew_sha.py +2 -2
  19. susops-3.0.0rc5.dev1/src/susops/version.py +0 -12
  20. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/LICENSE +0 -0
  21. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/setup.cfg +0 -0
  22. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/__init__.py +0 -0
  23. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icon.png +0 -0
  24. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_glasses/dark/error.svg +0 -0
  25. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_glasses/dark/running.svg +0 -0
  26. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_glasses/dark/stopped.svg +0 -0
  27. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_glasses/dark/stopped_partially.svg +0 -0
  28. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_glasses/light/error.svg +0 -0
  29. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_glasses/light/running.svg +0 -0
  30. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_glasses/light/stopped.svg +0 -0
  31. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_glasses/light/stopped_partially.svg +0 -0
  32. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_s/dark/error.svg +0 -0
  33. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_s/dark/running.svg +0 -0
  34. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_s/dark/stopped.svg +0 -0
  35. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_s/dark/stopped_partially.svg +0 -0
  36. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_s/light/error.svg +0 -0
  37. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_s/light/running.svg +0 -0
  38. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_s/light/stopped.svg +0 -0
  39. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_s/light/stopped_partially.svg +0 -0
  40. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/gear/dark/error.svg +0 -0
  41. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/gear/dark/running.svg +0 -0
  42. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/gear/dark/stopped.svg +0 -0
  43. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/gear/dark/stopped_partially.svg +0 -0
  44. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/gear/light/error.svg +0 -0
  45. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/gear/light/running.svg +0 -0
  46. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/gear/light/stopped.svg +0 -0
  47. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/gear/light/stopped_partially.svg +0 -0
  48. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/status/error.svg +0 -0
  49. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/status/running.svg +0 -0
  50. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/status/stopped.svg +0 -0
  51. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/status/stopped_partially.svg +0 -0
  52. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/client.py +0 -0
  53. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/__init__.py +0 -0
  54. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/browsers.py +0 -0
  55. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/log_style.py +0 -0
  56. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/pac.py +0 -0
  57. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/ports.py +0 -0
  58. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/process.py +0 -0
  59. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/rpc_protocol.py +0 -0
  60. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/services_daemon.py +0 -0
  61. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/share.py +0 -0
  62. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/socat.py +0 -0
  63. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/ssh.py +0 -0
  64. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/ssh_config.py +0 -0
  65. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/status.py +0 -0
  66. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/types.py +0 -0
  67. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/tray/__init__.py +0 -0
  68. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/__init__.py +0 -0
  69. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/__main__.py +0 -0
  70. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/app.py +0 -0
  71. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/app.tcss +0 -0
  72. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/screens/__init__.py +0 -0
  73. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/screens/connections.py +0 -0
  74. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/screens/dashboard.py +0 -0
  75. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/screens/shares.py +0 -0
  76. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/widgets/__init__.py +0 -0
  77. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/widgets/connection_card.py +0 -0
  78. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops.egg-info/dependency_links.txt +0 -0
  79. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops.egg-info/entry_points.txt +0 -0
  80. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops.egg-info/requires.txt +0 -0
  81. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/src/susops.egg-info/top_level.txt +0 -0
  82. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_bw_totals.py +0 -0
  83. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_cli.py +0 -0
  84. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_client.py +0 -0
  85. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_config.py +0 -0
  86. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_conftest_smoke.py +0 -0
  87. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_openapi.py +0 -0
  88. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_pac.py +0 -0
  89. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_packaging.py +0 -0
  90. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_process.py +0 -0
  91. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_rpc_protocol.py +0 -0
  92. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_rpc_server.py +0 -0
  93. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_scripts.py +0 -0
  94. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_services_daemon.py +0 -0
  95. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_share.py +0 -0
  96. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_share_aiohttp.py +0 -0
  97. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_socat.py +0 -0
  98. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_ssh.py +0 -0
  99. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_status.py +0 -0
  100. {susops-3.0.0rc5.dev1 → susops-3.0.0rc6.dev2}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: susops
3
- Version: 3.0.0rc5.dev1
3
+ Version: 3.0.0rc6.dev2
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. 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:
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 add-forward dialog:
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. 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:
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 add-forward dialog:
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-rc5.dev1"
7
+ version = "3.0.0-rc6.dev2"
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
@@ -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
@@ -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
- conn = get_connection(self.config, conn_tag)
1122
- if conn is None:
1123
- return
1124
- # Update existing entry (clear stopped flag on re-share) or append new
1125
- existing = [f for f in conn.file_shares if f.file_path == file_path]
1126
- if existing:
1127
- new_shares = [
1128
- fs.model_copy(update={"password": password, "port": port, "stopped": False})
1129
- if fs.file_path == file_path else fs
1130
- for fs in conn.file_shares
1131
- ]
1132
- else:
1133
- new_shares = list(conn.file_shares) + [
1134
- FileShare(file_path=file_path, password=password, port=port)
1135
- ]
1136
- self._replace_connection(conn.model_copy(update={"file_shares": new_shares}))
1137
- self._save()
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
- new_conns = []
1141
- for conn in self.config.connections:
1142
- updated_shares = [f for f in conn.file_shares if f.port != port]
1143
- if len(updated_shares) != len(conn.file_shares):
1144
- new_conns.append(conn.model_copy(update={"file_shares": updated_shares}))
1145
- else:
1146
- new_conns.append(conn)
1147
- self.config = self.config.model_copy(update={"connections": new_conns})
1148
- self._save()
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
- new_conns = []
1153
- for conn in self.config.connections:
1154
- updated = [
1155
- fs.model_copy(update={"stopped": stopped}) if fs.port == port else fs
1156
- for fs in conn.file_shares
1157
- ]
1158
- new_conns.append(conn.model_copy(update={"file_shares": updated}))
1159
- self.config = self.config.model_copy(update={"connections": new_conns})
1160
- self._save()
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 process)
2127
- self._reload_config()
2128
- for conn in self.config.connections:
2129
- for fs in conn.file_shares:
2130
- if fs.port not in running_ports:
2131
- result.append(ShareInfo(
2132
- file_path=fs.file_path,
2133
- port=fs.port,
2134
- password=fs.password,
2135
- url=f"http://localhost:{fs.port}",
2136
- conn_tag=conn.tag,
2137
- running=False,
2138
- stopped=fs.stopped,
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._reload_config()
2432
- self.config = self.config.model_copy(
2433
- update={"susops_app": self.config.susops_app.model_copy(update=kwargs)}
2434
- )
2435
- self._save()
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._reload_config()
2448
- self.config = self.config.model_copy(update=kwargs)
2449
- self._save()
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 = Path.home() / ".susops"
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 f"Connection '{tag}' {'enabled' if new_state else 'disabled'}."
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, lambda msg: self.show_alert("Toggle Connection", msg))
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 f"Domain '{host}' {'enabled' if currently_disabled else 'disabled'}."
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, lambda msg: self.show_alert("Toggle Domain", msg))
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 f"Forward :{src_port} toggled."
476
+ return None, True
457
477
  except Exception as e:
458
- return f"Error: {e}"
478
+ return f"Error: {e}", False
459
479
 
460
- self.run_in_background(_run, lambda msg: self.show_alert("Toggle Forward", msg))
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():