commit 5194cd740003dd6957ccc8e1774aa181c5f678b7
parent fea803de1c4dc23a9dff99a9177d4b5e8d686b79
Author: Michael Camilleri <[email protected]>
Date: Fri, 19 Jun 2026 16:01:41 +0900
Add a Simulator demo seed
Inspecting the Game List colour strips, the friends list, and the puzzle
scoreboard previously needed a real shared game and a second iCloud
account. This commit adds a --crossmate-seed-demo launch argument that
brings the app up against a throwaway in-memory store pre-filled with a
couple of shared, in-progress games and a few crossmates. It also
injects a fixed local authorID, so the seeded peers classify as remote —
without it, with no iCloud user, the roster (and the scoreboard's nudge
button) stay empty. The real on-disk store is untouched.
A new build script builds and launches the app with the flag on a
dedicated 'Crossmate Demo' simulator — created on first run, reused and
re-fronted thereafter, and removable with --delete — so it never
collides with whatever device Xcode currently has open.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
2 files changed, 223 insertions(+), 2 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -4,6 +4,121 @@ import Foundation
import UIKit
import UserNotifications
+/// Fills a throwaway in-memory store with a handful of shared, in-progress
+/// games and crossmates so the Game List colour strips and the friends list can
+/// be inspected in the Simulator without an iCloud account or a real opponent.
+/// Driven solely by the `--crossmate-seed-demo` launch argument; a normal
+/// launch never reaches this. The same crossmate authorIDs are reused across
+/// both games on purpose, so one friend visibly takes a *different* colour in
+/// each game — the per-game colour derivation made visible.
+enum DemoSeed {
+ private static let puzzleSource = """
+ Title: Demo Puzzle
+ Author: Crossmate
+
+
+ ABC
+ D#E
+ FGH
+
+
+ A1. Across 1 ~ ABC
+ A4. Across 4 ~ DE
+ A5. Across 5 ~ FGH
+ D1. Down 1 ~ ADF
+ D2. Down 2 ~ BG
+ D3. Down 3 ~ CEH
+ """
+
+ /// The local user's authorID in demo mode, injected into `AuthorIdentity`
+ /// so the seeded crossmates classify as remote players. Kept distinct from
+ /// every crossmate id below.
+ static let localAuthorID = "_demo-you"
+
+ private static let crossmates: [(id: String, name: String)] = [
+ ("_demo-alice", "Alice"),
+ ("_demo-bob", "Bob"),
+ ("_demo-carol", "Carol"),
+ ]
+
+ @MainActor
+ static func populate(persistence: PersistenceController, preferences: PlayerPreferences) {
+ let ctx = persistence.viewContext
+
+ // Keep the Game List out of its "set your profile name" empty state.
+ if !preferences.hasName {
+ preferences.name = "You"
+ }
+
+ guard let xd = try? XD.parse(puzzleSource) else { return }
+ let puzzle = Puzzle(xd: xd)
+
+ for crossmate in crossmates {
+ seedFriend(crossmate, in: ctx)
+ }
+
+ seedGame(
+ title: "Tuesday Mini",
+ participants: ["_demo-alice", "_demo-bob"],
+ puzzle: puzzle,
+ in: ctx
+ )
+ seedGame(
+ title: "Sunday Giant",
+ participants: ["_demo-alice", "_demo-bob", "_demo-carol"],
+ puzzle: puzzle,
+ in: ctx
+ )
+
+ try? ctx.save()
+ }
+
+ private static func seedFriend(
+ _ crossmate: (id: String, name: String),
+ in ctx: NSManagedObjectContext
+ ) {
+ let friend = FriendEntity(context: ctx)
+ friend.authorID = crossmate.id
+ friend.createdAt = Date()
+ friend.databaseScope = 0
+ friend.displayName = crossmate.name
+ friend.displayNameVersion = 0
+ friend.friendZoneName = "demo-zone-\(crossmate.id)"
+ friend.friendZoneOwnerName = "_demo-you"
+ friend.isBlocked = false
+ friend.nickname = ""
+ friend.nicknameVersion = 0
+ friend.pairKey = "demo-pair-\(crossmate.id)"
+ }
+
+ private static func seedGame(
+ title: String,
+ participants: [String],
+ puzzle: Puzzle,
+ in ctx: NSManagedObjectContext
+ ) {
+ let game = GameEntity(context: ctx)
+ game.id = UUID()
+ game.title = title
+ game.puzzleSource = puzzleSource
+ game.createdAt = Date()
+ game.updatedAt = Date()
+ // A non-nil share record name is what marks the game as shared, which is
+ // the gate for the Game List participant colour strip.
+ game.ckShareRecordName = "demo-share-\(title)"
+ game.populateCachedSummaryFields(from: puzzle)
+
+ for authorID in participants {
+ let player = PlayerEntity(context: ctx)
+ player.game = game
+ player.authorID = authorID
+ player.name = crossmates.first { $0.id == authorID }?.name
+ player.ckRecordName = "demo-player-\(title)-\(authorID)"
+ player.updatedAt = Date()
+ }
+ }
+}
+
@MainActor
final class AppServices {
enum ReadCursorPublishMode {
@@ -190,8 +305,17 @@ final class AppServices {
self.preferences = preferences
let eventLog = EventLog()
self.eventLog = eventLog
- let persistence = PersistenceController(eventLog: eventLog)
+ // `--crossmate-seed-demo` (set in the Run scheme's arguments) brings the
+ // app up against a throwaway in-memory store pre-filled with a couple of
+ // shared games and a few crossmates, purely so the Game List colour
+ // strips and the friends list can be eyeballed in the Simulator without
+ // iCloud. It never touches the real on-disk store.
+ let isDemoSeed = ProcessInfo.processInfo.arguments.contains("--crossmate-seed-demo")
+ let persistence = PersistenceController(inMemory: isDemoSeed, eventLog: eventLog)
self.persistence = persistence
+ if isDemoSeed {
+ DemoSeed.populate(persistence: persistence, preferences: preferences)
+ }
let syncEngine = SyncEngine(container: self.ckContainer, persistence: persistence)
self.syncEngine = syncEngine
self.syncMonitor = SyncMonitor(log: eventLog)
@@ -201,7 +325,11 @@ final class AppServices {
})
self.nytFetcher = NYTPuzzleFetcher { NYTAuthService.currentCookieResult() }
self.inputMonitor = InputMonitor()
- let identity = AuthorIdentity()
+ // In demo mode, inject a fixed local authorID so the seeded peers
+ // classify as remote — otherwise, with no iCloud user, the roster comes
+ // up empty and the puzzle scoreboard (and its nudge button) never
+ // populate. A real launch always resolves the ID from CloudKit.
+ let identity = isDemoSeed ? AuthorIdentity(testing: DemoSeed.localAuthorID) : AuthorIdentity()
self.identity = identity
let pushSyncMonitor = self.syncMonitor
self.pushClient = PushClient(log: { message in
diff --git a/Scripts/run-demo.sh b/Scripts/run-demo.sh
@@ -0,0 +1,93 @@
+#!/bin/bash
+# Builds Crossmate and launches it with the `--crossmate-seed-demo` flag, which
+# brings the app up against a throwaway in-memory store pre-filled with a couple
+# of shared, in-progress games and a few crossmates. Lets you eyeball the Game
+# List colour strips, the friends list, and the puzzle scoreboard without iCloud
+# or a real opponent. The real on-disk store is untouched.
+#
+# Runs on a dedicated "Crossmate Demo" simulator — created on first run, reused
+# thereafter — so it never collides with whatever device Xcode has open.
+#
+# Usage: bash Scripts/run-demo.sh [iOS-major] (default major: 26)
+# bash Scripts/run-demo.sh --delete (remove the demo simulator)
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+source "${SCRIPT_DIR}/select-simulator.sh"
+
+DEMO_NAME="Crossmate Demo"
+BUNDLE_ID="net.inqk.crossmate"
+DERIVED_DATA="/tmp/crossmate-demo-derived"
+
+# Look up the demo device (empty if it doesn't exist yet). `|| true` keeps a
+# no-match grep (the expected first-run case) from tripping `set -o pipefail`.
+UDID=$(xcrun simctl list devices \
+ | grep -F "${DEMO_NAME} (" \
+ | head -1 \
+ | sed -E 's/.*\(([0-9A-Fa-f-]{36})\).*/\1/' || true)
+
+# `--delete` tears the demo simulator down and exits.
+if [ "${1:-}" = "--delete" ]; then
+ if [ -n "$UDID" ]; then
+ xcrun simctl shutdown "$UDID" 2>/dev/null || true
+ xcrun simctl delete "$UDID"
+ echo "Deleted '${DEMO_NAME}' (${UDID})."
+ else
+ echo "No '${DEMO_NAME}' simulator to delete."
+ fi
+ exit 0
+fi
+
+MAJOR="${1:-26}"
+
+if [ -n "$UDID" ]; then
+ echo "Reusing '${DEMO_NAME}' (${UDID})"
+else
+ # Create it on the newest available iOS <major> runtime, using the iPhone
+ # model select-simulator picks. Resolve both as CoreSimulator identifiers,
+ # which is what `simctl create` expects.
+ select_simulator "$MAJOR"
+ DEVICE_TYPE_ID=$(xcrun simctl list devicetypes \
+ | grep -E "^${DEVICE} \(" \
+ | head -1 \
+ | sed -E 's/.*\((com\.apple[^)]*)\).*/\1/' || true)
+ RUNTIME_ID=$(xcrun simctl list runtimes available \
+ | grep "iOS ${RUNTIME} (" \
+ | head -1 \
+ | sed -E 's/.* - (com\.apple[^ ]*).*/\1/' || true)
+ if [ -z "$DEVICE_TYPE_ID" ] || [ -z "$RUNTIME_ID" ]; then
+ echo "Couldn't resolve a device type / runtime to create '${DEMO_NAME}'." >&2
+ exit 1
+ fi
+ echo "Creating '${DEMO_NAME}' (${DEVICE}, iOS ${RUNTIME})"
+ UDID=$(xcrun simctl create "${DEMO_NAME}" "${DEVICE_TYPE_ID}" "${RUNTIME_ID}")
+fi
+
+# Bring the Simulator app forward, then boot the demo device into it.
+open -a Simulator
+xcrun simctl boot "$UDID" 2>/dev/null || true
+xcrun simctl bootstatus "$UDID" -b
+
+xcodebuild build \
+ -scheme "Crossmate" \
+ -project "${REPO_DIR}/Crossmate.xcodeproj" \
+ -destination "id=${UDID}" \
+ -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-seed-demo
+
+# Bring the Simulator back to the front now the app is up.
+open -a Simulator
+
+echo ""
+echo "Launched ${BUNDLE_ID} with --crossmate-seed-demo on '${DEMO_NAME}' (${UDID})."