specrails-hub 1.61.1 → 1.61.3

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.1",
3
+ "version": "1.61.3",
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,
@@ -3,31 +3,43 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.bundledChromiumCandidates = bundledChromiumCandidates;
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");
10
13
  /**
11
14
  * Resolve the Chromium executable the browser-capture feature should launch.
12
15
  *
13
- * Mirrors the bundled-runtime resolution used by `path-resolver.ts` for node/git:
14
- * in desktop mode (`SPECRAILS_IS_DESKTOP=1`) we existence-gate a bundled Chromium
15
- * shipped under `<runtimes>/chromium/` (declared in tauri.conf.json via the
16
- * `runtimes/**` glob, codesigned in CI before `tauri build`). When the bundled
17
- * binary is absent (dev, a runtimes-less build, or a partial extraction) we return
18
- * `null` so Playwright falls back to its own managed Chromium download — never
19
- * dead-ending the feature.
16
+ * In desktop mode (`SPECRAILS_IS_DESKTOP=1`) we ship Chromium INSIDE the app, but
17
+ * NOT as an unpacked directory: Tauri's resource bundler dereferences symlinks when
18
+ * it copies `bundle.resources` into the .app (tauri-apps/tauri#13219), which mangles
19
+ * Chromium's versioned `*.framework` (the `Versions/Current` + top-level symlinks
20
+ * become flat duplicate copies) and invalidates its code signature macOS
21
+ * notarization then rejects the app ("The signature of the binary is invalid").
20
22
  *
21
- * Layout (matches the CI assembly + smoke test):
22
- * macOS/Linux: runtimes/chromium/chrome-<plat>/Chromium.app/Contents/MacOS/Chromium
23
- * or runtimes/chromium/bin/chromium
24
- * Windows: runtimes/chromium/chrome-win/chrome.exe
25
- * or runtimes/chromium/bin/chromium.exe
23
+ * So we ship Chromium as a single OPAQUE archive (`<runtimes>/chromium/chromium.tar.gz`):
24
+ * - Tauri copies one regular file — nothing to dereference, and notarization treats
25
+ * archive bytes as opaque data (it never inspects Mach-O inside a .tar.gz), so the
26
+ * app notarizes cleanly with NO Developer-ID signing of Chromium required.
27
+ * - At first use we extract it to a writable cache (`~/.specrails/runtimes/chromium`),
28
+ * which restores the framework symlinks intact. The extracted Chromium keeps
29
+ * Google's original ad-hoc ("linker-signed") signature, which is sufficient to
30
+ * execute on Apple Silicon, and — being self-extracted rather than downloaded —
31
+ * carries no `com.apple.quarantine` xattr, so Gatekeeper does not block it.
26
32
  *
27
- * Multiple candidate paths are probed so the exact Playwright tarball layout
28
- * (which nests under chrome-<platform>/) and a flattened bin/ layout both work.
33
+ * When no archive is present (dev, a runtimes-less build, a partial extraction) we
34
+ * fall back to discovering an UNPACKED `<runtimes>/chromium` tree, and finally to
35
+ * `null` so Playwright uses its own managed browser — never dead-ending the feature.
36
+ *
37
+ * We DISCOVER rather than hard-code the executable path because Playwright's layout
38
+ * changes across versions (`chrome-mac/Chromium.app` → `chrome-mac-arm64/Google Chrome
39
+ * for Testing.app`, `chrome-win/chrome.exe`, `chrome-linux/chrome`).
29
40
  */
30
- function fileExists(p) {
41
+ const MAX_DEPTH = 6;
42
+ function isFile(p) {
31
43
  try {
32
44
  return fs_1.default.statSync(p).isFile();
33
45
  }
@@ -35,32 +47,90 @@ function fileExists(p) {
35
47
  return false;
36
48
  }
37
49
  }
38
- function bundledChromiumCandidates(runtimesPath) {
39
- const root = path_1.default.join(runtimesPath, 'chromium');
50
+ /** Depth-bounded search for the first file whose basename satisfies `match`. */
51
+ function findFirstFile(root, match, depth = 0) {
52
+ if (depth > MAX_DEPTH)
53
+ return null;
54
+ let entries;
55
+ try {
56
+ entries = fs_1.default.readdirSync(root, { withFileTypes: true });
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ // Files first (cheap), then recurse into dirs.
62
+ for (const e of entries) {
63
+ if (e.isFile() && match(e.name))
64
+ return path_1.default.join(root, e.name);
65
+ }
66
+ for (const e of entries) {
67
+ if (e.isDirectory()) {
68
+ const hit = findFirstFile(path_1.default.join(root, e.name), match, depth + 1);
69
+ if (hit)
70
+ return hit;
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+ /** On macOS: locate the main executable inside the first `*.app` under `root`. */
76
+ function findMacAppExecutable(root, depth = 0) {
77
+ if (depth > MAX_DEPTH)
78
+ return null;
79
+ let entries;
80
+ try {
81
+ entries = fs_1.default.readdirSync(root, { withFileTypes: true });
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ for (const e of entries) {
87
+ if (e.isDirectory() && e.name.endsWith('.app')) {
88
+ const macosDir = path_1.default.join(root, e.name, 'Contents', 'MacOS');
89
+ // The main binary is conventionally named like the app (sans ".app");
90
+ // fall back to the first regular file in MacOS/.
91
+ const preferred = path_1.default.join(macosDir, e.name.slice(0, -'.app'.length));
92
+ if (isFile(preferred))
93
+ return preferred;
94
+ try {
95
+ for (const inner of fs_1.default.readdirSync(macosDir, { withFileTypes: true })) {
96
+ if (inner.isFile())
97
+ return path_1.default.join(macosDir, inner.name);
98
+ }
99
+ }
100
+ catch { /* keep searching */ }
101
+ }
102
+ }
103
+ for (const e of entries) {
104
+ if (e.isDirectory() && !e.name.endsWith('.app')) {
105
+ const hit = findMacAppExecutable(path_1.default.join(root, e.name), depth + 1);
106
+ if (hit)
107
+ return hit;
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+ /** Find the bundled Chromium executable under `<chromiumRoot>`, or null. */
113
+ function discoverChromiumExecutable(chromiumRoot) {
114
+ if (!fs_1.default.existsSync(chromiumRoot))
115
+ return null;
40
116
  if (process.platform === 'win32') {
41
- return [
42
- path_1.default.join(root, 'bin', 'chromium.exe'),
43
- path_1.default.join(root, 'chrome-win', 'chrome.exe'),
44
- path_1.default.join(root, 'chrome.exe'),
45
- ];
117
+ return findFirstFile(chromiumRoot, (n) => n === 'chrome.exe' || n === 'chromium.exe');
46
118
  }
47
119
  if (process.platform === 'darwin') {
48
- return [
49
- path_1.default.join(root, 'bin', 'chromium'),
50
- path_1.default.join(root, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
51
- path_1.default.join(root, 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
52
- ];
120
+ return (findMacAppExecutable(chromiumRoot) ??
121
+ findFirstFile(chromiumRoot, (n) => n === 'Chromium' || n === 'chromium' || n === 'chrome'));
53
122
  }
54
123
  // linux
55
- return [
56
- path_1.default.join(root, 'bin', 'chromium'),
57
- path_1.default.join(root, 'chrome-linux', 'chrome'),
58
- path_1.default.join(root, 'chrome'),
59
- ];
124
+ return findFirstFile(chromiumRoot, (n) => n === 'chrome' || n === 'chromium' || n === 'chrome-wrapper');
60
125
  }
61
126
  /**
62
- * Returns the absolute path to the bundled Chromium binary, or `null` when no
63
- * bundle is present (so Playwright uses its managed browser). Never throws.
127
+ * Returns the absolute path to an UNPACKED bundled Chromium binary, or `null`.
128
+ *
129
+ * This is the synchronous, no-extraction path: it only inspects a chromium tree that
130
+ * already exists on disk under `<runtimes>/chromium`. Prefer the async
131
+ * `resolveBundledChromiumExecutable()` for the launch path — it additionally extracts
132
+ * the shipped `chromium.tar.gz` archive. Kept for the unpacked fallback (local builds)
133
+ * and never throws.
64
134
  */
65
135
  function resolveBundledChromiumPath() {
66
136
  if (process.env.SPECRAILS_IS_DESKTOP !== '1')
@@ -68,9 +138,130 @@ function resolveBundledChromiumPath() {
68
138
  const runtimesPath = process.env.SPECRAILS_BUNDLED_RUNTIMES_PATH;
69
139
  if (!runtimesPath)
70
140
  return null;
71
- for (const candidate of bundledChromiumCandidates(runtimesPath)) {
72
- if (fileExists(candidate))
73
- return candidate;
141
+ try {
142
+ return discoverChromiumExecutable(path_1.default.join(runtimesPath, 'chromium'));
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ }
148
+ /** Candidate archive names under `<runtimes>/chromium`, in preference order. */
149
+ const ARCHIVE_NAMES = ['chromium.tar.gz', 'chromium.tar'];
150
+ /** Writable extraction destination (overridable for tests via env). */
151
+ function chromiumCacheDir() {
152
+ return (process.env.SPECRAILS_CHROMIUM_CACHE_DIR ||
153
+ path_1.default.join(os_1.default.homedir(), '.specrails', 'runtimes', 'chromium'));
154
+ }
155
+ /** Identity string for an archive (size:mtime) used to skip re-extraction. */
156
+ function archiveIdentity(archivePath) {
157
+ const st = fs_1.default.statSync(archivePath);
158
+ return `${st.size}:${Math.round(st.mtimeMs)}`;
159
+ }
160
+ /** Resolve the platform `tar` binary. macOS/Linux ship `/usr/bin/tar`; Windows 10+ ships `tar` (bsdtar) on PATH. */
161
+ function tarBinary() {
162
+ if (process.platform !== 'win32' && isFile('/usr/bin/tar'))
163
+ return '/usr/bin/tar';
164
+ return 'tar';
165
+ }
166
+ /** Extract `archivePath` into `destDir` using the system tar (auto-detects gzip). */
167
+ function runTarExtract(archivePath, destDir) {
168
+ return new Promise((resolve, reject) => {
169
+ const child = (0, child_process_1.spawn)(tarBinary(), ['-xf', archivePath, '-C', destDir], { stdio: ['ignore', 'ignore', 'pipe'] });
170
+ let stderr = '';
171
+ child.stderr?.on('data', (d) => { stderr += d.toString(); });
172
+ child.on('error', reject);
173
+ child.on('close', (code) => {
174
+ if (code === 0)
175
+ resolve();
176
+ else
177
+ reject(new Error(`tar exited ${code}: ${stderr.trim().slice(0, 500)}`));
178
+ });
179
+ });
180
+ }
181
+ // In-process single-slot cache so concurrent / StrictMode double-invokes share one
182
+ // extraction instead of racing. Keyed by archive identity; invalidated if it changes.
183
+ let _extractInflight = null;
184
+ /** First archive that exists under `<runtimes>/chromium`, or null. */
185
+ function findBundledArchive(runtimesPath) {
186
+ for (const name of ARCHIVE_NAMES) {
187
+ const p = path_1.default.join(runtimesPath, 'chromium', name);
188
+ if (isFile(p))
189
+ return p;
74
190
  }
75
191
  return null;
76
192
  }
193
+ async function extractAndDiscover(archivePath, identity) {
194
+ const destRoot = chromiumCacheDir();
195
+ const marker = path_1.default.join(destRoot, '.source');
196
+ // Fast path: already extracted from this exact archive.
197
+ try {
198
+ if (fs_1.default.readFileSync(marker, 'utf8') === identity) {
199
+ const exe = discoverChromiumExecutable(destRoot);
200
+ if (exe)
201
+ return exe;
202
+ }
203
+ }
204
+ catch { /* not yet extracted / stale → fall through */ }
205
+ // Extract to a temp sibling, then atomically swap in.
206
+ const tmpDir = `${destRoot}.tmp-${process.pid}`;
207
+ fs_1.default.rmSync(tmpDir, { recursive: true, force: true });
208
+ fs_1.default.mkdirSync(tmpDir, { recursive: true });
209
+ try {
210
+ await runTarExtract(archivePath, tmpDir);
211
+ const exeInTmp = discoverChromiumExecutable(tmpDir);
212
+ if (!exeInTmp)
213
+ throw new Error('no chromium executable found after extraction');
214
+ try {
215
+ fs_1.default.chmodSync(exeInTmp, 0o755);
216
+ }
217
+ catch { /* perms best-effort */ }
218
+ fs_1.default.rmSync(destRoot, { recursive: true, force: true });
219
+ fs_1.default.mkdirSync(path_1.default.dirname(destRoot), { recursive: true });
220
+ fs_1.default.renameSync(tmpDir, destRoot);
221
+ fs_1.default.writeFileSync(marker, identity);
222
+ return discoverChromiumExecutable(destRoot);
223
+ }
224
+ finally {
225
+ fs_1.default.rmSync(tmpDir, { recursive: true, force: true });
226
+ }
227
+ }
228
+ /**
229
+ * Resolve the bundled Chromium executable for the launch path, extracting the
230
+ * shipped archive on first use. Returns `null` (never throws) when not in desktop
231
+ * mode, when no bundle is present, or when extraction fails — so Playwright falls
232
+ * back to its managed browser rather than dead-ending.
233
+ */
234
+ async function resolveBundledChromiumExecutable() {
235
+ if (process.env.SPECRAILS_IS_DESKTOP !== '1')
236
+ return null;
237
+ const runtimesPath = process.env.SPECRAILS_BUNDLED_RUNTIMES_PATH;
238
+ if (!runtimesPath)
239
+ return null;
240
+ let archivePath = null;
241
+ try {
242
+ archivePath = findBundledArchive(runtimesPath);
243
+ }
244
+ catch {
245
+ archivePath = null;
246
+ }
247
+ // No archive shipped → fall back to an unpacked tree (local/dev builds).
248
+ if (!archivePath)
249
+ return resolveBundledChromiumPath();
250
+ let identity;
251
+ try {
252
+ identity = archiveIdentity(archivePath);
253
+ }
254
+ catch {
255
+ return resolveBundledChromiumPath();
256
+ }
257
+ if (_extractInflight && _extractInflight.identity === identity) {
258
+ return _extractInflight.promise;
259
+ }
260
+ const promise = extractAndDiscover(archivePath, identity).catch((err) => {
261
+ console.error('[chromium-resolver] extraction failed:', err instanceof Error ? err.message : err);
262
+ _extractInflight = null; // allow a later retry
263
+ return resolveBundledChromiumPath();
264
+ });
265
+ _extractInflight = { identity, promise };
266
+ return promise;
267
+ }