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 +4 -0
- susops/client.py +230 -0
- susops/core/__init__.py +0 -0
- susops/core/browsers.py +330 -0
- susops/core/config.py +253 -0
- susops/core/log_style.py +92 -0
- susops/core/pac.py +185 -0
- susops/core/ports.py +57 -0
- susops/core/process.py +167 -0
- susops/core/rpc_protocol.py +186 -0
- susops/core/rpc_server.py +131 -0
- susops/core/services_daemon.py +312 -0
- susops/core/share.py +323 -0
- susops/core/socat.py +200 -0
- susops/core/ssh.py +330 -0
- susops/core/ssh_config.py +40 -0
- susops/core/status.py +245 -0
- susops/core/types.py +171 -0
- susops/facade.py +2237 -0
- susops/tray/__init__.py +20 -0
- susops/tray/base.py +650 -0
- susops/tray/linux.py +1623 -0
- susops/tray/mac.py +3105 -0
- susops/tui/__init__.py +0 -0
- susops/tui/__main__.py +44 -0
- susops/tui/app.py +191 -0
- susops/tui/cli.py +665 -0
- susops/tui/screens/__init__.py +114 -0
- susops/tui/screens/connections.py +871 -0
- susops/tui/screens/dashboard.py +935 -0
- susops/tui/screens/shares.py +357 -0
- susops/tui/widgets/__init__.py +0 -0
- susops/tui/widgets/connection_card.py +137 -0
- susops/version.py +12 -0
- susops-3.0.0rc3.dev1.dist-info/METADATA +977 -0
- susops-3.0.0rc3.dev1.dist-info/RECORD +40 -0
- susops-3.0.0rc3.dev1.dist-info/WHEEL +5 -0
- susops-3.0.0rc3.dev1.dist-info/entry_points.txt +7 -0
- susops-3.0.0rc3.dev1.dist-info/licenses/LICENSE +674 -0
- susops-3.0.0rc3.dev1.dist-info/top_level.txt +1 -0
susops/__init__.py
ADDED
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
|
susops/core/__init__.py
ADDED
|
File without changes
|
susops/core/browsers.py
ADDED
|
@@ -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)
|