listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

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:
MListless.xcodeproj/project.pbxproj | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListless.xcodeproj/xcshareddata/xcschemes/Listless watchOS.xcscheme | 10++++++++++
MListlessMac/ListlessMacApp.swift | 9++++++++-
MListlessWatch/ListlessWatchApp.swift | 14+++++++++++++-
AScripts/screenshots-ios-compose.swift | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AScripts/screenshots-ipad.sh | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DScripts/screenshots-iphone-compose.swift | 109-------------------------------------------------------------------------------
MScripts/screenshots-iphone.sh | 2+-
AScripts/screenshots-mac-desktop.swift | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AScripts/screenshots-mac.sh | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AScripts/screenshots-watch.sh | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/UI/ListlessMacScreenshots.swift | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/UI/ListlessWatchScreenshots.swift | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 20++++++++++++++++++++
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: