peachsim 0.1.527

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 (324) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +640 -0
  3. package/cli/app-config.ts +82 -0
  4. package/cli/app-fonts.ts +388 -0
  5. package/cli/app-project.ts +59 -0
  6. package/cli/app-splash.ts +171 -0
  7. package/cli/app-state-reset.ts +26 -0
  8. package/cli/auth.ts +184 -0
  9. package/cli/bin.ts +59 -0
  10. package/cli/bridge-diagnostics.ts +226 -0
  11. package/cli/bridge-flow-runner.ts +2806 -0
  12. package/cli/browser-backend.ts +44 -0
  13. package/cli/browser-evals.ts +96 -0
  14. package/cli/cli-update.ts +261 -0
  15. package/cli/cloud-client.ts +1530 -0
  16. package/cli/cloud-dispatch.ts +206 -0
  17. package/cli/cloud-session.ts +227 -0
  18. package/cli/command-contract.ts +221 -0
  19. package/cli/command-registry.ts +304 -0
  20. package/cli/commands/agent-wrapper.ts +1048 -0
  21. package/cli/commands/agent.ts +439 -0
  22. package/cli/commands/app-fonts.ts +99 -0
  23. package/cli/commands/assert.ts +542 -0
  24. package/cli/commands/auth.ts +71 -0
  25. package/cli/commands/box/bundle-plane.ts +71 -0
  26. package/cli/commands/box/checkout-plane.ts +256 -0
  27. package/cli/commands/box/plane-share.ts +208 -0
  28. package/cli/commands/box/rnx-command.ts +60 -0
  29. package/cli/commands/box/search-command.ts +157 -0
  30. package/cli/commands/box.ts +925 -0
  31. package/cli/commands/camera.ts +266 -0
  32. package/cli/commands/cleanup.ts +169 -0
  33. package/cli/commands/compat.ts +176 -0
  34. package/cli/commands/config.ts +34 -0
  35. package/cli/commands/control.ts +2154 -0
  36. package/cli/commands/cpu-profile.ts +941 -0
  37. package/cli/commands/daemon-mac-app.ts +165 -0
  38. package/cli/commands/daemon.ts +800 -0
  39. package/cli/commands/debug.ts +751 -0
  40. package/cli/commands/desktop.ts +39 -0
  41. package/cli/commands/detect.ts +201 -0
  42. package/cli/commands/detox.ts +481 -0
  43. package/cli/commands/device.ts +134 -0
  44. package/cli/commands/diagnose.ts +589 -0
  45. package/cli/commands/do-chain.ts +408 -0
  46. package/cli/commands/electron.ts +94 -0
  47. package/cli/commands/film.ts +379 -0
  48. package/cli/commands/flow.ts +1240 -0
  49. package/cli/commands/inspect/actions.ts +516 -0
  50. package/cli/commands/inspect/core.ts +2086 -0
  51. package/cli/commands/inspect/count.ts +17 -0
  52. package/cli/commands/inspect/describe.ts +119 -0
  53. package/cli/commands/inspect/env.ts +25 -0
  54. package/cli/commands/inspect/find.ts +169 -0
  55. package/cli/commands/inspect/get-layout.ts +53 -0
  56. package/cli/commands/inspect/keyboard.ts +54 -0
  57. package/cli/commands/inspect/list.ts +175 -0
  58. package/cli/commands/inspect/memory.ts +215 -0
  59. package/cli/commands/inspect/redaction.ts +39 -0
  60. package/cli/commands/inspect/resolve-target.ts +77 -0
  61. package/cli/commands/inspect/screens.ts +78 -0
  62. package/cli/commands/inspect/settle.ts +32 -0
  63. package/cli/commands/inspect/settling.ts +197 -0
  64. package/cli/commands/inspect/shared.ts +259 -0
  65. package/cli/commands/inspect/sleep.ts +15 -0
  66. package/cli/commands/inspect/tree.ts +23 -0
  67. package/cli/commands/inspect/url.ts +17 -0
  68. package/cli/commands/inspect/wait-event.ts +211 -0
  69. package/cli/commands/inspect/wait-idle.ts +25 -0
  70. package/cli/commands/inspect/wait-ready.ts +87 -0
  71. package/cli/commands/inspect/wait-selector.ts +59 -0
  72. package/cli/commands/inspect/wait.ts +32 -0
  73. package/cli/commands/inspect.ts +4738 -0
  74. package/cli/commands/install-cli.ts +275 -0
  75. package/cli/commands/install-desktop.ts +385 -0
  76. package/cli/commands/login.ts +357 -0
  77. package/cli/commands/logout.ts +24 -0
  78. package/cli/commands/maestro-generate.ts +375 -0
  79. package/cli/commands/maestro.ts +536 -0
  80. package/cli/commands/mode.ts +58 -0
  81. package/cli/commands/no-bridge-hint.ts +80 -0
  82. package/cli/commands/perf.ts +67 -0
  83. package/cli/commands/permissions.ts +203 -0
  84. package/cli/commands/platform.ts +410 -0
  85. package/cli/commands/preview.ts +103 -0
  86. package/cli/commands/profile.ts +106 -0
  87. package/cli/commands/react.ts +353 -0
  88. package/cli/commands/record.ts +1435 -0
  89. package/cli/commands/report-issue.ts +305 -0
  90. package/cli/commands/reset.ts +89 -0
  91. package/cli/commands/runtime.ts +348 -0
  92. package/cli/commands/screenshot-command.ts +106 -0
  93. package/cli/commands/screenshot-layers.ts +144 -0
  94. package/cli/commands/screenshot-mode.ts +38 -0
  95. package/cli/commands/screenshot.ts +492 -0
  96. package/cli/commands/screenshots-capture.ts +608 -0
  97. package/cli/commands/screenshots.ts +127 -0
  98. package/cli/commands/serve.ts +139 -0
  99. package/cli/commands/setup.ts +416 -0
  100. package/cli/commands/shell-boolean-mode.ts +82 -0
  101. package/cli/commands/skills.ts +468 -0
  102. package/cli/commands/slides.ts +357 -0
  103. package/cli/commands/state.ts +94 -0
  104. package/cli/commands/storage.ts +58 -0
  105. package/cli/commands/telemetry.ts +55 -0
  106. package/cli/commands/test.ts +535 -0
  107. package/cli/commands/three-mode.ts +771 -0
  108. package/cli/commands/timeline.ts +121 -0
  109. package/cli/commands/upgrade.ts +167 -0
  110. package/cli/commands/upload.ts +1203 -0
  111. package/cli/commands/version.ts +63 -0
  112. package/cli/commands/welcome.ts +17 -0
  113. package/cli/commands/what-happened.ts +328 -0
  114. package/cli/current-sim.ts +217 -0
  115. package/cli/desktop-companion.ts +315 -0
  116. package/cli/drivers/electron.ts +70 -0
  117. package/cli/drivers/index.ts +20 -0
  118. package/cli/drivers/playwright-provisioning.ts +184 -0
  119. package/cli/drivers/playwright-sim-host.ts +537 -0
  120. package/cli/drivers/playwright.ts +209 -0
  121. package/cli/drivers/registry.ts +65 -0
  122. package/cli/drivers/types.ts +102 -0
  123. package/cli/flow-export.ts +55 -0
  124. package/cli/flow-file.ts +142 -0
  125. package/cli/flow-live-status.ts +126 -0
  126. package/cli/flow-session.ts +140 -0
  127. package/cli/help-core.ts +143 -0
  128. package/cli/help.ts +44 -0
  129. package/cli/hidden-runtime-alias.ts +19 -0
  130. package/cli/hints.ts +214 -0
  131. package/cli/inspect-notice-state.ts +114 -0
  132. package/cli/internal-child.ts +176 -0
  133. package/cli/maestro-js.ts +323 -0
  134. package/cli/main.ts +704 -0
  135. package/cli/open-url.ts +8 -0
  136. package/cli/outbound-endpoints.ts +830 -0
  137. package/cli/parent-pid.ts +204 -0
  138. package/cli/parse-args.ts +189 -0
  139. package/cli/privacy.ts +53 -0
  140. package/cli/prompt.ts +56 -0
  141. package/cli/recording-access.ts +108 -0
  142. package/cli/registry.ts +2 -0
  143. package/cli/resolve-assets.ts +63 -0
  144. package/cli/rnx-in-process.ts +98 -0
  145. package/cli/run-registry.ts +226 -0
  146. package/cli/run-rnx-core.ts +148 -0
  147. package/cli/run-rnx.ts +137 -0
  148. package/cli/runtime-notes.ts +66 -0
  149. package/cli/runtime-summary.ts +25 -0
  150. package/cli/secure-file.ts +31 -0
  151. package/cli/self-invocation.ts +58 -0
  152. package/cli/send-to-box.ts +109 -0
  153. package/cli/setup-repository.ts +191 -0
  154. package/cli/shell-init.ts +98 -0
  155. package/cli/standalone.ts +4 -0
  156. package/cli/telemetry.ts +84 -0
  157. package/cli/ui.ts +68 -0
  158. package/cli/ws-bridge.ts +1020 -0
  159. package/detox/attempt-events.cjs +23 -0
  160. package/detox/colors.ts +54 -0
  161. package/detox/config-loader.ts +135 -0
  162. package/detox/element-types.ts +48 -0
  163. package/detox/expectations.ts +534 -0
  164. package/detox/gestures.ts +501 -0
  165. package/detox/index.ts +2694 -0
  166. package/detox/jest-preset.cjs +29 -0
  167. package/detox/jest-setup-after-env.cjs +22 -0
  168. package/detox/matchers.ts +30 -0
  169. package/detox/motion-change.cjs +109 -0
  170. package/detox/motion-change.d.cts +35 -0
  171. package/detox/navigation-lifecycle.ts +290 -0
  172. package/detox/navigation.ts +43 -0
  173. package/detox/proof-frame.cjs +114 -0
  174. package/detox/record-block-execution.cjs +78 -0
  175. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  176. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  177. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  178. package/detox/screenshots/theme-blue-toggle.png +0 -0
  179. package/detox/screenshots/theme-blue.png +0 -0
  180. package/detox/screenshots/theme-red-toggle.png +0 -0
  181. package/detox/screenshots/theme-red.png +0 -0
  182. package/dist-lib/agent-daemon-client.cjs +502 -0
  183. package/dist-lib/agent-events.cjs +48 -0
  184. package/dist-lib/agent-identity.cjs +53 -0
  185. package/dist-lib/agent-sessions.cjs +759 -0
  186. package/dist-lib/attached-projects.cjs +526 -0
  187. package/dist-lib/auth/shared-session.cjs +208 -0
  188. package/dist-lib/backend-origin.cjs +88 -0
  189. package/dist-lib/beta.cjs +41 -0
  190. package/dist-lib/beta.mjs +13 -0
  191. package/dist-lib/bridge-constants.cjs +161 -0
  192. package/dist-lib/bridge-contract-input.cjs +451 -0
  193. package/dist-lib/bridge-contract-input.mjs +414 -0
  194. package/dist-lib/bridge-contract.cjs +130 -0
  195. package/dist-lib/bridge-contract.mjs +94 -0
  196. package/dist-lib/capture-contract.cjs +220 -0
  197. package/dist-lib/capture-contract.mjs +190 -0
  198. package/dist-lib/cli-constants.cjs +32 -0
  199. package/dist-lib/cloud-contract.cjs +194 -0
  200. package/dist-lib/cloud-contract.mjs +145 -0
  201. package/dist-lib/cloud.cjs +670 -0
  202. package/dist-lib/cloud.mjs +639 -0
  203. package/dist-lib/config.cjs +96 -0
  204. package/dist-lib/detox/index.cjs +3789 -0
  205. package/dist-lib/detox/jest-preset.cjs +29 -0
  206. package/dist-lib/dev-bundle-resolution.cjs +290 -0
  207. package/dist-lib/home-paths.cjs +638 -0
  208. package/dist-lib/host/bridge-host.cjs +10903 -0
  209. package/dist-lib/host/fetch-proxy-handler.cjs +421 -0
  210. package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
  211. package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
  212. package/dist-lib/host/replacement-module-handler.cjs +469 -0
  213. package/dist-lib/host/websocket-proxy.cjs +298 -0
  214. package/dist-lib/index.cjs +4268 -0
  215. package/dist-lib/jump-to-source-babel.cjs +93 -0
  216. package/dist-lib/jump-to-source-native.cjs +112 -0
  217. package/dist-lib/menu.cjs +646 -0
  218. package/dist-lib/menu.mjs +604 -0
  219. package/dist-lib/metro-fingerprint-registry.cjs +186 -0
  220. package/dist-lib/metro-fingerprint-registry.mjs +156 -0
  221. package/dist-lib/metro-production-bundle.cjs +329 -0
  222. package/dist-lib/metro-production-bundle.mjs +295 -0
  223. package/dist-lib/metro.cjs +904 -0
  224. package/dist-lib/profiles.cjs +246 -0
  225. package/dist-lib/public-brand.cjs +39 -0
  226. package/dist-lib/react-native-host-modules.cjs +51 -0
  227. package/dist-lib/react-native-host-modules.mjs +25 -0
  228. package/dist-lib/render-mode.cjs +54 -0
  229. package/dist-lib/scripts/dev-server-scanner.cjs +1232 -0
  230. package/dist-lib/sdk.cjs +2433 -0
  231. package/dist-lib/sdk.mjs +2336 -0
  232. package/dist-lib/skills.cjs +39132 -0
  233. package/dist-lib/swift-submit.cjs +92 -0
  234. package/dist-lib/swift-submit.mjs +65 -0
  235. package/dist-lib/swift.cjs +1646 -0
  236. package/dist-lib/vite.cjs +1591 -0
  237. package/package.json +290 -0
  238. package/scripts/dev-server-scanner.ts +1291 -0
  239. package/scripts/optional-demo-registry.ts +113 -0
  240. package/skills/contrast/SKILL.md +165 -0
  241. package/skills/rnx-debug/SKILL.md +433 -0
  242. package/skills/rnx-perf/SKILL.md +399 -0
  243. package/skills/rnx-setup/SKILL.md +172 -0
  244. package/skills/rnx-test/SKILL.md +321 -0
  245. package/skills/rnx-visual/SKILL.md +126 -0
  246. package/src/agent-daemon-client.ts +410 -0
  247. package/src/agent-events.ts +71 -0
  248. package/src/agent-identity.ts +23 -0
  249. package/src/agent-prompt.ts +71 -0
  250. package/src/agent-sessions.ts +580 -0
  251. package/src/app-url.ts +39 -0
  252. package/src/attached-projects.ts +539 -0
  253. package/src/auth/shared-session.ts +235 -0
  254. package/src/backend-origin.ts +70 -0
  255. package/src/beta.ts +19 -0
  256. package/src/bridge-constants.ts +175 -0
  257. package/src/bridge-contract-input.ts +502 -0
  258. package/src/bridge-contract.ts +558 -0
  259. package/src/browser-cache.ts +16 -0
  260. package/src/capture-contract.ts +587 -0
  261. package/src/cli-constants.ts +1 -0
  262. package/src/cli-version.ts +40 -0
  263. package/src/cloud-contract.ts +274 -0
  264. package/src/cloud.ts +762 -0
  265. package/src/codex-client.ts +234 -0
  266. package/src/config.ts +132 -0
  267. package/src/connect.ts +101 -0
  268. package/src/dev-bundle-resolution.ts +346 -0
  269. package/src/dev-server-open.ts +198 -0
  270. package/src/disk-cleanup.ts +540 -0
  271. package/src/flow-timeout-scale.ts +15 -0
  272. package/src/home-paths.ts +806 -0
  273. package/src/host/agent-host.ts +666 -0
  274. package/src/host/bridge-host.ts +2788 -0
  275. package/src/host/fetch-proxy-handler.ts +449 -0
  276. package/src/host/fetch-proxy-overrides.ts +51 -0
  277. package/src/host/open-url.ts +89 -0
  278. package/src/host/plane-host.ts +203 -0
  279. package/src/host/replacement-module-handler.ts +158 -0
  280. package/src/host/websocket-proxy.ts +315 -0
  281. package/src/index.ts +71 -0
  282. package/src/jump-to-source-babel.ts +135 -0
  283. package/src/jump-to-source-native.ts +102 -0
  284. package/src/menu.ts +777 -0
  285. package/src/metro-fingerprint-registry.ts +302 -0
  286. package/src/metro-plugin.ts +341 -0
  287. package/src/metro-production-bundle.ts +471 -0
  288. package/src/native-dev-bundle-url.ts +118 -0
  289. package/src/native-seam-manifest.ts +341 -0
  290. package/src/playwright-host-log.ts +16 -0
  291. package/src/plist.ts +27 -0
  292. package/src/poll-until.ts +123 -0
  293. package/src/profiles.ts +179 -0
  294. package/src/public-brand.ts +9 -0
  295. package/src/react-native-host-modules.ts +26 -0
  296. package/src/render-mode.ts +27 -0
  297. package/src/rolldown-plugin-swift.ts +240 -0
  298. package/src/runtime-assets.ts +233 -0
  299. package/src/runtime-delivery.ts +54 -0
  300. package/src/screenshots/compose.ts +422 -0
  301. package/src/screenshots/frame-compose.ts +514 -0
  302. package/src/screenshots/orchestrate.ts +256 -0
  303. package/src/screenshots/org-deck.ts +91 -0
  304. package/src/screenshots/registry.ts +58 -0
  305. package/src/screenshots/schema.ts +364 -0
  306. package/src/sdk.ts +15 -0
  307. package/src/settings-bundle.ts +418 -0
  308. package/src/shell-bridge-identity.ts +15 -0
  309. package/src/sim-client.ts +697 -0
  310. package/src/skills/builtin/a11y-review.ts +132 -0
  311. package/src/skills/builtin/compat-check.ts +71 -0
  312. package/src/skills/builtin/maestro-test.ts +121 -0
  313. package/src/skills/builtin/perf-profile.ts +90 -0
  314. package/src/skills/builtin/screenshot-all.ts +52 -0
  315. package/src/skills/builtin/visual-diff.ts +100 -0
  316. package/src/skills/registry.ts +107 -0
  317. package/src/skills/types.ts +41 -0
  318. package/src/swift-submit.ts +185 -0
  319. package/src/swift.ts +7 -0
  320. package/src/team-machine-prompt.ts +73 -0
  321. package/src/vite-plugin-one.ts +264 -0
  322. package/src/vite-plugin-swift.ts +620 -0
  323. package/src/vite-plugin.ts +1946 -0
  324. package/src/worklets-babel.ts +250 -0
@@ -0,0 +1,608 @@
1
+ // rnx screenshot appstore capture — headlessly (re)capture an app's App-Store
2
+ // slides as EDITABLE 3-layer refs (captureRef + cleanRef + screenRef, plus
3
+ // threeScene for 3d) and let the shell's existing auto-upload push them to the
4
+ // signed-in user's org deck. NOT a reimplementation of capture/upload: it
5
+ // launches a GPU headless Chrome, loads the org-mode build shell, seeds the
6
+ // `rnx login` session into the page so the in-browser auto-upload PUTs
7
+ // authenticate as the user, drives the same `SootSim.bridges.screenshotControl`
8
+ // surface the rail UI uses, then waits for the deck/capture PUTs to flush.
9
+ //
10
+ // auth: `rnx login` (resolveCliAuth → session token). the token is seeded
11
+ // into the shell page's localStorage (`sootsim.session.token`) — the exact key
12
+ // `getSession()` reads, which both the deck PUT (projects-persistence) and the
13
+ // per-ref capture PUT (capture-store) attach as `Authorization: Bearer`.
14
+ //
15
+ // safety: upload is the default, but the command REFUSES to overwrite a
16
+ // non-empty org deck unless `--force` is passed (the deck PUT has snapshot
17
+ // semantics — it replaces the whole deck). `--dry-run` captures and reports the
18
+ // refs without uploading (PUTs are intercepted + aborted in the page).
19
+
20
+ import { execSync } from 'node:child_process'
21
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
22
+ import { tmpdir } from 'node:os'
23
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
24
+ import { chromium, type Frame, type Page } from 'playwright-core'
25
+ import { launchReapedChromeScoped } from '../../../../scripts/lib/reap-browser'
26
+ import { rnxPublicBrand } from '../../src/public-brand'
27
+ import { resolveCliAuth, type CliAuth } from '../auth'
28
+ import { rnxExit } from '../run-rnx'
29
+
30
+ const HELP = `
31
+ rnx screenshot appstore capture — headlessly capture + upload App Store slides
32
+
33
+ usage:
34
+ rnx screenshot appstore capture --org <org> --repo <repo> [options]
35
+
36
+ what it does:
37
+ boots the org-mode build shell in headless GPU Chrome (authed via your
38
+ \`rnx login\` session), captures each slide through the live screenshot
39
+ composer so every slide gets editable layers (captureRef + cleanRef +
40
+ screenRef), and lets the shell auto-upload them to your org deck.
41
+
42
+ options:
43
+ --org <org> github org slug (required)
44
+ --repo <repo> repo name (required)
45
+ --branch <branch> build branch to boot (default: main)
46
+ --platform <p> ios | android (default: ios)
47
+ --slides <list> 1-based indices to capture, e.g. 1,3,5 (default: all)
48
+ --dry-run capture + report refs, do NOT upload (PUTs aborted)
49
+ --force allow overwriting a NON-EMPTY existing deck
50
+ --out <dir> also write composed App-Store PNGs locally
51
+ --shell-origin <url> origin serving /build (default: your session origin)
52
+ --api-origin <url> origin for deck/capture APIs (default: session origin)
53
+ --timeout <ms> per-step bridge timeout (default: 45000)
54
+ --json machine-readable summary
55
+
56
+ examples:
57
+ rnx screenshot appstore capture --org acme --repo my-app --dry-run
58
+ rnx screenshot appstore capture --org acme --repo my-app --branch main --force
59
+ `
60
+
61
+ interface CaptureSlide {
62
+ index: number
63
+ id: string
64
+ title: string
65
+ captureRef: string | null
66
+ cleanRef: string | null
67
+ screenRef: string | null
68
+ captureMode: '2d' | '3d' | null
69
+ }
70
+
71
+ interface ControlState {
72
+ projectName: string
73
+ deviceModel: string
74
+ slides: CaptureSlide[]
75
+ }
76
+
77
+ interface Flags {
78
+ org: string
79
+ repo: string
80
+ branch: string
81
+ platform: string
82
+ slides: number[] | null
83
+ dryRun: boolean
84
+ force: boolean
85
+ out: string | null
86
+ shellOrigin: string | null
87
+ apiOrigin: string | null
88
+ timeoutMs: number
89
+ json: boolean
90
+ }
91
+
92
+ function parseFlags(args: string[]): Flags {
93
+ const get = (name: string): string | undefined => {
94
+ const i = args.indexOf(name)
95
+ return i >= 0 ? args[i + 1] : undefined
96
+ }
97
+ const has = (name: string) => args.includes(name)
98
+ const slidesRaw = get('--slides')
99
+ const slides = slidesRaw
100
+ ? slidesRaw
101
+ .split(',')
102
+ .map((s) => Number(s.trim()))
103
+ .filter((n) => Number.isInteger(n) && n > 0)
104
+ : null
105
+ return {
106
+ org: get('--org') ?? '',
107
+ repo: get('--repo') ?? '',
108
+ branch: get('--branch') ?? 'main',
109
+ platform: get('--platform') ?? 'ios',
110
+ slides: slides && slides.length ? slides : null,
111
+ dryRun: has('--dry-run'),
112
+ force: has('--force'),
113
+ out: get('--out') ?? null,
114
+ shellOrigin: get('--shell-origin') ?? null,
115
+ apiOrigin: get('--api-origin') ?? null,
116
+ timeoutMs: Number(get('--timeout') ?? '45000') || 45000,
117
+ json: has('--json'),
118
+ }
119
+ }
120
+
121
+ // the session token + origin power both the page auth seed and the API calls.
122
+ // only a logged-in session works here (the in-browser PUTs read a bearer); an
123
+ // api-key/github CliAuth has no browser session origin, so we direct those to
124
+ // `rnx login` rather than silently failing mid-capture.
125
+ function resolveSessionAuth(): { token: string; origin: string } {
126
+ const auth: CliAuth | null = resolveCliAuth()
127
+ if (auth && auth.kind === 'session') return { token: auth.token, origin: auth.origin }
128
+ process.stderr.write(
129
+ '\n rnx screenshot appstore capture needs a logged-in session.\n' +
130
+ ' run `rnx login` first (the captured slides upload to that account).\n\n',
131
+ )
132
+ rnxExit(1)
133
+ }
134
+
135
+ function isLoopback(hostname: string): boolean {
136
+ const h = hostname.replace(/^\[|\]$/g, '').toLowerCase()
137
+ return (
138
+ h === 'localhost' ||
139
+ h.endsWith('.localhost') ||
140
+ h === '::1' ||
141
+ /^127(?:\.\d{1,3}){3}$/.test(h)
142
+ )
143
+ }
144
+
145
+ // the build shell (/build/...) is served by the sootsim PAGES site, a DIFFERENT
146
+ // origin from the contrast API/auth origin. derive it from the api origin:
147
+ // loopback dev pairs the 3000(+offset) app with the 5173(+offset) shell (the
148
+ // inverse of engine origin.ts's pairedLocalAppOrigin); prod's contrast.dev is
149
+ // fronted by sootsim.com for /build. the engine itself resolves where the
150
+ // deck/capture PUTs go from wherever the shell loads, so we only need to land
151
+ // the shell on the right origin and seed the token there.
152
+ function deriveShellOrigin(apiOrigin: string): string {
153
+ const u = new URL(apiOrigin)
154
+ if (isLoopback(u.hostname)) {
155
+ const port = Number(u.port) || 3000
156
+ const offset = port - 3000
157
+ if (offset >= 0 && offset <= 1000) {
158
+ u.port = String(5173 + offset)
159
+ return u.origin
160
+ }
161
+ }
162
+ if (u.hostname === 'contrast.dev' || u.hostname.endsWith('.contrast.dev')) {
163
+ return rnxPublicBrand.origin
164
+ }
165
+ // single-origin deploys serve /build from the same origin as the api.
166
+ return apiOrigin
167
+ }
168
+
169
+ function loadAvg1(): number {
170
+ try {
171
+ return Number(execSync('sysctl -n vm.loadavg').toString().trim().split(/\s+/)[1]) || 0
172
+ } catch {
173
+ return 0
174
+ }
175
+ }
176
+
177
+ // a deck frame's persisted 3d scene (pose + look). present only on 3d slides.
178
+ interface DeckFrame {
179
+ threeScene?: Record<string, unknown> | null
180
+ screenRef?: string | null
181
+ }
182
+
183
+ // fetch the org deck's frames (in slide order) so a re-bake can restore each
184
+ // slide's EXACT authored pose before capturing. without this the 3d bake
185
+ // snapshots whatever pose the async focus-restore happens to have settled on —
186
+ // which races to the default pose and silently flattens a slide (real bug).
187
+ async function fetchDeckFrames(
188
+ apiOrigin: string,
189
+ token: string,
190
+ org: string,
191
+ repo: string,
192
+ ): Promise<DeckFrame[]> {
193
+ const res = await fetch(
194
+ `${apiOrigin}/api/sootsim/screenshot-decks?org=${encodeURIComponent(org)}&repo=${encodeURIComponent(repo)}`,
195
+ { headers: { authorization: `Bearer ${token}` } },
196
+ )
197
+ if (!res.ok) return []
198
+ const body = (await res.json().catch(() => null)) as {
199
+ decks?: Array<{ config?: { frames?: DeckFrame[] } }>
200
+ } | null
201
+ const frames: DeckFrame[] = []
202
+ for (const deck of body?.decks ?? []) {
203
+ if (Array.isArray(deck.config?.frames)) frames.push(...deck.config.frames)
204
+ }
205
+ return frames
206
+ }
207
+
208
+ // the bare saved screen (screenRef) decoded to a data url, so a device-free
209
+ // re-bake textures the posed phone with the slide's own screen.
210
+ async function fetchScreenImage(
211
+ apiOrigin: string,
212
+ token: string,
213
+ org: string,
214
+ ref: string,
215
+ ): Promise<string | null> {
216
+ const res = await fetch(
217
+ `${apiOrigin}/api/sootsim/screenshot-capture?ref=${encodeURIComponent(ref)}&org=${encodeURIComponent(org)}`,
218
+ { headers: { authorization: `Bearer ${token}` } },
219
+ )
220
+ if (!res.ok) return null
221
+ const buf = Buffer.from(await res.arrayBuffer())
222
+ return `data:image/png;base64,${buf.toString('base64')}`
223
+ }
224
+
225
+ // find the frame that hosts the screenshot plugin bridge. the build shell mounts
226
+ // a /sootsim/ iframe and the screenshot plugin (contexts:['host']) publishes
227
+ // screenshotControl on that frame's window — origin-agnostic detection by
228
+ // probing for the bridge rather than matching a url pattern.
229
+ async function waitForShellFrame(page: Page, timeoutMs: number): Promise<Frame> {
230
+ const deadline = Date.now() + timeoutMs
231
+ while (Date.now() < deadline) {
232
+ for (const frame of page.frames()) {
233
+ const ready = await frame
234
+ .evaluate(() => {
235
+ const ss = (
236
+ globalThis as { SootSim?: { bridges?: { screenshotControl?: unknown } } }
237
+ ).SootSim
238
+ return !!ss?.bridges?.screenshotControl
239
+ })
240
+ .catch(() => false)
241
+ if (ready) return frame
242
+ }
243
+ await page.waitForTimeout(250)
244
+ }
245
+ throw new Error(
246
+ 'screenshot bridge never appeared — the build shell did not boot screenshot mode (check --branch/--platform has a ready build, and you are logged in)',
247
+ )
248
+ }
249
+
250
+ async function getState(frame: Frame): Promise<ControlState> {
251
+ return frame.evaluate(async () => {
252
+ const ss = (
253
+ globalThis as {
254
+ SootSim?: { bridges?: { screenshotControl?: { getState: () => unknown } } }
255
+ }
256
+ ).SootSim
257
+ const control = ss?.bridges?.screenshotControl
258
+ if (!control) throw new Error('screenshotControl missing')
259
+ return control.getState() as unknown
260
+ }) as Promise<ControlState>
261
+ }
262
+
263
+ // ssMode=1 in the url makes the screenshot plugin auto-enter the mode at boot;
264
+ // wait for that, and only if it never lands, set it directly through the
265
+ // published settings bridge (the same store-routing the plugin uses — never a
266
+ // raw shell-command event, which is a cross-worker footgun).
267
+ async function ensureScreenshotMode(frame: Frame): Promise<void> {
268
+ await frame.evaluate(async () => {
269
+ const settings = (
270
+ globalThis as {
271
+ SootSim?: {
272
+ bridges?: {
273
+ settings?: {
274
+ get: () => { screenshotMode?: boolean; showFrame?: boolean }
275
+ set: (key: string, value: boolean) => void
276
+ }
277
+ }
278
+ }
279
+ }
280
+ ).SootSim?.bridges?.settings
281
+ const deadline = Date.now() + 3000
282
+ while (Date.now() < deadline) {
283
+ if (settings?.get?.()?.screenshotMode) return
284
+ await new Promise((r) => setTimeout(r, 50))
285
+ }
286
+ if (settings && !settings.get().screenshotMode) {
287
+ settings.set('screenshotMode', true)
288
+ if (!settings.get().showFrame) settings.set('showFrame', true)
289
+ }
290
+ })
291
+ }
292
+
293
+ // capture one slide into editable layers. for a 3d slide (has a persisted
294
+ // scene) we EXPLICITLY restore the scene + pose and texture the saved screen
295
+ // onto the phone BEFORE baking, rather than relying on the composer's async
296
+ // focus-restore having settled — that race silently snapshots the default pose
297
+ // and flattens the slide. for a 2d/fresh slide (no scene) this is a plain
298
+ // capture, unchanged.
299
+ async function captureSlide(
300
+ frame: Frame,
301
+ index: number,
302
+ scene: Record<string, unknown> | null,
303
+ screenDataUrl: string | null,
304
+ ): Promise<void> {
305
+ await frame.evaluate(
306
+ async ([n, sc, screen]) => {
307
+ const ss = (
308
+ globalThis as {
309
+ SootSim?: {
310
+ bridges?: {
311
+ screenshotControl?: {
312
+ capture: (ref: number) => Promise<unknown>
313
+ focusSlide: (ref: number) => unknown
314
+ }
315
+ threeMode?: {
316
+ configure?: (opts: unknown) => unknown
317
+ setScreenImage?: (dataUrl: string | null) => void
318
+ captureStage?: (opts?: unknown) => unknown
319
+ getState?: () => { ready?: boolean } | null
320
+ }
321
+ }
322
+ }
323
+ }
324
+ ).SootSim
325
+ const control = ss?.bridges?.screenshotControl
326
+ if (!control) throw new Error('screenshotControl missing')
327
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
328
+
329
+ if (sc) {
330
+ // focus → composer auto-toggles 3d + mounts the stage (whose bridge
331
+ // methods land a few frames later).
332
+ control.focusSlide(n as number)
333
+ const deadline = Date.now() + 30000
334
+ let three = ss?.bridges?.threeMode
335
+ while (Date.now() < deadline) {
336
+ three = ss?.bridges?.threeMode
337
+ if (three?.setScreenImage && three?.captureStage && three?.getState?.()?.ready)
338
+ break
339
+ await sleep(150)
340
+ }
341
+ await sleep(1000)
342
+ const s = sc as Record<string, unknown>
343
+ // re-apply the EXACT saved scene+pose so the bake's getPose() snapshot
344
+ // reads the authored pose, not a transient.
345
+ three?.configure?.({
346
+ colorway: s.colorway,
347
+ background: s.background,
348
+ ...(s.customGradient ? { customGradient: s.customGradient } : {}),
349
+ setting: s.setting,
350
+ focus: s.focus,
351
+ environment: s.environmentActive
352
+ ? { pins: s.environmentPins ?? {}, effects: s.environmentEffects ?? {} }
353
+ : false,
354
+ pose: {
355
+ phonePosition: s.phonePosition,
356
+ phoneRotation: s.phoneRotation,
357
+ cameraPosition: s.cameraPosition,
358
+ target: s.target,
359
+ },
360
+ })
361
+ await sleep(900)
362
+ if (screen && three?.setScreenImage) {
363
+ three.setScreenImage(screen as string)
364
+ await sleep(1200)
365
+ }
366
+ }
367
+
368
+ await control.capture(n as number)
369
+ },
370
+ [index, scene, screenDataUrl] as const,
371
+ )
372
+ }
373
+
374
+ export async function runScreenshotsCapture(args: string[]): Promise<number> {
375
+ if (args.includes('--help') || args.includes('-h')) {
376
+ console.log(HELP)
377
+ return 0
378
+ }
379
+ const flags = parseFlags(args)
380
+ if (!flags.org || !flags.repo) {
381
+ process.stderr.write(' --org and --repo are required (see --help)\n')
382
+ return 1
383
+ }
384
+
385
+ const { token, origin } = resolveSessionAuth()
386
+ const apiOrigin = (flags.apiOrigin ?? origin).replace(/\/$/, '')
387
+ const shellOrigin = (flags.shellOrigin ?? deriveShellOrigin(apiOrigin)).replace(
388
+ /\/$/,
389
+ '',
390
+ )
391
+
392
+ // fetch the deck once: drives both the clobber guard and the per-slide scene
393
+ // restore (so a 3d re-bake keeps each slide's exact authored pose).
394
+ const deckFrames = await fetchDeckFrames(apiOrigin, token, flags.org, flags.repo)
395
+
396
+ // guard against clobbering a non-empty deck (PUT replaces the whole deck).
397
+ if (!flags.dryRun && !flags.force && deckFrames.length > 0) {
398
+ process.stderr.write(
399
+ ` refusing to overwrite the existing ${flags.org}/${flags.repo} deck ` +
400
+ `(${deckFrames.length} slide(s)). re-run with --force to replace it, or --dry-run to preview.\n`,
401
+ )
402
+ return 1
403
+ }
404
+
405
+ // heavy-process guard: a headless engine + canvaskit boot pegs cores; bail if
406
+ // the box is already saturated rather than piling on.
407
+ const load = loadAvg1()
408
+ const cores = Number(execSync('sysctl -n hw.ncpu').toString().trim()) || 8
409
+ if (load > cores) {
410
+ process.stderr.write(
411
+ ` load average ${load.toFixed(1)} exceeds ${cores} cores — tear down other work first.\n`,
412
+ )
413
+ return 1
414
+ }
415
+
416
+ const url =
417
+ `${shellOrigin}/build/${encodeURIComponent(flags.org)}/${encodeURIComponent(flags.repo)}/` +
418
+ `${encodeURIComponent(flags.branch)}?platform=${encodeURIComponent(flags.platform)}&ssMode=1`
419
+
420
+ const profileDir = mkdtempSync(join(tmpdir(), 'rnx-capture-'))
421
+ const { browser: ctx, reapNow } = await launchReapedChromeScoped(
422
+ (options) => chromium.launchPersistentContext(profileDir, options),
423
+ {
424
+ headless: true,
425
+ viewport: { width: 393, height: 852 },
426
+ deviceScaleFactor: 3,
427
+ args: ['--hide-scrollbars'],
428
+ },
429
+ )
430
+
431
+ // seed the login session into every frame's localStorage BEFORE any script
432
+ // runs, so the shell boots already authenticated and the auto-upload PUTs
433
+ // carry the bearer. this is the exact key getSession() reads.
434
+ await ctx.addInitScript((t: string) => {
435
+ try {
436
+ localStorage.setItem('sootsim.session.token', t)
437
+ } catch {}
438
+ }, token)
439
+
440
+ // track the auto-upload PUTs so we can PROVE the refs round-tripped (not just
441
+ // that we captured locally). in dry-run, abort them so nothing is written.
442
+ const uploads: Array<{ kind: 'deck' | 'capture'; status: number }> = []
443
+ const page = ctx.pages()[0] ?? (await ctx.newPage())
444
+ if (flags.dryRun) {
445
+ // abort only the WRITES (PUT). the GET that loads the org deck must go
446
+ // through, or the shell falls back to an empty default project and there's
447
+ // nothing to capture.
448
+ await page.route(/\/api\/sootsim\/screenshot-(decks|capture)/, (route) =>
449
+ route.request().method() === 'PUT' ? route.abort() : route.continue(),
450
+ )
451
+ } else {
452
+ page.on('response', (res) => {
453
+ const u = res.url()
454
+ const method = res.request().method()
455
+ if (method !== 'PUT') return
456
+ if (u.includes('/api/sootsim/screenshot-decks'))
457
+ uploads.push({ kind: 'deck', status: res.status() })
458
+ else if (u.includes('/api/sootsim/screenshot-capture'))
459
+ uploads.push({ kind: 'capture', status: res.status() })
460
+ })
461
+ }
462
+
463
+ const pageErrors: string[] = []
464
+ page.on('pageerror', (e) => pageErrors.push(e.message))
465
+
466
+ // the build page forwards ssOrg/ssRepo into the shell, which GETs the team
467
+ // deck on boot. wait for that load so we capture against the real deck, not
468
+ // the empty default project the shell starts with.
469
+ const deckLoaded = page
470
+ .waitForResponse(
471
+ (r) =>
472
+ r.url().includes('/api/sootsim/screenshot-decks') &&
473
+ r.request().method() === 'GET',
474
+ { timeout: flags.timeoutMs },
475
+ )
476
+ .catch(() => null)
477
+
478
+ try {
479
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: flags.timeoutMs })
480
+ const frame = await waitForShellFrame(page, flags.timeoutMs)
481
+ await ensureScreenshotMode(frame)
482
+ // let the org deck GET resolve + the projects store apply it before reading.
483
+ await deckLoaded
484
+ await page.waitForTimeout(800)
485
+
486
+ const before = await getState(frame)
487
+ if (before.slides.length === 0) {
488
+ process.stderr.write(
489
+ ' the deck has no slides to capture — add slides in the composer first.\n',
490
+ )
491
+ return 1
492
+ }
493
+ const targets = flags.slides
494
+ ? before.slides.filter((s) => flags.slides!.includes(s.index))
495
+ : before.slides
496
+
497
+ console.log(
498
+ ` ${before.projectName} on ${before.deviceModel} — capturing ${targets.length}/${before.slides.length} slide(s)${flags.dryRun ? ' (dry-run, no upload)' : ''}`,
499
+ )
500
+
501
+ for (const slide of targets) {
502
+ try {
503
+ // restore the slide's authored 3d scene + its saved bare screen so the
504
+ // re-bake keeps the exact pose and shows the right screen (pose-accurate,
505
+ // device-free). 2d/fresh slides have no scene and capture live.
506
+ const deckFrame = deckFrames[slide.index - 1]
507
+ const scene = deckFrame?.threeScene ?? null
508
+ const screenDataUrl =
509
+ scene && deckFrame?.screenRef
510
+ ? await fetchScreenImage(apiOrigin, token, flags.org, deckFrame.screenRef)
511
+ : null
512
+ await captureSlide(frame, slide.index, scene, screenDataUrl)
513
+ console.log(` ✓ slide ${slide.index} "${slide.title.slice(0, 40)}"`)
514
+ } catch (err) {
515
+ console.log(
516
+ ` ✗ slide ${slide.index} "${slide.title.slice(0, 40)}" — ${(err as Error).message}`,
517
+ )
518
+ }
519
+ }
520
+
521
+ // let the debounced deck (800ms) + capture (400ms) PUTs flush.
522
+ if (!flags.dryRun) await page.waitForTimeout(2500)
523
+
524
+ const after = await getState(frame)
525
+ const captured = after.slides.filter((s) => targets.some((t) => t.id === s.id))
526
+
527
+ // optional local PNG export.
528
+ const written: string[] = []
529
+ if (flags.out) {
530
+ const outDir = isAbsolute(flags.out) ? flags.out : resolve(process.cwd(), flags.out)
531
+ for (const slide of captured) {
532
+ if (!slide.captureRef) continue
533
+ const png = await frame.evaluate(async (n: number) => {
534
+ const ss = (
535
+ globalThis as {
536
+ SootSim?: {
537
+ bridges?: {
538
+ screenshotControl?: {
539
+ exportSlide: (
540
+ ref: number,
541
+ ) => Promise<{ name: string; dataUrl: string }>
542
+ }
543
+ }
544
+ }
545
+ }
546
+ ).SootSim
547
+ const control = ss?.bridges?.screenshotControl
548
+ if (!control) throw new Error('screenshotControl missing')
549
+ return control.exportSlide(n)
550
+ }, slide.index)
551
+ const m = png.dataUrl.match(/^data:[^;]+;base64,(.+)$/)
552
+ if (m) {
553
+ const path = join(outDir, png.name)
554
+ mkdirSync(dirname(path), { recursive: true })
555
+ writeFileSync(path, Buffer.from(m[1], 'base64'))
556
+ written.push(path)
557
+ }
558
+ }
559
+ }
560
+
561
+ const editable = captured.filter((s) => s.captureRef && s.cleanRef)
562
+ const summary = {
563
+ org: flags.org,
564
+ repo: flags.repo,
565
+ branch: flags.branch,
566
+ dryRun: flags.dryRun,
567
+ slides: captured.map((s) => ({
568
+ index: s.index,
569
+ title: s.title,
570
+ captureRef: !!s.captureRef,
571
+ cleanRef: !!s.cleanRef,
572
+ screenRef: !!s.screenRef,
573
+ captureMode: s.captureMode,
574
+ })),
575
+ editableCount: editable.length,
576
+ uploads: flags.dryRun ? 'skipped (dry-run)' : uploads,
577
+ written,
578
+ pageErrors: pageErrors.slice(0, 5),
579
+ }
580
+
581
+ if (flags.json) {
582
+ console.log(JSON.stringify(summary, null, 2))
583
+ } else {
584
+ console.log(
585
+ `\n ${editable.length}/${captured.length} slide(s) now have editable layers (captureRef + cleanRef)`,
586
+ )
587
+ const flat = captured.filter((s) => s.captureRef && !s.cleanRef)
588
+ if (flat.length) {
589
+ console.log(
590
+ ` ⚠ ${flat.length} slide(s) baked a flat capture only (no cleanRef) — typically 3d slides whose scene didn't restore for a re-bake.`,
591
+ )
592
+ }
593
+ if (!flags.dryRun) {
594
+ const ok = uploads.filter((u) => u.status >= 200 && u.status < 300).length
595
+ console.log(` uploads: ${ok}/${uploads.length} PUT(s) succeeded`)
596
+ }
597
+ if (written.length)
598
+ console.log(` wrote ${written.length} local PNG(s) to ${flags.out}`)
599
+ if (pageErrors.length)
600
+ console.log(` ⚠ ${pageErrors.length} page error(s) during capture`)
601
+ }
602
+ return 0
603
+ } finally {
604
+ await ctx.close().catch(() => {})
605
+ reapNow()
606
+ rmSync(profileDir, { recursive: true, force: true })
607
+ }
608
+ }