wpr-runtime-core 0.2.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.
- wpr_runtime_core/__init__.py +3 -0
- wpr_runtime_core/_leases.py +120 -0
- wpr_runtime_core/data_plane.py +148 -0
- wpr_runtime_core/installer.py +326 -0
- wpr_runtime_core/iocp_pipe.py +204 -0
- wpr_runtime_core/pipe.py +169 -0
- wpr_runtime_core/protocols.py +23 -0
- wpr_runtime_core/registry.py +157 -0
- wpr_runtime_core/runtime-catalog.json +37 -0
- wpr_runtime_core/session.py +867 -0
- wpr_runtime_core-0.2.0.dist-info/METADATA +18 -0
- wpr_runtime_core-0.2.0.dist-info/RECORD +14 -0
- wpr_runtime_core-0.2.0.dist-info/WHEEL +5 -0
- wpr_runtime_core-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Windows Proactor JSON-line pipe transport with no per-RPC executor hop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _PipeProtocol(asyncio.Protocol):
|
|
12
|
+
def __init__(self, multiplex=False):
|
|
13
|
+
self.multiplex = multiplex
|
|
14
|
+
self.pending: dict[int, asyncio.Future] = {}
|
|
15
|
+
self.transport = None
|
|
16
|
+
self.buffer = bytearray()
|
|
17
|
+
self.response: asyncio.Future | None = None
|
|
18
|
+
self.closed = asyncio.get_running_loop().create_future()
|
|
19
|
+
self.flow_waiter: asyncio.Future | None = None
|
|
20
|
+
|
|
21
|
+
def connection_made(self, transport):
|
|
22
|
+
self.transport = transport
|
|
23
|
+
|
|
24
|
+
def pause_writing(self):
|
|
25
|
+
self.flow_waiter = asyncio.get_running_loop().create_future()
|
|
26
|
+
|
|
27
|
+
def resume_writing(self):
|
|
28
|
+
if self.flow_waiter is not None and not self.flow_waiter.done():
|
|
29
|
+
self.flow_waiter.set_result(None)
|
|
30
|
+
self.flow_waiter = None
|
|
31
|
+
|
|
32
|
+
def data_received(self, data):
|
|
33
|
+
callback_ns = time.perf_counter_ns()
|
|
34
|
+
self.buffer.extend(data)
|
|
35
|
+
while (newline := self.buffer.find(b"\n")) >= 0:
|
|
36
|
+
if newline >= 16 * 1024 * 1024:
|
|
37
|
+
self.fail(RuntimeError("WPR RPC response exceeded the framing limit"))
|
|
38
|
+
return
|
|
39
|
+
line = bytes(self.buffer[: newline + 1])
|
|
40
|
+
del self.buffer[: newline + 1]
|
|
41
|
+
try:
|
|
42
|
+
started = time.perf_counter_ns()
|
|
43
|
+
value = json.loads(line)
|
|
44
|
+
if not isinstance(value, dict):
|
|
45
|
+
raise TypeError("invalid WPR RPC envelope")
|
|
46
|
+
decoded_ms = (time.perf_counter_ns() - started) / 1_000_000
|
|
47
|
+
ipc = value.get("_wpr_ipc")
|
|
48
|
+
if isinstance(ipc, dict):
|
|
49
|
+
ipc["client_callback_ns"] = callback_ns
|
|
50
|
+
ipc["client_decoded_ns"] = time.perf_counter_ns()
|
|
51
|
+
future = (
|
|
52
|
+
self.pending.pop(value.get("request_id"), None)
|
|
53
|
+
if self.multiplex
|
|
54
|
+
else self.response
|
|
55
|
+
)
|
|
56
|
+
if future is None or future.done():
|
|
57
|
+
raise RuntimeError("unexpected uncorrelated WPR pipe response")
|
|
58
|
+
future.set_result((value, decoded_ms, len(line)))
|
|
59
|
+
except (ValueError, TypeError, RuntimeError) as error:
|
|
60
|
+
self.fail(error)
|
|
61
|
+
return
|
|
62
|
+
if len(self.buffer) > 16 * 1024 * 1024:
|
|
63
|
+
self.fail(RuntimeError("WPR RPC response exceeded the framing limit"))
|
|
64
|
+
|
|
65
|
+
def fail(self, error):
|
|
66
|
+
for future in self.pending.values():
|
|
67
|
+
if not future.done():
|
|
68
|
+
future.set_exception(error)
|
|
69
|
+
if self.response is not None and not self.response.done():
|
|
70
|
+
self.response.set_exception(error)
|
|
71
|
+
if self.transport is not None:
|
|
72
|
+
self.transport.abort()
|
|
73
|
+
|
|
74
|
+
def connection_lost(self, error):
|
|
75
|
+
error = error or RuntimeError("gecko worker closed the RPC pipe")
|
|
76
|
+
for future in self.pending.values():
|
|
77
|
+
if not future.done():
|
|
78
|
+
future.set_exception(error)
|
|
79
|
+
if self.response is not None and not self.response.done():
|
|
80
|
+
self.response.set_exception(error)
|
|
81
|
+
if self.flow_waiter is not None and not self.flow_waiter.done():
|
|
82
|
+
self.flow_waiter.set_exception(error)
|
|
83
|
+
if not self.closed.done():
|
|
84
|
+
self.closed.set_result(None)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class IocpJsonLinePipe:
|
|
88
|
+
"""Serial or request-ID-multiplexed RPC over a Proactor duplex pipe."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, pipe_name: str, *, multiplex=False, on_cancel=None):
|
|
91
|
+
self.multiplex = multiplex
|
|
92
|
+
self.on_cancel = on_cancel
|
|
93
|
+
self._next_id = 0
|
|
94
|
+
self.pipe_name = pipe_name
|
|
95
|
+
self.protocol: _PipeProtocol | None = None
|
|
96
|
+
self.transport = None
|
|
97
|
+
self._closed = False
|
|
98
|
+
|
|
99
|
+
async def connect(self):
|
|
100
|
+
if self._closed:
|
|
101
|
+
raise RuntimeError("IOCP pipe is closed")
|
|
102
|
+
if self.transport is not None:
|
|
103
|
+
return
|
|
104
|
+
loop = asyncio.get_running_loop()
|
|
105
|
+
connect = getattr(loop, "create_pipe_connection", None)
|
|
106
|
+
if connect is None:
|
|
107
|
+
raise RuntimeError("IOCP backend requires a Windows Proactor event loop")
|
|
108
|
+
deadline = loop.time() + 60
|
|
109
|
+
delay = 0.002
|
|
110
|
+
while True:
|
|
111
|
+
try:
|
|
112
|
+
self.transport, self.protocol = await asyncio.wait_for(
|
|
113
|
+
connect(lambda: _PipeProtocol(self.multiplex), self.pipe_name),
|
|
114
|
+
max(0.001, deadline - loop.time()),
|
|
115
|
+
)
|
|
116
|
+
return
|
|
117
|
+
except OSError as error:
|
|
118
|
+
if (
|
|
119
|
+
getattr(error, "winerror", None) not in {2, 231}
|
|
120
|
+
or loop.time() >= deadline
|
|
121
|
+
):
|
|
122
|
+
raise
|
|
123
|
+
await asyncio.sleep(delay)
|
|
124
|
+
delay = min(0.05, delay * 2)
|
|
125
|
+
|
|
126
|
+
async def request(self, payload: dict[str, Any], timing_out=None):
|
|
127
|
+
started = time.perf_counter_ns()
|
|
128
|
+
phase = started
|
|
129
|
+
was_connected = self.transport is not None
|
|
130
|
+
await self.connect()
|
|
131
|
+
timings = {
|
|
132
|
+
"pipe_connect_ms": 0.0
|
|
133
|
+
if was_connected
|
|
134
|
+
else (time.perf_counter_ns() - phase) / 1_000_000
|
|
135
|
+
}
|
|
136
|
+
assert self.protocol is not None and self.transport is not None
|
|
137
|
+
future = asyncio.get_running_loop().create_future()
|
|
138
|
+
self._next_id += 1
|
|
139
|
+
request_id = self._next_id
|
|
140
|
+
if self.multiplex:
|
|
141
|
+
payload = {**payload, "request_id": request_id}
|
|
142
|
+
self.protocol.pending[request_id] = future
|
|
143
|
+
else:
|
|
144
|
+
self.protocol.response = future
|
|
145
|
+
try:
|
|
146
|
+
phase = time.perf_counter_ns()
|
|
147
|
+
encoded = (json.dumps(payload, separators=(",", ":")) + "\n").encode()
|
|
148
|
+
timings["json_encode_ms"] = (time.perf_counter_ns() - phase) / 1_000_000
|
|
149
|
+
phase = time.perf_counter_ns()
|
|
150
|
+
self.transport.write(encoded)
|
|
151
|
+
if self.protocol.flow_waiter is not None:
|
|
152
|
+
await self.protocol.flow_waiter
|
|
153
|
+
timings["pipe_write_ms"] = (time.perf_counter_ns() - phase) / 1_000_000
|
|
154
|
+
phase = time.perf_counter_ns()
|
|
155
|
+
value, decode_ms, size = await asyncio.shield(future)
|
|
156
|
+
resumed_ns = time.perf_counter_ns()
|
|
157
|
+
ipc = value.get("_wpr_ipc")
|
|
158
|
+
if isinstance(ipc, dict):
|
|
159
|
+
timings["request_id"] = request_id
|
|
160
|
+
timings["ipc_timeline"] = {**ipc, "client_resumed_ns": resumed_ns}
|
|
161
|
+
timings["pipe_read_ms"] = (time.perf_counter_ns() - phase) / 1_000_000
|
|
162
|
+
timings["json_decode_ms"] = decode_ms
|
|
163
|
+
timings["response_metadata_bytes"] = size
|
|
164
|
+
return value
|
|
165
|
+
except asyncio.CancelledError:
|
|
166
|
+
if (
|
|
167
|
+
self.multiplex
|
|
168
|
+
and self.on_cancel is not None
|
|
169
|
+
and payload.get("method") == "network.fetch"
|
|
170
|
+
):
|
|
171
|
+
try:
|
|
172
|
+
await self.on_cancel(request_id, future, payload)
|
|
173
|
+
except BaseException:
|
|
174
|
+
future.cancel()
|
|
175
|
+
self.transport.abort()
|
|
176
|
+
self._closed = True
|
|
177
|
+
raise
|
|
178
|
+
else:
|
|
179
|
+
future.cancel()
|
|
180
|
+
self.transport.abort()
|
|
181
|
+
self._closed = True
|
|
182
|
+
raise
|
|
183
|
+
except BaseException:
|
|
184
|
+
future.cancel()
|
|
185
|
+
self.transport.abort()
|
|
186
|
+
self._closed = True
|
|
187
|
+
raise
|
|
188
|
+
finally:
|
|
189
|
+
if self.multiplex:
|
|
190
|
+
self.protocol.pending.pop(request_id, None)
|
|
191
|
+
else:
|
|
192
|
+
self.protocol.response = None
|
|
193
|
+
if timing_out is not None:
|
|
194
|
+
timing_out.update(timings)
|
|
195
|
+
timing_out["pipe_total_ms"] = (
|
|
196
|
+
time.perf_counter_ns() - started
|
|
197
|
+
) / 1_000_000
|
|
198
|
+
|
|
199
|
+
async def aclose(self):
|
|
200
|
+
self._closed = True
|
|
201
|
+
if self.transport is not None:
|
|
202
|
+
self.transport.close()
|
|
203
|
+
assert self.protocol is not None
|
|
204
|
+
await asyncio.shield(self.protocol.closed)
|
wpr_runtime_core/pipe.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
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 os
|
|
8
|
+
import time
|
|
9
|
+
from ctypes import wintypes
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
if os.name == "nt":
|
|
13
|
+
import msvcrt
|
|
14
|
+
|
|
15
|
+
KERNEL32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
16
|
+
KERNEL32.CreateFileW.argtypes = [
|
|
17
|
+
wintypes.LPCWSTR,
|
|
18
|
+
wintypes.DWORD,
|
|
19
|
+
wintypes.DWORD,
|
|
20
|
+
ctypes.c_void_p,
|
|
21
|
+
wintypes.DWORD,
|
|
22
|
+
wintypes.DWORD,
|
|
23
|
+
wintypes.HANDLE,
|
|
24
|
+
]
|
|
25
|
+
KERNEL32.CreateFileW.restype = wintypes.HANDLE
|
|
26
|
+
KERNEL32.WaitNamedPipeW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD]
|
|
27
|
+
KERNEL32.WaitNamedPipeW.restype = wintypes.BOOL
|
|
28
|
+
else:
|
|
29
|
+
KERNEL32 = None
|
|
30
|
+
GENERIC_READ = 0x80000000
|
|
31
|
+
GENERIC_WRITE = 0x40000000
|
|
32
|
+
OPEN_EXISTING = 3
|
|
33
|
+
INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def connect_pipe(pipe_name: str, timeout_seconds: float = 60.0):
|
|
37
|
+
if KERNEL32 is None:
|
|
38
|
+
raise OSError("native WPR named-pipe transport requires Windows")
|
|
39
|
+
deadline = time.monotonic() + timeout_seconds
|
|
40
|
+
while time.monotonic() < deadline:
|
|
41
|
+
handle = KERNEL32.CreateFileW(
|
|
42
|
+
pipe_name,
|
|
43
|
+
GENERIC_READ | GENERIC_WRITE,
|
|
44
|
+
0,
|
|
45
|
+
None,
|
|
46
|
+
OPEN_EXISTING,
|
|
47
|
+
0,
|
|
48
|
+
None,
|
|
49
|
+
)
|
|
50
|
+
invalid = {-1, 0xFFFFFFFF, 0xFFFFFFFFFFFFFFFF, INVALID_HANDLE_VALUE}
|
|
51
|
+
if int(handle) not in invalid and int(handle) != 0:
|
|
52
|
+
return os.fdopen(msvcrt.open_osfhandle(handle, 0), "r+b", buffering=0)
|
|
53
|
+
KERNEL32.WaitNamedPipeW(pipe_name, 1000)
|
|
54
|
+
raise TimeoutError(f"named pipe did not become available: {pipe_name}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _elapsed_ms(started_ns: int, ended_ns: int) -> float:
|
|
58
|
+
return (ended_ns - started_ns) / 1_000_000
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class JsonLinePipe:
|
|
62
|
+
"""Synchronous JSON-lines RPC channel, optionally retained across calls."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, pipe_name: str, *, persistent: bool = False):
|
|
65
|
+
self.pipe_name = pipe_name
|
|
66
|
+
self.persistent = persistent
|
|
67
|
+
self._pipe = None
|
|
68
|
+
self._pending = bytearray()
|
|
69
|
+
self.read_mode = os.environ.get("WPR_PIPE_READ_MODE", "chunk")
|
|
70
|
+
if self.read_mode not in {"byte", "chunk"}:
|
|
71
|
+
raise ValueError("WPR_PIPE_READ_MODE must be 'byte' or 'chunk'")
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
pipe, self._pipe = self._pipe, None
|
|
75
|
+
if pipe is not None:
|
|
76
|
+
pipe.close()
|
|
77
|
+
self._pending.clear()
|
|
78
|
+
|
|
79
|
+
def _readline(self, pipe) -> tuple[bytes, int]:
|
|
80
|
+
if self.read_mode == "byte":
|
|
81
|
+
line = pipe.readline()
|
|
82
|
+
return line, len(line)
|
|
83
|
+
reads = 0
|
|
84
|
+
while True:
|
|
85
|
+
newline = self._pending.find(b"\n")
|
|
86
|
+
if newline >= 0:
|
|
87
|
+
if newline >= 16 * 1024 * 1024:
|
|
88
|
+
raise RuntimeError("WPR RPC response exceeded the framing limit")
|
|
89
|
+
line = bytes(self._pending[: newline + 1])
|
|
90
|
+
del self._pending[: newline + 1]
|
|
91
|
+
return line, reads
|
|
92
|
+
chunk = pipe.read(64 * 1024)
|
|
93
|
+
reads += 1
|
|
94
|
+
if not chunk:
|
|
95
|
+
return b"", reads
|
|
96
|
+
self._pending.extend(chunk)
|
|
97
|
+
if len(self._pending) > 16 * 1024 * 1024:
|
|
98
|
+
raise RuntimeError("WPR RPC response exceeded the framing limit")
|
|
99
|
+
|
|
100
|
+
def request(
|
|
101
|
+
self,
|
|
102
|
+
payload: dict[str, Any],
|
|
103
|
+
timing_out: dict[str, float] | None = None,
|
|
104
|
+
) -> dict[str, Any]:
|
|
105
|
+
if timing_out is not None:
|
|
106
|
+
timing_out.clear()
|
|
107
|
+
timings: dict[str, float] = {"pipe_connect_ms": 0.0}
|
|
108
|
+
started_ns = time.perf_counter_ns()
|
|
109
|
+
pipe = self._pipe if self.persistent else None
|
|
110
|
+
try:
|
|
111
|
+
if pipe is None:
|
|
112
|
+
phase_ns = time.perf_counter_ns()
|
|
113
|
+
pipe = connect_pipe(self.pipe_name)
|
|
114
|
+
timings["pipe_connect_ms"] = _elapsed_ms(
|
|
115
|
+
phase_ns, time.perf_counter_ns()
|
|
116
|
+
)
|
|
117
|
+
if self.persistent:
|
|
118
|
+
self._pipe = pipe
|
|
119
|
+
|
|
120
|
+
phase_ns = time.perf_counter_ns()
|
|
121
|
+
encoded = (json.dumps(payload, separators=(",", ":")) + "\n").encode()
|
|
122
|
+
timings["json_encode_ms"] = _elapsed_ms(phase_ns, time.perf_counter_ns())
|
|
123
|
+
|
|
124
|
+
phase_ns = time.perf_counter_ns()
|
|
125
|
+
offset = 0
|
|
126
|
+
while offset < len(encoded):
|
|
127
|
+
written = pipe.write(memoryview(encoded)[offset:])
|
|
128
|
+
if not written:
|
|
129
|
+
raise RuntimeError("gecko worker closed the RPC pipe while writing")
|
|
130
|
+
offset += written
|
|
131
|
+
timings["pipe_write_ms"] = _elapsed_ms(phase_ns, time.perf_counter_ns())
|
|
132
|
+
|
|
133
|
+
phase_ns = time.perf_counter_ns()
|
|
134
|
+
line, read_calls = self._readline(pipe)
|
|
135
|
+
timings["pipe_read_ms"] = _elapsed_ms(phase_ns, time.perf_counter_ns())
|
|
136
|
+
timings["pipe_read_calls"] = float(read_calls)
|
|
137
|
+
timings["response_metadata_bytes"] = float(len(line))
|
|
138
|
+
if not line:
|
|
139
|
+
raise RuntimeError("gecko worker closed the RPC pipe")
|
|
140
|
+
|
|
141
|
+
phase_ns = time.perf_counter_ns()
|
|
142
|
+
value = json.loads(line)
|
|
143
|
+
timings["json_decode_ms"] = _elapsed_ms(phase_ns, time.perf_counter_ns())
|
|
144
|
+
if not isinstance(value, dict):
|
|
145
|
+
raise RuntimeError("invalid WPR RPC envelope") # noqa: TRY004 -- protocol error
|
|
146
|
+
return value
|
|
147
|
+
except BaseException:
|
|
148
|
+
if self.persistent:
|
|
149
|
+
self.close()
|
|
150
|
+
raise
|
|
151
|
+
finally:
|
|
152
|
+
if not self.persistent and pipe is not None:
|
|
153
|
+
phase_ns = time.perf_counter_ns()
|
|
154
|
+
pipe.close()
|
|
155
|
+
timings["pipe_close_ms"] = _elapsed_ms(phase_ns, time.perf_counter_ns())
|
|
156
|
+
if timing_out is not None:
|
|
157
|
+
timing_out.update(timings)
|
|
158
|
+
timing_out["pipe_total_ms"] = _elapsed_ms(
|
|
159
|
+
started_ns, time.perf_counter_ns()
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def request(
|
|
164
|
+
payload: dict[str, Any],
|
|
165
|
+
pipe_name: str,
|
|
166
|
+
timing_out: dict[str, float] | None = None,
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
"""Send one RPC using the legacy connect/send/close pipe lifecycle."""
|
|
169
|
+
return JsonLinePipe(pipe_name).request(payload, timing_out)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Small structural interfaces shared by network and browser data consumers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ReleaseSession(Protocol):
|
|
9
|
+
@property
|
|
10
|
+
def is_open(self) -> bool: ...
|
|
11
|
+
|
|
12
|
+
async def data_release(self, blob_id: int) -> Any: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BodyLease(Protocol):
|
|
16
|
+
descriptor: Any
|
|
17
|
+
mapping_open_ms: float
|
|
18
|
+
copy_ms: float
|
|
19
|
+
release_timings: dict[str, Any]
|
|
20
|
+
|
|
21
|
+
def open(self) -> Any: ...
|
|
22
|
+
def read_bytes(self) -> bytes: ...
|
|
23
|
+
async def release(self) -> dict[str, Any]: ...
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Resolve actual network engines from versioned manifests, never UA labels."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .installer import (
|
|
13
|
+
RUNTIME_RELEASES,
|
|
14
|
+
config_path,
|
|
15
|
+
configured_runtime_home,
|
|
16
|
+
select_release,
|
|
17
|
+
)
|
|
18
|
+
from .session import RuntimeConfig
|
|
19
|
+
|
|
20
|
+
CLIENT_API_VERSION = 1
|
|
21
|
+
HTTP_PROTOCOL = "wpr-http-v1"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RuntimeCompatibilityError(ValueError):
|
|
25
|
+
"""An installed runtime does not satisfy the selected native engine ABI."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def engine_major(value: str | int) -> str:
|
|
29
|
+
if isinstance(value, bool) or not re.fullmatch(r"[1-9]\d*(?:\.\d+)*", str(value)):
|
|
30
|
+
raise RuntimeCompatibilityError(f"invalid Firefox engine version: {value!r}")
|
|
31
|
+
return str(value).split(".", 1)[0]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class RuntimeManifest:
|
|
36
|
+
home: Path
|
|
37
|
+
firefox_major: str
|
|
38
|
+
runtime_version: str
|
|
39
|
+
network_worker: Path
|
|
40
|
+
library_dir: Path
|
|
41
|
+
protocol: str
|
|
42
|
+
manifest_schema: str
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def load(cls, home: str | Path, requested: str | int) -> RuntimeManifest:
|
|
46
|
+
home = Path(home).expanduser().resolve()
|
|
47
|
+
path = home / "manifest.json"
|
|
48
|
+
if not path.is_file():
|
|
49
|
+
raise FileNotFoundError(
|
|
50
|
+
f"runtime manifest missing: {path}; install the selected runtime first"
|
|
51
|
+
)
|
|
52
|
+
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
53
|
+
if data.get("schema") != "wpr.runtime-manifest.v1":
|
|
54
|
+
raise RuntimeCompatibilityError("unsupported runtime manifest schema")
|
|
55
|
+
version = str(data.get("version", ""))
|
|
56
|
+
component = data.get("components", {}).get("network", {})
|
|
57
|
+
major = str(component.get("firefox_major") or engine_major(version))
|
|
58
|
+
if major != engine_major(version):
|
|
59
|
+
raise RuntimeCompatibilityError("Firefox major conflicts with runtime build version")
|
|
60
|
+
if major != engine_major(requested):
|
|
61
|
+
raise RuntimeCompatibilityError(
|
|
62
|
+
f"requested Firefox {requested}, manifest contains Firefox {major}"
|
|
63
|
+
)
|
|
64
|
+
if "." in str(requested) and str(requested) != version:
|
|
65
|
+
raise RuntimeCompatibilityError(
|
|
66
|
+
f"requested runtime {requested}, installed runtime is {version}"
|
|
67
|
+
)
|
|
68
|
+
protocols = data.get("protocols", [])
|
|
69
|
+
protocol = component.get(
|
|
70
|
+
"protocol", HTTP_PROTOCOL if HTTP_PROTOCOL in protocols else ""
|
|
71
|
+
)
|
|
72
|
+
if protocol != HTTP_PROTOCOL or protocol not in protocols:
|
|
73
|
+
raise RuntimeCompatibilityError(
|
|
74
|
+
f"expected protocol {HTTP_PROTOCOL}, found {protocol!r}"
|
|
75
|
+
)
|
|
76
|
+
abi = component.get("client_api", {"min": 1, "max": 1})
|
|
77
|
+
if not int(abi["min"]) <= CLIENT_API_VERSION <= int(abi["max"]):
|
|
78
|
+
raise RuntimeCompatibilityError(
|
|
79
|
+
f"runtime client API range {abi} excludes {CLIENT_API_VERSION}"
|
|
80
|
+
)
|
|
81
|
+
if data.get("platform") != "windows-x86_64":
|
|
82
|
+
raise RuntimeCompatibilityError(
|
|
83
|
+
f"unsupported runtime platform: {data.get('platform')!r}"
|
|
84
|
+
)
|
|
85
|
+
relative = component.get("worker") or data.get("network_worker")
|
|
86
|
+
if not isinstance(relative, str):
|
|
87
|
+
raise RuntimeCompatibilityError("network worker path missing from manifest")
|
|
88
|
+
worker = (home / relative).resolve()
|
|
89
|
+
if home not in worker.parents:
|
|
90
|
+
raise RuntimeCompatibilityError("network worker escapes runtime directory")
|
|
91
|
+
if not worker.is_file():
|
|
92
|
+
raise FileNotFoundError(worker)
|
|
93
|
+
library_dir = (home / component.get("library_dir", "runtime")).resolve()
|
|
94
|
+
if home not in library_dir.parents or not library_dir.is_dir():
|
|
95
|
+
raise RuntimeCompatibilityError("runtime library directory is invalid")
|
|
96
|
+
entry = data.get("files", {}).get(relative)
|
|
97
|
+
if not isinstance(entry, dict) or not entry.get("sha256"):
|
|
98
|
+
raise RuntimeCompatibilityError("network worker hash missing from manifest")
|
|
99
|
+
with worker.open("rb") as stream:
|
|
100
|
+
digest = hashlib.sha256()
|
|
101
|
+
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
102
|
+
digest.update(block)
|
|
103
|
+
if digest.hexdigest() != entry["sha256"]:
|
|
104
|
+
raise RuntimeCompatibilityError(
|
|
105
|
+
"network worker SHA256 differs from manifest"
|
|
106
|
+
)
|
|
107
|
+
return cls(home, major, version, worker, library_dir, protocol, data["schema"])
|
|
108
|
+
|
|
109
|
+
def config(self) -> RuntimeConfig:
|
|
110
|
+
defaults = RuntimeConfig.bundled()
|
|
111
|
+
return replace(
|
|
112
|
+
defaults,
|
|
113
|
+
package_root=self.home,
|
|
114
|
+
runtime_dir=self.library_dir,
|
|
115
|
+
worker_exe=self.home / "runtime" / "gecko-web-worker.exe",
|
|
116
|
+
network_worker_exe=self.network_worker,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def resolve_runtime(
|
|
121
|
+
version: str | int = "155", *, runtime_home: str | Path | None = None
|
|
122
|
+
) -> RuntimeManifest:
|
|
123
|
+
major = engine_major(version)
|
|
124
|
+
if runtime_home is None:
|
|
125
|
+
try:
|
|
126
|
+
settings = json.loads(config_path().read_text(encoding="utf-8"))
|
|
127
|
+
except (OSError, ValueError):
|
|
128
|
+
settings = {}
|
|
129
|
+
runtime_home = os.environ.get(f"WPR_RUNTIME_HOME_{major}") or os.environ.get(
|
|
130
|
+
"WPR_RUNTIME_HOME"
|
|
131
|
+
)
|
|
132
|
+
runtime_home = runtime_home or settings.get("network_runtimes", {}).get(str(version))
|
|
133
|
+
if runtime_home is None and "." not in str(version):
|
|
134
|
+
runtime_home = settings.get("network_runtimes", {}).get(major)
|
|
135
|
+
runtime_home = runtime_home or settings.get("runtimes", {}).get(str(version))
|
|
136
|
+
runtime_home = runtime_home or settings.get("runtimes", {}).get(major)
|
|
137
|
+
legacy = configured_runtime_home()
|
|
138
|
+
if runtime_home is None and legacy is not None:
|
|
139
|
+
try:
|
|
140
|
+
data = json.loads(
|
|
141
|
+
(legacy / "manifest.json").read_text(encoding="utf-8")
|
|
142
|
+
)
|
|
143
|
+
if engine_major(data["version"]) == major:
|
|
144
|
+
runtime_home = legacy
|
|
145
|
+
except (OSError, ValueError, KeyError):
|
|
146
|
+
pass
|
|
147
|
+
if runtime_home is None:
|
|
148
|
+
if major not in RUNTIME_RELEASES:
|
|
149
|
+
raise RuntimeCompatibilityError(
|
|
150
|
+
f"Firefox {major} has no published runtime in this catalog; available: {', '.join(RUNTIME_RELEASES)}"
|
|
151
|
+
)
|
|
152
|
+
release = select_release(version, product="network")
|
|
153
|
+
base = Path(
|
|
154
|
+
os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local"
|
|
155
|
+
)
|
|
156
|
+
runtime_home = base / "WPR" / "network-runtimes" / f"firefox{major}" / release["version"]
|
|
157
|
+
return RuntimeManifest.load(runtime_home, version)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "wpr.runtime-catalog.v2",
|
|
3
|
+
"client_api": 1,
|
|
4
|
+
"engines": {
|
|
5
|
+
"firefox155": {
|
|
6
|
+
"default_build": "155.0.6",
|
|
7
|
+
"builds": {
|
|
8
|
+
"155.0.6": {
|
|
9
|
+
"version": "155.0.6",
|
|
10
|
+
"firefox_major": 155,
|
|
11
|
+
"protocol": "wpr-http-v1",
|
|
12
|
+
"client_api_min": 1,
|
|
13
|
+
"client_api_max": 1,
|
|
14
|
+
"platform": "windows-x86_64",
|
|
15
|
+
"products": {
|
|
16
|
+
"browser": {
|
|
17
|
+
"product": "wpr-runtime",
|
|
18
|
+
"repository": "childclm/gecko-web-runtime",
|
|
19
|
+
"tag": "v155.0.6",
|
|
20
|
+
"asset": "wpr-runtime-155-win64.zip",
|
|
21
|
+
"url": "https://github.com/childclm/gecko-web-runtime/releases/download/v155.0.6/wpr-runtime-155-win64.zip",
|
|
22
|
+
"sha256": "4830a4e7b56292892261e67ed8079c43631bb542c847b17e09c0989c9ef6d5dc"
|
|
23
|
+
},
|
|
24
|
+
"network": {
|
|
25
|
+
"product": "wpr-network-runtime",
|
|
26
|
+
"repository": "childclm/gecko-web-runtime",
|
|
27
|
+
"tag": "network-155.0.6",
|
|
28
|
+
"asset": "wpr-network-155.0.6-win64.zip",
|
|
29
|
+
"url": "https://github.com/childclm/gecko-web-runtime/releases/download/network-155.0.6/wpr-network-155.0.6-win64.zip",
|
|
30
|
+
"sha256": "7df04dff79ee942fb2d3180bb9b7d06b34708cb395c2d6a416c33fba1bd5bc80"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|