wpr-runtime-core 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wpr_runtime_core-0.2.0/PKG-INFO +18 -0
- wpr_runtime_core-0.2.0/README.md +8 -0
- wpr_runtime_core-0.2.0/pyproject.toml +22 -0
- wpr_runtime_core-0.2.0/setup.cfg +4 -0
- wpr_runtime_core-0.2.0/tests/test_products.py +18 -0
- wpr_runtime_core-0.2.0/wpr_runtime_core/__init__.py +3 -0
- wpr_runtime_core-0.2.0/wpr_runtime_core/_leases.py +120 -0
- wpr_runtime_core-0.2.0/wpr_runtime_core/data_plane.py +148 -0
- wpr_runtime_core-0.2.0/wpr_runtime_core/installer.py +326 -0
- wpr_runtime_core-0.2.0/wpr_runtime_core/iocp_pipe.py +204 -0
- wpr_runtime_core-0.2.0/wpr_runtime_core/pipe.py +169 -0
- wpr_runtime_core-0.2.0/wpr_runtime_core/protocols.py +23 -0
- wpr_runtime_core-0.2.0/wpr_runtime_core/registry.py +157 -0
- wpr_runtime_core-0.2.0/wpr_runtime_core/runtime-catalog.json +37 -0
- wpr_runtime_core-0.2.0/wpr_runtime_core/session.py +867 -0
- wpr_runtime_core-0.2.0/wpr_runtime_core.egg-info/PKG-INFO +18 -0
- wpr_runtime_core-0.2.0/wpr_runtime_core.egg-info/SOURCES.txt +17 -0
- wpr_runtime_core-0.2.0/wpr_runtime_core.egg-info/dependency_links.txt +1 -0
- wpr_runtime_core-0.2.0/wpr_runtime_core.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wpr-runtime-core
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Shared process, IPC and versioned runtime support for WPR clients
|
|
5
|
+
License: MPL-2.0-compatible client; see runtime bundle notices
|
|
6
|
+
Project-URL: Repository, https://github.com/childclm/gecko-web-runtime
|
|
7
|
+
Project-URL: Documentation, https://github.com/childclm/gecko-web-runtime/blob/main/docs/runtime-products.md
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# WPR runtime core
|
|
12
|
+
|
|
13
|
+
Shared Worker process lifecycle, named-pipe IPC, IOCP, shared-memory leases and
|
|
14
|
+
versioned runtime resolution. This internal dependency is used by the standalone
|
|
15
|
+
HTTP client and the unchanged WPR browser product. It contains no Page API,
|
|
16
|
+
browser workflow or DOM interaction implementation.
|
|
17
|
+
|
|
18
|
+
Current native runtime support: Windows x86-64, Firefox 155, wpr-http-v1.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# WPR runtime core
|
|
2
|
+
|
|
3
|
+
Shared Worker process lifecycle, named-pipe IPC, IOCP, shared-memory leases and
|
|
4
|
+
versioned runtime resolution. This internal dependency is used by the standalone
|
|
5
|
+
HTTP client and the unchanged WPR browser product. It contains no Page API,
|
|
6
|
+
browser workflow or DOM interaction implementation.
|
|
7
|
+
|
|
8
|
+
Current native runtime support: Windows x86-64, Firefox 155, wpr-http-v1.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wpr-runtime-core"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Shared process, IPC and versioned runtime support for WPR clients"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MPL-2.0-compatible client; see runtime bundle notices"}
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Repository = "https://github.com/childclm/gecko-web-runtime"
|
|
16
|
+
Documentation = "https://github.com/childclm/gecko-web-runtime/blob/main/docs/runtime-products.md"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
include = ["wpr_runtime_core*"]
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.package-data]
|
|
22
|
+
wpr_runtime_core = ["runtime-catalog.json"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from wpr_runtime_core.installer import RUNTIME_CATALOG, select_release
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_browser_and_network_share_build_but_not_asset():
|
|
7
|
+
browser = select_release("155", product="browser")
|
|
8
|
+
network = select_release("155", product="network")
|
|
9
|
+
assert browser["version"] == network["version"] == "155.0.6"
|
|
10
|
+
assert browser["repository"] == network["repository"]
|
|
11
|
+
assert browser["asset"] != network["asset"]
|
|
12
|
+
assert browser["sha256"] != network["sha256"]
|
|
13
|
+
assert select_release("155.0.6", product="network") == network
|
|
14
|
+
assert RUNTIME_CATALOG["schema"] == "wpr.runtime-catalog.v2"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_local_catalog_is_serializable():
|
|
18
|
+
assert json.loads(json.dumps(RUNTIME_CATALOG))["engines"]["firefox155"]["default_build"] == "155.0.6"
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Acknowledged, session-scoped deferred release of network response blobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
|
|
9
|
+
_logger = logging.getLogger("wpr_runtime_core.leases")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DeferredBlobReleases:
|
|
13
|
+
max_batch = 32
|
|
14
|
+
flush_bytes = 8 * 1024 * 1024
|
|
15
|
+
idle_seconds = 0.05
|
|
16
|
+
|
|
17
|
+
def __init__(self, flush: Callable[[], Awaitable[None]]) -> None:
|
|
18
|
+
self.pending: dict[int, int] = {}
|
|
19
|
+
self.reserved: set[int] = set()
|
|
20
|
+
self._changed = asyncio.Event()
|
|
21
|
+
self._flush = flush
|
|
22
|
+
self._timer: asyncio.TimerHandle | None = None
|
|
23
|
+
self._background: asyncio.Task | None = None
|
|
24
|
+
self._drain_lock = asyncio.Lock()
|
|
25
|
+
self._stopped = False
|
|
26
|
+
|
|
27
|
+
async def enqueue(self, blob_id: int, size: int) -> dict:
|
|
28
|
+
if self._stopped:
|
|
29
|
+
raise RuntimeError("deferred release queue is closed")
|
|
30
|
+
self.pending.setdefault(blob_id, size)
|
|
31
|
+
if self._timer is None:
|
|
32
|
+
self._timer = asyncio.get_running_loop().call_later(
|
|
33
|
+
self.idle_seconds, self._idle_flush
|
|
34
|
+
)
|
|
35
|
+
if (
|
|
36
|
+
len(self.pending) >= self.max_batch
|
|
37
|
+
or sum(self.pending.values()) >= self.flush_bytes
|
|
38
|
+
):
|
|
39
|
+
await self.drain()
|
|
40
|
+
return {
|
|
41
|
+
"released": blob_id not in self.pending,
|
|
42
|
+
"release_deferred": blob_id in self.pending,
|
|
43
|
+
"pending_release_count": len(self.pending),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
def snapshot(self) -> list[int]:
|
|
47
|
+
return [item for item in self.pending if item not in self.reserved][
|
|
48
|
+
: self.max_batch
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
def reserve_batch(self) -> list[int]:
|
|
52
|
+
batch = self.snapshot()
|
|
53
|
+
self.reserved.update(batch)
|
|
54
|
+
return batch
|
|
55
|
+
|
|
56
|
+
def unreserve(self, batch: list[int]) -> None:
|
|
57
|
+
self.reserved.difference_update(batch)
|
|
58
|
+
self._changed.set()
|
|
59
|
+
|
|
60
|
+
def acknowledge(self, batch: list[int], receipt: object) -> None:
|
|
61
|
+
if not isinstance(receipt, list) or any(
|
|
62
|
+
type(item) is not int for item in receipt
|
|
63
|
+
):
|
|
64
|
+
raise RuntimeError("Worker omitted valid blob release acknowledgements")
|
|
65
|
+
if set(receipt) != set(batch) or len(receipt) != len(batch):
|
|
66
|
+
raise RuntimeError(
|
|
67
|
+
"Worker blob release acknowledgements did not match the batch"
|
|
68
|
+
)
|
|
69
|
+
for blob_id in batch:
|
|
70
|
+
self.pending.pop(blob_id, None)
|
|
71
|
+
self.unreserve(batch)
|
|
72
|
+
if not self.pending and self._timer is not None:
|
|
73
|
+
self._timer.cancel()
|
|
74
|
+
self._timer = None
|
|
75
|
+
|
|
76
|
+
def _idle_flush(self) -> None:
|
|
77
|
+
self._timer = None
|
|
78
|
+
if self.pending and not self._stopped and self._background is None:
|
|
79
|
+
self._background = asyncio.create_task(self.drain())
|
|
80
|
+
self._background.add_done_callback(self._background_done)
|
|
81
|
+
|
|
82
|
+
def _background_done(self, task: asyncio.Task) -> None:
|
|
83
|
+
self._background = None
|
|
84
|
+
if not task.cancelled() and (error := task.exception()) is not None:
|
|
85
|
+
_logger.warning("http.blob_release_flush_failed error=%s", error)
|
|
86
|
+
|
|
87
|
+
async def drain(self) -> None:
|
|
88
|
+
async with self._drain_lock:
|
|
89
|
+
while self.pending and not self._stopped:
|
|
90
|
+
self._changed.clear()
|
|
91
|
+
before = self.snapshot()
|
|
92
|
+
if not before:
|
|
93
|
+
await self._changed.wait()
|
|
94
|
+
continue
|
|
95
|
+
await self._flush()
|
|
96
|
+
if before == self.snapshot():
|
|
97
|
+
raise RuntimeError("Worker made no progress flushing blob releases")
|
|
98
|
+
|
|
99
|
+
async def close(self) -> None:
|
|
100
|
+
if self._timer is not None:
|
|
101
|
+
self._timer.cancel()
|
|
102
|
+
self._timer = None
|
|
103
|
+
await self.drain()
|
|
104
|
+
self._stopped = True
|
|
105
|
+
if self._background is not None:
|
|
106
|
+
await self._background
|
|
107
|
+
|
|
108
|
+
def discard(self) -> None:
|
|
109
|
+
self._stopped = True
|
|
110
|
+
self.pending.clear()
|
|
111
|
+
self.reserved.clear()
|
|
112
|
+
self._changed.set()
|
|
113
|
+
if self._timer is not None:
|
|
114
|
+
self._timer.cancel()
|
|
115
|
+
self._timer = None
|
|
116
|
+
if (
|
|
117
|
+
self._background is not None
|
|
118
|
+
and self._background is not asyncio.current_task()
|
|
119
|
+
):
|
|
120
|
+
self._background.cancel()
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Large-result data-plane leases for the WPR control RPC.
|
|
2
|
+
|
|
3
|
+
The Worker returns a small JSON descriptor for a named read-only mapping. The
|
|
4
|
+
consumer maps it only when needed and explicitly releases the Worker-side
|
|
5
|
+
handle after closing its local view.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import mmap
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .protocols import ReleaseSession
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class SharedBlobDescriptor:
|
|
21
|
+
transport: str
|
|
22
|
+
blob_id: int
|
|
23
|
+
mapping_name: str
|
|
24
|
+
size: int
|
|
25
|
+
encoding: str = "utf-8"
|
|
26
|
+
readonly: bool = True
|
|
27
|
+
mapping_size: int | None = None
|
|
28
|
+
offset: int = 0
|
|
29
|
+
generation: int | None = None
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_value(cls, value: Any) -> SharedBlobDescriptor:
|
|
33
|
+
if not isinstance(value, dict) or value.get("transport") not in {
|
|
34
|
+
"shared_memory",
|
|
35
|
+
"shared_memory_ring",
|
|
36
|
+
}:
|
|
37
|
+
raise ValueError("invalid shared blob descriptor")
|
|
38
|
+
size = int(value["size"])
|
|
39
|
+
mapping_size = int(value.get("mapping_size") or size)
|
|
40
|
+
offset = int(value.get("offset", 0))
|
|
41
|
+
if size < 0 or offset < 0 or mapping_size < size + offset:
|
|
42
|
+
raise ValueError("invalid shared blob bounds")
|
|
43
|
+
return cls(
|
|
44
|
+
transport=str(value["transport"]),
|
|
45
|
+
blob_id=int(value["blob_id"]),
|
|
46
|
+
mapping_name=str(value["mapping_name"]),
|
|
47
|
+
size=size,
|
|
48
|
+
encoding=str(value.get("encoding") or "utf-8"),
|
|
49
|
+
readonly=bool(value.get("readonly", True)),
|
|
50
|
+
mapping_size=mapping_size,
|
|
51
|
+
offset=offset,
|
|
52
|
+
generation=(int(value["generation"]) if "generation" in value else None),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SharedBlob:
|
|
57
|
+
"""A mapped blob with an explicit asynchronous release handshake."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
descriptor: SharedBlobDescriptor,
|
|
62
|
+
session: ReleaseSession,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.descriptor = descriptor
|
|
65
|
+
self._session = session
|
|
66
|
+
self._mapping: mmap.mmap | None = None
|
|
67
|
+
self._released = False
|
|
68
|
+
self.mapping_open_ms = 0.0
|
|
69
|
+
self.copy_ms = 0.0
|
|
70
|
+
self.release_timings: dict[str, float] = {}
|
|
71
|
+
|
|
72
|
+
def open(self) -> SharedBlob:
|
|
73
|
+
if self._mapping is None:
|
|
74
|
+
if self.descriptor.size < 0:
|
|
75
|
+
raise ValueError("shared blob size must be non-negative")
|
|
76
|
+
# Windows mmap opens an existing named CreateFileMapping object via
|
|
77
|
+
# the tagname argument. Keep a one-byte view for an empty result.
|
|
78
|
+
started_ns = time.perf_counter_ns()
|
|
79
|
+
self._mapping = mmap.mmap(
|
|
80
|
+
-1,
|
|
81
|
+
max(1, int(self.descriptor.mapping_size or self.descriptor.size)),
|
|
82
|
+
tagname=self.descriptor.mapping_name,
|
|
83
|
+
access=mmap.ACCESS_READ,
|
|
84
|
+
)
|
|
85
|
+
self.mapping_open_ms += (time.perf_counter_ns() - started_ns) / 1_000_000
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def view(self) -> memoryview:
|
|
89
|
+
if self._mapping is None:
|
|
90
|
+
self.open()
|
|
91
|
+
assert self._mapping is not None
|
|
92
|
+
start = self.descriptor.offset
|
|
93
|
+
return memoryview(self._mapping)[start : start + self.descriptor.size]
|
|
94
|
+
|
|
95
|
+
def read_bytes(self) -> bytes:
|
|
96
|
+
started_ns = time.perf_counter_ns()
|
|
97
|
+
data = self.view().tobytes()
|
|
98
|
+
self.copy_ms += (time.perf_counter_ns() - started_ns) / 1_000_000
|
|
99
|
+
return data
|
|
100
|
+
|
|
101
|
+
def read_text(self) -> str:
|
|
102
|
+
return self.read_bytes().decode(self.descriptor.encoding)
|
|
103
|
+
|
|
104
|
+
def close(self) -> None:
|
|
105
|
+
if self._mapping is not None:
|
|
106
|
+
self._mapping.close()
|
|
107
|
+
self._mapping = None
|
|
108
|
+
|
|
109
|
+
async def release(self) -> dict[str, Any]:
|
|
110
|
+
if self._released:
|
|
111
|
+
return {"released": True, "already_released": True}
|
|
112
|
+
self.close()
|
|
113
|
+
if not self._session.is_open:
|
|
114
|
+
# A Worker may be aborted after Python mapped the response. The
|
|
115
|
+
# OS mapping remains readable while this local view is open, and
|
|
116
|
+
# the dead process has already discarded its own blob registry.
|
|
117
|
+
self._released = True
|
|
118
|
+
return {"released": False, "worker_closed": True}
|
|
119
|
+
try:
|
|
120
|
+
if getattr(self._session, "defer_body_releases", False):
|
|
121
|
+
response = await self._session.queue_body_release(
|
|
122
|
+
self.descriptor.blob_id, self.descriptor.size
|
|
123
|
+
)
|
|
124
|
+
self.release_timings = {
|
|
125
|
+
"release_deferred": float(response["release_deferred"])
|
|
126
|
+
}
|
|
127
|
+
elif hasattr(self._session, "data_release_with_timing"):
|
|
128
|
+
(
|
|
129
|
+
response,
|
|
130
|
+
self.release_timings,
|
|
131
|
+
) = await self._session.data_release_with_timing(
|
|
132
|
+
self.descriptor.blob_id
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
response = await self._session.data_release(self.descriptor.blob_id)
|
|
136
|
+
except Exception:
|
|
137
|
+
if self._session.is_open:
|
|
138
|
+
raise
|
|
139
|
+
self._released = True
|
|
140
|
+
return {"released": False, "worker_closed": True}
|
|
141
|
+
self._released = True
|
|
142
|
+
return response
|
|
143
|
+
|
|
144
|
+
async def __aenter__(self) -> SharedBlob: # noqa: PYI034
|
|
145
|
+
return self.open()
|
|
146
|
+
|
|
147
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
148
|
+
await self.release()
|
|
@@ -0,0 +1,326 @@
|
|
|
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.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
import uuid
|
|
15
|
+
import warnings
|
|
16
|
+
import zipfile
|
|
17
|
+
from importlib.resources import files
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
RUNTIME_CATALOG = json.loads(
|
|
21
|
+
files(__package__).joinpath("runtime-catalog.json").read_text(encoding="utf-8")
|
|
22
|
+
)
|
|
23
|
+
RUNTIME_RELEASES = {
|
|
24
|
+
engine.removeprefix("firefox"): {
|
|
25
|
+
**definition["builds"][definition["default_build"]],
|
|
26
|
+
**definition["builds"][definition["default_build"]]["products"]["browser"],
|
|
27
|
+
}
|
|
28
|
+
for engine, definition in RUNTIME_CATALOG["engines"].items()
|
|
29
|
+
}
|
|
30
|
+
NETWORK_RELEASES = {
|
|
31
|
+
engine.removeprefix("firefox"): {
|
|
32
|
+
**definition["builds"][definition["default_build"]],
|
|
33
|
+
**definition["builds"][definition["default_build"]]["products"]["network"],
|
|
34
|
+
}
|
|
35
|
+
for engine, definition in RUNTIME_CATALOG["engines"].items()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def select_release(version: str | int, *, product: str = "browser") -> dict[str, str | int]:
|
|
40
|
+
"""Resolve one exact native product without crossing engine versions."""
|
|
41
|
+
from .registry import RuntimeCompatibilityError, engine_major
|
|
42
|
+
|
|
43
|
+
major = engine_major(version)
|
|
44
|
+
if product not in {"browser", "network"}:
|
|
45
|
+
raise ValueError(f"unknown runtime product: {product}")
|
|
46
|
+
engine = RUNTIME_CATALOG["engines"].get(f"firefox{major}")
|
|
47
|
+
if engine is None:
|
|
48
|
+
raise RuntimeCompatibilityError(
|
|
49
|
+
f"Firefox {major} has no published runtime; available: {', '.join(RUNTIME_RELEASES)}"
|
|
50
|
+
)
|
|
51
|
+
build = str(version) if "." in str(version) else engine["default_build"]
|
|
52
|
+
build_info = engine["builds"].get(build)
|
|
53
|
+
if build_info is None:
|
|
54
|
+
raise RuntimeCompatibilityError(
|
|
55
|
+
f"Firefox {version} has no published runtime; available builds: {', '.join(engine['builds'])}"
|
|
56
|
+
)
|
|
57
|
+
# Keep the browser default mapping patchable for the historical installer
|
|
58
|
+
# compatibility tests and existing third-party imports.
|
|
59
|
+
if product == "browser" and build == engine["default_build"]:
|
|
60
|
+
release = RUNTIME_RELEASES[major]
|
|
61
|
+
else:
|
|
62
|
+
release = build_info["products"][product]
|
|
63
|
+
if (build_info["firefox_major"] != int(major)
|
|
64
|
+
or build_info["version"] != build
|
|
65
|
+
or release["product"] != ("wpr-runtime" if product == "browser" else "wpr-network-runtime")):
|
|
66
|
+
raise RuntimeCompatibilityError("runtime catalog entry is inconsistent")
|
|
67
|
+
return {**build_info, **release}
|
|
68
|
+
return release
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _github_token() -> str | None:
|
|
72
|
+
for name in ("WPR_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"):
|
|
73
|
+
value = os.environ.get(name)
|
|
74
|
+
if value:
|
|
75
|
+
return value.strip()
|
|
76
|
+
# Git Credential Manager may already hold the user's HTTPS credential.
|
|
77
|
+
try:
|
|
78
|
+
query = "protocol=https\nhost=github.com\n\n"
|
|
79
|
+
result = subprocess.run(
|
|
80
|
+
["git", "credential", "fill"],
|
|
81
|
+
input=query,
|
|
82
|
+
text=True,
|
|
83
|
+
capture_output=True,
|
|
84
|
+
timeout=10,
|
|
85
|
+
check=True,
|
|
86
|
+
)
|
|
87
|
+
for line in result.stdout.splitlines():
|
|
88
|
+
if line.startswith("password=") and line[9:]:
|
|
89
|
+
return line[9:].strip()
|
|
90
|
+
except (OSError, subprocess.SubprocessError):
|
|
91
|
+
pass
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _github_asset_request(release: dict[str, str]) -> urllib.request.Request:
|
|
96
|
+
token = _github_token()
|
|
97
|
+
if not token:
|
|
98
|
+
return urllib.request.Request(
|
|
99
|
+
release["url"],
|
|
100
|
+
headers={"User-Agent": "gecko-web-runtime-client/1.3.1"},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
api = (
|
|
104
|
+
f"https://api.github.com/repos/{release['repository']}"
|
|
105
|
+
f"/releases/tags/{release['tag']}"
|
|
106
|
+
)
|
|
107
|
+
metadata_request = urllib.request.Request(
|
|
108
|
+
api,
|
|
109
|
+
headers={
|
|
110
|
+
"Accept": "application/vnd.github+json",
|
|
111
|
+
"Authorization": f"Bearer {token}",
|
|
112
|
+
"User-Agent": "gecko-web-runtime-client/1.3.1",
|
|
113
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
with _open_github(metadata_request, timeout=30) as response:
|
|
117
|
+
metadata = json.load(response)
|
|
118
|
+
asset = next(
|
|
119
|
+
(
|
|
120
|
+
item
|
|
121
|
+
for item in metadata.get("assets", [])
|
|
122
|
+
if item.get("name") == release["asset"]
|
|
123
|
+
),
|
|
124
|
+
None,
|
|
125
|
+
)
|
|
126
|
+
if not asset:
|
|
127
|
+
raise RuntimeError(f"GitHub release asset not found: {release['asset']}")
|
|
128
|
+
return urllib.request.Request(
|
|
129
|
+
f"https://api.github.com/repos/{release['repository']}"
|
|
130
|
+
f"/releases/assets/{asset['id']}",
|
|
131
|
+
headers={
|
|
132
|
+
"Accept": "application/octet-stream",
|
|
133
|
+
"Authorization": f"Bearer {token}",
|
|
134
|
+
"User-Agent": "gecko-web-runtime-client/1.3.1",
|
|
135
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _open_github(request: urllib.request.Request, *, timeout: int):
|
|
141
|
+
"""Retry GitHub through a direct route if a system proxy breaks TLS."""
|
|
142
|
+
try:
|
|
143
|
+
return urllib.request.urlopen(request, timeout=timeout)
|
|
144
|
+
except urllib.error.URLError:
|
|
145
|
+
host = urllib.parse.urlsplit(request.full_url).hostname or ""
|
|
146
|
+
if host not in {"api.github.com", "github.com"}:
|
|
147
|
+
raise
|
|
148
|
+
return urllib.request.build_opener(urllib.request.ProxyHandler({})).open(
|
|
149
|
+
request, timeout=timeout
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def config_path() -> Path:
|
|
154
|
+
base = os.environ.get("LOCALAPPDATA")
|
|
155
|
+
if not base:
|
|
156
|
+
base = str(Path.home() / "AppData" / "Local")
|
|
157
|
+
return Path(base) / "WPR" / "config.json"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def configured_runtime_home() -> Path | None:
|
|
161
|
+
path = config_path()
|
|
162
|
+
try:
|
|
163
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
164
|
+
value = data.get("runtime_home")
|
|
165
|
+
return Path(value).resolve() if value else None
|
|
166
|
+
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _sha256(path: Path) -> str:
|
|
171
|
+
digest = hashlib.sha256()
|
|
172
|
+
with path.open("rb") as stream:
|
|
173
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
174
|
+
digest.update(chunk)
|
|
175
|
+
return digest.hexdigest()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _extract_safely(archive: Path, destination: Path) -> None:
|
|
179
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
root = destination.resolve()
|
|
181
|
+
with zipfile.ZipFile(archive) as source:
|
|
182
|
+
for member in source.infolist():
|
|
183
|
+
target = (destination / member.filename).resolve()
|
|
184
|
+
if target != root and root not in target.parents:
|
|
185
|
+
raise RuntimeError(f"unsafe path in runtime archive: {member.filename}")
|
|
186
|
+
source.extractall(destination)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _runtime_is_current(destination: Path, version: str, product: str = "browser") -> bool:
|
|
190
|
+
try:
|
|
191
|
+
manifest = json.loads(
|
|
192
|
+
(destination / "manifest.json").read_text(encoding="utf-8")
|
|
193
|
+
)
|
|
194
|
+
if manifest.get("version") != version:
|
|
195
|
+
return False
|
|
196
|
+
if product == "network":
|
|
197
|
+
if manifest.get("product") != "wpr-network-runtime":
|
|
198
|
+
return False
|
|
199
|
+
from .registry import RuntimeManifest
|
|
200
|
+
RuntimeManifest.load(destination, version)
|
|
201
|
+
elif not (destination / "runtime" / "gecko-web-worker.exe").is_file():
|
|
202
|
+
return False
|
|
203
|
+
except (FileNotFoundError, OSError, ValueError, KeyError, TypeError):
|
|
204
|
+
return False
|
|
205
|
+
return True
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def install_runtime(
|
|
209
|
+
version: str,
|
|
210
|
+
runtime_home: str | os.PathLike[str] | None = None,
|
|
211
|
+
*,
|
|
212
|
+
force: bool = False,
|
|
213
|
+
product: str = "browser",
|
|
214
|
+
) -> Path:
|
|
215
|
+
"""Install a pinned runtime and persist its location for the client."""
|
|
216
|
+
if os.name != "nt":
|
|
217
|
+
raise OSError("wpr-runtime-155 requires Windows")
|
|
218
|
+
release = select_release(version, product=product)
|
|
219
|
+
key = str(release["firefox_major"])
|
|
220
|
+
|
|
221
|
+
if runtime_home is None:
|
|
222
|
+
local = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
|
|
223
|
+
if product == "browser":
|
|
224
|
+
destination = Path(local) / "WPR" / "runtime" / key
|
|
225
|
+
else:
|
|
226
|
+
destination = Path(local) / "WPR" / "network-runtimes" / f"firefox{key}" / release["version"]
|
|
227
|
+
else:
|
|
228
|
+
destination = Path(runtime_home)
|
|
229
|
+
destination = destination.expanduser().resolve()
|
|
230
|
+
|
|
231
|
+
if (
|
|
232
|
+
destination.exists()
|
|
233
|
+
and not force
|
|
234
|
+
and _runtime_is_current(destination, release["version"], product)
|
|
235
|
+
):
|
|
236
|
+
_write_config(destination, product=product)
|
|
237
|
+
return destination
|
|
238
|
+
|
|
239
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
stage = destination.parent / f".{destination.name}.staging-{uuid.uuid4().hex}"
|
|
241
|
+
backup = None
|
|
242
|
+
moved_new = False
|
|
243
|
+
preserve_stage = False
|
|
244
|
+
try:
|
|
245
|
+
with tempfile.TemporaryDirectory(prefix="wpr-runtime-download-") as temp:
|
|
246
|
+
archive = Path(temp) / "runtime.zip"
|
|
247
|
+
request = _github_asset_request(release)
|
|
248
|
+
with (
|
|
249
|
+
_open_github(request, timeout=120) as response,
|
|
250
|
+
archive.open("wb") as output,
|
|
251
|
+
):
|
|
252
|
+
shutil.copyfileobj(response, output)
|
|
253
|
+
actual = _sha256(archive)
|
|
254
|
+
if actual != release["sha256"]:
|
|
255
|
+
raise RuntimeError(
|
|
256
|
+
f"runtime SHA256 mismatch: expected {release['sha256']}, got {actual}"
|
|
257
|
+
)
|
|
258
|
+
_extract_safely(archive, stage)
|
|
259
|
+
|
|
260
|
+
if not _runtime_is_current(stage, release["version"], product):
|
|
261
|
+
raise RuntimeError(
|
|
262
|
+
f"{product} runtime archive is missing its Worker or manifest"
|
|
263
|
+
)
|
|
264
|
+
if destination.exists():
|
|
265
|
+
backup = (
|
|
266
|
+
destination.parent / f"{destination.name}.backup-{uuid.uuid4().hex}"
|
|
267
|
+
)
|
|
268
|
+
destination.rename(backup)
|
|
269
|
+
try:
|
|
270
|
+
stage.replace(destination)
|
|
271
|
+
moved_new = True
|
|
272
|
+
if not _runtime_is_current(destination, release["version"], product):
|
|
273
|
+
raise RuntimeError("installed runtime failed post-swap validation")
|
|
274
|
+
_write_config(destination, product=product)
|
|
275
|
+
except BaseException:
|
|
276
|
+
try:
|
|
277
|
+
if moved_new:
|
|
278
|
+
destination.rename(stage)
|
|
279
|
+
if backup is not None:
|
|
280
|
+
backup.rename(destination)
|
|
281
|
+
except OSError as rollback_error:
|
|
282
|
+
preserve_stage = True
|
|
283
|
+
raise RuntimeError(
|
|
284
|
+
f"runtime upgrade rollback incomplete; inspect {destination}, {backup}, and {stage}"
|
|
285
|
+
) from rollback_error
|
|
286
|
+
raise
|
|
287
|
+
if backup is not None:
|
|
288
|
+
try:
|
|
289
|
+
shutil.rmtree(backup)
|
|
290
|
+
except OSError:
|
|
291
|
+
warnings.warn(
|
|
292
|
+
f"Upgrade succeeded, but the temporary backup remains at {backup}",
|
|
293
|
+
stacklevel=2,
|
|
294
|
+
)
|
|
295
|
+
return destination
|
|
296
|
+
finally:
|
|
297
|
+
if not preserve_stage and stage.exists():
|
|
298
|
+
shutil.rmtree(stage, ignore_errors=True)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _write_config(destination: Path, *, product: str = "browser") -> None:
|
|
302
|
+
path = config_path()
|
|
303
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
staged = path.with_name(f".{path.name}.staging-{uuid.uuid4().hex}")
|
|
305
|
+
try:
|
|
306
|
+
settings = json.loads(path.read_text(encoding="utf-8"))
|
|
307
|
+
except (OSError, ValueError):
|
|
308
|
+
settings = {}
|
|
309
|
+
manifest = json.loads((destination / "manifest.json").read_text(encoding="utf-8"))
|
|
310
|
+
major = str(manifest["version"]).split(".", 1)[0]
|
|
311
|
+
if product == "network":
|
|
312
|
+
settings.setdefault("network_runtimes", {})[str(manifest["version"])] = str(destination)
|
|
313
|
+
if NETWORK_RELEASES.get(major, {}).get("version", manifest["version"]) == manifest["version"]:
|
|
314
|
+
settings["network_runtimes"][major] = str(destination)
|
|
315
|
+
else:
|
|
316
|
+
settings.setdefault("runtimes", {})[major] = str(destination)
|
|
317
|
+
settings["runtimes"][str(manifest["version"])] = str(destination)
|
|
318
|
+
settings["runtime_home"] = str(destination)
|
|
319
|
+
try:
|
|
320
|
+
staged.write_text(
|
|
321
|
+
json.dumps(settings, indent=2) + "\n",
|
|
322
|
+
encoding="utf-8",
|
|
323
|
+
)
|
|
324
|
+
staged.replace(path)
|
|
325
|
+
finally:
|
|
326
|
+
staged.unlink(missing_ok=True)
|