crossmate

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

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:
M.gitignore | 2++
MCrossmate.xcodeproj/project.pbxproj | 8++++----
MCrossmate/CrossmateApp.swift | 144++++++++++++++++++++++++++++++++++++++++++-------------------------------------
DCrossmate/MarketingPuzzleScreenshotView.swift | 98-------------------------------------------------------------------------------
MCrossmate/Models/PlayerRoster.swift | 22+++++++++++++++-------
MCrossmate/Models/ReplayControls.swift | 15+++++++++++++++
ACrossmate/Views/MarketingScreenshots.swift | 295+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/Puzzle/KeyboardView.swift | 6++++++
AScripts/screenshots-ios-compose.swift | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AScripts/screenshots-ipad.sh | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AScripts/screenshots-marketing.sh | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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