commit 577bbfaf924dc233025c203a3c106a9cf36dac07
parent cc62a31d019dc3a62ffcb4a215cdc562a68ee8ee
Author: Michael Camilleri <[email protected]>
Date: Fri, 3 Apr 2026 00:45:31 +0900
Add automated screenshot mechanism for other platforms
This commit adds scripts for creating screenshots for other Apple
platforms.
Co-Authored-By: Claude 4.6 Opus <[email protected]>
Diffstat:
14 files changed, 1068 insertions(+), 112 deletions(-)
diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj
@@ -41,6 +41,7 @@
42CB50C94EDF48562F680B56 /* ItemStoreDeleteAndNormalizeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5EFAB5629C31FC2C6FFE0A1 /* ItemStoreDeleteAndNormalizeTests.swift */; };
47D9272442A5F15B324D3DAC /* ItemStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8EC97702E7B218A03B7898 /* ItemStore.swift */; };
4A0682E85B50DF42ECF83B48 /* Listless watchOS.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = C6812E535A24C599C28F9278 /* Listless watchOS.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ 4CA383C2B90DA7EF080BDABB /* ListlessWatchScreenshots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76A89CFEB95B9BB9A0540CD9 /* ListlessWatchScreenshots.swift */; };
4DD2030E321567BD25661760 /* SyncDiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9288507CE6023425D1DE724 /* SyncDiagnosticsView.swift */; };
4F150E893D1F78BC23A49659 /* AccentColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C96B8A403274C4CC7F57460 /* AccentColorTests.swift */; };
53700EA974FE4AD771FE89EC /* CloudKitSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */; };
@@ -102,6 +103,7 @@
DBFE5DADCAAF26CF77245410 /* ItemListTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848DBC251E2D2EB7BD089768 /* ItemListTypes.swift */; };
DC103B1BFDC5940F63DD48ED /* ItemRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05AB46824DCDD04903EA4C82 /* ItemRowDragGesture.swift */; };
DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
+ DD2F098B472D048B66CEC8B1 /* ListlessMacScreenshots.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE3578459F9888CA59AACBEC /* ListlessMacScreenshots.swift */; };
DEE187A790A4058FE4AFDB2E /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8448C5778F75F52719114AF /* PlatformTextFieldWidthModifier.swift */; };
E0BDC0FCAB43CEE0C9AC5279 /* ItemListView+Undo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4B98BD127A56CE4669DCD5 /* ItemListView+Undo.swift */; };
E12C1304464FC7799856B2BA /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B048B19C5219862BBED2E7 /* TestHelpers.swift */; };
@@ -128,6 +130,13 @@
remoteGlobalIDString = 0FB4F07A37999BBC6DFE4DBB;
remoteInfo = "Listless macOS";
};
+ 47C07B5C46AB5A2CD8741632 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 3256C2BF8F1DAF371DA32120 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 9BDC1B2175AB9CE26790448D;
+ remoteInfo = "Listless watchOS";
+ };
642EE6A5908DB4216F049E9C /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 3256C2BF8F1DAF371DA32120 /* Project object */;
@@ -186,6 +195,7 @@
1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessMacApp.swift; sourceTree = "<group>"; };
20A09A6C1C2251E96E1B5D96 /* ItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListView.swift; sourceTree = "<group>"; };
23060125DF03EE84F3AED8CB /* ItemListView+PullToClear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemListView+PullToClear.swift"; sourceTree = "<group>"; };
+ 2523E01C8F130C2799123479 /* Listless watchOS UI Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Listless watchOS UI Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
2B8EC97702E7B218A03B7898 /* ItemStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemStore.swift; sourceTree = "<group>"; };
3048ACA1CAF1284F99E1400E /* ItemRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemRowView.swift; sourceTree = "<group>"; };
3116A37F1353BF6E18308DD2 /* ItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListView.swift; sourceTree = "<group>"; };
@@ -216,6 +226,7 @@
72BAC76C5B5ED291048705CF /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
74255E6B6C40899E9B17D927 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
75B048B19C5219862BBED2E7 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
+ 76A89CFEB95B9BB9A0540CD9 /* ListlessWatchScreenshots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessWatchScreenshots.swift; sourceTree = "<group>"; };
78232E9D9F77FA3630F9D089 /* ItemListView+PullToCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemListView+PullToCreate.swift"; sourceTree = "<group>"; };
7C73E9D4C42CCABBF0F33543 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
82F2C024B6C1F2F1FD7A25B0 /* ItemListView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemListView+Logic.swift"; sourceTree = "<group>"; };
@@ -262,6 +273,7 @@
E8F7B7D46010E217FC0A5BFF /* TutorialSeeder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialSeeder.swift; sourceTree = "<group>"; };
E9288507CE6023425D1DE724 /* SyncDiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDiagnosticsView.swift; sourceTree = "<group>"; };
EADAA53B8BCBC80AEFF191EF /* ListlessMacUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessMacUITests.swift; sourceTree = "<group>"; };
+ EE3578459F9888CA59AACBEC /* ListlessMacScreenshots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessMacScreenshots.swift; sourceTree = "<group>"; };
F15BF645DEDE8D9E94DB508B /* BackgroundClickMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundClickMonitor.swift; sourceTree = "<group>"; };
F1E998119283F784B9ADEE28 /* AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColors.swift; sourceTree = "<group>"; };
F31EDB2C34C8B255000A2525 /* ItemListViewProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListViewProtocol.swift; sourceTree = "<group>"; };
@@ -294,7 +306,9 @@
children = (
16D0F534D5B4D5ECF74CF6E6 /* ListlessiOSScreenshots.swift */,
88C0D6F2667BD14F29CB84E5 /* ListlessiOSUITests.swift */,
+ EE3578459F9888CA59AACBEC /* ListlessMacScreenshots.swift */,
EADAA53B8BCBC80AEFF191EF /* ListlessMacUITests.swift */,
+ 76A89CFEB95B9BB9A0540CD9 /* ListlessWatchScreenshots.swift */,
);
name = UI;
path = Tests/UI;
@@ -320,6 +334,7 @@
CCB5F87A520B1CD47F2F71D0 /* Listless macOS UI Tests.xctest */,
B88DC6E36FA41DCB6CEB9647 /* Listless macOS Unit Tests.xctest */,
5B0E22B8F7B2B7283CAF749E /* Listless macOS.app */,
+ 2523E01C8F130C2799123479 /* Listless watchOS UI Tests.xctest */,
C6812E535A24C599C28F9278 /* Listless watchOS.app */,
);
name = Products;
@@ -662,6 +677,24 @@
productReference = CCB5F87A520B1CD47F2F71D0 /* Listless macOS UI Tests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
+ FAC694EFB69F0D9F12557137 /* Listless watchOS UI Tests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = C82D32230C96FF767EC26C77 /* Build configuration list for PBXNativeTarget "Listless watchOS UI Tests" */;
+ buildPhases = (
+ 6B19BAB29A5C66A5E7516EB8 /* Sources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 05AD385080A44E4C08F53091 /* PBXTargetDependency */,
+ );
+ name = "Listless watchOS UI Tests";
+ packageProductDependencies = (
+ );
+ productName = "Listless watchOS UI Tests";
+ productReference = 2523E01C8F130C2799123479 /* Listless watchOS UI Tests.xctest */;
+ productType = "com.apple.product-type.bundle.ui-testing";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -701,6 +734,11 @@
ProvisioningStyle = Automatic;
TestTargetID = 0FB4F07A37999BBC6DFE4DBB;
};
+ FAC694EFB69F0D9F12557137 = {
+ DevelopmentTeam = 7TD7PZBNXP;
+ ProvisioningStyle = Automatic;
+ TestTargetID = 9BDC1B2175AB9CE26790448D;
+ };
};
};
buildConfigurationList = CAACA40A09D5F78ECE7A0EDF /* Build configuration list for PBXProject "Listless" */;
@@ -724,6 +762,7 @@
ECF4D3D0597D0648A1FBC4A4 /* Listless macOS UI Tests */,
7F0B17D1EC9FD4A80BC99002 /* Listless macOS Unit Tests */,
9BDC1B2175AB9CE26790448D /* Listless watchOS */,
+ FAC694EFB69F0D9F12557137 /* Listless watchOS UI Tests */,
);
};
/* End PBXProject section */
@@ -778,6 +817,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ DD2F098B472D048B66CEC8B1 /* ListlessMacScreenshots.swift in Sources */,
5E6BE0BA881F6CAEF455D9ED /* ListlessMacUITests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -886,6 +926,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 6B19BAB29A5C66A5E7516EB8 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 4CA383C2B90DA7EF080BDABB /* ListlessWatchScreenshots.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
8A2F25417981C1E116479B25 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -925,6 +973,11 @@
target = 34A03D42B91730DEAC2EBD8E /* Listless iOS */;
targetProxy = 6F7E207AE10E1516EF8A683C /* PBXContainerItemProxy */;
};
+ 05AD385080A44E4C08F53091 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 9BDC1B2175AB9CE26790448D /* Listless watchOS */;
+ targetProxy = 47C07B5C46AB5A2CD8741632 /* PBXContainerItemProxy */;
+ };
56F7D0BF0FAA18615E95FB41 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 0FB4F07A37999BBC6DFE4DBB /* Listless macOS */;
@@ -979,6 +1032,25 @@
};
name = Release;
};
+ 10A7B7F1EF302597A276E649 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ GENERATE_INFOPLIST_FILE = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = net.inqk.listless.watchos.uitests;
+ SDKROOT = watchos;
+ SKIP_INSTALL = YES;
+ TARGETED_DEVICE_FAMILY = 4;
+ TEST_TARGET_NAME = "Listless watchOS";
+ };
+ name = Debug;
+ };
2D4E3CC5FF8E6299F754CCFC /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -1012,6 +1084,25 @@
};
name = Release;
};
+ 8AEF59A64EEB64E23C516357 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ GENERATE_INFOPLIST_FILE = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = net.inqk.listless.watchos.uitests;
+ SDKROOT = watchos;
+ SKIP_INSTALL = YES;
+ TARGETED_DEVICE_FAMILY = 4;
+ TEST_TARGET_NAME = "Listless watchOS";
+ };
+ name = Release;
+ };
A59B52CB6CD91C01F164C0F6 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -1374,6 +1465,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
+ C82D32230C96FF767EC26C77 /* Build configuration list for PBXNativeTarget "Listless watchOS UI Tests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 10A7B7F1EF302597A276E649 /* Debug */,
+ 8AEF59A64EEB64E23C516357 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
CAACA40A09D5F78ECE7A0EDF /* Build configuration list for PBXProject "Listless" */ = {
isa = XCConfigurationList;
buildConfigurations = (
diff --git a/Listless.xcodeproj/xcshareddata/xcschemes/Listless watchOS.xcscheme b/Listless.xcodeproj/xcshareddata/xcschemes/Listless watchOS.xcscheme
@@ -55,6 +55,16 @@
</BuildableReference>
</MacroExpansion>
<Testables>
+ <TestableReference
+ skipped = "NO">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "FAC694EFB69F0D9F12557137"
+ BuildableName = "Listless watchOS UI Tests.xctest"
+ BlueprintName = "Listless watchOS UI Tests"
+ ReferencedContainer = "container:Listless.xcodeproj">
+ </BuildableReference>
+ </TestableReference>
</Testables>
</TestAction>
<LaunchAction
diff --git a/ListlessMac/ListlessMacApp.swift b/ListlessMac/ListlessMacApp.swift
@@ -28,9 +28,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
}
override init() {
- let isUITesting = ProcessInfo.processInfo.arguments.contains("UI_TESTING")
+ let args = ProcessInfo.processInfo.arguments
+ let isUITesting = args.contains("UI_TESTING")
persistenceController = isUITesting ? PersistenceController(inMemory: true) : .shared
super.init()
+
+ if isUITesting, args.contains("SCREENSHOT_LIGHT") {
+ UserDefaults.standard.set(1, forKey: Self.appearanceModeKey)
+ } else if isUITesting, args.contains("SCREENSHOT_DARK") {
+ UserDefaults.standard.set(2, forKey: Self.appearanceModeKey)
+ }
}
func applicationDidFinishLaunching(_ notification: Notification) {
diff --git a/ListlessWatch/ListlessWatchApp.swift b/ListlessWatch/ListlessWatchApp.swift
@@ -2,11 +2,23 @@ import SwiftUI
@main
struct ListlessWatchApp: App {
- private let persistenceController = PersistenceController.shared
+ private let persistenceController: PersistenceController
private let keyValueSyncBridge = KeyValueSyncBridge(keys: ["listName", "colorTheme"])
init() {
+ let args = ProcessInfo.processInfo.arguments
+ let isUITesting = args.contains("UI_TESTING")
+ persistenceController = isUITesting ? PersistenceController(inMemory: true) : .shared
keyValueSyncBridge.start()
+
+ if isUITesting, args.contains("SCREENSHOT_SEED") {
+ let store = ItemStore(persistenceController: persistenceController)
+ try? store.createItem(title: "Make smartband", sortOrder: 1000)
+ try? store.createItem(title: "Add custom faces", sortOrder: 2000)
+ let item = try? store.createItem(title: "Focus on fitness", sortOrder: 3000)
+ if let item { try? store.complete(itemID: item.id) }
+ try? store.save()
+ }
}
var body: some Scene {
diff --git a/Scripts/screenshots-ios-compose.swift b/Scripts/screenshots-ios-compose.swift
@@ -0,0 +1,115 @@
+#!/usr/bin/env swift
+
+import AppKit
+import CoreGraphics
+import CoreText
+import Foundation
+import ImageIO
+
+guard CommandLine.arguments.count >= 4 else {
+ fputs(
+ "Usage: screenshots-ios-compose.swift <input.png> <output.png> <text> [width height]\n",
+ stderr)
+ exit(1)
+}
+
+let inputPath = CommandLine.arguments[1]
+let outputPath = CommandLine.arguments[2]
+let text = CommandLine.arguments[3]
+
+// Output dimensions (default: 6.9" iPhone for App Store)
+let canvasWidth: CGFloat =
+ CommandLine.arguments.count >= 5 ? CGFloat(Int(CommandLine.arguments[4]) ?? 1320) : 1320
+let canvasHeight: CGFloat =
+ CommandLine.arguments.count >= 6 ? CGFloat(Int(CommandLine.arguments[5]) ?? 2868) : 2868
+
+// Background: rgb(30, 16, 40)
+let bgColor = CGColor(red: 30.0 / 255.0, green: 16.0 / 255.0, blue: 40.0 / 255.0, alpha: 1.0)
+
+// Load framed device image
+guard let dataProvider = CGDataProvider(filename: inputPath),
+ let deviceImage = CGImage(
+ pngDataProviderSource: dataProvider, decode: nil, shouldInterpolate: true,
+ intent: .defaultIntent)
+else {
+ fputs("Failed to load image: \(inputPath)\n", stderr)
+ exit(1)
+}
+
+let deviceWidth = CGFloat(deviceImage.width)
+let deviceHeight = CGFloat(deviceImage.height)
+
+// Create canvas
+guard
+ let context = CGContext(
+ data: nil,
+ width: Int(canvasWidth),
+ height: Int(canvasHeight),
+ bitsPerComponent: 8,
+ bytesPerRow: 0,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ )
+else {
+ fputs("Failed to create graphics context\n", stderr)
+ exit(1)
+}
+
+// Fill background
+context.setFillColor(bgColor)
+context.fill(CGRect(x: 0, y: 0, width: canvasWidth, height: canvasHeight))
+
+// Scale device image to fit width with padding, leaving space for text
+let isCompact = canvasWidth < 600
+let isLandscape = canvasWidth > canvasHeight
+let bottomPadding: CGFloat = isCompact ? -60 : isLandscape ? -350 : 60
+let sidePadding: CGFloat = isCompact ? 20 : isLandscape ? 20 : 60
+let topReserved: CGFloat = isCompact ? 90 : isLandscape ? 200 : 300
+let maxDeviceWidth = canvasWidth - sidePadding * 2
+let maxDeviceHeight = canvasHeight - bottomPadding - topReserved
+let scale = min(maxDeviceWidth / deviceWidth, maxDeviceHeight / deviceHeight, 1.0)
+let scaledWidth = deviceWidth * scale
+let scaledHeight = deviceHeight * scale
+let deviceX = (canvasWidth - scaledWidth) / 2
+let deviceY = bottomPadding // CG origin is bottom-left
+
+context.draw(
+ deviceImage, in: CGRect(x: deviceX, y: deviceY, width: scaledWidth, height: scaledHeight))
+
+// Draw text centered above device
+let fontSize: CGFloat = isCompact ? canvasWidth * 0.08 : isLandscape ? canvasHeight * 0.06 : canvasWidth * 0.0545
+let font = NSFont.systemFont(ofSize: fontSize, weight: .bold)
+let attributes: [NSAttributedString.Key: Any] = [
+ .font: font,
+ .foregroundColor: NSColor.white,
+]
+let attrString = NSAttributedString(string: text, attributes: attributes)
+let ctLine = CTLineCreateWithAttributedString(attrString)
+let textBounds = CTLineGetBoundsWithOptions(ctLine, .useOpticalBounds)
+
+let textAreaTop = isLandscape ? canvasHeight - 40 : canvasHeight
+let textAreaBottom = deviceY + scaledHeight
+let textX = (canvasWidth - textBounds.width) / 2 - textBounds.origin.x
+let textY = (textAreaTop + textAreaBottom) / 2 - textBounds.height / 2 - textBounds.origin.y
+
+context.textPosition = CGPoint(x: textX, y: textY)
+CTLineDraw(ctLine, context)
+
+// Save
+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-ipad.sh b/Scripts/screenshots-ipad.sh
@@ -0,0 +1,139 @@
+#!/bin/bash
+set -euo pipefail
+
+DO_COMPOSE=false
+while getopts "c" opt; do
+ case $opt in
+ c) DO_COMPOSE=true ;;
+ *) ;;
+ esac
+done
+shift $((OPTIND - 1))
+
+MAJOR="${1:-26}"
+RUNTIME=$(xcrun simctl list runtimes available \
+ | grep "iOS ${MAJOR}\." \
+ | sed 's/.*iOS \([0-9.]*\).*/\1/' \
+ | sort -t. -k1,1n -k2,2n \
+ | tail -1)
+
+if [ -z "$RUNTIME" ]; then
+ echo "No available iOS ${MAJOR}.x simulator runtime found." >&2
+ exit 1
+fi
+
+# Prefer iPad Pro 13" for ASC screenshots (2064x2752)
+DEVICE=$(xcrun simctl list devices available "iOS ${RUNTIME}" \
+ | grep "iPad Pro 13" \
+ | head -1 \
+ | sed 's/^ *\(.*\) ([A-F0-9-]*).*/\1/')
+if [ -z "$DEVICE" ]; then
+ DEVICE=$(xcrun simctl list devices available "iOS ${RUNTIME}" \
+ | grep "iPad" \
+ | head -1 \
+ | sed 's/^ *\(.*\) ([A-F0-9-]*).*/\1/')
+fi
+
+if [ -z "$DEVICE" ]; then
+ echo "No available iPad simulator found for iOS ${RUNTIME}." >&2
+ exit 1
+fi
+
+echo "Using ${DEVICE}, iOS ${RUNTIME}"
+
+# Boot simulator if needed
+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
+
+# Override status bar: 9:41, full signal, full battery
+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"
+
+SCREENSHOT_TMP="/tmp/listless-screenshots"
+FRAMED_TMP="/tmp/listless-framed"
+MARKETING_DIR="$(pwd)/Marketing"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+# iPad Pro 13" ASC canvas dimensions
+CANVAS_WIDTH=2064
+CANVAS_HEIGHT=2752
+
+CAPTIONS=(
+ "Syncs via iCloud"
+ "Minimalist"
+ "Gesture Based"
+ "(Slightly) Customisable"
+)
+
+# Run screenshot tests (same iOS UI test target, different simulator)
+xcodebuild test \
+ -scheme "Listless iOS" \
+ -destination "platform=iOS Simulator,name=${DEVICE},OS=${RUNTIME}" \
+ -only-testing:"Listless iOS UI Tests/ListlessiOSScreenshots" \
+ 2>&1
+
+# Clear status bar override (ignore errors if simulator already shut down)
+xcrun simctl status_bar "$UDID" clear 2>/dev/null || true
+
+if ! ls "${SCREENSHOT_TMP}"/*.png 1>/dev/null 2>&1; then
+ echo "No screenshots found in ${SCREENSHOT_TMP}."
+ exit 1
+fi
+
+mkdir -p "${MARKETING_DIR}"
+
+if [ "$DO_COMPOSE" = true ]; then
+ mkdir -p "${FRAMED_TMP}"
+
+ echo ""
+ echo "Framing screenshots..."
+ n=0
+ for file in "${SCREENSHOT_TMP}"/0*.png; do
+ shortcuts run "Frame Screenshots" -i "$file" -o "${FRAMED_TMP}/framed_${n}.png"
+ echo " Framed screenshot ${n}"
+ n=$((n + 1))
+ done
+
+ echo "Composing final images..."
+ n=0
+ for file in "${FRAMED_TMP}"/framed_*.png; do
+ caption="${CAPTIONS[$n]:-}"
+ swift "${SCRIPT_DIR}/screenshots-ios-compose.swift" "$file" "${MARKETING_DIR}/ipad_${n}.png" "$caption" "$CANVAS_WIDTH" "$CANVAS_HEIGHT"
+ echo " ipad_${n}.png — ${caption}"
+ n=$((n + 1))
+ done
+
+ rm -rf "${FRAMED_TMP}"
+else
+ OUTPUT_DIR="$(pwd)"
+ echo ""
+ n=0
+ for file in "${SCREENSHOT_TMP}"/0*.png; do
+ cp "$file" "${OUTPUT_DIR}/ipad_${n}.png"
+ n=$((n + 1))
+ done
+fi
+
+rm -rf "${SCREENSHOT_TMP}"
+
+if [ "$DO_COMPOSE" = true ]; then
+ echo ""
+ echo "Screenshots saved to ${MARKETING_DIR}/"
+ ls -la "${MARKETING_DIR}"/ipad_*.png
+else
+ echo ""
+ echo "Screenshots saved to ${OUTPUT_DIR}/"
+ ls -la "${OUTPUT_DIR}"/ipad_*.png
+fi
diff --git a/Scripts/screenshots-iphone-compose.swift b/Scripts/screenshots-iphone-compose.swift
@@ -1,109 +0,0 @@
-#!/usr/bin/env swift
-
-import AppKit
-import CoreGraphics
-import CoreText
-import Foundation
-import ImageIO
-
-guard CommandLine.arguments.count >= 4 else {
- fputs("Usage: compose-screenshot.swift <input.png> <output.png> <text>\n", stderr)
- exit(1)
-}
-
-let inputPath = CommandLine.arguments[1]
-let outputPath = CommandLine.arguments[2]
-let text = CommandLine.arguments[3]
-
-// Output dimensions (6.9" App Store)
-let canvasWidth: CGFloat = 1320
-let canvasHeight: CGFloat = 2868
-
-// Background: rgb(30, 16, 40)
-let bgColor = CGColor(red: 30.0 / 255.0, green: 16.0 / 255.0, blue: 40.0 / 255.0, alpha: 1.0)
-
-// Load framed device image
-guard let dataProvider = CGDataProvider(filename: inputPath),
- let deviceImage = CGImage(
- pngDataProviderSource: dataProvider, decode: nil, shouldInterpolate: true,
- intent: .defaultIntent)
-else {
- fputs("Failed to load image: \(inputPath)\n", stderr)
- exit(1)
-}
-
-let deviceWidth = CGFloat(deviceImage.width)
-let deviceHeight = CGFloat(deviceImage.height)
-
-// Create canvas
-guard
- let context = CGContext(
- data: nil,
- width: Int(canvasWidth),
- height: Int(canvasHeight),
- bitsPerComponent: 8,
- bytesPerRow: 0,
- space: CGColorSpaceCreateDeviceRGB(),
- bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
- )
-else {
- fputs("Failed to create graphics context\n", stderr)
- exit(1)
-}
-
-// Fill background
-context.setFillColor(bgColor)
-context.fill(CGRect(x: 0, y: 0, width: canvasWidth, height: canvasHeight))
-
-// Scale device image to fit width with padding, leaving space for text
-let bottomPadding: CGFloat = 60
-let sidePadding: CGFloat = 60
-let topReserved: CGFloat = 300
-let maxDeviceWidth = canvasWidth - sidePadding * 2
-let maxDeviceHeight = canvasHeight - bottomPadding - topReserved
-let scale = min(maxDeviceWidth / deviceWidth, maxDeviceHeight / deviceHeight, 1.0)
-let scaledWidth = deviceWidth * scale
-let scaledHeight = deviceHeight * scale
-let deviceX = (canvasWidth - scaledWidth) / 2
-let deviceY = bottomPadding // CG origin is bottom-left
-
-context.draw(
- deviceImage, in: CGRect(x: deviceX, y: deviceY, width: scaledWidth, height: scaledHeight))
-
-// Draw text centered above device
-let fontSize: CGFloat = 72
-let font = NSFont.systemFont(ofSize: fontSize, weight: .bold)
-let attributes: [NSAttributedString.Key: Any] = [
- .font: font,
- .foregroundColor: NSColor.white,
-]
-let attrString = NSAttributedString(string: text, attributes: attributes)
-let ctLine = CTLineCreateWithAttributedString(attrString)
-let textBounds = CTLineGetBoundsWithOptions(ctLine, .useOpticalBounds)
-
-let textAreaTop = canvasHeight
-let textAreaBottom = deviceY + scaledHeight
-let textX = (canvasWidth - textBounds.width) / 2 - textBounds.origin.x
-let textY = (textAreaTop + textAreaBottom) / 2 - textBounds.height / 2 - textBounds.origin.y
-
-context.textPosition = CGPoint(x: textX, y: textY)
-CTLineDraw(ctLine, context)
-
-// Save
-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
@@ -101,7 +101,7 @@ if [ "$DO_COMPOSE" = true ]; then
n=0
for file in "${FRAMED_TMP}"/framed_*.png; do
caption="${CAPTIONS[$n]:-}"
- swift "${SCRIPT_DIR}/screenshots-iphone-compose.swift" "$file" "${MARKETING_DIR}/iphone_${n}.png" "$caption"
+ swift "${SCRIPT_DIR}/screenshots-ios-compose.swift" "$file" "${MARKETING_DIR}/iphone_${n}.png" "$caption"
echo " iphone_${n}.png — ${caption}"
n=$((n + 1))
done
diff --git a/Scripts/screenshots-mac-desktop.swift b/Scripts/screenshots-mac-desktop.swift
@@ -0,0 +1,178 @@
+#!/usr/bin/env swift
+
+import AppKit
+import CoreGraphics
+import CoreText
+import Foundation
+import ImageIO
+
+guard CommandLine.arguments.count >= 3 else {
+ fputs("Usage: screenshots-mac-desktop.swift <window.png> <output.png> [wallpaper]\n", stderr)
+ exit(1)
+}
+
+let windowPath = CommandLine.arguments[1]
+let outputPath = CommandLine.arguments[2]
+let wallpaperPath =
+ CommandLine.arguments.count >= 4
+ ? CommandLine.arguments[3]
+ : "sequoia-light.jpg"
+
+// MacBook Air 13" 4th Gen native resolution
+let canvasWidth: CGFloat = 2560
+let canvasHeight: CGFloat = 1664
+
+// Load wallpaper (supports HEIC, JPEG, PNG via ImageIO)
+guard let wpData = try? Data(contentsOf: URL(fileURLWithPath: wallpaperPath)),
+ let wpSource = CGImageSourceCreateWithData(wpData as CFData, nil),
+ let wallpaper = CGImageSourceCreateImageAtIndex(wpSource, 0, nil)
+else {
+ fputs("Failed to load wallpaper: \(wallpaperPath)\n", stderr)
+ exit(1)
+}
+
+// Load window screenshot
+guard let winProvider = CGDataProvider(filename: windowPath),
+ let windowImage = CGImage(
+ pngDataProviderSource: winProvider, decode: nil, shouldInterpolate: true,
+ intent: .defaultIntent)
+else {
+ fputs("Failed to load window image: \(windowPath)\n", stderr)
+ exit(1)
+}
+
+// Create canvas
+guard
+ let context = CGContext(
+ data: nil,
+ width: Int(canvasWidth),
+ height: Int(canvasHeight),
+ bitsPerComponent: 8,
+ bytesPerRow: 0,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ )
+else {
+ fputs("Failed to create graphics context\n", stderr)
+ exit(1)
+}
+
+// Draw wallpaper: scale to fit width, align to top, crop from bottom
+let wpWidth = CGFloat(wallpaper.width)
+let wpHeight = CGFloat(wallpaper.height)
+let wpScale = canvasWidth / wpWidth
+let scaledWpHeight = wpHeight * wpScale
+// CG origin is bottom-left; align top edge of wallpaper to top of canvas
+let wpY = canvasHeight - scaledWpHeight
+context.draw(
+ wallpaper, in: CGRect(x: 0, y: wpY, width: canvasWidth, height: scaledWpHeight))
+
+// Draw menu bar: translucent strip at the top (@2x: 60px ≈ 30pt)
+let menuBarHeight: CGFloat = 60
+let menuBarY = canvasHeight - menuBarHeight
+context.setFillColor(CGColor(red: 1, green: 1, blue: 1, alpha: 0.25))
+context.fill(CGRect(x: 0, y: menuBarY, width: canvasWidth, height: menuBarHeight))
+
+// Helper to draw a menu bar SF Symbol via CTFont
+func drawMenuSymbol(_ name: String, at x: CGFloat, size: CGFloat) -> CGFloat {
+ guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil) else {
+ return x
+ }
+ let config = NSImage.SymbolConfiguration(pointSize: size, weight: .regular)
+ let configured = image.withSymbolConfiguration(config) ?? image
+ // Tint the symbol
+ let tinted = NSImage(size: configured.size, flipped: false) { rect in
+ menuTextColor.set()
+ rect.fill(using: .sourceOver)
+ configured.draw(in: rect, from: .zero, operation: .destinationIn, fraction: 1.0)
+ return true
+ }
+ let imgWidth = tinted.size.width
+ let imgHeight = tinted.size.height
+ let imgY = menuBarY + (menuBarHeight - imgHeight) / 2
+ guard let cgImage = tinted.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
+ return x
+ }
+ context.draw(cgImage, in: CGRect(x: x, y: imgY, width: imgWidth, height: imgHeight))
+ return x + imgWidth
+}
+
+// Helper to draw menu bar text
+let menuTextColor = NSColor(white: 0.0, alpha: 0.85)
+func drawMenuText(_ text: String, at x: CGFloat, size: CGFloat, weight: NSFont.Weight = .regular)
+ -> CGFloat
+{
+ let font = NSFont.systemFont(ofSize: size, weight: weight)
+ let attrs: [NSAttributedString.Key: Any] = [
+ .font: font,
+ .foregroundColor: menuTextColor,
+ ]
+ let attrStr = NSAttributedString(string: text, attributes: attrs)
+ let line = CTLineCreateWithAttributedString(attrStr)
+ let bounds = CTLineGetBoundsWithOptions(line, .useOpticalBounds)
+ let y = menuBarY + (menuBarHeight - bounds.height) / 2 - bounds.origin.y
+ context.textPosition = CGPoint(x: x, y: y)
+ CTLineDraw(line, context)
+ return x + bounds.width
+}
+
+// Left side: Apple logo, app name, menus
+let appleFont = NSFont(name: "SF Pro Display", size: 42) ?? NSFont.systemFont(ofSize: 42)
+let appleAttrs: [NSAttributedString.Key: Any] = [
+ .font: appleFont,
+ .foregroundColor: menuTextColor,
+]
+let appleString = NSAttributedString(string: "\u{F8FF}", attributes: appleAttrs)
+let appleLine = CTLineCreateWithAttributedString(appleString)
+let appleBounds = CTLineGetBoundsWithOptions(appleLine, .useOpticalBounds)
+let appleX: CGFloat = 32
+let appleTextY = menuBarY + (menuBarHeight - appleBounds.height) / 2 - appleBounds.origin.y
+context.textPosition = CGPoint(x: appleX, y: appleTextY)
+CTLineDraw(appleLine, context)
+
+var curX = appleX + appleBounds.width + 40
+curX = drawMenuText("Listless", at: curX, size: 28, weight: .bold) + 36
+curX = drawMenuText("File", at: curX, size: 28) + 36
+curX = drawMenuText("Edit", at: curX, size: 28) + 36
+curX = drawMenuText("View", at: curX, size: 28) + 36
+curX = drawMenuText("Window", at: curX, size: 28) + 36
+_ = drawMenuText("Help", at: curX, size: 28)
+
+// Right side: icons, date and time
+var rightX = canvasWidth - 260
+_ = drawMenuText("Wed 9 Apr 9:41 AM", at: rightX, size: 28)
+rightX -= 20
+_ = drawMenuSymbol("switch.2", at: rightX - 44, size: 32)
+_ = drawMenuSymbol("magnifyingglass", at: rightX - 100, size: 28)
+
+// Scale and draw window screenshot offset slightly right of centre
+let winWidth = CGFloat(windowImage.width)
+let winHeight = CGFloat(windowImage.height)
+let winScale = (canvasWidth * 0.4) / winWidth
+let scaledWinWidth = winWidth * winScale
+let scaledWinHeight = winHeight * winScale
+let winX = (canvasWidth - scaledWinWidth) / 2 + 400
+let availableHeight = canvasHeight - menuBarHeight
+let winY = (availableHeight - scaledWinHeight) / 2 + 150
+context.draw(
+ windowImage,
+ in: CGRect(x: winX, y: winY, width: scaledWinWidth, height: scaledWinHeight))
+
+// Save
+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-mac.sh b/Scripts/screenshots-mac.sh
@@ -0,0 +1,161 @@
+#!/bin/bash
+set -euo pipefail
+
+DO_COMPOSE=false
+while getopts "c" opt; do
+ case $opt in
+ c) DO_COMPOSE=true ;;
+ *) ;;
+ esac
+done
+shift $((OPTIND - 1))
+
+REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+SECRETS="$REPO_ROOT/.asc/secrets.sh"
+
+if [[ ! -f "$SECRETS" ]]; then
+ echo "Error: $SECRETS not found. See Scripts/secrets.sh.example for the required format."
+ exit 1
+fi
+
+source "$SECRETS"
+
+DEV_P12="$REPO_ROOT/.asc/dev.p12"
+TMP_KEYCHAIN="$REPO_ROOT/.asc/build.keychain-db"
+
+echo "==> Setting up temporary keychain..."
+security delete-keychain "$TMP_KEYCHAIN" 2>/dev/null || true
+security create-keychain -p "$TMP_KEYCHAIN_PASS" "$TMP_KEYCHAIN"
+security unlock-keychain -p "$TMP_KEYCHAIN_PASS" "$TMP_KEYCHAIN"
+security set-keychain-settings -lut 21600 "$TMP_KEYCHAIN"
+security import "$DEV_P12" -k "$TMP_KEYCHAIN" -P "$DEV_P12_PASS" \
+ -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild
+security set-key-partition-list -S apple-tool:,apple:,codesign:,productbuild: \
+ -s -k "$TMP_KEYCHAIN_PASS" "$TMP_KEYCHAIN"
+security list-keychains -d user -s "$TMP_KEYCHAIN" ~/Library/Keychains/login.keychain-db
+
+cleanup_keychain() {
+ echo "==> Restoring keychain search list..."
+ security list-keychains -d user -s ~/Library/Keychains/login.keychain-db
+ security default-keychain -d user -s ~/Library/Keychains/login.keychain-db
+ security delete-keychain "$TMP_KEYCHAIN" 2>/dev/null || true
+}
+trap cleanup_keychain EXIT
+
+SCREENSHOT_TMP="/tmp/listless-screenshots"
+FRAMED_TMP="/tmp/listless-framed"
+MARKETING_DIR="$(pwd)/Marketing"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+# Mac ASC canvas dimensions (16:10)
+CANVAS_WIDTH=2880
+CANVAS_HEIGHT=1800
+
+CAPTIONS=(
+ "Syncs via iCloud"
+ "Dark Mode"
+ "Natural Selection"
+)
+
+XCRESULT_DIR=$(mktemp -d)
+
+# Run screenshot tests (attachments stored in xcresult bundle)
+echo "==> Running macOS screenshot tests..."
+xcodebuild test \
+ -scheme "Listless macOS" \
+ -destination "platform=macOS" \
+ -only-testing:"Listless macOS UI Tests/ListlessMacScreenshots" \
+ -resultBundlePath "${XCRESULT_DIR}/result.xcresult" \
+ 2>&1
+
+# Extract attachments from xcresult bundle
+echo "==> Extracting screenshots from xcresult..."
+rm -rf "${SCREENSHOT_TMP}"
+mkdir -p "${SCREENSHOT_TMP}"
+xcrun xcresulttool export attachments \
+ --path "${XCRESULT_DIR}/result.xcresult" \
+ --output-path "${SCREENSHOT_TMP}"
+
+# Rename attachments using manifest.json (attachment name -> file)
+osascript -l JavaScript -e "
+var manifest = JSON.parse($.NSString.alloc.initWithDataEncoding(
+ $.NSData.dataWithContentsOfFile('${SCREENSHOT_TMP}/manifest.json'),
+ $.NSUTF8StringEncoding).js);
+var fm = $.NSFileManager.defaultManager;
+manifest.forEach(function(entry) {
+ (entry.attachments || []).forEach(function(att) {
+ var exported = att.exportedFileName || '';
+ var suggested = att.suggestedHumanReadableName || '';
+ if (exported && suggested) {
+ var name = suggested.replace(/_\d+_[A-F0-9-]+\.png$/, '');
+ var src = '${SCREENSHOT_TMP}/' + exported;
+ var dst = '${SCREENSHOT_TMP}/' + name + '.png';
+ fm.moveItemAtPathToPathError(src, dst, null);
+ }
+ });
+});"
+
+rm -rf "${XCRESULT_DIR}"
+
+if ! ls "${SCREENSHOT_TMP}"/*.png 1>/dev/null 2>&1; then
+ echo "No screenshots found in ${SCREENSHOT_TMP}."
+ exit 1
+fi
+
+mkdir -p "${MARKETING_DIR}"
+
+if [ "$DO_COMPOSE" = true ]; then
+ DESKTOP_TMP="/tmp/listless-desktop"
+ mkdir -p "${DESKTOP_TMP}"
+ mkdir -p "${FRAMED_TMP}"
+
+ echo ""
+ echo "Composing desktop images..."
+ n=0
+ for file in "${SCREENSHOT_TMP}"/0*.png; do
+ swift "${SCRIPT_DIR}/screenshots-mac-desktop.swift" "$file" "${DESKTOP_TMP}/desktop_${n}.png" "$(pwd)/sequoia-light.jpg"
+ echo " desktop_${n}.png"
+ n=$((n + 1))
+ done
+
+ echo "Framing screenshots..."
+ n=0
+ for file in "${DESKTOP_TMP}"/desktop_*.png; do
+ shortcuts run "Frame Screenshots" -i "$file" -o "${FRAMED_TMP}/framed_${n}.png"
+ echo " Framed screenshot ${n}"
+ n=$((n + 1))
+ done
+
+ rm -rf "${DESKTOP_TMP}"
+
+ echo "Composing final images..."
+ n=0
+ for file in "${FRAMED_TMP}"/framed_*.png; do
+ caption="${CAPTIONS[$n]:-}"
+ swift "${SCRIPT_DIR}/screenshots-ios-compose.swift" "$file" "${MARKETING_DIR}/mac_${n}.png" "$caption" "$CANVAS_WIDTH" "$CANVAS_HEIGHT"
+ echo " mac_${n}.png — ${caption}"
+ n=$((n + 1))
+ done
+
+ rm -rf "${FRAMED_TMP}"
+else
+ OUTPUT_DIR="$(pwd)"
+ echo ""
+ n=0
+ for file in "${SCREENSHOT_TMP}"/0*.png; do
+ cp "$file" "${OUTPUT_DIR}/mac_${n}.png"
+ n=$((n + 1))
+ done
+fi
+
+rm -rf "${SCREENSHOT_TMP}"
+
+if [ "$DO_COMPOSE" = true ]; then
+ echo ""
+ echo "Screenshots saved to ${MARKETING_DIR}/"
+ ls -la "${MARKETING_DIR}"/mac_*.png
+else
+ echo ""
+ echo "Screenshots saved to ${OUTPUT_DIR}/"
+ ls -la "${OUTPUT_DIR}"/mac_*.png
+fi
diff --git a/Scripts/screenshots-watch.sh b/Scripts/screenshots-watch.sh
@@ -0,0 +1,96 @@
+#!/bin/bash
+set -euo pipefail
+
+DO_COMPOSE=false
+while getopts "c" opt; do
+ case $opt in
+ c) DO_COMPOSE=true ;;
+ *) ;;
+ esac
+done
+shift $((OPTIND - 1))
+
+MAJOR="${1:-11}"
+RUNTIME=$(xcrun simctl list runtimes available \
+ | grep "watchOS ${MAJOR}\." \
+ | sed 's/.*watchOS \([0-9.]*\).*/\1/' \
+ | sort -t. -k1,1n -k2,2n \
+ | tail -1)
+
+if [ -z "$RUNTIME" ]; then
+ echo "No available watchOS ${MAJOR}.x simulator runtime found." >&2
+ exit 1
+fi
+
+DEVICE=$(xcrun simctl list devices available "watchOS ${RUNTIME}" \
+ | grep "Apple Watch" \
+ | head -1 \
+ | sed 's/^ *\(.*\) ([A-F0-9-]*).*/\1/')
+
+if [ -z "$DEVICE" ]; then
+ echo "No available Apple Watch simulator found for watchOS ${RUNTIME}." >&2
+ exit 1
+fi
+
+echo "Using ${DEVICE}, watchOS ${RUNTIME}"
+
+# Boot simulator if needed
+UDID=$(xcrun simctl list devices available "watchOS ${RUNTIME}" \
+ | grep "$DEVICE" \
+ | head -1 \
+ | sed 's/.*(\([A-F0-9-]*\)).*/\1/')
+
+xcrun simctl boot "$UDID" 2>/dev/null || true
+
+SCREENSHOT_TMP="/tmp/listless-screenshots"
+FRAMED_TMP="/tmp/listless-framed"
+MARKETING_DIR="$(pwd)/Marketing"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+CAPTION="Get Up and Do"
+
+# Run screenshot tests (writes to SCREENSHOT_TMP)
+xcodebuild test \
+ -scheme "Listless watchOS" \
+ -destination "platform=watchOS Simulator,name=${DEVICE},OS=${RUNTIME}" \
+ -only-testing:"Listless watchOS UI Tests/ListlessWatchScreenshots" \
+ 2>&1
+
+if ! ls "${SCREENSHOT_TMP}"/*.png 1>/dev/null 2>&1; then
+ echo "No screenshots found in ${SCREENSHOT_TMP}."
+ exit 1
+fi
+
+mkdir -p "${MARKETING_DIR}"
+
+if [ "$DO_COMPOSE" = true ]; then
+ mkdir -p "${FRAMED_TMP}"
+
+ echo ""
+ echo "Framing screenshot..."
+ file="${SCREENSHOT_TMP}/01-items.png"
+ shortcuts run "Frame Screenshots" -i "$file" -o "${FRAMED_TMP}/framed_0.png"
+ echo " Framed screenshot"
+
+ echo "Composing final image..."
+ swift "${SCRIPT_DIR}/screenshots-ios-compose.swift" "${FRAMED_TMP}/framed_0.png" "${MARKETING_DIR}/watch_0.png" "$CAPTION" 422 514
+ echo " watch_0.png — ${CAPTION}"
+
+ rm -rf "${FRAMED_TMP}"
+else
+ OUTPUT_DIR="$(pwd)"
+ echo ""
+ cp "${SCREENSHOT_TMP}/01-items.png" "${OUTPUT_DIR}/watch_0.png"
+fi
+
+rm -rf "${SCREENSHOT_TMP}"
+
+if [ "$DO_COMPOSE" = true ]; then
+ echo ""
+ echo "Screenshots saved to ${MARKETING_DIR}/"
+ ls -la "${MARKETING_DIR}"/watch_*.png
+else
+ echo ""
+ echo "Screenshots saved to ${OUTPUT_DIR}/"
+ ls -la "${OUTPUT_DIR}"/watch_*.png
+fi
diff --git a/Tests/UI/ListlessMacScreenshots.swift b/Tests/UI/ListlessMacScreenshots.swift
@@ -0,0 +1,179 @@
+import CoreGraphics
+import XCTest
+
+@MainActor
+final class ListlessMacScreenshots: XCTestCase {
+ var app: XCUIApplication!
+
+ override func tearDownWithError() throws {
+ app.terminate()
+ }
+
+ // MARK: - Helpers
+
+ private func launchApp(_ additionalArgs: [String] = []) {
+ app = XCUIApplication()
+ let appearance = additionalArgs.contains("SCREENSHOT_DARK") ? "SCREENSHOT_DARK" : "SCREENSHOT_LIGHT"
+ app.launchArguments = ["UI_TESTING", appearance] + additionalArgs.filter { $0 != "SCREENSHOT_DARK" }
+ app.launch()
+ }
+
+ var draftTextField: XCUIElement {
+ app.textFields["draft-row-append"]
+ }
+
+ func itemText(_ title: String) -> XCUIElement {
+ app.textFields.matching(
+ NSPredicate(format: "identifier BEGINSWITH 'item-text-' AND value == %@", title)
+ ).firstMatch
+ }
+
+ func createItem(_ title: String) {
+ let textField = draftTextField
+ if !textField.exists {
+ app.typeKey("n", modifierFlags: .command)
+ if !textField.waitForExistence(timeout: 2) {
+ XCTFail("Draft text field should appear after Cmd+N")
+ return
+ }
+ }
+ textField.click()
+ textField.typeText(title)
+ textField.typeKey(.return, modifierFlags: [])
+ }
+
+ func itemCheckbox(at index: Int) -> XCUIElement {
+ app.buttons.matching(identifier: "item-checkbox").element(boundBy: index)
+ }
+
+ func navigateToItem(at index: Int) {
+ app.typeKey(.escape, modifierFlags: [])
+ for _ in 0...index {
+ app.typeKey(.downArrow, modifierFlags: [])
+ }
+ }
+
+ func cmdClickRow(withText title: String) {
+ let textField = itemText(title)
+ XCTAssertTrue(textField.waitForExistence(timeout: 2))
+ let coord = textField.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0.5))
+ .withOffset(CGVector(dx: -40, dy: 0))
+ let point = coord.screenPoint
+
+ let src = CGEventSource(stateID: .combinedSessionState)
+
+ let mouseDown = CGEvent(
+ mouseEventSource: src, mouseType: .leftMouseDown,
+ mouseCursorPosition: point, mouseButton: .left
+ )!
+ mouseDown.flags = .maskCommand
+ mouseDown.post(tap: .cgSessionEventTap)
+ usleep(50_000)
+
+ let mouseUp = CGEvent(
+ mouseEventSource: src, mouseType: .leftMouseUp,
+ mouseCursorPosition: point, mouseButton: .left
+ )!
+ mouseUp.flags = .maskCommand
+ mouseUp.post(tap: .cgSessionEventTap)
+ usleep(200_000)
+ }
+
+ func saveScreenshot(name: String) {
+ let windowList = CGWindowListCopyWindowInfo(
+ [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID
+ ) as? [[String: Any]] ?? []
+
+ guard let windowID = windowList.first(where: {
+ ($0[kCGWindowOwnerName as String] as? String) == "Listless"
+ && ($0[kCGWindowLayer as String] as? Int) == 0
+ })?[kCGWindowNumber as String] as? Int else {
+ XCTFail("Could not find Listless window")
+ return
+ }
+
+ let tmpFile = NSTemporaryDirectory() + "\(name).png"
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
+ process.arguments = ["-l\(windowID)", tmpFile]
+ try? process.run()
+ process.waitUntilExit()
+
+ guard let data = try? Data(contentsOf: URL(fileURLWithPath: tmpFile)) else {
+ XCTFail("screencapture failed to write \(tmpFile)")
+ return
+ }
+
+ let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.png")
+ attachment.name = name
+ attachment.lifetime = .keepAlways
+ add(attachment)
+
+ try? FileManager.default.removeItem(atPath: tmpFile)
+ }
+
+ // MARK: - Screenshots
+
+ /// Four items with draft row focused: three active, one empty draft row,
+ /// one completed at the bottom.
+ func testScreenshot01_ItemsWithEditing() throws {
+ launchApp()
+
+ createItem("Add items on your iPhone")
+ createItem("Edit items on your iPad")
+ createItem("Reorder items on your Mac")
+ createItem("Complete items on your Watch")
+
+ app.typeKey(.escape, modifierFlags: [])
+ usleep(500_000)
+
+ // Complete the last item
+ let checkbox = itemCheckbox(at: 3)
+ XCTAssertTrue(checkbox.waitForExistence(timeout: 2))
+ checkbox.click()
+ usleep(500_000)
+
+ // Create an empty draft row
+ app.typeKey("n", modifierFlags: .command)
+ XCTAssertTrue(draftTextField.waitForExistence(timeout: 2))
+ usleep(300_000)
+
+ saveScreenshot(name: "01-items")
+ }
+
+ /// Single item in dark mode.
+ func testScreenshot02_DarkMode() throws {
+ launchApp(["SCREENSHOT_DARK"])
+
+ createItem("Ask Alfred about utility belt")
+
+ usleep(500_000)
+
+ saveScreenshot(name: "02-click-to-create")
+ }
+
+ /// Multiple items with discontinuous selection via Cmd+Click.
+ func testScreenshot03_MultipleSelection() throws {
+ launchApp()
+
+ createItem("Write shell script")
+ createItem("Rewrite shell script")
+ createItem("Question life choices")
+ createItem("Cry tragically")
+ createItem("Achieve catharsis")
+
+ app.typeKey(.escape, modifierFlags: [])
+ usleep(500_000)
+
+ // Navigate to first item then select it
+ navigateToItem(at: 0)
+ usleep(200_000)
+
+ // Cmd+Click to add non-adjacent items to selection
+ cmdClickRow(withText: "Question life choices")
+ cmdClickRow(withText: "Achieve catharsis")
+ usleep(300_000)
+
+ saveScreenshot(name: "03-selection")
+ }
+}
diff --git a/Tests/UI/ListlessWatchScreenshots.swift b/Tests/UI/ListlessWatchScreenshots.swift
@@ -0,0 +1,48 @@
+import XCTest
+
+final class ListlessWatchScreenshots: XCTestCase {
+ var app: XCUIApplication!
+
+ static let screenshotDir = "/tmp/listless-screenshots"
+
+ override class func setUp() {
+ super.setUp()
+ try? FileManager.default.createDirectory(
+ atPath: screenshotDir,
+ withIntermediateDirectories: true
+ )
+ }
+
+ override func tearDownWithError() throws {
+ app.terminate()
+ }
+
+ private func launchApp(_ additionalArgs: [String] = []) {
+ app = XCUIApplication()
+ app.launchArguments = ["UI_TESTING"] + additionalArgs
+ app.launch()
+ }
+
+ func saveScreenshot(name: String) {
+ let screenshot = XCUIScreen.main.screenshot()
+ let attachment = XCTAttachment(screenshot: screenshot)
+ attachment.name = name
+ attachment.lifetime = .keepAlways
+ add(attachment)
+
+ let data = screenshot.pngRepresentation
+ let path = "\(Self.screenshotDir)/\(name).png"
+ try? data.write(to: URL(fileURLWithPath: path))
+ }
+
+ func testScreenshot01_Items() throws {
+ launchApp(["SCREENSHOT_SEED"])
+
+ let list = app.collectionViews.firstMatch
+ XCTAssertTrue(list.waitForExistence(timeout: 5))
+
+ usleep(500_000)
+
+ saveScreenshot(name: "01-items")
+ }
+}
diff --git a/project.yml b/project.yml
@@ -200,6 +200,7 @@ targets:
- path: Tests/UI
includes:
- "ListlessMacUITests.swift"
+ - "ListlessMacScreenshots.swift"
dependencies:
- target: Listless macOS
settings:
@@ -207,6 +208,20 @@ targets:
CODE_SIGN_STYLE: Automatic
GENERATE_INFOPLIST_FILE: YES
+ Listless watchOS UI Tests:
+ type: bundle.ui-testing
+ platform: watchOS
+ sources:
+ - path: Tests/UI
+ includes:
+ - "ListlessWatchScreenshots.swift"
+ dependencies:
+ - target: Listless watchOS
+ settings:
+ PRODUCT_BUNDLE_IDENTIFIER: net.inqk.listless.watchos.uitests
+ CODE_SIGN_STYLE: Automatic
+ GENERATE_INFOPLIST_FILE: YES
+
schemes:
Listless iOS:
build:
@@ -279,6 +294,11 @@ schemes:
settingsTarget: Listless watchOS
run:
config: Debug
+ test:
+ config: Debug
+ targets:
+ - name: Listless watchOS UI Tests
+ parallelizable: false
profile:
config: Release
analyze: