crossmate

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

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:
MCrossmate/Services/AppServices.swift | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
AScripts/run-demo.sh | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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})."