├── .xcode-version ├── fixtures └── ios_app │ ├── App.app │ ├── PkgInfo │ ├── App │ ├── Assets.car │ ├── Info.plist │ ├── App.debug.dylib │ ├── __preview.dylib │ ├── embedded.mobileprovision │ └── _CodeSignature │ │ └── CodeResources │ ├── App.xcarchive │ ├── Products │ │ └── Applications │ │ │ └── App.app │ │ │ ├── PkgInfo │ │ │ ├── App │ │ │ ├── Assets.car │ │ │ ├── Info.plist │ │ │ ├── embedded.mobileprovision │ │ │ └── _CodeSignature │ │ │ └── CodeResources │ └── Info.plist │ ├── App.ipa │ ├── App │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── Image.imageset │ │ │ ├── Tuist-Filled.png │ │ │ ├── Tuist-Filled@2x.png │ │ │ ├── Tuist-Filled@3x.png │ │ │ └── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── AppApp.swift │ └── ContentView.swift │ └── App.xcodeproj │ ├── project.xcworkspace │ └── contents.xcworkspacedata │ └── project.pbxproj ├── pnpm-workspace.yaml ├── docs ├── public │ ├── logo.png │ └── favicon.ico ├── package.json ├── api │ ├── rosalind.md │ └── schema.md ├── index.md ├── .vitepress │ ├── config.mjs │ └── icons.mjs └── quick-start │ └── add-dependency.md ├── mise └── tasks │ ├── docs │ ├── build │ ├── dev │ ├── deploy │ └── preview │ ├── cache │ ├── test │ ├── build │ ├── install │ ├── test-linux │ ├── build-linux │ ├── lint │ └── build-fixture.sh ├── mise.toml ├── .swiftlint.yml ├── .github └── workflows │ ├── conventional-pr.yml │ ├── docs.yml │ ├── deploy.yml │ ├── rosalind.yml │ └── release.yml ├── renovate.json ├── Sources └── Rosalind │ ├── Extensions │ ├── FileHandle+Read.swift │ └── Sequence+Concurrency.swift │ ├── PoolLock.swift │ ├── ShasumCalculator.swift │ ├── AppBundleLoader.swift │ ├── AppBundleReport.swift │ ├── AssetUtilController.swift │ ├── AppBundle.swift │ └── Rosalind.swift ├── .swiftformat ├── LICENSE ├── Tests └── RosalindTests │ ├── Extensions │ └── Snapshotting+RosalindReport.swift │ ├── __Snapshots__ │ └── RosalindAcceptanceTests │ │ ├── ios_app_xcarchive.1 │ │ ├── ios_app_ipa.1 │ │ └── ios_app.1 │ ├── RosalindAcceptanceTests.swift │ ├── RosalindTests.swift │ └── AssetUtilControllerTests.swift ├── README.md ├── Package.swift ├── cliff.toml ├── Package.resolved └── .gitignore /.xcode-version: -------------------------------------------------------------------------------- 1 | 26.0 2 | -------------------------------------------------------------------------------- /fixtures/ios_app/App.app/PkgInfo: -------------------------------------------------------------------------------- 1 | APPL???? -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "docs" -------------------------------------------------------------------------------- /fixtures/ios_app/App.xcarchive/Products/Applications/App.app/PkgInfo: -------------------------------------------------------------------------------- 1 | APPL???? -------------------------------------------------------------------------------- /docs/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuist/Rosalind/HEAD/docs/public/logo.png -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuist/Rosalind/HEAD/docs/public/favicon.ico -------------------------------------------------------------------------------- /fixtures/ios_app/App.ipa: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuist/Rosalind/HEAD/fixtures/ios_app/App.ipa -------------------------------------------------------------------------------- /fixtures/ios_app/App.app/App: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuist/Rosalind/HEAD/fixtures/ios_app/App.app/App -------------------------------------------------------------------------------- /mise/tasks/docs/build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | pnpm run -C $MISE_PROJECT_ROOT/docs build -------------------------------------------------------------------------------- /mise/tasks/docs/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | pnpm run -C $MISE_PROJECT_ROOT/docs dev -------------------------------------------------------------------------------- /mise/tasks/docs/deploy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | pnpm run -C $MISE_PROJECT_ROOT/docs deploy -------------------------------------------------------------------------------- /mise/tasks/docs/preview: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | pnpm run -C $MISE_PROJECT_ROOT/docs preview -------------------------------------------------------------------------------- /fixtures/ios_app/App.app/Assets.car: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuist/Rosalind/HEAD/fixtures/ios_app/App.app/Assets.car -------------------------------------------------------------------------------- /fixtures/ios_app/App.app/Info.plist: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuist/Rosalind/HEAD/fixtures/ios_app/App.app/Info.plist -------------------------------------------------------------------------------- /fixtures/ios_app/App.app/App.debug.dylib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuist/Rosalind/HEAD/fixtures/ios_app/App.app/App.debug.dylib -------------------------------------------------------------------------------- /fixtures/ios_app/App.app/__preview.dylib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuist/Rosalind/HEAD/fixtures/ios_app/App.app/__preview.dylib -------------------------------------------------------------------------------- /fixtures/ios_app/App/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /fixtures/ios_app/App.app/embedded.mobileprovision: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuist/Rosalind/HEAD/fixtures/ios_app/App.app/embedded.mobileprovision -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | "git-cliff" = "2.4.0" 3 | "pnpm" = "10.26.2" 4 | "nodejs" = "22.21.1" 5 | "swiftlint" = "0.54.0" 6 | "swiftformat" = "0.52.10" 7 | -------------------------------------------------------------------------------- /mise/tasks/cache: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # mise description="Cache the dependencies using Tuist" 3 | set -euo pipefail 4 | 5 | tuist cache --path $MISE_PROJECT_ROOT -------------------------------------------------------------------------------- /fixtures/ios_app/App/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /fixtures/ios_app/App.xcarchive/Products/Applications/App.app/App: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuist/Rosalind/HEAD/fixtures/ios_app/App.xcarchive/Products/Applications/App.app/App -------------------------------------------------------------------------------- /mise/tasks/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # mise description="Test the project using Swift Package Manager" 3 | 4 | set -euo pipefail 5 | 6 | swift test --package-path $MISE_PROJECT_ROOT -------------------------------------------------------------------------------- /fixtures/ios_app/App/Assets.xcassets/Image.imageset/Tuist-Filled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuist/Rosalind/HEAD/fixtures/ios_app/App/Assets.xcassets/Image.imageset/Tuist-Filled.png -------------------------------------------------------------------------------- /fixtures/ios_app/App.xcarchive/Products/Applications/App.app/Assets.car: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuist/Rosalind/HEAD/fixtures/ios_app/App.xcarchive/Products/Applications/App.app/Assets.car -------------------------------------------------------------------------------- /fixtures/ios_app/App.xcarchive/Products/Applications/App.app/Info.plist: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuist/Rosalind/HEAD/fixtures/ios_app/App.xcarchive/Products/Applications/App.app/Info.plist -------------------------------------------------------------------------------- /fixtures/ios_app/App/Assets.xcassets/Image.imageset/Tuist-Filled@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuist/Rosalind/HEAD/fixtures/ios_app/App/Assets.xcassets/Image.imageset/Tuist-Filled@2x.png -------------------------------------------------------------------------------- /fixtures/ios_app/App/Assets.xcassets/Image.imageset/Tuist-Filled@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuist/Rosalind/HEAD/fixtures/ios_app/App/Assets.xcassets/Image.imageset/Tuist-Filled@3x.png -------------------------------------------------------------------------------- /fixtures/ios_app/App.xcarchive/Products/Applications/App.app/embedded.mobileprovision: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuist/Rosalind/HEAD/fixtures/ios_app/App.xcarchive/Products/Applications/App.app/embedded.mobileprovision -------------------------------------------------------------------------------- /fixtures/ios_app/App/AppApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct AppApp: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | ContentView() 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /mise/tasks/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # mise description="Build the project using Swift Package Manager" 3 | set -euo pipefail 4 | 5 | swift build --product Rosalind --package-path $MISE_PROJECT_ROOT --configuration release 6 | -------------------------------------------------------------------------------- /fixtures/ios_app/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /fixtures/ios_app/App/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /mise/tasks/install: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # mise description="Performs additional tasks that are necessary to work in this repository" 3 | 4 | set -euo pipefail 5 | 6 | pnpm install -C $MISE_PROJECT_ROOT/docs/ 7 | if [[ "$(uname)" == "Darwin" ]]; then 8 | tuist install --path $MISE_PROJECT_ROOT 9 | fi -------------------------------------------------------------------------------- /mise/tasks/test-linux: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # mise description="Builds the project using Swift Package Manager in Linux" 3 | set -euo pipefail 4 | 5 | docker run --rm \ 6 | --volume "$MISE_PROJECT_ROOT:/package" \ 7 | --workdir "/package" \ 8 | swiftlang/swift:nightly-6.0-focal \ 9 | /bin/bash -c \ 10 | "swift test --build-path ./.build/linux" 11 | -------------------------------------------------------------------------------- /mise/tasks/build-linux: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # mise description="Builds the project using Swift Package Manager in Linux" 3 | set -euo pipefail 4 | 5 | docker run --rm \ 6 | --volume "$MISE_PROJECT_ROOT:/package" \ 7 | --workdir "/package" \ 8 | swiftlang/swift:nightly-6.0-focal \ 9 | /bin/bash -c \ 10 | "swift build --product Rosalind --build-path ./.build/linux" 11 | -------------------------------------------------------------------------------- /fixtures/ios_app/App/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ContentView: View { 4 | var body: some View { 5 | VStack { 6 | Image(systemName: "globe") 7 | .imageScale(.large) 8 | .foregroundStyle(.tint) 9 | Text("Hello, world!") 10 | } 11 | .padding() 12 | } 13 | } 14 | 15 | #Preview { 16 | ContentView() 17 | } 18 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tuist/rosalind", 3 | "devDependencies": { 4 | "vitepress": "^1.3.2", 5 | "wrangler": "^4.0.0", 6 | "esbuild": "0.27.2", 7 | "cookie": ">=0.7.0" 8 | }, 9 | "scripts": { 10 | "dev": "vitepress dev", 11 | "build": "vitepress build", 12 | "preview": "vitepress preview", 13 | "deploy": "vitepress build && wrangler pages deploy .vitepress/dist --project-name tuist-rosalind --branch main" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - trailing_whitespace 3 | - trailing_comma 4 | - nesting 5 | - cyclomatic_complexity 6 | - file_length 7 | - todo 8 | - function_parameter_count 9 | - opening_brace 10 | - line_length 11 | identifier_name: 12 | min_length: 13 | error: 1 14 | warning: 1 15 | max_length: 16 | warning: 60 17 | error: 80 18 | inclusive_language: 19 | override_allowed_terms: 20 | - masterKey 21 | type_name: 22 | min_length: 23 | error: 1 24 | warning: 1 -------------------------------------------------------------------------------- /.github/workflows/conventional-pr.yml: -------------------------------------------------------------------------------- 1 | name: conventional-pr 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | - master 7 | types: 8 | - opened 9 | - edited 10 | - synchronize 11 | jobs: 12 | lint-pr: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: CondeNast/conventional-pull-request-action@v0.2.0 17 | with: 18 | commitTitleMatch: false 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | -------------------------------------------------------------------------------- /mise/tasks/lint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # mise description="Lint the project using SwiftLint and SwiftFormat" 3 | #USAGE flag "-f --fix" help="Fix the fixable issues" 4 | set -eo pipefail 5 | 6 | if [ "$usage_fix" = "true" ]; then 7 | swiftformat $MISE_PROJECT_ROOT 8 | swiftlint lint --fix --quiet --config $MISE_PROJECT_ROOT/.swiftlint.yml $MISE_PROJECT_ROOT/Sources 9 | else 10 | swiftformat $MISE_PROJECT_ROOT --lint 11 | swiftlint lint --quiet --config $MISE_PROJECT_ROOT/.swiftlint.yml $MISE_PROJECT_ROOT/Sources 12 | fi 13 | -------------------------------------------------------------------------------- /fixtures/ios_app/App/Assets.xcassets/Image.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Tuist-Filled.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "Tuist-Filled@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "Tuist-Filled@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | ":semanticCommits", 5 | ":disableDependencyDashboard", 6 | "config:recommended" 7 | ], 8 | "packageRules": [ 9 | { 10 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 11 | "automerge": true, 12 | "automergeType": "pr", 13 | "automergeStrategy": "auto" 14 | } 15 | ], 16 | "lockFileMaintenance": { 17 | "enabled": true, 18 | "automerge": true 19 | } 20 | } -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | concurrency: 10 | group: docs-${{ github.head_ref }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | MISE_EXPERIMENTAL: 1 15 | 16 | jobs: 17 | build: 18 | name: Build 19 | runs-on: 'ubuntu-latest' 20 | timeout-minutes: 15 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: jdx/mise-action@v2 24 | - name: Install dependencies 25 | run: mise run install 26 | - name: Build 27 | run: mise run docs:build -------------------------------------------------------------------------------- /Sources/Rosalind/Extensions/FileHandle+Read.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension FileHandle { 4 | @_spi(Support) 5 | public func read( 6 | offset: UInt64, 7 | swapHandler: ((inout Data) -> Void)? = nil 8 | ) -> Element? { 9 | seek(toFileOffset: offset) 10 | var data = readData( 11 | ofLength: MemoryLayout.size 12 | ) 13 | guard data.count >= MemoryLayout.size else { return nil } 14 | if let swapHandler { swapHandler(&data) } 15 | return data.withUnsafeBytes { 16 | $0.load(as: Element.self) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | 7 | concurrency: 8 | group: deploy-${{ github.head_ref }} 9 | cancel-in-progress: true 10 | 11 | env: 12 | MISE_EXPERIMENTAL: 1 13 | 14 | jobs: 15 | docs: 16 | name: Docs 17 | runs-on: ubuntu-latest 18 | env: 19 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 20 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 21 | steps: 22 | - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 23 | - uses: jdx/mise-action@v2 24 | with: 25 | experimental: true 26 | - run: mise run install 27 | - run: mise run docs:deploy -------------------------------------------------------------------------------- /docs/api/rosalind.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Schema 3 | titleTemplate: ':title | Rosalind | Rosalind | Tuist' 4 | description: "Learn how to use Rosalind to analyze your build artifacts." 5 | --- 6 | 7 | # Rosalind 8 | 9 | The interface with Rosalind is through an instance of the `Rosalind` struct. 10 | It exposes a function, `analyze` that takes a path and returns a [report](/api/schema). 11 | 12 | ```swift 13 | let report = Rosalind().analyze(path: try AbsolutePath(validating: "/path/to/MyApp.app")) 14 | ``` 15 | 16 | Since `RosalindReport` conforms to `Coddable`, you can serialize it into a JSON `Data` instance: 17 | 18 | ```swift 19 | let jsonEncoder = JSONEncoder() 20 | jsonEncoder.outputFormatting = [.prettyPrinted, .sortedKeys] 21 | let reportData = try jsonEncoder.encode(value) 22 | ``` 23 | -------------------------------------------------------------------------------- /fixtures/ios_app/App/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Rosalind/PoolLock.swift: -------------------------------------------------------------------------------- 1 | actor PoolLock { 2 | private let capacity: Int 3 | private var inUse: Int = 0 4 | private var waitQueue: [CheckedContinuation] = [] 5 | 6 | init(capacity: Int) { 7 | self.capacity = capacity 8 | } 9 | 10 | func acquire() async { 11 | if inUse < capacity { 12 | inUse += 1 13 | return 14 | } 15 | 16 | await withCheckedContinuation { continuation in 17 | waitQueue.append(continuation) 18 | } 19 | } 20 | 21 | func release() { 22 | guard inUse > 0 else { return } 23 | 24 | if waitQueue.isEmpty { 25 | inUse -= 1 26 | } else { 27 | let continuation = waitQueue.removeFirst() 28 | continuation.resume() 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Rosalind/Extensions/Sequence+Concurrency.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Sequence { 4 | func asyncMap(_ transform: @Sendable @escaping (Element) async throws -> T) async throws -> [T] 5 | where Element: Sendable 6 | { 7 | let elements = Array(self) 8 | 9 | if elements.isEmpty { 10 | return [] 11 | } 12 | var results = [T?](repeating: nil, count: elements.count) 13 | 14 | try await withThrowingTaskGroup(of: (Int, T).self) { group in 15 | for (index, element) in elements.enumerated() { 16 | group.addTask { 17 | let result = try await transform(element) 18 | return (index, result) 19 | } 20 | } 21 | for try await (index, result) in group { 22 | results[index] = result 23 | } 24 | } 25 | return results.compactMap { $0 } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | # file options 2 | 3 | --symlinks ignore 4 | --disable hoistAwait 5 | --disable hoistTry 6 | --swiftversion 5.7 7 | 8 | # format options 9 | 10 | --allman false 11 | --binarygrouping 4,8 12 | --closingparen balanced 13 | --commas always 14 | --comments indent 15 | --decimalgrouping 3,6 16 | --elseposition same-line 17 | --empty void 18 | --exponentcase lowercase 19 | --exponentgrouping disabled 20 | --extensionacl on-declarations 21 | --fractiongrouping disabled 22 | --header strip 23 | --hexgrouping 4,8 24 | --hexliteralcase uppercase 25 | --ifdef indent 26 | --indent 4 27 | --indentcase false 28 | --importgrouping testable-bottom 29 | --linebreaks lf 30 | --octalgrouping 4,8 31 | --operatorfunc spaced 32 | --patternlet hoist 33 | --ranges spaced 34 | --self remove 35 | --semicolons inline 36 | --stripunusedargs always 37 | --trimwhitespace always 38 | --maxwidth 130 39 | --wraparguments before-first 40 | --wrapcollections before-first 41 | --wrapconditions after-first 42 | --wrapparameters before-first -------------------------------------------------------------------------------- /mise/tasks/build-fixture.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # mise description="Bundles the CLI for distribution" 3 | #USAGE arg "" help="The fixture to build" 4 | 5 | set -euo pipefail 6 | 7 | PROJECT_PATH="$MISE_PROJECT_ROOT/fixtures/$usage_fixture" 8 | rm -rf "$PROJECT_PATH/App.app" "$PROJECT_PATH/App.xcarchive" "$PROJECT_PATH/App.ipa" 9 | 10 | xcodebuild build -scheme App -destination 'generic/platform=iOS' -project "$PROJECT_PATH/App.xcodeproj" -derivedDataPath "$PROJECT_PATH/.build" 11 | 12 | mv "$PROJECT_PATH/.build/Build/Products/Debug-iphoneos/App.app" "$PROJECT_PATH/App.app" 13 | 14 | xcodebuild archive -project "$PROJECT_PATH/App.xcodeproj" -scheme App -sdk iphoneos -destination "generic/platform=iOS" -archivePath "$PROJECT_PATH/App.xcarchive" 15 | 16 | xcodebuild -exportArchive -archivePath "$PROJECT_PATH/App.xcarchive" -exportOptionsPlist "$PROJECT_PATH/App.xcarchive/Info.plist" -exportPath "$PROJECT_PATH/.build/ExportedApp" 17 | mv "$PROJECT_PATH/.build/ExportedApp/App.ipa" "$PROJECT_PATH/App.ipa" 18 | rm -rf "$PROJECT_PATH/.build" 19 | -------------------------------------------------------------------------------- /fixtures/ios_app/App.xcarchive/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ApplicationProperties 6 | 7 | ApplicationPath 8 | Applications/App.app 9 | Architectures 10 | 11 | arm64 12 | 13 | CFBundleIdentifier 14 | dev.tuist.rosalind.App 15 | CFBundleShortVersionString 16 | 1.0 17 | CFBundleVersion 18 | 1 19 | SigningIdentity 20 | Apple Development: Marek Fort (K5Q5B5S4HJ) 21 | Team 22 | U6LC622NKF 23 | 24 | ArchiveVersion 25 | 2 26 | CreationDate 27 | 2025-04-25T10:49:15Z 28 | Name 29 | App 30 | SchemeName 31 | App 32 | 33 | 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Tuist GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/api/schema.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Schema 3 | titleTemplate: ':title | API | Rosalind | Tuist' 4 | description: "Learn about the schema of the results of the analysis." 5 | --- 6 | 7 | # Schema 8 | 9 | > [!TIP] 10 | > We haven't reached 1.0 yet, so breaking changes are possible. 11 | 12 | Rosalind returns an instance of `RosalindReport`, a type that can be encoded to [JSON](https://www.w3schools.com/whatis/whatis_json.asp) thanks to its conformance to the `Codable` protocol. 13 | 14 | `RosalindReport` creates a hierarchical tree structure where each node represents either a file or a directory, along with corresponding metadata. 15 | 16 | ### Attributes 17 | 18 | Each node in the tree includes these attributes: 19 | 20 | - **artifactType:** Categorizes the artifact as an `app`, `directory`, or `file`. 21 | - **path:** Specifies the artifact's path relative to the project root. 22 | - **size:** Records the artifact's size in bytes. 23 | - **shasum:** Provides a SHA-256 checksum of the artifact for integrity verification. 24 | - **children:** Contains an array of child artifacts (present only if the node is a directory). 25 | 26 | > [!NOTE] 27 | > In future updates, we plan to expand the `artifactType` enumeration to include more specific categories that better describe various artifacts. 28 | -------------------------------------------------------------------------------- /Tests/RosalindTests/Extensions/Snapshotting+RosalindReport.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Rosalind 3 | import SnapshotTesting 4 | import XCTest 5 | 6 | extension Diffing { 7 | fileprivate static func rosalind() -> Diffing { 8 | let jsonEncoder = JSONEncoder() 9 | let jsonDecoder = JSONDecoder() 10 | jsonEncoder.outputFormatting = [.prettyPrinted, .sortedKeys] 11 | 12 | return Diffing.init(toData: { value in 13 | try! jsonEncoder.encode(value) 14 | }, fromData: { data in 15 | try! jsonDecoder.decode(AppBundleReport.self, from: data) 16 | }, diff: { (lhs: AppBundleReport, rhs: AppBundleReport) -> (String, [XCTAttachment])? in 17 | if lhs == rhs { 18 | return nil 19 | } else { 20 | return Snapshotting.json.diffing.diff( 21 | try! String(decoding: jsonEncoder.encode(lhs), as: UTF8.self), 22 | try! String(decoding: jsonEncoder.encode(rhs), as: UTF8.self) 23 | ) 24 | } 25 | }) 26 | } 27 | } 28 | 29 | extension Snapshotting { 30 | static func rosalind() -> Snapshotting { 31 | Snapshotting.init(pathExtension: nil, diffing: .rosalind()) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Why Rosalind?" 3 | titleTemplate: ':title | Quick start | Rosalind | Tuist' 4 | description: "Rosalind is a tool that helps you understand the size of your Xcode bundles and how to reduce it." 5 | --- 6 | 7 | # Why Rosalind? 8 | 9 | Understanding and optimizing applications requires standardized, detailed reports that reveal an application's internal architecture. Recognizing this universal challenge across development teams, we've open-sourced Rosalind under the MIT license to make this sophisticated analysis accessible to everyone. 10 | 11 | `Rosalind` is actively maintained by our dedicated team, ensuring it stays current with evolving development practices and toolchains across the mobile ecosystem. 12 | 13 | ## Schema 14 | 15 | Unlocking a layer of solutions that can help improve the quality and efficiency of mobile applications requires a **standard and open schema** that releases changes in a backward-compatible way. This standardization gives teams and organizations the freedom to migrate between tools with minimal effort and disruption. 16 | 17 | Rosalind's [schema](/api/schema) follows these principles and is fully documented, providing a foundation for building powerful tools and workflows to analyze and optimize applications. 18 | 19 | Whether you're working in a small team or a large organization, Rosalind's schema provides the flexibility and reliability needed to understand the deep structure of your applications. 20 | -------------------------------------------------------------------------------- /Sources/Rosalind/ShasumCalculator.swift: -------------------------------------------------------------------------------- 1 | import Command 2 | import Crypto 3 | @preconcurrency import FileSystem 4 | import Foundation 5 | import Mockable 6 | import Path 7 | 8 | @Mockable 9 | protocol ShasumCalculating: Sendable { 10 | func calculate(childrenShasums: [String]) async throws -> String 11 | func calculate(filePath: AbsolutePath) async throws -> String 12 | } 13 | 14 | struct ShasumCalculator: ShasumCalculating { 15 | private let fileSystem: FileSysteming 16 | private let commandRunner: CommandRunning 17 | 18 | init(fileSystem: FileSysteming = FileSystem(), commandRunner: CommandRunning = CommandRunner()) { 19 | self.fileSystem = fileSystem 20 | self.commandRunner = commandRunner 21 | } 22 | 23 | func calculate(childrenShasums: [String]) async throws -> String { 24 | let digest = SHA256.hash(data: childrenShasums.joined().data(using: .utf8)!) 25 | return digest.compactMap { String(format: "%02x", $0) }.joined() 26 | } 27 | 28 | func calculate(filePath: Path.AbsolutePath) async throws -> String { 29 | let fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: filePath.pathString)) 30 | defer { try? fileHandle.close() } 31 | 32 | var hasher = SHA256() 33 | let chunkSize = 1024 * 1024 34 | 35 | var shouldContinue = true 36 | while shouldContinue { 37 | guard let data = try? fileHandle.read(upToCount: chunkSize), !data.isEmpty else { 38 | shouldContinue = false 39 | continue 40 | } 41 | hasher.update(data: data) 42 | } 43 | 44 | let digest = hasher.finalize() 45 | return digest.compactMap { String(format: "%02x", $0) }.joined() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/Rosalind/AppBundleLoader.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import FileSystem 2 | import Foundation 3 | import Mockable 4 | import Path 5 | 6 | enum AppBundleLoaderError: LocalizedError, Equatable { 7 | case missingInfoPlist(AbsolutePath) 8 | case failedDecodingInfoPlist(AbsolutePath, String) 9 | 10 | var errorDescription: String? { 11 | switch self { 12 | case let .missingInfoPlist(path): 13 | return "Expected Info.plist at \(path) was not found. Make sure it exists." 14 | case let .failedDecodingInfoPlist(path, reason): 15 | return "Failed decoding Info.plist at \(path) due to: \(reason)" 16 | } 17 | } 18 | } 19 | 20 | @Mockable 21 | protocol AppBundleLoading: Sendable { 22 | func load(_ appBundle: AbsolutePath) async throws -> AppBundle 23 | } 24 | 25 | struct AppBundleLoader: AppBundleLoading { 26 | private let fileSystem: FileSysteming 27 | 28 | init( 29 | fileSystem: FileSysteming = FileSystem() 30 | ) { 31 | self.fileSystem = fileSystem 32 | } 33 | 34 | func load(_ appBundle: AbsolutePath) async throws -> AppBundle { 35 | let infoPlistPath = appBundle.appending(component: "Info.plist") 36 | 37 | if try await !fileSystem.exists(infoPlistPath) { 38 | throw AppBundleLoaderError.missingInfoPlist(infoPlistPath) 39 | } 40 | 41 | let data = try Data(contentsOf: URL(fileURLWithPath: infoPlistPath.pathString)) 42 | let decoder = PropertyListDecoder() 43 | 44 | let infoPlist: AppBundle.InfoPlist 45 | do { 46 | infoPlist = try decoder.decode(AppBundle.InfoPlist.self, from: data) 47 | } catch { 48 | throw AppBundleLoaderError.failedDecodingInfoPlist(infoPlistPath, error.localizedDescription) 49 | } 50 | 51 | return AppBundle( 52 | path: appBundle, 53 | infoPlist: infoPlist 54 | ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rosalind 2 | 3 | [Rosalind Franklin](https://en.wikipedia.org/wiki/Rosalind_Franklin) was a pioneering scientist whose X-ray crystallography work revealed the fundamental structure of DNA, transforming our understanding of life itself. In a similar way, modern applications across platforms—iOS, Android, and React Native—have complex internal architectures that, when properly understood, can be optimized for performance, efficiency, and user experience. 4 | 5 | Our tool, Rosalind, analyzes application bundles to uncover these hidden structures, providing developers with clear, actionable insights about their code, dependencies, resources, and overall composition. By making the invisible visible, Rosalind empowers development teams to make informed decisions when refining their applications. 6 | 7 | > [!NOTE] 8 | > Inspired by Franklin's commitment to scientific discovery, we've made Rosalind open-source under the MIT license. We believe that understanding application architecture should be accessible to all developers, regardless of platform or team size, fostering a more collaborative and innovative development community. 9 | 10 | > [!WARNING] 11 | > While Rosalind can be built on Linux systems, it currently can only be run on macOS due to a runtime dependency on the macOS-only `assetutil` CLI tool. This is something we'd like to address in the future. 12 | 13 | Rosalind currently provides comprehensive analysis of Xcode-built application artifacts, with planned support for Android and React Native platforms in our development roadmap. Our vision is to offer a unified approach to understanding the DNA of your applications across all major app development ecosystems. 14 | 15 | ## Development 16 | 17 | ### Set up 18 | 19 | 1. Clone the repository: `git clone https://github.com/tuist/rosalind`. 20 | 2. Install system dependencies: `mise install`. 21 | 3. Install project dependencies: `mise run install`. 22 | 4. Build the project: `mise run build`. 23 | -------------------------------------------------------------------------------- /docs/.vitepress/config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitepress"; 2 | import { 3 | cubeOutlineIcon, 4 | cube02Icon, 5 | cube01Icon, 6 | barChartSquare02Icon, 7 | code02Icon, 8 | dataIcon, 9 | checkCircleIcon, 10 | tuistIcon, 11 | cloudBlank02Icon, 12 | server04Icon, 13 | } from "./icons.mjs"; 14 | 15 | // https://vitepress.dev/reference/site-config 16 | export default defineConfig({ 17 | title: "Rosalind", 18 | titleTemplate: ":title | Rosalind | Tuist", 19 | description: "Analyze Apple-generated bundles", 20 | sitemap: { 21 | hostname: "https://rosalind.tuist.io", 22 | }, 23 | themeConfig: { 24 | logo: "/logo.png", 25 | search: { 26 | provider: "local", 27 | }, 28 | nav: [ 29 | { 30 | text: "Changelog", 31 | link: "https://github.com/tuist/Rosalind/releases", 32 | }, 33 | ], 34 | editLink: { 35 | pattern: "https://github.com/tuist/Rosalind/edit/main/docs/:path", 36 | }, 37 | sidebar: [ 38 | { 39 | text: `Quick start ${tuistIcon()}`, 40 | items: [ 41 | { text: "Why Rosalind?", link: "/" }, 42 | { text: "Add dependency", link: "/quick-start/add-dependency" }, 43 | ], 44 | }, 45 | { 46 | text: `API ${cube01Icon()}`, 47 | items: [ 48 | { text: "Rosalind", link: "/api/rosalind" }, 49 | { text: "Schema", link: "/api/schema" }, 50 | ], 51 | }, 52 | ], 53 | 54 | socialLinks: [ 55 | { icon: "github", link: "https://github.com/tuist/tuist" }, 56 | { icon: "mastodon", link: "https://fosstodon.org/@tuist" }, 57 | { 58 | icon: "discourse", 59 | link: "https://community.tuist.dev", 60 | }, 61 | ], 62 | footer: { 63 | message: "Released under the MIT License.", 64 | copyright: "Copyright © 2024-present Tuist Inc.", 65 | }, 66 | }, 67 | }); 68 | -------------------------------------------------------------------------------- /docs/quick-start/add-dependency.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Add dependency" 3 | titleTemplate: ':title | Quick start | Rosalind | Tuist' 4 | description: "Learn how to add Rosalind to your project." 5 | --- 6 | 7 | # Add dependency 8 | 9 | The first step is to add Rosalind as a dependency to your project. The method you choose depends on the type of project you have. 10 | 11 | ### Swift Package Manager 12 | 13 | You can edit your project's `Package.swift` and add `Command` as a dependency: 14 | 15 | ```swift 16 | import PackageDescription 17 | 18 | let package = Package( 19 | name: "MyProject", 20 | dependencies: [ 21 | .package(url: "https://github.com/tuist/Rosalind.git", .upToNextMajor(from: "0.1.0")) // [!code ++] 22 | ], 23 | targets: [ 24 | .target(name: "MyProject", 25 | dependencies: ["Rosalind", .product(name: "Rosalind", package: "Rosalind")]), // [!code ++] 26 | ] 27 | ) 28 | ``` 29 | 30 | ### Tuist 31 | 32 | First, you'll have to add the `Rosalind` package to your project's `Package.swift` file: 33 | 34 | ```swift 35 | import PackageDescription 36 | 37 | let package = Package( 38 | name: "MyProject", 39 | dependencies: [ 40 | .package(url: "https://github.com/tuist/Rosalind.git", .upToNextMajor(from: "0.1.0")) // [!code ++] 41 | ] 42 | ) 43 | ``` 44 | 45 | And then declare it as a dependency of one of your project's targets: 46 | 47 | ::: code-group 48 | ```swift [Project.swift] 49 | import ProjectDescription 50 | 51 | let project = Project( 52 | name: "App", 53 | organizationName: "tuist.io", 54 | targets: [ 55 | .target( 56 | name: "App", 57 | destinations: [.iPhone], 58 | product: .app, 59 | bundleId: "io.tuist.app", 60 | deploymentTargets: .iOS("13.0"), 61 | infoPlist: .default, 62 | sources: ["Targets/App/Sources/**"], 63 | dependencies: [ 64 | .external(name: "Rosalind"), // [!code ++] 65 | ] 66 | ), 67 | ] 68 | ) 69 | ``` 70 | ::: 71 | 72 | Make sure you run `tuist install` to fetch the dependencies before you generate the Xcode project. 73 | -------------------------------------------------------------------------------- /.github/workflows/rosalind.yml: -------------------------------------------------------------------------------- 1 | name: Rosalind 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: {} 8 | 9 | env: 10 | TUIST_CONFIG_TOKEN: ${{ secrets.TUIST_CONFIG_CLOUD_TOKEN }} 11 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 12 | 13 | concurrency: 14 | group: rosalind-${{ github.head_ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | build: 19 | name: "Release build on ${{ matrix.os }}" 20 | timeout-minutes: 10 21 | strategy: 22 | matrix: 23 | os: [ubuntu-latest, macos-latest] 24 | swift: ["6.0"] 25 | runs-on: ${{ matrix.os }} 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: SwiftyLab/setup-swift@latest 29 | with: 30 | swift-version: ${{ matrix.swift }} 31 | - uses: jdx/mise-action@v2 32 | if: runner.os == 'Linux' || runner.os == 'macOS' 33 | with: 34 | experimental: true 35 | - name: Run 36 | if: runner.os == 'Linux' || runner.os == 'macOS' 37 | run: mise run build 38 | - name: Run 39 | if: runner.os == 'Windows' 40 | run: swift build --product Rosalind 41 | 42 | test: 43 | name: "Test on ${{ matrix.os }}" 44 | timeout-minutes: 10 45 | strategy: 46 | matrix: 47 | os: [macos-latest] 48 | swift: ["6.0"] 49 | runs-on: ${{ matrix.os }} 50 | steps: 51 | - uses: actions/checkout@v4 52 | - name: Select Xcode 53 | run: sudo xcode-select -switch /Applications/Xcode_$(cat .xcode-version).app 54 | - uses: SwiftyLab/setup-swift@latest 55 | with: 56 | swift-version: ${{ matrix.swift }} 57 | - uses: jdx/mise-action@v2 58 | if: runner.os == 'Linux' || runner.os == 'macOS' 59 | with: 60 | experimental: true 61 | - name: Run 62 | if: runner.os == 'Linux' || runner.os == 'macOS' 63 | run: mise run test 64 | - name: Run 65 | if: runner.os == 'Windows' 66 | run: swift test 67 | 68 | lint: 69 | name: Lint 70 | timeout-minutes: 10 71 | runs-on: macos-latest 72 | steps: 73 | - uses: actions/checkout@v4 74 | - uses: jdx/mise-action@v2 75 | with: 76 | experimental: true 77 | - name: Run 78 | run: mise run lint 79 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Rosalind", 8 | platforms: [.macOS("14.0")], 9 | products: [ 10 | .library( 11 | name: "Rosalind", 12 | type: .static, 13 | targets: ["Rosalind"] 14 | ), 15 | ], 16 | dependencies: [ 17 | .package(url: "https://github.com/tuist/Path.git", .upToNextMajor(from: "0.3.8")), 18 | .package(url: "https://github.com/tuist/FileSystem.git", .upToNextMajor(from: "0.14.8")), 19 | .package(url: "https://github.com/tuist/Command.git", .upToNextMajor(from: "0.13.0")), 20 | .package( 21 | url: "https://github.com/pointfreeco/swift-snapshot-testing", 22 | .upToNextMajor(from: "1.18.7") 23 | ), 24 | // To our surprise (note the irony), CryptoSwift is an AppleOS-only framework, therefore 25 | // crypto capabilities need to be imported using a package. 26 | .package(url: "https://github.com/apple/swift-crypto.git", .upToNextMajor(from: "3.15.1")), 27 | .package(url: "https://github.com/p-x9/MachOKit", .upToNextMajor(from: "0.44.0")), 28 | .package(url: "https://github.com/Kolos65/Mockable", .upToNextMajor(from: "0.5.0")), 29 | ], 30 | targets: [ 31 | .target( 32 | name: "Rosalind", 33 | dependencies: [ 34 | .product(name: "Crypto", package: "swift-crypto"), 35 | .product(name: "Path", package: "Path"), 36 | .product(name: "FileSystem", package: "FileSystem"), 37 | .product(name: "Command", package: "Command"), 38 | .product(name: "MachOKit", package: "MachOKit"), 39 | ], 40 | swiftSettings: [ 41 | .enableExperimentalFeature("StrictConcurrency"), 42 | .define("MOCKING", .when(configuration: .debug)), 43 | ] 44 | ), 45 | .testTarget( 46 | name: "RosalindTests", 47 | dependencies: [ 48 | .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), 49 | .product(name: "Mockable", package: "Mockable"), 50 | "Rosalind", 51 | ] 52 | ), 53 | ] 54 | ) 55 | -------------------------------------------------------------------------------- /Tests/RosalindTests/__Snapshots__/RosalindAcceptanceTests/ios_app_xcarchive.1: -------------------------------------------------------------------------------- 1 | { 2 | "artifacts" : [ 3 | { 4 | "artifactType" : "binary", 5 | "path" : "App.app\/App", 6 | "shasum" : "6c96b831f3b2a53d1bda562788cdcc33039ab6c43612d7cce60bcd0de137b6f5", 7 | "size" : 93600 8 | }, 9 | { 10 | "artifactType" : "asset", 11 | "children" : [ 12 | { 13 | "artifactType" : "asset", 14 | "path" : "App.app\/Assets.car\/Tuist-Filled.png", 15 | "shasum" : "880c0fec121c36e2fe248e730234ccd6cdea5bbe46dea6c132d7c9bfb6a4e32b", 16 | "size" : 699 17 | }, 18 | { 19 | "artifactType" : "asset", 20 | "path" : "App.app\/Assets.car\/Tuist-Filled@2x.png", 21 | "shasum" : "62618f6f558cd57f6ecd5a3171324b2328ba44df7f13fda31cee214bc9ba2b10", 22 | "size" : 1370 23 | }, 24 | { 25 | "artifactType" : "asset", 26 | "path" : "App.app\/Assets.car\/Tuist-Filled@3x.png", 27 | "shasum" : "3171a380ea518c7de3fd35a0cb5a5dcb47d9df2bb72a2c7d75f48762dbda6463", 28 | "size" : 2033 29 | } 30 | ], 31 | "path" : "App.app\/Assets.car", 32 | "shasum" : "9bab6256f2842bbe1e55116e3dc95a5be7c8cbf679ea9d593c871ee3ba97d52b", 33 | "size" : 26175 34 | }, 35 | { 36 | "artifactType" : "file", 37 | "path" : "App.app\/Info.plist", 38 | "shasum" : "8c649d8b1680706da7c41b250849f5fa8f3faa7c39ae3083f9ef8d40ea04abe0", 39 | "size" : 1246 40 | }, 41 | { 42 | "artifactType" : "file", 43 | "path" : "App.app\/PkgInfo", 44 | "shasum" : "82502191c9484b04d685374f9879a0066069c49b8acae7a04b01d38d07e8eca0", 45 | "size" : 8 46 | }, 47 | { 48 | "artifactType" : "directory", 49 | "children" : [ 50 | { 51 | "artifactType" : "file", 52 | "path" : "App.app\/_CodeSignature\/CodeResources", 53 | "shasum" : "430f792abd6a987126b4eb5aa267528e23040fdfaf27856d08a547b2dacc4461", 54 | "size" : 2317 55 | } 56 | ], 57 | "path" : "App.app\/_CodeSignature", 58 | "shasum" : "4cc29bd23aa69892a00a6a668566fa8fac7af5f14a1d6bcca55084206e9567b3", 59 | "size" : 2317 60 | }, 61 | { 62 | "artifactType" : "file", 63 | "path" : "App.app\/embedded.mobileprovision", 64 | "shasum" : "c1f9bf606f548105625f57e1e9011f47d5f33d2309a30f7b50a38fe81522640f", 65 | "size" : 18493 66 | } 67 | ], 68 | "bundleId" : "dev.tuist.rosalind.App", 69 | "installSize" : 141839, 70 | "name" : "App", 71 | "platforms" : [ 72 | "iPhoneOS" 73 | ], 74 | "type" : "xcarchive", 75 | "version" : "1.0" 76 | } -------------------------------------------------------------------------------- /Tests/RosalindTests/__Snapshots__/RosalindAcceptanceTests/ios_app_ipa.1: -------------------------------------------------------------------------------- 1 | { 2 | "artifacts" : [ 3 | { 4 | "artifactType" : "binary", 5 | "path" : "App.app\/App", 6 | "shasum" : "b9ed48c2cf99b440f6b08c1a0c2a151a581b5d0801332a1487f6594ed6fd94a5", 7 | "size" : 93600 8 | }, 9 | { 10 | "artifactType" : "asset", 11 | "children" : [ 12 | { 13 | "artifactType" : "asset", 14 | "path" : "App.app\/Assets.car\/Tuist-Filled.png", 15 | "shasum" : "880c0fec121c36e2fe248e730234ccd6cdea5bbe46dea6c132d7c9bfb6a4e32b", 16 | "size" : 699 17 | }, 18 | { 19 | "artifactType" : "asset", 20 | "path" : "App.app\/Assets.car\/Tuist-Filled@2x.png", 21 | "shasum" : "62618f6f558cd57f6ecd5a3171324b2328ba44df7f13fda31cee214bc9ba2b10", 22 | "size" : 1370 23 | }, 24 | { 25 | "artifactType" : "asset", 26 | "path" : "App.app\/Assets.car\/Tuist-Filled@3x.png", 27 | "shasum" : "3171a380ea518c7de3fd35a0cb5a5dcb47d9df2bb72a2c7d75f48762dbda6463", 28 | "size" : 2033 29 | } 30 | ], 31 | "path" : "App.app\/Assets.car", 32 | "shasum" : "9bab6256f2842bbe1e55116e3dc95a5be7c8cbf679ea9d593c871ee3ba97d52b", 33 | "size" : 26175 34 | }, 35 | { 36 | "artifactType" : "file", 37 | "path" : "App.app\/Info.plist", 38 | "shasum" : "8c649d8b1680706da7c41b250849f5fa8f3faa7c39ae3083f9ef8d40ea04abe0", 39 | "size" : 1246 40 | }, 41 | { 42 | "artifactType" : "file", 43 | "path" : "App.app\/PkgInfo", 44 | "shasum" : "82502191c9484b04d685374f9879a0066069c49b8acae7a04b01d38d07e8eca0", 45 | "size" : 8 46 | }, 47 | { 48 | "artifactType" : "directory", 49 | "children" : [ 50 | { 51 | "artifactType" : "file", 52 | "path" : "App.app\/_CodeSignature\/CodeResources", 53 | "shasum" : "430f792abd6a987126b4eb5aa267528e23040fdfaf27856d08a547b2dacc4461", 54 | "size" : 2317 55 | } 56 | ], 57 | "path" : "App.app\/_CodeSignature", 58 | "shasum" : "4cc29bd23aa69892a00a6a668566fa8fac7af5f14a1d6bcca55084206e9567b3", 59 | "size" : 2317 60 | }, 61 | { 62 | "artifactType" : "file", 63 | "path" : "App.app\/embedded.mobileprovision", 64 | "shasum" : "c1f9bf606f548105625f57e1e9011f47d5f33d2309a30f7b50a38fe81522640f", 65 | "size" : 18493 66 | } 67 | ], 68 | "bundleId" : "dev.tuist.rosalind.App", 69 | "downloadSize" : 27193, 70 | "installSize" : 141839, 71 | "name" : "App", 72 | "platforms" : [ 73 | "iPhoneOS" 74 | ], 75 | "type" : "ipa", 76 | "version" : "1.0" 77 | } -------------------------------------------------------------------------------- /Tests/RosalindTests/RosalindAcceptanceTests.swift: -------------------------------------------------------------------------------- 1 | import Command 2 | import FileSystem 3 | import Foundation 4 | import Path 5 | import Rosalind 6 | import SnapshotTesting 7 | import Testing 8 | 9 | struct RosalindAcceptanceTests { 10 | private let fileSystem = FileSystem() 11 | private let subject = Rosalind() 12 | 13 | // We run `assetutils` as part of these acceptance tests, so these won't run on Linux 14 | #if os(macOS) 15 | @Test func ios_app() async throws { 16 | try await withFixtureInTemporaryDirectory("ios_app") { _, fixtureDirectory in 17 | // When 18 | let got = try await subject 19 | .analyzeAppBundle( 20 | at: fixtureDirectory.appending(component: "App.app") 21 | ) 22 | 23 | // Then 24 | assertSnapshot( 25 | of: got, 26 | as: .rosalind() 27 | ) 28 | } 29 | } 30 | 31 | @Test func ios_app_xcarchive() async throws { 32 | try await withFixtureInTemporaryDirectory("ios_app") { _, fixtureDirectory in 33 | // When 34 | let got = try await subject 35 | .analyzeAppBundle( 36 | at: fixtureDirectory.appending(component: "App.xcarchive") 37 | ) 38 | 39 | // Then 40 | assertSnapshot( 41 | of: got, 42 | as: .rosalind() 43 | ) 44 | } 45 | } 46 | 47 | @Test func ios_app_ipa() async throws { 48 | try await withFixtureInTemporaryDirectory("ios_app") { _, fixtureDirectory in 49 | // When 50 | let got = try await subject 51 | .analyzeAppBundle( 52 | at: fixtureDirectory.appending(component: "App.ipa") 53 | ) 54 | 55 | // Then 56 | assertSnapshot( 57 | of: got, 58 | as: .rosalind() 59 | ) 60 | } 61 | } 62 | #endif 63 | 64 | private func withFixtureInTemporaryDirectory( 65 | _ fixturePath: String, 66 | callback: (AbsolutePath, AbsolutePath) async throws -> Void 67 | ) async throws { 68 | try await fileSystem.runInTemporaryDirectory(prefix: UUID().uuidString) { temporaryDirectory in 69 | let sourceFixtureDirectory = try AbsolutePath(validating: "\(#file)").parentDirectory.parentDirectory.parentDirectory 70 | .appending(component: "fixtures") 71 | .appending(try RelativePath(validating: fixturePath)) 72 | let targetFixtureDirectory = temporaryDirectory.appending(component: sourceFixtureDirectory.basename) 73 | try await fileSystem.copy(sourceFixtureDirectory, to: targetFixtureDirectory) 74 | try await callback(temporaryDirectory, targetFixtureDirectory) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /fixtures/ios_app/App.xcarchive/Products/Applications/App.app/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | files 6 | 7 | Assets.car 8 | 9 | y4yCYzPATMC06C4hf7A3KewSVi4= 10 | 11 | Info.plist 12 | 13 | ERHu2xM3WDwWtkA8S3+T0agN/rY= 14 | 15 | PkgInfo 16 | 17 | n57qDP4tZfLD1rCS43W0B4LQjzE= 18 | 19 | embedded.mobileprovision 20 | 21 | /wTiKJtdECfEQyPKNmCqg415FBc= 22 | 23 | 24 | files2 25 | 26 | Assets.car 27 | 28 | hash2 29 | 30 | m6tiVvKEK74eVRFuPclaW+fIy/Z56p1ZPIce47qX1Ss= 31 | 32 | 33 | embedded.mobileprovision 34 | 35 | hash2 36 | 37 | wfm/YG9UgQViX1fh6QEfR9XzPSMJow97UKOP6BUiZA8= 38 | 39 | 40 | 41 | rules 42 | 43 | ^.* 44 | 45 | ^.*\.lproj/ 46 | 47 | optional 48 | 49 | weight 50 | 1000 51 | 52 | ^.*\.lproj/locversion.plist$ 53 | 54 | omit 55 | 56 | weight 57 | 1100 58 | 59 | ^Base\.lproj/ 60 | 61 | weight 62 | 1010 63 | 64 | ^version.plist$ 65 | 66 | 67 | rules2 68 | 69 | .*\.dSYM($|/) 70 | 71 | weight 72 | 11 73 | 74 | ^(.*/)?\.DS_Store$ 75 | 76 | omit 77 | 78 | weight 79 | 2000 80 | 81 | ^.* 82 | 83 | ^.*\.lproj/ 84 | 85 | optional 86 | 87 | weight 88 | 1000 89 | 90 | ^.*\.lproj/locversion.plist$ 91 | 92 | omit 93 | 94 | weight 95 | 1100 96 | 97 | ^Base\.lproj/ 98 | 99 | weight 100 | 1010 101 | 102 | ^Info\.plist$ 103 | 104 | omit 105 | 106 | weight 107 | 20 108 | 109 | ^PkgInfo$ 110 | 111 | omit 112 | 113 | weight 114 | 20 115 | 116 | ^embedded\.provisionprofile$ 117 | 118 | weight 119 | 20 120 | 121 | ^version\.plist$ 122 | 123 | weight 124 | 20 125 | 126 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /Tests/RosalindTests/__Snapshots__/RosalindAcceptanceTests/ios_app.1: -------------------------------------------------------------------------------- 1 | { 2 | "artifacts" : [ 3 | { 4 | "artifactType" : "binary", 5 | "path" : "App.app\/App", 6 | "shasum" : "3317dd834f446547065042f12c9a953dd61bf6007f53b2da8bfb9d48906f23d6", 7 | "size" : 91520 8 | }, 9 | { 10 | "artifactType" : "binary", 11 | "path" : "App.app\/App.debug.dylib", 12 | "shasum" : "5a1f7e4e7ce91c23a024d8776e95e86c0ad71d48df8cff239220f7589f89d49d", 13 | "size" : 146880 14 | }, 15 | { 16 | "artifactType" : "asset", 17 | "children" : [ 18 | { 19 | "artifactType" : "asset", 20 | "path" : "App.app\/Assets.car\/Tuist-Filled.png", 21 | "shasum" : "880c0fec121c36e2fe248e730234ccd6cdea5bbe46dea6c132d7c9bfb6a4e32b", 22 | "size" : 699 23 | }, 24 | { 25 | "artifactType" : "asset", 26 | "path" : "App.app\/Assets.car\/Tuist-Filled@2x.png", 27 | "shasum" : "62618f6f558cd57f6ecd5a3171324b2328ba44df7f13fda31cee214bc9ba2b10", 28 | "size" : 1370 29 | }, 30 | { 31 | "artifactType" : "asset", 32 | "path" : "App.app\/Assets.car\/Tuist-Filled@3x.png", 33 | "shasum" : "3171a380ea518c7de3fd35a0cb5a5dcb47d9df2bb72a2c7d75f48762dbda6463", 34 | "size" : 2033 35 | } 36 | ], 37 | "path" : "App.app\/Assets.car", 38 | "shasum" : "9bab6256f2842bbe1e55116e3dc95a5be7c8cbf679ea9d593c871ee3ba97d52b", 39 | "size" : 26175 40 | }, 41 | { 42 | "artifactType" : "file", 43 | "path" : "App.app\/Info.plist", 44 | "shasum" : "8c649d8b1680706da7c41b250849f5fa8f3faa7c39ae3083f9ef8d40ea04abe0", 45 | "size" : 1246 46 | }, 47 | { 48 | "artifactType" : "file", 49 | "path" : "App.app\/PkgInfo", 50 | "shasum" : "82502191c9484b04d685374f9879a0066069c49b8acae7a04b01d38d07e8eca0", 51 | "size" : 8 52 | }, 53 | { 54 | "artifactType" : "directory", 55 | "children" : [ 56 | { 57 | "artifactType" : "file", 58 | "path" : "App.app\/_CodeSignature\/CodeResources", 59 | "shasum" : "2434d1efbebaceaf3a7718beb9a2a0777840a6e8d5042db8b17d123141df4879", 60 | "size" : 2749 61 | } 62 | ], 63 | "path" : "App.app\/_CodeSignature", 64 | "shasum" : "37cc25d3eff8466abed46755183e96e3202702bb6669f9d21b3c5655e3a7e9b7", 65 | "size" : 2749 66 | }, 67 | { 68 | "artifactType" : "binary", 69 | "path" : "App.app\/__preview.dylib", 70 | "shasum" : "8aa3f10d52b0596b5de2668b3d3620378ec41b1a51cf732fe60695dcbb9e2c2f", 71 | "size" : 35024 72 | }, 73 | { 74 | "artifactType" : "file", 75 | "path" : "App.app\/embedded.mobileprovision", 76 | "shasum" : "c1f9bf606f548105625f57e1e9011f47d5f33d2309a30f7b50a38fe81522640f", 77 | "size" : 18493 78 | } 79 | ], 80 | "bundleId" : "dev.tuist.rosalind.App", 81 | "installSize" : 322095, 82 | "name" : "App", 83 | "platforms" : [ 84 | "iPhoneOS" 85 | ], 86 | "type" : "app", 87 | "version" : "1.0" 88 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | inputs: 9 | version: 10 | description: "The version to release" 11 | type: string 12 | 13 | permissions: 14 | contents: write 15 | pull-requests: read 16 | statuses: write 17 | packages: write 18 | 19 | jobs: 20 | release: 21 | name: Release 22 | runs-on: "ubuntu-latest" 23 | timeout-minutes: 15 24 | if: "!startsWith(github.event.head_commit.message, '[Release]')" 25 | steps: 26 | - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 27 | with: 28 | fetch-depth: 0 29 | - uses: jdx/mise-action@v2 30 | with: 31 | experimental: true 32 | - name: Check if there are releasable changes 33 | id: is-releasable 34 | run: | 35 | bumped_output=$(git cliff --bump) 36 | changelog_content=$(cat CHANGELOG.md) 37 | 38 | bumped_hash=$(echo -n "$bumped_output" | shasum -a 256 | awk '{print $1}') 39 | changelog_hash=$(echo -n "$changelog_content" | shasum -a 256 | awk '{print $1}') 40 | 41 | if [ "$bumped_hash" != "$changelog_hash" ]; then 42 | echo "should-release=true" >> $GITHUB_ENV 43 | else 44 | echo "should-release=false" >> $GITHUB_ENV 45 | fi 46 | 47 | - name: Get next version 48 | id: next-version 49 | if: env.should-release == 'true' 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | run: echo "NEXT_VERSION=$(git cliff --bumped-version)" >> "$GITHUB_OUTPUT" 53 | - name: Get release notes 54 | id: release-notes 55 | if: env.should-release == 'true' 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | run: | 59 | echo "RELEASE_NOTES<> "$GITHUB_OUTPUT" 60 | git cliff --unreleased >> "$GITHUB_OUTPUT" 61 | echo "EOF" >> "$GITHUB_OUTPUT" 62 | - name: Update CHANGELOG.md 63 | if: env.should-release == 'true' 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | run: git cliff --bump -o CHANGELOG.md 67 | - name: Commit changes 68 | id: auto-commit-action 69 | uses: stefanzweifel/git-auto-commit-action@v5 70 | if: env.should-release == 'true' 71 | env: 72 | GITHUB_TOKEN: ${{ secrets.TUIST_FILE_SYSTEM_RELEASE_TOKEN }} 73 | with: 74 | commit_options: "--allow-empty" 75 | tagging_message: ${{ steps.next-version.outputs.NEXT_VERSION }} 76 | skip_dirty_check: true 77 | commit_message: "[Release] Rosalind ${{ steps.next-version.outputs.NEXT_VERSION }}" 78 | - name: Create GitHub Release 79 | uses: softprops/action-gh-release@v2 80 | if: env.should-release == 'true' 81 | with: 82 | draft: false 83 | repository: tuist/Rosalind 84 | name: ${{ steps.next-version.outputs.NEXT_VERSION }} 85 | tag_name: ${{ steps.next-version.outputs.NEXT_VERSION }} 86 | body: ${{ steps.release-notes.outputs.RELEASE_NOTES }} 87 | target_commitish: ${{ steps.auto-commit-action.outputs.commit_hash }} 88 | -------------------------------------------------------------------------------- /Sources/Rosalind/AppBundleReport.swift: -------------------------------------------------------------------------------- 1 | // A Rosalind report of an app bundle such as `.ipa`. 2 | public struct AppBundleReport: Sendable, Codable, Equatable { 3 | /// The type of app bundle being analyzed. 4 | public enum BundleType: String, Sendable, Codable, Equatable { 5 | /// A standalone `.app` bundle 6 | case app 7 | /// An iOS App Store Package (`.ipa`) containing a compressed app bundle 8 | case ipa 9 | /// An Xcode archive (`.xcarchive`) containing the app bundle and additional metadata 10 | case xcarchive 11 | } 12 | 13 | /// App's Bundle ID 14 | public let bundleId: String 15 | /// The app name 16 | public let name: String 17 | /// The type of the bundle 18 | public let type: BundleType 19 | /// The app install size in bytes. This is the size of the `.app` bundle and represents the value that will be installed on 20 | /// the device. 21 | public let installSize: Int 22 | /// The app download size in bytes. Only available for `.ipa`. It represents the compressed size that the users will end up 23 | /// downloading over the network. 24 | public let downloadSize: Int? 25 | /// List of supported platforms, such as `iPhoneSimulator`. List of possible values is the same as for 26 | /// `CFBundleSupportedPlatforms`. 27 | public let platforms: [String] 28 | /// The app version. 29 | public let version: String 30 | /// List of app-specific artifacts, such as fonts or binaries. 31 | public let artifacts: [AppBundleArtifact] 32 | 33 | public init( 34 | bundleId: String, 35 | name: String, 36 | type: BundleType, 37 | installSize: Int, 38 | downloadSize: Int?, 39 | platforms: [String], 40 | version: String, 41 | artifacts: [AppBundleArtifact] 42 | ) { 43 | self.bundleId = bundleId 44 | self.name = name 45 | self.type = type 46 | self.installSize = installSize 47 | self.downloadSize = downloadSize 48 | self.platforms = platforms 49 | self.version = version 50 | self.artifacts = artifacts 51 | } 52 | } 53 | 54 | public struct AppBundleArtifact: Sendable, Codable, Equatable { 55 | public enum ArtifactType: String, Sendable, Codable, Equatable { 56 | /// A generic directory artifact type. 57 | case directory 58 | /// A generic file artifact type when the file is not recognized as something more specific, such as `.font`. 59 | case file 60 | /// A font artifact. A font is considered any file with one of the following extensions: `.otf`, `.ttc`, `.ttf`, `.woff`. 61 | case font 62 | /// A binary recognized by the file's header which has to be either `MH_*` or `FAT_*`. 63 | case binary 64 | /// A localization file is any file that has one of the following extensions: `.strings` or `.xcstrings`. 65 | case localization 66 | /// An asset – either a `.car` file or a file inside the `.car` obtained using the `assetutils`. 67 | case asset 68 | } 69 | 70 | /// The type of the artifact, such as `.font`. 71 | public let artifactType: ArtifactType 72 | public let path: String 73 | public let size: Int 74 | public let shasum: String 75 | public let children: [AppBundleArtifact]? 76 | } 77 | -------------------------------------------------------------------------------- /Sources/Rosalind/AssetUtilController.swift: -------------------------------------------------------------------------------- 1 | import Command 2 | import Foundation 3 | import Mockable 4 | import Path 5 | 6 | enum AssetUtilControllerError: LocalizedError { 7 | case parsingFailed(AbsolutePath) 8 | case decodingFailed(path: AbsolutePath, jsonString: String, underlyingError: Error) 9 | 10 | var errorDescription: String? { 11 | switch self { 12 | case let .parsingFailed(path): 13 | return "Parsing of \(path.pathString) failed. Make sure the file is valid." 14 | case let .decodingFailed(path: path, jsonString: jsonString, underlyingError: error): 15 | return """ 16 | Failed to decode asset info from \(path.pathString). 17 | Underlying error: \(error.localizedDescription) 18 | JSON excerpt: \(String(jsonString.prefix(200)))... 19 | """ 20 | } 21 | } 22 | } 23 | 24 | struct AssetInfo: Decodable { 25 | enum CodingKeys: String, CodingKey { 26 | case sizeOnDisk = "SizeOnDisk" 27 | case sha1Digest = "SHA1Digest" 28 | case renditionName = "RenditionName" 29 | } 30 | 31 | // All properties are optional because [AssetInfo] is a hetergoneous array 32 | let sizeOnDisk: Int? 33 | let sha1Digest: String? 34 | let renditionName: String? 35 | } 36 | 37 | @Mockable 38 | protocol AssetUtilControlling: Sendable { 39 | func info(at path: AbsolutePath) async throws -> [AssetInfo] 40 | } 41 | 42 | struct AssetUtilController: AssetUtilControlling { 43 | @TaskLocal static var poolLock: PoolLock = .init(capacity: 5) 44 | 45 | static func acquiringPoolLock(_ closure: () async throws -> Void) async throws { 46 | await poolLock.acquire() 47 | do { 48 | try await closure() 49 | } catch { 50 | await poolLock.release() 51 | throw error 52 | } 53 | await poolLock.release() 54 | } 55 | 56 | private let commandRunner: CommandRunning 57 | private let jsonDecoder = JSONDecoder() 58 | 59 | init(commandRunner: CommandRunning = CommandRunner()) { 60 | self.commandRunner = commandRunner 61 | } 62 | 63 | func info(at path: AbsolutePath) async throws -> [AssetInfo] { 64 | await Self.poolLock.acquire() 65 | 66 | let output = try await commandRunner.run(arguments: ["/usr/bin/xcrun", "assetutil", "--info", path.pathString]) 67 | .concatenatedString() 68 | 69 | await Self.poolLock.release() 70 | 71 | // Extract JSON part by finding the first '[' or '{' character 72 | // assetutil sometimes outputs warnings before the JSON 73 | guard let jsonStartIndex = output.firstIndex(of: "[") ?? output.firstIndex(of: "{") else { 74 | throw AssetUtilControllerError.parsingFailed(path) 75 | } 76 | 77 | let jsonString = String(output[jsonStartIndex...]) 78 | 79 | guard let data = jsonString.data(using: .utf8) else { 80 | throw AssetUtilControllerError.parsingFailed(path) 81 | } 82 | 83 | do { 84 | let result = try jsonDecoder.decode([AssetInfo].self, from: data) 85 | return result 86 | } catch { 87 | throw AssetUtilControllerError.decodingFailed( 88 | path: path, 89 | jsonString: jsonString, 90 | underlyingError: error 91 | ) 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Sources/Rosalind/AppBundle.swift: -------------------------------------------------------------------------------- 1 | import Path 2 | 3 | struct AppBundle: Equatable { 4 | /// Path to the app bundle 5 | let path: AbsolutePath 6 | 7 | /// The app's Info.plist 8 | let infoPlist: InfoPlist 9 | 10 | struct InfoPlist: Decodable, Equatable { 11 | /// App version number (e.g. 10.3) 12 | let version: String 13 | 14 | /// Name of the app 15 | let name: String 16 | 17 | /// Bundle ID 18 | let bundleId: String 19 | 20 | /// Minimum OS version 21 | let minimumOSVersion: String 22 | 23 | /// Supported destination platforms. 24 | let supportedPlatforms: [String] 25 | 26 | init( 27 | version: String, 28 | name: String, 29 | bundleId: String, 30 | minimumOSVersion: String, 31 | supportedPlatforms: [String] 32 | ) { 33 | self.version = version 34 | self.name = name 35 | self.bundleId = bundleId 36 | self.minimumOSVersion = minimumOSVersion 37 | self.supportedPlatforms = supportedPlatforms 38 | } 39 | 40 | enum CodingKeys: String, CodingKey { 41 | case version = "CFBundleShortVersionString" 42 | case name = "CFBundleName" 43 | case bundleId = "CFBundleIdentifier" 44 | case minimumOSVersion = "MinimumOSVersion" 45 | case supportedPlatforms = "CFBundleSupportedPlatforms" 46 | } 47 | 48 | init(from decoder: any Decoder) throws { 49 | let container: KeyedDecodingContainer = try decoder 50 | .container(keyedBy: AppBundle.InfoPlist.CodingKeys.self) 51 | version = try container.decode(String.self, forKey: AppBundle.InfoPlist.CodingKeys.version) 52 | name = try container.decode(String.self, forKey: AppBundle.InfoPlist.CodingKeys.name) 53 | bundleId = try container.decode(String.self, forKey: AppBundle.InfoPlist.CodingKeys.bundleId) 54 | minimumOSVersion = try container.decode(String.self, forKey: AppBundle.InfoPlist.CodingKeys.minimumOSVersion) 55 | supportedPlatforms = try container.decode([String].self, forKey: AppBundle.InfoPlist.CodingKeys.supportedPlatforms) 56 | } 57 | } 58 | } 59 | 60 | #if DEBUG 61 | extension AppBundle { 62 | static func test( 63 | // swiftlint:disable:next force_try 64 | path: AbsolutePath = try! AbsolutePath(validating: "/App.app"), 65 | infoPlist: AppBundle.InfoPlist = .test() 66 | ) -> AppBundle { 67 | AppBundle( 68 | path: path, 69 | infoPlist: infoPlist 70 | ) 71 | } 72 | } 73 | 74 | extension AppBundle.InfoPlist { 75 | static func test( 76 | version: String = "1.0", 77 | name: String = "App", 78 | bundleId: String = "com.App", 79 | minimumOSVersion: String = "18.0", 80 | supportedPlatforms: [String] = ["iPhoneOS"] 81 | ) -> AppBundle.InfoPlist { 82 | AppBundle.InfoPlist( 83 | version: version, 84 | name: name, 85 | bundleId: bundleId, 86 | minimumOSVersion: minimumOSVersion, 87 | supportedPlatforms: supportedPlatforms 88 | ) 89 | } 90 | } 91 | #endif 92 | -------------------------------------------------------------------------------- /fixtures/ios_app/App.app/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | files 6 | 7 | App.debug.dylib 8 | 9 | rdJBFzW0lAeZ6Hi1SPCjWMgKZfg= 10 | 11 | Assets.car 12 | 13 | y4yCYzPATMC06C4hf7A3KewSVi4= 14 | 15 | Info.plist 16 | 17 | ERHu2xM3WDwWtkA8S3+T0agN/rY= 18 | 19 | PkgInfo 20 | 21 | n57qDP4tZfLD1rCS43W0B4LQjzE= 22 | 23 | __preview.dylib 24 | 25 | 6n4QPxM7TZU9hogf/4x6nQGUYaY= 26 | 27 | embedded.mobileprovision 28 | 29 | /wTiKJtdECfEQyPKNmCqg415FBc= 30 | 31 | 32 | files2 33 | 34 | App.debug.dylib 35 | 36 | hash2 37 | 38 | Wh9+TnzpHCOgJNh3bpXobArXHUjfjP8jkiD3WJ+J1J0= 39 | 40 | 41 | Assets.car 42 | 43 | hash2 44 | 45 | m6tiVvKEK74eVRFuPclaW+fIy/Z56p1ZPIce47qX1Ss= 46 | 47 | 48 | __preview.dylib 49 | 50 | hash2 51 | 52 | iqPxDVKwWWtd4maLPTYgN47EGxpRz3Mv5gaV3LueLC8= 53 | 54 | 55 | embedded.mobileprovision 56 | 57 | hash2 58 | 59 | wfm/YG9UgQViX1fh6QEfR9XzPSMJow97UKOP6BUiZA8= 60 | 61 | 62 | 63 | rules 64 | 65 | ^.* 66 | 67 | ^.*\.lproj/ 68 | 69 | optional 70 | 71 | weight 72 | 1000 73 | 74 | ^.*\.lproj/locversion.plist$ 75 | 76 | omit 77 | 78 | weight 79 | 1100 80 | 81 | ^Base\.lproj/ 82 | 83 | weight 84 | 1010 85 | 86 | ^version.plist$ 87 | 88 | 89 | rules2 90 | 91 | .*\.dSYM($|/) 92 | 93 | weight 94 | 11 95 | 96 | ^(.*/)?\.DS_Store$ 97 | 98 | omit 99 | 100 | weight 101 | 2000 102 | 103 | ^.* 104 | 105 | ^.*\.lproj/ 106 | 107 | optional 108 | 109 | weight 110 | 1000 111 | 112 | ^.*\.lproj/locversion.plist$ 113 | 114 | omit 115 | 116 | weight 117 | 1100 118 | 119 | ^Base\.lproj/ 120 | 121 | weight 122 | 1010 123 | 124 | ^Info\.plist$ 125 | 126 | omit 127 | 128 | weight 129 | 20 130 | 131 | ^PkgInfo$ 132 | 133 | omit 134 | 135 | weight 136 | 20 137 | 138 | ^embedded\.provisionprofile$ 139 | 140 | weight 141 | 20 142 | 143 | ^version\.plist$ 144 | 145 | weight 146 | 20 147 | 148 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ configuration file 2 | # https://git-cliff.org/docs/configuration 3 | 4 | [remote.github] 5 | owner = "tuist" 6 | repo = "Rosalind" 7 | # token = "" 8 | 9 | [changelog] 10 | # template for the changelog header 11 | header = """ 12 | # Changelog\n 13 | All notable changes to this project will be documented in this file. 14 | 15 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 16 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n 17 | """ 18 | # template for the changelog body 19 | # https://keats.github.io/tera/docs/#introduction 20 | body = """ 21 | {%- macro remote_url() -%} 22 | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} 23 | {%- endmacro -%} 24 | 25 | {% if version -%} 26 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 27 | {% else -%} 28 | ## [Unreleased] 29 | {% endif -%} 30 | 31 | ### Details\ 32 | 33 | {% for group, commits in commits | group_by(attribute="group") %} 34 | #### {{ group | upper_first }} 35 | {%- for commit in commits %} 36 | - {{ commit.message | upper_first | trim }}\ 37 | {% if commit.github.username %} by @{{ commit.github.username }}{%- endif -%} 38 | {% if commit.github.pr_number %} in \ 39 | [#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}) \ 40 | {%- endif -%} 41 | {% endfor %} 42 | {% endfor %} 43 | 44 | {%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} 45 | ## New Contributors 46 | {%- endif -%} 47 | 48 | {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} 49 | * @{{ contributor.username }} made their first contribution 50 | {%- if contributor.pr_number %} in \ 51 | [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ 52 | {%- endif %} 53 | {%- endfor %}\n 54 | """ 55 | # template for the changelog footer 56 | footer = """ 57 | {%- macro remote_url() -%} 58 | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} 59 | {%- endmacro -%} 60 | 61 | {% for release in releases -%} 62 | {% if release.version -%} 63 | {% if release.previous.version -%} 64 | [{{ release.version | trim_start_matches(pat="v") }}]: \ 65 | {{ self::remote_url() }}/compare/{{ release.previous.version }}..{{ release.version }} 66 | {% endif -%} 67 | {% else -%} 68 | [unreleased]: {{ self::remote_url() }}/compare/{{ release.previous.version }}..HEAD 69 | {% endif -%} 70 | {% endfor %} 71 | 72 | """ 73 | # remove the leading and trailing whitespace from the templates 74 | trim = true 75 | # postprocessors 76 | postprocessors = [] 77 | 78 | [git] 79 | # parse the commits based on https://www.conventionalcommits.org 80 | conventional_commits = true 81 | # filter out the commits that are not conventional 82 | filter_unconventional = true 83 | # process each line of a commit as an individual commit 84 | split_commits = false 85 | # regex for preprocessing the commit messages 86 | commit_preprocessors = [ 87 | # remove issue numbers from commits 88 | { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }, 89 | ] 90 | # protect breaking changes from being skipped due to matching a skipping commit_parser 91 | protect_breaking_commits = false 92 | # filter out the commits that are not matched by commit parsers 93 | filter_commits = false 94 | # regex for matching git tags 95 | tag_pattern = "[0-9].*" 96 | # regex for skipping tags 97 | skip_tags = "beta|alpha" 98 | # regex for ignoring tags 99 | ignore_tags = "rc" 100 | # sort the tags topologically 101 | topo_order = false 102 | # sort the commits inside sections by oldest/newest order 103 | sort_commits = "newest" 104 | 105 | [bump] 106 | breaking_always_bump_major = true 107 | features_always_bump_minor = true 108 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "f6107cbf024d83cfcfdb58f5807082a5b43da986acac44a9efa28f2d7f2bce82", 3 | "pins" : [ 4 | { 5 | "identity" : "command", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/tuist/Command.git", 8 | "state" : { 9 | "revision" : "079a7803b581d3022469b3a331bccd51d48d2fc0", 10 | "version" : "0.13.0" 11 | } 12 | }, 13 | { 14 | "identity" : "filesystem", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/tuist/FileSystem.git", 17 | "state" : { 18 | "revision" : "a1e269795deada75a1a8291ca1595a0418283dcb", 19 | "version" : "0.13.2" 20 | } 21 | }, 22 | { 23 | "identity" : "machokit", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/p-x9/MachOKit", 26 | "state" : { 27 | "revision" : "fc1f2646220b3ce9db2e415b6b8848f5a89ed865", 28 | "version" : "0.39.0" 29 | } 30 | }, 31 | { 32 | "identity" : "mockable", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/Kolos65/Mockable", 35 | "state" : { 36 | "revision" : "ee133a696dce312da292b00d0944aafaa808eaca", 37 | "version" : "0.4.0" 38 | } 39 | }, 40 | { 41 | "identity" : "path", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/tuist/Path.git", 44 | "state" : { 45 | "revision" : "7c74ac435e03a927c3a73134c48b61e60221abcb", 46 | "version" : "0.3.8" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-asn1", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/apple/swift-asn1.git", 53 | "state" : { 54 | "revision" : "ae33e5941bb88d88538d0a6b19ca0b01e6c76dcf", 55 | "version" : "1.3.1" 56 | } 57 | }, 58 | { 59 | "identity" : "swift-atomics", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/apple/swift-atomics.git", 62 | "state" : { 63 | "revision" : "cd142fd2f64be2100422d658e7411e39489da985", 64 | "version" : "1.2.0" 65 | } 66 | }, 67 | { 68 | "identity" : "swift-collections", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/apple/swift-collections.git", 71 | "state" : { 72 | "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", 73 | "version" : "1.1.2" 74 | } 75 | }, 76 | { 77 | "identity" : "swift-crypto", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/apple/swift-crypto.git", 80 | "state" : { 81 | "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", 82 | "version" : "3.15.1" 83 | } 84 | }, 85 | { 86 | "identity" : "swift-custom-dump", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 89 | "state" : { 90 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", 91 | "version" : "1.3.3" 92 | } 93 | }, 94 | { 95 | "identity" : "swift-fileio", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/p-x9/swift-fileio.git", 98 | "state" : { 99 | "revision" : "76a4c719e796a1e5052e283788d8cd92408758fe", 100 | "version" : "0.12.0" 101 | } 102 | }, 103 | { 104 | "identity" : "swift-log", 105 | "kind" : "remoteSourceControl", 106 | "location" : "https://github.com/apple/swift-log", 107 | "state" : { 108 | "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", 109 | "version" : "1.6.4" 110 | } 111 | }, 112 | { 113 | "identity" : "swift-nio", 114 | "kind" : "remoteSourceControl", 115 | "location" : "https://github.com/apple/swift-nio", 116 | "state" : { 117 | "revision" : "a18bddb0acf7a40d982b2f128ce73ce4ee31f352", 118 | "version" : "2.86.2" 119 | } 120 | }, 121 | { 122 | "identity" : "swift-snapshot-testing", 123 | "kind" : "remoteSourceControl", 124 | "location" : "https://github.com/pointfreeco/swift-snapshot-testing", 125 | "state" : { 126 | "revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b", 127 | "version" : "1.18.7" 128 | } 129 | }, 130 | { 131 | "identity" : "swift-syntax", 132 | "kind" : "remoteSourceControl", 133 | "location" : "https://github.com/swiftlang/swift-syntax.git", 134 | "state" : { 135 | "revision" : "0687f71944021d616d34d922343dcef086855920", 136 | "version" : "600.0.1" 137 | } 138 | }, 139 | { 140 | "identity" : "swift-system", 141 | "kind" : "remoteSourceControl", 142 | "location" : "https://github.com/apple/swift-system.git", 143 | "state" : { 144 | "revision" : "a34201439c74b53f0fd71ef11741af7e7caf01e1", 145 | "version" : "1.4.2" 146 | } 147 | }, 148 | { 149 | "identity" : "xctest-dynamic-overlay", 150 | "kind" : "remoteSourceControl", 151 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 152 | "state" : { 153 | "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", 154 | "version" : "1.5.2" 155 | } 156 | }, 157 | { 158 | "identity" : "zipfoundation", 159 | "kind" : "remoteSourceControl", 160 | "location" : "https://github.com/tuist/ZIPFoundation", 161 | "state" : { 162 | "revision" : "e9b1917bd4d7d050e0ff4ec157b5d6e253c84385", 163 | "version" : "0.9.20" 164 | } 165 | } 166 | ], 167 | "version" : 3 168 | } 169 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/macos,swift 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos,swift 3 | 4 | ### macOS ### 5 | # General 6 | .DS_Store 7 | .AppleDouble 8 | .LSOverride 9 | 10 | # Icon must end with two \r 11 | Icon 12 | 13 | 14 | # Thumbnails 15 | ._* 16 | 17 | # Files that might appear in the root of a volume 18 | .DocumentRevisions-V100 19 | .fseventsd 20 | .Spotlight-V100 21 | .TemporaryItems 22 | .Trashes 23 | .VolumeIcon.icns 24 | .com.apple.timemachine.donotpresent 25 | 26 | # Directories potentially created on remote AFP share 27 | .AppleDB 28 | .AppleDesktop 29 | Network Trash Folder 30 | Temporary Items 31 | .apdisk 32 | 33 | ### macOS Patch ### 34 | # iCloud generated files 35 | *.icloud 36 | 37 | ### Swift ### 38 | # Xcode 39 | # 40 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 41 | 42 | ## User settings 43 | xcuserdata/ 44 | 45 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 46 | *.xcscmblueprint 47 | *.xccheckout 48 | 49 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 50 | build/ 51 | DerivedData/ 52 | *.moved-aside 53 | *.pbxuser 54 | !default.pbxuser 55 | *.mode1v3 56 | !default.mode1v3 57 | *.mode2v3 58 | !default.mode2v3 59 | *.perspectivev3 60 | !default.perspectivev3 61 | 62 | ## Obj-C/Swift specific 63 | *.hmap 64 | 65 | ## App packaging 66 | *.dSYM.zip 67 | *.dSYM 68 | 69 | ## Playgrounds 70 | timeline.xctimeline 71 | playground.xcworkspace 72 | 73 | # Swift Package Manager 74 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 75 | # Packages/ 76 | # Package.pins 77 | # Package.resolved 78 | # *.xcodeproj 79 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 80 | # hence it is not needed unless you have added a package configuration file to your project 81 | .swiftpm 82 | 83 | .build/ 84 | 85 | # CocoaPods 86 | # We recommend against adding the Pods directory to your .gitignore. However 87 | # you should judge for yourself, the pros and cons are mentioned at: 88 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 89 | # Pods/ 90 | # Add this line if you want to avoid checking in source code from the Xcode workspace 91 | # *.xcworkspace 92 | 93 | # Carthage 94 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 95 | # Carthage/Checkouts 96 | 97 | Carthage/Build/ 98 | 99 | # Accio dependency management 100 | Dependencies/ 101 | .accio/ 102 | 103 | # fastlane 104 | # It is recommended to not store the screenshots in the git repo. 105 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 106 | # For more information about the recommended setup visit: 107 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 108 | 109 | fastlane/report.xml 110 | fastlane/Preview.html 111 | fastlane/screenshots/**/*.png 112 | fastlane/test_output 113 | 114 | # Code Injection 115 | # After new code Injection tools there's a generated folder /iOSInjectionProject 116 | # https://github.com/johnno1962/injectionforxcode 117 | 118 | iOSInjectionProject/ 119 | 120 | # End of https://www.toptal.com/developers/gitignore/api/macos,swift 121 | 122 | ### Tuist derived files ### 123 | graph.dot 124 | Derived/ 125 | 126 | ### Tuist managed dependencies ### 127 | Tuist/.build 128 | 129 | # Created by https://www.toptal.com/developers/gitignore/api/node 130 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 131 | 132 | ### Node ### 133 | # Logs 134 | logs 135 | *.log 136 | npm-debug.log* 137 | yarn-debug.log* 138 | yarn-error.log* 139 | lerna-debug.log* 140 | .pnpm-debug.log* 141 | 142 | # Diagnostic reports (https://nodejs.org/api/report.html) 143 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 144 | 145 | # Runtime data 146 | pids 147 | *.pid 148 | *.seed 149 | *.pid.lock 150 | 151 | # Directory for instrumented libs generated by jscoverage/JSCover 152 | lib-cov 153 | 154 | # Coverage directory used by tools like istanbul 155 | coverage 156 | *.lcov 157 | 158 | # nyc test coverage 159 | .nyc_output 160 | 161 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 162 | .grunt 163 | 164 | # Bower dependency directory (https://bower.io/) 165 | bower_components 166 | 167 | # node-waf configuration 168 | .lock-wscript 169 | 170 | # Compiled binary addons (https://nodejs.org/api/addons.html) 171 | build/Release 172 | 173 | # Dependency directories 174 | node_modules/ 175 | jspm_packages/ 176 | 177 | # Snowpack dependency directory (https://snowpack.dev/) 178 | web_modules/ 179 | 180 | # TypeScript cache 181 | *.tsbuildinfo 182 | 183 | # Optional npm cache directory 184 | .npm 185 | 186 | # Optional eslint cache 187 | .eslintcache 188 | 189 | # Optional stylelint cache 190 | .stylelintcache 191 | 192 | # Microbundle cache 193 | .rpt2_cache/ 194 | .rts2_cache_cjs/ 195 | .rts2_cache_es/ 196 | .rts2_cache_umd/ 197 | 198 | # Optional REPL history 199 | .node_repl_history 200 | 201 | # Output of 'npm pack' 202 | *.tgz 203 | 204 | # Yarn Integrity file 205 | .yarn-integrity 206 | 207 | # dotenv environment variable files 208 | .env 209 | .env.development.local 210 | .env.test.local 211 | .env.production.local 212 | .env.local 213 | 214 | # parcel-bundler cache (https://parceljs.org/) 215 | .cache 216 | .parcel-cache 217 | 218 | # Next.js build output 219 | .next 220 | out 221 | 222 | # Nuxt.js build / generate output 223 | .nuxt 224 | dist 225 | 226 | # Gatsby files 227 | .cache/ 228 | # Comment in the public line in if your project uses Gatsby and not Next.js 229 | # https://nextjs.org/blog/next-9-1#public-directory-support 230 | # public 231 | 232 | # vuepress build output 233 | .vuepress/dist 234 | 235 | # vuepress v2.x temp and cache directory 236 | .temp 237 | 238 | # Docusaurus cache and generated files 239 | .docusaurus 240 | 241 | # Serverless directories 242 | .serverless/ 243 | 244 | # FuseBox cache 245 | .fusebox/ 246 | 247 | # DynamoDB Local files 248 | .dynamodb/ 249 | 250 | # TernJS port file 251 | .tern-port 252 | 253 | # Stores VSCode versions used for testing VSCode extensions 254 | .vscode-test 255 | 256 | # yarn v2 257 | .yarn/cache 258 | .yarn/unplugged 259 | .yarn/build-state.yml 260 | .yarn/install-state.gz 261 | .pnp.* 262 | 263 | ### Node Patch ### 264 | # Serverless Webpack directories 265 | .webpack/ 266 | 267 | # Optional stylelint cache 268 | 269 | # SvelteKit build / generate output 270 | .svelte-kit 271 | 272 | # End of https://www.toptal.com/developers/gitignore/api/node 273 | 274 | docs/.vitepress/cache 275 | 276 | .swiftpm/**/*.xcworkspace/ 277 | .swiftpm/**/*.xcodeproj/ 278 | .claude 279 | -------------------------------------------------------------------------------- /Sources/Rosalind/Rosalind.swift: -------------------------------------------------------------------------------- 1 | import Command 2 | @preconcurrency import FileSystem 3 | import Foundation 4 | import MachOKit 5 | import Path 6 | 7 | enum RosalindError: LocalizedError, Equatable { 8 | case notFound(AbsolutePath) 9 | case appNotFound(AbsolutePath) 10 | case notSupported(AbsolutePath) 11 | 12 | var errorDescription: String? { 13 | switch self { 14 | case let .notFound(path): 15 | return "File not found at path \(path.pathString)" 16 | case let .appNotFound(path): 17 | return "No app found at \(path). Make sure the passed app bundle is valid." 18 | case let .notSupported(path): 19 | return "The app bundle \(path) is not supported. Only `.xcarchive`, `.ipa`, and `.app` bundles are supported." 20 | } 21 | } 22 | } 23 | 24 | public protocol Rosalindable: Sendable { 25 | func analyzeAppBundle(at path: AbsolutePath) async throws -> AppBundleReport 26 | } 27 | 28 | enum FileSystemArtifact { 29 | case file(AbsolutePath) 30 | case directory(AbsolutePath) 31 | 32 | var path: AbsolutePath { 33 | switch self { 34 | case let .file(path): return path 35 | case let .directory(path): return path 36 | } 37 | } 38 | 39 | var isFile: Bool { 40 | switch self { 41 | case .file: 42 | return true 43 | case .directory: 44 | return false 45 | } 46 | } 47 | 48 | var isDirectory: Bool { 49 | switch self { 50 | case .file: 51 | return false 52 | case .directory: 53 | return true 54 | } 55 | } 56 | } 57 | 58 | /// Rosalind is the main interface to analyzing app artifacts. 59 | /// Once instantiated, you can invoke the function `analyze` passing an absolute path to the artifact, 60 | /// and you'll get a `Codable` report back. 61 | public struct Rosalind: Rosalindable { 62 | private let fileSystem: FileSysteming 63 | private let appBundleLoader: AppBundleLoading 64 | private let shasumCalculator: ShasumCalculating 65 | private let assetUtilController: AssetUtilControlling 66 | 67 | /// The default constructor of Rosalind. 68 | public init() { 69 | self.init( 70 | fileSystem: FileSystem(), 71 | appBundleLoader: AppBundleLoader(), 72 | shasumCalculator: ShasumCalculator(), 73 | assetUtilController: AssetUtilController() 74 | ) 75 | } 76 | 77 | init( 78 | fileSystem: FileSysteming, 79 | appBundleLoader: AppBundleLoading, 80 | shasumCalculator: ShasumCalculating, 81 | assetUtilController: AssetUtilControlling 82 | ) { 83 | self.fileSystem = fileSystem 84 | self.appBundleLoader = appBundleLoader 85 | self.shasumCalculator = shasumCalculator 86 | self.assetUtilController = assetUtilController 87 | } 88 | 89 | /// Given the absolute path to an artifact that's result of a compilation, for example a .app bundle, 90 | /// Rosalind analyzes it and returns a report. 91 | /// - Parameter path: Absolute path to the artifact. If it doesn't exist, Rosalind throws. 92 | /// - Returns: A `RosalindReport` instance that captures the analysis. 93 | public func analyzeAppBundle(at path: AbsolutePath) async throws -> AppBundleReport { 94 | guard try await fileSystem.exists(path) else { throw RosalindError.notFound(path) } 95 | return try await fileSystem.runInTemporaryDirectory(prefix: UUID().uuidString) { temporaryDirectory in 96 | let appBundlePath = try await appBundlePath(path, temporaryDirectory: temporaryDirectory) 97 | let artifactPath = try await pathToArtifact(appBundlePath) 98 | let artifact = try await traverse( 99 | artifact: artifactPath, 100 | baseArtifact: artifactPath 101 | ) 102 | let appBundle = try await appBundleLoader.load(appBundlePath) 103 | 104 | let downloadSize: Int? 105 | let bundleType: AppBundleReport.BundleType 106 | switch path.extension { 107 | case "ipa": 108 | downloadSize = try fileSize(at: path) 109 | bundleType = .ipa 110 | case "xcarchive": 111 | downloadSize = nil 112 | bundleType = .xcarchive 113 | default: 114 | downloadSize = nil 115 | bundleType = .app 116 | } 117 | 118 | return AppBundleReport( 119 | bundleId: appBundle.infoPlist.bundleId, 120 | name: appBundle.infoPlist.name, 121 | type: bundleType, 122 | installSize: artifact.size, 123 | downloadSize: downloadSize, 124 | platforms: appBundle.infoPlist.supportedPlatforms, 125 | version: appBundle.infoPlist.version, 126 | artifacts: artifact.children ?? [] 127 | ) 128 | } 129 | } 130 | 131 | private func appBundlePath( 132 | _ path: AbsolutePath, 133 | temporaryDirectory: AbsolutePath 134 | ) async throws -> AbsolutePath { 135 | switch path.extension { 136 | case "xcarchive": 137 | guard let appPath = try await fileSystem.glob( 138 | directory: path.appending(components: "Products", "Applications"), 139 | include: ["*.app"] 140 | ) 141 | .collect() 142 | .first else { 143 | throw RosalindError.appNotFound(path) 144 | } 145 | return appPath 146 | case "ipa": 147 | let unzippedPath = temporaryDirectory.appending(component: "App") 148 | try await fileSystem.unzip(path, to: unzippedPath) 149 | guard let appPath = try await fileSystem.glob( 150 | directory: unzippedPath.appending(component: "Payload"), 151 | include: ["*.app"] 152 | ) 153 | .collect() 154 | .first else { 155 | throw RosalindError.appNotFound(path) 156 | } 157 | return appPath 158 | case "app": 159 | return path 160 | default: 161 | throw RosalindError.notSupported(path) 162 | } 163 | } 164 | 165 | private func traverse(artifact: FileSystemArtifact, baseArtifact: FileSystemArtifact) async throws -> AppBundleArtifact { 166 | let children: [AppBundleArtifact]? 167 | let artifactType = try artifactType(for: artifact) 168 | switch artifactType { 169 | case .asset: 170 | let infos = try await assetUtilController.info(at: artifact.path) 171 | children = try infos.compactMap { info -> AppBundleArtifact? in 172 | guard let sizeOnDisk = info.sizeOnDisk, 173 | let sha1Digest = info.sha1Digest, 174 | let renditionName = info.renditionName 175 | else { return nil } 176 | 177 | return AppBundleArtifact( 178 | artifactType: .asset, 179 | path: try RelativePath(validating: baseArtifact.path.basename) 180 | .appending(artifact.path.appending(component: renditionName).relative(to: baseArtifact.path)).pathString, 181 | size: sizeOnDisk, 182 | shasum: sha1Digest.lowercased(), 183 | children: nil 184 | ) 185 | } 186 | case .directory: 187 | children = try await fileSystem.glob(directory: artifact.path, include: ["*"]).collect().sorted() 188 | .asyncMap { 189 | try await traverse(artifact: pathToArtifact($0), baseArtifact: baseArtifact) 190 | } 191 | case .file, .binary, .localization, .font: 192 | children = nil 193 | } 194 | 195 | let size = try await size(artifact: artifact, children: children ?? []) 196 | let shasum = try await shasum(artifact: artifact, children: children ?? []) 197 | return AppBundleArtifact( 198 | artifactType: artifactType, 199 | path: try RelativePath(validating: baseArtifact.path.basename) 200 | .appending(artifact.path.relative(to: baseArtifact.path)).pathString, 201 | size: size, 202 | shasum: shasum, 203 | children: children 204 | ) 205 | } 206 | 207 | private func artifactType(for artifact: FileSystemArtifact) throws -> AppBundleArtifact.ArtifactType { 208 | switch artifact.path.extension { 209 | case "otf", "ttc", "ttf", "woff": return .font 210 | case "strings", "xcstrings": return .localization 211 | case "car": return .asset 212 | default: 213 | if artifact.isDirectory { 214 | return .directory 215 | } else { 216 | let fileURL = URL(fileURLWithPath: artifact.path.pathString) 217 | let fileHandle = try FileHandle(forReadingFrom: fileURL) 218 | defer { try? fileHandle.close() } 219 | 220 | if let magicRaw: UInt32 = fileHandle.read(offset: 0), 221 | Magic(rawValue: magicRaw) != nil 222 | { 223 | return .binary 224 | } else { 225 | return .file 226 | } 227 | } 228 | } 229 | } 230 | 231 | private func shasum(artifact: FileSystemArtifact, children: [AppBundleArtifact]) async throws -> String { 232 | if artifact.isDirectory { 233 | return try await shasumCalculator.calculate(childrenShasums: children.map(\.shasum).sorted()) 234 | } else { 235 | return try await shasumCalculator.calculate(filePath: artifact.path) 236 | } 237 | } 238 | 239 | private func pathToArtifact(_ path: AbsolutePath) async throws -> FileSystemArtifact { 240 | (try await fileSystem.exists(path, isDirectory: true)) ? .directory(path) : .file(path) 241 | } 242 | 243 | private func size(artifact: FileSystemArtifact, children: [AppBundleArtifact]) async throws -> Int { 244 | if artifact.isDirectory { 245 | return children.map(\.size).reduce(0, +) 246 | } else { 247 | return try fileSize(at: artifact.path) 248 | } 249 | } 250 | 251 | private func fileSize(at path: AbsolutePath) throws -> Int { 252 | ((try FileManager.default.attributesOfItem(atPath: path.pathString))[.size] as? Int) ?? 0 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /docs/.vitepress/icons.mjs: -------------------------------------------------------------------------------- 1 | export function cubeOutlineIcon(size = 15) { 2 | return ` 3 | 4 | 5 | `; 6 | } 7 | 8 | export function cube02Icon(size = 15) { 9 | return ` 10 | 11 | 12 | `; 13 | } 14 | 15 | export function cube01Icon(size = 15) { 16 | return ` 17 | 18 | 19 | 20 | `; 21 | } 22 | 23 | export function barChartSquare02Icon(size = 15) { 24 | return ` 25 | 26 | 27 | `; 28 | } 29 | 30 | export function code02Icon(size = 15) { 31 | return ` 32 | 33 | 34 | `; 35 | } 36 | 37 | export function dataIcon(size = 15) { 38 | return ` 39 | 40 | 41 | 42 | 43 | `; 44 | } 45 | 46 | export function checkCircleIcon(size = 15) { 47 | return ` 48 | 49 | 50 | `; 51 | } 52 | 53 | export function tuistIcon(size = 15) { 54 | return ` 55 | 56 | `; 57 | } 58 | 59 | export function cloudBlank02Icon(size = 15) { 60 | return ` 61 | 62 | 63 | `; 64 | } 65 | 66 | export function server04Icon(size = 15) { 67 | return ` 68 | 69 | 70 | `; 71 | } 72 | -------------------------------------------------------------------------------- /Tests/RosalindTests/RosalindTests.swift: -------------------------------------------------------------------------------- 1 | import FileSystem 2 | import Foundation 3 | import Mockable 4 | import Testing 5 | 6 | @testable import Rosalind 7 | 8 | struct RosalindTests { 9 | private let fileSystem = FileSystem() 10 | private let appBundleLoader = MockAppBundleLoading() 11 | private let shasumCalculator = MockShasumCalculating() 12 | private let assetUtilController = MockAssetUtilControlling() 13 | private let subject: Rosalind 14 | 15 | init() { 16 | given(shasumCalculator) 17 | .calculate(filePath: .any) 18 | .willProduce { $0.basename } 19 | given(shasumCalculator) 20 | .calculate(childrenShasums: .any) 21 | .willProduce { $0.joined(separator: "-") } 22 | subject = Rosalind( 23 | fileSystem: fileSystem, 24 | appBundleLoader: appBundleLoader, 25 | shasumCalculator: shasumCalculator, 26 | assetUtilController: assetUtilController 27 | ) 28 | } 29 | 30 | @Test func appBundleDoesNotExist() async throws { 31 | try await fileSystem.runInTemporaryDirectory(prefix: UUID().uuidString) { temporaryDirectory in 32 | // Given 33 | let appBundlePath = temporaryDirectory.appending(component: "App.app") 34 | // When / Then 35 | await #expect( 36 | throws: RosalindError.notFound(appBundlePath) 37 | ) { 38 | try await subject.analyzeAppBundle(at: appBundlePath) 39 | } 40 | } 41 | } 42 | 43 | @Test func appBundle() async throws { 44 | try await fileSystem.runInTemporaryDirectory(prefix: UUID().uuidString) { temporaryDirectory in 45 | // Given 46 | let appBundlePath = temporaryDirectory.appending(component: "App.app") 47 | try await fileSystem.makeDirectory(at: appBundlePath) 48 | try await fileSystem.writeText("font-binary", at: appBundlePath.appending(component: "Font.ttf")) 49 | given(appBundleLoader) 50 | .load(.any) 51 | .willReturn( 52 | .test( 53 | infoPlist: .test( 54 | name: "App", 55 | bundleId: "com.App", 56 | supportedPlatforms: ["iPhoneOS"] 57 | ) 58 | ) 59 | ) 60 | try await fileSystem.makeDirectory(at: appBundlePath.appending(component: "en.lproj")) 61 | try await fileSystem.writeText("app = App;", at: appBundlePath.appending(components: "en.lproj", "App.strings")) 62 | 63 | // When 64 | let got = try await subject.analyzeAppBundle(at: appBundlePath) 65 | 66 | // Then 67 | #expect( 68 | got == AppBundleReport( 69 | bundleId: "com.App", 70 | name: "App", 71 | type: .app, 72 | installSize: 21, 73 | downloadSize: nil, 74 | platforms: ["iPhoneOS"], 75 | version: "1.0", 76 | artifacts: [ 77 | AppBundleArtifact( 78 | artifactType: .font, 79 | path: "App.app/Font.ttf", 80 | size: 11, 81 | shasum: "Font.ttf", 82 | children: nil 83 | ), 84 | AppBundleArtifact( 85 | artifactType: .directory, 86 | path: "App.app/en.lproj", 87 | size: 10, 88 | shasum: "App.strings", 89 | children: [ 90 | AppBundleArtifact( 91 | artifactType: .localization, 92 | path: "App.app/en.lproj/App.strings", 93 | size: 10, 94 | shasum: "App.strings", 95 | children: nil 96 | ), 97 | ] 98 | ), 99 | ] 100 | ) 101 | ) 102 | } 103 | } 104 | 105 | @Test func appInXCArchiveDoesNotExist() async throws { 106 | try await fileSystem.runInTemporaryDirectory(prefix: UUID().uuidString) { temporaryDirectory in 107 | // Given 108 | let xcarchivePath = temporaryDirectory.appending(component: "App.xcarchive") 109 | try await fileSystem.makeDirectory(at: xcarchivePath) 110 | // When / Then 111 | await #expect( 112 | throws: RosalindError.appNotFound(xcarchivePath) 113 | ) { 114 | try await subject.analyzeAppBundle(at: xcarchivePath) 115 | } 116 | } 117 | } 118 | 119 | @Test func appInIPADoesNotExist() async throws { 120 | try await fileSystem.runInTemporaryDirectory(prefix: UUID().uuidString) { temporaryDirectory in 121 | // Given 122 | try await fileSystem.makeDirectory(at: temporaryDirectory.appending(component: "Payload")) 123 | let ipaPath = temporaryDirectory.appending(component: "App.ipa") 124 | try await fileSystem.zipFileOrDirectoryContent(at: temporaryDirectory.appending(component: "Payload"), to: ipaPath) 125 | // When / Then 126 | await #expect( 127 | throws: RosalindError.appNotFound(ipaPath) 128 | ) { 129 | try await subject.analyzeAppBundle(at: ipaPath) 130 | } 131 | } 132 | } 133 | 134 | @Test func appBundleNotSupported() async throws { 135 | try await fileSystem.runInTemporaryDirectory(prefix: UUID().uuidString) { temporaryDirectory in 136 | // Given 137 | let apkPath = temporaryDirectory.appending(component: "App.apk") 138 | try await fileSystem.makeDirectory(at: apkPath) 139 | // When / Then 140 | await #expect( 141 | throws: RosalindError.notSupported(apkPath) 142 | ) { 143 | try await subject.analyzeAppBundle(at: apkPath) 144 | } 145 | } 146 | } 147 | 148 | @Test func xcarchiveBundle() async throws { 149 | try await fileSystem.runInTemporaryDirectory(prefix: UUID().uuidString) { temporaryDirectory in 150 | // Given 151 | let xcarchivePath = temporaryDirectory.appending(component: "App.xcarchive") 152 | let appBundlePath = xcarchivePath.appending(components: "Products", "Applications", "App.app") 153 | try await fileSystem.makeDirectory(at: appBundlePath) 154 | try await fileSystem.writeText("binary-content", at: appBundlePath.appending(component: "App")) 155 | try await fileSystem.writeText("config-content", at: appBundlePath.appending(component: "Info.plist")) 156 | 157 | given(appBundleLoader) 158 | .load(.any) 159 | .willReturn( 160 | .test( 161 | infoPlist: .test( 162 | name: "App", 163 | bundleId: "com.App", 164 | supportedPlatforms: ["iPhoneOS"] 165 | ) 166 | ) 167 | ) 168 | 169 | // When 170 | let got = try await subject.analyzeAppBundle(at: xcarchivePath) 171 | 172 | // Then 173 | #expect( 174 | got == AppBundleReport( 175 | bundleId: "com.App", 176 | name: "App", 177 | type: .xcarchive, 178 | installSize: 28, 179 | downloadSize: nil, 180 | platforms: ["iPhoneOS"], 181 | version: "1.0", 182 | artifacts: [ 183 | AppBundleArtifact( 184 | artifactType: .file, 185 | path: "App.app/App", 186 | size: 14, 187 | shasum: "App", 188 | children: nil 189 | ), 190 | AppBundleArtifact( 191 | artifactType: .file, 192 | path: "App.app/Info.plist", 193 | size: 14, 194 | shasum: "Info.plist", 195 | children: nil 196 | ), 197 | ] 198 | ) 199 | ) 200 | #expect(got.downloadSize == nil) 201 | } 202 | } 203 | 204 | @Test func ipaBundle() async throws { 205 | try await fileSystem.runInTemporaryDirectory(prefix: UUID().uuidString) { temporaryDirectory in 206 | // Given 207 | let payloadPath = temporaryDirectory.appending(component: "ipa-contents").appending(component: "Payload") 208 | let appBundlePath = payloadPath.appending(component: "App.app") 209 | try await fileSystem.makeDirectory(at: appBundlePath) 210 | try await fileSystem.writeText("binary-content", at: appBundlePath.appending(component: "App")) 211 | try await fileSystem.writeText("font-binary", at: appBundlePath.appending(component: "Font.ttf")) 212 | 213 | given(appBundleLoader) 214 | .load(.any) 215 | .willReturn( 216 | .test( 217 | infoPlist: .test( 218 | name: "App", 219 | bundleId: "com.App", 220 | supportedPlatforms: ["iPhoneOS"] 221 | ) 222 | ) 223 | ) 224 | 225 | // Create IPA file 226 | let ipaPath = temporaryDirectory.appending(component: "App.ipa") 227 | try await fileSystem.zipFileOrDirectoryContent( 228 | at: temporaryDirectory.appending(component: "ipa-contents"), 229 | to: ipaPath 230 | ) 231 | 232 | // When 233 | let got = try await subject.analyzeAppBundle(at: ipaPath) 234 | 235 | // Then 236 | #expect( 237 | got == AppBundleReport( 238 | bundleId: "com.App", 239 | name: "App", 240 | type: .ipa, 241 | installSize: 25, 242 | downloadSize: got.downloadSize, 243 | platforms: ["iPhoneOS"], 244 | version: "1.0", 245 | artifacts: [ 246 | AppBundleArtifact( 247 | artifactType: .file, 248 | path: "App.app/App", 249 | size: 14, 250 | shasum: "App", 251 | children: nil 252 | ), 253 | AppBundleArtifact( 254 | artifactType: .font, 255 | path: "App.app/Font.ttf", 256 | size: 11, 257 | shasum: "Font.ttf", 258 | children: nil 259 | ), 260 | ] 261 | ) 262 | ) 263 | } 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /fixtures/ios_app/App.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXFileReference section */ 10 | 6C32E67D2D7772ED00949239 /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; 11 | /* End PBXFileReference section */ 12 | 13 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 14 | 6C32E67F2D7772ED00949239 /* App */ = { 15 | isa = PBXFileSystemSynchronizedRootGroup; 16 | path = App; 17 | sourceTree = ""; 18 | }; 19 | /* End PBXFileSystemSynchronizedRootGroup section */ 20 | 21 | /* Begin PBXFrameworksBuildPhase section */ 22 | 6C32E67A2D7772ED00949239 /* Frameworks */ = { 23 | isa = PBXFrameworksBuildPhase; 24 | buildActionMask = 2147483647; 25 | files = ( 26 | ); 27 | runOnlyForDeploymentPostprocessing = 0; 28 | }; 29 | /* End PBXFrameworksBuildPhase section */ 30 | 31 | /* Begin PBXGroup section */ 32 | 6C32E6742D7772ED00949239 = { 33 | isa = PBXGroup; 34 | children = ( 35 | 6C32E67F2D7772ED00949239 /* App */, 36 | 6C32E67E2D7772ED00949239 /* Products */, 37 | ); 38 | sourceTree = ""; 39 | }; 40 | 6C32E67E2D7772ED00949239 /* Products */ = { 41 | isa = PBXGroup; 42 | children = ( 43 | 6C32E67D2D7772ED00949239 /* App.app */, 44 | ); 45 | name = Products; 46 | sourceTree = ""; 47 | }; 48 | /* End PBXGroup section */ 49 | 50 | /* Begin PBXNativeTarget section */ 51 | 6C32E67C2D7772ED00949239 /* App */ = { 52 | isa = PBXNativeTarget; 53 | buildConfigurationList = 6C32E68B2D7772EF00949239 /* Build configuration list for PBXNativeTarget "App" */; 54 | buildPhases = ( 55 | 6C32E6792D7772ED00949239 /* Sources */, 56 | 6C32E67A2D7772ED00949239 /* Frameworks */, 57 | 6C32E67B2D7772ED00949239 /* Resources */, 58 | ); 59 | buildRules = ( 60 | ); 61 | dependencies = ( 62 | ); 63 | fileSystemSynchronizedGroups = ( 64 | 6C32E67F2D7772ED00949239 /* App */, 65 | ); 66 | name = App; 67 | packageProductDependencies = ( 68 | ); 69 | productName = App; 70 | productReference = 6C32E67D2D7772ED00949239 /* App.app */; 71 | productType = "com.apple.product-type.application"; 72 | }; 73 | /* End PBXNativeTarget section */ 74 | 75 | /* Begin PBXProject section */ 76 | 6C32E6752D7772ED00949239 /* Project object */ = { 77 | isa = PBXProject; 78 | attributes = { 79 | BuildIndependentTargetsInParallel = 1; 80 | LastSwiftUpdateCheck = 1620; 81 | LastUpgradeCheck = 1620; 82 | TargetAttributes = { 83 | 6C32E67C2D7772ED00949239 = { 84 | CreatedOnToolsVersion = 16.2; 85 | }; 86 | }; 87 | }; 88 | buildConfigurationList = 6C32E6782D7772ED00949239 /* Build configuration list for PBXProject "App" */; 89 | developmentRegion = en; 90 | hasScannedForEncodings = 0; 91 | knownRegions = ( 92 | en, 93 | Base, 94 | ); 95 | mainGroup = 6C32E6742D7772ED00949239; 96 | minimizedProjectReferenceProxies = 1; 97 | preferredProjectObjectVersion = 77; 98 | productRefGroup = 6C32E67E2D7772ED00949239 /* Products */; 99 | projectDirPath = ""; 100 | projectRoot = ""; 101 | targets = ( 102 | 6C32E67C2D7772ED00949239 /* App */, 103 | ); 104 | }; 105 | /* End PBXProject section */ 106 | 107 | /* Begin PBXResourcesBuildPhase section */ 108 | 6C32E67B2D7772ED00949239 /* Resources */ = { 109 | isa = PBXResourcesBuildPhase; 110 | buildActionMask = 2147483647; 111 | files = ( 112 | ); 113 | runOnlyForDeploymentPostprocessing = 0; 114 | }; 115 | /* End PBXResourcesBuildPhase section */ 116 | 117 | /* Begin PBXSourcesBuildPhase section */ 118 | 6C32E6792D7772ED00949239 /* Sources */ = { 119 | isa = PBXSourcesBuildPhase; 120 | buildActionMask = 2147483647; 121 | files = ( 122 | ); 123 | runOnlyForDeploymentPostprocessing = 0; 124 | }; 125 | /* End PBXSourcesBuildPhase section */ 126 | 127 | /* Begin XCBuildConfiguration section */ 128 | 6C32E6892D7772EF00949239 /* Debug */ = { 129 | isa = XCBuildConfiguration; 130 | buildSettings = { 131 | ALWAYS_SEARCH_USER_PATHS = NO; 132 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 133 | CLANG_ANALYZER_NONNULL = YES; 134 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 135 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 136 | CLANG_ENABLE_MODULES = YES; 137 | CLANG_ENABLE_OBJC_ARC = YES; 138 | CLANG_ENABLE_OBJC_WEAK = YES; 139 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 140 | CLANG_WARN_BOOL_CONVERSION = YES; 141 | CLANG_WARN_COMMA = YES; 142 | CLANG_WARN_CONSTANT_CONVERSION = YES; 143 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 144 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 145 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 146 | CLANG_WARN_EMPTY_BODY = YES; 147 | CLANG_WARN_ENUM_CONVERSION = YES; 148 | CLANG_WARN_INFINITE_RECURSION = YES; 149 | CLANG_WARN_INT_CONVERSION = YES; 150 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 151 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 152 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 153 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 154 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 155 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 156 | CLANG_WARN_STRICT_PROTOTYPES = YES; 157 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 158 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 159 | CLANG_WARN_UNREACHABLE_CODE = YES; 160 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 161 | COPY_PHASE_STRIP = NO; 162 | DEBUG_INFORMATION_FORMAT = dwarf; 163 | ENABLE_STRICT_OBJC_MSGSEND = YES; 164 | ENABLE_TESTABILITY = YES; 165 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 166 | GCC_C_LANGUAGE_STANDARD = gnu17; 167 | GCC_DYNAMIC_NO_PIC = NO; 168 | GCC_NO_COMMON_BLOCKS = YES; 169 | GCC_OPTIMIZATION_LEVEL = 0; 170 | GCC_PREPROCESSOR_DEFINITIONS = ( 171 | "DEBUG=1", 172 | "$(inherited)", 173 | ); 174 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 175 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 176 | GCC_WARN_UNDECLARED_SELECTOR = YES; 177 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 178 | GCC_WARN_UNUSED_FUNCTION = YES; 179 | GCC_WARN_UNUSED_VARIABLE = YES; 180 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 181 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 182 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 183 | MTL_FAST_MATH = YES; 184 | ONLY_ACTIVE_ARCH = YES; 185 | SDKROOT = iphoneos; 186 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 187 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 188 | }; 189 | name = Debug; 190 | }; 191 | 6C32E68A2D7772EF00949239 /* Release */ = { 192 | isa = XCBuildConfiguration; 193 | buildSettings = { 194 | ALWAYS_SEARCH_USER_PATHS = NO; 195 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 196 | CLANG_ANALYZER_NONNULL = YES; 197 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 198 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 199 | CLANG_ENABLE_MODULES = YES; 200 | CLANG_ENABLE_OBJC_ARC = YES; 201 | CLANG_ENABLE_OBJC_WEAK = YES; 202 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 203 | CLANG_WARN_BOOL_CONVERSION = YES; 204 | CLANG_WARN_COMMA = YES; 205 | CLANG_WARN_CONSTANT_CONVERSION = YES; 206 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 207 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 208 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 209 | CLANG_WARN_EMPTY_BODY = YES; 210 | CLANG_WARN_ENUM_CONVERSION = YES; 211 | CLANG_WARN_INFINITE_RECURSION = YES; 212 | CLANG_WARN_INT_CONVERSION = YES; 213 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 214 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 215 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 216 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 217 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 218 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 219 | CLANG_WARN_STRICT_PROTOTYPES = YES; 220 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 221 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 222 | CLANG_WARN_UNREACHABLE_CODE = YES; 223 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 224 | COPY_PHASE_STRIP = NO; 225 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 226 | ENABLE_NS_ASSERTIONS = NO; 227 | ENABLE_STRICT_OBJC_MSGSEND = YES; 228 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 229 | GCC_C_LANGUAGE_STANDARD = gnu17; 230 | GCC_NO_COMMON_BLOCKS = YES; 231 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 232 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 233 | GCC_WARN_UNDECLARED_SELECTOR = YES; 234 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 235 | GCC_WARN_UNUSED_FUNCTION = YES; 236 | GCC_WARN_UNUSED_VARIABLE = YES; 237 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 238 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 239 | MTL_ENABLE_DEBUG_INFO = NO; 240 | MTL_FAST_MATH = YES; 241 | SDKROOT = iphoneos; 242 | SWIFT_COMPILATION_MODE = wholemodule; 243 | VALIDATE_PRODUCT = YES; 244 | }; 245 | name = Release; 246 | }; 247 | 6C32E68C2D7772EF00949239 /* Debug */ = { 248 | isa = XCBuildConfiguration; 249 | buildSettings = { 250 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 251 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 252 | CODE_SIGN_STYLE = Automatic; 253 | CURRENT_PROJECT_VERSION = 1; 254 | DEVELOPMENT_ASSET_PATHS = "\"App/Preview Content\""; 255 | DEVELOPMENT_TEAM = U6LC622NKF; 256 | ENABLE_PREVIEWS = YES; 257 | GENERATE_INFOPLIST_FILE = YES; 258 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 259 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 260 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 261 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 262 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 263 | LD_RUNPATH_SEARCH_PATHS = ( 264 | "$(inherited)", 265 | "@executable_path/Frameworks", 266 | ); 267 | MARKETING_VERSION = 1.0; 268 | PRODUCT_BUNDLE_IDENTIFIER = dev.tuist.rosalind.App; 269 | PRODUCT_NAME = "$(TARGET_NAME)"; 270 | SWIFT_EMIT_LOC_STRINGS = YES; 271 | SWIFT_VERSION = 5.0; 272 | TARGETED_DEVICE_FAMILY = "1,2"; 273 | }; 274 | name = Debug; 275 | }; 276 | 6C32E68D2D7772EF00949239 /* Release */ = { 277 | isa = XCBuildConfiguration; 278 | buildSettings = { 279 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 280 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 281 | CODE_SIGN_STYLE = Automatic; 282 | CURRENT_PROJECT_VERSION = 1; 283 | DEVELOPMENT_ASSET_PATHS = "\"App/Preview Content\""; 284 | DEVELOPMENT_TEAM = U6LC622NKF; 285 | ENABLE_PREVIEWS = YES; 286 | GENERATE_INFOPLIST_FILE = YES; 287 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 288 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 289 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 290 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 291 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 292 | LD_RUNPATH_SEARCH_PATHS = ( 293 | "$(inherited)", 294 | "@executable_path/Frameworks", 295 | ); 296 | MARKETING_VERSION = 1.0; 297 | PRODUCT_BUNDLE_IDENTIFIER = dev.tuist.rosalind.App; 298 | PRODUCT_NAME = "$(TARGET_NAME)"; 299 | SWIFT_EMIT_LOC_STRINGS = YES; 300 | SWIFT_VERSION = 5.0; 301 | TARGETED_DEVICE_FAMILY = "1,2"; 302 | }; 303 | name = Release; 304 | }; 305 | /* End XCBuildConfiguration section */ 306 | 307 | /* Begin XCConfigurationList section */ 308 | 6C32E6782D7772ED00949239 /* Build configuration list for PBXProject "App" */ = { 309 | isa = XCConfigurationList; 310 | buildConfigurations = ( 311 | 6C32E6892D7772EF00949239 /* Debug */, 312 | 6C32E68A2D7772EF00949239 /* Release */, 313 | ); 314 | defaultConfigurationIsVisible = 0; 315 | defaultConfigurationName = Release; 316 | }; 317 | 6C32E68B2D7772EF00949239 /* Build configuration list for PBXNativeTarget "App" */ = { 318 | isa = XCConfigurationList; 319 | buildConfigurations = ( 320 | 6C32E68C2D7772EF00949239 /* Debug */, 321 | 6C32E68D2D7772EF00949239 /* Release */, 322 | ); 323 | defaultConfigurationIsVisible = 0; 324 | defaultConfigurationName = Release; 325 | }; 326 | /* End XCConfigurationList section */ 327 | }; 328 | rootObject = 6C32E6752D7772ED00949239 /* Project object */; 329 | } 330 | -------------------------------------------------------------------------------- /Tests/RosalindTests/AssetUtilControllerTests.swift: -------------------------------------------------------------------------------- 1 | import Command 2 | import Foundation 3 | import Mockable 4 | import Path 5 | import Testing 6 | @testable import Rosalind 7 | 8 | @Suite struct AssetUtilControllerTests { 9 | private let commandRunner = MockCommandRunning() 10 | private let subject: AssetUtilController 11 | 12 | private let assetInfoJSON = """ 13 | [ 14 | { 15 | "Appearances" : { 16 | "UIAppearanceAny" : 0 17 | }, 18 | "AssetStorageVersion" : "Xcode 16.0 (16A242d) via AssetCatalogSimulatorAgent", 19 | "Authoring Tool" : "@(#)PROGRAM:CoreThemeDefinition PROJECT:CoreThemeDefinition-609 [IIO-2629.0.1.9]", 20 | "CoreUIVersion" : 916, 21 | "DumpToolVersion" : 969, 22 | "Key Format" : [ 23 | "kCRThemeAppearanceName", 24 | "kCRThemeLocalizationName", 25 | "kCRThemeScaleName", 26 | "kCRThemeIdiomName", 27 | "kCRThemeSubtypeName", 28 | "kCRThemeDimension2Name", 29 | "kCRThemeDimension1Name", 30 | "kCRThemeIdentifierName", 31 | "kCRThemeElementName", 32 | "kCRThemePartName" 33 | ], 34 | "MainVersion" : "@(#)PROGRAM:CoreUI PROJECT:CoreUI-916\\n", 35 | "Platform" : "ios", 36 | "PlatformVersion" : "17.0.0", 37 | "SchemaVersion" : 2, 38 | "StorageVersion" : 17, 39 | "Thinning With CoreUI Version" : 2147483647, 40 | "ThinningParameters" : "optimized ", 41 | "Timestamp" : 1758560442 42 | }, 43 | { 44 | "AssetType" : "Icon Image", 45 | "BitsPerComponent" : 8, 46 | "ColorModel" : "RGB", 47 | "Colorspace" : "srgb", 48 | "Compression" : "lzfse", 49 | "Encoding" : "ARGB", 50 | "Icon Index" : 5, 51 | "Idiom" : "phone", 52 | "Name" : "AppIcon", 53 | "NameIdentifier" : 6849, 54 | "Opaque" : true, 55 | "PixelHeight" : 114, 56 | "PixelWidth" : 114, 57 | "RenditionName" : "114.png", 58 | "Scale" : 2, 59 | "SHA1Digest" : "2C428DBA40B27BFB34BE8D92568563C224F94E43EB75C16D099378D022947D9F", 60 | "SizeOnDisk" : 3590 61 | }, 62 | { 63 | "AssetType" : "Icon Image", 64 | "BitsPerComponent" : 8, 65 | "ColorModel" : "RGB", 66 | "Colorspace" : "srgb", 67 | "Compression" : "lzfse", 68 | "Encoding" : "ARGB", 69 | "Icon Index" : 1, 70 | "Idiom" : "phone", 71 | "Name" : "AppIcon", 72 | "NameIdentifier" : 6849, 73 | "Opaque" : true, 74 | "PixelHeight" : 60, 75 | "PixelWidth" : 60, 76 | "RenditionName" : "60.png", 77 | "Scale" : 3, 78 | "SHA1Digest" : "90875667E4F22292756509061023DF8DA463AB4C9F7E5C8EFBD1294D5D54146F", 79 | "SizeOnDisk" : 338 80 | }, 81 | { 82 | "AssetType" : "Icon Image", 83 | "BitsPerComponent" : 8, 84 | "ColorModel" : "RGB", 85 | "Colorspace" : "srgb", 86 | "Compression" : "lzfse", 87 | "Encoding" : "ARGB", 88 | "Icon Index" : 2, 89 | "Idiom" : "phone", 90 | "Name" : "AppIcon", 91 | "NameIdentifier" : 6849, 92 | "Opaque" : true, 93 | "PixelHeight" : 87, 94 | "PixelWidth" : 87, 95 | "RenditionName" : "87.png", 96 | "Scale" : 3, 97 | "SHA1Digest" : "AD9861B1E50F976B8CABAECCCF45C963002E8DE161E7A4EDBD5386D68AFB8794", 98 | "SizeOnDisk" : 334 99 | }, 100 | { 101 | "AssetType" : "Icon Image", 102 | "BitsPerComponent" : 8, 103 | "ColorModel" : "RGB", 104 | "Colorspace" : "srgb", 105 | "Compression" : "lzfse", 106 | "Encoding" : "ARGB", 107 | "Icon Index" : 3, 108 | "Idiom" : "phone", 109 | "Name" : "AppIcon", 110 | "NameIdentifier" : 6849, 111 | "Opaque" : true, 112 | "PixelHeight" : 120, 113 | "PixelWidth" : 120, 114 | "RenditionName" : "120.png", 115 | "Scale" : 3, 116 | "SHA1Digest" : "5A181B49205BF7384718ADB041755435085995ED37B338475E02D05AED12EFA1", 117 | "SizeOnDisk" : 334 118 | }, 119 | { 120 | "AssetType" : "Icon Image", 121 | "BitsPerComponent" : 8, 122 | "ColorModel" : "RGB", 123 | "Colorspace" : "srgb", 124 | "Compression" : "lzfse", 125 | "Encoding" : "ARGB", 126 | "Icon Index" : 6, 127 | "Idiom" : "phone", 128 | "Name" : "AppIcon", 129 | "NameIdentifier" : 6849, 130 | "Opaque" : true, 131 | "PixelHeight" : 180, 132 | "PixelWidth" : 180, 133 | "RenditionName" : "180.png", 134 | "Scale" : 3, 135 | "SHA1Digest" : "84D9F533EDADFD6C99A74E09A85B5926CF6EEDEF9800A532BC2DC2AAB4D1308D", 136 | "SizeOnDisk" : 334 137 | }, 138 | { 139 | "AssetType" : "MultiSized Image", 140 | "Idiom" : "phone", 141 | "Name" : "AppIcon", 142 | "NameIdentifier" : 6849, 143 | "Scale" : 1, 144 | "SHA1Digest" : "91F8F588A3FCC4CFB2DD2CB7BDC22C8ACC9056A0C06207A72C78C49642F2BDD4", 145 | "SizeOnDisk" : 284, 146 | "Sizes" : [ 147 | "20x20 index:1 idiom:phone", 148 | "29x29 index:2 idiom:phone", 149 | "40x40 index:3 idiom:phone", 150 | "57x57 index:5 idiom:phone", 151 | "60x60 index:6 idiom:phone" 152 | ] 153 | }, 154 | { 155 | "AssetType" : "PackedImage", 156 | "BitsPerComponent" : 8, 157 | "ColorModel" : "RGB", 158 | "Colorspace" : "srgb", 159 | "Compression" : "lzfse", 160 | "Encoding" : "ARGB", 161 | "Idiom" : "phone", 162 | "Name" : "ZZZZPackedAsset-3.1.0-gamut0", 163 | "Opaque" : true, 164 | "PixelHeight" : 272, 165 | "PixelWidth" : 306, 166 | "RenditionName" : "ZZZZPackedAsset-3.1.0-gamut0", 167 | "Scale" : 3, 168 | "SHA1Digest" : "A4AFC948CED333BC481CFACF191F6817EADB607AA5731879BC6B238BC2116090", 169 | "SizeOnDisk" : 9764 170 | }, 171 | { 172 | "AssetType" : "PackedImage", 173 | "BitsPerComponent" : 8, 174 | "ColorModel" : "RGB", 175 | "Colorspace" : "srgb", 176 | "Compression" : "lzfse", 177 | "Dimension1" : 1, 178 | "Encoding" : "ARGB", 179 | "Idiom" : "phone", 180 | "Name" : "ZZZZPackedAsset-3.1.0-gamut0", 181 | "Opaque" : true, 182 | "PixelHeight" : 64, 183 | "PixelWidth" : 64, 184 | "RenditionName" : "ZZZZPackedAsset-3.1.0-gamut0", 185 | "Scale" : 3, 186 | "SHA1Digest" : "1E5AB066475B405781D6ECD5E264CCB3F11D268808E74080B719CAD5AA2A11CC", 187 | "SizeOnDisk" : 1935 188 | } 189 | ] 190 | """ 191 | 192 | init() { 193 | subject = AssetUtilController(commandRunner: commandRunner) 194 | } 195 | 196 | @Test func info_returnsAssetInfo_whenCommandSucceeds() async throws { 197 | let path = try AbsolutePath(validating: "/path/to/asset.car") 198 | 199 | given(commandRunner) 200 | .run( 201 | arguments: .value(["/usr/bin/xcrun", "assetutil", "--info", path.pathString]), 202 | environment: .any, 203 | workingDirectory: .any 204 | ) 205 | .willReturn( 206 | AsyncThrowingStream { continuation in 207 | continuation.yield(CommandEvent.standardOutput(Array(assetInfoJSON.utf8))) 208 | continuation.finish() 209 | } 210 | ) 211 | 212 | let result = try await subject.info(at: path) 213 | 214 | #expect(result.count == 9) 215 | 216 | #expect(result[1].sizeOnDisk == 3590) 217 | #expect(result[1].sha1Digest == "2C428DBA40B27BFB34BE8D92568563C224F94E43EB75C16D099378D022947D9F") 218 | #expect(result[1].renditionName == "114.png") 219 | 220 | #expect(result[2].sizeOnDisk == 338) 221 | #expect(result[2].sha1Digest == "90875667E4F22292756509061023DF8DA463AB4C9F7E5C8EFBD1294D5D54146F") 222 | #expect(result[2].renditionName == "60.png") 223 | } 224 | 225 | @Test func info_handlesWarningsBeforeJSON() async throws { 226 | let path = try AbsolutePath(validating: "/path/to/asset.car") 227 | let outputWithWarnings = """ 228 | CoreUI: Expecting a kCSIElementSignature but didn't find it: 'IconComposer_Assets/Gradient-3' 229 | carutil: couldn't materialize rendition 'IconComposer_Assets/Gradient-3' skipping 230 | CoreUI: Expecting a kCSIElementSignature but didn't find it: 'IconComposer_Assets/Gradient-1' 231 | carutil: couldn't materialize rendition 'IconComposer_Assets/Gradient-1' skipping 232 | CoreUI: Expecting a kCSIElementSignature but didn't find it: 'IconComposer_Assets/Gradient-2' 233 | carutil: couldn't materialize rendition 'IconComposer_Assets/Gradient-2' skipping 234 | CoreUI: Expecting a kCSIElementSignature but didn't find it: 'IconGroup' 235 | carutil: couldn't materialize rendition 'IconComposer/Group' skipping 236 | CoreUI: Expecting a kCSIElementSignature but didn't find it: 'IconComposer.iconstack' 237 | carutil: couldn't materialize rendition 'IconComposer' skipping 238 | CoreUI: Expecting a kCSIElementSignature but didn't find it: 'IconGroup' 239 | carutil: couldn't materialize rendition 'IconComposer/Group' skipping 240 | CoreUI: Expecting a kCSIElementSignature but didn't find it: 'IconComposer.iconstack' 241 | carutil: couldn't materialize rendition 'IconComposer' skipping 242 | CoreUI: Expecting a kCSIElementSignature but didn't find it: 'IconGroup' 243 | carutil: couldn't materialize rendition 'IconComposer/Group' skipping 244 | CoreUI: Expecting a kCSIElementSignature but didn't find it: 'IconComposer.iconstack' 245 | carutil: couldn't materialize rendition 'IconComposer' skipping 246 | \(assetInfoJSON) 247 | """ 248 | 249 | given(commandRunner) 250 | .run( 251 | arguments: .value(["/usr/bin/xcrun", "assetutil", "--info", path.pathString]), 252 | environment: .any, 253 | workingDirectory: .any 254 | ) 255 | .willReturn( 256 | AsyncThrowingStream { continuation in 257 | continuation.yield(CommandEvent.standardOutput(Array(outputWithWarnings.utf8))) 258 | continuation.finish() 259 | } 260 | ) 261 | 262 | let result = try await subject.info(at: path) 263 | 264 | #expect(result.count == 9) 265 | 266 | #expect(result[1].sizeOnDisk == 3590) 267 | #expect(result[1].sha1Digest == "2C428DBA40B27BFB34BE8D92568563C224F94E43EB75C16D099378D022947D9F") 268 | #expect(result[1].renditionName == "114.png") 269 | 270 | #expect(result[2].sizeOnDisk == 338) 271 | #expect(result[2].sha1Digest == "90875667E4F22292756509061023DF8DA463AB4C9F7E5C8EFBD1294D5D54146F") 272 | #expect(result[2].renditionName == "60.png") 273 | } 274 | 275 | @Test func info_throwsParsingError_whenOutputIsInvalid() async throws { 276 | let path = try AbsolutePath(validating: "/path/to/asset.car") 277 | let invalidOutput = "Invalid JSON output" 278 | 279 | given(commandRunner) 280 | .run( 281 | arguments: .value(["/usr/bin/xcrun", "assetutil", "--info", path.pathString]), 282 | environment: .any, 283 | workingDirectory: .any 284 | ) 285 | .willReturn( 286 | AsyncThrowingStream { continuation in 287 | continuation.yield(CommandEvent.standardOutput(Array(invalidOutput.utf8))) 288 | continuation.finish() 289 | } 290 | ) 291 | 292 | await #expect { 293 | try await subject.info(at: path) 294 | } throws: { error in 295 | if let assetError = error as? AssetUtilControllerError, 296 | case let .parsingFailed(errorPath) = assetError 297 | { 298 | return errorPath == path 299 | } 300 | return false 301 | } 302 | } 303 | 304 | @Test func info_throwsDecodingError_whenJSONIsInvalid() async throws { 305 | let path = try AbsolutePath(validating: "/path/to/asset.car") 306 | let invalidJSON = "[{\"invalid\": json}]" 307 | 308 | given(commandRunner) 309 | .run( 310 | arguments: .value(["/usr/bin/xcrun", "assetutil", "--info", path.pathString]), 311 | environment: .any, 312 | workingDirectory: .any 313 | ) 314 | .willReturn( 315 | AsyncThrowingStream { continuation in 316 | continuation.yield(CommandEvent.standardOutput(Array(invalidJSON.utf8))) 317 | continuation.finish() 318 | } 319 | ) 320 | 321 | await #expect { 322 | try await subject.info(at: path) 323 | } throws: { error in 324 | if let assetError = error as? AssetUtilControllerError, 325 | case let .decodingFailed(errorPath, jsonString, underlyingError) = assetError 326 | { 327 | return errorPath == path && 328 | jsonString == invalidJSON && 329 | underlyingError is DecodingError 330 | } 331 | return false 332 | } 333 | } 334 | 335 | @Test func info_throwsError_whenCommandFails() async throws { 336 | let path = try AbsolutePath(validating: "/path/to/asset.car") 337 | let expectedError = CommandError.terminated(1, stderr: "Command failed") 338 | 339 | given(commandRunner) 340 | .run( 341 | arguments: .value(["/usr/bin/xcrun", "assetutil", "--info", path.pathString]), 342 | environment: .any, 343 | workingDirectory: .any 344 | ) 345 | .willReturn( 346 | AsyncThrowingStream { continuation in 347 | continuation.finish(throwing: expectedError) 348 | } 349 | ) 350 | 351 | await #expect { 352 | try await subject.info(at: path) 353 | } throws: { error in 354 | if let commandError = error as? CommandError, 355 | case let .terminated(code, stderr) = commandError 356 | { 357 | return code == 1 && stderr == "Command failed" 358 | } 359 | return false 360 | } 361 | } 362 | } 363 | --------------------------------------------------------------------------------