susops 3.0.0rc6.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 (99) hide show
  1. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/PKG-INFO +1 -1
  2. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/pyproject.toml +1 -1
  3. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/tray/config_window_model.py +2 -1
  4. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/tray/mac.py +29 -4
  5. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/tray/mac_config_window.py +236 -17
  6. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops.egg-info/PKG-INFO +1 -1
  7. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_update_homebrew_sha.py +2 -2
  8. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/LICENSE +0 -0
  9. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/README.md +0 -0
  10. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/setup.cfg +0 -0
  11. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/__init__.py +0 -0
  12. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icon.png +0 -0
  13. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_glasses/dark/error.svg +0 -0
  14. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_glasses/dark/running.svg +0 -0
  15. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_glasses/dark/stopped.svg +0 -0
  16. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_glasses/dark/stopped_partially.svg +0 -0
  17. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_glasses/light/error.svg +0 -0
  18. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_glasses/light/running.svg +0 -0
  19. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_glasses/light/stopped.svg +0 -0
  20. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_glasses/light/stopped_partially.svg +0 -0
  21. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_s/dark/error.svg +0 -0
  22. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_s/dark/running.svg +0 -0
  23. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_s/dark/stopped.svg +0 -0
  24. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_s/dark/stopped_partially.svg +0 -0
  25. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_s/light/error.svg +0 -0
  26. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_s/light/running.svg +0 -0
  27. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_s/light/stopped.svg +0 -0
  28. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/colored_s/light/stopped_partially.svg +0 -0
  29. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/gear/dark/error.svg +0 -0
  30. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/gear/dark/running.svg +0 -0
  31. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/gear/dark/stopped.svg +0 -0
  32. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/gear/dark/stopped_partially.svg +0 -0
  33. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/gear/light/error.svg +0 -0
  34. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/gear/light/running.svg +0 -0
  35. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/gear/light/stopped.svg +0 -0
  36. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/gear/light/stopped_partially.svg +0 -0
  37. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/status/error.svg +0 -0
  38. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/status/running.svg +0 -0
  39. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/status/stopped.svg +0 -0
  40. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/assets/icons/status/stopped_partially.svg +0 -0
  41. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/client.py +0 -0
  42. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/__init__.py +0 -0
  43. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/browsers.py +0 -0
  44. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/config.py +0 -0
  45. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/log_style.py +0 -0
  46. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/pac.py +0 -0
  47. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/ports.py +0 -0
  48. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/process.py +0 -0
  49. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/rpc_protocol.py +0 -0
  50. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/rpc_server.py +0 -0
  51. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/services_daemon.py +0 -0
  52. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/share.py +0 -0
  53. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/socat.py +0 -0
  54. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/ssh.py +0 -0
  55. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/ssh_config.py +0 -0
  56. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/status.py +0 -0
  57. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/core/types.py +0 -0
  58. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/facade.py +0 -0
  59. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/tray/__init__.py +0 -0
  60. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/tray/base.py +0 -0
  61. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/tray/debug_server.py +0 -0
  62. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/tray/linux.py +0 -0
  63. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/__init__.py +0 -0
  64. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/__main__.py +0 -0
  65. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/app.py +0 -0
  66. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/app.tcss +0 -0
  67. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/cli.py +0 -0
  68. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/screens/__init__.py +0 -0
  69. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/screens/connections.py +0 -0
  70. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/screens/dashboard.py +0 -0
  71. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/screens/shares.py +0 -0
  72. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/widgets/__init__.py +0 -0
  73. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/tui/widgets/connection_card.py +0 -0
  74. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops/version.py +0 -0
  75. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops.egg-info/SOURCES.txt +0 -0
  76. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops.egg-info/dependency_links.txt +0 -0
  77. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops.egg-info/entry_points.txt +0 -0
  78. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops.egg-info/requires.txt +0 -0
  79. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/src/susops.egg-info/top_level.txt +0 -0
  80. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_bw_totals.py +0 -0
  81. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_cli.py +0 -0
  82. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_client.py +0 -0
  83. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_config.py +0 -0
  84. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_conftest_smoke.py +0 -0
  85. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_facade.py +0 -0
  86. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_openapi.py +0 -0
  87. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_pac.py +0 -0
  88. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_packaging.py +0 -0
  89. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_process.py +0 -0
  90. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_rpc_protocol.py +0 -0
  91. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_rpc_server.py +0 -0
  92. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_scripts.py +0 -0
  93. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_services_daemon.py +0 -0
  94. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_share.py +0 -0
  95. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_share_aiohttp.py +0 -0
  96. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_socat.py +0 -0
  97. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_ssh.py +0 -0
  98. {susops-3.0.0rc6.dev1 → susops-3.0.0rc6.dev2}/tests/test_status.py +0 -0
  99. {susops-3.0.0rc6.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.0rc6.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "susops"
7
- version = "3.0.0-rc6.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" }
@@ -453,7 +453,8 @@ def build_share_detail(info, status, conn_tags=()) -> DetailSpec:
453
453
  status_dot = "red"
454
454
  fields = [
455
455
  FormField(key="file", label="File", kind="static",
456
- value=str(info.file_path)),
456
+ value=str(info.file_path),
457
+ trailing=(("share.open_folder", "Open Folder"),)),
457
458
  FormField(key="conn_tag", label="Connection", kind="popup",
458
459
  value=getattr(info, "conn_tag", "") or "",
459
460
  options=list(conn_tags)),
@@ -1471,12 +1471,22 @@ def _set_launch_at_login(enable: bool) -> None:
1471
1471
  path = _login_item_path()
1472
1472
  name = Path(path).stem
1473
1473
  if enable:
1474
+ # Wrap in try-catch to handle the case where the login item already exists.
1475
+ # Creating a duplicate will fail; error suppression allows idempotency.
1474
1476
  script = (
1475
- 'tell application "System Events" to make login item '
1476
- f'at end with properties {{path:"{path}", hidden:false}}'
1477
+ 'try\n'
1478
+ ' tell application "System Events" to make login item '
1479
+ f'at end with properties {{path:"{path}", hidden:false}}\n'
1480
+ 'end try'
1477
1481
  )
1478
1482
  else:
1479
- script = f'tell application "System Events" to delete login item "{name}"'
1483
+ # Wrap delete in try-catch to gracefully handle missing login items.
1484
+ # Error -1728 occurs when the item doesn't exist (e.g., in dev environments).
1485
+ script = (
1486
+ 'try\n'
1487
+ f' tell application "System Events" to delete login item "{name}"\n'
1488
+ 'end try'
1489
+ )
1480
1490
  try:
1481
1491
  subprocess.run(["osascript", "-e", script], check=False, timeout=3)
1482
1492
  except Exception:
@@ -1789,6 +1799,18 @@ class SusOpsMacTray(AbstractTrayApp):
1789
1799
  self._copy_to_pasteboard(f"http://localhost:{port}")
1790
1800
  elif action_id == "share.copy_password":
1791
1801
  self._copy_to_pasteboard(info.password or "")
1802
+ elif action_id == "share.open_folder":
1803
+ try:
1804
+ from AppKit import NSWorkspace, NSURL # type: ignore[import]
1805
+
1806
+ p = Path(str(info.file_path)).expanduser()
1807
+ if p.exists():
1808
+ u = NSURL.fileURLWithPath_(str(p))
1809
+ NSWorkspace.sharedWorkspace().activateFileViewerSelectingURLs_([u])
1810
+ else:
1811
+ self.show_alert("File Not Found", str(p))
1812
+ except Exception as e:
1813
+ self.show_alert("Could Not Open Folder", str(e))
1792
1814
  self._refresh_config_window()
1793
1815
 
1794
1816
  def _reserve_share_silent(self, info) -> None:
@@ -2925,12 +2947,15 @@ class SusOpsMacTray(AbstractTrayApp):
2925
2947
  return self._apply_server_ports(rpc, sse, pac, ctx)
2926
2948
 
2927
2949
  def _done(err):
2950
+ cw = getattr(self, "_config_window", None)
2928
2951
  if err:
2952
+ if cw is not None:
2953
+ cw.mark_settings_save_failed()
2929
2954
  self.show_alert("Could Not Apply Settings", err)
2930
2955
  return
2931
- cw = getattr(self, "_config_window", None)
2932
2956
  if cw is not None:
2933
2957
  cw.clear_settings_dirty()
2958
+ cw.mark_settings_saved()
2934
2959
  cw.refresh()
2935
2960
  self.run_in_background(_work, _done)
2936
2961
 
@@ -45,6 +45,8 @@ _action_handler_cls = None
45
45
  _text_delegate_cls = None
46
46
  _row_view_cls = None
47
47
  _vcenter_text_cell_cls = None
48
+ _padded_secure_cell_cls = None
49
+ _padded_plain_pw_cell_cls = None
48
50
  _TEXT_CELL_FLAG_ACCESSORS = (
49
51
  ("isEditable", "setEditable_"),
50
52
  ("isSelectable", "setSelectable_"),
@@ -62,9 +64,6 @@ def _make_vcenter_cell_cls(base_cell_cls, padding: float = 0.0):
62
64
  from Foundation import NSMakeRect # type: ignore[import]
63
65
 
64
66
  class _SusOpsVCenterCell(base_cell_cls):
65
- def __init__(self):
66
- super().__init__()
67
- self._padding = 0.0
68
67
 
69
68
  def _baseTitleRect_(self, frame):
70
69
  try:
@@ -140,6 +139,108 @@ def _get_vcenter_text_cell_cls():
140
139
  return _vcenter_text_cell_cls
141
140
 
142
141
 
142
+ def _get_padded_secure_cell_cls():
143
+ global _padded_secure_cell_cls
144
+ if _padded_secure_cell_cls is not None:
145
+ return _padded_secure_cell_cls
146
+ from Cocoa import NSMakeRect, NSSecureTextFieldCell # type: ignore[import]
147
+
148
+ class _SusOpsPaddedSecureCell(NSSecureTextFieldCell):
149
+ def _insetCenteredRect_(self, frame):
150
+ try:
151
+ rect = objc.super(_SusOpsPaddedSecureCell, self).titleRectForBounds_(frame)
152
+ except Exception:
153
+ rect = objc.super(_SusOpsPaddedSecureCell, self).drawingRectForBounds_(frame)
154
+ text_h = rect.size.height
155
+ try:
156
+ size = objc.super(_SusOpsPaddedSecureCell, self).cellSizeForBounds_(frame)
157
+ if size is not None and getattr(size, "height", 0) > 0:
158
+ text_h = min(rect.size.height, float(size.height))
159
+ except Exception:
160
+ pass
161
+ text_h = max(1.0, min(float(frame.size.height), float(text_h)))
162
+ p = float(getattr(self, "_padding", 0.0))
163
+ return NSMakeRect(
164
+ float(frame.origin.x) + max(0.0, p),
165
+ float(frame.origin.y) + max(0.0, (float(frame.size.height) - text_h) / 2.0),
166
+ max(1.0, float(frame.size.width) - max(0.0, p)),
167
+ text_h,
168
+ )
169
+
170
+ def drawInteriorWithFrame_inView_(self, frame, view):
171
+ objc.super(_SusOpsPaddedSecureCell, self).drawInteriorWithFrame_inView_(
172
+ self._insetCenteredRect_(frame), view
173
+ )
174
+
175
+ def selectWithFrame_inView_editor_delegate_start_length_(
176
+ self, frame, view, editor, delegate, start, length
177
+ ):
178
+ objc.super(_SusOpsPaddedSecureCell, self).selectWithFrame_inView_editor_delegate_start_length_(
179
+ self._insetCenteredRect_(frame), view, editor, delegate, start, length
180
+ )
181
+
182
+ def editWithFrame_inView_editor_delegate_event_(
183
+ self, frame, view, editor, delegate, event
184
+ ):
185
+ objc.super(_SusOpsPaddedSecureCell, self).editWithFrame_inView_editor_delegate_event_(
186
+ self._insetCenteredRect_(frame), view, editor, delegate, event
187
+ )
188
+
189
+ _padded_secure_cell_cls = _SusOpsPaddedSecureCell
190
+ return _padded_secure_cell_cls
191
+
192
+
193
+ def _get_padded_plain_password_cell_cls():
194
+ global _padded_plain_pw_cell_cls
195
+ if _padded_plain_pw_cell_cls is not None:
196
+ return _padded_plain_pw_cell_cls
197
+ from Cocoa import NSMakeRect, NSTextFieldCell # type: ignore[import]
198
+
199
+ class _SusOpsPaddedPlainPasswordCell(NSTextFieldCell):
200
+ def _insetCenteredRect_(self, frame):
201
+ try:
202
+ rect = objc.super(_SusOpsPaddedPlainPasswordCell, self).titleRectForBounds_(frame)
203
+ except Exception:
204
+ rect = objc.super(_SusOpsPaddedPlainPasswordCell, self).drawingRectForBounds_(frame)
205
+ text_h = rect.size.height
206
+ try:
207
+ size = objc.super(_SusOpsPaddedPlainPasswordCell, self).cellSizeForBounds_(frame)
208
+ if size is not None and getattr(size, "height", 0) > 0:
209
+ text_h = min(rect.size.height, float(size.height))
210
+ except Exception:
211
+ pass
212
+ text_h = max(1.0, min(float(frame.size.height), float(text_h)))
213
+ p = float(getattr(self, "_padding", 0.0))
214
+ return NSMakeRect(
215
+ float(frame.origin.x) + max(0.0, p),
216
+ float(frame.origin.y) + max(0.0, (float(frame.size.height) - text_h) / 2.0),
217
+ max(1.0, float(frame.size.width) - max(0.0, p)),
218
+ text_h,
219
+ )
220
+
221
+ def drawInteriorWithFrame_inView_(self, frame, view):
222
+ objc.super(_SusOpsPaddedPlainPasswordCell, self).drawInteriorWithFrame_inView_(
223
+ self._insetCenteredRect_(frame), view
224
+ )
225
+
226
+ def selectWithFrame_inView_editor_delegate_start_length_(
227
+ self, frame, view, editor, delegate, start, length
228
+ ):
229
+ objc.super(_SusOpsPaddedPlainPasswordCell, self).selectWithFrame_inView_editor_delegate_start_length_(
230
+ self._insetCenteredRect_(frame), view, editor, delegate, start, length
231
+ )
232
+
233
+ def editWithFrame_inView_editor_delegate_event_(
234
+ self, frame, view, editor, delegate, event
235
+ ):
236
+ objc.super(_SusOpsPaddedPlainPasswordCell, self).editWithFrame_inView_editor_delegate_event_(
237
+ self._insetCenteredRect_(frame), view, editor, delegate, event
238
+ )
239
+
240
+ _padded_plain_pw_cell_cls = _SusOpsPaddedPlainPasswordCell
241
+ return _padded_plain_pw_cell_cls
242
+
243
+
143
244
  def _truncate_tail(field) -> None:
144
245
  """Single-line + truncate an NSTextField so overflow shows an ellipsis
145
246
  instead of a raw hard cut at the field edge. NSLineBreakByTruncatingMiddle = 5
@@ -445,11 +546,12 @@ TOP_INSET = 45 # traffic lights overlay col 1; start content below them
445
546
  SIDEBAR_TOP_INSET = TOP_INSET - 9 # nav-only top inset (kept independent for fine tuning)
446
547
  SEARCH_H = 30
447
548
  ADDBAR_H = 40
549
+ ADDBAR_BOTTOM_INSET = 4
448
550
  ROW_PILL_INSET_X = 12 # left/right margin for nav/list rows and selection pill
449
551
  ROW_TEXT_RIGHT_PAD = 18 # extra right breathing room before row edge
450
552
  NAV_ICON_INSET_X = 8 # nav icon left inset within row pill
451
553
  NAV_COUNT_RIGHT_INSET = NAV_ICON_INSET_X + 28 # match count inset to icon inset
452
- DEBUG_ROW_BOUNDS = True # temporary visual aid: draw row pills in red
554
+ DEBUG_ROW_BOUNDS = False # temporary visual aid: draw row pills in red
453
555
 
454
556
  # Column-3 content is constrained to a fixed-width column anchored top-left,
455
557
  # NOT stretched to the window edge. The Enabled toggle anchors to the RIGHT
@@ -531,11 +633,16 @@ class ConfigWindow:
531
633
  self._settings_widgets: dict = {} # field key -> control
532
634
  self._settings_kinds: dict = {} # field key -> kind
533
635
  self._settings_ctx: dict = {} # ctx from settings_field_specs
636
+ self._settings_status_label = None # tiny status text next to Save
637
+ self._settings_save_message = ""
638
+ self._settings_save_error = False
639
+ self._settings_save_clear_timer = None # timer for auto-clearing "Saved"
534
640
  # Settings staging: all settings changes are staged locally and only
535
641
  # persist on Apply. _settings_dirty tracks pending edits so leaving the
536
642
  # category or closing the window can discard them (and revert the logo
537
643
  # preview).
538
644
  self._settings_dirty = False
645
+ self._revealed_secure_tokens: set[tuple] = set()
539
646
 
540
647
  # ------------------------------------------------------------------ #
541
648
  # Lifecycle
@@ -573,6 +680,13 @@ class ConfigWindow:
573
680
  # Closing the window without Apply discards any staged settings
574
681
  # changes (and reverts a logo icon preview to the saved logo).
575
682
  self.discard_settings()
683
+ # Cancel any pending auto-clear timer.
684
+ if self._settings_save_clear_timer is not None:
685
+ try:
686
+ self._settings_save_clear_timer.cancel()
687
+ except Exception:
688
+ pass
689
+ self._settings_save_clear_timer = None
576
690
  if self._policy_scope is not None:
577
691
  try:
578
692
  self._policy_scope.__exit__(None, None, None)
@@ -798,7 +912,7 @@ class ConfigWindow:
798
912
  self._search_field = sf
799
913
 
800
914
  list_top = ch - TOP_INSET - SEARCH_H - 8
801
- list_h = list_top - ADDBAR_H - 8
915
+ list_h = list_top - ADDBAR_H - ADDBAR_BOTTOM_INSET
802
916
  tv = NSTableView.alloc().initWithFrame_(
803
917
  NSMakeRect(0, 0, COL2_W, list_h))
804
918
  col = NSTableColumn.alloc().initWithIdentifier_("list")
@@ -816,7 +930,7 @@ class ConfigWindow:
816
930
  tv.setDataSource_(ds)
817
931
  tv.setDelegate_(ds)
818
932
  scroll = NSScrollView.alloc().initWithFrame_(
819
- NSMakeRect(0, ADDBAR_H + 8, COL2_W, list_h))
933
+ NSMakeRect(0, ADDBAR_H + ADDBAR_BOTTOM_INSET, COL2_W, list_h))
820
934
  scroll.setDrawsBackground_(False)
821
935
  scroll.setHasHorizontalScroller_(False)
822
936
  scroll.setHasVerticalScroller_(True)
@@ -975,10 +1089,13 @@ class ConfigWindow:
975
1089
  return [] # settings has no list
976
1090
 
977
1091
  def _reload_list(self, *, preserve: bool, skip_detail: bool = False) -> None:
978
- # When a col-3 form is dirty (skip_detail), keep the col-2 highlight on
979
- # the dirty identity and do NOT touch column 3.
980
- prev = (self._dirty_identity if skip_detail
981
- else (self.selected_identity if preserve else None))
1092
+ # When skip_detail is set, keep the col-2 highlight pinned to the dirty
1093
+ # identity when available, otherwise preserve the current selected item
1094
+ # (focused-but-not-dirty detail fields still need stable row selection).
1095
+ if skip_detail:
1096
+ prev = self._dirty_identity or self.selected_identity
1097
+ else:
1098
+ prev = self.selected_identity if preserve else None
982
1099
  self._all_rows = self._build_category_rows()
983
1100
  self.rows = filter_rows(self._all_rows, self.search_text)
984
1101
  self._suppress_selection_cb = True
@@ -1019,6 +1136,8 @@ class ConfigWindow:
1019
1136
  # _on_selection which discards; this covers the programmatic/debug path.
1020
1137
  if self.category == "settings" and category != "settings":
1021
1138
  self.discard_settings()
1139
+ if category != self.category:
1140
+ self._clear_revealed_password_state()
1022
1141
  self.category = category
1023
1142
  idx = next((i for i, n in enumerate(self.nav_items)
1024
1143
  if n.key == category), None)
@@ -1062,6 +1181,8 @@ class ConfigWindow:
1062
1181
  self._dirty = False
1063
1182
  self._dirty_identity = None
1064
1183
  self._create_kind = None
1184
+ if new_cat != self.category:
1185
+ self._clear_revealed_password_state()
1065
1186
  self.category = new_cat
1066
1187
  self.selected_identity = None
1067
1188
  self.search_text = ""
@@ -1095,6 +1216,8 @@ class ConfigWindow:
1095
1216
  self._dirty = False
1096
1217
  self._dirty_identity = None
1097
1218
  self._create_kind = None
1219
+ if new_identity != self.selected_identity:
1220
+ self._clear_revealed_password_state()
1098
1221
  self.selected_identity = new_identity
1099
1222
  self._render_selection_placeholder()
1100
1223
 
@@ -1447,7 +1570,9 @@ class ConfigWindow:
1447
1570
  specs = self._add_button_specs()
1448
1571
  if not specs:
1449
1572
  return
1450
- bar = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, COL2_W, ADDBAR_H))
1573
+ bar = NSView.alloc().initWithFrame_(
1574
+ NSMakeRect(0, ADDBAR_BOTTOM_INSET, COL2_W, ADDBAR_H)
1575
+ )
1451
1576
  bar.setAutoresizingMask_(2 | 32) # WidthSizable | MaxYMargin
1452
1577
  n = len(specs)
1453
1578
  gap = 8
@@ -1501,6 +1626,7 @@ class ConfigWindow:
1501
1626
  preselect = self._preselected_conn_tag(conn_tags)
1502
1627
  self._dirty = False
1503
1628
  self._dirty_identity = None
1629
+ self._clear_revealed_password_state()
1504
1630
  self._create_kind = kind
1505
1631
  self.selected_identity = None
1506
1632
  # Deselect any highlighted col-2 row without firing the selection cb.
@@ -1523,6 +1649,7 @@ class ConfigWindow:
1523
1649
 
1524
1650
  def exit_create_mode(self) -> None:
1525
1651
  """Leave create mode and restore normal selection rendering."""
1652
+ self._clear_revealed_password_state()
1526
1653
  self._create_kind = None
1527
1654
  self._dirty = False
1528
1655
  self._dirty_identity = None
@@ -1670,6 +1797,53 @@ class ConfigWindow:
1670
1797
  self._header_toggle_label = None
1671
1798
  self._settings_widgets = {}
1672
1799
  self._settings_kinds = {}
1800
+ self._settings_status_label = None
1801
+
1802
+ def _set_settings_save_feedback(self, message: str = "", *, is_error: bool = False) -> None:
1803
+ """Update the settings Save feedback state and repaint the inline label
1804
+ when it exists."""
1805
+ from Cocoa import NSColor # type: ignore[import]
1806
+
1807
+ # Cancel any pending auto-clear timer.
1808
+ if self._settings_save_clear_timer is not None:
1809
+ try:
1810
+ self._settings_save_clear_timer.cancel()
1811
+ except Exception:
1812
+ pass
1813
+ self._settings_save_clear_timer = None
1814
+
1815
+ self._settings_save_message = str(message or "")
1816
+ self._settings_save_error = bool(is_error)
1817
+ lbl = self._settings_status_label
1818
+ if lbl is None:
1819
+ return
1820
+ lbl.setStringValue_(self._settings_save_message)
1821
+ if not self._settings_save_message:
1822
+ lbl.setTextColor_(NSColor.tertiaryLabelColor())
1823
+ return
1824
+ if self._settings_save_error:
1825
+ lbl.setTextColor_(NSColor.systemRedColor())
1826
+ return
1827
+ lbl.setTextColor_(NSColor.systemGreenColor())
1828
+
1829
+ def mark_settings_saved(self) -> None:
1830
+ """Show 'Saved' and auto-clear it after 3 seconds."""
1831
+ self._set_settings_save_feedback("Saved", is_error=False)
1832
+ # Schedule auto-clear on a background thread to avoid blocking the UI.
1833
+ import threading
1834
+ def _clear():
1835
+ try:
1836
+ # Marshal the UI update back to the main thread.
1837
+ from susops.tray.mac import _on_main
1838
+ _on_main(lambda: self._set_settings_save_feedback())
1839
+ except Exception:
1840
+ pass
1841
+ self._settings_save_clear_timer = threading.Timer(1.0, _clear)
1842
+ self._settings_save_clear_timer.daemon = True
1843
+ self._settings_save_clear_timer.start()
1844
+
1845
+ def mark_settings_save_failed(self) -> None:
1846
+ self._set_settings_save_feedback("Save failed", is_error=True)
1673
1847
 
1674
1848
  def _content_column(self):
1675
1849
  """Create the content column: a fixed CONTENT_MAX_W view anchored
@@ -2121,6 +2295,7 @@ class ConfigWindow:
2121
2295
  frame = NSMakeRect(x, cy, ctrl_w, 22)
2122
2296
  secure = NSSecureTextField.alloc().initWithFrame_(frame)
2123
2297
  plain = NSTextField.alloc().initWithFrame_(frame)
2298
+ token = self._secure_state_token(f.key)
2124
2299
  for tf in (secure, plain):
2125
2300
  tf.setStringValue_(str(f.value or ""))
2126
2301
  tf.setFont_(NSFont.systemFontOfSize_(13))
@@ -2135,14 +2310,27 @@ class ConfigWindow:
2135
2310
  except Exception:
2136
2311
  pass
2137
2312
  self._style_input_field(tf)
2138
- _apply_vcenter_cell(tf, _get_vcenter_text_cell_cls(), padding=10.0)
2313
+ if tf is plain:
2314
+ _apply_vcenter_cell(tf, _get_padded_plain_password_cell_cls(), padding=10.0)
2315
+ else:
2316
+ _apply_vcenter_cell(tf, _get_padded_secure_cell_cls(), padding=10.0)
2139
2317
  self._wire_text_dirty(tf)
2140
2318
  card.addSubview_(tf)
2141
- plain.setHidden_(True)
2319
+ revealed = token in self._revealed_secure_tokens
2320
+ plain.setHidden_(not revealed)
2321
+ secure.setHidden_(revealed)
2142
2322
  # The visible field is the one collect_form_values / set_field read.
2143
- self._field_widgets[f.key] = secure
2323
+ self._field_widgets[f.key] = plain if revealed else secure
2144
2324
  self._secure_pair[f.key] = (secure, plain)
2145
2325
 
2326
+ def _secure_state_token(self, key: str) -> tuple:
2327
+ """Stable token for preserving secure-field reveal state across rerenders."""
2328
+ return (self.category, self._create_kind, self.selected_identity, key)
2329
+
2330
+ def _clear_revealed_password_state(self) -> None:
2331
+ """Auto-hide any revealed password when navigating away from its context."""
2332
+ self._revealed_secure_tokens.clear()
2333
+
2146
2334
  def _reserve_trailing(self, card, f, x, ry, width) -> float:
2147
2335
  """Render f.trailing buttons right-aligned within [x, x+width] and
2148
2336
  return the control width remaining on the left. share.reveal is a local
@@ -2171,8 +2359,11 @@ class ConfigWindow:
2171
2359
  ctrl_w = max(60, rx - x - 8)
2172
2360
  bx = rx
2173
2361
  for (aid, label), bw in zip(trailing, widths):
2362
+ btn_label = label
2363
+ if aid == "share.reveal" and self._secure_state_token(f.key) in self._revealed_secure_tokens:
2364
+ btn_label = "Hide"
2174
2365
  btn = self._styled_neutral_button(
2175
- label, NSMakeRect(bx, btn_y, bw, btn_h))
2366
+ btn_label, NSMakeRect(bx, btn_y, bw, btn_h))
2176
2367
  if aid == "share.reveal":
2177
2368
  handler = _get_action_handler_cls().alloc().initWithCallback_(
2178
2369
  lambda _s, key=f.key, b=btn: self._on_reveal_password(key, b))
@@ -2226,12 +2417,14 @@ class ConfigWindow:
2226
2417
  return
2227
2418
  secure, plain = pair
2228
2419
  showing_plain = bool(plain.isHidden()) is False
2420
+ token = self._secure_state_token(key)
2229
2421
  try:
2230
2422
  if showing_plain:
2231
2423
  secure.setStringValue_(str(plain.stringValue() or ""))
2232
2424
  plain.setHidden_(True)
2233
2425
  secure.setHidden_(False)
2234
2426
  self._field_widgets[key] = secure
2427
+ self._revealed_secure_tokens.discard(token)
2235
2428
  btn.setAttributedTitle_(
2236
2429
  self._attr_title("Reveal", _hex_color(PALETTE["input_text"])))
2237
2430
  else:
@@ -2239,6 +2432,7 @@ class ConfigWindow:
2239
2432
  secure.setHidden_(True)
2240
2433
  plain.setHidden_(False)
2241
2434
  self._field_widgets[key] = plain
2435
+ self._revealed_secure_tokens.add(token)
2242
2436
  btn.setAttributedTitle_(
2243
2437
  self._attr_title("Hide", _hex_color(PALETTE["input_text"])))
2244
2438
  except Exception:
@@ -2943,7 +3137,8 @@ class ConfigWindow:
2943
3137
  single row. Save commits ALL staged settings (toggles + logo + login +
2944
3138
  ports); it is always enabled. Open Config File opens the YAML in
2945
3139
  $EDITOR."""
2946
- from Cocoa import NSMakeRect # type: ignore[import]
3140
+ from AppKit import NSFont # type: ignore[import]
3141
+ from Cocoa import NSColor, NSMakeRect, NSTextField # type: ignore[import]
2947
3142
  btn_h = 28
2948
3143
  # Match the button baseline to the section-label baseline used in
2949
3144
  # _section_label (label frame origin: section_top - 20, height: 18).
@@ -2972,6 +3167,26 @@ class ConfigWindow:
2972
3167
  save_btn.setTarget_(save_handler)
2973
3168
  save_btn.setAction_("fire:")
2974
3169
  doc.addSubview_(save_btn)
3170
+
3171
+ # Inline save feedback gives immediate visual confirmation without
3172
+ # requiring another dialog.
3173
+ status_x = save_x + save_w + 12
3174
+ status_w = max(80, width - (status_x - x))
3175
+ status = NSTextField.alloc().initWithFrame_(
3176
+ NSMakeRect(status_x, btn_y + 5, status_w, 16))
3177
+ status.setStringValue_("")
3178
+ status.setFont_(NSFont.systemFontOfSize_(11))
3179
+ status.setBezeled_(False)
3180
+ status.setDrawsBackground_(False)
3181
+ status.setEditable_(False)
3182
+ status.setSelectable_(False)
3183
+ status.setTextColor_(NSColor.tertiaryLabelColor())
3184
+ doc.addSubview_(status)
3185
+ self._settings_status_label = status
3186
+ self._set_settings_save_feedback(
3187
+ self._settings_save_message,
3188
+ is_error=self._settings_save_error,
3189
+ )
2975
3190
  # Keep the section height consistent with other rows.
2976
3191
  return btn_y - 6
2977
3192
 
@@ -2980,6 +3195,7 @@ class ConfigWindow:
2980
3195
  Marks the settings pane dirty. For logo_style, show a LIVE icon preview
2981
3196
  (reverted on leave/close without Apply)."""
2982
3197
  self._settings_dirty = True
3198
+ self._set_settings_save_feedback()
2983
3199
  if key == "logo_style":
2984
3200
  try:
2985
3201
  self.tray.preview_logo_style(int(value))
@@ -2995,7 +3211,7 @@ class ConfigWindow:
2995
3211
  try:
2996
3212
  self.tray.apply_all_settings(values)
2997
3213
  except Exception:
2998
- pass
3214
+ self.mark_settings_save_failed()
2999
3215
 
3000
3216
  def discard_settings(self) -> None:
3001
3217
  """Drop all staged settings changes: revert any logo icon preview to the
@@ -3053,6 +3269,7 @@ class ConfigWindow:
3053
3269
  except Exception:
3054
3270
  return {"error": f"could not set '{key}'"}
3055
3271
  self._settings_dirty = True
3272
+ self._set_settings_save_feedback()
3056
3273
  return {"ok": True, "key": key, "value": value}
3057
3274
 
3058
3275
  def clear_settings_dirty(self) -> None:
@@ -3090,6 +3307,8 @@ class ConfigWindow:
3090
3307
  "settings": (self._settings_values()
3091
3308
  if self.category == "settings" else None),
3092
3309
  "settings_dirty": bool(getattr(self, "_settings_dirty", False)),
3310
+ "settings_save_message": str(getattr(self, "_settings_save_message", "")),
3311
+ "settings_save_error": bool(getattr(self, "_settings_save_error", False)),
3093
3312
  "fields": self._dump_fields(),
3094
3313
  # Cumulative auto-answered modal panels (debug mode) so tests can
3095
3314
  # assert no unexpected dialog fired.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: susops
3
- Version: 3.0.0rc6.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
@@ -52,9 +52,9 @@ def test_cask_update_handles_legacy_initial_state(tmp_path: Path) -> None:
52
52
  p = tmp_path / "susops.rb"
53
53
  p.write_text(LEGACY_CASK)
54
54
 
55
- update_cask_sha(p, "3.0.0-rc6.dev1", "c" * 64)
55
+ update_cask_sha(p, "3.0.0-rc6.dev2", "c" * 64)
56
56
  out = p.read_text()
57
- assert 'version "3.0.0-rc6.dev1"' in out
57
+ assert 'version "3.0.0-rc6.dev2"' in out
58
58
  assert f'sha256 "{"c" * 64}"' in out
59
59
  assert '#{version}' in out
60
60
 
File without changes
File without changes
File without changes