├── .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 `
5 | `;
6 | }
7 |
8 | export function cube02Icon(size = 15) {
9 | return `
12 | `;
13 | }
14 |
15 | export function cube01Icon(size = 15) {
16 | return `
19 |
20 | `;
21 | }
22 |
23 | export function barChartSquare02Icon(size = 15) {
24 | return `
27 | `;
28 | }
29 |
30 | export function code02Icon(size = 15) {
31 | return `
34 | `;
35 | }
36 |
37 | export function dataIcon(size = 15) {
38 | return ``;
44 | }
45 |
46 | export function checkCircleIcon(size = 15) {
47 | return `
50 | `;
51 | }
52 |
53 | export function tuistIcon(size = 15) {
54 | return ``;
57 | }
58 |
59 | export function cloudBlank02Icon(size = 15) {
60 | return `
63 | `;
64 | }
65 |
66 | export function server04Icon(size = 15) {
67 | return `
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 |
--------------------------------------------------------------------------------