The same bundle, in a native frame
The iPhone and iPad app does not reimplement the studio. A build script compiles the web client and copies it into the app's Web/ folder with a file naming the commit it came from. The app, built with XcodeGen for iOS 18 on iPhone and iPad in Swift 6, hosts that bundle in a WKWebView and adds what a browser cannot do well: picking original photos, sharing files, reading offline, and playing clips through its own scheme. The shared plumbing comes from the family kit (CwkKitCore, CwkKitWeb, CwkKitMedia), which Sibling Kit Quest explains.
A packaged origin with one door
The page loads from avatars-app://client, served from the bundle by a custom scheme handler. The navigation policy allows exactly one kind of main-frame load: the app's own origin at / or /index.html. A link to Pippa's panel opens in a separate sheet with its own web view, and that remote Sidekick never receives the native bridge. Firebrand Quest's lesson "One Locked WebView, Not a Browser" makes the same split for a coding client: one locked view for the work, a second isolated view for family chrome.
Script talks to native code through one message handler, and the kit's origin-gated handler admits a message only if six things hold: it came from this web view, from the main frame, from the page's content world, from a sender whose scheme and host are the app's own, while the web view itself is still on that origin, and within a size budget. For Avatars the budget is 145 << 20 bytes, room for a 100 MB photo after base64.
The bridge replaces fetch
bridge.js is injected at document start and does nothing unless it runs in the top frame at avatars-app://client. There it swaps window.fetch for a version that sends every same-origin /api/ call through the message handler. Form fields travel as text, files as base64, with the same 100 MB limit the engine enforces. Anything else goes to the browser's own fetch untouched.
Why not let the scheme handler take API writes too? Because WKWebView can discard the body of a POST sent to a custom scheme. So the scheme serves only GET resources and the bundle, and every write rides the gated message, as a comment in StudioHost.swift says. The bridge also keeps a long timer of 965 seconds, and its message is a rule in disguise: the request is still pending, check saved batches before generating again. A slow answer is never a reason to submit twice.
One API policy on the native side
Engine is the only place that turns a page's path into a network request. The path must start with /api/, carry no host or scheme, no backslash or fragment, no encoded slash or backslash, and no .. segment, and the resolved URL must land on the studio's own host and port. A test feeds it a full foreign URL, a protocol-relative one, /internal/providers, two kinds of dot-dot escape and both encoded slashes, and every one is refused. The family session token lives in the Keychain, this device only, and rides an X-Pippa-Session header. A 401 or 403 retires the offline reads saved under that credential.
Sharing is narrower still. A click on a link to an original or a delivery export is intercepted, the host fetches the file, names it by its real type, and hands it to the share sheet. A clip is shared as .mp4, never as an image. No other route can be shared.
175 requests to the first frame
Clips broke the simple path. WebKit's player reads a video through the scheme one byte range at a time and waits for each read before asking the next: a two-byte probe, box headers eight bytes at a time, then the index at the end of the file in reads of four to eight bytes, then sample by sample. It plays only from 206 answers with exactly the requested bytes and a Content-Range that places them, and on iPhone it goes full screen unless the configuration allows inline playback.
Relaying each range to the engine worked, slowly. Every engine request paid about 50 ms of authorization before its first byte, so an eight-second 720p clip took 175 requests and 6.2 seconds to its first frame, measured on the engine host's own loopback before any network round trip. ClipBlocks reads the engine in 1 MiB blocks instead, each one placed by the engine's own Content-Range, keeps up to 64 MiB, reads the next block ahead, and lets waiters for one block share a single read. The player's ranges are cut from those blocks. The same clip took 4 engine reads and played in real time, 8.08 seconds of wall time for 8 seconds of video. An original never changes, so a kept block stays true, and every block is still read with the engine's authorization.