commit 51dce3833634990a781dce26e0f00fbfacaf425a
parent b599a2652e26f6be26d905644a6c11afc66fd299
Author: Michael Camilleri <[email protected]>
Date: Tue, 16 Jun 2026 20:04:01 +0900
Fix joining a shared puzzle from a link or invite
Tapping a Crossmate share link opened the app to the Game List and did
nothing else — no placeholder, no join. The app installs a custom scene
delegate so it can receive the OS CKShare-accept callback, but once a
custom scene delegate exists the universal-link activity is delivered to
it rather than to SwiftUI's .onContinueUserActivity, and the delegate
implemented only the CKShare method. The tapped link's activity was
therefore dropped before any join logic ran.
This commit forwards those activities. The scene delegate now handles
both the warm continue callback and the cold-launch willConnectTo
options, passing the link URL to RootView through a new ShareLinkBroker
that buffers anything arriving before the root task wires up, mirroring
the existing CloudShareAcceptanceBroker. The same cold-launch path also
forwards a CKShare that launched the app, which was previously dropped.
Even once acceptance ran, the joined puzzle often failed to open. The
accept path identified the new game by diffing the set of joined games
before and after acceptance, which came up empty whenever the game was
already present — a re-tapped link, a sibling device, or a friend added
to the share by identity before Accept was tapped. The game is now read
directly from the share metadata's zone, since Crossmate uses zone-wide
shares whose zone name is the game's 'game-<UUID>'. It still opens only
once the puzzle has synced, so a slow join lands in the Game List rather
than on an empty grid.
A joined game also kept the title 'Joining…' even after its puzzle
loaded. A participant minting the engagement or push credential re-saves
the Game record, and that write stamped its own transient placeholder
title over the owner's. The title and the owner-only share marker are
now written only by the owner, and on arrival the title is re-derived
from the puzzle's own content rather than trusted from the record, so a
clobbered title heals on the next sync that carries the asset.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
4 files changed, 229 insertions(+), 28 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -355,13 +355,79 @@ final class CloudShareAcceptanceBroker {
}
}
+/// Bridges a tapped Crossmate universal link from the `SceneDelegate` to
+/// `RootView`. A custom scene delegate is installed for the OS CKShare-accept
+/// callback, and once that exists, `NSUserActivityTypeBrowsingWeb` activities
+/// are delivered to *it* rather than SwiftUI's `.onContinueUserActivity` — so
+/// they must be forwarded explicitly. Buffers links that arrive before
+/// `RootView` wires up its handler (a cold launch delivers the activity in
+/// `scene(_:willConnectTo:)`, before the root `.task` runs), mirroring
+/// `CloudShareAcceptanceBroker`.
+@MainActor
+final class ShareLinkBroker {
+ static let shared = ShareLinkBroker()
+
+ var onOpenShareLink: ((URL) -> Void)? {
+ didSet { flushPendingLinks() }
+ }
+
+ private var pendingURLs: [URL] = []
+
+ private init() {}
+
+ func openShareLink(_ url: URL) {
+ guard let onOpenShareLink else {
+ pendingURLs.append(url)
+ return
+ }
+ onOpenShareLink(url)
+ }
+
+ private func flushPendingLinks() {
+ guard let onOpenShareLink, !pendingURLs.isEmpty else { return }
+ let urls = pendingURLs
+ pendingURLs.removeAll()
+ for url in urls { onOpenShareLink(url) }
+ }
+}
+
final class SceneDelegate: NSObject, UIWindowSceneDelegate {
+ func scene(
+ _ scene: UIScene,
+ willConnectTo session: UISceneSession,
+ options connectionOptions: UIScene.ConnectionOptions
+ ) {
+ // SwiftUI owns the window in this lifecycle — only read the launch
+ // options here, never create a window. A universal link (or a CKShare)
+ // that cold-launches the app arrives via `connectionOptions`, not
+ // through the `continue` / `userDidAcceptCloudKitShareWith` callbacks.
+ for activity in connectionOptions.userActivities {
+ handle(userActivity: activity)
+ }
+ if let metadata = connectionOptions.cloudKitShareMetadata {
+ CloudShareAcceptanceBroker.shared.acceptCloudKitShare(metadata)
+ }
+ }
+
+ func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
+ handle(userActivity: userActivity)
+ }
+
func windowScene(
_ windowScene: UIWindowScene,
userDidAcceptCloudKitShareWith metadata: CKShare.Metadata
) {
CloudShareAcceptanceBroker.shared.acceptCloudKitShare(metadata)
}
+
+ /// Forwards a tapped Crossmate universal link to `RootView` via
+ /// `ShareLinkBroker`. Non-web activities (and web activities without a URL)
+ /// are ignored.
+ private func handle(userActivity: NSUserActivity) {
+ guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
+ let url = userActivity.webpageURL else { return }
+ ShareLinkBroker.shared.openShareLink(url)
+ }
}
// MARK: - Root View
@@ -416,6 +482,24 @@ struct RootView: View {
navigationPath = NavigationPath()
navigationPath.append(gameID)
}
+ // A tapped Crossmate share link (universal link), routed here by the
+ // `SceneDelegate` through `ShareLinkBroker` — `.onContinueUserActivity`
+ // never fires once a custom scene delegate is installed. Show the
+ // placeholder immediately off the silhouette in the URL, then accept
+ // the share (the iCloud token is reconstructed locally, so there's no
+ // Safari → iCloud bounce). `.cloudShareAcceptanceCompleted` clears the
+ // placeholder and navigates to the joined game.
+ ShareLinkBroker.shared.onOpenShareLink = { url in
+ guard let route = ShareLinkRoute(shortLink: url) else { return }
+ withAnimation { pendingJoin = PendingJoinPlaceholder(shape: route.shape) }
+ Task {
+ do {
+ try await services.cloudService.acceptShare(url: route.iCloudShareURL)
+ } catch {
+ withAnimation { pendingJoin = nil }
+ }
+ }
+ }
await services.start(appDelegate: appDelegate)
}
.onOpenURL { url in
@@ -423,23 +507,6 @@ struct RootView: View {
navigationPath.append(id)
}
}
- .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
- // A tapped Crossmate share link (universal link). Show the
- // placeholder immediately off the silhouette in the URL, then
- // accept the share — the iCloud token is reconstructed locally, so
- // there's no Safari → iCloud bounce. `.cloudShareAcceptanceCompleted`
- // clears the placeholder and navigates to the joined game.
- guard let url = activity.webpageURL,
- let route = ShareLinkRoute(shortLink: url) else { return }
- withAnimation { pendingJoin = PendingJoinPlaceholder(shape: route.shape) }
- Task {
- do {
- try await services.cloudService.acceptShare(url: route.iCloudShareURL)
- } catch {
- withAnimation { pendingJoin = nil }
- }
- }
- }
.onReceive(NotificationCenter.default.publisher(for: .cloudShareAcceptanceStarted)) { _ in
UIApplication.shared.dismissPresentedViewControllers()
}
diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift
@@ -33,8 +33,9 @@ final class CloudService {
}
/// Fetches share metadata for a URL and joins via `acceptShare(metadata:)`.
- /// Used by the "Invited" section, where the share URL arrived in an
- /// `.invite` Ping rather than from the OS share-accept handler.
+ /// Used by the "Invited" section and the universal-link tap, where the
+ /// share URL arrived in an `.invite` Ping or a tapped link rather than from
+ /// the OS share-accept handler.
func acceptShare(url: URL) async throws {
let metadata = try await withCheckedThrowingContinuation {
(cont: CheckedContinuation<CKShare.Metadata, Error>) in
@@ -71,7 +72,14 @@ final class CloudService {
)
throw CKError(.permissionFailure)
}
- let existingJoinedGameIDs = store.joinedSharedGameIDs()
+ // The share names the game it covers: Crossmate uses zone-wide shares,
+ // so the metadata's zone ("game-<UUID>") identifies the game outright.
+ // This replaces a fragile before/after join diff that came up empty
+ // whenever the game was already present — a re-tapped link, a sibling
+ // device, or a directly-invited friend added by identity before Accept.
+ let sharedGameID = RecordSerializer.gameID(
+ fromGameRecordName: metadata.share.recordID.zoneID.zoneName
+ )
do {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
let op = CKAcceptSharesOperation(shareMetadatas: [metadata])
@@ -82,9 +90,14 @@ final class CloudService {
await syncMonitor.run("share-accept shared discovery") {
_ = try await syncEngine.discoverNewZonesDirect(scope: .shared)
}
- let joinedGameID = store.joinedSharedGameIDs()
- .subtracting(existingJoinedGameIDs)
- .first
+ // Navigate once the game's puzzle has actually synced and is
+ // playable. Discovery downloads the Game record (and its
+ // `puzzleSource` asset) inline, so the common case is ready here;
+ // `joinedSharedGameIDs()` excludes a bare placeholder, so a slow
+ // join simply lands in the list rather than opening an empty grid.
+ let joinedGameID = sharedGameID.flatMap {
+ store.joinedSharedGameIDs().contains($0) ? $0 : nil
+ }
if let joinedGameID {
try await shareController.confirmSeatAfterJoin(gameID: joinedGameID)
}
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -33,6 +33,15 @@ enum RecordSerializer {
"game-\(gameID.uuidString)"
}
+ /// Recovers the game UUID from a `"game-<UUID>"` record or zone name — the
+ /// inverse of `recordName(forGameID:)`. Returns nil when the name isn't a
+ /// game name or the UUID doesn't parse. A game's zone name and its root
+ /// record name are identical, so this also resolves a share's zone.
+ static func gameID(fromGameRecordName name: String) -> UUID? {
+ guard name.hasPrefix("game-") else { return nil }
+ return UUID(uuidString: String(name.dropFirst("game-".count)))
+ }
+
/// One Moves record per `(game, authorID, deviceID)`. Each device only
/// writes to its own slot, so there are no write-write conflicts on the
/// `cells` field.
@@ -310,14 +319,26 @@ enum RecordSerializer {
from entity: GameEntity,
includePuzzleSource: Bool
) {
- record["title"] = entity.title as CKRecordValue?
+ // `title` and the `shareRecordName` marker are owner-authoritative: the
+ // title comes from the puzzle the owner authored, and only owner devices
+ // track the share record. A participant only ever re-saves this record to
+ // mint the engagement/notification creds below, and at join time its
+ // local `title` is still the transient "Joining…" placeholder
+ // (`SyncEngine.handleFetchedDatabaseChanges`) until the owner's Game
+ // record lands. Writing it from a participant would LWW-clobber the real
+ // title on the shared record for everyone — so a non-owner leaves these
+ // fields untouched and the server keeps the owner's value.
+ let isOwner = entity.databaseScope == 0
+ if isOwner {
+ record["title"] = entity.title as CKRecordValue?
+ // Owner-side share marker. Propagated so other owner-devices can flip
+ // their `isShared` flag without reading the zone's CKShare directly.
+ record["shareRecordName"] = entity.ckShareRecordName as CKRecordValue?
+ }
record["completedAt"] = entity.completedAt as CKRecordValue?
// Solver's authorID on a win; nil for a resignation. Single-writer
// (the device that first completes the game) so plain LWW is safe.
record["completedBy"] = entity.completedBy as CKRecordValue?
- // Owner-side share marker. Propagated so other owner-devices can flip
- // their `isShared` flag without reading the zone's CKShare directly.
- record["shareRecordName"] = entity.ckShareRecordName as CKRecordValue?
// The shared live-engagement room credentials (encoded
// EngagementRoomCredentials). Any present participant may mint these
// when the field is empty; convergence is plain record-level LWW, and
@@ -752,8 +773,18 @@ enum RecordSerializer {
let source = try String(contentsOf: fileURL, encoding: .utf8)
entity.puzzleSource = source
if let xd = try? XD.parse(source) {
+ let puzzle = Puzzle(xd: xd)
entity.puzzleCmVersion = Int64(XD.currentCmVersion)
- entity.populateCachedSummaryFields(from: Puzzle(xd: xd))
+ // The title is always derived from the puzzle content (there
+ // is no custom game title), so trust the asset over the
+ // record's `title` field — which was already applied above.
+ // This re-derives the real title even when `record["title"]`
+ // carried a stale value, e.g. a participant's transient
+ // "Joining…" placeholder that a prior build wrote to the
+ // shared record, so the title self-heals on the next sync
+ // that carries the asset.
+ entity.title = puzzle.title
+ entity.populateCachedSummaryFields(from: puzzle)
}
} catch {
// CKSyncEngine has already committed this batch by the time
diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift
@@ -25,6 +25,18 @@ struct RecordSerializerTests {
#expect(a == b)
}
+ @Test("gameID(fromGameRecordName:) round-trips and rejects non-game names")
+ func gameIDFromGameRecordName() {
+ let id = UUID()
+ // Round-trips the forward encoding — this is how a share's zone name
+ // ("game-<UUID>") is resolved back to the game it covers.
+ #expect(RecordSerializer.gameID(fromGameRecordName: RecordSerializer.recordName(forGameID: id)) == id)
+ // Non-game names and malformed UUIDs yield nil rather than a bogus id.
+ #expect(RecordSerializer.gameID(fromGameRecordName: "account") == nil)
+ #expect(RecordSerializer.gameID(fromGameRecordName: "friend-\(UUID().uuidString)") == nil)
+ #expect(RecordSerializer.gameID(fromGameRecordName: "game-not-a-uuid") == nil)
+ }
+
// MARK: - Per-game zone
@Test("zoneID(for:) uses game-<UUID> as zone name")
@@ -421,6 +433,84 @@ struct RecordSerializerTests {
#expect(merged.title == "Updated") // mutable field updated
}
+ /// A valid XD whose title ("Test Puzzle") differs from any `record["title"]`
+ /// the tests set, so the parse-derived title is observable.
+ private static let validXDSource = """
+ Title: Test Puzzle
+ Author: Test
+
+
+ ABC
+ D#E
+ FGH
+
+
+ A1. Across 1 ~ ABC
+ A4. Across 4 ~ DE
+ A5. Across 5 ~ FGH
+ D1. Down 1 ~ ADF
+ D2. Down 2 ~ BG
+ D3. Down 3 ~ CEH
+ """
+
+ @Test("applyGameRecord derives the title from the puzzle asset, overriding a stale record title")
+ @MainActor func applyGameRecordDerivesTitleFromAsset() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let gameID = UUID()
+ let recordID = CKRecord.ID(
+ recordName: RecordSerializer.recordName(forGameID: gameID),
+ zoneID: RecordSerializer.zoneID(for: gameID)
+ )
+
+ // The record's title field carries a stale "Joining…" placeholder — the
+ // exact value a participant's Game-record push can clobber the shared
+ // record with — but the puzzleSource asset parses to "Test Puzzle".
+ let (asset, tmpURL) = try makePuzzleAsset(source: Self.validXDSource)
+ defer { try? FileManager.default.removeItem(at: tmpURL) }
+ let record = CKRecord(recordType: "Game", recordID: recordID)
+ record["title"] = "Joining\u{2026}" as CKRecordValue
+ record["puzzleSource"] = asset as CKRecordValue
+
+ let entity = RecordSerializer.applyGameRecord(record, to: ctx)
+ try ctx.save()
+
+ // The asset wins: the stale title self-heals to the puzzle's real title.
+ #expect(entity.title == "Test Puzzle")
+ }
+
+ @Test("populateGameRecord writes the title for an owner but not a participant")
+ @MainActor func populateGameRecordGatesTitleOnOwnership() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+
+ func makeGame(databaseScope: Int16) -> GameEntity {
+ let entity = GameEntity(context: ctx)
+ entity.id = UUID()
+ entity.ckRecordName = "game-\(UUID().uuidString)"
+ entity.title = "Joining\u{2026}"
+ entity.ckShareRecordName = "share-marker"
+ entity.puzzleSource = ""
+ entity.databaseScope = databaseScope
+ return entity
+ }
+
+ // Owner (databaseScope == 0): title and share marker are written.
+ let ownerEntity = makeGame(databaseScope: 0)
+ let ownerRecord = CKRecord(recordType: "Game", recordID: CKRecord.ID(recordName: ownerEntity.ckRecordName!))
+ RecordSerializer.populateGameRecord(ownerRecord, from: ownerEntity, includePuzzleSource: false)
+ #expect(ownerRecord["title"] as? String == "Joining\u{2026}")
+ #expect(ownerRecord["shareRecordName"] as? String == "share-marker")
+
+ // Participant (databaseScope == 1): the transient placeholder title is
+ // not written, so a cred-minting re-save can't clobber the owner's title.
+ let participantEntity = makeGame(databaseScope: 1)
+ let participantRecord = CKRecord(recordType: "Game", recordID: CKRecord.ID(recordName: participantEntity.ckRecordName!))
+ RecordSerializer.populateGameRecord(participantRecord, from: participantEntity, includePuzzleSource: false)
+ #expect(participantRecord["title"] == nil)
+ #expect(participantRecord["shareRecordName"] == nil)
+ }
+
@Test("applyGameRecord preserves local mutable fields when a save is pending")
@MainActor func applyGameRecordPreservesLocalFieldsWhenSavePending() throws {
let persistence = makeTestPersistence()