commit 9a502109ec7b85b10a204393b7ee4dedd46cbe2a parent 642d5ecccb5b231031cce6986b55a095756c2dbc Author: Michael Camilleri <[email protected]> Date: Sun, 12 Apr 2026 16:52:28 +0900 Add support for additional puzzle sources This commit adds support for additional puzzle sources. This version is intentionally incomplete but is being checked in for use with TestFlight. Co-Authored-By: Claude Opus 4.6 <[email protected]> Diffstat:
17 files changed, 675 insertions(+), 10 deletions(-)
diff --git a/Crossmate.sketch b/Crossmate.sketch Binary files differ. diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; }; 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; }; + 5AB52A0BA2934922EB94E5D1 /* UbiquityMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9876B49C8BE966CC2A37BF52 /* UbiquityMonitor.swift */; }; 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; }; 765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; }; 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; }; @@ -25,9 +26,12 @@ 818B1F2693962832BE14578E /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */; }; 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */; }; 83639982D028AA8459BE748F /* PendingChangeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F9D7D0F3C61B2D6B8DAF0C5 /* PendingChangeTests.swift */; }; + 8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */; }; 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; }; + 9789150602A3321D2E1E7E81 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0BF60C84D92A9024AC1A53FC /* Media.xcassets */; }; 97D77230A98330DCB757FA81 /* sample.xd in Resources */ = {isa = PBXBuildFile; fileRef = 5C63A148D98E2D37EABF2CF5 /* sample.xd */; }; 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; }; + AA28425BD26F72A9E2B58742 /* BundledBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */; }; AA992F67F509EC8EFDDFC7CB /* morning.xd in Resources */ = {isa = PBXBuildFile; fileRef = 0B73A791FD061430AE286E11 /* morning.xd */; }; AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB851649DE78AAAC5A928C52 /* Square.swift */; }; B42454D72FAA219D60DEA334 /* garden.xd in Resources */ = {isa = PBXBuildFile; fileRef = 50992CDA4082429EBB17F65C /* garden.xd */; }; @@ -42,9 +46,11 @@ D66C1A4FDEA5E912E00FB742 /* PendingChange+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = E524780E360E008FACE4F213 /* PendingChange+Helpers.swift */; }; DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */; }; DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */; }; + E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */; }; ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */; }; F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */; }; F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */; }; + FFBE2EC8A3A60E119A0D314F /* NYTBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,8 +66,10 @@ /* 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>"; }; + 0BF60C84D92A9024AC1A53FC /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; }; 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; }; 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossmateApp.swift; sourceTree = "<group>"; }; + 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; }; 20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; }; 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; }; 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<group>"; }; @@ -77,10 +85,13 @@ 7B3E1A382B24A7803701D947 /* Crossmate.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Crossmate.entitlements; sourceTree = "<group>"; }; 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>"; }; 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>"; }; + 9876B49C8BE966CC2A37BF52 /* UbiquityMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UbiquityMonitor.swift; sourceTree = "<group>"; }; 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncState+Helpers.swift"; sourceTree = "<group>"; }; + 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledBrowseView.swift; sourceTree = "<group>"; }; A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; }; ACC295195602B3DDF7BB3895 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; }; AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleView.swift; sourceTree = "<group>"; }; @@ -97,6 +108,7 @@ DB851649DE78AAAC5A928C52 /* Square.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Square.swift; sourceTree = "<group>"; }; E512D95B4518EE3DE6E350C0 /* PendingChangePayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangePayload.swift; sourceTree = "<group>"; }; E524780E360E008FACE4F213 /* PendingChange+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PendingChange+Helpers.swift"; sourceTree = "<group>"; }; + E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSource.swift; sourceTree = "<group>"; }; F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGameSheet.swift; sourceTree = "<group>"; }; F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; }; F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; }; @@ -152,6 +164,7 @@ 20B331CC55827FEF3420ABCE /* PlayerSession.swift */, 64C8064F04FC6177D987ACA2 /* Puzzle.swift */, 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */, + E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */, DB851649DE78AAAC5A928C52 /* Square.swift */, B9031A1574C21866940F6A2C /* XD.swift */, F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */, @@ -176,6 +189,7 @@ 7B3E1A382B24A7803701D947 /* Crossmate.entitlements */, 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */, 9447F0FE34C63810C6F1D8BE /* Info.plist */, + 0BF60C84D92A9024AC1A53FC /* Media.xcassets */, 41DB2417FF67A47FE6890256 /* Models */, 565DBAFC8DB2589B3F0AF90E /* Persistence */, F53443E4827221C62DB7AA36 /* Resources */, @@ -189,12 +203,15 @@ 84445EA9CACB6AAAEDE6965F /* Views */ = { isa = PBXGroup; children = ( + 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */, F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */, 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */, D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */, CAB4BB9E160C3A59C653E7A9 /* GridView.swift */, + 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */, 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */, F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */, + 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */, 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */, AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */, 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */, @@ -219,6 +236,7 @@ A253416F4FEA271A80B22A73 /* NYTAuthService.swift */, B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */, BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */, + 9876B49C8BE966CC2A37BF52 /* UbiquityMonitor.swift */, ); path = Services; sourceTree = "<group>"; @@ -316,6 +334,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 9789150602A3321D2E1E7E81 /* Media.xcassets in Resources */, B42454D72FAA219D60DEA334 /* garden.xd in Resources */, AA992F67F509EC8EFDDFC7CB /* morning.xd in Resources */, 97D77230A98330DCB757FA81 /* sample.xd in Resources */, @@ -340,6 +359,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AA28425BD26F72A9E2B58742 /* BundledBrowseView.swift in Sources */, 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */, CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */, DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */, @@ -350,9 +370,11 @@ D58980B92C99122C368D4216 /* GameStore.swift in Sources */, C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */, 765B50552B13175F91A25EA1 /* GridView.swift in Sources */, + 8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */, F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */, 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */, 1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */, + FFBE2EC8A3A60E119A0D314F /* NYTBrowseView.swift in Sources */, D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */, 0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */, B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */, @@ -364,12 +386,14 @@ 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */, 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */, 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */, + E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */, 2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */, CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */, 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */, AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */, 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */, F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */, + 5AB52A0BA2934922EB94E5D1 /* UbiquityMonitor.swift in Sources */, 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Crossmate/Crossmate.entitlements b/Crossmate/Crossmate.entitlements @@ -11,6 +11,11 @@ <key>com.apple.developer.icloud-services</key> <array> <string>CloudKit</string> + <string>CloudDocuments</string> + </array> + <key>com.apple.developer.ubiquity-container-identifiers</key> + <array> + <string>iCloud.net.inqk.crossmate</string> </array> </dict> </plist> diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -8,6 +8,7 @@ struct CrossmateApp: App { @State private var store: GameStore @State private var syncEngine: SyncEngine @State private var nytAuth = NYTAuthService() + @State private var ubiquityMonitor = UbiquityMonitor() private let persistence: PersistenceController init() { @@ -26,6 +27,7 @@ struct CrossmateApp: App { WindowGroup { RootView(store: store, syncEngine: syncEngine, appDelegate: appDelegate) .environment(nytAuth) + .environment(ubiquityMonitor) } } } @@ -60,6 +62,7 @@ struct RootView: View { let appDelegate: AppDelegate @Environment(NYTAuthService.self) private var nytAuth + @Environment(UbiquityMonitor.self) private var ubiquityMonitor @Environment(\.scenePhase) private var scenePhase @State private var syncBootstrapped = false @State private var lastVisitedGameID: UUID? @@ -90,6 +93,7 @@ struct RootView: View { syncBootstrapped = true nytAuth.loadStoredSession() + ubiquityMonitor.start() // Wire app delegate → sync engine fetch appDelegate.onRemoteNotification = { [syncEngine] in diff --git a/Crossmate/Info.plist b/Crossmate/Info.plist @@ -24,6 +24,18 @@ <false/> <key>LSRequiresIPhoneOS</key> <true/> + <key>NSUbiquitousContainers</key> + <dict> + <key>iCloud.net.inqk.crossmate</key> + <dict> + <key>NSUbiquitousContainerIsDocumentScopePublic</key> + <true/> + <key>NSUbiquitousContainerName</key> + <string>Crossmate</string> + <key>NSUbiquitousContainerSupportedFolderLevels</key> + <string>Any</string> + </dict> + </dict> <key>UIBackgroundModes</key> <array> <string>remote-notification</string> diff --git a/Crossmate/Media.xcassets/AppIcon.appiconset/Contents.json b/Crossmate/Media.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "Icon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Crossmate/Media.xcassets/AppIcon.appiconset/Icon.png b/Crossmate/Media.xcassets/AppIcon.appiconset/Icon.png Binary files differ. diff --git a/Crossmate/Media.xcassets/Contents.json b/Crossmate/Media.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Crossmate/Models/PuzzleSource.swift b/Crossmate/Models/PuzzleSource.swift @@ -0,0 +1,17 @@ +import Foundation + +enum PuzzleSource: String, CaseIterable, Identifiable { + case bundled + case imported + case nyt + + var id: String { rawValue } + + var title: String { + switch self { + case .bundled: "Bundled" + case .imported: "Imported" + case .nyt: "NYT" + } + } +} diff --git a/Crossmate/Services/UbiquityMonitor.swift b/Crossmate/Services/UbiquityMonitor.swift @@ -0,0 +1,265 @@ +import Foundation +import Observation + +struct UbiquityItem: Identifiable, Hashable { + let id: URL + let name: String + let url: URL + let isDirectory: Bool + let isDownloaded: Bool + let children: [UbiquityItem] + + var fileExtension: String { url.pathExtension.lowercased() } +} + +enum UbiquityError: LocalizedError { + case containerUnavailable + case readFailed(URL, Error) + case importFailed(URL, Error) + + var errorDescription: String? { + switch self { + case .containerUnavailable: + "iCloud Drive is not available. Make sure you're signed in to iCloud and iCloud Drive is enabled." + case .readFailed(let url, let error): + "Couldn't read \(url.lastPathComponent): \(error.localizedDescription)" + case .importFailed(let url, let error): + "Couldn't import \(url.lastPathComponent): \(error.localizedDescription)" + } + } +} + +@MainActor +@Observable +final class UbiquityMonitor { + private(set) var root: UbiquityItem? + private(set) var containerAvailable: Bool = false + + private let containerID = "iCloud.net.inqk.crossmate" + private var documentsURL: URL? + private let query = NSMetadataQuery() + private var observers: [NSObjectProtocol] = [] + + init() { + resolveContainer() + } + + func start() { + guard containerAvailable, !query.isStarted else { return } + + query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope] + query.predicate = NSPredicate(format: "%K LIKE[c] %@", NSMetadataItemFSNameKey, "*.xd") + query.sortDescriptors = [NSSortDescriptor(key: NSMetadataItemFSNameKey, ascending: true)] + + let center = NotificationCenter.default + let handler: @Sendable (Notification) -> Void = { [weak self] _ in + Task { @MainActor in + self?.rebuildTree() + } + } + + observers.append(center.addObserver( + forName: .NSMetadataQueryDidFinishGathering, + object: query, + queue: .main, + using: handler + )) + observers.append(center.addObserver( + forName: .NSMetadataQueryDidUpdate, + object: query, + queue: .main, + using: handler + )) + + query.start() + } + + func startDownloading(_ item: UbiquityItem) { + guard !item.isDownloaded else { return } + try? FileManager.default.startDownloadingUbiquitousItem(at: item.url) + } + + func readSource(at url: URL) throws -> String { + do { + let coordinator = NSFileCoordinator() + var readError: NSError? + var result: String? + var innerError: Error? + + coordinator.coordinate(readingItemAt: url, options: [], error: &readError) { readURL in + do { + result = try String(contentsOf: readURL, encoding: .utf8) + } catch { + innerError = error + } + } + + if let readError { throw readError } + if let innerError { throw innerError } + guard let result else { throw CocoaError(.fileReadUnknown) } + return result + } catch { + throw UbiquityError.readFailed(url, error) + } + } + + func importFile(from sourceURL: URL) throws { + guard let documentsURL else { throw UbiquityError.containerUnavailable } + + let needsScopedAccess = sourceURL.startAccessingSecurityScopedResource() + defer { + if needsScopedAccess { + sourceURL.stopAccessingSecurityScopedResource() + } + } + + let destination = uniqueDestination( + for: sourceURL.lastPathComponent, + in: documentsURL + ) + + do { + let coordinator = NSFileCoordinator() + var coordError: NSError? + var innerError: Error? + + coordinator.coordinate( + readingItemAt: sourceURL, + options: [.withoutChanges], + writingItemAt: destination, + options: [.forReplacing], + error: &coordError + ) { readURL, writeURL in + do { + try FileManager.default.copyItem(at: readURL, to: writeURL) + } catch { + innerError = error + } + } + + if let coordError { throw coordError } + if let innerError { throw innerError } + } catch { + throw UbiquityError.importFailed(sourceURL, error) + } + } + + // MARK: - Private + + private func resolveContainer() { + guard let container = FileManager.default.url(forUbiquityContainerIdentifier: containerID) else { + containerAvailable = false + return + } + let documents = container.appendingPathComponent("Documents", isDirectory: true) + if !FileManager.default.fileExists(atPath: documents.path) { + try? FileManager.default.createDirectory( + at: documents, + withIntermediateDirectories: true + ) + } + self.documentsURL = documents + self.containerAvailable = true + } + + private func uniqueDestination(for filename: String, in directory: URL) -> URL { + let candidate = directory.appendingPathComponent(filename) + if !FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + let base = (filename as NSString).deletingPathExtension + let ext = (filename as NSString).pathExtension + var index = 2 + while true { + let nextName = ext.isEmpty ? "\(base) \(index)" : "\(base) \(index).\(ext)" + let next = directory.appendingPathComponent(nextName) + if !FileManager.default.fileExists(atPath: next.path) { + return next + } + index += 1 + } + } + + private func rebuildTree() { + guard let documentsURL else { return } + + query.disableUpdates() + defer { query.enableUpdates() } + + var files: [(url: URL, downloaded: Bool)] = [] + for i in 0..<query.resultCount { + guard let item = query.result(at: i) as? NSMetadataItem, + let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL + else { continue } + let status = item.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String + let downloaded = status == NSMetadataUbiquitousItemDownloadingStatusCurrent + || status == NSMetadataUbiquitousItemDownloadingStatusDownloaded + files.append((url: url.standardizedFileURL, downloaded: downloaded)) + } + + self.root = buildTree(documentsURL: documentsURL.standardizedFileURL, files: files) + } + + private func buildTree(documentsURL: URL, files: [(url: URL, downloaded: Bool)]) -> UbiquityItem { + let docComponents = documentsURL.pathComponents + + final class Node { + var children: [String: Node] = [:] + var files: [(url: URL, downloaded: Bool)] = [] + } + + let root = Node() + for file in files { + let components = file.url.pathComponents + guard components.count > docComponents.count, + Array(components.prefix(docComponents.count)) == docComponents + else { continue } + let dirs = components.dropFirst(docComponents.count).dropLast() + + var current = root + for dir in dirs { + if let next = current.children[dir] { + current = next + } else { + let next = Node() + current.children[dir] = next + current = next + } + } + current.files.append(file) + } + + func materialize(node: Node, url: URL, name: String) -> UbiquityItem { + let folderChildren: [UbiquityItem] = node.children + .sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending } + .map { key, childNode in + let childURL = url.appendingPathComponent(key, isDirectory: true) + return materialize(node: childNode, url: childURL, name: key) + } + let fileChildren: [UbiquityItem] = node.files + .sorted { + $0.url.lastPathComponent.localizedCaseInsensitiveCompare($1.url.lastPathComponent) == .orderedAscending + } + .map { file in + UbiquityItem( + id: file.url, + name: file.url.deletingPathExtension().lastPathComponent, + url: file.url, + isDirectory: false, + isDownloaded: file.downloaded, + children: [] + ) + } + return UbiquityItem( + id: url, + name: name, + url: url, + isDirectory: true, + isDownloaded: true, + children: folderChildren + fileChildren + ) + } + + return materialize(node: root, url: documentsURL, name: documentsURL.lastPathComponent) + } +} diff --git a/Crossmate/Views/BundledBrowseView.swift b/Crossmate/Views/BundledBrowseView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct BundledBrowseView: View { + let store: GameStore + let onCreated: (UUID) -> Void + + private var puzzles: [PuzzleCatalog.Entry] { + PuzzleCatalog.bundledPuzzles() + } + + var body: some View { + List(puzzles) { entry in + Button { + if let id = try? store.createGame(from: entry.source) { + onCreated(id) + } + } label: { + Text(entry.title) + .foregroundStyle(.primary) + } + } + } +} diff --git a/Crossmate/Views/ImportedBrowseView.swift b/Crossmate/Views/ImportedBrowseView.swift @@ -0,0 +1,94 @@ +import SwiftUI + +struct ImportedBrowseView: View { + let store: GameStore + let onCreated: (UUID) -> Void + + @Environment(UbiquityMonitor.self) private var monitor + @State private var errorMessage: String? + + var body: some View { + Group { + if !monitor.containerAvailable { + ContentUnavailableView { + Label("iCloud Drive Unavailable", systemImage: "icloud.slash") + } description: { + Text("Sign in to iCloud and enable iCloud Drive to import puzzles.") + } + } else if let root = monitor.root, !root.children.isEmpty { + List { + ForEach(root.children) { item in + UbiquityItemRow(item: item, onOpen: open) + } + } + } else { + ContentUnavailableView { + Label("No Imported Puzzles", systemImage: "folder") + } description: { + Text("Add .xd files to the Crossmate folder in Files to import them.") + } + } + } + .alert( + "Couldn't Open Puzzle", + isPresented: .init( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + ), + presenting: errorMessage + ) { _ in + Button("OK", role: .cancel) {} + } message: { message in + Text(message) + } + } + + private func open(_ item: UbiquityItem) { + if !item.isDownloaded { + monitor.startDownloading(item) + errorMessage = "This puzzle is still downloading from iCloud. Try again in a moment." + return + } + do { + let source = try monitor.readSource(at: item.url) + if let id = try? store.createGame(from: source) { + onCreated(id) + } else { + errorMessage = "The selected file isn't a valid .xd puzzle." + } + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct UbiquityItemRow: View { + let item: UbiquityItem + let onOpen: (UbiquityItem) -> Void + + var body: some View { + if item.isDirectory { + DisclosureGroup { + ForEach(item.children) { child in + UbiquityItemRow(item: child, onOpen: onOpen) + } + } label: { + Label(item.name, systemImage: "folder") + } + } else { + Button { + onOpen(item) + } label: { + HStack { + Label(item.name, systemImage: "doc.text") + .foregroundStyle(.primary) + Spacer() + if !item.isDownloaded { + Image(systemName: "icloud.and.arrow.down") + .foregroundStyle(.secondary) + } + } + } + } + } +} diff --git a/Crossmate/Views/NYTBrowseView.swift b/Crossmate/Views/NYTBrowseView.swift @@ -0,0 +1,14 @@ +import SwiftUI + +struct NYTBrowseView: View { + let store: GameStore + let onCreated: (UUID) -> Void + + var body: some View { + ContentUnavailableView { + Label("NYT Crosswords", systemImage: "calendar") + } description: { + Text("The NYT calendar browser will appear here.") + } + } +} diff --git a/Crossmate/Views/NewGameSheet.swift b/Crossmate/Views/NewGameSheet.swift @@ -5,22 +5,34 @@ struct NewGameSheet: View { let onCreated: (UUID) -> Void @Environment(\.dismiss) private var dismiss + @Environment(NYTAuthService.self) private var nytAuth + @AppStorage("lastPuzzleSource") private var storedSource: PuzzleSource = .bundled + @State private var selection: PuzzleSource = .bundled - private var puzzles: [PuzzleCatalog.Entry] { - PuzzleCatalog.bundledPuzzles() + private var availableSources: [PuzzleSource] { + nytAuth.isSignedIn ? [.bundled, .imported, .nyt] : [.bundled, .imported] } var body: some View { NavigationStack { - List(puzzles) { entry in - Button { - if let id = try? store.createGame(from: entry.source) { - onCreated(id) - dismiss() + VStack(spacing: 0) { + Picker("Source", selection: $selection) { + ForEach(availableSources) { source in + Text(source.title).tag(source) + } + } + .pickerStyle(.segmented) + .padding() + + Group { + switch selection { + case .bundled: + BundledBrowseView(store: store, onCreated: handleCreated) + case .imported: + ImportedBrowseView(store: store, onCreated: handleCreated) + case .nyt: + NYTBrowseView(store: store, onCreated: handleCreated) } - } label: { - Text(entry.title) - .foregroundStyle(.primary) } } .navigationTitle("New Puzzle") @@ -31,5 +43,16 @@ struct NewGameSheet: View { } } } + .onAppear { + selection = availableSources.contains(storedSource) ? storedSource : .bundled + } + .onChange(of: selection) { _, newValue in + storedSource = newValue + } + } + + private func handleCreated(_ id: UUID) { + onCreated(id) + dismiss() } } diff --git a/Scripts/publish-ios.sh b/Scripts/publish-ios.sh @@ -0,0 +1,127 @@ +#!/bin/bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SECRETS="$REPO_ROOT/.asc/secrets.sh" + +if [[ ! -f "$SECRETS" ]]; then + echo "Error: $SECRETS not found. See Scripts/secrets.sh.example for the required format." + exit 1 +fi + +source "$SECRETS" + +SCHEME="Crossmate" +ARCHIVE_PATH="/tmp/Crossmate-latest.xcarchive" +EXPORT_PATH="/tmp/Crossmate-export" +EXPORT_PLIST="/tmp/Crossmate-ExportOptions.plist" +IPA_PATH="$EXPORT_PATH/Crossmate.ipa" +DEV_P12="$REPO_ROOT/.asc/dev.p12" +DIST_P12="$REPO_ROOT/.asc/ios-signing/dist-headless.p12" +TMP_KEYCHAIN="$REPO_ROOT/.asc/build.keychain-db" + +CHECK_ONLY=false +if [[ "${1:-}" == "--check" ]]; then + CHECK_ONLY=true +fi + +cd "$REPO_ROOT" + +if ! git diff --quiet || ! git diff --cached --quiet; then + echo "Error: Git repository is dirty. Commit or stash changes before publishing." + exit 1 +fi + +echo "==> Setting up temporary keychain..." +security delete-keychain "$TMP_KEYCHAIN" 2>/dev/null || true +security create-keychain -p "$TMP_KEYCHAIN_PASS" "$TMP_KEYCHAIN" +security unlock-keychain -p "$TMP_KEYCHAIN_PASS" "$TMP_KEYCHAIN" +security set-keychain-settings -lut 21600 "$TMP_KEYCHAIN" +security import "$DEV_P12" -k "$TMP_KEYCHAIN" -P "$DEV_P12_PASS" \ + -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild +security import "$DIST_P12" -k "$TMP_KEYCHAIN" -P "$DIST_P12_PASS" \ + -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild +security set-key-partition-list -S apple-tool:,apple:,codesign:,productbuild: \ + -s -k "$TMP_KEYCHAIN_PASS" "$TMP_KEYCHAIN" +security list-keychains -d user -s "$TMP_KEYCHAIN" ~/Library/Keychains/login.keychain-db + +cleanup_keychain() { + echo "==> Restoring keychain search list..." + security list-keychains -d user -s ~/Library/Keychains/login.keychain-db + security default-keychain -d user -s ~/Library/Keychains/login.keychain-db + security delete-keychain "$TMP_KEYCHAIN" 2>/dev/null || true +} +trap cleanup_keychain EXIT + +echo "==> Archiving $SCHEME..." +xcodebuild \ + -scheme "$SCHEME" \ + -project Crossmate.xcodeproj \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath "$ARCHIVE_PATH" \ + archive + +echo "==> Writing export options..." +cat > "$EXPORT_PLIST" <<PLIST +<?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>method</key> + <string>app-store-connect</string> + <key>signingStyle</key> + <string>manual</string> + <key>teamID</key> + <string>$TEAM_ID</string> + <key>signingCertificate</key> + <string>iPhone Distribution</string> + <key>provisioningProfiles</key> + <dict> + <key>net.inqk.crossmate</key> + <string>Crossmate iOS Distribution</string> + </dict> + <key>destination</key> + <string>export</string> + <key>stripSwiftSymbols</key> + <true/> + <key>manageAppVersionAndBuildNumber</key> + <false/> +</dict> +</plist> +PLIST + +echo "==> Exporting IPA..." +xcodebuild \ + -exportArchive \ + -archivePath "$ARCHIVE_PATH" \ + -exportPath "$EXPORT_PATH" \ + -exportOptionsPlist "$EXPORT_PLIST" + +echo "==> Checking entitlements in exported IPA..." +CHECK_DIR="/tmp/Crossmate-ipa-check" +rm -rf "$CHECK_DIR" +unzip -q "$IPA_PATH" -d "$CHECK_DIR" +echo "--- iOS app entitlements ---" +codesign -d --entitlements - "$CHECK_DIR/Payload/Crossmate.app" +rm -rf "$CHECK_DIR" + +if $CHECK_ONLY; then + echo "==> Check complete. Skipping upload." + exit 0 +fi + +echo "==> Uploading to App Store Connect..." +mkdir -p "$REPO_ROOT/private_keys" +cp "$REPO_ROOT/.asc/AuthKey_${KEY_ID}.p8" "$REPO_ROOT/private_keys/" +xcrun iTMSTransporter \ + -m upload \ + -assetFile "$IPA_PATH" \ + -apiKey "$KEY_ID" \ + -apiIssuer "$ISSUER_ID" \ + -v informational +rm -f "$REPO_ROOT/private_keys/AuthKey_${KEY_ID}.p8" +rmdir "$REPO_ROOT/private_keys" 2>/dev/null || true + +echo "==> Done!" diff --git a/Scripts/secrets.sh.example b/Scripts/secrets.sh.example @@ -0,0 +1,10 @@ +#!/bin/bash +# Secrets and signing configuration — kept out of version control. +# Copy this file to .asc/secrets.sh and fill in the values. + +TEAM_ID="your-team-id" +KEY_ID="your-api-key-id" +ISSUER_ID="your-issuer-id" +DEV_P12_PASS="your-dev-p12-password" +DIST_P12_PASS="your-dist-p12-password" +TMP_KEYCHAIN_PASS="your-tmp-keychain-password" diff --git a/project.yml b/project.yml @@ -31,6 +31,11 @@ targets: LSRequiresIPhoneOS: true UIBackgroundModes: - remote-notification + NSUbiquitousContainers: + iCloud.net.inqk.crossmate: + NSUbiquitousContainerIsDocumentScopePublic: true + NSUbiquitousContainerName: Crossmate + NSUbiquitousContainerSupportedFolderLevels: Any UILaunchScreen: {} UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait