peachsim 0.1.1481 → 0.1.1619
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/README.md +56 -13
- package/cli/app-catalog.ts +7 -5
- package/cli/app-config.ts +47 -8
- package/cli/app-fonts.ts +84 -49
- package/cli/app-stubs.ts +433 -0
- package/cli/bin.ts +15 -2
- package/cli/bridge-flow-runner.ts +383 -123
- package/cli/cloud-client.ts +26 -25
- package/cli/cloud-test-tunnel.ts +2 -0
- package/cli/command-registry.ts +2 -1
- package/cli/commands/agent-indicator-mode.ts +39 -0
- package/cli/commands/box/bundle-plane.ts +4 -0
- package/cli/commands/box.ts +16 -23
- package/cli/commands/control.ts +221 -126
- package/cli/commands/daemon.ts +99 -24
- package/cli/commands/debug.ts +31 -3
- package/cli/commands/detect.ts +1 -1
- package/cli/commands/device.ts +6 -8
- package/cli/commands/diagnose.ts +2 -0
- package/cli/commands/do-chain.ts +2 -2
- package/cli/commands/doctor.ts +1030 -0
- package/cli/commands/flow.ts +447 -406
- package/cli/commands/inspect/actions.ts +2 -0
- package/cli/commands/inspect/core.ts +138 -46
- package/cli/commands/inspect/env.ts +10 -0
- package/cli/commands/inspect/list.ts +25 -8
- package/cli/commands/inspect/memory.ts +9 -0
- package/cli/commands/inspect/settling.ts +219 -66
- package/cli/commands/inspect/shared.ts +53 -12
- package/cli/commands/inspect/wait-event.ts +0 -1
- package/cli/commands/inspect/wait.ts +2 -2
- package/cli/commands/inspect.ts +51 -16
- package/cli/commands/install-desktop.ts +3 -2
- package/cli/commands/kotlin.ts +81 -22
- package/cli/commands/login.ts +1 -1
- package/cli/commands/maestro-compare.ts +41 -5
- package/cli/commands/maestro-generate.ts +11 -7
- package/cli/commands/maestro.ts +43 -15
- package/cli/commands/mode.ts +13 -1
- package/cli/commands/no-bridge-hint.ts +26 -3
- package/cli/commands/perf-record.ts +296 -0
- package/cli/commands/perf.ts +6 -1
- package/cli/commands/platform.ts +15 -4
- package/cli/commands/preview.ts +1 -1
- package/cli/commands/record.ts +37 -6
- package/cli/commands/report-issue.ts +2 -1
- package/cli/commands/runtime.ts +9 -11
- package/cli/commands/screenshots.ts +4 -3
- package/cli/commands/serve.ts +30 -2
- package/cli/commands/setup.ts +32 -7
- package/cli/commands/skills.ts +16 -43
- package/cli/commands/swift.ts +22 -3
- package/cli/commands/upgrade.ts +7 -6
- package/cli/commands/upload.ts +118 -69
- package/cli/commands/window.ts +449 -0
- package/cli/current-sim.ts +1 -2
- package/cli/desktop-companion.ts +84 -10
- package/cli/device-option.ts +8 -0
- package/cli/drivers/desktop.ts +3 -1
- package/cli/drivers/playwright-sim-host.ts +30 -11
- package/cli/drivers/playwright.ts +1 -4
- package/cli/flow-export.ts +2 -2
- package/cli/flow-file.ts +19 -18
- package/cli/internal-child.ts +7 -6
- package/cli/maestro-compare.ts +28 -6
- package/cli/maestro-js.ts +2 -1
- package/cli/main.ts +25 -4
- package/cli/material-symbol-fonts.ts +171 -0
- package/cli/outbound-endpoints.ts +132 -54
- package/cli/parse-args.ts +7 -0
- package/cli/preview-capture.ts +12 -0
- package/cli/privacy.ts +1 -1
- package/cli/run-registry.ts +5 -2
- package/cli/runtime-compat.ts +16 -0
- package/cli/telemetry.ts +6 -3
- package/cli/ui.ts +5 -0
- package/cli/ws-bridge.ts +121 -62
- package/detox/attempt-events.cjs +762 -0
- package/detox/attempt-events.d.cts +106 -0
- package/detox/gestures.ts +115 -2
- package/detox/index.ts +762 -426
- package/detox/jest-environment.cjs +86 -0
- package/detox/jest-preset.cjs +3 -1
- package/detox/jest-setup-after-env.cjs +1 -0
- package/detox/matchers.ts +3 -6
- package/detox/motion-change.cjs +259 -0
- package/detox/motion-change.d.cts +73 -0
- package/detox/proof-frame.cjs +83 -9
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +15 -5
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +3 -1
- package/dist-lib/bridge-contract-input.cjs +1 -1
- package/dist-lib/bridge-contract-input.mjs +1 -1
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/bundle-channel.cjs +5950 -1775
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cloud-contract.cjs +6 -1
- package/dist-lib/cloud-contract.mjs +5 -1
- package/dist-lib/cloud-test-args.cjs +1 -1
- package/dist-lib/cloud-test-args.mjs +1 -1
- package/dist-lib/cloud.cjs +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +34 -3
- package/dist-lib/config.mjs +33 -3
- package/dist-lib/detox/index.cjs +3443 -564
- package/dist-lib/detox/jest-environment.cjs +86 -0
- package/dist-lib/detox/jest-preset.cjs +3 -1
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/expo-swift-bundle.cjs +5518 -1446
- package/dist-lib/expo-swift-metadata.cjs +21 -13
- package/dist-lib/expo-swift-metadata.mjs +21 -13
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +21728 -16808
- package/dist-lib/host/fetch-proxy-handler.cjs +177 -102
- package/dist-lib/host/fetch-proxy-overrides.cjs +84 -4
- package/dist-lib/host/fetch-proxy-overrides.mjs +74 -3
- package/dist-lib/host/replacement-module-handler.cjs +16 -15
- package/dist-lib/host/websocket-proxy.cjs +243 -6
- package/dist-lib/index.cjs +6063 -4113
- package/dist-lib/jump-to-source-babel.cjs +73 -35
- package/dist-lib/jump-to-source-native.cjs +118 -10
- package/dist-lib/kotlin-host.cjs +264 -121
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro-fingerprint-registry.cjs +1 -1
- package/dist-lib/metro-fingerprint-registry.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +147 -99
- package/dist-lib/metro-production-bundle.mjs +142 -99
- package/dist-lib/metro.cjs +4249 -360
- package/dist-lib/native-app-identity-project.cjs +318 -131
- package/dist-lib/native-app-identity-project.mjs +317 -131
- package/dist-lib/native-app-identity.cjs +322 -33
- package/dist-lib/native-app-identity.mjs +319 -32
- package/dist-lib/native-firebase-metadata.cjs +1 -1
- package/dist-lib/native-firebase-metadata.mjs +1 -1
- package/dist-lib/plist.cjs +1 -1
- package/dist-lib/plist.mjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +11 -1
- package/dist-lib/react-native-host-modules.mjs +11 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +127 -42
- package/dist-lib/sdk.cjs +311 -103
- package/dist-lib/sdk.mjs +311 -103
- package/dist-lib/skills.cjs +18344 -11121
- package/dist-lib/swift-compile.cjs +3291 -544
- package/dist-lib/swift-compile.mjs +3227 -478
- package/dist-lib/swift-package.cjs +25 -3
- package/dist-lib/swift-package.mjs +22 -2
- package/dist-lib/swift-submit.cjs +2 -2
- package/dist-lib/swift-submit.mjs +2 -2
- package/dist-lib/swift-toolchain.cjs +1 -1
- package/dist-lib/swift.cjs +1 -1
- package/dist-lib/vite-swift.cjs +5523 -1428
- package/dist-lib/vite.cjs +251 -60
- package/dist-lib/worklet-executable.cjs +18 -4
- package/dist-lib/worklet-executable.mjs +18 -4
- package/install.sh +8 -3
- package/kotlin/AndroidViewSources/android/graphics/Canvas.kt +2 -0
- package/kotlin/AndroidViewSources/android/graphics/Paint.kt +18 -0
- package/kotlin/AndroidViewSources/dev/peach/app/JavaFormat.kt +16 -8
- package/kotlin/AppSources/android/view/Window.kt +4 -1
- package/kotlin/AppSources/android/window/BackEvent.kt +24 -0
- package/kotlin/AppSources/android/window/OnBackAnimationCallback.kt +7 -0
- package/kotlin/AppSources/android/window/OnBackInvokedDispatcher.kt +37 -1
- package/kotlin/AppSources/androidx/activity/BackEventCompat.kt +5 -0
- package/kotlin/AppSources/androidx/activity/OnBackPressedDispatcher.kt +8 -1
- package/kotlin/AppSources/dev/peach/app/PeachApp.kt +14 -0
- package/kotlin/CompilerSources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar +1 -0
- package/kotlin/CompilerSources/PeachJniIncoming.kt +243 -0
- package/kotlin/CompilerSources/PeachJniPlugin.kt +116 -0
- package/kotlin/ComposeSources/PeachComposeHost.kt +58 -32
- package/kotlin/ComposeSources/PeachMagnifier.kt +185 -0
- package/kotlin/ExpoComposeSources/PeachAndroidView.kt +16 -0
- package/kotlin/JniPlatformSources/androidx/annotation/CallSuper.kt +5 -0
- package/kotlin/JniPlatformSources/com/facebook/proguard/annotations/DoNotStrip.kt +5 -0
- package/kotlin/JniPlatformSources/dalvik/annotation/optimization/FastNative.kt +5 -0
- package/kotlin/JniPlatformSources/kotlin/Synchronized.kt +6 -0
- package/kotlin/JniPlatformSources/kotlin/jvm/JvmField.kt +5 -0
- package/kotlin/JniPlatformSources/kotlin/jvm/JvmStatic.kt +5 -0
- package/kotlin/JniSources/com/facebook/jni/HybridData.kt +68 -0
- package/kotlin/JniSources/dev/peach/jni/IncomingBindings.kt +157 -0
- package/kotlin/JniSources/dev/peach/jni/NativeBindings.kt +79 -0
- package/kotlin/JniTransportSources/dev/peach/jni/integration/JniTransport.kt +91 -0
- package/kotlin/README.md +18 -0
- package/kotlin/Sources/java/util/IllegalFormatCodePointException.kt +10 -0
- package/kotlin/Sources/java/util/IllegalFormatException.kt +5 -0
- package/kotlin/fixtures/counter/CounterApp.native.tsx +30 -3
- package/kotlin/fixtures/counter/CounterScreen.d.kt.ts +1 -0
- package/kotlin/fixtures/counter/CounterScreen.kt +2 -1
- package/kotlin/fixtures/counter/package.json +15 -2
- package/kotlin/fixtures/counter/vite.config.ts +11 -1
- package/kotlin/fork/NOTICE +1 -0
- package/kotlin/fork/README.md +5 -4
- package/kotlin/fork/compose-worker-globals.patch +629 -0
- package/kotlin/fork/fork.json +1 -1
- package/kotlin/toolchain.json +3 -3
- package/package.json +1 -1
- package/scripts/dev-server-scanner.ts +166 -81
- package/skills/contrast/SKILL.md +4 -4
- package/skills/peach-debug/SKILL.md +1 -1
- package/skills/peach-perf/SKILL.md +25 -0
- package/skills/peach-setup/SKILL.md +47 -13
- package/skills/peach-test/SKILL.md +2 -3
- package/src/agent-identity.ts +18 -0
- package/src/assetCatalogContract.ts +31 -3
- package/src/auth/shared-session.ts +3 -2
- package/src/bridge-constants.ts +1 -1
- package/src/bridge-contract.ts +5 -0
- package/src/bundle-channel.mjs +38 -7
- package/src/bundleGlobalFooter.ts +70 -20
- package/src/cloud-contract.ts +11 -0
- package/src/config.ts +47 -1
- package/src/configured-bundle-producer.ts +4 -1
- package/src/desktop-host-delivery.ts +4 -3
- package/src/expo-kotlin-host.ts +7 -2
- package/src/guest-bundle.ts +15 -8
- package/src/host/agent-host.ts +2 -4
- package/src/host/bridge-host.ts +260 -119
- package/src/host/fetch-proxy-handler.ts +237 -146
- package/src/host/fetch-proxy-overrides.ts +118 -0
- package/src/host/fetch-proxy-transport.ts +77 -63
- package/src/host/lease-owner.test.ts +18 -1
- package/src/host/lease-owner.ts +4 -5
- package/src/host/loopback-ports.ts +34 -0
- package/src/host/replacement-module-handler.ts +24 -15
- package/src/host/server-scan-handler.ts +87 -0
- package/src/host/staged-host-files.ts +92 -0
- package/src/host/websocket-proxy.ts +271 -5
- package/src/installed-package-versions.ts +66 -0
- package/src/jump-to-source-babel.ts +97 -89
- package/src/jump-to-source-native.ts +109 -11
- package/src/kotlin-host.ts +144 -27
- package/src/kotlin-module.ts +52 -0
- package/src/kotlinDiscoveryContract.ts +27 -0
- package/src/metro-layout-parser.ts +78 -0
- package/src/metro-plugin.ts +129 -39
- package/src/metro-production-bundle.ts +140 -139
- package/src/native-app-identity-project.ts +103 -5
- package/src/native-app-identity.ts +173 -11
- package/src/native-firebase-config.ts +83 -80
- package/src/native-seam-manifest.ts +0 -44
- package/src/nativeAppIconContract.ts +137 -0
- package/src/nativeViewSourceContract.ts +49 -0
- package/src/nativeViewWebContract.ts +41 -0
- package/src/nitro-bundle.ts +43 -0
- package/src/nitro-compile.ts +107 -0
- package/src/nitro-source.ts +310 -0
- package/src/nitroContract.ts +51 -0
- package/src/one-widget-metadata.ts +23 -3
- package/src/oneWidgetContract.ts +36 -3
- package/src/packageVersionsContract.ts +52 -0
- package/src/process-group.ts +306 -0
- package/src/process-liveness.ts +24 -0
- package/src/process-output.ts +21 -2
- package/src/react-devtools-frontend-react.ts +45 -0
- package/src/react-native-host-modules.ts +10 -0
- package/src/serverDiscoveryContract.ts +41 -0
- package/src/skills/builtin/maestro-test.ts +2 -4
- package/src/swift-compile.ts +3195 -650
- package/src/swift-design-time.ts +552 -0
- package/src/swift-distribution-contract.ts +11 -0
- package/src/swift-distribution.lock.json +46 -11
- package/src/swift-host.ts +144 -30
- package/src/swift-icu.ts +10 -2
- package/src/swift-objc-sdk.ts +726 -0
- package/src/swift-objc-standin.ts +441 -0
- package/src/swift-package.ts +25 -0
- package/src/swift-packages.ts +25 -10
- package/src/swift-standin-config.ts +94 -0
- package/src/swift-standin-emit.ts +5 -3
- package/src/swift-standins.ts +225 -81
- package/src/swift-submit.ts +2 -1
- package/src/swiftContract.ts +4 -0
- package/src/target-matcher.ts +67 -0
- package/src/visit-parsed-program.ts +21 -0
- package/src/vite-plugin-one.ts +65 -17
- package/src/vite-plugin-swift.ts +1 -0
- package/src/vite-plugin.ts +168 -70
- package/src/worker-system-chunks.ts +89 -10
- package/src/worklet-executable.ts +21 -3
- package/src/worklets-babel.ts +85 -13
- package/src/xcode-project.ts +81 -134
- package/src/xcode-target-members.ts +107 -0
- package/cli/commands/compat.ts +0 -395
package/README.md
CHANGED
|
@@ -12,7 +12,8 @@ drivers, and library exports; its only command, `npx peachsim`, runs that same
|
|
|
12
12
|
installer from the copy of `install.sh` it ships.
|
|
13
13
|
|
|
14
14
|
The executable updates itself with `peach upgrade`, which then restarts a running
|
|
15
|
-
background daemon onto the new executable
|
|
15
|
+
background daemon onto the new executable. A new runtime needs no restart: the
|
|
16
|
+
next new sim opens on it while open sims keep theirs. Successful interactive human
|
|
16
17
|
commands refresh a cached stable CLI version in a detached process at most once
|
|
17
18
|
every 20 hours. A newer version produces one short notice on stderr, once per
|
|
18
19
|
version and no more than weekly. CI, agents, JSON output, pipes, and
|
|
@@ -162,6 +163,11 @@ a local foreground bridge when no daemon is reachable, then opens a `WsBridge`
|
|
|
162
163
|
`cli/commands/inspect/*` and are re-exported as the programmatic `peachsim/sdk`;
|
|
163
164
|
the Maestro runner is `cli/bridge-flow-runner.ts`; the Detox compat driver
|
|
164
165
|
is in `detox/`. Test and screenshot paths additionally use `cli/drivers/*`.
|
|
166
|
+
Opening a simulator and waiting for reconnection share one app-tree readiness
|
|
167
|
+
poller. Its nominal wait budget includes WebSocket handshakes, target lookup,
|
|
168
|
+
evaluation and poll pauses; unanswered commands do not restart the deadline.
|
|
169
|
+
A timeout reports the last node count, missing test hook or bridge error and
|
|
170
|
+
elapsed time. Registering a simulator alone does not satisfy readiness.
|
|
165
171
|
Browser sims launch in an isolated Playwright Chrome for Testing profile.
|
|
166
172
|
An explicit `--base-url` keeps that shell's origin and uses its assets without
|
|
167
173
|
installing an active runtime. A bridge started for that shell skips automatic
|
|
@@ -185,6 +191,12 @@ config dictionary is empty and the app can select its own public defaults.
|
|
|
185
191
|
Preserve failures from those endpoints rather than remapping them to a successful
|
|
186
192
|
local response. The same flag is available on `peach flow`.
|
|
187
193
|
|
|
194
|
+
A project with several Xcode application targets names the one to run, as an
|
|
195
|
+
Xcode scheme does: `peach open <app-port> --ios-target <name>` (also on
|
|
196
|
+
`peach flow` and `peach maestro test`) reads that target's version, build,
|
|
197
|
+
bundle id and display name. Without it, such an app served by a plain Metro
|
|
198
|
+
reads the runtime defaults.
|
|
199
|
+
|
|
188
200
|
**The daemon is one process** — `SootSimBridgeHost` (`src/host/bridge-host.ts`).
|
|
189
201
|
`peach serve` runs it in the foreground; the background daemon is the *same
|
|
190
202
|
process* wrapped with autostart and the `~/.peach/daemon.json` lockfile. It is
|
|
@@ -196,6 +208,14 @@ guest-app fetch/WebSocket traffic so the cross-origin tenant worker can reach
|
|
|
196
208
|
localhost. The `AgentHost` extension owns one FIFO reader per agent session and
|
|
197
209
|
fans agent events out to every subscriber.
|
|
198
210
|
|
|
211
|
+
`peach open` auto-installs a published runtime only when the runtime directory
|
|
212
|
+
is empty and no live bridge already owns the runtime. When a live bridge is
|
|
213
|
+
performing its first download, `open` waits for that bridge's bootstrap state
|
|
214
|
+
to finish, then uses its installed runtime. Without a live bridge, runtime
|
|
215
|
+
files without a selected runtime containing `index.html` still cause a refusal.
|
|
216
|
+
Finish copying the runtime, then select it with `peach runtime use <version>`.
|
|
217
|
+
Write the active marker after staging is complete when preparing an isolated home.
|
|
218
|
+
|
|
199
219
|
During source development, each Vite bridge owns one
|
|
200
220
|
`~/.peach/dev-bridges/<port>.json` heartbeat record. CLI defaults select the
|
|
201
221
|
current `PORT_OFFSET`, then the current checkout, then the canonical `:7668`
|
|
@@ -203,12 +223,13 @@ bridge, so concurrent worktrees cannot replace or delete one another's address.
|
|
|
203
223
|
Each Vite shell also publishes its exact bridge at `/__sootsim/bridge`, so a
|
|
204
224
|
launcher that reuses a shell from another isolated `PEACH_HOME` stays in that
|
|
205
225
|
shell's bridge world without reading the other process's lockfile directory.
|
|
206
|
-
An explicit `--port`
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
SDK `connect({ sim })
|
|
210
|
-
|
|
211
|
-
|
|
226
|
+
An explicit CLI `--port` or SDK port connects directly to that bridge without
|
|
227
|
+
scanning other worlds or requiring a local heartbeat record. That bridge handles
|
|
228
|
+
the named sim and reports an error if it is absent.
|
|
229
|
+
Without an explicit port, `--sim`, SDK `connect({ sim })`, and batched `do --then`
|
|
230
|
+
actions discover the sim's owning world. Each world probe has one two-second
|
|
231
|
+
wall deadline covering both connection and response. An inferred default port
|
|
232
|
+
does not override the named sim's world.
|
|
212
233
|
|
|
213
234
|
**The sim** is a browser/Electron *page* launched by a CLI driver. Inside that
|
|
214
235
|
page run three workers: the **shell worker** owns device and native state, the
|
|
@@ -267,6 +288,7 @@ therefore runs before assertions in each flow.
|
|
|
267
288
|
curl -fsSL https://peachsim.com/install.sh | sh # or: npx peachsim
|
|
268
289
|
peach # open ConnectRN and choose a running app in the simulator
|
|
269
290
|
peach open /settings # dispatch a React Native deep link into that app
|
|
291
|
+
peach open /settings --no-describe --quiet # keep open options with deep links
|
|
270
292
|
```
|
|
271
293
|
|
|
272
294
|
Start Metro, Expo, One, or another supported React Native development server
|
|
@@ -389,6 +411,8 @@ outside `node_modules`, `dist`, and `build`. Keep it before
|
|
|
389
411
|
`react-native-worklets/plugin` in a custom Babel stack that lists both plugins.
|
|
390
412
|
Apps using `babel-preset-expo` omit the explicit Worklets plugin because the
|
|
391
413
|
preset configures it automatically.
|
|
414
|
+
Runtime Fragment aliases are excluded before element creation. Component tag
|
|
415
|
+
expressions are evaluated once, and ordinary components retain their locations.
|
|
392
416
|
|
|
393
417
|
For One's Rolldown native bundler, use the native transform in `vite.config.ts`:
|
|
394
418
|
|
|
@@ -421,13 +445,14 @@ peach open 8081 # load a running metro/expo dev server
|
|
|
421
445
|
peach open /settings # route via React Native Linking
|
|
422
446
|
peach describe # dump UI tree
|
|
423
447
|
peach get errors 5 # recent runtime failures
|
|
424
|
-
peach
|
|
448
|
+
peach doctor # check what of this app Peach runs
|
|
425
449
|
peach support report "…" # preview an opt-in compatibility report
|
|
426
450
|
peach find --testid cta # inspect one node deeply
|
|
427
451
|
peach debug enable animated,layout
|
|
428
452
|
peach debug trace shell on 240
|
|
429
453
|
peach do tap-text "..." # interact
|
|
430
454
|
peach do tap email --then type user@example.com --then tap submit
|
|
455
|
+
peach shell window --x 120 --y 140 --scale fit # place/scale desktop sim window
|
|
431
456
|
peach debug snapshot before
|
|
432
457
|
peach debug snapshot after
|
|
433
458
|
peach debug diff before after
|
|
@@ -499,6 +524,11 @@ options and enables two integrations:
|
|
|
499
524
|
The URL helpers request ordinary Metro graphs: `dev=false&minify=true` for
|
|
500
525
|
production and `dev=true&minify=false` for development.
|
|
501
526
|
|
|
527
|
+
Metro registration readers retain the original factory bytes and module IDs.
|
|
528
|
+
Empty dependencies may be an empty array, an omitted production argument, or a
|
|
529
|
+
null development argument before the module name. Identity validation and
|
|
530
|
+
fingerprinting treat each as zero dependencies and reject malformed arguments.
|
|
531
|
+
|
|
502
532
|
```js
|
|
503
533
|
const {
|
|
504
534
|
toPeachProductionBundleUrl,
|
|
@@ -531,6 +561,16 @@ peach detox # auto-discovers e2e/ tests and launches a shell
|
|
|
531
561
|
peach detox init # scaffold a config + sample test
|
|
532
562
|
```
|
|
533
563
|
|
|
564
|
+
`device.launchApp()` reuses a running guest by default. With a URL,
|
|
565
|
+
`device.launchApp({ url })` and `device.openURL({ url })` deliver a Linking
|
|
566
|
+
URL event to the running guest through the shell's deep-link bridge. Warm `launchApp` brings the guest forward; `openURL` retains its
|
|
567
|
+
foreground or background state. Both retain its browser session and app state.
|
|
568
|
+
`sourceApp` does not add fields to React Native's `{ url }` event. A
|
|
569
|
+
stopped guest rejects URL delivery. Explicit `newInstance: true`, deletion,
|
|
570
|
+
and video recording create a new browser context; cold launches still use the
|
|
571
|
+
supplied shell URL. When synchronization is enabled, URL delivery waits for
|
|
572
|
+
existing work before dispatch and for navigation and painting afterward.
|
|
573
|
+
|
|
534
574
|
See `../../docs/migrating-from-detox.md` for details.
|
|
535
575
|
|
|
536
576
|
## Coming from Maestro
|
|
@@ -584,6 +624,9 @@ the framed export inline without changing Maestro syntax:
|
|
|
584
624
|
native content, a populated node tree, or a live-frame channel whose publish
|
|
585
625
|
count advances across probes. Canvas-only GL, WebGPU, and video surfaces can
|
|
586
626
|
therefore prove that they are painting without weakening the boot-card guard.
|
|
627
|
+
A confirmed guest launch error ends readiness immediately, including waits longer
|
|
628
|
+
than one bridge request. Loading and suppressed entry errors keep their existing
|
|
629
|
+
readiness rules.
|
|
587
630
|
|
|
588
631
|
```yaml
|
|
589
632
|
- takeScreenshot: hero
|
|
@@ -695,11 +738,11 @@ serves its selected version from a version-specific localhost origin, so a
|
|
|
695
738
|
background activation cannot mix an already loaded shell with chunks from the
|
|
696
739
|
next runtime. Without config, each new open selects the active stable version.
|
|
697
740
|
When `peach.config.ts` sets `runtimeVersion`, `peach open` installs that
|
|
698
|
-
version without changing the machine default. The same file
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
741
|
+
version without changing the machine default. The same file answers native code Peach does
|
|
742
|
+
not know under `packages`, keyed by the package and then its native module
|
|
743
|
+
and view names, and an installed `peach-stub-<package>` package answers in the
|
|
744
|
+
same shape with no config line; the configuration guide in the docs lists
|
|
745
|
+
every slot. App config, native-linked fonts,
|
|
703
746
|
and splash assets resolve from the local directory named by `--project-root`
|
|
704
747
|
when supplied, otherwise the project root advertised by the selected development
|
|
705
748
|
server. Hosted recorders pass their checked-out repository explicitly because
|
package/cli/app-catalog.ts
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path'
|
|
|
3
3
|
import {
|
|
4
4
|
enumerateBundleColors,
|
|
5
5
|
enumerateBundleImages,
|
|
6
|
+
enumerateBundleSymbols,
|
|
6
7
|
} from '../../sootsim-swift/src/bundleAssets'
|
|
7
8
|
import type { AppCatalog } from 'peach-engine/engine/app-catalog-wire'
|
|
8
9
|
|
|
@@ -36,7 +37,7 @@ function catalogFiles(dir: string, found: string[]) {
|
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
// the app's asset-catalog colors
|
|
40
|
+
// the app's asset-catalog colors, images, symbols and declared accent, read off
|
|
40
41
|
// the ios project a prebuild or a bare app keeps on disk: the app target is
|
|
41
42
|
// the folder holding the AppDelegate, the catalogs are its .xcassets, and the
|
|
42
43
|
// accent is what its Info.plist or project build settings name. metro serves
|
|
@@ -65,9 +66,9 @@ export function discoverAppCatalog(
|
|
|
65
66
|
const relative = files.map((file) =>
|
|
66
67
|
path.relative(targetDir, file).split(path.sep).join(path.posix.sep),
|
|
67
68
|
)
|
|
68
|
-
const readContents = (candidate: string) =>
|
|
69
|
-
candidate.endsWith('Contents.json') ? readText(path.join(targetDir, candidate)) : null
|
|
69
|
+
const readContents = (candidate: string) => readText(path.join(targetDir, candidate))
|
|
70
70
|
const colors = enumerateBundleColors(relative, readContents)
|
|
71
|
+
const symbols = enumerateBundleSymbols(relative, readContents, () => '.')
|
|
71
72
|
const served = path.relative(appDir, targetDir).split(path.sep).join(path.posix.sep)
|
|
72
73
|
const images: AppCatalog['images'] = {}
|
|
73
74
|
for (const { name, file, scale } of enumerateBundleImages(relative, readContents)) {
|
|
@@ -85,8 +86,9 @@ export function discoverAppCatalog(
|
|
|
85
86
|
if (
|
|
86
87
|
accentName === null &&
|
|
87
88
|
Object.keys(colors).length === 0 &&
|
|
88
|
-
Object.keys(images).length === 0
|
|
89
|
+
Object.keys(images).length === 0 &&
|
|
90
|
+
Object.keys(symbols).length === 0
|
|
89
91
|
)
|
|
90
92
|
return null
|
|
91
|
-
return { colors, images, accentName }
|
|
93
|
+
return { colors, images, symbols, accentName }
|
|
92
94
|
}
|
package/cli/app-config.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as peachConfigModule from '../src/config'
|
|
|
4
4
|
import { isPeachConfig, type PeachConfig } from '../src/config'
|
|
5
5
|
import { rnxPublicBrand } from '../src/public-brand'
|
|
6
6
|
import { resolveAppProject, type ResolvedAppProject } from './app-project'
|
|
7
|
+
import { resolveAppStubs, wireSlotMigration, type AppStubs } from './app-stubs'
|
|
7
8
|
|
|
8
9
|
// `{ file }` entries are relative to the config file; the engine loads them by
|
|
9
10
|
// absolute path from the runtime's replacement-module endpoint.
|
|
@@ -33,16 +34,42 @@ function resolveConfigReplacementFiles(
|
|
|
33
34
|
return {
|
|
34
35
|
...config,
|
|
35
36
|
modules: resolveFileEntries(config.modules, configDir),
|
|
36
|
-
turboModules: resolveFileEntries(config.turboModules, configDir),
|
|
37
|
-
nativeModules: resolveFileEntries(config.nativeModules, configDir),
|
|
38
|
-
nativeViews: resolveFileEntries(config.nativeViews, configDir),
|
|
39
37
|
}
|
|
40
38
|
}
|
|
41
39
|
|
|
40
|
+
// the engine's wire slots; an app names native stubs by package instead
|
|
41
|
+
const WIRE_SLOTS = ['nativeModules', 'turboModules', 'nativeViews'] as const
|
|
42
|
+
|
|
43
|
+
// the app's config with every native stub lowered into the engine's wire slots:
|
|
44
|
+
// its own `packages` and the installed stub packages, applied with or without
|
|
45
|
+
// a peach.config.ts.
|
|
42
46
|
export async function loadRNXAppConfig(
|
|
43
47
|
project: ResolvedAppProject | null,
|
|
44
48
|
): Promise<PeachConfig | undefined> {
|
|
45
|
-
|
|
49
|
+
return (await loadRNXAppStubs(project)).config
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function loadRNXAppStubs(
|
|
53
|
+
project: ResolvedAppProject | null,
|
|
54
|
+
): Promise<{ config: PeachConfig | undefined; stubs: AppStubs }> {
|
|
55
|
+
if (!project) return { config: undefined, stubs: { applied: [], skipped: [] } }
|
|
56
|
+
const config = await loadConfigFile(project)
|
|
57
|
+
try {
|
|
58
|
+
return resolveAppStubs(
|
|
59
|
+
project.appDir,
|
|
60
|
+
config,
|
|
61
|
+
project.configPath ? path.dirname(project.configPath) : project.appDir,
|
|
62
|
+
)
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
65
|
+
throw new Error(`could not load the native stubs of ${project.appDir}: ${message}`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function loadConfigFile(
|
|
70
|
+
project: ResolvedAppProject,
|
|
71
|
+
): Promise<PeachConfig | undefined> {
|
|
72
|
+
if (!project.configPath) return undefined
|
|
46
73
|
try {
|
|
47
74
|
const jiti = createJiti(import.meta.url, {
|
|
48
75
|
interopDefault: true,
|
|
@@ -57,6 +84,16 @@ export async function loadRNXAppConfig(
|
|
|
57
84
|
if (!isPeachConfig(loaded)) {
|
|
58
85
|
throw new Error('the default export must be a peach config object')
|
|
59
86
|
}
|
|
87
|
+
const wire = WIRE_SLOTS.filter((slot) => loaded[slot] !== undefined)
|
|
88
|
+
if (wire.length > 0) {
|
|
89
|
+
const migration = wireSlotMigration(project.appDir, {
|
|
90
|
+
modules: { ...loaded.turboModules, ...loaded.nativeModules },
|
|
91
|
+
views: { ...loaded.nativeViews },
|
|
92
|
+
})
|
|
93
|
+
throw new Error(
|
|
94
|
+
`${wire.join(', ')} moved under packages, keyed by the package whose native side they answer. replace them with:\n${migration}`,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
60
97
|
return resolveConfigReplacementFiles(loaded, project.configPath)
|
|
61
98
|
} catch (error) {
|
|
62
99
|
const message = error instanceof Error ? error.message : String(error)
|
|
@@ -64,9 +101,11 @@ export async function loadRNXAppConfig(
|
|
|
64
101
|
}
|
|
65
102
|
}
|
|
66
103
|
|
|
67
|
-
export async function resolveRNXAppConfig(
|
|
68
|
-
|
|
69
|
-
|
|
104
|
+
export async function resolveRNXAppConfig(startDir: string = process.cwd()): Promise<{
|
|
105
|
+
project: ResolvedAppProject | null
|
|
106
|
+
config: PeachConfig | undefined
|
|
107
|
+
stubs: AppStubs
|
|
108
|
+
}> {
|
|
70
109
|
const project = resolveAppProject(startDir)
|
|
71
|
-
return { project,
|
|
110
|
+
return { project, ...(await loadRNXAppStubs(project)) }
|
|
72
111
|
}
|
package/cli/app-fonts.ts
CHANGED
|
@@ -3,16 +3,24 @@ import fs from 'node:fs'
|
|
|
3
3
|
import { createRequire } from 'node:module'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import {
|
|
6
|
+
encodeAppFontsWire,
|
|
6
7
|
expoFontPathsFromPlugins,
|
|
7
8
|
nativeLinkedFontSpecsFromPaths,
|
|
8
9
|
metroFontSpecsFromPaths,
|
|
10
|
+
appFontAssetUrlsFromWire,
|
|
9
11
|
type ConfigPluginFontSpec,
|
|
12
|
+
type FontPlatform,
|
|
10
13
|
} from 'peach-engine/engine/config-plugin-fonts'
|
|
11
14
|
import { resolveListeningPid, resolveProcessCwd } from '../scripts/dev-server-scanner'
|
|
12
15
|
import { loopbackOriginOf } from '../src/backend-origin'
|
|
13
16
|
import { MANIFEST_PROBE_TIMEOUT_MS } from '../src/dev-bundle-resolution'
|
|
17
|
+
import { stageHostFile } from '../src/host/staged-host-files'
|
|
14
18
|
import { parsePlistSource } from '../src/plist.ts'
|
|
15
19
|
import { resolveAppProject } from './app-project'
|
|
20
|
+
import {
|
|
21
|
+
materialSymbolFontPaths,
|
|
22
|
+
materialSymbolFontAssets,
|
|
23
|
+
} from './material-symbol-fonts'
|
|
16
24
|
|
|
17
25
|
const require = createRequire(import.meta.url)
|
|
18
26
|
|
|
@@ -31,24 +39,62 @@ export function discoverNativeLinkedAppFonts(opts: {
|
|
|
31
39
|
bundleUrl: string
|
|
32
40
|
projectDir: string
|
|
33
41
|
platform?: string
|
|
42
|
+
nativeAssetOrigin: string
|
|
34
43
|
}): DiscoveredAppFontSpec[] {
|
|
35
44
|
const metroOrigin = loopbackOriginOf(opts.bundleUrl)
|
|
36
45
|
if (!metroOrigin) return []
|
|
46
|
+
const { paths, materialSymbols } = discoverNativeLinkedAppFontAssets(opts)
|
|
37
47
|
const specs = metroFontSpecsFromPaths(
|
|
38
|
-
projectRelativeFontPaths(opts.projectDir,
|
|
48
|
+
projectRelativeFontPaths(opts.projectDir, paths),
|
|
39
49
|
metroOrigin,
|
|
50
|
+
nativeFontPlatform(opts.platform),
|
|
51
|
+
)
|
|
52
|
+
attachMaterialSymbolMetadata(specs, materialSymbols, (asset) =>
|
|
53
|
+
stageHostFile(asset, opts.nativeAssetOrigin),
|
|
40
54
|
)
|
|
41
|
-
|
|
42
|
-
const byFamily = new Map<string, DiscoveredAppFontSpec>()
|
|
43
|
-
for (const spec of specs)
|
|
44
|
-
if (!byFamily.has(spec.family)) byFamily.set(spec.family, spec)
|
|
45
|
-
return [...byFamily.values()]
|
|
55
|
+
return specs
|
|
46
56
|
}
|
|
47
57
|
|
|
48
|
-
function
|
|
49
|
-
return
|
|
50
|
-
|
|
58
|
+
function nativeFontPlatform(platform: string | undefined): FontPlatform {
|
|
59
|
+
return (platform || process.env.CONTRAST_PLATFORM || 'ios').toLowerCase() === 'android'
|
|
60
|
+
? 'android'
|
|
61
|
+
: 'ios'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function attachMaterialSymbolMetadata(
|
|
65
|
+
specs: ConfigPluginFontSpec[],
|
|
66
|
+
files: ReturnType<typeof materialSymbolFontAssets>,
|
|
67
|
+
assetUrl: (filePath: string) => string,
|
|
68
|
+
): void {
|
|
69
|
+
const first = specs.find(
|
|
70
|
+
(spec) =>
|
|
71
|
+
spec.family !== undefined &&
|
|
72
|
+
/^MaterialSymbols(?:Outlined|Rounded|Sharp)_/.test(spec.family),
|
|
51
73
|
)
|
|
74
|
+
if (first && files)
|
|
75
|
+
first.materialSymbols = {
|
|
76
|
+
hash: files.hash,
|
|
77
|
+
codepointsUrl: assetUrl(files.codepointsPath),
|
|
78
|
+
mirroredUrl: assetUrl(files.mirroredPath),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function projectRelativeFontPaths(projectDir: string, fontPaths: string[]): string[] {
|
|
83
|
+
// metro resolves an asset path lexically against its project root and reads
|
|
84
|
+
// through symlinks, so a font declared inside the project keeps its declared
|
|
85
|
+
// path even when node_modules is a symlink to a shared install. a font that
|
|
86
|
+
// node resolved outside the project (a hoisted workspace or pnpm store) is
|
|
87
|
+
// named through its real location, in the namespace of the real project root.
|
|
88
|
+
const projectRoot = path.resolve(projectDir)
|
|
89
|
+
const realProjectRoot = fs.realpathSync(projectDir)
|
|
90
|
+
return fontPaths.map((fontPath) => {
|
|
91
|
+
const lexical = path.relative(projectRoot, fontPath)
|
|
92
|
+
const relative =
|
|
93
|
+
lexical.startsWith('..') || path.isAbsolute(lexical)
|
|
94
|
+
? path.relative(realProjectRoot, fs.realpathSync(fontPath))
|
|
95
|
+
: lexical
|
|
96
|
+
return relative.split(path.sep).join('/')
|
|
97
|
+
})
|
|
52
98
|
}
|
|
53
99
|
|
|
54
100
|
function isFile(filePath: string): boolean {
|
|
@@ -60,15 +106,15 @@ function isFile(filePath: string): boolean {
|
|
|
60
106
|
}
|
|
61
107
|
|
|
62
108
|
// absolute paths of every existing font file the native app links.
|
|
63
|
-
function
|
|
109
|
+
function discoverNativeLinkedAppFontAssets(opts: {
|
|
64
110
|
projectDir: string
|
|
65
111
|
platform?: string
|
|
66
|
-
}): string[] {
|
|
112
|
+
}): { paths: string[]; materialSymbols?: ReturnType<typeof materialSymbolFontAssets> } {
|
|
67
113
|
const project = resolveAppProject(opts.projectDir)
|
|
68
114
|
const selectedAppDir = project?.appDir ?? null
|
|
69
|
-
if (!selectedAppDir) return []
|
|
115
|
+
if (!selectedAppDir) return { paths: [] }
|
|
70
116
|
|
|
71
|
-
const platform = (opts.platform
|
|
117
|
+
const platform = nativeFontPlatform(opts.platform)
|
|
72
118
|
|
|
73
119
|
let getConfig: ExpoConfigGetter | null = null
|
|
74
120
|
// resolve from the exact app root advertised by the development server.
|
|
@@ -97,13 +143,13 @@ function discoverNativeLinkedAppFontPaths(opts: {
|
|
|
97
143
|
}
|
|
98
144
|
|
|
99
145
|
// a declared path with no file behind it links nothing natively.
|
|
100
|
-
const fontPaths = expoFontPathsFromPlugins(
|
|
101
|
-
plugins,
|
|
102
|
-
platform === 'android' ? 'android' : 'ios',
|
|
103
|
-
)
|
|
146
|
+
const fontPaths = expoFontPathsFromPlugins(plugins, platform)
|
|
104
147
|
.slice(0, 256)
|
|
105
148
|
.map((fontPath) => path.resolve(selectedAppDir, fontPath))
|
|
106
149
|
.filter(isFile)
|
|
150
|
+
const materialFonts =
|
|
151
|
+
platform === 'android' ? materialSymbolFontPaths(selectedAppDir) : []
|
|
152
|
+
fontPaths.push(...materialFonts)
|
|
107
153
|
// the walk is bounded by WORK, not by a clock: at most 64 readdirs, at most 64
|
|
108
154
|
// queued dirs, 4 levels deep, 256 paths. those ceilings are what keep a stray
|
|
109
155
|
// node_modules from being crawled, and no machine can blow through them. a
|
|
@@ -270,7 +316,10 @@ function discoverNativeLinkedAppFontPaths(opts: {
|
|
|
270
316
|
} catch {}
|
|
271
317
|
}
|
|
272
318
|
|
|
273
|
-
return
|
|
319
|
+
return {
|
|
320
|
+
paths: [...new Set(fontPaths)],
|
|
321
|
+
materialSymbols: materialSymbolFontAssets(materialFonts),
|
|
322
|
+
}
|
|
274
323
|
}
|
|
275
324
|
|
|
276
325
|
// prebuilt guests cannot discover files linked into their native binary.
|
|
@@ -282,15 +331,23 @@ export function readNativeLinkedAppFontsWire(opts: {
|
|
|
282
331
|
if (!resolveAppProject(opts.projectDir)) {
|
|
283
332
|
throw new Error(`native font project is unavailable: ${opts.projectDir}`)
|
|
284
333
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
334
|
+
const projectDir = fs.realpathSync(opts.projectDir)
|
|
335
|
+
const { paths, materialSymbols } = discoverNativeLinkedAppFontAssets(opts)
|
|
336
|
+
const specs = nativeLinkedFontSpecsFromPaths(
|
|
337
|
+
projectRelativeFontPaths(projectDir, paths),
|
|
338
|
+
nativeFontPlatform(opts.platform),
|
|
339
|
+
(fontPath) => {
|
|
340
|
+
const bytes = fs.readFileSync(path.resolve(projectDir, fontPath))
|
|
341
|
+
return `data:${fontContentType(fontPath)};base64,${bytes.toString('base64')}`
|
|
342
|
+
},
|
|
343
|
+
)
|
|
344
|
+
attachMaterialSymbolMetadata(
|
|
345
|
+
specs,
|
|
346
|
+
materialSymbols,
|
|
347
|
+
(filePath) =>
|
|
348
|
+
`data:application/octet-stream;base64,${fs.readFileSync(filePath).toString('base64')}`,
|
|
293
349
|
)
|
|
350
|
+
return encodeAppFontsWire(specs)
|
|
294
351
|
}
|
|
295
352
|
|
|
296
353
|
// resolve a metro dev server's project root. Expo's middleware advertises it
|
|
@@ -355,28 +412,6 @@ export async function resolveMetroProjectRoot(bundleUrl: string): Promise<string
|
|
|
355
412
|
return pid === null ? null : resolveProcessCwd(pid)
|
|
356
413
|
}
|
|
357
414
|
|
|
358
|
-
export function encodeAppFontsWire(specs: DiscoveredAppFontSpec[]): string {
|
|
359
|
-
return specs
|
|
360
|
-
.map((spec) => `${encodeURIComponent(spec.url)}::${encodeURIComponent(spec.family)}`)
|
|
361
|
-
.join(',')
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
export function parseAppFontUrls(wire: string): string[] {
|
|
365
|
-
const urls: string[] = []
|
|
366
|
-
for (const entry of wire.split(',')) {
|
|
367
|
-
const trimmed = entry.trim()
|
|
368
|
-
if (!trimmed) continue
|
|
369
|
-
const sep = trimmed.indexOf('::')
|
|
370
|
-
const urlPart = sep === -1 ? trimmed : trimmed.slice(0, sep)
|
|
371
|
-
try {
|
|
372
|
-
urls.push(decodeURIComponent(urlPart))
|
|
373
|
-
} catch {
|
|
374
|
-
urls.push(urlPart)
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
return urls.filter(Boolean)
|
|
378
|
-
}
|
|
379
|
-
|
|
380
415
|
// content type for a config-plugin font url, by extension. config fonts only
|
|
381
416
|
// ever ship as ttf/otf/woff(2); fall back to octet-stream for anything else
|
|
382
417
|
// so R2 still serves bytes the engine can register.
|
|
@@ -413,7 +448,7 @@ export async function fetchAppFontFiles(
|
|
|
413
448
|
): Promise<FetchedAppFont[]> {
|
|
414
449
|
const out: FetchedAppFont[] = []
|
|
415
450
|
const seen = new Set<string>()
|
|
416
|
-
for (const url of
|
|
451
|
+
for (const url of appFontAssetUrlsFromWire(wire)) {
|
|
417
452
|
const urlhash = createHash('sha256').update(url).digest('hex')
|
|
418
453
|
if (seen.has(urlhash)) continue
|
|
419
454
|
seen.add(urlhash)
|