crossmate

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

commit 726cdcd67c40f69cb96dd2361b1e9d8ca47a6bb4
parent 3ac5039baa81a9cde432c02119d6ad501e4d3e2c
Author: Michael Camilleri <[email protected]>
Date:   Wed, 29 Apr 2026 23:30:14 +0900

Add app notifications for shared puzzle activity

This commit adds shared-puzzle activity notifications built around lightweight
CloudKit SessionPing records. MoveBuffer emits a ping at the start of an edit
session, SyncEngine serializes it as a write-once CloudKit record, and
SessionPingSubscriber registers per-zone query subscriptions in the shared
database while filtering out pings authored by the local user.

Crossmate now requests notification authorisation when opening shared puzzles,
tracks the active puzzle through App Group state, and suppresses foreground
notifications for the puzzle currently on screen. A Notification Service
Extension rewrites CloudKit's placeholder alert body using the sender name and
puzzle title, demotes duplicate notifications for the same game within the
deduplication window, and shares suppression state with the host app.

The project configuration adds the NotificationService target, shared
NotificationState source, app group entitlements, embedded extension wiring,
and export signing configuration for distribution.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate.xcodeproj/xcuserdata/pyrmont.xcuserdatad/xcschemes/xcschememanagement.plist | 19+++++++++++++++++++
MCrossmate/Crossmate.entitlements | 4++++
MCrossmate/CrossmateApp.swift | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
MCrossmate/Services/AppServices.swift | 46+++++++++++++++++++++++++++++++++++++++++++++-
MCrossmate/Sync/MoveBuffer.swift | 34+++++++++++++++++++++++++++++++++-
MCrossmate/Sync/RecordSerializer.swift | 38++++++++++++++++++++++++++++++++++++++
ACrossmate/Sync/SessionPingSubscriber.swift | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 99++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
ANotificationService/Info.plist | 31+++++++++++++++++++++++++++++++
ANotificationService/NotificationService.entitlements | 10++++++++++
ANotificationService/NotificationService.swift | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MScripts/publish-ios.sh | 2++
AShared/NotificationState.swift | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 27+++++++++++++++++++++++++++
15 files changed, 738 insertions(+), 10 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -29,17 +29,20 @@ 4A89595E3F6AB50E1D9E6BA8 /* ImportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 462CE0FD356F6137C9BFD30F /* ImportService.swift */; }; 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; }; + 6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; }; 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; }; 765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; }; 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; }; 78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; }; 7C0AAD1DD6086E76A6DA806B /* NameBroadcasterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */; }; + 7D4A56FBB1C5D5F89271B77F /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507B4DC893CE8AC4778CBACE /* NotificationService.swift */; }; 7E54EC2E507C3BFD615FD621 /* MoveLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7422F19AA1F1692A98E3602 /* MoveLog.swift */; }; 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; }; 818B1F2693962832BE14578E /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */; }; 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */; }; 8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */; }; 849970A21D62C34EC382A27E /* GameShareItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABB557BA10CBE9909056882 /* GameShareItem.swift */; }; + 88BACA1689459AC9AED20D43 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; }; 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; }; 905D79CFE454D2E4374544C7 /* GameStoreSnapshotPruningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F61F35F4B4AEF51C02276A3 /* GameStoreSnapshotPruningTests.swift */; }; 9789150602A3321D2E1E7E81 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0BF60C84D92A9024AC1A53FC /* Media.xcassets */; }; @@ -58,6 +61,7 @@ BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C74683332956B0D1CA37589 /* ShareController.swift */; }; BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.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 */; }; C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */; }; CC250D6BA9B41CB722D8A62E /* CloudService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BC76178319D0D669CD50FF /* CloudService.swift */; }; @@ -77,10 +81,18 @@ F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */; }; F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */; }; F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */; }; + F9B17DFD6A460AA3266B34B6 /* SessionPingSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3145BFE3D25B6025BA277D9 /* SessionPingSubscriber.swift */; }; FFBE2EC8A3A60E119A0D314F /* NYTBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */; }; /* 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 */; @@ -90,6 +102,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>"; }; 0B73A791FD061430AE286E11 /* morning.xd */ = {isa = PBXFileReference; path = morning.xd; sourceTree = "<group>"; }; @@ -103,6 +129,7 @@ 20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; }; 26397B9DBC57DCF7B58899D4 /* BuildNumber.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BuildNumber.xcconfig; sourceTree = "<group>"; }; 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.swift; sourceTree = "<group>"; }; + 2D2FD896D75863554E31654C /* NotificationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationState.swift; sourceTree = "<group>"; }; 3292748EAE27B608C769D393 /* PlayerRoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRoster.swift; sourceTree = "<group>"; }; 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; }; 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<group>"; }; @@ -114,7 +141,9 @@ 465F2BB469EFE84CF3733398 /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; }; 4803C2E84FC5B3BFE593171D /* NameBroadcaster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBroadcaster.swift; sourceTree = "<group>"; }; 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalog.swift; sourceTree = "<group>"; }; + 507B4DC893CE8AC4778CBACE /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; }; 50992CDA4082429EBB17F65C /* garden.xd */ = {isa = PBXFileReference; path = garden.xd; sourceTree = "<group>"; }; + 51318FC5DAE02D35CB005729 /* NotificationService.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 52B8E26067849A63758DDEA4 /* MoveBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveBuffer.swift; sourceTree = "<group>"; }; 543481AA9FA32BF14076EB1C /* MoveLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveLogTests.swift; sourceTree = "<group>"; }; 56BC76178319D0D669CD50FF /* CloudService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudService.swift; sourceTree = "<group>"; }; @@ -132,6 +161,7 @@ 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = "<group>"; }; 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializerTests.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>"; }; 927186458ED03FD0C5660765 /* CrossmateModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CrossmateModel.xcdatamodel; sourceTree = "<group>"; }; 93EE5BA78566EDED68D846AB /* GameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStore.swift; sourceTree = "<group>"; }; 9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; @@ -146,6 +176,7 @@ B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleFetcher.swift; sourceTree = "<group>"; }; B135C285570F91181595B405 /* CellMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellMark.swift; sourceTree = "<group>"; }; B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorIdentity.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; }; B9031A1574C21866940F6A2C /* XD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XD.swift; sourceTree = "<group>"; }; BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveBufferTests.swift; sourceTree = "<group>"; }; @@ -155,6 +186,7 @@ C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverterTests.swift; sourceTree = "<group>"; }; CAB4BB9E160C3A59C653E7A9 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; }; CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = "<group>"; }; + D3145BFE3D25B6025BA277D9 /* SessionPingSubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPingSubscriber.swift; sourceTree = "<group>"; }; D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; }; DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; }; @@ -187,6 +219,7 @@ F7422F19AA1F1692A98E3602 /* MoveLog.swift */, 19D51F4A1CAEB849A09EC2C1 /* PresencePublisher.swift */, 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */, + D3145BFE3D25B6025BA277D9 /* SessionPingSubscriber.swift */, 5C74683332956B0D1CA37589 /* ShareController.swift */, 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */, AC00F4D5060EDA4859A6B3F1 /* SyncMonitor.swift */, @@ -200,6 +233,7 @@ children = ( D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */, B689A7138429641E61E9E558 /* Crossmate.app */, + 51318FC5DAE02D35CB005729 /* NotificationService.appex */, ); name = Products; sourceTree = "<group>"; @@ -303,6 +337,14 @@ path = Views; sourceTree = "<group>"; }; + 9BF7383FE2AB07F12434C013 /* Shared */ = { + isa = PBXGroup; + children = ( + 2D2FD896D75863554E31654C /* NotificationState.swift */, + ); + path = Shared; + sourceTree = "<group>"; + }; ABB371EF2574E95782CB05FD /* Sync */ = { isa = PBXGroup; children = ( @@ -318,12 +360,24 @@ children = ( 5770CE69DB2B0B7462FACE53 /* Crossmate */, 6F470E54D9E6E99FCEA893D1 /* Generated */, + D7910EDB740AFF963BDCA6CE /* NotificationService */, + 9BF7383FE2AB07F12434C013 /* Shared */, 01B07D8724DEA04C3E74558E /* Support */, 212DB6FCF46C41F81C41D232 /* Unit */, 12BCF7948BC2C200C647C279 /* Products */, ); 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 = ( @@ -353,16 +407,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 = ( @@ -398,6 +471,10 @@ BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1430; TargetAttributes = { + 350366A68B75DC4BDA91F8E5 = { + DevelopmentTeam = 7TD7PZBNXP; + ProvisioningStyle = Automatic; + }; 7708D1C8A0145D43BD15DEB7 = { DevelopmentTeam = 7TD7PZBNXP; ProvisioningStyle = Automatic; @@ -424,6 +501,7 @@ targets = ( 7708D1C8A0145D43BD15DEB7 /* Crossmate */, C38EBD1A6B9D37EF81FF3511 /* Crossmate Unit Tests */, + 350366A68B75DC4BDA91F8E5 /* NotificationService */, ); }; /* End PBXProject section */ @@ -443,6 +521,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; @@ -501,6 +588,7 @@ B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */, CCF2B0EBE9F414B5CA6AADBA /* NameBroadcaster.swift in Sources */, DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */, + 6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */, 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */, 47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */, F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */, @@ -513,6 +601,7 @@ E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */, 2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */, CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */, + F9B17DFD6A460AA3266B34B6 /* SessionPingSubscriber.swift in Sources */, 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */, BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */, AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */, @@ -527,6 +616,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 0638C125467274AA03088E07 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 350366A68B75DC4BDA91F8E5 /* NotificationService */; + targetProxy = 0751E0359C340223ADBA2B05 /* PBXContainerItemProxy */; + }; 42035D5EEE61A5D459E1D46D /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 7708D1C8A0145D43BD15DEB7 /* Crossmate */; @@ -635,6 +729,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; + }; + name = Release; + }; 8BC97916898B0BF1E6951C48 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -671,6 +782,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; + }; + name = Debug; + }; E7B092DD549FA4FFED8BC20E /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 26397B9DBC57DCF7B58899D4 /* BuildNumber.xcconfig */; @@ -754,6 +882,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 @@ -0,0 +1,19 @@ +<?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>SchemeUserState</key> + <dict> + <key>Crossmate.xcscheme_^#shared#^_</key> + <dict> + <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/Crossmate.entitlements b/Crossmate/Crossmate.entitlements @@ -4,6 +4,10 @@ <dict> <key>aps-environment</key> <string>development</string> + <key>com.apple.security.application-groups</key> + <array> + <string>group.net.inqk.crossmate</string> + </array> <key>com.apple.developer.icloud-container-identifiers</key> <array> <string>iCloud.net.inqk.crossmate.v2</string> diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -1,5 +1,6 @@ import CloudKit import SwiftUI +import UserNotifications @main struct CrossmateApp: App { @@ -30,7 +31,7 @@ struct CrossmateApp: App { // MARK: - App Delegate -final class AppDelegate: NSObject, UIApplicationDelegate, @unchecked Sendable { +final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate, @unchecked Sendable { var onRemoteNotification: ((String) async -> Void)? func application( @@ -38,9 +39,51 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @unchecked Sendable { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { application.registerForRemoteNotifications() + UNUserNotificationCenter.current().delegate = self return true } + /// Foreground notification arrival. If the user is currently viewing the + /// puzzle the ping refers to, hide it entirely (`[]`); otherwise show it + /// as a banner with sound. In both cases the dedup map is updated so a + /// rapid follow-up doesn't refire. + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + let userInfo = notification.request.content.userInfo + guard let gameID = Self.gameID(from: userInfo) else { + completionHandler([.banner, .sound]) + return + } + NotificationState.recordShown(gameID: gameID) + if NotificationState.activePuzzleID() == gameID { + completionHandler([]) + } else { + completionHandler([.banner, .sound]) + } + } + + private static func gameID(from userInfo: [AnyHashable: Any]) -> UUID? { + guard let ck = userInfo["ck"] as? [AnyHashable: Any], + let qry = ck["qry"] as? [AnyHashable: Any], + let zoneName = qry["zid"] as? String, + zoneName.hasPrefix("game-") + else { return nil } + return UUID(uuidString: String(zoneName.dropFirst("game-".count))) + } + + /// Asks the user for notification permission only if they haven't yet + /// answered the prompt. Idempotent — once the user has decided either + /// way, this is a no-op. + static func requestNotificationAuthorizationIfNeeded() async { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + guard settings.authorizationStatus == .notDetermined else { return } + _ = try? await center.requestAuthorization(options: [.alert, .sound]) + } + func application( _ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any] @@ -110,7 +153,6 @@ struct RootView: View { let appDelegate: AppDelegate @Environment(\.scenePhase) private var scenePhase - @State private var preferences = PlayerPreferences() @State private var navigationPath = NavigationPath() var body: some View { @@ -129,9 +171,9 @@ struct RootView: View { ) } } - .environment(preferences) + .environment(services.preferences) .task { - await services.start(appDelegate: appDelegate, preferences: preferences) + await services.start(appDelegate: appDelegate) } .onOpenURL { url in if let id = services.importService.importGame(from: url) { @@ -212,10 +254,12 @@ private struct PuzzleDisplayView: View { await pollOpenSharedPuzzle() } .task(id: gameID) { + NotificationState.setActivePuzzleID(gameID) do { let (game, mutator) = try store.loadGame(id: gameID) let newSession = PlayerSession(game: game, mutator: mutator) if mutator.isShared { + Task { await AppDelegate.requestNotificationAuthorizationIfNeeded() } roster = services.makePlayerRoster(for: gameID, preferences: preferences) // Fan out the local user's name to every shared/joined // game's zone before any presence write — otherwise the @@ -247,8 +291,14 @@ private struct PuzzleDisplayView: View { } } .onDisappear { + NotificationState.setActivePuzzleID(nil) let presence = services.presencePublisher - Task { await presence.clear() } + let moveBuffer = services.moveBuffer + let exitedID = gameID + Task { + await presence.clear() + await moveBuffer.noteSessionEnded(gameID: exitedID) + } } } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -18,6 +18,8 @@ final class AppServices { let cloudService: CloudService let importService: ImportService + let preferences: PlayerPreferences + private let ckContainer = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2") private var started = false private(set) var nameBroadcaster: NameBroadcaster? @@ -26,6 +28,7 @@ final class AppServices { private var pendingShareMetadatas: [CKShare.Metadata] = [] init() { + self.preferences = PlayerPreferences() self.persistence = PersistenceController() let store = GameStore(persistence: persistence) self.store = store @@ -56,6 +59,14 @@ final class AppServices { await syncEngine.enqueueSnapshot(ckRecordName: name) } await syncEngine.enqueueDeleteRecords(result.prunedMoveNames) + }, + sessionPingSink: { [preferences] gameID, authorID in + let name = await MainActor.run { preferences.name } + await syncEngine.enqueueSessionPing( + gameID: gameID, + authorID: authorID, + playerName: name + ) } ) self.moveBuffer = moveBuffer @@ -88,7 +99,7 @@ final class AppServices { self.importService = ImportService(store: store, driveMonitor: self.driveMonitor) } - func start(appDelegate: AppDelegate, preferences: PlayerPreferences) async { + func start(appDelegate: AppDelegate) async { guard !started else { return } started = true @@ -131,6 +142,20 @@ final class AppServices { // Fetch identity before starting engines so first moves get an authorID. await identity.refresh(using: ckContainer) + // SessionPing alert-push subscription registry. Created after the + // identity refresh so the predicate-author filter is set on the + // first save attempt. + let identity = self.identity + let subscriber = SessionPingSubscriber( + container: ckContainer, + identityProvider: { @MainActor [identity] in identity.currentID }, + tracer: { [syncMonitor] message in + Task { @MainActor in syncMonitor.note(message) } + } + ) + await syncEngine.setSessionPingSubscriber(subscriber) + await bootstrapSessionPingSubscriptions(subscriber) + // NameBroadcaster fans out name changes to all shared/joined games. // PuzzleDisplayView also calls `broadcastName()` when a shared puzzle // is opened, which covers first-sync-after-share-create / accept. @@ -199,6 +224,25 @@ final class AppServices { ) } + /// Walks the shared database's existing zones and ensures a SessionPing + /// subscription exists for each. This catches zones that were already on + /// the account before the feature shipped (or after a reinstall lost the + /// local UserDefaults registry); the live path in + /// `SyncEngine.handleFetchedDatabaseChanges` covers zones that arrive + /// during the session. + private func bootstrapSessionPingSubscriptions(_ subscriber: SessionPingSubscriber) async { + let zones: [CKRecordZone] + do { + zones = try await ckContainer.sharedCloudDatabase.allRecordZones() + } catch { + syncMonitor.note("session-ping: skipping bootstrap — \(error.localizedDescription)") + return + } + for zone in zones where zone.zoneID.zoneName.hasPrefix("game-") { + await subscriber.ensureSubscribed(zoneID: zone.zoneID) + } + } + private func handleRemoteNotification(summary: String) async { syncMonitor.note("remote notification: \(summary)") await syncMonitor.run("remote-notification fetch") { diff --git a/Crossmate/Sync/MoveBuffer.swift b/Crossmate/Sync/MoveBuffer.swift @@ -28,9 +28,11 @@ actor MoveBuffer { } private let debounceInterval: Duration + private let sessionPingStaleInterval: TimeInterval private let persistence: PersistenceController private let sink: @Sendable ([Move]) async -> Void private let afterFlush: (@Sendable (Set<UUID>) async -> Void)? + private let sessionPingSink: (@Sendable (UUID, String) async -> Void)? private var buffer: [Key: Pending] = [:] /// Insertion order so that lamports within a single flush are assigned @@ -42,17 +44,25 @@ actor MoveBuffer { /// replace the pending value without flushing. private var lastCell: Key? private var debounceTask: Task<Void, Never>? + /// Per-game timestamp of the last SessionPing fired. The first + /// `enqueue` for a game with no entry — or one stale beyond + /// `sessionPingStaleInterval` — counts as a new session and fires a ping. + private var lastSessionPingAt: [UUID: Date] = [:] init( debounceInterval: Duration = .milliseconds(1500), + sessionPingStaleInterval: TimeInterval = 30 * 60, persistence: PersistenceController, sink: @escaping @Sendable ([Move]) async -> Void, - afterFlush: (@Sendable (Set<UUID>) async -> Void)? = nil + afterFlush: (@Sendable (Set<UUID>) async -> Void)? = nil, + sessionPingSink: (@Sendable (UUID, String) async -> Void)? = nil ) { self.debounceInterval = debounceInterval + self.sessionPingStaleInterval = sessionPingStaleInterval self.persistence = persistence self.sink = sink self.afterFlush = afterFlush + self.sessionPingSink = sessionPingSink } /// Registers a cell edit. If the edit targets a different cell than the @@ -85,6 +95,28 @@ actor MoveBuffer { ) lastCell = key scheduleDebounce() + + if let authorID, !authorID.isEmpty { + await maybeFireSessionPing(gameID: gameID, authorID: authorID) + } + } + + /// Resets session-ping tracking for `gameID`. Called from the puzzle + /// view's teardown so re-entry counts as a fresh session. + func noteSessionEnded(gameID: UUID) { + lastSessionPingAt.removeValue(forKey: gameID) + } + + private func maybeFireSessionPing(gameID: UUID, authorID: String) async { + let now = Date() + if let last = lastSessionPingAt[gameID], + now.timeIntervalSince(last) < sessionPingStaleInterval { + return + } + lastSessionPingAt[gameID] = now + if let sessionPingSink { + await sessionPingSink(gameID, authorID) + } } /// Flushes any pending edits immediately and cancels the debounce. Safe diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -51,6 +51,17 @@ enum RecordSerializer { "player-\(gameID.uuidString)-\(authorID)" } + /// One SessionPing record per session-start. The session-start timestamp + /// (ms since epoch) makes the name unique across sessions and devices, so + /// repeated pings from the same author for the same game don't collide. + static func recordName( + forSessionPingInGame gameID: UUID, + authorID: String, + sessionStartMs: Int64 + ) -> String { + "sessionping-\(gameID.uuidString)-\(authorID)-\(sessionStartMs)" + } + // MARK: - Zone /// Zone ID for a per-game zone. `ownerName` defaults to the current user @@ -145,6 +156,33 @@ enum RecordSerializer { record["puzzleSource"] = CKAsset(fileURL: url) } + /// Builds a freshly-minted SessionPing record. SessionPings are + /// write-once — they have no Core Data equivalent and no system-fields + /// archive. + /// - `authorID` lets the subscription predicate filter out self-sends. + /// - `playerName` and `puzzleTitle` are read by the receiver's NSE to + /// render the alert body (e.g. "Alice made an edit to Sunday Crossword"). + static func sessionPingRecord( + gameID: UUID, + authorID: String, + playerName: String, + puzzleTitle: String, + sessionStartMs: Int64, + zone: CKRecordZone.ID + ) -> CKRecord { + let name = recordName( + forSessionPingInGame: gameID, + authorID: authorID, + sessionStartMs: sessionStartMs + ) + let recordID = CKRecord.ID(recordName: name, zoneID: zone) + let record = CKRecord(recordType: "SessionPing", recordID: recordID) + record["authorID"] = authorID as CKRecordValue + record["playerName"] = playerName as CKRecordValue + record["puzzleTitle"] = puzzleTitle as CKRecordValue + return record + } + static func playerRecord( gameID: UUID, authorID: String, diff --git a/Crossmate/Sync/SessionPingSubscriber.swift b/Crossmate/Sync/SessionPingSubscriber.swift @@ -0,0 +1,93 @@ +import CloudKit +import Foundation + +/// Registers and tracks `CKQuerySubscription`s for `SessionPing` records in +/// the shared CloudKit database. One subscription per shared zone, with a +/// predicate that filters out pings authored by the local user so the sender +/// doesn't receive their own push. +/// +/// Registered zone names are mirrored to `UserDefaults` so we don't pay the +/// network cost of re-saving an identical subscription on every launch. +actor SessionPingSubscriber { + private let container: CKContainer + private let identityProvider: @Sendable () async -> String? + private let tracer: (@Sendable (String) -> Void)? + + private static let registeredKey = "crossmate.sessionPingSubscribedZones" + + init( + container: CKContainer, + identityProvider: @escaping @Sendable () async -> String?, + tracer: (@Sendable (String) -> Void)? = nil + ) { + self.container = container + self.identityProvider = identityProvider + self.tracer = tracer + } + + /// Saves a SessionPing query subscription for `zoneID` to the shared + /// database, if one isn't already on file. Idempotent — safe to call + /// every time the zone surfaces via fetched-database-changes. + func ensureSubscribed(zoneID: CKRecordZone.ID) async { + if isRegistered(zoneName: zoneID.zoneName) { return } + guard let authorID = await identityProvider() else { + tracer?("session-ping: skip subscribe — no authorID for \(zoneID.zoneName)") + return + } + + let predicate = NSPredicate(format: "authorID != %@", authorID) + let subscriptionID = "sessionping-\(zoneID.zoneName)" + let subscription = CKQuerySubscription( + recordType: "SessionPing", + predicate: predicate, + subscriptionID: subscriptionID, + options: .firesOnRecordCreation + ) + subscription.zoneID = zoneID + + let info = CKSubscription.NotificationInfo() + // Placeholder body — the NSE rewrites it from `playerName` and + // `puzzleTitle` so a partner-readable banner shows. + info.alertBody = "A player made an edit" + info.shouldSendMutableContent = true + info.desiredKeys = ["authorID", "playerName", "puzzleTitle"] + subscription.notificationInfo = info + + do { + _ = try await container.sharedCloudDatabase.save(subscription) + markRegistered(zoneName: zoneID.zoneName) + tracer?("session-ping: subscribed \(subscriptionID)") + } catch let error as CKError where error.code == .serverRejectedRequest { + // Subscription already exists server-side — likely from a prior + // install whose UserDefaults didn't survive. Treat as success. + markRegistered(zoneName: zoneID.zoneName) + tracer?("session-ping: \(subscriptionID) already on server") + } catch { + tracer?("session-ping: failed to subscribe \(subscriptionID) — \(error.localizedDescription)") + } + } + + /// Removes a zone from the registered set so that, if the share is + /// re-accepted later, we re-subscribe. The server-side subscription is + /// cleaned up by CloudKit when the zone disappears. + func forgetZone(zoneName: String) { + var set = registeredZones() + guard set.remove(zoneName) != nil else { return } + UserDefaults.standard.set(Array(set), forKey: Self.registeredKey) + } + + private func isRegistered(zoneName: String) -> Bool { + registeredZones().contains(zoneName) + } + + private func markRegistered(zoneName: String) { + var set = registeredZones() + set.insert(zoneName) + UserDefaults.standard.set(Array(set), forKey: Self.registeredKey) + } + + private func registeredZones() -> Set<String> { + let arr = UserDefaults.standard.stringArray(forKey: Self.registeredKey) ?? [] + return Set(arr) + } +} diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -35,6 +35,19 @@ actor SyncEngine { private var privateEngine: CKSyncEngine? private var sharedEngine: CKSyncEngine? + /// In-memory map for SessionPing records pending send. SessionPings have + /// no Core Data backing — they're write-once-and-forget — so we stash the + /// minimal data here keyed by record name and look it up in `buildRecord`. + private var pendingSessionPings: [String: SessionPingPayload] = [:] + + private struct SessionPingPayload { + let gameID: UUID + let authorID: String + let playerName: String + let puzzleTitle: String + let sessionStartMs: Int64 + } + /// Label for the in-flight fetch — surfaced in traces so the diagnostics /// log can distinguish push-driven fetches from polls / foreground / etc. /// `nil` means CKSyncEngine drove the fetch itself (its internal scheduler). @@ -49,6 +62,11 @@ actor SyncEngine { private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)? private var onSnapshotsSaved: (@MainActor @Sendable ([String]) async -> Void)? private var tracer: (@MainActor @Sendable (String) -> Void)? + private var sessionPingSubscriber: SessionPingSubscriber? + + func setSessionPingSubscriber(_ subscriber: SessionPingSubscriber) { + sessionPingSubscriber = subscriber + } func setTracer(_ t: @MainActor @Sendable @escaping (String) -> Void) { tracer = t @@ -159,6 +177,40 @@ actor SyncEngine { engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) } + /// Registers a SessionPing record as a pending send. Called by + /// `MoveBuffer` on the first move of a session. Sender-only state — the + /// payload is stashed in `pendingSessionPings` and only used to build the + /// outgoing `CKRecord`; nothing is persisted. + func enqueueSessionPing(gameID: UUID, authorID: String, playerName: String) { + let ctx = persistence.container.newBackgroundContext() + let zoneAndTitle: (info: ZoneInfo, title: String)? = ctx.performAndWait { + guard let info = self.zoneInfo(forGameID: gameID, in: ctx) else { return nil } + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + req.fetchLimit = 1 + let title = (try? ctx.fetch(req).first?.title) ?? "" + return (info, title) + } + guard let zoneAndTitle else { return } + let engine = zoneAndTitle.info.scope == 1 ? sharedEngine : privateEngine + guard let engine else { return } + let sessionStartMs = Int64(Date().timeIntervalSince1970 * 1000) + let recordName = RecordSerializer.recordName( + forSessionPingInGame: gameID, + authorID: authorID, + sessionStartMs: sessionStartMs + ) + pendingSessionPings[recordName] = SessionPingPayload( + gameID: gameID, + authorID: authorID, + playerName: playerName, + puzzleTitle: zoneAndTitle.title, + sessionStartMs: sessionStartMs + ) + let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneAndTitle.info.zoneID) + engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) + } + /// Registers a Player record as a pending send. Used by `NameBroadcaster` /// when the local user renames; one record per (game, authorID), so /// participants only ever write their own slot. @@ -315,7 +367,8 @@ actor SyncEngine { } /// Extracts the game UUID from any of our record name formats: - /// `game-<UUID>`, `move-<UUID>-…`, `snapshot-<UUID>-…`, `player-<UUID>-…`. + /// `game-<UUID>`, `move-<UUID>-…`, `snapshot-<UUID>-…`, `player-<UUID>-…`, + /// `sessionping-<UUID>-…`. private nonisolated func gameID(fromRecordName name: String) -> UUID? { if name.hasPrefix("game-") { return UUID(uuidString: String(name.dropFirst("game-".count))) @@ -324,6 +377,7 @@ actor SyncEngine { if name.hasPrefix("move-") { prefix = "move-" } else if name.hasPrefix("snapshot-") { prefix = "snapshot-" } else if name.hasPrefix("player-") { prefix = "player-" } + else if name.hasPrefix("sessionping-") { prefix = "sessionping-" } else { return nil } let rest = name.dropFirst(prefix.count) return UUID(uuidString: String(rest.prefix(36))) @@ -353,9 +407,25 @@ actor SyncEngine { /// Builds the `CKRecord` for a pending change. Uses the zone ID already /// embedded in the `recordID` — set correctly at enqueue time. - private nonisolated func buildRecord(for recordID: CKRecord.ID) -> CKRecord? { + /// `sessionPings` is a snapshot taken from the actor before this is + /// invoked, since the framework calls back synchronously off-actor. + private nonisolated func buildRecord( + for recordID: CKRecord.ID, + sessionPings: [String: SessionPingPayload] + ) -> CKRecord? { let name = recordID.recordName let zoneID = recordID.zoneID + if name.hasPrefix("sessionping-") { + guard let payload = sessionPings[name] else { return nil } + return RecordSerializer.sessionPingRecord( + gameID: payload.gameID, + authorID: payload.authorID, + playerName: payload.playerName, + puzzleTitle: payload.puzzleTitle, + sessionStartMs: payload.sessionStartMs, + zone: zoneID + ) + } let ctx = persistence.container.newBackgroundContext() return ctx.performAndWait { if name.hasPrefix("game-") { @@ -699,6 +769,13 @@ actor SyncEngine { // For the shared engine, create placeholder game entities for new // zones (populated fully when the Game record arrives), and mark // games access-revoked when the owner removes us from the share. + let newZoneIDs: [CKRecordZone.ID] = event.modifications + .map(\.zoneID) + .filter { $0.zoneName.hasPrefix("game-") } + let removedZoneNames: [String] = event.deletions + .map(\.zoneID.zoneName) + .filter { $0.hasPrefix("game-") } + let ctx = persistence.container.newBackgroundContext() let revokedIDs: [UUID] = ctx.performAndWait { var ids: [UUID] = [] @@ -741,6 +818,15 @@ actor SyncEngine { for id in revokedIDs { if let cb = onGameAccessRevoked { await cb(id) } } + + if let subscriber = sessionPingSubscriber { + for zoneID in newZoneIDs { + await subscriber.ensureSubscribed(zoneID: zoneID) + } + for zoneName in removedZoneNames { + await subscriber.forgetZone(zoneName: zoneName) + } + } } private func handleFetchedRecordZoneChanges( @@ -860,6 +946,12 @@ actor SyncEngine { "\(event.failedRecordSaves.count) failed, " + "\(event.deletedRecordIDs.count) deleted" ) + for record in event.savedRecords { + let name = record.recordID.recordName + if name.hasPrefix("sessionping-") { + pendingSessionPings.removeValue(forKey: name) + } + } let ctx = persistence.container.newBackgroundContext() let (savedSnapshotNames, failureMessages): ([String], [String]) = ctx.performAndWait { var snapshotNames: [String] = [] @@ -978,9 +1070,10 @@ extension SyncEngine: CKSyncEngineDelegate { ) async -> CKSyncEngine.RecordZoneChangeBatch? { let pending = syncEngine.state.pendingRecordZoneChanges guard !pending.isEmpty else { return nil } + let pingSnapshot = pendingSessionPings return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) { [weak self] recordID in guard let self else { return nil } - return self.buildRecord(for: recordID) + return self.buildRecord(for: recordID, sessionPings: pingSnapshot) } } } 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 Notifications</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,83 @@ +import CloudKit +import UserNotifications + +/// Notification Service Extension. Intercepts incoming alert pushes from the +/// CloudKit `SessionPing` subscription and decides how prominently to show +/// them. +/// +/// Suppression policy: +/// - If the user is currently viewing the puzzle the ping refers to, demote +/// to `.passive` (notification appears in Notification Center only — no +/// banner, no sound). +/// - If a ping for the same game was already shown within `dedupWindow`, +/// demote to `.passive`. +/// - Otherwise pass through and record the timestamp so subsequent pings for +/// the same game are demoted. +/// +/// Foreground suppression (returning `[]` from `willPresent`) is handled by +/// the host app's UNUserNotificationCenter delegate, since NSEs cannot fully +/// hide a notification. +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 content = bestAttemptContent else { + contentHandler(request.content) + return + } + + Self.rewriteBody(into: content, from: request.content.userInfo) + + if let gameID = Self.gameID(from: request.content.userInfo) { + if NotificationState.shouldSuppress(gameID: gameID) { + content.interruptionLevel = .passive + content.sound = nil + } + NotificationState.recordShown(gameID: gameID) + } + + contentHandler(content) + } + + /// Replaces the placeholder alert body with "<player> made an edit to + /// <title>" using fields delivered in the CloudKit query notification. + /// Falls back to the original body if either field is missing. + private static func rewriteBody( + into content: UNMutableNotificationContent, + from userInfo: [AnyHashable: Any] + ) { + guard let note = CKNotification(fromRemoteNotificationDictionary: userInfo) + as? CKQueryNotification, + let fields = note.recordFields + else { return } + let player = (fields["playerName"] as? String) ?? "" + let title = (fields["puzzleTitle"] as? String) ?? "" + guard !player.isEmpty, !title.isEmpty else { return } + content.body = "\(player) made an edit to \(title)" + } + + override func serviceExtensionTimeWillExpire() { + if let contentHandler, let bestAttemptContent { + contentHandler(bestAttemptContent) + } + } + + /// CloudKit query subscriptions deliver the affected zone in + /// `userInfo["ck"]["qry"]["zid"]`. Our zone names encode the gameID as + /// `game-<UUID>`. + private static func gameID(from userInfo: [AnyHashable: Any]) -> UUID? { + guard let ck = userInfo["ck"] as? [AnyHashable: Any], + let qry = ck["qry"] as? [AnyHashable: Any], + let zoneName = qry["zid"] as? String, + zoneName.hasPrefix("game-") + else { return nil } + return UUID(uuidString: String(zoneName.dropFirst("game-".count))) + } +} diff --git a/Scripts/publish-ios.sh b/Scripts/publish-ios.sh @@ -81,6 +81,8 @@ cat > "$EXPORT_PLIST" <<PLIST <dict> <key>net.inqk.crossmate</key> <string>Crossmate iOS Distribution</string> + <key>net.inqk.crossmate.notificationservice</key> + <string>Crossmate Notification Service iOS Distribution</string> </dict> <key>destination</key> <string>export</string> diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -0,0 +1,65 @@ +import Foundation + +/// State shared between the host app and the Notification Service Extension +/// via App Group UserDefaults. Both targets compile this file directly. +/// +/// Two pieces of state are tracked: +/// - `activePuzzleID` — set by the app while the user is viewing a puzzle so +/// the NSE can quietly drop notifications for that same puzzle. +/// - `shownByGame` — a `[gameID: Date]` map used to debounce repeat +/// notifications. Once a SessionPing for game X has been shown, further +/// pings for X within `dedupWindow` are demoted to `.passive`. +enum NotificationState { + static let appGroup = "group.net.inqk.crossmate" + + /// How long after a shown notification subsequent pings for the same game + /// are demoted. Two hours matches the user's "few hours" intent without + /// being so long that returning to a long-idle puzzle is silent. + static let dedupWindow: TimeInterval = 2 * 60 * 60 + + private static let activeKey = "notif.activePuzzleID" + private static let shownKey = "notif.shownByGame" + + private static var defaults: UserDefaults? { + UserDefaults(suiteName: appGroup) + } + + static func activePuzzleID() -> UUID? { + guard let s = defaults?.string(forKey: activeKey) else { return nil } + return UUID(uuidString: s) + } + + static func setActivePuzzleID(_ id: UUID?) { + guard let defaults else { return } + if let id { + defaults.set(id.uuidString, forKey: activeKey) + } else { + defaults.removeObject(forKey: activeKey) + } + } + + /// Returns true if a notification for `gameID` was shown within + /// `dedupWindow`, or if the user is currently viewing it. + static func shouldSuppress(gameID: UUID, now: Date = Date()) -> Bool { + if activePuzzleID() == gameID { return true } + let map = shownMap() + guard let last = map[gameID.uuidString] else { return false } + return now.timeIntervalSince1970 - last < dedupWindow + } + + /// Records that a notification for `gameID` was surfaced (or would have + /// been, before suppression decisions). Trims old entries so the map + /// stays small. + static func recordShown(gameID: UUID, now: Date = Date()) { + guard let defaults else { return } + var map = shownMap() + map[gameID.uuidString] = now.timeIntervalSince1970 + let cutoff = now.timeIntervalSince1970 - 2 * dedupWindow + map = map.filter { $0.value >= cutoff } + defaults.set(map, forKey: shownKey) + } + + private static func shownMap() -> [String: TimeInterval] { + defaults?.dictionary(forKey: shownKey) as? [String: TimeInterval] ?? [:] + } +} diff --git a/project.yml b/project.yml @@ -25,6 +25,7 @@ targets: platform: iOS sources: - Crossmate + - Shared info: path: Crossmate/Info.plist properties: @@ -66,6 +67,32 @@ targets: CODE_SIGN_ENTITLEMENTS: Crossmate/Crossmate.entitlements TARGETED_DEVICE_FAMILY: "1" CODE_SIGN_STYLE: Automatic + dependencies: + - target: NotificationService + embed: true + codeSign: true + + NotificationService: + type: app-extension + platform: iOS + sources: + - NotificationService + - Shared + info: + path: NotificationService/Info.plist + properties: + CFBundleDisplayName: Crossmate Notifications + CFBundleShortVersionString: "1.0.0" + CFBundleVersion: $(CURRENT_PROJECT_VERSION) + NSExtension: + NSExtensionPointIdentifier: com.apple.usernotifications.service + NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).NotificationService + settings: + PRODUCT_BUNDLE_IDENTIFIER: net.inqk.crossmate.notificationservice + INFOPLIST_FILE: NotificationService/Info.plist + CODE_SIGN_ENTITLEMENTS: NotificationService/NotificationService.entitlements + TARGETED_DEVICE_FAMILY: "1" + CODE_SIGN_STYLE: Automatic Crossmate Unit Tests: type: bundle.unit-test