commit b4cbdfc8571b452ad780a4fbcf4818fc7b4194dc
parent b85fe533bc4672c6224399fadb3cfecbd7a3f178
Author: Michael Camilleri <[email protected]>
Date: Mon, 22 Jun 2026 20:19:15 +0900
Notify the room when a player joins a shared game
A user who joins a game may not be able to enumerate the other
participants because their Player records are only just syncing in. The
alternatives are to wait until this sync has happened (which cannot be
known) or allow the user to send a message to 'the room'. This commit
takes the latter approach.
The push is sent as a room broadcast rather than an addressed fan-out.
The worker gains a broadcast mode that resolves targets from every
device registered under the game's credID, still gated by the game
signature so it remains participant-only, and skips the sender's own
device and its account's other devices via excludeAddress. The same path
now also carries the manual nudge, which no longer depends on the local
recipient list being current. While resolving targets the worker also
records each target's exact storage key, so a token APNs reports as
stale is pruned under its credID-scoped key rather than a reconstructed
bare key that never matched.
Everyone already in a shared game now receives a notification — 'Alice
joined the puzzle X' — the moment a new player accepts the invitation,
so the owner and existing players learn a collaborator has arrived
rather than discovering it only when moves start appearing.
On the app side PushPayload.Event gains a presence-only .join case —
like .nudge it changes no grid cells, so it never marks the game unread
— and AppServices fires the broadcast from onShareJoined after stamping
the account's own derived game address for the exclusion. A Joins toggle
joins the existing notification switches in Settings; when off, the
device registers join in its worker denylist and the push is dropped
before APNs, exactly as the other per-device preferences behave.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
12 files changed, 211 insertions(+), 30 deletions(-)
diff --git a/Crossmate/Models/PlayerPreferences.swift b/Crossmate/Models/PlayerPreferences.swift
@@ -19,6 +19,7 @@ final class PlayerPreferences {
static let name = "playerName"
static let isICloudSyncEnabled = "isICloudSyncEnabled"
static let notifiesNudges = "notifiesNudges"
+ static let notifiesJoins = "notifiesJoins"
static let notifiesPauses = "notifiesPauses"
static let notifiesCompletions = "notifiesCompletions"
static let notifiesInvitations = "notifiesInvitations"
@@ -62,6 +63,10 @@ final class PlayerPreferences {
didSet { local.set(notifiesNudges, forKey: Keys.notifiesNudges) }
}
+ var notifiesJoins: Bool {
+ didSet { local.set(notifiesJoins, forKey: Keys.notifiesJoins) }
+ }
+
var notifiesPauses: Bool {
didSet { local.set(notifiesPauses, forKey: Keys.notifiesPauses) }
}
@@ -89,6 +94,7 @@ final class PlayerPreferences {
self.hasName = storedName.map(Self.isUsableName) ?? false
self.isICloudSyncEnabled = local.object(forKey: Keys.isICloudSyncEnabled) as? Bool ?? true
self.notifiesNudges = local.object(forKey: Keys.notifiesNudges) as? Bool ?? true
+ self.notifiesJoins = local.object(forKey: Keys.notifiesJoins) as? Bool ?? true
self.notifiesPauses = local.object(forKey: Keys.notifiesPauses) as? Bool ?? true
self.notifiesCompletions = local.object(forKey: Keys.notifiesCompletions) as? Bool ?? true
self.notifiesInvitations = local.object(forKey: Keys.notifiesInvitations) as? Bool ?? true
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -1877,6 +1877,13 @@ final class GameStore {
return fetchPlayerEntity(gameID: gameID, authorID: authorID)?.readAt
}
+ /// The local author's own derived push address for `gameID`, read off the
+ /// local Player row, or nil if one hasn't been stamped yet. Used to keep a
+ /// room broadcast from notifying the sender's own other devices.
+ func localPushAddress(gameID: UUID, authorID: String) -> String? {
+ fetchPlayerEntity(gameID: gameID, authorID: authorID)?.pushAddress
+ }
+
private func fetchPlayerEntity(gameID: UUID, authorID: String) -> PlayerEntity? {
let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
request.predicate = NSPredicate(
diff --git a/Crossmate/Services/AccountPushCoordinator.swift b/Crossmate/Services/AccountPushCoordinator.swift
@@ -52,11 +52,13 @@ final class AccountPushCoordinator {
/// "Completions" toggle covers both completion outcomes.
nonisolated static func mutedPushKinds(
nudges: Bool,
+ joins: Bool,
pauses: Bool,
completions: Bool
) -> Set<String> {
var muted: Set<String> = []
if !nudges { muted.insert("nudge") }
+ if !joins { muted.insert("join") }
if !pauses { muted.insert("pause") }
if !completions { muted.formUnion(["win", "resign"]) }
return muted
@@ -65,6 +67,7 @@ final class AccountPushCoordinator {
private func currentMutedPushKinds() -> Set<String> {
Self.mutedPushKinds(
nudges: preferences.notifiesNudges,
+ joins: preferences.notifiesJoins,
pauses: preferences.notifiesPauses,
completions: preferences.notifiesCompletions
)
@@ -81,6 +84,7 @@ final class AccountPushCoordinator {
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
withObservationTracking {
_ = self.preferences.notifiesNudges
+ _ = self.preferences.notifiesJoins
_ = self.preferences.notifiesPauses
_ = self.preferences.notifiesCompletions
} onChange: {
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -877,9 +877,16 @@ final class AppServices {
// a collaboration. Mirrors the owner path in `onShareSaved` so the
// app is in Settings > Notifications before any inbound moves.
await AppDelegate.requestNotificationAuthorizationIfNeeded()
- self.syncMonitor.note("share joined: join ping skipped")
await self.accountPush.reconcilePushRegistration()
+ // Stamp (minting if needed) this account's own derived push address
+ // for the joined game, both so the room broadcast below can exclude
+ // our own devices and so we're addressable for inbound pushes.
+ let ownAddress = self.identity.currentID.flatMap {
+ self.accountPush.setDerivedPushAddress(gameID: gameID, authorID: $0)
+ }
await self.accountPush.publishAccountJoinedPush(gameID: gameID)
+ // Tell everyone already in the room that we've joined.
+ await self.sessions.publishJoinPush(gameID: gameID, excludeAddress: ownAddress)
}
// PlayerNamePublisher fans out name changes as `name` Decisions to the
diff --git a/Crossmate/Services/PushClient.swift b/Crossmate/Services/PushClient.swift
@@ -187,6 +187,14 @@ final class PushClient {
/// that account's registered device tokens and fans the push out to all
/// of them. Failures are logged but never surfaced — pushes are advisory
/// and the next event will retry the underlying state on its own.
+ /// When `broadcast` is true the worker fans the push out to every device
+ /// registered under the game's credential — the whole room — instead of an
+ /// explicit `addressees` list, so the sender needn't know who the
+ /// participants are (their Player records may not have synced yet). The
+ /// uniform `broadcastPayload` and `body` are delivered to all of them, and
+ /// `excludeAddress` (the sender's own derived game address) keeps the
+ /// sender's other devices from being notified. Broadcast is only meaningful
+ /// for a game-credentialed push.
func publish(
kind: String,
gameID: UUID,
@@ -195,10 +203,13 @@ final class PushClient {
puzzleTitle: String? = nil,
background: Bool = false,
gameCredentialed: Bool = true,
+ broadcast: Bool = false,
+ excludeAddress: String? = nil,
+ broadcastPayload: PushPayload? = nil,
extra: [String: Any] = [:],
body: String
) async {
- guard !addressees.isEmpty else { return }
+ guard broadcast || !addressees.isEmpty else { return }
// A game push must prove participation: resolve (minting if needed) the
// game's shared credential, register it with the worker, and sign the
// request below. Account-scoped publishes (sibling-device hints) carry
@@ -212,7 +223,9 @@ final class PushClient {
await registerGameCredential(creds)
credential = creds
}
- log("push(\(kind)): publishing to \(addressees.count) addressee(s)")
+ log(broadcast
+ ? "push(\(kind)): broadcasting to room"
+ : "push(\(kind)): publishing to \(addressees.count) addressee(s)")
// Stamp the puzzle title onto each addressee's structured payload so the
// receiver's extension can recompose the body from components (swapping
// in its private nickname) rather than editing the sender's text.
@@ -242,6 +255,20 @@ final class PushClient {
if let credential {
payload["credID"] = credential.credID.uuidString
}
+ // Broadcast: the worker resolves targets from the credential's whole
+ // address set, so the empty `addressees` above is ignored. Carry the
+ // uniform payload at top level (the per-addressee slot is unused) and
+ // name the sender's own address so its other devices are skipped.
+ if broadcast {
+ payload["broadcast"] = true
+ if let excludeAddress { payload["excludeAddress"] = excludeAddress }
+ if var broadcastPayload {
+ if let puzzleTitle { broadcastPayload.puzzleTitle = puzzleTitle }
+ if let encoded = broadcastPayload.encodedString() {
+ payload["payload"] = encoded
+ }
+ }
+ }
for (key, value) in extra {
payload[key] = value
}
diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift
@@ -212,10 +212,6 @@ final class SessionCoordinator {
return
}
let plan = pushPlan(for: gameID, excluding: localAuthorID)
- guard !plan.recipients.isEmpty else {
- syncMonitor.note("push(nudge): skipped (no recipients)")
- return
- }
guard plan.completedAt == nil else {
syncMonitor.note("push(nudge): skipped (game completed)")
return
@@ -224,26 +220,65 @@ final class SessionCoordinator {
syncMonitor.note("push(nudge): skipped (access revoked)")
return
}
- // Send to every participant: presence is no longer guessed here. A
- // recipient who is actually present suppresses the banner on the device
- // they're using (foreground `isSuppressed`) and sweeps it from their
- // other devices once their present device's read cursor syncs.
- let addressees = plan.recipients.compactMap { recipient in
- recipient.pushAddress.map {
- PushClient.Addressee(address: $0, payload: PushPayload(event: .nudge))
- }
+ // Broadcast to the whole room rather than an enumerated recipient list:
+ // every participant registered under the game credential is reached even
+ // if their Player record hasn't synced to us. A recipient who is
+ // actually present suppresses the banner on the device they're using
+ // (foreground `isSuppressed`) and sweeps it from their other devices
+ // once their present device's read cursor syncs; `excludeAddress` keeps
+ // the nudge off our own other devices.
+ await pushClient.publish(
+ kind: "nudge",
+ gameID: gameID,
+ addressees: [],
+ title: "Crossmate",
+ puzzleTitle: plan.title,
+ broadcast: true,
+ excludeAddress: store.localPushAddress(gameID: gameID, authorID: localAuthorID),
+ broadcastPayload: PushPayload(event: .nudge),
+ body: PuzzleNotificationText.nudgeBody(
+ playerName: preferences.name,
+ puzzleTitle: plan.title
+ )
+ )
+ }
+
+ /// Announces to everyone already in the room that this account has accepted
+ /// an invitation and joined `gameID`. Broadcast like `nudge` — the joiner
+ /// can't enumerate the other participants because their Player records are
+ /// only just syncing in — and carries no grid summary, just "Alice joined
+ /// 'X'". `excludeAddress` (passed by the join hook, which derives it as part
+ /// of stamping the local push address) keeps the joiner's own other devices
+ /// from being notified. Skipped on a finished or access-revoked game, which
+ /// can't be meaningfully joined.
+ func publishJoinPush(gameID: UUID, excludeAddress: String?) async {
+ guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else {
+ syncMonitor.note("push(join): skipped (no authorID)")
+ return
}
- guard !addressees.isEmpty else {
- syncMonitor.note("push(nudge): skipped (no addressable recipients)")
+ guard let pushClient else {
+ syncMonitor.note("push(join): skipped (no pushClient)")
+ return
+ }
+ let plan = pushPlan(for: gameID, excluding: localAuthorID)
+ guard plan.completedAt == nil else {
+ syncMonitor.note("push(join): skipped (game completed)")
+ return
+ }
+ guard !plan.isAccessRevoked else {
+ syncMonitor.note("push(join): skipped (access revoked)")
return
}
await pushClient.publish(
- kind: "nudge",
+ kind: "join",
gameID: gameID,
- addressees: addressees,
+ addressees: [],
title: "Crossmate",
puzzleTitle: plan.title,
- body: PuzzleNotificationText.nudgeBody(
+ broadcast: true,
+ excludeAddress: excludeAddress,
+ broadcastPayload: PushPayload(event: .join),
+ body: PuzzleNotificationText.joinBody(
playerName: preferences.name,
puzzleTitle: plan.title
)
diff --git a/Crossmate/Views/Settings/SettingsView.swift b/Crossmate/Views/Settings/SettingsView.swift
@@ -70,11 +70,12 @@ struct SettingsView: View {
Toggle("Nudges", isOn: $preferences.notifiesNudges)
Toggle("Pauses", isOn: $preferences.notifiesPauses)
Toggle("Invitations", isOn: $preferences.notifiesInvitations)
+ Toggle("Joins", isOn: $preferences.notifiesJoins)
Toggle("Completions", isOn: $preferences.notifiesCompletions)
} header: {
Text("Notifications")
} footer: {
- Text("Receive notifications when friends nudge you, pause playing after making changes, invite you to play or finish a puzzle. These settings apply only to this device.")
+ Text("Receive notifications when friends nudge you, pause playing after making changes, invite you to play, join one of your puzzles or finish a puzzle. These settings apply only to this device.")
}
if debugMode {
diff --git a/Shared/PushPayload.swift b/Shared/PushPayload.swift
@@ -130,6 +130,10 @@ struct PushPayload: Codable, Sendable, Equatable {
/// rouse the others into the puzzle. Presence only — it carries no
/// grid change — so it never marks the game unread.
case nudge
+ /// A player has accepted an invitation and joined the shared game,
+ /// announced to everyone already in the room. Presence only — joining
+ /// changes no grid cells — so it never marks the game unread.
+ case join
/// An event introduced by a newer build. Treated as carrying no
/// unseen content for badge purposes.
case unknown
@@ -144,7 +148,7 @@ struct PushPayload: Codable, Sendable, Equatable {
case .pause(let fills, let clears, let checks, let reveals):
return fills + clears + checks + reveals > 0
case .win, .resign: return true
- case .replay, .nudge, .unknown: return false
+ case .replay, .nudge, .join, .unknown: return false
}
}
}
@@ -191,6 +195,11 @@ extension PushPayload {
playerName: playerName,
puzzleTitle: puzzleTitle
)
+ case .join:
+ return PuzzleNotificationText.joinBody(
+ playerName: playerName,
+ puzzleTitle: puzzleTitle
+ )
case .replay, .unknown:
return nil
}
@@ -219,7 +228,7 @@ extension PushPayload.Event: Codable {
}
private enum Discriminator: String {
- case pause, win, resign, replay, nudge
+ case pause, win, resign, replay, nudge, join
}
init(from decoder: Decoder) throws {
@@ -240,6 +249,8 @@ extension PushPayload.Event: Codable {
self = .replay
case .nudge:
self = .nudge
+ case .join:
+ self = .join
case nil:
// A discriminator this build doesn't know — a newer sender.
self = .unknown
@@ -263,6 +274,8 @@ extension PushPayload.Event: Codable {
try container.encode(Discriminator.replay.rawValue, forKey: .type)
case .nudge:
try container.encode(Discriminator.nudge.rawValue, forKey: .type)
+ case .join:
+ try container.encode(Discriminator.join.rawValue, forKey: .type)
case .unknown:
// Not produced as an outgoing event by this build; encode a stable
// marker so an `.unknown` round-trips back to `.unknown`.
diff --git a/Shared/PuzzleNotificationText.swift b/Shared/PuzzleNotificationText.swift
@@ -21,6 +21,13 @@ enum PuzzleNotificationText {
"\(resolvedName(playerName)) nudged you to play \(puzzleSuffix(puzzleTitle))"
}
+ /// Body for a join push: "Alice joined the puzzle 'X'". Sent to everyone
+ /// already in the room when a new player accepts the invitation, so it
+ /// always names the action even though joining changes nothing in the grid.
+ static func joinBody(playerName: String, puzzleTitle: String) -> String {
+ "\(resolvedName(playerName)) joined \(puzzleSuffix(puzzleTitle))"
+ }
+
/// Body for a completion push — "Alice solved …" or, when `resigned`,
/// "Alice resigned …" (the resign sentence ends in a full stop to match the
/// app's existing wording).
diff --git a/Tests/Unit/PushPayloadTests.swift b/Tests/Unit/PushPayloadTests.swift
@@ -19,7 +19,8 @@ struct PushPayloadTests {
.win,
.resign,
.replay,
- .nudge
+ .nudge,
+ .join
]
for event in cases {
let decoded = try roundTrip(PushPayload(event: event))
@@ -42,6 +43,7 @@ struct PushPayloadTests {
.composedBody(playerName: "Mum")
}
#expect(body(.nudge) == "Mum nudged you to play the puzzle 'Saturday'")
+ #expect(body(.join) == "Mum joined the puzzle 'Saturday'")
#expect(body(.win) == "Mum solved the puzzle 'Saturday'")
#expect(body(.resign) == "Mum resigned the puzzle 'Saturday'.")
#expect(body(.pause(fills: 3, clears: 0, checks: 0, reveals: 0))
@@ -162,6 +164,8 @@ struct PushPayloadTests {
#expect(!PushPayload(event: .pause(fills: 0, clears: 0, checks: 0, reveals: 0)).marksUnread)
// A nudge is a manual presence ping — it never marks a game unread.
#expect(!PushPayload(event: .nudge).marksUnread)
+ // Joining is presence only — no grid change, so no unread.
+ #expect(!PushPayload(event: .join).marksUnread)
#expect(!PushPayload(event: .replay).marksUnread)
#expect(!PushPayload(event: .unknown).marksUnread)
}
diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift
@@ -41,6 +41,15 @@ struct PuzzleNotificationTextTests {
== "A player nudged you to play the puzzle")
}
+ @Test("joinBody names the joiner and puzzle")
+ func joinBodyNamesJoiner() {
+ #expect(PuzzleNotificationText.joinBody(playerName: "Alice", puzzleTitle: "Saturday Puzzle")
+ == "Alice joined the puzzle 'Saturday Puzzle'")
+ // Empty name and title fall back to neutral wording.
+ #expect(PuzzleNotificationText.joinBody(playerName: "", puzzleTitle: "")
+ == "A player joined the puzzle")
+ }
+
@Test("completionBody distinguishes a solve from a resignation")
func completionBodySolveVsResign() {
#expect(PuzzleNotificationText.completionBody(
@@ -177,6 +186,7 @@ struct NotificationMutedKindsTests {
func allOnMutesNothing() {
let muted = AccountPushCoordinator.mutedPushKinds(
nudges: true,
+ joins: true,
pauses: true,
completions: true
)
@@ -188,16 +198,25 @@ struct NotificationMutedKindsTests {
func togglesMapToKinds() {
#expect(AccountPushCoordinator.mutedPushKinds(
nudges: false,
+ joins: true,
pauses: true,
completions: true
) == ["nudge"])
#expect(AccountPushCoordinator.mutedPushKinds(
nudges: true,
+ joins: false,
+ pauses: true,
+ completions: true
+ ) == ["join"])
+ #expect(AccountPushCoordinator.mutedPushKinds(
+ nudges: true,
+ joins: true,
pauses: false,
completions: true
) == ["pause"])
#expect(AccountPushCoordinator.mutedPushKinds(
nudges: true,
+ joins: true,
pauses: true,
completions: false
) == ["win", "resign"])
@@ -207,11 +226,12 @@ struct NotificationMutedKindsTests {
func neverMutesBackgroundKinds() {
let muted = AccountPushCoordinator.mutedPushKinds(
nudges: false,
+ joins: false,
pauses: false,
completions: false
)
- #expect(muted == ["nudge", "pause", "win", "resign"])
+ #expect(muted == ["join", "nudge", "pause", "win", "resign"])
#expect(!muted.contains("replay"))
#expect(!muted.contains("accountJoined"))
#expect(!muted.contains("accountSeen"))
diff --git a/Workers/push-worker.js b/Workers/push-worker.js
@@ -424,10 +424,22 @@ export class PushRegistry {
readAt,
title,
alertBody,
- background
+ background,
+ broadcast,
+ excludeAddress,
+ payload
} = body;
- if (!kind || !Array.isArray(addressees) || addressees.length === 0) {
- return badRequest("kind and non-empty addressees required");
+ if (!kind) {
+ return badRequest("kind required");
+ }
+ // A broadcast fans out to every device registered under the game's credID
+ // (the whole room), so it carries no addressees but must be game-scoped —
+ // the credID is both the delivery scope and, via its signature, the
+ // participation proof. A non-broadcast publish names its recipients.
+ if (broadcast === true) {
+ if (!credID) return badRequest("broadcast requires credID");
+ } else if (!Array.isArray(addressees) || addressees.length === 0) {
+ return badRequest("non-empty addressees required");
}
if (credID) {
@@ -437,7 +449,9 @@ export class PushRegistry {
}
}
- const targets = await this.resolveTargets(addressees, senderDeviceID, credID);
+ const targets = broadcast === true
+ ? await this.resolveBroadcastTargets(credID, senderDeviceID, excludeAddress, alertBody, payload)
+ : await this.resolveTargets(addressees, senderDeviceID, credID);
if (targets.length === 0) {
return Response.json({ delivered: 0, removed: 0, muted: 0, failed: 0 });
}
@@ -466,7 +480,10 @@ export class PushRegistry {
});
if (result === "ok") delivered += 1;
else if (result === "drop") {
- await this.state.storage.delete(`addr:${target.address}:${target.deviceID}`);
+ // Delete by the exact key the target was resolved from — game
+ // addresses are stored credID-scoped (`addr:<credID>:<address>:<dev>`),
+ // so reconstructing a bare `addr:<address>:<dev>` key would miss them.
+ await this.state.storage.delete(target.storageKey);
removed += 1;
} else {
failed += 1;
@@ -527,6 +544,7 @@ export class PushRegistry {
targets.push({
address: addressee.address,
deviceID,
+ storageKey: key,
body,
payload,
...value
@@ -536,6 +554,38 @@ export class PushRegistry {
return targets;
}
+ // Resolves every device registered under a game's credID — the whole room —
+ // for a broadcast publish. Keys are `addr:<credID>:<address>:<deviceID>`;
+ // addresses (base64url / `acct-…`) and device IDs (hex) never contain a
+ // colon, so the first colon after the prefix splits address from deviceID.
+ // The sender's own device and (via `excludeAddress`) its account's other
+ // devices are skipped, and the uniform `body`/`payload` ride every target.
+ async resolveBroadcastTargets(credID, senderDeviceID, excludeAddress, alertBody, payload) {
+ const body = typeof alertBody === "string" ? alertBody : undefined;
+ const forwarded = typeof payload === "string" ? payload : undefined;
+ const prefix = `addr:${credID}:`;
+ const map = await this.state.storage.list({ prefix });
+ const targets = [];
+ for (const [key, value] of map) {
+ const rest = key.slice(prefix.length);
+ const sep = rest.indexOf(":");
+ if (sep < 0) continue;
+ const address = rest.slice(0, sep);
+ const deviceID = rest.slice(sep + 1);
+ if (senderDeviceID && deviceID === senderDeviceID) continue;
+ if (excludeAddress && address === excludeAddress) continue;
+ targets.push({
+ address,
+ deviceID,
+ storageKey: key,
+ body,
+ payload: forwarded,
+ ...value
+ });
+ }
+ return targets;
+ }
+
async sendOne(target, message) {
const topic = this.env.APNS_TOPIC || "net.inqk.crossmate";
const host = target.environment === "sandbox"