Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
7a9c4ea
CC-169: capture iOS App Store screenshots on a runner and upload them…
jaywedgeworth22 Oct 19, 2026
cdd9dfc
CC-169: keep the seeded App Store snapshot to platforms that render a…
jaywedgeworth22 Oct 19, 2026
5a70093
CC-169: fix the capture script bugs the review bots found
jaywedgeworth22 Oct 19, 2026
34c9b5f
CC-169: fail the capture when the pixels are not the family we booted
jaywedgeworth22 Oct 19, 2026
9c82aae
CC-169: find the iPad simulator by prefix, not by exact name
jaywedgeworth22 Oct 19, 2026
73a92de
CC-169: always regenerate the companion project before the capture
jaywedgeworth22 Oct 19, 2026
15370c5
CC-169: refuse a mixed screenshot list before anything is reserved
jaywedgeworth22 Oct 19, 2026
6a27530
CC-169: wait for the app to draw before capturing a screenshot
jaywedgeworth22 Oct 19, 2026
4b15713
CC-169: label the source scope after the device and open the alarms t…
jaywedgeworth22 Oct 19, 2026
d8e8301
autofix: pass empty launch args through Bash 3.2 set -u
github-actions[bot] Oct 20, 2026
690bec3
CC-169: keep the capture script inside what Bash 3.2 can run
jaywedgeworth22 Oct 20, 2026
597996e
CC-169: answer the eight screenshot-tooling review findings
jaywedgeworth22 Oct 20, 2026
fbe9a61
autofix: create the per-device capture directory and correct the chec…
github-actions[bot] Oct 20, 2026
279e6e9
Merge fbe9a61aa5eb97f07d1e26b2c28f7dd4abaf061e into d1e4fdd5e6117a2d4…
jaywedgeworth22 Oct 20, 2026
0dcb81e
autofix: reject a truncated PNG before reading its header
github-actions[bot] Oct 20, 2026
c6f81da
Merge 0dcb81e0d8e09efffd5b281f01464f0b53981227 into d1e4fdd5e6117a2d4…
jaywedgeworth22 Oct 20, 2026
ac5ced0
autofix: fix the real defects in the screenshot tooling review
github-actions[bot] Oct 20, 2026
a64389d
CC-169: close the second round of screenshot-tooling findings
jaywedgeworth22 Oct 20, 2026
debf87a
CC-169: close the third round of screenshot-tooling findings
jaywedgeworth22 Oct 20, 2026
ea8b466
Merge debf87aff9e62d6ba5c2e7e3f2843dd319966dca into 771b1d05296a39c69…
jaywedgeworth22 Oct 20, 2026
8e42882
autofix: keep a malformed newest-build probe out of "none"
github-actions[bot] Oct 20, 2026
db35f69
CC-169: identify the key in play with the fleet's masked preview
jaywedgeworth22 Oct 20, 2026
a1736c5
Merge db35f69c33775827e383a99228ec766ef7301567 into 771b1d05296a39c69…
jaywedgeworth22 Oct 20, 2026
22a94a9
autofix: keep the HTTP status and validate the app id on an ASC lookup
github-actions[bot] Oct 20, 2026
97367f8
CC-169: submit the version for review only when it is ready
jaywedgeworth22 Oct 20, 2026
10b330c
autofix: make the masked ASC key preview identify the key it previews
github-actions[bot] Oct 20, 2026
aa2a080
CC-169: refuse a version whose version string is not the build's
jaywedgeworth22 Oct 20, 2026
dccc12d
CC-169: merge main into the screenshot branch
jaywedgeworth22 Oct 20, 2026
a74c43a
merge main into minimax/ios-appstore-screenshots
swu-echo[bot] Oct 20, 2026
9025657
CC-169: make the submit-review gate honest about what it checked
jaywedgeworth22 Oct 20, 2026
da841d9
CC-169: correct the comment that contradicted the masked preview
jaywedgeworth22 Oct 20, 2026
93dec10
autofix: answer the four open submit-review findings
github-actions[bot] Oct 21, 2026
56eeaab
CC-169: let the upload side take a Mac screenshot too
jaywedgeworth22 Oct 21, 2026
6a6e68c
CC-169: refuse a submission when the build's marketing version is unr…
jaywedgeworth22 Oct 21, 2026
5224ad7
autofix: read the marketing version off the pre-release version
github-actions[bot] Oct 21, 2026
17103ac
CC-169: say which field the marketing version is read from
jaywedgeworth22 Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions .github/workflows/ios-appstore-screenshots.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
name: iOS App Store Screenshots

# Captures the App Store screenshots for the CodeCaps iOS companion.
#
# There is no iOS simulator runtime on Jay's Mac, so the captures have to come
# from a runner. The script builds the companion for one iPhone and one iPad
# simulator, seeds each with a realistic quota snapshot and writes one PNG per
# screen at the exact pixel size App Store Connect accepts, asserting the size
# before the file is handed on.
#
# The app needs seconds to draw its first frame, so the script polls the screen
# and keeps the first frame that holds more than the status bar. A fixed
# sleep captured a blank window, and a blank window is a picture of nothing.
#
# The upload to App Store Connect is deliberately NOT done here. It is a
# separate, explicit step run from a machine with the App Store Connect key:
#
# node scripts/ios-fleet/asc-api.mjs upload-screenshots \
# <localizationId> auto app_iphone_67-fleet-status.png
# node scripts/ios-fleet/asc-api.mjs upload-screenshots \
# <localizationId> auto app_ipad_pro_129-fleet-status.png
#
# so a re-run of this workflow can never quietly add duplicate screenshots to
# a live version.

on:
workflow_dispatch:
push:
branches: [main]
paths:
- "ios/CodeCapsCompanion/**"
# The exact shared files the companion compiles, and not the whole of
# Sources/QuotaCore: most QuotaCore edits are Mac-only, and a capture
# that shows more than the shipped build would be worse than a stale
# one. This list mirrors ios-ship.yml, so the screenshots and the
# TestFlight build change together.
- "Sources/QuotaCore/CompanionReadTokenStore.swift"
- "Sources/QuotaCore/FleetSourceDisagreement.swift"
- "Sources/QuotaCore/QuotaWindowVocabulary.swift"
- "Sources/QuotaCore/ResetAlarmCadence.swift"
- "Sources/QuotaCore/ResetAlarmMessage.swift"
- "Sources/QuotaCore/ResetAlarmTracker.swift"
- "Sources/QuotaCore/WidgetPlatformDisplay.swift"
- "Sources/QuotaCore/WidgetSnapshotFetcher.swift"
# The helpers the capture calls. A change to either of them changes
# what the capture keeps, so it has to bring a new capture with it.
- "scripts/ios-fleet/capture-appstore-screenshots.sh"
- "scripts/ios-fleet/pick_simulator.py"
- "scripts/ios-fleet/screenshot_ink.py"
- ".github/workflows/ios-appstore-screenshots.yml"
Comment thread
jaywedgeworth22 marked this conversation as resolved.

concurrency:
group: ios-appstore-screenshots-${{ github.ref }}
# Only supersede a run for the same pull request. Cancelling on a push to
# main would report cancelled checks for a commit that nothing replaced.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read

jobs:
screenshots:
name: Capture iPhone and iPad screenshots
runs-on: macos-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7

- name: Install XcodeGen
run: |
if ! command -v xcodegen >/dev/null 2>&1; then
brew install xcodegen
fi

- name: Capture App Store screenshots
run: |
chmod +x scripts/ios-fleet/capture-appstore-screenshots.sh
scripts/ios-fleet/capture-appstore-screenshots.sh "$RUNNER_TEMP/appstore-screenshots"

- name: Report the captured sizes
run: |
for shot in "$RUNNER_TEMP"/appstore-screenshots/*; do
echo "::group::$(basename "$shot")"
sips -g pixelWidth -g pixelHeight -g hasAlpha "$shot" | grep -E "pixel|/sips" || true
echo "::endgroup::"
done

- name: Upload the captured screenshots
uses: actions/upload-artifact@v7
with:
name: appstore-screenshots
path: ${{ runner.temp }}/appstore-screenshots/*
if-no-files-found: error
6 changes: 6 additions & 0 deletions .github/workflows/ios-ship-contract.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,9 @@ jobs:
node --test scripts/ios-ship-workflow.test.mjs
bash scripts/ios-fleet/test-asc-credentials.sh
node --test scripts/ios-fleet/test-asc-credentials.mjs
node --test scripts/ios-fleet/test-asc-client.mjs
node --test scripts/ios-fleet/test-asc-upload.mjs
node --test scripts/ios-fleet/test-asc-submit.mjs
python3 scripts/ios-fleet/test_pick_simulator.py
python3 scripts/ios-fleet/test_screenshot_ink.py
bash scripts/ios-fleet/test-capture-script.sh
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ DerivedData/
xcuserdata/
_site/
ios/CodeCapsCompanion/build/
__pycache__/
*.pyc
63 changes: 63 additions & 0 deletions Tests/CodeCapsTests/CompanionModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -454,4 +454,67 @@ final class CompanionModelTests: XCTestCase {
"Reset alarm bell button must meet the 44x44 minimum touch target standard"
)
}

// MARK: - Source scope label names the device the reader is holding

func testSourceScopeLabelNamesTheDeviceFamily() throws {
let source = try readCompanionModelSource()
XCTAssertTrue(
source.contains(#"case thisIPhone = "This iPhone""#),
"The scope's stored raw value must stay \"This iPhone\" so a saved preference is not lost"
)
XCTAssertTrue(
source.contains(#"case .thisIPhone: return "This \(family)""#),
"The this-device scope must be labelled with the device the reader is holding"
)
XCTAssertTrue(
source.contains("case .allSources: return rawValue"),
"The all-sources scope must keep its own label"
)
XCTAssertTrue(
source.contains(#"UIDevice.current.userInterfaceIdiom == .pad ? "iPad" : "iPhone""#),
"The device family must follow the interface idiom, so an iPad never says This iPhone"
)
XCTAssertTrue(
source.contains(#"return "Mac""#),
"The Mac build of the companion must name the Mac"
)
}

func testSourceScopePickerShowsTheDeviceAwareLabel() throws {
let view = try readCompanionContentViewSource()
XCTAssertTrue(
view.contains("Text(scope.displayTitle).tag(scope)"),
"The source-scope picker must show the device-aware label"
)
XCTAssertFalse(
view.contains("Text(scope.rawValue).tag(scope)"),
"The source-scope picker must not show the stored raw value, which always says This iPhone"
)
}

// MARK: - A launch argument opens the alarms tab

func testAlarmsLaunchArgumentOpensTheAlarmsTab() throws {
let view = try readCompanionContentViewSource()
// Both spellings are accepted: -openAlarms is the screenshot
// capture's, and -openAlerts is the one the alert work introduced
// for the same tab. Either one has to open it.
XCTAssertTrue(
view.contains(#"contains("-openAlarms")"#),
"A launch argument must open the Alarms tab, so the screenshot capture can reach it without a tap"
)
XCTAssertTrue(
view.contains(#"contains("-openAlerts")"#),
"The alert work's launch argument must open the same tab"
)
XCTAssertTrue(
view.contains(#"initialValue: opensAlarms ? .alarms : .display"#),
"Either launch argument must select the Alarms tab"
)
XCTAssertTrue(
view.contains(#"case alarms = "Alarms""#),
"The Alarms tab must still exist as a tab of the settings sheet"
)
}
}
34 changes: 34 additions & 0 deletions ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import Security
import SwiftUI
import UserNotifications

#if os(iOS)
import UIKit
#endif

public struct CompanionInAppAlert: Identifiable, Equatable {
public let id: String
public let title: String
Expand All @@ -17,6 +21,36 @@ public enum CompanionSourceScope: String, CaseIterable, Identifiable, Sendable {
case allSources = "All Sources"

public var id: String { rawValue }

/// The label the source-scope picker shows.
///
/// The stored raw value still says "This iPhone" because that is what it
/// always was and it is what a saved preference is keyed on. The app
/// ships for iPhone and iPad, and the label on screen has to name the
/// device the reader is actually holding.
public var displayTitle: String {
title(for: Self.currentDeviceFamily)
}

/// The device word the picker label uses: "iPhone", "iPad" or "Mac".
public static var currentDeviceFamily: String {
#if os(iOS)
return UIDevice.current.userInterfaceIdiom == .pad ? "iPad" : "iPhone"
#else
return "Mac"
#endif
}

/// The label for a given device family.
///
/// It is separate from the idiom lookup so the wording can be checked
/// without a device.
public func title(for family: String) -> String {
switch self {
case .thisIPhone: return "This \(family)"
case .allSources: return rawValue
}
}
}
#if canImport(WidgetKit)
import WidgetKit
Expand Down
12 changes: 9 additions & 3 deletions ios/CodeCapsCompanion/App/Views/CompanionContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public struct CompanionContentView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var showingSettings: Bool
@State private var expandedIds: Set<String> = []
@State private var settingsTab: CompanionSettingsTab = .display
@State private var settingsTab: CompanionSettingsTab
#if os(iOS)
@State private var platformOrderEditMode: EditMode = .inactive
#endif
Expand All @@ -24,7 +24,13 @@ public struct CompanionContentView: View {
public init(model: CompanionQuotaModel, showingSettings: Bool = ProcessInfo.processInfo.arguments.contains("-openSettings")) {
self.model = model
self._showingSettings = State(initialValue: showingSettings)
self._settingsTab = State(initialValue: ProcessInfo.processInfo.arguments.contains("-openAlerts") ? .alarms : .display)
// A launch argument picks the tab the settings sheet opens on, so the
// screenshot capture can reach a tab without a tap. Both spellings
// are accepted: -openAlarms is the capture's, and -openAlerts is
// the one the alert work introduced for the same tab.
let opensAlarms = ProcessInfo.processInfo.arguments.contains("-openAlarms")
|| ProcessInfo.processInfo.arguments.contains("-openAlerts")
self._settingsTab = State(initialValue: opensAlarms ? .alarms : .display)
#if os(iOS)
self._platformOrderEditMode = State(
initialValue: ProcessInfo.processInfo.arguments.contains("-reorderPlatforms") ? .active : .inactive
Expand Down Expand Up @@ -189,7 +195,7 @@ public struct CompanionContentView: View {
private var headerCard: some View {
Picker("Source scope", selection: $model.sourceScope) {
ForEach(CompanionSourceScope.allCases) { scope in
Text(scope.rawValue).tag(scope)
Text(scope.displayTitle).tag(scope)
}
}
.pickerStyle(.segmented)
Expand Down
Loading
Loading