crossmate

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

commit 7dfc576bc1d9a6a0b8523fe077856efc014cebd7
parent aafa8d9886166a5ee560ff14120bac0015db1b03
Author: Michael Camilleri <[email protected]>
Date:   Fri, 12 Jun 2026 18:23:31 +0900

Add script to generate marketing image

Diffstat:
MCrossmate/CrossmateApp.swift | 227++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
MCrossmate/Models/PlayerRoster.swift | 31+++++++++++++++++++++++++++++++
MCrossmate/Views/PuzzleView.swift | 2++
AScripts/screenshots-iphone-island.swift | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AScripts/screenshots-iphone.sh | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 349 insertions(+), 60 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -14,66 +14,74 @@ struct CrossmateApp: App { var body: some Scene { WindowGroup { - RootView( - services: services, - appDelegate: appDelegate - ) - .environment(\.managedObjectContext, services.persistence.viewContext) - .environment(services.driveMonitor) - .environment(services.inputMonitor) - .environment(services.announcements) - .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(\.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) - }) + if ProcessInfo.processInfo.arguments.contains("--crossmate-marketing-screenshot") { + MarketingPuzzleScreenshotView(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.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(\.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) + }) + } } } } @@ -433,6 +441,105 @@ private extension UIApplication { } } +// MARK: - Marketing Screenshots + +@MainActor +private 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: 1, + col: 10, + 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: 3 + 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. Possibly the best crossword app ~ 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) + } +} + // MARK: - Game Destination /// Loads a game when navigated to. diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift @@ -105,6 +105,7 @@ final class PlayerRoster { private let container: CKContainer private let engagementStore: EngagementStore private let tracer: (@MainActor @Sendable (String) -> Void)? + private let isStaticPreview: Bool private var cachedShare: CKShare? private var observationTasks: [Task<Void, Never>] = [] @@ -130,9 +131,38 @@ final class PlayerRoster { self.container = container self.engagementStore = engagementStore self.tracer = tracer + self.isStaticPreview = false startObserving() } + init( + previewGameID gameID: UUID, + localName: String, + localColor: PlayerColor, + remoteSelection: RemoteSelection + ) { + self.gameID = gameID + self.authorIdentity = AuthorIdentity(testing: "marketing-local") + self.preferences = PlayerPreferences() + self.persistence = PersistenceController(inMemory: true) + self.container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") + self.engagementStore = EngagementStore() + self.tracer = nil + self.isStaticPreview = true + self.localAuthorID = "marketing-local" + self.entries = [ + Entry(authorID: "marketing-local", name: localName, color: localColor, isLocal: true), + Entry( + authorID: remoteSelection.authorID, + name: "Teammate", + color: remoteSelection.color, + isLocal: false + ), + ] + self.persistedRemoteSelections = [remoteSelection.authorID: remoteSelection] + self.remoteReadAt = [remoteSelection.authorID: Date().addingTimeInterval(600)] + } + isolated deinit { for task in observationTasks { task.cancel() @@ -172,6 +202,7 @@ final class PlayerRoster { // MARK: - Refresh func refresh() async { + guard !isStaticPreview else { return } // Without a known local authorID we can't classify any participant as // self vs. remote, so the only safe answer is an empty roster. The // next refresh (after AuthorIdentity populates) will do the real work. diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -74,6 +74,8 @@ struct PuzzleView: View { let subtitle: String? if let publisher = session.puzzle.publisher, let formattedDate { subtitle = "\(publisher) ยท \(formattedDate)" + } else if let publisher = session.puzzle.publisher { + subtitle = publisher } else { subtitle = formattedDate } diff --git a/Scripts/screenshots-iphone-island.swift b/Scripts/screenshots-iphone-island.swift @@ -0,0 +1,78 @@ +#!/usr/bin/env swift + +import CoreGraphics +import Foundation +import ImageIO + +guard CommandLine.arguments.count >= 2 else { + fputs("Usage: add-dynamic-island.swift <image.png> [output.png]\n", stderr) + exit(1) +} + +let inputPath = CommandLine.arguments[1] +let outputPath = CommandLine.arguments.count >= 3 ? CommandLine.arguments[2] : inputPath + +guard let dataProvider = CGDataProvider(filename: inputPath), + let image = CGImage( + pngDataProviderSource: dataProvider, decode: nil, shouldInterpolate: true, + intent: .defaultIntent) +else { + fputs("Failed to load image: \(inputPath)\n", stderr) + exit(1) +} + +let width = CGFloat(image.width) +let height = CGFloat(image.height) + +// Dynamic Island dimensions as proportions of screen width +let pillWidth = width * 0.280 +let pillHeight = width * 0.062 +let pillY = width * 0.050 +let pillX = (width - pillWidth) / 2 +let cornerRadius = pillHeight / 2 + +guard + let context = CGContext( + data: nil, + width: Int(width), + height: Int(height), + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) +else { + fputs("Failed to create graphics context\n", stderr) + exit(1) +} + +// Draw original image +context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + +// Draw Dynamic Island pill (CG origin is bottom-left, so flip Y) +let flippedY = height - pillY - pillHeight +let pillRect = CGRect(x: pillX, y: flippedY, width: pillWidth, height: pillHeight) +let pillPath = CGPath( + roundedRect: pillRect, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) + +context.setFillColor(CGColor(red: 0, green: 0, blue: 0, alpha: 1)) +context.addPath(pillPath) +context.fillPath() + +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-iphone.sh b/Scripts/screenshots-iphone.sh @@ -0,0 +1,71 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +source "${SCRIPT_DIR}/select-simulator.sh" + +select_simulator "${1:-26}" + +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-screenshot.png" +OUTPUT_PATH="${REPO_DIR}/iphone_0.png" +BUNDLE_ID="net.inqk.crossmate" + +rm -rf "$DERIVED_DATA" "$SCREENSHOT_TMP" + +xcodebuild build \ + -scheme "Crossmate" \ + -project "${REPO_DIR}/Crossmate.xcodeproj" \ + -destination "platform=iOS Simulator,name=${DEVICE},OS=${RUNTIME}" \ + -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 "$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 "" +echo "Adding Dynamic Island..." +swift "${SCRIPT_DIR}/screenshots-iphone-island.swift" "$SCREENSHOT_TMP" "$OUTPUT_PATH" + +echo "" +echo "Screenshot saved to ${OUTPUT_PATH}" +ls -la "$OUTPUT_PATH"