MacVersion.md (6783B)
1 # Bringing Crossmate to the Mac 2 3 Notes from an exploration in June 2026. We did not ship anything — we evaluated 4 two routes, prototyped the Catalyst one far enough to launch, then decided it 5 was not worth pursuing right now. This records what we learned so a future 6 attempt does not start from zero. 7 8 ## The two routes 9 10 **Mac Catalyst** — almost free to get building, a poor long-term fit. 11 **Native macOS (SwiftUI)** — the right shape, but a multi-week effort. 12 13 Catalyst is genuinely ~2 lines of `project.yml` plus a couple of fixups. Native 14 is a real project: a Mac UI layer, sharing rewritten off `UICloudSharingController`, 15 the notification extension re-targeted, NYT re-plumbed. The honest framing is 16 that Crossmate is a collaborative, touch-first crossword app, and the Mac 17 audience may not justify either the Catalyst compromises or the native cost. 18 That is why we stopped. 19 20 ## What we confirmed about the codebase (the encouraging part) 21 22 The engine is already cleanly separated and almost entirely UIKit-free, which is 23 the precondition for *either* route: 24 25 - `Crossmate/Models` (21 files), `Crossmate/Sync` (21), `Crossmate/Persistence` 26 (5): **zero `import UIKit`**. The few `import SwiftUI` (PlayerColor, 27 PlayerPreferences, SyncEngine) are fine — SwiftUI is cross-platform. 28 - Core puzzle subviews `CellView.swift`, `GridView.swift`, `ClueList.swift` are 29 **pure SwiftUI** — reusable on macOS as-is. 30 - UIKit coupling is concentrated in `Crossmate/Views` and 4 services. The 31 background-task coupling (`UIApplication.beginBackgroundTask` / 32 `UIBackgroundTaskIdentifier`) is already abstracted behind closures in 33 `PuzzleSession` (the `beginBackgroundAssertion`/`endBackgroundAssertion` 34 pair), with the UIApplication implementations in `SessionCoordinator` and 35 `AppServices.ensureInBackground`. On macOS the replacement is 36 `ProcessInfo.performExpiringActivity`. 37 - Engine constructors are light: `SyncEngine(container:persistence:)` and 38 `GameStore(persistence:movesUpdater:authorIDProvider:…closures)`. A minimal 39 Mac composition root is feasible **without** reusing the monolithic 40 `AppServices` (which eagerly builds push/NYT/engagement and `import UIKit`s). 41 42 `../Listless` is the working reference for the native route — same 43 CloudKit/CKShare stack, shipped natively on macOS. 44 45 ## Catalyst route — what it actually took 46 47 For the record, getting Catalyst to *build* was: 48 49 1. `SUPPORTS_MACCATALYST: YES` in `project.yml` settings. 50 2. Remove `LSSupportsOpeningDocumentsInPlace: false` from the iOS Info.plist — 51 macOS rejects an explicit `NO`; absent defaults to the same behaviour. 52 3. Guard three iOS-26-only SwiftUI APIs with `#available(iOS 26.0, *)` + 53 fallbacks: `glassEffect` (in `SuccessPanel` ×2 and `PuzzleView` pencil 54 button) and `ToolbarSpacer` (in `GameListView`). These guards are worth 55 keeping regardless of route — they make the version-gating explicit. 56 57 ### The deployment-target trap (important, non-obvious) 58 59 Catalyst derives its **minimum macOS from the iOS deployment target** via a fixed 60 Apple mapping (iOS 26 → macOS 26 Tahoe; iOS 18 → macOS 15). Crossmate targets 61 iOS 26, so a stock Catalyst build demands macOS 26 and Xcode refuses "My Mac" as 62 a run destination on anything older. 63 64 - To run on macOS 15 you must lower the **iOS-equivalent availability floor**, 65 not the link-time minimum. The lever is 66 `IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*] = 18.0` (Catalyst compiles under the 67 `macosx` SDK), which keeps the real iOS app at 26 while the Catalyst variant 68 targets iOS 18 / macOS 15. 69 - **Do not** instead set `MACOSX_DEPLOYMENT_TARGET = 15.0`. That silences the 70 compiler (it still uses the iOS-26 floor for availability) so iOS-26 API 71 compiles, but the binary then *crashes at runtime* on macOS 15 when it hits a 72 symbol that does not exist there. We verified this: it "builds" with zero 73 errors and would have shipped a crash. 74 - Verify the result with `vtool -show-build <binary>`: a Catalyst binary reports 75 `platform MACCATALYST` and `minos` in **iOS** numbers (18.0 == macOS 15). 76 77 With the floor lowered, "My Mac (Mac Catalyst)" became an eligible destination 78 and the only remaining blocker was code signing (no Mac Catalyst provisioning 79 profiles — a portal/account step, not code). 80 81 ### The runtime wall we hit 82 83 Catalyst launched and ran. Opening the **puzzle picker** (`NewGameSheet`) then 84 crashed: 85 86 ``` 87 No Observable object of type NYTAuthService found. 88 ``` 89 90 `NewGameSheet` reads `@Environment(NYTAuthService.self)`, injected once at the 91 root in `CrossmateApp`. On iOS, sheets inherit that environment; **on Catalyst 92 the `@Observable` object did not propagate across the sheet boundary.** Several 93 sheets read root-injected observables (`SettingsView` reads `nytAuth` too), so 94 this is systemic, not one view. The fix would be to re-inject the needed 95 observables on each presented sheet from the presenting side (you cannot read 96 them inside the sheet — that is the broken environment). We did not finish it; 97 this quirk is a good example of why Catalyst is "runs but fights you." 98 99 ## If we revisit: recommended shape (native) 100 101 We had settled on, and still recommend, a **scaffolding-slice** first increment: 102 103 - SwiftUI App lifecycle (not a full AppKit shell like ListlessMac — far less 104 code; add AppKit only where needed, e.g. sharing). 105 - **XcodeGen source globs, no file moves**: a new `Crossmate macOS` target whose 106 `sources` curate the clean engine folders + a whitelist of portable views + 107 a new `CrossmateMac/` UI layer. Prefer **excluding** iOS-only files over 108 `#if`-guarding them — keeps the diff small and the engine untouched. 109 - A **minimal Mac composition root** wiring `PersistenceController → 110 MovesUpdater → GameStore (+ SyncEngine)` with no-op change closures, rather 111 than reusing `AppServices`. Factor `AppServices` into a shareable base only 112 later. 113 - Set `deploymentTarget.macOS` to match whatever the dev host runs (we used 114 15.0 against a macOS 15.7 host) so the native target builds **and runs/tests 115 locally** — the native route sidesteps the Catalyst deployment-target trap 116 entirely. 117 - First slice = launches + game list + **read-only** puzzle (reuse `GridView`/ 118 `CellView`/`ClueList`). Defer, each as its own increment: CKShare sharing UI 119 (`UICloudSharingController` → `ShareLink`/AppKit), push + `NotificationService` 120 extension, NYT import/auth, and text entry (no on-screen keyboard on Mac — 121 input is already hardware-keyboard-driven via `HardwareKeyboardInputView` + 122 `UIKeyCommand`, with `InputMonitor` hiding the custom keyboard when a hardware 123 keyboard is present, which on a Mac is always). 124 125 The full draft plan lived only in the planning tool and was not saved to the 126 repo; this note is the durable summary.