gecko-web-runtime-client 1.0.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.
- gecko_web_runtime/__init__.py +17 -0
- gecko_web_runtime/__main__.py +23 -0
- gecko_web_runtime/installer.py +187 -0
- gecko_web_runtime/pipe.py +52 -0
- gecko_web_runtime/session.py +281 -0
- gecko_web_runtime_client-1.0.0.dist-info/METADATA +34 -0
- gecko_web_runtime_client-1.0.0.dist-info/RECORD +9 -0
- gecko_web_runtime_client-1.0.0.dist-info/WHEEL +5 -0
- gecko_web_runtime_client-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Portable Python client for the Firefox-derived WPR runtime bundle."""
|
|
2
|
+
|
|
3
|
+
from .session import (
|
|
4
|
+
AsyncGeckoWorkerSession,
|
|
5
|
+
GeckoWorkerSession,
|
|
6
|
+
RuntimeConfig,
|
|
7
|
+
WprProtocolError,
|
|
8
|
+
)
|
|
9
|
+
from .installer import install_runtime
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AsyncGeckoWorkerSession",
|
|
13
|
+
"GeckoWorkerSession",
|
|
14
|
+
"RuntimeConfig",
|
|
15
|
+
"WprProtocolError",
|
|
16
|
+
"install_runtime",
|
|
17
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from .installer import install_runtime
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> int:
|
|
9
|
+
parser = argparse.ArgumentParser(prog="python -m gecko_web_runtime")
|
|
10
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
11
|
+
install = commands.add_parser("install-runtime", help="download a pinned Gecko runtime")
|
|
12
|
+
install.add_argument("--version", required=True, help="runtime major version, e.g. 155")
|
|
13
|
+
install.add_argument("--runtime-home", help="optional installation directory")
|
|
14
|
+
install.add_argument("--force", action="store_true", help="replace an existing installation")
|
|
15
|
+
args = parser.parse_args()
|
|
16
|
+
if args.command == "install-runtime":
|
|
17
|
+
location = install_runtime(args.version, args.runtime_home, force=args.force)
|
|
18
|
+
print(location)
|
|
19
|
+
return 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Download and install a pinned WPR runtime release."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
import urllib.request
|
|
12
|
+
import uuid
|
|
13
|
+
import zipfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
RUNTIME_RELEASES = {
|
|
18
|
+
"155": {
|
|
19
|
+
"version": "155.0.3",
|
|
20
|
+
"repository": "childclm/gecko-web-runtime",
|
|
21
|
+
"tag": "v155.0.3",
|
|
22
|
+
"asset": "wpr-runtime-155-win64.zip",
|
|
23
|
+
"url": "https://github.com/childclm/gecko-web-runtime/releases/download/v155.0.3/wpr-runtime-155-win64.zip",
|
|
24
|
+
"sha256": "c8321d3eeed0dfa0978c70c84ad57126c0a131f825aa0a475db5f6ae7c178f7d",
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _github_token() -> str | None:
|
|
30
|
+
for name in ("WPR_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"):
|
|
31
|
+
value = os.environ.get(name)
|
|
32
|
+
if value:
|
|
33
|
+
return value.strip()
|
|
34
|
+
# Git Credential Manager may already hold the user's HTTPS credential.
|
|
35
|
+
try:
|
|
36
|
+
query = "protocol=https\nhost=github.com\n\n"
|
|
37
|
+
result = subprocess.run(
|
|
38
|
+
["git", "credential", "fill"],
|
|
39
|
+
input=query,
|
|
40
|
+
text=True,
|
|
41
|
+
capture_output=True,
|
|
42
|
+
timeout=10,
|
|
43
|
+
check=True,
|
|
44
|
+
)
|
|
45
|
+
for line in result.stdout.splitlines():
|
|
46
|
+
if line.startswith("password=") and line[9:]:
|
|
47
|
+
return line[9:].strip()
|
|
48
|
+
except (OSError, subprocess.SubprocessError):
|
|
49
|
+
pass
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _github_asset_request(release: dict[str, str]) -> urllib.request.Request:
|
|
54
|
+
token = _github_token()
|
|
55
|
+
if not token:
|
|
56
|
+
return urllib.request.Request(
|
|
57
|
+
release["url"],
|
|
58
|
+
headers={"User-Agent": "gecko-web-runtime-client/1.0.0"},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
api = (
|
|
62
|
+
f"https://api.github.com/repos/{release['repository']}"
|
|
63
|
+
f"/releases/tags/{release['tag']}"
|
|
64
|
+
)
|
|
65
|
+
metadata_request = urllib.request.Request(
|
|
66
|
+
api,
|
|
67
|
+
headers={
|
|
68
|
+
"Accept": "application/vnd.github+json",
|
|
69
|
+
"Authorization": f"Bearer {token}",
|
|
70
|
+
"User-Agent": "gecko-web-runtime-client/1.0.0",
|
|
71
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
with urllib.request.urlopen(metadata_request, timeout=30) as response:
|
|
75
|
+
metadata = json.load(response)
|
|
76
|
+
asset = next(
|
|
77
|
+
(item for item in metadata.get("assets", []) if item.get("name") == release["asset"]),
|
|
78
|
+
None,
|
|
79
|
+
)
|
|
80
|
+
if not asset:
|
|
81
|
+
raise RuntimeError(f"GitHub release asset not found: {release['asset']}")
|
|
82
|
+
return urllib.request.Request(
|
|
83
|
+
f"https://api.github.com/repos/{release['repository']}"
|
|
84
|
+
f"/releases/assets/{asset['id']}",
|
|
85
|
+
headers={
|
|
86
|
+
"Accept": "application/octet-stream",
|
|
87
|
+
"Authorization": f"Bearer {token}",
|
|
88
|
+
"User-Agent": "gecko-web-runtime-client/1.0.0",
|
|
89
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def config_path() -> Path:
|
|
95
|
+
base = os.environ.get("LOCALAPPDATA")
|
|
96
|
+
if not base:
|
|
97
|
+
base = str(Path.home() / "AppData" / "Local")
|
|
98
|
+
return Path(base) / "WPR" / "config.json"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def configured_runtime_home() -> Path | None:
|
|
102
|
+
path = config_path()
|
|
103
|
+
try:
|
|
104
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
105
|
+
value = data.get("runtime_home")
|
|
106
|
+
return Path(value).resolve() if value else None
|
|
107
|
+
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _sha256(path: Path) -> str:
|
|
112
|
+
digest = hashlib.sha256()
|
|
113
|
+
with path.open("rb") as stream:
|
|
114
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
115
|
+
digest.update(chunk)
|
|
116
|
+
return digest.hexdigest()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _extract_safely(archive: Path, destination: Path) -> None:
|
|
120
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
root = destination.resolve()
|
|
122
|
+
with zipfile.ZipFile(archive) as source:
|
|
123
|
+
for member in source.infolist():
|
|
124
|
+
target = (destination / member.filename).resolve()
|
|
125
|
+
if target != root and root not in target.parents:
|
|
126
|
+
raise RuntimeError(f"unsafe path in runtime archive: {member.filename}")
|
|
127
|
+
source.extractall(destination)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def install_runtime(version: str, runtime_home: str | os.PathLike[str] | None = None,
|
|
131
|
+
*, force: bool = False) -> Path:
|
|
132
|
+
"""Install a pinned runtime and persist its location for the client."""
|
|
133
|
+
if os.name != "nt":
|
|
134
|
+
raise OSError("wpr-runtime-155 requires Windows")
|
|
135
|
+
key = str(version).split(".", 1)[0]
|
|
136
|
+
release = RUNTIME_RELEASES.get(key)
|
|
137
|
+
if release is None:
|
|
138
|
+
raise ValueError(f"unsupported runtime version: {version}")
|
|
139
|
+
|
|
140
|
+
if runtime_home is None:
|
|
141
|
+
local = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
|
|
142
|
+
destination = Path(local) / "WPR" / "runtime" / key
|
|
143
|
+
else:
|
|
144
|
+
destination = Path(runtime_home)
|
|
145
|
+
destination = destination.expanduser().resolve()
|
|
146
|
+
|
|
147
|
+
if destination.exists() and not force:
|
|
148
|
+
worker = destination / "runtime" / "gecko-web-worker.exe"
|
|
149
|
+
if worker.is_file():
|
|
150
|
+
_write_config(destination)
|
|
151
|
+
return destination
|
|
152
|
+
raise FileExistsError(f"runtime destination already exists: {destination}")
|
|
153
|
+
|
|
154
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
stage = destination.parent / f".{destination.name}.staging-{uuid.uuid4().hex}"
|
|
156
|
+
try:
|
|
157
|
+
with tempfile.TemporaryDirectory(prefix="wpr-runtime-download-") as temp:
|
|
158
|
+
archive = Path(temp) / "runtime.zip"
|
|
159
|
+
request = _github_asset_request(release)
|
|
160
|
+
with urllib.request.urlopen(request, timeout=120) as response, archive.open("wb") as output:
|
|
161
|
+
shutil.copyfileobj(response, output)
|
|
162
|
+
actual = _sha256(archive)
|
|
163
|
+
if actual != release["sha256"]:
|
|
164
|
+
raise RuntimeError(
|
|
165
|
+
f"runtime SHA256 mismatch: expected {release['sha256']}, got {actual}"
|
|
166
|
+
)
|
|
167
|
+
_extract_safely(archive, stage)
|
|
168
|
+
|
|
169
|
+
if not (stage / "runtime" / "gecko-web-worker.exe").is_file():
|
|
170
|
+
raise RuntimeError("runtime archive does not contain runtime/gecko-web-worker.exe")
|
|
171
|
+
if destination.exists():
|
|
172
|
+
shutil.rmtree(destination)
|
|
173
|
+
stage.replace(destination)
|
|
174
|
+
_write_config(destination)
|
|
175
|
+
return destination
|
|
176
|
+
finally:
|
|
177
|
+
if stage.exists():
|
|
178
|
+
shutil.rmtree(stage, ignore_errors=True)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _write_config(destination: Path) -> None:
|
|
182
|
+
path = config_path()
|
|
183
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
path.write_text(
|
|
185
|
+
json.dumps({"runtime_home": str(destination)}, indent=2) + "\n",
|
|
186
|
+
encoding="utf-8",
|
|
187
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Windows named-pipe transport for the public WPR JSON protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import json
|
|
7
|
+
import msvcrt
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
KERNEL32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
14
|
+
GENERIC_READ = 0x80000000
|
|
15
|
+
GENERIC_WRITE = 0x40000000
|
|
16
|
+
OPEN_EXISTING = 3
|
|
17
|
+
INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def connect_pipe(pipe_name: str, timeout_seconds: float = 60.0):
|
|
21
|
+
deadline = time.monotonic() + timeout_seconds
|
|
22
|
+
while time.monotonic() < deadline:
|
|
23
|
+
handle = KERNEL32.CreateFileW(
|
|
24
|
+
pipe_name,
|
|
25
|
+
GENERIC_READ | GENERIC_WRITE,
|
|
26
|
+
0,
|
|
27
|
+
None,
|
|
28
|
+
OPEN_EXISTING,
|
|
29
|
+
0,
|
|
30
|
+
None,
|
|
31
|
+
)
|
|
32
|
+
invalid = {-1, 0xFFFFFFFF, 0xFFFFFFFFFFFFFFFF, INVALID_HANDLE_VALUE}
|
|
33
|
+
if int(handle) not in invalid and int(handle) != 0:
|
|
34
|
+
return os.fdopen(msvcrt.open_osfhandle(handle, 0), "r+b", buffering=0)
|
|
35
|
+
KERNEL32.WaitNamedPipeW(pipe_name, 1000)
|
|
36
|
+
raise TimeoutError(f"named pipe did not become available: {pipe_name}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def request(payload: dict[str, Any], pipe_name: str) -> dict[str, Any]:
|
|
40
|
+
"""Send one JSON request. The Worker creates one pipe instance per RPC."""
|
|
41
|
+
pipe = connect_pipe(pipe_name)
|
|
42
|
+
try:
|
|
43
|
+
pipe.write((json.dumps(payload, separators=(",", ":")) + "\n").encode())
|
|
44
|
+
line = pipe.readline()
|
|
45
|
+
if not line:
|
|
46
|
+
raise RuntimeError("gecko worker closed the RPC pipe")
|
|
47
|
+
value = json.loads(line)
|
|
48
|
+
if not isinstance(value, dict):
|
|
49
|
+
raise RuntimeError("invalid WPR RPC envelope")
|
|
50
|
+
return value
|
|
51
|
+
finally:
|
|
52
|
+
pipe.close()
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Relocatable sync/async client for one real Gecko Worker session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .pipe import request
|
|
16
|
+
from .installer import configured_runtime_home
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
PACKAGE_ROOT = Path(__file__).resolve().parents[2]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class RuntimeConfig:
|
|
24
|
+
"""Runtime paths resolved from the bundle, not from a developer checkout."""
|
|
25
|
+
|
|
26
|
+
package_root: Path
|
|
27
|
+
runtime_dir: Path
|
|
28
|
+
worker_exe: Path
|
|
29
|
+
profile_root: Path | None = None
|
|
30
|
+
log_root: Path | None = None
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def bundled(cls) -> "RuntimeConfig":
|
|
34
|
+
configured = configured_runtime_home()
|
|
35
|
+
root = Path(
|
|
36
|
+
os.environ.get("WPR_RUNTIME_HOME")
|
|
37
|
+
or (str(configured) if configured else str(PACKAGE_ROOT))
|
|
38
|
+
).resolve()
|
|
39
|
+
runtime = Path(os.environ.get("WPR_RUNTIME_DIR", root / "runtime")).resolve()
|
|
40
|
+
worker = Path(
|
|
41
|
+
os.environ.get("WPR_WORKER_EXE", runtime / "gecko-web-worker.exe")
|
|
42
|
+
).resolve()
|
|
43
|
+
profile = os.environ.get("WPR_PROFILE_ROOT")
|
|
44
|
+
logs = os.environ.get("WPR_LOG_ROOT")
|
|
45
|
+
return cls(
|
|
46
|
+
package_root=root,
|
|
47
|
+
runtime_dir=runtime,
|
|
48
|
+
worker_exe=worker,
|
|
49
|
+
profile_root=Path(profile).resolve() if profile else None,
|
|
50
|
+
log_root=Path(logs).resolve() if logs else None,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def validate(self) -> None:
|
|
54
|
+
if os.name != "nt":
|
|
55
|
+
raise OSError("wpr-runtime-155-win64 requires Windows")
|
|
56
|
+
if not self.worker_exe.is_file():
|
|
57
|
+
raise FileNotFoundError(f"Worker executable not found: {self.worker_exe}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class WprProtocolError(RuntimeError):
|
|
61
|
+
def __init__(self, method: str, response: dict[str, Any]):
|
|
62
|
+
error = response.get("error")
|
|
63
|
+
if isinstance(error, dict):
|
|
64
|
+
self.code = str(error.get("code", "UNKNOWN_ERROR"))
|
|
65
|
+
self.phase = str(error.get("phase", "unknown"))
|
|
66
|
+
message = str(error.get("message", self.code))
|
|
67
|
+
else:
|
|
68
|
+
self.code = "UNKNOWN_ERROR"
|
|
69
|
+
self.phase = "unknown"
|
|
70
|
+
message = str(error or "request failed")
|
|
71
|
+
self.method = method
|
|
72
|
+
self.response = response
|
|
73
|
+
super().__init__(f"{method} [{self.code}] {message}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def require_ok(method: str, response: dict[str, Any]) -> Any:
|
|
77
|
+
if not response.get("ok"):
|
|
78
|
+
raise WprProtocolError(method, response)
|
|
79
|
+
return response.get("value") or {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class GeckoWorkerSession:
|
|
83
|
+
"""One Worker process and one BrowsingContext/CookieStore session."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, config: RuntimeConfig | None = None):
|
|
86
|
+
self.config = config or RuntimeConfig.bundled()
|
|
87
|
+
self.config.validate()
|
|
88
|
+
self.session_id = uuid.uuid4().hex[:12]
|
|
89
|
+
self.pipe_name = rf"\\.\pipe\wpr-gecko-{self.session_id}"
|
|
90
|
+
profile_parent = self.config.profile_root
|
|
91
|
+
if profile_parent:
|
|
92
|
+
profile_parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
else:
|
|
94
|
+
profile_parent = Path(tempfile.gettempdir())
|
|
95
|
+
self.profile_dir = Path(
|
|
96
|
+
tempfile.mkdtemp(prefix=f"wpr-profile-{self.session_id}-", dir=profile_parent)
|
|
97
|
+
)
|
|
98
|
+
(self.profile_dir / "local").mkdir(parents=True, exist_ok=True)
|
|
99
|
+
log_root = self.config.log_root or Path(tempfile.gettempdir()) / "wpr-runtime-logs"
|
|
100
|
+
log_root.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
self.stdout = (log_root / f"wpr-{self.session_id}.stdout.log").open(
|
|
102
|
+
"w", encoding="utf-8", errors="replace"
|
|
103
|
+
)
|
|
104
|
+
self.stderr = (log_root / f"wpr-{self.session_id}.stderr.log").open(
|
|
105
|
+
"w", encoding="utf-8", errors="replace"
|
|
106
|
+
)
|
|
107
|
+
env = os.environ.copy()
|
|
108
|
+
env.update(
|
|
109
|
+
{
|
|
110
|
+
"WPR_RPC": "1",
|
|
111
|
+
"WPR_REMOTE_PAGE": "1",
|
|
112
|
+
"WPR_PIPE_NAME": self.pipe_name,
|
|
113
|
+
"WPR_PROFILE_DIR": str(self.profile_dir),
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
env["PATH"] = str(self.config.runtime_dir) + os.pathsep + env.get("PATH", "")
|
|
117
|
+
self.process = subprocess.Popen(
|
|
118
|
+
[str(self.config.worker_exe)],
|
|
119
|
+
cwd=str(self.config.runtime_dir),
|
|
120
|
+
env=env,
|
|
121
|
+
stdout=self.stdout,
|
|
122
|
+
stderr=self.stderr,
|
|
123
|
+
)
|
|
124
|
+
self.closed = False
|
|
125
|
+
|
|
126
|
+
def request(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
127
|
+
if self.closed:
|
|
128
|
+
raise RuntimeError("GeckoWorkerSession is closed")
|
|
129
|
+
return request(payload, self.pipe_name)
|
|
130
|
+
|
|
131
|
+
def page_create(self) -> dict[str, Any]:
|
|
132
|
+
return self.request({"method": "page.create"})
|
|
133
|
+
|
|
134
|
+
def page_navigate(self, url: str) -> dict[str, Any]:
|
|
135
|
+
return self.request({"method": "page.navigate", "url": url})
|
|
136
|
+
|
|
137
|
+
def page_eval(self, source: str, args: Any = None) -> Any:
|
|
138
|
+
payload: dict[str, Any] = {"method": "page.eval", "source": source}
|
|
139
|
+
if args is not None:
|
|
140
|
+
payload["args"] = args
|
|
141
|
+
return require_ok("page.eval", self.request(payload))
|
|
142
|
+
|
|
143
|
+
def controlled_navigation_begin(self, url: str) -> Any:
|
|
144
|
+
return require_ok(
|
|
145
|
+
"controlled_navigation_begin",
|
|
146
|
+
self.request({"method": "controlled_navigation_begin", "url": url}),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def navigation_allow_scripts(self, transaction_id: str, scripts: list[str]) -> Any:
|
|
150
|
+
return require_ok(
|
|
151
|
+
"navigation.allow_scripts",
|
|
152
|
+
self.request(
|
|
153
|
+
{
|
|
154
|
+
"method": "navigation.allow_scripts",
|
|
155
|
+
"transaction_id": transaction_id,
|
|
156
|
+
"scripts": scripts,
|
|
157
|
+
}
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def navigation_commit(self, transaction_id: str) -> Any:
|
|
162
|
+
return require_ok(
|
|
163
|
+
"navigation.commit",
|
|
164
|
+
self.request({"method": "navigation.commit", "transaction_id": transaction_id}),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def navigation_wait(self, transaction_id: str) -> Any:
|
|
168
|
+
return require_ok(
|
|
169
|
+
"navigation.wait",
|
|
170
|
+
self.request({"method": "navigation.wait", "transaction_id": transaction_id}),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def page_cookie_snapshot(self) -> Any:
|
|
174
|
+
return self.page_eval(
|
|
175
|
+
"(() => ({url: location.href, cookie: document.cookie, "
|
|
176
|
+
"readyState: document.readyState}))()"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def page_close(self) -> dict[str, Any]:
|
|
180
|
+
return self.request({"method": "page.close"})
|
|
181
|
+
|
|
182
|
+
def close(self) -> None:
|
|
183
|
+
if self.closed:
|
|
184
|
+
return
|
|
185
|
+
try:
|
|
186
|
+
if self.process.poll() is None:
|
|
187
|
+
try:
|
|
188
|
+
self.request({"method": "runtime.shutdown"})
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
try:
|
|
192
|
+
self.process.wait(timeout=15)
|
|
193
|
+
except subprocess.TimeoutExpired:
|
|
194
|
+
self.process.kill()
|
|
195
|
+
self.process.wait(timeout=5)
|
|
196
|
+
finally:
|
|
197
|
+
self.closed = True
|
|
198
|
+
self.stdout.close()
|
|
199
|
+
self.stderr.close()
|
|
200
|
+
shutil.rmtree(self.profile_dir, ignore_errors=True)
|
|
201
|
+
|
|
202
|
+
def __enter__(self) -> "GeckoWorkerSession":
|
|
203
|
+
return self
|
|
204
|
+
|
|
205
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
206
|
+
self.close()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class AsyncGeckoWorkerSession:
|
|
210
|
+
"""Async facade; DOM/JS still run only on Gecko's owner thread."""
|
|
211
|
+
|
|
212
|
+
def __init__(self, config: RuntimeConfig | None = None):
|
|
213
|
+
self.config = config
|
|
214
|
+
self._sync: GeckoWorkerSession | None = None
|
|
215
|
+
self._lock = asyncio.Lock()
|
|
216
|
+
|
|
217
|
+
async def __aenter__(self) -> "AsyncGeckoWorkerSession":
|
|
218
|
+
self._sync = await asyncio.to_thread(GeckoWorkerSession, self.config)
|
|
219
|
+
return self
|
|
220
|
+
|
|
221
|
+
async def request(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
222
|
+
if self._sync is None:
|
|
223
|
+
raise RuntimeError("AsyncGeckoWorkerSession is not open")
|
|
224
|
+
async with self._lock:
|
|
225
|
+
return await asyncio.to_thread(self._sync.request, payload)
|
|
226
|
+
|
|
227
|
+
async def page_create(self) -> dict[str, Any]:
|
|
228
|
+
return await self.request({"method": "page.create"})
|
|
229
|
+
|
|
230
|
+
async def page_navigate(self, url: str) -> dict[str, Any]:
|
|
231
|
+
return await self.request({"method": "page.navigate", "url": url})
|
|
232
|
+
|
|
233
|
+
async def page_eval(self, source: str, args: Any = None) -> Any:
|
|
234
|
+
payload: dict[str, Any] = {"method": "page.eval", "source": source}
|
|
235
|
+
if args is not None:
|
|
236
|
+
payload["args"] = args
|
|
237
|
+
return require_ok("page.eval", await self.request(payload))
|
|
238
|
+
|
|
239
|
+
async def controlled_navigation_begin(self, url: str) -> Any:
|
|
240
|
+
return require_ok(
|
|
241
|
+
"controlled_navigation_begin",
|
|
242
|
+
await self.request({"method": "controlled_navigation_begin", "url": url}),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
async def navigation_allow_scripts(self, transaction_id: str, scripts: list[str]) -> Any:
|
|
246
|
+
return require_ok(
|
|
247
|
+
"navigation.allow_scripts",
|
|
248
|
+
await self.request(
|
|
249
|
+
{
|
|
250
|
+
"method": "navigation.allow_scripts",
|
|
251
|
+
"transaction_id": transaction_id,
|
|
252
|
+
"scripts": scripts,
|
|
253
|
+
}
|
|
254
|
+
),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
async def navigation_commit(self, transaction_id: str) -> Any:
|
|
258
|
+
return require_ok(
|
|
259
|
+
"navigation.commit",
|
|
260
|
+
await self.request({"method": "navigation.commit", "transaction_id": transaction_id}),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
async def navigation_wait(self, transaction_id: str) -> Any:
|
|
264
|
+
return require_ok(
|
|
265
|
+
"navigation.wait",
|
|
266
|
+
await self.request({"method": "navigation.wait", "transaction_id": transaction_id}),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
async def page_cookie_snapshot(self) -> Any:
|
|
270
|
+
return await self.page_eval(
|
|
271
|
+
"(() => ({url: location.href, cookie: document.cookie, "
|
|
272
|
+
"readyState: document.readyState}))()"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
async def page_close(self) -> dict[str, Any]:
|
|
276
|
+
return await self.request({"method": "page.close"})
|
|
277
|
+
|
|
278
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
279
|
+
if self._sync is not None:
|
|
280
|
+
await asyncio.to_thread(self._sync.close)
|
|
281
|
+
self._sync = None
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gecko-web-runtime-client
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Thin Python client for the Firefox-derived WPR Gecko Worker protocol
|
|
5
|
+
Author: WPR project
|
|
6
|
+
License: MPL-2.0-compatible client; see the runtime bundle notices
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# Python client
|
|
11
|
+
|
|
12
|
+
The client uses only Python's standard library. From this directory:
|
|
13
|
+
|
|
14
|
+
```powershell
|
|
15
|
+
$env:PYTHONPATH = "$PWD"
|
|
16
|
+
python ..\examples\normal_navigation.py
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The package resolves `runtime/gecko-web-worker.exe` relative to its own
|
|
20
|
+
bundle. Override paths with `WPR_RUNTIME_HOME`, `WPR_RUNTIME_DIR`,
|
|
21
|
+
`WPR_WORKER_EXE`, `WPR_PROFILE_ROOT`, or `WPR_LOG_ROOT` when embedding it in a
|
|
22
|
+
larger application.
|
|
23
|
+
|
|
24
|
+
To install the pinned runtime automatically from the GitHub Release:
|
|
25
|
+
|
|
26
|
+
```powershell
|
|
27
|
+
python -m gecko_web_runtime install-runtime --version 155
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The command verifies the pinned SHA256 and stores the selected runtime under
|
|
31
|
+
`%LOCALAPPDATA%\WPR\runtime\155`.
|
|
32
|
+
|
|
33
|
+
For a private GitHub Release, set `GITHUB_TOKEN`, `GH_TOKEN`, or
|
|
34
|
+
`WPR_GITHUB_TOKEN`, or configure Git Credential Manager for the GitHub account.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
gecko_web_runtime/__init__.py,sha256=hkHerbBDOqtedS3xHYPWvgBwOPf5V-aRl-A5q1LojJc,372
|
|
2
|
+
gecko_web_runtime/__main__.py,sha256=1crNdy71fYiQfTqH0FV-5SzlRftCQEO9rgtoUDMkT8g,875
|
|
3
|
+
gecko_web_runtime/installer.py,sha256=htGjrYYzka8CTI51JPAEGNKdMXapsB-0-y0V_K2egbQ,6579
|
|
4
|
+
gecko_web_runtime/pipe.py,sha256=DApeCdA0qa1iEvmPb_v9u5E-lXj4mJnXzIYbF_mWyew,1663
|
|
5
|
+
gecko_web_runtime/session.py,sha256=2kPoqyNzK2ffy3HI8DwSHYY32DY8DIQqab5felnzxe0,10104
|
|
6
|
+
gecko_web_runtime_client-1.0.0.dist-info/METADATA,sha256=DcutF_FYVsJJlyI5bruBAsklymChLAuRdSsrW4tsJhE,1146
|
|
7
|
+
gecko_web_runtime_client-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
gecko_web_runtime_client-1.0.0.dist-info/top_level.txt,sha256=oSRLteQmBqmmSldH5FG_meNE87A7DortZyec99kekIs,18
|
|
9
|
+
gecko_web_runtime_client-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gecko_web_runtime
|