commit ed2353e55709dd5a5397b9f46c4b144bc990aeb1
parent abef15d53bbeb091d96513bc4821b8dfb5b4ede2
Author: Michael Camilleri <[email protected]>
Date: Tue, 14 Apr 2026 18:52:50 +0900
Improve support for importing XD puzzles
This commit improves the importing of XD puzzles so that users can add
their own crosswords to the app.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Diffstat:
6 files changed, 118 insertions(+), 1 deletion(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -32,6 +32,7 @@
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 */; };
+ 9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61E2582D94B1E6EC67136 /* XDFileType.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 */; };
@@ -116,6 +117,7 @@
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>"; };
+ EAC61E2582D94B1E6EC67136 /* XDFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDFileType.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>"; };
@@ -175,6 +177,7 @@
E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */,
DB851649DE78AAAC5A928C52 /* Square.swift */,
B9031A1574C21866940F6A2C /* XD.swift */,
+ EAC61E2582D94B1E6EC67136 /* XDFileType.swift */,
F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */,
);
path = Models;
@@ -417,6 +420,7 @@
F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */,
5AB52A0BA2934922EB94E5D1 /* UbiquityMonitor.swift in Sources */,
7FFEACFC672925A0968ACC1C /* XD.swift in Sources */,
+ 9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -137,6 +137,9 @@ struct RootView: View {
}
await refreshSnapshot()
}
+ .onOpenURL { url in
+ handleOpenURL(url)
+ }
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
Task {
@@ -152,6 +155,36 @@ struct RootView: View {
}
}
+ private func handleOpenURL(_ url: URL) {
+ guard url.pathExtension.lowercased() == "xd" else { return }
+
+ let needsAccess = url.startAccessingSecurityScopedResource()
+ defer {
+ if needsAccess {
+ url.stopAccessingSecurityScopedResource()
+ }
+ }
+
+ let source: String
+ do {
+ source = try String(contentsOf: url, encoding: .utf8)
+ } catch {
+ return
+ }
+
+ if ubiquityMonitor.containerAvailable {
+ try? ubiquityMonitor.importFile(from: url)
+ }
+
+ if let existing = store.findGameID(matching: source) {
+ navigationPath.append(existing)
+ return
+ }
+ if let id = try? store.createGame(from: source) {
+ navigationPath.append(id)
+ }
+ }
+
private func refreshSnapshot() async {
let snapshot = await syncEngine.diagnosticSnapshot()
syncMonitor.updateSnapshot(snapshot)
diff --git a/Crossmate/Info.plist b/Crossmate/Info.plist
@@ -6,6 +6,19 @@
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Crossmate</string>
+ <key>CFBundleDocumentTypes</key>
+ <array>
+ <dict>
+ <key>CFBundleTypeName</key>
+ <string>Crossmate Puzzle</string>
+ <key>LSHandlerRank</key>
+ <string>Owner</string>
+ <key>LSItemContentTypes</key>
+ <array>
+ <string>net.inqk.crossmate.xd</string>
+ </array>
+ </dict>
+ </array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -48,5 +61,25 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
+ <key>UTImportedTypeDeclarations</key>
+ <array>
+ <dict>
+ <key>UTTypeConformsTo</key>
+ <array>
+ <string>public.plain-text</string>
+ </array>
+ <key>UTTypeDescription</key>
+ <string>Crossmate Puzzle</string>
+ <key>UTTypeIdentifier</key>
+ <string>net.inqk.crossmate.xd</string>
+ <key>UTTypeTagSpecification</key>
+ <dict>
+ <key>public.filename-extension</key>
+ <array>
+ <string>xd</string>
+ </array>
+ </dict>
+ </dict>
+ </array>
</dict>
</plist>
diff --git a/Crossmate/Models/XDFileType.swift b/Crossmate/Models/XDFileType.swift
@@ -0,0 +1,5 @@
+import UniformTypeIdentifiers
+
+extension UTType {
+ static let xdPuzzle = UTType(importedAs: "net.inqk.crossmate.xd")
+}
diff --git a/Crossmate/Views/ImportedBrowseView.swift b/Crossmate/Views/ImportedBrowseView.swift
@@ -1,10 +1,12 @@
import SwiftUI
+import UniformTypeIdentifiers
struct ImportedBrowseView: View {
let onSelected: (String) -> Void
@Environment(UbiquityMonitor.self) private var monitor
@State private var errorMessage: String?
+ @State private var showingImporter = false
var body: some View {
Group {
@@ -24,10 +26,27 @@ struct ImportedBrowseView: View {
ContentUnavailableView {
Label("No Imported Puzzles", systemImage: "folder")
} description: {
- Text("Add .xd files to the Crossmate folder in Files to import them.")
+ Text("Add .xd files to the Crossmate folder in Files, or tap the import button to bring one in.")
}
}
}
+ .toolbar {
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ showingImporter = true
+ } label: {
+ Label("Import", systemImage: "square.and.arrow.down")
+ }
+ .disabled(!monitor.containerAvailable)
+ }
+ }
+ .fileImporter(
+ isPresented: $showingImporter,
+ allowedContentTypes: [.xdPuzzle],
+ allowsMultipleSelection: false
+ ) { result in
+ handleImport(result)
+ }
.alert(
"Couldn't Open Puzzle",
isPresented: .init(
@@ -42,6 +61,16 @@ struct ImportedBrowseView: View {
}
}
+ private func handleImport(_ result: Result<[URL], Error>) {
+ do {
+ let urls = try result.get()
+ guard let url = urls.first else { return }
+ try monitor.importFile(from: url)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
private func open(_ item: UbiquityItem) {
if !item.isDownloaded {
monitor.startDownloading(item)
diff --git a/project.yml b/project.yml
@@ -40,6 +40,19 @@ targets:
NSUbiquitousContainerIsDocumentScopePublic: true
NSUbiquitousContainerName: Crossmate
NSUbiquitousContainerSupportedFolderLevels: Any
+ UTImportedTypeDeclarations:
+ - UTTypeIdentifier: net.inqk.crossmate.xd
+ UTTypeDescription: Crossmate Puzzle
+ UTTypeConformsTo:
+ - public.plain-text
+ UTTypeTagSpecification:
+ public.filename-extension:
+ - xd
+ CFBundleDocumentTypes:
+ - CFBundleTypeName: Crossmate Puzzle
+ LSHandlerRank: Owner
+ LSItemContentTypes:
+ - net.inqk.crossmate.xd
UILaunchScreen: {}
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait