crossmate

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

commit 8f191f2af629623a73ebd5d3f0a92b5c311f3610
parent 11e31ab4918b656b4b415d1af43c2a7eb7c6d7f5
Author: Michael Camilleri <[email protected]>
Date:   Thu, 30 Apr 2026 12:43:57 +0900

Remove remote-alert notification path

Crossmate now delivers shared-puzzle activity alerts by syncing SessionPing
records and scheduling local notifications in the host app. As a result, the
old CloudKit query-subscription alert path is no longer needed.

This commit removes the NotificationService extension target, its
plist/entitlements/source, the embedded extension wiring, and the stale scheme
entry. It also deletes SessionPingSubscriber and the remaining SyncEngine hooks
that tried to maintain per-zone query subscriptions in the shared database.

The local notification copy is now consistently phrased as "<player> has
updated the puzzle <title>", with fallback variants for missing player or title
metadata.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 125-------------------------------------------------------------------------------
MCrossmate.xcodeproj/xcuserdata/pyrmont.xcuserdatad/xcschemes/xcschememanagement.plist | 5-----
MCrossmate/Services/AppServices.swift | 8+++++---
MCrossmate/Sync/RecordSerializer.swift | 6+++---
DCrossmate/Sync/SessionPingSubscriber.swift | 93-------------------------------------------------------------------------------
MCrossmate/Sync/SyncEngine.swift | 21---------------------
DNotificationService/Info.plist | 31-------------------------------
DNotificationService/NotificationService.entitlements | 10----------
DNotificationService/NotificationService.swift | 83-------------------------------------------------------------------------------
MShared/NotificationState.swift | 7+++----
Mproject.yml | 26--------------------------
11 files changed, 11 insertions(+), 404 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -35,14 +35,12 @@ 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 */; }; @@ -61,7 +59,6 @@ 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 */; }; @@ -81,18 +78,10 @@ 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 */; @@ -102,20 +91,6 @@ }; /* 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>"; }; @@ -141,9 +116,7 @@ 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>"; }; @@ -161,7 +134,6 @@ 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>"; }; @@ -176,7 +148,6 @@ 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>"; }; @@ -186,7 +157,6 @@ 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>"; }; @@ -219,7 +189,6 @@ F7422F19AA1F1692A98E3602 /* MoveLog.swift */, 19D51F4A1CAEB849A09EC2C1 /* PresencePublisher.swift */, 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */, - D3145BFE3D25B6025BA277D9 /* SessionPingSubscriber.swift */, 5C74683332956B0D1CA37589 /* ShareController.swift */, 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */, AC00F4D5060EDA4859A6B3F1 /* SyncMonitor.swift */, @@ -233,7 +202,6 @@ children = ( D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */, B689A7138429641E61E9E558 /* Crossmate.app */, - 51318FC5DAE02D35CB005729 /* NotificationService.appex */, ); name = Products; sourceTree = "<group>"; @@ -360,7 +328,6 @@ children = ( 5770CE69DB2B0B7462FACE53 /* Crossmate */, 6F470E54D9E6E99FCEA893D1 /* Generated */, - D7910EDB740AFF963BDCA6CE /* NotificationService */, 9BF7383FE2AB07F12434C013 /* Shared */, 01B07D8724DEA04C3E74558E /* Support */, 212DB6FCF46C41F81C41D232 /* Unit */, @@ -368,16 +335,6 @@ ); 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 = ( @@ -407,35 +364,16 @@ /* 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 = ( @@ -471,10 +409,6 @@ BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1430; TargetAttributes = { - 350366A68B75DC4BDA91F8E5 = { - DevelopmentTeam = 7TD7PZBNXP; - ProvisioningStyle = Automatic; - }; 7708D1C8A0145D43BD15DEB7 = { DevelopmentTeam = 7TD7PZBNXP; ProvisioningStyle = Automatic; @@ -501,7 +435,6 @@ targets = ( 7708D1C8A0145D43BD15DEB7 /* Crossmate */, C38EBD1A6B9D37EF81FF3511 /* Crossmate Unit Tests */, - 350366A68B75DC4BDA91F8E5 /* NotificationService */, ); }; /* End PBXProject section */ @@ -521,15 +454,6 @@ /* 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; @@ -601,7 +525,6 @@ 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 */, @@ -616,11 +539,6 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 0638C125467274AA03088E07 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 350366A68B75DC4BDA91F8E5 /* NotificationService */; - targetProxy = 0751E0359C340223ADBA2B05 /* PBXContainerItemProxy */; - }; 42035D5EEE61A5D459E1D46D /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 7708D1C8A0145D43BD15DEB7 /* Crossmate */; @@ -729,23 +647,6 @@ }; 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 = { @@ -782,23 +683,6 @@ }; 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 */; @@ -882,15 +766,6 @@ 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,11 +9,6 @@ <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/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -249,11 +249,13 @@ final class AppServices { let content = UNMutableNotificationContent() content.title = "Crossmate" if !ping.playerName.isEmpty, !ping.puzzleTitle.isEmpty { - content.body = "\(ping.playerName) made an edit to \(ping.puzzleTitle)" + content.body = "\(ping.playerName) has updated the puzzle \(ping.puzzleTitle)" } else if !ping.playerName.isEmpty { - content.body = "\(ping.playerName) made an edit" + content.body = "\(ping.playerName) has updated a puzzle" + } else if !ping.puzzleTitle.isEmpty { + content.body = "A player has updated the puzzle \(ping.puzzleTitle)" } else { - content.body = "A player made an edit" + content.body = "A player has updated a puzzle" } content.sound = .default content.userInfo = ["crossmateGameID": ping.gameID.uuidString] diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -159,9 +159,9 @@ enum RecordSerializer { /// 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"). + /// - `authorID` lets receivers filter out self-sends. + /// - `playerName` and `puzzleTitle` let receivers render the alert body + /// (e.g. "Alice has updated the puzzle Sunday Crossword"). static func sessionPingRecord( gameID: UUID, authorID: String, diff --git a/Crossmate/Sync/SessionPingSubscriber.swift b/Crossmate/Sync/SessionPingSubscriber.swift @@ -1,93 +0,0 @@ -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 @@ -70,11 +70,6 @@ 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 @@ -781,13 +776,6 @@ 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] = [] @@ -830,15 +818,6 @@ 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( diff --git a/NotificationService/Info.plist b/NotificationService/Info.plist @@ -1,31 +0,0 @@ -<?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 @@ -1,10 +0,0 @@ -<?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 @@ -1,83 +0,0 @@ -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/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -1,14 +1,13 @@ import Foundation -/// State shared between the host app and the Notification Service Extension -/// via App Group UserDefaults. Both targets compile this file directly. +/// Notification suppression state persisted via App Group UserDefaults. /// /// 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. +/// local notifications for that same puzzle can be skipped. /// - `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`. +/// pings for X within `dedupWindow` are suppressed. enum NotificationState { static let appGroup = "group.net.inqk.crossmate" diff --git a/project.yml b/project.yml @@ -67,32 +67,6 @@ 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