This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Remote Shutter is an iOS + Mac Catalyst app (Swift, UIKit + SwiftUI) that turns two Apple devices into a remote-controlled camera system via peer-to-peer connectivity. One device acts as the camera, the other as the monitor (remote control). A Mac can take either role; it selects among N attached cameras (built-in, Continuity, USB) rather than front/back. Published on the App Store (iOS).
# Build (uses xcworkspace because of CocoaPods)
xcodebuild -workspace RemoteShutter.xcworkspace -scheme RemoteCam \
-destination 'platform=iOS Simulator,OS=18.5,name=iPhone 16' \
-configuration Debug clean build \
CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO
# Run tests
xcodebuild -workspace RemoteShutter.xcworkspace -scheme RemoteCam \
-destination 'platform=iOS Simulator,OS=18.5,name=iPhone 16' \
-configuration Debug test \
CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO
# Build for Mac Catalyst (build-only in CI; tests run on the iOS Simulator)
xcodebuild -workspace RemoteShutter.xcworkspace -scheme RemoteCam \
-destination 'platform=macOS,variant=Mac Catalyst' \
-configuration Debug build \
CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO
# Release (via fastlane)
fastlane releaseAlways use RemoteShutter.xcworkspace (not .xcodeproj) due to CocoaPods. The scheme is RemoteCam. Minimum deployment target is iOS 15.0; the same target builds for Mac Catalyst ("Optimize for Mac" idiom, device family 1,2,6).
Releases are deployed via GitHub Actions CI. The workflow is:
- Commit and push the branch to GitHub
- Create a PR to
master - CI runs
fastlane releasewhich builds, signs (viamatch), and uploads to App Store Connect
Never attempt to run fastlane release locally — it requires CI environment variables (ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_CONTENT, MATCH_GIT_URL) and setup_ci. Always push to GitHub and let CI handle it.
See Docs/ARCHITECTURE.md for the readable overview. The session is a Swift
actor — SessionCoordinator (SessionCoordinator.swift) — holding the
complete state space as the SessionState enum (~20 states across the
scanning/connected, camera, monitor, and watch families) plus all per-state
message handlers in the same file. Messages enter through tell(_:) (or the
coordinator ! msg operator sugar) into a FIFO AsyncStream inbox and are
processed one at a time; state context (peer, lobby, CameraControlling ctrl,
MonitorPresenter) lives in actor-isolated properties.
Key collaborators (plain objects, injected by the screen that owns the session):
MonitorPresenter— routes session results to the monitor screen'sMonitorDisplay/view model, hopping to main internally.FrameSender— queue-confined preview-frame streamer with credit-window back-pressure (only sends when the monitor has acked).CameraRig— the camera device as one non-UI object; the productionCameraControllingconformer (anasync throwsprotocol).
Ownership: DeviceScannerViewController creates the coordinator + FrameSender
pair and injects them into the camera/monitor screens; the Watch-remote screen
owns its own pair. Transient request states arm 10-second generation-counted
timeouts; long-lived states (recording, modes) deliberately have none.
Two message hierarchies (both subclassing the app's Message base in
Messages.swift) handle communication:
RemoteCmd(RemoteCmds.swift) — Messages sent between devices over the peer session, serialized as FlatBuffers (RemoteCmdFlatBuffers.swift+ schemas inFlatBufferSchemas.fbs).UICmd(UICmds.swift) — Local messages from screens into the session coordinator within a single device.
When adding new remote commands, add a table to FlatBufferSchemas.fbs, regenerate FlatBufferSchemas_generated.swift with flatc, and wire the encode/decode paths in RemoteCmdFlatBuffers.swift. All FlatBuffer enums must have Unknown = 0 as the default.
Every screen is a SwiftUI view hosted by a thin UIKit shell (no storyboards or xibs; the window is built programmatically in SceneDelegate):
- WelcomeViewController — entry point (root of the nav controller), hosts
WelcomeView - RolePickerController — role selection, hosts
RolePickerView - DeviceScannerViewController — peer discovery, hosts
DeviceScannerView; owns theSessionCoordinator+FrameSenderlifecycle - MonitorViewController — hosts
MonitorView; implementsMonitorDisplay, the protocol seam through whichMonitorPresenterdrives the screen - CameraHostController — hosts
CameraScreenView(preview + chrome) and owns aCameraRig, which holds the capture stack (CaptureEngine+RecordingPipeline+FrameStreamingCoordinator) - WatchRemoteCameraController — Watch-remote mode, embeds the camera screen and bridges
WCSessioncommands into the coordinator - View models (
WelcomeViewModel,DeviceScannerViewModel,MonitorViewModel,CameraViewModel) are ObservableObjects; all@Publishedwrites happen on main
Threading: the coordinator serializes via its actor inbox; CaptureEngine state is confined to its sessionQueue; recording state to the single dataOutputQueue (which delivers both video and audio frames); the few per-frame cross-domain values use Locked<T>. The full test suite runs clean under Thread Sanitizer — keep it that way.
Uses Stormo (github.com/security-union/Stormo — QUIC over Network.framework, pinned exact 2.0.0) via its MPCCompat drop-in API; app code keeps the legacy MC type names through app-local typealiases in MultipeerCompatAliases.swift. Service type "remotecam" (bare MPC style; MPCCompat translates it to _remotecam._udp — must match NSBonjourServices in Info.plist). Encapsulated in MultipeerService (MultipeerServiceProtocol is the test seam); messages are FlatBuffers via MCSession.send(), video files via MCSession.sendResource() with progress. Requires the Keychain Sharing entitlement (TLS identity lives in the data-protection keychain; advertising fails at startup without it on Catalyst). QUIC_DEBUG=1 in the scheme makes the transport narrate to the console; both devices must run Stormo builds (no wire interop with MPC-era versions). See Stormo's CLAUDE.md for transport failure modes.
- SwiftLint (~0.41.0) — Linting
- FlatBuffers (local podspec) — Wire-protocol serialization (iOS + watchOS targets)
The session uses Swift's native actor for concurrency (see Docs/ARCHITECTURE.md).
The Mac build is the same app target. Rules that matter when touching platform-y code:
- Camera identity is
uniqueID-based (CameraDeviceDescriptor,CameraControlling.selectCameraDevice); Mac cameras report.unspecifiedposition, which serializes asBack + has_unspecified_positionon the wire. - A monitor may only send
RemoteCmd.SelectCameraDeviceto a peer whose capabilities carried a non-emptycamera_deviceslist. The gate lives inSessionCoordinatorand is pinned by a loopback test. (CommandAction.Unknown = 0, so an action a peer doesn't know is ignored, not misread — the gate is about not sending meaningless commands.) - WatchConnectivity does not exist on Catalyst:
WatchSessionManagerhas a stub branch; keep new Watch code behind it. - Macs don't rotate:
getOrientation()returns.landscapeRighton Catalyst; don't add rotation handling outside the#if !targetEnvironment(macCatalyst)paths. - Platform shims (sleep, System Settings deep links) use
#if targetEnvironment(macCatalyst)— extend those branches rather than adding UIKit-only calls. - Preview frames are ALWAYS sent
.unreliable(never.reliable— live preview drops, never queues); the transport's datagram channel is warmed with a no-op ping at peer connect. Frame durations must be clamped into theAVFrameRateRange's own CMTimes (CaptureEngine.resolveFrameRate), never rebuilt asCMTimeMake(1, fps). - Hardware integration tests (real cameras, skip on simulator/CI):
xcodebuild test … -destination 'platform=macOS,variant=Mac Catalyst' -only-testing:RemoteShutterTests/CaptureIntegrationTests.
Centralized in FeatureFlags.swift. Check existing flags before adding new ones.
Metadata lives in fastlane/metadata/<locale>/. When editing keywords.txt files, keywords must be 100 characters or fewer (including commas). App Store Connect will reject the upload if any locale exceeds this limit. Always verify with wc -m (characters, not bytes — CJK keyword sets legitimately exceed 100 bytes) before committing. Other field limits: app name 30 chars, subtitle 30 chars, promotional text 170 chars, description 4000 chars.
Search indexing (cross-localization): each storefront indexes exactly two locales — its own language plus a designated secondary. Most European storefronts (France, Germany, Italy, …) index English (U.K.), not en-US; the US storefront indexes en-US + es-MX. For this reason en-GB is a byte-for-byte copy of en-US, and es-ES of es-MX — keep them in sync whenever the source locale changes. Keywords never combine across locales (a word in en-US can't form a phrase with a word in fr-FR), so every locale's keyword set must stand alone. The app name is indexed on every storefront and outranks the keyword field — brand queries ("remote shutter") match everywhere regardless of keywords. Screenshots for en-GB/es-ES are not duplicated; ASC falls back to the primary locale's media. Product-name rule from the 6.0.10 rejection (5.2.5): Apple product names ("Apple Watch", "iPhone") are compatibility-context-only — fine in descriptions, never in app name or subtitles. Metadata must not contradict in-app requirements (2.3.1): the app requires the Wi-Fi radio ON, so say "no router / no Wi-Fi network needed", never "no WiFi".
Screenshots for all 10 locales are generated by store_assets/screenshot-pipeline/ (see its README for full docs). Rendering is deterministic — headless Chrome over manifest.js (layouts/quads) + translations.js (all localized strings); no AI calls at render time.
- Regenerate everything:
cd store_assets/screenshot-pipeline && ./ship-locales.sh(writes iPhone/iPad shots tofastlane/screenshots/<locale>/, Mac shots tofastlane/screenshots_mac/<locale>/; keeps Watch captures). The trees are separate because deliver uploads everything in its screenshots_path to one platform — APP_DESKTOP display types are rejected on the iOS version and vice versa. - Change a caption/translation: edit
translations.js, re-run./ship-locales.sh [locale...]. - New scenes need the
AI_STUDIOenv var (Google AI Studio key) andgenerate.mjs(Nano Banana); screens are generated black/off and the pipeline composites real app UI captures (App Review 2.3.3). Quads are measured withtools.py detect/overlay— always verify overlays visually. - Rules: iPad screenshots show iPads; the camera device's orientation must match the remote's live-preview aspect; the subject shown on screens must match the scene (same object, same orientation).
- Event banners land in
out/<locale>/event_card_3840x2160.png— uploaded manually in App Store Connect (In-App Events), not via fastlane.
Per Google's official guidance (cloud.google.com "Ultimate prompting guide for Nano Banana", blog.google Nano Banana Pro prompt tips):
- Positive framing only — never negatives. Gemini-family image models have no negative-prompt mechanism; the whole prompt is one semantic target, so "no cars" mostly injects cars. Describe the desired state instead: "empty street with clear pavement", "bare brushed-metal bezel", "pure glossy black switched-off screen", "her hair smooth and continuous". This applies to artifact fixes too — describe what the clean region looks like, not the blemish to remove.
- Iterate, don't re-roll. When a candidate is ~80% right, EDIT that
candidate (
node generate.mjs edit <candidate> <out> "<prompt>") with targeted changes instead of regenerating from the base scene — a re-roll gambles away everything that already landed well. - Re-anchor what must not change. In edit prompts, close by re-describing the elements that stay (subject, devices, mounts, lighting) — that is what keeps an edit from drifting.
- Generate 2–3 candidates and reject; record the keeper in
CHOSEN. Prompt clauses reduce but never guarantee; the rejection pass is the real quality gate. - Recurring failure modes to inspect at full res before accepting: brand text on device bezels ("MacBook Pro"), fake logos/glow on "off" screens (screens must read black), floating artifact shapes (check hair/skin especially), merged/overlapping devices, Apple logos anywhere.
- Structure new base-scene prompts on the five-part formula already noted in
generate.mjs: [Cinematography] + [Subject] + [Action] + [Context] + [Style & Ambiance]; use concrete camera/lighting vocabulary ("85mm f/2.8", "soft daylight", "over-the-shoulder").
Managed by PKIAPHandler.swift (InAppPurchasesManager). Product IDs: 05 (remove ads), 06 (enable video), 07 (enable torch), 08 (enable video only).
IAP display names/descriptions for all locales live in fastlane/iap_localizations.json; sync them to App Store Connect with the "Sync IAP Localizations" GitHub workflow or bundle exec fastlane ios sync_iap (needs ASC key env vars). Limits: name ≤30 chars, description ≤45 chars.