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:
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"