commit a6f2a3c58b2ec66bfc1e550ba9afb40741aac504
parent eb13c77f1af8ad246309c67e8bd1c513045b4822
Author: Michael Camilleri <[email protected]>
Date: Mon, 6 Jul 2026 08:33:06 +0900
Add machinery for generating marketing images
Diffstat:
11 files changed, 832 insertions(+), 177 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -10,5 +10,7 @@ CLAUDE.md
cookies.txt
Generated/
examples/
+Marketing/
private_keys/
symlinks/
+testflight.txt
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -128,6 +128,7 @@
8AE376C0726116082B15241D /* ShareLinkRouteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5990D989AD745211A18848E4 /* ShareLinkRouteTests.swift */; };
8B356C953DA0FAF149C3391A /* Puzzles in Resources */ = {isa = PBXBuildFile; fileRef = BA67C509B467132D1B7510A4 /* Puzzles */; };
8D8A9F70731C98DD00BE1DA5 /* Layouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836B8D4B351C9225162A82C0 /* Layouts.swift */; };
+ 8ECA254A8BB852BF4B9CE6D7 /* MarketingScreenshots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBEE60E9D850739CA3D01AA /* MarketingScreenshots.swift */; };
8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; };
8FEE38B2B540F0E91560747F /* PushPayloadCipher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DE10D2A5AFBAC73469BD33 /* PushPayloadCipher.swift */; };
903681985C17FCB5F97773A9 /* OpenPuzzleBannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA0FC750259EB1D762B0EE /* OpenPuzzleBannerTests.swift */; };
@@ -158,7 +159,6 @@
A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */; };
AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */; };
AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */; };
- AB6D98C7A78D91D7BEFB4A4C /* MarketingPuzzleScreenshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AEFE2C7623A899BAABD85F4 /* MarketingPuzzleScreenshotView.swift */; };
ADBEAD1C0139BCF864CA8A1D /* PuzzleCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89528AF69DE06C61FA8E91A1 /* PuzzleCommands.swift */; };
AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */; };
AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB851649DE78AAAC5A928C52 /* Square.swift */; };
@@ -341,6 +341,7 @@
5990D989AD745211A18848E4 /* ShareLinkRouteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLinkRouteTests.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>"; };
+ 5CBEE60E9D850739CA3D01AA /* MarketingScreenshots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketingScreenshots.swift; sourceTree = "<group>"; };
5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisherTests.swift; sourceTree = "<group>"; };
603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayCacheTests.swift; sourceTree = "<group>"; };
605CA0FC7AF069CE3A3B38C1 /* RecentChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentChanges.swift; sourceTree = "<group>"; };
@@ -383,7 +384,6 @@
89528AF69DE06C61FA8E91A1 /* PuzzleCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCommands.swift; sourceTree = "<group>"; };
89B1FFD2F90141EA949A8540 /* JournalReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalReplayTests.swift; sourceTree = "<group>"; };
8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushClient.swift; sourceTree = "<group>"; };
- 8AEFE2C7623A899BAABD85F4 /* MarketingPuzzleScreenshotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketingPuzzleScreenshotView.swift; sourceTree = "<group>"; };
8C040D5EBC73B1ED47C2C9D4 /* SessionPushPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPushPlannerTests.swift; sourceTree = "<group>"; };
8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCursorStore.swift; sourceTree = "<group>"; };
8D4A76B233E16B7C5A248EB7 /* TipStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipStore.swift; sourceTree = "<group>"; };
@@ -688,7 +688,6 @@
7B3E1A382B24A7803701D947 /* Crossmate.entitlements */,
14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */,
9447F0FE34C63810C6F1D8BE /* Info.plist */,
- 8AEFE2C7623A899BAABD85F4 /* MarketingPuzzleScreenshotView.swift */,
0BF60C84D92A9024AC1A53FC /* Media.xcassets */,
41DB2417FF67A47FE6890256 /* Models */,
565DBAFC8DB2589B3F0AF90E /* Persistence */,
@@ -710,6 +709,7 @@
84445EA9CACB6AAAEDE6965F /* Views */ = {
isa = PBXGroup;
children = (
+ 5CBEE60E9D850739CA3D01AA /* MarketingScreenshots.swift */,
AB1863007AA59F4769675599 /* Browse */,
99CEFF0AB23C2B9329A9F8B2 /* Components */,
486B6F0E3B86BC2765CCEC33 /* Friends */,
@@ -1158,7 +1158,7 @@
CF1DC343A5D3110EDFA703AB /* LastUpdatedView.swift in Sources */,
8D8A9F70731C98DD00BE1DA5 /* Layouts.swift in Sources */,
6BD5C5B6BED06651312E2D01 /* MailComposeView.swift in Sources */,
- AB6D98C7A78D91D7BEFB4A4C /* MarketingPuzzleScreenshotView.swift in Sources */,
+ 8ECA254A8BB852BF4B9CE6D7 /* MarketingScreenshots.swift in Sources */,
91703E54DB4679C1911BF994 /* Moves.swift in Sources */,
4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */,
1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -14,86 +14,94 @@ struct CrossmateApp: App {
var body: some Scene {
WindowGroup {
+ #if DEBUG
if ProcessInfo.processInfo.arguments.contains("--crossmate-marketing-screenshot") {
- MarketingPuzzleScreenshotView(services: services)
+ MarketingScreenshotView(services: services)
.environment(services.preferences)
.environment(services.inputMonitor)
.environment(services.announcements)
.environment(\.engagementStatus, services.engagementStatus)
} else {
- RootView(
- services: services,
- appDelegate: appDelegate
- )
- .environment(\.managedObjectContext, services.persistence.viewContext)
- .environment(services.driveMonitor)
- .environment(services.inputMonitor)
- .environment(services.announcements)
- .environment(services.tips)
- .environment(services.syncMonitor)
- .environment(services.eventLog)
- .environment(\.syncEngine, services.syncEngine)
- .environment(\.engagementStatus, services.engagementStatus)
- .environment(services.nytAuth)
- .environment(\.nytPuzzleFetcher, services.nytFetcher)
- .environment(\.resetDatabase, {
- do {
- try await services.cloudService.resetAllData()
- } catch {
- services.announcements.post(Announcement(
- id: "reset-database-error",
- scope: .global,
- severity: .error,
- title: "Resetting Failed",
- body: error.localizedDescription,
- dismissal: .manual
- ))
- }
- })
- .environment(\.inviteFriend, { gameID, friendAuthorID in
- try await services.invites.inviteFriend(gameID: gameID, friendAuthorID: friendAuthorID)
- })
- .environment(\.acceptInvite, { shareURL, pingRecordName in
- _ = try await services.invites.acceptInvite(
- shareURL: shareURL,
- pingRecordName: pingRecordName
- )
- })
- .environment(\.declineInvite, { gameID in
- try await services.invites.declineInvite(gameID: gameID)
- })
- .environment(\.blockFriend, { friendAuthorID in
- await services.invites.blockFriend(authorID: friendAuthorID)
- })
- .environment(\.unblockFriend, { friendAuthorID in
- await services.invites.unblockFriend(authorID: friendAuthorID)
- })
- .environment(\.renameFriend, { friendAuthorID, nickname in
- do {
- try await services.friendController.setNickname(
- friendAuthorID: friendAuthorID,
- nickname: nickname
- )
- } catch {
- services.announcements.post(Announcement(
- id: "rename-friend-error-\(friendAuthorID)",
- scope: .global,
- severity: .error,
- title: "Renaming Failed",
- body: error.localizedDescription,
- dismissal: .manual
- ))
- }
- })
- .environment(\.sendResignPings, { gameID in
- await services.sessions.sendCompletionPings(gameID: gameID, resigned: true)
- })
+ rootContent
}
+ #else
+ rootContent
+ #endif
}
.commands {
PuzzleCommands()
}
}
+
+ private var rootContent: some View {
+ RootView(
+ services: services,
+ appDelegate: appDelegate
+ )
+ .environment(\.managedObjectContext, services.persistence.viewContext)
+ .environment(services.driveMonitor)
+ .environment(services.inputMonitor)
+ .environment(services.announcements)
+ .environment(services.tips)
+ .environment(services.syncMonitor)
+ .environment(services.eventLog)
+ .environment(\.syncEngine, services.syncEngine)
+ .environment(\.engagementStatus, services.engagementStatus)
+ .environment(services.nytAuth)
+ .environment(\.nytPuzzleFetcher, services.nytFetcher)
+ .environment(\.resetDatabase, {
+ do {
+ try await services.cloudService.resetAllData()
+ } catch {
+ services.announcements.post(Announcement(
+ id: "reset-database-error",
+ scope: .global,
+ severity: .error,
+ title: "Resetting Failed",
+ body: error.localizedDescription,
+ dismissal: .manual
+ ))
+ }
+ })
+ .environment(\.inviteFriend, { gameID, friendAuthorID in
+ try await services.invites.inviteFriend(gameID: gameID, friendAuthorID: friendAuthorID)
+ })
+ .environment(\.acceptInvite, { shareURL, pingRecordName in
+ _ = try await services.invites.acceptInvite(
+ shareURL: shareURL,
+ pingRecordName: pingRecordName
+ )
+ })
+ .environment(\.declineInvite, { gameID in
+ try await services.invites.declineInvite(gameID: gameID)
+ })
+ .environment(\.blockFriend, { friendAuthorID in
+ await services.invites.blockFriend(authorID: friendAuthorID)
+ })
+ .environment(\.unblockFriend, { friendAuthorID in
+ await services.invites.unblockFriend(authorID: friendAuthorID)
+ })
+ .environment(\.renameFriend, { friendAuthorID, nickname in
+ do {
+ try await services.friendController.setNickname(
+ friendAuthorID: friendAuthorID,
+ nickname: nickname
+ )
+ } catch {
+ services.announcements.post(Announcement(
+ id: "rename-friend-error-\(friendAuthorID)",
+ scope: .global,
+ severity: .error,
+ title: "Renaming Failed",
+ body: error.localizedDescription,
+ dismissal: .manual
+ ))
+ }
+ })
+ .environment(\.sendResignPings, { gameID in
+ await services.sessions.sendCompletionPings(gameID: gameID, resigned: true)
+ })
+ }
}
// MARK: - App Delegate
diff --git a/Crossmate/MarketingPuzzleScreenshotView.swift b/Crossmate/MarketingPuzzleScreenshotView.swift
@@ -1,98 +0,0 @@
-import SwiftUI
-
-@MainActor
-struct MarketingPuzzleScreenshotView: View {
- @State private var session: PlayerSession
- @State private var navigationPath: [UUID]
- private let roster: PlayerRoster
- private let gameID: UUID
-
- init(services: AppServices) {
- let model = Self.makeModel()
- _session = State(initialValue: model.session)
- _navigationPath = State(initialValue: [model.gameID])
- self.gameID = model.gameID
- self.roster = PlayerRoster(
- previewGameID: model.gameID,
- localName: services.preferences.name,
- localColor: services.preferences.color,
- remoteSelection: PlayerRoster.RemoteSelection(
- authorID: "marketing-teammate",
- row: 6,
- col: 11,
- direction: .down,
- color: .red,
- updatedAt: Date()
- )
- )
- }
-
- var body: some View {
- NavigationStack(path: $navigationPath) {
- Color(.systemBackground)
- .navigationDestination(for: UUID.self) { destination in
- if destination == gameID {
- PuzzleView(
- session: session,
- roster: roster,
- loadReplay: { .unavailable }
- )
- .navigationTitle("")
- .navigationBarTitleDisplayMode(.inline)
- }
- }
- }
- .preferredColorScheme(.light)
- }
-
- private static func makeModel() -> (session: PlayerSession, gameID: UUID) {
- let source = """
- Title: Crossmate
- Publisher: Collaborative Crossword App for iOS & iPadOS
- CmVer: \(XD.currentCmVersion)
- Author: Crossmate
-
-
- ALTO#OGRE#PIPER
- RAID#TREE#ADANA
- MUNI#HOER#RAREE
- ORACLEOFOMAHA##
- RAS#ALMS#ADOBES
- IT#OIL##FEE#ONO
- BUCK#ROGER#PLAN
- ###CROSSMATE###
- ANAIS#ATA#ALICE
- SIRE#RESURRECTS
- SON#POL##AND###
- ONEARM#SPCA#BAA
- ##LEAPINLIZARDS
- HAITI#PEAS#SEES
- DANAE#ORES#ADES
-
-
- A37. The best crossword app, perhaps ~ CROSSMATE
- """
- let puzzle = try! Puzzle(xd: XD.parse(source))
- let game = Game(puzzle: puzzle)
- let gameID = UUID(uuidString: "43524F53-534D-4154-452D-53484F545321")!
- let mutator = GameMutator(
- game: game,
- gameID: gameID,
- movesUpdater: nil,
- isShared: true
- )
- let session = PlayerSession(game: game, mutator: mutator)
- for (offset, letter) in Array("CROSSMA").enumerated() {
- game.setLetter(
- String(letter),
- atRow: 7,
- atCol: 3 + offset,
- pencil: false
- )
- }
- session.direction = .across
- session.selectedRow = 7
- session.selectedCol = 10
- return (session, gameID)
- }
-}
diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift
@@ -163,11 +163,12 @@ final class PlayerRoster {
startObserving()
}
+ #if DEBUG
init(
previewGameID gameID: UUID,
localName: String,
localColor: PlayerColor,
- remoteSelection: RemoteSelection
+ remoteSelection: RemoteSelection?
) {
self.gameID = gameID
self.authorIdentity = AuthorIdentity(testing: "marketing-local")
@@ -178,18 +179,25 @@ final class PlayerRoster {
self.tracer = nil
self.isStaticPreview = true
self.localAuthorID = "marketing-local"
- self.entries = [
+ var entries = [
Entry(authorID: "marketing-local", name: localName, color: localColor, isLocal: true),
- Entry(
+ ]
+ if let remoteSelection {
+ entries.append(Entry(
authorID: remoteSelection.authorID,
name: "Teammate",
color: remoteSelection.color,
isLocal: false
- ),
- ]
- self.persistedRemoteSelections = [remoteSelection.authorID: remoteSelection]
- self.remoteReadAt = [remoteSelection.authorID: Date().addingTimeInterval(600)]
+ ))
+ self.persistedRemoteSelections = [remoteSelection.authorID: remoteSelection]
+ self.remoteReadAt = [remoteSelection.authorID: Date().addingTimeInterval(600)]
+ } else {
+ self.persistedRemoteSelections = [:]
+ self.remoteReadAt = [:]
+ }
+ self.entries = entries
}
+ #endif
isolated deinit {
for task in observationTasks {
diff --git a/Crossmate/Models/ReplayControls.swift b/Crossmate/Models/ReplayControls.swift
@@ -177,7 +177,11 @@ final class ReplayControls {
switch result {
case .ready(let timeline):
status = .ready(timeline)
+ #if DEBUG
+ position = Self.marketingInitialPosition(in: timeline) ?? timeline.count
+ #else
position = timeline.count
+ #endif
case .waiting(let missing):
status = .waiting(missing: missing)
case .unavailable:
@@ -185,6 +189,17 @@ final class ReplayControls {
}
}
+ #if DEBUG
+ private static func marketingInitialPosition(in timeline: ReplayTimeline) -> Int? {
+ let arguments = ProcessInfo.processInfo.arguments
+ guard let index = arguments.firstIndex(of: "--crossmate-marketing-replay-position"),
+ arguments.indices.contains(index + 1),
+ let value = Int(arguments[index + 1])
+ else { return nil }
+ return min(max(value, 0), timeline.count)
+ }
+ #endif
+
/// Drops back to `.idle` so the next `load` re-checks. Triggered by the
/// inbound-journal sync signal (a contributor's journal arrived) and by the
/// manual "Check Again" affordance.
diff --git a/Crossmate/Views/MarketingScreenshots.swift b/Crossmate/Views/MarketingScreenshots.swift
@@ -0,0 +1,295 @@
+import SwiftUI
+
+#if DEBUG
+
+@MainActor
+struct MarketingScreenshotView: View {
+ private let services: AppServices
+ private let scene: MarketingScene
+
+ init(services: AppServices) {
+ self.services = services
+ self.scene = MarketingScene.current
+ }
+
+ var body: some View {
+ switch scene {
+ case .import:
+ MarketingImportScreenshotView()
+ case .together, .solo, .replay, .undoRedo:
+ MarketingPuzzleSceneView(scene: scene, services: services)
+ }
+ }
+}
+
+private enum MarketingScene: String {
+ case together
+ case solo
+ case `import`
+ case replay
+ case undoRedo = "undo-redo"
+
+ static var current: MarketingScene {
+ let arguments = ProcessInfo.processInfo.arguments
+ guard let flagIndex = arguments.firstIndex(of: "--crossmate-marketing-scene"),
+ arguments.indices.contains(flagIndex + 1),
+ let scene = MarketingScene(rawValue: arguments[flagIndex + 1])
+ else {
+ return .together
+ }
+ return scene
+ }
+
+ var isShared: Bool {
+ switch self {
+ case .together, .replay:
+ true
+ case .solo, .import, .undoRedo:
+ false
+ }
+ }
+
+}
+
+private struct MarketingPuzzleSceneView: View {
+ @State private var session: PlayerSession
+ @State private var navigationPath: [UUID]
+ private let scene: MarketingScene
+ private let roster: PlayerRoster
+ private let gameID: UUID
+ private let replayTimeline: ReplayTimeline?
+
+ init(scene: MarketingScene, services: AppServices) {
+ let model = Self.makeModel(scene: scene)
+ self.scene = scene
+ _session = State(initialValue: model.session)
+ _navigationPath = State(initialValue: [model.gameID])
+ self.gameID = model.gameID
+ self.replayTimeline = model.replayTimeline
+
+ let remoteSelection = scene.isShared
+ ? PlayerRoster.RemoteSelection(
+ authorID: "marketing-teammate",
+ row: 6,
+ col: 11,
+ direction: .down,
+ color: .red,
+ updatedAt: Date()
+ )
+ : nil
+ self.roster = PlayerRoster(
+ previewGameID: model.gameID,
+ localName: services.preferences.name,
+ localColor: services.preferences.color,
+ remoteSelection: remoteSelection
+ )
+ }
+
+ var body: some View {
+ NavigationStack(path: $navigationPath) {
+ Color(.systemBackground)
+ .navigationDestination(for: UUID.self) { destination in
+ if destination == gameID {
+ PuzzleView(
+ session: session,
+ roster: roster,
+ loadReplay: loadReplay
+ )
+ .navigationTitle("")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+ }
+ }
+ .preferredColorScheme(.light)
+ }
+
+ private var loadReplay: () async -> JournalReplayResult {
+ {
+ guard let replayTimeline else { return .unavailable }
+ return .ready(replayTimeline)
+ }
+ }
+
+ private static func makeModel(
+ scene: MarketingScene
+ ) -> (session: PlayerSession, gameID: UUID, replayTimeline: ReplayTimeline?) {
+ let puzzle = try! Puzzle(xd: XD.parse(source))
+ let game = Game(puzzle: puzzle)
+ let gameID = UUID(uuidString: "43524F53-534D-4154-452D-53484F545321")!
+ let movesJournal = scene == .undoRedo
+ ? MovesJournal(persistence: PersistenceController(inMemory: true))
+ : nil
+ let mutator = GameMutator(
+ game: game,
+ gameID: gameID,
+ movesUpdater: nil,
+ movesJournal: movesJournal,
+ authorIDProvider: { "marketing-local" },
+ isShared: scene.isShared,
+ isCompleted: scene == .replay
+ )
+ let session = PlayerSession(game: game, mutator: mutator)
+
+ if scene == .replay {
+ seedReplayStart(game: game)
+ } else if scene != .undoRedo {
+ seedPuzzleStart(game: game)
+ }
+
+ session.direction = .across
+ session.selectedRow = 7
+ session.selectedCol = scene == .undoRedo ? 7 : 10
+
+ if scene == .undoRedo {
+ session.selectedCol = 3
+ for letter in Array("CROSSMA") {
+ session.enter(String(letter))
+ }
+ session.undo()
+ }
+
+ return (
+ session,
+ gameID,
+ scene == .replay ? replayTimeline(for: puzzle) : nil
+ )
+ }
+
+ private static func seedPuzzleStart(game: Game) {
+ for (offset, letter) in Array("CROSSMA").enumerated() {
+ game.setLetter(String(letter), atRow: 7, atCol: 3 + offset, pencil: false)
+ }
+ }
+
+ private static func seedReplayStart(game: Game) {
+ game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "marketing-local")
+ game.setLetter("L", atRow: 0, atCol: 1, pencil: false, authorID: "marketing-teammate")
+ for (offset, letter) in Array("CROSSMATE").enumerated() {
+ let authorID = offset.isMultiple(of: 2) ? "marketing-local" : "marketing-teammate"
+ game.setLetter(String(letter), atRow: 7, atCol: 3 + offset, pencil: false, authorID: authorID)
+ }
+ }
+
+ private static func replayTimeline(for puzzle: Puzzle) -> ReplayTimeline {
+ let earlyMoves: [(row: Int, col: Int, letter: Character, authorID: String)] =
+ [
+ (0, 0, "A", "marketing-local"),
+ (0, 1, "L", "marketing-teammate"),
+ ] + Array("CROSSMATE").enumerated().map { offset, letter in
+ (
+ row: 7,
+ col: 3 + offset,
+ letter: letter,
+ authorID: offset.isMultiple(of: 2) ? "marketing-local" : "marketing-teammate"
+ )
+ }
+ var moves = earlyMoves
+ for row in puzzle.cells {
+ for cell in row where !cell.isBlock {
+ guard let solution = cell.solution,
+ !moves.contains(where: { $0.row == cell.row && $0.col == cell.col })
+ else { continue }
+ guard let letter = solution.uppercased().first else { continue }
+ let index = moves.count
+ moves.append((
+ row: cell.row,
+ col: cell.col,
+ letter: letter,
+ authorID: index.isMultiple(of: 2) ? "marketing-local" : "marketing-teammate"
+ ))
+ }
+ }
+ let start = Date(timeIntervalSinceReferenceDate: 800_000_000)
+ let entries = moves.enumerated().map { offset, move in
+ JournalValue(
+ seq: Int64(offset + 1),
+ timestamp: start.addingTimeInterval(Double(offset) * 1.8),
+ position: GridPosition(row: move.row, col: move.col),
+ beforeState: .empty,
+ state: JournalCellState(
+ letter: String(move.letter),
+ mark: .none,
+ cellAuthorID: move.authorID
+ ),
+ actingAuthorID: move.authorID,
+ kind: .input,
+ targetSeq: nil,
+ batchID: nil,
+ prevSeqAtCell: nil,
+ direction: .across
+ )
+ }
+ return ReplayTimeline(merging: [entries])
+ }
+
+ private static let source = """
+ Title: Crossmate
+ Publisher: Collaborative Crossword App for iOS & iPadOS
+ CmVer: \(XD.currentCmVersion)
+ Author: Crossmate
+
+
+ ALTO#OGRE#PIPER
+ RAID#TREE#ADANA
+ MUNI#HOER#RAREE
+ ORACLEOFOMAHA##
+ RAS#ALMS#ADOBES
+ IT#OIL##FEE#ONO
+ BUCK#ROGER#PLAN
+ ###CROSSMATE###
+ ANAIS#ATA#ALICE
+ SIRE#RESURRECTS
+ SON#POL##AND###
+ ONEARM#SPCA#BAA
+ ##LEAPINLIZARDS
+ HAITI#PEAS#SEES
+ DANAE#ORES#ADES
+
+
+ A37. The best crossword app, perhaps ~ CROSSMATE
+ """
+}
+
+private struct MarketingImportScreenshotView: View {
+ @State private var selection = PuzzleSource.imported
+
+ var body: some View {
+ NavigationStack {
+ VStack(spacing: 0) {
+ Picker("Source", selection: $selection) {
+ Text(PuzzleSource.bundles.title).tag(PuzzleSource.bundles)
+ Text(PuzzleSource.imported.title).tag(PuzzleSource.imported)
+ }
+ .pickerStyle(.segmented)
+ .padding()
+
+ List {
+ Section("Imported") {
+ Label("Sunday Sample.xd", systemImage: "doc.text")
+ Label("Tournament Pack.puz", systemImage: "doc.text")
+ Label("Cryptic Practice.xd", systemImage: "doc.text")
+ }
+ }
+ .listStyle(.insetGrouped)
+ }
+ .navigationTitle("New Puzzle")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button {} label: {
+ Image(systemName: "xmark")
+ }
+ .accessibilityLabel("Cancel")
+ }
+ ToolbarItem(placement: .primaryAction) {
+ Button {} label: {
+ Label("Import", systemImage: "square.and.arrow.down")
+ }
+ }
+ }
+ }
+ .preferredColorScheme(.light)
+ }
+}
+
+#endif
diff --git a/Crossmate/Views/Puzzle/KeyboardView.swift b/Crossmate/Views/Puzzle/KeyboardView.swift
@@ -12,7 +12,13 @@ struct KeyboardView: View {
/// keystrokes (crossword input isn't predominantly numeric, so we don't
/// auto-revert). Stays available during rebus entry so mixed fills like
/// `H2O` can be typed.
+ #if DEBUG
+ @State private var showsSymbols = ProcessInfo.processInfo.arguments.contains(
+ "--crossmate-marketing-symbol-keyboard"
+ )
+ #else
@State private var showsSymbols = false
+ #endif
private let topRow = Array("QWERTYUIOP").map(String.init)
private let middleRow = Array("ASDFGHJKL").map(String.init)
diff --git a/Scripts/screenshots-ios-compose.swift b/Scripts/screenshots-ios-compose.swift
@@ -0,0 +1,117 @@
+#!/usr/bin/env swift
+
+import AppKit
+import CoreGraphics
+import CoreText
+import Foundation
+import ImageIO
+
+guard CommandLine.arguments.count >= 4 else {
+ fputs(
+ "Usage: screenshots-ios-compose.swift <input.png> <output.png> <text> [width height]\n",
+ stderr)
+ exit(1)
+}
+
+let inputPath = CommandLine.arguments[1]
+let outputPath = CommandLine.arguments[2]
+let text = CommandLine.arguments[3]
+
+let canvasWidth: CGFloat =
+ CommandLine.arguments.count >= 5 ? CGFloat(Int(CommandLine.arguments[4]) ?? 1320) : 1320
+let canvasHeight: CGFloat =
+ CommandLine.arguments.count >= 6 ? CGFloat(Int(CommandLine.arguments[5]) ?? 2868) : 2868
+
+let backgroundColor = CGColor(
+ red: 29.0 / 255.0,
+ green: 37.0 / 255.0,
+ blue: 48.0 / 255.0,
+ alpha: 1.0
+)
+
+guard let dataProvider = CGDataProvider(filename: inputPath),
+ let deviceImage = CGImage(
+ pngDataProviderSource: dataProvider,
+ decode: nil,
+ shouldInterpolate: true,
+ intent: .defaultIntent
+ )
+else {
+ fputs("Failed to load image: \(inputPath)\n", stderr)
+ exit(1)
+}
+
+let deviceWidth = CGFloat(deviceImage.width)
+let deviceHeight = CGFloat(deviceImage.height)
+
+guard
+ let context = CGContext(
+ data: nil,
+ width: Int(canvasWidth),
+ height: Int(canvasHeight),
+ bitsPerComponent: 8,
+ bytesPerRow: 0,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ )
+else {
+ fputs("Failed to create graphics context\n", stderr)
+ exit(1)
+}
+
+context.setFillColor(backgroundColor)
+context.fill(CGRect(x: 0, y: 0, width: canvasWidth, height: canvasHeight))
+
+let isLandscape = canvasWidth > canvasHeight
+let isPad = canvasWidth >= 1800
+let sidePadding: CGFloat = isPad ? 140 : 70
+let bottomPadding: CGFloat = isPad ? 150 : 80
+let topReserved: CGFloat = isPad ? 460 : (isLandscape ? 220 : 330)
+let maxDeviceWidth = canvasWidth - sidePadding * 2
+let maxDeviceHeight = canvasHeight - bottomPadding - topReserved
+let scale = min(maxDeviceWidth / deviceWidth, maxDeviceHeight / deviceHeight, 1.0)
+let scaledWidth = deviceWidth * scale
+let scaledHeight = deviceHeight * scale
+let deviceX = (canvasWidth - scaledWidth) / 2
+let deviceY = bottomPadding
+
+context.draw(
+ deviceImage,
+ in: CGRect(x: deviceX, y: deviceY, width: scaledWidth, height: scaledHeight)
+)
+
+let fontSize: CGFloat = isPad ? 118 : (isLandscape ? canvasHeight * 0.06 : canvasWidth * 0.066)
+let font = NSFont.systemFont(ofSize: fontSize, weight: .bold)
+let attributes: [NSAttributedString.Key: Any] = [
+ .font: font,
+ .foregroundColor: NSColor.white,
+]
+let attrString = NSAttributedString(string: text, attributes: attributes)
+let ctLine = CTLineCreateWithAttributedString(attrString)
+let textBounds = CTLineGetBoundsWithOptions(ctLine, .useOpticalBounds)
+
+let textAreaTop = canvasHeight - (isPad ? 80 : 40)
+let textAreaBottom = deviceY + scaledHeight
+let textX = (canvasWidth - textBounds.width) / 2 - textBounds.origin.x
+let textY = (textAreaTop + textAreaBottom) / 2 - textBounds.height / 2 - textBounds.origin.y
+
+context.textPosition = CGPoint(x: textX, y: textY)
+CTLineDraw(ctLine, context)
+
+guard let resultImage = context.makeImage(),
+ let destination = CGImageDestinationCreateWithURL(
+ URL(fileURLWithPath: outputPath) as CFURL,
+ "public.png" as CFString,
+ 1,
+ nil
+ )
+else {
+ fputs("Failed to create output\n", stderr)
+ exit(1)
+}
+
+CGImageDestinationAddImage(destination, resultImage, nil)
+guard CGImageDestinationFinalize(destination) else {
+ fputs("Failed to write output\n", stderr)
+ exit(1)
+}
diff --git a/Scripts/screenshots-ipad.sh b/Scripts/screenshots-ipad.sh
@@ -0,0 +1,134 @@
+#!/bin/bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+
+DO_COMPOSE=false
+while getopts "c" opt; do
+ case $opt in
+ c) DO_COMPOSE=true ;;
+ *) ;;
+ esac
+done
+shift $((OPTIND - 1))
+
+MAJOR="${1:-26}"
+RUNTIME=$(xcrun simctl list runtimes available \
+ | grep "iOS ${MAJOR}\." \
+ | sed 's/.*iOS \([0-9.]*\).*/\1/' \
+ | sort -t. -k1,1n -k2,2n \
+ | tail -1 || true)
+DESTINATION_OS=$(xcrun simctl list runtimes available \
+ | grep "iOS ${MAJOR}\." \
+ | sed 's/.*(\([0-9.]*\) -.*/\1/' \
+ | sort -t. -k1,1n -k2,2n -k3,3n \
+ | tail -1 || true)
+
+if [ -z "$RUNTIME" ] || [ -z "$DESTINATION_OS" ]; then
+ echo "No available iOS ${MAJOR}.x simulator runtime found." >&2
+ exit 1
+fi
+
+DEVICE=$(xcrun simctl list devices available "iOS ${RUNTIME}" \
+ | grep "iPad Pro 13" \
+ | head -1 \
+ | sed 's/^ *\(.*\) ([A-F0-9-]*).*/\1/' || true)
+if [ -z "$DEVICE" ]; then
+ DEVICE=$(xcrun simctl list devices available "iOS ${RUNTIME}" \
+ | grep "iPad" \
+ | head -1 \
+ | sed 's/^ *\(.*\) ([A-F0-9-]*).*/\1/' || true)
+fi
+
+if [ -z "$DEVICE" ]; then
+ echo "No available iPad simulator found for iOS ${RUNTIME}." >&2
+ exit 1
+fi
+
+echo "Using ${DEVICE}, iOS ${RUNTIME}"
+
+UDID=$(xcrun simctl list devices available "iOS ${RUNTIME}" \
+ | grep "$DEVICE" \
+ | head -1 \
+ | sed 's/.*(\([A-F0-9-]*\)).*/\1/')
+
+xcrun simctl boot "$UDID" 2>/dev/null || true
+xcrun simctl bootstatus "$UDID" -b
+
+xcrun simctl status_bar "$UDID" override \
+ --time "9:41" \
+ --batteryState discharging \
+ --batteryLevel 100 \
+ --wifiBars 3 \
+ --cellularBars 4 \
+ --cellularMode active \
+ --dataNetwork wifi
+
+echo "Status bar overridden"
+
+DERIVED_DATA="/tmp/crossmate-screenshots-derived"
+SCREENSHOT_TMP="/tmp/crossmate-ipad-screenshot.png"
+FRAMED_TMP="/tmp/crossmate-ipad-framed"
+RAW_OUTPUT_PATH="${REPO_DIR}/ipad_0.png"
+MARKETING_DIR="${REPO_DIR}/Marketing"
+BUNDLE_ID="net.inqk.crossmate"
+CAPTION="Built for iPad too"
+CANVAS_WIDTH=2064
+CANVAS_HEIGHT=2752
+
+rm -rf "$DERIVED_DATA" "$SCREENSHOT_TMP" "$FRAMED_TMP"
+
+xcodebuild build \
+ -scheme "Crossmate" \
+ -project "${REPO_DIR}/Crossmate.xcodeproj" \
+ -destination "platform=iOS Simulator,name=${DEVICE},OS=${DESTINATION_OS}" \
+ -derivedDataPath "$DERIVED_DATA" \
+ 2>&1
+
+APP_PATH="${DERIVED_DATA}/Build/Products/Debug-iphonesimulator/Crossmate.app"
+if [ ! -d "$APP_PATH" ]; then
+ echo "Built app not found at ${APP_PATH}" >&2
+ exit 1
+fi
+
+xcrun simctl install "$UDID" "$APP_PATH"
+xcrun simctl launch --terminate-running-process "$UDID" "$BUNDLE_ID" \
+ --crossmate-marketing-screenshot
+
+sleep 3
+
+xcrun simctl io "$UDID" screenshot --mask ignored "$SCREENSHOT_TMP"
+xcrun simctl status_bar "$UDID" clear 2>/dev/null || true
+
+if [ ! -f "$SCREENSHOT_TMP" ]; then
+ echo "Screenshot capture failed." >&2
+ exit 1
+fi
+
+echo ""
+if [ "$DO_COMPOSE" = true ]; then
+ mkdir -p "$FRAMED_TMP" "$MARKETING_DIR"
+
+ echo "Framing screenshot..."
+ shortcuts run "Frame Screenshots" -i "$SCREENSHOT_TMP" -o "${FRAMED_TMP}/framed.png"
+
+ echo "Composing final image..."
+ swift "${SCRIPT_DIR}/screenshots-ios-compose.swift" \
+ "${FRAMED_TMP}/framed.png" \
+ "${MARKETING_DIR}/ipad_0.png" \
+ "$CAPTION" \
+ "$CANVAS_WIDTH" \
+ "$CANVAS_HEIGHT"
+
+ rm -rf "$FRAMED_TMP"
+
+ echo ""
+ echo "Screenshot saved to ${MARKETING_DIR}/ipad_0.png"
+ ls -la "${MARKETING_DIR}/ipad_0.png"
+else
+ cp "$SCREENSHOT_TMP" "$RAW_OUTPUT_PATH"
+
+ echo "Screenshot saved to ${RAW_OUTPUT_PATH}"
+ ls -la "$RAW_OUTPUT_PATH"
+fi
diff --git a/Scripts/screenshots-marketing.sh b/Scripts/screenshots-marketing.sh
@@ -0,0 +1,168 @@
+#!/bin/bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+source "${SCRIPT_DIR}/select-simulator.sh"
+
+PLATFORM="iphone"
+ADD_DYNAMIC_ISLAND=true
+while getopts "p:n" opt; do
+ case $opt in
+ p) PLATFORM="$OPTARG" ;;
+ n) ADD_DYNAMIC_ISLAND=false ;;
+ *) ;;
+ esac
+done
+shift $((OPTIND - 1))
+
+MAJOR="${1:-26}"
+case "$PLATFORM" in
+ iphone|ipad) ;;
+ *)
+ echo "Unsupported platform '${PLATFORM}'. Use iphone or ipad." >&2
+ exit 1
+ ;;
+esac
+
+if [ "$PLATFORM" = "iphone" ]; then
+ select_simulator "$MAJOR"
+ CANVAS_ARGS=()
+else
+ RUNTIME=$(xcrun simctl list runtimes available \
+ | grep "iOS ${MAJOR}\." \
+ | sed 's/.*iOS \([0-9.]*\).*/\1/' \
+ | sort -t. -k1,1n -k2,2n \
+ | tail -1 || true)
+ DESTINATION_OS=$(xcrun simctl list runtimes available \
+ | grep "iOS ${MAJOR}\." \
+ | sed 's/.*(\([0-9.]*\) -.*/\1/' \
+ | sort -t. -k1,1n -k2,2n -k3,3n \
+ | tail -1 || true)
+ if [ -z "$RUNTIME" ] || [ -z "$DESTINATION_OS" ]; then
+ echo "No available iOS ${MAJOR}.x simulator runtime found." >&2
+ exit 1
+ fi
+ DEVICE=$(xcrun simctl list devices available "iOS ${RUNTIME}" \
+ | grep "iPad Pro 13" \
+ | head -1 \
+ | sed 's/^ *\(.*\) ([A-F0-9-]*).*/\1/' || true)
+ if [ -z "$DEVICE" ]; then
+ DEVICE=$(xcrun simctl list devices available "iOS ${RUNTIME}" \
+ | grep "iPad" \
+ | head -1 \
+ | sed 's/^ *\(.*\) ([A-F0-9-]*).*/\1/' || true)
+ fi
+ if [ -z "$DEVICE" ]; then
+ echo "No available iPad simulator found for iOS ${RUNTIME}." >&2
+ exit 1
+ fi
+ CANVAS_ARGS=(2064 2752)
+fi
+
+echo "Using ${DEVICE}, iOS ${RUNTIME}"
+
+UDID=$(xcrun simctl list devices available "iOS ${RUNTIME}" \
+ | grep "$DEVICE" \
+ | head -1 \
+ | sed 's/.*(\([A-F0-9-]*\)).*/\1/')
+
+xcrun simctl boot "$UDID" 2>/dev/null || true
+xcrun simctl bootstatus "$UDID" -b
+
+xcrun simctl status_bar "$UDID" override \
+ --time "9:41" \
+ --batteryState discharging \
+ --batteryLevel 100 \
+ --wifiBars 3 \
+ --cellularBars 4 \
+ --cellularMode active \
+ --dataNetwork wifi
+
+echo "Status bar overridden"
+
+DERIVED_DATA="/tmp/crossmate-marketing-derived"
+WORK_DIR="/tmp/crossmate-marketing-${PLATFORM}"
+MARKETING_DIR="${REPO_DIR}/Marketing"
+BUNDLE_ID="net.inqk.crossmate"
+
+SCENES=(together solo import replay undo-redo)
+CAPTIONS=(
+ "Solve Crosswords Together"
+ "Solve Crosswords Solo"
+ "Import Across Lite and XD"
+ "Watch Puzzle Replays"
+ "Undo/Redo Moves"
+)
+
+rm -rf "$DERIVED_DATA" "$WORK_DIR"
+mkdir -p "$WORK_DIR" "$MARKETING_DIR"
+
+xcodebuild build \
+ -quiet \
+ -scheme "Crossmate" \
+ -project "${REPO_DIR}/Crossmate.xcodeproj" \
+ -destination "platform=iOS Simulator,name=${DEVICE},OS=${DESTINATION_OS}" \
+ -derivedDataPath "$DERIVED_DATA"
+
+APP_PATH="${DERIVED_DATA}/Build/Products/Debug-iphonesimulator/Crossmate.app"
+if [ ! -d "$APP_PATH" ]; then
+ echo "Built app not found at ${APP_PATH}" >&2
+ exit 1
+fi
+
+xcrun simctl install "$UDID" "$APP_PATH"
+
+for index in "${!SCENES[@]}"; do
+ scene="${SCENES[$index]}"
+ caption="${CAPTIONS[$index]}"
+ raw_path="${WORK_DIR}/${scene}.png"
+ island_path="${WORK_DIR}/${scene}-island.png"
+ framed_path="${WORK_DIR}/${scene}-framed.png"
+ output_path="${MARKETING_DIR}/${PLATFORM}_${index}.png"
+
+ launch_args=(
+ --crossmate-marketing-screenshot
+ --crossmate-marketing-scene "$scene"
+ )
+ if [ "$scene" = "undo-redo" ]; then
+ launch_args+=(--crossmate-marketing-symbol-keyboard)
+ fi
+ if [ "$scene" = "replay" ]; then
+ launch_args+=(--crossmate-marketing-replay-position 11)
+ fi
+
+ echo ""
+ echo "Capturing ${PLATFORM}_${index}.png - ${caption}"
+ xcrun simctl launch --terminate-running-process "$UDID" "$BUNDLE_ID" "${launch_args[@]}"
+ sleep 3
+ xcrun simctl io "$UDID" screenshot --mask ignored "$raw_path"
+
+ frame_input="$raw_path"
+ if [ "$PLATFORM" = "iphone" ] && [ "$ADD_DYNAMIC_ISLAND" = true ]; then
+ swift "${SCRIPT_DIR}/screenshots-iphone-island.swift" "$raw_path" "$island_path"
+ frame_input="$island_path"
+ fi
+
+ shortcuts run "Frame Screenshots" -i "$frame_input" -o "$framed_path"
+ if [ "$PLATFORM" = "ipad" ]; then
+ swift "${SCRIPT_DIR}/screenshots-ios-compose.swift" \
+ "$framed_path" \
+ "$output_path" \
+ "$caption" \
+ "${CANVAS_ARGS[@]}"
+ else
+ swift "${SCRIPT_DIR}/screenshots-ios-compose.swift" \
+ "$framed_path" \
+ "$output_path" \
+ "$caption"
+ fi
+ ls -la "$output_path"
+done
+
+xcrun simctl status_bar "$UDID" clear 2>/dev/null || true
+rm -rf "$WORK_DIR"
+
+echo ""
+echo "Screenshots saved to ${MARKETING_DIR}/"
+ls -la "${MARKETING_DIR}/${PLATFORM}_"*.png