crossmate

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

commit ed9cd0a764a4f0a062bb06d714a002decc3c8e21
parent 334341f9e9786f7462792098d7687d75b625a35c
Author: Michael Camilleri <[email protected]>
Date:   Thu, 28 May 2026 14:33:09 +0900

Update app badge from Notification Service Extension

Push notifications from the worker were arriving without updating the app icon
badge when the device was locked or the app suspended. The migration to APN-based
notifications dropped the wake-on-push that CKSubscription silent pushes used to
provide. This left the badge frequently stale.

This commit adds a NotificationService extension target that mutates the APNs
payload on receipt and stamps aps.badge with the current unread-games count.
The badge model — shared games with new activity — moves to App Group
UserDefaults as BadgeState, so the NSE and main app share state without the NSE
needing access to Core Data. The main app's refreshAppBadge unions the Core
Data ground truth (a new GameStore.unreadOtherMovesGameIDs) into the same set
on every refresh, and dismissDeliveredNotifications clears the entry when the
user opens — or a sibling device reads — the game.

In addition, the push worker now stamps mutable-content: 1 on every payload and
forwards optional cellsAdded / cellsCleared fields. The NSE bumps the badge for
win/resign unconditionally, for pause only when cellsAdded or cellsCleared is
non-zero, and never for play; it also honours
NotificationState.isSuppressed(gameID:) so a push for the puzzle the user is
currently viewing — including the leave-grace tail — doesn't tick the count.

Finally, publishSessionEndPush no longer skips no-edit sessions: peers still
need to know the session ended, so the push goes out with a 'stopped solving
the puzzle X without making any changes.' body and zeroed counts. The existing
'added N letters' / 'cleared N letters' wording is unchanged for sessions that
produced edits.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate.xcodeproj/xcuserdata/pyrmont.xcuserdatad/xcschemes/xcschememanagement.plist | 5+++++
MCrossmate/Persistence/GameStore.swift | 19+++++++++++++++++--
MCrossmate/Services/AppServices.swift | 61++++++++++++++++++++++++++++++++++++++++++-------------------
MCrossmate/Services/PushClient.swift | 8++++++--
ANotificationService/Info.plist | 31+++++++++++++++++++++++++++++++
ANotificationService/NotificationService.entitlements | 10++++++++++
ANotificationService/NotificationService.swift | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MShared/NotificationState.swift | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MWorker/push-worker.js | 10+++++++---
Mproject.yml | 27+++++++++++++++++++++++++++
11 files changed, 406 insertions(+), 26 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; }; 78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; }; 7BD1A9F69953F9C3288969AF /* PlayerRecordPresenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */; }; + 7D4A56FBB1C5D5F89271B77F /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507B4DC893CE8AC4778CBACE /* NotificationService.swift */; }; 7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */; }; 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; }; 818B1F2693962832BE14578E /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */; }; @@ -69,6 +70,7 @@ 85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */; }; 85B9BAC5ED404FE4496250CB /* NYTPuzzleUpgrader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE54EF557E8D808BAA20EA54 /* NYTPuzzleUpgrader.swift */; }; 886A451A134D88B9324D9A1C /* FriendPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE3412F437AABD2988B6976D /* FriendPickerView.swift */; }; + 88BACA1689459AC9AED20D43 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; }; 89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */; }; 8B356C953DA0FAF149C3391A /* Puzzles in Resources */ = {isa = PBXBuildFile; fileRef = BA67C509B467132D1B7510A4 /* Puzzles */; }; 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; }; @@ -98,6 +100,7 @@ C1930083671621AC79CF95DD /* MovesUpdaterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */; }; C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */; }; C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; }; + C511387D9FFBCC2E2F5EF699 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 51318FC5DAE02D35CB005729 /* NotificationService.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */; }; C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */; }; C8ACF431021E7BEE61A99153 /* FriendController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E655698481325C92EF5C348B /* FriendController.swift */; }; @@ -133,6 +136,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 0751E0359C340223ADBA2B05 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 9167165F088B7698D1319D3C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 350366A68B75DC4BDA91F8E5; + remoteInfo = NotificationService; + }; F0122CF3E216720C4437CE6A /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 9167165F088B7698D1319D3C /* Project object */; @@ -142,6 +152,20 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + 2768812850886EB633B6C27C /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + C511387D9FFBCC2E2F5EF699 /* NotificationService.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTLoginView.swift; sourceTree = "<group>"; }; 07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDiagnostics.swift; sourceTree = "<group>"; }; @@ -180,6 +204,8 @@ 4AF633D73818BD59F759FAC4 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; }; 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalog.swift; sourceTree = "<group>"; }; 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDAcceptTests.swift; sourceTree = "<group>"; }; + 507B4DC893CE8AC4778CBACE /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; }; + 51318FC5DAE02D35CB005729 /* NotificationService.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 5267DDA1A330DCBD07303D44 /* RecordBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordBuilder.swift; sourceTree = "<group>"; }; 56BC76178319D0D669CD50FF /* CloudService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudService.swift; sourceTree = "<group>"; }; 5ABB557BA10CBE9909056882 /* GameShareItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameShareItem.swift; sourceTree = "<group>"; }; @@ -207,6 +233,7 @@ 800CCFBE90554F287E765755 /* FriendZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendZoneTests.swift; sourceTree = "<group>"; }; 86470163BFF956F3DE438506 /* Moves.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Moves.swift; sourceTree = "<group>"; }; 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportedBrowseView.swift; sourceTree = "<group>"; }; + 88E8AACB638FE5724B534B41 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushClient.swift; sourceTree = "<group>"; }; 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCursorStore.swift; sourceTree = "<group>"; }; 8FDE03B4A77A8095ED2C23AB /* EngagementCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementCoordinator.swift; sourceTree = "<group>"; }; @@ -232,6 +259,7 @@ B23A692318044351247606DF /* SuccessPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuccessPanel.swift; sourceTree = "<group>"; }; B34489D0864DF76AF436E391 /* NYTPuzzleUpgraderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleUpgraderTests.swift; sourceTree = "<group>"; }; B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PUZToXDConverter.swift; sourceTree = "<group>"; }; + B3D873ABDF871E14794A2845 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = "<group>"; }; B689A7138429641E61E9E558 /* Crossmate.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Crossmate.app; sourceTree = BUILT_PRODUCTS_DIR; }; B766E872B12DC79ECCD80941 /* FriendModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendModelTests.swift; sourceTree = "<group>"; }; B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalogTests.swift; sourceTree = "<group>"; }; @@ -314,6 +342,7 @@ children = ( D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */, B689A7138429641E61E9E558 /* Crossmate.app */, + 51318FC5DAE02D35CB005729 /* NotificationService.appex */, ); name = Products; sourceTree = "<group>"; @@ -475,6 +504,7 @@ BA67C509B467132D1B7510A4 /* Puzzles */, 5770CE69DB2B0B7462FACE53 /* Crossmate */, 6F470E54D9E6E99FCEA893D1 /* Generated */, + D7910EDB740AFF963BDCA6CE /* NotificationService */, 9BF7383FE2AB07F12434C013 /* Shared */, 01B07D8724DEA04C3E74558E /* Support */, 212DB6FCF46C41F81C41D232 /* Unit */, @@ -482,6 +512,16 @@ ); sourceTree = "<group>"; }; + D7910EDB740AFF963BDCA6CE /* NotificationService */ = { + isa = PBXGroup; + children = ( + 88E8AACB638FE5724B534B41 /* Info.plist */, + B3D873ABDF871E14794A2845 /* NotificationService.entitlements */, + 507B4DC893CE8AC4778CBACE /* NotificationService.swift */, + ); + path = NotificationService; + sourceTree = "<group>"; + }; D8F0E3376B2616B4E917129C /* Services */ = { isa = PBXGroup; children = ( @@ -509,16 +549,35 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 350366A68B75DC4BDA91F8E5 /* NotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97D15A585690B87E7C17FF8A /* Build configuration list for PBXNativeTarget "NotificationService" */; + buildPhases = ( + 6D4D7955C5F70F9F18D7C1F0 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = NotificationService; + packageProductDependencies = ( + ); + productName = NotificationService; + productReference = 51318FC5DAE02D35CB005729 /* NotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; 7708D1C8A0145D43BD15DEB7 /* Crossmate */ = { isa = PBXNativeTarget; buildConfigurationList = AB7D49875A042FD78EDD157A /* Build configuration list for PBXNativeTarget "Crossmate" */; buildPhases = ( C17B62906BBF281D006D8DC2 /* Sources */, C475EFB2B47245175F9B415C /* Resources */, + 2768812850886EB633B6C27C /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 0638C125467274AA03088E07 /* PBXTargetDependency */, ); name = Crossmate; packageProductDependencies = ( @@ -554,6 +613,10 @@ BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1430; TargetAttributes = { + 350366A68B75DC4BDA91F8E5 = { + DevelopmentTeam = 7TD7PZBNXP; + ProvisioningStyle = Automatic; + }; 7708D1C8A0145D43BD15DEB7 = { DevelopmentTeam = 7TD7PZBNXP; ProvisioningStyle = Automatic; @@ -580,6 +643,7 @@ targets = ( 7708D1C8A0145D43BD15DEB7 /* Crossmate */, C38EBD1A6B9D37EF81FF3511 /* Crossmate Unit Tests */, + 350366A68B75DC4BDA91F8E5 /* NotificationService */, ); }; /* End PBXProject section */ @@ -597,6 +661,15 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 6D4D7955C5F70F9F18D7C1F0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7D4A56FBB1C5D5F89271B77F /* NotificationService.swift in Sources */, + 88BACA1689459AC9AED20D43 /* NotificationState.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 931E2DAAD4EC47B06F7AB60A /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -735,6 +808,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 0638C125467274AA03088E07 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 350366A68B75DC4BDA91F8E5 /* NotificationService */; + targetProxy = 0751E0359C340223ADBA2B05 /* PBXContainerItemProxy */; + }; 42035D5EEE61A5D459E1D46D /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 7708D1C8A0145D43BD15DEB7 /* Crossmate */; @@ -843,6 +921,23 @@ }; name = Release; }; + 68A4416DD8D2ADB28F35E46C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = NotificationService/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = net.inqk.crossmate.notificationservice; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; 8BC97916898B0BF1E6951C48 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -886,6 +981,23 @@ }; name = Debug; }; + C32934743A6313F80D8A4C76 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = NotificationService/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = net.inqk.crossmate.notificationservice; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; E7B092DD549FA4FFED8BC20E /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 0BDC16DA7F762F4C5F4BED14 /* Config.xcconfig */; @@ -969,6 +1081,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + 97D15A585690B87E7C17FF8A /* Build configuration list for PBXNativeTarget "NotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C32934743A6313F80D8A4C76 /* Debug */, + 68A4416DD8D2ADB28F35E46C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; 9A436EF03A8593C66A18A832 /* Build configuration list for PBXProject "Crossmate" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Crossmate.xcodeproj/xcuserdata/pyrmont.xcuserdatad/xcschemes/xcschememanagement.plist b/Crossmate.xcodeproj/xcuserdata/pyrmont.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,6 +9,11 @@ <key>orderHint</key> <integer>0</integer> </dict> + <key>NotificationService.xcscheme_^#shared#^_</key> + <dict> + <key>orderHint</key> + <integer>1</integer> + </dict> </dict> </dict> </plist> diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -424,13 +424,28 @@ final class GameStore { func unreadOtherMovesGameCount() -> Int { let request = NSFetchRequest<NSNumber>(entityName: "GameEntity") request.resultType = .countResultType - request.predicate = NSPredicate( + request.predicate = unreadOtherMovesPredicate + return (try? context.count(for: request)) ?? 0 + } + + /// The same heuristic as `unreadOtherMovesGameCount`, returning the + /// individual game IDs so the App Group `BadgeState` set can be unioned + /// with NSE-added entries. + func unreadOtherMovesGameIDs() -> Set<UUID> { + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.predicate = unreadOtherMovesPredicate + request.propertiesToFetch = ["id"] + let rows = (try? context.fetch(request)) ?? [] + return Set(rows.compactMap(\.id)) + } + + private var unreadOtherMovesPredicate: NSPredicate { + NSPredicate( format: "(databaseScope == 1 OR ckShareRecordName != nil) " + "AND completedAt == nil " + "AND latestOtherMoveAt != nil " + "AND (lastReadOtherMoveAt == nil OR latestOtherMoveAt > lastReadOtherMoveAt)" ) - return (try? context.count(for: request)) ?? 0 } // MARK: - Load a specific game diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -720,7 +720,10 @@ final class AppServices { /// the push when a peer device of this author wrote to Player during /// the grace window — that device is still playing and will report the /// cumulative delta when it pauses. Otherwise clears the shared - /// snapshot so the next session-begin re-anchors fresh. + /// snapshot so the next session-begin re-anchors fresh. A no-edit + /// pause still fires (peers need to know the session ended); the + /// `cellsAdded`/`cellsCleared` fields ride along so the receiver-side + /// Notification Service Extension can decide whether to bump the badge. func publishSessionEndPush(gameID: UUID, pauseStart: Date = Date()) async { // A direct call (e.g. from `.onDisappear`) supersedes any pending // grace-window timer for this game — drop it so we don't fire a @@ -758,10 +761,6 @@ final class AppServices { authorID: localAuthorID, reason: "session-end" ) - guard added > 0 || cleared > 0 else { - syncMonitor.note("push(pause): skipped (no edits)") - return - } guard let pushClient else { syncMonitor.note("push(pause): skipped (no pushClient)") return @@ -783,20 +782,31 @@ final class AppServices { } let playerName = preferences.name.isEmpty ? "A player" : preferences.name let puzzleSuffix = plan.title.isEmpty ? "the puzzle" : "the puzzle '\(plan.title)'" - var parts: [String] = [] - if added > 0 { - parts.append("added \(added) \(added == 1 ? "letter" : "letters")") - } - if cleared > 0 { - parts.append("cleared \(cleared) \(cleared == 1 ? "letter" : "letters")") + let body: String + if added == 0 && cleared == 0 { + // Peers still need to know the session ended — otherwise they're + // left thinking the player is still active. The badge logic on the + // receiver side keys off `cellsAdded`/`cellsCleared` so a zero + // pause doesn't tick the unread-games count. + body = "\(playerName) stopped solving \(puzzleSuffix) without making any changes." + } else { + var parts: [String] = [] + if added > 0 { + parts.append("added \(added) \(added == 1 ? "letter" : "letters")") + } + if cleared > 0 { + parts.append("cleared \(cleared) \(cleared == 1 ? "letter" : "letters")") + } + body = "\(playerName) \(parts.joined(separator: " and ")) in \(puzzleSuffix)" } - let action = parts.joined(separator: " and ") await pushClient.publish( kind: "pause", gameID: gameID, addressees: plan.recipients, title: "Crossmate", - body: "\(playerName) \(action) in \(puzzleSuffix)" + body: body, + cellsAdded: added, + cellsCleared: cleared ) } @@ -2192,6 +2202,12 @@ final class AppServices { /// account learn about the dismissal indirectly: a directed ping is /// deleted on consumption (the `onPingDeleted` path then withdraws their /// copy), and the unread-moves badge converges via `Player.readAt`. + /// + /// Every dismissal path is also a "user has seen this game" signal, so + /// we drop `gameID` from `BadgeState.unreadGameIDs` and refresh the + /// app-icon badge. Without this, pause/win/resign entries added by the + /// Notification Service Extension would otherwise linger past the point + /// where their banners have already been withdrawn. func dismissDeliveredNotifications(for gameID: UUID) async { let center = UNUserNotificationCenter.current() let delivered = await center.deliveredNotifications() @@ -2206,6 +2222,8 @@ final class AppServices { center.removeDeliveredNotifications(withIdentifiers: identifiers) syncMonitor.note("notif: dismissed \(identifiers.count) delivered for \(gameID.uuidString)") } + BadgeState.clearUnread(gameID: gameID) + await refreshAppBadge() } /// Publishes this account's read horizon for other-author moves by @@ -2243,14 +2261,19 @@ final class AppServices { ) } - /// Sets the app icon badge to the number of shared games with unread - /// other-author moves — the same `hasUnreadOtherMoves` signal that drives - /// the per-row dot in the library list. Silently no-ops when the user - /// hasn't granted badge permission; iOS just won't render the value. + /// Sets the app icon badge to the cardinality of `BadgeState.unreadGameIDs` + /// — Core Data ground truth (the `hasUnreadOtherMoves` heuristic that + /// drives the per-row dot in the library list) unioned with any + /// pause/win/resign games the Notification Service Extension added since + /// the last refresh. Silently no-ops when the user hasn't granted badge + /// permission; iOS just won't render the value. func refreshAppBadge() async { - let count = store.unreadOtherMovesGameCount() + let coreDataUnread = store.unreadOtherMovesGameIDs() + var merged = BadgeState.unreadGameIDs() + merged.formUnion(coreDataUnread) + BadgeState.setUnreadGameIDs(merged) do { - try await UNUserNotificationCenter.current().setBadgeCount(count) + try await UNUserNotificationCenter.current().setBadgeCount(merged.count) } catch { syncMonitor.note("app badge update failed: \(error.localizedDescription)") } diff --git a/Crossmate/Services/PushClient.swift b/Crossmate/Services/PushClient.swift @@ -102,11 +102,13 @@ final class PushClient { gameID: UUID, addressees: [String], title: String, - body: String + body: String, + cellsAdded: Int? = nil, + cellsCleared: Int? = nil ) async { guard !addressees.isEmpty else { return } log("push(\(kind)): publishing to \(addressees.count) addressee(s)") - let payload: [String: Any] = [ + var payload: [String: Any] = [ "kind": kind, "gameID": gameID.uuidString, "fromAuthorID": authorID ?? "", @@ -114,6 +116,8 @@ final class PushClient { "alertBody": body, "addressees": addressees.map { ["authorID": $0] } ] + if let cellsAdded { payload["cellsAdded"] = cellsAdded } + if let cellsCleared { payload["cellsCleared"] = cellsCleared } var request = URLRequest(url: baseURL.appendingPathComponent("publish")) request.httpMethod = "POST" applyAuth(&request) diff --git a/NotificationService/Info.plist b/NotificationService/Info.plist @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleDevelopmentRegion</key> + <string>$(DEVELOPMENT_LANGUAGE)</string> + <key>CFBundleDisplayName</key> + <string>Crossmate Notification Service</string> + <key>CFBundleExecutable</key> + <string>$(EXECUTABLE_NAME)</string> + <key>CFBundleIdentifier</key> + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>CFBundleName</key> + <string>$(PRODUCT_NAME)</string> + <key>CFBundlePackageType</key> + <string>XPC!</string> + <key>CFBundleShortVersionString</key> + <string>1.0.0</string> + <key>CFBundleVersion</key> + <string>$(CURRENT_PROJECT_VERSION)</string> + <key>NSExtension</key> + <dict> + <key>NSExtensionPointIdentifier</key> + <string>com.apple.usernotifications.service</string> + <key>NSExtensionPrincipalClass</key> + <string>$(PRODUCT_MODULE_NAME).NotificationService</string> + </dict> +</dict> +</plist> diff --git a/NotificationService/NotificationService.entitlements b/NotificationService/NotificationService.entitlements @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>com.apple.security.application-groups</key> + <array> + <string>group.net.inqk.crossmate</string> + </array> +</dict> +</plist> diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift @@ -0,0 +1,84 @@ +import UserNotifications + +/// Notification Service Extension. Runs in its own process when an APNs alert +/// arrives with `mutable-content: 1`, with ~30s to mutate the content before +/// iOS displays it. +/// +/// Crossmate uses the NSE for one job only: keep the app-icon badge accurate +/// when push notifications land while the main app is suspended or +/// terminated. The badge model is "shared games with new activity worth +/// surfacing"; we track that as a set of game UUIDs in App Group +/// UserDefaults (`BadgeState`) and stamp the resulting count on the outgoing +/// notification's `badge` field. Once the main app foregrounds it unions +/// Core Data ground truth into the same set and re-stamps. +/// +/// The payload from the push worker carries `kind` and `gameID`. Bumping +/// rules: +/// - `play` — presence ping, never bumps. +/// - `pause` — bumps only when `cellsAdded`/`cellsCleared` indicate edits +/// (peers still need a no-edit pause to know the session ended). +/// - `win` / `resign` — always bump (completion is by definition new). +/// +/// We also honour `NotificationState.isSuppressed(gameID:)` so a peer push +/// for the puzzle the user is currently viewing (or just left, within the +/// leave-grace window) doesn't tick the badge. +final class NotificationService: UNNotificationServiceExtension { + + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.contentHandler = contentHandler + self.bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent + + guard let bestAttemptContent else { + contentHandler(request.content) + return + } + + let userInfo = request.content.userInfo + let kind = userInfo["kind"] as? String + let gameID = (userInfo["gameID"] as? String).flatMap(UUID.init(uuidString:)) + + if shouldBumpBadge(kind: kind, gameID: gameID, userInfo: userInfo), + let gameID { + let newCount = BadgeState.markUnread(gameID: gameID) + bestAttemptContent.badge = NSNumber(value: newCount) + } else { + // Either presence-only (play), no-edit pause, suppressed, or the + // payload is malformed. Stamp the current count so the badge at + // least tracks reality, but don't grow it. + bestAttemptContent.badge = NSNumber(value: BadgeState.unreadGameIDs().count) + } + + contentHandler(bestAttemptContent) + } + + override func serviceExtensionTimeWillExpire() { + if let contentHandler, let bestAttemptContent { + contentHandler(bestAttemptContent) + } + } + + private func shouldBumpBadge( + kind: String?, + gameID: UUID?, + userInfo: [AnyHashable: Any] + ) -> Bool { + guard let kind, let gameID else { return false } + if NotificationState.isSuppressed(gameID: gameID) { return false } + switch kind { + case "win", "resign": + return true + case "pause": + let added = (userInfo["cellsAdded"] as? NSNumber)?.intValue ?? 0 + let cleared = (userInfo["cellsCleared"] as? NSNumber)?.intValue ?? 0 + return added > 0 || cleared > 0 + default: + return false + } + } +} diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -170,4 +170,60 @@ enum NotificationState { static func markLegacyPlayPingPurged() { defaults?.set(true, forKey: legacyPlayPingPurgeKey) } + + /// Exposes the (testing-aware) App Group defaults to siblings in this + /// module — currently `BadgeState`, which shares storage but lives in + /// its own namespace. + static var sharedDefaultsForSiblings: UserDefaults? { defaults } +} + +/// App-icon badge model, persisted in the same App Group as +/// `NotificationState` so the Notification Service Extension can mutate it +/// from a separate process when an APNs alert arrives. The badge value is +/// the cardinality of `unreadGameIDs` — a set of shared games with unread +/// other-author activity (moves merged via the sync engine, plus pause / +/// win / resign pushes the NSE added between launches). The main app's +/// `refreshAppBadge` unions the Core Data-derived ground truth into this +/// set on every refresh; opening a game clears its entry. +enum BadgeState { + private static let unreadKey = "badge.unreadGameIDs" + + private static var defaults: UserDefaults? { + NotificationState.sharedDefaultsForSiblings + } + + static func unreadGameIDs() -> Set<UUID> { + guard let raw = defaults?.array(forKey: unreadKey) as? [String] else { + return [] + } + return Set(raw.compactMap(UUID.init(uuidString:))) + } + + static func setUnreadGameIDs(_ ids: Set<UUID>) { + guard let defaults else { return } + if ids.isEmpty { + defaults.removeObject(forKey: unreadKey) + } else { + defaults.set(ids.map(\.uuidString), forKey: unreadKey) + } + } + + /// Adds `gameID` to the unread set. Returns the resulting count so the + /// caller (typically the NSE) can stamp the APNs badge in one step. + @discardableResult + static func markUnread(gameID: UUID) -> Int { + var current = unreadGameIDs() + current.insert(gameID) + setUnreadGameIDs(current) + return current.count + } + + /// Removes `gameID` from the unread set. Returns the resulting count. + @discardableResult + static func clearUnread(gameID: UUID) -> Int { + var current = unreadGameIDs() + current.remove(gameID) + setUnreadGameIDs(current) + return current.count + } } diff --git a/Worker/push-worker.js b/Worker/push-worker.js @@ -70,7 +70,7 @@ export class PushRegistry { async handlePublish(request) { const body = await readJSON(request); if (!body) return badRequest("Body must be JSON"); - const { kind, addressees, gameID, fromAuthorID, title, alertBody } = body; + const { kind, addressees, gameID, fromAuthorID, title, alertBody, cellsAdded, cellsCleared } = body; if (!kind || !Array.isArray(addressees) || addressees.length === 0) { return badRequest("kind and non-empty addressees required"); } @@ -89,7 +89,9 @@ export class PushRegistry { gameID, fromAuthorID, title, - body: alertBody + body: alertBody, + cellsAdded, + cellsCleared }); if (result === "ok") delivered += 1; else if (result === "drop") { @@ -144,11 +146,13 @@ export class PushRegistry { if (message.title) alert.title = message.title; if (message.body) alert.body = message.body; const payload = { - aps: { alert, sound: "default" }, + aps: { alert, sound: "default", "mutable-content": 1 }, kind: message.kind }; if (message.gameID) payload.gameID = message.gameID; if (message.fromAuthorID) payload.fromAuthorID = message.fromAuthorID; + if (Number.isFinite(message.cellsAdded)) payload.cellsAdded = message.cellsAdded; + if (Number.isFinite(message.cellsCleared)) payload.cellsCleared = message.cellsCleared; const response = await fetch(`https://${host}/3/device/${target.token}`, { method: "POST", diff --git a/project.yml b/project.yml @@ -29,6 +29,10 @@ targets: - path: Puzzles type: folder buildPhase: resources + dependencies: + - target: NotificationService + embed: true + codeSign: true info: path: Crossmate/Info.plist properties: @@ -95,6 +99,29 @@ targets: Release: OTHER_LDFLAGS: $(inherited) -Wl,-no_deduplicate + NotificationService: + type: app-extension + platform: iOS + sources: + - NotificationService + - Shared + info: + path: NotificationService/Info.plist + properties: + CFBundleDisplayName: Crossmate Notification Service + CFBundleShortVersionString: "1.0.0" + CFBundleVersion: $(CURRENT_PROJECT_VERSION) + NSExtension: + NSExtensionPointIdentifier: com.apple.usernotifications.service + NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).NotificationService + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: net.inqk.crossmate.notificationservice + INFOPLIST_FILE: NotificationService/Info.plist + CODE_SIGN_ENTITLEMENTS: NotificationService/NotificationService.entitlements + CODE_SIGN_STYLE: Automatic + TARGETED_DEVICE_FAMILY: "1,2" + Crossmate Unit Tests: type: bundle.unit-test platform: iOS