nexus-browser 2.9.0__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.
- nexus_browser/VERSION +1 -0
- nexus_browser/__init__.py +3 -0
- nexus_browser/__main__.py +17 -0
- nexus_browser/client/bridge_client.py +383 -0
- nexus_browser/client/browser_cli.py +374 -0
- nexus_browser/client/emergency_stop.py +219 -0
- nexus_browser/client/mcp_server.py +1050 -0
- nexus_browser/native-host/auth_host.py +117 -0
- nexus_browser/native-host/bridge_auth.py +116 -0
- nexus_browser/native-host/bridge_config.json +6 -0
- nexus_browser/native-host/bridge_security.py +378 -0
- nexus_browser/native-host/bridge_server.py +696 -0
- nexus_browser/pyproject.toml +82 -0
- nexus_browser-2.9.0.dist-info/METADATA +211 -0
- nexus_browser-2.9.0.dist-info/RECORD +18 -0
- nexus_browser-2.9.0.dist-info/WHEEL +4 -0
- nexus_browser-2.9.0.dist-info/entry_points.txt +2 -0
- nexus_browser-2.9.0.dist-info/licenses/LICENSE +21 -0
nexus_browser/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
2.9.0
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
"""NexusBrowser packaged for PyPI: the repository's client/ and native-host/ ship unchanged under this package, so the
|
|
2
|
+
MCP server finds its bridge modules exactly as it does in a checkout. The bridge and the Chrome extension still have to
|
|
3
|
+
be installed once on the PC (install.ps1 from the repository)."""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# pyright: reportMissingImports=false
|
|
2
|
+
# (nexus_browser.client exists only in the built wheel, where client/ is mirrored under this package.)
|
|
3
|
+
"""Entry point of the `nexus-browser` command: the MCP server over stdio, as `python client/mcp_server.py` does."""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
from nexus_browser.client import emergency_stop, mcp_server
|
|
8
|
+
from nexus_browser.client.bridge_client import start_heartbeat
|
|
9
|
+
|
|
10
|
+
# Shared emergency stop (Ctrl+Alt+Shift+Esc), session heartbeat, then the server (same order as mcp_server.py).
|
|
11
|
+
emergency_stop.start()
|
|
12
|
+
start_heartbeat()
|
|
13
|
+
mcp_server.browser_mcp.run(transport="stdio")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
main()
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Shared authenticated transport for NexusBrowser browser clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
import uuid
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
# Aucun __pycache__ à côté des sources (OneDrive), quel que soit le lanceur.
|
|
19
|
+
sys.dont_write_bytecode = True
|
|
20
|
+
|
|
21
|
+
REPOSITORY_ROOT = Path(__file__).resolve().parent.parent
|
|
22
|
+
NATIVE_HOST_DIRECTORY = REPOSITORY_ROOT / "native-host"
|
|
23
|
+
BRIDGE_SERVER_PATH = NATIVE_HOST_DIRECTORY / "bridge_server.py"
|
|
24
|
+
API_URL = "http://127.0.0.1:9225/api/command"
|
|
25
|
+
STATUS_URL = "http://127.0.0.1:9225/api/status"
|
|
26
|
+
|
|
27
|
+
sys.path.insert(0, str(NATIVE_HOST_DIRECTORY))
|
|
28
|
+
|
|
29
|
+
# Aucun repli : sans ce module, pas de jeton ni de liste des lectures ; l'erreur d'import remonte telle quelle.
|
|
30
|
+
from bridge_security import ( # noqa: E402
|
|
31
|
+
AGENT_HEADER,
|
|
32
|
+
CLIENT_HEADER,
|
|
33
|
+
PID_HEADER,
|
|
34
|
+
READ_ONLY_ACTIONS,
|
|
35
|
+
TOKEN_HEADER,
|
|
36
|
+
check_identity,
|
|
37
|
+
check_upload_files,
|
|
38
|
+
is_read_only_action,
|
|
39
|
+
is_upload_action,
|
|
40
|
+
protected_upload_roots,
|
|
41
|
+
read_token,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"READ_ONLY_ACTIONS",
|
|
46
|
+
"TOKEN_HEADER",
|
|
47
|
+
"call_bridge",
|
|
48
|
+
"check_upload_files",
|
|
49
|
+
"is_read_only_action",
|
|
50
|
+
"protected_upload_roots",
|
|
51
|
+
"set_identity",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
# Identité de CE client auprès du pont : nom de l'agent et identifiant de sa session. L'extension rattache à cette
|
|
55
|
+
# identité les onglets ouverts par browser_new_tab et refuse toute commande de page sur les autres. Le serveur MCP
|
|
56
|
+
# d'un agent la fixe une fois au démarrage (un identifiant neuf par processus) ; la CLI garde un identifiant stable
|
|
57
|
+
# (« CLI » / « cli-local ») pour retrouver ses onglets d'un appel à l'autre.
|
|
58
|
+
_identity: dict[str, str] = {
|
|
59
|
+
"agent": os.environ.get("NEXUS_BROWSER_AGENT", "").strip() or "CLI",
|
|
60
|
+
"id": os.environ.get("NEXUS_BROWSER_CLIENT", "").strip() or "cli-local",
|
|
61
|
+
}
|
|
62
|
+
# Processus annoncé au pont : seulement pour une session liée à CE processus (set_identity). Quand il disparaît
|
|
63
|
+
# (serveur MCP redémarré, planté), ses onglets deviennent orphelins et le même agent peut les reprendre. La CLI, à
|
|
64
|
+
# identité stable d'un appel à l'autre, n'en annonce aucun.
|
|
65
|
+
_session_pid: int | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def set_identity(agent: str, client_id: str | None = None) -> dict[str, str]:
|
|
69
|
+
"""Fixe l'identité de ce processus ; client_id absent : identifiant neuf (session unique, liée à ce processus)."""
|
|
70
|
+
global _session_pid
|
|
71
|
+
identity = check_identity(agent, client_id or f"{agent.lower().replace(' ', '-')}-{uuid.uuid4().hex[:12]}")
|
|
72
|
+
if identity is None:
|
|
73
|
+
raise ValueError(f"Identité de client invalide : agent={agent!r}, client={client_id!r}")
|
|
74
|
+
_identity.update(identity)
|
|
75
|
+
_session_pid = os.getpid()
|
|
76
|
+
return dict(_identity)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def identity() -> dict[str, str]:
|
|
80
|
+
return dict(_identity)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def authentication_headers() -> dict[str, str]:
|
|
84
|
+
"""Jeton relu à chaque commande (une rotation prend effet aussitôt), en LECTURE seule : ses droits sont réglés à
|
|
85
|
+
sa création par le pont, jamais ici (aucun whoami ni icacls par commande). Identité du client jointe, et son
|
|
86
|
+
processus pour une session liée à ce processus."""
|
|
87
|
+
headers = {TOKEN_HEADER: read_token(), AGENT_HEADER: _identity["agent"], CLIENT_HEADER: _identity["id"]}
|
|
88
|
+
if _session_pid is not None:
|
|
89
|
+
headers[PID_HEADER] = str(_session_pid)
|
|
90
|
+
return headers
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Signe de vie d'une session (serveur MCP) : sans commande ni battement pendant SESSION_DEAD_S, l'extension la tient
|
|
94
|
+
# pour morte et ses onglets deviennent orphelins. Battement toutes les 60 / 1,618 s, silence toléré 1,618 x 60 s.
|
|
95
|
+
HEARTBEAT_S = 60 / 1.618
|
|
96
|
+
SESSION_DEAD_S = 1.618 * 60
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def heartbeat(reclaim: bool = False) -> Any:
|
|
100
|
+
"""Un battement : jamais de démarrage du pont ni de Chrome, aucun nouvel essai. reclaim=True : l'extension confie
|
|
101
|
+
à cette session les onglets orphelins de son agent si elle n'en a aucun (reprise après redémarrage)."""
|
|
102
|
+
return call_bridge("heartbeat", {"reclaim": bool(reclaim)}, 5.0, retry=False)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def start_heartbeat(interval_s: float = HEARTBEAT_S, stop: threading.Event | None = None) -> threading.Thread:
|
|
106
|
+
"""Battement de fond du serveur MCP (jusqu'à stop) ; le premier réussi reprend les onglets orphelins de l'agent."""
|
|
107
|
+
stop = stop or threading.Event()
|
|
108
|
+
|
|
109
|
+
def beat() -> None:
|
|
110
|
+
reclaim = True
|
|
111
|
+
while not stop.is_set():
|
|
112
|
+
try:
|
|
113
|
+
reclaimed = (heartbeat(reclaim) or {}).get("reclaimed") or []
|
|
114
|
+
if reclaimed:
|
|
115
|
+
print(f"[nexus-browser] Onglets orphelins repris : {reclaimed}", file=sys.stderr, flush=True)
|
|
116
|
+
reclaim = False
|
|
117
|
+
except Exception: # pont arrêté, Chrome fermé : réessayé au battement suivant
|
|
118
|
+
pass
|
|
119
|
+
stop.wait(interval_s)
|
|
120
|
+
|
|
121
|
+
thread = threading.Thread(target=beat, name="nexus-browser-heartbeat", daemon=True)
|
|
122
|
+
thread.start()
|
|
123
|
+
return thread
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def bridge_is_running() -> bool:
|
|
127
|
+
"""Probe the authenticated bridge status endpoint."""
|
|
128
|
+
try:
|
|
129
|
+
request = urllib.request.Request(STATUS_URL, headers=authentication_headers())
|
|
130
|
+
with urllib.request.urlopen(request, timeout=1.0) as response:
|
|
131
|
+
return response.status == 200
|
|
132
|
+
except (urllib.error.URLError, TimeoutError, OSError):
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def ensure_bridge_running() -> bool:
|
|
137
|
+
"""Ask the single Windows supervisor to start the bridge."""
|
|
138
|
+
if bridge_is_running():
|
|
139
|
+
return True
|
|
140
|
+
if not BRIDGE_SERVER_PATH.exists():
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
if sys.platform != "win32":
|
|
144
|
+
return False
|
|
145
|
+
try:
|
|
146
|
+
subprocess.run(
|
|
147
|
+
["schtasks.exe", "/Run", "/TN", "Nexus Browser Bridge"],
|
|
148
|
+
check=True,
|
|
149
|
+
capture_output=True,
|
|
150
|
+
creationflags=subprocess.CREATE_NO_WINDOW,
|
|
151
|
+
timeout=5,
|
|
152
|
+
)
|
|
153
|
+
except (OSError, subprocess.SubprocessError):
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
for _ in range(30):
|
|
157
|
+
time.sleep(0.1)
|
|
158
|
+
if bridge_is_running():
|
|
159
|
+
return True
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def is_chrome_running() -> bool:
|
|
164
|
+
"""Check if any chrome.exe process is currently alive."""
|
|
165
|
+
if sys.platform != "win32":
|
|
166
|
+
return False
|
|
167
|
+
try:
|
|
168
|
+
output = subprocess.check_output(
|
|
169
|
+
["tasklist.exe", "/FI", "IMAGENAME eq chrome.exe", "/FO", "CSV"],
|
|
170
|
+
creationflags=subprocess.CREATE_NO_WINDOW,
|
|
171
|
+
text=True,
|
|
172
|
+
timeout=3,
|
|
173
|
+
)
|
|
174
|
+
return "chrome.exe" in output.lower()
|
|
175
|
+
except (OSError, subprocess.SubprocessError):
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
FIXED_CHROME_PATHS = [
|
|
180
|
+
Path("C:/Program Files/Google/Chrome/Application/chrome.exe"),
|
|
181
|
+
Path("C:/Program Files (x86)/Google/Chrome/Application/chrome.exe"),
|
|
182
|
+
]
|
|
183
|
+
CHROME_APP_PATHS_KEY = r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _registered_chrome() -> list[Path]:
|
|
187
|
+
"""Chemins déclarés par l'installeur de Chrome dans App Paths (utilisateur puis machine)."""
|
|
188
|
+
if sys.platform != "win32":
|
|
189
|
+
return []
|
|
190
|
+
import winreg
|
|
191
|
+
|
|
192
|
+
found = []
|
|
193
|
+
for hive in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE):
|
|
194
|
+
try:
|
|
195
|
+
with winreg.OpenKey(hive, CHROME_APP_PATHS_KEY) as key:
|
|
196
|
+
value, _ = winreg.QueryValueEx(key, "")
|
|
197
|
+
except OSError:
|
|
198
|
+
continue
|
|
199
|
+
if isinstance(value, str) and value.strip():
|
|
200
|
+
found.append(Path(os.path.expandvars(value.strip().strip('"'))))
|
|
201
|
+
return found
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def get_chrome_executable() -> Path | None:
|
|
205
|
+
"""Locate the Google Chrome executable on Windows : registre App Paths, puis installations machine et utilisateur."""
|
|
206
|
+
candidates = [*_registered_chrome()]
|
|
207
|
+
for variable in ("ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"):
|
|
208
|
+
base = os.environ.get(variable)
|
|
209
|
+
if base:
|
|
210
|
+
candidates.append(Path(base) / "Google" / "Chrome" / "Application" / "chrome.exe")
|
|
211
|
+
candidates += FIXED_CHROME_PATHS
|
|
212
|
+
for candidate in candidates:
|
|
213
|
+
if candidate.is_file():
|
|
214
|
+
return candidate
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# Relance de Chrome : profil par défaut seulement. Arguments en plus : variable d'environnement
|
|
219
|
+
# NEXUS_CHROME_EXTRA_ARGS, des drapeaux séparés par des espaces, pris dans une LISTE BLANCHE (langue, pays des
|
|
220
|
+
# variations, thème sombre, fonctions Glic désactivées) ; tout autre élément est ignoré (une liste noire laissait passer
|
|
221
|
+
# des drapeaux qui affaiblissent Chrome, comme --disable-web-security ; audit du 18/10).
|
|
222
|
+
CHROME_BASE_ARGS: tuple[str, ...] = ("--profile-directory=Default",)
|
|
223
|
+
EXTRA_ARGS_VARIABLE = "NEXUS_CHROME_EXTRA_ARGS"
|
|
224
|
+
_ALLOWED_FLAGS: dict[str, re.Pattern[str] | None] = {
|
|
225
|
+
"--lang": re.compile(r"[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*"),
|
|
226
|
+
"--accept-lang": re.compile(r"[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*(,[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*)*"),
|
|
227
|
+
"--variations-override-country": re.compile(r"[A-Za-z]{2}"),
|
|
228
|
+
"--disable-features": re.compile(r"Glic\w*(,Glic\w*)*"),
|
|
229
|
+
"--force-dark-mode": None,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def chrome_extra_args(value: str | None = None) -> list[str]:
|
|
234
|
+
"""Drapeaux supplémentaires lus dans NEXUS_CHROME_EXTRA_ARGS (vide par défaut), liste blanche seulement."""
|
|
235
|
+
raw = os.environ.get(EXTRA_ARGS_VARIABLE, "") if value is None else value
|
|
236
|
+
accepted: list[str] = []
|
|
237
|
+
for item in raw.split():
|
|
238
|
+
name, sep, flag_value = item.partition("=")
|
|
239
|
+
if name not in _ALLOWED_FLAGS:
|
|
240
|
+
continue
|
|
241
|
+
expected = _ALLOWED_FLAGS[name]
|
|
242
|
+
valid = not sep if expected is None else bool(sep) and expected.fullmatch(flag_value) is not None
|
|
243
|
+
if valid:
|
|
244
|
+
accepted.append(item)
|
|
245
|
+
return accepted
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def chrome_launch_args() -> list[str]:
|
|
249
|
+
return [*CHROME_BASE_ARGS, *chrome_extra_args()]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def launch_chrome() -> bool:
|
|
253
|
+
"""Relance Google Chrome normalement sur le profil Default, avec les drapeaux de NEXUS_CHROME_EXTRA_ARGS.
|
|
254
|
+
|
|
255
|
+
L'extension Nexus Browser Bridge doit y être installée et activée : Chrome stable n'accepte plus
|
|
256
|
+
--load-extension, ce chemin ne sert donc plus de relance. Vrai si le processus a été lancé (la connexion de
|
|
257
|
+
l'extension est vérifiée ensuite par ensure_chrome_running).
|
|
258
|
+
"""
|
|
259
|
+
chrome_exe = get_chrome_executable()
|
|
260
|
+
if not chrome_exe:
|
|
261
|
+
return False
|
|
262
|
+
try:
|
|
263
|
+
subprocess.Popen(
|
|
264
|
+
[str(chrome_exe), *chrome_launch_args()],
|
|
265
|
+
creationflags=getattr(subprocess, "DETACHED_PROCESS", 0),
|
|
266
|
+
)
|
|
267
|
+
return True
|
|
268
|
+
except OSError:
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def ping_bridge() -> bool:
|
|
273
|
+
"""Test if the Chrome extension is connected and responding to ping."""
|
|
274
|
+
payload = {"action": "ping", "params": {}, "timeout": 1.0}
|
|
275
|
+
try:
|
|
276
|
+
request = urllib.request.Request(
|
|
277
|
+
API_URL,
|
|
278
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
279
|
+
headers={"Content-Type": "application/json", **authentication_headers()},
|
|
280
|
+
)
|
|
281
|
+
with urllib.request.urlopen(request, timeout=1.5) as response:
|
|
282
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
283
|
+
return bool(data.get("success"))
|
|
284
|
+
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError):
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
EXTENSION_NOT_CONNECTED = (
|
|
289
|
+
"L'extension Nexus Browser Bridge ne s'est pas connectée au pont NexusBrowser{detail}. Vérifier qu'elle est "
|
|
290
|
+
"installée et activée dans chrome://extensions sur le profil Default (Chrome stable n'accepte plus "
|
|
291
|
+
"--load-extension : elle doit être chargée une fois depuis le dossier extension du dépôt)."
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def ensure_chrome_running(timeout: float = 6.0) -> bool:
|
|
296
|
+
"""Ensure Chrome is started and the extension has established its bridge connection."""
|
|
297
|
+
ensure_bridge_running()
|
|
298
|
+
|
|
299
|
+
if not is_chrome_running():
|
|
300
|
+
launch_chrome()
|
|
301
|
+
|
|
302
|
+
deadline = time.time() + timeout
|
|
303
|
+
while time.time() < deadline:
|
|
304
|
+
if ping_bridge():
|
|
305
|
+
return True
|
|
306
|
+
time.sleep(0.25)
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# Téléversements : jamais un fichier des dossiers de secrets de Julien (bridge_security.upload_refusal). Contrôlé ici
|
|
311
|
+
# (avant tout envoi) ET par le pont lui-même, pour tout client HTTP.
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _not_delivered(error: urllib.error.URLError) -> bool:
|
|
315
|
+
"""Vrai si la requête n'a certainement pas atteint le pont (connexion refusée) : la renvoyer est sans risque."""
|
|
316
|
+
reason = getattr(error, "reason", None)
|
|
317
|
+
return isinstance(reason, ConnectionRefusedError)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def call_bridge(action: str, params: dict[str, Any] | None = None, timeout: float = 20.0, *, retry: bool = True) -> Any:
|
|
321
|
+
"""Execute one browser action and return its decoded result.
|
|
322
|
+
|
|
323
|
+
Nouvel essai unique, seulement quand c'est sûr : la commande n'a pas été remise (pont arrêté, extension non
|
|
324
|
+
connectée), ou c'est une lecture. Après un délai dépassé, une action (clic, saisie, navigation...) n'est jamais
|
|
325
|
+
renvoyée : elle a pu être exécutée.
|
|
326
|
+
"""
|
|
327
|
+
if is_upload_action(action):
|
|
328
|
+
check_upload_files((params or {}).get("files"))
|
|
329
|
+
payload = {"action": action, "params": params or {}, "timeout": timeout}
|
|
330
|
+
try:
|
|
331
|
+
headers = authentication_headers()
|
|
332
|
+
except FileNotFoundError:
|
|
333
|
+
# Jeton pas encore créé : c'est le pont qui le crée (droits restreints) à son démarrage.
|
|
334
|
+
if not (retry and ensure_bridge_running()):
|
|
335
|
+
raise ConnectionError(
|
|
336
|
+
f"Impossible de joindre le pont NexusBrowser sur {API_URL} : jeton absent et démarrage du pont échoué. "
|
|
337
|
+
"Pont et extension pas encore installés sur ce PC ? Une fois : install.ps1 "
|
|
338
|
+
"(https://github.com/pironjulien/NexusBrowser#installation)."
|
|
339
|
+
) from None
|
|
340
|
+
headers = authentication_headers()
|
|
341
|
+
request = urllib.request.Request(
|
|
342
|
+
API_URL,
|
|
343
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
344
|
+
headers={"Content-Type": "application/json", **headers},
|
|
345
|
+
)
|
|
346
|
+
try:
|
|
347
|
+
with urllib.request.urlopen(request, timeout=timeout + 2) as response:
|
|
348
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
349
|
+
except urllib.error.HTTPError as error:
|
|
350
|
+
try:
|
|
351
|
+
try:
|
|
352
|
+
payload = json.loads(error.read().decode("utf-8"))
|
|
353
|
+
message = payload.get("error", f"HTTP {error.code}: {error.reason}")
|
|
354
|
+
except (json.JSONDecodeError, UnicodeDecodeError, OSError):
|
|
355
|
+
message = f"HTTP {error.code}: {error.reason}"
|
|
356
|
+
finally:
|
|
357
|
+
error.close()
|
|
358
|
+
|
|
359
|
+
if "No Chrome browser extension connected" in message:
|
|
360
|
+
# Commande non remise : relancer Chrome (normalement) puis la renvoyer une fois est sans risque.
|
|
361
|
+
if retry and ensure_chrome_running():
|
|
362
|
+
return call_bridge(action, params, timeout, retry=False)
|
|
363
|
+
detail = "" if is_chrome_running() else " (Chrome n'a pas pu être relancé)"
|
|
364
|
+
raise RuntimeError(EXTENSION_NOT_CONNECTED.format(detail=detail)) from error
|
|
365
|
+
|
|
366
|
+
raise RuntimeError(message) from error
|
|
367
|
+
except (urllib.error.URLError, TimeoutError) as error:
|
|
368
|
+
delivered = not (isinstance(error, urllib.error.URLError) and _not_delivered(error))
|
|
369
|
+
if retry and (not delivered or is_read_only_action(action)) and ensure_bridge_running():
|
|
370
|
+
return call_bridge(action, params, timeout, retry=False)
|
|
371
|
+
if delivered and not is_read_only_action(action):
|
|
372
|
+
raise ConnectionError(
|
|
373
|
+
f"Pas de réponse du pont NexusBrowser pour '{action}' dans le délai : la commande a pu être exécutée. "
|
|
374
|
+
"Elle n'est pas renvoyée automatiquement (risque de double action) ; vérifier l'état de la page "
|
|
375
|
+
"avant de la relancer."
|
|
376
|
+
) from error
|
|
377
|
+
raise ConnectionError(
|
|
378
|
+
f"Impossible de joindre le pont NexusBrowser sur {API_URL}. Le démarrage automatique a échoué."
|
|
379
|
+
) from error
|
|
380
|
+
|
|
381
|
+
if not data.get("success"):
|
|
382
|
+
raise RuntimeError(data.get("error", "Erreur inconnue du pont NexusBrowser"))
|
|
383
|
+
return data.get("data")
|