diff --git a/SingleThread.xcodeproj/project.pbxproj b/SingleThread.xcodeproj/project.pbxproj index 057255a7..ab5957a4 100644 --- a/SingleThread.xcodeproj/project.pbxproj +++ b/SingleThread.xcodeproj/project.pbxproj @@ -759,7 +759,7 @@ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = SingleThread/SingleThread.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -784,7 +784,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThread; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -810,7 +810,7 @@ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = SingleThread/SingleThread.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -835,7 +835,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThread; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -856,12 +856,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThreadTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -885,12 +885,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThreadTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -913,12 +913,12 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThreadUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -937,12 +937,12 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThreadUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -962,14 +962,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SingleThread; INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "SingleThread needs access to show your reminders."; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.alanvardy.SingleThread; INFOPLIST_KEY_WKWatchOnly = NO; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThread.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -990,14 +990,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SingleThread; INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "SingleThread needs access to show your reminders."; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.alanvardy.SingleThread; INFOPLIST_KEY_WKWatchOnly = NO; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThread.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -1018,7 +1018,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = SingleThread/AppGroup.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SingleThreadWidget/Info.plist; @@ -1030,7 +1030,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThread.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1049,7 +1049,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = SingleThread/AppGroup.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SingleThreadWidget/Info.plist; @@ -1061,7 +1061,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThread.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1079,10 +1079,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThreadWatchUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -1101,10 +1101,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThreadWatchUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -1124,10 +1124,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThreadWatchTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -1148,10 +1148,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 6NWX2DHB9Q; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.SingleThreadWatchTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; diff --git a/SingleThread/ContentView+Settings.swift b/SingleThread/ContentView+Settings.swift index 34b06e32..7dfab669 100644 --- a/SingleThread/ContentView+Settings.swift +++ b/SingleThread/ContentView+Settings.swift @@ -13,7 +13,8 @@ extension ContentView { availableLists: viewModel.store.availableLists, excludedLists: excludedListsBinding, entitlementStore: viewModel.store.entitlementStore, - viewModel: SettingsViewModel()) + viewModel: SettingsViewModel(), + initialRoute: LaunchArgument.isPresent(.openSettingsReminder) ? .reminder : nil) // Standard-suite keys are plain in-memory values; write each change // back to the @AppStorage-backed property so settings survive relaunch. // App-Group keys write straight through their store types instead diff --git a/SingleThread/ContentView.swift b/SingleThread/ContentView.swift index b07618f8..b6fc7803 100644 --- a/SingleThread/ContentView.swift +++ b/SingleThread/ContentView.swift @@ -278,11 +278,12 @@ struct ContentView: View { handleScenePhaseChange(phase) } .task { - // `--ui-testing-open-settings` replays the gear button's action at - // launch. `simctl` cannot tap, so this is the only way screenshot - // automation can reach the options screen; bag first, then the flag, - // because `settingsSheetContent` renders nothing until the bag exists. - if LaunchArgument.isPresent(.openSettings), settingsBag == nil { + // The settings seams replay the gear button's action at launch. + // `simctl` cannot tap, so this is the only way screenshot automation + // reaches the options screen (and, for `openSettingsReminder`, its + // Reminder subscreen); bag first, then the flag, because + // `settingsSheetContent` renders nothing until the bag exists. + if LaunchArgument.opensSettings(), settingsBag == nil { settingsBag = makeSettingsBag() isShowingSettings = true } diff --git a/SingleThread/LaunchArgument.swift b/SingleThread/LaunchArgument.swift index c54a417d..cae9b240 100644 --- a/SingleThread/LaunchArgument.swift +++ b/SingleThread/LaunchArgument.swift @@ -17,8 +17,24 @@ enum LaunchArgument: String { /// until the bag exists. case openSettings = "--ui-testing-open-settings" + /// Presents the root Settings sheet and pushes the "Reminder" subscreen. + /// + /// The subscreen is otherwise reachable only by tapping the Reminder row, + /// which `simctl` cannot do, so the App Store screenshot harness has no other + /// route to it. It opens the sheet exactly as ``openSettings`` does — same + /// bag-then-flag order — and then seeds the navigation path with the row's + /// destination. + case openSettingsReminder = "--ui-testing-open-settings-reminder" + // MARK: Internal + /// Whether either Settings seam is present — the sheet opener or the + /// Reminder-subscreen opener (which opens the sheet too). + static func opensSettings( + in arguments: [String] = ProcessInfo.processInfo.arguments) -> Bool { + isPresent(.openSettings, in: arguments) || isPresent(.openSettingsReminder, in: arguments) + } + /// Whether `argument` was passed to this process. static func isPresent( _ argument: Self, diff --git a/SingleThread/SettingsView.swift b/SingleThread/SettingsView.swift index 537bd62c..adddf16b 100644 --- a/SingleThread/SettingsView.swift +++ b/SingleThread/SettingsView.swift @@ -1,6 +1,16 @@ import SingleThreadCore import SwiftUI +// MARK: - SettingsRoute + +/// One pushed destination inside the Settings stack. Only the Reminder row is +/// value-driven: the screenshot harness seeds it through +/// ``LaunchArgument/openSettingsReminder``, and the value form is what lets a +/// `NavigationPath` pre-select it before the stack renders. +enum SettingsRoute: Hashable { + case reminder +} + // MARK: - SettingsView /// Modal settings screen presented from the gear button. Owns no state — @@ -22,7 +32,8 @@ struct SettingsView: View { availableLists: [String], excludedLists: Binding>, entitlementStore: EntitlementStore = EntitlementStore(), - viewModel: SettingsViewModel = SettingsViewModel()) { + viewModel: SettingsViewModel = SettingsViewModel(), + initialRoute: SettingsRoute? = nil) { self.bindings = bindings self.viewModel = viewModel self.store = store @@ -30,12 +41,19 @@ struct SettingsView: View { self.availableLists = availableLists self.entitlementStore = entitlementStore _excludedLists = excludedLists + // Seed the path before the stack renders so a seam-driven launch lands + // on the subscreen in the first frame instead of pushing afterwards. + var path = NavigationPath() + if let initialRoute { + path.append(initialRoute) + } + _path = State(initialValue: path) } // MARK: Internal var body: some View { - NavigationStack { + NavigationStack(path: $path) { List { Section { NavigationLink { @@ -80,16 +98,7 @@ struct SettingsView: View { } .accessibilityIdentifier("settingsNotificationsRow") #endif - NavigationLink { - ReminderSettingsView( - showDate: $bindings.showDate, - showList: $bindings.showList, - showRecurrence: $bindings.showRecurrence, - showAlarms: $bindings.showAlarms, - showCompletionGlow: $bindings.showCompletionGlow, - showCompletionMomentum: $bindings.showCompletionMomentum, - viewModel: viewModel) - } label: { + NavigationLink(value: SettingsRoute.reminder) { SettingsLinkLabel( title: SharedStrings.reminder, systemImage: "bell.badge", @@ -177,6 +186,19 @@ struct SettingsView: View { .accessibilityAddTraits(.isButton) } } + .navigationDestination(for: SettingsRoute.self) { route in + switch route { + case .reminder: + ReminderSettingsView( + showDate: $bindings.showDate, + showList: $bindings.showList, + showRecurrence: $bindings.showRecurrence, + showAlarms: $bindings.showAlarms, + showCompletionGlow: $bindings.showCompletionGlow, + showCompletionMomentum: $bindings.showCompletionMomentum, + viewModel: viewModel) + } + } .localizedNavigationTitle("Settings") .toolbar { ToolbarItem(placement: .confirmationAction) { @@ -188,6 +210,16 @@ struct SettingsView: View { } } .modifier(TextSizeModifier(textSize: bindings.textSize)) + .onDisappear { + // Drop the seeded route when the sheet goes away so a re-presented + // Settings opens at its root. Reading `path` directly — not only + // through `$path` — is also what keeps it visible to Periphery, + // which misses `$`-projection-only `@State` under Xcode 27 and would + // otherwise report the property as unused. + if !path.isEmpty { + path = NavigationPath() + } + } } // MARK: Private @@ -195,6 +227,8 @@ struct SettingsView: View { @Environment(\.dismiss) private var dismiss + @State private var path: NavigationPath + @Binding private var excludedLists: Set @Bindable private var bindings: SettingsBindings diff --git a/SingleThreadTests/AISortCoordinatorTests.swift b/SingleThreadTests/AISortCoordinatorTests.swift index ae0384f7..c8154a6a 100644 --- a/SingleThreadTests/AISortCoordinatorTests.swift +++ b/SingleThreadTests/AISortCoordinatorTests.swift @@ -569,7 +569,9 @@ struct AISortCoordinatorTests { /// Polls until `condition` holds (or `timeout` elapses). Under parallel Swift /// Testing on macOS the coordinator's debounced MainActor task can be delayed /// past any fixed sleep, so the tests must await the *condition*, not a duration. - private func waitUntil(timeout: Duration = .seconds(20), _ condition: () -> Bool) async { + /// 25 s leaves headroom over the observed ~20.7 s worst case on a loaded host; + /// at the previous 20 s bound the poll expired and the caller's `#expect` failed. + private func waitUntil(timeout: Duration = .seconds(25), _ condition: () -> Bool) async { let clock = ContinuousClock() let deadline = clock.now + timeout while !condition(), clock.now < deadline { diff --git a/SingleThreadTests/LaunchArgumentTests.swift b/SingleThreadTests/LaunchArgumentTests.swift index 1e077a53..db01cbd1 100644 --- a/SingleThreadTests/LaunchArgumentTests.swift +++ b/SingleThreadTests/LaunchArgumentTests.swift @@ -28,4 +28,35 @@ struct LaunchArgumentTests { #expect(!LaunchArgument.isPresent(.openSettings, in: ["--ui-testing-open"])) #expect(!LaunchArgument.isPresent(.openSettings, in: ["ui-testing-open-settings"])) } + + @Test + func openSettingsReminderIsDetectedAmongOtherFlags() { + #expect( + LaunchArgument.isPresent( + .openSettingsReminder, + in: ["--ui-testing", "--ui-testing-open-settings-reminder"])) + } + + @Test + func openSettingsReminderIsAbsentWhenNotPassed() { + #expect(!LaunchArgument.isPresent(.openSettingsReminder, in: [])) + #expect(!LaunchArgument.isPresent(.openSettingsReminder, in: ["--ui-testing-open-settings"])) + } + + @Test + func openSettingsAndOpenSettingsReminderAreDistinctFlags() { + #expect(!LaunchArgument.isPresent(.openSettings, in: ["--ui-testing-open-settings-reminder"])) + #expect(!LaunchArgument.isPresent(.openSettingsReminder, in: ["--ui-testing-open"])) + #expect( + !LaunchArgument.isPresent( + .openSettingsReminder, in: ["ui-testing-open-settings-reminder"])) + } + + @Test + func opensSettingsIsTrueForEitherSettingsSeam() { + #expect(LaunchArgument.opensSettings(in: ["--ui-testing-open-settings"])) + #expect(LaunchArgument.opensSettings(in: ["--ui-testing-open-settings-reminder"])) + #expect(!LaunchArgument.opensSettings(in: ["--ui-testing"])) + #expect(!LaunchArgument.opensSettings(in: [])) + } } diff --git a/scripts/appstore-screenshots.sh b/scripts/appstore-screenshots.sh index 84fc9157..440ed897 100755 --- a/scripts/appstore-screenshots.sh +++ b/scripts/appstore-screenshots.sh @@ -1,23 +1,25 @@ #!/usr/bin/env bash # -# App Store Connect screenshots — iPhone 6.9" and iPad 13" display classes. +# App Store Connect screenshots — iPhone 6.3" and iPad 13" display classes. # -# Per language, per display class, this captures: +# Per language, per display class, this captures (the numeric prefixes put the +# files in the drag-over order the App Store listing wants): # -# main-light.png main reminder card, light appearance -# options-light.png root Settings sheet, light appearance -# main-dark.png main reminder card, dark appearance -# empty-dark.png "All Done" empty state, dark appearance -# no-reminders-dark.png "No Reminders" empty state, dark appearance +# 1-main-dark.png main reminder card, dark appearance +# 2-empty-dark.png "All Done" empty state, dark appearance +# 3-options-light.png root Settings sheet, light appearance +# 4-options-reminder-dark.png Settings' Reminder subscreen, dark appearance +# 5-main-light.png main reminder card, light appearance +# 6-no-reminders-dark.png "No Reminders" empty state, dark appearance # # The watch class captures a third, separate set — the watch app has no # wallpaper, no settings screen, and no `--seed`, so its demo copy comes from the # watch's own seams in `WatchDemoReminder`: # -# main.png reminder card -# more.png three-action menu — Skip / Reschedule / Delete -# all-done.png "All Done" (`--ui-testing-excluded-list`) -# no-reminders.png "No Reminders" (`--ui-testing-empty`) +# 1-main.png reminder card +# 2-more.png three-action menu — Skip / Reschedule / Delete +# 3-all-done.png "All Done" (`--ui-testing-excluded-list`) +# 4-no-reminders.png "No Reminders" (`--ui-testing-empty`) # # The menu and the empty states exist only behind seams: the menu needs a tap on # the Skip button (`--ui-testing-open-action-menu` presents it at launch), and @@ -31,9 +33,12 @@ # The options screen is presented by the `--ui-testing-open-settings` launch # seam (`LaunchArgument.openSettings`), which replays the gear button's action # at launch — the sheet has no URL scheme or menu command, and `simctl` cannot -# tap, so no other route reaches it. Capturing options therefore needs an app -# build that carries the seam; the script rebuilds by default rather than -# reusing a possibly stale .app (pass --no-build to skip). +# tap, so no other route reaches it. Its Reminder subscreen is reached the same +# way through `--ui-testing-open-settings-reminder` +# (`LaunchArgument.openSettingsReminder`), which seeds the Settings navigation +# path with the Reminder row's destination. Capturing either needs an app build +# that carries the seam; the script rebuilds by default rather than reusing a +# possibly stale .app (pass --no-build to skip). # # The main card shows one localized demo reminder — "Teach the cat how to do my # taxes" and its five translations, in `demo_reminder_seed` below. It travels in @@ -48,9 +53,17 @@ # before the seeded captures, because --ui-testing-app-language is read only # on the --ui-testing path. # -# Output: build/appstore-screenshots///.png +# Output: build/appstore-screenshots///.png # -# The captures are the simulator's native pixels — 1320 x 2868 (6.9") and +# Folders are language-major, so every localization of one device sits in a +# single directory, and each language directory carries the App Store's own +# ordering as a numeric prefix (`language_folder` below): 1-en, 2-zh-Hans, +# 3-fr, 4-de, 5-ja, 6-es. The device directory inside it is numbered the same +# way (`class_folder` below) so the three display classes sort as +# 1-iphone, 2-ipad, 3-watch. The output tree is cleared at the start of every +# run, so a stale layout can never linger beside the new one. +# +# The captures are the simulator's native pixels — 1206 x 2622 (6.3") and # 2064 x 2752 (13") — which are App Store Connect's accepted pairs for those # slots, so no resize or pad is needed. `--mask ignored` drops the rounded # device mask that the store does not want. @@ -76,12 +89,15 @@ # Wallpapers are keyed by "slot" = -, NOT by language, so # each device/screen pair keeps one fixed photo across all six languages: # -# iphone-6.9-main-light ipad-13-main-light (own photo: the light -# iphone-6.9-main-dark ipad-13-main-dark appearance composites the +# iphone-6.3-main-light ipad-13-main-light (own photo: the light +# iphone-6.3-main-dark ipad-13-main-dark appearance composites the # photo over white, not black) -# iphone-6.9-options ipad-13-options -# iphone-6.9-empty ipad-13-empty ("All Done") -# iphone-6.9-no-reminders ipad-13-no-reminders +# iphone-6.3-options ipad-13-options +# iphone-6.3-empty ipad-13-empty ("All Done") +# iphone-6.3-no-reminders ipad-13-no-reminders +# +# `4-options-reminder-dark` is the same Settings sheet pushed one level in, so +# it stages the `options` photo rather than adding a slot of its own. # # The set is COMMITTED — scripts/fixtures/appstore-wallpapers/ holds the ten # photos and manifest.json — so a clone captures the same wallpapers with no @@ -110,8 +126,8 @@ # installed on the simulator. REPO_ROOT overrides the root captures land under. # # Usage: scripts/appstore-screenshots.sh [options] [class ...] -# class: iphone-6.9 | ipad-13 | watch-series11-46mm -# (default: the two iOS classes) +# class: iphone-6.3 | ipad-13 | watch-series11-46mm +# (default: every class — both iOS classes and the watch) set -euo pipefail @@ -178,15 +194,57 @@ watch_locale () { # $1 = language → " " esac } -LANGUAGES=(en de es fr ja zh-Hans) +# App Store Connect's localization order, with the folder label each language +# writes into: language-major output, ordered exactly as the store lists them. +# `es` ships as "6-es", matching the raw-code style of the other five. +language_folder () { # $1 = language → numbered output directory name + case "$1" in + en) echo "1-en" ;; + zh-Hans) echo "2-zh-Hans" ;; + fr) echo "3-fr" ;; + de) echo "4-de" ;; + ja) echo "5-ja" ;; + es) echo "6-es" ;; + *) return 1 ;; + esac +} -# Wallpaper slots per device class, in capture order. Slot names match the -# captured file names so a staged photo maps 1:1 onto an output PNG. -SCREENS=(main-light options main-dark empty no-reminders) +LANGUAGES=(en zh-Hans fr de ja es) + +# App Store Connect's device order, mirrored onto the output directory inside +# each language folder. The class labels stay the internal keys (they key the +# committed wallpaper slots, e.g. `iphone-6.3-main-light`), so only the +# directory name is remapped here. +class_folder () { # $1 = class label → numbered output directory name + case "$1" in + iphone-6.3) echo "1-iphone" ;; + ipad-13) echo "2-ipad" ;; + watch-series11-46mm) echo "3-watch" ;; + *) return 1 ;; + esac +} + +# Capture order per device class, as `:`. The +# stems carry a numeric prefix so the listing's drag-over order is the file +# name's sort order; slots stay unprefixed because they key the committed +# wallpaper set (see the wallpaper section above), not the output names. +# +# `options` is staged twice on purpose: `4-options-reminder-dark` is the Reminder +# subscreen of the same sheet, so it shares the sheet's photo. +SCREENS=( + "main-dark:1-main-dark" + "empty:2-empty-dark" + "options:3-options-light" + "options:4-options-reminder-dark" + "main-light:5-main-light" + "no-reminders:6-no-reminders-dark" +) # class:simctl devicetype name regex DEVICE_TYPES=( - "iphone-6.9:iPhone 18 Pro Max" + # `iPhone 17` is 6.3" (1206 x 2622) — an accepted App Store iPhone slot. The + # `$` keeps the pattern off `iPhone 17 Pro` / `iPhone 17 Pro Max` / `iPhone 17e`. + "iphone-6.3:iPhone 17$" "ipad-13:iPad Pro 13-inch \\(M5\\)" ) @@ -228,7 +286,7 @@ while [ $# -gt 0 ]; do shift ;; -h | --help) - sed -n '2,70p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,128p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; -*) @@ -275,7 +333,7 @@ slot_names () { # every slot for the device classes in play, as - for entry in "${DEVICE_TYPES[@]}"; do label=${entry%%:*} for screen in "${SCREENS[@]}"; do - echo "$label-$screen" + echo "$label-${screen%%:*}" done done } @@ -433,7 +491,7 @@ prepare_wallpaper () { # never capture before a fresh photo is on disk { for screen in "${SCREENS[@]}"; do - slot="$LABEL-$screen" + slot="$LABEL-${screen%%:*}" if ! python3 -c ' import json, os, sys @@ -495,27 +553,42 @@ capture_class () { # $1 = class label, $2 = device type regex prepare_wallpaper for language in "${LANGUAGES[@]}"; do - dir="$OUT/$label/$language" + dir="$OUT/$(language_folder "$language")/$(class_folder "$label")" mkdir -p "$dir" echo " -> $language" demo_seed=$(demo_reminder_seed "$language") prime_language "$language" - stage_slot "$label" main-light - xcrun simctl ui "$UDID" appearance light - shoot "$dir/main-light.png" \ + + # Capture order follows the file-name prefixes in SCREENS, so appearance is + # set explicitly before each shot rather than once per language. + xcrun simctl ui "$UDID" appearance dark + stage_slot "$label" main-dark + shoot "$dir/1-main-dark.png" \ --seed "$demo_seed" --ui-testing-noop-settle -showSwipePrompt NO + + stage_slot "$label" empty + shoot "$dir/2-empty-dark.png" --seed "$ALL_DONE_SEED" + + xcrun simctl ui "$UDID" appearance light stage_slot "$label" options - shoot "$dir/options-light.png" \ + shoot "$dir/3-options-light.png" \ --ui-testing --ui-testing-noop-settle --ui-testing-app-language "$language" \ --ui-testing-open-settings - stage_slot "$label" main-dark + xcrun simctl ui "$UDID" appearance dark - shoot "$dir/main-dark.png" \ + stage_slot "$label" options + shoot "$dir/4-options-reminder-dark.png" \ + --ui-testing --ui-testing-noop-settle --ui-testing-app-language "$language" \ + --ui-testing-open-settings-reminder + + xcrun simctl ui "$UDID" appearance light + stage_slot "$label" main-light + shoot "$dir/5-main-light.png" \ --seed "$demo_seed" --ui-testing-noop-settle -showSwipePrompt NO - stage_slot "$label" empty - shoot "$dir/empty-dark.png" --seed "$ALL_DONE_SEED" + + xcrun simctl ui "$UDID" appearance dark stage_slot "$label" no-reminders - shoot "$dir/no-reminders-dark.png" --no-reminders + shoot "$dir/6-no-reminders-dark.png" --no-reminders done xcrun simctl shutdown "$UDID" >/dev/null 2>&1 || true @@ -563,17 +636,17 @@ capture_watch () { # $1 = device type regex xcrun simctl install "$UDID" "$WATCH_APP" for language in "${LANGUAGES[@]}"; do - dir="$OUT/$WATCH_CLASS/$language" + dir="$OUT/$(language_folder "$language")/$(class_folder "$WATCH_CLASS")" mkdir -p "$dir" echo " -> $language" watch_set_language "$language" - watch_shot "$dir/main.png" --ui-testing \ + watch_shot "$dir/1-main.png" --ui-testing \ --ui-testing-title "$(demo_reminder_title "$language")" --ui-testing-notes "" - watch_shot "$dir/more.png" --ui-testing \ + watch_shot "$dir/2-more.png" --ui-testing \ --ui-testing-title "$(demo_reminder_title "$language")" --ui-testing-notes "" \ --ui-testing-open-action-menu - watch_shot "$dir/all-done.png" --ui-testing --ui-testing-excluded-list Errands - watch_shot "$dir/no-reminders.png" --ui-testing --ui-testing-empty + watch_shot "$dir/3-all-done.png" --ui-testing --ui-testing-excluded-list Errands + watch_shot "$dir/4-no-reminders.png" --ui-testing --ui-testing-empty done xcrun simctl shutdown "$UDID" >/dev/null 2>&1 || true @@ -582,9 +655,15 @@ capture_watch () { # $1 = device type regex # ── Run ────────────────────────────────────────────────────────────────────── if [ ${#CLASSES[@]} -eq 0 ]; then - CLASSES=(iphone-6.9 ipad-13) + CLASSES=(iphone-6.3 ipad-13 watch-series11-46mm) fi +# Clear the output tree: the language-major layout replaced an older +# device-major one, and stale PNGs would otherwise sit beside (or be swept into +# the size check with) the fresh captures. +rm -rf "$OUT" +mkdir -p "$OUT" + if [ "$FETCH_WALLPAPERS" -eq 1 ]; then echo "==> wallpapers" fetch_wallpapers @@ -635,7 +714,7 @@ for shot in "$OUT"/*/*/*.png; do count=$((count + 1)) dims=$(sips -g pixelWidth -g pixelHeight "$shot" | tail -2 | tr -dc '0-9\n' | paste -sd x -) case "$dims" in - 1320x2868 | 2064x2752 | 416x496) ;; + 1206x2622 | 1179x2556 | 1320x2868 | 2064x2752 | 416x496) ;; *) echo "!! ${shot} is ${dims}" >&2 fail=1 diff --git a/scripts/fixtures/appstore-wallpapers/iphone-6.9-empty.jpg b/scripts/fixtures/appstore-wallpapers/iphone-6.3-empty.jpg similarity index 100% rename from scripts/fixtures/appstore-wallpapers/iphone-6.9-empty.jpg rename to scripts/fixtures/appstore-wallpapers/iphone-6.3-empty.jpg diff --git a/scripts/fixtures/appstore-wallpapers/iphone-6.9-main-dark.jpg b/scripts/fixtures/appstore-wallpapers/iphone-6.3-main-dark.jpg similarity index 100% rename from scripts/fixtures/appstore-wallpapers/iphone-6.9-main-dark.jpg rename to scripts/fixtures/appstore-wallpapers/iphone-6.3-main-dark.jpg diff --git a/scripts/fixtures/appstore-wallpapers/iphone-6.9-main-light.jpg b/scripts/fixtures/appstore-wallpapers/iphone-6.3-main-light.jpg similarity index 100% rename from scripts/fixtures/appstore-wallpapers/iphone-6.9-main-light.jpg rename to scripts/fixtures/appstore-wallpapers/iphone-6.3-main-light.jpg diff --git a/scripts/fixtures/appstore-wallpapers/iphone-6.9-no-reminders.jpg b/scripts/fixtures/appstore-wallpapers/iphone-6.3-no-reminders.jpg similarity index 100% rename from scripts/fixtures/appstore-wallpapers/iphone-6.9-no-reminders.jpg rename to scripts/fixtures/appstore-wallpapers/iphone-6.3-no-reminders.jpg diff --git a/scripts/fixtures/appstore-wallpapers/iphone-6.9-options.jpg b/scripts/fixtures/appstore-wallpapers/iphone-6.3-options.jpg similarity index 100% rename from scripts/fixtures/appstore-wallpapers/iphone-6.9-options.jpg rename to scripts/fixtures/appstore-wallpapers/iphone-6.3-options.jpg diff --git a/scripts/fixtures/appstore-wallpapers/manifest.json b/scripts/fixtures/appstore-wallpapers/manifest.json index 9523eec9..04aea8b0 100644 --- a/scripts/fixtures/appstore-wallpapers/manifest.json +++ b/scripts/fixtures/appstore-wallpapers/manifest.json @@ -34,37 +34,37 @@ "photographerURL": "https://unsplash.com/@markbasarabvisuals", "source": "https://vardy.cc/unsplash/random" }, - "iphone-6.9-empty": { + "iphone-6.3-empty": { "fetchedAt": "2026-10-13T05:32:43Z", - "file": "iphone-6.9-empty.jpg", + "file": "iphone-6.3-empty.jpg", "photographer": "v2osk", "photographerURL": "https://unsplash.com/@v2osk", "source": "https://vardy.cc/unsplash/random" }, - "iphone-6.9-main-dark": { + "iphone-6.3-main-dark": { "fetchedAt": "2026-10-14T17:43:37Z", - "file": "iphone-6.9-main-dark.jpg", + "file": "iphone-6.3-main-dark.jpg", "photographer": "R Ra", "photographerURL": "https://unsplash.com/@renran", "source": "https://vardy.cc/unsplash/random" }, - "iphone-6.9-main-light": { + "iphone-6.3-main-light": { "fetchedAt": "2026-10-14T18:16:57Z", - "file": "iphone-6.9-main-light.jpg", + "file": "iphone-6.3-main-light.jpg", "photographer": "Florian van Duyn", "photographerURL": "https://unsplash.com/@flovayn", "source": "https://vardy.cc/unsplash/random" }, - "iphone-6.9-no-reminders": { + "iphone-6.3-no-reminders": { "fetchedAt": "2026-10-13T05:32:43Z", - "file": "iphone-6.9-no-reminders.jpg", + "file": "iphone-6.3-no-reminders.jpg", "photographer": "Aaron Burden", "photographerURL": "https://unsplash.com/@aaronburden", "source": "https://vardy.cc/unsplash/random" }, - "iphone-6.9-options": { + "iphone-6.3-options": { "fetchedAt": "2026-10-14T18:16:05Z", - "file": "iphone-6.9-options.jpg", + "file": "iphone-6.3-options.jpg", "photographer": "Daniela Kokina", "photographerURL": "https://unsplash.com/@danielakokina", "source": "https://vardy.cc/unsplash/random"