Skip to content

Add --features watchos-swift-app (native-lib-provided @main App for SwiftUI-hosted rendering) #118

@proggeramlug

Description

@proggeramlug

Summary

Follow-up to #106. watchos-game-loop solved the main-thread ownership problem but assumed a UIKit-style programmatic view presentation model that doesn't apply on watchOS. For any bloom-style custom-rendering app on the watch, the native lib needs to own the @main struct App: App scene tree so SwiftUI (SceneView for 3D / Canvas for 2D) can be the rendering surface.

Why the current paths both fall short

  1. Default watchOS path (Add watchOS compile target (Digital Crown input) #105) — Perry ships its own PerryWatchApp.swift with @main struct PerryApp: App that polls a perry-ui-watchos scene tree. Great for declarative widget UIs, but locks the renderer to SwiftUI's built-in primitives polled from a scene tree — no way for bloom to populate a SceneView or drive a Canvas imperatively from its own game loop.

  2. watchos-game-loop (Add --features watchos-game-loop (Metal-surface apps on watchOS) #106) — drops PerryWatchApp.swift, spawns the user's TS on a game thread, calls WKApplicationMain with a minimal NSObject <WKApplicationDelegate>. This works for the classic WatchKit model (storyboards + WKInterfaceController) but watchOS has no programmatic way to present a SwiftUI root from applicationDidFinishLaunching. UIHostingController / UIViewRepresentable don't exist on watchOS. WKInterfaceController is storyboard-only. So the game-loop path today launches a black-screen .app with no rendering path available.

Proposed design: --features watchos-swift-app

Let the native library ship its own @main struct App: App Swift file. Perry's job shrinks to: compile it, drop the auto-generated PerryWatchApp.swift, rename TS _main_perry_user_main, and link SwiftUI + WatchKit frameworks.

Concretely:

  1. When --features watchos-swift-app is set:

    • Perry does not emit crates/perry-ui-watchos/swift/PerryWatchApp.swift.
    • Perry does not provide a C main() (there is no watchos_swift_app.rs runtime crate — the native lib's @main is the real entry).
    • TS entry's _main still gets renamed to _perry_user_main so the Swift side can call it on a background thread.
    • Perry looks for a new optional entry in the native lib manifest — perry.nativeLibrary.targets.watchos.swift_sources (array of paths relative to the crate dir) — and compiles them with swiftc -parse-as-library -emit-object using the appropriate watchos / watchos-simulator SDK and triple, then links the resulting .o files into the final binary.
    • Link line includes -framework SwiftUI -framework WatchKit -framework SceneKit (and swift runtime via -L \$(xcrun --show-sdk-path --sdk <sdk>)/usr/lib/swift -lswiftCore — the swift standard library paths).
  2. Native lib (bloom-watchos) provides:

    • BloomWatchApp.swift with @main struct BloomWatchApp: App { ... } and a WKApplicationDelegateAdaptor if it wants an app delegate.
    • Inside the App's init() or the root view's .task { ... }, calls an exported C function like bloom_watchos_start_game_loop() that spawns the game thread and invokes _perry_user_main on it.
    • Root view uses whatever combination of SceneView (3D via SceneKit), Canvas (2D), overlays, .digitalCrownRotation(...), .onTapGesture to render and collect input. These feed bloom via C hooks the native lib already exports.

Acceptance

perry compile --target watchos-simulator --features watchos-swift-app src/main.ts -o BloomJumpWatch
xcrun simctl install booted BloomJumpWatch.app
xcrun simctl launch booted com.bloom.jump

…launches a BloomWatchApp whose root SceneView(scene: bloomScene) renders a bloom-populated SCNScene on the watch simulator, with the game loop driving bloom_scene_* FFI from TS on a background thread. Digital Crown rotation reaches TS via getCrownRotation().

Why this instead of method-swizzling the delegate or relying on storyboards

  • Swizzling gets input flowing but still can't host a SwiftUI root programmatically — there's no API for that on watchOS. This is the blocker.
  • Storyboards work but require authoring .storyboard files and embedding them in the bundle. That's fine for one app, but for a game engine targeting many projects it's an ergonomic regression vs "one Swift file in the native crate."

Compatibility

  • watchos-game-loop stays useful for classic WKInterfaceController apps — don't remove it.
  • Default watchos path (Perry's own SwiftUI tree) stays the default for UI-style apps.
  • watchos-swift-app is purely additive — a third modality for engine-backed renderers.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions