@homie-rocks/studio 0.8.0 → 0.10.0

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/lib/check.mjs CHANGED
@@ -8,21 +8,25 @@
8
8
  * `report` (optional; the open progress feed, lib/progress.mjs) hears each step as it goes green or red, gets a
9
9
  * small picture of the computer's view, and can ask the check to stop: then the browsers close and the result
10
10
  * says `stopped`. Without it the check is exactly what it was.
11
+ *
12
+ * UNDER LOAD (a busy computer, another project's browsers): Chrome gets 150 s to start, a page 90 s to open and a
13
+ * seat 90 s. A browser that stalls for 10 s is let go by the relay and comes back with its seat token; the round it
14
+ * was away for lists its body as a bot, so that round cannot prove anything and the check waits for the next one
15
+ * (up to `rounds` rounds). A person the game lists as a bot while it keeps their seat (an idle player driven by
16
+ * the game's autopilot) is still that person. When no round counts, the result says which seat was missing from
17
+ * each finished round and whether that browser lost its connection, instead of only "no round finished".
11
18
  */
12
- import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
13
- import { tmpdir } from 'node:os';
19
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
20
+ import { cpus, loadavg, tmpdir } from 'node:os';
14
21
  import { join } from 'node:path';
15
-
16
- const CHROMES = [
17
- process.env.CHROME_PATH,
18
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
19
- '/Applications/Chromium.app/Contents/MacOS/Chromium',
20
- '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
21
- ].filter(Boolean);
22
+ import { SOFTWARE_GL, chromeArgs, findChrome, measureFrames, noChrome } from './chrome.mjs';
22
23
 
23
24
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
24
25
 
25
26
  /** The steps a progress feed shows for this check, in order. */
27
+ /** Chrome's own start, on a loaded computer (a cold start there took over a minute). */
28
+ export const LAUNCH_TIMEOUT_MS = 150_000;
29
+
26
30
  export const CHECK_STEPS = [
27
31
  ['computer-seated', 'A computer gets a seat'],
28
32
  ['phone-seated', 'A phone gets a seat'],
@@ -32,10 +36,29 @@ export const CHECK_STEPS = [
32
36
 
33
37
  class Stopped extends Error {}
34
38
 
35
- export async function check({ url, game, roundTimeoutMs = 150_000, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
39
+ /**
40
+ * WHICH FINISHED ROUND PROVES THE TWO BROWSERS PLAYED TOGETHER. `browsers`: each `{ kind, seats, seen }`, where
41
+ * `seats` is every seat it held (a Set) and `seen` the rounds it saw finish after both were seated (a Map from
42
+ * `n:endsAt` to `{ n, endsAt, results, ms }`). A round counts when both saw it and each has a row carrying one of
43
+ * its seats, whatever the row's `bot` says (a game may hand an idle person's body to its autopilot and still keep
44
+ * their seat on the row: that is still the person). A round seen by both without one of them is a miss, kept in
45
+ * `missed` with who was missing and the rows, so the check can say why. Returns the round that counts, or null.
46
+ */
47
+ export function judgeRounds(browsers, missed = new Map()) {
48
+ const mine = new Set(browsers.flatMap((b) => [...b.seats]));
49
+ for (const [key, r] of browsers[0]?.seen ?? []) {
50
+ if (missed.has(key) || !browsers.every((b) => b.seen.has(key))) continue;
51
+ const absent = browsers.filter((b) => !(r.results ?? []).some((row) => b.seats.has(row?.seat))).map((b) => b.kind);
52
+ if (!absent.length) return { ...r, key, overMs: browsers.map((b) => b.seen.get(key).ms), people: r.results.filter((row) => !row?.bot || mine.has(row?.seat)).length };
53
+ missed.set(key, { n: r.n, missing: absent, rows: (r.results ?? []).slice(0, 12).map((row) => ({ seat: row?.seat ?? null, name: row?.name ?? null, bot: Boolean(row?.bot) })) });
54
+ }
55
+ return null;
56
+ }
57
+
58
+ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
36
59
  if (!url || !game) throw new Error('usage: homie-studio check <game> --url <site url>');
37
- const chrome = CHROMES.find((p) => existsSync(p));
38
- if (!chrome) return { ok: false, command: 'check', why: 'no Chrome found (set CHROME_PATH)' };
60
+ const chrome = findChrome();
61
+ if (!chrome) return { ok: false, command: 'check', why: noChrome() };
39
62
  let puppeteer;
40
63
  try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'check', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
41
64
  const base = String(url).replace(/\/+$/, '');
@@ -49,6 +72,8 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
49
72
  { name: 'phone', viewport: { width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 } },
50
73
  ];
51
74
  const players = [];
75
+ // The playing loop runs until this is false: the round is settled, or the check ends in any way (finally).
76
+ let playingOn = true;
52
77
  const step = (id, state, opts = {}) => { try { report?.check?.(id, state, { label: CHECK_STEPS.find(([k]) => k === id)?.[1], ...opts }); } catch { /* a feed never breaks a check */ } };
53
78
  const halt = () => { if (report?.stopped?.()) throw new Stopped('stopped by the person'); };
54
79
  // The computer's view, small (480 px wide), for the feed's preview.
@@ -69,8 +94,8 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
69
94
  const profile = mkdtempSync(join(tmpdir(), 'homie-studio-check-'));
70
95
  profiles.push(profile);
71
96
  const browser = await puppeteer.launch({
72
- executablePath: chrome, headless: true, userDataDir: profile,
73
- args: ['--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist', '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
97
+ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, protocolTimeout: 180_000,
98
+ args: [...chromeArgs(), '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
74
99
  });
75
100
  browsers.push(browser);
76
101
  const page = await browser.newPage();
@@ -78,13 +103,13 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
78
103
  const ua = await browser.userAgent();
79
104
  await page.setUserAgent(`${ua} ${userAgentTag}`);
80
105
  const t0 = Date.now();
81
- await page.goto(play, { waitUntil: 'domcontentloaded', timeout: 60_000 });
106
+ await page.goto(play, { waitUntil: 'domcontentloaded', timeout: 90_000 });
82
107
  players.push({ kind: kind.name, page, t0 });
83
108
  log(`${kind.name} opened ${play}`);
84
109
  }
85
110
  // Seated: the shell knows its room and the game reported a seat.
86
111
  const seated = await Promise.all(players.map(async (p) => {
87
- const deadline = Date.now() + 45_000;
112
+ const deadline = Date.now() + 90_000;
88
113
  while (Date.now() < deadline) {
89
114
  const s = await p.page.evaluate(() => { const s = window.__shell; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, players: s.facts?.counts?.players ?? null } : null; }).catch(() => null);
90
115
  if (s?.room && s.seat !== null && s.seat !== undefined && s.role) {
@@ -95,67 +120,102 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
95
120
  halt();
96
121
  await sleep(250);
97
122
  }
98
- step(`${p.kind}-seated`, 'fail', { note: 'no seat within 45 s' });
123
+ step(`${p.kind}-seated`, 'fail', { note: 'no seat within 90 s' });
99
124
  return null;
100
125
  }));
101
- if (seated.some((s) => !s)) return { ok: false, command: 'check', why: 'a browser never got a seat within 45 s', seated };
126
+ if (seated.some((s) => !s)) return { ok: false, command: 'check', why: 'a browser never got a seat within 90 s', seated };
102
127
  const sameRoom = seated[0].room === seated[1].room;
103
128
  log(`seated: ${seated.map((s, i) => `${kinds[i].name} room ${s.room} seat ${s.seat} ${s.role}`).join('; ')}`);
104
129
  step('same-room', sameRoom ? 'pass' : 'fail', { note: sameRoom ? `room ${seated[0].room}` : `rooms ${seated[0].room} and ${seated[1].room}` });
105
130
  if (!sameRoom) return { ok: false, command: 'check', why: `the two browsers landed in different rooms (${seated[0].room}, ${seated[1].room})`, seated };
106
131
  step('round', 'running');
107
- // Play: the computer holds keys, the phone drags from the lower left.
132
+ // Play: the computer holds keys; the phone drags from the lower left (a stick) and swipes across the bottom
133
+ // middle (a wheel, a slider). A game that hands an untouched player to its autopilot, or lets an idle one go,
134
+ // sees a hand on both. It plays until the check is over, through as many rounds as that takes.
108
135
  const playing = (async () => {
109
136
  const keys = ['KeyD', 'KeyS', 'KeyA', 'KeyW'];
110
- for (let i = 0; Date.now() - started < roundTimeoutMs && i < 400; i++) {
137
+ const vp = kinds[1].viewport;
138
+ for (let i = 0; playingOn && i < 5000; i++) {
111
139
  const k = keys[i % keys.length];
112
140
  await players[0].page.keyboard.down(k).catch(() => {});
113
141
  await sleep(600);
114
142
  await players[0].page.keyboard.up(k).catch(() => {});
115
- const vp = kinds[1].viewport;
116
- await players[1].page.touchscreen.touchStart(vp.width * 0.2, vp.height * 0.8).catch(() => {});
117
- await players[1].page.touchscreen.touchMove(vp.width * 0.2 + (i % 2 ? 60 : -60), vp.height * 0.8 - 40).catch(() => {});
143
+ const [x, y, dx, dy] = i % 3 === 2 ? [vp.width * 0.5, vp.height * 0.86, i % 2 ? 70 : -70, 0] : [vp.width * 0.2, vp.height * 0.8, i % 2 ? 60 : -60, -40];
144
+ await players[1].page.touchscreen.touchStart(x, y).catch(() => {});
145
+ await players[1].page.touchscreen.touchMove(x + dx, y + dy).catch(() => {});
118
146
  await sleep(300);
119
147
  await players[1].page.touchscreen.touchEnd().catch(() => {});
120
- if (players.every((p) => p.over) || report?.stopped?.()) break;
148
+ if (report?.stopped?.()) break;
121
149
  }
122
150
  })();
123
151
  if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-playing.png`) }).catch(() => {});
124
152
  await sleep(1500);
125
153
  await picture('Playing: computer and phone in one room');
126
- // Only a round that finishes AFTER both browsers were seated, with BOTH of them in its results, counts: an
127
- // idle room remembers its last round for a while, and that stale result proves nothing.
154
+ // How fast each browser draws the game while it plays (reported, never judged: a VM without a GPU draws
155
+ // with SwiftShader, which is not a person's frame rate).
156
+ const frames = await Promise.all(players.map((p) => measureFrames(p.page)));
157
+ const software = frames.some((f) => f.renderer && SOFTWARE_GL.test(f.renderer));
158
+ const drawn = frames.map((f, i) => `${kinds[i].name} ${f.fps ?? '?'} fps`).join(', ');
159
+ log(`drawing: ${drawn}${frames[0]?.renderer ? ` (${frames[0].renderer})` : ''}`);
160
+ // Only a round that finishes AFTER both browsers were seated, seen by both, with BOTH of them in its results,
161
+ // counts: an idle room remembers its last round for a while, and that stale result proves nothing. A browser is
162
+ // in a round when a row carries a seat it held (a reconnect keeps its seat; a game may still mark an idle
163
+ // person's row as a bot, and that is still the person). A round finished without one of them is a miss: its
164
+ // cause is kept, and the check waits for the next round, up to `rounds` of them.
128
165
  const bothSeated = Math.max(...players.map((p) => p.seatedAt));
129
- const seats = players.map((p) => p.seat);
130
- const over = await Promise.all(players.map(async (p) => {
131
- const deadline = Date.now() + roundTimeoutMs;
132
- while (Date.now() < deadline) {
133
- const r = await p.page.evaluate((after, want) => {
134
- const s = window.__shell;
135
- const hit = (s?.results ?? []).find((x) => x.at > after && want.every((seat) => x.results.some((row) => row.seat === seat)));
136
- return hit ? { n: hit.n, endsAt: hit.endsAt, results: hit.results, room: s.room } : null;
137
- }, bothSeated, seats).catch(() => null);
138
- if (r) { p.over = true; return { ...r, ms: Date.now() - p.t0 }; }
139
- halt();
140
- await sleep(500);
166
+ for (const p of players) { p.seats = new Set([p.seat]); p.reconnects = null; p.reconnects0 = null; p.closed = null; p.seen = new Map(); }
167
+ const t1 = Date.now();
168
+ const budget = roundTimeoutMs * Math.max(1, rounds);
169
+ const missed = new Map();
170
+ let hit = null;
171
+ while (!hit && Date.now() - t1 < budget && missed.size < Math.max(1, rounds)) {
172
+ for (const p of players) {
173
+ const s = await p.page.evaluate(() => {
174
+ const sh = window.__shell;
175
+ return sh ? { seat: sh.seat, room: sh.room, closed: sh.closed, reconnects: sh.stats?.reconnects ?? null, results: (sh.results ?? []).map((x) => ({ n: x.n, endsAt: x.endsAt, at: x.at, results: x.results })) } : null;
176
+ }).catch(() => null);
177
+ if (!s) continue;
178
+ if (Number.isInteger(s.seat)) p.seats.add(s.seat);
179
+ if (Number.isFinite(s.reconnects)) { p.reconnects0 ??= s.reconnects; p.reconnects = s.reconnects; }
180
+ if (s.closed) p.closed = s.closed;
181
+ for (const r of s.results) if (r.at > bothSeated && Array.isArray(r.results) && !p.seen.has(`${r.n}:${r.endsAt}`)) p.seen.set(`${r.n}:${r.endsAt}`, { ...r, room: s.room, ms: Date.now() - p.t0 });
141
182
  }
142
- return null;
143
- }));
183
+ const before = missed.size;
184
+ hit = judgeRounds(players, missed);
185
+ for (const m of [...missed.values()].slice(before)) log(`round ${m.n} finished without the ${m.missing.join(' and the ')}: waiting for the next one`);
186
+ if (hit) break;
187
+ halt();
188
+ await sleep(700);
189
+ }
190
+ playingOn = false;
144
191
  await playing.catch(() => {});
145
192
  if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-round-over.png`) }).catch(() => {});
146
- if (over.some((o) => !o)) { step('round', 'fail', { note: 'no round finished in time' }); return { ok: false, command: 'check', why: 'a browser did not see a round finish in time', seated, over }; }
147
- const humans = over[0].results.filter((r) => !r.bot).length;
148
- const sameRound = over[0].n === over[1].n && over[0].endsAt === over[1].endsAt && over[0].room === over[1].room;
149
- step('round', sameRound && humans >= 2 ? 'pass' : 'fail', { ms: Math.max(...over.map((o) => o.ms)), note: `round ${over[0].n}: ${humans} people, ${over[0].results.length - humans} bots` });
150
- await picture(`Round ${over[0].n} finished with both; the next one is on`);
193
+ const connection = players.map((p) => ({ browser: p.kind, seats: [...p.seats], reconnects: p.reconnects !== null && p.reconnects0 !== null ? p.reconnects - p.reconnects0 : null, closed: p.closed }));
194
+ const load = { perCore: +(loadavg()[0] / Math.max(1, cpus().length)).toFixed(2), average: +loadavg()[0].toFixed(1), cores: cpus().length };
195
+ if (!hit) {
196
+ const misses = [...missed.values()];
197
+ const dropped = connection.filter((c) => c.reconnects > 0).map((c) => `the ${c.browser} lost its connection ${c.reconnects} time${c.reconnects === 1 ? '' : 's'}`);
198
+ const why = misses.length
199
+ ? `${misses.length} round${misses.length === 1 ? '' : 's'} finished, and none had both browsers in it: ${misses.map((m) => `round ${m.n} left out the ${m.missing.join(' and the ')}`).join('; ')}.${dropped.length ? ` ${dropped.join(', ')} (a browser silent for 10 s is let go by the relay and its body plays as a bot until it is back).` : ' The game listed that seat\'s body as a bot with no seat: it may hand an idle player to a bot.'}${load.perCore > 1.5 ? ` This computer is busy (load ${load.average} on ${load.cores} cores): run the check again when it is quieter.` : ''}`
200
+ : `no round finished within ${Math.round(budget / 1000)} s of both browsers being seated${load.perCore > 1.5 ? ` (this computer is busy: load ${load.average} on ${load.cores} cores)` : ''}`;
201
+ step('round', 'fail', { note: misses.length ? `${misses.length} round(s) without both` : 'no round finished in time' });
202
+ return { ok: false, command: 'check', why, play, room: seated[0].room, seated, missed: misses, connection, load, totalMs: Date.now() - started };
203
+ }
204
+ const humans = hit.people;
205
+ step('round', humans >= 2 ? 'pass' : 'fail', { ms: Math.max(...hit.overMs), note: `round ${hit.n}: ${humans} people, ${hit.results.length - humans} bot${hit.results.length - humans === 1 ? '' : 's'}${missed.size ? ` (after ${missed.size} round(s) without both)` : ''}; ${drawn}${software ? ' (software GL: not measurable here)' : ''}` });
206
+ await picture(`Round ${hit.n} finished with both; the next one is on`);
151
207
  return {
152
- ok: sameRoom && sameRound && humans >= 2,
208
+ ok: sameRoom && humans >= 2,
153
209
  command: 'check',
154
210
  play,
155
211
  room: seated[0].room,
156
212
  seats: seated.map((s, i) => ({ browser: kinds[i].name, seat: s.seat, role: s.role, seatedMs: s.ms })),
157
- round: { n: over[0].n, humans, bots: over[0].results.length - humans, results: over[0].results, overMs: over.map((o) => o.ms) },
158
- why: !sameRound ? 'the two browsers saw different rounds finish' : humans >= 2 ? undefined : `the round's results list ${humans} human(s); both browsers should be in it`,
213
+ round: { n: hit.n, humans, bots: hit.results.length - humans, results: hit.results, overMs: hit.overMs },
214
+ ...(missed.size ? { missed: [...missed.values()] } : {}),
215
+ connection,
216
+ frames: frames.map((f, i) => ({ browser: kinds[i].name, fps: f.fps, renderer: f.renderer })),
217
+ ...(software ? { software: 'This machine has no GPU (WebGL is SwiftShader): seats, rooms and rounds are measured; the frame rate is not a person\'s.' } : {}),
218
+ why: humans >= 2 ? undefined : `the round's results list ${humans} person(s); both browsers should be in it`,
159
219
  totalMs: Date.now() - started,
160
220
  };
161
221
  } catch (error) {
@@ -163,6 +223,7 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
163
223
  for (const [id] of CHECK_STEPS) { try { if (['pending', 'running'].includes(report?.state?.(id))) step(id, 'skip', { note: 'stopped' }); } catch { /* fine */ } }
164
224
  return { ok: false, command: 'check', stopped: true, why: 'stopped by the person', play, totalMs: Date.now() - started };
165
225
  } finally {
226
+ playingOn = false;
166
227
  for (const b of browsers) await b.close().catch(() => {});
167
228
  for (const p of profiles) rmSync(p, { recursive: true, force: true });
168
229
  }
package/lib/chrome.mjs ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * WHICH CHROME THE CHECKS RUN, AND HOW (check, port check, look).
3
+ *
4
+ * On a Mac: that Mac's Chrome, on its GPU (`--use-angle=metal`). A software renderer runs a 3D game at a
5
+ * few frames a second and makes it look stuck, so a Mac never falls back to one.
6
+ *
7
+ * On Linux (a Claude Code cloud session, GitHub Actions, a container) there is usually no GPU and often no
8
+ * Chrome. `homie-studio chrome install` puts Chrome for Testing in this user's cache (from
9
+ * storage.googleapis.com, which a Claude Code cloud session's default "Trusted" network reaches), and the
10
+ * checks render WebGL with SwiftShader (`--enable-unsafe-swiftshader`, needed since Chrome 137), headless,
11
+ * without the setuid sandbox when they run as root (as a container does). What they measure there is seats,
12
+ * rooms and rounds on the room's own clock; a frame rate on SwiftShader is not a person's, so the checks
13
+ * report it with the renderer's name and never judge it.
14
+ */
15
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
16
+ import { homedir } from 'node:os';
17
+ import { join } from 'node:path';
18
+
19
+ /** Where `homie-studio chrome install` keeps Chrome for Testing (one copy per user, shared by every studio). */
20
+ export const CHROME_CACHE = join(homedir(), '.cache', 'homie-studio', 'chrome');
21
+
22
+ /** The newest build of `browser` in a @puppeteer/browsers or Playwright cache, or null. */
23
+ function newestIn(dir, rel) {
24
+ if (!existsSync(dir)) return null;
25
+ const builds = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
26
+ .sort((a, b) => b.localeCompare(a, 'en', { numeric: true }));
27
+ for (const b of builds) {
28
+ for (const r of rel) {
29
+ const path = join(dir, b, ...r);
30
+ if (existsSync(path)) return path;
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /** Chrome for this machine: CHROME_PATH, an installed Chrome, or one in a known cache. */
37
+ export function findChrome() {
38
+ const fixed = [
39
+ process.env.CHROME_PATH,
40
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
41
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
42
+ '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/opt/google/chrome/chrome',
43
+ ].filter(Boolean);
44
+ const found = fixed.find((p) => existsSync(p));
45
+ if (found) return found;
46
+ const home = homedir();
47
+ return newestIn(join(CHROME_CACHE, 'chrome'), [['chrome-linux64', 'chrome'], ['chrome-mac-arm64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing'], ['chrome-mac-x64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing']])
48
+ ?? newestIn(join(home, '.cache', 'puppeteer', 'chrome'), [['chrome-linux64', 'chrome']])
49
+ ?? newestIn(join(home, '.cache', 'ms-playwright'), [['chrome-linux', 'chrome'], ['chrome-linux64', 'chrome']]);
50
+ }
51
+
52
+ /** Linux without a GPU renders with SwiftShader: say so with every number measured there. */
53
+ export const SOFTWARE_GL = /swiftshader|llvmpipe|softpipe|software/i;
54
+
55
+ /*
56
+ * Chrome's own sandbox needs unprivileged user namespaces. Running as root (a container), or on a Linux that
57
+ * restricts them with AppArmor (Ubuntu 23.10 and later: a system Chrome package brings its own AppArmor profile,
58
+ * Chrome for Testing does not), Chrome refuses to start without --no-sandbox. The checks only open the studio's
59
+ * own site, in throwaway profiles.
60
+ */
61
+ function noSandboxNeeded() {
62
+ if (typeof process.getuid === 'function' && process.getuid() === 0) return true;
63
+ try { return readFileSync('/proc/sys/kernel/apparmor_restrict_unprivileged_userns', 'utf8').trim() === '1'; } catch { return false; }
64
+ }
65
+
66
+ /** The launch flags for this machine (the GPU on a Mac; SwiftShader, headless and container-safe on Linux). */
67
+ export function chromeArgs() {
68
+ if (process.platform === 'darwin') return ['--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist'];
69
+ return [
70
+ '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--disable-dev-shm-usage',
71
+ ...(noSandboxNeeded() ? ['--no-sandbox'] : []),
72
+ ];
73
+ }
74
+
75
+ /** Why there is no Chrome, and the one command that fixes it on this machine. */
76
+ export function noChrome() {
77
+ return process.platform === 'linux'
78
+ ? 'no Chrome on this machine: run `npx --no-install homie-studio chrome install` (Chrome for Testing into .cache/homie-studio in the home folder, from storage.googleapis.com), or set CHROME_PATH'
79
+ : 'no Chrome found: install Google Chrome, or set CHROME_PATH';
80
+ }
81
+
82
+ /**
83
+ * `homie-studio chrome install`: Chrome for Testing (stable) into CHROME_CACHE, through @puppeteer/browsers (it comes
84
+ * with puppeteer-core). About 170 MB, once per user; a second run finds it and downloads nothing.
85
+ */
86
+ export async function installChrome({ log = () => {}, fresh = false } = {}) {
87
+ // `fresh`: Chrome for Testing even when the machine has a Chrome (what a machine without one would get).
88
+ const existing = fresh ? null : findChrome();
89
+ if (existing) return { ok: true, command: 'chrome install', chrome: existing, already: true };
90
+ let browsers;
91
+ try { browsers = await import('@puppeteer/browsers'); } catch { return { ok: false, command: 'chrome install', why: '@puppeteer/browsers is missing (it comes with @homie-rocks/studio: npm install)' }; }
92
+ const platform = browsers.detectBrowserPlatform();
93
+ if (!platform) return { ok: false, command: 'chrome install', why: `Chrome for Testing has no build for ${process.platform} ${process.arch}` };
94
+ const buildId = await browsers.resolveBuildId(browsers.Browser.CHROME, platform, 'stable');
95
+ log(`downloading Chrome for Testing ${buildId} (${platform}) into ${CHROME_CACHE}`);
96
+ const got = await browsers.install({ browser: browsers.Browser.CHROME, buildId, cacheDir: CHROME_CACHE });
97
+ return { ok: true, command: 'chrome install', chrome: got.executablePath, buildId, platform };
98
+ }
99
+
100
+ /**
101
+ * The frame rate a page's game frame draws at, over `ms` (requestAnimationFrame in the game's own frame, or the
102
+ * page's), and the renderer WebGL reports there. Never throws: { fps: null } when it cannot tell.
103
+ */
104
+ export async function measureFrames(page, ms = 2000) {
105
+ const frame = page.frames().find((f) => /\/__game\//.test(f.url())) ?? page.mainFrame();
106
+ // Runs inside the frame (puppeteer sends the function itself, so a game's CSP never blocks it).
107
+ const probe = (span) => new Promise((resolve) => {
108
+ let renderer = null;
109
+ try {
110
+ const c = document.createElement('canvas');
111
+ const gl = c.getContext('webgl2') || c.getContext('webgl');
112
+ const d = gl && gl.getExtension('WEBGL_debug_renderer_info');
113
+ renderer = gl ? String(d ? gl.getParameter(d.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)) : 'no WebGL';
114
+ } catch { renderer = null; }
115
+ let n = 0;
116
+ const t0 = performance.now();
117
+ const tick = () => { n++; if (performance.now() - t0 < span) requestAnimationFrame(tick); else resolve({ fps: Math.round((n * 1000) / (performance.now() - t0)), renderer }); };
118
+ requestAnimationFrame(tick);
119
+ });
120
+ try {
121
+ return await Promise.race([
122
+ frame.evaluate(probe, ms),
123
+ new Promise((r) => setTimeout(() => r({ fps: null, renderer: null }), ms + 5000)),
124
+ ]);
125
+ } catch { return { fps: null, renderer: null }; }
126
+ }
@@ -19,11 +19,11 @@
19
19
  * before anything happens and calls nothing.
20
20
  */
21
21
  import { spawnSync } from 'node:child_process';
22
- import { existsSync, writeFileSync } from 'node:fs';
22
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { build } from './build.mjs';
25
25
  import { ensureLocalIgnored, ensureStatsMigration, wranglerConfig } from './scaffold.mjs';
26
- import { LOCAL_STATE, isWorkersDev, readLocal, readStudio, siteUrl, writeLocal, writeStudio } from './studio.mjs';
26
+ import { LOCAL_STATE, configPath, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
27
27
 
28
28
  const ANSI = /\u001b\[[0-9;]*m/g;
29
29
 
@@ -36,7 +36,7 @@ export function wranglerBin(root) {
36
36
  export function runner(root, env) {
37
37
  const bin = wranglerBin(root);
38
38
  if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
39
- return (args, { cwd = join(root, 'site'), input } = {}) => {
39
+ return (args, { cwd = workerDir(root), input } = {}) => {
40
40
  const res = spawnSync(bin, args, {
41
41
  cwd, env: { ...process.env, ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
42
42
  input, maxBuffer: 64 * 1024 * 1024, timeout: 10 * 60_000,
@@ -186,7 +186,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
186
186
  const r2 = cf.r2 && created.has(`r2:${cf.r2}`) ? cf.r2 : null;
187
187
  if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
188
188
 
189
- writeFileSync(join(root, 'site', 'wrangler.jsonc'), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2 }));
189
+ writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root) }));
190
190
  const added = ensureStatsMigration(root);
191
191
  if (added) step(`added ${added} (the studio's own stats: counts, never tracks)`);
192
192
  const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
@@ -211,20 +211,14 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
211
211
  const url = siteUrl(root);
212
212
  step(`deployed ${cf.worker} in ${Math.round((Date.now() - started) / 1000)} s`, { url, ...(workersDev && workersDev !== url ? { workersDev } : {}) });
213
213
 
214
- // The homie.rocks directory claim, for the site's public address (its own domain when it has one): the site
215
- // serves it, which proves this studio controls the site.
214
+ // The homie.rocks directory claim: the live site claims itself the first time its manifest is read (0.10.0), so
215
+ // reading it once now is all it takes; nothing is stored by hand.
216
216
  const directory = homie || studio.homie?.directory || 'https://homie.rocks';
217
217
  let claim = null;
218
218
  if (url) {
219
- try {
220
- const res = await fetch(`${directory.replace(/\/+$/, '')}/api/studio/claim?site=${encodeURIComponent(url)}`);
221
- const body = await res.json();
222
- if (res.ok && /^[a-f0-9]{16,128}$/.test(body.claim ?? '')) claim = body.claim;
223
- } catch { /* the directory is optional for a live site */ }
224
- if (claim) {
225
- const put = w(['d1', 'execute', cf.d1, '--remote', '--command', `INSERT OR REPLACE INTO meta (key, value) VALUES ('homie_claim', '${claim}')`]);
226
- step(put.code === 0 ? 'directory claim stored' : 'could not store the directory claim (publish will say so)');
227
- }
219
+ claim = await warmClaim(url);
220
+ if (claim) step('the site claimed itself in the directory');
221
+ else step('the directory claim is not there yet (the site claims itself when the directory first reads it)');
228
222
  }
229
223
 
230
224
  // studio.json keeps what is safe to commit: names, ids, the custom domain; never the workers.dev address.
@@ -240,6 +234,81 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
240
234
  };
241
235
  }
242
236
 
237
+ /**
238
+ * Read the live site's manifest once, which makes a 0.10.0 site claim itself in its directory. Returns the claim it
239
+ * serves, or null (the directory did not answer, the site is not reachable yet, or it is a Preview). A fresh deploy
240
+ * can take a few seconds to answer everywhere, so it asks three times.
241
+ */
242
+ export async function warmClaim(site, { tries = 3, wait = 2500 } = {}) {
243
+ if (process.env.HOMIE_STUDIO_WARM === '0') return null; // the toolkit's own tests: their live site is made up
244
+ for (let i = 0; i < tries; i++) {
245
+ try {
246
+ const res = await fetch(`${String(site).replace(/\/+$/, '')}/.well-known/homie-studio.json`, { headers: { accept: 'application/json', 'user-agent': 'homie-studio-deploy' }, signal: AbortSignal.timeout(15_000) });
247
+ if (res.ok) {
248
+ const body = await res.json();
249
+ if (/^[a-f0-9]{16,128}$/.test(body?.claim ?? '')) return body.claim;
250
+ }
251
+ } catch { /* not answering yet */ }
252
+ if (i < tries - 1) await new Promise((r) => setTimeout(r, wait));
253
+ }
254
+ return null;
255
+ }
256
+
257
+ /** The Worker's name and D1 database, as the studio's wrangler.jsonc says (Cloudflare's forms may have renamed them). */
258
+ export function configNames(root) {
259
+ try {
260
+ const text = readFileSync(configPath(root), 'utf8');
261
+ const json = JSON.parse(text.replace(/^\s*\/\/.*$/gm, ''));
262
+ return { worker: json.name ?? null, d1: json.d1_databases?.[0]?.database_name ?? null, binding: json.d1_databases?.[0]?.binding ?? 'DB' };
263
+ } catch { return { worker: null, d1: null, binding: 'DB' }; }
264
+ }
265
+
266
+ /*
267
+ * `npm run deploy` IN WORKERS BUILDS (WORKERS_CI=1, or `deploy --ci`). Cloudflare's own CI has already run
268
+ * `npm run build`, and holds its own API token for this one account; the Worker and its database were made by the
269
+ * "Deploy to Cloudflare" flow or by an earlier deploy. So this only applies the D1 migrations (by binding name: the
270
+ * form may have renamed the database) and deploys. It creates nothing by hand and refuses nothing, and writes
271
+ * nothing back (a CI checkout is thrown away). On the very first deploy the database may not exist yet: Wrangler
272
+ * creates the one the binding names while it deploys, and the migrations run right after.
273
+ */
274
+ export async function ciDeploy(root, { log = () => {} } = {}) {
275
+ const w = runner(root, {});
276
+ const names = configNames(root);
277
+ const steps = [];
278
+ const step = (what) => { steps.push({ what }); log(what); };
279
+ const refuse = (why, out) => ({ ok: false, command: 'deploy', ci: true, ...(explainCloudflare(out) ?? { why }), steps });
280
+ if (!existsSync(join(root, 'site', 'dist', 'games.json'))) return { ok: false, command: 'deploy', ci: true, why: 'nothing is built: the build command is `npm run build` (homie-studio build), and it runs before this', steps };
281
+ if (ensureStatsMigration(root)) step('added the stats migration (the studio\'s own counters)');
282
+ const apply = () => w(['d1', 'migrations', 'apply', names.binding, '--remote']);
283
+ let migrate = apply();
284
+ const first = migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
285
+ if (migrate.code !== 0 && !first) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
286
+ if (!first) step('D1 migrations applied');
287
+ const started = Date.now();
288
+ const dep = w(['deploy']);
289
+ if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
290
+ step(`deployed ${names.worker ?? 'the Worker'} in ${Math.round((Date.now() - started) / 1000)} s`);
291
+ if (first) {
292
+ migrate = apply();
293
+ if (migrate.code !== 0) return refuse(`D1 migrations failed after the first deploy: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
294
+ step('D1 migrations applied (the database was created with this first deploy)');
295
+ }
296
+ // The live address, from what Wrangler printed (a custom domain in studio.json wins).
297
+ const workersDev = /https:\/\/[a-z0-9.-]+\.workers\.dev/i.exec(dep.out)?.[0] ?? null;
298
+ let url = null;
299
+ try { url = siteUrl(root) ?? workersDev; } catch { url = workersDev; }
300
+ const claim = url ? await warmClaim(url) : null;
301
+ step(claim ? 'the site claimed itself in the directory' : 'no directory claim yet (the site claims itself when the directory first reads it)');
302
+ let games = [];
303
+ try { games = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')).games.map((g) => g.id); } catch { games = []; }
304
+ return {
305
+ ok: true, command: 'deploy', ci: true, url, worker: names.worker, d1: names.d1, r2: null, steps, announced: [],
306
+ commit: process.env.WORKERS_CI_COMMIT_SHA ?? null, branch: process.env.WORKERS_CI_BRANCH ?? null,
307
+ games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null })), songs: [], videos: [],
308
+ claim: Boolean(claim),
309
+ };
310
+ }
311
+
243
312
  /*
244
313
  * `homie-studio storage add` — the studio's own storage for large media (songs,
245
314
  * videos, big art), as an R2 bucket on its account, bound as MEDIA and served at
@@ -283,7 +352,7 @@ export async function storageAdd(root, { log = () => {} } = {}) {
283
352
  const next = { ...readStudio(root), cloudflare: { ...readStudio(root).cloudflare, accountId, r2: bucket, created: [...created].sort() } };
284
353
  writeStudio(root, next);
285
354
  if (next.cloudflare.d1Id) {
286
- writeFileSync(join(root, 'site', 'wrangler.jsonc'), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket }));
355
+ writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket, layout: layoutOf(root) }));
287
356
  }
288
357
  log(`created R2 ${bucket}`);
289
358
  return { ok: true, command: 'storage add', bucket, account: accountId, next: ['npm run deploy (binds the bucket as MEDIA; the site serves it at /media/<key>)', 'npx --no-install homie-studio media put <file>'] };
package/lib/look.mjs CHANGED
@@ -7,16 +7,11 @@
7
7
  * `--shots` (default .studio/look/), which git ignores. One Chrome, closed at the end; its visits are house QA
8
8
  * (the studio's stats leave them out).
9
9
  */
10
- import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
10
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
11
11
  import { tmpdir } from 'node:os';
12
12
  import { join } from 'node:path';
13
+ import { chromeArgs, findChrome, noChrome } from './chrome.mjs';
13
14
 
14
- const CHROMES = [
15
- process.env.CHROME_PATH,
16
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
17
- '/Applications/Chromium.app/Contents/MacOS/Chromium',
18
- '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
19
- ].filter(Boolean);
20
15
 
21
16
  export const LOOK_DEVICES = {
22
17
  computer: { width: 1440, height: 900, deviceScaleFactor: 1 },
@@ -29,16 +24,17 @@ const nameOf = (path) => path.replace(/^\/+|\/+$/g, '').replace(/[^a-z0-9]+/gi,
29
24
 
30
25
  export async function look({ url, paths, shots, devices = Object.keys(LOOK_DEVICES), log = () => {} }) {
31
26
  if (!url) return { ok: false, command: 'look', why: 'give --url (the local dev address or the live site)' };
32
- const chrome = CHROMES.find((p) => existsSync(p));
33
- if (!chrome) return { ok: false, command: 'look', why: 'no Chrome found (set CHROME_PATH)' };
27
+ const chrome = findChrome();
28
+ if (!chrome) return { ok: false, command: 'look', why: noChrome() };
34
29
  let puppeteer;
35
30
  try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'look', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
36
31
  const base = String(url).replace(/\/+$/, '');
37
32
  mkdirSync(shots, { recursive: true });
38
33
  const profile = mkdtempSync(join(tmpdir(), 'homie-studio-look-'));
34
+ // 150 s for Chrome to start: on a loaded computer a cold start has taken over a minute.
39
35
  const browser = await puppeteer.launch({
40
- executablePath: chrome, headless: true, userDataDir: profile,
41
- args: ['--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist', '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
36
+ executablePath: chrome, headless: true, userDataDir: profile, timeout: 150_000, protocolTimeout: 180_000,
37
+ args: [...chromeArgs(), '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
42
38
  });
43
39
  const rows = [];
44
40
  try {
@@ -51,7 +47,7 @@ export async function look({ url, paths, shots, devices = Object.keys(LOOK_DEVIC
51
47
  await page.setViewport(LOOK_DEVICES[device]);
52
48
  await page.setUserAgent(`${await browser.userAgent()} homie-studio-check look`);
53
49
  const t0 = Date.now();
54
- const res = await page.goto(`${base}${path}`, { waitUntil: 'networkidle2', timeout: 45_000 }).catch((e) => { errors.push(`open: ${e.message}`); return null; });
50
+ const res = await page.goto(`${base}${path}`, { waitUntil: 'networkidle2', timeout: 90_000 }).catch((e) => { errors.push(`open: ${e.message}`); return null; });
55
51
  const ms = Date.now() - t0;
56
52
  await sleep(900);
57
53
  const fold = await page.evaluate(() => {
package/lib/media.mjs CHANGED
@@ -50,6 +50,11 @@ export function resolveMedia(root, kind, { r2 = false } = {}) {
50
50
  const entries = [];
51
51
  const skipped = [];
52
52
  const seen = new Set();
53
+ // The manifest's own picture (music: `cover`, videos: `poster`): an album cover for every song that has none of
54
+ // its own. A path in the studio (copied into the site) or an https:// address, like any file.
55
+ const artRole = kind === 'music' ? 'cover' : 'poster';
56
+ const shared = manifest[artRole];
57
+ const sharedFile = typeof shared === 'string' ? (/^https:\/\//.test(shared) ? { url: shared } : { path: shared }) : shared && typeof shared === 'object' ? shared : null;
53
58
  for (const item of manifest.items) {
54
59
  if (!item || typeof item !== 'object') continue;
55
60
  const label = item.slug ?? item.key ?? item.file ?? '(unnamed)';
@@ -58,7 +63,9 @@ export function resolveMedia(root, kind, { r2 = false } = {}) {
58
63
  seen.add(item.slug);
59
64
  if (item.published !== true) { skipped.push({ kind, item: label, why: 'not published yet ("published": true shows it)' }); continue; }
60
65
  const files = [];
61
- for (const f of Array.isArray(item.files) ? item.files : []) {
66
+ const own = Array.isArray(item.files) ? item.files : [];
67
+ const list = sharedFile && !own.some((f) => f?.role === artRole) ? [...own, { ...sharedFile, role: artRole }] : own;
68
+ for (const f of list) {
62
69
  if (!f || f.public === false) continue;
63
70
  let src = null;
64
71
  if (typeof f.key === 'string' && f.key && r2) src = { from: 'r2', url: `/media/${f.key.split('/').map(encodeURIComponent).join('/')}` };