specrails-hub 1.61.2 → 1.61.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-hub",
3
- "version": "1.61.2",
3
+ "version": "1.61.4",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -7,39 +7,6 @@
7
7
  //
8
8
  // Screencast uses CDP (`Page.startScreencast`) since Playwright exposes no
9
9
  // screencast API; input + screenshot + DOM use Playwright's high-level page API.
10
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
- if (k2 === undefined) k2 = k;
12
- var desc = Object.getOwnPropertyDescriptor(m, k);
13
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
- desc = { enumerable: true, get: function() { return m[k]; } };
15
- }
16
- Object.defineProperty(o, k2, desc);
17
- }) : (function(o, m, k, k2) {
18
- if (k2 === undefined) k2 = k;
19
- o[k2] = m[k];
20
- }));
21
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
22
- Object.defineProperty(o, "default", { enumerable: true, value: v });
23
- }) : function(o, v) {
24
- o["default"] = v;
25
- });
26
- var __importStar = (this && this.__importStar) || (function () {
27
- var ownKeys = function(o) {
28
- ownKeys = Object.getOwnPropertyNames || function (o) {
29
- var ar = [];
30
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
31
- return ar;
32
- };
33
- return ownKeys(o);
34
- };
35
- return function (mod) {
36
- if (mod && mod.__esModule) return mod;
37
- var result = {};
38
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
39
- __setModuleDefault(result, mod);
40
- return result;
41
- };
42
- })();
43
10
  Object.defineProperty(exports, "__esModule", { value: true });
44
11
  exports.createPlaywrightLauncher = createPlaywrightLauncher;
45
12
  const chromium_resolver_1 = require("./chromium-resolver");
@@ -577,9 +544,14 @@ class PlaywrightContextHandle {
577
544
  */
578
545
  function createPlaywrightLauncher() {
579
546
  return async (opts) => {
547
+ // Use CommonJS require (NOT dynamic import()): in the packaged pkg sidecar the
548
+ // native-addon banner redirects require('playwright') to the externally-copied
549
+ // package via _Module._load. A dynamic import() goes through Node's ESM resolver
550
+ // instead, which can't find 'playwright' inside the /snapshot pkg vfs
551
+ // ("Cannot find package 'playwright'"). require keeps it on the patched path.
580
552
  // eslint-disable-next-line @typescript-eslint/no-require-imports
581
- const { chromium } = (await Promise.resolve().then(() => __importStar(require('playwright'))));
582
- const executablePath = opts.executablePath ?? (0, chromium_resolver_1.resolveBundledChromiumPath)() ?? undefined;
553
+ const { chromium } = require('playwright');
554
+ const executablePath = opts.executablePath ?? (await (0, chromium_resolver_1.resolveBundledChromiumExecutable)()) ?? undefined;
583
555
  const context = await chromium.launchPersistentContext(opts.userDataDir, {
584
556
  headless: true,
585
557
  executablePath,
@@ -5,23 +5,47 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.discoverChromiumExecutable = discoverChromiumExecutable;
7
7
  exports.resolveBundledChromiumPath = resolveBundledChromiumPath;
8
+ exports.resolveBundledChromiumExecutable = resolveBundledChromiumExecutable;
8
9
  const fs_1 = __importDefault(require("fs"));
10
+ const os_1 = __importDefault(require("os"));
9
11
  const path_1 = __importDefault(require("path"));
12
+ const child_process_1 = require("child_process");
13
+ const stream_1 = require("stream");
14
+ const promises_1 = require("stream/promises");
10
15
  /**
11
16
  * Resolve the Chromium executable the browser-capture feature should launch.
12
17
  *
13
- * Mirrors the bundled-runtime resolution used by `path-resolver.ts` for node/git:
14
- * in desktop mode (`SPECRAILS_IS_DESKTOP=1`) we DISCOVER a bundled Chromium shipped
15
- * under `<runtimes>/chromium/` (declared in tauri.conf.json via the `runtimes/**`
16
- * glob, codesigned in CI before `tauri build`). When no bundle is present (dev, a
17
- * runtimes-less build, or a partial extraction) we return `null` so Playwright
18
- * falls back to its own managed browser never dead-ending the feature.
18
+ * In desktop mode (`SPECRAILS_IS_DESKTOP=1`) we ship Chromium INSIDE the app, but
19
+ * NOT as an unpacked directory: Tauri's resource bundler dereferences symlinks when
20
+ * it copies `bundle.resources` into the .app (tauri-apps/tauri#13219), which mangles
21
+ * Chromium's versioned `*.framework` (the `Versions/Current` + top-level symlinks
22
+ * become flat duplicate copies) and invalidates its code signature macOS
23
+ * notarization then rejects the app ("The signature of the binary is invalid").
19
24
  *
20
- * We DISCOVER rather than hard-code the path because Playwright's layout changes
21
- * across versions (e.g. `chrome-mac/Chromium.app` → `chrome-mac-arm64/Google Chrome
22
- * for Testing.app`, `chrome-win/chrome.exe`, `chrome-linux/chrome`). The CI assembly
23
- * copies Playwright's platform folder verbatim under `<runtimes>/chromium/`; this
24
- * walks that tree to find the real executable.
25
+ * So we ship Chromium as a single OPAQUE, OBFUSCATED blob
26
+ * (`<runtimes>/chromium/chromium.pak`):
27
+ * - It is an XOR-transformed `chromium.tar.gz`. The notarization service recursively
28
+ * unpacks archives it recognises (.zip .tar.gz .tar → …) and validates every
29
+ * Mach-O inside; Chromium's ~50 nested binaries are only ad-hoc ("linker") signed
30
+ * by Google, so a plain archive fails notarization. XOR-ing breaks the gzip/tar
31
+ * magic, so the notary cannot identify the file as a container and treats it as
32
+ * opaque data — nothing inside is inspected, and the app notarizes with NO
33
+ * Developer-ID signing of Chromium. (Obfuscation, not security: the key is public.)
34
+ * - Tauri also copies it as one regular file — nothing to dereference.
35
+ * - At first use we reverse the XOR and extract it to a writable cache
36
+ * (`~/.specrails/runtimes/chromium`), restoring the framework symlinks intact. The
37
+ * extracted Chromium keeps Google's ad-hoc signature, which is sufficient to execute
38
+ * on Apple Silicon, and — being self-extracted rather than downloaded — carries no
39
+ * `com.apple.quarantine` xattr, so Gatekeeper does not block it.
40
+ *
41
+ * A plain `chromium.tar.gz`/`chromium.tar` is also accepted (e.g. unobfuscated local
42
+ * builds). When no archive is present (dev, a runtimes-less build, a partial
43
+ * extraction) we fall back to discovering an UNPACKED `<runtimes>/chromium` tree, and
44
+ * finally to `null` so Playwright uses its own managed browser — never dead-ending.
45
+ *
46
+ * We DISCOVER rather than hard-code the executable path because Playwright's layout
47
+ * changes across versions (`chrome-mac/Chromium.app` → `chrome-mac-arm64/Google Chrome
48
+ * for Testing.app`, `chrome-win/chrome.exe`, `chrome-linux/chrome`).
25
49
  */
26
50
  const MAX_DEPTH = 6;
27
51
  function isFile(p) {
@@ -109,8 +133,13 @@ function discoverChromiumExecutable(chromiumRoot) {
109
133
  return findFirstFile(chromiumRoot, (n) => n === 'chrome' || n === 'chromium' || n === 'chrome-wrapper');
110
134
  }
111
135
  /**
112
- * Returns the absolute path to the bundled Chromium binary, or `null` when no
113
- * bundle is present (so Playwright uses its managed browser). Never throws.
136
+ * Returns the absolute path to an UNPACKED bundled Chromium binary, or `null`.
137
+ *
138
+ * This is the synchronous, no-extraction path: it only inspects a chromium tree that
139
+ * already exists on disk under `<runtimes>/chromium`. Prefer the async
140
+ * `resolveBundledChromiumExecutable()` for the launch path — it additionally extracts
141
+ * the shipped `chromium.tar.gz` archive. Kept for the unpacked fallback (local builds)
142
+ * and never throws.
114
143
  */
115
144
  function resolveBundledChromiumPath() {
116
145
  if (process.env.SPECRAILS_IS_DESKTOP !== '1')
@@ -125,3 +154,155 @@ function resolveBundledChromiumPath() {
125
154
  return null;
126
155
  }
127
156
  }
157
+ /** Candidate archive names under `<runtimes>/chromium`, in preference order. */
158
+ const ARCHIVE_NAMES = ['chromium.pak', 'chromium.tar.gz', 'chromium.tar'];
159
+ // XOR key for the obfuscated `chromium.pak` blob. Keep byte-identical to KEY in
160
+ // scripts/obfuscate-chromium.mjs — the round-trip is covered by a unit test.
161
+ const OBFUSCATION_KEY = Buffer.from('specrails-hub-chromium-pack-v1', 'utf8');
162
+ /** Streaming XOR transform (symmetric: packs and unpacks). */
163
+ function xorStream() {
164
+ let offset = 0;
165
+ return new stream_1.Transform({
166
+ transform(chunk, _enc, cb) {
167
+ const out = Buffer.allocUnsafe(chunk.length);
168
+ for (let i = 0; i < chunk.length; i++) {
169
+ out[i] = chunk[i] ^ OBFUSCATION_KEY[(offset + i) % OBFUSCATION_KEY.length];
170
+ }
171
+ offset += chunk.length;
172
+ cb(null, out);
173
+ },
174
+ });
175
+ }
176
+ /** De-obfuscate a `.pak` blob into a real `.tar.gz` at `outPath`. */
177
+ async function deobfuscate(pakPath, outPath) {
178
+ await (0, promises_1.pipeline)(fs_1.default.createReadStream(pakPath), xorStream(), fs_1.default.createWriteStream(outPath));
179
+ }
180
+ /** Writable extraction destination (overridable for tests via env). */
181
+ function chromiumCacheDir() {
182
+ return (process.env.SPECRAILS_CHROMIUM_CACHE_DIR ||
183
+ path_1.default.join(os_1.default.homedir(), '.specrails', 'runtimes', 'chromium'));
184
+ }
185
+ /** Identity string for an archive (size:mtime) used to skip re-extraction. */
186
+ function archiveIdentity(archivePath) {
187
+ const st = fs_1.default.statSync(archivePath);
188
+ return `${st.size}:${Math.round(st.mtimeMs)}`;
189
+ }
190
+ /** Resolve the platform `tar` binary. macOS/Linux ship `/usr/bin/tar`; Windows 10+ ships `tar` (bsdtar) on PATH. */
191
+ function tarBinary() {
192
+ if (process.platform !== 'win32' && isFile('/usr/bin/tar'))
193
+ return '/usr/bin/tar';
194
+ return 'tar';
195
+ }
196
+ /** Extract `archivePath` into `destDir` using the system tar (auto-detects gzip). */
197
+ function runTarExtract(archivePath, destDir) {
198
+ return new Promise((resolve, reject) => {
199
+ const child = (0, child_process_1.spawn)(tarBinary(), ['-xf', archivePath, '-C', destDir], { stdio: ['ignore', 'ignore', 'pipe'] });
200
+ let stderr = '';
201
+ child.stderr?.on('data', (d) => { stderr += d.toString(); });
202
+ child.on('error', reject);
203
+ child.on('close', (code) => {
204
+ if (code === 0)
205
+ resolve();
206
+ else
207
+ reject(new Error(`tar exited ${code}: ${stderr.trim().slice(0, 500)}`));
208
+ });
209
+ });
210
+ }
211
+ // In-process single-slot cache so concurrent / StrictMode double-invokes share one
212
+ // extraction instead of racing. Keyed by archive identity; invalidated if it changes.
213
+ let _extractInflight = null;
214
+ /** First archive that exists under `<runtimes>/chromium`, or null. */
215
+ function findBundledArchive(runtimesPath) {
216
+ for (const name of ARCHIVE_NAMES) {
217
+ const p = path_1.default.join(runtimesPath, 'chromium', name);
218
+ if (isFile(p))
219
+ return p;
220
+ }
221
+ return null;
222
+ }
223
+ async function extractAndDiscover(archivePath, identity) {
224
+ const destRoot = chromiumCacheDir();
225
+ const marker = path_1.default.join(destRoot, '.source');
226
+ // Fast path: already extracted from this exact archive.
227
+ try {
228
+ if (fs_1.default.readFileSync(marker, 'utf8') === identity) {
229
+ const exe = discoverChromiumExecutable(destRoot);
230
+ if (exe)
231
+ return exe;
232
+ }
233
+ }
234
+ catch { /* not yet extracted / stale → fall through */ }
235
+ // Extract to a temp sibling, then atomically swap in.
236
+ const tmpDir = `${destRoot}.tmp-${process.pid}`;
237
+ fs_1.default.rmSync(tmpDir, { recursive: true, force: true });
238
+ fs_1.default.mkdirSync(tmpDir, { recursive: true });
239
+ // An obfuscated `.pak` is XOR-decoded to a real `.tar.gz` first; plain archives
240
+ // are fed straight to tar.
241
+ const isPak = archivePath.endsWith('.pak');
242
+ const decodedTar = isPak ? `${tmpDir}.tar.gz` : null;
243
+ try {
244
+ let tarSource = archivePath;
245
+ if (decodedTar) {
246
+ await deobfuscate(archivePath, decodedTar);
247
+ tarSource = decodedTar;
248
+ }
249
+ await runTarExtract(tarSource, tmpDir);
250
+ const exeInTmp = discoverChromiumExecutable(tmpDir);
251
+ if (!exeInTmp)
252
+ throw new Error('no chromium executable found after extraction');
253
+ try {
254
+ fs_1.default.chmodSync(exeInTmp, 0o755);
255
+ }
256
+ catch { /* perms best-effort */ }
257
+ fs_1.default.rmSync(destRoot, { recursive: true, force: true });
258
+ fs_1.default.mkdirSync(path_1.default.dirname(destRoot), { recursive: true });
259
+ fs_1.default.renameSync(tmpDir, destRoot);
260
+ fs_1.default.writeFileSync(marker, identity);
261
+ return discoverChromiumExecutable(destRoot);
262
+ }
263
+ finally {
264
+ fs_1.default.rmSync(tmpDir, { recursive: true, force: true });
265
+ if (decodedTar)
266
+ fs_1.default.rmSync(decodedTar, { force: true });
267
+ }
268
+ }
269
+ /**
270
+ * Resolve the bundled Chromium executable for the launch path, extracting the
271
+ * shipped archive on first use. Returns `null` (never throws) when not in desktop
272
+ * mode, when no bundle is present, or when extraction fails — so Playwright falls
273
+ * back to its managed browser rather than dead-ending.
274
+ */
275
+ async function resolveBundledChromiumExecutable() {
276
+ if (process.env.SPECRAILS_IS_DESKTOP !== '1')
277
+ return null;
278
+ const runtimesPath = process.env.SPECRAILS_BUNDLED_RUNTIMES_PATH;
279
+ if (!runtimesPath)
280
+ return null;
281
+ let archivePath = null;
282
+ try {
283
+ archivePath = findBundledArchive(runtimesPath);
284
+ }
285
+ catch {
286
+ archivePath = null;
287
+ }
288
+ // No archive shipped → fall back to an unpacked tree (local/dev builds).
289
+ if (!archivePath)
290
+ return resolveBundledChromiumPath();
291
+ let identity;
292
+ try {
293
+ identity = archiveIdentity(archivePath);
294
+ }
295
+ catch {
296
+ return resolveBundledChromiumPath();
297
+ }
298
+ if (_extractInflight && _extractInflight.identity === identity) {
299
+ return _extractInflight.promise;
300
+ }
301
+ const promise = extractAndDiscover(archivePath, identity).catch((err) => {
302
+ console.error('[chromium-resolver] extraction failed:', err instanceof Error ? err.message : err);
303
+ _extractInflight = null; // allow a later retry
304
+ return resolveBundledChromiumPath();
305
+ });
306
+ _extractInflight = { identity, promise };
307
+ return promise;
308
+ }