crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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.