susops 3.0.0rc3.dev1__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from susops.version import VERSION
2
+
3
+ __version__ = VERSION
4
+ __all__ = ["__version__"]
susops/client.py ADDED
@@ -0,0 +1,230 @@
1
+ """Thin RPC client that mirrors SusOpsManager's public API.
2
+
3
+ Designed so frontends can replace
4
+ self.manager = SusOpsManager(workspace=...)
5
+ with
6
+ self.manager = SusOpsClient(workspace=...)
7
+ and have everything just work. All known facade methods are forwarded over
8
+ the daemon's /rpc endpoint; exceptions raised in the daemon are
9
+ reconstructed (by name) and re-raised in the client.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from susops.core.rpc_protocol import InvocationRequest, InvocationResponse
23
+
24
+ _WORKSPACE_DEFAULT = Path.home() / ".susops"
25
+ _DAEMON_SPAWN_TIMEOUT = 5.0
26
+
27
+
28
+ class DaemonUnavailableError(RuntimeError):
29
+ """Raised when the daemon can't be reached or won't start."""
30
+
31
+
32
+ def _port_path(workspace: Path) -> Path:
33
+ return workspace / "pids" / "susops-services.port"
34
+
35
+
36
+ def _pid_path(workspace: Path) -> Path:
37
+ return workspace / "pids" / "susops-services.pid"
38
+
39
+
40
+ def _read_port(workspace: Path) -> int | None:
41
+ try:
42
+ return int(_port_path(workspace).read_text().strip())
43
+ except Exception:
44
+ return None
45
+
46
+
47
+ def _is_daemon_alive(workspace: Path) -> bool:
48
+ pid_file = _pid_path(workspace)
49
+ if not pid_file.exists():
50
+ return False
51
+ try:
52
+ pid = int(pid_file.read_text().strip())
53
+ os.kill(pid, 0) # signal 0 = liveness probe
54
+ return True
55
+ except (OSError, ValueError):
56
+ return False
57
+
58
+
59
+ def ensure_daemon_running(workspace: Path = _WORKSPACE_DEFAULT) -> int:
60
+ """Make sure the susops-services daemon is up; spawn it if not.
61
+
62
+ Returns the RPC port. Raises DaemonUnavailableError on timeout.
63
+
64
+ Captures the spawned daemon's stderr so a preflight failure
65
+ (PAC port squatted, peer daemon alive, …) is surfaced to the caller
66
+ instead of getting buried under a generic "didn't come up" message.
67
+ """
68
+ if _is_daemon_alive(workspace):
69
+ port = _read_port(workspace)
70
+ if port:
71
+ return port
72
+
73
+ proc = subprocess.Popen(
74
+ [sys.executable, "-m", "susops.core.services_daemon",
75
+ "--workspace", str(workspace)],
76
+ stdin=subprocess.DEVNULL,
77
+ stdout=subprocess.DEVNULL,
78
+ stderr=subprocess.PIPE,
79
+ start_new_session=True,
80
+ )
81
+
82
+ deadline = time.monotonic() + _DAEMON_SPAWN_TIMEOUT
83
+ while time.monotonic() < deadline:
84
+ if _is_daemon_alive(workspace):
85
+ port = _read_port(workspace)
86
+ if port:
87
+ return port
88
+ # If the daemon exited (e.g. preflight rejected it), don't keep
89
+ # polling — surface the reason and bail out immediately.
90
+ rc = proc.poll()
91
+ if rc is not None:
92
+ try:
93
+ _, err = proc.communicate(timeout=0.5)
94
+ stderr = err.decode(errors="replace").strip() if err else ""
95
+ except Exception:
96
+ stderr = ""
97
+ msg = (
98
+ f"Daemon exited during startup (rc={rc})"
99
+ + (f":\n{stderr}" if stderr else "")
100
+ )
101
+ raise DaemonUnavailableError(msg)
102
+ time.sleep(0.1)
103
+
104
+ # Spawn is still alive but never wrote its PID/port file in time.
105
+ try:
106
+ proc.terminate()
107
+ except Exception:
108
+ pass
109
+ raise DaemonUnavailableError("Daemon did not come up within timeout")
110
+
111
+
112
+ # Map of error_type strings to Python exception classes the client
113
+ # re-raises. Anything not listed falls back to RuntimeError so we never
114
+ # silently swallow a server-side failure.
115
+ _EXC_MAP: dict[str, type] = {
116
+ "ValueError": ValueError,
117
+ "RuntimeError": RuntimeError,
118
+ "FileNotFoundError": FileNotFoundError,
119
+ "PermissionError": PermissionError,
120
+ "KeyError": KeyError,
121
+ "AttributeError": AttributeError,
122
+ }
123
+
124
+
125
+ class SusOpsClient:
126
+ """RPC proxy with the same API surface as `SusOpsManager`.
127
+
128
+ Lazy: only resolves the daemon on first call. If it isn't running,
129
+ auto-spawns it via `ensure_daemon_running`.
130
+ """
131
+
132
+ def __init__(self, workspace: Path = _WORKSPACE_DEFAULT,
133
+ process_name: str = "susops-client") -> None:
134
+ self.workspace = workspace
135
+ # process_name kept for API compatibility with frontends.
136
+ self._process_name = process_name
137
+ self._port: int | None = None
138
+
139
+ # ------------------------------------------------------------------ #
140
+ # Compatibility shims that some frontends read directly.
141
+ # ------------------------------------------------------------------ #
142
+
143
+ @property
144
+ def app_config(self):
145
+ """Frontends sometimes read `manager.app_config.<field>` directly."""
146
+ return self.list_config().susops_app
147
+
148
+ @property
149
+ def config(self):
150
+ """Snapshot of the current config. Per call → fresh RPC."""
151
+ return self.list_config()
152
+
153
+ # ------------------------------------------------------------------ #
154
+ # Auto-proxy: any unknown public attribute becomes an RPC call.
155
+ # ------------------------------------------------------------------ #
156
+
157
+ def __getattr__(self, name: str):
158
+ # Block dunders + private names so they never reach /rpc.
159
+ if name.startswith("_"):
160
+ raise AttributeError(name)
161
+
162
+ def _proxy(*args, **kwargs):
163
+ return self._invoke(name, list(args), kwargs)
164
+
165
+ # Cache the proxy so repeated lookups don't rebuild it.
166
+ self.__dict__[name] = _proxy
167
+ return _proxy
168
+
169
+ # ------------------------------------------------------------------ #
170
+ # Internal: RPC dispatch.
171
+ # ------------------------------------------------------------------ #
172
+
173
+ def _invoke(self, method: str, args: list, kwargs: dict) -> Any:
174
+ """Issue one RPC call. On connection-refused (daemon died / wasn't
175
+ running yet), reset the cached port, re-run ensure_daemon_running
176
+ (which respawns if needed), and retry exactly once. Tray + TUI
177
+ otherwise crash on the first menu click after a daemon restart.
178
+ """
179
+ req = InvocationRequest(method=method, args=args, kwargs=kwargs)
180
+ last_exc: Exception | None = None
181
+
182
+ for attempt in (1, 2):
183
+ if self._port is None:
184
+ try:
185
+ self._port = ensure_daemon_running(self.workspace)
186
+ except DaemonUnavailableError as exc:
187
+ last_exc = exc
188
+ continue
189
+
190
+ http_req = urllib.request.Request(
191
+ f"http://127.0.0.1:{self._port}/rpc",
192
+ data=req.to_json().encode("utf-8"),
193
+ headers={"Content-Type": "application/json"},
194
+ )
195
+ try:
196
+ with urllib.request.urlopen(http_req, timeout=30) as resp:
197
+ body = InvocationResponse.from_json(resp.read().decode("utf-8"))
198
+ except urllib.error.HTTPError as exc:
199
+ # 4xx/5xx — body should still be a valid InvocationResponse JSON.
200
+ # Don't retry; the daemon answered, the call just failed.
201
+ try:
202
+ body = InvocationResponse.from_json(exc.read().decode("utf-8"))
203
+ except Exception:
204
+ raise DaemonUnavailableError(f"Daemon HTTP error: {exc}") from exc
205
+ except urllib.error.URLError as exc:
206
+ # Connection refused / unreachable. The daemon died, was
207
+ # killed, or hasn't come back up yet. Drop the cached port
208
+ # so the next iteration ensures+respawns.
209
+ last_exc = exc
210
+ self._port = None
211
+ if attempt == 1:
212
+ continue
213
+ raise DaemonUnavailableError(f"Daemon unreachable: {exc}") from exc
214
+ except Exception as exc:
215
+ # Unknown transport error. Reset and retry once.
216
+ last_exc = exc
217
+ self._port = None
218
+ if attempt == 1:
219
+ continue
220
+ raise DaemonUnavailableError(f"Daemon RPC failed: {exc}") from exc
221
+
222
+ if body.ok:
223
+ return body.result
224
+ exc_cls = _EXC_MAP.get(body.error_type or "", RuntimeError)
225
+ raise exc_cls(body.error or f"RPC {method} failed")
226
+
227
+ # Both attempts exhausted (e.g. ensure_daemon_running kept failing).
228
+ raise DaemonUnavailableError(
229
+ f"Daemon unreachable after retry: {last_exc}"
230
+ ) from last_exc
File without changes
@@ -0,0 +1,330 @@
1
+ """Cross-platform browser detection + PAC-aware launch.
2
+
3
+ Single source of truth for browser metadata and launch logic so every
4
+ frontend (TUI, macOS tray, Linux tray) shares one detection pipeline.
5
+
6
+ Detection is **auto-discovery** — we scan platform-native registries for
7
+ HTTP-scheme handlers and classify chromium-vs-firefox via well-known
8
+ bundle-id / executable-name patterns. The result is that out-of-the-box
9
+ forks (Chrome Beta/Canary, LibreWolf, Waterfox, Tor Browser, Arc, etc.)
10
+ appear in the list without us maintaining a hardcoded table.
11
+
12
+ Public API:
13
+ detect_browsers() → list[Browser]
14
+ launch_with_pac(browser, pac_url, profile_dir)
15
+ open_proxy_settings(browser)
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import dataclasses
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ from pathlib import Path
24
+
25
+
26
+ @dataclasses.dataclass(frozen=True)
27
+ class Browser:
28
+ """A detected browser installation."""
29
+ name: str # display name, e.g. "Chrome"
30
+ launch_cmd: list[str] # base command, e.g. ["open", "-a", "Google Chrome"] or ["/usr/bin/google-chrome"]
31
+ is_chromium: bool # True for Chrome/Brave/Edge/Vivaldi/Chromium/Arc; False for Firefox-family
32
+ bundle: str | None = None # macOS app-bundle name (None on Linux)
33
+
34
+
35
+ _PROXY_SETTINGS_URL = "chrome://net-internals/#proxy"
36
+
37
+ # Bundle-id substrings that mark a browser as chromium-family on macOS.
38
+ # Match is case-insensitive `in` substring (so "google.chrome.beta" matches).
39
+ _MAC_CHROMIUM_BUNDLE_IDS = (
40
+ "com.google.chrome",
41
+ "com.brave.browser",
42
+ "org.chromium",
43
+ "com.microsoft.edgemac",
44
+ "com.vivaldi",
45
+ "com.thebrowser.browser", # Arc
46
+ "company.thebrowser.browser",
47
+ "com.operasoftware.opera",
48
+ )
49
+
50
+ # Same for Firefox-family. Anything matching neither is dropped (e.g. Safari
51
+ # can't be steered via `--proxy-pac-url` or chrome://-style URLs, so we
52
+ # don't surface it).
53
+ _MAC_FIREFOX_BUNDLE_IDS = (
54
+ "org.mozilla.firefox",
55
+ "org.mozilla.nightly",
56
+ "io.gitlab.librewolf",
57
+ "net.waterfox",
58
+ "org.torproject.torbrowser",
59
+ )
60
+
61
+ # Linux: executable basename substrings (case-insensitive).
62
+ _LINUX_CHROMIUM_EXES = (
63
+ "google-chrome", "chrome", "chromium",
64
+ "brave", "vivaldi", "microsoft-edge", "edge", "opera",
65
+ "arc", # speculative as Arc on Linux is in beta as of writing
66
+ )
67
+ _LINUX_FIREFOX_EXES = (
68
+ "firefox", "librewolf", "waterfox", "torbrowser", "tor-browser",
69
+ )
70
+
71
+
72
+ def detect_browsers() -> list[Browser]:
73
+ """Return browsers detected on the current platform.
74
+
75
+ Result is ordered: chromium-family first (alphabetized by display name),
76
+ Firefox-family last. Order matters for UX — chromium-style PAC support
77
+ is more reliable, so it goes first in pickers.
78
+ """
79
+ if sys.platform == "darwin":
80
+ browsers = _detect_macos()
81
+ else:
82
+ browsers = _detect_linux()
83
+ return sorted(browsers, key=lambda b: (not b.is_chromium, b.name.lower()))
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # macOS — scan .app bundles for HTTP-scheme handlers via Info.plist
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ def _detect_macos() -> list[Browser]:
92
+ import plistlib
93
+
94
+ found: list[Browser] = []
95
+ seen_bundles: set[str] = set()
96
+ candidate_dirs = [Path("/Applications"), Path.home() / "Applications"]
97
+ for d in candidate_dirs:
98
+ if not d.is_dir():
99
+ continue
100
+ for app_dir in d.glob("*.app"):
101
+ bundle_name = app_dir.stem # e.g. "Google Chrome"
102
+ if bundle_name in seen_bundles:
103
+ continue
104
+ plist_path = app_dir / "Contents" / "Info.plist"
105
+ if not plist_path.is_file():
106
+ continue
107
+ try:
108
+ with open(plist_path, "rb") as f:
109
+ info = plistlib.load(f)
110
+ except Exception:
111
+ continue
112
+ if not _macos_handles_http(info):
113
+ continue
114
+ bundle_id = (info.get("CFBundleIdentifier") or "").lower()
115
+ is_chromium = any(p in bundle_id for p in _MAC_CHROMIUM_BUNDLE_IDS)
116
+ is_firefox = any(p in bundle_id for p in _MAC_FIREFOX_BUNDLE_IDS)
117
+ if not (is_chromium or is_firefox):
118
+ # Safari/Firefox can't accept a PAC URL via the command line
119
+ continue
120
+ seen_bundles.add(bundle_name)
121
+ display_name = (
122
+ info.get("CFBundleDisplayName")
123
+ or info.get("CFBundleName")
124
+ or bundle_name
125
+ )
126
+ found.append(Browser(
127
+ name=str(display_name),
128
+ launch_cmd=["open", "-a", bundle_name],
129
+ is_chromium=is_chromium,
130
+ bundle=bundle_name,
131
+ ))
132
+ return found
133
+
134
+
135
+ def _macos_handles_http(info: dict) -> bool:
136
+ """Return True if the Info.plist declares an http/https URL handler."""
137
+ for url_type in info.get("CFBundleURLTypes", []) or []:
138
+ for scheme in url_type.get("CFBundleURLSchemes", []) or []:
139
+ if str(scheme).lower() in ("http", "https"):
140
+ return True
141
+ return False
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Linux — scan .desktop files in standard XDG application directories
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ def _detect_linux() -> list[Browser]:
150
+ import os
151
+
152
+ desktop_dirs: list[Path] = []
153
+ xdg_data_dirs = os.environ.get(
154
+ "XDG_DATA_DIRS", "/usr/local/share:/usr/share"
155
+ ).split(":")
156
+ xdg_data_home = os.environ.get(
157
+ "XDG_DATA_HOME", str(Path.home() / ".local" / "share")
158
+ )
159
+ for base in [xdg_data_home, *xdg_data_dirs]:
160
+ p = Path(base) / "applications"
161
+ if p.is_dir():
162
+ desktop_dirs.append(p)
163
+ # Flatpak / Snap exports often land here too.
164
+ for extra in (
165
+ Path("/var/lib/flatpak/exports/share/applications"),
166
+ Path.home() / ".local" / "share" / "flatpak" / "exports" / "share" / "applications",
167
+ Path("/var/lib/snapd/desktop/applications"),
168
+ ):
169
+ if extra.is_dir():
170
+ desktop_dirs.append(extra)
171
+
172
+ found: list[Browser] = []
173
+ seen_exes: set[str] = set()
174
+ for d in desktop_dirs:
175
+ for path in d.glob("*.desktop"):
176
+ entry = _parse_desktop_entry(path)
177
+ if entry is None:
178
+ continue
179
+ if not _linux_handles_http(entry):
180
+ continue
181
+ exec_cmd = _linux_resolve_exec(entry.get("Exec", ""))
182
+ if not exec_cmd:
183
+ continue
184
+ exe_path = exec_cmd[0]
185
+ exe_basename = Path(exe_path).name.lower()
186
+ if exe_basename in seen_exes:
187
+ continue
188
+ is_chromium = any(p in exe_basename for p in _LINUX_CHROMIUM_EXES)
189
+ is_firefox = any(p in exe_basename for p in _LINUX_FIREFOX_EXES)
190
+ if not (is_chromium or is_firefox):
191
+ continue
192
+ seen_exes.add(exe_basename)
193
+ display_name = entry.get("Name", exe_basename)
194
+ found.append(Browser(
195
+ name=display_name,
196
+ launch_cmd=[exe_path],
197
+ is_chromium=is_chromium,
198
+ ))
199
+ return found
200
+
201
+
202
+ def _parse_desktop_entry(path: Path) -> dict | None:
203
+ """Parse the [Desktop Entry] section of a .desktop file into a dict.
204
+
205
+ Returns None if the file isn't a valid desktop entry (NoDisplay=true,
206
+ Type != Application, etc.).
207
+ """
208
+ try:
209
+ text = path.read_text(errors="replace")
210
+ except OSError:
211
+ return None
212
+ entry: dict = {}
213
+ in_section = False
214
+ for line in text.splitlines():
215
+ line = line.strip()
216
+ if line.startswith("[Desktop Entry]"):
217
+ in_section = True
218
+ continue
219
+ if line.startswith("[") and in_section:
220
+ break
221
+ if not in_section or "=" not in line or line.startswith("#"):
222
+ continue
223
+ key, _, value = line.partition("=")
224
+ # Skip locale-specific variants (Name[de], etc.)
225
+ if "[" in key:
226
+ continue
227
+ entry.setdefault(key.strip(), value.strip())
228
+ if entry.get("Type", "Application") != "Application":
229
+ return None
230
+ if entry.get("NoDisplay", "false").lower() == "true":
231
+ return None
232
+ if entry.get("Hidden", "false").lower() == "true":
233
+ return None
234
+ return entry
235
+
236
+
237
+ def _linux_handles_http(entry: dict) -> bool:
238
+ mime = entry.get("MimeType", "")
239
+ return ("x-scheme-handler/http" in mime) or ("x-scheme-handler/https" in mime)
240
+
241
+
242
+ def _linux_resolve_exec(exec_field: str) -> list[str]:
243
+ """Resolve the Exec= field to an absolute command list.
244
+
245
+ `Exec=` may contain field codes (%u, %U, %f, %F) — we strip them.
246
+ The first token is the executable; we resolve it via shutil.which if
247
+ it's not already an absolute path. Returns [] if the executable can't
248
+ be found on PATH (skip it).
249
+ """
250
+ if not exec_field:
251
+ return []
252
+ # Strip %X field codes, they're placeholders for URL/file args
253
+ tokens = [t for t in exec_field.split() if not (t.startswith("%") and len(t) == 2)]
254
+ if not tokens:
255
+ return []
256
+ exe = tokens[0]
257
+ if not exe.startswith("/"):
258
+ resolved = shutil.which(exe)
259
+ if resolved is None:
260
+ return []
261
+ exe = resolved
262
+ return [exe, *tokens[1:]]
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # Launch
267
+ # ---------------------------------------------------------------------------
268
+
269
+
270
+ def launch_with_pac(browser: Browser, pac_url: str,
271
+ profile_dir: Path | None = None) -> None:
272
+ """Launch the browser with PAC URL pre-configured.
273
+
274
+ Chromium-family: passed as ``--proxy-pac-url=<url>``. macOS uses
275
+ ``open -na`` so a new instance picks up the flag rather than the
276
+ existing process ignoring it.
277
+
278
+ Firefox: requires a profile directory with ``user.js`` written —
279
+ Firefox doesn't accept a PAC URL via command-line flag. The caller
280
+ is expected to provide a workspace-owned profile dir; we write the
281
+ prefs and launch with ``-profile <dir> -no-remote``.
282
+
283
+ Raises subprocess.SubprocessError or OSError on launch failure;
284
+ caller is responsible for surfacing.
285
+ """
286
+ if browser.is_chromium:
287
+ if sys.platform == "darwin" and browser.bundle is not None:
288
+ cmd = ["open", "-na", browser.bundle, "--args", f"--proxy-pac-url={pac_url}"]
289
+ else:
290
+ cmd = browser.launch_cmd + [f"--proxy-pac-url={pac_url}"]
291
+ subprocess.Popen(cmd)
292
+ return
293
+
294
+ # Firefox path
295
+ if profile_dir is None:
296
+ raise ValueError(
297
+ "Firefox launch requires a profile_dir (Firefox doesn't accept "
298
+ "a PAC URL as a command-line flag)."
299
+ )
300
+ profile_dir.mkdir(parents=True, exist_ok=True)
301
+ (profile_dir / "user.js").write_text(
302
+ f'user_pref("network.proxy.type", 2);\n'
303
+ f'user_pref("network.proxy.autoconfig_url", "{pac_url}");\n'
304
+ f'user_pref("network.proxy.no_proxies_on", "localhost, 127.0.0.1");\n'
305
+ )
306
+ if sys.platform == "darwin" and browser.bundle is not None:
307
+ cmd = ["open", "-na", browser.bundle, "--args",
308
+ "-profile", str(profile_dir), "-no-remote"]
309
+ else:
310
+ cmd = browser.launch_cmd + ["-profile", str(profile_dir), "-no-remote"]
311
+ subprocess.Popen(cmd)
312
+
313
+
314
+ def open_proxy_settings(browser: Browser) -> None:
315
+ """Open the browser at its internal proxy debug page.
316
+
317
+ Chromium-family browsers interpret ``chrome://net-internals/#proxy``
318
+ as an internal page; macOS ``open -a <bundle> <url>`` and Linux
319
+ ``<exe> <url>`` both navigate straight to it.
320
+
321
+ Firefox doesn't have a single chrome://-style proxy debug URL, so
322
+ this is a no-op for non-Chromium browsers.
323
+ """
324
+ if not browser.is_chromium:
325
+ return
326
+ if sys.platform == "darwin" and browser.bundle is not None:
327
+ cmd = ["open", "-a", browser.bundle, _PROXY_SETTINGS_URL]
328
+ else:
329
+ cmd = browser.launch_cmd + [_PROXY_SETTINGS_URL]
330
+ subprocess.Popen(cmd)