commit 23a2e4b017d917e0b6e5f614b601114d46e9fc71
parent 70268eec5c5389ed8af7cb346c39f30604de3e08
Author: Michael Camilleri <[email protected]>
Date: Mon, 15 Jun 2026 01:45:28 +0900
Show the puzzle while a shared link is being joined
Opening a shared puzzle link on a slow connection showed nothing for
many seconds. The share-metadata fetch, the shared-zone discovery, and
the 'puzzleSource' asset download all have to complete before any puzzle
data exists locally, and only then does the game surface — so the
recipient stares at an empty list (or an empty grid) through the whole
join and reasonably concludes it has failed, then force-quits.
This changes in this commit. The share short link now carries the grid's
block layout as a compact silhouette segment, so the puzzle's shape is
available the instant the link is seen or tapped — before any CloudKit
round-trip. 'GridSilhouette' encodes a square grid as a symmetric
half-bitmask (a 180°-symmetric 15×15 lands in ~22 characters) and omits
non-square grids, which simply get no preview.
The link worker learns those segment types, tolerating the extra path
component so the token-only redirect still lands, and renders the
silhouette into the Open Graph image — a dependency-free grayscale PNG
built in the worker — so the messaging preview shows the actual grid
rather than a generic card. Links minted before the segments were typed
still resolve their old bare-base64 title, so previews degrade
gracefully rather than break.
The short link is now also a universal link: the worker serves an
'apple-app-site-association' claiming '/s/*', and the app declares the
matching 'applinks:' entitlement. A tap therefore opens the app straight
to a greyed-out placeholder of the puzzle's shape — decoded from the
URL, not fetched — with a "Joining puzzle…" spinner, while the share is
accepted in the background from the iCloud token reconstructed locally.
The placeholder clears and the game opens once acceptance completes.
When the universal link is not intercepted the worker's existing
redirect to iCloud and the OS share-accept path remain the fallback,
unchanged.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
15 files changed, 823 insertions(+), 54 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -18,6 +18,7 @@
04FA202932E8B187075CA698 /* FriendsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099C611C9CD5D47D89B62AD0 /* FriendsView.swift */; };
06AE6DF7AA3274480C591E47 /* EngagementStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B09D52DB46731E92C3E9297C /* EngagementStore.swift */; };
07A46496EE0B12FD526F36FB /* SessionPushPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C040D5EBC73B1ED47C2C9D4 /* SessionPushPlannerTests.swift */; };
+ 085B70680087464B8A7BA3EE /* GridSilhouetteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF7062403AC9CFB4FF04BBF3 /* GridSilhouetteTests.swift */; };
0A7AEB93A473AFCCD9217F49 /* PuzzleSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 362E3A93102B6C9AECD4133A /* PuzzleSessionTests.swift */; };
0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3292748EAE27B608C769D393 /* PlayerRoster.swift */; };
1016604FBD4D63A0B9AAE503 /* CloudQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AAC1E8D2CB3B5117159934 /* CloudQuery.swift */; };
@@ -54,6 +55,7 @@
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; };
4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; };
4A89595E3F6AB50E1D9E6BA8 /* ImportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 462CE0FD356F6137C9BFD30F /* ImportService.swift */; };
+ 4B8CA45845618D75A3313816 /* GridSilhouette.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16AC7215D0269195FEA8BA8 /* GridSilhouette.swift */; };
4C874B69A9D9187490BC2C42 /* FriendAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F91707302CBA406BF7FB8F8A /* FriendAvatarView.swift */; };
4D7BF839BA71E1BF0AE9BCE9 /* GameStoreContributingDevicesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09EA25E2AE98ACD029EC0129 /* GameStoreContributingDevicesTests.swift */; };
4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */; };
@@ -87,6 +89,7 @@
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 */; };
+ 81EFADDD76DC5F24E944C792 /* JoiningPuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 802540DC0A64DE64242ADBE5 /* JoiningPuzzleView.swift */; };
8225918652DCC822CA1C862F /* PendingEditFlagTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D491B7232333AA8957732387 /* PendingEditFlagTests.swift */; };
82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */; };
8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */; };
@@ -97,6 +100,7 @@
88A34C8857B2B3D45A6FBCB2 /* PuzzleSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 710BCB6A647A820B106CE666 /* PuzzleSession.swift */; };
88BACA1689459AC9AED20D43 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; };
89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */; };
+ 8AE376C0726116082B15241D /* ShareLinkRouteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5990D989AD745211A18848E4 /* ShareLinkRouteTests.swift */; };
8B356C953DA0FAF149C3391A /* Puzzles in Resources */ = {isa = PBXBuildFile; fileRef = BA67C509B467132D1B7510A4 /* Puzzles */; };
8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; };
903681985C17FCB5F97773A9 /* OpenPuzzleBannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA0FC750259EB1D762B0EE /* OpenPuzzleBannerTests.swift */; };
@@ -150,6 +154,7 @@
D13ECFAE05DB508577D2FF66 /* RecordBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5267DDA1A330DCBD07303D44 /* RecordBuilder.swift */; };
D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */; };
D240BF6498A9148855DB7734 /* EngagementLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB0580C9B7C778F34BE6AC2 /* EngagementLifecycle.swift */; };
+ D4EDC0D426688B295DA77C08 /* ShareLinkRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2ED0D601BB574618C15B5EF /* ShareLinkRoute.swift */; };
D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */; };
D519F54F8CE0BD53D9C6144C /* AppServicesAnnouncementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9998739ED0875A17271B7899 /* AppServicesAnnouncementTests.swift */; };
D58980B92C99122C368D4216 /* GameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EE5BA78566EDED68D846AB /* GameStore.swift */; };
@@ -274,6 +279,7 @@
5267DDA1A330DCBD07303D44 /* RecordBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordBuilder.swift; sourceTree = "<group>"; };
52B50A841D92D1F2B173E7DF /* ShareLinkShortener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLinkShortener.swift; sourceTree = "<group>"; };
56BC76178319D0D669CD50FF /* CloudService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudService.swift; sourceTree = "<group>"; };
+ 5990D989AD745211A18848E4 /* ShareLinkRouteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLinkRouteTests.swift; sourceTree = "<group>"; };
5ABB557BA10CBE9909056882 /* GameShareItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameShareItem.swift; sourceTree = "<group>"; };
5C74683332956B0D1CA37589 /* ShareController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareController.swift; sourceTree = "<group>"; };
5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRecordPresenceTests.swift; sourceTree = "<group>"; };
@@ -301,6 +307,7 @@
7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesUpdater.swift; sourceTree = "<group>"; };
7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializerTests.swift; sourceTree = "<group>"; };
800CCFBE90554F287E765755 /* FriendZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendZoneTests.swift; sourceTree = "<group>"; };
+ 802540DC0A64DE64242ADBE5 /* JoiningPuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoiningPuzzleView.swift; sourceTree = "<group>"; };
847415468DBB1C566D18BC17 /* ReplayControlsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayControlsTests.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>"; };
@@ -347,6 +354,7 @@
BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangeReapTests.swift; sourceTree = "<group>"; };
BD63A9B20168F3B81AF4348F /* RecordApplier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordApplier.swift; sourceTree = "<group>"; };
BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverter.swift; sourceTree = "<group>"; };
+ BF7062403AC9CFB4FF04BBF3 /* GridSilhouetteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridSilhouetteTests.swift; sourceTree = "<group>"; };
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutatorTests.swift; sourceTree = "<group>"; };
C06E2CC3A77CB306BD2DF867 /* LogScrubberTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogScrubberTests.swift; sourceTree = "<group>"; };
C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayCell.swift; sourceTree = "<group>"; };
@@ -359,6 +367,7 @@
CE54EF557E8D808BAA20EA54 /* NYTPuzzleUpgrader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleUpgrader.swift; sourceTree = "<group>"; };
CF3D29B227D2B0E699423C48 /* Journal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Journal.swift; sourceTree = "<group>"; };
CFC4FF046BF772646B5CA73F /* Presence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presence.swift; sourceTree = "<group>"; };
+ D16AC7215D0269195FEA8BA8 /* GridSilhouette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridSilhouette.swift; sourceTree = "<group>"; };
D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationText.swift; sourceTree = "<group>"; };
D491B7232333AA8957732387 /* PendingEditFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingEditFlagTests.swift; sourceTree = "<group>"; };
D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -366,6 +375,7 @@
DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; };
DB851649DE78AAAC5A928C52 /* Square.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Square.swift; sourceTree = "<group>"; };
E25A040EA4DC9672C895A7AC /* InviteEntity+DisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InviteEntity+DisplayName.swift"; sourceTree = "<group>"; };
+ E2ED0D601BB574618C15B5EF /* ShareLinkRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLinkRoute.swift; sourceTree = "<group>"; };
E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordEditorView.swift; sourceTree = "<group>"; };
E5EEBF169823C172000FC45B /* GameSummaryThumbnailTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameSummaryThumbnailTests.swift; sourceTree = "<group>"; };
E655698481325C92EF5C348B /* FriendController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendController.swift; sourceTree = "<group>"; };
@@ -448,6 +458,7 @@
9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */,
31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */,
E5EEBF169823C172000FC45B /* GameSummaryThumbnailTests.swift */,
+ BF7062403AC9CFB4FF04BBF3 /* GridSilhouetteTests.swift */,
6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */,
89B1FFD2F90141EA949A8540 /* JournalReplayTests.swift */,
2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */,
@@ -477,6 +488,7 @@
603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */,
847415468DBB1C566D18BC17 /* ReplayControlsTests.swift */,
8C040D5EBC73B1ED47C2C9D4 /* SessionPushPlannerTests.swift */,
+ 5990D989AD745211A18848E4 /* ShareLinkRouteTests.swift */,
057F2B8B8A894D08BB801219 /* ShareLinkShortenerTests.swift */,
0C190EA5717C291B3F2AE46C /* SyncMonitorTests.swift */,
4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */,
@@ -572,6 +584,7 @@
CAB4BB9E160C3A59C653E7A9 /* GridView.swift */,
947102E58EFCF898D258AC3E /* HardwareKeyboardInputView.swift */,
87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */,
+ 802540DC0A64DE64242ADBE5 /* JoiningPuzzleView.swift */,
7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */,
F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */,
1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */,
@@ -653,6 +666,7 @@
18C701DAE36000DE19F7CC95 /* EngagementHost.swift */,
400B3C87248F3FCA3F76400B /* EngagementHostEnvironment.swift */,
4DB0580C9B7C778F34BE6AC2 /* EngagementLifecycle.swift */,
+ D16AC7215D0269195FEA8BA8 /* GridSilhouette.swift */,
462CE0FD356F6137C9BFD30F /* ImportService.swift */,
6BDD06460A76D4AF31077732 /* InputMonitor.swift */,
10064D171DB7C48D3DE1E769 /* InviteCoordinator.swift */,
@@ -669,6 +683,7 @@
3FFD574AC2D0A910053E2A73 /* ReplayLoader.swift */,
7A7BD8DFDB41BFBA694A0933 /* SessionCoordinator.swift */,
CA49DA9E0ADEB11A920787FA /* SessionPushPlanner.swift */,
+ E2ED0D601BB574618C15B5EF /* ShareLinkRoute.swift */,
52B50A841D92D1F2B173E7DF /* ShareLinkShortener.swift */,
);
path = Services;
@@ -820,6 +835,7 @@
2C5A15054CCCBF9FD626AFBB /* GameStorePushAddressTests.swift in Sources */,
449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */,
7714B1C2FBCBBAD9BE8FEAF8 /* GameSummaryThumbnailTests.swift in Sources */,
+ 085B70680087464B8A7BA3EE /* GridSilhouetteTests.swift in Sources */,
AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */,
0158184A413AE177F75B4150 /* JournalReplayTests.swift in Sources */,
6E67C0DCB0416F382EA065B7 /* JournalUploadTests.swift in Sources */,
@@ -856,6 +872,7 @@
C58F15CBEADA72032B54009D /* ReplayControlsTests.swift in Sources */,
AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */,
07A46496EE0B12FD526F36FB /* SessionPushPlannerTests.swift in Sources */,
+ 8AE376C0726116082B15241D /* ShareLinkRouteTests.swift in Sources */,
41290C86E72D6567C43C31B7 /* ShareLinkShortenerTests.swift in Sources */,
BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */,
025377AF80D45967CE910423 /* SyncMonitorTests.swift in Sources */,
@@ -911,6 +928,7 @@
3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */,
849970A21D62C34EC382A27E /* GameShareItem.swift in Sources */,
D58980B92C99122C368D4216 /* GameStore.swift in Sources */,
+ 4B8CA45845618D75A3313816 /* GridSilhouette.swift in Sources */,
ED6C21CD9F5AB286B69A02E4 /* GridStateMerger.swift in Sources */,
C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */,
765B50552B13175F91A25EA1 /* GridView.swift in Sources */,
@@ -920,6 +938,7 @@
1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */,
59230713D85AE6895852B06A /* InviteCoordinator.swift in Sources */,
7D9337A19747C79070AB3D59 /* InviteEntity+DisplayName.swift in Sources */,
+ 81EFADDD76DC5F24E944C792 /* JoiningPuzzleView.swift in Sources */,
9502840161DB88BB6BB409D5 /* Journal.swift in Sources */,
B5F78A55C9BCCD24E44D865F /* JournalReplay.swift in Sources */,
F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */,
@@ -967,6 +986,7 @@
B48DE7079BE2F31D2367C5F7 /* SessionPushPlanner.swift in Sources */,
54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */,
BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */,
+ D4EDC0D426688B295DA77C08 /* ShareLinkRoute.swift in Sources */,
779D1955F350B507A47B1E5B /* ShareLinkShortener.swift in Sources */,
AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */,
740F5EC3331CA9DCCDA682F0 /* SuccessPanel.swift in Sources */,
@@ -1122,6 +1142,7 @@
CROSSMATE_ENGAGEMENT_SOCKET_URL = "$(inherited)";
CROSSMATE_PUSH_BASE_URL = "$(inherited)";
CROSSMATE_SHARE_LINK_BASE_URL = "$(inherited)";
+ CROSSMATE_SHARE_LINK_HOST = "$(inherited)";
INFOPLIST_FILE = Crossmate/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -1146,6 +1167,7 @@
CROSSMATE_ENGAGEMENT_SOCKET_URL = "$(inherited)";
CROSSMATE_PUSH_BASE_URL = "$(inherited)";
CROSSMATE_SHARE_LINK_BASE_URL = "$(inherited)";
+ CROSSMATE_SHARE_LINK_HOST = "$(inherited)";
INFOPLIST_FILE = Crossmate/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
diff --git a/Crossmate/Crossmate.entitlements b/Crossmate/Crossmate.entitlements
@@ -27,5 +27,9 @@
<array>
<string>group.net.inqk.crossmate</string>
</array>
+ <key>com.apple.developer.associated-domains</key>
+ <array>
+ <string>applinks:$(CROSSMATE_SHARE_LINK_HOST)</string>
+ </array>
</dict>
</plist>
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -366,12 +366,20 @@ final class SceneDelegate: NSObject, UIWindowSceneDelegate {
// MARK: - Root View
+/// Drives the join placeholder overlay while a tapped share link is being
+/// accepted; `shape` is the silhouette decoded from the link, if any.
+struct PendingJoinPlaceholder: Identifiable {
+ let id = UUID()
+ let shape: GridSilhouette.Grid?
+}
+
struct RootView: View {
let services: AppServices
let appDelegate: AppDelegate
@Environment(\.scenePhase) private var scenePhase
@State private var navigationPath = NavigationPath()
+ @State private var pendingJoin: PendingJoinPlaceholder?
var body: some View {
NavigationStack(path: $navigationPath) {
@@ -394,6 +402,13 @@ struct RootView: View {
}
}
.environment(services.preferences)
+ .overlay {
+ if let pendingJoin {
+ JoiningPuzzleView(shape: pendingJoin.shape)
+ .transition(.opacity)
+ .zIndex(1)
+ }
+ }
.task {
NotificationState.setActivePuzzleID(nil)
NotificationNavigationBroker.shared.onOpenGame = { gameID in
@@ -408,10 +423,28 @@ struct RootView: View {
navigationPath.append(id)
}
}
+ .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
+ // A tapped Crossmate share link (universal link). Show the
+ // placeholder immediately off the silhouette in the URL, then
+ // accept the share — the iCloud token is reconstructed locally, so
+ // there's no Safari → iCloud bounce. `.cloudShareAcceptanceCompleted`
+ // clears the placeholder and navigates to the joined game.
+ guard let url = activity.webpageURL,
+ let route = ShareLinkRoute(shortLink: url) else { return }
+ withAnimation { pendingJoin = PendingJoinPlaceholder(shape: route.shape) }
+ Task {
+ do {
+ try await services.cloudService.acceptShare(url: route.iCloudShareURL)
+ } catch {
+ withAnimation { pendingJoin = nil }
+ }
+ }
+ }
.onReceive(NotificationCenter.default.publisher(for: .cloudShareAcceptanceStarted)) { _ in
UIApplication.shared.dismissPresentedViewControllers()
}
.onReceive(NotificationCenter.default.publisher(for: .cloudShareAcceptanceCompleted)) { notification in
+ withAnimation { pendingJoin = nil }
guard let gameID = notification.userInfo?["gameID"] as? UUID else { return }
// A join driven from inside the puzzle view (an `.invite`
// notification tap) is already showing this game — appending
diff --git a/Crossmate/Services/GridSilhouette.swift b/Crossmate/Services/GridSilhouette.swift
@@ -0,0 +1,106 @@
+import Foundation
+
+/// Encodes a crossword's block layout — its grid "silhouette" — into a compact,
+/// URL-safe path segment for a share link, and back. The segment lets the link
+/// worker render a preview of the actual grid (and, later, lets the app paint a
+/// placeholder the instant a link is tapped) without any CloudKit round-trip.
+///
+/// Only square grids are supported. An irregular shape returns `nil` and simply
+/// gets no segment — and therefore no rich preview — which keeps the format free
+/// of width/height bookkeeping (per the "no preview for weird shapes" rule).
+///
+/// Wire format: `<tag><size><payload>`
+/// - `tag` `s` when the grid is 180°-rotationally symmetric (only the first
+/// ⌈N/2⌉ cells are stored, since the rest mirror them); `f` for a
+/// full dump of every cell.
+/// - `size` the grid's side length as a single base-36 digit (`f` = 15,
+/// `l` = 21), so sides are bounded to 2…35.
+/// - `payload` the cell bits, row-major, MSB-first, `1` = block, as base64url
+/// without padding. Each character therefore carries six cells.
+///
+/// A symmetric 15×15 lands in ~22 characters (`s` `f` + 20); the full fallback
+/// for an asymmetric 15×15 is ~40. See `GridSilhouetteTests`.
+enum GridSilhouette {
+ static let minSide = 2
+ /// A single base-36 size digit tops out at 35.
+ static let maxSide = 35
+
+ struct Grid: Equatable {
+ let side: Int
+ /// Row-major, `true` = block.
+ let blocks: [Bool]
+ }
+
+ /// Encodes a square grid, or `nil` when the grid is non-square or its side
+ /// falls outside `minSide...maxSide`.
+ static func encode(side: Int, blocks: [Bool]) -> String? {
+ guard (minSide...maxSide).contains(side), blocks.count == side * side else {
+ return nil
+ }
+ let n = blocks.count
+ let symmetric = (0..<(n / 2)).allSatisfy { blocks[$0] == blocks[n - 1 - $0] }
+ let stored = symmetric ? Array(blocks.prefix((n + 1) / 2)) : blocks
+ let tag = symmetric ? "s" : "f"
+ return tag + String(side, radix: 36) + Self.base64URLEncode(Self.packBits(stored))
+ }
+
+ /// Reverses `encode`, or `nil` when the segment is malformed.
+ static func decode(_ segment: String) -> Grid? {
+ let chars = Array(segment)
+ guard chars.count >= 2 else { return nil }
+ let tag = chars[0]
+ guard tag == "s" || tag == "f" else { return nil }
+ guard let side = Int(String(chars[1]), radix: 36),
+ (minSide...maxSide).contains(side) else { return nil }
+
+ let n = side * side
+ let storedCount = tag == "s" ? (n + 1) / 2 : n
+ guard let bytes = Self.base64URLDecode(String(chars[2...])) else { return nil }
+ let bits = Self.unpackBits(bytes, count: storedCount)
+ guard bits.count == storedCount else { return nil }
+
+ var blocks = [Bool](repeating: false, count: n)
+ for k in 0..<n {
+ // The stored half holds cells 0..<⌈N/2⌉; every later cell mirrors
+ // its 180°-rotation partner, which lands inside the stored half.
+ blocks[k] = k < storedCount ? bits[k] : bits[n - 1 - k]
+ }
+ return Grid(side: side, blocks: blocks)
+ }
+
+ // MARK: - Bit packing
+
+ private static func packBits(_ bits: [Bool]) -> [UInt8] {
+ var bytes = [UInt8](repeating: 0, count: (bits.count + 7) / 8)
+ for (i, bit) in bits.enumerated() where bit {
+ bytes[i / 8] |= UInt8(0x80) >> (i % 8)
+ }
+ return bytes
+ }
+
+ private static func unpackBits(_ bytes: [UInt8], count: Int) -> [Bool] {
+ guard bytes.count * 8 >= count else { return [] }
+ return (0..<count).map { i in
+ bytes[i / 8] & (UInt8(0x80) >> (i % 8)) != 0
+ }
+ }
+
+ // MARK: - base64url
+
+ static func base64URLEncode(_ bytes: [UInt8]) -> String {
+ Data(bytes).base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+
+ static func base64URLDecode(_ string: String) -> [UInt8]? {
+ var s = string
+ .replacingOccurrences(of: "-", with: "+")
+ .replacingOccurrences(of: "_", with: "/")
+ let remainder = s.count % 4
+ if remainder != 0 { s += String(repeating: "=", count: 4 - remainder) }
+ guard let data = Data(base64Encoded: s) else { return nil }
+ return [UInt8](data)
+ }
+}
diff --git a/Crossmate/Services/ShareLinkRoute.swift b/Crossmate/Services/ShareLinkRoute.swift
@@ -0,0 +1,34 @@
+import Foundation
+
+/// Parses a Crossmate short share link — `https://<host>/s/<token>[/<deco>]…`
+/// — into the iCloud share token and, when the link carries one, the grid
+/// silhouette. A universal-link tap can then paint a placeholder grid and
+/// accept the share itself, skipping the Safari → iCloud redirect bounce.
+///
+/// Mirrors `link-worker.js`: the token is constrained to RFC 3986 unreserved
+/// characters so the reconstructed iCloud URL can only ever be a share link,
+/// and each decoration segment is classified by trying to decode it as a
+/// silhouette (the title segment simply fails that and is ignored).
+struct ShareLinkRoute: Equatable {
+ let token: String
+ let shape: GridSilhouette.Grid?
+
+ init?(shortLink url: URL) {
+ guard url.scheme == "https" else { return nil }
+ // ["s", token, deco?, deco?]
+ let parts = url.pathComponents.filter { $0 != "/" }
+ guard (2...4).contains(parts.count), parts[0] == "s" else { return nil }
+
+ let token = parts[1]
+ guard token.wholeMatch(of: /[A-Za-z0-9._~-]{8,128}/) != nil else { return nil }
+
+ self.token = token
+ self.shape = parts.dropFirst(2).lazy.compactMap { GridSilhouette.decode($0) }.first
+ }
+
+ /// The iCloud share URL the token addresses, fed to `CloudService` for
+ /// acceptance. Safe to force-unwrap: the token charset is validated above.
+ var iCloudShareURL: URL {
+ URL(string: "https://www.icloud.com/share/\(token)")!
+ }
+}
diff --git a/Crossmate/Services/ShareLinkShortener.swift b/Crossmate/Services/ShareLinkShortener.swift
@@ -14,6 +14,18 @@ import Foundation
/// remain raw iCloud URLs.
enum ShareLinkShortener {
+ /// Custom titles are capped well below the old 80 so the link stays short;
+ /// a longer puzzle name is truncated rather than bloating the URL.
+ static let maxCustomTitleLength = 32
+
+ /// The English weekday names that, followed by " Crossword", form the NYT
+ /// puzzle titles `NYTToXDConverter` emits. A title matching one of these is
+ /// sent as its single-digit index instead of a base64url blob — the common
+ /// case (`"Monday Crossword"` → `1`) collapses to one character.
+ static let weekdayTitles = [
+ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
+ ]
+
/// The link worker's origin, from `CrossmateShareLinkBaseURL` in
/// Info.plist (filled by `CROSSMATE_SHARE_LINK_BASE_URL` in
/// `Local.xcconfig`). `nil` on a checkout without the setting, in which
@@ -27,39 +39,48 @@ enum ShareLinkShortener {
return URL(string: trimmed)
}()
- static func shortURL(for shareURL: URL, title: String?) -> URL {
- shortURL(for: shareURL, title: title, baseURL: configuredBaseURL)
+ static func shortURL(for shareURL: URL, title: String?, shape: GridSilhouette.Grid? = nil) -> URL {
+ shortURL(for: shareURL, title: title, shape: shape, baseURL: configuredBaseURL)
}
/// Returns `shareURL` unchanged whenever it does not look like a CKShare
/// link the worker is known to handle, so an unexpected URL shape from
/// CloudKit degrades to sharing the working raw link rather than minting
/// a short link that 404s.
- static func shortURL(for shareURL: URL, title: String?, baseURL: URL?) -> URL {
+ ///
+ /// The optional title and grid-silhouette segments are both decoration the
+ /// worker uses only for the link preview; each is self-typed by its leading
+ /// character (`t`/digit for the title, `s`/`f` for the shape), so the worker
+ /// can tell them apart regardless of which are present. Neither affects the
+ /// redirect, which keys on the token alone.
+ static func shortURL(for shareURL: URL, title: String?, shape: GridSilhouette.Grid?, baseURL: URL?) -> URL {
guard let baseURL, let token = shareToken(from: shareURL) else {
return shareURL
}
- guard let encodedTitle = encodedTitle(title) else {
- return baseURL.appending(path: "s/\(token)")
+ var path = "s/\(token)"
+ if let titleSegment = encodedTitle(title) {
+ path += "/\(titleSegment)"
}
- return baseURL.appending(path: "s/\(token)/\(encodedTitle)")
+ if let shape, let shapeSegment = GridSilhouette.encode(side: shape.side, blocks: shape.blocks) {
+ path += "/\(shapeSegment)"
+ }
+ return baseURL.appending(path: path)
}
/// The game title rides the short link as an extra path segment so the
- /// worker can personalise the link preview, encoded as unpadded base64url
- /// to keep the URL one opaque blob instead of a legible puzzle name. The
- /// worker treats the segment as decoration: it never affects where the
- /// link redirects.
+ /// worker can personalise the link preview. A standard NYT `"<Weekday>
+ /// Crossword"` title becomes a single digit; any other title is unpadded
+ /// base64url under a `t` tag, keeping it an opaque blob rather than a
+ /// legible puzzle name. The worker treats the segment as decoration: it
+ /// never affects where the link redirects.
private static func encodedTitle(_ title: String?) -> String? {
let trimmed = (title ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
- // Matches the worker's MAX_TITLE_LENGTH; anything longer is
- // truncated there anyway, so sending it would only bloat the URL.
- let capped = String(trimmed.prefix(80))
- return Data(capped.utf8).base64EncodedString()
- .replacingOccurrences(of: "+", with: "-")
- .replacingOccurrences(of: "/", with: "_")
- .replacingOccurrences(of: "=", with: "")
+ if let weekday = weekdayTitles.firstIndex(where: { "\($0) Crossword" == trimmed }) {
+ return String(weekday)
+ }
+ let capped = String(trimmed.prefix(maxCustomTitleLength))
+ return "t" + GridSilhouette.base64URLEncode([UInt8](capped.utf8))
}
/// Extracts the token from `https://www.icloud.com/share/<token>#…`. The
diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift
@@ -319,6 +319,24 @@ final class ShareController {
return Self.inviteeCount(in: share) >= Self.maximumInviteesPerPuzzle
}
+ /// The game's grid silhouette for share-link previews, read from the
+ /// cached block layout so it costs nothing at link-creation time. Returns
+ /// `nil` when the grid isn't square or the cache hasn't been populated, in
+ /// which case the link simply carries no shape segment.
+ func gridSilhouette(for gameID: UUID) -> GridSilhouette.Grid? {
+ let ctx = persistence.viewContext
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ request.fetchLimit = 1
+ guard let entity = try? ctx.fetch(request).first else { return nil }
+ let side = Int(entity.gridWidth)
+ guard side > 0, Int(entity.gridHeight) == side,
+ let mask = entity.blockMask, mask.count == side * side else {
+ return nil
+ }
+ return GridSilhouette.Grid(side: side, blocks: mask.map { $0 != 0 })
+ }
+
private func prepareShareRecord(
for gameID: UUID,
publicPermission: CKShare.ParticipantPermission,
diff --git a/Crossmate/Views/GameShareItem.swift b/Crossmate/Views/GameShareItem.swift
@@ -252,8 +252,9 @@ struct GameShareSheet: View {
defer { isLoadingExistingLink = false }
do {
+ let shape = shareController.gridSilhouette(for: gameID)
shareURL = (try await shareController.existingShareLink(for: gameID))
- .map { ShareLinkShortener.shortURL(for: $0, title: title) }
+ .map { ShareLinkShortener.shortURL(for: $0, title: title, shape: shape) }
} catch {
errorMessage = describe(error)
}
@@ -269,7 +270,8 @@ struct GameShareSheet: View {
do {
shareURL = ShareLinkShortener.shortURL(
for: try await shareController.createShareLink(for: gameID),
- title: title
+ title: title,
+ shape: shareController.gridSilhouette(for: gameID)
)
} catch {
errorMessage = describe(error)
diff --git a/Crossmate/Views/GridThumbnailView.swift b/Crossmate/Views/GridThumbnailView.swift
@@ -8,8 +8,8 @@ struct GridThumbnailView: View {
let width: Int
let height: Int
let cells: [GameThumbnailCell]
+ var size: CGFloat = 60
- private let size: CGFloat = 60
private let spacing: CGFloat = 0.5
var body: some View {
diff --git a/Crossmate/Views/JoiningPuzzleView.swift b/Crossmate/Views/JoiningPuzzleView.swift
@@ -0,0 +1,40 @@
+import SwiftUI
+
+/// The placeholder shown the instant a share link is tapped, while the share
+/// is being accepted and its zone fetched. When the link carried a grid
+/// silhouette it paints that grid greyed-out — so the user immediately sees
+/// the shape of the puzzle they're joining instead of an empty screen — with a
+/// spinner underneath. With no silhouette it falls back to the spinner alone.
+struct JoiningPuzzleView: View {
+ let shape: GridSilhouette.Grid?
+
+ var body: some View {
+ ZStack {
+ Color(.systemBackground).ignoresSafeArea()
+
+ VStack(spacing: 28) {
+ if let shape {
+ GridThumbnailView(
+ width: shape.side,
+ height: shape.side,
+ // Open cells render grey (not white) to read as
+ // "not editable yet" rather than a playable grid.
+ cells: shape.blocks.map { $0 ? .block : .filled },
+ size: 220
+ )
+ .opacity(0.55)
+ .accessibilityHidden(true)
+ }
+
+ VStack(spacing: 12) {
+ ProgressView()
+ Text("Joining puzzle…")
+ .font(.headline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("Joining puzzle")
+ }
+}
diff --git a/Tests/Unit/GridSilhouetteTests.swift b/Tests/Unit/GridSilhouetteTests.swift
@@ -0,0 +1,79 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("Grid silhouette codec")
+struct GridSilhouetteTests {
+
+ @Test("encodes a small symmetric grid to a stable segment")
+ func encodesSymmetricSegment() {
+ // 3×3 with only the centre blocked. Symmetric, so the first ⌈9/2⌉ = 5
+ // cells (00001) pack into one byte 0x08 → base64url "CA"; side 3 → "3".
+ var blocks = [Bool](repeating: false, count: 9)
+ blocks[4] = true
+ #expect(GridSilhouette.encode(side: 3, blocks: blocks) == "s3CA")
+ }
+
+ @Test("tags an asymmetric grid for a full dump")
+ func tagsAsymmetricGrid() {
+ // A single corner block has no 180° partner → not symmetric → "f".
+ var blocks = [Bool](repeating: false, count: 9)
+ blocks[0] = true
+ let segment = GridSilhouette.encode(side: 3, blocks: blocks)
+ #expect(segment?.first == "f")
+ #expect(GridSilhouette.decode(segment ?? "") == GridSilhouette.Grid(side: 3, blocks: blocks))
+ }
+
+ @Test("round-trips a symmetric grid through decode")
+ func roundTripsSymmetric() {
+ var blocks = [Bool](repeating: false, count: 9)
+ blocks[4] = true
+ let grid = GridSilhouette.Grid(side: 3, blocks: blocks)
+ let segment = GridSilhouette.encode(side: grid.side, blocks: grid.blocks)
+ #expect(GridSilhouette.decode(segment ?? "") == grid)
+ }
+
+ @Test("round-trips a realistic 15×15 and stays compact")
+ func roundTrips15x15() {
+ // A symmetric scatter of blocks across a standard 15×15.
+ let side = 15
+ let n = side * side
+ var blocks = [Bool](repeating: false, count: n)
+ for k in [0, 4, 16, 30, 47, 88, 100, 112] {
+ blocks[k] = true
+ blocks[n - 1 - k] = true // keep it symmetric
+ }
+ let grid = GridSilhouette.Grid(side: side, blocks: blocks)
+ let segment = GridSilhouette.encode(side: side, blocks: blocks)
+ #expect(segment?.first == "s")
+ // ~2 tag/size chars + ⌈113 bits / 6⌉ ≈ 19 payload chars.
+ #expect((segment?.count ?? .max) <= 24)
+ #expect(GridSilhouette.decode(segment ?? "") == grid)
+ }
+
+ @Test("encodes the largest supported side via base-36")
+ func encodesLargeSide() {
+ let side = GridSilhouette.maxSide // 35 → base-36 'z'
+ let blocks = [Bool](repeating: false, count: side * side)
+ let segment = GridSilhouette.encode(side: side, blocks: blocks)
+ #expect(segment?.dropFirst().first == "z")
+ #expect(GridSilhouette.decode(segment ?? "") == GridSilhouette.Grid(side: side, blocks: blocks))
+ }
+
+ @Test("refuses non-square or out-of-range grids")
+ func refusesUnsupportedGrids() {
+ #expect(GridSilhouette.encode(side: 3, blocks: [Bool](repeating: false, count: 6)) == nil)
+ #expect(GridSilhouette.encode(side: 1, blocks: [false]) == nil)
+ #expect(GridSilhouette.encode(side: 36, blocks: [Bool](repeating: false, count: 36 * 36)) == nil)
+ }
+
+ @Test("rejects malformed segments")
+ func rejectsMalformedSegments() {
+ #expect(GridSilhouette.decode("") == nil)
+ #expect(GridSilhouette.decode("x3CA") == nil) // unknown tag
+ #expect(GridSilhouette.decode("s") == nil) // no size/payload
+ #expect(GridSilhouette.decode("s3") == nil) // empty payload, no bits
+ #expect(GridSilhouette.decode("s1CA") == nil) // side 1 is below minSide
+ }
+}
diff --git a/Tests/Unit/ShareLinkRouteTests.swift b/Tests/Unit/ShareLinkRouteTests.swift
@@ -0,0 +1,57 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("Share link route")
+struct ShareLinkRouteTests {
+
+ private let token = "0a1BcDeFgHiJkLmNoPqRsTuVw"
+ private func url(_ path: String) -> URL {
+ URL(string: "https://crossmate.example.net\(path)")!
+ }
+
+ @Test("parses token and silhouette from a full link")
+ func parsesTokenAndShape() {
+ let route = ShareLinkRoute(shortLink: url("/s/\(token)/1/s3CA"))
+ #expect(route?.token == token)
+ #expect(route?.shape?.side == 3)
+ #expect(route?.shape?.blocks[4] == true)
+ }
+
+ @Test("parses a shape-only link (no title segment)")
+ func parsesShapeOnly() {
+ let route = ShareLinkRoute(shortLink: url("/s/\(token)/s3CA"))
+ #expect(route?.token == token)
+ #expect(route?.shape?.side == 3)
+ }
+
+ @Test("a title-only link yields no shape")
+ func titleOnlyHasNoShape() {
+ let route = ShareLinkRoute(shortLink: url("/s/\(token)/1"))
+ #expect(route?.token == token)
+ #expect(route?.shape == nil)
+ }
+
+ @Test("a bare link yields token and no shape")
+ func bareLink() {
+ let route = ShareLinkRoute(shortLink: url("/s/\(token)"))
+ #expect(route?.token == token)
+ #expect(route?.shape == nil)
+ }
+
+ @Test("reconstructs the iCloud share URL")
+ func reconstructsICloudURL() {
+ let route = ShareLinkRoute(shortLink: url("/s/\(token)"))
+ #expect(route?.iCloudShareURL.absoluteString == "https://www.icloud.com/share/\(token)")
+ }
+
+ @Test("rejects non-share and malformed links")
+ func rejectsBadLinks() {
+ #expect(ShareLinkRoute(shortLink: url("/s/short")) == nil) // token too short
+ #expect(ShareLinkRoute(shortLink: url("/notshare/\(token)")) == nil) // wrong prefix
+ #expect(ShareLinkRoute(shortLink: url("/s")) == nil) // no token
+ #expect(ShareLinkRoute(shortLink: url("/s/\(token)/a/b/c")) == nil) // too many segments
+ #expect(ShareLinkRoute(shortLink: URL(string: "http://crossmate.example.net/s/\(token)")!) == nil) // not https
+ }
+}
diff --git a/Tests/Unit/ShareLinkShortenerTests.swift b/Tests/Unit/ShareLinkShortenerTests.swift
@@ -7,62 +7,127 @@ import Testing
struct ShareLinkShortenerTests {
private let base = URL(string: "https://crossmate.example.net")!
+ private let token = "0a1BcDeFgHiJkLmNoPqRsTuVw"
+ private var share: URL { URL(string: "https://www.icloud.com/share/\(token)")! }
@Test("rewrites an iCloud share URL to the worker short form")
func rewritesShareURL() {
- let share = URL(string: "https://www.icloud.com/share/0a1BcDeFgHiJkLmNoPqRsTuVw")!
- let short = ShareLinkShortener.shortURL(for: share, title: nil, baseURL: base)
- #expect(short.absoluteString == "https://crossmate.example.net/s/0a1BcDeFgHiJkLmNoPqRsTuVw")
+ let short = ShareLinkShortener.shortURL(for: share, title: nil, shape: nil, baseURL: base)
+ #expect(short.absoluteString == "https://crossmate.example.net/s/\(token)")
}
@Test("drops the title-slug fragment from the short link")
func dropsFragment() {
- let share = URL(string: "https://www.icloud.com/share/0a1BcDeFgHiJkLmNoPqRsTuVw#Saturday_Stumper")!
- let short = ShareLinkShortener.shortURL(for: share, title: nil, baseURL: base)
- #expect(short.absoluteString == "https://crossmate.example.net/s/0a1BcDeFgHiJkLmNoPqRsTuVw")
+ let fragmented = URL(string: "https://www.icloud.com/share/\(token)#Saturday_Stumper")!
+ let short = ShareLinkShortener.shortURL(for: fragmented, title: nil, shape: nil, baseURL: base)
+ #expect(short.absoluteString == "https://crossmate.example.net/s/\(token)")
}
- @Test("encodes the game title as an unpadded base64url path segment")
+ @Test("encodes a custom game title as a t-tagged base64url segment")
func encodesTitleSegment() {
- let share = URL(string: "https://www.icloud.com/share/0a1BcDeFgHiJkLmNoPqRsTuVw#Saturday_Stumper")!
- let short = ShareLinkShortener.shortURL(for: share, title: "Saturday Stumper", baseURL: base)
+ let short = ShareLinkShortener.shortURL(
+ for: share, title: "Saturday Stumper", shape: nil, baseURL: base
+ )
#expect(
short.absoluteString
- == "https://crossmate.example.net/s/0a1BcDeFgHiJkLmNoPqRsTuVw/U2F0dXJkYXkgU3R1bXBlcg"
+ == "https://crossmate.example.net/s/\(token)/tU2F0dXJkYXkgU3R1bXBlcg"
)
}
@Test("maps base64 plus and slash into the URL-safe alphabet")
func encodesTitleURLSafely() {
- let share = URL(string: "https://www.icloud.com/share/0a1BcDeFgHiJkLmNoPqRsTuVw")!
// The em dash and π force '+' into the standard base64 encoding.
- let short = ShareLinkShortener.shortURL(for: share, title: "Sunday? Yes — π!", baseURL: base)
+ let short = ShareLinkShortener.shortURL(
+ for: share, title: "Sunday? Yes — π!", shape: nil, baseURL: base
+ )
#expect(
short.absoluteString
- == "https://crossmate.example.net/s/0a1BcDeFgHiJkLmNoPqRsTuVw/U3VuZGF5PyBZZXMg4oCUIM-AIQ"
+ == "https://crossmate.example.net/s/\(token)/tU3VuZGF5PyBZZXMg4oCUIM-AIQ"
)
}
+ @Test("collapses a standard NYT weekday title to a single digit")
+ func encodesWeekdayTitle() {
+ let cases: [(String, String)] = [
+ ("Sunday Crossword", "0"),
+ ("Monday Crossword", "1"),
+ ("Saturday Crossword", "6"),
+ (" Monday Crossword ", "1")
+ ]
+ for (title, digit) in cases {
+ let short = ShareLinkShortener.shortURL(for: share, title: title, shape: nil, baseURL: base)
+ #expect(short.absoluteString == "https://crossmate.example.net/s/\(token)/\(digit)")
+ }
+ }
+
+ @Test("a near-miss weekday title falls back to the base64url form")
+ func nonWeekdayTitleStaysCustom() {
+ // No trailing "Crossword", so it is not the NYT shortcut.
+ let short = ShareLinkShortener.shortURL(for: share, title: "Monday", shape: nil, baseURL: base)
+ #expect(
+ short.absoluteString == "https://crossmate.example.net/s/\(token)/tTW9uZGF5"
+ )
+ }
+
+ @Test("truncates an over-long custom title to the cap")
+ func truncatesLongTitle() {
+ let long = String(repeating: "A", count: ShareLinkShortener.maxCustomTitleLength + 12)
+ let capped = String(long.prefix(ShareLinkShortener.maxCustomTitleLength))
+ let expected = "t" + GridSilhouette.base64URLEncode([UInt8](capped.utf8))
+ let short = ShareLinkShortener.shortURL(for: share, title: long, shape: nil, baseURL: base)
+ #expect(short.absoluteString == "https://crossmate.example.net/s/\(token)/\(expected)")
+ }
+
+ @Test("appends the grid silhouette after the title")
+ func appendsShapeAfterTitle() {
+ // 3×3, centre cell blocked → symmetric → "s3CA".
+ var blocks = [Bool](repeating: false, count: 9)
+ blocks[4] = true
+ let grid = GridSilhouette.Grid(side: 3, blocks: blocks)
+ let short = ShareLinkShortener.shortURL(
+ for: share, title: "Monday Crossword", shape: grid, baseURL: base
+ )
+ #expect(short.absoluteString == "https://crossmate.example.net/s/\(token)/1/s3CA")
+ }
+
+ @Test("emits the silhouette segment even when there is no title")
+ func appendsShapeWithoutTitle() {
+ var blocks = [Bool](repeating: false, count: 9)
+ blocks[4] = true
+ let grid = GridSilhouette.Grid(side: 3, blocks: blocks)
+ let short = ShareLinkShortener.shortURL(for: share, title: nil, shape: grid, baseURL: base)
+ #expect(short.absoluteString == "https://crossmate.example.net/s/\(token)/s3CA")
+ }
+
+ @Test("omits a non-square silhouette rather than emitting a bad segment")
+ func omitsNonSquareShape() {
+ let grid = GridSilhouette.Grid(side: 3, blocks: [Bool](repeating: false, count: 6))
+ let short = ShareLinkShortener.shortURL(for: share, title: nil, shape: grid, baseURL: base)
+ #expect(short.absoluteString == "https://crossmate.example.net/s/\(token)")
+ }
+
@Test("omits the title segment for an empty or whitespace title")
func omitsBlankTitle() {
- let share = URL(string: "https://www.icloud.com/share/0a1BcDeFgHiJkLmNoPqRsTuVw")!
for title in [nil, "", " ", "\n"] as [String?] {
- let short = ShareLinkShortener.shortURL(for: share, title: title, baseURL: base)
- #expect(short.absoluteString == "https://crossmate.example.net/s/0a1BcDeFgHiJkLmNoPqRsTuVw")
+ let short = ShareLinkShortener.shortURL(for: share, title: title, shape: nil, baseURL: base)
+ #expect(short.absoluteString == "https://crossmate.example.net/s/\(token)")
}
}
@Test("accepts the bare icloud.com host")
func acceptsBareHost() {
- let share = URL(string: "https://icloud.com/share/0a1BcDeFgHiJkLmNoPqRsTuVw")!
- let short = ShareLinkShortener.shortURL(for: share, title: nil, baseURL: base)
- #expect(short.absoluteString == "https://crossmate.example.net/s/0a1BcDeFgHiJkLmNoPqRsTuVw")
+ let bare = URL(string: "https://icloud.com/share/\(token)")!
+ let short = ShareLinkShortener.shortURL(for: bare, title: nil, shape: nil, baseURL: base)
+ #expect(short.absoluteString == "https://crossmate.example.net/s/\(token)")
}
@Test("passes through unchanged when no base URL is configured")
func passesThroughWithoutBase() {
- let share = URL(string: "https://www.icloud.com/share/0a1BcDeFgHiJkLmNoPqRsTuVw#Title")!
- #expect(ShareLinkShortener.shortURL(for: share, title: "Title", baseURL: nil) == share)
+ let fragmented = URL(string: "https://www.icloud.com/share/\(token)#Title")!
+ #expect(
+ ShareLinkShortener.shortURL(for: fragmented, title: "Title", shape: nil, baseURL: nil)
+ == fragmented
+ )
}
@Test(
@@ -77,7 +142,7 @@ struct ShareLinkShortenerTests {
)
func passesThroughForeignURLs(raw: String) {
let url = URL(string: raw)!
- #expect(ShareLinkShortener.shortURL(for: url, title: nil, baseURL: base) == url)
+ #expect(ShareLinkShortener.shortURL(for: url, title: nil, shape: nil, baseURL: base) == url)
}
@Test("rejects tokens outside the unreserved charset or length bounds")
@@ -85,9 +150,9 @@ struct ShareLinkShortenerTests {
// The encoded slash decodes into the last path component; passing it
// through to the worker would change the redirect target's shape.
let smuggled = URL(string: "https://www.icloud.com/share/0a1BcDeF%2F..%2FbAd")!
- #expect(ShareLinkShortener.shortURL(for: smuggled, title: nil, baseURL: base) == smuggled)
+ #expect(ShareLinkShortener.shortURL(for: smuggled, title: nil, shape: nil, baseURL: base) == smuggled)
let tooShort = URL(string: "https://www.icloud.com/share/0a1Bc")!
- #expect(ShareLinkShortener.shortURL(for: tooShort, title: nil, baseURL: base) == tooShort)
+ #expect(ShareLinkShortener.shortURL(for: tooShort, title: nil, shape: nil, baseURL: base) == tooShort)
}
}
diff --git a/Workers/link-worker.js b/Workers/link-worker.js
@@ -5,22 +5,39 @@
//
// into
//
-// https://<this worker>/s/<token>[/<base64url game title>]
+// https://<this worker>/s/<token>[/<deco>][/<deco>]
//
// so that the link people actually paste into chat is short, carries no
// legible puzzle title, and serves Crossmate's own Open Graph metadata
// instead of iCloud's. Link-preview crawlers get a 200 HTML page with OG
// tags; everyone else gets a 302 straight to iCloud. The share token is the
// entire state, so nothing is stored and a link can never expire on this
-// side. The optional second segment exists only to personalise the preview
-// title — it is ignored by the redirect, so a link with a stale or mangled
-// title still lands on the right share.
+// side.
+//
+// Up to two optional decoration segments personalise the preview. Each is
+// self-typed by its leading character, so order doesn't matter and either
+// may be absent: a single 0–6 is a weekday-name title, a `t` prefix is a
+// custom base64url title, an `s`/`f` structure is the grid silhouette, and
+// anything else is a legacy bare-base64url title (links minted before the
+// types existed). Decoration is ignored by the redirect, so a stale or
+// mangled segment still lands on the right share.
// Bundled by the Data module rule in wrangler.link.toml; served at /og.png.
import ogImage from "./og.png";
const ICLOUD_SHARE_BASE = "https://www.icloud.com/share/";
+// Claims the share-link paths as universal links for the app. `/s/*` is all the
+// app needs to intercept; the AASA fetch itself and /og(.png)/og image routes
+// are unaffected because they don't match.
+const APPLE_APP_SITE_ASSOCIATION = JSON.stringify({
+ applinks: {
+ details: [
+ { appIDs: ["7TD7PZBNXP.net.inqk.crossmate"], components: [{ "/": "/s/*" }] }
+ ]
+ }
+});
+
// iCloud share tokens are short base62-ish strings. Restricting the charset
// to RFC 3986 unreserved characters means the redirect target cannot contain
// a path separator, query, or authority — the worker can only ever redirect
@@ -56,6 +73,23 @@ export default {
}
const url = new URL(request.url);
+
+ // Apple fetches this to authorise universal links, so a tapped share link
+ // opens the app directly (which paints a placeholder and accepts the share)
+ // instead of bouncing through Safari and iCloud. Must be served as JSON
+ // over https with no redirect.
+ if (
+ url.pathname === "/apple-app-site-association" ||
+ url.pathname === "/.well-known/apple-app-site-association"
+ ) {
+ return new Response(APPLE_APP_SITE_ASSOCIATION, {
+ headers: {
+ "Content-Type": "application/json",
+ "Cache-Control": "public, max-age=3600"
+ }
+ });
+ }
+
if (url.pathname === "/og.png") {
return new Response(ogImage, {
headers: {
@@ -65,7 +99,27 @@ export default {
});
}
- const match = url.pathname.match(/^\/s\/([^/]+)(?:\/([^/]+))?$/);
+ // The per-puzzle preview image. The grid silhouette rides in the path, so
+ // the image is a pure function of it — deterministic and immutably
+ // cacheable. A malformed segment falls back to the generic card image
+ // rather than erroring, so a link preview never breaks on a bad shape.
+ const imageMatch = url.pathname.match(/^\/g\/([A-Za-z0-9_-]+)\.png$/);
+ if (imageMatch) {
+ const grid = decodeSilhouette(imageMatch[1]);
+ if (!grid) {
+ return new Response(ogImage, {
+ headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=86400" }
+ });
+ }
+ return new Response(renderSilhouettePNG(grid.side, grid.blocks), {
+ headers: {
+ "Content-Type": "image/png",
+ "Cache-Control": "public, max-age=86400, immutable"
+ }
+ });
+ }
+
+ const match = url.pathname.match(/^\/s\/([^/]+)(?:\/([^/]+))?(?:\/([^/]+))?$/);
if (!match) {
return new Response("Not found", { status: 404 });
}
@@ -89,7 +143,7 @@ export default {
});
}
- return new Response(previewPage(url.origin, token, target, match[2]), {
+ return new Response(previewPage(url.origin, token, target, [match[2], match[3]]), {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
@@ -99,8 +153,8 @@ export default {
}
};
-function previewPage(origin, token, target, encodedTitle) {
- const gameTitle = decodeTitle(encodedTitle);
+function previewPage(origin, token, target, segments) {
+ const { title: gameTitle, shape } = classifySegments(segments);
const title = gameTitle
? `Solve ‘${gameTitle}’ together on Crossmate`
: "Solve a crossword together on Crossmate";
@@ -108,6 +162,12 @@ function previewPage(origin, token, target, encodedTitle) {
"You’re invited to a collaborative crossword. " +
"Open the link to join the puzzle in Crossmate.";
+ // A valid shape gets its own rendered silhouette; otherwise the generic
+ // card. The dimension hint lets clients lay the card out before the fetch.
+ const grid = shape ? decodeSilhouette(shape) : null;
+ const imageURL = grid ? `${origin}/g/${shape}.png` : `${origin}/og.png`;
+ const imageSize = grid ? silhouettePixelSize(grid.side) : null;
+
// og:url stays the token-only canonical form so scrapers collapse links
// that differ only in their title segment onto the same card. The meta
// refresh and visible link are a fallback for anything the UA sniff
@@ -123,7 +183,9 @@ function previewPage(origin, token, target, encodedTitle) {
<meta property="og:site_name" content="Crossmate">
<meta property="og:title" content="${escapeHTML(title)}">
<meta property="og:description" content="${description}">
-<meta property="og:image" content="${origin}/og.png">
+<meta property="og:image" content="${imageURL}">${imageSize ? `
+<meta property="og:image:width" content="${imageSize}">
+<meta property="og:image:height" content="${imageSize}">` : ""}
<meta property="og:url" content="${origin}/s/${token}">
<meta name="twitter:card" content="summary">
<meta http-equiv="refresh" content="0; url=${target}">
@@ -135,11 +197,62 @@ function previewPage(origin, token, target, encodedTitle) {
`;
}
+const WEEKDAYS = [
+ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
+];
+
+// A grid-silhouette segment: an `s`/`f` tag, a base-36 side digit (2…35), then
+// the base64url bit payload. Validated structurally — including that the
+// payload carries enough bytes for the grid's bit count — so a legacy
+// base64url title is very unlikely to be mistaken for a shape. The silhouette
+// is recognised and skipped here; the dynamic preview image that renders it is
+// a later change.
+const SHAPE_SEGMENT_PATTERN = /^([sf])([0-9a-z])([A-Za-z0-9_-]+)$/;
+
+function isShapeSegment(seg) {
+ const m = SHAPE_SEGMENT_PATTERN.exec(seg);
+ if (!m) return false;
+ const side = parseInt(m[2], 36);
+ if (!(side >= 2 && side <= 35)) return false;
+ const cells = side * side;
+ const bits = m[1] === "s" ? Math.ceil(cells / 2) : cells;
+ const payloadBytes = Math.floor((m[3].length * 6) / 8);
+ return payloadBytes >= Math.ceil(bits / 8);
+}
+
+// Sorts the up-to-two decoration segments into a display title and a
+// (reserved) shape segment, classifying each by its leading character so the
+// order the app emits them in doesn't matter.
+function classifySegments(segments) {
+ let title = null;
+ let shape = null;
+ for (const seg of segments) {
+ if (!seg) continue;
+ if (shape === null && isShapeSegment(seg)) {
+ shape = seg;
+ continue;
+ }
+ if (title === null) {
+ title = decodeTitleSegment(seg);
+ }
+ }
+ return { title, shape };
+}
+
+function decodeTitleSegment(seg) {
+ if (/^[0-6]$/.test(seg)) {
+ return `${WEEKDAYS[Number(seg)]} Crossword`;
+ }
+ // A `t` prefix marks a tagged custom title; an untagged segment is a legacy
+ // bare-base64url title from before the types existed.
+ return seg[0] === "t" ? decodeBase64Title(seg.slice(1)) : decodeBase64Title(seg);
+}
+
// The decoded title lands inside HTML the worker serves, so it is treated as
// hostile input end to end: charset-checked while encoded, decoded strictly
// (an invalid UTF-8 sequence falls back to the generic preview), stripped of
// control characters, capped, and entity-escaped at the interpolation site.
-function decodeTitle(encoded) {
+function decodeBase64Title(encoded) {
if (!encoded || !TITLE_SEGMENT_PATTERN.test(encoded)) {
return null;
}
@@ -162,3 +275,173 @@ function escapeHTML(text) {
.replaceAll(">", ">")
.replaceAll('"', """);
}
+
+// MARK: - Grid silhouette
+
+// The JS counterpart of the app's `GridSilhouette` codec — decode only, since
+// the worker never mints links. Mirrors the wire format `<tag><size><payload>`:
+// `s` stores the first ⌈N/2⌉ cells (the rest mirror by 180° rotation), `f`
+// stores all N; the payload is the cell bits MSB-first as base64url.
+function decodeSilhouette(seg) {
+ if (!isShapeSegment(seg)) return null;
+ const tag = seg[0];
+ const side = parseInt(seg[1], 36);
+ const n = side * side;
+ const storedCount = tag === "s" ? Math.ceil(n / 2) : n;
+ const bytes = base64urlToBytes(seg.slice(2));
+ if (!bytes || bytes.length * 8 < storedCount) return null;
+
+ const stored = new Array(storedCount);
+ for (let i = 0; i < storedCount; i++) {
+ stored[i] = (bytes[i >> 3] & (0x80 >> (i & 7))) !== 0;
+ }
+ const blocks = new Array(n);
+ for (let k = 0; k < n; k++) {
+ blocks[k] = k < storedCount ? stored[k] : stored[n - 1 - k];
+ }
+ return { side, blocks };
+}
+
+function base64urlToBytes(s) {
+ try {
+ const base64 = s.replaceAll("-", "+").replaceAll("_", "/");
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
+ return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
+ } catch {
+ return null;
+ }
+}
+
+// Render constants: each cell is CELL px with a MARGIN-px quiet border, so the
+// image side is a pure function of the grid side (35 → 798px is the largest).
+const CELL = 22;
+const MARGIN = 14;
+const LINE = 205; // grid line gray
+const BLOCK = 0; // block square
+const BG = 255; // empty square / background
+
+function silhouettePixelSize(side) {
+ return MARGIN * 2 + side * CELL;
+}
+
+// Paints the grid into an 8-bit grayscale buffer and wraps it in a PNG.
+function renderSilhouettePNG(side, blocks) {
+ const dim = silhouettePixelSize(side);
+ const pixels = new Uint8Array(dim * dim).fill(BG);
+ const set = (x, y, v) => { pixels[y * dim + x] = v; };
+
+ // Grid lines bounding every cell.
+ const extent = side * CELL;
+ for (let i = 0; i <= side; i++) {
+ const at = MARGIN + i * CELL;
+ for (let t = 0; t <= extent; t++) {
+ set(MARGIN + t, at, LINE);
+ set(at, MARGIN + t, LINE);
+ }
+ }
+ // Block squares fill their interior solid, leaving the gray lines between.
+ for (let r = 0; r < side; r++) {
+ for (let c = 0; c < side; c++) {
+ if (!blocks[r * side + c]) continue;
+ const x0 = MARGIN + c * CELL;
+ const y0 = MARGIN + r * CELL;
+ for (let y = 1; y < CELL; y++) {
+ for (let x = 1; x < CELL; x++) set(x0 + x, y0 + y, BLOCK);
+ }
+ }
+ }
+
+ return encodeGrayscalePNG(dim, dim, pixels);
+}
+
+// MARK: - Minimal PNG encoder (8-bit grayscale, stored DEFLATE)
+
+function encodeGrayscalePNG(width, height, pixels) {
+ // Each scanline is prefixed with a filter-type byte (0 = None).
+ const raw = new Uint8Array(height * (width + 1));
+ for (let y = 0; y < height; y++) {
+ raw[y * (width + 1)] = 0;
+ raw.set(pixels.subarray(y * width, (y + 1) * width), y * (width + 1) + 1);
+ }
+
+ const ihdr = new Uint8Array(13);
+ const dv = new DataView(ihdr.buffer);
+ dv.setUint32(0, width);
+ dv.setUint32(4, height);
+ ihdr[8] = 8; // bit depth
+ ihdr[9] = 0; // color type: grayscale
+ // [10..12] compression/filter/interlace = 0
+
+ const signature = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
+ return concatBytes([
+ signature,
+ pngChunk("IHDR", ihdr),
+ pngChunk("IDAT", zlibStore(raw)),
+ pngChunk("IEND", new Uint8Array(0))
+ ]);
+}
+
+function pngChunk(type, data) {
+ const out = new Uint8Array(12 + data.length);
+ const dv = new DataView(out.buffer);
+ dv.setUint32(0, data.length);
+ for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
+ out.set(data, 8);
+ dv.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
+ return out;
+}
+
+// A zlib stream whose payload is one or more uncompressed (stored) DEFLATE
+// blocks — no compression code, just framing — terminated by the Adler-32 of
+// the raw data. Fine here: the response is small and edge-cached.
+function zlibStore(raw) {
+ const parts = [new Uint8Array([0x78, 0x01])]; // zlib header
+ for (let off = 0; off < raw.length; off += 65535) {
+ const len = Math.min(65535, raw.length - off);
+ const final = off + len >= raw.length ? 1 : 0;
+ const header = new Uint8Array(5);
+ header[0] = final; // BFINAL bit; BTYPE 00 = stored
+ header[1] = len & 0xff;
+ header[2] = (len >> 8) & 0xff;
+ const nlen = ~len & 0xffff;
+ header[3] = nlen & 0xff;
+ header[4] = (nlen >> 8) & 0xff;
+ parts.push(header, raw.subarray(off, off + len));
+ }
+ const adler = new Uint8Array(4);
+ new DataView(adler.buffer).setUint32(0, adler32(raw));
+ parts.push(adler);
+ return concatBytes(parts);
+}
+
+function crc32(bytes) {
+ let crc = 0xffffffff;
+ for (let i = 0; i < bytes.length; i++) {
+ crc ^= bytes[i];
+ for (let j = 0; j < 8; j++) {
+ crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
+ }
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+}
+
+function adler32(bytes) {
+ let a = 1;
+ let b = 0;
+ for (let i = 0; i < bytes.length; i++) {
+ a = (a + bytes[i]) % 65521;
+ b = (b + a) % 65521;
+ }
+ return ((b << 16) | a) >>> 0;
+}
+
+function concatBytes(chunks) {
+ const total = chunks.reduce((sum, c) => sum + c.length, 0);
+ const out = new Uint8Array(total);
+ let off = 0;
+ for (const c of chunks) {
+ out.set(c, off);
+ off += c.length;
+ }
+ return out;
+}
diff --git a/project.yml b/project.yml
@@ -92,6 +92,11 @@ targets:
CROSSMATE_ENGAGEMENT_SOCKET_URL: $(inherited)
CROSSMATE_PUSH_BASE_URL: $(inherited)
CROSSMATE_SHARE_LINK_BASE_URL: $(inherited)
+ # The share-link host (no scheme) for the associated-domains
+ # entitlement; set alongside CROSSMATE_SHARE_LINK_BASE_URL in
+ # Local.xcconfig. Empty on a checkout without it, which just means no
+ # universal-link handling (links still work via the worker redirect).
+ CROSSMATE_SHARE_LINK_HOST: $(inherited)
APP_ATTEST_ENVIRONMENT: production
TARGETED_DEVICE_FAMILY: "1,2"
CODE_SIGN_STYLE: Automatic