dsh-ios-webview-app-control 0.0.0-stage → 0.2.2

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +250 -2
  4. package/THIRD_PARTY_NOTICES.md +42 -0
  5. package/cordis.patch.yml +20 -0
  6. package/docs/installation.md +68 -0
  7. package/docs/new-session-prompt.md +29 -0
  8. package/docs/runtime-environment.md +40 -0
  9. package/docs/sdk-contract.md +79 -0
  10. package/docs/verification.md +64 -0
  11. package/icon.svg +8 -0
  12. package/lib/cli.mjs +51 -0
  13. package/lib/client.js +223 -0
  14. package/lib/core/dom.mjs +26 -0
  15. package/lib/core/leases.mjs +31 -0
  16. package/lib/core/protocol.mjs +101 -0
  17. package/lib/core/python.mjs +47 -0
  18. package/lib/core/service.mjs +122 -0
  19. package/lib/host/environment-service.mjs +175 -0
  20. package/lib/host/subprocess-runner.mjs +34 -0
  21. package/lib/plugin.mjs +303 -0
  22. package/lib/runtime/environment-manager.mjs +544 -0
  23. package/lib/runtime/errors.mjs +33 -0
  24. package/lib/runtime/runner.mjs +75 -0
  25. package/lib/runtime/safe-tar.mjs +179 -0
  26. package/lib/typert-environment.mjs +103 -0
  27. package/locale/en.json +6 -0
  28. package/locale/zh.json +6 -0
  29. package/package.json +72 -4
  30. package/requirements.txt +1 -0
  31. package/resources/notices/PBS-LICENSE.bdb.txt +126 -0
  32. package/resources/notices/PBS-LICENSE.bzip2.txt +37 -0
  33. package/resources/notices/PBS-LICENSE.cpython.txt +771 -0
  34. package/resources/notices/PBS-LICENSE.expat.txt +21 -0
  35. package/resources/notices/PBS-LICENSE.libX11.txt +942 -0
  36. package/resources/notices/PBS-LICENSE.libXau.txt +21 -0
  37. package/resources/notices/PBS-LICENSE.libedit.txt +29 -0
  38. package/resources/notices/PBS-LICENSE.libffi.txt +21 -0
  39. package/resources/notices/PBS-LICENSE.liblzma.txt +13 -0
  40. package/resources/notices/PBS-LICENSE.libuuid.txt +27 -0
  41. package/resources/notices/PBS-LICENSE.libxcb.txt +30 -0
  42. package/resources/notices/PBS-LICENSE.mpdecimal.txt +24 -0
  43. package/resources/notices/PBS-LICENSE.ncurses.txt +29 -0
  44. package/resources/notices/PBS-LICENSE.openssl-3.txt +177 -0
  45. package/resources/notices/PBS-LICENSE.sqlite.txt +23 -0
  46. package/resources/notices/PBS-LICENSE.tcl.txt +40 -0
  47. package/resources/notices/PBS-LICENSE.tix.txt +54 -0
  48. package/resources/notices/PBS-LICENSE.zlib-ng.txt +19 -0
  49. package/resources/notices/PBS-LICENSE.zlib.txt +21 -0
  50. package/resources/notices/PBS-LICENSE.zstd.txt +30 -0
  51. package/resources/notices/PYMOBILEDEVICE3-LICENSE.txt +674 -0
  52. package/resources/notices/PYTHON-LICENSE.txt +277 -0
  53. package/resources/notices/provenance.json +134 -0
  54. package/resources/runtime-lock.json +4252 -0
  55. package/scripts/manage-desktop.ps1 +56 -0
  56. package/scripts/package-validation.mjs +223 -0
  57. package/scripts/verify-package.mjs +13 -0
@@ -0,0 +1,544 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { copyFile, link, lstat, mkdir, open, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { EnvironmentError, EnvironmentNotReadyError, aborted, bounded, normalize } from './errors.mjs';
7
+ import { defaultRunner } from './runner.mjs';
8
+ import { extractSafeTarGz } from './safe-tar.mjs';
9
+
10
+ export { EnvironmentError, EnvironmentNotReadyError, defaultRunner };
11
+ const LOCK_URL = new URL('../../resources/runtime-lock.json', import.meta.url);
12
+ const PHASES = new Set(['unprepared', 'checking', 'downloading', 'creating', 'installing', 'verifying', 'ready', 'failed', 'cancelled']);
13
+ const BUSY_PHASES = new Set(['checking', 'downloading', 'creating', 'installing', 'verifying']);
14
+ const HASH = /^[a-f0-9]{64}$/;
15
+ const PYTHON_VERSION = /^3\.([0-9]+)(?:\.([0-9]+))?$/;
16
+ const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,250}$/;
17
+ const OWNER = 'dsh-ios-webview-app-control.runtime.v1';
18
+ const PROBE_MS = 10000;
19
+ const MAX_LOCK_BYTES = 2 * 1024 * 1024;
20
+ const MAX_ASSET_BYTES = 256 * 1024 * 1024;
21
+ const PROBE = 'import json,sys,struct;print(json.dumps({"implementation":sys.implementation.name,"version":"%d.%d.%d"%sys.version_info[:3],"bits":struct.calcsize("P")*8,"executable":sys.executable,"prefix":sys.prefix,"basePrefix":sys.base_prefix},separators=(",",":")))';
22
+ const HAS_VENV = 'import venv,ensurepip;print("venv-ok")';
23
+ const PACKAGE_CHECK = 'import importlib.metadata;print(importlib.metadata.version("pymobiledevice3"))';
24
+ const NATIVE_CHECK = 'import pymobiledevice3,cryptography,psutil,lzfse;from PIL import Image;from Crypto.Cipher import AES;print("native-imports-ok")';
25
+ const BOOTSTRAP = 'import sys;wheel=sys.argv.pop(1);sys.path.insert(0,wheel);from pip._internal.cli.main import main;raise SystemExit(main())';
26
+
27
+ const clone = value => JSON.parse(JSON.stringify(value));
28
+ const majorMinor = value => { const match = PYTHON_VERSION.exec(value || ''); return match ? `3.${Number(match[1])}` : null; };
29
+ const abiOf = value => { const version = majorMinor(value); return version ? 'cp' + version.replace('.', '') : null; };
30
+ const normalizedName = name => name.toLowerCase().replace(/[-_.]+/g, '-');
31
+ function inside(root, candidate) {
32
+ if (typeof candidate !== 'string' || !path.isAbsolute(candidate)) return false;
33
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
34
+ return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative);
35
+ }
36
+ function descriptor(asset, label, wheel = false) {
37
+ if (!asset || typeof asset !== 'object' || !FILE_NAME.test(asset.filename || '') || asset.filename.includes('..') || (wheel && !asset.filename.endsWith('.whl'))) throw new EnvironmentError('runtime_lock_invalid', `${label} has an unsafe filename.`);
38
+ let url;
39
+ try { url = new URL(asset.url); } catch { throw new EnvironmentError('runtime_lock_invalid', `${label} URL is invalid.`); }
40
+ if (url.protocol !== 'https:' || url.username || url.password || url.hash || !HASH.test(asset.sha256 || '') || !Number.isSafeInteger(asset.size) || asset.size < 1 || asset.size > MAX_ASSET_BYTES) throw new EnvironmentError('runtime_lock_invalid', `${label} must have a locked HTTPS URL, SHA-256 and bounded size.`);
41
+ return asset;
42
+ }
43
+ /** Strict schemaVersion 1 resource validation; an ABI is never selected without its entire wheel closure. */
44
+ export function validateRuntimeLock(input) {
45
+ const lock = clone(input);
46
+ if (!lock || lock.schemaVersion !== 1 || lock.platform !== 'win32' || lock.arch !== 'x64' || typeof lock.pymobiledevice3 !== 'string') throw new EnvironmentError('runtime_lock_invalid', 'runtime-lock.json must describe schemaVersion 1 win32 x64.');
47
+ if (lock.fallback?.version !== '3.13.18' || lock.fallback.rootDir !== 'python' || lock.fallback.executable !== 'python/python.exe') throw new EnvironmentError('runtime_lock_invalid', 'Fallback must be the fixed PBS 3.13.18 private runtime.');
48
+ descriptor(lock.fallback.archive, 'fallback.archive');
49
+ if (!lock.fallback.archive.filename.endsWith('.tar.gz')) throw new EnvironmentError('runtime_lock_invalid', 'Fallback archive must be tar.gz.');
50
+ if (!lock.environments || Array.isArray(lock.environments) || typeof lock.environments !== 'object') throw new EnvironmentError('runtime_lock_invalid', 'ABI environment locks are missing.');
51
+ for (const [abi, environment] of Object.entries(lock.environments)) {
52
+ if (!/^cp3[0-9]{1,2}$/.test(abi) || abi !== abiOf(environment?.python) || environment.python !== majorMinor(environment.python)) throw new EnvironmentError('runtime_lock_invalid', `ABI ${abi} is invalid.`);
53
+ descriptor(environment.pip, `${abi}.pip`, true);
54
+ if (environment.pip.name !== 'pip' || typeof environment.pip.version !== 'string' || !Array.isArray(environment.wheels) || !environment.wheels.length || !Array.isArray(environment.requirements)) throw new EnvironmentError('runtime_lock_invalid', `ABI ${abi} is incomplete.`);
55
+ const byName = new Map();
56
+ for (const wheel of environment.wheels) {
57
+ descriptor(wheel, `${abi}.wheel`, true);
58
+ if (typeof wheel.name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(wheel.name) || typeof wheel.version !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9.!+_-]*$/.test(wheel.version)) throw new EnvironmentError('runtime_lock_invalid', `ABI ${abi} wheel metadata is invalid.`);
59
+ const name = normalizedName(wheel.name);
60
+ if (byName.has(name)) throw new EnvironmentError('runtime_lock_invalid', `ABI ${abi} has duplicate package names.`);
61
+ byName.set(name, wheel);
62
+ }
63
+ const required = new Set();
64
+ for (const line of environment.requirements) {
65
+ const match = typeof line === 'string' && line.match(/^([A-Za-z0-9][A-Za-z0-9_.-]*)==([A-Za-z0-9][A-Za-z0-9.!+_-]*) --hash=sha256:([a-f0-9]{64})$/);
66
+ if (!match) throw new EnvironmentError('runtime_lock_invalid', `ABI ${abi} requirements must be exact single-wheel hash pins.`);
67
+ const name = normalizedName(match[1]);
68
+ const wheel = byName.get(name);
69
+ if (!wheel || wheel.version !== match[2] || wheel.sha256 !== match[3] || required.has(name)) throw new EnvironmentError('runtime_lock_invalid', `ABI ${abi} requirements do not match its wheel closure.`);
70
+ required.add(name);
71
+ }
72
+ if (required.size !== byName.size || byName.get('pymobiledevice3')?.version !== lock.pymobiledevice3) throw new EnvironmentError('runtime_lock_invalid', `ABI ${abi} does not completely lock pymobiledevice3.`);
73
+ }
74
+ if (!lock.environments.cp313) throw new EnvironmentError('runtime_lock_invalid', 'The fallback cp313 ABI lock is missing.');
75
+ return lock;
76
+ }
77
+ function parseProbe(stdout) {
78
+ const lines = String(stdout).trim().split(/\r?\n/);
79
+ for (let index = lines.length - 1; index >= 0; index--) { try { return JSON.parse(lines[index]); } catch {} }
80
+ return null;
81
+ }
82
+ function safeEnvironment(source, home, baseDir, venvDir) {
83
+ const env = {};
84
+ for (const name of ['SystemRoot', 'WINDIR', 'SYSTEMDRIVE', 'OS', 'PROCESSOR_ARCHITECTURE', 'NUMBER_OF_PROCESSORS']) {
85
+ const key = Object.keys(source).find(key => key.toLowerCase() === name.toLowerCase());
86
+ if (key && source[key] !== undefined) env[name] = String(source[key]);
87
+ }
88
+ const windows = env.SystemRoot || env.WINDIR || 'C:\\Windows';
89
+ env.PATH = [venvDir ? path.join(venvDir, 'Scripts') : null, baseDir, path.win32.join(windows, 'System32'), windows].filter(Boolean).join(';');
90
+ env.HOME = home;
91
+ env.USERPROFILE = home;
92
+ env.APPDATA = path.join(home, 'AppData', 'Roaming');
93
+ env.LOCALAPPDATA = path.join(home, 'AppData', 'Local');
94
+ env.TEMP = path.join(home, 'tmp');
95
+ env.TMP = env.TEMP;
96
+ env.PYTHONIOENCODING = 'utf-8';
97
+ env.PYTHONUTF8 = '1';
98
+ env.PYTHONNOUSERSITE = '1';
99
+ env.PIP_CACHE_DIR = path.join(home, 'pip-cache');
100
+ env.PIP_CONFIG_FILE = 'NUL';
101
+ env.PIP_NO_INPUT = '1';
102
+ env.PIP_DISABLE_PIP_VERSION_CHECK = '1';
103
+ return env;
104
+ }
105
+ async function fileHash(file) {
106
+ const hash = createHash('sha256');
107
+ let size = 0;
108
+ for await (const bytes of createReadStream(file)) { hash.update(bytes); size += bytes.length; }
109
+ return { sha256: hash.digest('hex'), size };
110
+ }
111
+ async function realFile(file) {
112
+ try { const info = await lstat(file); return info.isFile() && !info.isSymbolicLink() ? info : null; } catch { return null; }
113
+ }
114
+
115
+ /** Node-only environment provisioning. It never opens a bridge, discovers devices or installs a host runtime. */
116
+ export class EnvironmentManager {
117
+ constructor(config = {}, deps = {}) {
118
+ this.config = config;
119
+ this.deps = deps;
120
+ this.platform = deps.platform || process.platform;
121
+ this.arch = deps.arch || process.arch;
122
+ this.env = deps.env || process.env;
123
+ this.runner = deps.runner || defaultRunner;
124
+ this.fetch = deps.fetch || globalThis.fetch;
125
+ this.runtimeDir = path.resolve(deps.root || config.runtime?.directory || path.join(deps.home || os.homedir(), '.dsh-ios-webview-app-control', 'runtime'));
126
+ this.timeoutMs = config.runtime?.timeoutMs ?? 900000;
127
+ if (!Number.isSafeInteger(this.timeoutMs) || this.timeoutMs < 10000 || this.timeoutMs > 3600000) throw new EnvironmentError('invalid_config', 'runtime.timeoutMs must be 10000..3600000.');
128
+ this.offline = config.runtime?.offline === true;
129
+ this.lock = null;
130
+ this.lockDigest = null;
131
+ this.lockOwner = null;
132
+ this.listeners = new Set();
133
+ this.controller = null;
134
+ this.work = null;
135
+ this.reads = new Set();
136
+ this.disposed = false;
137
+ this.generation = null;
138
+ this.currentPython = null;
139
+ this.privateLogs = [];
140
+ this.snapshot = { phase: 'unprepared', busy: false, step: null, stage: null, progress: { completedBytes: 0, totalBytes: 0 }, bytes: { downloaded: 0, total: 0 }, python: { path: null, version: null, source: null, abi: null, provenance: null }, runtimeDir: this.runtimeDir, pymobiledevice3: null, error: null };
141
+ }
142
+ status() {
143
+ const state=clone(this.snapshot);
144
+ // Step and byte counters describe an in-flight preparation only. Once it
145
+ // settles they read like a stalled download, so a public status drops them.
146
+ if(BUSY_PHASES.has(state.phase)){
147
+ state.step=state.step||state.stage||state.phase;
148
+ state.progress=state.bytes.total>0?{completedBytes:state.bytes.downloaded,totalBytes:state.bytes.total}:null;
149
+ }else{
150
+ state.step='';
151
+ state.progress=null;
152
+ }
153
+ if(!state.python?.path||!state.python?.version||!['host','private'].includes(state.python?.source))state.python=null;
154
+ if(typeof state.pymobiledevice3!=='string')delete state.pymobiledevice3;
155
+ return state;
156
+ }
157
+ subscribe(fn) {
158
+ if (typeof fn !== 'function') throw new TypeError('subscribe requires a function.');
159
+ this.listeners.add(fn);
160
+ try { fn(this.status()); } catch {}
161
+ return () => this.listeners.delete(fn);
162
+ }
163
+ _state(patch) {
164
+ const next = { ...this.snapshot, ...patch, runtimeDir: this.runtimeDir };
165
+ if (!PHASES.has(next.phase)) throw new TypeError('Invalid runtime phase.');
166
+ next.busy = BUSY_PHASES.has(next.phase);
167
+ next.step = next.stage;
168
+ next.python = { ...this.snapshot.python, ...patch.python };
169
+ if (patch.bytes) next.bytes = { ...this.snapshot.bytes, ...patch.bytes };
170
+ next.progress = { completedBytes: next.bytes.downloaded, totalBytes: next.bytes.total };
171
+ if (next.error) next.error = { code: bounded(next.error.code, 80), message: bounded(next.error.message), stage: next.stage };
172
+ this.snapshot = next;
173
+ for (const listener of [...this.listeners]) { try { listener(this.status()); } catch {} }
174
+ }
175
+ async _resources() {
176
+ if (this.lock) return this.lock;
177
+ let value = this.deps.lock;
178
+ if (!value) {
179
+ let text;
180
+ try { text = await readFile(LOCK_URL, 'utf8'); } catch { throw new EnvironmentError('runtime_lock_missing', 'resources/runtime-lock.json is required before environment preparation.'); }
181
+ if (Buffer.byteLength(text) > MAX_LOCK_BYTES) throw new EnvironmentError('runtime_lock_invalid', 'Runtime lock exceeds its bounded size.');
182
+ try { value = JSON.parse(text); } catch { throw new EnvironmentError('runtime_lock_invalid', 'Runtime lock is not valid JSON.'); }
183
+ }
184
+ this.lock = validateRuntimeLock(value);
185
+ this.lockDigest = createHash('sha256').update(JSON.stringify(this.lock)).digest('hex');
186
+ this.snapshot.pymobiledevice3 = this.lock.pymobiledevice3;
187
+ return this.lock;
188
+ }
189
+ _supported() {
190
+ if (this.platform !== 'win32' || this.arch !== 'x64') throw new EnvironmentError('unsupported_platform', 'EnvironmentManager supports only win32 x64.');
191
+ }
192
+ async _home(home) {
193
+ await mkdir(path.join(home, 'tmp'), { recursive: true, mode: 0o700 });
194
+ await mkdir(path.join(home, 'pip-cache'), { recursive: true, mode: 0o700 });
195
+ }
196
+ async _run(executable, args, { signal, home = path.join(this.runtimeDir, '_setup'), generation, timeoutMs = this.timeoutMs, allowFailure = false } = {}) {
197
+ aborted(signal);
198
+ // -B also protects a host base whose stdlib happens not to have bytecode caches.
199
+ const argv = args[0] === '-I' && !args.includes('-B') ? ['-I', '-B', ...args.slice(1)] : args;
200
+ const outcome = await this.runner(executable, argv, { signal, timeoutMs, env: safeEnvironment(this.env, home, path.isAbsolute(executable) ? path.dirname(executable) : null, generation), cwd: path.resolve(generation || this.runtimeDir), onOutput: (stream, text) => { this.privateLogs.push({ stream, text: bounded(text, 1000) }); if (this.privateLogs.length > 40) this.privateLogs.shift(); } });
201
+ aborted(signal);
202
+ const result = { stdout: outcome?.stdout || '', stderr: outcome?.stderr || '', exitCode: outcome?.exitCode ?? 1 };
203
+ if (!allowFailure && result.exitCode !== 0) throw new EnvironmentError('runtime_command_failed', `Runtime ${this.snapshot.stage || 'command'} failed (exit ${result.exitCode}).`, { stderr: bounded(result.stderr), exitCode: result.exitCode });
204
+ return result;
205
+ }
206
+ async _acquireLock() {
207
+ const file = path.join(this.runtimeDir, 'prepare.lock');
208
+ const owner = { owner: OWNER, pid: process.pid, token: randomUUID(), createdAt: new Date().toISOString() };
209
+ try { await writeFile(file, JSON.stringify(owner), { flag: 'wx', mode: 0o600 }); } catch (error) {
210
+ if (error.code !== 'EEXIST') throw error;
211
+ throw new EnvironmentError('environment_busy', 'Another process owns the runtime preparation lock. Live or unverified owners are never displaced.');
212
+ }
213
+ this.lockOwner = owner;
214
+ }
215
+ async _releaseLock() {
216
+ if (!this.lockOwner) return;
217
+ const owner = this.lockOwner;
218
+ this.lockOwner = null;
219
+ const file = path.join(this.runtimeDir, 'prepare.lock');
220
+ try { const actual = JSON.parse(await readFile(file, 'utf8')); if (actual.token === owner.token && actual.pid === owner.pid) await rm(file); } catch {}
221
+ }
222
+ async _fingerprint(executable, version) {
223
+ const info = await realFile(executable);
224
+ return info ? { path: path.resolve(executable), size: info.size, mtimeMs: info.mtimeMs, version } : null;
225
+ }
226
+ async _probe(executable, provenance, lock, signal, venv = false) {
227
+ if (!path.isAbsolute(executable) || /[/\\]WindowsApps(?:[/\\]|$)/i.test(executable) || !await realFile(executable)) return null;
228
+ let result;
229
+ try { result = await this._run(executable, ['-I', '-c', PROBE], { signal, timeoutMs: PROBE_MS, allowFailure: true }); } catch (error) { aborted(signal); return null; }
230
+ const data = result.exitCode === 0 && parseProbe(result.stdout);
231
+ const abi = abiOf(data?.version);
232
+ if (!data || data.implementation !== 'cpython' || data.bits !== 64 || !abi || !lock.environments[abi] || typeof data.prefix !== 'string' || typeof data.basePrefix !== 'string') return null;
233
+ if (venv && (path.resolve(data.prefix) === path.resolve(data.basePrefix) || !inside(this.runtimeDir, data.prefix))) return null;
234
+ try { if (!venv && (await this._run(executable, ['-I', '-c', HAS_VENV], { signal, timeoutMs: PROBE_MS, allowFailure: true })).exitCode !== 0) return null; } catch { aborted(signal); return null; }
235
+ return { executable: path.resolve(executable), version: data.version, abi, source: 'host', provenance, fingerprint: await this._fingerprint(executable, data.version), environment: lock.environments[abi] };
236
+ }
237
+ async *_candidates(signal) {
238
+ const seen = new Set();
239
+ const entry = (value, provenance) => {
240
+ if (typeof value !== 'string' || !path.isAbsolute(value) || /[/\\]WindowsApps(?:[/\\]|$)/i.test(value)) return null;
241
+ const key = path.resolve(value).toLowerCase();
242
+ if (seen.has(key)) return null;
243
+ seen.add(key);
244
+ return { executable: path.resolve(value), provenance };
245
+ };
246
+ for (const [value, provenance] of [[this.config.pythonPath, 'config'], [this.env.DSH_IOS_PYTHON, 'DSH_IOS_PYTHON'], [typeof this.env.DSH_PRIMARY_RUNTIME === 'string' && path.isAbsolute(this.env.DSH_PRIMARY_RUNTIME) ? path.join(this.env.DSH_PRIMARY_RUNTIME, 'dependencies', 'python', 'python.exe') : null, 'DSH_PRIMARY_RUNTIME']]) {
247
+ const candidate = entry(value, provenance); if (candidate) yield candidate;
248
+ }
249
+ const sourcePath = String(this.env.PATH || this.env.Path || '').split(';').filter(value => value && path.isAbsolute(value) && !/[/\\]WindowsApps(?:[/\\]|$)/i.test(value));
250
+ const windows = this.env.SystemRoot || this.env.WINDIR || 'C:\\Windows';
251
+ const launchers = [path.join(windows, 'py.exe'), ...sourcePath.map(directory => path.join(directory, 'py.exe'))];
252
+ for (const launcher of [...new Set(launchers)]) {
253
+ if (!await realFile(launcher)) continue;
254
+ try {
255
+ const result = await this._run(launcher, ['-0p'], { signal, timeoutMs: PROBE_MS, allowFailure: true });
256
+ for (const line of result.stdout.split(/\r?\n/)) {
257
+ const match = line.match(/([A-Za-z]:[\\/].*?\.exe)\s*$/i);
258
+ const candidate = match && entry(match[1], 'py-launcher'); if (candidate) yield candidate;
259
+ }
260
+ } catch { aborted(signal); }
261
+ }
262
+ const registry = path.win32.join(windows, 'System32', 'reg.exe');
263
+ for (const key of ['HKCU\\Software\\Python\\PythonCore', 'HKLM\\Software\\Python\\PythonCore', 'HKLM\\Software\\WOW6432Node\\Python\\PythonCore']) {
264
+ try {
265
+ const result = await this._run(registry, ['query', key, '/s'], { signal, timeoutMs: PROBE_MS, allowFailure: true });
266
+ let installPath = false;
267
+ for (const line of result.stdout.split(/\r?\n/)) {
268
+ if (/^HKEY_/i.test(line.trim())) { installPath = /\\InstallPath\s*$/i.test(line.trim()); continue; }
269
+ if (!installPath) continue;
270
+ const match = line.match(/^\s*(?:\(Default\)|ExecutablePath|默认)?\s+REG_(?:EXPAND_)?SZ\s+(.+?)\s*$/i);
271
+ if (!match || match[1].includes('%')) continue;
272
+ const value = /\.exe$/i.test(match[1]) ? match[1] : path.join(match[1], 'python.exe');
273
+ const candidate = entry(value, 'registry'); if (candidate) yield candidate;
274
+ }
275
+ } catch { aborted(signal); }
276
+ }
277
+ for (const directory of sourcePath) {
278
+ for (const name of ['python.exe', 'python3.exe']) { const candidate = entry(path.join(directory, name), 'PATH'); if (candidate) yield candidate; }
279
+ }
280
+ }
281
+ _assetPath(asset, archive = false) { return path.join(this.runtimeDir, 'cache', archive ? 'archives' : 'wheels', asset.sha256, asset.filename); }
282
+ _bytes(amount) { this._state({ bytes: { downloaded: Math.min(this.snapshot.bytes.total, this.snapshot.bytes.downloaded + amount) } }); }
283
+ async _fetchAsset(asset, archive, signal) {
284
+ const file = this._assetPath(asset, archive);
285
+ if (await realFile(file)) {
286
+ const existing = await fileHash(file);
287
+ if (existing.sha256 === asset.sha256 && existing.size === asset.size) { this._bytes(asset.size); return file; }
288
+ await rm(file);
289
+ }
290
+ if (this.offline) throw new EnvironmentError('runtime_offline', 'Offline preparation requires every selected locked asset in the validated local cache.');
291
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
292
+ this._state({ phase: 'downloading', stage: archive ? 'python-archive' : 'wheels' });
293
+ const original = new URL(asset.url);
294
+ let url = original;
295
+ let response;
296
+ const redirectHosts = new Set([original.hostname]);
297
+ if (original.hostname === 'github.com') for (const name of ['release-assets.githubusercontent.com', 'objects.githubusercontent.com', 'github-releases.githubusercontent.com']) redirectHosts.add(name);
298
+ for (let redirects = 0; redirects <= 5; redirects++) {
299
+ response = await this.fetch(url.href, { signal, redirect: 'manual' });
300
+ if (![301, 302, 303, 307, 308].includes(response.status)) break;
301
+ const location = response.headers.get('location');
302
+ const next = location && new URL(location, url);
303
+ await response.body?.cancel?.();
304
+ if (redirects === 5 || !next || next.protocol !== 'https:' || next.username || next.password || !redirectHosts.has(next.hostname)) throw new EnvironmentError('runtime_download_redirect', 'Locked download redirected outside its allowed HTTPS asset hosts.');
305
+ url = next;
306
+ }
307
+ if (!response?.ok || !response.body?.getReader) throw new EnvironmentError('runtime_download_failed', `Locked asset download returned HTTP ${response?.status ?? 'unknown'}.`);
308
+ const advertised = response.headers.get('content-length');
309
+ if (advertised && Number(advertised) !== asset.size) { await response.body.cancel(); throw new EnvironmentError('runtime_integrity', 'Locked asset Content-Length does not match.'); }
310
+ const temporary = file + '.part-' + randomUUID();
311
+ const handle = await open(temporary, 'wx', 0o600);
312
+ const reader = response.body.getReader();
313
+ const hash = createHash('sha256');
314
+ let bytes = 0;
315
+ try {
316
+ for (;;) {
317
+ aborted(signal);
318
+ const next = await reader.read();
319
+ if (next.done) break;
320
+ const chunk = Buffer.from(next.value);
321
+ bytes += chunk.length;
322
+ if (bytes > asset.size) throw new EnvironmentError('runtime_integrity', 'Locked asset exceeds its declared size.');
323
+ hash.update(chunk);
324
+ await handle.writeFile(chunk);
325
+ this._bytes(chunk.length);
326
+ }
327
+ if (bytes !== asset.size || hash.digest('hex') !== asset.sha256) throw new EnvironmentError('runtime_integrity', 'Locked asset size or SHA-256 differs from runtime-lock.json.');
328
+ await handle.sync();
329
+ await handle.close();
330
+ await rename(temporary, file);
331
+ return file;
332
+ } catch (error) {
333
+ await handle.close().catch(() => {});
334
+ await rm(temporary, { force: true }).catch(() => {});
335
+ throw normalize(error, signal, 'runtime_download_failed');
336
+ } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
337
+ }
338
+ async _base(lock, signal, forcePrivate) {
339
+ if (!forcePrivate) for await (const candidate of this._candidates(signal)) {
340
+ aborted(signal);
341
+ const base = await this._probe(candidate.executable, candidate.provenance, lock, signal);
342
+ if (base) return base;
343
+ }
344
+ const archive = lock.fallback.archive;
345
+ this._state({ bytes: { downloaded: 0, total: archive.size + lock.environments.cp313.pip.size + lock.environments.cp313.wheels.reduce((sum, wheel) => sum + wheel.size, 0) }, python: { source: 'private', version: lock.fallback.version, abi: 'cp313', provenance: 'PBS' } });
346
+ const file = await this._fetchAsset(archive, true, signal);
347
+ const baseDirectory = path.join(this.runtimeDir, 'bases', `b-${randomUUID()}`);
348
+ await mkdir(baseDirectory, { recursive: true, mode: 0o700 });
349
+ await writeFile(path.join(baseDirectory, 'owner.json'), JSON.stringify({ owner: OWNER, archive: archive.sha256 }), { flag: 'wx', mode: 0o600 });
350
+ this._state({ phase: 'creating', stage: 'extracting-python' });
351
+ await extractSafeTarGz(await readFile(file), baseDirectory, { signal });
352
+ const executable = path.join(baseDirectory, ...lock.fallback.executable.split('/'));
353
+ const probe = await this._probe(executable, 'PBS', lock, signal);
354
+ if (!probe || probe.version !== lock.fallback.version) throw new EnvironmentError('runtime_verify_failed', 'The PBS runtime did not verify as locked CPython 3.13.18 x64.');
355
+ return { ...probe, source: 'private', baseDirectory, archiveSha256: archive.sha256 };
356
+ }
357
+ async _rawPointer() {
358
+ try {
359
+ const file = await readFile(path.join(this.runtimeDir, 'ready.json'), 'utf8');
360
+ if (file.length > 8192) return null;
361
+ const pointer = JSON.parse(file);
362
+ if (pointer?.owner !== OWNER || pointer.schemaVersion !== 1 || !inside(path.join(this.runtimeDir, 'generations'), pointer.generation) || pointer.interpreter !== path.join(pointer.generation, 'Scripts', 'python.exe') || !await realFile(pointer.interpreter)) return null;
363
+ return pointer;
364
+ } catch { return null; }
365
+ }
366
+ async _ready(lock, signal) {
367
+ const pointer = await this._rawPointer();
368
+ if (!pointer || pointer.lockDigest !== this.lockDigest || !lock.environments[pointer.python?.abi]) return null;
369
+ if (!pointer.base?.fingerprint || !path.isAbsolute(pointer.base.executable || '')) return null;
370
+ const fingerprint = await this._fingerprint(pointer.base.executable, pointer.python.version);
371
+ if (JSON.stringify(fingerprint) !== JSON.stringify(pointer.base.fingerprint)) return null;
372
+ if (pointer.base.source === 'private' && (!inside(path.join(this.runtimeDir, 'bases'), pointer.base.executable) || pointer.base.archiveSha256 !== lock.fallback.archive.sha256)) return null;
373
+ const probe = await this._probe(pointer.interpreter, 'ready', lock, signal, true);
374
+ if (!probe || probe.abi !== pointer.python.abi || probe.version !== pointer.python.version) return null;
375
+ try {
376
+ const result = await this._run(pointer.interpreter, ['-I', '-c', PACKAGE_CHECK], { signal, timeoutMs: PROBE_MS, home: path.join(pointer.generation, '_home'), generation: pointer.generation, allowFailure: true });
377
+ if (result.exitCode !== 0 || result.stdout.trim() !== lock.pymobiledevice3) return null;
378
+ } catch { aborted(signal); return null; }
379
+ return pointer;
380
+ }
381
+ async _wheelhouse(assets, generation, signal) {
382
+ const wheelhouse = path.join(generation, 'wheelhouse');
383
+ await mkdir(wheelhouse, { mode: 0o700 });
384
+ const files = new Map();
385
+ for (const asset of assets) {
386
+ aborted(signal);
387
+ const cached = await this._fetchAsset(asset, false, signal);
388
+ const target = path.join(wheelhouse, asset.filename);
389
+ if (!files.has(asset.filename)) {
390
+ try { await link(cached, target); } catch { await copyFile(cached, target); }
391
+ files.set(asset.filename, target);
392
+ }
393
+ }
394
+ return { wheelhouse, files };
395
+ }
396
+ async _selfcheck(interpreter, generation, lock, signal) {
397
+ const options = { signal, generation, home: path.join(generation, '_home') };
398
+ const probe = await this._probe(interpreter, 'new-venv', lock, signal, true);
399
+ if (!probe) throw new EnvironmentError('runtime_verify_failed', 'New Python is not an isolated private venv.');
400
+ const cfg = await readFile(path.join(generation, 'pyvenv.cfg'), 'utf8');
401
+ if (!/^include-system-site-packages\s*=\s*false\s*$/mi.test(cfg)) throw new EnvironmentError('runtime_verify_failed', 'Private venv must not include system site packages.');
402
+ const version = await this._run(interpreter, ['-I', '-c', PACKAGE_CHECK], options);
403
+ if (version.stdout.trim() !== lock.pymobiledevice3) throw new EnvironmentError('runtime_verify_failed', 'Installed pymobiledevice3 does not match the lock.');
404
+ await this._run(interpreter, ['-I', '-m', 'pip', '--isolated', '--require-virtualenv', 'check'], options);
405
+ await this._run(interpreter, ['-I', '-c', NATIVE_CHECK], options);
406
+ await this._run(interpreter, ['-I', '-m', 'pymobiledevice3', '--help'], options);
407
+ return probe;
408
+ }
409
+ async _publish(pointer, signal) {
410
+ const target = path.join(this.runtimeDir, 'ready.json');
411
+ const temporary = target + '.tmp-' + randomUUID();
412
+ aborted(signal);
413
+ await writeFile(temporary, JSON.stringify(pointer), { flag: 'wx', mode: 0o600 });
414
+ try { aborted(signal); await rename(temporary, target); } finally { await rm(temporary, { force: true }).catch(() => {}); }
415
+ }
416
+ async _prepare(signal, forcePrivate) {
417
+ this._supported();
418
+ aborted(signal);
419
+ this._state({ phase: 'checking', stage: 'runtime-lock', error: null, bytes: { downloaded: 0, total: 0 } });
420
+ const lock = await this._resources();
421
+ await mkdir(this.runtimeDir, { recursive: true, mode: 0o700 });
422
+ await this._acquireLock();
423
+ try {
424
+ await this._home(path.join(this.runtimeDir, '_setup'));
425
+ this._state({ phase: 'verifying', stage: 'ready-pointer' });
426
+ const prior = await this._ready(lock, signal);
427
+ if (prior && (!forcePrivate || prior.base.source === 'private')) {
428
+ this.currentPython = prior.interpreter;
429
+ this._state({ phase: 'ready', stage: 'reused', python: prior.python, error: null });
430
+ return this.status();
431
+ }
432
+ this._state({ phase: 'checking', stage: 'host-python' });
433
+ const base = await this._base(lock, signal, forcePrivate);
434
+ const environment = base.environment;
435
+ if (base.source === 'host') this._state({ bytes: { downloaded: 0, total: environment.pip.size + environment.wheels.reduce((sum, wheel) => sum + wheel.size, 0) } });
436
+ const generation = path.join(this.runtimeDir, 'generations', `g-${Date.now()}-${randomUUID()}`);
437
+ this.generation = generation;
438
+ await mkdir(generation, { recursive: true, mode: 0o700 });
439
+ await writeFile(path.join(generation, 'owner.json'), JSON.stringify({ owner: OWNER, pid: process.pid, baseDirectory: base.baseDirectory || null }), { flag: 'wx', mode: 0o600 });
440
+ await this._home(path.join(generation, '_home'));
441
+ const python = { path: path.join(generation, 'Scripts', 'python.exe'), source: base.source, provenance: base.provenance, version: base.version, abi: base.abi };
442
+ this._state({ phase: 'downloading', stage: 'wheels', python });
443
+ const assets = [environment.pip, ...environment.wheels];
444
+ const { wheelhouse, files } = await this._wheelhouse(assets, generation, signal);
445
+ this._state({ phase: 'creating', stage: 'venv' });
446
+ const options = { signal, generation, home: path.join(generation, '_home') };
447
+ await this._run(base.executable, ['-I', '-m', 'venv', '--without-pip', generation], options);
448
+ if (!await realFile(python.path)) throw new EnvironmentError('runtime_create_failed', 'Python did not create the private venv interpreter.');
449
+ const venv = await this._probe(python.path, 'new-venv', lock, signal, true);
450
+ if (!venv || venv.abi !== base.abi) throw new EnvironmentError('runtime_verify_failed', 'Created interpreter is not the expected private venv.');
451
+ const pipWheel = files.get(environment.pip.filename);
452
+ this._state({ phase: 'installing', stage: 'pip-bootstrap' });
453
+ await this._run(python.path, ['-I', '-c', BOOTSTRAP, pipWheel, '--isolated', '--require-virtualenv', 'install', '--no-deps', '--no-index', '--no-user', '--no-compile', '--disable-pip-version-check', '--only-binary', ':all:', pipWheel], options);
454
+ const requirements = path.join(generation, 'requirements.txt');
455
+ await writeFile(requirements, environment.requirements.join('\n') + '\n', { flag: 'wx', mode: 0o600 });
456
+ this._state({ phase: 'installing', stage: 'packages' });
457
+ await this._run(python.path, ['-I', '-m', 'pip', '--isolated', '--require-virtualenv', 'install', '--no-index', '--find-links', wheelhouse, '--require-hashes', '--only-binary', ':all:', '--no-user', '--no-compile', '--disable-pip-version-check', '--no-input', '--no-cache-dir', '-r', requirements], options);
458
+ this._state({ phase: 'verifying', stage: 'self-check' });
459
+ await this._selfcheck(python.path, generation, lock, signal);
460
+ const pointer = { owner: OWNER, schemaVersion: 1, generation, interpreter: python.path, python, base: { executable: base.executable, source: base.source, fingerprint: base.fingerprint, baseDirectory: base.baseDirectory || null, archiveSha256: base.archiveSha256 || null }, lockDigest: this.lockDigest, preparedAt: new Date().toISOString() };
461
+ await this._publish(pointer, signal);
462
+ this.currentPython = python.path;
463
+ this._state({ phase: 'ready', stage: 'published', python, error: null });
464
+ return this.status();
465
+ } finally { this.generation = null; await this._releaseLock(); }
466
+ }
467
+ prepare({ signal, forcePrivate = false } = {}) {
468
+ if (this.disposed) return Promise.reject(new EnvironmentError('environment_disposed', 'EnvironmentManager has been disposed.'));
469
+ if (this.work) return this.work;
470
+ const controller = new AbortController();
471
+ this.controller = controller;
472
+ const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
473
+ const timeout = setTimeout(() => controller.abort(new EnvironmentError('runtime_timeout', 'Runtime preparation exceeded its deadline.')), this.timeoutMs);
474
+ this.work = this._prepare(combined, forcePrivate).catch(error => {
475
+ const failure = normalize(error, combined);
476
+ this._state({ phase: failure.code === 'cancelled' ? 'cancelled' : 'failed', error: failure });
477
+ throw failure;
478
+ }).finally(() => { clearTimeout(timeout); this.controller = null; this.work = null; });
479
+ return this.work;
480
+ }
481
+ cancel() { this.controller?.abort(new EnvironmentError('cancelled', 'Runtime preparation was cancelled.')); }
482
+ async requirePython() {
483
+ if (this.disposed) throw new EnvironmentNotReadyError('EnvironmentManager has been disposed.');
484
+ this._supported();
485
+ const controller = new AbortController();
486
+ const work = (async () => {
487
+ const lock = await this._resources();
488
+ const pointer = await this._ready(lock, controller.signal);
489
+ if (!pointer) throw new EnvironmentNotReadyError('Runtime is not ready; prepare it explicitly before device operations.');
490
+ this.currentPython = pointer.interpreter;
491
+ return pointer.interpreter;
492
+ })();
493
+ const record = { controller, work };
494
+ this.reads.add(record);
495
+ try { return await work; } catch (error) { if (error instanceof EnvironmentNotReadyError) throw error; throw new EnvironmentNotReadyError(bounded(error.message)); } finally { this.reads.delete(record); }
496
+ }
497
+ async cleanup({ signal } = {}) {
498
+ if (this.disposed) throw new EnvironmentError('environment_disposed', 'EnvironmentManager has been disposed.');
499
+ if (this.work) throw new EnvironmentError('environment_busy', 'Cannot clean runtime files while preparation is active.');
500
+ aborted(signal);
501
+ await mkdir(this.runtimeDir, { recursive: true, mode: 0o700 });
502
+ await this._acquireLock();
503
+ try {
504
+ const pointer = await this._rawPointer();
505
+ const generations = path.join(this.runtimeDir, 'generations');
506
+ const retainedBases = new Set(pointer?.base?.baseDirectory ? [pointer.base.baseDirectory] : []);
507
+ for (const entry of await readdir(generations, { withFileTypes: true }).catch(() => [])) {
508
+ aborted(signal);
509
+ const directory = path.join(generations, entry.name);
510
+ if (!entry.isDirectory() || entry.isSymbolicLink() || !/^g-[0-9]+-[a-f0-9-]+$/.test(entry.name)) continue;
511
+ let ownership;
512
+ try { ownership = JSON.parse(await readFile(path.join(directory, 'owner.json'), 'utf8')); } catch { continue; }
513
+ if (ownership.owner !== OWNER) continue;
514
+ if (directory === pointer?.generation) { if (ownership.baseDirectory) retainedBases.add(ownership.baseDirectory); continue; }
515
+ await rm(directory, { recursive: true });
516
+ }
517
+ const bases = path.join(this.runtimeDir, 'bases');
518
+ for (const entry of await readdir(bases, { withFileTypes: true }).catch(() => [])) {
519
+ aborted(signal);
520
+ const directory = path.join(bases, entry.name);
521
+ if (!entry.isDirectory() || entry.isSymbolicLink() || !/^b-[a-f0-9-]+$/.test(entry.name) || retainedBases.has(directory) || (pointer?.base?.executable && inside(directory, pointer.base.executable))) continue;
522
+ try { if (JSON.parse(await readFile(path.join(directory, 'owner.json'), 'utf8')).owner !== OWNER) continue; } catch { continue; }
523
+ await rm(directory, { recursive: true });
524
+ }
525
+ for (const kind of ['wheels', 'archives']) {
526
+ const cache = path.join(this.runtimeDir, 'cache', kind);
527
+ for (const entry of await readdir(cache, { withFileTypes: true }).catch(() => [])) {
528
+ aborted(signal);
529
+ if (entry.isDirectory() && !entry.isSymbolicLink() && HASH.test(entry.name)) await rm(path.join(cache, entry.name), { recursive: true });
530
+ }
531
+ }
532
+ return this.status();
533
+ } finally { await this._releaseLock(); }
534
+ }
535
+ async dispose() {
536
+ if (this.disposed) return;
537
+ this.disposed = true;
538
+ this.cancel();
539
+ for (const record of this.reads) record.controller.abort();
540
+ await Promise.allSettled([...(this.work ? [this.work] : []), ...[...this.reads].map(record => record.work)]);
541
+ this.listeners.clear();
542
+ }
543
+ }
544
+ export default EnvironmentManager;
@@ -0,0 +1,33 @@
1
+ export class EnvironmentError extends Error {
2
+ constructor(code, message, details = {}) {
3
+ super(message);
4
+ this.name = 'EnvironmentError';
5
+ this.code = code;
6
+ this.details = details;
7
+ }
8
+ }
9
+
10
+ export class EnvironmentNotReadyError extends EnvironmentError {
11
+ constructor(message = 'A ready private Python environment is not available.') {
12
+ super('environment_not_ready', message);
13
+ this.name = 'EnvironmentNotReadyError';
14
+ }
15
+ }
16
+
17
+ export function aborted(signal) {
18
+ if (signal?.aborted) throw new EnvironmentError(signal.reason?.code === 'runtime_timeout' ? 'runtime_timeout' : 'cancelled', signal.reason?.code === 'runtime_timeout' ? 'Runtime preparation exceeded its deadline.' : 'Runtime preparation was cancelled.');
19
+ }
20
+
21
+ export function bounded(value, limit = 512) {
22
+ const text = String(value ?? '');
23
+ return text.length > limit ? text.slice(0, limit) + '…' : text;
24
+ }
25
+
26
+ export function normalize(error, signal, fallback = 'environment_failed') {
27
+ if (signal?.aborted) {
28
+ try { aborted(signal); } catch (failure) { return failure; }
29
+ }
30
+ if (error instanceof EnvironmentError) return error;
31
+ if (error?.name === 'AbortError') return new EnvironmentError('cancelled', 'Runtime preparation was cancelled.');
32
+ return new EnvironmentError(fallback, bounded(error?.message || error || 'Runtime preparation failed.'));
33
+ }