diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8c271c2..6e4f660 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,4 +2,6 @@ /.github/ @hideouts-io /scripts/ @hideouts-io -/packaging/ @hideouts-io +/project.yml @hideouts-io +/Package.swift @hideouts-io +/Package.resolved @hideouts-io diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 4d74125..c61f2e2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report a reproducible problem with the toolkit. +description: Report a reproducible problem with the app or idt. title: "[Bug]: " labels: - bug @@ -8,7 +8,7 @@ body: - type: markdown attributes: value: | - Thanks for helping improve iOS Developer Toolkit. Remove UDIDs, serial numbers, account data, coordinates, backup contents, packet payloads, and case evidence before posting. + Thanks for helping improve iOS Developer Toolkit. Remove UDIDs, serial numbers, device names, account data, coordinates, backup contents, packet payloads, and evidence before posting. - type: checkboxes id: prerequisites @@ -26,8 +26,8 @@ body: id: toolkit-version attributes: label: Toolkit version - description: For example, v0.3.1 or a commit SHA. - placeholder: v0.3.1 + description: From iOS Developer Toolkit › About, or a commit SHA. + placeholder: 1.0.0 validations: required: true @@ -36,26 +36,26 @@ body: attributes: label: Installation type options: - - Apple Silicon release - - Intel release - - Source checkout + - Release download + - Built from source (Xcode) + - idt command-line tool validations: required: true - type: input id: host attributes: - label: Mac and macOS - description: Include Mac architecture and macOS version. - placeholder: Apple Silicon, macOS 26.3 + label: Mac, macOS, and Xcode + description: Mac architecture, macOS version, and Xcode version (or "no Xcode"). + placeholder: Apple silicon, macOS 26.3, Xcode 26.1 validations: required: true - type: input id: device attributes: - label: Device and iOS or iPadOS - description: Give only the product family and OS version. Do not include a UDID, serial number, phone number, or device name. + label: Device or simulator and iOS/iPadOS version + description: Product family and OS version only. No UDID, serial number, phone number, or device name. placeholder: iPhone 16 family, iOS 26.3.1 validations: required: true @@ -63,12 +63,13 @@ body: - type: dropdown id: connection attributes: - label: Connection state + label: Connection options: - USB, trusted and unlocked - - USB, trust or pairing incomplete - - Wi-Fi or tunnel - - No device required + - USB, not trusted yet + - Wi-Fi sync or network (Xcode pairing) + - Simulator + - Demo Mode / no device - Other or unknown validations: required: true @@ -76,20 +77,19 @@ body: - type: input id: workspace attributes: - label: Workspace or command - placeholder: Live Logs / DVT OSLog + label: Page, action, or command + placeholder: Live Logs / Unified, or idt collect validations: required: true - type: textarea id: reproduction attributes: - label: Reproduction steps - description: List the smallest reliable sequence, including the button or preset used. + label: Steps to reproduce placeholder: | 1. Connect and select the device 2. Open ... - 3. Press ... + 3. Click ... validations: required: true @@ -103,19 +103,19 @@ body: - type: textarea id: actual attributes: - label: Actual behavior and sanitized error - description: Paste the complete error after removing private data. Do not attach raw backups, PCAPs, sysdiagnose archives, or evidence cases. + label: Actual behavior and message + description: The message shown, plus the technical details from Help › Diagnostic Log with private data removed. Do not attach backups, captures, sysdiagnose archives, or evidence. validations: required: true - type: textarea - id: capability + id: readiness attributes: - label: Relevant Capability Matrix result - description: Include the bounded state and remediation text, if the affected workflow has a capability row. + label: Readiness Check result + description: The rows that are not ready, with their explanation. - type: textarea id: additional attributes: label: Additional sanitized context - description: Add screenshots or logs only when they contain no sensitive identifiers or evidence. + description: Screenshots only if they contain no identifiers or evidence (Demo Mode or a simulator is ideal). diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index f0645c2..5ddd2e5 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -10,7 +10,7 @@ body: attributes: label: Before submitting options: - - label: I searched existing issues and reviewed the current workspace guide. + - label: I searched existing issues and read the README. required: true - label: The request is for authorized device development, diagnostics, administration, backup, or evidence preservation. required: true @@ -36,15 +36,18 @@ body: attributes: label: Primary area options: - - Device connection and pairing - - Developer Mode, DDI, or tunnel - - Capability Matrix - - Commands and Man Pages - - Live logs or packet capture + - Device connection and trust + - Developer Mode or developer services + - Readiness Check + - Actions, Advanced Mode, or Tool Reference + - Live Logs or packet capture - Location Lab - - Installed apps or IPA handling - - Backup or UFADE - - Evidence capture and integrity + - Apps or app installation + - Backup + - Evidence Capture + - Simulators + - External tools (MVT, UFADE, idb) + - idt command-line tool - Packaging or release - Documentation - Other @@ -55,7 +58,7 @@ body: id: upstream attributes: label: Upstream command or documentation - description: Link official Apple or upstream pymobiledevice3 documentation when the request depends on a specific service or command. + description: Link Apple documentation (for example devicectl, simctl, or xctrace help) when the request depends on a specific service or tool. - type: textarea id: safety diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5819304..6e0ad87 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,6 @@ version: 2 updates: - - package-ecosystem: pip + - package-ecosystem: swift directory: "/" schedule: interval: monthly diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c1f01a8..97c5c64 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,24 +4,25 @@ Describe the user-visible problem and the smallest change that solves it. ## Verification -- [ ] `python -m unittest discover -s tests -v` -- [ ] `python -m compileall -q ios_developer_toolkit tests` -- [ ] `QT_QPA_PLATFORM=offscreen python -m ios_developer_toolkit --toolkit-internal-smoke-test` -- [ ] Relevant real-device or no-device behavior was exercised and is described below. +- [ ] `swift build -Xswiftc -warnings-as-errors` +- [ ] `swift test` +- [ ] `xcodebuild -project iOSDeveloperToolkit.xcodeproj -scheme iOSDeveloperToolkit -destination 'platform=macOS' build` +- [ ] UI changes: UI tests and a minimum-size (`-window-size 900x560`) render without `SQUEEZED`/`OVERFLOW` +- [ ] Relevant device, simulator, or no-device behavior was exercised and is described below. Device and host coverage: - + -## Safety and publication +## Safety and privacy -- [ ] The change preserves explicit device selection and authorization boundaries. -- [ ] Mutating actions remain labeled, confirmed, bounded, and reversible where possible. -- [ ] Errors remain visible and actionable; unsupported states are not reported as success. -- [ ] No private device data, logs, PCAPs, backups, profiles, IPAs, DDIs, credentials, or evidence artifacts are included. -- [ ] New subprocess arguments avoid shell interpretation and are validated at the boundary. -- [ ] Documentation reflects current behavior without overstating iOS access or forensic coverage. +- [ ] Operations still take an explicit target; physical devices and simulators stay separate. +- [ ] Device-changing actions are classified correctly and confirmed. +- [ ] No `Process` outside `CommandRunner`; arguments are a vector; no shell; no `sudo`. +- [ ] Errors are actionable; unsupported states are not reported as success. +- [ ] No private device data, logs, captures, backups, profiles, IPAs, credentials, or evidence are included. +- [ ] Documentation matches the new behavior without overstating access or coverage. ## Notes -List any capability, iOS-version, signing, packaging, or follow-up limitations. +List limitations, iOS-version differences, or follow-up work. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48ab2d1..aad7383 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,34 +14,113 @@ concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + # Optional repository variable, for example "26.2"; otherwise the newest non-beta Xcode. + XCODE_VERSION: ${{ vars.XCODE_VERSION }} + jobs: - tests: - name: Tests - runs-on: macos-15 - timeout-minutes: 25 + package: + name: Swift package tests + runs-on: macos-26 + timeout-minutes: 30 steps: - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - name: Select Xcode + run: scripts/ci-select-xcode.sh + - uses: actions/cache@v6 with: - python-version: "3.13" - cache: pip - - name: Install project + path: .build + key: spm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Package.resolved') }} + restore-keys: | + spm-${{ runner.os }}-${{ runner.arch }}- + - name: Build with warnings as errors + run: swift build --build-tests -Xswiftc -warnings-as-errors + - name: Test + run: swift test -Xswiftc -warnings-as-errors + - name: Command-line tool smoke test + run: | + swift run idt --version + swift run idt toolchain + swift run idt devices --simulators + - name: Record the runner's devicectl syntax (diagnostic) + # Shows how this Xcode spells the commands whose syntax changed between Xcode versions. + if: always() + run: | + xcrun devicectl --version || true + for route in "device" "device capture" "device capture screenshot" "device process" "device process launch" "device profile" "device profile list" "device simulate location"; do + echo "::group::devicectl help $route" + xcrun devicectl help $route || true + echo "::endgroup::" + done + + app: + name: App build, UI tests, and layout + runs-on: macos-26 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + - name: Select Xcode + run: scripts/ci-select-xcode.sh + - name: Build the app and UI tests with zero warnings + # project.yml makes warnings errors, but Swift does not promote every diagnostic (for + # example actor-isolation warnings involving XCTest), so also fail on any warning in + # this repository's sources. run: | - python -m pip install --disable-pip-version-check --upgrade pip - python -m pip install --disable-pip-version-check --editable . - - name: Run test suite - run: python -m unittest discover -s tests -v - - name: Validate Python modules - run: python -m compileall -q ios_developer_toolkit tests - - name: Validate command entry points + set -o pipefail + xcodebuild -project iOSDeveloperToolkit.xcodeproj -scheme iOSDeveloperToolkit \ + -configuration Debug -destination 'platform=macOS' \ + -derivedDataPath "$RUNNER_TEMP/dd" build-for-testing 2>&1 | tee "$RUNNER_TEMP/build.log" + if grep -E "\.swift:[0-9]+:[0-9]+: warning:" "$RUNNER_TEMP/build.log" | grep -v -e /SourcePackages/ -e /checkouts/; then + echo "::error::The build has warnings." + exit 1 + fi + - name: UI tests run: | - python -m ios_developer_toolkit.collector --help - python -m ios_developer_toolkit.local_ddi --help - python -m ios_developer_toolkit.ipa_inspector --help - python -m ios_developer_toolkit --toolkit-internal-pymobiledevice3 version - - name: Verify guided command catalog - run: python scripts/verify_command_catalog.py - - name: Validate GUI actions + xcodebuild -project iOSDeveloperToolkit.xcodeproj -scheme iOSDeveloperToolkit \ + -configuration Debug -destination 'platform=macOS' \ + -derivedDataPath "$RUNNER_TEMP/dd" -resultBundlePath "$RUNNER_TEMP/UITests.xcresult" \ + test-without-building + - name: Render every page at the default and minimum window sizes + run: | + scripts/check-layout.sh \ + "$RUNNER_TEMP/dd/Build/Products/Debug/iOS Developer Toolkit.app/Contents/MacOS/iOS Developer Toolkit" \ + "$RUNNER_TEMP/layout" + - uses: actions/upload-artifact@v7 + if: always() + with: + name: ui-test-results + path: | + ${{ runner.temp }}/UITests.xcresult + ${{ runner.temp }}/layout + if-no-files-found: ignore + + simulator: + name: Real simulator end-to-end test + runs-on: macos-26 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + - name: Select Xcode + run: scripts/ci-select-xcode.sh + - uses: actions/cache@v6 + with: + path: .build + key: spm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Package.resolved') }} + restore-keys: | + spm-${{ runner.os }}-${{ runner.arch }}- + - name: Boot, use, and shut down a simulator env: - QT_QPA_PLATFORM: offscreen - run: python -m ios_developer_toolkit --toolkit-internal-smoke-test + IDT_SIMULATOR_TESTS: "1" + run: swift test --filter RealSimulator + + release-build: + name: Release packaging + runs-on: macos-26 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + - name: Select Xcode + run: scripts/ci-select-xcode.sh + - name: Build, sign, and verify a release (not published) + # An empty VERSION means the version in ToolkitVersion.swift. + run: scripts/build-release.sh "" "$RUNNER_TEMP/release" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b7098f6..132a86c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -14,20 +14,32 @@ on: permissions: contents: read +env: + XCODE_VERSION: ${{ vars.XCODE_VERSION }} + jobs: analyze: - name: Analyze Python - runs-on: macos-15 - timeout-minutes: 30 + name: Analyze Swift + runs-on: macos-26 + timeout-minutes: 60 permissions: contents: read packages: read security-events: write steps: - uses: actions/checkout@v7 + - name: Select Xcode + run: scripts/ci-select-xcode.sh - uses: github/codeql-action/init@v4 with: - languages: python + languages: swift + build-mode: manual + - name: Build the package and the app + run: | + swift build + xcodebuild -project iOSDeveloperToolkit.xcodeproj -scheme iOSDeveloperToolkit \ + -configuration Debug -destination 'platform=macOS' \ + -derivedDataPath "$RUNNER_TEMP/dd" build - uses: github/codeql-action/analyze@v4 with: - category: "/language:python" + category: "/language:swift" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 1896069..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Documentation - -on: - push: - branches: - - main - paths: - - ".github/workflows/docs.yml" - - "docs/**" - - "mkdocs.yml" - - "requirements/docs.txt" - - "README.md" - - "SECURITY.md" - - "CONTRIBUTING.md" - - "SOURCE_AVAILABILITY.md" - - "THIRD_PARTY_NOTICES.md" - pull_request: - paths: - - ".github/workflows/docs.yml" - - "docs/**" - - "mkdocs.yml" - - "requirements/docs.txt" - - "README.md" - - "SECURITY.md" - - "CONTRIBUTING.md" - - "SOURCE_AVAILABILITY.md" - - "THIRD_PARTY_NOTICES.md" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: documentation-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - name: Build documentation site - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 - with: - python-version: "3.13" - cache: pip - cache-dependency-path: requirements/docs.txt - - name: Install documentation dependencies - run: python -m pip install --disable-pip-version-check --requirement requirements/docs.txt - - name: Build strict documentation site - run: python -m mkdocs build --strict --clean - - uses: actions/configure-pages@v6 - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - - uses: actions/upload-pages-artifact@v5 - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - with: - path: site - - deploy: - name: Publish documentation site - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: build - runs-on: ubuntu-latest - timeout-minutes: 10 - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - permissions: - pages: write - id-token: write - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v5 diff --git a/.github/workflows/frozen-macos-smoke.yml b/.github/workflows/frozen-macos-smoke.yml deleted file mode 100644 index bbdf52c..0000000 --- a/.github/workflows/frozen-macos-smoke.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Frozen macOS smoke - -on: - pull_request: - paths: - - ".github/workflows/frozen-macos-smoke.yml" - - "ios_developer_toolkit/**" - - "macos/**" - - "packaging/**" - - "requirements/**" - - "scripts/build_macos_release.sh" - - "scripts/collect_third_party_licenses.py" - - "scripts/verify_release_metadata.py" - - "scripts/verify_macos_bundle.py" - - "scripts/verify_command_catalog.py" - - "pyproject.toml" - - "tests/**" - push: - branches: - - main - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: frozen-macos-smoke-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-and-verify: - name: Build and verify ${{ matrix.architecture }} bundle - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15 - architecture: arm64 - python_architecture: arm64 - - runner: macos-15-intel - architecture: x86_64 - python_architecture: x64 - runs-on: ${{ matrix.runner }} - timeout-minutes: 90 - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 - with: - python-version: "3.13" - architecture: ${{ matrix.python_architecture }} - cache: pip - - uses: actions/cache@v6 - with: - path: ${{ runner.temp }}/nuitka-cache - key: nuitka-${{ runner.os }}-${{ matrix.architecture }}-${{ hashFiles('pyproject.toml', 'requirements/**', 'packaging/**') }} - restore-keys: | - nuitka-${{ runner.os }}-${{ matrix.architecture }}- - - name: Build the frozen application and run embedded checks - env: - MACOSX_DEPLOYMENT_TARGET: "13.0" - NUITKA_CACHE_DIR: ${{ runner.temp }}/nuitka-cache - run: | - release_version="$(python3 -c 'from ios_developer_toolkit import APP_VERSION; print(APP_VERSION)')" - ./scripts/build_macos_release.sh "$release_version" "$RUNNER_TEMP/release-smoke" python3 - - name: Confirm smoke artifacts exist - run: | - find "$RUNNER_TEMP/release-smoke" -maxdepth 1 -type f -name '*.zip' -size +0c -print -quit | grep -q . - find "$RUNNER_TEMP/release-smoke" -maxdepth 1 -type f -name '*.cdx.json' -size +0c -print -quit | grep -q . diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml deleted file mode 100644 index ee703c4..0000000 --- a/.github/workflows/release-macos.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: Release macOS applications - -on: - push: - tags: - - "v*" - -permissions: - contents: read - -jobs: - build: - name: Build ${{ matrix.architecture }} - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15 - architecture: arm64 - python_architecture: arm64 - - runner: macos-15-intel - architecture: x86_64 - python_architecture: x64 - runs-on: ${{ matrix.runner }} - timeout-minutes: 90 - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 - with: - python-version: "3.13" - architecture: ${{ matrix.python_architecture }} - cache: pip - - uses: actions/cache@v6 - with: - path: ${{ runner.temp }}/nuitka-cache - key: nuitka-${{ runner.os }}-${{ matrix.architecture }}-${{ hashFiles('pyproject.toml', 'requirements/**', 'packaging/**') }} - restore-keys: | - nuitka-${{ runner.os }}-${{ matrix.architecture }}- - - name: Build and verify native application - env: - MACOSX_DEPLOYMENT_TARGET: "13.0" - NUITKA_CACHE_DIR: ${{ runner.temp }}/nuitka-cache - run: ./scripts/build_macos_release.sh "${GITHUB_REF_NAME#v}" release-assets python3 - - uses: actions/upload-artifact@v7 - with: - name: macos-${{ matrix.architecture }} - path: | - release-assets/*.zip - release-assets/*.cdx.json - if-no-files-found: error - - release: - name: Publish GitHub release - needs: build - runs-on: ubuntu-latest - permissions: - artifact-metadata: write - attestations: write - contents: write - id-token: write - steps: - - uses: actions/checkout@v7 - - uses: actions/download-artifact@v8 - with: - pattern: macos-* - path: release-assets - merge-multiple: true - - name: Create checksums - working-directory: release-assets - run: sha256sum *.zip *.cdx.json > SHA256SUMS.txt - - name: Attest build provenance - uses: actions/attest-build-provenance@v4.2.2 - with: - subject-path: release-assets/*.zip - - name: Attest Apple Silicon SBOM - uses: actions/attest@v4.2.2 - with: - subject-path: release-assets/iOS-Developer-Toolkit-${{ github.ref_name }}-macOS-arm64.zip - sbom-path: release-assets/iOS-Developer-Toolkit-${{ github.ref_name }}-macOS-arm64.cdx.json - - name: Attest Intel SBOM - uses: actions/attest@v4.2.2 - with: - subject-path: release-assets/iOS-Developer-Toolkit-${{ github.ref_name }}-macOS-x86_64.zip - sbom-path: release-assets/iOS-Developer-Toolkit-${{ github.ref_name }}-macOS-x86_64.cdx.json - - name: Publish release - env: - GH_TOKEN: ${{ github.token }} - run: | - gh release create "$GITHUB_REF_NAME" release-assets/* \ - --title "iOS Developer Toolkit $GITHUB_REF_NAME" \ - --generate-notes \ - --notes "Native Apple Silicon and Intel macOS application bundles with CycloneDX SBOMs and GitHub build-provenance/SBOM attestations. Both builds are ad-hoc signed and are not Apple-notarized. Verify SHA256SUMS.txt and the GitHub attestation before opening." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4727348 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,76 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +env: + XCODE_VERSION: ${{ vars.XCODE_VERSION }} + +jobs: + build: + name: Build universal app + runs-on: macos-26 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + - name: Select Xcode + run: scripts/ci-select-xcode.sh + - name: Test before packaging + run: swift test -Xswiftc -warnings-as-errors + - name: Build, sign, and verify the release + # Fails unless the tag (without "v") matches ToolkitVersion.current and MARKETING_VERSION. + run: scripts/build-release.sh "${GITHUB_REF_NAME#v}" release-assets + - uses: actions/upload-artifact@v7 + with: + name: release-assets + path: release-assets/* + if-no-files-found: error + + publish: + name: Attest and publish + needs: build + runs-on: ubuntu-latest + permissions: + artifact-metadata: write + attestations: write + contents: write + id-token: write + steps: + - uses: actions/download-artifact@v8 + with: + name: release-assets + path: release-assets + - name: Verify checksums and name the release files + id: files + working-directory: release-assets + run: | + sha256sum -c SHA256SUMS.txt + version="${GITHUB_REF_NAME#v}" + zip="release-assets/iOS-Developer-Toolkit-$version-macOS-universal.zip" + sbom="release-assets/iOS-Developer-Toolkit-$version.spdx.json" + test -f "../$zip" && test -f "../$sbom" + echo "zip=$zip" >> "$GITHUB_OUTPUT" + echo "sbom=$sbom" >> "$GITHUB_OUTPUT" + - name: Attest build provenance + uses: actions/attest-build-provenance@v4.2.2 + with: + subject-path: ${{ steps.files.outputs.zip }} + - name: Attest SBOM + uses: actions/attest@v4.2.2 + with: + subject-path: ${{ steps.files.outputs.zip }} + sbom-path: ${{ steps.files.outputs.sbom }} + - name: Publish the release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" release-assets/* \ + --repo "$GITHUB_REPOSITORY" \ + --title "iOS Developer Toolkit $GITHUB_REF_NAME" \ + --generate-notes \ + --notes "Universal (Apple silicon and Intel) macOS app, ad-hoc signed with the hardened runtime and not notarized. Verify SHA256SUMS.txt and the GitHub attestation before opening; see docs/release-verification.md." diff --git a/.gitignore b/.gitignore index b3a7ed4..1d95755 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,13 @@ -.venv/ -venv/ -build/ -dist/ -site/ -output/ -.playwright-cli/ -ios_developer_toolkit/deployment/ -packaging/deployment/ -*.egg-info/ -__pycache__/ -*.pyc +# Swift, Xcode, and build output +.build/ +.swiftpm/ +DerivedData/ +*.xcuserstate +xcuserdata/ +/build-output/ .DS_Store -# Private or generated device data +# Private or generated device data — never commit backups/ captures/ cases/ @@ -32,11 +27,12 @@ Generated Routes/ location-events.jsonl *.backup/ *.crash -nuitka-crash-report.xml *.ips *.log *.pcap *.pcapng +*.trace/ +*.xcresult/ *.ufd *.ufdr *.stix diff --git a/App/UITests/SmokeUITests.swift b/App/UITests/SmokeUITests.swift new file mode 100644 index 0000000..ede84f7 --- /dev/null +++ b/App/UITests/SmokeUITests.swift @@ -0,0 +1,107 @@ +import XCTest + +/// GUI smoke tests. The app runs with `-ui-testing` (no real device discovery) and +/// `-demo-mode` (a clearly labelled simulated iPhone), so results are deterministic. +@MainActor +final class SmokeUITests: XCTestCase { + var app: XCUIApplication! + + override func setUp() async throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["-ui-testing", "YES", "-demo-mode", "YES", "-ApplePersistenceIgnoreState", "YES"] + app.launch() + } + + override func tearDown() async throws { + app.terminate() + } + + private func attachScreenshot(_ name: String) { + let attachment = XCTAttachment(screenshot: app.windows.firstMatch.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + func testWindowOpensAtUsableSizeOnALaptopDisplay() throws { + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 10)) + let frame = window.frame + XCTAssertGreaterThanOrEqual(frame.width, 900, "Window too narrow: \(frame)") + XCTAssertGreaterThanOrEqual(frame.height, 560, "Window too short: \(frame)") + // A 13-inch MacBook Air is 1280×800 points (minus menu bar and Dock). + XCTAssertLessThanOrEqual(frame.width, 1280, "Window wider than a laptop display: \(frame)") + XCTAssertLessThanOrEqual(frame.height, 760, "Window taller than a laptop display: \(frame)") + attachScreenshot("overview") + } + + func testDemoModeIsClearlyLabelled() throws { + XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "value CONTAINS 'Demo Mode'")).firstMatch.waitForExistence(timeout: 10) || app.otherElements["demo-banner"].exists || app.staticTexts["demo-banner"].exists) + } + + func testEveryWorkspaceOpens() throws { + let workspaces = ["overview", "device", "readiness", "apps", "installApp", "location", "liveLogs", "actions", "backup", "evidence", "externalTools", "activity", "help", "safety"] + for workspace in workspaces { + let item = app.descendants(matching: .any)["sidebar-\(workspace)"] + XCTAssertTrue(item.waitForExistence(timeout: 5), "Missing sidebar item \(workspace)") + item.click() + // Each workspace shows the selected target or its own content without crashing. + XCTAssertTrue(app.windows.firstMatch.exists) + attachScreenshot(workspace) + } + } + + func testDemoActionsAreBlockedWithExplanation() throws { + app.descendants(matching: .any)["sidebar-actions"].click() + let action = app.descendants(matching: .any)["action-battery"] + XCTAssertTrue(action.waitForExistence(timeout: 5)) + action.click() + let run = app.buttons["run-action"] + XCTAssertTrue(run.waitForExistence(timeout: 5)) + XCTAssertFalse(run.isEnabled, "Actions must be disabled in Demo Mode") + attachScreenshot("action-detail") + } + + func testDeveloperImageCardShowsStateAndBlocksDemoMount() throws { + app.descendants(matching: .any)["sidebar-device"].click() + XCTAssertTrue(app.descendants(matching: .any)["ddi-state"].waitForExistence(timeout: 10), "The developer image state is not shown") + let mount = app.buttons["mount-ddi"] + XCTAssertTrue(mount.waitForExistence(timeout: 5)) + XCTAssertFalse(mount.isEnabled, "Mounting must be disabled in Demo Mode") + attachScreenshot("developer-image") + } + + func testCommandPaletteNavigates() throws { + app.typeKey("k", modifierFlags: .command) + let search = app.textFields["palette-search"] + XCTAssertTrue(search.waitForExistence(timeout: 5)) + search.typeText("Location Lab\r") + XCTAssertTrue(app.textFields["latitude-field"].waitForExistence(timeout: 5)) + attachScreenshot("location-lab") + } + + func testLocationLabValidatesInput() throws { + app.descendants(matching: .any)["sidebar-location"].click() + let latitude = app.textFields["latitude-field"] + XCTAssertTrue(latitude.waitForExistence(timeout: 5)) + latitude.doubleClick() + latitude.typeKey("a", modifierFlags: .command) + latitude.typeText("123") + XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "value CONTAINS 'latitude from -90 to 90'")).firstMatch.waitForExistence(timeout: 5)) + XCTAssertFalse(app.buttons["set-location"].isEnabled) + } + + func testMinimumSizeKeepsControlsReachable() throws { + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 10)) + // Drag the bottom-right corner inward as far as it goes. + let corner = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 1)).withOffset(CGVector(dx: -2, dy: -2)) + corner.press(forDuration: 0.2, thenDragTo: window.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.1))) + XCTAssertGreaterThanOrEqual(window.frame.width, 899) + XCTAssertGreaterThanOrEqual(window.frame.height, 559) + app.descendants(matching: .any)["sidebar-location"].click() + XCTAssertTrue(app.buttons["set-location"].waitForExistence(timeout: 5)) + attachScreenshot("minimum-size-location") + } +} diff --git a/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/Contents.json b/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2bac62a --- /dev/null +++ b/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images": [ + { + "filename": "icon_16.png", + "idiom": "mac", + "scale": "1x", + "size": "16x16" + }, + { + "filename": "icon_32.png", + "idiom": "mac", + "scale": "2x", + "size": "16x16" + }, + { + "filename": "icon_32.png", + "idiom": "mac", + "scale": "1x", + "size": "32x32" + }, + { + "filename": "icon_64.png", + "idiom": "mac", + "scale": "2x", + "size": "32x32" + }, + { + "filename": "icon_128.png", + "idiom": "mac", + "scale": "1x", + "size": "128x128" + }, + { + "filename": "icon_256.png", + "idiom": "mac", + "scale": "2x", + "size": "128x128" + }, + { + "filename": "icon_256.png", + "idiom": "mac", + "scale": "1x", + "size": "256x256" + }, + { + "filename": "icon_512.png", + "idiom": "mac", + "scale": "2x", + "size": "256x256" + }, + { + "filename": "icon_512.png", + "idiom": "mac", + "scale": "1x", + "size": "512x512" + }, + { + "filename": "icon_1024.png", + "idiom": "mac", + "scale": "2x", + "size": "512x512" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/macos/iOSDeveloperToolkit.icns b/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_1024.png similarity index 56% rename from macos/iOSDeveloperToolkit.icns rename to App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_1024.png index 21a35a8..25642c3 100644 Binary files a/macos/iOSDeveloperToolkit.icns and b/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ diff --git a/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_128.png b/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_128.png new file mode 100644 index 0000000..ebd7394 Binary files /dev/null and b/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_128.png differ diff --git a/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_16.png b/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_16.png new file mode 100644 index 0000000..9861ea0 Binary files /dev/null and b/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_16.png differ diff --git a/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_256.png b/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_256.png new file mode 100644 index 0000000..a34da70 Binary files /dev/null and b/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_256.png differ diff --git a/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_32.png b/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_32.png new file mode 100644 index 0000000..3c5ada3 Binary files /dev/null and b/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_32.png differ diff --git a/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_512.png b/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_512.png new file mode 100644 index 0000000..18361f5 Binary files /dev/null and b/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_512.png differ diff --git a/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_64.png b/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_64.png new file mode 100644 index 0000000..d7866ba Binary files /dev/null and b/App/iOSDeveloperToolkit/Assets.xcassets/AppIcon.appiconset/icon_64.png differ diff --git a/App/iOSDeveloperToolkit/Assets.xcassets/Contents.json b/App/iOSDeveloperToolkit/Assets.xcassets/Contents.json new file mode 100644 index 0000000..46cba7d --- /dev/null +++ b/App/iOSDeveloperToolkit/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{ "info" : { "author" : "xcode", "version" : 1 } } diff --git a/App/iOSDeveloperToolkit/Assets.xcassets/Logo.imageset/Contents.json b/App/iOSDeveloperToolkit/Assets.xcassets/Logo.imageset/Contents.json new file mode 100644 index 0000000..f4a794f --- /dev/null +++ b/App/iOSDeveloperToolkit/Assets.xcassets/Logo.imageset/Contents.json @@ -0,0 +1 @@ +{ "images" : [ { "filename" : "logo.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } diff --git a/ios_developer_toolkit/assets/iosdevtoolkit.png b/App/iOSDeveloperToolkit/Assets.xcassets/Logo.imageset/logo.png similarity index 100% rename from ios_developer_toolkit/assets/iosdevtoolkit.png rename to App/iOSDeveloperToolkit/Assets.xcassets/Logo.imageset/logo.png diff --git a/App/iOSDeveloperToolkit/Components/Components.swift b/App/iOSDeveloperToolkit/Components/Components.swift new file mode 100644 index 0000000..0853fa6 --- /dev/null +++ b/App/iOSDeveloperToolkit/Components/Components.swift @@ -0,0 +1,450 @@ +import AppKit +import UniformTypeIdentifiers +import DeviceKit +import SwiftUI +import ToolkitCore +import ToolkitFeatures + +/// A titled card used to group related controls. +struct Card: View { + let title: String + var systemImage: String? + var subtitle: String? + @ViewBuilder var content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + if let systemImage { Image(systemName: systemImage).foregroundStyle(.secondary) } + Text(title).font(.headline) + } + if let subtitle { + Text(subtitle).font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + content + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(.separator.opacity(0.6))) + } +} + +/// A scrollable workspace page with a consistent header. +struct WorkspacePage: View { + let workspace: Workspace + @ViewBuilder var content: Content + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text(workspace.subtitle) + .font(.title3) + .foregroundStyle(.secondary) + content + } + .padding(20) + .frame(maxWidth: 1100, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +/// A labelled value with an optional plain-language explanation popover. +struct InfoRow: View { + let title: String + let value: String + var explanation: String? + var monospaced = false + var sensitive = false + @State private var showsExplanation = false + @State private var revealed = false + + init(_ field: DeviceField, value: String?) { + title = field.title + self.value = value ?? "—" + explanation = field.explanation + monospaced = [.udid, .serialNumber, .ecid, .coreDeviceIdentifier, .hardwareIdentifier, .buildNumber].contains(field) + sensitive = field.isSensitive + } + + init(_ title: String, _ value: String, explanation: String? = nil, monospaced: Bool = false) { + self.title = title + self.value = value + self.explanation = explanation + self.monospaced = monospaced + } + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + HStack(spacing: 4) { + Text(title).foregroundStyle(.secondary) + if let explanation { + Button { + showsExplanation.toggle() + } label: { + Image(systemName: "questionmark.circle").imageScale(.small) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .accessibilityLabel("About \(title)") + .popover(isPresented: $showsExplanation, arrowEdge: .trailing) { + Text(explanation) + .font(.callout) + .padding() + .frame(width: 320, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .frame(width: 190, alignment: .leading) + Group { + if sensitive && !revealed && value != "—" { + Button("Show") { revealed = true } + .buttonStyle(.link) + .help("This value identifies the device. It is hidden until you choose to show it.") + } else { + Text(value) + .font(monospaced ? .body.monospaced() : .body) + .textSelection(.enabled) + } + } + Spacer(minLength: 0) + } + .accessibilityElement(children: .combine) + } +} + +struct StateBadge: View { + let state: CapabilityState + + var color: Color { + switch state { + case .ready: return .green + case .attention: return .orange + case .unavailable: return .red + case .blocked: return .gray + case .notTested, .notApplicable: return .secondary + } + } + + var body: some View { + Label(state.label, systemImage: state.symbolName) + .foregroundStyle(color) + .labelStyle(.titleAndIcon) + .font(.callout) + } +} + +struct RiskBadge: View { + let risk: ActionRisk + + var color: Color { + switch risk { + case .readOnly: return .green + case .hostWrite: return .blue + case .deviceChange: return .orange + case .highImpact: return .red + } + } + + var body: some View { + Label(risk.label, systemImage: risk.symbolName) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .foregroundStyle(color) + .background(color.opacity(0.12), in: Capsule()) + .help(risk.explanation) + } +} + +/// Shows exactly which device the page will act on, with its kind clearly labelled. +struct TargetHeader: View { + @Environment(AppModel.self) private var model + var allowedKinds: Set = [.physical, .simulator, .demo] + + var body: some View { + if let device = model.selectedDevice { + HStack(spacing: 12) { + Image(systemName: device.family.symbolName) + .font(.title2) + .frame(width: 32) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(device.name).font(.headline) + Text(device.kind.label) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background((device.kind == .simulator ? Color.purple : device.kind == .demo ? Color.orange : Color.blue).opacity(0.15), in: Capsule()) + } + Text("\(device.displayModel) · \(device.displayVersion) · \(device.primaryTransport?.label ?? "Not connected")") + .font(.callout) + .foregroundStyle(.secondary) + } + Spacer() + if !allowedKinds.contains(device.kind) { + Label("Not available for \(device.kind.label.lowercased())s", systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + .font(.callout) + } + } + .padding(12) + .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 10)) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("target-header") + } else { + NoDeviceView() + } + } +} + +struct NoDeviceView: View { + @Environment(AppModel.self) private var model + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label("No device selected", systemImage: "iphone.slash").font(.headline) + Text("Connect an iPhone or iPad with a USB cable, unlock it, and tap Trust. Or choose a simulator from the device menu in the toolbar.") + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack { + Button("Refresh Devices") { Task { await model.refreshDevices() } } + Button("Reconnect a Device…") { model.isReconnectGuidePresented = true } + Button("Use Demo Mode") { model.demoMode = true } + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 10)) + } +} + +/// Confirms an action according to its risk. Device-changing actions need a phrase bound to the +/// target's UDID; high-impact ones also need a backup acknowledgement. +struct ConfirmationSheet: View { + let title: String + let detail: String + let requirement: ConfirmationRequirement + let target: DeviceTarget? + let commandPreview: String? + let onConfirm: () -> Void + @Environment(\.dismiss) private var dismiss + @State private var typed = "" + @State private var backupAcknowledged = false + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text(title).font(.title2.bold()) + Spacer() + RiskBadge(risk: requirement.risk) + } + Text(detail).fixedSize(horizontal: false, vertical: true) + if let target { + InfoRow("Target", "\(target.name) — \(target.kind.label)") + InfoRow("UDID", target.udid, monospaced: true) + } + if let commandPreview { + GroupBox("Exact command") { + Text(commandPreview) + .font(.caption.monospaced()) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + if requirement.requiresBackupAcknowledgement { + Toggle("I have a current backup and understand this cannot be undone", isOn: $backupAcknowledged) + } + if let phrase = requirement.phrase { + VStack(alignment: .leading, spacing: 6) { + Text("Type **\(phrase)** to confirm. The code is the end of this device's UDID, so the confirmation cannot apply to a different device.") + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + TextField(phrase, text: $typed) + .textFieldStyle(.roundedBorder) + .font(.body.monospaced()) + .accessibilityIdentifier("confirmation-field") + } + } + HStack { + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + Button(requirement.risk == .readOnly ? "Run" : "Continue") { + dismiss() + onConfirm() + } + .keyboardShortcut(.defaultAction) + .disabled(!requirement.isSatisfied(typedPhrase: typed, backupAcknowledged: backupAcknowledged)) + .accessibilityIdentifier("confirm-button") + } + } + .padding(20) + .frame(width: 520) + } +} + +/// A sheet presenting an error with its plain message, recovery, and copyable details. +struct ErrorSheet: View { + let error: PresentedError + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Label(error.title, systemImage: "exclamationmark.triangle.fill") + .font(.title2.bold()) + .foregroundStyle(.orange) + Text(error.message).font(.body).fixedSize(horizontal: false, vertical: true) + if let recovery = error.recovery { + Text(recovery).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + if let details = error.details, !details.isEmpty { + DisclosureGroup("Technical details") { + ScrollView { + Text(details) + .font(.caption.monospaced()) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 180) + Button("Copy Details") { Pasteboard.copy(details) } + .controlSize(.small) + } + } + HStack { + Spacer() + Button("OK") { dismiss() }.keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(width: 520) + .accessibilityIdentifier("error-sheet") + } +} + +enum Pasteboard { + static func copy(_ text: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } +} + +/// Native open/save panels. +@MainActor +enum FilePanels { + /// Content types for file extensions (empty means any file). + static func contentTypes(_ extensions: [String]) -> [UTType] { + extensions.compactMap { fileExtension -> UTType? in + switch fileExtension { + case "app": return .applicationBundle + case "swift": return .swiftSource + default: return UTType(filenameExtension: fileExtension) + } + } + } + + static func chooseFile(title: String, allowedExtensions: [String], directory: URL? = nil) -> URL? { + let panel = NSOpenPanel() + panel.title = title + panel.canChooseFiles = true + panel.canChooseDirectories = allowedExtensions.contains("app") + panel.treatsFilePackagesAsDirectories = false + panel.allowsMultipleSelection = false + panel.allowedContentTypes = contentTypes(allowedExtensions) + if let directory { panel.directoryURL = directory } + return panel.runModal() == .OK ? panel.url : nil + } + + static func chooseFiles(title: String, allowedExtensions: [String]) -> [URL] { + let panel = NSOpenPanel() + panel.title = title + panel.canChooseFiles = true + panel.allowsMultipleSelection = true + panel.allowedContentTypes = contentTypes(allowedExtensions) + return panel.runModal() == .OK ? panel.urls : [] + } + + static func chooseFolder(title: String, directory: URL? = nil, canCreate: Bool = true) -> URL? { + let panel = NSOpenPanel() + panel.title = title + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = canCreate + panel.allowsMultipleSelection = false + if let directory { panel.directoryURL = directory } + return panel.runModal() == .OK ? panel.url : nil + } + + static func save(title: String, suggestedName: String, allowedExtension: String, directory: URL? = nil) -> URL? { + let panel = NSSavePanel() + panel.title = title + panel.nameFieldStringValue = suggestedName + panel.allowedContentTypes = allowedExtension.isEmpty ? [] : contentTypes([allowedExtension]) + panel.canCreateDirectories = true + if let directory { panel.directoryURL = directory } + return panel.runModal() == .OK ? panel.url : nil + } + + static func reveal(_ url: URL) { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } +} + +/// A read-only, selectable monospaced text block for raw output. +struct RawOutputView: View { + let text: String + var maxHeight: CGFloat = 320 + + var body: some View { + ScrollView([.vertical, .horizontal]) { + Text(text.isEmpty ? "No output." : text) + .font(.caption.monospaced()) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + .frame(maxHeight: maxHeight) + .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(.separator)) + } +} + +/// What the latest Readiness Check says about a set of prerequisites, with a way to run or open it. +struct ReadinessStatusView: View { + @Environment(AppModel.self) private var model + let requirements: [ActionRequirement] + let device: Device? + /// “this action”, “this collection”. + let subject: String + + var body: some View { + switch ActionReadiness.evaluate(requirements: requirements, results: model.readiness(for: device), device: device) { + case .ready: + Label("Readiness Check: everything \(subject) needs is ready.", systemImage: "checkmark.circle").foregroundStyle(.green).font(.callout) + case .notTested: + HStack { + Label("Not checked yet for this device.", systemImage: "circle.dashed").font(.callout).foregroundStyle(.secondary) + runButton("Run Readiness Check") + } + case .needsAttention(let problems): + VStack(alignment: .leading, spacing: 4) { + ForEach(problems, id: \.self) { Label($0, systemImage: "exclamationmark.triangle").font(.callout).foregroundStyle(.orange) } + HStack { + runButton("Check Again") + Button("Open Readiness Check") { model.workspace = .readiness }.controlSize(.small) + } + } + } + } + + @ViewBuilder + private func runButton(_ title: String) -> some View { + if let device, device.kind != .demo { + Button(title) { Task { await model.runReadiness(for: device) } } + .controlSize(.small) + .disabled(model.operations.contains { $0.title == "Readiness Check" }) + } + } +} diff --git a/App/iOSDeveloperToolkit/Info.plist b/App/iOSDeveloperToolkit/Info.plist new file mode 100644 index 0000000..0fd848c --- /dev/null +++ b/App/iOSDeveloperToolkit/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + iOS Developer Toolkit + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType + public.app-category.developer-tools + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + Copyright © 2026 hideouts-io. MIT License. + NSLocalNetworkUsageDescription + iOS Developer Toolkit looks for iPhones and iPads on your local network when you use “Discover devices on the network”. + NSBonjourServices + + _apple-mobdev2._tcp + _remotepairing._tcp + _remoted._tcp + + + diff --git a/App/iOSDeveloperToolkit/Model/AppModel.swift b/App/iOSDeveloperToolkit/Model/AppModel.swift new file mode 100644 index 0000000..7d831fd --- /dev/null +++ b/App/iOSDeveloperToolkit/Model/AppModel.swift @@ -0,0 +1,318 @@ +import DeviceKit +import Foundation +import Observation +import OSLog +import SwiftUI +import ToolkitCore +import ToolkitFeatures + +/// An error ready for an alert: plain message first, technical details on request. +struct PresentedError: Identifiable, Equatable { + let id = UUID() + let title: String + let message: String + let recovery: String? + let details: String? + + init(_ error: Error, context: String? = nil) { + if let toolkitError = error as? ToolkitError { + title = toolkitError.title + message = toolkitError.message + recovery = toolkitError.recovery + details = toolkitError.technicalDetail + } else { + title = context ?? "Something Went Wrong" + message = error.localizedDescription + recovery = nil + details = String(describing: error) + } + } +} + +/// A long-running operation shown in the toolbar activity list, with a Stop control. +@Observable +@MainActor +final class RunningOperation: Identifiable { + let id = UUID() + let title: String + let targetLabel: String? + let startedAt = Date() + var progress: Double? + var status: String = "" + fileprivate var cancelAction: (() -> Void)? + + init(title: String, targetLabel: String?) { + self.title = title + self.targetLabel = targetLabel + } + + func cancel() { + cancelAction?() + status = "Stopping…" + } + + /// Updates progress from any context. + nonisolated func report(_ status: String, progress: Double? = nil) { + Task { @MainActor in + self.status = status + if let progress { self.progress = progress } + } + } +} + +@Observable +@MainActor +final class AppModel { + // MARK: Services + + let runner: CommandRunning = ProcessCommandRunner() + let journal = OperationJournal() + let discovery: DeviceDiscovery + /// A fresh executor carries the current developer-image folders. + var executor: ActionExecutor { ActionExecutor(runner: runner, developerImageFolders: developerImage.folders) } + let logger = ToolkitLog.application + + // MARK: State + + var snapshot = DiscoverySnapshot() + var selectedDeviceID: String? { + didSet { UserDefaults.standard.set(selectedDeviceID, forKey: "selectedDeviceID") } + } + var workspace: Workspace = .overview + /// The Actions page's category filter and selection (set by profiles and the command palette). + var actionsCategory = "All" + var selectedActionID: String? + var demoMode: Bool { + didSet { + UserDefaults.standard.set(demoMode, forKey: "demoMode") + if demoMode { selectedDeviceID = DemoMode.device.id } + } + } + var journalRecords: [OperationRecord] = [] + var operations: [RunningOperation] = [] + var presentedError: PresentedError? + var developerTools: DeveloperToolsStatus? + var readinessResults: [String: [CapabilityResult]] = [:] + var toolchainReport = "" + var isCommandPalettePresented = false + var isDeveloperModeGuidePresented = false + var isReconnectGuidePresented = false + var isShortcutReferencePresented = false + var isAdvancedModePresented = false + /// The Advanced Mode command line (kept between openings; Tool Reference can fill it in). + var advancedModeText = "device info details" + var isRefreshing = false + var statusMessage: String? + + // Feature models + let developerImage: DeveloperImageModel + let location: LocationModel + let logs: LiveLogsModel + let apps: AppsModel + let backup: BackupModel + let evidence: EvidenceModel + let install: InstallModel + let externalTools: ExternalToolsModel + + private var discoveryTask: Task? + private var journalTask: Task? + + init(arguments: [String] = ProcessInfo.processInfo.arguments) { + // Launch flags are `-flag YES|NO` pairs (AppKit parses arguments as key/value pairs). + func flag(_ name: String) -> Bool? { + guard let index = arguments.firstIndex(of: name) else { return nil } + let value = arguments.indices.contains(index + 1) ? arguments[index + 1].uppercased() : "YES" + return !(value == "NO" || value == "0" || value == "FALSE") + } + let uiTesting = flag("-ui-testing") ?? false + let forceDemo = flag("-demo-mode") ?? false + demoMode = forceDemo || (!uiTesting && UserDefaults.standard.bool(forKey: "demoMode")) + selectedDeviceID = forceDemo ? DemoMode.device.id : UserDefaults.standard.string(forKey: "selectedDeviceID") + // UI tests run without touching real discovery sources. + let configuration: DeviceDiscovery.Configuration = uiTesting + ? .init(usbmux: USBMuxClient(socketPath: "/nonexistent/ui-testing-usbmuxd"), coreDevice: nil, simulators: nil, enrichWithLockdown: false) + : .init() + discovery = DeviceDiscovery(configuration: configuration) + developerImage = DeveloperImageModel() + location = LocationModel() + logs = LiveLogsModel() + apps = AppsModel() + backup = BackupModel() + evidence = EvidenceModel() + install = InstallModel() + externalTools = ExternalToolsModel() + if let destination = arguments.firstIndex(of: "-workspace").flatMap({ arguments.indices.contains($0 + 1) ? arguments[$0 + 1] : nil }).flatMap(Workspace.init(rawValue:)) { + workspace = destination + } + } + + // MARK: Lifecycle + + func start() { + guard discoveryTask == nil else { return } + logger.info("Application started") + let discovery = self.discovery + discoveryTask = Task { [weak self] in + await discovery.start() + for await snapshot in await discovery.updates() { + self?.apply(snapshot) + } + } + let journal = self.journal + journalTask = Task { [weak self] in + for await records in await journal.updates() { + self?.journalRecords = records + } + } + Task { [weak self] in + guard let self else { return } + self.developerTools = await DeveloperToolsStatus.probe(runner: self.runner) + } + } + + func stop() { + discoveryTask?.cancel() + journalTask?.cancel() + let discovery = self.discovery + Task { await discovery.stop() } + logs.stopAll() + location.stopPlayback() + } + + private func apply(_ snapshot: DiscoverySnapshot) { + let previous = Set(self.snapshot.devices.map(\.id)) + self.snapshot = snapshot + let current = Set(snapshot.devices.map(\.id)) + if let selectedDeviceID, !demoMode || selectedDeviceID != DemoMode.device.id, !current.contains(selectedDeviceID), previous.contains(selectedDeviceID) { + statusMessage = "The selected device disconnected." + } + if selectedDeviceID == nil || (selectedDevice == nil && !previous.contains(selectedDeviceID ?? "")) { + // Choose a sensible default: the first connected physical device, else nothing. + if let firstPhysical = snapshot.physicalDevices.first { selectedDeviceID = firstPhysical.id } + } + } + + func refreshDevices() async { + isRefreshing = true + await discovery.refreshAll() + isRefreshing = false + } + + // MARK: Devices + + var allDevices: [Device] { + demoMode ? [DemoMode.device] + snapshot.devices : snapshot.devices + } + + var physicalDevices: [Device] { allDevices.filter { $0.kind == .physical || $0.kind == .demo } } + var simulatorDevices: [Device] { allDevices.filter { $0.kind == .simulator } } + + var selectedDevice: Device? { + guard let selectedDeviceID else { return nil } + return allDevices.first { $0.id == selectedDeviceID } + } + + var selectedTarget: DeviceTarget? { selectedDevice?.target } + + func readiness(for device: Device?) -> [CapabilityResult] { + guard let device else { return [] } + return readinessResults[device.id] ?? CapabilityRow.rows(for: device.kind).map(\.untested) + } + + // MARK: Operations + + /// Runs `body` as a tracked, cancellable operation. Errors are presented to the user (never + /// silently dropped) and a journal record is written either way. + @discardableResult + func run( + _ title: String, + workspace: Workspace, + target: DeviceTarget?, + transport: String, + argv: [String] = [], + outputPaths: [String] = [], + presentErrors: Bool = true, + onStart: ((RunningOperation) -> Void)? = nil, + _ body: @escaping @Sendable (RunningOperation) async throws -> T + ) async -> T? { + let operation = RunningOperation(title: title, targetLabel: target?.shortLabel) + operations.append(operation) + onStart?(operation) + let started = Date() + let work = Task { try await body(operation) } + operation.cancelAction = { work.cancel() } + let result: Result = await withTaskCancellationHandler { + do { + return .success(try await work.value) + } catch { + return .failure(error) + } + } onCancel: { + work.cancel() + } + operations.removeAll { $0.id == operation.id } + + let finished = Date() + switch result { + case .success(let value): + record(title: title, workspace: workspace, target: target, transport: transport, argv: argv, started: started, finished: finished, outcome: .succeeded, error: nil, outputPaths: outputPaths) + return value + case .failure(let error): + let outcome = OperationOutcome.from(error) + record(title: title, workspace: workspace, target: target, transport: transport, argv: argv, started: started, finished: finished, outcome: outcome, error: (error as? ToolkitError)?.message ?? error.localizedDescription, outputPaths: outputPaths) + logger.error("\(title, privacy: .private) failed: \(outcome.rawValue, privacy: .public)") + if presentErrors && outcome != .cancelled { + presentedError = PresentedError(error, context: title) + } else if outcome == .cancelled { + statusMessage = "\(title) was stopped." + } + return nil + } + } + + func record(title: String, workspace: Workspace, target: DeviceTarget?, transport: String, argv: [String], started: Date, finished: Date, outcome: OperationOutcome, error: String?, outputPaths: [String]) { + let record = OperationRecord(title: title, workspace: workspace.title, target: target?.shortLabel ?? "This Mac", transport: transport, argv: argv, startedAt: started, finishedAt: finished, outcome: outcome, errorMessage: error, outputPaths: outputPaths) + let journal = self.journal + Task { await journal.append(record) } + } + + /// Opens Advanced Mode, optionally with a command line filled in (never run automatically). + func openAdvancedMode(command: String? = nil) { + if let command { advancedModeText = command } + isAdvancedModePresented = true + } + + /// Shows one action on the Actions page, widening the category filter if it hides the action. + func openAction(_ id: String) { + guard let action = ActionCatalog.descriptor(id) else { return } + if actionsCategory != "All" && actionsCategory != action.category { actionsCategory = "All" } + selectedActionID = id + workspace = .actions + } + + func present(_ error: Error) { + presentedError = PresentedError(error) + } + + // MARK: Readiness + + func runReadiness(for device: Device) async { + let probe = CapabilityProbe(runner: runner, developerImageFolders: developerImage.folders) + readinessResults[device.id] = CapabilityRow.rows(for: device.kind).map(\.untested) + let results = await run("Readiness Check", workspace: .readiness, target: device.target, transport: "Native services + CoreDevice", presentErrors: false) { _ in + await probe.run(for: device) + } + guard let results else { return } + readinessResults[device.id] = results + if device.kind == .physical { + try? CompatibilityStore().append(CompatibilityObservation(device: device, results: results)) + } + } + + func runToolchainCheck() async { + let runner = self.runner + if let results = await run("Toolchain check", workspace: .help, target: nil, transport: "xcrun help", { _ in await ToolchainCheck.run(runner: runner) }) { + toolchainReport = ToolchainCheck.render(results) + } + } +} diff --git a/App/iOSDeveloperToolkit/Model/DeveloperImageModel.swift b/App/iOSDeveloperToolkit/Model/DeveloperImageModel.swift new file mode 100644 index 0000000..91c1204 --- /dev/null +++ b/App/iOSDeveloperToolkit/Model/DeveloperImageModel.swift @@ -0,0 +1,106 @@ +import DeviceKit +import Foundation +import Observation +import ToolkitCore + +/// Developer-image (DDI) state for the Device page: per-device status, the folders of developer +/// images the user added, and the preferred mount mechanism. +@Observable +@MainActor +final class DeveloperImageModel { + private(set) var statuses: [String: DeveloperImageStatus] = [:] + private(set) var checking: Set = [] + private(set) var working: Set = [] + + /// Folders with developer images, in addition to the one Xcode installs. + var folders: [URL] { + didSet { UserDefaults.standard.set(folders.map(\.path), forKey: "developerImageFolders") } + } + + var mechanism: DeveloperImageMechanism { + didSet { UserDefaults.standard.set(mechanism.rawValue, forKey: "developerImageMechanism") } + } + + init() { + folders = (UserDefaults.standard.stringArray(forKey: "developerImageFolders") ?? []).map { URL(fileURLWithPath: $0, isDirectory: true) } + mechanism = UserDefaults.standard.string(forKey: "developerImageMechanism").flatMap(DeveloperImageMechanism.init(rawValue:)) ?? .automatic + } + + func status(for device: Device) -> DeveloperImageStatus? { statuses[device.id] } + func isBusy(_ device: Device) -> Bool { checking.contains(device.id) || working.contains(device.id) } + + private func manager(_ app: AppModel) -> DeveloperImageManager { + DeveloperImageManager(coreDevice: CoreDeviceClient(runner: app.runner)) + } + + /// Reads the device's state without changing anything. + func refresh(_ device: Device, app: AppModel) async { + guard !checking.contains(device.id) else { return } + checking.insert(device.id) + defer { checking.remove(device.id) } + if device.kind == .demo { + statuses[device.id] = DeveloperImageStatus(state: .personalizationRequired, headline: "A compatible image is on this Mac; Apple must personalize it (demo data).", explanation: "In Demo Mode nothing is read from a device. On a real iPhone this shows the mounted image, the iOS version and chip used to choose the image, and whether Apple's personalization is needed.", requiredKind: .personalized, facts: DeveloperImageDeviceFacts(productVersion: "26.0", buildVersion: "23A341", productType: "iPhone17,1", architecture: "arm64e"), hostImage: "Xcode developer image (demo data)", recommendedMechanism: .native) + return + } + let manager = manager(app) + let target = device.target + let folders = folders + statuses[device.id] = await manager.status(for: target, userFolders: folders) + } + + func mount(_ device: Device, app: AppModel) async { + let manager = manager(app) + let target = device.target + let folders = folders + let mechanism = mechanism + let failure = LockedValue(nil) + working.insert(device.id) + defer { working.remove(device.id) } + let result = await app.run("Mount developer image", workspace: .device, target: target, transport: mechanism.label) { operation in + do { + return try await manager.mount(target, mechanism: mechanism, userFolders: folders) { progress in + operation.report(progress.step, progress: progress.fraction) + } + } catch { + failure.withLock { $0 = error } + throw error + } + } + if let result { + statuses[device.id] = result + app.statusMessage = result.headline + } else if let error = failure.current { + var status = DeveloperImageStatus.failure(error, facts: statuses[device.id]?.facts) + if status.state != .blocked { status.explanation = "The last mount attempt failed. " + status.explanation } + statuses[device.id] = status + } + } + + func unmount(_ device: Device, app: AppModel) async { + let manager = manager(app) + let target = device.target + let folders = folders + working.insert(device.id) + defer { working.remove(device.id) } + if let result = await app.run("Unmount developer image", workspace: .device, target: target, transport: "mobile_image_mounter UnmountImage", { _ in + try await manager.unmount(target, userFolders: folders) + }) { + statuses[device.id] = result + app.statusMessage = "The developer image is no longer mounted." + } + } + + /// Adds a folder after checking that it holds a developer image. Returns a message for the user. + func addFolder(_ url: URL) -> String { + if (try? DeveloperImageLibrary.personalizedSource(at: url, origin: .userFolder)) != nil + || !DeveloperImageLibrary.legacySources(userFolders: [url], applications: URL(fileURLWithPath: "/nonexistent")).isEmpty { + if !folders.contains(url) { folders.append(url) } + return "Added \(url.lastPathComponent). Check the device again to use it." + } + return "\(url.lastPathComponent) does not contain a developer image. Choose a folder with BuildManifest.plist and the image (iOS 17 and later), or DeveloperDiskImage.dmg and its .signature (iOS 16 and earlier, in a folder named for the iOS version)." + } + + func removeFolder(_ url: URL) { + folders.removeAll { $0 == url } + } +} diff --git a/App/iOSDeveloperToolkit/Model/FeatureModels.swift b/App/iOSDeveloperToolkit/Model/FeatureModels.swift new file mode 100644 index 0000000..e0fb7ab --- /dev/null +++ b/App/iOSDeveloperToolkit/Model/FeatureModels.swift @@ -0,0 +1,450 @@ +import DeviceKit +import Foundation +import Observation +import ToolkitCore +import ToolkitFeatures + +extension URL { + static var documents: URL { FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Documents") } +} + +// MARK: - Apps + +/// One installed app from any source (lockdown, CoreDevice, or simctl). +struct AppRow: Identifiable, Hashable, Sendable { + var id: String { bundleIdentifier } + var name: String + var bundleIdentifier: String + var version: String + var build: String + var kind: String + var sizeBytes: Int64? + var isRemovable: Bool + + var sizeText: String { ByteFormatting.string(sizeBytes) } +} + +@Observable +@MainActor +final class AppsModel { + var rows: [AppRow] = [] + var loadedFor: DeviceTarget? + var source = "" + var search = "" + var includeSystemApps = false + var calculateSizes = true + var sortOrder = [KeyPathComparator(\AppRow.name, comparator: .localizedStandard)] + var selection: Set = [] + var isLoading = false + + var visibleRows: [AppRow] { + let filtered = rows.filter { row in + (includeSystemApps || row.kind != "System" && row.kind != "Built-in" && row.kind != "Hidden system app") + && (search.isEmpty || row.name.localizedCaseInsensitiveContains(search) || row.bundleIdentifier.localizedCaseInsensitiveContains(search)) + } + return filtered.sorted(using: sortOrder) + } + + func refresh(app: AppModel, device: Device) async { + let target = device.target + let runner = app.runner + let sizes = calculateSizes + isLoading = true + defer { isLoading = false } + let result: (rows: [AppRow], source: String)? = await app.run("Load installed apps", workspace: .apps, target: target, transport: device.kind == .simulator ? "simctl listapps" : (device.supportsLockdownServices ? "installation_proxy" : "devicectl device info apps")) { _ in + switch device.kind { + case .simulator: + let apps = try await SimulatorClient(runner: runner).apps(target) + return (apps.map { AppRow(name: $0.name, bundleIdentifier: $0.bundleIdentifier, version: $0.version ?? "", build: $0.build ?? "", kind: $0.applicationType == "User" ? "Installed by user" : "Built-in", sizeBytes: nil, isRemovable: $0.applicationType == "User") }, "Simulator (simctl)") + case .physical where device.supportsLockdownServices: + let apps = try await DeviceSession.with(target) { session in + let proxy = try await InstallationProxy.open(session) + defer { Task { await proxy.close() } } + return try await proxy.browse(includeSizes: sizes) + } + return (apps.map { AppRow(name: $0.name, bundleIdentifier: $0.bundleIdentifier, version: $0.version ?? "", build: $0.build ?? "", kind: $0.typeLabel, sizeBytes: $0.totalBytes, isRemovable: $0.applicationType == "User") }, "Installation service (USB)") + case .physical: + let apps = try await CoreDeviceClient(runner: runner).apps(target) + return (apps.map { AppRow(name: $0.name, bundleIdentifier: $0.bundleIdentifier, version: $0.version ?? "", build: $0.bundleVersion ?? "", kind: $0.isDefaultApp == true ? "Built-in" : ($0.isBuiltByDeveloper == true ? "Developer build" : "Installed by user"), sizeBytes: nil, isRemovable: $0.isRemovable ?? false) }, "Xcode device service") + case .demo: + return ([ + AppRow(name: "Safari", bundleIdentifier: "com.apple.mobilesafari", version: "26.0", build: "8621", kind: "Built-in", sizeBytes: nil, isRemovable: false), + AppRow(name: "Sample App", bundleIdentifier: "com.example.sample", version: "1.4", build: "28", kind: "Installed by user", sizeBytes: 48_200_000, isRemovable: true), + ], "Demo data") + } + } + if let result { + rows = result.rows + source = result.source + loadedFor = target + } + } + + func uninstall(_ row: AppRow, app: AppModel, device: Device) async { + let target = device.target + let runner = app.runner + let succeeded = await app.run("Remove \(row.name)", workspace: .apps, target: target, transport: device.kind == .simulator ? "simctl uninstall" : "installation_proxy / devicectl", argv: [row.bundleIdentifier]) { operation in + switch device.kind { + case .simulator: + try await SimulatorClient(runner: runner).uninstall(bundleIdentifier: row.bundleIdentifier, on: target) + case .physical where device.supportsLockdownServices: + try await DeviceSession.with(target) { session in + let proxy = try await InstallationProxy.open(session) + defer { Task { await proxy.close() } } + try await proxy.uninstall(bundleIdentifier: row.bundleIdentifier) { percent in operation.report("\(percent)%", progress: Double(percent) / 100) } + } + case .physical: + _ = try await CoreDeviceClient(runner: runner).uninstall(bundleIdentifier: row.bundleIdentifier, on: target) + case .demo: + throw ToolkitError(.unsupported, message: "Demo Mode cannot change apps.") + } + return true + } + if succeeded == true { + rows.removeAll { $0.id == row.id } + app.statusMessage = "Removed \(row.name) from \(target.name)." + } + } +} + +// MARK: - Install + +@Observable +@MainActor +final class InstallModel { + var packageURL: URL? + var inspection: IPAInspection? + var appBundleName: String? + var installAsDeveloperPackage = false + + var isAppBundle: Bool { packageURL?.pathExtension == "app" } + + func choose(_ url: URL, app: AppModel) async { + packageURL = url + inspection = nil + appBundleName = nil + if url.pathExtension == "app" { + appBundleName = url.deletingPathExtension().lastPathComponent + return + } + let result = await app.run("Inspect \(url.lastPathComponent)", workspace: .installApp, target: nil, transport: "Local inspection (Security.framework)", outputPaths: [url.path]) { _ in + try IPAInspector.inspect(url) + } + inspection = result + } + + func install(app: AppModel, device: Device) async { + guard let url = packageURL else { return } + let target = device.target + let runner = app.runner + let developer = installAsDeveloperPackage + let succeeded = await app.run("Install \(url.lastPathComponent)", workspace: .installApp, target: target, transport: device.kind == .simulator ? "simctl install" : (device.supportsCoreDevice ? "devicectl device install app" : "AFC + installation_proxy"), argv: [url.path]) { operation in + switch device.kind { + case .simulator: + try await SimulatorClient(runner: runner).install(appAt: url, on: target) + case .physical where device.supportsCoreDevice: + _ = try await CoreDeviceClient(runner: runner).install(appAt: url, on: target) + case .physical: + guard url.pathExtension == "ipa" else { throw ToolkitError(.unsupported, message: "Without Xcode, only .ipa packages can be installed.") } + try await DeviceSession.with(target) { session in + let afc = try await AFCClient.openMedia(session) + let stagedName = "idt-\(UUID().uuidString.prefix(8)).ipa" + try? await afc.makeDirectory("/PublicStaging") + try await afc.upload(url, to: "/PublicStaging/\(stagedName)") { sent, total in + operation.report("Uploading \(ByteFormatting.string(sent)) of \(ByteFormatting.string(total))", progress: total > 0 ? Double(sent) / Double(total) * 0.5 : nil) + } + await afc.close() + let proxy = try await InstallationProxy.open(session) + defer { Task { await proxy.close() } } + try await proxy.install(stagedPackagePath: "PublicStaging/\(stagedName)", developerPackage: developer) { percent in + operation.report("Installing \(percent)%", progress: 0.5 + Double(percent) / 200) + } + } + case .demo: + throw ToolkitError(.unsupported, message: "Demo Mode cannot install apps.") + } + return true + } + if succeeded == true { + app.statusMessage = "Installed \(inspection?.appName ?? url.lastPathComponent) on \(target.name)." + await app.apps.refresh(app: app, device: device) + } + } +} + +// MARK: - Backup + +@Observable +@MainActor +final class BackupModel { + var destination = URL.documents.appendingPathComponent("iOS Developer Toolkit Backups") + var forceFullBackup = false + var requireEncryption = true + var encryptionEnabled: Bool? + var encryptionCheckedFor: DeviceTarget? + var newPassword = "" + var confirmPassword = "" + var progress: Double? + var log: [String] = [] + var lastBackup: URL? + var isRunning = false + var activeOperation: RunningOperation? + + var passwordsValid: Bool { + newPassword.count >= 8 && newPassword == confirmPassword + } + + func checkEncryption(app: AppModel, target: DeviceTarget) async { + let enabled = await app.run("Check backup encryption", workspace: .backup, target: target, transport: "lockdownd com.apple.mobile.backup") { _ in + try await DeviceSession.with(target) { try await MobileBackup2.isEncryptionEnabled($0) } + } + encryptionEnabled = enabled + encryptionCheckedFor = enabled == nil ? nil : target + } + + func enableEncryption(app: AppModel, target: DeviceTarget) async { + guard passwordsValid else { return } + let password = newPassword + newPassword = "" + confirmPassword = "" + isRunning = true + defer { isRunning = false } + let succeeded = await app.run("Turn on backup encryption", workspace: .backup, target: target, transport: "com.apple.mobilebackup2 ChangePassword", onStart: { [weak self] in self?.activeOperation = $0 }) { [weak self] _ in + try await DeviceSession.with(target) { session in + try await MobileBackup2.enableEncryption(session, newPassword: password) { event in + Task { @MainActor in self?.handle(event) } + } + } + return true + } + activeOperation = nil + if succeeded == true { encryptionEnabled = true } + } + + func startBackup(app: AppModel, target: DeviceTarget) async { + let options = BackupOptions(destinationRoot: destination, forceFullBackup: forceFullBackup) + let mustEncrypt = requireEncryption + log = [] + progress = 0 + isRunning = true + defer { isRunning = false } + let result = await app.run("Back up \(target.name)", workspace: .backup, target: target, transport: "com.apple.mobilebackup2", outputPaths: [destination.path], onStart: { [weak self] in self?.activeOperation = $0 }) { [weak self] operation in + try await DeviceSession.with(target) { session in + if mustEncrypt, try await !MobileBackup2.isEncryptionEnabled(session) { + throw ToolkitError(.invalidInput, message: "Backup encryption is off on this device.", recovery: "Turn on encryption with a new backup password first, or clear “Require encrypted backup”.") + } + return try await MobileBackup2.backup(session, options: options) { event in + if case .progress(let value) = event { operation.report("\(Int(value))%", progress: value / 100) } + Task { @MainActor in self?.handle(event) } + } + } + } + progress = nil + activeOperation = nil + if let result { + lastBackup = result + app.statusMessage = "Backup of \(target.name) finished." + } + } + + private func handle(_ event: BackupEvent) { + switch event { + case .status(let message): log.append(message) + case .progress(let value): progress = value / 100 + case .encryption(let enabled): + encryptionEnabled = enabled + log.append(enabled ? "Backups from this device are encrypted." : "Backup encryption is off on this device.") + case .bytesReceived(let bytes): if log.last?.hasPrefix("Received") == true { log.removeLast() }; log.append("Received \(ByteFormatting.string(bytes))") + case .finished(let url): log.append("Saved to \(url.path)") + } + } +} + +// MARK: - Evidence + +@Observable +@MainActor +final class EvidenceModel { + var outputRoot = URL.documents.appendingPathComponent("iOS Developer Toolkit Cases") + var options = CollectionOptions() + var caseTitle = "" + var casePurpose = "" + var authorized = false + var activeCase: URL? + var activeIntake: CaseIntake? + var steps: [CollectionStep] = [] + var currentStep: String? + var secondsRemaining: Int? + var manifest: CollectionManifest? + var isCollecting = false + private var collector: EvidenceCollector? + + func createCase(app: AppModel, target: DeviceTarget) { + do { + let (folder, intake) = try CaseWorkflow.createGuidedCase(in: outputRoot, target: target, title: caseTitle, purpose: casePurpose, authorized: authorized) + activeCase = folder + activeIntake = intake + manifest = nil + steps = [] + app.statusMessage = "Created case “\(intake.title)”." + } catch { + app.present(error) + } + } + + func collect(app: AppModel, device: Device) async { + let target = device.target + let folder: URL + do { + if let activeCase { + try CaseWorkflow.validateForCollection(activeCase, target: target) + folder = activeCase + } else { + folder = try CaseWorkflow.createCaseFolder(in: outputRoot, target: target) + } + collector = try EvidenceCollector(device: device, caseFolder: folder, options: options, runner: app.runner) + } catch { + app.present(error) + return + } + guard let collector else { return } + steps = [] + manifest = nil + isCollecting = true + defer { + isCollecting = false + currentStep = nil + secondsRemaining = nil + self.collector = nil + } + let result = await app.run("Evidence collection", workspace: .evidence, target: target, transport: "Native services + CoreDevice", outputPaths: [folder.path]) { [weak self] _ in + await collector.run { event in + Task { @MainActor in + switch event { + case .stepStarted(let title): self?.currentStep = title + case .stepFinished(let step): self?.steps.append(step) + case .streaming(let remaining): self?.secondsRemaining = remaining + case .finalizing: self?.currentStep = "Writing manifest and hashes" + } + } + } + } + manifest = result + activeCase = nil + activeIntake = nil + if let result { + app.statusMessage = "Evidence case \(result.outcome == .complete ? "completed" : result.outcome == .partial ? "finished with coverage gaps" : "could not identify the device")." + } + lastCaseFolder = folder + } + + var lastCaseFolder: URL? + + func stop() { + guard let collector else { return } + Task { await collector.requestStop() } + } +} + +// MARK: - External tools + +@Observable +@MainActor +final class ExternalToolsModel { + // MVT + var mvtPath = MVTConnector.discover().first ?? "" + var mvt: ValidatedExecutable? + var mvtBackup: URL? + var mvtOutputParent = URL.documents + var mvtOutputName = "MVT Results \(ISO8601.compactUTC(Date()))" + var mvtIndicators: [URL] = [] + var mvtFast = false + var mvtHashes = false + var mvtAllowNetwork = false + var mvtConsent = false + var mvtNoVerdict = false + var mvtOutput = "" + var mvtRunning = false + + // UFADE + var ufadeCheckout: URL? + var ufadePython = "" + var ufadeWorkingDirectory = URL.documents + var ufade: UFADEConnector.Installation? + + // idb + var idbPath = IDBCompanionConnector.discover().first ?? "" + var idb: ValidatedExecutable? + var idbOutput = "" + + func validateMVT(app: AppModel) async { + let path = mvtPath + let runner = app.runner + mvt = await app.run("Validate MVT", workspace: .externalTools, target: nil, transport: "mvt-ios version", argv: [path]) { _ in + try await MVTConnector.validate(executablePath: path, runner: runner) + } + } + + func runMVT(app: AppModel) async { + guard let mvt, let backupSelection = mvtBackup else { return } + let backup: URL + do { backup = try MVTConnector.resolveBackup(backupSelection) } catch { app.present(error); return } + let request = MVTConnector.AnalysisRequest(executable: mvt, backup: backup, output: mvtOutputParent.appendingPathComponent(mvtOutputName), indicatorFiles: mvtIndicators, fast: mvtFast, hashes: mvtHashes, allowNetwork: mvtAllowNetwork) + let runner = app.runner + mvtOutput = "" + mvtRunning = true + defer { mvtRunning = false } + let result = await app.run("MVT backup analysis", workspace: .externalTools, target: nil, transport: "mvt-ios check-backup", argv: MVTConnector.arguments(for: request), outputPaths: [request.output.path]) { [weak self] _ in + let config = try SecureFileIO.makeTemporaryDirectory(prefix: "idt-mvt-config") + defer { try? FileManager.default.removeItem(at: config) } + let command = try MVTConnector.analysisRequest(request, configDirectory: config) + var final: CommandResult? + for try await event in runner.stream(command) { + switch event { + case .standardOutput(let data), .standardError(let data): + let text = String(decoding: data, as: UTF8.self) + Task { @MainActor in self?.mvtOutput += text } + case .finished(let result): final = result + } + } + guard let final, final.succeeded else { + throw ToolkitError(.commandFailed, message: "MVT stopped before finishing; the result folder is partial.", technicalDetail: final?.technicalSummary) + } + return final + } + if result != nil { app.statusMessage = "MVT finished. Review its output directly; no verdict is implied." } + } + + func validateUFADE(app: AppModel) async { + guard let checkout = ufadeCheckout else { return } + let python = ufadePython.isEmpty ? checkout.appendingPathComponent(".venv/bin/python").path : ufadePython + let runner = app.runner + ufade = await app.run("Validate UFADE", workspace: .externalTools, target: nil, transport: "UFADE Python checks", argv: [checkout.path]) { _ in + try await UFADEConnector.validate(checkout: checkout, python: python, runner: runner) + } + } + + func launchUFADE(app: AppModel) async { + guard let ufade else { return } + let directory = ufadeWorkingDirectory + _ = await app.run("Launch UFADE", workspace: .externalTools, target: nil, transport: "UFADE (separate process)") { _ in + try UFADEConnector.launch(ufade, workingDirectory: directory) + } + } + + func validateIDB(app: AppModel) async { + let path = idbPath + let runner = app.runner + idb = await app.run("Validate idb Companion", workspace: .externalTools, target: nil, transport: "idb_companion --version", argv: [path]) { _ in + try await IDBCompanionConnector.validate(executablePath: path, runner: runner) + } + } + + func probeIDB(app: AppModel) async { + guard let idb else { return } + let runner = app.runner + if let result = await app.run("idb Companion inventory", workspace: .externalTools, target: nil, transport: "idb_companion --list 1", { _ in try await runner.run(try IDBCompanionConnector.probeRequest(idb)) }) { + idbOutput = result.standardOutputText + result.standardErrorText + } + } +} diff --git a/App/iOSDeveloperToolkit/Model/LiveLogsModel.swift b/App/iOSDeveloperToolkit/Model/LiveLogsModel.swift new file mode 100644 index 0000000..8de7123 --- /dev/null +++ b/App/iOSDeveloperToolkit/Model/LiveLogsModel.swift @@ -0,0 +1,161 @@ +import DeviceKit +import Foundation +import Observation +import ToolkitCore +import ToolkitFeatures + +/// One live log stream: spooled completely to disk, with a bounded working view in memory. +@Observable +@MainActor +final class LogSession: Identifiable { + enum State: Equatable { + case starting, running, stopped(String), failed(String) + + var isActive: Bool { self == .starting || self == .running } + + var label: String { + switch self { + case .starting: return "Starting…" + case .running: return "Capturing" + case .stopped(let reason): return "Stopped — \(reason)" + case .failed(let message): return "Failed — \(message)" + } + } + } + + static let maximumLines = 50_000 + + let id = UUID() + let kind: LogStreamKind + let target: DeviceTarget + let capture: LogCapture + var state: State = .starting + var lines: [LogLine] = [] + var isPaused = false + var followTail = true + var filter = LogFilter() + var filterError: String? + var rawBytes: Int64 = 0 + var totalLines = 0 + var findings: [LiveLogFinding] = [] + var investigationReference = "" + var hasUnsavedData = true + fileprivate var task: Task? + private var pending: [LogLine] = [] + private var flushScheduled = false + + init(kind: LogStreamKind, target: DeviceTarget, capture: LogCapture) { + self.kind = kind + self.target = target + self.capture = capture + } + + var title: String { "\(kind.title) — \(target.name)" } + + /// Lines shown in the working view (filtered). Paused views keep their snapshot. + var visibleLines: [LogLine] { + guard !filter.isEmpty, let matcher = try? filter.matcher() else { return lines } + return lines.filter { matcher($0.rendered) } + } + + func receive(_ chunk: LogChunk) { + rawBytes += Int64(chunk.spoolBytes.count) + totalLines += chunk.lines.count + guard !isPaused else { return } + pending.append(contentsOf: chunk.lines) + // Batch UI updates so a busy stream does not redraw for every line. + if !flushScheduled { + flushScheduled = true + Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(150)) + self?.flush() + } + } + } + + private func flush() { + flushScheduled = false + lines.append(contentsOf: pending) + pending.removeAll(keepingCapacity: true) + if lines.count > Self.maximumLines { + lines.removeFirst(lines.count - Self.maximumLines) + } + } + + func validateFilter() { + do { + _ = try filter.matcher() + filterError = nil + } catch { + filterError = (error as? ToolkitError)?.message ?? error.localizedDescription + } + } + + func stop(reason: String = "stopped by you") { + task?.cancel() + task = nil + if state.isActive { state = .stopped(reason) } + let capture = self.capture + Task { try? await capture.finish(reason: reason) } + } +} + +@Observable +@MainActor +final class LiveLogsModel { + var sessions: [LogSession] = [] + var selectedSessionID: UUID? + + var selectedSession: LogSession? { + sessions.first { $0.id == selectedSessionID } ?? sessions.last + } + + func start(_ kind: LogStreamKind, target: DeviceTarget, app: AppModel) { + let capture: LogCapture + do { + capture = try LogCapture(kind: kind, target: target) + } catch { + app.present(error) + return + } + let session = LogSession(kind: kind, target: target, capture: capture) + sessions.append(session) + selectedSessionID = session.id + let runner = app.runner + let started = Date() + session.task = Task { [weak session] in + do { + let stream = try await LiveLogSource.open(kind, target: target, runner: runner) + session?.state = .running + for try await chunk in stream { + try await capture.append(chunk) + session?.receive(chunk) + } + session?.stop(reason: "the device ended the stream") + app.record(title: kind.title, workspace: .liveLogs, target: target, transport: kind.serviceDescription, argv: [], started: started, finished: Date(), outcome: .succeeded, error: nil, outputPaths: [capture.spoolURL.path]) + } catch is CancellationError { + app.record(title: kind.title, workspace: .liveLogs, target: target, transport: kind.serviceDescription, argv: [], started: started, finished: Date(), outcome: .cancelled, error: nil, outputPaths: [capture.spoolURL.path]) + } catch { + let message = (error as? ToolkitError)?.message ?? error.localizedDescription + if Task.isCancelled { + session?.stop() + } else { + session?.state = .failed(message) + try? await capture.finish(reason: "failed: \(message)") + app.present(error) + } + app.record(title: kind.title, workspace: .liveLogs, target: target, transport: kind.serviceDescription, argv: [], started: started, finished: Date(), outcome: OperationOutcome.from(error), error: message, outputPaths: [capture.spoolURL.path]) + } + } + } + + func close(_ session: LogSession) { + session.stop() + sessions.removeAll { $0.id == session.id } + if selectedSessionID == session.id { selectedSessionID = sessions.last?.id } + } + + func stopAll() { + for session in sessions where session.state.isActive { session.stop(reason: "the app quit") } + } +} diff --git a/App/iOSDeveloperToolkit/Model/LocationModel.swift b/App/iOSDeveloperToolkit/Model/LocationModel.swift new file mode 100644 index 0000000..b14b8a9 --- /dev/null +++ b/App/iOSDeveloperToolkit/Model/LocationModel.swift @@ -0,0 +1,216 @@ +import DeviceKit +import Foundation +import Observation +import ToolkitCore +import ToolkitFeatures + +@Observable +@MainActor +final class LocationModel { + var latitudeText = "37.3349" + var longitudeText = "-122.0090" + var linkText = "" + var nudgeMetres: Double = 100 + var savedLocations: [SavedLocation] = [] + var newPlaceName = "" + + var waypointsText = "37.3349,-122.0090\n37.3318,-122.0312" + var travelPreset: TravelPreset = .walk + var customSpeedKmh: Double = 25 + var routeIntervalSeconds = 2 + var routeTraversals = 1 + var generatedRoute: GeneratedRoute? + + var gpxInspection: GPXInspection? + var ignoreRecordedTiming = false + var fixedIntervalSeconds: Double = 1 + var jitterMilliseconds = 0 + + var playbackProgress: (index: Int, total: Int)? + var playbackTarget: DeviceTarget? + /// The device whose location this app last changed, kept for Clear even if the selection moves. + var lastSimulatedTarget: DeviceTarget? + private var playbackTask: Task? + + let evidenceDirectory = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Documents/iOS Developer Toolkit Location Logs") + + init() { + // Screenshot mode shows sample places so personal saved places never appear in docs. + savedLocations = ScreenshotHarness.isCapturing + ? [SavedLocation(name: "Apple Park", coordinates: Coordinates(latitude: 37.3349, longitude: -122.0090)), + SavedLocation(name: "London Eye", coordinates: Coordinates(latitude: 51.5033, longitude: -0.1196))] + : (try? LocationLab.loadSavedLocations()) ?? [] + } + + var coordinates: Coordinates? { + try? LocationLab.validate(latitude: latitudeText, longitude: longitudeText) + } + + var speedKmh: Double { travelPreset.speedKmh ?? customSpeedKmh } + + /// Adds the coordinate in the latitude and longitude fields to the end of the route. + func addCurrentWaypoint(app: AppModel) { + do { + let current = try LocationLab.validate(latitude: latitudeText, longitude: longitudeText) + waypointsText = LocationLab.appendingWaypoint(current, to: waypointsText) + generatedRoute = nil + } catch { + app.present(error) + } + } + + func show(_ coordinates: Coordinates) { + latitudeText = String(format: "%.6f", coordinates.latitude) + longitudeText = String(format: "%.6f", coordinates.longitude) + } + + func importLink(app: AppModel) { + do { + show(try LocationLab.parseLocationInput(linkText)) + linkText = "" + } catch { + app.present(error) + } + } + + func nudge(_ direction: CompassDirection, app: AppModel) { + do { + let origin = try LocationLab.validate(latitude: latitudeText, longitude: longitudeText) + show(try Geodesy.move(origin, bearing: direction.bearing, distance: nudgeMetres)) + } catch { + app.present(error) + } + } + + func savePlace(app: AppModel) { + do { + let coordinates = try LocationLab.validate(latitude: latitudeText, longitude: longitudeText) + savedLocations = try LocationLab.adding(newPlaceName, coordinates, to: savedLocations) + try LocationLab.storeSavedLocations(savedLocations) + newPlaceName = "" + } catch { + app.present(error) + } + } + + func removePlace(_ place: SavedLocation, app: AppModel) { + savedLocations.removeAll { $0 == place } + do { try LocationLab.storeSavedLocations(savedLocations) } catch { app.present(error) } + } + + private func log(_ event: String, status: String, target: DeviceTarget, mechanism: String, coordinates: Coordinates? = nil, gpx: GPXInspection? = nil, detail: String) { + try? LocationLab.append(LocationEvidenceEvent(event: event, status: status, deviceIdentifier: target.udid, deviceName: target.name, deviceKind: target.kind.rawValue, osVersion: target.osVersion, mechanism: mechanism, latitude: coordinates?.latitude, longitude: coordinates?.longitude, gpxPath: gpx?.url.path, gpxSHA256: gpx?.sha256, detail: detail), to: evidenceDirectory) + } + + // MARK: Device operations + + func setLocation(app: AppModel, target: DeviceTarget) async { + let coordinates: Coordinates + do { coordinates = try LocationLab.validate(latitude: latitudeText, longitude: longitudeText) } catch { app.present(error); return } + let controller = app.executor.location + let mechanism = controller.mechanism(for: target).rawValue + let succeeded = await app.run("Set simulated location", workspace: .location, target: target, transport: mechanism) { _ in + try await controller.set(latitude: coordinates.latitude, longitude: coordinates.longitude, on: target) + return true + } + log("set", status: succeeded == true ? "succeeded" : "failed", target: target, mechanism: mechanism, coordinates: coordinates, detail: "") + if succeeded == true { + lastSimulatedTarget = target + app.statusMessage = "\(target.name) now reports \(coordinates.formatted)." + } + } + + func clear(app: AppModel, target: DeviceTarget) async { + stopPlayback() + let controller = app.executor.location + let mechanism = controller.mechanism(for: target).rawValue + let succeeded = await app.run("Clear simulated location", workspace: .location, target: target, transport: mechanism) { _ in + try await controller.clear(on: target) + return true + } + log("clear", status: succeeded == true ? "succeeded" : "failed", target: target, mechanism: mechanism, detail: "") + if succeeded == true { + if lastSimulatedTarget == target { lastSimulatedTarget = nil } + app.statusMessage = "\(target.name) uses its real location again." + } + } + + // MARK: Routes + + func buildRoute(app: AppModel) { + do { + generatedRoute = try LocationLab.buildRoute(waypoints: try LocationLab.parseRouteWaypoints(waypointsText), speedKmh: speedKmh, intervalSeconds: routeIntervalSeconds, traversalCount: routeTraversals, startTime: Date()) + } catch { + generatedRoute = nil + app.present(error) + } + } + + /// Starts constant-speed movement handled by the device service (iOS 17+ and simulators). + func startNativeRoute(app: AppModel, target: DeviceTarget) async { + let waypoints: [Coordinates] + do { waypoints = try LocationLab.parseRouteWaypoints(waypointsText) } catch { app.present(error); return } + let controller = app.executor.location + let speed = speedKmh / 3.6 + let interval = Double(routeIntervalSeconds) + let mechanism = controller.mechanism(for: target).rawValue + let succeeded = await app.run("Start simulated route", workspace: .location, target: target, transport: mechanism) { _ in + try await controller.startRoute(waypoints.map { ($0.latitude, $0.longitude) }, speedMetresPerSecond: speed, intervalSeconds: interval, on: target) + return true + } + log("route", status: succeeded == true ? "succeeded" : "failed", target: target, mechanism: mechanism, coordinates: waypoints.first, detail: "\(waypoints.count) waypoints at \(Int(speedKmh)) km/h") + if succeeded == true { + lastSimulatedTarget = target + app.statusMessage = "\(target.name) is moving along the route." + } + } + + // MARK: GPX + + func inspectGPX(_ url: URL, app: AppModel) { + let accessed = url.startAccessingSecurityScopedResource() + defer { if accessed { url.stopAccessingSecurityScopedResource() } } + do { + gpxInspection = try LocationLab.inspectGPX(at: url) + } catch { + gpxInspection = nil + app.present(error) + } + } + + var isPlaying: Bool { playbackTask != nil } + + func startPlayback(app: AppModel, target: DeviceTarget) { + guard let inspection = gpxInspection, playbackTask == nil else { return } + let timing: PlaybackTiming = ignoreRecordedTiming ? .fixedInterval(seconds: fixedIntervalSeconds) : .recorded(jitterMilliseconds: jitterMilliseconds) + let playback: GPXPlayback + do { + playback = try GPXPlayback(points: inspection.points, timing: timing, target: target, controller: app.executor.location) + } catch { + app.present(error) + return + } + playbackTarget = target + lastSimulatedTarget = target + let mechanism = app.executor.location.mechanism(for: target).rawValue + log("gpx-play", status: "started", target: target, mechanism: mechanism, coordinates: inspection.firstPoint, gpx: inspection, detail: "\(inspection.trackPointCount) points") + playbackTask = Task { [weak self] in + _ = await app.run("GPX playback", workspace: .location, target: target, transport: mechanism, outputPaths: [inspection.url.path]) { operation in + try await playback.run { index, total in + operation.report("Point \(index) of \(total)", progress: Double(index) / Double(total)) + Task { @MainActor in self?.playbackProgress = (index, total) } + } + return true + } + self?.log("gpx-play", status: "finished", target: target, mechanism: mechanism, gpx: inspection, detail: "") + self?.playbackTask = nil + self?.playbackProgress = nil + } + } + + func stopPlayback() { + playbackTask?.cancel() + playbackTask = nil + playbackProgress = nil + } +} diff --git a/App/iOSDeveloperToolkit/Model/ScreenshotHarness.swift b/App/iOSDeveloperToolkit/Model/ScreenshotHarness.swift new file mode 100644 index 0000000..36101d6 --- /dev/null +++ b/App/iOSDeveloperToolkit/Model/ScreenshotHarness.swift @@ -0,0 +1,214 @@ +import AppKit +import DeviceKit +import SwiftUI +import ToolkitFeatures + +/// Renders the app's own window for documentation and GUI verification: +/// +/// "iOS Developer Toolkit" -capture-screenshots +/// [-demo-mode] [-ui-testing] [-window-size WxH] [-only overview,apps] +/// [-populate-demo YES] [-select-booted-simulator YES] [-start-simulator-log YES] +/// [-scroll-fraction 0.0–1.0] [-show-sheet reconnect|shortcuts|advanced] [-select-physical-device YES] +/// [-load-device-data YES] +/// +/// Every flag takes a value: AppKit reads arguments as `-key value` pairs, and a lone flag would +/// swallow the next argument, leaving a stray path that macOS treats as a file to open (which +/// stops SwiftUI from creating the main window). +/// +/// It visits the requested workspaces, writes one PNG each plus window-geometry.txt, and quits +/// (always within two minutes). Rendering uses AppKit's view caching, so no Screen Recording +/// permission is involved. Vibrancy cannot be cached, so the sidebar uses a plain list style +/// while capturing. +@MainActor +enum ScreenshotHarness { + static var isCapturing: Bool { + ProcessInfo.processInfo.arguments.contains("-capture-screenshots") + } + + static func runIfRequested(model: AppModel) { + let arguments = ProcessInfo.processInfo.arguments + guard let index = arguments.firstIndex(of: "-capture-screenshots"), arguments.indices.contains(index + 1) else { return } + let folder = URL(fileURLWithPath: arguments[index + 1], isDirectory: true) + func value(_ flag: String) -> String? { + arguments.firstIndex(of: flag).flatMap { arguments.indices.contains($0 + 1) ? arguments[$0 + 1] : nil } + } + var size: CGSize? + if let text = value("-window-size") { + let parts = text.split(separator: "x").compactMap { Double($0) } + if parts.count == 2 { size = CGSize(width: parts[0], height: parts[1]) } + } + let only = value("-only").map { Set($0.split(separator: ",").map(String.init)) } + let workspaces = Workspace.allCases.filter { only?.contains($0.rawValue) ?? true } + + // Hard stop so an unexpected state can never leave the harness running. + Task { @MainActor in + try? await Task.sleep(for: .seconds(120)) + try? "Timed out".write(to: folder.appendingPathComponent("TIMEOUT"), atomically: true, encoding: .utf8) + NSApp.terminate(nil) + // A presented sheet can block termination; never leave the harness running. + try? await Task.sleep(for: .seconds(5)) + exit(3) + } + + Task { @MainActor in + try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try? await Task.sleep(for: .seconds(2)) + guard let window = NSApp.windows.first(where: { $0.isVisible && $0.contentView != nil && $0.frame.width > 300 }) else { + try? "No visible window".write(to: folder.appendingPathComponent("window-geometry.txt"), atomically: true, encoding: .utf8) + NSApp.terminate(nil) + return + } + if let size { + window.setContentSize(size) + try? await Task.sleep(for: .milliseconds(500)) + } + if value("-populate-demo") == "YES" { + await populateDemo(model) + } + if value("-select-booted-simulator") == "YES" { + await selectBootedSimulator(model, startLog: value("-start-simulator-log") == "YES") + } + if value("-select-physical-device") == "YES" { + await selectPhysicalDevice(model) + } + // Read-only: runs the Readiness Check and loads the app list for the selected device. + if value("-load-device-data") == "YES", let device = model.selectedDevice, device.kind != .demo { + await model.runReadiness(for: device) + await model.apps.refresh(app: model, device: device) + } + var report = [ + "initial frame: \(window.frame)", + "screen: \(window.screen?.visibleFrame ?? .zero)", + "minSize: \(window.minSize)", + "selected: \(model.selectedDevice.map { "\($0.name) (\($0.kind.rawValue))" } ?? "none")", + ] + for workspace in workspaces { + model.workspace = workspace + try? await Task.sleep(for: .milliseconds(900)) + if let fraction = value("-scroll-fraction").flatMap(Double.init) { + scrollContent(of: window, to: fraction) + try? await Task.sleep(for: .milliseconds(400)) + } + render(window, to: folder.appendingPathComponent("\(workspace.rawValue).png")) + let sidebarWidth = sidebar(in: window)?.frame.width ?? 0 + let contentHeight = window.contentView?.frame.height ?? 0 + let layoutHeight = window.contentView?.subviews.first?.frame.height ?? 0 + let overflow = layoutHeight > contentHeight + 1 ? " OVERFLOW(\(layoutHeight) > \(contentHeight))" : "" + report.append("\(workspace.rawValue): window \(window.frame.size) sidebar \(sidebarWidth)\(sidebarWidth < 190 ? " SQUEEZED" : "")\(overflow)") + if value("-dump-views") == "YES" { report.append(dump(window.contentView, depth: 0)) } + } + if let sheetName = value("-show-sheet"), let presented = sheetBinding(sheetName, model) { + presented.wrappedValue = true + try? await Task.sleep(for: .milliseconds(900)) + if let sheet = window.attachedSheet { + render(sheet, to: folder.appendingPathComponent("sheet-\(sheetName).png")) + report.append("sheet-\(sheetName): \(sheet.frame.size)") + } else { + report.append("sheet-\(sheetName): not shown") + } + presented.wrappedValue = false + try? await Task.sleep(for: .milliseconds(500)) + } + try? report.joined(separator: "\n").write(to: folder.appendingPathComponent("window-geometry.txt"), atomically: true, encoding: .utf8) + model.logs.stopAll() + try? await Task.sleep(for: .milliseconds(300)) + NSApp.terminate(nil) + try? await Task.sleep(for: .seconds(5)) + exit(0) + } + } + + /// Demo content for documentation screenshots. Everything shown is labelled demo data. + static func populateDemo(_ model: AppModel) async { + guard model.demoMode else { return } + let demo = DemoMode.device + model.selectedDeviceID = demo.id + await model.apps.refresh(app: model, device: demo) + model.readinessResults[demo.id] = CapabilityRow.rows(for: .physical).map { row in + switch row { + case .developerServices: return row.result(.attention, "Personalization required: a compatible image is on this Mac (demo data).") + case .lockState: return row.result(.attention, "Locked — unlock the device to continue (demo data).") + default: return row.result(.ready, "Ready (demo data).") + } + } + } + + /// The sheets the harness can render. + static func sheetBinding(_ name: String, _ model: AppModel) -> Binding? { + switch name { + case "reconnect": return Binding(get: { model.isReconnectGuidePresented }, set: { model.isReconnectGuidePresented = $0 }) + case "shortcuts": return Binding(get: { model.isShortcutReferencePresented }, set: { model.isShortcutReferencePresented = $0 }) + case "advanced": return Binding(get: { model.isAdvancedModePresented }, set: { model.isAdvancedModePresented = $0 }) + default: return nil + } + } + + /// Selects the first physical device connected by USB (waits up to 30 seconds for discovery). + static func selectPhysicalDevice(_ model: AppModel) async { + for _ in 0..<30 { + if let device = model.physicalDevices.first(where: { $0.kind == .physical && $0.transports.contains(.usb) }) { + model.selectedDeviceID = device.id + try? await Task.sleep(for: .seconds(3)) + return + } + try? await Task.sleep(for: .seconds(1)) + } + } + + /// Selects the first running simulator and optionally starts a real simulator log stream. + static func selectBootedSimulator(_ model: AppModel, startLog: Bool) async { + for _ in 0..<30 { + if let simulator = model.simulatorDevices.first(where: { $0.simulatorState == .booted }) { + model.selectedDeviceID = simulator.id + if startLog { + model.logs.start(.simulator, target: simulator.target, app: model) + try? await Task.sleep(for: .seconds(6)) + } + return + } + try? await Task.sleep(for: .seconds(1)) + } + } + + /// Scrolls the workspace page (the widest scroll view that is not the sidebar) to a fraction of + /// its height, so cards below the fold can be rendered. + static func scrollContent(of window: NSWindow, to fraction: Double) { + let sidebarScroll = sidebar(in: window)?.enclosingScrollView + let candidates = scrollViews(in: window.contentView).filter { $0 !== sidebarScroll } + guard let scrollView = candidates.max(by: { $0.frame.width * $0.frame.height < $1.frame.width * $1.frame.height }), + let document = scrollView.documentView else { return } + let range = max(0, document.frame.height - scrollView.contentView.bounds.height) + let offset = range * min(max(fraction, 0), 1) + scrollView.contentView.scroll(to: NSPoint(x: 0, y: document.isFlipped ? offset : range - offset)) + scrollView.reflectScrolledClipView(scrollView.contentView) + } + + static func scrollViews(in view: NSView?) -> [NSScrollView] { + guard let view else { return [] } + return ((view as? NSScrollView).map { [$0] } ?? []) + view.subviews.flatMap(scrollViews(in:)) + } + + static func dump(_ view: NSView?, depth: Int) -> String { + guard let view, depth < 40 else { return "" } + let line = String(repeating: " ", count: depth) + "\(type(of: view)) \(view.frame)" + return ([line] + view.subviews.map { dump($0, depth: depth + 1) }.filter { !$0.isEmpty }).joined(separator: "\n") + } + + static func sidebar(in window: NSWindow) -> NSTableView? { + tables(in: window.contentView?.superview ?? window.contentView).first { $0.numberOfRows == Workspace.allCases.count + Workspace.Group.allCases.count } + } + + static func tables(in view: NSView?) -> [NSTableView] { + guard let view else { return [] } + return (view as? NSTableView).map { [$0] } ?? view.subviews.flatMap(tables(in:)) + } + + static func render(_ window: NSWindow, to url: URL) { + guard let view = window.contentView?.superview ?? window.contentView else { return } + guard let representation = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return } + view.cacheDisplay(in: view.bounds, to: representation) + if let data = representation.representation(using: .png, properties: [:]) { + try? data.write(to: url) + } + } +} diff --git a/App/iOSDeveloperToolkit/Views/ActionsViews.swift b/App/iOSDeveloperToolkit/Views/ActionsViews.swift new file mode 100644 index 0000000..f932ae0 --- /dev/null +++ b/App/iOSDeveloperToolkit/Views/ActionsViews.swift @@ -0,0 +1,358 @@ +import DeviceKit +import SwiftUI +import ToolkitCore +import ToolkitFeatures + +struct ActionsView: View { + @Environment(AppModel.self) private var model + @State private var search = "" + + private var actions: [ActionDescriptor] { + ActionCatalog.all.filter { action in + (model.actionsCategory == "All" || action.category == model.actionsCategory) + && (search.isEmpty || action.title.localizedCaseInsensitiveContains(search) || action.summary.localizedCaseInsensitiveContains(search) || (action.replacesLegacy ?? "").localizedCaseInsensitiveContains(search)) + } + } + + var body: some View { + @Bindable var model = model + HStack(spacing: 0) { + VStack(spacing: 8) { + TextField("Search actions", text: $search) + .textFieldStyle(.roundedBorder) + Picker("Category", selection: $model.actionsCategory) { + Text("All categories").tag("All") + ForEach(ActionCatalog.categories, id: \.self) { Text($0).tag($0) } + } + .labelsHidden() + List(selection: $model.selectedActionID) { + ForEach(ActionCatalog.categories.filter { name in actions.contains { $0.category == name } }, id: \.self) { name in + Section(name) { + ForEach(actions.filter { $0.category == name }) { action in + ActionListRow(action: action, device: model.selectedDevice) + .tag(action.id) + } + } + } + } + .accessibilityIdentifier("actions-list") + Button { + model.openAdvancedMode() + } label: { + Label("Advanced Mode (devicectl)…", systemImage: "terminal") + } + .frame(maxWidth: .infinity) + } + .padding(12) + .frame(width: 270) + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 14) { + TargetHeader() + if let id = model.selectedActionID, let action = ActionCatalog.descriptor(id) { + ActionDetailView(action: action) + .id(action.id) + } else { + ContentUnavailableView("Choose an Action", systemImage: "bolt", description: Text("Actions are grouped by what they do. Each one shows its risk, what it needs, and exactly how it runs before anything happens.")) + .frame(maxWidth: .infinity, minHeight: 300) + } + } + .padding(20) + .frame(maxWidth: 900, alignment: .leading) + } + .frame(maxWidth: .infinity) + } + + } +} + +struct ActionListRow: View { + let action: ActionDescriptor + let device: Device? + + var body: some View { + let available = device.map { action.supports($0.kind) && $0.kind != .demo } ?? false + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(action.title) + Text(action.summary).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + Image(systemName: action.risk.symbolName) + .foregroundStyle(action.risk == .readOnly ? Color.secondary : (action.risk == .highImpact ? Color.red : Color.orange)) + .help(action.risk.label) + } + .opacity(available ? 1 : 0.5) + // One element per row: otherwise the identifier is copied onto the title, summary, and + // risk icon, and VoiceOver reads them as three separate items. + .accessibilityElement(children: .ignore) + .accessibilityLabel(action.title) + .accessibilityValue(available ? action.risk.label : "\(action.risk.label), not available for this device") + .accessibilityHint(action.summary) + .accessibilityIdentifier("action-\(action.id)") + } +} + +struct ActionDetailView: View { + @Environment(AppModel.self) private var model + let action: ActionDescriptor + @State private var values: [String: String] = [:] + @State private var result: ActionResult? + @State private var confirmation: PendingConfirmation? + @State private var isRunning = false + + var body: some View { + let device = model.selectedDevice + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .firstTextBaseline) { + Text(action.title).font(.title2.bold()) + Spacer() + RiskBadge(risk: action.risk) + } + Text(action.summary).font(.title3) + if !action.notes.isEmpty { + Text(action.notes).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + Card(title: "How it runs", systemImage: "gearshape.2") { + InfoRow("Mechanism", action.mechanism, monospaced: true) + InfoRow("Works with", action.kinds.map(\.label).sorted().joined(separator: ", ")) + if let legacy = action.replacesLegacy { + InfoRow("Replaces", legacy, explanation: "The pymobiledevice3 command this action replaced in version 0.3.", monospaced: true) + } + if !action.requirements.isEmpty { + InfoRow("Needs", action.requirements.map(\.label).joined(separator: " · ")) + } + ReadinessStatusView(requirements: action.requirements, device: device, subject: "this action") + } + if !action.parameters.isEmpty { + Card(title: "Details", systemImage: "slider.horizontal.3") { + ForEach(action.parameters) { parameter in + ParameterField(parameter: parameter, value: Binding(get: { values[parameter.id] ?? parameter.defaultValue }, set: { values[parameter.id] = $0 })) + } + } + } + HStack { + Button { + request(device) + } label: { + Label(isRunning ? "Running…" : "Run", systemImage: "play.fill") + } + .keyboardShortcut(.defaultAction) + .disabled(isRunning || !canRun(device)) + .accessibilityIdentifier("run-action") + if isRunning { ProgressView().controlSize(.small) } + if let reason = blockReason(device) { + Text(reason).font(.callout).foregroundStyle(.secondary) + } + } + if let result { + ActionResultView(result: result) + } + } + .sheet(item: $confirmation) { pending in + ConfirmationSheet(title: pending.title, detail: pending.detail, requirement: pending.requirement, target: pending.target, commandPreview: pending.commandPreview, onConfirm: pending.action) + } + } + + private func canRun(_ device: Device?) -> Bool { blockReason(device) == nil } + + private func blockReason(_ device: Device?) -> String? { + guard let device else { + return action.requirements.contains(where: { $0 != .xcode }) ? "Select a device first." : nil + } + if device.kind == .demo { return "Actions are disabled in Demo Mode." } + if !action.supports(device.kind) { return "Not available for \(device.kind.label.lowercased())s." } + return nil + } + + private func request(_ device: Device?) { + let target = device?.target + let requirement = ConfirmationRequirement.make(for: action.risk, target: target) + let resolvedValues = action.parameters.reduce(into: [String: String]()) { $0[$1.id] = values[$1.id] ?? $1.defaultValue } + do { + _ = try ActionCatalog.validate(action, values: resolvedValues) + } catch { + model.present(error) + return + } + if requirement.risk == .readOnly { + run(target, resolvedValues) + } else { + confirmation = PendingConfirmation(title: action.title, detail: "\(action.risk.explanation)\n\n\(action.summary)", requirement: requirement, target: target, commandPreview: action.mechanism) { + run(target, resolvedValues) + } + } + } + + private func run(_ target: DeviceTarget?, _ values: [String: String]) { + let executor = model.executor + let action = self.action + isRunning = true + Task { + let outcome = await model.run(action.title, workspace: .actions, target: target, transport: action.mechanism, outputPaths: action.parameters.filter { $0.kind == .outputFile || $0.kind == .outputDirectory }.compactMap { values[$0.id] }) { _ in + try await executor.execute(action, target: target, values: values) + } + isRunning = false + if let outcome { result = outcome } + } + } +} + +struct ParameterField: View { + let parameter: ActionParameter + @Binding var value: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(parameter.label).frame(width: 150, alignment: .leading) + switch parameter.kind { + case .template: + Picker(parameter.label, selection: $value) { + ForEach(parameter.choices, id: \.self) { Text($0).tag($0) } + } + .labelsHidden() + case .outputFile: + TextField(parameter.label, text: $value).textFieldStyle(.roundedBorder) + Button("Choose…") { + let current = URL(fileURLWithPath: value) + if let url = FilePanels.save(title: parameter.label, suggestedName: current.lastPathComponent, allowedExtension: parameter.fileExtension ?? "", directory: current.deletingLastPathComponent()) { + value = url.path + } + } + case .outputDirectory: + TextField(parameter.label, text: $value).textFieldStyle(.roundedBorder) + Button("Choose…") { + if let url = FilePanels.chooseFolder(title: parameter.label, directory: URL(fileURLWithPath: value)) { value = url.path } + } + default: + TextField(parameter.label, text: $value) + .textFieldStyle(.roundedBorder) + .font(parameter.kind == .text ? .body : .body.monospaced()) + } + } + if !parameter.help.isEmpty { + Text(parameter.help).font(.caption).foregroundStyle(.secondary).padding(.leading, 150) + } + } + } +} + +struct ActionResultView: View { + let result: ActionResult + + var body: some View { + Card(title: "Result", systemImage: "checkmark.circle") { + Text(result.summary).font(.title3) + if !result.details.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(result.details.prefix(400).enumerated()), id: \.offset) { _, detail in + InfoRow(detail.0, detail.1) + } + if result.details.count > 400 { + Text("\(result.details.count - 400) more rows are in the raw output.").font(.caption).foregroundStyle(.secondary) + } + } + } + if !result.outputFiles.isEmpty { + HStack { + ForEach(result.outputFiles, id: \.self) { url in + Button("Show \(url.lastPathComponent) in Finder") { FilePanels.reveal(url) } + } + } + } + if !result.raw.isEmpty { + DisclosureGroup("Raw output") { + RawOutputView(text: result.raw) + Button("Copy Raw Output") { Pasteboard.copy(result.raw) }.controlSize(.small) + } + } + Text("Finished \(result.finishedAt.formatted(date: .omitted, time: .standard)) · \(String(format: "%.1f", result.finishedAt.timeIntervalSince(result.startedAt))) s · \(result.target?.shortLabel ?? "This Mac")") + .font(.caption) + .foregroundStyle(.tertiary) + } + } +} + +/// Free-form devicectl arguments, always bound to the selected device and classified by risk. +struct AdvancedModeView: View { + @Environment(AppModel.self) private var model + @Environment(\.dismiss) private var dismiss + @State private var text = "" + @State private var output = "" + @State private var confirmation: PendingConfirmation? + @State private var isRunning = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Advanced Mode").font(.title2.bold()) + Text("Runs `xcrun devicectl` with the arguments you type — without a shell, so pipes, redirects, and substitutions are plain text. Device commands always target the selected device; the toolkit adds `--device` for you and refuses any other device.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack { + Text("devicectl").font(.body.monospaced()).foregroundStyle(.secondary) + TextField("device info apps", text: $text) + .textFieldStyle(.roundedBorder) + .font(.body.monospaced()) + .onSubmit(prepare) + Button("Run", action: prepare).disabled(isRunning || text.isEmpty) + } + if let prepared = try? ActionExecutor.prepareAdvanced(text, target: model.selectedTarget) { + HStack { + RiskBadge(risk: prepared.risk) + Text("devicectl " + prepared.arguments.map(ShellQuoting.quote).joined(separator: " ")) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + RawOutputView(text: output, maxHeight: .infinity) + .frame(minHeight: 240) + HStack { + if isRunning { ProgressView().controlSize(.small) } + Spacer() + Button("Close") { dismiss() }.keyboardShortcut(.cancelAction) + } + } + .padding(20) + .frame(width: 720, height: 520) + .onAppear { text = model.advancedModeText } + .onChange(of: text) { _, value in model.advancedModeText = value } + .sheet(item: $confirmation) { pending in + ConfirmationSheet(title: pending.title, detail: pending.detail, requirement: pending.requirement, target: pending.target, commandPreview: pending.commandPreview, onConfirm: pending.action) + } + } + + private func prepare() { + do { + let prepared = try ActionExecutor.prepareAdvanced(text, target: model.selectedTarget) + let target = model.selectedTarget + let preview = "xcrun devicectl " + prepared.arguments.map(ShellQuoting.quote).joined(separator: " ") + if prepared.risk == .readOnly { + execute(prepared.arguments, target) + } else { + confirmation = PendingConfirmation(title: "Run this devicectl command?", detail: prepared.risk.explanation, requirement: .make(for: prepared.risk, target: target), target: target, commandPreview: preview) { + execute(prepared.arguments, target) + } + } + } catch { + model.present(error) + } + } + + private func execute(_ arguments: [String], _ target: DeviceTarget?) { + let executor = model.executor + isRunning = true + Task { + let result = await model.run("devicectl (Advanced Mode)", workspace: .actions, target: target, transport: "xcrun devicectl", argv: arguments) { _ in + try await executor.runAdvanced(arguments: arguments) + } + isRunning = false + if let result { + output = result.standardOutputText + (result.standardErrorText.isEmpty ? "" : "\n" + result.standardErrorText) + "\n[exit status \(result.exitCode.map(String.init) ?? "signal")]" + } + } + } +} diff --git a/App/iOSDeveloperToolkit/Views/AppsViews.swift b/App/iOSDeveloperToolkit/Views/AppsViews.swift new file mode 100644 index 0000000..06597e8 --- /dev/null +++ b/App/iOSDeveloperToolkit/Views/AppsViews.swift @@ -0,0 +1,193 @@ +import DeviceKit +import SwiftUI +import ToolkitCore +import ToolkitFeatures + +struct AppsView: View { + @Environment(AppModel.self) private var model + @State private var confirmation: PendingConfirmation? + + var body: some View { + @Bindable var apps = model.apps + VStack(alignment: .leading, spacing: 12) { + TargetHeader() + if let device = model.selectedDevice { + HStack(spacing: 12) { + Button { + Task { await apps.refresh(app: model, device: device) } + } label: { + Label(apps.loadedFor == device.target ? "Refresh" : "Load Apps", systemImage: "arrow.clockwise") + } + .disabled(apps.isLoading) + .accessibilityIdentifier("load-apps") + if apps.isLoading { ProgressView().controlSize(.small) } + Toggle("Include built-in apps", isOn: $apps.includeSystemApps) + if device.kind == .physical && device.supportsLockdownServices { + Toggle("Calculate sizes", isOn: $apps.calculateSizes) + } + Spacer() + TextField("Search name or bundle ID", text: $apps.search) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 260) + } + if apps.loadedFor != nil && apps.loadedFor != device.target { + Label("This list is from another device. Refresh to load \(device.name).", systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } + Table(apps.visibleRows, selection: $apps.selection, sortOrder: $apps.sortOrder) { + TableColumn("Name", value: \.name) + TableColumn("Bundle ID", value: \.bundleIdentifier) { Text($0.bundleIdentifier).font(.callout.monospaced()) } + TableColumn("Version", value: \.version) { Text($0.build.isEmpty ? $0.version : "\($0.version) (\($0.build))") } + TableColumn("Type", value: \.kind) + TableColumn("Size") { Text($0.sizeText).monospacedDigit() } + .width(min: 70, ideal: 90) + } + .contextMenu(forSelectionType: AppRow.ID.self) { ids in + if let id = ids.first, let row = apps.rows.first(where: { $0.id == id }) { + Button("Copy Bundle ID") { Pasteboard.copy(row.bundleIdentifier) } + Button("Launch") { launch(row, device) }.disabled(device.kind == .demo) + Divider() + Button("Remove…", role: .destructive) { confirmRemove(row, device) }.disabled(!row.isRemovable || device.kind == .demo) + } + } + .overlay { + if apps.rows.isEmpty && !apps.isLoading { + ContentUnavailableView("No Apps Loaded", systemImage: "app.dashed", description: Text("Choose Load Apps to list what is installed on \(device.name). An empty list is not proof that no apps exist.")) + } + } + HStack { + Text(apps.rows.isEmpty ? "" : "\(apps.visibleRows.count) of \(apps.rows.count) apps · source: \(apps.source)") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if let id = apps.selection.first, let row = apps.rows.first(where: { $0.id == id }) { + Button("Copy Bundle ID") { Pasteboard.copy(row.bundleIdentifier) } + Button("Launch") { launch(row, device) }.disabled(device.kind == .demo) + Button("Remove…", role: .destructive) { confirmRemove(row, device) } + .disabled(!row.isRemovable || device.kind == .demo) + } + } + } + } + .padding(20) + .sheet(item: $confirmation) { pending in + ConfirmationSheet(title: pending.title, detail: pending.detail, requirement: pending.requirement, target: pending.target, commandPreview: nil, onConfirm: pending.action) + } + } + + private func confirmRemove(_ row: AppRow, _ device: Device) { + confirmation = PendingConfirmation(title: "Remove \(row.name)?", detail: "\(row.name) (\(row.bundleIdentifier)) and its data will be deleted from \(device.name).", requirement: .make(for: .highImpact, target: device.target), target: device.target) { + Task { await model.apps.uninstall(row, app: model, device: device) } + } + } + + private func launch(_ row: AppRow, _ device: Device) { + guard let action = ActionCatalog.descriptor("launch-app") else { return } + confirmation = PendingConfirmation(title: "Launch \(row.name)?", detail: "The app is restarted if it is already running.", requirement: .make(for: .deviceChange, target: device.target), target: device.target) { + let executor = model.executor + let target = device.target + Task { + if let result = await model.run("Launch \(row.name)", workspace: .apps, target: target, transport: action.mechanism, { _ in try await executor.execute(action, target: target, values: ["bundle": row.bundleIdentifier]) }) { + model.statusMessage = result.summary + } + } + } + } +} + +struct InstallAppView: View { + @Environment(AppModel.self) private var model + @State private var confirmation: PendingConfirmation? + + var body: some View { + @Bindable var install = model.install + WorkspacePage(workspace: .installApp) { + TargetHeader(allowedKinds: [.physical, .simulator]) + Card(title: "Choose a package", systemImage: "shippingbox", subtitle: "Physical devices install signed .ipa packages (or .app bundles built for devices). Simulators install .app bundles built for the simulator. The package is inspected on this Mac before installation is offered.") { + HStack { + Button("Choose .ipa or .app…") { + if let url = FilePanels.chooseFile(title: "Choose an app package", allowedExtensions: ["ipa", "app"]) { + Task { await install.choose(url, app: model) } + } + } + .accessibilityIdentifier("choose-package") + if let url = install.packageURL { + Text(url.lastPathComponent).font(.callout.monospaced()).lineLimit(1).truncationMode(.middle) + } + } + } + if let inspection = install.inspection { + InspectionView(inspection: inspection, device: model.selectedDevice) + } else if let name = install.appBundleName { + Card(title: name, systemImage: "app") { + Text("App bundles are installed as they are. Build for the simulator to install on a simulator, or for a device (signed) to install on a device.") + .foregroundStyle(.secondary) + } + } + if let device = model.selectedDevice, install.packageURL != nil { + Card(title: "Install", systemImage: "square.and.arrow.down.on.square") { + if device.kind == .physical && !device.supportsCoreDevice { + Toggle("Install as developer package", isOn: $install.installAsDeveloperPackage) + .help("Asks iOS to treat the package as a development build. Only meaningful for development-signed apps.") + } + let blocked = blockReason(device) + if let blocked { + Label(blocked, systemImage: "hand.raised").foregroundStyle(.orange) + } + Button("Install on \(device.name)…") { + confirmation = PendingConfirmation(title: "Install \(install.inspection?.appName ?? install.packageURL?.lastPathComponent ?? "app")?", detail: "The app will be installed on \(device.name). iOS still checks the signature, provisioning, and entitlements.", requirement: .make(for: .deviceChange, target: device.target), target: device.target) { + Task { await install.install(app: model, device: device) } + } + } + .disabled(blocked != nil) + } + } + } + .sheet(item: $confirmation) { pending in + ConfirmationSheet(title: pending.title, detail: pending.detail, requirement: pending.requirement, target: pending.target, commandPreview: nil, onConfirm: pending.action) + } + } + + private func blockReason(_ device: Device) -> String? { + let install = model.install + switch device.kind { + case .demo: return "Demo Mode cannot install apps." + case .simulator: + if !install.isAppBundle { return "Simulators install .app bundles built for the simulator, not .ipa files." } + if device.simulatorState != .booted { return "Start the simulator first." } + return nil + case .physical: + if let inspection = install.inspection, !inspection.isInstallable { return inspection.installabilityExplanation } + return nil + } + } +} + +struct InspectionView: View { + let inspection: IPAInspection + let device: Device? + + var body: some View { + Card(title: "\(inspection.appName) \(inspection.version) (\(inspection.build))", systemImage: inspection.isInstallable ? "checkmark.shield" : "xmark.shield") { + Label(inspection.installabilityExplanation, systemImage: inspection.isInstallable ? "checkmark.circle" : "exclamationmark.triangle") + .foregroundStyle(inspection.isInstallable ? .green : .orange) + .fixedSize(horizontal: false, vertical: true) + InfoRow("Bundle identifier", inspection.bundleIdentifier, monospaced: true) + InfoRow("Minimum iOS", inspection.minimumOSVersion ?? "Not declared") + InfoRow("Code signature", inspection.signature.status.rawValue.capitalized, explanation: "Whether every file in the app still matches its signature. A broken signature always prevents installation.") + InfoRow("Signing team", inspection.signature.teamIdentifier ?? "—", monospaced: true) + InfoRow("Profile type", inspection.provisioning.profileKind, explanation: "Development and Ad Hoc profiles list the exact devices they allow; Enterprise profiles allow any device in the organization.") + if let expiration = inspection.provisioning.expirationDate { + InfoRow("Profile expires", expiration.formatted(date: .abbreviated, time: .omitted) + (inspection.provisioning.isExpired ? " (expired)" : "")) + } + if let device, device.kind == .physical, inspection.provisioning.status == .decoded { + let included = inspection.provisioning.includes(udid: device.udid) + Label(included ? "\(device.name) is included in the profile." : "\(device.name) is not listed in the profile, so iOS will likely refuse the app.", systemImage: included ? "checkmark.circle" : "xmark.circle") + .foregroundStyle(included ? .green : .orange) + } + DisclosureGroup("Full inspection report") { + RawOutputView(text: inspection.report, maxHeight: 260) + } + } + } +} diff --git a/App/iOSDeveloperToolkit/Views/ContentView.swift b/App/iOSDeveloperToolkit/Views/ContentView.swift new file mode 100644 index 0000000..9c62fa5 --- /dev/null +++ b/App/iOSDeveloperToolkit/Views/ContentView.swift @@ -0,0 +1,340 @@ +import DeviceKit +import SwiftUI +import ToolkitCore +import ToolkitFeatures + +struct ContentView: View { + @Environment(AppModel.self) private var model + @State private var columnVisibility = NavigationSplitViewVisibility.all + + var body: some View { + @Bindable var model = model + NavigationSplitView(columnVisibility: $columnVisibility) { + SidebarView() + .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 280) + } detail: { + VStack(spacing: 0) { + if model.selectedDevice?.kind == .demo { + DemoBanner() + } + WorkspaceView(workspace: model.workspace) + // Zero ideal size: pages fill the window but can never make it grow. + .frame(minWidth: 0, idealWidth: 0, maxWidth: .infinity, minHeight: 0, idealHeight: 0, maxHeight: .infinity) + } + .navigationTitle(model.workspace.title) + .navigationSubtitle(model.selectedDevice.map { "\($0.name) · \($0.kind.label)" } ?? "No device selected") + .toolbar { MainToolbar() } + } + .sheet(item: $model.presentedError) { error in + ErrorSheet(error: error) + } + .sheet(isPresented: $model.isCommandPalettePresented) { + CommandPaletteView() + } + .sheet(isPresented: $model.isDeveloperModeGuidePresented) { + DeveloperModeGuideView() + } + .sheet(isPresented: $model.isReconnectGuidePresented) { + ReconnectGuideView() + } + .sheet(isPresented: $model.isShortcutReferencePresented) { + ShortcutReferenceView() + } + .sheet(isPresented: $model.isAdvancedModePresented) { + AdvancedModeView() + } + .overlay(alignment: .bottom) { + if let message = model.statusMessage { + StatusToast(message: message) { model.statusMessage = nil } + .padding(.bottom, 16) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.2), value: model.statusMessage) + } +} + +struct WorkspaceView: View { + let workspace: Workspace + + var body: some View { + switch workspace { + case .overview: OverviewView() + case .device: DeviceDetailView() + case .readiness: ReadinessView() + case .apps: AppsView() + case .installApp: InstallAppView() + case .location: LocationLabView() + case .liveLogs: LiveLogsView() + case .actions: ActionsView() + case .backup: BackupView() + case .evidence: EvidenceView() + case .externalTools: ExternalToolsView() + case .activity: ActivityView() + case .help: ToolReferenceView() + case .safety: SafetyView() + } + } +} + +struct SidebarView: View { + @Environment(AppModel.self) private var model + + var body: some View { + @Bindable var model = model + VStack(spacing: 0) { + List(selection: Binding(get: { model.workspace }, set: { if let value = $0 { model.workspace = value } })) { + ForEach(Workspace.Group.allCases, id: \.self) { group in + Section(group.rawValue) { + ForEach(Workspace.allCases.filter { $0.group == group }) { workspace in + Label(workspace.title, systemImage: workspace.symbolName) + .tag(workspace) + .accessibilityIdentifier("sidebar-\(workspace.rawValue)") + .help(workspace.subtitle) + } + } + } + } + .modifier(SidebarListStyle()) + // The list scrolls within whatever height remains; it must never set the window height. + .frame(minHeight: 0, idealHeight: 0, maxHeight: .infinity) + ConnectionSummaryView() + .padding(10) + } + } +} + +/// Compact discovery health at the bottom of the sidebar. +struct ConnectionSummaryView: View { + @Environment(AppModel.self) private var model + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + row("USB & Wi-Fi", model.snapshot.usbmux) + row("Xcode devices", model.snapshot.coreDevice) + row("Simulators", model.snapshot.simulators) + } + .font(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("connection-summary") + } + + private func row(_ title: String, _ status: SourceStatus) -> some View { + HStack(spacing: 6) { + Circle() + .fill(status.isAvailable ? Color.green : (status == .notChecked ? Color.gray : Color.orange)) + .frame(width: 7, height: 7) + Text(title).foregroundStyle(.secondary) + Spacer(minLength: 4) + // The full reason for an unavailable source is a sentence; the sidebar shows a short + // state and keeps the reason in the tooltip, the accessibility label, and Device › + // Connection diagnostics. + Text(status.isAvailable || status == .notChecked ? status.summary : "Unavailable") + .lineLimit(1) + .truncationMode(.tail) + } + .help(status.summary) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(title): \(status.summary)") + } +} + +struct MainToolbar: ToolbarContent { + @Environment(AppModel.self) private var model + + var body: some ToolbarContent { + ToolbarItem(placement: .navigation) { + TargetPicker() + } + ToolbarItemGroup(placement: .primaryAction) { + if !model.operations.isEmpty { + OperationsButton() + } + Button { + Task { await model.refreshDevices() } + } label: { + Label("Refresh Devices", systemImage: "arrow.clockwise") + } + .help("Look for devices again (⌘R)") + .disabled(model.isRefreshing) + Button { + model.isCommandPalettePresented = true + } label: { + Label("Command Palette", systemImage: "command") + } + .help("Search every workspace and action (⌘K)") + } + } +} + +/// The global target picker. Physical devices and simulators are listed in separate sections +/// and labelled, so it is always clear which kind of device an action will affect. +struct TargetPicker: View { + @Environment(AppModel.self) private var model + + var body: some View { + Menu { + Section("Physical Devices") { + if model.physicalDevices.isEmpty { + Text("None connected") + } + ForEach(model.physicalDevices) { device in + Button { + model.selectedDeviceID = device.id + } label: { + Label("\(device.name) — \(device.displayModel), \(device.displayVersion)", systemImage: device.family.symbolName) + } + } + } + Section("Simulators") { + if model.simulatorDevices.isEmpty { + Text(model.snapshot.simulators.isAvailable ? "No simulators" : "Unavailable (needs Xcode)") + } + ForEach(model.simulatorDevices) { device in + Button { + model.selectedDeviceID = device.id + } label: { + Label("\(device.name) — \(device.displayVersion)\(device.simulatorState == .booted ? " (running)" : "")", systemImage: "\(device.family.symbolName)") + } + } + } + if model.selectedDeviceID != nil { + Divider() + Button("Clear Selection") { model.selectedDeviceID = nil } + } + } label: { + HStack(spacing: 6) { + Image(systemName: model.selectedDevice?.family.symbolName ?? "iphone.slash") + VStack(alignment: .leading, spacing: 0) { + Text(model.selectedDevice?.name ?? "Choose a Device") + .font(.headline) + .lineLimit(1) + if let device = model.selectedDevice { + Text(device.kind.label) + .font(.caption2) + .foregroundStyle(device.kind == .simulator ? Color.purple : (device.kind == .demo ? Color.orange : Color.blue)) + } + } + } + } + .menuIndicator(.visible) + .fixedSize() + .help("Choose the device or simulator that actions affect") + .accessibilityIdentifier("target-picker") + } +} + +struct OperationsButton: View { + @Environment(AppModel.self) private var model + @State private var isPresented = false + + var body: some View { + Button { + isPresented.toggle() + } label: { + HStack(spacing: 4) { + ProgressView().controlSize(.small) + Text("\(model.operations.count)") + .monospacedDigit() + } + } + .help("Running operations") + .accessibilityLabel("\(model.operations.count) running operations") + .popover(isPresented: $isPresented, arrowEdge: .bottom) { + VStack(alignment: .leading, spacing: 12) { + Text("Running").font(.headline) + ForEach(model.operations) { operation in + OperationRow(operation: operation) + } + } + .padding() + .frame(width: 360) + } + } +} + +struct OperationRow: View { + let operation: RunningOperation + + var body: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(operation.title).font(.callout.weight(.medium)) + if let target = operation.targetLabel { + Text(target).font(.caption).foregroundStyle(.secondary) + } + if let progress = operation.progress { + ProgressView(value: progress) + } else { + ProgressView().progressViewStyle(.linear) + } + if !operation.status.isEmpty { + Text(operation.status).font(.caption).foregroundStyle(.secondary) + } + Text(operation.startedAt, style: .timer) + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) + } + Spacer() + Button("Stop") { operation.cancel() } + .controlSize(.small) + } + } +} + +struct DemoBanner: View { + var body: some View { + HStack { + Image(systemName: "theatermasks") + Text(DemoMode.banner) + .font(.callout) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.18)) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("demo-banner") + } +} + +struct StatusToast: View { + let message: String + let dismiss: () -> Void + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "info.circle") + Text(message).lineLimit(2) + Button(action: dismiss) { Image(systemName: "xmark") } + .buttonStyle(.plain) + .accessibilityLabel("Dismiss") + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.regularMaterial, in: Capsule()) + .shadow(radius: 4, y: 2) + .task(id: message) { + try? await Task.sleep(for: .seconds(6)) + dismiss() + } + } +} + +/// The standard vibrant sidebar, or a plain list while the screenshot harness is capturing +/// (vibrancy cannot be rendered into an offscreen image). +struct SidebarListStyle: ViewModifier { + func body(content: Content) -> some View { + if ScreenshotHarness.isCapturing { + content + .listStyle(.inset) + .scrollContentBackground(.hidden) + .background(Color(nsColor: .windowBackgroundColor)) + } else { + content.listStyle(.sidebar) + } + } +} diff --git a/App/iOSDeveloperToolkit/Views/DataViews.swift b/App/iOSDeveloperToolkit/Views/DataViews.swift new file mode 100644 index 0000000..73d1b3d --- /dev/null +++ b/App/iOSDeveloperToolkit/Views/DataViews.swift @@ -0,0 +1,308 @@ +import DeviceKit +import SwiftUI +import ToolkitCore +import ToolkitFeatures + +// MARK: - Backup + +struct BackupView: View { + @Environment(AppModel.self) private var model + @State private var confirmation: PendingConfirmation? + + var body: some View { + @Bindable var backup = model.backup + WorkspacePage(workspace: .backup) { + TargetHeader(allowedKinds: [.physical]) + if let device = model.selectedDevice, device.kind == .physical { + if !device.supportsLockdownServices { + Label("Backups need a USB (or Wi-Fi sync) connection. This device is only reachable through Xcode's network connection.", systemImage: "cable.connector") + .foregroundStyle(.orange) + } + Card(title: "Encryption", systemImage: "lock", subtitle: "Encrypted backups also include saved passwords, Health, and Wi-Fi data, and cannot be read without the password. Encryption is a setting stored on the device.") { + HStack { + Button("Check Encryption") { Task { await backup.checkEncryption(app: model, target: device.target) } } + if backup.encryptionCheckedFor == device.target, let enabled = backup.encryptionEnabled { + Label(enabled ? "Backups are encrypted" : "Backup encryption is off", systemImage: enabled ? "lock.fill" : "lock.open") + .foregroundStyle(enabled ? .green : .orange) + } + } + if backup.encryptionCheckedFor == device.target, backup.encryptionEnabled == false { + VStack(alignment: .leading, spacing: 8) { + Text("Turn on encryption with a new backup password. Store it safely — an encrypted backup cannot be restored without it. Do not reuse the device passcode or an account password.") + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + SecureField("New backup password (8+ characters)", text: $backup.newPassword) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 320) + SecureField("Confirm password", text: $backup.confirmPassword) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 320) + if !backup.confirmPassword.isEmpty && backup.newPassword != backup.confirmPassword { + Text("The passwords do not match.").font(.caption).foregroundStyle(.red) + } + Button("Turn On Encryption…") { + confirmation = PendingConfirmation(title: "Turn on backup encryption", detail: "This changes a setting on \(device.name). The device may ask for its passcode. The password is sent only over the encrypted device connection and is never written to disk or logs.", requirement: .make(for: .deviceChange, target: device.target), target: device.target) { + Task { await backup.enableEncryption(app: model, target: device.target) } + } + } + .disabled(!backup.passwordsValid || backup.isRunning) + } + } + } + Card(title: "Back up", systemImage: "externaldrive.badge.timemachine") { + HStack { + Text("Save to").foregroundStyle(.secondary) + Text(backup.destination.path).font(.callout.monospaced()).lineLimit(1).truncationMode(.middle) + Button("Change…") { + if let url = FilePanels.chooseFolder(title: "Choose where to save backups", directory: backup.destination) { backup.destination = url } + } + } + Toggle("Require encrypted backup", isOn: $backup.requireEncryption) + Toggle("Always make a full backup", isOn: $backup.forceFullBackup) + .help("Otherwise, an existing backup of this device in the folder is updated incrementally.") + HStack { + Button("Start Backup…") { + confirmation = PendingConfirmation(title: "Back up \(device.name)", detail: "The backup is written to \(backup.destination.path)/\(device.udid). Keep the device unlocked and connected until it finishes.", requirement: .make(for: .hostWrite, target: device.target), target: device.target) { + Task { await backup.startBackup(app: model, target: device.target) } + } + } + .disabled(backup.isRunning || !device.supportsLockdownServices) + .accessibilityIdentifier("start-backup") + if let progress = backup.progress { + ProgressView(value: progress).frame(maxWidth: 240) + Text("\(Int(progress * 100))%").monospacedDigit() + } + if backup.isRunning { + Button("Stop") { backup.activeOperation?.cancel() } + } + } + if !backup.log.isEmpty { + RawOutputView(text: backup.log.joined(separator: "\n"), maxHeight: 140) + } + if let last = backup.lastBackup { + Button("Show Backup in Finder") { FilePanels.reveal(last) } + } + } + } + Card(title: "Analyze or acquire with separate tools", systemImage: "wrench.and.screwdriver", subtitle: "MVT (consented spyware-indicator checks on a decrypted backup) and UFADE (advanced logical acquisitions) are separate projects you install yourself.") { + Button("Open External Tools") { model.workspace = .externalTools } + } + } + .sheet(item: $confirmation) { pending in + ConfirmationSheet(title: pending.title, detail: pending.detail, requirement: pending.requirement, target: pending.target, commandPreview: nil, onConfirm: pending.action) + } + } +} + +// MARK: - Evidence + +struct EvidenceView: View { + @Environment(AppModel.self) private var model + @State private var confirmation: PendingConfirmation? + + var body: some View { + @Bindable var evidence = model.evidence + WorkspacePage(workspace: .evidence) { + TargetHeader(allowedKinds: [.physical]) + Text("Collects snapshots (device information, apps, profiles, diagnostics, crash inventory), optional timed streams, and optional artifacts into a new case folder with a manifest and SHA-256 hashes. A failed step is recorded as a coverage gap, never skipped silently.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + if let device = model.selectedDevice, device.kind == .physical { + Card(title: "Case", systemImage: "folder.badge.person.crop") { + HStack { + Text("Cases folder").foregroundStyle(.secondary) + Text(evidence.outputRoot.path).font(.callout.monospaced()).lineLimit(1).truncationMode(.middle) + Button("Change…") { + if let url = FilePanels.chooseFolder(title: "Choose where cases are created", directory: evidence.outputRoot) { evidence.outputRoot = url } + } + } + if let intake = evidence.activeIntake { + Label("Collecting into guided case “\(intake.title)”", systemImage: "folder.fill").foregroundStyle(.green) + } else { + DisclosureGroup("Guided case (optional)") { + VStack(alignment: .leading, spacing: 8) { + TextField("Case title", text: $evidence.caseTitle).textFieldStyle(.roundedBorder) + TextField("Purpose and scope", text: $evidence.casePurpose, axis: .vertical).lineLimit(2...4).textFieldStyle(.roundedBorder) + Toggle("I own this device or am authorized to examine it", isOn: $evidence.authorized) + Button("Create Case") { evidence.createCase(app: model, target: device.target) } + .disabled(evidence.caseTitle.trimmingCharacters(in: .whitespaces).isEmpty || !evidence.authorized) + Text("The intake records your stated purpose and authorization. It does not establish chain of custody.") + .font(.caption).foregroundStyle(.secondary) + } + .padding(.top, 6) + } + } + } + Card(title: "What to collect", systemImage: "slider.horizontal.3") { + Stepper("Stream for \(evidence.options.durationSeconds) seconds", value: $evidence.options.durationSeconds, in: 0...3600, step: 15) + Toggle("Unified Logs stream", isOn: $evidence.options.includeUnifiedLogs) + Toggle("Classic syslog stream", isOn: $evidence.options.includeClassicSyslog) + Toggle("Network packet capture (PCAP)", isOn: $evidence.options.includePacketCapture) + Toggle("Screenshot", isOn: $evidence.options.includeScreenshot) + Toggle("Copy crash reports", isOn: $evidence.options.includeCrashReports) + Text("Logs, packet captures, screenshots, and crash reports can contain private information.").font(.caption).foregroundStyle(.secondary) + ReadinessStatusView(requirements: evidence.options.requirements, device: device, subject: "this collection") + } + HStack { + Button("Start Collection…") { + confirmation = PendingConfirmation(title: "Collect evidence from \(device.name)", detail: "Information from the device is copied into a new case folder on this Mac. Nothing on the device is changed.", requirement: .make(for: .hostWrite, target: device.target), target: device.target) { + Task { await evidence.collect(app: model, device: device) } + } + } + .disabled(evidence.isCollecting) + .accessibilityIdentifier("start-collection") + if evidence.isCollecting { + Button("Stop and Finalize") { evidence.stop() } + ProgressView().controlSize(.small) + Text(evidence.currentStep ?? "").font(.callout).foregroundStyle(.secondary) + if let remaining = evidence.secondsRemaining { Text("\(remaining)s").monospacedDigit() } + } + } + } + if !evidence.steps.isEmpty { + Card(title: evidence.manifest.map { "Outcome: \($0.outcome.rawValue.capitalized)" } ?? "Progress", systemImage: "list.bullet.clipboard") { + ForEach(evidence.steps) { step in + HStack(alignment: .firstTextBaseline) { + Image(systemName: step.status == .succeeded ? "checkmark.circle.fill" : step.status == .unavailable ? "minus.circle" : "xmark.circle.fill") + .foregroundStyle(step.status == .succeeded ? .green : step.status == .unavailable ? .secondary : .orange) + Text(step.title) + Spacer() + Text(step.detail).font(.caption).foregroundStyle(.secondary).lineLimit(1).frame(maxWidth: 360, alignment: .trailing) + } + } + if let folder = evidence.lastCaseFolder, !evidence.isCollecting { + Button("Show Case in Finder") { FilePanels.reveal(folder) } + } + } + } + } + .sheet(item: $confirmation) { pending in + ConfirmationSheet(title: pending.title, detail: pending.detail, requirement: pending.requirement, target: pending.target, commandPreview: nil, onConfirm: pending.action) + } + } +} + +// MARK: - External tools + +struct ExternalToolsView: View { + @Environment(AppModel.self) private var model + @State private var confirmation: PendingConfirmation? + + var body: some View { + @Bindable var tools = model.externalTools + WorkspacePage(workspace: .externalTools) { + Text("These are independent projects you install yourself. The toolkit does not bundle, update, or import them; it validates the file you choose (path and SHA-256) and runs it with a minimal environment. None of them is required.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + mvtCard(tools) + ufadeCard(tools) + idbCard(tools) + } + .sheet(item: $confirmation) { pending in + ConfirmationSheet(title: pending.title, detail: pending.detail, requirement: pending.requirement, target: pending.target, commandPreview: pending.commandPreview, onConfirm: pending.action) + } + } + + private func mvtCard(_ tools: ExternalToolsModel) -> some View { + @Bindable var tools = tools + return Card(title: "MVT — Mobile Verification Toolkit", systemImage: "shield.checkered", subtitle: "Checks a decrypted iTunes-style backup for published indicators of compromise, with the device owner's consent. A run with no findings does not prove a device is clean.") { + HStack { + TextField("Path to mvt-ios", text: $tools.mvtPath).textFieldStyle(.roundedBorder).font(.callout.monospaced()) + Button("Choose…") { if let url = FilePanels.chooseFile(title: "Choose mvt-ios", allowedExtensions: []) { tools.mvtPath = url.path } } + Button("Validate") { Task { await tools.validateMVT(app: model) } }.disabled(tools.mvtPath.isEmpty) + } + if let mvt = tools.mvt { + Label("MVT \(mvt.version) · SHA-256 \(mvt.sha256.prefix(16))…", systemImage: "checkmark.seal").foregroundStyle(.green).font(.callout) + HStack { + Button("Choose Decrypted Backup…") { tools.mvtBackup = FilePanels.chooseFolder(title: "Choose a decrypted backup folder", canCreate: false) } + if let backup = tools.mvtBackup { Text(backup.lastPathComponent).font(.callout.monospaced()) } + } + HStack { + Text("Results").foregroundStyle(.secondary) + TextField("Result folder name", text: $tools.mvtOutputName).textFieldStyle(.roundedBorder) + Button("In…") { if let url = FilePanels.chooseFolder(title: "Choose where the result folder is created") { tools.mvtOutputParent = url } } + } + HStack { + Button("Add Indicator Files…") { tools.mvtIndicators += FilePanels.chooseFiles(title: "Choose STIX2 or JSON indicators", allowedExtensions: ["stix2", "stix", "json"]) } + Text(tools.mvtIndicators.isEmpty ? "No indicator files (MVT's defaults are not downloaded)" : "\(tools.mvtIndicators.count) indicator files").font(.caption).foregroundStyle(.secondary) + } + Toggle("Fast mode", isOn: $tools.mvtFast) + Toggle("Hash files", isOn: $tools.mvtHashes) + Toggle("Allow network access (for example to resolve short URLs)", isOn: $tools.mvtAllowNetwork) + Toggle("I own this backup or have the owner's explicit consent", isOn: $tools.mvtConsent) + Toggle("I understand that no findings is not proof that a device is safe", isOn: $tools.mvtNoVerdict) + HStack { + Button("Run Analysis…") { + confirmation = PendingConfirmation(title: "Run MVT", detail: "MVT reads the backup and writes results to a new folder. The backup is not modified.", requirement: .make(for: .hostWrite, target: nil), target: nil, commandPreview: tools.mvt.map { "\($0.path) check-backup …" }) { + Task { await tools.runMVT(app: model) } + } + } + .disabled(tools.mvtBackup == nil || !tools.mvtConsent || !tools.mvtNoVerdict || tools.mvtRunning) + if tools.mvtRunning { ProgressView().controlSize(.small) } + } + if !tools.mvtOutput.isEmpty { RawOutputView(text: tools.mvtOutput, maxHeight: 200) } + } + DisclosureGroup("Install MVT") { + RawOutputView(text: MVTConnector.setupCommands.joined(separator: "\n"), maxHeight: 80) + Link("MVT documentation", destination: MVTConnector.backupGuideURL) + } + } + } + + private func ufadeCard(_ tools: ExternalToolsModel) -> some View { + @Bindable var tools = tools + return Card(title: "UFADE", systemImage: "externaldrive.badge.person.crop", subtitle: "A separate GPL-3.0 acquisition app with its own Python 3.11 environment. It chooses its own device, asks for its own passwords, and keeps running if you quit the toolkit.") { + HStack { + Button("Choose UFADE Folder…") { tools.ufadeCheckout = FilePanels.chooseFolder(title: "Choose the UFADE checkout", canCreate: false) } + if let checkout = tools.ufadeCheckout { Text(checkout.path).font(.callout.monospaced()).lineLimit(1).truncationMode(.middle) } + } + TextField("Python 3.11 (defaults to the checkout's .venv)", text: $tools.ufadePython).textFieldStyle(.roundedBorder).font(.callout.monospaced()) + HStack { + Button("Validate") { Task { await tools.validateUFADE(app: model) } }.disabled(tools.ufadeCheckout == nil) + if let ufade = tools.ufade { + Label("UFADE \(ufade.ufadeVersion), Python \(ufade.python.version)", systemImage: "checkmark.seal").foregroundStyle(.green) + Button("Launch UFADE") { Task { await tools.launchUFADE(app: model) } } + } + } + if let ufade = tools.ufade { + if ufade.developerImagesAvailable { + Label("Developer-image submodule is populated.", systemImage: "checkmark.circle").font(.callout).foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 4) { + Label("Developer-image submodule is not populated. Logical acquisitions still work, but UFADE's Developer Options may be limited.", systemImage: "exclamationmark.triangle") + .font(.callout) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + Text("In the UFADE folder run `\(UFADEConnector.submoduleCommand)`, then validate again.") + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } + DisclosureGroup("Install UFADE") { + RawOutputView(text: UFADEConnector.setupCommands.joined(separator: "\n"), maxHeight: 110) + } + } + } + + private func idbCard(_ tools: ExternalToolsModel) -> some View { + @Bindable var tools = tools + return Card(title: "idb Companion", systemImage: "rectangle.connected.to.line.below", subtitle: "Meta's automation companion. Only a read-only inventory is offered here.") { + HStack { + TextField("Path to idb_companion", text: $tools.idbPath).textFieldStyle(.roundedBorder).font(.callout.monospaced()) + Button("Validate") { Task { await tools.validateIDB(app: model) } }.disabled(tools.idbPath.isEmpty) + } + if let idb = tools.idb { + HStack { + Label(idb.version, systemImage: "checkmark.seal").foregroundStyle(.green) + Button("List Targets") { Task { await tools.probeIDB(app: model) } } + } + } + if !tools.idbOutput.isEmpty { RawOutputView(text: tools.idbOutput, maxHeight: 160) } + Text("Install with: \(IDBCompanionConnector.setupCommand)").font(.caption.monospaced()).foregroundStyle(.secondary).textSelection(.enabled) + } + } +} diff --git a/App/iOSDeveloperToolkit/Views/DeviceViews.swift b/App/iOSDeveloperToolkit/Views/DeviceViews.swift new file mode 100644 index 0000000..e90fd3b --- /dev/null +++ b/App/iOSDeveloperToolkit/Views/DeviceViews.swift @@ -0,0 +1,445 @@ +import DeviceKit +import SwiftUI +import ToolkitCore +import ToolkitFeatures + +// MARK: - Overview + +struct OverviewView: View { + @Environment(AppModel.self) private var model + + var body: some View { + WorkspacePage(workspace: .overview) { + TargetHeader() + if let device = model.selectedDevice { + NextStepCard(device: device) + } + Card(title: "Get started", systemImage: "list.number") { + VStack(alignment: .leading, spacing: 8) { + step(1, "Connect and trust", "Connect the iPhone or iPad with a USB cable, unlock it, and tap Trust. Simulators appear automatically when Xcode is installed.") + step(2, "Check readiness", "Run the Readiness Check to see exactly which features are available and what to fix.") + step(3, "Do the work", "Use Actions, Location Lab, Live Logs, Apps, or Backup. Every change asks for confirmation and names the device it affects.") + step(4, "Clean up", "Stop streams, clear simulated locations, and store saved output somewhere protected.") + } + } + LazyVGrid(columns: [GridItem(.adaptive(minimum: 240), spacing: 12)], spacing: 12) { + ForEach(Workspace.allCases.filter { $0 != .overview }) { workspace in + Button { + model.workspace = workspace + } label: { + VStack(alignment: .leading, spacing: 6) { + Label(workspace.title, systemImage: workspace.symbolName).font(.headline) + Text(workspace.subtitle).font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, minHeight: 64, alignment: .topLeading) + .padding(12) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(.separator.opacity(0.6))) + } + .buttonStyle(.plain) + .accessibilityIdentifier("overview-\(workspace.rawValue)") + } + } + } + } + + private func step(_ number: Int, _ title: String, _ text: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Text("\(number)") + .font(.callout.bold()) + .frame(width: 22, height: 22) + .background(Color.accentColor.opacity(0.15), in: Circle()) + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.callout.weight(.semibold)) + Text(text).font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + } + } +} + +/// Suggests the single most useful next step for the selected device. +struct NextStepCard: View { + @Environment(AppModel.self) private var model + let device: Device + + var body: some View { + let (icon, title, text, action) = suggestion + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon).font(.title2).foregroundStyle(Color.accentColor) + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.headline) + Text(text).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + if let action { + Button(action.0, action: action.1).padding(.top, 2) + } + } + Spacer() + } + .padding(14) + .background(Color.accentColor.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + } + + private var suggestion: (String, String, String, (String, () -> Void)?) { + switch device.kind { + case .demo: + return ("theatermasks", "You are in Demo Mode", "Explore every workspace safely. Turn Demo Mode off in the Device menu to work with real devices.", ("Turn Off Demo Mode", { model.demoMode = false })) + case .simulator: + if device.simulatorState != .booted { + return ("play.circle", "Start the simulator", "Most simulator actions need it to be running.", ("Start Simulator", { Task { _ = try? await model.executor.simulators.boot(device.target) } })) + } + return ("checkmark.seal", "The simulator is running", "Try Location Lab, Live Logs, or Apps.", ("Open Location Lab", { model.workspace = .location })) + case .physical: + if device.pairingState == .unpaired || device.pairingState == .pairingInProgress { + return ("hand.tap", "Trust this Mac on the device", "Unlock the device and tap Trust when asked. Nothing else works until it trusts this Mac.", nil) + } + if device.developerMode == .disabled { + return ("hammer", "Turn on Developer Mode", "Location simulation, launching apps, screenshots, and Instruments need Developer Mode.", ("Show Me How", { model.isDeveloperModeGuidePresented = true })) + } + if model.readinessResults[device.id] == nil { + return ("checklist", "Run the Readiness Check", "See which features are ready on \(device.name) and what, if anything, needs fixing.", ("Run Readiness Check", { + model.workspace = .readiness + Task { await model.runReadiness(for: device) } + })) + } + return ("checkmark.seal", "\(device.name) is ready", "Choose a workspace below.", nil) + } + } +} + +// MARK: - Device + +struct DeviceDetailView: View { + @Environment(AppModel.self) private var model + @State private var rawDetails: String? + @State private var confirmation: PendingConfirmation? + + var body: some View { + WorkspacePage(workspace: .device) { + TargetHeader() + if let device = model.selectedDevice { + identity(device) + status(device) + if device.kind == .physical || device.kind == .demo { + developerServices(device) + } + if device.kind == .physical { + handoffs(device) + } + if device.kind == .simulator { + simulatorControls(device) + } + technical(device) + } + connectionDiagnostics + } + .sheet(item: $confirmation) { pending in + ConfirmationSheet(title: pending.title, detail: pending.detail, requirement: pending.requirement, target: pending.target, commandPreview: nil, onConfirm: pending.action) + } + .task(id: model.selectedDevice?.id) { + // Check the developer image once per device when it is shown (never changes the device). + if let device = model.selectedDevice, device.kind != .simulator, model.developerImage.status(for: device) == nil { + await model.developerImage.refresh(device, app: model) + } + } + } + + private func identity(_ device: Device) -> some View { + Card(title: "Identity", systemImage: "person.text.rectangle") { + InfoRow(.name, value: device.name) + InfoRow(.model, value: device.marketingName) + InfoRow(.hardwareIdentifier, value: device.productType) + if device.kind != .simulator { + InfoRow(.hardwareModel, value: device.hardwareModel) + } + InfoRow(.osVersion, value: device.osVersion.map { "\(device.osName ?? "iOS") \($0)" }) + InfoRow(.buildNumber, value: device.buildVersion) + InfoRow(.architecture, value: device.architecture) + if device.kind == .simulator { + InfoRow(.simulatorRuntime, value: device.simulatorRuntime) + } + InfoRow(.udid, value: device.udid) + if device.kind == .physical { + InfoRow(.serialNumber, value: device.serialNumber) + InfoRow(.ecid, value: device.ecid) + } + } + } + + private func status(_ device: Device) -> some View { + Card(title: "Status", systemImage: "antenna.radiowaves.left.and.right") { + InfoRow(.connection, value: device.transports.isEmpty ? "Not connected" : device.transports.map(\.label).sorted().joined(separator: " and ")) + if device.kind == .simulator { + InfoRow(.simulatorState, value: device.simulatorState?.label) + } else { + InfoRow(.pairing, value: device.pairingState.label) + HStack { + InfoRow(.developerMode, value: device.developerMode.label) + if device.developerMode != .enabled && device.kind == .physical { + Button("How to Turn On…") { model.isDeveloperModeGuidePresented = true } + .controlSize(.small) + } + } + InfoRow(.developerServices, value: model.developerImage.status(for: device)?.state.label ?? device.ddiServicesAvailable.map { $0 ? "Available" : "Not prepared" } ?? "Not checked yet") + InfoRow("Reported by", device.sources.map { source -> String in + switch source { + case .usbmux: return "USB services (usbmuxd)" + case .coreDevice: return "Xcode device service" + case .simctl: return "simctl" + case .demo: return "Demo Mode" + } + }.sorted().joined(separator: ", "), explanation: "Which macOS services currently see this device. USB services work without Xcode; developer features need Xcode's device service.") + } + } + } + + private func developerServices(_ device: Device) -> some View { + let images = model.developerImage + let status = images.status(for: device) + let busy = images.isBusy(device) + return Card(title: "Developer image", systemImage: "externaldrive.badge.checkmark", subtitle: "Screenshots, location simulation, launching apps, and Instruments need Apple's developer image (Developer Disk Image) mounted on the device.") { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + if let status { + Label(status.state.label, systemImage: status.state.symbolName) + .font(.callout.weight(.semibold)) + .foregroundStyle(Self.color(for: status.state)) + .accessibilityIdentifier("ddi-state") + Text(status.headline).font(.callout) + } else { + Text("Not checked yet.").font(.callout).foregroundStyle(.secondary) + } + Spacer(minLength: 0) + if busy { ProgressView().controlSize(.small) } + } + if let status { + Text(status.explanation) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + if let remediation = status.remediation { + Label(remediation, systemImage: "lightbulb") + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + } + DisclosureGroup("Details") { + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(status.detailRows.filter { $0.0 != "State" && $0.0 != "Next step" }.enumerated()), id: \.offset) { _, row in + InfoRow(row.0, row.1, monospaced: row.0 == "Chip / board") + } + if let technical = status.technicalDetail { + InfoRow("Technical detail", technical, monospaced: true) + } + } + .padding(.top, 4) + } + .font(.callout) + } + HStack { + Button("Check Again") { Task { await images.refresh(device, app: model) } } + .disabled(busy) + Button("Mount Developer Image") { + confirmation = PendingConfirmation(title: "Mount the developer image", detail: Self.mountDetail(status, device: device), requirement: .make(for: .deviceChange, target: device.target), target: device.target) { + Task { await images.mount(device, app: model) } + } + } + .disabled(busy || device.kind != .physical || !(status?.state.canMount ?? false)) + .accessibilityIdentifier("mount-ddi") + Button("Unmount") { + confirmation = PendingConfirmation(title: "Unmount the developer image", detail: "Developer services stop until the image is mounted again. Restarting the device has the same effect.", requirement: .make(for: .deviceChange, target: device.target), target: device.target) { + Task { await images.unmount(device, app: model) } + } + } + .disabled(busy || status?.state != .mounted || !device.supportsLockdownServices) + Menu("Options") { + Picker("Mount with", selection: Binding(get: { images.mechanism }, set: { images.mechanism = $0 })) { + ForEach(DeveloperImageMechanism.allCases, id: \.self) { Text($0.label).tag($0) } + } + Divider() + Button("Add Image Folder…") { + if let folder = FilePanels.chooseFolder(title: "Choose a folder that contains a developer image", canCreate: false) { + model.statusMessage = images.addFolder(folder) + Task { await images.refresh(device, app: model) } + } + } + if !images.folders.isEmpty { + Menu("Remove Image Folder") { + ForEach(images.folders, id: \.self) { folder in + Button(folder.path) { images.removeFolder(folder) } + } + } + } + Divider() + Button("Update This Mac's Images from Xcode") { runAction("host-ddis-update", device) } + .disabled(!device.supportsCoreDevice) + } + .fixedSize() + } + } + } + } + + static func color(for state: DeveloperImageState) -> Color { + switch state { + case .mounted: return .green + case .notRequired: return .secondary + case .available, .personalizationRequired: return .blue + case .missing, .incompatible, .blocked: return .orange + case .failed: return .red + } + } + + static func mountDetail(_ status: DeveloperImageStatus?, device: Device) -> String { + var lines = ["The developer image will be uploaded to \(device.name) and mounted. Keep the device unlocked and connected until it finishes."] + if status?.requiredKind == .personalized && status?.state == .personalizationRequired { + lines.append("Apple personalizes the image for this device first: this Mac sends the device's chip, board, and ECID with a one-time nonce to Apple's signing server (gs.apple.com), as Xcode does. An internet connection is required.") + } + if let host = status?.hostImage { lines.append("Image: \(host).") } + return lines.joined(separator: "\n\n") + } + + private func handoffs(_ device: Device) -> some View { + Card(title: "Apple developer tools", systemImage: "arrow.up.forward.app") { + HStack { + Button("Open Xcode Project…") { + guard let url = FilePanels.chooseFile(title: "Choose a project", allowedExtensions: ["xcodeproj", "xcworkspace", "swift"]) else { return } + let runner = model.runner + Task { _ = await model.run("Open project in Xcode", workspace: .device, target: nil, transport: "xed") { _ in try await runner.run(try XcodeHandoff.openProject(url)) } } + } + Button("Open Result or Trace…") { + guard let url = FilePanels.chooseFile(title: "Choose an .xcresult or .trace", allowedExtensions: ["xcresult", "trace"]) else { return } + let runner = model.runner + Task { _ = await model.run("Open result bundle", workspace: .device, target: nil, transport: "open") { _ in try await runner.run(try XcodeHandoff.openResult(url)) } } + } + Button("Remote Virtual Interfaces") { runAction("rvi", device) } + } + } + } + + private func simulatorControls(_ device: Device) -> some View { + Card(title: "Simulator", systemImage: "macwindow") { + HStack { + Button("Start") { runAction("sim-boot", device) }.disabled(device.simulatorState == .booted) + Button("Show in Simulator") { runAction("sim-open", device) } + Button("Shut Down") { + confirmation = PendingConfirmation(title: "Shut down \(device.name)", detail: "Running apps in the simulator stop.", requirement: .make(for: .deviceChange, target: device.target), target: device.target) { + runAction("sim-shutdown", device) + } + } + .disabled(device.simulatorState != .booted) + } + } + } + + private func technical(_ device: Device) -> some View { + Card(title: "Technical details", systemImage: "curlybraces") { + DisclosureGroup("Full device record") { + RawOutputView(text: (try? String(decoding: JSONOutput.encode(device), as: UTF8.self)) ?? "", maxHeight: 240) + } + if device.supportsCoreDevice { + Button("Load CoreDevice Details") { + let client = model.executor.coreDevice + Task { + rawDetails = await model.run("CoreDevice details", workspace: .device, target: device.target, transport: "devicectl device info details") { _ in + try await client.details(device.target).response.json.prettyString() + } + } + } + if let rawDetails { + RawOutputView(text: rawDetails, maxHeight: 280) + } + } + } + } + + private var connectionDiagnostics: some View { + Card(title: "Connection diagnostics", systemImage: "stethoscope", subtitle: "Which macOS services are answering. Useful when a device does not appear.") { + InfoRow("USB & Wi-Fi (usbmuxd)", model.snapshot.usbmux.summary, explanation: "macOS's own device service. It sees every trusted device connected by USB or Wi-Fi sync, even without Xcode.") + InfoRow("Xcode devices (CoreDevice)", model.snapshot.coreDevice.summary, explanation: "Xcode's device service, needed for developer features on iOS 17 and later.") + InfoRow("Simulators (simctl)", model.snapshot.simulators.summary, explanation: "Simulators installed with Xcode.") + InfoRow("Last updated", model.snapshot.updatedAt.formatted(date: .omitted, time: .standard)) + if let tools = model.developerTools { + InfoRow("Developer directory", tools.developerDirectory ?? "Not set", monospaced: true) + InfoRow("Xcode", tools.xcodeVersion ?? (tools.isCommandLineToolsOnly ? "Command Line Tools only" : "Not installed")) + } + Button("Reconnect a Device…") { model.isReconnectGuidePresented = true } + DisclosureGroup("Device not showing up?") { + VStack(alignment: .leading, spacing: 6) { + Text("1. Use a cable that carries data (some charging cables do not).") + Text("2. Unlock the device and keep the screen on.") + Text("3. Tap Trust when asked, and enter the device passcode on the device — never in this app.") + Text("4. If no prompt appears, disconnect and reconnect the cable.") + Text("5. Try another USB port or remove hubs.") + Text("6. If none of this works, restart the Mac. The toolkit never restarts system services itself.") + } + .font(.callout) + .padding(.top, 4) + } + } + } + + private func runAction(_ id: String, _ device: Device) { + guard let action = ActionCatalog.descriptor(id) else { return } + let executor = model.executor + let target = device.target + Task { + if let result = await model.run(action.title, workspace: .device, target: target, transport: action.mechanism, { _ in try await executor.execute(action, target: target, values: [:]) }) { + model.statusMessage = result.summary + if !result.raw.isEmpty && id != "sim-open" { rawDetails = result.raw } + } + } + } +} + +struct PendingConfirmation: Identifiable { + let id = UUID() + let title: String + let detail: String + let requirement: ConfirmationRequirement + let target: DeviceTarget? + var commandPreview: String? + let action: () -> Void + + init(title: String, detail: String, requirement: ConfirmationRequirement, target: DeviceTarget?, commandPreview: String? = nil, action: @escaping () -> Void) { + self.title = title + self.detail = detail + self.requirement = requirement + self.target = target + self.commandPreview = commandPreview + self.action = action + } +} + +// MARK: - Developer Mode guide + +struct DeveloperModeGuideView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Label("Turn on Developer Mode", systemImage: "hammer").font(.title2.bold()) + Text("Developer Mode (iOS 16 and later) lets the device run development features such as location simulation, screenshots through developer services, launching apps, and Instruments.") + .fixedSize(horizontal: false, vertical: true) + VStack(alignment: .leading, spacing: 8) { + Text("1. On the device, open **Settings › Privacy & Security › Developer Mode**.") + Text("2. Turn it on and tap **Restart**.") + Text("3. After the restart, unlock the device and tap **Turn On** when asked, then enter the passcode.") + Text("4. Reconnect the device and tap **Trust** if asked.") + } + GroupBox { + Text("If Developer Mode is missing from Settings, connect the device, open Xcode, and choose **Window › Devices and Simulators** once. Xcode makes the setting appear.") + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + Text("Developer Mode widens what a connected computer can do. Turn it off again (and restart) when you no longer need it.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack { + Spacer() + Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) + } + } + .padding(22) + .frame(width: 540) + } +} diff --git a/App/iOSDeveloperToolkit/Views/LocationViews.swift b/App/iOSDeveloperToolkit/Views/LocationViews.swift new file mode 100644 index 0000000..1346f0b --- /dev/null +++ b/App/iOSDeveloperToolkit/Views/LocationViews.swift @@ -0,0 +1,337 @@ +import AppKit +import DeviceKit +import SwiftUI +import ToolkitCore +import ToolkitFeatures + +struct LocationLabView: View { + @Environment(AppModel.self) private var model + @State private var confirmation: PendingConfirmation? + @State private var contentWidth: CGFloat = 0 + + var body: some View { + @Bindable var location = model.location + WorkspacePage(workspace: .location) { + TargetHeader(allowedKinds: [.physical, .simulator]) + Text("Simulated locations are reported to apps on the device until cleared. GPS hardware is not changed, and nothing here hides simulation from apps. No map service or geocoder is contacted.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + if let device = model.selectedDevice, device.kind != .demo { + mechanismNote(device) + } + Group { + // Two columns when the page is wide enough; one column otherwise. + if contentWidth >= 720 { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 16) { mapCard; routeCard; gpxCard } + VStack(alignment: .leading, spacing: 16) { coordinateCard; savedPlacesCard } + .frame(width: 320) + } + } else { + VStack(alignment: .leading, spacing: 16) { + coordinateCard + mapCard + savedPlacesCard + routeCard + gpxCard + } + } + } + .background(GeometryReader { proxy in + Color.clear + .onAppear { contentWidth = proxy.size.width } + .onChange(of: proxy.size.width) { _, width in contentWidth = width } + }) + } + .sheet(item: $confirmation) { pending in + ConfirmationSheet(title: pending.title, detail: pending.detail, requirement: pending.requirement, target: pending.target, commandPreview: nil, onConfirm: pending.action) + } + } + + private var mapCard: some View { + @Bindable var location = model.location + return Card(title: "Choose a point", systemImage: "map") { + WorldMapView(selection: location.coordinates) { coordinates in + location.show(coordinates) + } + .aspectRatio(2, contentMode: .fit) + .frame(maxWidth: 560) + HStack { + TextField("Paste latitude,longitude or a map link", text: $location.linkText) + .textFieldStyle(.roundedBorder) + .onSubmit { location.importLink(app: model) } + Button("Use") { location.importLink(app: model) }.disabled(location.linkText.isEmpty) + } + } + } + + private func mechanismNote(_ device: Device) -> some View { + let mechanism = model.executor.location.mechanism(for: device.target) + return Label("Uses \(mechanism.rawValue).\(mechanism == .coreDevice ? " Needs Developer Mode and Xcode." : mechanism == .legacyService ? " Needs the developer image mounted by Xcode." : "")", systemImage: "info.circle") + .font(.callout) + .foregroundStyle(.secondary) + } + + private var coordinateCard: some View { + @Bindable var location = model.location + return Card(title: "Coordinate", systemImage: "location") { + Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) { + GridRow { + Text("Latitude").foregroundStyle(.secondary) + TextField("-90 to 90", text: $location.latitudeText).textFieldStyle(.roundedBorder).accessibilityIdentifier("latitude-field") + } + GridRow { + Text("Longitude").foregroundStyle(.secondary) + TextField("-180 to 180", text: $location.longitudeText).textFieldStyle(.roundedBorder).accessibilityIdentifier("longitude-field") + } + } + if location.coordinates == nil { + Label("Enter a latitude from -90 to 90 and a longitude from -180 to 180.", systemImage: "exclamationmark.triangle").font(.caption).foregroundStyle(.orange) + } + Text("Nudge").font(.callout.weight(.medium)) + HStack(alignment: .center, spacing: 12) { + NudgePad { direction in location.nudge(direction, app: model) } + VStack(alignment: .leading) { + Picker("Distance", selection: $location.nudgeMetres) { + ForEach([1.0, 10, 100, 1_000, 10_000, 100_000], id: \.self) { metres in + Text(metres >= 1_000 ? "\(Int(metres / 1_000)) km" : "\(Int(metres)) m").tag(metres) + } + } + .labelsHidden() + .frame(width: 100) + } + } + HStack { + Button("Set Location") { confirmSet() } + .disabled(location.coordinates == nil || !canSimulate) + .accessibilityIdentifier("set-location") + Button("Clear") { confirmClear() } + .disabled(!canSimulate) + } + if let tracked = location.lastSimulatedTarget { + Label("\(tracked.name) is using a simulated location.", systemImage: "location.fill") + .font(.caption) + .foregroundStyle(.orange) + } + } + } + + private var savedPlacesCard: some View { + @Bindable var location = model.location + return Card(title: "Saved places", systemImage: "bookmark", subtitle: "Stored only on this Mac.") { + if location.savedLocations.isEmpty { + Text("No saved places yet.").foregroundStyle(.secondary) + } + ForEach(location.savedLocations) { place in + HStack { + Button(place.name) { location.show(place.coordinates) } + .buttonStyle(.link) + Spacer() + Text(place.coordinates.formatted).font(.caption.monospaced()).foregroundStyle(.secondary) + Button(role: .destructive) { location.removePlace(place, app: model) } label: { Image(systemName: "trash") } + .buttonStyle(.borderless) + .accessibilityLabel("Remove \(place.name)") + } + } + HStack { + TextField("Name", text: $location.newPlaceName).textFieldStyle(.roundedBorder) + Button("Save Current") { location.savePlace(app: model) } + .disabled(location.newPlaceName.trimmingCharacters(in: .whitespaces).isEmpty || location.coordinates == nil) + } + } + } + + private var routeCard: some View { + @Bindable var location = model.location + return Card(title: "Route", systemImage: "point.topleft.down.to.point.bottomright.curvepath", subtitle: "One latitude,longitude pair per line. Move along it at a constant speed, or save it as a timed GPX track.") { + TextEditor(text: $location.waypointsText) + .font(.callout.monospaced()) + .frame(height: 70) + .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(.separator)) + HStack { + Picker("Speed", selection: $location.travelPreset) { + ForEach(TravelPreset.allCases) { preset in + Text(preset.speedKmh.map { "\(preset.rawValue) (\(Int($0)) km/h)" } ?? preset.rawValue).tag(preset) + } + } + .frame(maxWidth: 220) + if location.travelPreset == .custom { + TextField("km/h", value: $location.customSpeedKmh, format: .number).frame(width: 60) + } + Stepper("Every \(location.routeIntervalSeconds)s", value: $location.routeIntervalSeconds, in: 1...60) + Stepper("× \(location.routeTraversals)", value: $location.routeTraversals, in: 1...20) + .help("Traversals: repeat the route back and forth") + } + HStack { + Button("Add Current Coordinate") { location.addCurrentWaypoint(app: model) } + .help("Add the latitude and longitude above as the last waypoint") + .disabled(location.coordinates == nil) + Button("Start Moving") { confirmRoute() }.disabled(!canSimulate) + Button("Build GPX") { location.buildRoute(app: model) } + if let route = location.generatedRoute { + Button("Save GPX…") { saveGPX(route) } + Text("\(route.points.count) points · \(Measurement(value: route.distanceMetres / 1_000, unit: UnitLength.kilometers).formatted(.measurement(width: .abbreviated, usage: .road))) · \(Duration.seconds(route.durationSeconds).formatted(.units(allowed: [.hours, .minutes, .seconds], width: .abbreviated)))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + private var gpxCard: some View { + @Bindable var location = model.location + return Card(title: "GPX playback", systemImage: "play.circle", subtitle: "Replays a GPX track point by point on the selected device. Files with DTDs or entities are refused.") { + HStack { + Button("Choose GPX…") { + if let url = FilePanels.chooseFile(title: "Choose a GPX track", allowedExtensions: ["gpx"]) { + location.inspectGPX(url, app: model) + } + } + if let inspection = location.gpxInspection { + Text("\(inspection.url.lastPathComponent): \(inspection.trackPointCount) points, \(inspection.timedPointCount) timed") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + if location.gpxInspection != nil { + Toggle("Ignore recorded timing", isOn: $location.ignoreRecordedTiming) + if location.ignoreRecordedTiming { + Stepper("One point every \(location.fixedIntervalSeconds, specifier: "%.1f") s", value: $location.fixedIntervalSeconds, in: 0.5...60, step: 0.5) + } else { + Stepper("Timing randomness ±\(location.jitterMilliseconds) ms", value: $location.jitterMilliseconds, in: 0...5_000, step: 100) + } + HStack { + if location.isPlaying { + Button("Stop Playback") { location.stopPlayback() } + if let progress = location.playbackProgress { + ProgressView(value: Double(progress.index), total: Double(progress.total)) + Text("\(progress.index)/\(progress.total)").font(.caption.monospacedDigit()) + } + } else { + Button("Play on Device") { confirmPlayback() }.disabled(!canSimulate) + } + } + if let inspection = location.gpxInspection { + DisclosureGroup("File details") { + InfoRow("SHA-256", inspection.sha256, monospaced: true) + InfoRow("Size", ByteFormatting.string(inspection.sizeBytes)) + InfoRow("Distance", String(format: "%.2f km", inspection.distanceMetres / 1_000)) + if let duration = inspection.recordedDuration { + InfoRow("Recorded duration", Duration.seconds(duration).formatted(.units(allowed: [.hours, .minutes, .seconds], width: .abbreviated))) + } + } + } + } + } + } + + private var canSimulate: Bool { + guard let device = model.selectedDevice else { return false } + return device.kind == .physical || device.kind == .simulator + } + + private func confirmSet() { + guard let device = model.selectedDevice, let coordinates = model.location.coordinates else { return } + confirmation = PendingConfirmation(title: "Set simulated location", detail: "\(device.name) will report \(coordinates.formatted) to apps until you clear it.", requirement: .make(for: .deviceChange, target: device.target), target: device.target) { + Task { await model.location.setLocation(app: model, target: device.target) } + } + } + + private func confirmClear() { + guard let device = model.selectedDevice else { return } + Task { await model.location.clear(app: model, target: device.target) } + } + + private func confirmRoute() { + guard let device = model.selectedDevice else { return } + confirmation = PendingConfirmation(title: "Start simulated movement", detail: "\(device.name) will move along the route at \(Int(model.location.speedKmh)) km/h until you clear the location.", requirement: .make(for: .deviceChange, target: device.target), target: device.target) { + Task { await model.location.startNativeRoute(app: model, target: device.target) } + } + } + + private func confirmPlayback() { + guard let device = model.selectedDevice else { return } + confirmation = PendingConfirmation(title: "Play GPX track", detail: "\(device.name) will follow the track. Stop playback and clear the location when finished.", requirement: .make(for: .deviceChange, target: device.target), target: device.target) { + model.location.startPlayback(app: model, target: device.target) + } + } + + private func saveGPX(_ route: GeneratedRoute) { + guard let url = FilePanels.save(title: "Save route", suggestedName: "route.gpx", allowedExtension: "gpx") else { return } + do { + try SecureFileIO.writeNewFile(Data(route.gpxDocument.utf8), to: url, mode: 0o644) + model.location.inspectGPX(url, app: model) + } catch { + model.present(error) + } + } +} + +struct NudgePad: View { + let action: (CompassDirection) -> Void + + var body: some View { + Grid(horizontalSpacing: 2, verticalSpacing: 2) { + GridRow { button(.northWest); button(.north); button(.northEast) } + GridRow { button(.west); Image(systemName: "location.circle").foregroundStyle(.secondary).frame(width: 28, height: 24); button(.east) } + GridRow { button(.southWest); button(.south); button(.southEast) } + } + } + + private func button(_ direction: CompassDirection) -> some View { + Button { action(direction) } label: { + Image(systemName: direction.symbolName).frame(width: 20, height: 16) + } + .help("Move \(direction.rawValue)") + .accessibilityLabel("Nudge \(direction.rawValue)") + } +} + +/// An offline equirectangular world map; click to choose a coordinate. +struct WorldMapView: View { + let selection: Coordinates? + let onSelect: (Coordinates) -> Void + private let image: NSImage? = ToolkitResources.worldMapURL.flatMap(NSImage.init(contentsOf:)) + + var body: some View { + GeometryReader { geometry in + let size = fittedSize(in: geometry.size) + ZStack(alignment: .topLeading) { + if let image { + Image(nsImage: image) + .resizable() + .frame(width: size.width, height: size.height) + } else { + Rectangle().fill(.quaternary).frame(width: size.width, height: size.height) + } + if let selection { + let fractions = LocationLab.mapFractions(for: selection) + Image(systemName: "mappin.circle.fill") + .font(.title2) + .foregroundStyle(.red) + .background(Circle().fill(.white).padding(3)) + .position(x: fractions.x * size.width, y: fractions.y * size.height) + .accessibilityHidden(true) + } + } + .frame(width: size.width, height: size.height) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .contentShape(Rectangle()) + .onTapGesture(coordinateSpace: .local) { point in + if let coordinates = try? LocationLab.coordinates(forMapFractionX: point.x / size.width, y: point.y / size.height) { + onSelect(coordinates) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .accessibilityElement() + .accessibilityLabel("World map. Use the latitude and longitude fields to enter a coordinate with the keyboard.") + } + + private func fittedSize(in available: CGSize) -> CGSize { + let width = min(available.width, available.height * 2) + return CGSize(width: width, height: width / 2) + } +} diff --git a/App/iOSDeveloperToolkit/Views/LogViews.swift b/App/iOSDeveloperToolkit/Views/LogViews.swift new file mode 100644 index 0000000..e40a8c6 --- /dev/null +++ b/App/iOSDeveloperToolkit/Views/LogViews.swift @@ -0,0 +1,339 @@ +import AppKit +import DeviceKit +import SwiftUI +import ToolkitCore +import ToolkitFeatures + +struct LiveLogsView: View { + @Environment(AppModel.self) private var model + @Environment(\.openWindow) private var openWindow + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + TargetHeader(allowedKinds: [.physical, .simulator]) + if let device = model.selectedDevice { + let kinds = LogStreamKind.available(for: device.kind) + HStack(spacing: 10) { + ForEach(kinds) { kind in + Button { + model.logs.start(kind, target: device.target, app: model) + } label: { + Label("Start \(kind.title)", systemImage: "play.fill") + } + .help(kind.summary) + .accessibilityIdentifier("start-\(kind.rawValue)") + } + if kinds.isEmpty { + Text("Live logs are not available for the demo device.").foregroundStyle(.secondary) + } + Spacer() + } + if !kinds.isEmpty { + Text(kinds.map(\.summary).joined(separator: " ")) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + if model.logs.sessions.isEmpty { + ContentUnavailableView("No Log Streams", systemImage: "text.alignleft", description: Text("Start a stream above. Every byte is saved to a private spool on this Mac, even while the view is paused or filtered.")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + Picker("Stream", selection: Binding(get: { model.logs.selectedSession?.id }, set: { model.logs.selectedSessionID = $0 })) { + ForEach(model.logs.sessions) { session in + Text(session.title).tag(Optional(session.id)) + } + } + .pickerStyle(.segmented) + .labelsHidden() + if let session = model.logs.selectedSession { + LogSessionView(session: session) + .frame(maxHeight: .infinity) + .toolbar { + ToolbarItem { + Button { + openWindow(id: "log-window", value: session.id) + } label: { + Label("Open in Window", systemImage: "macwindow.badge.plus") + } + .help("Open this stream in its own window") + } + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(20) + } +} + +/// The working view for one log stream. +struct LogSessionView: View { + @Environment(AppModel.self) private var model + @Bindable var session: LogSession + @State private var selection: Set = [] + @State private var isMarkingFinding = false + @State private var isReviewingFindings = false + + var body: some View { + let lines = session.visibleLines + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Label(session.state.label, systemImage: session.state.isActive ? "dot.radiowaves.left.and.right" : "stop.circle") + .foregroundStyle(session.state.isActive ? .green : .secondary) + .lineLimit(1) + Text("\(session.totalLines) lines · \(ByteFormatting.string(session.rawBytes)) saved") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + Spacer() + Toggle("Pause view", isOn: $session.isPaused) + .toggleStyle(.button) + .help("Pauses the view only; capture continues.") + Toggle("Follow", isOn: $session.followTail) + .toggleStyle(.button) + if session.state.isActive { + Button("Stop") { session.stop() } + .accessibilityIdentifier("stop-log") + } else { + Button("Close") { model.logs.close(session) } + } + } + HStack(spacing: 8) { + Image(systemName: "line.3.horizontal.decrease.circle").foregroundStyle(.secondary) + TextField("Filter", text: $session.filter.text) + .textFieldStyle(.roundedBorder) + .onChange(of: session.filter) { session.validateFilter() } + Toggle("Regex", isOn: $session.filter.isRegularExpression) + Toggle("Match case", isOn: $session.filter.isCaseSensitive) + if let error = session.filterError { + Text(error).font(.caption).foregroundStyle(.red) + } + } + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(lines.enumerated()), id: \.offset) { index, line in + LogLineRow(line: line) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .frame(maxWidth: .infinity, alignment: .leading) + .background(selection.contains(index) ? Color.accentColor.opacity(0.22) : Color.clear) + .contentShape(Rectangle()) + .onTapGesture { select(index) } + } + } + } + .frame(minHeight: 120, maxHeight: .infinity) + .font(.caption.monospaced()) + .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(.separator)) + // Keeps the newest line in view without scrolling any enclosing view. + .defaultScrollAnchor(session.followTail && !session.isPaused ? .bottom : .top) + .accessibilityLabel("Log lines") + .overlay { + if lines.isEmpty { + Text(session.state == .starting ? "Connecting…" : (session.filter.isEmpty ? "Waiting for log messages…" : "No lines match the filter.")) + .foregroundStyle(.secondary) + } + } + if let index = selection.max(), lines.indices.contains(index) { + ScrollView { + Text(lines[index].rendered) + .font(.caption.monospaced()) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 54) + .padding(6) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6)) + } + HStack { + TextField("Case or ticket reference", text: $session.investigationReference) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 220) + .onSubmit { + let capture = session.capture + let reference = session.investigationReference + Task { try? await capture.setInvestigationReference(reference) } + } + Button("Mark Finding…") { isMarkingFinding = true } + .disabled(selection.isEmpty) + Button("Findings (\(session.findings.count))") { isReviewingFindings = true } + .disabled(session.findings.isEmpty) + Spacer() + Button("Copy Visible") { Pasteboard.copy(lines.map(\.rendered).joined(separator: "\n")) } + Menu("Save") { + Button("Save Complete Raw Capture…") { saveRaw() } + Button("Save Filtered Lines…") { saveFiltered() } + Button("Export Evidence Bundle…") { exportBundle() } + Divider() + Button("Show Spool in Finder") { FilePanels.reveal(session.capture.spoolURL) } + } + .fixedSize() + } + } + .sheet(isPresented: $isMarkingFinding) { + FindingSheet(session: session, selectedText: selection.sorted().compactMap { lines.indices.contains($0) ? lines[$0].rendered : nil }.joined(separator: "\n")) + } + .sheet(isPresented: $isReviewingFindings) { + FindingsReviewSheet(findings: session.findings, capture: session.capture) + } + } + + /// Click selects one line; Shift-click extends; Command-click toggles. + private func select(_ index: Int) { + let flags = NSEvent.modifierFlags + if flags.contains(.shift), let anchor = selection.min() { + selection = Set(min(anchor, index)...max(anchor, index)) + } else if flags.contains(.command) { + if selection.contains(index) { selection.remove(index) } else { selection.insert(index) } + } else { + selection = [index] + } + } + + private func saveRaw() { + guard let url = FilePanels.save(title: "Save complete raw capture", suggestedName: session.capture.spoolURL.lastPathComponent, allowedExtension: session.kind.spoolExtension) else { return } + let capture = session.capture + Task { + do { + _ = try await capture.exportRaw(to: url) + session.hasUnsavedData = false + model.statusMessage = "Saved the raw capture with its metadata and findings." + } catch { model.present(error) } + } + } + + private func saveFiltered() { + guard let url = FilePanels.save(title: "Save filtered lines", suggestedName: "\(session.kind.rawValue)-filtered.log", allowedExtension: "log") else { return } + let capture = session.capture + let filter = session.filter + Task { + do { + let count = try await capture.exportFiltered(to: url, filter: filter) + model.statusMessage = "Saved \(count) matching lines." + } catch { model.present(error) } + } + } + + private func exportBundle() { + guard let folder = FilePanels.chooseFolder(title: "Choose a folder for the evidence bundle") else { return } + let capture = session.capture + Task { + do { + let bundle = try await capture.exportEvidenceBundle(into: folder) + session.hasUnsavedData = false + FilePanels.reveal(bundle) + } catch { model.present(error) } + } + } +} + +struct LogLineRow: View { + let line: LogLine + + var color: Color { + switch line.level?.lowercased() { + case "error": return .red + case "fault": return .purple + case "debug": return .secondary + default: return .primary + } + } + + var body: some View { + // Fixed single-line rows keep very busy streams fast to lay out; the full text of the + // selected line is shown below the list. + Text(line.rendered) + .foregroundStyle(color) + .lineLimit(1) + .truncationMode(.tail) + .help(line.rendered) + } +} + +struct FindingSheet: View { + @Environment(AppModel.self) private var model + @Environment(\.dismiss) private var dismiss + let session: LogSession + let selectedText: String + @State private var note = "" + @State private var tags = "" + @State private var assessment: FindingAssessment = .observation + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Mark Finding").font(.title2.bold()) + Text("A finding is your annotation. It is saved separately from the raw capture and is not a device-generated fact.") + .font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + RawOutputView(text: selectedText, maxHeight: 120) + Picker("Assessment", selection: $assessment) { + ForEach(FindingAssessment.allCases) { Text($0.label).tag($0) } + } + TextField("Tags (comma-separated, e.g. network, crash)", text: $tags).textFieldStyle(.roundedBorder) + TextField("Note", text: $note, axis: .vertical).lineLimit(3...6).textFieldStyle(.roundedBorder) + HStack { + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + Button("Save Finding") { save() }.keyboardShortcut(.defaultAction).disabled(note.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding(20) + .frame(width: 520) + } + + private func save() { + do { + let finding = try LiveLogFinding.make(note: note, selectedText: selectedText, stream: session.kind, target: session.target, rawBytesObserved: session.rawBytes, filter: session.filter, assessment: assessment, tags: try LiveLogFinding.parseTags(tags)) + let capture = session.capture + Task { + do { + try await capture.addFinding(finding) + session.findings.append(finding) + } catch { model.present(error) } + } + dismiss() + } catch { + model.present(error) + } + } +} + +struct FindingsReviewSheet: View { + @Environment(\.dismiss) private var dismiss + let findings: [LiveLogFinding] + let capture: LogCapture + @State private var copied = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Findings").font(.title2.bold()) + Text("Analyst annotations, kept separate from the raw device output.").foregroundStyle(.secondary) + List(findings) { finding in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(finding.assessment.label).font(.callout.weight(.semibold)) + Text(finding.createdAt.formatted(date: .omitted, time: .standard)).font(.caption).foregroundStyle(.secondary) + if !finding.tags.isEmpty { Text(finding.tags.joined(separator: ", ")).font(.caption).foregroundStyle(.blue) } + } + Text(finding.note) + Text(finding.selectedText).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(4) + } + } + .frame(minHeight: 240) + HStack { + Button("Copy Register") { + Task { + Pasteboard.copy(await capture.findingsRegister) + copied = true + } + } + .help("Copy the capture facts and every finding as Markdown") + if copied { Text("Copied.").font(.callout).foregroundStyle(.secondary) } + Spacer() + Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(width: 620, height: 440) + } +} diff --git a/App/iOSDeveloperToolkit/Views/ReadinessView.swift b/App/iOSDeveloperToolkit/Views/ReadinessView.swift new file mode 100644 index 0000000..c5e8d79 --- /dev/null +++ b/App/iOSDeveloperToolkit/Views/ReadinessView.swift @@ -0,0 +1,137 @@ +import DeviceKit +import SwiftUI +import ToolkitCore +import ToolkitFeatures + +struct ReadinessView: View { + @Environment(AppModel.self) private var model + @State private var selectedRow: CapabilityResult.ID? + @State private var isRunning = false + @State private var history: [CompatibilityObservation] = [] + + var body: some View { + WorkspacePage(workspace: .readiness) { + TargetHeader() + if let device = model.selectedDevice { + let results = model.readiness(for: device) + HStack { + Button { + isRunning = true + Task { + await model.runReadiness(for: device) + isRunning = false + history = CompatibilityStore().load() + } + } label: { + Label("Run Readiness Check", systemImage: "play.fill") + } + .keyboardShortcut("r", modifiers: [.command, .shift]) + .disabled(isRunning || device.kind == .demo) + .accessibilityIdentifier("run-readiness") + if isRunning { ProgressView().controlSize(.small) } + Spacer() + Button("Copy Report") { Pasteboard.copy(report(device, results)) } + } + Text("The check only reads state. It never mounts images, changes settings, or unlocks anything. Results are a snapshot; run it again after you change something.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Card(title: "Results", systemImage: "checklist") { + ForEach(results) { result in + ReadinessRow(result: result, isExpanded: selectedRow == result.id) { + selectedRow = selectedRow == result.id ? nil : result.id + } + if result.id != results.last?.id { Divider() } + } + } + compatibility + } + } + .onAppear { history = CompatibilityStore().load() } + } + + private var compatibility: some View { + Card(title: "Tested devices", systemImage: "rectangle.stack", subtitle: "A local history of completed checks. Devices are stored by a one-way fingerprint, never by name or UDID.") { + let latest = CompatibilityStore.latest(history) + if latest.isEmpty { + Text("No physical devices have been checked yet.").foregroundStyle(.secondary) + } else { + ForEach(latest) { observation in + HStack { + Text(observation.model).font(.callout.weight(.medium)) + Text("\(observation.osVersion ?? "—") (\(observation.buildVersion ?? "—")) · \(observation.connection)").font(.callout).foregroundStyle(.secondary) + Spacer() + let ready = observation.states.values.filter { $0 == .ready }.count + Text("\(ready)/\(observation.states.count) ready").font(.callout.monospacedDigit()) + Text(observation.observedAt, style: .date).font(.caption).foregroundStyle(.tertiary) + } + } + HStack { + Button("Export Sanitized JSON…") { export(json: true) } + Button("Export Sanitized Markdown…") { export(json: false) } + } + } + } + } + + private func export(json: Bool) { + guard let url = FilePanels.save(title: "Export readiness report", suggestedName: "readiness-report.\(json ? "json" : "md")", allowedExtension: json ? "json" : "md") else { return } + do { + let data = json ? try CompatibilityStore.renderJSON(history) : Data(CompatibilityStore.renderMarkdown(history).utf8) + try SecureFileIO.writeNewFile(data, to: url) + model.statusMessage = "Saved the sanitized report." + } catch { + model.present(error) + } + } + + private func report(_ device: Device, _ results: [CapabilityResult]) -> String { + var lines = ["Readiness Check — \(device.displayModel), \(device.displayVersion), \(device.kind.label)", "Checked: \(Date().formatted())", ""] + for result in results { + lines.append("[\(result.state.label)] \(result.layer) › \(result.title): \(result.summary)") + if !result.remediation.isEmpty { lines.append(" Next step: \(result.remediation)") } + } + return Sanitizer.sanitize(lines.joined(separator: "\n"), redactions: [device.name], limit: 20_000) + } +} + +struct ReadinessRow: View { + let result: CapabilityResult + let isExpanded: Bool + let toggle: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Button(action: toggle) { + HStack(alignment: .firstTextBaseline) { + StateBadge(state: result.state).frame(width: 150, alignment: .leading) + VStack(alignment: .leading, spacing: 2) { + Text(result.title).font(.callout.weight(.medium)) + Text(result.summary).font(.callout).foregroundStyle(.secondary).lineLimit(isExpanded ? nil : 1) + } + Spacer() + Text(result.layer).font(.caption).foregroundStyle(.tertiary) + Image(systemName: isExpanded ? "chevron.up" : "chevron.down").foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("readiness-\(result.id)") + if isExpanded { + if !result.remediation.isEmpty { + Label(result.remediation, systemImage: "lightbulb") + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, 150) + } + if !result.evidence.isEmpty { + DisclosureGroup("Evidence") { + RawOutputView(text: result.evidence, maxHeight: 140) + } + .padding(.leading, 150) + } + } + } + .padding(.vertical, 2) + } +} diff --git a/App/iOSDeveloperToolkit/Views/ReconnectGuideView.swift b/App/iOSDeveloperToolkit/Views/ReconnectGuideView.swift new file mode 100644 index 0000000..e14f03e --- /dev/null +++ b/App/iOSDeveloperToolkit/Views/ReconnectGuideView.swift @@ -0,0 +1,110 @@ +import DeviceKit +import SwiftUI +import ToolkitFeatures + +/// Guided reconnect: the steps, then a 30-second watch on device discovery with a clear outcome. +struct ReconnectGuideView: View { + @Environment(AppModel.self) private var model + @Environment(\.dismiss) private var dismiss + @State private var startedAt: Date? + @State private var now = Date() + + private var windowSeconds: Double { Double(ReconnectGuide.window.components.seconds) } + + private var outcome: ReconnectGuide.Outcome? { + guard let startedAt else { return nil } + return ReconnectGuide.evaluate(devices: model.allDevices, timeElapsed: now.timeIntervalSince(startedAt) >= windowSeconds) + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Label("Reconnect a device", systemImage: "cable.connector").font(.title2.bold()) + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(ReconnectGuide.steps.enumerated()), id: \.offset) { index, step in + Text("\(index + 1). \(step)").fixedSize(horizontal: false, vertical: true) + } + } + Text(ReconnectGuide.boundary) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + if let outcome { + GroupBox { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + if outcome.isFinished { + Image(systemName: symbol(for: outcome)).foregroundStyle(color(for: outcome)) + } else { + ProgressView().controlSize(.small) + } + Text(outcome.headline).font(.callout.weight(.semibold)) + Spacer() + if !outcome.isFinished, let startedAt { + Text("\(max(0, Int(windowSeconds - now.timeIntervalSince(startedAt))))s") + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + if let next = outcome.nextStep { + Text(next).font(.callout).fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityIdentifier("reconnect-outcome") + } + HStack { + Spacer() + Button("Close") { dismiss() } + if case .connected = outcome, let device = connectedDevice { + Button("Use \(device.name)") { + model.selectedDeviceID = device.id + dismiss() + } + .keyboardShortcut(.defaultAction) + } else { + Button(startedAt == nil ? "Start Watching" : "Watch Again") { start() } + .keyboardShortcut(.defaultAction) + .disabled(outcome.map { !$0.isFinished } ?? false) + .accessibilityIdentifier("reconnect-start") + } + } + } + .padding(22) + .frame(width: 560) + .task(id: startedAt) { + guard startedAt != nil else { return } + while !Task.isCancelled { + now = Date() + if outcome?.isFinished == true { break } + try? await Task.sleep(for: .milliseconds(500)) + } + } + } + + private var connectedDevice: Device? { + model.allDevices.first { $0.kind == .physical && $0.transports.contains(.usb) && $0.pairingState == .paired } + } + + private func start() { + startedAt = Date() + now = Date() + Task { await model.refreshDevices() } + } + + private func symbol(for outcome: ReconnectGuide.Outcome) -> String { + switch outcome { + case .connected: return "checkmark.circle.fill" + case .awaitingTrust: return "hand.tap" + case .timedOut, .waiting: return "exclamationmark.triangle" + } + } + + private func color(for outcome: ReconnectGuide.Outcome) -> Color { + switch outcome { + case .connected: return .green + case .awaitingTrust: return .blue + case .timedOut, .waiting: return .orange + } + } +} diff --git a/App/iOSDeveloperToolkit/Views/ReferenceViews.swift b/App/iOSDeveloperToolkit/Views/ReferenceViews.swift new file mode 100644 index 0000000..6250038 --- /dev/null +++ b/App/iOSDeveloperToolkit/Views/ReferenceViews.swift @@ -0,0 +1,460 @@ +import AppKit +import DeviceKit +import SwiftUI +import ToolkitCore +import ToolkitFeatures + +// MARK: - Session activity + +struct ActivityView: View { + @Environment(AppModel.self) private var model + @State private var selection: OperationRecord.ID? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(Workspace.activity.subtitle).font(.title3).foregroundStyle(.secondary) + Text("Only kept in memory for this session (up to \(OperationJournal.defaultCapacity) entries). Raw output is never stored — only its size and SHA-256. Exported manifests can contain device identifiers and paths from the command line, so review them before sharing.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Table(model.journalRecords.reversed(), selection: $selection) { + TableColumn("Finished") { Text($0.finishedAt.formatted(date: .omitted, time: .standard)).monospacedDigit() } + .width(min: 70, ideal: 80) + TableColumn("Operation", value: \.title) + TableColumn("Target", value: \.target) + TableColumn("Outcome") { record in + Text(record.outcome.label).foregroundStyle(record.outcome == .succeeded ? Color.green : (record.outcome == .cancelled ? Color.secondary : Color.orange)) + } + .width(min: 80, ideal: 100) + TableColumn("Duration") { Text(String(format: "%.1f s", Double($0.durationMilliseconds) / 1000)).monospacedDigit() } + .width(min: 60, ideal: 70) + } + .overlay { + if model.journalRecords.isEmpty { + ContentUnavailableView("Nothing Yet", systemImage: "clock", description: Text("Operations appear here as you run them.")) + } + } + if let id = selection, let record = model.journalRecords.first(where: { $0.id == id }) { + Card(title: record.title, systemImage: "doc.text.magnifyingglass") { + InfoRow("Workspace", record.workspace) + InfoRow("Mechanism", record.transport, monospaced: true) + if !record.argv.isEmpty { InfoRow("Arguments", record.argv.joined(separator: " "), monospaced: true) } + if let message = record.errorMessage { InfoRow("Error", message) } + if !record.outputPaths.isEmpty { InfoRow("Output", record.outputPaths.joined(separator: "\n"), monospaced: true) } + HStack { + Button("Copy Manifest") { if let data = try? record.manifestJSON() { Pasteboard.copy(String(decoding: data, as: UTF8.self)) } } + Button("Save Manifest…") { save(record) } + } + } + } + } + .padding(20) + } + + private func save(_ record: OperationRecord) { + guard let url = FilePanels.save(title: "Save operation manifest", suggestedName: "operation-\(record.id).json", allowedExtension: "json") else { return } + do { + try SecureFileIO.writeNewFile(try record.manifestJSON(), to: url) + } catch { + model.present(error) + } + } +} + +// MARK: - Tool reference + +struct ToolReferenceView: View { + @Environment(AppModel.self) private var model + @State private var selection: ToolReference.Topic? + @State private var expanded: [ToolReference.Topic: [ToolReference.Topic]] = [:] + @State private var helpText: [ToolReference.Topic: String] = [:] + @State private var loading = false + + var body: some View { + HStack(spacing: 0) { + List(selection: $selection) { + Section("Apple tools used by this app") { + ForEach(ToolReference.roots) { root in + topicTree(root) + } + } + Section("Toolchain") { + Button { + Task { await model.runToolchainCheck() } + } label: { + Label("Run Toolchain Check", systemImage: "checkmark.shield") + } + .buttonStyle(.plain) + } + } + .frame(width: 240) + Divider() + VStack(alignment: .leading, spacing: 10) { + if let selection { + HStack { + Text(selection.title).font(.title3.monospaced()) + Spacer() + if let command = ToolReference.advancedModeCommand(for: selection) { + Button("Use in Advanced Mode") { model.openAdvancedMode(command: command) } + .help("Open Advanced Mode with “devicectl \(command)” filled in. Nothing runs until you press Run.") + } + Button("Copy") { Pasteboard.copy(helpText[selection] ?? "") } + } + if loading { ProgressView() } + RawOutputView(text: helpText[selection] ?? "", maxHeight: .infinity) + } else if !model.toolchainReport.isEmpty { + Text("Toolchain Check").font(.title3) + RawOutputView(text: model.toolchainReport, maxHeight: .infinity) + } else { + ContentUnavailableView("Built-in Reference", systemImage: "book", description: Text("Choose a tool to read the exact help text of the version installed on this Mac. Run the Toolchain Check to confirm every command the app uses is available.")) + } + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .onChange(of: selection) { _, topic in + guard let topic, helpText[topic] == nil else { return } + load(topic) + } + } + + private func topicTree(_ topic: ToolReference.Topic) -> AnyView { + if let children = expanded[topic], !children.isEmpty { + return AnyView(DisclosureGroup { + ForEach(children) { topicTree($0) } + } label: { + Text(topic.path.last ?? topic.title).tag(topic) + }) + } + return AnyView(Text(topic.path.last ?? topic.title).tag(topic)) + } + + private func load(_ topic: ToolReference.Topic) { + let runner = model.runner + loading = true + Task { + let text = await model.run("Help for \(topic.title)", workspace: .help, target: nil, transport: "\(topic.tool.rawValue) help") { _ in + try await ToolReference.helpText(topic, runner: runner) + } + loading = false + if let text { + helpText[topic] = text + expanded[topic] = ToolReference.children(of: topic, helpText: text) + } + } + } +} + +// MARK: - Scope & safety + +struct SafetyView: View { + var body: some View { + WorkspacePage(workspace: .safety) { + Card(title: "What this app is", systemImage: "checkmark.shield") { + bullet("A macOS workbench for authorized development, testing, diagnostics, backup, and evidence preservation on devices you own or are allowed to examine.") + bullet("It uses Apple's own interfaces: the macOS device service (usbmuxd and lockdown), Xcode's CoreDevice, simctl, and Instruments.") + bullet("It runs without administrator rights and never uses sudo, never reads /var/db/lockdown, and never restarts system services.") + } + Card(title: "What it does not do", systemImage: "xmark.shield") { + bullet("No jailbreak, passcode bypass, sandbox escape, code-signing bypass, or decryption of protected data or traffic.") + bullet("Developer services (the Developer Disk Image) do not grant root access or unrestricted file system access.") + bullet("AFC and CoreDevice file views are Apple-defined windows onto specific areas, not full file system acquisitions.") + bullet("No one-click erase, restore, activation, or supervision. The only restart is a separately confirmed high-impact action.") + } + Card(title: "How changes are confirmed", systemImage: "hand.raised") { + bullet("Read-only actions run immediately.") + bullet("Actions that save files ask you to review where they go; existing files are never overwritten.") + bullet("Actions that change a device require typing RUN plus the last six characters of that device's UDID, so a confirmation cannot apply to another device.") + bullet("High-impact actions also require acknowledging a current backup and typing IRREVERSIBLE plus the same code.") + bullet("Every operation captures its target when it starts; switching the selection cannot redirect it.") + } + Card(title: "Interpreting results", systemImage: "text.magnifyingglass") { + bullet("A failed or unavailable step is a coverage gap, not proof that something is absent.") + bullet("Process, network, profile, and log observations need context before drawing conclusions.") + bullet("Hashes detect later changes; they do not prove when, where, or by whom something was collected.") + bullet("Findings you mark in logs are your annotations, kept separate from device output.") + } + Card(title: "Privacy", systemImage: "lock") { + bullet("Nothing is uploaded. Captures, backups, cases, and reports stay on this Mac with owner-only permissions.") + bullet("The app's own log records outcomes, not device content; identifiers are marked private in the unified log.") + bullet("Support bundles and compatibility reports are sanitized, but review them before sharing.") + } + } + } + + private func bullet(_ text: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("•") + Text(text).fixedSize(horizontal: false, vertical: true) + } + } +} + +// MARK: - Settings + +struct SettingsView: View { + @Environment(AppModel.self) private var model + @State private var profileMessage: String? + + var body: some View { + @Bindable var model = model + TabView { + Form { + Toggle("Demo Mode (show a simulated iPhone)", isOn: $model.demoMode) + LabeledContent("Backups folder", value: model.backup.destination.path) + LabeledContent("Evidence cases folder", value: model.evidence.outputRoot.path) + LabeledContent("Location event log", value: model.location.evidenceDirectory.path) + LabeledContent("Live log spool", value: LogCapture.defaultDirectory().path) + } + .padding() + .tabItem { Label("General", systemImage: "gearshape") } + + Form { + Text("A workspace profile shares workflow defaults with your team. It never contains device identity, paths, coordinates, passwords, or captured data, and importing one only changes defaults. Profiles exported by version 0.3.x can be imported too.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack { + Button("Export Profile…") { exportProfile() } + Button("Import Profile…") { importProfile() } + } + if let profileMessage { Text(profileMessage).font(.callout) } + } + .padding() + .tabItem { Label("Profiles", systemImage: "person.2") } + + Form { + if let tools = model.developerTools { + LabeledContent("Developer directory", value: tools.developerDirectory ?? "Not set") + LabeledContent("Xcode", value: tools.xcodeVersion ?? "Not installed") + LabeledContent("devicectl", value: tools.devicectl.isAvailable ? "Available" : "Missing") + LabeledContent("simctl", value: tools.simctl.isAvailable ? "Available" : "Missing") + LabeledContent("xctrace", value: tools.xctrace.isAvailable ? "Available" : "Missing") + } else { + ProgressView() + } + Text("Features that need Xcode: developer services, screenshots, location simulation on iOS 17+, launching apps, Instruments, simulators. Everything that uses USB services (logs, backups, diagnostics, apps, packet capture) works without Xcode.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding() + .tabItem { Label("Developer Tools", systemImage: "hammer") } + } + .frame(width: 560, height: 320) + } + + private func exportProfile() { + var profile = WorkspaceProfile(name: "Team defaults", defaultWorkspace: model.workspace, actionCategory: model.actionsCategory, selectedAction: model.selectedActionID, developerImageMechanism: model.developerImage.mechanism) + profile.apps = .init(calculateSizes: model.apps.calculateSizes, includeSystemApps: model.apps.includeSystemApps, installAsDeveloperPackage: model.install.installAsDeveloperPackage) + profile.backup = .init(forceFullBackup: model.backup.forceFullBackup, requireEncryption: model.backup.requireEncryption) + profile.evidence = model.evidence.options + profile.location = .init(timingJitterMilliseconds: model.location.jitterMilliseconds, ignoreRecordedTiming: model.location.ignoreRecordedTiming, routeSpeedKmh: Int(model.location.speedKmh), routeIntervalSeconds: model.location.routeIntervalSeconds, routeTraversals: model.location.routeTraversals) + guard let url = FilePanels.save(title: "Export workspace profile", suggestedName: "workspace-profile.json", allowedExtension: "json") else { return } + do { + try SecureFileIO.writeNewFile(try profile.encoded(), to: url) + profileMessage = "Exported.\n" + profile.preview + } catch { + model.present(error) + } + } + + private func importProfile() { + guard let url = FilePanels.chooseFile(title: "Import workspace profile", allowedExtensions: ["json"]) else { return } + do { + let imported = try WorkspaceProfile.importing(try Data(contentsOf: url)) + let profile = imported.profile + let alert = NSAlert() + alert.messageText = "Apply “\(profile.name)”?" + let translation = imported.legacyVersion.map { version in + "\n\nThis profile was exported by version \(version). How its settings carry over:\n" + imported.notes.map { "• \($0)" }.joined(separator: "\n") + } ?? "" + alert.informativeText = profile.preview + translation + "\n\nOnly defaults change. Nothing runs." + alert.addButton(withTitle: "Apply") + alert.addButton(withTitle: "Cancel") + guard alert.runModal() == .alertFirstButtonReturn else { return } + guard model.operations.isEmpty else { throw ToolkitError.invalidInput("Wait for running operations to finish before importing a profile.") } + model.workspace = profile.defaultWorkspace + model.actionsCategory = profile.actionCategory + model.selectedActionID = profile.selectedAction + if let mechanism = profile.developerImageMechanism { model.developerImage.mechanism = mechanism } + model.apps.calculateSizes = profile.apps.calculateSizes + model.apps.includeSystemApps = profile.apps.includeSystemApps + model.install.installAsDeveloperPackage = profile.apps.installAsDeveloperPackage + model.backup.forceFullBackup = profile.backup.forceFullBackup + model.backup.requireEncryption = profile.backup.requireEncryption + model.evidence.options = profile.evidence + model.location.jitterMilliseconds = profile.location.timingJitterMilliseconds + model.location.ignoreRecordedTiming = profile.location.ignoreRecordedTiming + model.location.travelPreset = .custom + model.location.customSpeedKmh = Double(profile.location.routeSpeedKmh) + model.location.routeIntervalSeconds = profile.location.routeIntervalSeconds + model.location.routeTraversals = profile.location.routeTraversals + profileMessage = imported.legacyVersion == nil ? "Applied “\(profile.name)”." : "Applied “\(profile.name)” from version \(imported.legacyVersion ?? "")." + } catch { + model.present(error) + } + } +} + +// MARK: - Diagnostic log + +struct DiagnosticLogView: View { + @Environment(AppModel.self) private var model + @State private var entries: [DiagnosticLogEntry] = [] + @State private var search = "" + @State private var error: String? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("The app's own log (last hour). Device identifiers appear as .").foregroundStyle(.secondary) + Spacer() + TextField("Filter", text: $search).textFieldStyle(.roundedBorder).frame(width: 200) + Button("Refresh", action: load) + Button("Export Sanitized…", action: export) + } + if let error { Text(error).foregroundStyle(.red) } + Table(entries.filter { search.isEmpty || $0.message.localizedCaseInsensitiveContains(search) || $0.category.localizedCaseInsensitiveContains(search) }) { + TableColumn("Time") { Text($0.date.formatted(date: .omitted, time: .standard)).monospacedDigit() }.width(80) + TableColumn("Level", value: \.level).width(60) + TableColumn("Category", value: \.category).width(130) + TableColumn("Message", value: \.message) + } + } + .padding(12) + .onAppear(perform: load) + } + + private func load() { + do { + entries = try DiagnosticLogReader.recentEntries().reversed() + error = nil + } catch { + self.error = "The log could not be read: \(error.localizedDescription)" + } + } + + private func export() { + guard let url = FilePanels.save(title: "Export diagnostic log", suggestedName: "iOS-Developer-Toolkit-log.txt", allowedExtension: "txt") else { return } + let redactions = model.allDevices.flatMap { [$0.name, $0.udid, $0.serialNumber ?? ""] } + do { + try SecureFileIO.writeNewFile(Data(Sanitizer.sanitize(DiagnosticLogReader.render(entries.reversed()), redactions: redactions, limit: 5_000_000).utf8), to: url) + } catch { + model.present(error) + } + } +} + +// MARK: - Command palette + +struct CommandPaletteView: View { + @Environment(AppModel.self) private var model + @Environment(\.dismiss) private var dismiss + @State private var query = "" + @FocusState private var focused: Bool + + private struct Entry: Identifiable { + let id: String + let title: String + let subtitle: String + let symbol: String + let perform: () -> Void + } + + private var entries: [Entry] { + var list: [Entry] = Workspace.allCases.map { workspace in + Entry(id: "workspace-\(workspace.rawValue)", title: workspace.title, subtitle: workspace.subtitle, symbol: workspace.symbolName) { model.workspace = workspace } + } + list += ActionCatalog.actions(for: model.selectedDevice?.kind).map { action in + Entry(id: "action-\(action.id)", title: action.title, subtitle: "\(action.category) · \(action.risk.label)", symbol: action.risk.symbolName) { + model.openAction(action.id) + } + } + list.append(Entry(id: "refresh", title: "Refresh Devices", subtitle: "Look for devices again", symbol: "arrow.clockwise") { Task { await model.refreshDevices() } }) + if let device = model.selectedDevice, device.kind != .demo { + list.append(Entry(id: "readiness", title: "Run Readiness Check", subtitle: device.name, symbol: "checklist") { + model.workspace = .readiness + Task { await model.runReadiness(for: device) } + }) + } + for device in model.allDevices { + list.append(Entry(id: "device-\(device.id)", title: "Select \(device.name)", subtitle: "\(device.kind.label) · \(device.displayVersion)", symbol: device.family.symbolName) { model.selectedDeviceID = device.id }) + } + guard !query.isEmpty else { return list } + return list.filter { $0.title.localizedCaseInsensitiveContains(query) || $0.subtitle.localizedCaseInsensitiveContains(query) } + } + + var body: some View { + VStack(spacing: 0) { + TextField("Search workspaces, actions, and devices", text: $query) + .textFieldStyle(.plain) + .font(.title3) + .padding(14) + .focused($focused) + .onSubmit { entries.first.map(choose) } + .accessibilityIdentifier("palette-search") + Divider() + List(entries) { entry in + Button { choose(entry) } label: { + HStack { + Image(systemName: entry.symbol).frame(width: 22) + VStack(alignment: .leading) { + Text(entry.title) + Text(entry.subtitle).font(.caption).foregroundStyle(.secondary) + } + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .listStyle(.plain) + } + .frame(width: 560, height: 420) + .onAppear { focused = true } + .onExitCommand { dismiss() } + } + + private func choose(_ entry: Entry) { + dismiss() + entry.perform() + } +} + +// MARK: - Support bundle + +@MainActor +enum SupportBundleExporter { + static func export(model: AppModel) { + guard let url = FilePanels.save(title: "Create support bundle", suggestedName: "iOS-Developer-Toolkit-support.zip", allowedExtension: "zip") else { return } + let device = model.selectedDevice + var counts: [String: Int] = [:] + for result in model.readiness(for: device) { counts[result.state.rawValue, default: 0] += 1 } + var tools: [String: String] = [:] + if let status = model.developerTools { + tools["xcode"] = status.xcodeVersion ?? "not installed" + tools["devicectl"] = status.devicectl.isAvailable ? "available" : "missing" + tools["simctl"] = status.simctl.isAvailable ? "available" : "missing" + tools["xctrace"] = status.xctrace.isAvailable ? "available" : "missing" + } + let context = SupportBundleContext( + workspace: model.workspace.title, + detectedDeviceCount: model.snapshot.devices.count, + selectedDeviceKind: device?.kind.rawValue, + discoveryStatus: ["usbmux": model.snapshot.usbmux.summary, "coreDevice": model.snapshot.coreDevice.summary, "simulators": model.snapshot.simulators.summary], + capabilityCounts: counts, + toolchainReport: model.toolchainReport, + developerTools: tools, + statuses: ["lastStatus": model.statusMessage ?? ""], + redactions: model.allDevices.flatMap { [$0.name, $0.udid, $0.serialNumber ?? "", $0.ecid ?? ""] }, + diagnosticLog: (try? DiagnosticLogReader.recentEntries()) ?? [] + ) + do { + try SupportBundle.write(to: url, context: context) + FilePanels.reveal(url) + } catch { + model.present(error) + } + } +} diff --git a/App/iOSDeveloperToolkit/Views/ShortcutReferenceView.swift b/App/iOSDeveloperToolkit/Views/ShortcutReferenceView.swift new file mode 100644 index 0000000..2f426a6 --- /dev/null +++ b/App/iOSDeveloperToolkit/Views/ShortcutReferenceView.swift @@ -0,0 +1,39 @@ +import SwiftUI +import ToolkitFeatures + +/// Help › Keyboard Shortcuts (⌘/). +struct ShortcutReferenceView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Label("Keyboard Shortcuts", systemImage: "keyboard").font(.title2.bold()) + ScrollView { + VStack(alignment: .leading, spacing: 14) { + ForEach(KeyboardShortcutReference.sections) { section in + VStack(alignment: .leading, spacing: 6) { + Text(section.title).font(.headline) + ForEach(section.entries) { entry in + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(entry.keys) + .font(.callout.monospaced()) + .frame(width: 70, alignment: .leading) + Text(entry.title).font(.callout) + } + .accessibilityElement(children: .combine) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + HStack { + Spacer() + Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(width: 480, height: 640) + .accessibilityIdentifier("shortcut-reference") + } +} diff --git a/App/iOSDeveloperToolkit/iOSDeveloperToolkit.entitlements b/App/iOSDeveloperToolkit/iOSDeveloperToolkit.entitlements new file mode 100644 index 0000000..6631ffa --- /dev/null +++ b/App/iOSDeveloperToolkit/iOSDeveloperToolkit.entitlements @@ -0,0 +1,6 @@ + + + + + + diff --git a/App/iOSDeveloperToolkit/iOSDeveloperToolkitApp.swift b/App/iOSDeveloperToolkit/iOSDeveloperToolkitApp.swift new file mode 100644 index 0000000..6737499 --- /dev/null +++ b/App/iOSDeveloperToolkit/iOSDeveloperToolkitApp.swift @@ -0,0 +1,132 @@ +import SwiftUI +import ToolkitCore +import ToolkitFeatures + +@main +struct IOSDeveloperToolkitApp: App { + @State private var model = AppModel() + @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate + + var body: some Scene { + WindowGroup("iOS Developer Toolkit", id: "main") { + ContentView() + .environment(model) + .frame(minWidth: 900, minHeight: 560) + .onAppear { + delegate.model = model + model.start() + ScreenshotHarness.runIfRequested(model: model) + } + } + // Fits a 13-inch MacBook Air (1280×800 points) with room for the Dock and menu bar. + .defaultSize(width: 1180, height: 700) + .windowResizability(.contentMinSize) + .commands { + SidebarCommands() + ToolkitCommands(model: model) + } + + WindowGroup("Live Log", id: "log-window", for: UUID.self) { $sessionID in + if let sessionID, let session = model.logs.sessions.first(where: { $0.id == sessionID }) { + LogSessionView(session: session) + .environment(model) + .frame(minWidth: 640, minHeight: 400) + } else { + ContentUnavailableView("Log Closed", systemImage: "text.alignleft", description: Text("This log window's capture is no longer open.")) + } + } + .defaultSize(width: 900, height: 600) + + Window("Diagnostic Log", id: "diagnostic-log") { + DiagnosticLogView() + .environment(model) + .frame(minWidth: 640, minHeight: 360) + } + .defaultSize(width: 860, height: 520) + + Settings { + SettingsView() + .environment(model) + } + } +} + +final class AppDelegate: NSObject, NSApplicationDelegate { + weak var model: AppModel? + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + MainActor.assumeIsolated { + guard let model else { return .terminateNow } + let busy = !model.operations.isEmpty || model.logs.sessions.contains { $0.state.isActive } + if busy { + let alert = NSAlert() + alert.messageText = "Operations are still running" + alert.informativeText = "Quitting stops them. Live log captures are saved and finalized; backups and evidence collections that are still running will be incomplete." + alert.addButton(withTitle: "Quit Anyway") + alert.addButton(withTitle: "Cancel") + guard alert.runModal() == .alertFirstButtonReturn else { return .terminateCancel } + } + if let target = model.location.lastSimulatedTarget { + // Leave devices as we found them: clear a location this session simulated. + let controller = model.executor.location + let semaphore = DispatchSemaphore(value: 0) + Task.detached { + try? await controller.clear(on: target) + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 8) + } + model.stop() + return .terminateNow + } + } +} + +struct ToolkitCommands: Commands { + let model: AppModel + @Environment(\.openWindow) private var openWindow + + var body: some Commands { + CommandGroup(after: .appInfo) { + Button("Create Support Bundle…") { SupportBundleExporter.export(model: model) } + } + CommandMenu("Device") { + Button("Refresh Devices") { Task { await model.refreshDevices() } } + .keyboardShortcut("r", modifiers: .command) + Button("Run Readiness Check") { + if let device = model.selectedDevice { + model.workspace = .readiness + Task { await model.runReadiness(for: device) } + } + } + .keyboardShortcut("r", modifiers: [.command, .shift]) + .disabled(model.selectedDevice == nil) + Divider() + Button("Reconnect a Device…") { model.isReconnectGuidePresented = true } + Button("Developer Mode Guide") { model.isDeveloperModeGuidePresented = true } + Toggle("Demo Mode", isOn: Binding(get: { model.demoMode }, set: { model.demoMode = $0 })) + } + CommandGroup(after: .sidebar) { + Button("Command Palette…") { model.isCommandPalettePresented = true } + .keyboardShortcut("k", modifiers: .command) + Divider() + Button("Previous Workspace") { model.workspace = model.workspace.previous } + .keyboardShortcut(.leftArrow, modifiers: [.command, .option]) + Button("Next Workspace") { model.workspace = model.workspace.next } + .keyboardShortcut(.rightArrow, modifiers: [.command, .option]) + Divider() + ForEach(Array(Workspace.numbered.enumerated()), id: \.element) { index, workspace in + Button(workspace.title) { model.workspace = workspace } + .keyboardShortcut(KeyEquivalent(Character("\(index + 1)")), modifiers: .command) + } + } + CommandGroup(after: .help) { + Button("Keyboard Shortcuts") { model.isShortcutReferencePresented = true } + .keyboardShortcut("/", modifiers: .command) + Button("Diagnostic Log") { openWindow(id: "diagnostic-log") } + Button("Scope & Safety") { model.workspace = .safety } + } + } +} diff --git a/CITATION.cff b/CITATION.cff index 26c818d..f418596 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -7,14 +7,14 @@ authors: repository-code: "https://github.com/hideouts-io/iOS-Developer-Toolkit" url: "https://github.com/hideouts-io/iOS-Developer-Toolkit/releases/latest" license: MIT -version: 0.3.4 -date-released: "2026-09-21" -abstract: "A safety-focused macOS workbench for Developer Disk Images, pymobiledevice3 and DVT diagnostics, iOS logs, packet capture, location simulation, app inspection, backups, and evidence preservation." +version: 1.0.0 +abstract: "A native Swift/SwiftUI macOS app for iPhone, iPad, and simulator development and diagnostics: device readiness, live Unified and classic logs, location simulation, app installation, encrypted backups, packet capture, and hashed evidence collection." keywords: - iOS development - - pymobiledevice3 - - Developer Disk Image + - macOS + - Swift - device diagnostics - - mobile forensics - unified logging - packet capture + - location simulation + - mobile forensics diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bec2d05..98293cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,72 +1,103 @@ # Contributing -Contributions that make authorized iOS development, diagnostics, testing, backup, and evidence workflows safer and easier to understand are welcome. The project is open to everyone through public issues, Discussions, forks, and pull requests, including first-time contributors. +Contributions that make authorized iOS development, diagnostics, testing, backup, and evidence +workflows safer and easier to understand are welcome, including from first-time contributors. -## Start with the intended scope +## Scope -iOS Developer Toolkit is a guided macOS front end around supported Apple-device service paths and pinned `pymobiledevice3` commands. It does not aim to jailbreak devices, bypass a passcode or activation, evade code signing, remove supervision, decrypt protected traffic, or expose unrestricted filesystem access. +iOS Developer Toolkit is a native macOS app that uses Apple's device services (usbmuxd and +lockdown), Xcode's CoreDevice, `simctl`, and Instruments. It does not aim to jailbreak devices, +bypass a passcode or activation, defeat code signing, remove supervision, decrypt protected +traffic, or expose unrestricted file-system access. -Use only devices and data you own or are explicitly authorized to test. Never submit real UDIDs, serial numbers, phone numbers, Apple Account data, coordinates, pairing records, backup contents, packet payloads, profiles, certificates, crash contents, or evidence cases. +Use only devices and data you own or are authorized to test. Never submit UDIDs, serial numbers, +phone numbers, Apple Account data, coordinates, pairing records, backup contents, packet payloads, +profiles, certificates, crash report contents, or evidence. -Use a bug report for reproducible defects, a feature request for a bounded workflow, and Discussions for setup or compatibility questions. Report vulnerabilities through the repository's private security reporting form. +Use a bug report for reproducible defects, a feature request for a new workflow, Discussions for +questions, and private vulnerability reporting for security issues. -## Development setup +## Setup -The supported development host is macOS 13 or later with Python 3.10 or later. Create an isolated environment and install the project in editable mode: +Requirements: macOS 14 or later and Xcode 16 or later (Swift 6). ```bash -python3 -m venv venv -venv/bin/python -m pip install --disable-pip-version-check --upgrade pip -venv/bin/python -m pip install --disable-pip-version-check --editable . +git clone https://github.com/hideouts-io/iOS-Developer-Toolkit.git +cd iOS-Developer-Toolkit +swift build +swift test +open iOSDeveloperToolkit.xcodeproj # run the iOSDeveloperToolkit scheme ``` -Launch from source with: +The Xcode project is generated from `project.yml`. After adding, removing, or moving files in +`App/`, or changing build settings, run `xcodegen generate` +([XcodeGen](https://github.com/yonaskolb/XcodeGen)) and commit the updated project. Code in +`Sources/` is picked up by SwiftPM automatically. + +Turn on **Device › Demo Mode** to work on the UI without a device. + +## Design rules + +- **Swift 6 strict concurrency, zero warnings.** CI builds with warnings as errors. +- **One process runner.** Never create a `Process` outside `CommandRunner` in ToolkitCore. Pass + arguments as a vector; never use a shell, `sh -c`, or string interpolation into commands. +- **No privilege escalation.** No `sudo`, no reading `/var/db/lockdown`, no restarting system + services, no disabling macOS protections. +- **Apple mechanisms first.** Prefer native protocol code or Apple's own tools. Do not add hidden + calls to third-party command-line tools; optional external tools belong on the External Tools + page with path and hash validation. +- **Explicit targets.** Operations take a `DeviceTarget` when they start and never read the + current selection later. Keep physical devices and simulators separate. +- **Accurate risk.** Classify every action (read-only, saves files, changes the device, high + impact) and require the matching confirmation. +- **Actionable errors.** Throw `ToolkitError` with a plain-language message, a recovery step, + and technical detail. Log through `ToolkitLog` with identifiers marked private. +- **Untrusted input.** Validate everything from devices, archives, and files at the boundary. +- **Evidence integrity.** Keep raw captures separate from filtered output and notes; record + failures as coverage gaps, never as success. + +## Checks before a pull request ```bash -venv/bin/python -m ios_developer_toolkit +swift build -Xswiftc -warnings-as-errors +swift test +xcodebuild -project iOSDeveloperToolkit.xcodeproj -scheme iOSDeveloperToolkit \ + -destination 'platform=macOS' SWIFT_TREAT_WARNINGS_AS_ERRORS=YES build ``` -Do not run launch or device tests during an active backup, acquisition, location simulation, packet capture, or log collection. +Also, when relevant: -## Design expectations +- **UI changes:** run the UI tests (`xcodebuild … test`; macOS asks once to allow UI + automation) and render every page at the default and minimum sizes, as CI does: -- Keep changes small and match the existing typed, function-oriented Python style. -- Reuse existing command, validation, runtime, and capability models before adding another path. -- Pass subprocess arguments as an argument vector; do not add shell evaluation. -- Validate external data and raise specific, actionable errors. -- Keep device selection explicit. Never silently switch to another connected target. -- Classify device mutations accurately and require confirmation for destructive or state-changing actions. -- Preserve complete raw evidence separately from filtered or formatted derivatives. -- Treat unavailable services and partial collection as coverage results, not success. -- Keep UFADE and other differently licensed tools isolated rather than importing their source or dependencies. -- Add dependencies to `pyproject.toml`, pin release-critical dependencies, and explain the need in the pull request. + ```bash + scripts/check-layout.sh "…/iOS Developer Toolkit.app/Contents/MacOS/iOS Developer Toolkit" /tmp/layout + ``` -## Verification + It fails if any page is squeezed, overflows the window, or does not render; the PNGs are in + `/tmp/layout` for review. +- **Simulator code:** `IDT_SIMULATOR_TESTS=1 swift test --filter RealSimulator`. +- **Device protocol code:** add a test against the fake device in `Tests/DeviceTestSupport`, and + if you can, run the relevant part of [docs/PHYSICAL_DEVICE_TEST_PROTOCOL.md](docs/PHYSICAL_DEVICE_TEST_PROTOCOL.md). + State the device family, iOS version, and connection you tested — no identifiers. +- **Intel (x86_64):** with Rosetta 2 installed, build the tests for x86_64 and run each bundle + with the universal `xctest` (SwiftPM's own test helper is arm64-only): -Run the complete local checks before opening a pull request: + ```bash + swift build --build-tests --arch x86_64 --scratch-path build-output/x86-tests + for t in ToolkitCoreTests DeviceKitTests ToolkitFeaturesTests; do + arch -x86_64 xcrun xctest build-output/x86-tests/out/Products/Debug/$t.xctest + done + ``` -```bash -venv/bin/python -m unittest discover -s tests -v -venv/bin/python -m compileall -q ios_developer_toolkit tests -venv/bin/python -m ios_developer_toolkit.collector --help -venv/bin/python -m ios_developer_toolkit.local_ddi --help -venv/bin/python -m ios_developer_toolkit.ipa_inspector --help -QT_QPA_PLATFORM=offscreen venv/bin/python -m ios_developer_toolkit --toolkit-internal-smoke-test -``` - -Documentation changes must also pass the strict site build: - -```bash -venv/bin/python -m pip install --requirement requirements/docs.txt -venv/bin/python -m mkdocs build --strict --clean -``` - -Prefer a real, authorized integration check when the change touches device discovery, pairing, developer services, DDI handling, tunnels, backup, installation, location simulation, logging, or packet capture. State exactly which host, device family, OS version, connection path, and cleanup action were tested, without publishing a unique identifier. - -Changes to packaging must additionally build the native app, run its embedded CLI and GUI smoke checks, verify the expected Mach-O architecture, and pass `codesign --verify --deep --strict`. The app must contain a matching `Contents/Resources/BOM.cdx.json`, `SOURCE_AVAILABILITY.md`, and the generated `Contents/Resources/Licenses/` inventory; `scripts/verify_release_metadata.py` enforces those links. Release assets must remain separate for Apple Silicon and Intel until a verified universal build exists, and the release workflow must retain checksums plus build-provenance and SBOM attestations. + `scripts/build-release.sh` also runs the Intel `idt` when Rosetta is available. +- **Release packaging:** `scripts/build-release.sh` must succeed (it builds the version in `ToolkitVersion.swift` into `build-output/release/`). ## Pull requests -Open a focused pull request against `main`. Explain the problem, the behavior change, validation performed, device coverage, privacy impact, and any remaining limitation. CI must pass before merge. Screenshots and logs must use synthetic or thoroughly sanitized data. +Open a focused pull request against `main`. Explain the problem, the change, how you verified it, +device coverage, privacy impact, and any remaining limitation. CI must pass. Screenshots and logs +must use Demo Mode, a simulator, or thoroughly sanitized data. -By contributing, you agree that your contribution is licensed under the repository's MIT License and that community participation follows the Code of Conduct. +By contributing, you agree that your contribution is licensed under the repository's MIT License +and that participation follows the [Code of Conduct](CODE_OF_CONDUCT.md). diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..d75195d --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,468 @@ +# Python → Swift migration record + +This document tracks the rewrite of iOS Developer Toolkit from the PySide6 / `pymobiledevice3` +application (v0.3.4) to a native Swift/SwiftUI macOS application. It is the working checklist +for the migration and is updated as each feature is migrated, tested, and verified. + +Status legend: ✅ migrated and verified end to end (real simulator, real Xcode tools, or the app +itself) · 🟡 migrated and tested against the protocol-accurate fake device or recorded tool output; +needs physical-device verification · 🔁 replaced by a different Apple-supported mechanism · ❌ not +migrated (see §6) or removed + +## 1. Audit of the Python application (v0.3.4) + +### 1.1 Repository inventory + +| Area | Files | Notes | +|---|---|---| +| GUI | `ios_developer_toolkit/app.py` (7,795 lines), `gui_pages.py`, `live_logs.py` (pop-out windows), `action_palette.py`, `operation_history.py` | PySide6 widgets, 13 workspaces, QProcess controllers | +| Entry points | `__main__.py`, `entrypoint.py`, `packaging/main.py`, `macos/iOSDeveloperToolkit` launcher | Frozen-runtime dispatch for internal workers and the embedded `pymobiledevice3` CLI | +| CLI tools | `collector.py` (`ios-developer-collect`), `local_ddi.py` (`ios-local-ddi`), `ipa_inspector.py` (`ios-ipa-inspect`) | argparse-based | +| Device access | Every device operation shells out to the `pymobiledevice3` CLI (pinned 11.15.1) | No in-process protocol code | +| Process control | `qt_process.py`, `interactive_process.py`, `backup_process.py`, `collection_process.py`, `capability_matrix_worker.py` | Five separate QProcess lifecycle controllers | +| External providers | `external_tools.py` (go-ios, idb, ipsw), `mvt_connector.py`, `ufade_connector.py` | User-installed executables validated by path + SHA-256 | +| Pure logic | `command_catalog.py`, `catalog.py`, `action_safety.py`, `location_lab.py`, `installed_apps.py`, `case_workflow.py`, `device_compatibility.py`, `workspace_profile.py`, `support_bundle.py`, `connection_diagnostics.py`, `command_drift.py`, `demo_mode.py`, `validation.py`, `file_integrity.py`, `models.py`, `runtime.py`, `xcode_handoff.py` | Ported feature-by-feature | +| Tests | `tests/` — 17 unittest modules (133 tests) + a 138-button GUI smoke test inside `entrypoint.py` | Used as the behavioural reference for the Swift tests | +| Packaging | `scripts/build_macos_release.sh` (Nuitka), `packaging/pysidedeploy.spec`, `macos/Info.plist`, `scripts/verify_*.py`, `scripts/collect_third_party_licenses.py` | Nuitka-frozen Python bundle, ad-hoc signed | +| CI | `.github/workflows/ci.yml`, `frozen-macos-smoke.yml`, `release-macos.yml`, `docs.yml`, `codeql.yml`, `dependency-review.yml`, `dependabot.yml` | Python-based | +| Docs | `README.md` (1,224 lines), `docs/*.md`, `mkdocs.yml`, 22 screenshots in `docs/screenshots/` | Heavily `pymobiledevice3`-specific | +| Assets | `assets/iosdevtoolkit.png` (logo), `assets/location-world-map.png` (Natural Earth, public domain), `macos/iOSDeveloperToolkit.icns` | Reused | +| Config | `pyproject.toml`, `requirements/docs.txt`, `requirements/release-sbom.txt`, `.gitignore` | | + +Secrets scan: no private keys, tokens, or credentials are committed. + +### 1.2 Privilege model + +The Python application never used `sudo`. Discovery polled `pymobiledevice3 usbmux list` every +3 seconds (a Python process launch per poll). The Swift version keeps the no-privilege model +and replaces polling with usbmuxd's `Listen` event stream. + +### 1.3 go-ios and blacktop/ipsw + +Both were **optional, user-installed adapters** in the *Ecosystem Tools* workspace +(`external_tools.py`). Each adapter validated an executable and ran exactly one read-only probe +(`ios list --details`, `ipsw idev list`). No other feature depended on them. Both adapters, +their tests, documentation, GUI tab, smoke-test steps, and README/third-party notice entries +are removed. Their only capability — listing connected devices — is covered natively by the +Swift device discovery (usbmuxd + CoreDevice + simctl), so nothing is lost. + +## 2. Feature inventory and migration map + +`pmd3` = `pymobiledevice3`. "Native lockdown" = the Swift usbmuxd/lockdown client in +`DeviceKit` (no external tools). "CoreDevice" = Apple's `xcrun devicectl` JSON interface. + +| # | Feature (Python) | Python implementation | Swift implementation | Apple API? | go-ios/ipsw? | Status | +|---|---|---|---|---|---|---| +| 1 | Device discovery | `pmd3 usbmux list` polled every 3 s | usbmuxd `Listen` event stream (event-driven) + CoreDevice `list devices` + `simctl list` | usbmuxd socket, devicectl, simctl | No | ✅ simulators · 🟡 physical | +| 2 | Device identity (name, model, iOS, build, UDID, connection) | `usbmux list` / `lockdown info` | Native lockdown `GetValue` + CoreDevice details, with plain-language explanations | Yes | No | 🟡 | +| 3 | Developer Mode status + on-device guide | `pmd3 amfi developer-mode-status` | Native lockdown (`com.apple.security.mac.amfi`) and CoreDevice `developerModeStatus`; guide sheet | Yes | No | 🟡 | +| 4 | Developer image mount — personalized (iOS 17+) and DeveloperDiskImage (iOS ≤ 16) | `pmd3 mounter auto-mount` (TSS; images from a third-party mirror) | Native `mobile_image_mounter` client + Apple TSS personalization, using the image Xcode installs or a user folder; or CoreDevice `ddiServices --auto-mount-ddis`. State model, no remount, error mapping (§8) | Yes (devicectl) + private lockdown service | No | 🟡 | +| 5 | Local Xcode DDI Cryptex install | `hdiutil` + `pmd3 cryptex auto-install` | 🔁 `devicectl manage ddis update` + `list preferredDDI` (host DDI store managed by Apple) | devicectl | No | 🔁 🟡 (Cryptex route replaced by devicectl and the native personalized mount of the same Xcode image) | +| 6 | Mounted image list / lookup / unmount | `pmd3 mounter list/lookup/umount` | Native `mobile_image_mounter` (`CopyDevices`, `LookupImage`, `UnmountImage` for `/System/Developer` and `/Developer`) | Lockdown service | No | 🟡 | +| 7 | CoreDevice details, RVI list, open project (`xed`), open .xcresult/.trace | `xcrun`, `rvictl`, `xed`, `open` | Same Apple tools through the central `CommandRunner` | Yes | No | 🟡 | +| 8 | Capability Matrix | Worker running `pmd3` probes | Native probes (usbmuxd, pair record, lockdown session, AMFI, image mounter) + CoreDevice probes (details, lock state, DDI services) + Xcode tools | Yes | No | ✅ simulators · 🟡 physical | +| 9 | Real-device compatibility history + sanitized JSON/Markdown export | `device_compatibility.py` | Ported (`CompatibilityStore`) | Foundation, CryptoKit | No | ✅ | +| 10 | Location Lab (coordinate, nudge, saved places, offline map, map-link parsing, route generator, GPX inspection/replay, evidence log, clear) | `pmd3 developer dvt simulate-location` | Physical: CoreDevice `simulate location coordinate/route/clear`; Simulator: `simctl location`; GPX replay driven by the app; offline MapKit-free world map | devicectl, simctl | No | ✅ simulators · 🟡 physical | +| 11 | Live Logs — Unified | `pmd3 syslog live --format json` (os_trace_relay) | Native `com.apple.os_trace_relay` client; Simulator: `simctl spawn log stream --style ndjson` | Lockdown service / simctl | No | ✅ simulators · 🟡 physical | +| 12 | Live Logs — Classic syslog | `pmd3 syslog live-old` | Native `com.apple.syslog_relay` client | Lockdown service | No | 🟡 | +| 13 | Live Logs — DVT OSLog | `pmd3 developer dvt oslog` | 🔁 Covered by #11 (os_trace_relay needs no DDI); DVT/DTX is not an Apple-public interface | — | No | 🔁 🟡 | +| 14 | Live log spool, pause, filter (literal/regex/case), findings, review, raw/filtered save, evidence bundle, metadata sidecar | `live_logs.py` | Ported (`LogCapture`, `FindingsStore`, `InvestigationReport`) | Foundation | No | ✅ | +| 15 | Command Center — 49 `pmd3` presets + Advanced Mode + risk classes + typed confirmation | `command_catalog.py`, `action_safety.py` | 🔁 Guided **Actions** catalog backed by native services / devicectl / simctl / xctrace, same risk classes and device-bound `RUN XXXXXX` / `IRREVERSIBLE XXXXXX` phrases; Advanced Mode for `devicectl` with safety classification | Yes | No | 🔁 ✅ simulators · 🟡 physical | +| 16 | Guided Command Drift | `pmd3 --help` probes | 🔁 **Toolchain Check**: verifies every devicectl/simctl/xctrace route the app uses is present in the installed Xcode | Yes | No | 🔁 ✅ | +| 17 | Man Pages (59 `pmd3` routes) | `pmd3 --help` | 🔁 Help browser for the Apple tools actually used (`devicectl help …`, `simctl help …`, `xctrace help …`) | Yes | No | 🔁 ✅ | +| 18 | Installed Apps (search, sort, sizes, copy bundle ID, uninstall) | `pmd3 apps list/uninstall` | Native `installation_proxy` (sizes) with CoreDevice `info apps` fallback; uninstall via native `installation_proxy` over USB, CoreDevice for network-only devices, `simctl` for simulators | Yes | No | ✅ simulators (list) · 🟡 physical | +| 19 | MobileBackup2 (encryption status, require encryption + new password, full/incremental, progress, cancel) | `pmd3` backup2 worker | Native `com.apple.mobilebackup2` DeviceLink client + `notification_proxy` sync lock; password never in argv | Lockdown service | No | 🟡 | +| 20 | UFADE external launch | `ufade_connector.py` | Kept as optional external provider through `CommandRunner` | — | No | 🟡 (stand-in executables) | +| 21 | MVT analysis handoff | `mvt_connector.py` | Kept as optional external provider through `CommandRunner` | — | No | 🟡 (stand-in executables) | +| 22 | Sideload IPA (safe archive validation, Info.plist, provisioning via `security cms`, `codesign --verify`) | `ipa_inspector.py` | Native ZIP reader + validated extraction, `CMSDecoder` (Security.framework) for provisioning, `SecStaticCode` for signature; install via CoreDevice when Xcode is available, otherwise native AFC upload + `installation_proxy`; simulators via `simctl install` | Security.framework | No | ✅ inspection · 🟡 install | +| 23 | Evidence Capture (guided case intake, 15 snapshots, syslog/OSLog/PCAP streams, screenshot, crash pull, manifest, SHA256SUMS) | `collector.py`, `case_workflow.py` | Ported collection engine over native services / CoreDevice | Yes | No | 🟡 | +| 24 | Network PCAP | `pmd3 pcap` | Native `com.apple.pcapd` client writing libpcap files | Lockdown service | No | 🟡 | +| 25 | Screenshot | `pmd3 developer dvt screenshot` | CoreDevice `capture screenshot`; Simulator `simctl io screenshot` | Yes | No | ✅ simulators · 🟡 physical | +| 26 | Crash report list / pull | `pmd3 crash ls/pull` | Native AFC over `com.apple.crashreportcopymobile` (after `crashreportmover`), no Xcode needed | Yes | No | 🟡 | +| 27 | Processes | `pmd3 processes ps`, DVT proclist, CoreDevice list-processes | Native `os_trace_relay` `PidList` over USB (§9 G1); CoreDevice `info processes` for network-only devices | Private lockdown service; devicectl | No | ✅ (read on one iPhone, §5.4) | +| 28 | Launch app / open URL | DVT launch, Web Inspector launch | CoreDevice `process launch` / `process openURL`; Simulator `simctl launch` / `openurl` | Yes | No | ✅ simulators · 🟡 physical | +| 29 | Configuration / provisioning profiles | `pmd3 profile list`, `provision list` | CoreDevice `profile list`; native `misagent` | Yes | No | 🟡 | +| 30 | Diagnostics, battery, IORegistry, MobileGestalt | `pmd3 diagnostics …` | Native `diagnostics_relay` | Lockdown service | No | 🟡 | +| 31 | SpringBoard orientation / icon metrics | `pmd3 springboard …` | Native `springboardservices`; CoreDevice `orientation get` | Yes | No | 🟡 | +| 32 | Activation state, personalization identifiers | `pmd3 activation state`, `mounter query-personalization-identifiers` | Native lockdown / `mobile_image_mounter` | Lockdown | No | 🟡 | +| 33 | DVT telemetry (sysmon, energy, graphics, netstat, notifications, KDebug/CoreProfile) | `pmd3 developer dvt …` | 🔁 Instruments recordings via `xcrun xctrace record --device` (Activity Monitor, Network, Power Profiler, System Trace, Time Profiler…) | xctrace | No | 🔁 🟡 | +| 34 | RSD / RemoteXPC Bonjour discovery | `pmd3 bonjour rsd`, `remote browse` | Network.framework `NWBrowser` for `_remotepairing._tcp` / `_apple-mobdev2._tcp` | Network.framework | No | 🟡 | +| 35 | Safari/WebView tab list | `pmd3 webinspector opened-tabs` | Native `com.apple.webinspector` client (Action “Safari and web view tabs”, Readiness row) — §9 G3 | Private lockdown service | No | 🟡 | +| 36 | Bluetooth HCI capture | `pmd3 btlogger` | Native `com.apple.bluetooth.BTPacketLogger` client writing `.pklg` (Action “Bluetooth capture”) — §9 G4 | Private lockdown service | No | 🟡 | +| 37 | DVT filesystem listing (`dvt ls /`), AFC media listing | `pmd3 developer dvt ls`, `afc ls` | AFC via native `com.apple.afc`; DVT listing ❌ (see §6) | Lockdown | No | 🟡 AFC · ❌ DVT | +| 38 | Session Activity journal + manifest export | `operation_history.py` | Ported (`OperationJournal` actor) | Foundation | No | ✅ | +| 39 | Workspace profiles import/export | `workspace_profile.py` | Ported (Codable + validation); imports 0.3.x files (§9 G6) | Foundation | No | ✅ | +| 40 | Sanitized support bundle | `support_bundle.py` | Ported; native ZIP writer; includes redacted OSLog export | OSLog, Foundation | No | ✅ | +| 41 | Action Palette (⌘K), keyboard shortcuts | `action_palette.py` | SwiftUI command palette + `Commands` | SwiftUI | No | ✅ (UI test runs in CI) | +| 42 | Demo Mode | `demo_mode.py` | Ported; also drives deterministic UI tests | — | No | ✅ | +| 43 | Connection diagnostics / Reconnect & Retry | `connection_diagnostics.py` | Ported to usbmuxd states; guided reconnect sheet | — | No | ✅ | +| 44 | Scope & Safety page, Home page | GUI text | Redesigned in SwiftUI | — | No | ✅ | +| 45 | Ecosystem Tools: go-ios adapter | `external_tools.py` | ❌ **Removed by request** — capability covered by #1 | — | **go-ios** | ❌ removed | +| 46 | Ecosystem Tools: blacktop ipsw adapter | `external_tools.py` | ❌ **Removed by request** — capability covered by #1 | — | **ipsw** | ❌ removed | +| 47 | Ecosystem Tools: idb Companion adapter | `external_tools.py` | Kept as an optional external provider | — | No | 🟡 (stand-in executable) | +| 48 | CLI: evidence collector, IPA inspector, local DDI | argparse scripts | `idt` Swift command-line tool (`collect`, `inspect-ipa`, `devices`, `ddi`) | — | No | ✅ | +| 49 | Simulators | Not supported | **New**: simulator discovery, boot/shutdown, install, launch, screenshot, location, logs, open URL — clearly separated from physical devices | simctl | No | ✅ | + +## 3. Architecture decisions + +1. **SwiftUI app + Swift Package libraries.** Logic lives in a Swift package (`Package.swift`) + so it builds and tests with `swift test` and in Xcode. The app target + (`iOSDeveloperToolkit.xcodeproj`, generated from `project.yml` with XcodeGen and committed) + contains only SwiftUI views and view state. +2. **Modules.** + - `ToolkitCore` — logging (`OSLog` categories), `ToolkitError` (user message + technical + detail + recovery suggestion), the single `CommandRunner` (the only place that creates a + `Process`), secure file helpers (owner-only, no-overwrite, atomic), hashing, sanitizer, + operation journal. + - `DeviceKit` — device models and plain-language explanations, usbmuxd client, lockdown + client, lockdown services, CoreDevice (`devicectl`) client, simulator (`simctl`) client, + discovery coordinator, capability probes. + - `ToolkitFeatures` — Location Lab, IPA inspection, live-log capture/findings, evidence + collection, workspace profiles, support bundle, compatibility history, action safety. + - `idt` — command-line tool. +3. **Physical devices use two Apple paths.** CoreDevice (`devicectl`, Xcode ≥ 15) for developer + services on iOS 17+ (it owns the RemoteXPC tunnel and personalized DDI); and a native Swift + usbmuxd/lockdown client for services available to any trusted device without Xcode + (identity, syslog, os_trace_relay, pcapd, MobileBackup2, diagnostics, installation proxy, + misagent, image mounter). Pair records are read through usbmuxd's `ReadPairRecord`, which + macOS allows without root. The app never creates pair records, never reads + `/var/db/lockdown`, and never uses `sudo`. +4. **TLS for lockdown uses swift-nio-ssl.** Lockdown upgrades an established plaintext stream + to TLS with the pair record's host certificate. Network.framework has no public STARTTLS + and SecureTransport has been deprecated since macOS 10.15, so the lockdown channel uses + Apple's open-source `swift-nio` + `swift-nio-ssl` (Apache-2.0). The device certificate is + pinned to the `DeviceCertificate` stored in the pair record. +5. **Event-driven discovery.** usbmuxd `Listen` pushes attach/detach events; CoreDevice and + simulator lists refresh on those events, on explicit refresh, and on a slow (30 s) timer only + for network-only CoreDevice devices that usbmuxd cannot report. +6. **Device targeting.** Every operation takes an immutable `DeviceTarget` (kind + UDID + + display name) captured when the operation starts; operations never read "the current + selection" later. Device-changing actions require a phrase bound to the target's UDID. +7. **Minimum macOS 14** (Observation, modern SwiftUI). Universal binary (arm64 + x86_64). +8. **Swift 6 language mode** with strict concurrency. + +## 4. Removed dependencies + +| Dependency | Reason | +|---|---| +| Python 3.10–3.13 runtime, PySide6, Nuitka | Replaced by native Swift/SwiftUI app | +| `pymobiledevice3` 11.15.1 (and its transitive deps: xonsh, IPython, etc.) | Replaced by native lockdown client + Apple CoreDevice/simctl/xctrace | +| go-ios adapter | Removed by request | +| blacktop/ipsw adapter | Removed by request | +| mkdocs-material, cyclonedx-bom (Python) | Docs moved into repository Markdown; SBOM generated from `Package.resolved` | + +## 5. Test results + +Environment: MacBook Pro (Apple silicon), macOS 27.0, Xcode 27.0 (Swift 6.4). One iPhone +(iPhone 17 Pro, iOS 26.3.1, locked, Developer Mode off) was connected on 2026-09-27 for the +read-only checks in §5.4; nothing that changes a device was run on it. + +### 5.1 Automated tests + +| Suite | Tests | What it exercises | Result | +|---|---:|---|---| +| `ToolkitCoreTests` | 30 | `CommandRunner` (argument vectors, timeouts, cancellation, output draining, minimal environment), `ToolkitError`, secure file I/O (owner-only, no overwrite, path traversal), sanitizer, hashing, journal, ZIP writer | ✅ pass | +| `DeviceKitTests` | 85 | usbmuxd framing and `Listen` events, developer images (image mounter, image library, TSS request, state evaluation, personalized and legacy mount/unmount), pairing-record handling, lockdown TLS with certificate pinning and UDID check, the lockdown service clients (syslog, os_trace incl. the process list, pcapd, MobileBackup2, diagnostics, installation proxy, AFC, image mounter, springboard, MCInstall, Web Inspector, Bluetooth PacketLogger) against an in-process **fake usbmuxd + lockdownd device**; CoreDevice JSON parsing; `simctl` parsing | ✅ pass | +| `ToolkitFeaturesTests` | 72 | Location Lab, GPX, route waypoints, location mechanism routing and legacy-service message encoding, provisioning profiles (misagent) and packet capture through the action executor, IPA inspection (fixtures incl. malicious archives), live-log capture/findings/register/export, action catalog and safety policy, actions and readiness against the fake device (incl. the Web Inspector and Instruments rows and slow Xcode tools), evidence collection and its prerequisites, workspace profiles incl. 0.3.x import, guided reconnect, keyboard navigation, support bundle, external-tool validation (incl. UFADE's submodule) | ✅ pass | +| Real simulator (opt-in, `IDT_SIMULATOR_TESTS=1`) | 1 | Boots an iOS 26.3.1 iPhone simulator; waits for boot to complete; sets, routes, and clears location; screenshot; app list; live unified log capture with hash; launches an app; Open URL action; readiness; compiles, installs, lists, launches, and uninstalls a minimal simulator app | ✅ pass locally (about 24 s) | +| Real device (opt-in, `IDT_DEVICE_TESTS=1`) | 4 | Read-only protocol checks against a connected iPhone or iPad (§5.4) | ✅ pass on one iPhone (2026-09-27) | +| XCUITest smoke tests (`App/UITests`) | 8 | Window size, Demo Mode labelling, every workspace, disabled demo actions, command palette, Location Lab validation, minimum size, developer-image card | ✅ the original 7 pass locally (2026-09-27, run by the maintainer). The first run failed `testDemoActionsAreBlockedWithExplanation`: each Actions row exposed its identifier on three child elements, so the click was ambiguous (and VoiceOver read three items). Rows are now single accessibility elements; the three affected tests were re-run and pass. All 8 (including the developer-image card test added in §8) pass in CI on Xcode 26.6 (PR #13), also after the parity work (`25ef72c`). A local run by the maintainer after the parity work (2026-09-27): 7 of 8 pass, including the developer-image card; `testEveryWorkspaceOpens` could not click the sidebar because another app's window covered it (XCTest reported the overlapping windows). Re-run alone with a clear screen, it passes, so all 8 pass locally. | + +Totals: 187 package tests pass with `-warnings-as-errors` (6 opt-in tests skipped); the app and +UI-test targets build with `SWIFT_TREAT_WARNINGS_AS_ERRORS=YES` and zero warnings. The UI added after +the parity audit (reconnect guide, shortcut reference, Advanced Mode prefill, readiness status, +profile import) is verified through the screenshot harness and unit tests, not by XCUITests. + +### 5.2 GUI verification + +The app's screenshot harness (`-capture-screenshots`, see `scripts/check-layout.sh`) rendered all +14 workspaces in Demo Mode at 1180×700 (default) and 900×560 (minimum): no page is squeezed or +overflows the window. The same harness rendered the Device, Live Logs (real simulator log stream, +about 35,000 lines in 6 s), Location Lab, and Actions pages with a booted simulator. The README +screenshots come from these renders. + +### 5.3 Release packaging + +`scripts/build-release.sh` produced a universal (arm64 + x86_64) app, ad-hoc signed with the +hardened runtime (`flags=0x10002(adhoc,runtime)`), with `idt`, dependency licenses, and the SPDX +SBOM inside, and verified it again from the ZIP. The release build launched and rendered, and its +`idt` listed devices. After Rosetta 2 was installed (2026-09-27), the x86_64 slices were run under +Rosetta: the release script's check ran the Intel `idt`; the Intel `idt` listed devices, found all +27 Xcode routes, and reported developer-image status; the Intel app rendered all 14 pages at both +window sizes without layout problems; and all 187 package tests pass when built for x86_64 and run +with `arch -x86_64 xctest` (re-run after the parity work). This is Rosetta on Apple silicon, not a real Intel Mac. `spctl` +rejects the app, as expected for an app that is not notarized. + +### 5.4 Physical devices + +**Partly tested on hardware (2026-09-27).** One iPhone 17 Pro (`iPhone18,1`) on iOS 26.3.1, on USB, +trusted, **locked, with Developer Mode off**. The native protocol layer was checked with the +opt-in, read-only suite `RealDeviceTests` (`IDT_DEVICE_TESTS=1 swift test --filter RealDeviceTests`) +and `idt`. The GUI protocol in +[docs/PHYSICAL_DEVICE_TEST_PROTOCOL.md](docs/PHYSICAL_DEVICE_TEST_PROTOCOL.md) has not been run. Nothing +that changes the device was run: no mounting, location, installation, or backup. + +| Check | Service | Result on the iPhone | +|---|---|---| +| Discovery, TLS session, UDID match | usbmuxd, lockdown | ✅ | +| Process list | `os_trace_relay` `PidList` | ✅ 519 processes, including launchd | +| Configuration profiles | `com.apple.mobile.MCInstall` | ✅ 2 profiles | +| Provisioning profiles | `misagent` | ✅ 3 profiles | +| Installed apps | `installation_proxy` | ✅ 462 apps | +| Diagnostics | `diagnostics_relay` | ✅ | +| Mounted images | `mobile_image_mounter` lookup | ✅ none mounted (correct: Developer Mode off) | +| Developer image state | evaluator | ✅ *Needs attention: Developer Mode is off* (the row's advice now says to turn on Developer Mode instead of the generic “Mount Developer Image”). With Developer Mode assumed on, the evaluator selects Xcode image 27A266a's `iPhone18,1` identity (*Personalization required*) | +| Classic syslog | `syslog_relay` | ✅ 3,476 lines in 4 s | +| Unified Logging | `os_trace_relay` | ✅ 15,535 records in 4 s | +| Packet capture | `pcapd` | ✅ 27 packets in 5 s, valid `.pcap` | +| Bluetooth capture | `BTPacketLogger` | ◐ service starts, 0 records (no Bluetooth logging profile installed) | +| Safari and web view tabs | `webinspector` | ◐ not answering (Web Inspector presumably off); the refusal message was shown | +| Instruments | `xctrace list devices` | ✅ device listed as available | +| CoreDevice | `devicectl` | ✅ connected, tunnel connected | + +| Device | iOS | Connection | Stage 1 | Stage 2 | Stage 3 | Stage 4 | Tester, date | +|---|---|---|---|---|---|---|---| +| iPhone 17 Pro | 26.3.1 | USB | discovery and trust | protocol layer (above), repeated after the parity work; `idt readiness`; every app page rendered with the phone selected, including the Readiness Check and the app list (462 apps with sizes) — interactive steps (live-log controls, exports, packet-capture action) not run | not run (Developer Mode off) | not run | maintainer, 2026-09-27 | + +### 5.5 Final verification (2026-09-27) + +| Check | Result | +|---|---| +| Fresh clone of `swift-native-migration` to a temporary folder; `swift build -Xswiftc -warnings-as-errors`; `swift test` | ✅ builds with no warnings; 147 tests pass | +| Clean `xcodebuild … clean build-for-testing` of the app and UI tests | ✅ succeeded. It exposed 78 Swift 6 actor-isolation warnings in the UI tests that a plain `build` never compiles and that `SWIFT_TREAT_WARNINGS_AS_ERRORS` does not promote; fixed, and CI now fails on any warning in the build log | +| Launch and render every screen | ✅ all 14 pages at 1180×700 and 900×560 (Demo Mode, `scripts/check-layout.sh`), and all 14 with a booted iOS 26.3.1 simulator selected and its live log streaming | +| Nothing requires Python | ✅ no Python in the repository except the optional, user-installed UFADE and MVT integrations (Python programs themselves); the release script fails if a binary links Python; the build, tests, SBOM generator, and release script use only Xcode | +| `idt devices`, `idt toolchain` | ✅ no devices → guidance and exit 0; simulators listed with `--simulators`; all 27 `devicectl`/`simctl`/`xctrace` routes present in Xcode 27.0 | +| Real simulator end-to-end (`IDT_SIMULATOR_TESTS=1`) | ✅ passed (9.6 s) | +| No Xcode (simulated with `DEVELOPER_DIR=/Library/Developer/CommandLineTools`) | ✅ usbmuxd discovery still works; CoreDevice and simulators report "Xcode is not installed…"; the app's sidebar shows them as Unavailable. The Toolchain Check blamed each individual command; fixed to report the missing Xcode (exit 2) | +| usbmuxd missing | ✅ the app (discovery pointed at a nonexistent socket) shows USB & Wi-Fi as Unavailable with the reason in the tooltip; the library error names usbmuxd (tested) | +| No device | ✅ Overview shows "No device selected" with next steps; `idt devices` explains how to connect | +| Unified log review | ✅ subsystem `io.hideouts.iOSDeveloperToolkit` logs discovery, commands (start/finish, duration, status), and outcomes; errors seen were the tests' deliberate negative cases. Found and fixed: default command names could put a simulator UDID in a public field, and some error descriptions and operation titles (paths, app names) were public | +| Default window on a 1280×800 display | ✅ first launch opens at 1180×700 including title bar and toolbar (minimum 900×612), within the ~1280×705 usable area below the menu bar with a bottom Dock | +| README matches the app | ✅ menus, shortcuts, pages, labels, `idt` options and exit codes, file locations, and the with/without-Xcode table checked against the code; the no-Xcode column was checked against the implementation (native lockdown paths) | +| Physical iPhone/iPad | ◐ read-only protocol checks passed on one iPhone (§5.4); the app's pages, mounting, location, installation, backup, and multi-device handling are **untested on hardware** | +| After the parity work (§9) | ✅ `swift test -Xswiftc -warnings-as-errors`: 187 pass; clean `xcodebuild … clean build-for-testing`: zero compiler warnings; `scripts/check-layout.sh`: 14 pages at both sizes; `scripts/build-release.sh`: passes; x86_64 tests under Rosetta: 187 pass; real simulator end-to-end: passes | + +## 6. Known limitations and features not reproduced + +### 6.1 Features not migrated + +| Feature (Python) | Why not in 1.0 | Alternatives investigated | Native implementation possible? | +|---|---|---|---| +| **Safari/WebView tab listing** (`pmd3 webinspector opened-tabs`) | ✅ Implemented after the parity audit (§9 G3): native `com.apple.webinspector` client, retrying refusals until a deadline. Opening a URL through Web Inspector (automation) is still not reproduced — it needs a WebDriver session and Safari’s Remote Automation setting; Open URL uses CoreDevice. | — | — | +| **Bluetooth HCI capture** (`pmd3 btlogger`) | ✅ Implemented after the parity audit (§9 G4): native `com.apple.bluetooth.BTPacketLogger` client; records are written as Apple PacketLogger `.pklg` (opened by PacketLogger and Wireshark) instead of 0.3.x's pcapng. Needs Apple's Bluetooth logging profile on the device. | — | — | +| **DVT file-system listing** (`pmd3 developer dvt ls`) | DVT uses Apple's private DTX protocol (NSKeyedArchiver messages over `com.apple.instruments.remoteserver*`). On iOS 17+ it is only reachable through the RemoteXPC tunnel that CoreDevice owns; creating that tunnel needs a utun interface (root) or CoreDevice's private frameworks — both excluded (no `sudo`, no private frameworks). | AFC (`com.apple.afc`, Media folder — implemented as *List Media folder*); `devicectl device info files` and `device copy from` for app containers and supported domains (available through Advanced Mode); crash reports through `crashreportcopymobile` (implemented). | **Not for iOS 17+** without privileges or private frameworks. For iOS 16 and earlier, DTX over lockdown is possible but serves only legacy devices and is not planned. | + +### 6.2 Verification gaps + +- **Developer images** (§8): the image-mounter protocol and Apple personalization are verified + against a stateful fake image mounter and a fake signing server, and the request is built from + the real image Xcode installed on this Mac. No device has mounted an image through this code yet. +- **Native lockdown services need more physical-device verification.** The read-only services + answered correctly on one iPhone (§5.4); Web Inspector and Bluetooth logging were reached but + returned nothing (setting and profile not present). usbmuxd, lockdown TLS, and all + service clients pass byte-level tests against the fake device, which reproduces Apple's framing + (plist headers, TLS upgrade, DeviceLink, AFC packets, pcapd records, os_trace records) from + public protocol documentation and prior implementations. Real devices can differ in details + (record versions, error codes, timing). Until the protocol in §5.4 has been run, treat 🟡 rows + as unverified. +- **CoreDevice commands** are verified for argument construction, JSON parsing (from recorded + output shapes), and presence in the installed Xcode (Toolchain Check), not against a device. +- **UI tests** run in CI and when the maintainer runs them locally (macOS asks once to allow UI + automation); all 8 pass in both. +- **Intel Macs:** the x86_64 slice is verified under Rosetta 2 on Apple silicon (tests, CLI, and + rendering), not on Intel hardware. +- **CI** (PR #13, `macos-26`, Xcode 26.6 / Swift 6.3.3): the app build with the zero-warning check, + all 8 UI tests, the layout check, and dependency review pass. Two fixes came out of the first + runs: an array-type inference difference in Swift 6.3 (test code), and waiting for simulators to + finish booting (`simctl bootstatus -b`) before launching apps. Later fixes: a race in the fake + device's TLS start (intermittent package-test timeout), `@main` in a file named `main.swift` + (rejected by Swift 6.3 in the universal release build; the file is now `IDT.swift`), and Xcode + tool probes that time out on a busy runner (now “did not answer in time”, not “Xcode missing”). +- **Older Xcode:** Xcode 26.6 (the CI runner) lacks `devicectl device simulate location`, the + screenshot `--destination` option, `device process openURL`, and `device profile list --type`. + With Xcode 26, location simulation on iOS 17+ and those actions through Xcode's device service are + unavailable; the app says so (“needs a newer Xcode”) and the Toolchain Check lists them. Native + replacements now avoid `devicectl` for the process list and configuration profiles over USB (§9 + G1, G2). The app is verified with Xcode 27. + +### 6.3 Behaviour differences from 0.3.x + +- Release builds are universal instead of separate Apple silicon and Intel downloads (kept after + macOS 27 began warning that Intel code run under Rosetta will not open in macOS 28); minimum + macOS is 14 (was 13). +- Guided actions replace the 49 raw `pymobiledevice3` presets; Advanced Mode runs `devicectl` + instead of arbitrary `pymobiledevice3` subcommands. +- DVT telemetry streams are replaced by Instruments recordings (`xctrace`); the DVT OSLog stream by + the Unified Logging stream, which needs no developer image. +- Features that need a developer tunnel (iOS 17+) now require Xcode, which owns the tunnel. + +## 7. Migration log + +- 2026-09-26 — Audit complete; migration branch `swift-native-migration` created. +- 2026-09-26 — Swift package (ToolkitCore, DeviceKit, ToolkitFeatures, idt CLI) complete with an end-to-end fake usbmuxd/lockdownd device, a real-Xcode toolchain check, and an opt-in real-simulator test. SwiftUI app and XCUITests written. +- 2026-09-26 — GUI layout fixed at the minimum size; documentation screenshot mode added. +- 2026-09-26 — Standalone packet capture action (parity with the Python app); warnings are errors in every target. +- 2026-09-26 — README and documentation rewritten for the Swift app; mkdocs removed. +- 2026-09-26 — GitHub Actions replaced (CI, release, CodeQL for Swift, dependency review); `scripts/build-release.sh` verified locally. +- 2026-09-27 — Test results and known limitations recorded (§5, §6); feature statuses set (§2). +- 2026-09-27 — Python implementation, packaging, and go-ios/ipsw references removed. +- 2026-09-27 — Final verification (§5.5): fresh clone, clean builds, every screen rendered, no-Xcode / no-usbmuxd / no-device states, unified log review. Fixed on the way: UI-test concurrency warnings (and a CI check for them), Toolchain Check message without Xcode, identifiers in public log fields. Physical-device verification remains open. +- 2026-09-27 — Developer-image (DDI) capability audited and restored natively (§8): detection, state model, personalized and legacy mounting, unmount, GUI, readiness, actions, and `idt ddi`. Physical-device verification remains open. + +- 2026-09-27 — Full parity audit against 0.3.4 (§9); gaps G1–G13 implemented, each with its own commit: native process list, configuration profiles, Safari/web view tabs, and Bluetooth capture; guided reconnect; 0.3.x profile import; `--include-oslog`; Instruments readiness row; route waypoint; findings register copy; shortcut reference and workspace stepping; readiness shortcuts and Tool Reference → Advanced Mode; UFADE submodule status. +- 2026-09-27 — First hardware pass (read-only, one iPhone, §5.4) and opt-in `RealDeviceTests`. + +## 8. Developer image (DDI) audit and restoration + +### 8.1 What the Python app did (0.3.4, via `pymobiledevice3` 11.15.1) + +| Path | Python behaviour | +|---|---| +| “Mount Personalized DDI” (default) | `pymobiledevice3 mounter auto-mount`. **iOS ≤ 16:** download `DeveloperDiskImage.dmg` + `.signature` for the device's `major.minor` from the third-party GitHub mirror `doronz88/DeveloperDiskImage`, `ReceiveBytes` (ImageType `Developer`) + `MountImage` over `com.apple.mobile.mobile_image_mounter`, mounted at `/Developer`. **iOS ≥ 17:** download `Image.dmg`, `Image.dmg.trustcache`, `BuildManifest.plist` (Xcode's personalized DDI) from the same mirror into `~/.pymobiledevice3`; `QueryPersonalizationManifest` (SHA-384 of the image) to reuse a manifest the device already holds, otherwise `QueryPersonalizationIdentifiers` + `QueryNonce` and an Apple TSS request (`gs.apple.com`) for an `ApImg4Ticket`; then `ReceiveBytes`/`MountImage` with ImageType `Personalized` and the trust cache; mounted at `/System/Developer`. Refused to mount when an image was already mounted or Developer Mode was off. | +| “Install Local Xcode DDI Cryptex” | `local_ddi.py`: `hdiutil attach -readonly` of `/Library/Developer/CoreDevice/CandidateDDIs/iOS_DDI.dmg`, then `pymobiledevice3 cryptex auto-install --restore-dir …/Restore` (personalized Cryptex install through the RemoteXPC `cryptexd` service), then detach. | +| Unmount | `mounter umount-personalized` (`/System/Developer`) or `cryptex uninstall com.apple.MobileAsset.DDI`. | +| Status | `mounter list`, `mounter lookup`, `query-developer-mode-status`, `query-nonce`, `query-personalization-identifiers`; Capability Matrix row “developer-image” from `mounter list`. | + +### 8.2 Gaps found in the Swift app (before this work) + +| # | Gap | +|---|---| +| G1 | No native detection of the mounted image (`LookupImage`) or of its compatibility; state came only from CoreDevice. | +| G2 | Chip ID, board ID, and ECID (needed to select and personalize an image) were never read. | +| G3 | No native iOS 17+ personalized mount (manifest reuse, TSS personalization, upload, mount). Only `devicectl … ddiServices --auto-mount-ddis`, which needs Xcode **and** a CoreDevice-paired device. | +| G4 | No iOS ≤ 16 mount at all. | +| G5 | No “already mounted → do not remount” guard on a native path. | +| G6 | Unmount handled only `/System/Developer`, not the legacy `/Developer`. | +| G7 | No image-state model; the GUI showed only “Available / Not prepared / Needs Xcode”. | +| G8 | Image-mounter errors surfaced as “The image mounter service did not answer”. | +| G9 | Readiness row and `idt ddi` depended on CoreDevice only. | + +Not reproduced by design: downloading Apple's images from the third-party mirror (redistributed Apple binaries — the Swift app uses the images Xcode installs in `/Library/Developer/DeveloperDiskImages`, or a folder the user chooses), and the RemoteXPC Cryptex install (needs a privileged tunnel; `devicectl` covers it). + +### 8.3 What the Swift app does now + +| Gap | Resolution | +|---|---| +| G1, G5 | `ImageMounter.lookup` (`LookupImage`) and `CopyDevices` decide whether a compatible image is mounted; mounting returns immediately (no upload, no Apple request) when one is. | +| G2 | Facts from lockdown (`ProductVersion`, `BuildVersion`, `ProductType`, `CPUArchitecture`, `HardwareModel`, `ChipID`, `BoardId`, Developer Mode), falling back to the image mounter's personalization identifiers for chip and board. ECID is read only for the signing request and never displayed. | +| G3 | Native personalized mount, as `pymobiledevice3` did: pick the build identity for the chip and board from the image Xcode installs (`/Library/Developer/DeveloperDiskImages/iOS_DDI/Restore`, 140 identities for 24 chip families with Xcode 27) or a user folder; reuse a manifest the device already holds (`QueryPersonalizationManifest`, SHA-384); otherwise `QueryPersonalizationIdentifiers` + `QueryNonce` + Apple TSS over HTTPS; then `ReceiveBytes` and `MountImage` with the trust cache. Alternatively (and by default when CoreDevice can reach the device) Xcode's `devicectl … ddiServices --auto-mount-ddis`. | +| G4 | Native legacy mount of `DeveloperDiskImage.dmg` + `.signature` for the exact iOS `major.minor`, found in any installed Xcode's DeviceSupport folder or a user folder. | +| G6 | Unmount handles `/System/Developer` and `/Developer`. | +| G7 | `DeveloperImageState`: not required, mounted, available, personalization required, missing, incompatible, needs attention (trust, Developer Mode, lock, USB), failed — each with a headline, explanation, next step, and details, shown on the Device page's **Developer image** card. | +| G8 | Image-mounter and TSS replies are classified (locked, Developer Mode off, already mounted, not mounted, signature rejected, unsupported, Apple refused, offline) into plain-language errors; raw replies go only to technical details. | +| G9 | Readiness Check, the Actions catalog, and `idt ddi status|mount|unmount` use the same `DeveloperImageManager`. | + +No runtime dependency was added: the image-mounter client uses the existing lockdown stack, and +personalization uses `URLSession`. The private service and the TSS request are isolated in +`Sources/DeviceKit/DeveloperImage` (see [docs/architecture.md](docs/architecture.md#developer-images)). + +### 8.4 Verification + +| Check | Result | +|---|---| +| Unit and fake-device tests (`DeveloperImageTests`, 14 tests) | ✅ every state; TSS request fields and restore-request rules; reply parsing and Apple's refusal codes; personalized mount end to end (identifiers, nonce, TSS, upload size and signature, trust cache); no remount when mounted; reuse of a stored personalization without contacting Apple; unmount; legacy mount without personalization; Developer Mode off and locked device; identifier fallback; simulators and network-only devices | +| The image Xcode 27 installed on this Mac | ✅ parsed: build identity for iPhone18,1 found, image and trust cache readable, a complete TSS request built | +| Apple's signing endpoint | ✅ `https://gs.apple.com/TSS/controller?action=2` reachable with valid TLS (plain GET, no device data). A real signing request needs a device nonce and was **not** sent | +| App | ✅ Developer image card rendered in Demo Mode at 1180×700 and 900×560 (no overflow); all pages pass `scripts/check-layout.sh`; a UI test checks the card and that demo mounting is blocked | +| `idt ddi` | ✅ `status` (text and JSON, exit codes), `mount`/`unmount` confirmation, hidden `prepare` alias | +| **Physical devices** | ❌ **Not tested.** No iPhone or iPad was connected. Still to verify on hardware: the real image-mounter replies, Apple's acceptance of the TSS request, the mount itself, and error wording on real failures — [docs/PHYSICAL_DEVICE_TEST_PROTOCOL.md](docs/PHYSICAL_DEVICE_TEST_PROTOCOL.md) Stage 3, steps 1–5 | + +Not reproduced: downloading images from the third-party mirror (the app uses Xcode's images or a +folder you choose), and the RemoteXPC Cryptex install (`devicectl` covers it without a privileged +tunnel). + +## 9. Full parity audit (against `origin/main`, Python 0.3.4) + +Scope: all 40 modules in `ios_developer_toolkit/`, the 253 named GUI controls and 130 user actions +in `app.py`/`gui_pages.py`, the smoke-test button list in `entrypoint.py`, all 49 Command Center +presets, the 17 evidence snapshots and man-page routes in `catalog.py`, the 11 Capability Matrix +rows, every CLI option, workspace-profile fields, shortcuts, and the behaviours asserted in +`tests/`. Classification: **=** equivalent · **🔁** replaced by a better Apple mechanism · +**◐** partial · **✗** missing · **—** intentionally excluded. + +**Outcome.** Every gap found (G1–G13, §9.4) is implemented. Intentionally excluded, with reasons +in §6.1 and the rows below: the DVT file listing and DVT app-state notifications (Apple's private +DTX protocol, reachable on iOS 17+ only through CoreDevice's tunnel), RemoteXPC service browsing +(same tunnel), opening URLs through Web Inspector automation (needs a WebDriver session; Open URL +uses CoreDevice instead), downloading developer images (images come from Xcode or a user folder), +and 0.3.x's shortcuts for the tenth and later pages and focus (⌘0, ⇧⌘E/M/S, ⌘L, ⌘F). + +### 9.1 Command Center presets (49) + +| Python preset (`pymobiledevice3 …`) | Swift | Class | Evidence | +|---|---|---|---| +| devices (`usbmux list`) | usbmuxd discovery, `idt devices` | = | `LockdownStackTests`, `idt devices` run | +| lockdown, activation, developer-mode | Actions `lockdown-values`, `activation-state`, `developer-mode-status` (native) | = | `nativeActionsRunAgainstTheCapturedTarget` | +| diagnostics, battery, ioregistry, mobilegestalt | Actions (native `diagnostics_relay`) | = | same | +| processes (`processes ps`, os_trace `PidList`, no Xcode) | Action `processes` and the evidence snapshot use native `PidList` over USB; CoreDevice only for network-only devices | = (**G1 resolved**) | `processListParsesPidListReplies`, `nativeActionsRunAgainstTheCapturedTarget`, `collectsSnapshotsStreamsAndHashes` | +| profiles (`profile list`, MCInstall, no Xcode) | Action `configuration-profiles` and the evidence snapshot use native MCInstall `GetProfileList` over USB; CoreDevice only for network-only devices | = (**G2 resolved**) | `configurationProfileListParsesMCInstallReplies`, `nativeActionsRunAgainstTheCapturedTarget`, `collectsSnapshotsStreamsAndHashes` | +| provisioning, orientation, icon-metrics | Actions (native misagent, springboardservices) | = | `ServiceTests.springBoardServicesAnswerQueries` | +| apps-list, apps-query | Apps page; Action `app-query` (native installation_proxy) | = | `ServiceTests` | +| afc-list | Action `media-list` (path parameter) | = | `nativeActionsRunAgainstTheCapturedTarget` | +| dvt-list (`developer dvt ls`) | — | — | §6.1: DTX over RemoteXPC on iOS 17+ | +| crash-list, crash-pull | Actions (native AFC) | = | `BackupAndAFCTests` | +| syslog | Live Logs · Classic syslog | = | `ServiceTests` | +| oslog (DVT) | Live Logs · Unified (os_trace_relay, no DDI) | 🔁 | `ServiceTests`, real-simulator test | +| pcap | Action `packet-capture`, Evidence stream | = | `nativeActionsRunAgainstTheCapturedTarget` | +| btlogger (`--format pcapng`) | Action `bluetooth-capture` (native, `.pklg`) | 🔁 (**G4 resolved**; PacketLogger format instead of pcapng) | `bluetoothRecordsBecomeAPacketLoggerFile`, `nativeActionsRunAgainstTheCapturedTarget` | +| dvt-device, dvt-proclist, dvt-applist | device details / processes / apps | 🔁 | — | +| dvt-netstat, dvt-energy, sysmon-system, sysmon-process, graphics, core-profile | Action `instruments` (xctrace templates) | 🔁 | `instrumentsRequestsAreBounded` | +| dvt-pid-check | Action `processes` (the list shows whether a pid runs) | 🔁 | — | +| notifications (DVT app-state notifications) | — | — | DTX-only; Instruments “App Launch”/“Activity Monitor” cover app state | +| screenshot, core-device-info, core-display, core-lock, core-processes, core-apps | Actions via CoreDevice (and simctl) | = | real-simulator test (screenshot) | +| mounted-images, personalization | Actions (native image mounter) | = | `DeveloperImageTests` | +| bonjour-rsd | Action `bonjour` (Network.framework) | = | — | +| remote-browse (RSD service list) | — | — | needs RemoteXPC (HTTP/2 + XPC over the device's USB network link); `devicectl device info details` lists capabilities instead | +| web-tabs (`webinspector opened-tabs`) | Action `web-tabs` (native `com.apple.webinspector`) | = (**G3 resolved**) | `webInspectorListsPagesAfterRetryingRefusals`, `nativeActionsRunAgainstTheCapturedTarget` | +| launch-app, location-set, location-clear | Actions via CoreDevice / simctl / legacy service | = | real-simulator test | +| open-url (`webinspector launch`, Safari automation) | Action `open-url` via CoreDevice / simctl | ◐ | the Web Inspector route needs a WebDriver automation session and Settings › Safari › Remote Automation; kept on CoreDevice | + +### 9.2 Workspaces, controls, and workflows + +| Area | Python | Swift | Class | +|---|---|---|---| +| Device & DDI | device info, Developer Mode check and guide, DDI source choice, mount/unmount, list images, CoreDevice details, RVI list, open project/artifact | Device page, Developer image card (§8), handoffs | = | +| Connection | banner, **Reconnect & Retry…** (guided 30-second reconnect window) | sidebar summary, Connection diagnostics, Next-step card, **Device › Reconnect a Device…** (guided 30-second window that watches discovery) | = (**G5 resolved**) | +| Capability Matrix | 11 rows incl. `rsd-tunnel`, `dvt`, `webinspector`; copy report; per-preset and per-case readiness buttons | Readiness Check (16 rows for devices; tunnel state in the CoreDevice row; “Safari Web Inspector” and “Instruments (xctrace)” rows); copy report; readiness status with Run / Check Again / Open Readiness Check on every action and on Evidence Capture | = (**G3**, **G8**, **G12** resolved) | +| Compatibility history | table, refresh, JSON/Markdown export with preview | Readiness history and exports | = | +| Location Lab | coordinates, map links, map, nudge, saved places, routes (speed presets, interval, traversals), **add current coordinate as waypoint**, GPX (ignore timing, randomness), evidence log, clear on stop | all, including **Add Current Coordinate** on the Route card | = (**G9 resolved**) | +| Live Logs | streams, reference, regex/case filter, pause, follow, stop, findings, **findings register with Copy**, copy visible, save raw/filtered, evidence bundle, pop-out | all, including **Copy Register** in the Findings sheet | = (**G10 resolved**) | +| Command Center / Man Pages / Drift | presets, console, prerequisites, risk badge, man pages, **use man-page command in console**, drift check | Actions, Advanced Mode, Tool Reference (**Use in Advanced Mode** fills in the `devicectl` command; nothing runs until Run), Toolchain Check | = (**G12 resolved**) | +| Installed Apps | table, filter, sizes, copy bundle ID, uninstall, stop | Apps page | = | +| Backup | encryption check/enable, destination, require encryption, full, progress, stop, open folder | Backup page | = | +| Sideload IPA | choose, inspect, developer package, install, stop | Install App | = | +| Evidence | guided case, authorization, streams, screenshot, crash pull, open last case, readiness | Evidence Capture, with the collection's own readiness status (screenshot adds Xcode's device service) | = | +| MVT | executable, backup, IOC files, output, fast, hashes, network, acknowledgements, guides | External Tools · MVT | = | +| UFADE | checkout, Python, output, validation incl. **developer-image submodule status**, guides, launch | External Tools · UFADE, including the developer-image submodule status and the command to populate it | = (**G13 resolved**) | +| Ecosystem tools | go-ios, ipsw, idb | idb Companion | = (go-ios/ipsw removed by request) | +| Workspace profiles | export/import with preview; fields incl. **`ddi_source`, `command_preset`**; imports schema-1 files | export/import with preview; developer-image mechanism, Actions category, and selected action; imports 0.3.x (schema 1) files, translating workspaces, presets (§9.1), `ddi_source` (→ built-in mounter) and DVT OSLog (→ Unified Logging), with notes in the preview | = (**G6 resolved**) | +| Support bundle, Session Activity, Demo Mode, Action Palette | — | ported | = | +| Shortcuts | ⌘1–9, ⌘0, ⌘R, ⌘K, ⌘L, ⌘F, **⌘/ reference**, **⌥⌘←/→ previous/next workspace** | ⌘1–9, ⌘R, ⇧⌘R, ⌘K, **⌘/** (Help › Keyboard Shortcuts), **⌥⌘←/→**, ⌃⌘S (sidebar). Not reproduced: ⌘0 and ⇧⌘E/M/S for the tenth and later pages (use ⌘K or ⌥⌘←/→), ⌘L/⌘F focus shortcuts (Tab and the search fields' own focus) | = (**G11 resolved**) | + +### 9.3 Command-line tools, evidence snapshots, tests + +| Item | Swift | Class | +|---|---|---| +| `ios-developer-collect` (all options) | `idt collect` — `--include-oslog` renamed `--include-unified-logs`; the old name is still accepted (hidden from help) | = (**G7 resolved**) | +| `ios-ipa-inspect`, `ios-local-ddi` | `idt inspect-ipa`, `idt ddi` | = / 🔁 | +| Evidence snapshots (17) | lockdown, images, diagnostics ×4, apps, provisioning, crashes, AFC root, CoreDevice details; processes and configuration profiles (native over USB since G1/G2); cryptex list and DVT ×3 excluded (RemoteXPC/DTX) | = (**G1**, **G2** resolved) | +| `tests/` behaviours | ported to Swift tests (see §5.1); packaging/runtime tests replaced by `scripts/build-release.sh` checks | = | + +### 9.4 Gap list (priority order) + +| # | Gap | Priority | Why | +|---|---|---|---| +| G1 | Native process list (`os_trace_relay` `PidList`) for actions and evidence | P1 | ✅ resolved — no Xcode needed over USB | +| G2 | Native configuration profiles (`com.apple.mobile.MCInstall` `GetProfileList`) | P1 | ✅ resolved — no Xcode needed over USB (also avoids `profile list --type`, missing in Xcode 26) | +| G3 | Safari/WebView tab listing (`com.apple.webinspector`) + Web Inspector readiness row | P1 | ✅ resolved — action “Safari and web view tabs”, Readiness row “Safari Web Inspector” | +| G4 | Bluetooth HCI capture (`com.apple.bluetooth.BTPacketLogger`) to `.pklg` | P1 | ✅ resolved — action “Bluetooth capture” | +| G5 | Guided reconnect | P2 | ✅ resolved — Device › Reconnect a Device…, also on the Connection diagnostics and No-device cards | +| G6 | Import 0.3.x workspace profiles; profile fields for the developer-image mechanism and selected action | P2 | ✅ resolved — tested with a profile written by 0.3.4's own exporter | +| G7 | `idt collect --include-oslog` accepted as an alias | P2 | ✅ resolved — hidden alias of `--include-unified-logs` | +| G8 | Instruments readiness row (replaces the DVT row) | P2 | ✅ resolved — Readiness row “Instruments (xctrace)” from `xctrace list devices` (available / offline / not listed); the Instruments recording action waits for it | +| G9 | Add current coordinate as a route waypoint | P3 | ✅ resolved — Location Lab › Route › Add Current Coordinate | +| G10 | Copy the findings register | P3 | ✅ resolved — Live Logs › Findings › Copy Register (Markdown, same as the evidence bundle's report) | +| G11 | Keyboard shortcut reference (⌘/) and previous/next workspace (⌥⌘← / ⌥⌘→) | P3 | ✅ resolved — Help › Keyboard Shortcuts; View › Previous/Next Workspace | +| G12 | “Use in Advanced Mode” from Tool Reference; readiness shortcuts on Actions and Evidence | P3 | ✅ resolved | +| G13 | UFADE developer-image submodule status | P3 | ✅ resolved — shown after Validate | diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..21875b5 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,60 @@ +{ + "originHash" : "15287537893581b424b35cbeac4ef33fc8fb83ca23b07f6f3c9673b536f72e3b", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "98ef3c98609a1e31b7e157b5b619579001a789d6", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d", + "version" : "2.103.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "322f3c2a4a21df31c84ca416bf65ee5e9059e440", + "version" : "2.37.5" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..e9fcd9d --- /dev/null +++ b/Package.swift @@ -0,0 +1,88 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "iOSDeveloperToolkit", + platforms: [.macOS(.v14)], + products: [ + .library(name: "ToolkitCore", targets: ["ToolkitCore"]), + .library(name: "DeviceKit", targets: ["DeviceKit"]), + .library(name: "ToolkitFeatures", targets: ["ToolkitFeatures"]), + .executable(name: "idt", targets: ["idt"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-nio.git", from: "2.70.0"), + .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.27.0"), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), + ], + targets: [ + .target( + name: "ToolkitCore", + path: "Sources/ToolkitCore" + ), + .target( + name: "DeviceKit", + dependencies: [ + "ToolkitCore", + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOTLS", package: "swift-nio"), + .product(name: "NIOSSL", package: "swift-nio-ssl"), + ], + path: "Sources/DeviceKit" + ), + .target( + name: "ToolkitFeatures", + dependencies: ["ToolkitCore", "DeviceKit"], + path: "Sources/ToolkitFeatures", + resources: [.process("Resources")] + ), + .executableTarget( + name: "idt", + dependencies: [ + "ToolkitCore", + "DeviceKit", + "ToolkitFeatures", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], + path: "Sources/idt" + ), + .testTarget( + name: "ToolkitCoreTests", + dependencies: ["ToolkitCore"], + path: "Tests/ToolkitCoreTests" + ), + .target( + name: "DeviceTestSupport", + dependencies: [ + "DeviceKit", + "ToolkitCore", + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOSSL", package: "swift-nio-ssl"), + ], + path: "Tests/DeviceTestSupport", + resources: [.copy("Fixtures")] + ), + .testTarget( + name: "DeviceKitTests", + dependencies: [ + "DeviceKit", + "DeviceTestSupport", + "ToolkitCore", + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOSSL", package: "swift-nio-ssl"), + ], + path: "Tests/DeviceKitTests", + resources: [.copy("Fixtures")] + ), + .testTarget( + name: "ToolkitFeaturesTests", + dependencies: ["ToolkitFeatures", "DeviceKit", "ToolkitCore", "DeviceTestSupport"], + path: "Tests/ToolkitFeaturesTests", + resources: [.copy("Fixtures")] + ), + ], + swiftLanguageModes: [.v6] +) diff --git a/README.md b/README.md index b6c13a3..db9323e 100644 --- a/README.md +++ b/README.md @@ -1,1224 +1,368 @@ # iOS Developer Toolkit

- iOS Developer Toolkit logo + iOS Developer Toolkit logo

-### iOS Developer Toolkit: a guided pymobiledevice3 GUI, Developer Disk Image mounter, and evidence workbench for macOS +**A native macOS app for working with iPhones, iPads, and simulators — device information, live logs, location simulation, app installs, backups, packet capture, readiness checks, and documented evidence collection.** [![CI](https://github.com/hideouts-io/iOS-Developer-Toolkit/actions/workflows/ci.yml/badge.svg)](https://github.com/hideouts-io/iOS-Developer-Toolkit/actions/workflows/ci.yml) -[![Documentation](https://github.com/hideouts-io/iOS-Developer-Toolkit/actions/workflows/docs.yml/badge.svg)](https://hideouts-io.github.io/iOS-Developer-Toolkit/) [![Latest release](https://img.shields.io/github/v/release/hideouts-io/iOS-Developer-Toolkit?display_name=tag)](https://github.com/hideouts-io/iOS-Developer-Toolkit/releases/latest) -![Platform](https://img.shields.io/badge/platform-macOS-000000?logo=apple&logoColor=white) -![Devices](https://img.shields.io/badge/device-iPhone%20%7C%20iPad-0969da) -![Python](https://img.shields.io/badge/Python-3.10%E2%80%933.13-3776ab?logo=python&logoColor=white) -![GUI](https://img.shields.io/badge/GUI-PySide6-41cd52) -![pymobiledevice3](https://img.shields.io/badge/pymobiledevice3-11.15.1-8250df) +![Platform](https://img.shields.io/badge/macOS-14%2B-000000?logo=apple&logoColor=white) +![Swift](https://img.shields.io/badge/Swift-6-F05138?logo=swift&logoColor=white) [![License](https://img.shields.io/badge/license-MIT-2da44e)](LICENSE) -> **Scope:** iOS Developer Toolkit is a macOS front end for authorized Apple-device development, diagnostics, testing, backup, and evidence-preservation workflows. It does not jailbreak iOS, bypass a passcode, disable the sandbox, defeat code signing, decrypt protected traffic, or provide unrestricted filesystem access. +![Overview](docs/screenshots/overview.png) -![iOS Developer Toolkit Home workspace](docs/screenshots/home.png) +iOS Developer Toolkit is written in Swift and SwiftUI. It talks to devices through macOS's own +device service (usbmuxd and lockdown), Xcode's CoreDevice service (`devicectl`), `simctl`, and +Instruments. It needs no Python, no Homebrew packages, and no administrator rights. -The current interface organizes Apple-device work into 13 focused workspaces. It mounts modern DDIs, checks device and developer-service readiness, runs validated `pymobiledevice3` presets, exposes the installed command help, simulates test locations, streams three forms of device logs, captures packets, inspects and installs eligible IPAs, inventories apps, creates encrypted backups, launches an isolated UFADE acquisition, hands decrypted backups to an external MVT analysis, validates optional ecosystem adapters, and builds hashed evidence cases. - -The screenshots use an illustrative device name, model, version, build, and UDID. They contain no real device capture, account identifier, backup, credential, or case evidence. - -Use the focused [documentation site](https://hideouts-io.github.io/iOS-Developer-Toolkit/) for quick start, architecture, safety, troubleshooting, release verification, contribution, and physical-device testing paths. This README remains the canonical complete feature and workspace reference. +> **Scope.** This is a tool for devices you own or are authorized to examine. It does not +> jailbreak iOS, bypass a passcode, disable the sandbox, defeat code signing, decrypt protected +> traffic, or provide unrestricted file-system access. ## Contents -- [Start here](#start-here) -- [Current release additions and visual tour](#current-release-additions-and-visual-tour) -- [What the workbench covers](#what-the-workbench-covers) -- [How the service layers fit together](#how-the-service-layers-fit-together) +- [What it does](#what-it-does) +- [Screenshots](#screenshots) - [Requirements](#requirements) -- [Installation](#installation) -- [First-device walkthrough](#first-device-walkthrough) -- [Workspace guide](#workspace-guide) - - [Home](#home) - - [Device and DDI](#device-and-ddi) - - [Device Capability Matrix](#device-capability-matrix) - - [Location Lab](#location-lab) - - [Live Logs](#live-logs) - - [Command Center](#command-center) - - [Installed Apps](#installed-apps) - - [Backup](#backup) - - [Sideload IPA](#sideload-ipa) - - [Evidence Capture](#evidence-capture) - - [Ecosystem Tools](#ecosystem-tools) - - [Man Pages](#man-pages) - - [Scope and Safety](#scope-and-safety) - - [Eligible Action Palette](#eligible-action-palette) - - [Session Activity and operation manifests](#session-activity-and-operation-manifests) -- [Developer Disk Images explained](#developer-disk-images-explained) -- [Guided command catalog](#guided-command-catalog) -- [Evidence case contents](#evidence-case-contents) -- [Command-line tools](#command-line-tools) -- [Privacy, integrity, and interpretation](#privacy-integrity-and-interpretation) +- [Install](#install) +- [First steps](#first-steps) +- [Workspaces](#workspaces) +- [Developer images](#developer-images) +- [Command-line tool](#command-line-tool) +- [Security and privacy](#security-and-privacy) - [Troubleshooting](#troubleshooting) -- [Development and packaging](#development-and-packaging) -- [Project boundaries and credits](#project-boundaries-and-credits) -- [Contributing, support, and security](#contributing-support-and-security) -- [License](#license) - -## Start here - -Start with [Clone the repository and run the app](#clone-the-repository-and-run-the-app) to launch the current source in a project-managed environment. The launcher creates the local virtual environment when needed, stages **iOS Developer Toolkit.app**, and opens it. If you prefer a portable application that does not require a repository checkout, use the architecture-specific downloads later in the Installation section. The GUI does not expose an interactive shell. IPython and Jedi remain source-installation tools, while the xonsh runtime is bundled because pymobiledevice3's AFC and backup services import it. - -Connect an unlocked iPhone or iPad with a data-capable USB cable, tap **Trust** on the device, and select the intended target in the top-right device picker. Developer Mode and a mounted DDI are required only for workflows that use Apple developer services; basic pairing, Lockdown, apps, backups, AFC, classic syslog, and many diagnostics can work without them. - -For a new device, a sensible sequence is: - -1. Open **Device & DDI**, confirm the selected device, and check Developer Mode. -2. Mount the downloaded personalized DDI or install the local Xcode DDI Cryptex when a developer workflow requires it. -3. Run **Capability Matrix** to verify the exact host, trust, DDI, tunnel, and developer-service path. -4. Begin with read-oriented presets in **Command Center**. -5. Use **Live Logs**, **Installed Apps**, or **Evidence Capture** for the intended task. -6. Save sensitive output to protected local storage. -7. Stop streams, clear a simulated location, and unmount the developer image when finished. - -The application executes the project-pinned binary directly. Guided values become an argument vector; the GUI does not pass them through a shell. Advanced Mode uses `shlex` to split arguments, but it does not evaluate pipes, redirects, substitutions, aliases, or shell operators. - -## Current release additions and visual tour - -Release `v0.3.4` combines the complete 13-workspace interface with the latest connection, streaming, packaging, and repository-readiness work: - -- **Guided Command Drift** checks the live `pymobiledevice3 --help` surface for all 49 presets before a device command is run, highlighting missing routes, changed options, failed checks, and cancellations without contacting a device; -- **Action Safety** makes state boundaries explicit: local-output actions require review, device changes require a typed device-bound `RUN` phrase, and high-impact actions additionally require a current-backup acknowledgement and an `IRREVERSIBLE` phrase; -- **Create Support Bundle…** produces an opt-in local ZIP with sanitized environment, readiness, status, and command-drift metadata plus a SHA-256 manifest; it excludes device identity, captures, backups, logs, command output, credentials, and common host/network identifiers; -- **Retry Scan** performs an immediate usbmux device check, while **Reconnect & Retry…** opens a guided detection window without attempting to restart SIP-protected Apple services; -- **Connection diagnostic** records whether usbmux did not launch, failed, returned malformed output, found no devices, or returned selectable devices; its privacy-safe summary is visible in Device & DDI and included in a sanitized support bundle; -- **Selected command readiness** maps each guided Command Center action to the exact connection, trust, Developer Mode, DDI, tunnel, CoreDevice, DVT, or Web Inspector checks it needs, with a one-click route to the bounded read-only matrix; -- **Action Palette** (`⌘ K`) searches all workspaces, guided presets, utilities, and currently eligible read actions while withholding device-only operations until a physical target is selected; -- **Session Activity** correlates completed typed operations with workspace, target, transport, exact argument vector, timing, terminal status, prerequisite snapshot, output paths, and output hashes without automatically persisting raw command output; -- **Workspace Profiles** preview and import/export reviewed control defaults for team reuse without including device identity, credentials, paths, coordinates, case text, command parameters, or output; -- **MVT Analysis** validates a user-installed `mvt-ios` executable and runs a consented decrypted-backup analysis with isolated output, opt-in indicators, network access off by default, no password input, and no clean-device verdict; -- **Ecosystem Tools** validates user-selected go-ios, idb Companion, and ipsw executables by path, SHA-256, and version/build identity, then enables one bounded read-only inventory probe per adapter; -- the desktop UI starts independently of the MobileBackup2 transport, and device discovery consumes output both while the child process runs and after it exits, so a fast successful `usbmux list` result is not lost before the picker is updated; -- **Demo Mode** shows a prominently labeled simulated iPhone for walkthroughs and screenshots, while deliberately withholding a selected physical-device target and disabling device operations; -- the manual **Capability Matrix** reports host, trust, Developer Mode, DDI, tunnel, DVT, CoreDevice, and related readiness as separate bounded results, then compares completed local probes across real devices and previews sanitized JSON or Markdown exports without disclosing stored device fingerprints; -- **DVT network activity** and **CoreDevice applications** are handled as long-running streams with explicit Stop controls instead of misleading finite snapshots; -- Unified Logs, classic syslog, and DVT OSLog use independent pop-out windows with raw spooling, pause, filtering, save, and explicit close behavior; -- Location Lab supports validated coordinates, saved places, offline map selection, generated routes, GPX playback, event evidence, and explicit location clearing; -- app inventory, local IPA inspection, eligible installation, encrypted MobileBackup2 workflows, isolated UFADE launch, external MVT analysis, PCAP, screenshots, crashes, and hashed evidence cases are integrated into one workbench; -- native Apple Silicon and Intel release ZIPs are built separately and verified with 133 tests, embedded CLI checks, a 138-button GUI smoke test, full-bundle architecture and deployment-floor inspection, strict code-signature validation, and one SHA-256 manifest; -- public contribution paths now include structured issues, Discussions, pull requests, CI, CodeQL, dependency review, Dependabot, private vulnerability reporting, and protected `main`. - -The README contains 22 sanitized screenshots. The six views below provide a quick tour; each workspace section later in the README contains the relevant full-size image and operational walkthrough. - -| Prepare the device and DDI | Observe live services | Run guided commands | -|---|---|---| -| [![Device and DDI workspace](docs/screenshots/device-and-ddi.png)](docs/screenshots/device-and-ddi.png) | [![Live Logs workspace](docs/screenshots/live-logs.png)](docs/screenshots/live-logs.png) | [![Command Center workspace](docs/screenshots/pymobiledevice3-console.png)](docs/screenshots/pymobiledevice3-console.png) | +- [Build from source](#build-from-source) +- [Project status and limitations](#project-status-and-limitations) +- [Contributing, support, and license](#contributing-support-and-license) -| Simulate test locations | Create protected backups | Preserve correlated evidence | -|---|---|---| -| [![Location Lab workspace](docs/screenshots/location-lab.png)](docs/screenshots/location-lab.png) | [![Backup workspace](docs/screenshots/backup.png)](docs/screenshots/backup.png) | [![Evidence Capture workspace](docs/screenshots/evidence-collection.png)](docs/screenshots/evidence-collection.png) | +## What it does -### New in v0.3.4 +| Area | What you get | +|---|---| +| **Devices** | Automatic discovery of USB and Wi-Fi–synced devices (event-driven, no polling), Xcode-paired network devices, and simulators — shown in separate *Physical Devices* and *Simulators* sections. | +| **Plain-language device details** | Name, model, hardware identifier, UDID, iOS version and build, architecture, connection, trust, Developer Mode, and developer-service status, each with an explanation. Identifying values stay hidden until you choose to show them. | +| **Readiness Check** | A read-only check of every prerequisite (Xcode, the macOS device service, connection, trust, Developer Mode, Xcode's device service, developer services, Instruments, lock state, logging and backup services, Safari Web Inspector) with a next step for anything not ready. | +| **Live Logs** | Unified Logging and classic syslog from physical devices, and the simulator's unified log. Every byte is spooled and hashed; the view can be paused and filtered (literal or regex) without affecting capture. Mark findings, then export the raw capture, filtered lines, or an evidence bundle. | +| **Location Lab** | Set a coordinate (offline world map, map-link parsing, nudges, saved places), move along a route at constant speed, or replay a GPX track. Always clearable; every change is logged. | +| **Apps** | Search and sort installed apps (with sizes over USB), launch, and remove with confirmation. | +| **Install App** | Inspect an `.ipa` on the Mac first — contents, provisioning profile, and code signature verified with Security.framework — then install it on a device, or install an `.app` on a simulator. | +| **Actions** | Over 40 guided actions (diagnostics, battery, IORegistry, provisioning and configuration profiles, crash reports, screenshots, sysdiagnose, Instruments recordings, packet capture, Bluetooth capture, Safari and web view tabs, network discovery, launch, open URL, simulated location, restart, simulator controls), each showing its risk, what it needs, and exactly how it runs. An Advanced Mode runs `devicectl` subcommands bound to the selected device. | +| **Backup** | Encrypted local backups with the same protocol Finder uses, full or incremental, with progress. Turn on backup encryption with a new password (never stored or logged). | +| **Evidence Capture** | A documented case folder with snapshots, optional timed streams (Unified Logs, syslog, packet capture), a screenshot, and crash reports, plus a manifest and SHA-256 hashes. Failed steps are recorded as coverage gaps. | +| **Packet capture** | Device-side network packets written as a standard `.pcap` file for Wireshark or tcpdump, as an action or as part of Evidence Capture. | +| **External tools** | Optional handoffs to separately installed [MVT](https://github.com/mvt-project/mvt), [UFADE](https://github.com/prosch88/UFADE), and [idb Companion](https://github.com/facebook/idb), validated by path and SHA-256. | +| **Session Activity** | Everything run in this session, with exportable manifests (output hashes, never raw output). | +| **Tool Reference** | The exact help text of the installed `devicectl`, `simctl`, and `xctrace`, and a Toolchain Check that confirms every command the app relies on exists in your Xcode. | + +## Screenshots + +The demo screenshots use the built-in **Demo Mode** (a clearly labelled simulated iPhone) or a +real iOS simulator. They contain no real device data. + +| Readiness Check | Device details (simulator) | +|---|---| +| [![Readiness Check](docs/screenshots/readiness-check.png)](docs/screenshots/readiness-check.png) | [![Device](docs/screenshots/device-simulator.png)](docs/screenshots/device-simulator.png) | -| Function | What it does | Safety boundary | -|---|---|---| -| Guided Command Drift | Checks the installed help surface for all 49 presets before device work and reports changed routes, options, failures, timeouts, or cancellation. | Read-only; does not run a preset or contact a device. | -| Action Safety | Classifies every guided and advanced command as read-only, host-write, device-change, or high-impact. | Device changes require a typed device-bound acknowledgement; high-impact actions additionally require backup acknowledgement and `IRREVERSIBLE`. | -| Reconnect & Retry | Opens a bounded, guided 30-second device-detection window. | Does not restart SIP-protected Apple services. | -| Real-Device Compatibility | Compares completed Capability Matrix observations across locally tested devices, builds, and connection types, then previews sanitized JSON or Markdown reports. | Local history stores a one-way fingerprint; exports omit names, raw identifiers, stored fingerprints, and local paths. | -| Guided Cases | Records authorized purpose and scope before a bounded evidence collection. | Creates local intake metadata only; collection remains explicit. | -| Investigative Live Logs | Adds references, annotated findings, reviewed findings, raw hashing, and evidence-bundle export to pop-out streams. | Keeps raw output distinct from analyst annotations and does not upload captures. | -| Keyboard-first access | Adds named controls, standard navigation, and application-wide workspace shortcuts. | Shortcuts never bypass action confirmation. | -| Sanitized Support Bundle | Creates a reviewable local ZIP with environment/readiness summaries and a SHA-256 manifest. | Excludes device identity, captures, backups, command output, credentials, and common host/network identifiers. | -| Demo Mode | Shows a local simulated iPhone for an honest product walkthrough or screenshot. | The banner identifies the simulation and no device service, command, mount, capture, backup, or location operation can run. | -| Eligible Action Palette | Searches workspaces, utilities, guided presets, and read actions that are valid for the current device and process state. | Selecting a preset opens it for review; it never runs automatically or bypasses confirmation. | -| Session Activity | Correlates completed typed operations and previews an exportable structured JSON manifest. | Session-only by default; raw output is omitted, and explicit exports can still contain identifiers and local paths. | -| Workspace Profiles | Shares reviewed DDI, guided-command, app, backup, evidence, and location-control defaults as validated JSON. | Exact preview; owner-only, no-overwrite export; import changes controls only and never runs a command. | -| Guided MVT handoff | Validates and records external MVT provenance, then analyzes one authorized decrypted backup into a new result path. | No password input; inherited password/IOC variables are removed, network is off by default, and no result is translated into a clean-device claim. | - -## What the workbench covers - -| Workspace | Primary purpose | DDI needed? | Important result | -|---|---|---:|---| -| **Home** | Understand the workflow and jump to a task | No | Service-layer overview and guided entry points | -| **Device & DDI** | Check Developer Mode; manage a developer image; hand off to CoreDevice, RVI, Xcode, or Instruments | For mounting and some CoreDevice details | Explicit device target, image source, and native-tool output | -| **Capability Matrix** | Test host, connection, trust, DDI, tunnel, and developer-service readiness | Only for the developer-service rows | Bounded per-capability state, evidence, remediation, and real-device comparison | -| **Location Lab** | Set a coordinate or replay a validated GPX track | Usually | Structured location-event evidence and explicit Clear | -| **Live Logs** | Open independent Unified Logs, classic syslog, and DVT OSLog windows | Only DVT OSLog | Complete raw spool plus filtered working view | -| **Command Center** | Run 49 guided commands or explicit advanced arguments | Command-specific | Validated parameters, risk label, exact preview, exit output | -| **Installed Apps** | Search the service-visible app inventory and uninstall with confirmation | No | Names, bundle IDs, versions, types, optional sizes | -| **Backup** | Run MobileBackup2, launch separate UFADE, or analyze a decrypted backup with external MVT | No | Encrypted backup, external acquisition, or isolated forensic records | -| **Sideload IPA** | Inspect a local IPA before attempting installation | No DDI for normal install | Archive, provisioning, and signature report | -| **Evidence Capture** | Correlate snapshots, timed streams, screenshots, crashes, and PCAP | Partial coverage without it | Timestamped case, coverage states, manifest, SHA-256 inventory | -| **Ecosystem Tools** | Validate optional go-ios, idb Companion, and ipsw installations; run bounded inventory probes | Uses each external tool's own requirements | Resolved path, SHA-256, version/build, raw session output | -| **Man Pages** | Browse 59 command routes instantly and request live help on demand | No | Version-matched syntax rather than copied examples | -| **Scope & Safety** | Keep access and interpretation limits visible | No | Operational boundaries inside the app | - -### Accessibility and keyboard-first use - -The interface gives named controls and descriptions to the primary device picker, workspace navigation, command and help browsers, app inventory, capability results, reports, and the keyboard alternative to Location Lab's mouse map. The offline map is intentionally skipped in keyboard tab order; use the coordinate importer or latitude and longitude fields instead. - -Use **Keyboard Shortcuts** in the window header, or press `⌘ /`, for the complete reference. The most useful shortcuts are `⌘ K` to open the eligible Action Palette, `⌘ L` to focus workspace navigation, `⌘ F` to focus contextual search, `⌘ R` to retry discovery, `⌘ 1` through `⌘ 0` to open the first ten workspaces, `⌘ ⇧ E` for Ecosystem Tools, `⌘ ⇧ M` for Man Pages, and `⌘ ⇧ S` for Scope & Safety. `⌘ ⌥ ←` and `⌘ ⌥ →` move between workspaces. Tab, Shift-Tab, Space, Return, and Arrow keys retain their standard Qt behavior. Shortcuts never skip device-action confirmation or typed acknowledgements. - -### Eligible Action Palette - -![Eligible Action Palette](docs/screenshots/action-palette.png) - -Press `⌘ K` or use **Action Palette** below the workspace list to search the interface without memorizing where an operation lives. The result set is computed from the current app state: host-only presets remain available while disconnected, device-only presets appear only after a physical target is selected, and actions disappear while their process controller is busy. Workspace and utility navigation is always available. - -Choosing a guided preset opens Command Center with that preset selected and its exact command, prerequisites, risk, and confirmation path visible. It does not execute the command. Direct entries are limited to eligible read actions such as discovery, Developer Mode status, developer-image listing, CoreDevice or RVI details, the Capability Matrix, app inventory, backup-encryption status, Command Drift, Man Pages help, and already-validated external-tool probes. State is checked again at activation so a device disconnect, changed external binary, or newly busy controller cannot use a stale palette result. - -### Session Activity and operation manifests - -![Session Activity and structured operation manifest](docs/screenshots/session-activity.png) - -**Session Activity** in the sidebar shows completed operations from typed controllers in the current app session. The journal currently covers Device & DDI and Apple handoffs, Command Center, Installed Apps, IPA inspection and installation, MobileBackup2, MVT, Ecosystem Tools, Evidence Capture, and live Man Pages. Periodic discovery, Command Drift's internal per-route probes, and raw live-log streams are intentionally excluded; those have their own aggregate reports or evidence sidecars. - -Each record distinguishes the workspace and target from the transport, exact argument vector, start and finish timestamps, duration, terminal process outcome, exit code, process error, prerequisite-state snapshot, declared output paths, and SHA-256 plus byte count for each captured output channel. The manifest does **not** embed stdout or stderr. The UI keeps at most 250 records in memory and writes nothing automatically. - -Use **Copy Selected Manifest** or **Save Selected Manifest…** only when you intend to preserve a record. A saved JSON file is created with owner-only permissions and is never overwritten. Because exact arguments and targets can include a UDID, device details, IPA or GPX paths, case locations, and other sensitive values, review the manifest before sharing it. Session Activity is separate from the sanitized support bundle, which continues to exclude command arguments and device identity. - -### Local workspace profiles - -Use **Export Workspace…** and **Import Workspace…** below the workspace list to share reviewed workflow defaults without copying operational data. A profile can set the default workspace, DDI source, guided-command category and preset, app-inventory and IPA-install options, backup policy, evidence coverage and duration, and non-coordinate Location Lab timing and route-builder settings. - -Export shows the exact JSON before creating an owner-only file and refuses to overwrite an existing file. Import accepts only the bounded versioned schema, previews every proposed setting, refuses to apply while an operation is active, and rechecks the imported values immediately before changing the controls. It does not persist automatically, run a command, choose a device, fill a path, import an acknowledgement, or start a device action. The schema excludes device identity, credential fields, local paths, coordinates, command parameters, case text, and captured output. Profile name and description are user-supplied text; common path, account, device, and network identifier patterns are rejected, but the exact preview still must be reviewed before sharing. - -### Sanitized support bundle - -**Create Support Bundle…** in the window header creates a local ZIP for a bug report or support request. It is opt-in and never uploads anything. The ZIP contains toolkit and dependency versions, macOS/Python metadata, selected workspace, device count without identity, aggregate Capability Matrix states, sanitized status summaries, the sanitized Command Drift report, and a SHA-256 manifest. - -It explicitly excludes device names, UDIDs, serial numbers, pairing records, backups, cases, screenshots, raw logs, PCAPs, crash reports, IPA files, command output, live-log payloads, passwords, typed confirmations, user-entered values, host names, user names, home directories, full filesystem paths, network addresses, and email addresses. The generator also redacts known connected-device identifiers and names plus common identifier, local-path, IP, MAC, and email patterns. Review the ZIP before sharing; sanitization reduces exposure but cannot make a support artifact risk-free. - -![Sanitized support bundle confirmation](docs/screenshots/support-bundle.png) - -Highlights of the current build: - -- shared, explicit device selection across the full interface; -- a manual, read-only capability matrix with bounded subprocesses, cancellation, exact evidence, and no automatic probing; -- downloaded and local Xcode/CoreDevice DDI paths kept separate; -- fixed and GPX location simulation with bounded route generation and cleanup tracking; -- independent log windows that keep capturing while the visible view is paused; -- guided presets across device, app, logging, DVT, CoreDevice, discovery, Web Inspector, and device-action families; -- local IPA archive, provisioning, and macOS signature inspection before installation is enabled; -- searchable app inventory with optional size calculation and confirmed uninstall; -- MobileBackup2 encryption checks and new-password handling through a private helper input stream; -- isolated external UFADE validation and launch without importing its dependencies into this project; -- consent-based external MVT validation and backup analysis without accepting passwords or weakening MVT's warning model; -- a multi-source collector that retains failures as coverage evidence and hashes finalized artifacts; -- no one-click erase, restore, activation, supervision, reboot, shutdown, or nonce-changing shortcut. - -## How the service layers fit together - -```mermaid -flowchart LR - Device[iPhone or iPad] - Trust[USB pairing and Trust] - Usbmux[usbmuxd] - Lockdown[lockdownd services] - Basic[AFC, apps, backup, diagnostics, syslog, PCAP] - Mode[Developer Mode] - DDI[Personalized DDI at /System/Developer] - RSD[RemoteXPC / RSD tunnel] - Dev[DVT and CoreDevice services] - Toolkit[iOS Developer Toolkit] - Case[Local evidence or working output] +| Live Logs (simulator) | Location Lab | +|---|---| +| [![Live Logs](docs/screenshots/live-logs.png)](docs/screenshots/live-logs.png) | [![Location Lab](docs/screenshots/location-lab.png)](docs/screenshots/location-lab.png) | - Device <--> Trust <--> Usbmux <--> Toolkit - Toolkit <--> Lockdown <--> Basic - Toolkit <--> RSD <--> Dev - Mode --> DDI --> Dev - Basic --> Case - Dev --> Case -``` +| Actions | Apps | +|---|---| +| [![Actions](docs/screenshots/actions.png)](docs/screenshots/actions.png) | [![Apps](docs/screenshots/apps.png)](docs/screenshots/apps.png) | -These layers are related but not interchangeable: +| Backup | Evidence Capture | +|---|---| +| [![Backup](docs/screenshots/backup.png)](docs/screenshots/backup.png) | [![Evidence Capture](docs/screenshots/evidence-capture.png)](docs/screenshots/evidence-capture.png) | -- **USB detection** means `usbmuxd` can see a paired device. It does not prove every service is available. -- **Trust** authorizes the host pairing relationship. It is separate from Developer Mode. -- **Developer Mode** enables development services after an on-device restart and confirmation. -- **A DDI** supplies the matching developer-service components. It does not grant root or bypass iOS protections. -- **RemoteXPC/RSD** is used by many modern developer services, especially on iOS 17 and later. -- **DVT/CoreDevice output** is a service-mediated view. A path such as `developer dvt ls /` is not a raw filesystem image. +More: [Install App](docs/screenshots/install-app.png) · [External Tools](docs/screenshots/external-tools.png) · [Scope & Safety](docs/screenshots/scope-and-safety.png) ## Requirements -- macOS 13 or later; -- Python 3.10 through 3.13 for this project; -- an unlocked iPhone or iPad you are authorized to test or examine; -- a data-capable USB cable; -- enough protected disk space for logs, PCAPs, backups, crash reports, and case output; -- internet access when the downloaded DDI cache must be populated or Apple TSS personalization is required; -- Xcode when using the local candidate DDI path or when iOS needs Xcode pairing before it exposes Developer Mode. - -Current pinned runtime: - -| Component | Version or path | +| | | |---|---| -| Python | `>=3.10,<3.14` | -| PySide6 Essentials | `6.9.3` | -| pymobiledevice3 | `11.15.1` | -| Local Xcode candidate | `/Library/Developer/CoreDevice/CandidateDDIs/iOS_DDI.dmg` | -| Toolkit release | `0.3.4` | - -The current GUI and launcher are macOS-specific. Although upstream `pymobiledevice3` supports other host platforms, this application currently depends on macOS tools and conventions such as Xcode/CoreDevice, `hdiutil`, `security`, `codesign`, `.app` bundles, and macOS user-library paths. - -## Installation +| **macOS** | macOS 14 Sonoma or later. | +| **Mac** | Apple silicon or Intel (universal binary). Tested on Apple silicon. | +| **Devices** | iPhone and iPad (iOS/iPadOS). Developer-service features on physical devices need iOS 17 or later through Xcode's device service; iOS 16 and earlier are supported for identity, logs, backups, diagnostics, and apps. | +| **Simulators** | Any iOS simulator installed with Xcode. | +| **Connection** | A data-capable USB cable, or Wi-Fi sync / Xcode network pairing after first pairing over USB. | +| **Xcode** | Optional — see below. | -### Clone the repository and run the app +### What needs Xcode -For the current main branch: +Many features use only macOS's built-in device service and work on a Mac **without Xcode**. +Features that use Xcode's developer tools need Xcode installed (open it once to finish setup): -```bash -git clone https://github.com/hideouts-io/iOS-Developer-Toolkit.git -cd iOS-Developer-Toolkit -./script/build_and_run.sh -``` - -For the published `v0.3.4` source state: - -```bash -git clone --branch v0.3.4 --depth 1 https://github.com/hideouts-io/iOS-Developer-Toolkit.git -cd iOS-Developer-Toolkit -./script/build_and_run.sh -``` - -The launcher: - -1. creates `venv/` when needed, or safely rebuilds it with a compatible Python if the interpreter or pinned runtime has drifted; -2. installs the pinned project dependencies into that environment; -3. stages `dist/iOS Developer Toolkit.app`; -4. opens the staged app. - -The locally staged app is a development wrapper around the repository environment. Use the architecture-specific release asset described below when you need a portable, self-contained application. - -### Download the native application - -Each release provides two independent application bundles. Download the ZIP whose architecture matches the current Mac, plus `SHA256SUMS.txt`. The manifest published with that release is the source of truth for its exact asset sizes and SHA-256 digests. - -| Mac | Release asset pattern | +| Works without Xcode | Needs Xcode | |---|---| -| Apple Silicon (`arm64`) | `iOS-Developer-Toolkit-vVERSION-macOS-arm64.zip` | -| Intel (`x86_64`) | `iOS-Developer-Toolkit-vVERSION-macOS-x86_64.zip` | - -Check the Mac architecture before downloading: - -```bash -uname -m -``` - -Download the matching ZIP and `SHA256SUMS.txt` from the [latest release page](https://github.com/hideouts-io/iOS-Developer-Toolkit/releases/latest) and place them in the same directory. Verify only the archive for the current Mac; the manifest contains entries for both architectures, so checking the complete manifest after downloading only one ZIP would correctly report the other archive as missing. - -```bash -toolkit_arch="$(uname -m)" -grep "macOS-${toolkit_arch}.zip" SHA256SUMS.txt | shasum -a 256 -c - -``` - -The expected result ends in `OK`. If it reports `FAILED`, a missing line, or a different digest, do not open that download. Delete it and download the asset and checksum file again from the release page. - -Releases built by the current workflow also publish one CycloneDX SBOM per architecture and record GitHub build-provenance and SBOM attestations for each ZIP. Verify the downloaded archive against GitHub's transparency-backed attestation with the [GitHub CLI](https://cli.github.com/): - -```bash -toolkit_arch="$(uname -m)" -gh attestation verify \ - "iOS-Developer-Toolkit-vVERSION-macOS-${toolkit_arch}.zip" \ - --repo hideouts-io/iOS-Developer-Toolkit -``` - -Current tagged releases are built and attested through GitHub Actions. A successful verification ties the ZIP digest to this repository's GitHub Actions workflow. It does not make the ad-hoc signature a Developer ID signature or an Apple notarization ticket. - -The matching `iOS-Developer-Toolkit-vVERSION-macOS-ARCH.cdx.json` is both a downloadable release asset and embedded inside the app at `Contents/Resources/BOM.cdx.json`. License notices, the source-availability statement, and generated installed-package inventory are embedded at `Contents/Resources/Licenses/`. - -Extract the verified ZIP in Finder, or use: - -```bash -toolkit_arch="$(uname -m)" -toolkit_version="v0.3.4" -ditto -x -k "iOS-Developer-Toolkit-${toolkit_version}-macOS-${toolkit_arch}.zip" "iOS Developer Toolkit ${toolkit_version}" -``` - -Open the extracted folder and drag **iOS Developer Toolkit.app** into `/Applications`. The application is self-contained; Python and the pinned runtime do not need to be installed separately. - -### Open the ad-hoc-signed app safely - -The published bundles pass strict nested code-signature verification, but the signature is ad-hoc: it does not identify a Developer ID publisher and the app is not Apple-notarized. Gatekeeper may therefore block the first launch even when the ZIP matches the published SHA-256 value. - -Use the Apple-supported per-app opening path: - -1. In Finder, open **Applications** and try to open **iOS Developer Toolkit.app** once. -2. If macOS blocks it, Control-click or right-click the app and choose **Open**. -3. Review the warning and choose **Open** when that option is available. -4. If Finder still offers no Open option, open **System Settings → Privacy & Security**. -5. Scroll to the Security section, find the message that iOS Developer Toolkit was blocked, and click **Open Anyway**. -6. Authenticate through the macOS-owned prompt if requested, review the final warning, and choose **Open**. - -The exception applies to this app; it does not require turning off Gatekeeper. On a managed Mac, organization policy may remove **Open Anyway** or require administrator approval. That policy has no safe local workaround—the administrator must approve the application or provide a trusted distribution path. - -After moving the app to `/Applications`, the bundled code can be checked independently: - -```bash -codesign --verify --deep --strict --verbose=2 "/Applications/iOS Developer Toolkit.app" -``` - -A successful `codesign` check confirms that the extracted bundle is internally consistent with its ad-hoc signature. It does not turn the build into a Developer ID-signed or notarized application; the release checksum is what ties the downloaded ZIP to the asset published by this repository. - -Do not disable Gatekeeper globally, alter System Integrity Protection, or run broad commands such as recursive quarantine removal. If the verified app still will not open after the supported exception, retain the exact macOS warning, confirm the Mac architecture, download a fresh copy, and open a sanitized support request. - -### Use a GitHub source archive - -Download the source archive from the [releases page](https://github.com/hideouts-io/iOS-Developer-Toolkit/releases), extract it, open Terminal in the extracted folder, and run: - -```bash -./script/build_and_run.sh -``` - -GitHub's automatically generated source ZIP and tarball are source packages. The two explicitly named macOS ZIP assets are the prebuilt applications. - -### Verify the host first - -```bash -sw_vers -productVersion -python3 --version -xcode-select -p -``` - -If Python 3.10 through 3.13 is unavailable, install a supported Python locally before launching. Dependencies belong in the project-created `venv/`; do not install this project's pinned packages globally. - -If macOS warns about downloaded content, follow [Open the ad-hoc-signed app safely](#open-the-ad-hoc-signed-app-safely). Source installations and local development wrappers are also not a substitute for Developer ID signing and notarization. - -### Launch and diagnostic modes - -```bash -./script/build_and_run.sh -./script/build_and_run.sh --verify -./script/build_and_run.sh --debug -./script/build_and_run.sh --logs -./script/build_and_run.sh --telemetry -``` - -Re-running the launcher updates the environment from the current checkout and rebuilds the staged wrapper. The script also stops an existing toolkit Python process before launching the rebuilt copy, so finish or save active captures first. - -## First-device walkthrough - -### 1. Connect and trust - -1. Connect the device by USB. -2. Unlock it. -3. Tap **Trust** if iOS prompts. -4. Enter the device passcode on the device, never into this toolkit. -5. Select the intended device in the global picker. - -The blue banner reports discovery state. Keep only the intended device attached during a sensitive backup, install, location test, or acquisition. - -### 2. Enable Developer Mode when needed - -![Developer Mode instructions](docs/screenshots/developer-mode-guide.png) - -On iOS: - -1. Open **Settings → Privacy & Security → Developer Mode**. -2. Turn Developer Mode on and restart when prompted. -3. After restart, unlock the device and confirm **Turn On** or **Enable**. -4. Reconnect and trust the Mac again if requested. - -If the Developer Mode setting is missing, pair the device in Xcode through **Window → Devices and Simulators**, then check Settings again. Developer Mode expands the device's development attack surface; turn it off and restart after the work if it is no longer needed. - -### 3. Mount the matching DDI - -Open **Device & DDI**. For iOS 17 and later, choose either the downloaded personalized DDI or the local Xcode candidate. List mounted images after the operation and retain the output if the mount state matters to your case. - -### 4. Verify capability readiness - -Open **Capability Matrix** and run the manual check against the selected device. Review each row independently: a working Lockdown connection does not prove that Developer Mode, the DDI, an RSD tunnel, CoreDevice, DVT, or Web Inspector is ready. Resolve **Needs attention**, **Unavailable**, or **Blocked** results required by your intended workflow before continuing. - -The matrix is a point-in-time readiness report, not a permanent certification. Save or copy the report when you need to document why a command was expected to work or which prerequisite remained unavailable. - -### 5. Run the intended workflow - -Use a guided workspace or a read-oriented Command Center preset first. The exact target, prerequisites, risk label, and argument preview are visible before execution. - -### 6. Clean up - -- Stop every live log, PCAP, metrics, or GPX playback process. -- Save or explicitly discard each pop-out log capture. -- Clear Location Lab state on the originally tracked device. -- Unmount the downloaded DDI or uninstall the local DDI Cryptex with the matching button. -- Protect or sanitize output before sharing it. - -## Workspace guide - -### Home - -![Home workspace](docs/screenshots/home.png) - -Home is the map of the application. It provides one-click entry points for preparing developer services, testing locations, running guided commands, collecting evidence, and reading advanced help. The protocol summary explains why logs, packets, processes, crash reports, and backups should be correlated rather than treated as interchangeable evidence. - -### Device and DDI - -![Device and personalized DDI workspace](docs/screenshots/device-and-ddi.png) - -This workspace shows the selected device name, iOS/build, model, and UDID; presents the on-device Developer Mode guide; queries Developer Mode state; and keeps the two modern DDI sources distinct. - -#### Downloaded personalized DDI - -The recommended iOS 17+ path runs `pymobiledevice3 mounter auto-mount`. Upstream retrieves the APFS image, `BuildManifest.plist`, and trust cache when needed, stores them under: - -```text -~/.pymobiledevice3/Xcode_iOS_DDI_Personalized/ -``` - -It then requests Apple TSS personalization for the selected device and mounts the result at `/System/Developer`. The matching cleanup action is **Unmount Personalized DDI**. - -#### Local Apple/Xcode DDI - -![Local Xcode DDI workspace](docs/screenshots/local-xcode-ddi.png) - -The local path uses: - -```text -/Library/Developer/CoreDevice/CandidateDDIs/iOS_DDI.dmg -``` - -The toolkit attaches this outer host image read-only, validates its `Restore` payload and build manifest, asks `pymobiledevice3 cryptex auto-install --restore-dir` to personalize and install `com.apple.MobileAsset.DDI`, and detaches the host image even when an error occurs. The outer DMG itself is not uploaded directly to iOS. The matching cleanup action is **Uninstall Local DDI Cryptex**. - -Both modern paths normally require Apple TSS access. A cached DDI payload does not guarantee that personalization can complete offline. - -Developer Mode queries and DDI mount, list, unmount, install, and uninstall actions use the shared bounded operation controller. It drains both output channels at completion, reports launch failures and crashes distinctly, prevents periodic device refreshes from re-enabling conflicting controls, and stops an action that exceeds the 15-minute safety limit. - -#### Apple developer-tool handoff - -![Apple developer-tool handoff controls](docs/screenshots/xcode-handoff.png) - -The final group in Device & DDI deliberately hands native work back to Apple tools: - -- **CoreDevice Details** runs `xcrun devicectl device info details --device --timeout 30` and displays Apple's human-readable output without treating it as a stable machine schema; -- **List RVI Interfaces** runs the installed `rvictl -l` so an existing Remote Virtual Interface can be cross-checked before packet capture; -- **Open Xcode Project…** validates an `.xcodeproj`, `.xcworkspace`, or `Package.swift` and hands it to Apple's `xed` launcher; -- **Open Result / Trace…** validates and opens an `.xcresult` or `.trace` bundle in Xcode or Instruments. - -These are read-only host handoffs. They do not create a project, run tests, start a trace, create or remove an RVI, or parse proprietary Xcode result formats. CoreDevice, RVI, and `xed` handoffs have a 60-second GUI safety limit and retain the exact command and terminal result in the shared output panel. - -### Device Capability Matrix - -![Device Capability Matrix workspace](docs/screenshots/capability-matrix.png) - -The matrix is a manual readiness check for the currently selected device. It never runs merely because a device connects or because you open the workspace. **Run Capability Matrix** starts a separate bounded worker so a slow Apple service or Python import cannot freeze the interface; **Cancel** stops the current probe and preserves every completed result. - -The current matrix reports: - -- the project-pinned `pymobiledevice3` runtime; -- Apple `devicectl` and `xctrace` availability through `xcrun`; -- the selected device and transport from the most recent usbmux discovery; -- pairing and Lockdown trust; -- Developer Mode state; -- mounted Developer Disk Image records; -- the iOS 17+ Remote Service Discovery/tunnel route, inferred only after a successful CoreDevice request; -- CoreDevice device-information and lock-state services; -- DVT instrumentation reachability; -- Safari Web Inspector response state. - -Every row uses one of six explicit states: **Ready**, **Needs attention**, **Unavailable**, **Blocked**, **Not tested**, or **Not applicable**. Select a row to see the bounded evidence and its next step. **Copy Report** creates a plain-text snapshot for a bug report or development note; command errors redact the selected device identifier. - -The **Real-Device Compatibility** tab retains a local, append-only observation only after the worker completes a Capability Matrix run against a connected device. It compares the latest observed result for each locally tested device across model, iOS version, build, connection type, and individual capabilities. The history stores a one-way device fingerprint rather than the raw UDID or device name, and it never predicts compatibility for untested hardware or builds. It lives at `~/Library/Application Support/iOS Developer Toolkit/Compatibility/real-device-observations.jsonl`. - -**Export Sanitized JSON…** and **Export Sanitized Markdown…** build an exact local preview before saving an owner-only file. The exported report includes the toolkit, macOS, architecture, Python, PySide6, and `pymobiledevice3` versions plus device model, iOS version/build, connection type, probe time, states, and sanitized evidence and remediation. It omits device names, raw identifiers, and stored fingerprints, and redacts common local paths, email addresses, IPv4 addresses, and MAC addresses. The application never uploads the report; model and build metadata can still be identifying in a small fleet, so review the preview before sharing it. - -The matrix does not mount a DDI, enable Developer Mode, start a tunnel daemon, change Safari settings, or unlock the device. A service being reachable at refresh time is not proof that every command in that family will succeed, and an empty Web Inspector tab list is different from a failed Web Inspector request. - -### Location Lab - -![Location Lab workspace](docs/screenshots/location-lab.png) - -Location Lab uses Apple developer services for explicit application testing. It does not alter GPS hardware and does not claim to hide simulation from applications. - -Capabilities: - -- click a bundled, offline Natural Earth world map to select a coordinate without contacting a mapping service; -- import coordinates directly or extract visible coordinates from full Apple Maps, Google Maps, and `geo:` links; -- reject shortened or text-only map links instead of resolving them through an external service; -- validate finite latitude and longitude values and enforce geographic ranges; -- set a fixed simulated coordinate; -- nudge coordinate fields north, northeast, east, southeast, south, southwest, west, or northwest; -- select a nudge distance from 1 through 100,000 metres; -- save and remove named places in the local application-support folder; -- accept manual `latitude,longitude` waypoints; -- generate timestamped GPX routes using Walk, Run, Bicycle, Urban drive, Highway, or a custom speed; -- configure 1–60 second point intervals, 1–20 traversals, and forward or ping-pong travel; -- cap generated routes at 100,000 points; -- inspect a local GPX up to 64 MiB, reject DTD/entity input, require track points, validate every coordinate, and calculate SHA-256; -- report point count, timed-point count, first and last coordinates, distance, and expected duration; -- replay original timing, add bounded timing randomness, or explicitly choose fast playback; -- retain the original target identity for cleanup even if the global device picker changes; -- stop playback and issue Clear, including a retry-and-evidence path during application close; -- append structured events to `location-events.jsonl`. - -Saved locations, map selections, imported coordinates, and generated routes stay local. The feature contains no address search, external geocoder, automatic route provider, location-link resolver, or anti-detection behavior. The bundled world map uses public-domain [Natural Earth 1:110m land data](https://www.naturalearthdata.com/downloads/110m-physical-vectors/). - -Modern devices use `developer dvt simulate-location`; older supported paths use the legacy developer location service. Availability still depends on the selected iOS build, Developer Mode, DDI, and any required tunnel. - -### Live Logs - -![Live Logs workspace](docs/screenshots/live-logs.png) - -Live Logs opens three independent windows, so investigators and developers can compare service views without forcing all data into a single combined stream. - -![Unified Logs pop-out window](docs/screenshots/live-log-window.png) - -| Window | Command family | DDI requirement | Format | -|---|---|---:|---| -| **Unified Logs** | `syslog live --format json --label` | No | Structured JSON lines from `os_trace_relay` | -| **Classic Syslog** | `syslog live-old` | No | Raw compatibility text stream | -| **DVT OSLog** | `developer dvt oslog --format json` | Yes | Structured developer-service stream | - -Each window provides: - -- a continuously scrolling view; -- Pause View without pausing the underlying capture; -- follow-tail control; -- literal or regular-expression filtering; -- case-sensitive filtering; -- optional local case or ticket reference stored with capture metadata; -- selected-line **Mark Finding** annotations with an explicit assessment (**Observation**, **Lead to correlate**, or **Needs corroboration**), tags, analyst note, filter context, capture byte position, and timestamp; -- a separate **Review Findings** register, so analyst annotations remain visibly distinct from raw device output; -- copy-visible, save-filtered, and save-raw actions; -- **Export Evidence Bundle** for the raw capture, metadata, findings, a readable `investigation-report.md`, and a SHA-256 inventory in one local folder; -- an explicit Stop Capture action; -- save, discard, or cancel when closing an unsaved stream. - -The complete raw byte stream is spooled below `~/Library/Caches/iOS Developer Toolkit/Live Logs`. A metadata sidecar records the exact command, target UDID, timestamps, byte and line counts, exit code, process error, raw SHA-256 once finalized, investigation reference, and the number of findings. Findings are append-only local annotations in `*.findings.jsonl`; they preserve an analyst-selected visible excerpt and context but are not device-generated facts or proof of causality. `investigation-report.md` makes the same boundary explicit in the export: capture facts first, then analyst annotations and selected snippets. The responsive working view retains the latest 50,000 decoded lines and renders at most 20,000 blocks; those display limits do not truncate the raw spool. - -For retained system log archives, use the applicable `syslog collect` command through Command Center/Advanced Mode and analyze the resulting `.logarchive` with Console.app or the macOS `log` tool. - -### Command Center - -![Command Center](docs/screenshots/pymobiledevice3-console.png) - -Command Center is the low-typing interface to the pinned `pymobiledevice3` runtime. Search or filter a preset, review its description and prerequisites, fill only the required parameters, inspect the exact command, and run it directly. The **Selected command readiness** pane evaluates only the capabilities that preset needs. **Run Device Readiness Check** opens the existing bounded, read-only Capability Matrix; it does not execute the selected command or repair the device automatically. Guided and advanced commands use a typed interactive-process controller that drains both output channels at completion, distinguishes launch failure, crash, failure, success, and cancellation, and preserves the explicit **Stop** control required by streaming presets without imposing an arbitrary runtime limit. **Check Guided Command Drift** is a separate host-only preflight that calls the installed CLI's `--help` for every guided route and verifies any preset option flags such as `--out`; it does not run a preset or contact a device. Each drift route uses the shared finite-operation controller for complete output draining, a five-second timeout, cancellation, launch diagnostics, and clean sequential relaunch. The report distinguishes unavailable routes, changed option syntax, failed checks, and routes not completed before cancellation. - -Every preset has a visible risk class: - -- **Read-oriented** requests information or starts an observation stream. -- **Writes output** creates a host-side artifact, such as a screenshot, crash pull, or PCAP. -- **Changes device state** launches an app, opens a URL, changes a simulated location, or performs another explicit device action. - -Advanced Mode accepts a `pymobiledevice3` argument string. It never invokes a shell, but it can still reach high-impact upstream commands. The workbench classifies commands before execution: read-only commands run normally; local-output commands require a review dialog; device-state changes require typing `RUN` plus the selected device's final six alphanumeric identifier characters; and restore, erase, activation, supervision, reboot, shutdown, and nonce-related actions also require a current-backup acknowledgement and typing `IRREVERSIBLE` plus that suffix. The typed phrase prevents an accidental click and binds the approval to the selected device; it does not authenticate the user, override iOS security, or make an unsafe command safe. These high-impact operations are intentionally not promoted as guided one-click actions. - -Long-running commands remain attached to a visible Stop control. Stopping a process requests termination; always inspect the command output to determine whether the device or host operation completed before it stopped. - -### Installed Apps - -![Installed Apps workspace](docs/screenshots/installed-apps.png) - -Refresh loads the app inventory for the selected trusted device. The table can search and sort by app name, bundle ID, version, build, type, and optional calculated size. It can copy a selected bundle ID and uninstall a selected app only after explicit confirmation. - -Inventory and uninstall operations use the shared bounded controller, including terminal output draining, explicit launch/crash/timeout/cancellation results, and a 10-minute safety limit. The visible Stop control requests controller cancellation and retains the terminal result in the workspace output. - -The inventory is held in memory unless it is included in an evidence collection. App names and bundle IDs can reveal sensitive usage or organizational information; do not publish them without review. - -An empty inventory is not proof that no apps exist. It may instead indicate device lock state, pairing, service availability, filters, command failure, or incomplete visibility. - -### Backup - -![Backup providers workspace](docs/screenshots/backup.png) - -The Backup workspace keeps three providers isolated. - -#### MobileBackup2 - -The built-in provider supports full and reusable incremental backup state. Before starting, it can query whether persistent backup encryption is enabled and enforce an encrypted-backup policy. - -If encryption is currently off and **Require encrypted backup** is selected: - -1. enter and confirm a new backup password; -2. the toolkit passes it to a private helper over standard input as structured data; -3. the password is cleared from the fields; -4. it is never placed in process arguments, normal logs, or saved settings; -5. the operation becomes a full backup because encryption state changed. - -Backup encryption is a persistent device setting. The toolkit never disables it automatically. Store the password securely: an encrypted backup cannot be restored without it. Do not reuse an account password or device passcode. - -The GUI starts the backup helper asynchronously, validates each structured progress event, drains terminal output, and converges launch failure, worker failure, cancellation, and success on one typed result. Malformed worker output stops the operation instead of silently continuing with an unreliable progress channel. Stopping a backup asks the worker to stop and preserves visible status. Confirm the finalized backup state before depending on it for recovery or evidence. - -#### UFADE External - -[UFADE](https://github.com/prosch88/UFADE) remains an independent GPL-3.0 application. This toolkit does not vendor, import, patch, relicense, or redistribute it. - -![External UFADE provider workspace](docs/screenshots/ufade-backup.png) - -The external provider validates: - -- an absolute path to a user-managed UFADE checkout; -- the presence of `ufade.py`, `LICENSE`, and `requirements.txt`; -- the expected GPL-3.0 license text and UFADE version declaration; -- a separate Python 3.11 executable; -- UFADE's required runtime imports in that isolated environment; -- a user-selected working/output directory. - -After validation, **Launch UFADE** starts its own process and UI. UFADE controls device selection, passwords, acquisition type, stop behavior, and output. The toolkit does not read its passwords or acquisition data and does not terminate it when the toolkit closes. - -Acquisition choices are made inside UFADE: - -- **Logical** — MobileBackup2-style acquisition; -- **Logical+** — backup plus additional service-visible media, shared folders, crash reports, and optional Unified Logs; -- **Logical+ UFD** — advanced logical ZIP with a UFD descriptor; -- **PRFS** — a decrypted, filesystem-shaped logical archive assembled from service-visible data; -- **Full filesystem** — only when the device is already jailbroken; the integration supplies no jailbreak or bypass. - -##### Set up and launch UFADE on macOS - -UFADE must use its own Python 3.11 environment. Do not install UFADE's pinned dependencies into the iOS Developer Toolkit environment. The **Copy Setup Commands** button provides the current recommended commands: - -```bash -brew install python@3.11 python-tk@3.11 -git clone --recurse-submodules https://github.com/prosch88/UFADE.git -cd UFADE -python3.11 -m venv .venv -.venv/bin/python -m pip install --upgrade pip -.venv/bin/python -m pip install -r requirements.txt -``` - -Then, in **Backup → UFADE External**: - -1. Choose the cloned `UFADE` folder containing `ufade.py`, `requirements.txt`, and `LICENSE`. -2. Click **Use Checkout .venv**, or select `UFADE/.venv/bin/python` manually. -3. Choose a protected working/output directory with enough free space for the intended acquisition. -4. Click **Validate Installation** and resolve every missing-file, Python-version, submodule, or import error. -5. Connect, unlock, and trust only the intended device, then click **Launch UFADE**. -6. Select the acquisition and answer password prompts inside UFADE. Use UFADE's own progress and stop controls. - -The selected toolkit device is shown only as a cross-check; UFADE performs its own discovery and device selection. Closing iOS Developer Toolkit does not stop the separately launched UFADE process. UFADE output may contain decrypted backups, app-shared data, logs, device identifiers, and account content, so keep it outside the source checkout on access-controlled storage. - -Use UFADE's own documentation to assess version compatibility, licensing, dependencies, and the forensic meaning of each output format. - -#### MVT Analysis - -[MVT](https://github.com/mvt-project/mvt) is an independent forensic research tool intended for consented mobile-device analysis. It remains separately installed under the MVT License; the toolkit does not bundle, import, patch, update, relicense, or redistribute it. - -![Guided external MVT backup analysis](docs/screenshots/mvt-analysis.png) - -The guided handoff validates the selected `mvt-ios` executable asynchronously, records its resolved path, version, and SHA-256, and disables automatic MVT version and indicator update checks for a reproducible run. Choose one decrypted iTunes-style backup containing `Manifest.db` and `Info.plist`, a new output path that does not exist, and optional `.stix`, `.stix2`, or `.json` indicator files. An encrypted backup is rejected with instructions to prepare a protected decrypted working copy outside the toolkit. - -The toolkit has no MVT password field. It removes inherited MVT backup-password, implicit IOC, VirusTotal-key, profiling, and hashing variables before starting the external process. MVT receives an isolated temporary configuration directory that is deleted when the process completes. Network access is off by default; enabling it can allow shortened-URL resolution and other MVT requests. The selected source backup is never modified by the toolkit, and MVT must create a fresh result directory outside that source. - -Both acknowledgements are required: the operator must own the backup or have explicit authorization and consent, and must accept that successful completion or no findings does not prove that a device is clean, safe, uncompromised, or never targeted. The toolkit displays and records process outcome but does not parse MVT output into a verdict. Public indicators may be incomplete or stale; high-risk cases require qualified forensic support and appropriate non-public threat intelligence. - -##### Install and run MVT on macOS - -The **Copy Setup Commands** button follows MVT's separate-installation approach: - -```bash -brew install python3 pipx sqlite3 -pipx ensurepath -pipx install mvt -``` - -Then, in **Backup → MVT Analysis**: - -1. Click **Find Installed** or choose an absolute `mvt-ios` path, then click **Validate Installation**. -2. Choose the authorized decrypted backup. If the backup is encrypted, follow the [official decryption and backup-check guide](https://docs.mvt.re/en/latest/ios/backup/check/) outside this app; never place its password in a command, issue, or support bundle. -3. Choose a parent for a new result folder. Existing paths and locations inside the source backup are rejected. -4. Optionally select reviewed STIX2/JSON indicators, Fast mode, MVT hashing, or explicit network access. -5. Read and select both required acknowledgements, review the complete launch confirmation, and start the analysis. -6. Use **Stop** to request termination. A stopped or failed output directory is partial and must not be interpreted as a completed analysis. -7. Review MVT's `command.log`, structured records, alerts, timeline, hashes, tool version, and indicator provenance directly. Protect the output before sharing it. - -### Sideload IPA - -![Sideload IPA workspace](docs/screenshots/sideload-ipa.png) - -Selecting an IPA starts host-side inspection before the install control can be enabled. The inspector: - -- rejects absolute, parent-traversal, duplicate, ambiguous, and unsafe archive paths; -- locates the main `.app` and reads its `Info.plist`; -- extracts into a protected temporary directory; -- decodes `embedded.mobileprovision` with the macOS `security` tool when present; -- verifies the extracted app through macOS `codesign --verify --deep --strict`; -- reports bundle ID, display name, version, build, executable, signature status, team, certificate authorities, provisioning UUID, expiration, device count, debugging entitlement, and all-device provisioning state where available; -- keeps installation disabled when the signature is missing or invalid. - -After inspection, choose normal installation or **Install as developer package** and confirm the operation. Inspection and installation use the same bounded operation lifecycle as other finite toolkit actions: terminal output is drained, launch failures are explicit, installation can be cancelled, inspection is limited to five minutes, and installation is limited to 15 minutes. The toolkit does not sign, patch, re-sign, decrypt, or repair the IPA. Stock iOS still enforces package integrity, provisioning, trust, device eligibility, entitlements, and any App Store DRM. A DDI does not bypass those policies. - -Successful installation refreshes the Installed Apps inventory. Removal is a separate confirmed action in that workspace. - -### Evidence Capture - -![Evidence Capture workspace](docs/screenshots/evidence-collection.png) - -Evidence Capture starts with an optional guided case intake. Start with **Run Device Readiness Check** to launch the existing bounded, read-only Capability Matrix for the selected device, then enter a title and a local-purpose note, acknowledge that you own the device or are authorized to examine it, and create the case. The readiness shortcut does not mount images, change device settings, start a capture, or write device data; it opens the full matrix so every prerequisite and its remediation remain visible. The intake is stored locally as `case-intake.json`; it records the selected UDID, creation time, authorization acknowledgement, title, and purpose without transmitting anything. - -Start collection from an active guided case to attach the normal bounded snapshot, stream, manifest, and hash workflow to that folder. A guided case finalizes once; create another case for another collection. The standard **Start Evidence Collection** path remains available when an intake is not needed and creates a timestamped case immediately. - -Every run includes the core snapshot set and can add timed streams or larger artifacts. - -Core snapshots: - -- connected-device inventory; -- Lockdown device information; -- mounted developer images and installed Cryptex inventory; -- diagnostics service information; -- known MobileGestalt values; -- IORegistry; -- one battery snapshot; -- installed apps; -- process inventory; -- configuration and provisioning profiles; -- crash-report inventory; -- AFC media-root listing; -- DVT device information; -- DVT detailed process snapshot; -- DVT root service-view listing. - -Optional scope: - -- classic syslog stream; -- DVT structured OSLog stream; -- device-side network PCAP; -- current device screenshot; -- complete crash-report pull. - -The collector retries failed snapshots once, keeps the final artifact and a complete per-attempt command log, records semantic validation failures, and distinguishes required identification failures from optional coverage gaps. Its typed GUI controller reassembles JSON events across arbitrary output chunks, drains terminal output, and records launch, protocol, cancellation, crash, and exit outcomes explicitly. - -**Stop/Finalize** sends a graceful stop request and gives the collector up to two minutes to close streams, write `manifest.json`, and regenerate `SHA256SUMS.txt`. Closing the application while a collection is active waits for that finalization instead of immediately killing the worker. If finalization is not confirmed, the guided case remains active for review or retry rather than being labeled complete. - -### Ecosystem Tools - -![Optional ecosystem tool adapters](docs/screenshots/ecosystem-tools.png) - -Ecosystem Tools is an interoperability surface for three independently maintained MIT-licensed projects. Nothing is bundled, auto-downloaded, auto-updated, imported as a Python dependency, or treated as trusted merely because it was found on `PATH`. - -| Adapter | Installation command shown by the app | Validation | Bounded probe | -|---|---|---|---| -| [go-ios](https://github.com/danielpaulus/go-ios) | `npm install -g go-ios` | `ios --version` | `ios list --details` | -| [Meta idb](https://github.com/facebook/idb) | `brew install facebook/fb/idb` | `idb_companion --version` | `idb_companion --list 1` | -| [blacktop ipsw](https://github.com/blacktop/ipsw) | `brew install blacktop/tap/ipsw` | `ipsw version` | `ipsw idev list` | - -For each adapter: - -1. Click **Find Installed** or choose an executable manually. -2. Review the resolved path, SHA-256, version/build arguments, and third-party execution warning. -3. Validate the executable. A changed hash invalidates the installation before any probe. -4. Review the probe's exact argument vector and independent target-selection boundary. -5. Run or stop the 30-second read-only probe. The raw output stays in the session and the typed result appears in Session Activity. - -The adapters remove inherited target-routing and known credential variables such as `IDB_UDID`, `IDB_COMPANION`, `P12_PASSWORD`, and IPSW/GitHub API tokens before launch. That prevents an invisible environment value from selecting a remote target or supplying a credential to these specific probes. It is not a sandbox or a guarantee that a third-party executable performs no other I/O. - -go-ios is a separate device protocol implementation and may require its own tunnel setup for modern iOS. idb is a client/companion automation system whose current companion reports build identity rather than a semantic client version. ipsw is primarily a firmware and Apple-platform research suite; only its local `idev list` surface is exposed here. The toolkit does not reconcile their inventories with its selected-device state, infer that one tool is more authoritative, or expose mutating commands from these projects. - -### Man Pages - -![Man Pages and Possibilities workspace](docs/screenshots/man-pages.png) - -The Man Pages browser indexes 59 top-level and nested command routes. Selecting a route is immediate and does not start a process or contact the device. Click **Refresh Live Help** when you want the project-local executable's verbatim `--help` output. The shared finite-operation controller drains output at completion, accepts help written to either output channel, supports cancellation and clean relaunch, and stops a request automatically after 15 seconds so the page cannot remain stuck on “Loading live help.” Successful results are cached for the current app session. You can also copy the command prefix or send it to Command Center's Advanced Mode. - -This is the safest source for exact syntax in the installed environment. A command listed by the client is still not proof that the selected device build advertises the corresponding Apple service. - -The index covers activation, AFC, apps, backup, Bluetooth logging, Bonjour, companion, crash, Cryptex, developer services, diagnostics, IDAM, Lockdown, mounter, notifications, PCAP, power assertions, processes, profiles, provisioning, RemoteXPC, restore, SpringBoard, syslog, usbmux, Web Inspector, version, DVT, CoreDevice, DebugServer, accessibility, WDA, and other installed families. - -#### Advanced command interpretation - -The command catalog tells you what the installed client can request. Interpret its output according to the Apple service layer that produced it: - -| Layer | Examples | Correct interpretation | +| Device discovery (USB and Wi-Fi sync), identity, trust, Developer Mode status | Simulators (everything in the Simulators section) | +| Live Logs from physical devices (Unified and classic syslog) | Location simulation on iOS 17 and later | +| Packet capture; Bluetooth capture (with Apple's logging profile); Safari and web view tab listing (with Web Inspector on); checking the developer image, and mounting it over USB from an image Xcode installed or a folder you choose ([Developer images](#developer-images)) | Xcode's device service (`devicectl`) route for the developer image | +| Encrypted backups and encryption setup | Screenshots, launch app, open URL, stop a process | +| Diagnostics, battery, IORegistry, MobileGestalt, running processes, configuration profiles | Lock state, displays | +| Installed apps (with sizes), removing apps, installing `.ipa` packages | Sysdiagnose and Instruments recordings | +| Provisioning profiles, crash reports, Media folder listing | Restart device, Advanced Mode (`devicectl`), Tool Reference | +| IPA inspection, Evidence Capture (without the Xcode-only steps) | Readiness rows for Xcode's device service and Instruments | + +The Command Line Tools alone are not enough for the Xcode column: `devicectl`, `simctl`, and +`xctrace` ship with Xcode.app. + +## Install + +### Download a release + +1. Download `iOS-Developer-Toolkit-VERSION-macOS-universal.zip` and `SHA256SUMS.txt` from the + [latest release](https://github.com/hideouts-io/iOS-Developer-Toolkit/releases/latest). +2. Verify the download: + + ```bash + shasum -a 256 -c SHA256SUMS.txt --ignore-missing + ``` + + Optionally verify GitHub's build attestation: + + ```bash + gh attestation verify iOS-Developer-Toolkit-VERSION-macOS-universal.zip --repo hideouts-io/iOS-Developer-Toolkit + ``` + +3. Unzip it and move **iOS Developer Toolkit.app** to `/Applications`. +4. Release builds are ad-hoc signed and not notarized, so macOS asks for confirmation the first + time. Control-click the app, choose **Open**, and confirm. If there is no Open button, go to + **System Settings › Privacy & Security** and choose **Open Anyway**. Do not disable Gatekeeper. + +Nothing else needs to be installed. The release also contains `idt`, the command-line tool, at +`iOS Developer Toolkit.app/Contents/MacOS/idt`. + +### Build it yourself + +See [Build from source](#build-from-source). + +## First steps + +1. **Connect and trust.** Connect the iPhone or iPad with a USB cable, unlock it, and tap + **Trust**. Enter the passcode on the device — never in this app. +2. **Choose the target.** Use the device menu at the left of the toolbar. Physical devices and + simulators are listed separately, and every page shows which one it acts on. +3. **Run the Readiness Check.** It tells you exactly what works and what to fix next. +4. **Turn on Developer Mode if needed** (iOS 16+): *Settings › Privacy & Security › Developer + Mode*, then restart and confirm. **Device › Developer Mode Guide** walks through it. +5. **Mount the developer image** for screenshots, location simulation, and launching apps: the + **Developer image** card on the Device page shows its state; click **Mount Developer Image**. + See [Developer images](#developer-images). +6. **Clean up** when finished: stop streams and clear simulated locations. Quitting the app + clears a location this session simulated. + +No device handy? Turn on **Device › Demo Mode** to explore every workspace with a simulated iPhone. + +## Workspaces + +- **Overview** — status of the selected device, a suggested next step, and entry points. +- **Device** — identity and status with explanations, the developer image (state, mount, + unmount), Apple developer tool handoffs (open a project in Xcode, open an `.xcresult` or + `.trace`, list Remote Virtual Interfaces), simulator controls, raw records, and connection + diagnostics. +- **Readiness Check** — the read-only prerequisite check, a copyable report, and a local history + of tested devices that can be exported as sanitized JSON or Markdown. +- **Apps** — installed apps with search, sort, sizes, launch, and confirmed removal. +- **Install App** — inspect an `.ipa` (or choose a simulator `.app`), see whether the device is in + the provisioning profile, then install. +- **Location Lab** — coordinates, map, saved places (stored only on this Mac), routes, GPX playback. + Location events are appended to `~/Documents/iOS Developer Toolkit Location Logs/location-events.jsonl`. +- **Live Logs** — several concurrent streams; pop out any stream into its own window. +- **Actions** — the guided action catalog and Advanced Mode. +- **Backup** — encryption status and setup, full or incremental backups. +- **Evidence Capture** — optional guided case intake (title, purpose, authorization), collection, + and a summary of every step. +- **External Tools** — MVT, UFADE, and idb Companion. +- **Session Activity**, **Tool Reference**, **Scope & Safety** — history, built-in tool help, and + the app's boundaries. + +Press **⌘K** for the command palette, **⌘R** to refresh devices, **⇧⌘R** to run the Readiness +Check, **⌘1–⌘9** to switch workspaces, and **⌥⌘←** / **⌥⌘→** for the previous or next +workspace. **Help › Keyboard Shortcuts** (**⌘/**) lists them all. + +### How changes are confirmed + +| Risk | Example | Confirmation | |---|---|---| -| Lockdown services | AFC, Installation Proxy, MobileBackup2, diagnostics, syslog, profiles | Apple-defined views available through the pairing relationship; not root or unrestricted storage access. | -| RemoteXPC / RSD | `remote`, CoreDevice, modern display, HID, and location services | A transport and service-discovery layer; an advertised service can still reject a request or be absent on a particular build. | -| DVT / DTX | Sysmon, graphics, energy, OSLog, notifications, CoreProfile | Instruments-like developer telemetry that generally depends on Developer Mode, a compatible DDI, and the required tunnel. | -| Packet and log capture | PCAP, syslog, OSLog, Bluetooth HCI | Complementary observations with independent encryption, retention, permission, and visibility limits. | -| Process control | Launch, signal, kill, DebugServer | Runtime-changing operations that remain constrained by Apple service authorization. | -| Web automation | Web Inspector, CDP, WDA | Features requiring explicit device settings or a correctly signed WebDriverAgent; not arbitrary application automation by default. | -| Restore and profile management | IPSW, erase, supervision, activation, profile installation | High-impact administrative operations requiring exact authorization, current syntax, backups, and a verified recovery plan. | - -### Scope and Safety - -![Scope and Safety workspace](docs/screenshots/scope-and-safety.png) - -The final workspace states the application's boundaries where they are visible during use: - -- the app is a guided macOS workbench, not a jailbreak; -- a personalized DDI is a device-specific developer-service payload; -- a DVT listing is not unrestricted filesystem acquisition; -- TLS, process, app, DNS, profile, and endpoint observations require context; -- a failed command is a coverage gap, not proof of absence; -- Developer Mode, mounted images, logging, and location simulation can change device state or create sensitive artifacts; -- destructive upstream command families are documented through live help but not promoted as guided presets. - -## Developer Disk Images explained - -A Developer Disk Image supplies Apple device-side components used by developer and diagnostic services. The image must match the supported device generation and, on modern iOS, be personalized for the specific device. - -| Generation | Typical image | Toolkit path | -|---|---|---| -| iOS below 17 | `DeveloperDiskImage.dmg` plus matching `.signature` | Supported upstream; not the main GUI workflow | -| iOS 17 and later | APFS image, build manifest, and trust cache | Downloaded personalized mount or local Xcode Cryptex install | - -Modern personalization uses identifiers and a nonce obtained from the selected device. Apple TSS returns a device-specific personalization manifest, and the result is mounted as a developer image at `/System/Developer`. - -What mounting a DDI can enable: - -- DVT device and process instrumentation; -- developer OSLog streaming; -- CoreDevice queries; -- screenshots through developer services; -- simulated location services; -- developer-service filesystem listings; -- other services explicitly exposed by the device build. - -What mounting a DDI does not do: - -- grant root; -- bypass a passcode or Secure Enclave; -- disable app sandboxing or entitlements; -- decrypt protected data or traffic; -- make an invalid IPA installable; -- turn a developer-service view into physical or full-filesystem acquisition; -- guarantee every client-side command exists on every iOS build. - -## Guided command catalog - -The current Command Center contains 49 presets in six categories. - -| Category | Count | Presets | -|---|---:|---| -| **Device Basics** | 13 | Connected devices; Lockdown overview; activation state; Developer Mode status; diagnostics overview; battery; IORegistry; MobileGestalt; processes; configuration profiles; provisioning profiles; screen orientation; Home Screen icon metrics | -| **Apps & Files** | 6 | Installed apps; one-app query; AFC directory; DVT path; crash inventory; crash pull | -| **Logging & Capture** | 4 | Live syslog; DVT Unified Logging; network PCAP; Bluetooth HCI capture | -| **Developer & DVT** | 19 | DVT device, process, app, network, PID, energy, system/process metrics, graphics, notifications, KDebug, screenshot; CoreDevice information, display, lock, processes, apps; mounted images; personalization identifiers | -| **Web & Discovery** | 3 | RSD discovery; RemoteXPC browsing; Safari and WebView tabs | -| **Device Actions** | 4 | Launch app; open URL; set location; clear location | - -Presets minimize typing, not judgment. The displayed prerequisites and risks are part of the operation, and the exact argument preview should be retained when reproducibility matters. - -## Evidence case contents - -A finalized case follows this shape: - -```text -ios-case-YYYYMMDDTHHMMSSZ-/ -├── case-intake.json # optional guided title, scope, target, and acknowledgement -├── artifacts/ -│ ├── crashes/ # optional -│ ├── network.pcap # optional -│ └── screen.png # optional -├── snapshots/ -│ ├── afc-root.txt -│ ├── apps.json -│ ├── battery.json -│ ├── crash-list.txt -│ ├── cryptex-list.json -│ ├── diagnostics-info.json -│ ├── dvt-device-information.json -│ ├── dvt-root-listing.txt -│ ├── dvt-sysmon-processes.txt -│ ├── ioregistry.json -│ ├── lockdown-info.json -│ ├── mobilegestalt.json -│ ├── mounter-list.json -│ ├── processes.txt -│ ├── profiles.json -│ ├── provisioning.txt -│ ├── usbmux.json -│ └── *.command.log -├── streams/ -│ ├── dvt-oslog.txt # optional -│ ├── pcap-metadata.txt # optional -│ └── syslog.txt # optional -├── manifest.json -└── SHA256SUMS -``` - -`manifest.json` records the toolkit and `pymobiledevice3` versions, target UDID, selected options, timestamps, commands, output paths, attempts, exit codes, and status for each step. `SHA256SUMS` inventories finalized files. +| Read-only | Battery snapshot, lock state | Runs immediately | +| Saves files on this Mac | Screenshot, crash reports, backup | Review sheet; files are never overwritten | +| Changes the device | Launch app, set location, install | Type `RUN` plus the last six characters of the device's UDID | +| High impact | Restart device, remove an app, erase a simulator | Also confirm a current backup and type `IRREVERSIBLE` plus the same code | -Hashes help detect later change; they do not by themselves prove when, where, or by whom evidence was acquired. Preserve the original case on protected storage, document custody separately, and analyze a verified copy. +Because the code comes from the target's UDID, a confirmation can never apply to a different +device. Each operation also captures its target when it starts, and lockdown sessions verify +that the device answering is the one you selected. -Collector exit status: +## Developer images -| Exit | Meaning | -|---:|---| -| `0` | Required steps completed and no optional coverage failed | -| `2` | Case finalized with one or more optional gaps | -| `1` | Fatal setup or required target-identification failure | +Screenshots, location simulation, launching apps, and Instruments need Apple's developer image +(Developer Disk Image) mounted on the device. The **Developer image** card on the Device page +checks it automatically and shows one of these states: -## Command-line tools +[![Developer image card](docs/screenshots/developer-image.png)](docs/screenshots/developer-image.png) -The virtual environment exposes four entry points: -```bash -venv/bin/ios-developer-toolkit -venv/bin/ios-developer-collect --help -venv/bin/ios-local-ddi --help -venv/bin/ios-ipa-inspect --help -``` - -### Evidence collector - -```bash -venv/bin/ios-developer-collect \ - --udid 00008110-0000000000000000 \ - --output-root "$PWD/cases" \ - --duration 300 \ - --include-syslog \ - --include-oslog \ - --include-pcap \ - --include-screenshot \ - --include-crash-pull -``` - -To attach collection to a guided case created in the app, use that case folder instead of `--output-root`: - -```bash -venv/bin/ios-developer-collect \ - --udid 00008110-0000000000000000 \ - --case-directory /absolute/path/to/ios-case-YYYYMMDDTHHMMSSZ-UDIDSUFFIX \ - --duration 300 \ - --include-syslog -``` - -`--output-root` and `--case-directory` are mutually exclusive. A supplied guided case must contain a valid `case-intake.json`, match the selected UDID, and not already contain `manifest.json`. - -Use only the optional flags needed for the task. PCAP, screenshots, app lists, crash reports, profiles, and logs can contain private information. - -### Local Xcode DDI installer - -```bash -venv/bin/ios-local-ddi \ - --candidate /Library/Developer/CoreDevice/CandidateDDIs/iOS_DDI.dmg \ - --udid 00008110-0000000000000000 -``` - -### IPA inspector +| State | Meaning | +|---|---| +| Mounted | A compatible image is mounted. Nothing is mounted again. | +| Available | A compatible image is on this Mac and can be mounted now (the device already holds Apple's personalization for it, or it is an iOS 16-or-earlier image). | +| Personalization required | A compatible image is on this Mac; Apple must sign it for this device first (iOS 17 and later, needs the internet). | +| Missing | No compatible image is on this Mac. | +| Incompatible | The image on this Mac (or the one mounted) does not fit this device or iOS version. | +| Needs attention | Trust, Developer Mode, unlocking, or a USB connection is needed first. | +| Failed | The check or the last mount attempt failed; the card shows why and what to do. | +| Not required | Simulators and the demo device. | + +The details list the iOS version and build, model, architecture, chip and board used to choose the +image, where it is mounted, and which image on this Mac would be used. + +**iOS 17 and later** use a *personalized* image. Xcode installs it in +`/Library/Developer/DeveloperDiskImages/iOS_DDI`. Mounting picks the build identity for the +device's chip and board and, unless the device already holds a personalization for that image, +asks Apple's signing server (`gs.apple.com`) for one — sending the device's chip, board, and ECID +with a one-time nonce, exactly as Xcode does. The confirmation says so before anything is sent. + +**iOS 16 and earlier** use `DeveloperDiskImage.dmg` and its `.signature` for the exact iOS +`major.minor` version. Current Xcode versions no longer include them; add a folder that contains +them (for example an older Xcode's `Platforms/iPhoneOS.platform/DeviceSupport/16.4`) with +**Options › Add Image Folder…**. + +**How it mounts** (Options › Mount with): *Automatic* uses Xcode's device service (`devicectl`) +when it can reach the device on iOS 17 and later, and otherwise the built-in client, which talks +to the device's image-mounter service over USB and works without Xcode's device service. The app +never downloads images from third parties. + +## Command-line tool + +`idt` provides the automation-friendly parts of the app: ```bash -venv/bin/ios-ipa-inspect /absolute/path/to/Application.ipa +idt devices --simulators # list devices and simulators +idt readiness --udid # read-only readiness check +idt inspect-ipa App.ipa [--json] # inspect a package +idt collect --udid --output-root ~/Cases --duration 120 --include-unified-logs +idt ddi status --udid [--json] # developer image state (exit 0 mounted, 2 mountable) +idt ddi mount --udid --confirm "RUN ABC123" [--mechanism automatic|core-device|native] +idt ddi unmount --udid --confirm "RUN ABC123" +idt toolchain # check the installed Xcode ``` -The CLI inspector reports local package state; it does not install or repair the IPA. - -## Privacy, integrity, and interpretation - -### Sensitive material - -Treat these outputs as potentially sensitive: - -- UDIDs, serials, pairing metadata, device names, and OS/build information; -- app and provisioning-profile inventories; -- URLs, hostnames, IP addresses, packet payloads, and Bluetooth traffic; -- Unified Logs, classic syslog, DVT logs, process lists, and crash reports; -- screenshots, AFC listings, GPX routes, and simulated coordinates; -- MobileBackup2 and UFADE acquisitions; -- MVT source backups, indicator files, command logs, and analysis results; -- go-ios, idb, and ipsw inventory output, which can include device or simulator identifiers; -- IPA provisioning records and signing identities. - -The repository `.gitignore` excludes the toolkit's common backup, case, capture, crash, log, packet, GPX, UFADE, MVT, DDI, certificate, profile, and IPA artifact patterns. That is a publication guard, not an access-control system. Store evidence outside a public checkout when possible, restrict filesystem permissions, encrypt sensitive archives, and review every staged file before committing. - -### Capability is not observed behavior - -| Observation | Supported conclusion | Unsupported shortcut | -|---|---|---| -| A DDI mounted | Developer services may now be available | The device is jailbroken or compromised | -| A process or app appears | The queried service reported it at that time | It performed a specific malicious action | -| A hostname appears in PCAP or logs | Traffic or text referenced that hostname | Ownership, purpose, or compromise without correlation | -| A profile appears | The profile service reported installation metadata | Who authorized it or how it was used without provenance | -| A DVT root path is listed | The DVT service exposed a path view | Raw, complete filesystem access | -| A command returned no data | The request produced empty output | The data or activity does not exist | -| A command failed | That collection path lacked coverage | The device is clean or the feature is absent | - -Correlate timestamps and independent sources. Logs, network packets, processes, apps, profiles, crash reports, and backups answer different questions. - -### Device-changing operations - -Mounting a DDI, enabling Developer Mode, installing or uninstalling an app, changing backup encryption, creating a backup, launching an app, opening a URL, and simulating location all change device or host state. Obtain authorization, preserve pre-change state when relevant, and record the action. +`--include-oslog`, the 0.3.x name of `--include-unified-logs`, is still accepted. + +`idt collect` exits with `0` when complete, `2` when finished with coverage gaps, and `1` when the +device could not be identified. + +## Security and privacy + +- **Least privilege.** No administrator rights and no `sudo`. The app never reads + `/var/db/lockdown`, never creates pairing records, and never restarts system services. Pairing + material is requested from macOS's device service and kept only in memory. +- **Verified connections.** Lockdown sessions use TLS with this Mac's pairing certificate, pin the + device certificate from the pairing record, and confirm the device's UDID before any request. +- **No shell.** External tools run from fixed paths with an argument vector and a minimal + environment; nothing is interpreted by a shell. All process launches go through one audited + runner with timeouts and cancellation. +- **Untrusted input.** Device responses, backup file paths, IPA archives, GPX files, and workspace + profiles are validated; path traversal, symbolic links, encrypted or oversized archive entries, + and XML entities are rejected. +- **Local only.** Nothing is uploaded, with one confirmed exception: mounting a developer image on + iOS 17 and later asks Apple's signing server (`gs.apple.com`) to personalize it, sending the + device's chip, board, and ECID with a one-time nonce — as Xcode does. Captures, backups, cases, and reports are written with + owner-only permissions. The app's own log records outcomes rather than device content, and marks + identifiers as private. +- **Sanitized sharing.** **iOS Developer Toolkit › Create Support Bundle…** and the readiness + report exports remove names, identifiers, paths, and addresses. Review them before sharing. +- **Hardened runtime**, no App Sandbox: the app must reach the system's device service socket and + run Xcode's tools. See [SECURITY.md](SECURITY.md) to report a vulnerability. ## Troubleshooting -### No device detected - -- click **Retry Scan** for an immediate usbmux check, or **Reconnect & Retry…** for a guided 30-second detection window; -- use a known data-capable cable and direct USB port; -- unlock the device and keep its Home Screen visible before reconnecting; -- on a USB-C iPhone or iPad, review **Settings → Privacy & Security → Wired Accessories** and allow the connection while unlocked; -- accept **Allow accessory to connect** on macOS, then accept **Trust** on iOS; Finder can also expose the device-level **Trust** action; -- reconnect after the trust prompt completes; -- close competing tools that may be holding device services; -- run `venv/bin/pymobiledevice3 usbmux list` and retain its error output; -- verify the selected device when more than one is connected. - -The toolkit does not use `sudo`, delete pairing records, or restart SIP-protected Apple discovery agents or the root-owned `usbmuxd` service. If the iPhone is absent from both the macOS USB device tree and `usbmux list`, resolve the physical data connection before changing DDIs, tunnels, or developer services. +| Problem | Try this | +|---|---| +| The device does not appear | Use a data cable, unlock the device, tap **Trust**, try another port, and check **Connection diagnostics** on the Device page. If nothing appears after reconnecting, restart the Mac. | +| “This device has not trusted this Mac” | Unlock the device and reconnect it; tap **Trust**. If no prompt appears, reset *Settings › General › Transfer or Reset › Reset › Reset Location & Privacy*. | +| Developer Mode is missing on the device | Connect it and open Xcode › *Window › Devices and Simulators* once. | +| Developer features say they need Xcode | Install Xcode, open it once, and check *Xcode › Settings › Locations › Command Line Tools*. | +| The developer image will not mount | Read the Developer image card: it names the problem (Developer Mode, lock, missing or incompatible image) and the fix. On iOS 17 and later keep the Mac online (Apple personalizes the image); if one route fails, switch **Options › Mount with**. | +| A backup stops with “must stay unlocked” | Unlock the device and keep it awake until the backup finishes. | +| An `.ipa` cannot be installed | Check the inspection: the signature must be valid and the profile must include the device. | +| Live logs are very busy | Filter the view or pause it; capture continues in the background. | +| Something else | Run the **Readiness Check**, then create a support bundle and open a discussion. | -If `usbmux list` returns a device but the picker remains empty, use **Retry Scan** once more, then create a sanitized support bundle. The scanner retains stdout and stderr that become available only when its child process exits; the support bundle records the redacted connection state without including your UDID, pairing record, or command output. +More detail: [docs/troubleshooting.md](docs/troubleshooting.md). -### Developer Mode is missing +## Build from source -- pair the device in Xcode through **Window → Devices and Simulators**; -- wait for Xcode's device preparation to complete; -- check **Settings → Privacy & Security** again; -- restart and complete the post-restart confirmation on the device. +Requirements: macOS 14+, Xcode 16 or later (Swift 6). -### Personalized DDI does not mount +```bash +git clone https://github.com/hideouts-io/iOS-Developer-Toolkit.git +cd iOS-Developer-Toolkit -- confirm Developer Mode is enabled, not merely visible; -- verify that the device is unlocked and trusted; -- check internet access to the payload source and Apple TSS; -- open **List Mounted Images** before retrying; -- inspect the full command output for service, tunnel, version, personalization, or cache errors; -- do not substitute a random legacy DDI for a modern personalized image. +swift test # unit and integration tests +swift build -c release --product idt # the command-line tool -### Local Xcode DDI is unavailable +xcodebuild -project iOSDeveloperToolkit.xcodeproj -scheme iOSDeveloperToolkit \ + -configuration Release -destination 'platform=macOS' build -```bash -ls -l /Library/Developer/CoreDevice/CandidateDDIs/iOS_DDI.dmg -xcode-select -p +scripts/build-release.sh # universal, ad-hoc-signed release ZIP, SBOM, checksums in build-output/release/ ``` -Install or update Xcode if the candidate is absent. The toolkit requires the expected `Restore` contents and rejects an incomplete or wrong container. - -### A DVT/CoreDevice command fails - -- confirm Developer Mode and DDI state; -- establish the modern tunnel when required by the installed `pymobiledevice3` command; -- treat **DVT network activity** and **CoreDevice applications** as streams: let them run for the intended observation window, then use **Stop**; -- read the corresponding live Man Page; -- remember that client syntax can exist even when an iOS build does not advertise the service; -- keep the failure as a coverage result. - -### IPA inspection or installation fails - -- treat archive-path and signature failures as package problems, not installer problems; -- confirm that the provisioning profile is current and includes the target device where required; -- confirm certificate trust, entitlements, team identity, and app identifier; -- do not expect a DDI to repair signing or bypass DRM; -- inspect the complete host and device error rather than repeatedly retrying. - -### Backup does not start - -- unlock and trust the device; -- verify free space and permissions on the destination; -- check encryption state explicitly; -- confirm and securely retain a new encryption password before enabling it; -- use a new destination to distinguish corrupt incremental state from a device-service failure; -- remember that changing encryption forces a full backup. - -### Live Logs becomes visually busy - -- pause the view; raw capture continues; -- apply a literal or regex filter; -- save the filtered view only as an analysis derivative; -- use Save Raw for the complete capture and metadata; -- stop and close the window cleanly to make the save/discard decision explicit. - -## Development and packaging - -### Repository layout - -```text -. -├── ios_developer_toolkit/ -│ ├── app.py # PySide6 workbench and workflow orchestration -│ ├── action_palette.py # searchable state-eligible navigation and actions -│ ├── action_safety.py # typed confirmation policy for state-changing actions -│ ├── backup_process.py # password-safe backup-worker lifecycle controller -│ ├── backup_protocol.py # dependency-free backup request/event schema -│ ├── backup_worker.py # MobileBackup2 worker and password-input protocol -│ ├── capability_matrix.py # typed readiness catalog, probes, and result validation -│ ├── capability_matrix_worker.py # bounded NDJSON capability worker -│ ├── case_workflow.py # guided evidence-case intake and validation -│ ├── catalog.py # evidence snapshot catalog and mutation classification -│ ├── collector.py # case creation, streams, retries, manifest, hashes -│ ├── command_catalog.py # guided presets and live-help routes -│ ├── collection_process.py # evidence-worker lifecycle and graceful finalization -│ ├── collection_protocol.py # validated collector JSON-line events -│ ├── device_compatibility.py # redacted local real-device readiness history -│ ├── external_tools.py # optional executable provenance and probe policies -│ ├── file_integrity.py # shared streaming file SHA-256 helper -│ ├── gui_pages.py # stateless Home, Live Logs, Safety pages and styling -│ ├── installed_apps.py # app inventory validation and formatting -│ ├── interactive_process.py # typed user-stoppable process lifecycle controller -│ ├── ipa_inspector.py # safe IPA extraction, provisioning, signature checks -│ ├── live_logs.py # independent raw-spooling log windows -│ ├── local_ddi.py # local Xcode candidate/Cryptex workflow -│ ├── location_lab.py # coordinates, GPX, routes, saved places, evidence -│ ├── models.py # typed device and collection models -│ ├── mvt_connector.py # external MVT provenance, request, and isolation policy -│ ├── operation_history.py # session journal, output digests, and explicit JSON export -│ ├── entrypoint.py # packaged internal CLI and worker dispatch -│ ├── qt_process.py # typed, bounded finite-process lifecycle controller -│ ├── runtime.py # source/frozen commands and device environment -│ ├── ufade_connector.py # isolated external UFADE validation and launch -│ ├── xcode_handoff.py # validated CoreDevice, RVI, project, and artifact handoffs -│ └── assets/ -├── .github/workflows/ # native Intel and Apple Silicon release builds -├── docs/screenshots/ # sanitized current-interface captures -├── macos/ # wrapper executable, Info.plist, and icon -├── packaging/ # pinned PySide6 deployment configuration -├── scripts/ # self-contained native release builder -├── script/build_and_run.sh # environment, staging, launch, verification modes -├── tests/ # core, capability, and packaged-runtime tests -├── pyproject.toml # package metadata and pinned dependencies -└── LICENSE -``` +The Xcode project is generated from `project.yml` with [XcodeGen](https://github.com/yonaskolb/XcodeGen) +and committed, so you only need XcodeGen when you change the project structure (`xcodegen generate`). -### Run the verification suite +Optional test suites: ```bash -venv/bin/python -m unittest discover -s tests -v -venv/bin/python -m compileall -q ios_developer_toolkit -venv/bin/python -m ios_developer_toolkit.collector --help -venv/bin/python -m ios_developer_toolkit.local_ddi --help -venv/bin/python -m ios_developer_toolkit.ipa_inspector --help -./script/build_and_run.sh --verify +IDT_SIMULATOR_TESTS=1 swift test --filter RealSimulator # boots a simulator end to end +xcodebuild -project iOSDeveloperToolkit.xcodeproj -scheme iOSDeveloperToolkit \ + -destination 'platform=macOS' test # UI tests (macOS asks to allow automation) ``` -The final launcher check opens the application and briefly verifies the process. It stops an existing toolkit process first, so do not run it during an active capture or backup. - -Build the focused documentation site with its isolated pinned dependency: +Documentation screenshots are produced by the app itself: ```bash -venv/bin/python -m pip install --requirement requirements/docs.txt -venv/bin/python -m mkdocs build --strict --clean +"iOS Developer Toolkit.app/Contents/MacOS/iOS Developer Toolkit" \ + -demo-mode YES -populate-demo YES -window-size 1180x700 -capture-screenshots ~/Desktop/shots ``` -Pull requests validate the site without publishing it. A documentation change merged to `main` publishes through the dedicated GitHub Pages workflow. - -Physical-device validation is opt-in and is not required for pull requests. Use the [physical-device test protocol](docs/PHYSICAL_DEVICE_TEST_PROTOCOL.md) to separate USB, usbmux, CoreDevice, Developer Mode, DDI, tunnel, DVT, and state-changing checks; publish only sanitized results. - -### Release model - -The release workflow builds natively on separate Apple Silicon and Intel GitHub-hosted macOS runners. Each job creates a self-contained PySide6/Nuitka `.app`, runs all 133 tests, verifies the embedded pymobiledevice3 command, checks the internal worker route, runs the 138-button offscreen GUI smoke test, verifies live help and a synthetic external-adapter lifecycle from inside the app, verifies the native launcher architecture, and checks the architecture and macOS deployment floor of every bundled Mach-O file. It also embeds third-party notices and a CycloneDX SBOM with the serial number required for GitHub attestation, applies an ad-hoc signature, and uploads an architecture-labeled ZIP and SBOM. The release job publishes both architectures with one SHA-256 inventory and creates GitHub build-provenance and SBOM attestations for each ZIP. - -The builder requires `MACOSX_DEPLOYMENT_TARGET=13.0`. It rejects any bundled executable, library, extension, or framework slice that requires a newer macOS version or omits the native release architecture. A component may support an older minimum because the application still advertises macOS 13 as its supported floor. PySide6 is pinned to the newest validated line whose actual Shiboken load commands satisfy that floor; wheel filenames alone are not treated as compatibility evidence. Local release builds should use a Python toolchain capable of producing macOS 13-compatible binaries; GitHub release CI supplies the target explicitly. - -The artifacts are not universal binaries: choose the ZIP matching `uname -m`. They are also not Developer ID signed or Apple-notarized because this repository has no release signing identity. A future signing upgrade should use a narrowly scoped Developer ID Application certificate, hardened runtime, Apple notarization, and stapling without changing the two-architecture verification gates. - -Windows and Linux would require a separate host implementation or deliberately isolated adapters for discovery, pairing, filesystem paths, DDI acquisition, local signature inspection, packaging, and platform-specific dependencies. Copying the macOS wrapper is not a cross-platform port. - -## Project boundaries and credits - -- [`pymobiledevice3`](https://github.com/doronz88/pymobiledevice3) supplies the Apple-device protocol implementation and command surface. -- [`DeveloperDiskImage`](https://github.com/doronz88/DeveloperDiskImage) supplies the downloadable modern DDI payload used by upstream auto-mount. -- [Apple Developer Mode documentation](https://developer.apple.com/documentation/xcode/enabling-developer-mode-on-a-device) describes the on-device security workflow. -- [`UFADE`](https://github.com/prosch88/UFADE) is supported only as a separately installed and independently licensed external provider. -- [`MVT`](https://github.com/mvt-project/mvt) is supported only as a separately installed external analysis provider under its own license and warning model. -- [`go-ios`](https://github.com/danielpaulus/go-ios), [`idb`](https://github.com/facebook/idb), and [`ipsw`](https://github.com/blacktop/ipsw) are supported only through user-selected, separately installed MIT-licensed executables; no source or binary from these projects is bundled. -- [`ostrace`](https://github.com/BerkayCaglar/ostrace) informed live-log interaction design; no GPL source is copied, imported, or linked into this MIT project. -- [`LocationSimulator`](https://github.com/Schlaubischlump/LocationSimulator) informed the offline map/teleport workflow. Its GPL source is not copied or linked, and its public backend does not support iOS 17 or later. -- [Natural Earth](https://www.naturalearthdata.com/) provides the public-domain 1:110m land geometry rendered into the bundled offline Location Lab map. -- The project logo is stored at `ios_developer_toolkit/assets/iosdevtoolkit.png` and is used unchanged in the application and documentation. - -The release-critical dependency versions, declared licenses, source links, generated inventory, and redistribution boundary are documented in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). [SOURCE_AVAILABILITY.md](SOURCE_AVAILABILITY.md) identifies the matching tagged project source and bundled-component upstream sources. Each packaged app carries its own copies of both documents and the license files supplied by the installed Python distributions. - -Location Lab uses the pinned `pymobiledevice3` developer-service commands and this project's own map interaction, link parsing, validation, route, cleanup, and evidence code. Modern devices use the DVT path instead of the incompatible public LocationSimulator backend. - -Apple, iPhone, iPad, iOS, macOS, and Xcode are trademarks of Apple Inc. This project is independent and is not affiliated with or endorsed by Apple. - -Use the toolkit only on devices and data you own or are explicitly authorized to test, administer, develop against, or examine. - -## Contributing, support, and security - -Everyone is welcome to fork the repository, discuss ideas, report reproducible problems, and submit focused pull requests. Contributions from first-time and experienced iOS developers, testers, incident responders, and documentation writers are welcome within the project's authorization and privacy boundaries. - -- Read [CONTRIBUTING.md](CONTRIBUTING.md) before proposing code or a new device workflow. -- Use [GitHub Discussions](https://github.com/hideouts-io/iOS-Developer-Toolkit/discussions) for setup and compatibility help, following [SUPPORT.md](SUPPORT.md). -- Open a structured [bug report](https://github.com/hideouts-io/iOS-Developer-Toolkit/issues/new?template=bug_report.yml) or [feature request](https://github.com/hideouts-io/iOS-Developer-Toolkit/issues/new?template=feature_request.yml) when appropriate. -- Report vulnerabilities privately under the [security policy](SECURITY.md); never place sensitive device or evidence data in a public issue. -- Cite the project with the repository's [CITATION.cff](CITATION.cff) metadata when it supports published research. - -## License - -This repository's original code is released under the [MIT License](LICENSE). External tools, bundled runtimes, and upstream dependencies retain their own licenses; review [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) before redistributing a prebuilt or modified application. +Layout: `Sources/ToolkitCore` (process runner, errors, logging, secure files), +`Sources/DeviceKit` (usbmuxd, lockdown and its services, CoreDevice, simctl, discovery), +`Sources/ToolkitFeatures` (Location Lab, IPA inspection, logs, actions, readiness, evidence, +external tools), `Sources/idt` (CLI), `App/` (SwiftUI app and UI tests), `Tests/`. +See [docs/architecture.md](docs/architecture.md). + +## Project status and limitations + +Version 1.0 is a complete rewrite of the earlier Python/PySide6 app (0.3.x), which depended on +`pymobiledevice3`. See [MIGRATION.md](MIGRATION.md) for the feature-by-feature mapping. Workspace +profiles exported by 0.3.x can be imported in **Settings › Profiles**; the preview explains how each +setting carries over. + +- The native lockdown services (logs, packet capture, backup, diagnostics, app installation over + USB, developer-image checking and mounting including Apple personalization) are tested end to + end against a protocol-accurate simulated device and against macOS's real device service. Their + read-only protocol layer has been checked on one iPhone (iOS 26, Developer Mode off); mounting, + location, installation, backup, and the app's pages **have not yet been tested on physical + iPhones or iPads**. Please report results using the + [physical-device test protocol](docs/PHYSICAL_DEVICE_TEST_PROTOCOL.md). +- Not available in 1.0: the developer-service file listing. Details and alternatives are in [MIGRATION.md](MIGRATION.md#6-known-limitations-and-features-not-reproduced). +- Release builds are ad-hoc signed and not notarized. +- Release builds are universal (Apple silicon and Intel). If the Intel half is run under Rosetta on + an Apple silicon Mac, macOS warns that the app includes a component that will not open in + macOS 28; the Apple silicon half, which runs by default, is not affected. + +## Contributing, support, and license + +- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, design rules, and checks. +- [SUPPORT.md](SUPPORT.md) — where to ask questions. +- [SECURITY.md](SECURITY.md) — private vulnerability reporting. +- [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) and [SOURCE_AVAILABILITY.md](SOURCE_AVAILABILITY.md). + +iOS Developer Toolkit is released under the [MIT License](LICENSE). The bundled world map uses +public-domain [Natural Earth](https://www.naturalearthdata.com) data. iPhone, iPad, Xcode, and +macOS are trademarks of Apple Inc.; this project is not affiliated with Apple. diff --git a/SECURITY.md b/SECURITY.md index 2d1ce53..40cce93 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,36 +2,64 @@ ## Supported versions -Security fixes are made against the latest release and the current `main` branch. Older releases are not supported once a newer release is available. - | Version | Supported | |---|---:| -| Latest release | Yes | +| Latest release (1.x, Swift) | Yes | | `main` | Yes | -| Older releases | No | +| 0.3.x and earlier (Python) | No | ## Report a vulnerability privately -Use [GitHub private vulnerability reporting](https://github.com/hideouts-io/iOS-Developer-Toolkit/security/advisories/new). Do not disclose a suspected vulnerability in a public issue, Discussion, pull request, log, screenshot, or evidence archive. +Use [GitHub private vulnerability reporting](https://github.com/hideouts-io/iOS-Developer-Toolkit/security/advisories/new). +Do not disclose a suspected vulnerability in a public issue, discussion, pull request, log, +screenshot, or evidence archive. Include: -- the affected toolkit version or commit; -- the affected workspace, command, or release artifact; -- the security impact and required preconditions; -- minimal reproduction steps using synthetic or sanitized data; -- the relevant macOS, architecture, iOS or iPadOS, and device family; -- whether the behavior requires trust, Developer Mode, a DDI, a tunnel, elevated privileges, or physical device access; -- a proposed mitigation, if known. +- the affected version or commit; +- the affected page, action, `idt` command, or release file; +- the impact and the preconditions (trust, Developer Mode, Xcode, physical access, and so on); +- minimal reproduction steps with synthetic or sanitized data; +- macOS version, Mac architecture, and iOS or iPadOS version. + +Never include credentials, pairing records, private keys, UDIDs, serial numbers, account data, +coordinates, packet payloads, backups, profiles, IPAs, crash report contents, or evidence. If a +reproduction cannot be sanitized, describe it first and wait for a private handling plan. + +The maintainer will acknowledge complete reports when practical, validate them, coordinate a fix +and release, and credit the reporter on request. No response deadline is guaranteed. + +## Design boundaries -Never include credentials, pairing records, private keys, real UDIDs, serial numbers, account data, coordinates, packet payloads, backups, profiles, IPAs, crash contents, or forensic evidence. If a minimal reproducer cannot be sanitized, describe it first and wait for a private handling plan. +In scope for reports: anything that lets the app act on a device other than the selected one, +run a command through a shell or with attacker-controlled arguments, write outside the chosen +folder, overwrite existing files, leak identifiers or passwords into logs or exports, accept a +lockdown peer that is not the paired device, or be driven by crafted device responses, IPA +archives, backups, GPX files, or workspace profiles. -The maintainer will acknowledge a complete report when practical, validate scope and impact, coordinate a fix and release, and credit the reporter when requested. No response or remediation deadline is guaranteed. +The app: -## Security boundaries +- runs without administrator rights and never uses `sudo`; +- never reads `/var/db/lockdown`, creates pairing records, or restarts system services; +- pins the device certificate from the pairing record and checks the device's UDID on every + lockdown session; +- starts external processes only through one runner, from fixed paths, with an argument vector + and a minimal environment, never through a shell; +- keeps passwords (backup encryption) in memory only and never passes them as process arguments; +- connects to the internet from its own code only to personalize a developer image (iOS 17 and + later), after the user confirms: it sends the device's chip, board, and ECID with a one-time + nonce to Apple's signing server over HTTPS, as Xcode does, and never downloads images from + third parties; +- writes captures, backups, and reports with owner-only permissions and never overwrites files. -The toolkit is a local orchestration interface. It does not jailbreak iOS, bypass a passcode or activation, evade code signing, disable the sandbox, remove supervision, decrypt protected traffic, or provide unrestricted filesystem access. A mounted DDI, successful developer service, entitlement, profile, or surprising log entry is not by itself evidence of compromise. +It does not jailbreak iOS, bypass a passcode or activation, defeat code signing, disable the +sandbox, decrypt protected traffic, or provide unrestricted file-system access. A mounted +developer image, an available developer service, or an unusual log line is not by itself evidence +of compromise. -Published application bundles are currently ad-hoc signed and are not Apple-notarized. Verify `SHA256SUMS.txt` from the release, use the archive matching the Mac architecture, and review the stated signing status before opening it. Releases produced by the current workflow provide GitHub build-provenance and SBOM attestations; when a release contains them, verify with `gh attestation verify ARCHIVE --repo hideouts-io/iOS-Developer-Toolkit`. Never trust an archive whose checksum or available attestation does not match. +## Releases -Device output can contain highly sensitive information. Store logs, PCAPs, backups, acquisitions, coordinates, screenshots, crash reports, manifests, and evidence cases in access-controlled local storage and apply the user's retention policy. +Release builds are ad-hoc signed with the hardened runtime and are **not notarized**. Verify +`SHA256SUMS.txt` and the GitHub attestation before opening a download +(see [docs/release-verification.md](docs/release-verification.md)). Never run an archive whose +checksum or attestation does not match. diff --git a/SOURCE_AVAILABILITY.md b/SOURCE_AVAILABILITY.md index 50c8fc9..5328e75 100644 --- a/SOURCE_AVAILABILITY.md +++ b/SOURCE_AVAILABILITY.md @@ -1,17 +1,20 @@ -# Source availability for distributed application bundles +# Source availability -Every published iOS Developer Toolkit release is built from a signed Git tag in [this public repository](https://github.com/hideouts-io/iOS-Developer-Toolkit). GitHub provides source archives for each release tag, and the complete project source can also be obtained with: +Every published release of iOS Developer Toolkit is built from a Git tag in +[this public repository](https://github.com/hideouts-io/iOS-Developer-Toolkit) by the release +workflow, with a GitHub build-provenance attestation that links the archive to that workflow run +and commit. GitHub provides source archives for each tag, or: ```bash git clone --branch vVERSION --depth 1 https://github.com/hideouts-io/iOS-Developer-Toolkit.git ``` -The prebuilt application is accompanied by an architecture-specific CycloneDX SBOM. It identifies the exact Python distribution versions used for that build, including the bundled `pymobiledevice3` component. +The app is written in Swift. Its third-party dependencies are Swift packages resolved from their +public repositories at the exact revisions recorded in [`Package.resolved`](Package.resolved) for +that tag. They are listed with their licenses in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) +and in the SPDX SBOM attached to each release. No binary-only third-party code is included. -## Bundled third-party source +Optional external tools (MVT, UFADE, idb Companion) are installed and managed by the user and are +not part of the app or its SBOM. -The release-critical upstream source locations and license information are recorded in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). In particular, the packaged `pymobiledevice3` release is available at its [matching upstream tag](https://github.com/doronz88/pymobiledevice3/tree/v11.15.1), including its GPL-3.0-or-later license. The project’s public tagged source, package inventory, and embedded notices are intended to make the source and license boundary inspectable before redistribution. - -PySide6/Qt, Nuitka, CPython, and every other dependency remain subject to their own terms. Consult the generated `Contents/Resources/Licenses/` inventory in the application and the matching SBOM for the exact package set. This document is an availability and attribution statement, not legal advice. - -Optional UFADE, MVT, go-ios, idb, and ipsw integrations launch user-managed external installations. Their source is not part of the application bundle or release SBOM; consult their upstream repositories and licenses for the exact external version selected by the operator. The Ecosystem Tools workspace records the resolved executable path, SHA-256, and reported version or build identity for go-ios, idb Companion, and ipsw before enabling a probe. +This document is an availability and attribution statement, not legal advice. diff --git a/SUPPORT.md b/SUPPORT.md index acb5a1e..5e453f6 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,16 +1,24 @@ # Support -Use [GitHub Discussions](https://github.com/hideouts-io/iOS-Developer-Toolkit/discussions) for installation, pairing, Developer Mode, DDI, tunnel, command, compatibility, and workflow questions. +Use [GitHub Discussions](https://github.com/hideouts-io/iOS-Developer-Toolkit/discussions) for +installation, pairing, Developer Mode, developer services, compatibility, and workflow questions. -Before asking for help: +Before asking: -1. Read the README's requirements, first-device walkthrough, workspace guide, and troubleshooting sections. -2. Confirm the data cable, unlock and trust state, selected device, and relevant Capability Matrix row. -3. Test the latest release and record the exact sanitized error. -4. Distinguish host detection, pairing, Developer Mode, DDI mounting, tunnel readiness, and the requested device service; they are separate capabilities. +1. Read the README's [Requirements](README.md#requirements), [First steps](README.md#first-steps), + and [Troubleshooting](README.md#troubleshooting), and [docs/troubleshooting.md](docs/troubleshooting.md). +2. Run the **Readiness Check** for the device and note the first row that is not ready. +3. Try the latest release and note the exact message (the technical details are in + **Help › Diagnostic Log**). +4. Say whether Xcode is installed and which version. -Open a structured bug report when the latest release behaves reproducibly incorrectly. Open a feature request for a bounded new workflow. Use private vulnerability reporting for security issues. +Open a bug report when the latest release behaves incorrectly and reproducibly, a feature request +for a new workflow, and a private vulnerability report for security issues. -Do not publish real UDIDs, serial numbers, phone numbers, device names, Apple Account data, coordinates, pairing records, packet payloads, backups, profiles, certificates, IPAs, crash contents, or evidence cases. A product family and OS version are usually sufficient. +Do not publish UDIDs, serial numbers, phone numbers, device names, Apple Account data, +coordinates, pairing records, packet payloads, backups, profiles, certificates, IPAs, crash report +contents, or evidence. The device model and iOS version are usually enough. **Create Support +Bundle…** produces a sanitized report; review it before attaching it. -Support is best effort. Apple service availability varies by host, device, iOS version, trust state, Developer Mode, DDI, tunnel, and upstream `pymobiledevice3` support. +Support is best effort. What works depends on the Mac, Xcode, the device, its iOS version, trust, +and Developer Mode. diff --git a/Sources/DeviceKit/CoreDevice/CoreDeviceClient.swift b/Sources/DeviceKit/CoreDevice/CoreDeviceClient.swift new file mode 100644 index 0000000..0b557f6 --- /dev/null +++ b/Sources/DeviceKit/CoreDevice/CoreDeviceClient.swift @@ -0,0 +1,372 @@ +import Foundation +import OSLog +import ToolkitCore + +/// Typed access to Apple's CoreDevice service through `xcrun devicectl`. +/// +/// Only the documented, versioned JSON output (`--json-output`) is parsed; human-readable +/// output is kept for display only. Every call names its target explicitly with `--device`. +public struct CoreDeviceClient: Sendable { + public let runner: CommandRunning + private let logger = ToolkitLog.deviceCommunication + + public init(runner: CommandRunning = ProcessCommandRunner()) { + self.runner = runner + } + + /// The raw outcome of one devicectl call. + public struct Response: Sendable { + public let json: JSONValue + public let command: CommandResult + + public var result: JSONValue? { json["result"] } + } + + // MARK: Discovery and information + + public func listDevices(timeout: TimeInterval = 30) async throws -> [CoreDeviceRecord] { + let response = try await invoke(["list", "devices"], timeout: timeout, displayName: "devicectl list devices") + return Self.parseDevices(response.json) + } + + public func details(_ target: DeviceTarget, timeout: TimeInterval = 30) async throws -> (record: CoreDeviceRecord?, response: Response) { + let response = try await invoke(["device", "info", "details", "--device", target.coreDeviceSelector], timeout: timeout, displayName: "devicectl device info details") + let record = response.result.flatMap(CoreDeviceRecord.init(json:)) + return (record, response) + } + + public func apps(_ target: DeviceTarget, includeSystemApps: Bool = true) async throws -> [CoreDeviceApp] { + var arguments = ["device", "info", "apps", "--device", target.coreDeviceSelector] + if includeSystemApps { arguments.append("--include-default-apps") } + let response = try await invoke(arguments, timeout: 90, displayName: "devicectl device info apps") + return Self.parseApps(response.json) + } + + public func processes(_ target: DeviceTarget) async throws -> [CoreDeviceProcess] { + let response = try await invoke(["device", "info", "processes", "--device", target.coreDeviceSelector], timeout: 60, displayName: "devicectl device info processes") + return Self.parseProcesses(response.json) + } + + public func lockState(_ target: DeviceTarget) async throws -> CoreDeviceLockState { + let response = try await invoke(["device", "info", "lockState", "--device", target.coreDeviceSelector], timeout: 30, displayName: "devicectl device info lockState") + return Self.parseLockState(response.json) + } + + /// Queries developer disk image services. With `autoMount`, CoreDevice installs the + /// correct personalized DDI first (the Apple-supported replacement for manual mounting). + public func ddiServices(_ target: DeviceTarget, autoMount: Bool) async throws -> Response { + try await invoke( + ["device", "info", "ddiServices", "--device", target.coreDeviceSelector, autoMount ? "--auto-mount-ddis" : "--no-auto-mount-ddis"], + timeout: autoMount ? 600 : 60, + displayName: autoMount ? "devicectl device info ddiServices (mount)" : "devicectl device info ddiServices" + ) + } + + public func displays(_ target: DeviceTarget) async throws -> Response { + try await invoke(["device", "info", "displays", "--device", target.coreDeviceSelector], timeout: 30, displayName: "devicectl device info displays") + } + + public func profiles(_ target: DeviceTarget, type: String? = nil) async throws -> Response { + var arguments = ["device", "profile", "list", "--device", target.coreDeviceSelector] + if let type { arguments += ["--type", type] } + return try await invoke(arguments, timeout: 60, displayName: "devicectl device profile list") + } + + public func orientation(_ target: DeviceTarget) async throws -> Response { + try await invoke(["device", "orientation", "get", "--device", target.coreDeviceSelector], timeout: 30, displayName: "devicectl device orientation get") + } + + public func crashLogs(_ target: DeviceTarget) async throws -> [CoreDeviceFile] { + let response = try await invoke( + ["device", "info", "files", "--device", target.coreDeviceSelector, "--domain-type", "systemCrashLogs"], + timeout: 120, + displayName: "devicectl device info files (crash logs)" + ) + return Self.parseFiles(response.json) + } + + public func preferredDDI(platform: String = "iOS") async throws -> Response { + try await invoke(["list", "preferredDDI", "--platform", platform], timeout: 60, displayName: "devicectl list preferredDDI") + } + + // MARK: Host-side operations + + public func copyCrashLogs(_ target: DeviceTarget, source: String = "/", to destination: URL) async throws -> Response { + try await invoke( + ["device", "copy", "from", "--device", target.coreDeviceSelector, "--domain-type", "systemCrashLogs", "--source", source, "--destination", destination.path], + timeout: 900, + displayName: "devicectl device copy from (crash logs)" + ) + } + + public func screenshot(_ target: DeviceTarget, to destination: URL) async throws -> Response { + guard destination.pathExtension.lowercased() == "png" else { + throw ToolkitError.invalidInput("Screenshots are saved as PNG files. Choose a file name ending in .png.") + } + return try await invoke(["device", "capture", "screenshot", "--device", target.coreDeviceSelector, "--destination", destination.path], timeout: 120, displayName: "devicectl device capture screenshot") + } + + public func sysdiagnose(_ target: DeviceTarget, destination: URL, fullLogs: Bool) async throws -> Response { + var arguments = ["device", "sysdiagnose", "--device", target.coreDeviceSelector, "--destination", destination.path] + if fullLogs { arguments.append("--gather-full-logs") } + return try await invoke(arguments, timeout: 1_800, displayName: "devicectl device sysdiagnose") + } + + public func updateHostDDIs() async throws -> Response { + try await invoke(["manage", "ddis", "update"], timeout: 900, displayName: "devicectl manage ddis update") + } + + public func pair(_ target: DeviceTarget) async throws -> Response { + try await invoke(["manage", "pair", "--device", target.coreDeviceSelector], timeout: 180, displayName: "devicectl manage pair") + } + + // MARK: Device-changing operations + + public func install(appAt path: URL, on target: DeviceTarget) async throws -> Response { + try await invoke(["device", "install", "app", "--device", target.coreDeviceSelector, path.path], timeout: 900, displayName: "devicectl device install app") + } + + public func uninstall(bundleIdentifier: String, on target: DeviceTarget) async throws -> Response { + try BundleIdentifier.validate(bundleIdentifier) + return try await invoke(["device", "uninstall", "app", "--device", target.coreDeviceSelector, bundleIdentifier], timeout: 300, displayName: "devicectl device uninstall app") + } + + public func launch(bundleIdentifier: String, on target: DeviceTarget, terminateExisting: Bool) async throws -> Response { + try BundleIdentifier.validate(bundleIdentifier) + var arguments = ["device", "process", "launch", "--device", target.coreDeviceSelector] + if terminateExisting { arguments.append("--terminate-existing") } + arguments.append(bundleIdentifier) + return try await invoke(arguments, timeout: 120, displayName: "devicectl device process launch") + } + + public func terminate(pid: Int, on target: DeviceTarget, force: Bool) async throws -> Response { + guard pid > 0 else { throw ToolkitError.invalidInput("The process identifier must be a positive number.") } + var arguments = ["device", "process", "terminate", "--device", target.coreDeviceSelector, "--pid", String(pid)] + if force { arguments.append("--kill") } + return try await invoke(arguments, timeout: 60, displayName: "devicectl device process terminate") + } + + public func openURL(_ url: URL, on target: DeviceTarget) async throws -> Response { + guard let scheme = url.scheme, !scheme.isEmpty else { + throw ToolkitError.invalidInput("Enter a complete URL including its scheme, for example https://example.com.") + } + return try await invoke(["device", "process", "openURL", "--device", target.coreDeviceSelector, url.absoluteString], timeout: 60, displayName: "devicectl device process openURL") + } + + public func setLocation(latitude: Double, longitude: Double, on target: DeviceTarget) async throws -> Response { + try Coordinate.validate(latitude: latitude, longitude: longitude) + return try await invoke( + ["device", "simulate", "location", "coordinate", "--device", target.coreDeviceSelector, "--latitude", Coordinate.format(latitude), "--longitude", Coordinate.format(longitude)], + timeout: 120, + displayName: "devicectl device simulate location coordinate" + ) + } + + /// Starts constant-speed movement along waypoints (CoreDevice route simulation). + public func simulateRoute(_ waypoints: [(latitude: Double, longitude: Double)], speedMetresPerSecond: Double, updateIntervalSeconds: Double, on target: DeviceTarget) async throws -> Response { + guard waypoints.count >= 2 else { throw ToolkitError.invalidInput("A route needs at least two waypoints.") } + guard speedMetresPerSecond.isFinite, speedMetresPerSecond > 0, speedMetresPerSecond <= 100 else { + throw ToolkitError.invalidInput("Route speed must be between 0 and 100 metres per second.") + } + guard updateIntervalSeconds.isFinite, updateIntervalSeconds >= 0.5, updateIntervalSeconds <= 60 else { + throw ToolkitError.invalidInput("The update interval must be between 0.5 and 60 seconds.") + } + for point in waypoints { try Coordinate.validate(latitude: point.latitude, longitude: point.longitude) } + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "idt-route") + defer { try? FileManager.default.removeItem(at: directory) } + let routeFile = directory.appendingPathComponent("route.json") + let document: [String: Any] = [ + "mode": "interval", + "interval": updateIntervalSeconds, + "speed": speedMetresPerSecond, + "waypoints": waypoints.map { ["latitude": $0.latitude, "longitude": $0.longitude] }, + ] + try SecureFileIO.writeNewFile(try JSONSerialization.data(withJSONObject: document, options: [.sortedKeys]), to: routeFile) + return try await invoke( + ["device", "simulate", "location", "route", "--device", target.coreDeviceSelector, "--route-file", routeFile.path], + timeout: 120, + displayName: "devicectl device simulate location route" + ) + } + + public func clearLocation(on target: DeviceTarget) async throws -> Response { + try await invoke(["device", "simulate", "location", "clear", "--device", target.coreDeviceSelector], timeout: 120, displayName: "devicectl device simulate location clear") + } + + public func reboot(_ target: DeviceTarget) async throws -> Response { + try await invoke(["device", "reboot", "--device", target.coreDeviceSelector], timeout: 180, displayName: "devicectl device reboot") + } + + // MARK: Help (for the in-app help browser and the toolchain check) + + public func help(_ route: [String]) async throws -> String { + let request = try XcodeTool.devicectl.request(["help"] + route, timeout: 20, displayName: "devicectl help \(route.joined(separator: " "))") + let result = try await runner.run(request) + let text = result.standardOutputText.isEmpty ? result.standardErrorText : result.standardOutputText + guard result.succeeded || !text.isEmpty else { + throw ToolkitError(.commandFailed, message: "Help for devicectl \(route.joined(separator: " ")) is unavailable.", technicalDetail: result.technicalSummary) + } + return text + } + + // MARK: Invocation + + /// Runs devicectl with `--json-output` to a private temporary file, then parses and + /// interprets the documented result envelope. + public func invoke(_ arguments: [String], timeout: TimeInterval, displayName: String) async throws -> Response { + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "idt-devicectl") + defer { try? FileManager.default.removeItem(at: directory) } + let jsonFile = directory.appendingPathComponent("result.json") + let devicectlTimeout = max(5, Int(timeout.rounded())) + let request = try XcodeTool.devicectl.request( + arguments + ["--timeout", String(devicectlTimeout), "--json-output", jsonFile.path], + timeout: timeout + 20, + displayName: displayName + ) + let result = try await runner.run(request) + let data = try? Data(contentsOf: jsonFile) + let json = data.flatMap { try? JSONValue.parse($0) } + if let json, json["info"]?["outcome"]?.string == "success", result.succeeded { + return Response(json: json, command: result) + } + let error = CoreDeviceErrorInterpreter.interpret(json: json, command: result, operation: displayName) + logger.error("\(displayName, privacy: .public) failed: \(error.kind.rawValue, privacy: .public)") + throw error + } + + // MARK: Parsers (static for unit tests) + + public static func parseDevices(_ json: JSONValue) -> [CoreDeviceRecord] { + (json.value(at: "result.devices")?.array ?? []).compactMap(CoreDeviceRecord.init(json:)) + } + + public static func parseApps(_ json: JSONValue) -> [CoreDeviceApp] { + (json.value(at: "result.apps")?.array ?? []) + .compactMap(CoreDeviceApp.init(json:)) + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + public static func parseProcesses(_ json: JSONValue) -> [CoreDeviceProcess] { + (json.value(at: "result.runningProcesses")?.array ?? json.value(at: "result.processes")?.array ?? []) + .compactMap(CoreDeviceProcess.init(json:)) + .sorted { $0.pid < $1.pid } + } + + public static func parseLockState(_ json: JSONValue) -> CoreDeviceLockState { + CoreDeviceLockState( + passcodeRequired: json.value(at: "result.passcodeRequired")?.bool, + unlockedSinceBoot: json.value(at: "result.unlockedSinceBoot")?.bool + ) + } + + public static func parseFiles(_ json: JSONValue) -> [CoreDeviceFile] { + (json.value(at: "result.files")?.array ?? []).compactMap(CoreDeviceFile.init(json:)) + } +} + +/// Turns devicectl failures into actionable, plain-language errors. +public enum CoreDeviceErrorInterpreter { + public static func interpret(json: JSONValue?, command: CommandResult, operation: String) -> ToolkitError { + let descriptions = json.map { collectDescriptions($0["error"]) } ?? [] + let deviceText = descriptions.joined(separator: " ") + let combined = (deviceText + " " + command.standardErrorText).lowercased() + let detailLines = [ + descriptions.isEmpty ? nil : "Device error: " + descriptions.joined(separator: " — "), + json?["error"]?["domain"]?.string.map { "Domain: \($0)" }, + json?["error"]?["code"]?.string.map { "Code: \($0)" }, + command.technicalSummary, + ].compactMap { $0 } + let detail = detailLines.joined(separator: "\n") + + func error(_ kind: ToolkitError.Kind, _ message: String, _ recovery: String) -> ToolkitError { + ToolkitError(kind, message: message, recovery: recovery, technicalDetail: detail) + } + + if (json == nil && command.exitCode == 72) || combined.contains("unable to find utility \"devicectl\"") { + return error(.toolMissing, "CoreDevice tools (devicectl) are not available.", "Install Xcode 15 or later from the App Store, open it once, and select it with xcode-select.") + } + // devicectl rejected the command line itself: the installed Xcode predates this command or + // option (for example Xcode 26.6 has no `device simulate location`). Checked before the + // phrase matches below, which could otherwise match words in the usage text. + let syntaxPhrases = ["unknown option", "unexpected argument", "unknown subcommand", "unrecognized subcommand"] + if json == nil, syntaxPhrases.contains(where: combined.contains) { + return error(.unsupported, "The installed Xcode's device tool (devicectl) does not support this command.", "This feature needs a newer Xcode (the app is verified with Xcode 27). Update Xcode, or open Tool Reference › Toolchain Check to see which features your Xcode supports.") + } + let notFoundPhrases = ["no devices matched", "device was not found", "device not found", "unable to locate device", "no device found", "could not find device", "no such device"] + if notFoundPhrases.contains(where: combined.contains) { + return error(.deviceNotFound, "The selected device is no longer available to Xcode's device service.", "Reconnect the device with a USB cable, unlock it, and refresh the device list.") + } + if combined.contains("developer mode") && (combined.contains("disabled") || combined.contains("not enabled") || combined.contains("is off") || combined.contains("turned off")) { + return error(.developerModeDisabled, "Developer Mode is turned off on the selected device.", "On the device, open Settings › Privacy & Security › Developer Mode, turn it on, restart, and confirm when asked.") + } + if combined.contains("locked") || combined.contains("passcode") || combined.contains("unlock") { + return error(.deviceLocked, "The selected device is locked.", "Unlock the device and keep it awake, then try again.") + } + if combined.contains("not paired") || combined.contains("pairing") || combined.contains("trust") { + return error(.notPaired, "The selected device has not trusted this Mac.", "Unlock the device, connect it with a USB cable, and tap Trust when asked. Then try again.") + } + if combined.contains("developer disk image") || combined.contains("ddi") { + return error(.developerDiskImageUnavailable, "The developer image could not be mounted on the selected device.", "Keep the device unlocked and connected with USB, make sure this Mac is online, and use “Mount Developer Image” on the Device page (its Options menu can switch to the built-in mount).") + } + if combined.contains("timed out") || combined.contains("timeout") { + return error(.timedOut, "The device did not respond in time.", "Make sure the device is unlocked, awake, and connected, then try again.") + } + if combined.contains("disconnected") || combined.contains("connection was invalidated") || combined.contains("not connected") || combined.contains("unavailable") { + return error(.deviceDisconnected, "Unable to communicate with the selected device.", "Make sure the device is unlocked, connected, and has trusted this Mac.") + } + if combined.contains("not supported") || combined.contains("unsupported") { + return error(.unsupported, "The selected device or its iOS version does not support this operation.", "Check the device's iOS version and Xcode version; newer features need both to be current.") + } + let summary = descriptions.first ?? "\(operation) did not complete." + return error(.commandFailed, "The device reported a problem: \(summary)", "Review the technical details, make sure the device is unlocked and connected, and try again.") + } + + /// Collects localized descriptions from CoreDevice's error envelope, where values are + /// often wrapped as `{"string": "..."}` and errors can be nested under NSUnderlyingError. + static func collectDescriptions(_ error: JSONValue?, depth: Int = 0) -> [String] { + guard let error, depth < 6 else { return [] } + var results: [String] = [] + if let userInfo = error["userInfo"] { + for key in ["NSLocalizedDescription", "NSLocalizedFailureReason", "NSLocalizedRecoverySuggestion"] { + if let value = userInfo[key], let text = unwrapString(value), !results.contains(text) { + results.append(text) + } + } + if let underlying = userInfo["NSUnderlyingError"] { + let nested = underlying["error"] ?? underlying + results += collectDescriptions(nested, depth: depth + 1).filter { !results.contains($0) } + } + } + return results + } + + static func unwrapString(_ value: JSONValue) -> String? { + if let text = value.nonEmptyString { return text } + if let text = value["string"]?.nonEmptyString { return text } + return nil + } +} + +public enum BundleIdentifier { + public static func validate(_ value: String) throws { + let pattern = #"^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$"# + guard value.count <= 255, value.range(of: pattern, options: .regularExpression) != nil else { + throw ToolkitError.invalidInput("“\(value)” is not a valid bundle identifier. Use a reverse-DNS name such as com.example.app.") + } + } +} + +public enum Coordinate { + public static func validate(latitude: Double, longitude: Double) throws { + guard latitude.isFinite, (-90...90).contains(latitude) else { + throw ToolkitError.invalidInput("Latitude must be between -90 and 90 degrees.") + } + guard longitude.isFinite, (-180...180).contains(longitude) else { + throw ToolkitError.invalidInput("Longitude must be between -180 and 180 degrees.") + } + } + + public static func format(_ value: Double) -> String { + String(format: "%.8f", value) + } +} diff --git a/Sources/DeviceKit/CoreDevice/CoreDeviceModels.swift b/Sources/DeviceKit/CoreDevice/CoreDeviceModels.swift new file mode 100644 index 0000000..644a9a0 --- /dev/null +++ b/Sources/DeviceKit/CoreDevice/CoreDeviceModels.swift @@ -0,0 +1,207 @@ +import Foundation +import ToolkitCore + +/// One device as reported by `devicectl list devices` / `device info details`. +public struct CoreDeviceRecord: Sendable, Hashable { + public var identifier: String + public var udid: String? + public var name: String + public var productType: String? + public var marketingName: String? + public var deviceType: String? + public var platform: String? + public var reality: String? + public var hardwareModel: String? + public var architecture: String? + public var serialNumber: String? + public var ecid: String? + public var osVersion: String? + public var buildVersion: String? + public var developerMode: DeveloperModeState + public var ddiServicesAvailable: Bool? + public var bootState: String? + public var transportType: String? + public var pairingState: PairingState + public var tunnelState: String? + public var visibilityClass: String? + public var capabilities: [String] + public var raw: JSONValue + + public var isPhysical: Bool { + guard let reality else { return true } + return reality.lowercased() == "physical" + } + + public var transport: DeviceTransport? { + guard let transportType = transportType?.lowercased() else { return nil } + if transportType.contains("wired") || transportType.contains("usb") { return .usb } + if transportType.contains("network") || transportType.contains("wireless") || transportType.contains("wifi") { return .network } + return nil + } + + /// A merged `Device` for the UI. + public func device(lastSeen: Date = Date()) -> Device { + Device( + kind: .physical, + udid: udid ?? identifier, + name: name, + productType: productType, + marketingName: marketingName, + family: DeviceFamily.from(productType: productType, deviceType: deviceType), + osName: platform.map { $0 == "iOS" && deviceType == "iPad" ? "iPadOS" : $0 }, + osVersion: osVersion, + buildVersion: buildVersion, + architecture: architecture, + hardwareModel: hardwareModel, + serialNumber: serialNumber, + ecid: ecid, + transports: transport.map { [$0] } ?? [], + pairingState: pairingState, + developerMode: developerMode, + ddiServicesAvailable: ddiServicesAvailable, + tunnelState: tunnelState, + coreDeviceIdentifier: identifier, + sources: [.coreDevice], + lastSeen: lastSeen + ) + } + + /// Parses a device object. Both the classic property groups and the newer flattened + /// `properties` dictionary are read, so the parser works across devicectl JSON versions. + public init?(json: JSONValue) { + guard json.object != nil else { return nil } + let hardware = json["hardwareProperties"] + let device = json["deviceProperties"] + let connection = json["connectionProperties"] + let flattened = CoreDeviceRecord.flatten(json["properties"]) + + func pick(_ group: JSONValue?, _ key: String) -> JSONValue? { + group?[key] ?? flattened[key] + } + + guard let identifier = json["identifier"]?.nonEmptyString ?? pick(hardware, "udid")?.nonEmptyString else { return nil } + self.identifier = identifier + udid = pick(hardware, "udid")?.nonEmptyString + name = pick(device, "name")?.nonEmptyString ?? pick(hardware, "marketingName")?.nonEmptyString ?? "Unnamed device" + productType = pick(hardware, "productType")?.nonEmptyString + marketingName = pick(hardware, "marketingName")?.nonEmptyString + deviceType = pick(hardware, "deviceType")?.nonEmptyString + platform = pick(hardware, "platform")?.nonEmptyString + reality = pick(hardware, "reality")?.nonEmptyString + hardwareModel = pick(hardware, "hardwareModel")?.nonEmptyString + architecture = pick(hardware, "cpuType")?["name"]?.nonEmptyString + serialNumber = pick(hardware, "serialNumber")?.nonEmptyString + ecid = pick(hardware, "ecid")?.string + osVersion = pick(device, "osVersionNumber")?.nonEmptyString + buildVersion = pick(device, "osBuildUpdate")?.nonEmptyString + developerMode = DeveloperModeState.fromCoreDevice(pick(device, "developerModeStatus")?.string) + ddiServicesAvailable = pick(device, "ddiServicesAvailable")?.bool + bootState = pick(device, "bootState")?.nonEmptyString + transportType = pick(connection, "transportType")?.nonEmptyString + pairingState = PairingState.fromCoreDevice(pick(connection, "pairingState")?.string) + tunnelState = pick(connection, "tunnelState")?.nonEmptyString + visibilityClass = json["visibilityClass"]?.nonEmptyString ?? flattened["visibilityClass"]?.nonEmptyString + capabilities = json["capabilities"]?.array?.compactMap { $0["name"]?.nonEmptyString } ?? [] + raw = json + } + + /// Flattens `properties..` into `field → value`. + static func flatten(_ properties: JSONValue?) -> [String: JSONValue] { + guard let categories = properties?.object else { return [:] } + var flattened: [String: JSONValue] = [:] + for (_, category) in categories { + guard let fields = category.object else { continue } + for (key, value) in fields where flattened[key] == nil { + flattened[key] = value + } + } + return flattened + } +} + +public struct CoreDeviceApp: Sendable, Hashable, Identifiable { + public var id: String { bundleIdentifier } + public var name: String + public var bundleIdentifier: String + public var version: String? + public var bundleVersion: String? + public var isRemovable: Bool? + public var isBuiltByDeveloper: Bool? + public var isAppClip: Bool? + public var isHidden: Bool? + public var isDefaultApp: Bool? + public var url: String? + + public init?(json: JSONValue) { + guard let bundleIdentifier = json["bundleIdentifier"]?.nonEmptyString else { return nil } + self.bundleIdentifier = bundleIdentifier + name = json["name"]?.nonEmptyString ?? bundleIdentifier + version = json["version"]?.nonEmptyString + bundleVersion = json["bundleVersion"]?.nonEmptyString + isRemovable = json["removable"]?.bool + isBuiltByDeveloper = json["builtByDeveloper"]?.bool + isAppClip = json["appClip"]?.bool + isHidden = json["hidden"]?.bool + isDefaultApp = json["defaultApp"]?.bool + url = json["url"]?.nonEmptyString + } +} + +public struct CoreDeviceProcess: Sendable, Hashable, Identifiable { + public var id: Int { pid } + public var pid: Int + public var executablePath: String? + + public var name: String { + guard let executablePath else { return "PID \(pid)" } + let trimmed = executablePath.hasSuffix("/") ? String(executablePath.dropLast()) : executablePath + return URL(string: trimmed)?.lastPathComponent.removingPercentEncoding + ?? (trimmed as NSString).lastPathComponent + } + + public init?(json: JSONValue) { + guard let pid = json["processIdentifier"]?.int else { return nil } + self.pid = pid + executablePath = json["executable"]?.nonEmptyString + } + + public init(pid: Int, executablePath: String?) { + self.pid = pid + self.executablePath = executablePath + } +} + +public struct CoreDeviceLockState: Sendable, Hashable { + public var passcodeRequired: Bool? + public var unlockedSinceBoot: Bool? + + public init(passcodeRequired: Bool?, unlockedSinceBoot: Bool?) { + self.passcodeRequired = passcodeRequired + self.unlockedSinceBoot = unlockedSinceBoot + } + + public var summary: String { + switch (passcodeRequired, unlockedSinceBoot) { + case (.some(true), _): return "Locked — unlock the device to continue." + case (.some(false), .some(true)): return "Unlocked" + case (.some(false), .some(false)): return "Unlocked, but not unlocked since restart" + case (.some(false), .none): return "Unlocked" + case (.none, .some(false)): return "Not unlocked since restart — unlock it once to continue." + default: return "Unknown" + } + } +} + +public struct CoreDeviceFile: Sendable, Hashable, Identifiable { + public var id: String { path } + public var path: String + public var size: Int64? + public var modified: Date? + + public init?(json: JSONValue) { + guard let path = json["relativePath"]?.nonEmptyString ?? json["path"]?.nonEmptyString ?? json["name"]?.nonEmptyString else { return nil } + self.path = path + size = (json["size"] ?? json["fileSize"])?.double.map { Int64($0) } + modified = (json["lastModDate"] ?? json["modificationDate"])?.string.flatMap(ISO8601.parse) + } +} diff --git a/Sources/DeviceKit/DeveloperImage/DeveloperImageLibrary.swift b/Sources/DeviceKit/DeveloperImage/DeveloperImageLibrary.swift new file mode 100644 index 0000000..7f9791a --- /dev/null +++ b/Sources/DeviceKit/DeveloperImage/DeveloperImageLibrary.swift @@ -0,0 +1,241 @@ +import Foundation +import ToolkitCore + +/// Where a developer image on this Mac came from. +public enum DeveloperImageOrigin: String, Sendable, Hashable, Codable { + /// Installed by Xcode (`/Library/Developer/DeveloperDiskImages`, or an Xcode app's DeviceSupport). + case xcode + /// A folder the user chose. + case userFolder +} + +/// One build identity of a personalized image's `BuildManifest.plist`: the components Apple signs +/// for one chip/board combination. +public struct DeveloperImageBuildIdentity: Sendable, Hashable { + public var chipID: Int + public var boardID: Int + public var productType: String? + public var variant: String? + public var imagePath: String + public var trustCachePath: String + /// The identity's `Manifest` dictionary, used to build the signing request. + public var manifest: [String: PlistValue] +} + +/// A personalized developer image (iOS 17 and later) on this Mac: a folder with +/// `BuildManifest.plist`, the personalized DMG, and its trust cache. +public struct PersonalizedImageSource: Sendable, Hashable { + public var directory: URL + public var origin: DeveloperImageOrigin + /// Xcode's build of the image (for example 27A266a), when known. + public var buildVersion: String? + public var supportedProductTypes: [String] + public var identities: [DeveloperImageBuildIdentity] + + public var displayName: String { + (origin == .xcode ? "Xcode developer image" : "Developer image in \(directory.lastPathComponent)") + (buildVersion.map { " (\($0))" } ?? "") + } + + /// The identity for a device's chip and board, preferring the personalized-DMG variant. + public func identity(chipID: Int, boardID: Int) -> DeveloperImageBuildIdentity? { + identities.first { $0.chipID == chipID && $0.boardID == boardID } + } + + public func supports(productType: String?) -> Bool? { + guard let productType, !supportedProductTypes.isEmpty else { return nil } + return supportedProductTypes.contains(productType) + } + + /// The image and trust-cache files for an identity. Paths in the manifest are relative to the + /// folder; folders in the older flat layout use `Image.dmg` and `Image.dmg.trustcache`. + public func files(for identity: DeveloperImageBuildIdentity) throws -> (image: URL, trustCache: URL) { + let image = try DeveloperImageLibrary.resolve(identity.imagePath, fallback: "Image.dmg", in: directory) + let trustCache = try DeveloperImageLibrary.resolve(identity.trustCachePath, fallback: "Image.dmg.trustcache", in: directory) + return (image, trustCache) + } +} + +/// A legacy Developer Disk Image (iOS 16 and earlier): `DeveloperDiskImage.dmg` plus its +/// detached signature, for one iOS `major.minor` version. +public struct LegacyImageSource: Sendable, Hashable { + public var version: String + public var image: URL + public var signature: URL + public var origin: DeveloperImageOrigin + + public var displayName: String { "Developer Disk Image for iOS \(version)" + (origin == .userFolder ? " (\(image.deletingLastPathComponent().lastPathComponent))" : "") } +} + +/// Finds and validates developer images on this Mac. It never downloads anything: images come +/// from Xcode or from a folder the user chooses. +public enum DeveloperImageLibrary { + /// Where Xcode 16 and later install the iOS developer image. + public static let xcodePersonalizedImage = URL(fileURLWithPath: "/Library/Developer/DeveloperDiskImages/iOS_DDI", isDirectory: true) + static let maximumImageSize = 2 << 30 + static let maximumSmallFileSize = 64 << 20 + + // MARK: Personalized images + + /// Xcode's installed image plus any user folders that contain a personalized image. + public static func personalizedSources(userFolders: [URL] = [], xcodeImage: URL = xcodePersonalizedImage) -> [PersonalizedImageSource] { + var sources: [PersonalizedImageSource] = [] + // Folders the user chose come first: an explicit choice wins over what Xcode installed. + for folder in userFolders { + if let source = try? personalizedSource(at: folder, origin: .userFolder) { sources.append(source) } + } + if let source = try? personalizedSource(at: xcodeImage, origin: .xcode) { sources.append(source) } + return sources + } + + /// Reads a personalized image folder: the folder itself, or its `Restore` subfolder (Xcode's + /// layout), must contain `BuildManifest.plist`. + public static func personalizedSource(at folder: URL, origin: DeveloperImageOrigin) throws -> PersonalizedImageSource { + let candidates = [folder.appendingPathComponent("Restore", isDirectory: true), folder] + guard let directory = candidates.first(where: { FileManager.default.fileExists(atPath: $0.appendingPathComponent("BuildManifest.plist").path) }) else { + throw ToolkitError(.developerDiskImageUnavailable, message: "\(folder.lastPathComponent) does not contain a personalized developer image.", recovery: "Choose a folder that contains BuildManifest.plist, the image (.dmg), and its trust cache — for example /Library/Developer/DeveloperDiskImages/iOS_DDI.") + } + let manifestURL = directory.appendingPathComponent("BuildManifest.plist") + let manifest = try PlistValue.decode(try readValidatedFile(manifestURL, limit: maximumSmallFileSize)) + var identities: [DeveloperImageBuildIdentity] = [] + for identity in manifest["BuildIdentities"]?.arrayValue ?? [] { + guard let parsed = parseIdentity(identity) else { continue } + identities.append(parsed) + } + guard !identities.isEmpty else { + throw ToolkitError(.developerDiskImageUnavailable, message: "The image's BuildManifest.plist lists no personalized developer image.", technicalDetail: manifestURL.path) + } + var buildVersion = manifest["ProductBuildVersion"]?.stringValue + let versionPlist = directory.deletingLastPathComponent().appendingPathComponent("version.plist") + if let data = try? readValidatedFile(versionPlist, limit: 1 << 20), let version = try? PlistValue.decode(data) { + buildVersion = version["ProductBuildVersion"]?.stringValue ?? buildVersion + } + return PersonalizedImageSource( + directory: directory, + origin: origin, + buildVersion: buildVersion, + supportedProductTypes: (manifest["SupportedProductTypes"]?.arrayValue ?? []).compactMap(\.stringValue), + identities: identities + ) + } + + static func parseIdentity(_ identity: PlistValue) -> DeveloperImageBuildIdentity? { + guard let chip = parseHex(identity["ApChipID"]), let board = parseHex(identity["ApBoardID"]), + let manifest = identity["Manifest"]?.dictionaryValue, + let imagePath = manifest["PersonalizedDMG"]?["Info"]?["Path"]?.stringValue, + let trustCachePath = manifest["LoadableTrustCache"]?["Info"]?["Path"]?.stringValue else { + return nil + } + return DeveloperImageBuildIdentity( + chipID: chip, + boardID: board, + productType: identity["Ap,ProductType"]?.stringValue, + variant: identity["Info"]?["Variant"]?.stringValue, + imagePath: imagePath, + trustCachePath: trustCachePath, + manifest: manifest + ) + } + + /// Parses `"0x8150"`, `"33104"`, or an integer. + static func parseHex(_ value: PlistValue?) -> Int? { + if let number = value?.intValue { return number } + guard let text = value?.stringValue?.trimmingCharacters(in: .whitespaces).lowercased() else { return nil } + return text.hasPrefix("0x") ? Int(text.dropFirst(2), radix: 16) : Int(text) + } + + static func resolve(_ relativePath: String, fallback: String, in directory: URL) throws -> URL { + for candidate in [relativePath, fallback] { + let url = try SecureFileIO.safeChild(of: directory, relativePath: candidate) + if FileManager.default.fileExists(atPath: url.path) { return url } + } + throw ToolkitError(.developerDiskImageUnavailable, message: "The developer image folder is incomplete.", recovery: "Reinstall Xcode's device support (Xcode › Settings › Components) or choose a complete image folder.", technicalDetail: "Missing \(relativePath) in \(directory.path)") + } + + // MARK: Legacy images + + /// Legacy images from every installed Xcode's DeviceSupport folder plus user folders. A user + /// folder may hold the image directly (version taken from the folder name, for example + /// "16.4") or version subfolders like Xcode's DeviceSupport. + public static func legacySources(userFolders: [URL] = [], applications: URL = URL(fileURLWithPath: "/Applications", isDirectory: true)) -> [LegacyImageSource] { + var sources: [LegacyImageSource] = [] + // Folders the user chose come first: an explicit choice wins over what Xcode installed. + for folder in userFolders { + if let direct = legacyImage(in: folder, origin: .userFolder) { + sources.append(direct) + } else { + sources += legacyImages(inContainer: folder, origin: .userFolder) + } + } + let xcodes = ((try? FileManager.default.contentsOfDirectory(at: applications, includingPropertiesForKeys: nil)) ?? []) + .filter { $0.lastPathComponent.hasPrefix("Xcode") && $0.pathExtension == "app" } + for xcode in xcodes { + let deviceSupport = xcode.appendingPathComponent("Contents/Developer/Platforms/iPhoneOS.platform/DeviceSupport", isDirectory: true) + sources += legacyImages(inContainer: deviceSupport, origin: .xcode) + } + return sources + } + + static func legacyImages(inContainer container: URL, origin: DeveloperImageOrigin) -> [LegacyImageSource] { + let folders = (try? FileManager.default.contentsOfDirectory(at: container, includingPropertiesForKeys: [.isDirectoryKey])) ?? [] + return folders.compactMap { legacyImage(in: $0, origin: origin) } + } + + static func legacyImage(in folder: URL, origin: DeveloperImageOrigin) -> LegacyImageSource? { + let image = folder.appendingPathComponent("DeveloperDiskImage.dmg") + let signature = folder.appendingPathComponent("DeveloperDiskImage.dmg.signature") + guard FileManager.default.fileExists(atPath: image.path), FileManager.default.fileExists(atPath: signature.path), + let version = majorMinor(folder.lastPathComponent) else { return nil } + return LegacyImageSource(version: version, image: image, signature: signature, origin: origin) + } + + /// The `major.minor` at the start of a version or folder name ("16.4 (20E247)" → "16.4"). + public static func majorMinor(_ text: String) -> String? { + let parts = text.split(whereSeparator: { !$0.isNumber && $0 != "." }).first.map(String.init)?.split(separator: ".") ?? [] + guard parts.count >= 2, let major = Int(parts[0]), let minor = Int(parts[1]) else { + if parts.count == 1, let major = Int(parts[0]) { return "\(major).0" } + return nil + } + return "\(major).\(minor)" + } + + /// The legacy image for an iOS version: an exact `major.minor` match. + public static func legacyImage(forVersion version: String?, in sources: [LegacyImageSource]) -> LegacyImageSource? { + guard let wanted = version.flatMap(majorMinor) else { return nil } + return sources.first { $0.version == wanted } + } + + // MARK: Files + + /// Reads a regular file (not a symbolic link) up to `limit` bytes. + public static func readValidatedFile(_ url: URL, limit: Int) throws -> Data { + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey]) + guard values.isRegularFile == true, values.isSymbolicLink != true else { + throw ToolkitError(.developerDiskImageUnavailable, message: "\(url.lastPathComponent) is not a regular file.", technicalDetail: url.path) + } + guard let size = values.fileSize, size > 0, size <= limit else { + throw ToolkitError(.developerDiskImageUnavailable, message: "\(url.lastPathComponent) has an unexpected size.", technicalDetail: "\(url.path): \(values.fileSize ?? -1) bytes") + } + return try Data(contentsOf: url, options: .mappedIfSafe) + } + + static func readImage(_ url: URL) throws -> Data { try readValidatedFile(url, limit: maximumImageSize) } + static func readSmallFile(_ url: URL) throws -> Data { try readValidatedFile(url, limit: maximumSmallFileSize) } +} + +/// Where Xcode keeps developer images on this Mac. Injectable so tests see only their fixtures. +public struct DeveloperImageHostLocations: Sendable, Hashable { + /// Xcode 16 and later: the personalized iOS image. + public var xcodePersonalizedImage: URL + /// The folder searched for `Xcode*.app` bundles with legacy DeviceSupport images. + public var applications: URL + + public init(xcodePersonalizedImage: URL, applications: URL) { + self.xcodePersonalizedImage = xcodePersonalizedImage + self.applications = applications + } + + public static let system = DeveloperImageHostLocations( + xcodePersonalizedImage: DeveloperImageLibrary.xcodePersonalizedImage, + applications: URL(fileURLWithPath: "/Applications", isDirectory: true) + ) +} diff --git a/Sources/DeviceKit/DeveloperImage/DeveloperImageManager.swift b/Sources/DeviceKit/DeveloperImage/DeveloperImageManager.swift new file mode 100644 index 0000000..c56cb4a --- /dev/null +++ b/Sources/DeviceKit/DeveloperImage/DeveloperImageManager.swift @@ -0,0 +1,540 @@ +import CryptoKit +import Foundation +import OSLog +import ToolkitCore + +/// The developer-image state shown to users. +public enum DeveloperImageState: String, Sendable, Hashable, Codable, CaseIterable { + /// Simulators and the demo device do not use developer images. + case notRequired + /// A compatible developer image is mounted; developer services can start. + case mounted + /// A compatible image is on this Mac and can be mounted right away. + case available + /// A compatible image is on this Mac, but Apple must personalize it for this device first + /// (needs an internet connection). + case personalizationRequired + /// No compatible image is on this Mac. + case missing + /// An image is available or mounted, but it does not fit this device or iOS version. + case incompatible + /// Something on the device must change first (trust, Developer Mode, unlock, USB). + case blocked + /// The state could not be determined, or the last mount attempt failed. + case failed + + public var label: String { + switch self { + case .notRequired: return "Not required" + case .mounted: return "Mounted" + case .available: return "Available" + case .personalizationRequired: return "Personalization required" + case .missing: return "Missing" + case .incompatible: return "Incompatible" + case .blocked: return "Needs attention" + case .failed: return "Failed" + } + } + + public var symbolName: String { + switch self { + case .notRequired: return "minus.circle" + case .mounted: return "checkmark.circle.fill" + case .available: return "arrow.down.circle" + case .personalizationRequired: return "signature" + case .missing: return "questionmark.circle" + case .incompatible: return "exclamationmark.triangle" + case .blocked: return "hand.raised" + case .failed: return "xmark.octagon" + } + } + + /// Whether a mount can be started from this state. + public var canMount: Bool { self == .available || self == .personalizationRequired } +} + +/// How an image is mounted. +public enum DeveloperImageMechanism: String, Sendable, Hashable, Codable, CaseIterable { + /// Let the toolkit choose (Xcode's device service when it can reach the device on iOS 17+, + /// otherwise the built-in image mounter client). + case automatic + /// `devicectl device info ddiServices --auto-mount-ddis` — Apple's documented tool. + case coreDevice + /// The built-in client for the device's image mounter service over USB. + case native + + public var label: String { + switch self { + case .automatic: return "Automatic" + case .coreDevice: return "Xcode device service (devicectl)" + case .native: return "Built-in (image mounter over USB)" + } + } +} + +/// Facts about the device that decide which image it needs. +public struct DeveloperImageDeviceFacts: Sendable, Hashable, Codable { + public var productVersion: String? + public var buildVersion: String? + public var productType: String? + public var architecture: String? + public var hardwareModel: String? + public var chipID: Int? + public var boardID: Int? + public var developerModeEnabled: Bool? + + public init(productVersion: String? = nil, buildVersion: String? = nil, productType: String? = nil, architecture: String? = nil, hardwareModel: String? = nil, chipID: Int? = nil, boardID: Int? = nil, developerModeEnabled: Bool? = nil) { + self.productVersion = productVersion + self.buildVersion = buildVersion + self.productType = productType + self.architecture = architecture + self.hardwareModel = hardwareModel + self.chipID = chipID + self.boardID = boardID + self.developerModeEnabled = developerModeEnabled + } + + public var majorVersion: Int? { productVersion.flatMap { Int($0.split(separator: ".").first ?? "") } } + public var requiredKind: DeveloperImageKind { .required(forMajorVersion: majorVersion) } +} + +/// What was found on the device's image mounter. +public struct DeveloperImageObservation: Sendable, Hashable { + /// Signatures of mounted images of the required kind (empty: none mounted). + public var mountedSignatures: [Data] + /// Signatures of mounted images of the other kind. + public var otherKindMounted: Bool + public var mountedImages: [MountedImage] + /// Whether the device already holds a personalization manifest for the host image (nil: not checked). + public var manifestOnDevice: Bool? + + public init(mountedSignatures: [Data] = [], otherKindMounted: Bool = false, mountedImages: [MountedImage] = [], manifestOnDevice: Bool? = nil) { + self.mountedSignatures = mountedSignatures + self.otherKindMounted = otherKindMounted + self.mountedImages = mountedImages + self.manifestOnDevice = manifestOnDevice + } +} + +/// The evaluated developer-image status of one device. +public struct DeveloperImageStatus: Sendable, Hashable { + public var state: DeveloperImageState + public var headline: String + public var explanation: String + public var remediation: String? + public var requiredKind: DeveloperImageKind? + public var facts: DeveloperImageDeviceFacts? + public var mountedImages: [MountedImage] + /// The host image that would be (or was) used. + public var hostImage: String? + public var recommendedMechanism: DeveloperImageMechanism? + public var technicalDetail: String? + public var checkedAt: Date + + public init(state: DeveloperImageState, headline: String, explanation: String, remediation: String? = nil, requiredKind: DeveloperImageKind? = nil, facts: DeveloperImageDeviceFacts? = nil, mountedImages: [MountedImage] = [], hostImage: String? = nil, recommendedMechanism: DeveloperImageMechanism? = nil, technicalDetail: String? = nil, checkedAt: Date = Date()) { + self.state = state + self.headline = headline + self.explanation = explanation + self.remediation = remediation + self.requiredKind = requiredKind + self.facts = facts + self.mountedImages = mountedImages + self.hostImage = hostImage + self.recommendedMechanism = recommendedMechanism + self.technicalDetail = technicalDetail + self.checkedAt = checkedAt + } + + /// A status for an error, with the error's plain-language message and recovery. + public static func failure(_ error: Error, facts: DeveloperImageDeviceFacts? = nil) -> DeveloperImageStatus { + let toolkitError = error as? ToolkitError + let state: DeveloperImageState + switch toolkitError?.kind { + case .deviceLocked, .developerModeDisabled, .notPaired, .pairingPending: state = .blocked + default: state = .failed + } + return DeveloperImageStatus( + state: state, + headline: toolkitError?.message ?? "The developer image could not be checked.", + explanation: state == .blocked ? "The device needs attention before a developer image can be checked or mounted." : "The last developer-image operation did not complete.", + remediation: toolkitError?.recovery ?? "Reconnect the device and check again.", + requiredKind: facts?.requiredKind, + facts: facts, + technicalDetail: toolkitError?.technicalDetail ?? error.localizedDescription + ) + } +} + +/// Where developer images can come from on this Mac. +public struct DeveloperImageHostInventory: Sendable, Hashable { + public var personalized: [PersonalizedImageSource] + public var legacy: [LegacyImageSource] + /// Whether Xcode's device service can be used for this device (Xcode installed and the device visible to CoreDevice). + public var coreDeviceAvailable: Bool + + public init(personalized: [PersonalizedImageSource] = [], legacy: [LegacyImageSource] = [], coreDeviceAvailable: Bool = false) { + self.personalized = personalized + self.legacy = legacy + self.coreDeviceAvailable = coreDeviceAvailable + } + + public static func discover(userFolders: [URL], coreDeviceAvailable: Bool, locations: DeveloperImageHostLocations = .system) -> DeveloperImageHostInventory { + DeveloperImageHostInventory( + personalized: DeveloperImageLibrary.personalizedSources(userFolders: userFolders, xcodeImage: locations.xcodePersonalizedImage), + legacy: DeveloperImageLibrary.legacySources(userFolders: userFolders, applications: locations.applications), + coreDeviceAvailable: coreDeviceAvailable + ) + } + + /// The first personalized source with a build identity for this chip and board. + public func personalizedMatch(chipID: Int?, boardID: Int?) -> (source: PersonalizedImageSource, identity: DeveloperImageBuildIdentity)? { + guard let chipID, let boardID else { return nil } + for source in personalized { + if let identity = source.identity(chipID: chipID, boardID: boardID) { return (source, identity) } + } + return nil + } +} + +/// Decides the developer-image state. Pure, so every rule is unit-tested. +public enum DeveloperImageEvaluator { + public static func evaluate(facts: DeveloperImageDeviceFacts, observation: DeveloperImageObservation, host: DeveloperImageHostInventory) -> DeveloperImageStatus { + let kind = facts.requiredKind + var status = DeveloperImageStatus(state: .missing, headline: "", explanation: "", requiredKind: kind, facts: facts, mountedImages: observation.mountedImages) + let version = facts.productVersion.map { "iOS \($0)" } ?? "this iOS version" + + if !observation.mountedSignatures.isEmpty { + status.state = .mounted + status.headline = "A developer image is mounted." + status.explanation = "\(kind.label) is mounted at \(kind.mountPath). Developer services can start; nothing needs to be mounted again." + return status + } + if observation.otherKindMounted { + status.state = .incompatible + status.headline = "The mounted developer image does not match \(version)." + status.explanation = "An image of the other kind is mounted. \(version) needs a \(kind.label.lowercased())." + status.remediation = "Unmount the current image (or restart the device), then mount the matching image." + return status + } + if let major = facts.majorVersion, major >= 16, facts.developerModeEnabled == false { + status.state = .blocked + status.headline = "Developer Mode is off." + status.explanation = "iOS 16 and later only mount developer images when Developer Mode is on." + status.remediation = "Turn on Settings › Privacy & Security › Developer Mode, restart the device, and confirm." + return status + } + + switch kind { + case .personalized: + if let match = host.personalizedMatch(chipID: facts.chipID, boardID: facts.boardID) { + status.hostImage = match.source.displayName + if match.source.supports(productType: facts.productType) == false { + status.state = .incompatible + status.headline = "The developer image on this Mac does not list this device model." + status.explanation = "\(match.source.displayName) does not include \(facts.productType ?? "this model") in its supported devices." + status.remediation = "Update Xcode, open it once to install its device support, and check again." + return status + } + status.recommendedMechanism = host.coreDeviceAvailable ? .coreDevice : .native + if observation.manifestOnDevice == true { + status.state = .available + status.headline = "A compatible developer image is ready to mount." + status.explanation = "\(match.source.displayName) supports this device, and the device already holds Apple's personalization for it, so mounting does not need the internet." + } else { + status.state = .personalizationRequired + status.headline = "A compatible developer image is on this Mac; Apple must personalize it." + status.explanation = "iOS 17 and later only mount images signed by Apple for this specific device. Mounting sends the device's chip, board, and ECID with a one-time nonce to Apple's signing server (as Xcode does) and needs an internet connection." + } + return status + } + if !host.personalized.isEmpty, facts.chipID != nil { + status.state = .incompatible + status.hostImage = host.personalized.first?.displayName + status.headline = "The developer image on this Mac does not support this device." + status.explanation = "No build identity in \(host.personalized.first?.displayName ?? "the image") matches this device's chip (\(hex(facts.chipID))) and board (\(hex(facts.boardID)))." + status.remediation = "Update Xcode — newer devices need newer device support — then check again." + return status + } + if host.coreDeviceAvailable { + status.state = .personalizationRequired + status.recommendedMechanism = .coreDevice + status.headline = "Xcode's device service can prepare the developer image." + status.explanation = "Xcode chooses, personalizes, and mounts the matching image. This needs an internet connection." + return status + } + status.state = .missing + status.headline = "No developer image for \(version) is on this Mac." + status.explanation = "Xcode installs the image in /Library/Developer/DeveloperDiskImages. Without it, developer services cannot start." + status.remediation = "Install Xcode and open it once (or run “Update this Mac's developer images” in Actions). You can also choose a folder that contains a developer image." + return status + + case .legacy: + if let source = DeveloperImageLibrary.legacyImage(forVersion: facts.productVersion, in: host.legacy) { + status.state = .available + status.hostImage = source.displayName + status.recommendedMechanism = .native + status.headline = "A matching Developer Disk Image is ready to mount." + status.explanation = "\(source.displayName) matches \(version). Mounting uploads it to the device over USB." + return status + } + let others = Set(host.legacy.map(\.version)).sorted { $0.compare($1, options: .numeric) == .orderedAscending } + status.state = others.isEmpty ? .missing : .incompatible + status.headline = others.isEmpty + ? "No Developer Disk Image for \(version) is on this Mac." + : "The Developer Disk Images on this Mac are for other iOS versions." + status.explanation = "iOS 16 and earlier need DeveloperDiskImage.dmg and its signature for exactly iOS \(DeveloperImageLibrary.majorMinor(facts.productVersion ?? "") ?? "?")." + + (others.isEmpty ? "" : " Found: iOS \(others.joined(separator: ", "))." ) + + " Current Xcode versions no longer include these images." + status.remediation = "Choose a folder containing DeveloperDiskImage.dmg and DeveloperDiskImage.dmg.signature for this version (for example from an older Xcode's Platforms/iPhoneOS.platform/DeviceSupport), or connect the device to an Xcode version that supports it once." + return status + } + } + + static func hex(_ value: Int?) -> String { + value.map { "0x" + String($0, radix: 16, uppercase: true) } ?? "unknown" + } +} + +/// Checks, mounts, and unmounts developer images for a physical device. +public struct DeveloperImageManager: Sendable { + public let usbmux: USBMuxClient + public let coreDevice: CoreDeviceClient + public let transport: PersonalizationTransport + public let locations: DeveloperImageHostLocations + static let logger = ToolkitLog.logger(.deviceCommunication) + + public init(usbmux: USBMuxClient = USBMuxClient(), coreDevice: CoreDeviceClient = CoreDeviceClient(), transport: PersonalizationTransport = AppleTSSTransport(), locations: DeveloperImageHostLocations = .system) { + self.usbmux = usbmux + self.coreDevice = coreDevice + self.transport = transport + self.locations = locations + } + + // MARK: Status + + /// Reads the device and evaluates its developer-image state. Never changes the device. + public func status(for target: DeviceTarget, userFolders: [URL] = []) async -> DeveloperImageStatus { + switch target.kind { + case .simulator, .demo: + return DeveloperImageStatus(state: .notRequired, headline: "Not required.", explanation: target.kind == .simulator ? "Simulators include developer services; no developer image is needed." : "The demo device is simulated.") + case .physical: + break + } + let host = DeveloperImageHostInventory.discover(userFolders: userFolders, coreDeviceAvailable: target.coreDeviceIdentifier != nil, locations: locations) + guard target.usbmuxDeviceID != nil else { + return coreDeviceOnlyStatus(target: target, host: host) + } + do { + return try await DeviceSession.with(target, usbmux: usbmux) { session in + var gathered = try await gatherFacts(session) + do { + let mounter = try await ImageMounter.open(session) + defer { Task { await mounter.close() } } + if gathered.requiredKind == .personalized, gathered.chipID == nil || gathered.boardID == nil, + let identifiers = try? PersonalizationIdentifiers(await mounter.personalizationIdentifiers()) { + // Some devices do not report ChipID/BoardId through lockdown; the image mounter does. + gathered.chipID = identifiers.chipID + gathered.boardID = identifiers.boardID + } + let observation = try await observe(mounter, facts: gathered, host: host) + return DeveloperImageEvaluator.evaluate(facts: gathered, observation: observation, host: host) + } catch { + return .failure(error, facts: gathered) + } + } + } catch { + return .failure(error) + } + } + + func coreDeviceOnlyStatus(target: DeviceTarget, host: DeveloperImageHostInventory) -> DeveloperImageStatus { + let facts = DeveloperImageDeviceFacts(productVersion: target.osVersion) + guard host.coreDeviceAvailable else { + return DeveloperImageStatus(state: .blocked, headline: "Connect the device by USB.", explanation: "The developer image can only be checked over USB, or over the network through Xcode's device service.", remediation: "Connect the device with a USB cable, unlock it, and check again.", requiredKind: facts.requiredKind, facts: facts) + } + return DeveloperImageStatus(state: .personalizationRequired, headline: "Xcode's device service can prepare the developer image over the network.", explanation: "This device is reachable only through Xcode's device service, which checks and mounts the image itself.", requiredKind: facts.requiredKind, facts: facts, recommendedMechanism: .coreDevice) + } + + func gatherFacts(_ session: DeviceSession) async throws -> DeveloperImageDeviceFacts { + let values = try await session.getValue() + var facts = DeveloperImageDeviceFacts( + productVersion: values?["ProductVersion"]?.stringValue, + buildVersion: values?["BuildVersion"]?.stringValue, + productType: values?["ProductType"]?.stringValue, + architecture: values?["CPUArchitecture"]?.stringValue, + hardwareModel: values?["HardwareModel"]?.stringValue, + chipID: values?["ChipID"]?.intValue, + boardID: values?["BoardId"]?.intValue + ) + if let major = facts.majorVersion, major >= 16 { + facts.developerModeEnabled = try? await session.getValue(domain: "com.apple.security.mac.amfi", key: "DeveloperModeStatus")?.boolValue + } + return facts + } + + func observe(_ mounter: ImageMounter, facts: DeveloperImageDeviceFacts, host: DeveloperImageHostInventory) async throws -> DeveloperImageObservation { + let kind = facts.requiredKind + var observation = DeveloperImageObservation() + observation.mountedSignatures = try await mounter.lookup(kind) + observation.mountedImages = (try? await mounter.mountedImages()) ?? [] + if observation.mountedSignatures.isEmpty { + let other: DeveloperImageKind = kind == .personalized ? .legacy : .personalized + observation.otherKindMounted = observation.mountedImages.contains { $0.isDeveloperImage && $0.mountPath == other.mountPath } + } + if kind == .personalized, observation.mountedSignatures.isEmpty, + let match = host.personalizedMatch(chipID: facts.chipID, boardID: facts.boardID), + let files = try? match.source.files(for: match.identity), + let image = try? DeveloperImageLibrary.readImage(files.image) { + observation.manifestOnDevice = (try? await mounter.personalizationManifest(imageDigest: Data(SHA384.hash(data: image)))) != nil + } + return observation + } + + // MARK: Mount + + public struct Progress: Sendable { + public var step: String + public var fraction: Double? + } + + /// Mounts the developer image the device needs. If a compatible image is already mounted, + /// nothing is changed. Returns the state after the attempt. + public func mount(_ target: DeviceTarget, mechanism: DeveloperImageMechanism = .automatic, userFolders: [URL] = [], progress: @Sendable (Progress) -> Void = { _ in }) async throws -> DeveloperImageStatus { + guard target.kind == .physical else { + return await status(for: target, userFolders: userFolders) + } + progress(Progress(step: "Checking the device", fraction: nil)) + let before = await status(for: target, userFolders: userFolders) + if before.state == .mounted { + Self.logger.info("Developer image already mounted; not remounting") + return before + } + if before.state == .blocked || before.state == .incompatible || before.state == .missing { + throw ToolkitError(.developerDiskImageUnavailable, message: before.headline, recovery: before.remediation, technicalDetail: before.technicalDetail) + } + if before.state == .failed, target.usbmuxDeviceID != nil { + throw ToolkitError(.developerDiskImageUnavailable, message: before.headline, recovery: before.remediation, technicalDetail: before.technicalDetail) + } + + let chosen = resolve(mechanism, for: target, status: before) + switch chosen { + case .coreDevice: + progress(Progress(step: "Xcode's device service is preparing the image", fraction: nil)) + _ = try await coreDevice.ddiServices(target, autoMount: true) + case .native, .automatic: + try await mountNatively(target, userFolders: userFolders, progress: progress) + } + progress(Progress(step: "Confirming the mount", fraction: 1)) + let after = await status(for: target, userFolders: userFolders) + if chosen == .coreDevice && target.usbmuxDeviceID == nil { + return DeveloperImageStatus(state: .mounted, headline: "Xcode's device service prepared the developer image.", explanation: "Developer services can start.", requiredKind: after.requiredKind, facts: after.facts, recommendedMechanism: .coreDevice) + } + guard after.state == .mounted else { + throw ToolkitError(.developerDiskImageUnavailable, message: "The developer image did not mount.", recovery: after.remediation ?? "Keep the device unlocked, reconnect it, and try again.", technicalDetail: "After mount: \(after.state.rawValue) — \(after.headline)") + } + return after + } + + func resolve(_ mechanism: DeveloperImageMechanism, for target: DeviceTarget, status: DeveloperImageStatus) -> DeveloperImageMechanism { + switch mechanism { + case .coreDevice: + return .coreDevice + case .native: + return .native + case .automatic: + if target.usbmuxDeviceID == nil { return .coreDevice } + if status.requiredKind == .legacy { return .native } + return status.recommendedMechanism == .coreDevice ? .coreDevice : .native + } + } + + func mountNatively(_ target: DeviceTarget, userFolders: [URL], progress: @Sendable (Progress) -> Void) async throws { + guard target.usbmuxDeviceID != nil else { + throw ToolkitError(.unsupported, message: "The built-in mount needs a USB connection.", recovery: "Connect the device with a USB cable, or use Xcode's device service.") + } + let host = DeveloperImageHostInventory.discover(userFolders: userFolders, coreDeviceAvailable: false, locations: locations) + try await DeviceSession.with(target, usbmux: usbmux) { session in + let facts = try await gatherFacts(session) + let mounter = try await ImageMounter.open(session) + defer { Task { await mounter.close() } } + let kind = facts.requiredKind + let alreadyMounted = try await mounter.lookup(kind) + if !alreadyMounted.isEmpty { return } + switch kind { + case .legacy: + guard let source = DeveloperImageLibrary.legacyImage(forVersion: facts.productVersion, in: host.legacy) else { + throw ToolkitError(.developerDiskImageUnavailable, message: "No Developer Disk Image for iOS \(facts.productVersion ?? "?") is on this Mac.", recovery: "Choose a folder containing DeveloperDiskImage.dmg and its .signature for this version.") + } + let image = try DeveloperImageLibrary.readImage(source.image) + let signature = try DeveloperImageLibrary.readSmallFile(source.signature) + progress(Progress(step: "Uploading \(source.displayName)", fraction: 0)) + try await mounter.upload(.legacy, image: image, signature: signature) { progress(Progress(step: "Uploading the image", fraction: $0 * 0.9)) } + progress(Progress(step: "Mounting", fraction: 0.95)) + _ = try await mounter.mount(.legacy, signature: signature) + + case .personalized: + let identifiers = try PersonalizationIdentifiers(await mounter.personalizationIdentifiers()) + guard let match = host.personalizedMatch(chipID: identifiers.chipID, boardID: identifiers.boardID) else { + throw ToolkitError(.developerDiskImageUnavailable, message: "No developer image on this Mac supports this device.", recovery: "Update Xcode and open it once, or use Xcode's device service.", technicalDetail: "chip \(DeveloperImageEvaluator.hex(identifiers.chipID)) board \(DeveloperImageEvaluator.hex(identifiers.boardID))") + } + let files = try match.source.files(for: match.identity) + let image = try DeveloperImageLibrary.readImage(files.image) + let trustCache = try DeveloperImageLibrary.readSmallFile(files.trustCache) + progress(Progress(step: "Checking for an existing personalization", fraction: 0.05)) + let manifest: Data + if let existing = try await mounter.personalizationManifest(imageDigest: Data(SHA384.hash(data: image))) { + manifest = existing + } else { + progress(Progress(step: "Asking Apple to personalize the image", fraction: 0.1)) + let nonce = try await mounter.personalizationNonce() + manifest = try await ImagePersonalization.personalize(identity: match.identity, identifiers: identifiers, nonce: nonce, transport: transport) + } + progress(Progress(step: "Uploading the image", fraction: 0.2)) + try await mounter.upload(.personalized, image: image, signature: manifest) { progress(Progress(step: "Uploading the image", fraction: 0.2 + $0 * 0.7)) } + progress(Progress(step: "Mounting", fraction: 0.95)) + _ = try await mounter.mount(.personalized, signature: manifest, trustCache: trustCache) + } + Self.logger.info("Developer image mounted natively (\(kind.rawValue, privacy: .public))") + } + } + + // MARK: Unmount + + /// Unmounts the developer image. Returns the state afterwards. + public func unmount(_ target: DeviceTarget, userFolders: [URL] = []) async throws -> DeveloperImageStatus { + guard target.kind == .physical, target.usbmuxDeviceID != nil else { + throw ToolkitError(.unsupported, message: "Unmounting needs a USB connection.", recovery: "Connect the device with a USB cable, or restart the device (which also removes the image).") + } + try await DeviceSession.with(target, usbmux: usbmux) { session in + let mounter = try await ImageMounter.open(session) + defer { Task { await mounter.close() } } + try await mounter.unmountDeveloperImage() + } + return await status(for: target, userFolders: userFolders) + } +} + +extension DeveloperImageStatus { + /// Label/value rows shared by the app and `idt`. The ECID is never shown. + public var detailRows: [(String, String)] { + var rows: [(String, String)] = [("State", state.label)] + if let requiredKind { rows.append(("Image needed", requiredKind.label)) } + if let facts { + if let version = facts.productVersion { rows.append(("iOS", version + (facts.buildVersion.map { " (\($0))" } ?? ""))) } + if let productType = facts.productType { rows.append(("Model", productType + (facts.hardwareModel.map { " · \($0)" } ?? ""))) } + if let architecture = facts.architecture { rows.append(("Architecture", architecture)) } + if facts.chipID != nil || facts.boardID != nil { + rows.append(("Chip / board", "\(DeveloperImageEvaluator.hex(facts.chipID)) / \(DeveloperImageEvaluator.hex(facts.boardID))")) + } + if let developerMode = facts.developerModeEnabled { rows.append(("Developer Mode", developerMode ? "On" : "Off")) } + } + let developerImages = mountedImages.filter(\.isDeveloperImage) + if !developerImages.isEmpty { + rows.append(("Mounted at", developerImages.compactMap(\.mountPath).joined(separator: ", "))) + } + if let hostImage { rows.append(("Image on this Mac", hostImage)) } + if let recommendedMechanism, state.canMount { rows.append(("Will mount with", recommendedMechanism.label)) } + if let remediation { rows.append(("Next step", remediation)) } + return rows + } +} diff --git a/Sources/DeviceKit/DeveloperImage/ImageMounter.swift b/Sources/DeviceKit/DeveloperImage/ImageMounter.swift new file mode 100644 index 0000000..c57eb52 --- /dev/null +++ b/Sources/DeviceKit/DeveloperImage/ImageMounter.swift @@ -0,0 +1,264 @@ +import Foundation +import ToolkitCore + +/// The two kinds of developer image iOS accepts through the image mounter. +public enum DeveloperImageKind: String, Sendable, Hashable, Codable, CaseIterable { + /// iOS 17 and later: an Apple-personalized image (signed per device through Apple's TSS + /// server), mounted at `/System/Developer`. + case personalized = "Personalized" + /// iOS 16 and earlier: `DeveloperDiskImage.dmg` with its detached `.signature`, mounted at + /// `/Developer`. + case legacy = "Developer" + + public var mountPath: String { + switch self { + case .personalized: return "/System/Developer" + case .legacy: return "/Developer" + } + } + + public var label: String { + switch self { + case .personalized: return "Personalized developer image (iOS 17 and later)" + case .legacy: return "Developer Disk Image (iOS 16 and earlier)" + } + } + + /// The kind a device needs, from its iOS major version (unknown versions are treated as modern). + public static func required(forMajorVersion major: Int?) -> DeveloperImageKind { + guard let major else { return .personalized } + return major >= 17 ? .personalized : .legacy + } +} + +public struct MountedImage: Sendable, Hashable { + public var mountPath: String? + public var imageType: String? + public var isMounted: Bool? + public var raw: PlistValue + + /// Whether this entry is a developer image (personalized or legacy). + public var isDeveloperImage: Bool { + if let mountPath, mountPath == DeveloperImageKind.personalized.mountPath || mountPath == DeveloperImageKind.legacy.mountPath { + return true + } + let type = (imageType ?? "").lowercased() + return type.contains("developer") || type == "ddi" || type.hasSuffix(".ddi") + } +} + +/// `com.apple.mobile.mobile_image_mounter`, the lockdown service iOS uses for developer images. +/// +/// This is a private MobileDevice service (not documented by Apple). It is the same service Xcode +/// uses on iOS 16 and earlier and for personalized images on iOS 17 and later; the message +/// formats here follow the behaviour of the open-source implementations the 0.3.x app relied on. +/// Everything that talks to it lives in this type so the rest of the app never sees raw replies. +public struct ImageMounter: Sendable { + public static let serviceName = "com.apple.mobile.mobile_image_mounter" + public static let personalizedImageType = "DeveloperDiskImage" + /// Chunk size for uploads; keeps progress responsive without many tiny writes. + static let uploadChunkSize = 1 << 20 + + let connection: ServiceConnection + + public static func open(_ session: DeviceSession) async throws -> ImageMounter { + ImageMounter(connection: try await session.openService(serviceName)) + } + + // MARK: Queries + + public func mountedImages() async throws -> [MountedImage] { + let reply = try await command(["Command": "CopyDevices"], operation: "list mounted images") + return (reply["EntryList"]?.arrayValue ?? []).map { entry in + MountedImage( + mountPath: entry["MountPath"]?.stringValue, + imageType: entry["DiskImageType"]?.stringValue ?? entry["ImageType"]?.stringValue ?? entry["PersonalizedImageType"]?.stringValue, + isMounted: entry["IsMounted"]?.boolValue, + raw: entry + ) + } + } + + public func developerModeStatus() async throws -> Bool? { + try await command(["Command": "QueryDeveloperModeStatus"], operation: "read Developer Mode status")["DeveloperModeStatus"]?.boolValue + } + + /// The signatures of mounted images of `kind`; empty when none is mounted. + public func lookup(_ kind: DeveloperImageKind) async throws -> [Data] { + let reply = try await connection.messages.request(["Command": "LookupImage", "ImageType": .string(kind.rawValue)], timeout: 60) + if reply["Error"] != nil { + let failure = Self.classify(reply) + if failure == .notMounted { return [] } + throw Self.error(for: failure, reply: reply, operation: "check the mounted developer image") + } + if reply["ImagePresent"]?.boolValue == false { return [] } + switch reply["ImageSignature"] { + case .array(let values): return values.compactMap(\.dataValue) + case .data(let value): return [value] + default: return [] + } + } + + public func personalizationIdentifiers() async throws -> PlistValue { + try await command( + ["Command": "QueryPersonalizationIdentifiers", "PersonalizedImageType": .string(Self.personalizedImageType)], + operation: "read personalization identifiers" + )["PersonalizationIdentifiers"] ?? .dictionary([:]) + } + + public func personalizationNonce() async throws -> Data { + let reply = try await command( + ["Command": "QueryNonce", "PersonalizedImageType": .string(Self.personalizedImageType)], + operation: "read the personalization nonce" + ) + guard let nonce = reply["PersonalizationNonce"]?.dataValue, !nonce.isEmpty else { + throw ToolkitError(.protocolViolation, message: "The device did not provide a personalization nonce.", recovery: "Unlock the device, reconnect it, and try again.", technicalDetail: reply.prettyJSONString()) + } + return nonce + } + + /// A personalization manifest the device already holds for an image with this SHA-384 + /// digest, or nil when the image must be personalized by Apple first. + public func personalizationManifest(imageDigest: Data) async throws -> Data? { + let reply = try await connection.messages.request([ + "Command": "QueryPersonalizationManifest", + "PersonalizedImageType": .string(Self.personalizedImageType), + "ImageType": .string(Self.personalizedImageType), + "ImageSignature": .data(imageDigest), + ], timeout: 60) + if let manifest = reply["ImageSignature"]?.dataValue, !manifest.isEmpty { return manifest } + if reply["Error"] != nil { + let failure = Self.classify(reply) + if failure == .deviceLocked || failure == .developerModeDisabled { + throw Self.error(for: failure, reply: reply, operation: "check the personalization manifest") + } + } + return nil + } + + // MARK: Changes + + public enum MountOutcome: Sendable, Equatable { + case mounted + case alreadyMounted + } + + /// Sends the image bytes to the device (`ReceiveBytes`). + public func upload(_ kind: DeveloperImageKind, image: Data, signature: Data, progress: @Sendable (Double) -> Void = { _ in }) async throws { + let ack = try await connection.messages.request([ + "Command": "ReceiveBytes", + "ImageType": .string(kind.rawValue), + "ImageSize": .integer(Int64(image.count)), + "ImageSignature": .data(signature), + ], timeout: 60) + guard ack["Status"]?.stringValue == "ReceiveBytesAck" else { + throw Self.error(for: Self.classify(ack), reply: ack, operation: "start the image upload") + } + var offset = 0 + while offset < image.count { + try Task.checkCancellation() + let end = min(offset + Self.uploadChunkSize, image.count) + try await connection.channel.write(image.subdata(in: offset.. MountOutcome { + var request: [String: PlistValue] = [ + "Command": "MountImage", + "ImageType": .string(kind.rawValue), + "ImageSignature": .data(signature), + ] + if let trustCache { request["ImageTrustCache"] = .data(trustCache) } + let reply = try await connection.messages.request(.dictionary(request), timeout: 300) + if reply["Status"]?.stringValue == "Complete" { return .mounted } + let failure = Self.classify(reply) + if failure == .alreadyMounted { return .alreadyMounted } + throw Self.error(for: failure, reply: reply, operation: "mount the developer image") + } + + /// Unmounts the image of `kind`. Returns false when nothing was mounted there. + @discardableResult + public func unmount(_ kind: DeveloperImageKind) async throws -> Bool { + let reply = try await connection.messages.request(["Command": "UnmountImage", "MountPath": .string(kind.mountPath)], timeout: 60) + guard reply["Error"] != nil else { return true } + let failure = Self.classify(reply) + if failure == .notMounted { return false } + throw Self.error(for: failure, reply: reply, operation: "unmount the developer image") + } + + /// Unmounts whichever developer image is mounted (personalized first, then legacy). + public func unmountDeveloperImage() async throws { + if try await unmount(.personalized) { return } + try await unmount(.legacy) + } + + func command(_ request: PlistValue, operation: String) async throws -> PlistValue { + let reply = try await connection.messages.request(request, timeout: 60) + if reply["Error"] != nil { + throw Self.error(for: Self.classify(reply), reply: reply, operation: operation) + } + return reply + } + + public func close() async { + _ = try? await connection.messages.request(["Command": "Hangup"], timeout: 5) + await connection.close() + } + + // MARK: Errors + + /// What an image-mounter error reply means, independent of its exact wording. + public enum Failure: String, Sendable, Equatable { + case deviceLocked + case developerModeDisabled + case alreadyMounted + case notMounted + case signatureRejected + case unsupported + case other + } + + public static func classify(_ reply: PlistValue) -> Failure { + let error = reply["Error"]?.stringValue ?? "" + let detail = (reply["DetailedError"]?.stringValue ?? "").lowercased() + let combined = (error + " " + detail).lowercased() + if combined.contains("devicelocked") || combined.contains("device is locked") || combined.contains("passcode") { return .deviceLocked } + if combined.contains("developer mode is not enabled") || combined.contains("developermode") { return .developerModeDisabled } + if combined.contains("already mounted") || combined.contains("imagealreadymounted") { return .alreadyMounted } + if combined.contains("no matching entry") || combined.contains("not mounted") || combined.contains("notmounted") || combined.contains("imagenotpresent") { return .notMounted } + if error == "UnknownCommand" || error == "UnsupportedCommand" || combined.contains("unsupported") { return .unsupported } + if combined.contains("signature") || combined.contains("personaliz") || combined.contains("manifest") || combined.contains("img4") || combined.contains("trust cache") { return .signatureRejected } + return .other + } + + /// A plain-language error for an image-mounter failure. The raw reply goes only into the + /// technical detail. + public static func error(for failure: Failure, reply: PlistValue, operation: String) -> ToolkitError { + let detail = [reply["Error"]?.stringValue, reply["DetailedError"]?.stringValue, reply["Status"]?.stringValue] + .compactMap { $0 }.joined(separator: " — ") + let technical = "mobile_image_mounter: \(operation): \(detail.isEmpty ? reply.prettyJSONString() : detail)" + switch failure { + case .deviceLocked: + return ToolkitError(.deviceLocked, message: "The device is locked.", recovery: "Unlock the device and keep it unlocked until the developer image is mounted.", technicalDetail: technical) + case .developerModeDisabled: + return ToolkitError(.developerModeDisabled, message: "Developer Mode is off on the device.", recovery: "Turn it on in Settings › Privacy & Security › Developer Mode, restart, confirm, then try again.", technicalDetail: technical) + case .alreadyMounted: + return ToolkitError(.commandFailed, message: "A developer image is already mounted.", recovery: "Nothing to do; developer services can use it.", technicalDetail: technical) + case .notMounted: + return ToolkitError(.developerDiskImageUnavailable, message: "No developer image is mounted.", recovery: "Mount the developer image from the Device page.", technicalDetail: technical) + case .signatureRejected: + return ToolkitError(.developerDiskImageUnavailable, message: "The device did not accept the developer image's signature.", recovery: "The image may not match this iOS version, or its personalization expired. Update Xcode (it installs current images), keep this Mac online so Apple can personalize the image, and try again.", technicalDetail: technical) + case .unsupported: + return ToolkitError(.unsupported, message: "This iOS version does not support that developer-image operation.", recovery: "Check the image type: iOS 17 and later use personalized images; iOS 16 and earlier use DeveloperDiskImage.dmg.", technicalDetail: technical) + case .other: + return ToolkitError(.developerDiskImageUnavailable, message: "The device could not \(operation).", recovery: "Keep the device unlocked and connected by USB, then try again. Restarting the device clears a stuck image mount.", technicalDetail: technical) + } + } +} diff --git a/Sources/DeviceKit/DeveloperImage/ImagePersonalization.swift b/Sources/DeviceKit/DeveloperImage/ImagePersonalization.swift new file mode 100644 index 0000000..373936e --- /dev/null +++ b/Sources/DeviceKit/DeveloperImage/ImagePersonalization.swift @@ -0,0 +1,184 @@ +import Foundation +import OSLog +import ToolkitCore + +/// Sends a personalization (TSS) request and returns the raw response body. +public protocol PersonalizationTransport: Sendable { + func send(_ body: Data) async throws -> Data +} + +/// Apple's signing server. Xcode contacts the same server to personalize developer images; the +/// request carries the device's chip, board, ECID, and a one-time nonce. +public struct AppleTSSTransport: PersonalizationTransport { + public static let url = URL(string: "https://gs.apple.com/TSS/controller?action=2")! + + public init() {} + + public func send(_ body: Data) async throws -> Data { + var request = URLRequest(url: Self.url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 60) + request.httpMethod = "POST" + request.httpBody = body + request.setValue("text/xml; charset=\"utf-8\"", forHTTPHeaderField: "Content-Type") + request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") + request.setValue("InetURL/1.0", forHTTPHeaderField: "User-Agent") + let session = URLSession(configuration: .ephemeral) + defer { session.finishTasksAndInvalidate() } + do { + let (data, response) = try await session.data(for: request) + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw ToolkitError(.serviceUnavailable, message: "Apple's signing server returned an error (HTTP \(http.statusCode)).", recovery: "Try again in a few minutes.", technicalDetail: String(decoding: data.prefix(500), as: UTF8.self)) + } + return data + } catch let error as ToolkitError { + throw error + } catch { + throw ToolkitError(.serviceUnavailable, message: "This Mac could not reach Apple's signing server.", recovery: "Personalizing a developer image needs an internet connection to gs.apple.com. Check the network (and any proxy or firewall), then try again.", technicalDetail: error.localizedDescription) + } + } +} + +/// The identifiers the image mounter reports for personalization. +public struct PersonalizationIdentifiers: Sendable, Hashable { + public var chipID: Int + public var boardID: Int + public var ecid: UInt64 + /// `Ap,*` values the device asks to be included in the request. + public var additional: [String: PlistValue] + + public init(chipID: Int, boardID: Int, ecid: UInt64, additional: [String: PlistValue] = [:]) { + self.chipID = chipID + self.boardID = boardID + self.ecid = ecid + self.additional = additional + } + + public init(_ plist: PlistValue) throws { + guard let chip = plist["ChipID"]?.intValue, let board = plist["BoardId"]?.intValue ?? plist["BoardID"]?.intValue, + let ecid = plist["UniqueChipID"]?.uint64Value ?? plist["ECID"]?.uint64Value else { + throw ToolkitError(.protocolViolation, message: "The device did not report the identifiers needed to personalize the developer image.", recovery: "Unlock the device, reconnect it, and try again.", technicalDetail: "Keys: \((plist.dictionaryValue ?? [:]).keys.sorted().joined(separator: ", "))") + } + chipID = chip + boardID = board + self.ecid = ecid + additional = (plist.dictionaryValue ?? [:]).filter { $0.key.hasPrefix("Ap,") } + } +} + +/// Builds and interprets the personalization request for a developer image. +/// +/// This reproduces the request `pymobiledevice3` (and Xcode) send for the “Customer iOS Developer +/// PDI” build identities: device identifiers and nonce, production-mode flags, and every trusted +/// component of the identity's manifest with its restore-request rules applied. Apple's reply +/// carries the `ApImg4Ticket` the device needs to mount the image. +public enum ImagePersonalization { + static let logger = ToolkitLog.logger(.networking) + static let clientVersion = "libauthinstall-1033.0.2" + + /// Parameters the restore-request rules are evaluated against (a production device). + static let ruleParameters: [String: Bool] = [ + "ApProductionMode": true, + "ApSecurityMode": true, + "ApSupportsImg4": true, + ] + + public static func request(identity: DeveloperImageBuildIdentity, identifiers: PersonalizationIdentifiers, nonce: Data, requestID: UUID = UUID()) -> PlistValue { + var request: [String: PlistValue] = [ + "@HostPlatformInfo": "mac", + "@VersionInfo": .string(clientVersion), + "@UUID": .string(requestID.uuidString.uppercased()), + "@ApImg4Ticket": true, + "@BBTicket": true, + "ApBoardID": .integer(Int64(identifiers.boardID)), + "ApChipID": .integer(Int64(identifiers.chipID)), + "ApECID": .integer(Int64(bitPattern: identifiers.ecid)), + "ApNonce": .data(nonce), + "ApProductionMode": true, + "ApSecurityDomain": 1, + "ApSecurityMode": true, + "SepNonce": .data(Data(count: 20)), + "UID_MODE": false, + ] + for (key, value) in identifiers.additional { request[key] = value } + let fallbackRules = identity.manifest["LoadableTrustCache"]?["Info"]?["RestoreRequestRules"]?.arrayValue ?? [] + for (key, item) in identity.manifest { + guard let entry = item.dictionaryValue, let info = entry["Info"], entry["Trusted"]?.boolValue == true else { continue } + var tssEntry = entry + tssEntry.removeValue(forKey: "Info") + let rules = info["RestoreRequestRules"]?.arrayValue ?? fallbackRules + tssEntry = applyRules(rules, to: tssEntry) + if tssEntry["Digest"] == nil { tssEntry["Digest"] = .data(Data()) } + request[key] = .dictionary(tssEntry) + } + return .dictionary(request) + } + + /// Applies `RestoreRequestRules`: when every condition matches the production parameters, the + /// rule's actions are written into the entry (255 means “leave unchanged”). + static func applyRules(_ rules: [PlistValue], to entry: [String: PlistValue]) -> [String: PlistValue] { + var entry = entry + for rule in rules { + let conditions = rule["Conditions"]?.dictionaryValue ?? [:] + let fulfilled = !conditions.isEmpty && conditions.allSatisfy { key, value in + let parameter: Bool? + switch key { + case "ApRawProductionMode", "ApCurrentProductionMode": parameter = ruleParameters["ApProductionMode"] + case "ApRawSecurityMode": parameter = ruleParameters["ApSecurityMode"] + case "ApRequiresImage4": parameter = ruleParameters["ApSupportsImg4"] + default: parameter = nil + } + guard let parameter, parameter else { return false } + return value.boolValue == parameter + } + guard fulfilled else { continue } + for (key, value) in rule["Actions"]?.dictionaryValue ?? [:] where value.intValue != 255 { + entry[key] = value + } + } + return entry + } + + /// Extracts the ticket from Apple's reply (`STATUS=0&MESSAGE=SUCCESS&REQUEST_STRING=`). + public static func ticket(fromResponse body: Data) throws -> Data { + let text = String(decoding: body, as: UTF8.self) + let status = field("STATUS", in: text).flatMap { Int($0) } + let message = field("MESSAGE", in: text) ?? "" + guard status == 0, message.uppercased() == "SUCCESS", let range = text.range(of: "REQUEST_STRING=") else { + throw rejection(status: status, message: message, body: text) + } + let plist = try PlistValue.decode(Data(text[range.upperBound...].utf8)) + guard let ticket = plist["ApImg4Ticket"]?.dataValue, !ticket.isEmpty else { + throw ToolkitError(.protocolViolation, message: "Apple's signing server did not return a ticket for the developer image.", technicalDetail: String(text.prefix(500))) + } + return ticket + } + + static func field(_ name: String, in text: String) -> String? { + for pair in text.split(separator: "&", maxSplits: 3) { + let parts = pair.split(separator: "=", maxSplits: 1) + if parts.count == 2, parts[0] == Substring(name) { return String(parts[1]) } + } + return nil + } + + static func rejection(status: Int?, message: String, body: String) -> ToolkitError { + let technical = "TSS STATUS=\(status.map(String.init) ?? "?") MESSAGE=\(message)" + switch status { + case 94: + return ToolkitError(.developerDiskImageUnavailable, message: "Apple would not sign this developer image for the device.", recovery: "The image is probably older or newer than this iOS version supports. Update Xcode (it installs matching images) or update iOS, then try again.", technicalDetail: technical) + case 69, 128: + return ToolkitError(.developerDiskImageUnavailable, message: "Apple's signing server rejected the request.", recovery: "Reconnect the device (this refreshes its one-time nonce) and try again.", technicalDetail: technical) + default: + return ToolkitError(.serviceUnavailable, message: "Apple's signing server did not personalize the developer image.", recovery: "Try again in a few minutes. If it keeps failing, use the Xcode device service instead.", technicalDetail: technical + " " + String(body.prefix(300))) + } + } + + /// Requests a personalization ticket for `identity` from Apple. + public static func personalize(identity: DeveloperImageBuildIdentity, identifiers: PersonalizationIdentifiers, nonce: Data, transport: PersonalizationTransport) async throws -> Data { + let body = try request(identity: identity, identifiers: identifiers, nonce: nonce).encoded(format: .xml) + logger.info("Requesting developer image personalization from Apple") + let response = try await transport.send(body) + let ticket = try ticket(fromResponse: response) + logger.info("Developer image personalization received (\(ticket.count, privacy: .public) bytes)") + return ticket + } +} diff --git a/Sources/DeviceKit/Discovery/DeviceDiscovery.swift b/Sources/DeviceKit/Discovery/DeviceDiscovery.swift new file mode 100644 index 0000000..afed333 --- /dev/null +++ b/Sources/DeviceKit/Discovery/DeviceDiscovery.swift @@ -0,0 +1,429 @@ +import Foundation +import OSLog +import ToolkitCore + +/// What lockdown told us about a USB/Wi-Fi device (used when CoreDevice is unavailable, and to +/// fill in pairing and Developer Mode state). +public struct LockdownEnrichment: Sendable, Hashable { + public var name: String? + public var productType: String? + public var productVersion: String? + public var buildVersion: String? + public var architecture: String? + public var hardwareModel: String? + public var serialNumber: String? + public var pairingState: PairingState + public var developerMode: DeveloperModeState + public var problem: String? + + public init(name: String? = nil, productType: String? = nil, productVersion: String? = nil, buildVersion: String? = nil, architecture: String? = nil, hardwareModel: String? = nil, serialNumber: String? = nil, pairingState: PairingState = .unknown, developerMode: DeveloperModeState = .unknown, problem: String? = nil) { + self.name = name + self.productType = productType + self.productVersion = productVersion + self.buildVersion = buildVersion + self.architecture = architecture + self.hardwareModel = hardwareModel + self.serialNumber = serialNumber + self.pairingState = pairingState + self.developerMode = developerMode + self.problem = problem + } +} + +/// The health of one discovery source, shown in the connection diagnostics panel. +public enum SourceStatus: Sendable, Hashable { + case notChecked + case available(count: Int) + case unavailable(reason: String) + + public var summary: String { + switch self { + case .notChecked: return "Not checked yet" + case .available(let count): return count == 1 ? "1 device" : "\(count) devices" + case .unavailable(let reason): return reason + } + } + + public var isAvailable: Bool { + if case .available = self { return true } + return false + } +} + +public struct DiscoverySnapshot: Sendable, Hashable { + public var devices: [Device] + public var usbmux: SourceStatus + public var coreDevice: SourceStatus + public var simulators: SourceStatus + public var updatedAt: Date + + public init(devices: [Device] = [], usbmux: SourceStatus = .notChecked, coreDevice: SourceStatus = .notChecked, simulators: SourceStatus = .notChecked, updatedAt: Date = Date()) { + self.devices = devices + self.usbmux = usbmux + self.coreDevice = coreDevice + self.simulators = simulators + self.updatedAt = updatedAt + } + + public var physicalDevices: [Device] { devices.filter { $0.kind == .physical } } + public var simulatorDevices: [Device] { devices.filter { $0.kind == .simulator } } +} + +/// Pure merge of all discovery sources into display records. +public enum DeviceMerger { + public static func merge( + usbmux: [USBMuxDevice], + enrichment: [String: LockdownEnrichment], + coreDevice: [CoreDeviceRecord], + simulators: [SimulatorRecord], + now: Date = Date() + ) -> [Device] { + var physical: [String: Device] = [:] + + // CoreDevice records that are currently reachable (wired or network transport). + for record in coreDevice where record.isPhysical && (record.transport != nil || usbmux.contains { $0.udid == record.udid }) { + var device = record.device(lastSeen: now) + device.udid = record.udid ?? record.identifier + physical[key(device.udid)] = device + } + + for mux in usbmux { + let identifier = key(mux.udid) + var device = physical[identifier] ?? Device(kind: .physical, udid: mux.udid, name: "iOS device", lastSeen: now) + if device.usbmuxDeviceID == nil || mux.transport == .usb { + device.usbmuxDeviceID = mux.deviceID + } + device.transports.insert(mux.transport) + device.sources.insert(.usbmux) + if let info = enrichment[identifier] { + if !device.sources.contains(.coreDevice) { + device.name = info.name ?? device.name + device.productType = info.productType ?? device.productType + device.marketingName = device.marketingName ?? info.productType.flatMap(ProductCatalog.marketingName(for:)) + device.family = DeviceFamily.from(productType: device.productType) + device.osVersion = info.productVersion ?? device.osVersion + device.buildVersion = info.buildVersion ?? device.buildVersion + device.architecture = info.architecture ?? device.architecture + device.hardwareModel = info.hardwareModel ?? device.hardwareModel + device.serialNumber = info.serialNumber ?? device.serialNumber + device.osName = device.family == .iPad ? "iPadOS" : "iOS" + } + if device.pairingState == .unknown || info.pairingState == .unpaired { + device.pairingState = info.pairingState + } + if device.developerMode == .unknown { + device.developerMode = info.developerMode + } + } + physical[identifier] = device + } + + let simulatorDevices = simulators.filter(\.isAvailable).map(\.device) + let sortedPhysical = physical.values.sorted { + if $0.transports.contains(.usb) != $1.transports.contains(.usb) { return $0.transports.contains(.usb) } + return $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + return sortedPhysical + simulatorDevices + } + + static func key(_ udid: String) -> String { + USBMuxDevice.normalizedUDID(udid).uppercased() + } +} + +/// Coordinates device discovery without busy polling: +/// - usbmuxd `Listen` pushes USB and Wi-Fi attach/detach events; +/// - CoreDevice (`devicectl`) is refreshed after those events and every 30 s for network-only +/// devices usbmuxd cannot see; +/// - simulators are refreshed when FSEvents reports a change under CoreSimulator/Devices. +public actor DeviceDiscovery { + public struct Configuration: Sendable { + public var usbmux: USBMuxClient + public var coreDevice: CoreDeviceClient? + public var simulators: SimulatorClient? + public var enrichWithLockdown: Bool + public var coreDeviceInterval: TimeInterval + public var fallbackSimulatorInterval: TimeInterval + + public init(usbmux: USBMuxClient = USBMuxClient(), coreDevice: CoreDeviceClient? = CoreDeviceClient(), simulators: SimulatorClient? = SimulatorClient(), enrichWithLockdown: Bool = true, coreDeviceInterval: TimeInterval = 30, fallbackSimulatorInterval: TimeInterval = 120) { + self.usbmux = usbmux + self.coreDevice = coreDevice + self.simulators = simulators + self.enrichWithLockdown = enrichWithLockdown + self.coreDeviceInterval = coreDeviceInterval + self.fallbackSimulatorInterval = fallbackSimulatorInterval + } + } + + private let configuration: Configuration + private let logger = ToolkitLog.deviceDiscovery + private var usbmuxDevices: [Int: USBMuxDevice] = [:] + private var enrichment: [String: LockdownEnrichment] = [:] + private var enrichmentTasks: [String: Task] = [:] + private var coreDeviceRecords: [CoreDeviceRecord] = [] + private var simulatorRecords: [SimulatorRecord] = [] + private var snapshot = DiscoverySnapshot() + private var observers: [UUID: AsyncStream.Continuation] = [:] + private var tasks: [Task] = [] + private var watcher: DirectoryWatcher? + private var coreDeviceRefreshTask: Task? + private var simulatorRefreshTask: Task? + private var started = false + + public init(configuration: Configuration = Configuration()) { + self.configuration = configuration + } + + public var current: DiscoverySnapshot { snapshot } + + public func updates() -> AsyncStream { + let identifier = UUID() + let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + continuation.yield(snapshot) + observers[identifier] = continuation + continuation.onTermination = { [weak self] _ in + Task { await self?.removeObserver(identifier) } + } + return stream + } + + private func removeObserver(_ identifier: UUID) { + observers[identifier] = nil + } + + public func start() { + guard !started else { return } + started = true + tasks.append(Task { [weak self] in await self?.runUSBMuxListener() }) + if configuration.coreDevice != nil { + tasks.append(Task { [weak self] in + while !Task.isCancelled { + await self?.refreshCoreDevice() + try? await Task.sleep(nanoseconds: UInt64((self?.configuration.coreDeviceInterval ?? 30) * 1_000_000_000)) + } + }) + } + if configuration.simulators != nil { + let path = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Developer/CoreSimulator/Devices").path + let watcher = DirectoryWatcher(path: path, latency: 1.0) { [weak self] in + Task { await self?.scheduleSimulatorRefresh() } + } + let watching = watcher.start() + self.watcher = watcher + tasks.append(Task { [weak self] in + while !Task.isCancelled { + await self?.refreshSimulators() + let interval = watching ? (self?.configuration.fallbackSimulatorInterval ?? 120) : 15 + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + }) + } + logger.info("Device discovery started") + } + + public func stop() { + tasks.forEach { $0.cancel() } + tasks.removeAll() + enrichmentTasks.values.forEach { $0.cancel() } + enrichmentTasks.removeAll() + coreDeviceRefreshTask?.cancel() + simulatorRefreshTask?.cancel() + watcher?.stop() + watcher = nil + started = false + for observer in observers.values { observer.finish() } + observers.removeAll() + } + + /// Explicit refresh ("Refresh" button, ⌘R). + public func refreshAll() async { + await refreshUSBMuxList() + async let core: Void = refreshCoreDevice() + async let sims: Void = refreshSimulators() + _ = await (core, sims) + for device in usbmuxDevices.values { scheduleEnrichment(device, force: true) } + } + + // MARK: usbmuxd + + private func runUSBMuxListener() async { + var backoff: UInt64 = 1 + while !Task.isCancelled { + guard configuration.usbmux.isSocketPresent else { + snapshot.usbmux = .unavailable(reason: "The macOS device service (usbmuxd) is not running.") + publish() + try? await Task.sleep(nanoseconds: 10_000_000_000) + continue + } + await refreshUSBMuxList() + do { + for try await event in configuration.usbmux.listen() { + backoff = 1 + await handle(event) + } + } catch { + logger.error("usbmuxd listener ended: \((error as? ToolkitError)?.kind.rawValue ?? "error", privacy: .public) \(String(describing: error), privacy: .private)") + } + try? await Task.sleep(nanoseconds: backoff * 1_000_000_000) + backoff = min(backoff * 2, 30) + } + } + + private func refreshUSBMuxList() async { + do { + let devices = try await configuration.usbmux.listDevices() + usbmuxDevices = Dictionary(devices.map { ($0.deviceID, $0) }, uniquingKeysWith: { $1 }) + snapshot.usbmux = .available(count: Set(devices.map(\.udid)).count) + for device in devices { scheduleEnrichment(device, force: false) } + } catch { + usbmuxDevices = [:] + snapshot.usbmux = .unavailable(reason: (error as? ToolkitError)?.message ?? error.localizedDescription) + } + publish() + } + + private func handle(_ event: USBMuxEvent) async { + switch event { + case .attached(let device): + logger.info("Device attached via \(device.connectionType, privacy: .public)") + usbmuxDevices[device.deviceID] = device + scheduleEnrichment(device, force: false) + scheduleCoreDeviceRefresh() + case .detached(let deviceID): + logger.info("Device detached") + if let device = usbmuxDevices.removeValue(forKey: deviceID), + !usbmuxDevices.values.contains(where: { $0.udid == device.udid }) { + enrichmentTasks[DeviceMerger.key(device.udid)]?.cancel() + enrichmentTasks[DeviceMerger.key(device.udid)] = nil + enrichment[DeviceMerger.key(device.udid)] = nil + } + scheduleCoreDeviceRefresh() + case .paired(let deviceID): + if let device = usbmuxDevices[deviceID] { scheduleEnrichment(device, force: true) } + scheduleCoreDeviceRefresh() + } + snapshot.usbmux = .available(count: Set(usbmuxDevices.values.map(\.udid)).count) + publish() + } + + // MARK: Lockdown enrichment + + private func scheduleEnrichment(_ device: USBMuxDevice, force: Bool) { + guard configuration.enrichWithLockdown else { return } + let identifier = DeviceMerger.key(device.udid) + if !force, enrichment[identifier] != nil || enrichmentTasks[identifier] != nil { return } + enrichmentTasks[identifier]?.cancel() + let usbmux = configuration.usbmux + enrichmentTasks[identifier] = Task { [weak self] in + let result = await Self.enrich(device, usbmux: usbmux) + await self?.store(result, for: identifier) + } + } + + private func store(_ result: LockdownEnrichment, for identifier: String) { + enrichmentTasks[identifier] = nil + guard usbmuxDevices.values.contains(where: { DeviceMerger.key($0.udid) == identifier }) else { return } + enrichment[identifier] = result + publish() + } + + static func enrich(_ device: USBMuxDevice, usbmux: USBMuxClient) async -> LockdownEnrichment { + let target = DeviceTarget(kind: .physical, udid: device.udid, name: "iOS device", osVersion: nil, usbmuxDeviceID: device.deviceID, coreDeviceIdentifier: nil, transport: device.transport) + do { + return try await DeviceSession.with(target, usbmux: usbmux) { session in + let values = try await session.getValue() + let developerMode = try? await session.developerModeEnabled() + return LockdownEnrichment( + name: values?["DeviceName"]?.stringValue, + productType: values?["ProductType"]?.stringValue, + productVersion: values?["ProductVersion"]?.stringValue, + buildVersion: values?["BuildVersion"]?.stringValue, + architecture: values?["CPUArchitecture"]?.stringValue, + hardwareModel: values?["HardwareModel"]?.stringValue, + serialNumber: values?["SerialNumber"]?.stringValue, + pairingState: .paired, + developerMode: developerMode.map { $0 ? .enabled : .disabled } ?? .unknown + ) + } + } catch let error as ToolkitError { + let basic = try? await DeviceSession.basicInfo(for: device, usbmux: usbmux) + let pairing: PairingState + switch error.kind { + case .notPaired: pairing = .unpaired + case .pairingPending: pairing = .pairingInProgress + case .deviceLocked: pairing = .paired + default: pairing = .unknown + } + return LockdownEnrichment( + name: basic?.deviceName, + productType: basic?.productType, + productVersion: basic?.productVersion, + buildVersion: basic?.buildVersion, + pairingState: pairing, + problem: error.message + ) + } catch { + return LockdownEnrichment(problem: error.localizedDescription) + } + } + + // MARK: CoreDevice + + private func scheduleCoreDeviceRefresh() { + guard configuration.coreDevice != nil else { return } + coreDeviceRefreshTask?.cancel() + coreDeviceRefreshTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 1_500_000_000) + guard !Task.isCancelled else { return } + await self?.refreshCoreDevice() + } + } + + private func refreshCoreDevice() async { + guard let client = configuration.coreDevice else { + snapshot.coreDevice = .unavailable(reason: "Not configured") + return + } + do { + coreDeviceRecords = try await client.listDevices() + snapshot.coreDevice = .available(count: coreDeviceRecords.filter { $0.transport != nil }.count) + } catch { + coreDeviceRecords = [] + let reason = (error as? ToolkitError).map { $0.kind == .toolMissing ? "Xcode is not installed, so developer services are unavailable." : $0.message } ?? error.localizedDescription + snapshot.coreDevice = .unavailable(reason: reason) + } + publish() + } + + // MARK: Simulators + + private func scheduleSimulatorRefresh() { + simulatorRefreshTask?.cancel() + simulatorRefreshTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + await self?.refreshSimulators() + } + } + + private func refreshSimulators() async { + guard let client = configuration.simulators else { return } + do { + simulatorRecords = try await client.list() + snapshot.simulators = .available(count: simulatorRecords.filter(\.isAvailable).count) + } catch { + simulatorRecords = [] + let reason = (error as? ToolkitError).map { $0.kind == .toolMissing ? "Xcode is not installed, so simulators are unavailable." : $0.message } ?? error.localizedDescription + snapshot.simulators = .unavailable(reason: reason) + } + publish() + } + + // MARK: Publishing + + private func publish() { + snapshot.devices = DeviceMerger.merge(usbmux: Array(usbmuxDevices.values), enrichment: enrichment, coreDevice: coreDeviceRecords, simulators: simulatorRecords) + snapshot.updatedAt = Date() + for observer in observers.values { observer.yield(snapshot) } + } +} diff --git a/Sources/DeviceKit/Discovery/DirectoryWatcher.swift b/Sources/DeviceKit/Discovery/DirectoryWatcher.swift new file mode 100644 index 0000000..3174ea8 --- /dev/null +++ b/Sources/DeviceKit/Discovery/DirectoryWatcher.swift @@ -0,0 +1,64 @@ +import CoreServices +import Foundation + +/// Watches a directory tree with FSEvents and calls `onChange` (coalesced by `latency`). +/// Used to notice simulators being created, booted, or shut down without polling simctl. +public final class DirectoryWatcher: @unchecked Sendable { + private let path: String + private let latency: TimeInterval + private let onChange: @Sendable () -> Void + private var stream: FSEventStreamRef? + private let queue = DispatchQueue(label: "io.hideouts.iOSDeveloperToolkit.fsevents") + + public init(path: String, latency: TimeInterval = 1.0, onChange: @escaping @Sendable () -> Void) { + self.path = path + self.latency = latency + self.onChange = onChange + } + + deinit { + stop() + } + + /// Returns false when the directory does not exist (for example, Xcode is not installed). + @discardableResult + public func start() -> Bool { + guard stream == nil, FileManager.default.fileExists(atPath: path) else { return stream != nil } + var context = FSEventStreamContext( + version: 0, + info: Unmanaged.passUnretained(self).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + let callback: FSEventStreamCallback = { _, info, _, _, _, _ in + guard let info else { return } + Unmanaged.fromOpaque(info).takeUnretainedValue().onChange() + } + guard let created = FSEventStreamCreate( + kCFAllocatorDefault, + callback, + &context, + [path] as CFArray, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + latency, + FSEventStreamCreateFlags(kFSEventStreamCreateFlagNoDefer | kFSEventStreamCreateFlagFileEvents) + ) else { return false } + FSEventStreamSetDispatchQueue(created, queue) + guard FSEventStreamStart(created) else { + FSEventStreamInvalidate(created) + FSEventStreamRelease(created) + return false + } + stream = created + return true + } + + public func stop() { + guard let stream else { return } + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + self.stream = nil + } +} diff --git a/Sources/DeviceKit/Discovery/NetworkServiceBrowser.swift b/Sources/DeviceKit/Discovery/NetworkServiceBrowser.swift new file mode 100644 index 0000000..9b84e04 --- /dev/null +++ b/Sources/DeviceKit/Discovery/NetworkServiceBrowser.swift @@ -0,0 +1,55 @@ +import Foundation +import Network +import ToolkitCore + +/// A Bonjour service advertised by an Apple device on the local network. +public struct AdvertisedDeviceService: Sendable, Hashable, Identifiable { + public var id: String { "\(type)|\(name)|\(domain)" } + public var name: String + public var type: String + public var domain: String + public var interface: String? + + public var meaning: String { + switch type { + case "_apple-mobdev2._tcp": return "Wi-Fi sync / network pairing (usbmuxd over the network)" + case "_remotepairing._tcp": return "Remote pairing for CoreDevice (iOS 17+ developer services)" + case "_remoted._tcp": return "Remote Service Discovery (RemoteXPC)" + default: return type + } + } +} + +/// Browses for device services with Network.framework for a bounded period. Discovery shows +/// what is advertised; it does not prove pairing or service authorization. +public enum NetworkServiceBrowser { + public static let defaultTypes = ["_apple-mobdev2._tcp", "_remotepairing._tcp", "_remoted._tcp"] + + public static func browse(types: [String] = defaultTypes, duration: TimeInterval = 5) async -> [AdvertisedDeviceService] { + let found = LockedValue>([]) + let queue = DispatchQueue(label: "io.hideouts.iOSDeveloperToolkit.bonjour") + let browsers = types.map { type -> NWBrowser in + let parameters = NWParameters() + parameters.includePeerToPeer = false + let browser = NWBrowser(for: .bonjour(type: type, domain: "local."), using: parameters) + browser.browseResultsChangedHandler = { results, _ in + for result in results { + if case .service(let name, let serviceType, let domain, let interface) = result.endpoint { + _ = found.withLock { $0.insert(AdvertisedDeviceService(name: name, type: serviceType, domain: domain, interface: interface?.name)) } + } + } + } + browser.stateUpdateHandler = { state in + if case .failed(let error) = state { + ToolkitLog.networking.error("Bonjour browse failed: \(error.localizedDescription, privacy: .public)") + } + } + browser.start(queue: queue) + return browser + } + try? await Task.sleep(nanoseconds: UInt64(max(0.5, duration) * 1_000_000_000)) + browsers.forEach { $0.cancel() } + ToolkitLog.networking.info("Bonjour browse found \(found.current.count, privacy: .public) services") + return found.current.sorted { ($0.type, $0.name) < ($1.type, $1.name) } + } +} diff --git a/Sources/DeviceKit/Lockdown/DeviceSession.swift b/Sources/DeviceKit/Lockdown/DeviceSession.swift new file mode 100644 index 0000000..58c5741 --- /dev/null +++ b/Sources/DeviceKit/Lockdown/DeviceSession.swift @@ -0,0 +1,157 @@ +import Foundation +import OSLog +import ToolkitCore + +/// A connection to one lockdown service on the device. +public struct ServiceConnection: Sendable { + public let name: String + public let channel: DeviceChannel + + public var messages: PlistMessageConnection { PlistMessageConnection(channel: channel) } + + public func close() async { + await channel.close() + } +} + +/// Basic identity readable before trust (no session), used to label untrusted devices. +public struct LockdownBasicInfo: Sendable, Hashable { + public var deviceName: String? + public var productType: String? + public var productVersion: String? + public var buildVersion: String? + public var deviceClass: String? +} + +/// An authenticated lockdown session bound to exactly one device. +/// +/// `open(target:)` resolves the device by UDID at the moment the operation starts, establishes +/// a TLS session using this Mac's pairing record, and then asks the device for its UDID. If the +/// device that answered is not the intended one, the session is closed and the operation fails, +/// so an action can never reach a different device than the one the user confirmed. +public actor DeviceSession { + public nonisolated let target: DeviceTarget + public nonisolated let usbmuxDevice: USBMuxDevice + private let usbmux: USBMuxClient + private let pairRecord: PairRecord + private let lockdown: LockdownClient + private var openServices: [ServiceConnection] = [] + private let logger = ToolkitLog.deviceCommunication + + private init(target: DeviceTarget, usbmuxDevice: USBMuxDevice, usbmux: USBMuxClient, pairRecord: PairRecord, lockdown: LockdownClient) { + self.target = target + self.usbmuxDevice = usbmuxDevice + self.usbmux = usbmux + self.pairRecord = pairRecord + self.lockdown = lockdown + } + + public static func open(target: DeviceTarget, usbmux: USBMuxClient = USBMuxClient(), timeout: TimeInterval = 30) async throws -> DeviceSession { + guard target.kind == .physical else { + throw ToolkitError(.internalInconsistency, message: "Lockdown services are only available for physical devices.") + } + return try await withTimeout(timeout, operation: "Connecting to \(target.name)") { + let device = try await resolve(target: target, usbmux: usbmux) + let pairRecord = try await usbmux.readPairRecord(for: device) + let lockdown = try await LockdownClient.connect(usbmux: usbmux, device: device) + do { + try await lockdown.startSession(pairRecord: pairRecord) + let reported = try await lockdown.getValue(key: "UniqueDeviceID")?.stringValue + guard let reported, USBMuxDevice.normalizedUDID(reported).caseInsensitiveCompare(target.udid) == .orderedSame else { + throw ToolkitError( + .internalInconsistency, + message: "The device that answered is not the one you selected, so nothing was sent to it.", + recovery: "Disconnect other devices, refresh the device list, and select the intended device again.", + technicalDetail: "Expected …\(target.confirmationSuffix)" + ) + } + return DeviceSession(target: target, usbmuxDevice: device, usbmux: usbmux, pairRecord: pairRecord, lockdown: lockdown) + } catch { + await lockdown.close() + throw error + } + } + } + + /// Finds the usbmuxd record for the target UDID right now (usbmuxd device IDs change on + /// every reconnect). USB is preferred over network when both are present. + static func resolve(target: DeviceTarget, usbmux: USBMuxClient) async throws -> USBMuxDevice { + let devices = try await usbmux.listDevices() + let matches = devices.filter { $0.udid.caseInsensitiveCompare(target.udid) == .orderedSame } + if let usb = matches.first(where: { $0.transport == .usb }) { return usb } + if let any = matches.first { return any } + throw ToolkitError( + .deviceNotFound, + message: "\(target.name) is not connected to this Mac.", + recovery: "Connect the device with a USB cable, unlock it, and wait for it to appear in the device list." + ) + } + + /// Reads identity values that lockdown exposes before a device trusts the Mac. + public static func basicInfo(for device: USBMuxDevice, usbmux: USBMuxClient = USBMuxClient()) async throws -> LockdownBasicInfo { + let lockdown = try await LockdownClient.connect(usbmux: usbmux, device: device) + defer { Task { await lockdown.close() } } + let all = try await lockdown.getValue() + return LockdownBasicInfo( + deviceName: all?["DeviceName"]?.stringValue, + productType: all?["ProductType"]?.stringValue, + productVersion: all?["ProductVersion"]?.stringValue, + buildVersion: all?["BuildVersion"]?.stringValue, + deviceClass: all?["DeviceClass"]?.stringValue + ) + } + + // MARK: Values + + public func getValue(domain: String? = nil, key: String? = nil) async throws -> PlistValue? { + try await lockdown.getValue(domain: domain, key: key) + } + + /// Developer Mode status from AMFI (iOS 16+). `nil` when the device does not report it. + public func developerModeEnabled() async throws -> Bool? { + try await lockdown.getValue(domain: "com.apple.security.mac.amfi", key: "DeveloperModeStatus")?.boolValue + } + + // MARK: Services + + public func openService(_ name: String, useEscrowBag: Bool = false) async throws -> ServiceConnection { + let descriptor = try await lockdown.startService(name, escrowBag: useEscrowBag ? pairRecord.escrowBag : nil) + let channel = try await usbmux.connect(to: usbmuxDevice, port: descriptor.port) + do { + if descriptor.usesTLS { + try await channel.startTLS(pairRecord.tlsCredentials) + } + } catch { + await channel.close() + throw error + } + let connection = ServiceConnection(name: name, channel: channel) + openServices.append(connection) + return connection + } + + public func close() async { + for service in openServices { await service.close() } + openServices.removeAll() + await lockdown.stopSession() + await lockdown.close() + logger.info("Device session closed") + } + + /// Opens a session, runs `body`, and always closes the session. + public static func with( + _ target: DeviceTarget, + usbmux: USBMuxClient = USBMuxClient(), + _ body: @Sendable (DeviceSession) async throws -> T + ) async throws -> T { + let session = try await open(target: target, usbmux: usbmux) + do { + let value = try await body(session) + await session.close() + return value + } catch { + await session.close() + throw error + } + } +} diff --git a/Sources/DeviceKit/Lockdown/LockdownClient.swift b/Sources/DeviceKit/Lockdown/LockdownClient.swift new file mode 100644 index 0000000..22c4898 --- /dev/null +++ b/Sources/DeviceKit/Lockdown/LockdownClient.swift @@ -0,0 +1,128 @@ +import Foundation +import OSLog +import ToolkitCore + +public struct LockdownServiceDescriptor: Sendable, Hashable { + public let name: String + public let port: UInt16 + public let usesTLS: Bool +} + +/// The lockdownd protocol (TCP port 62078 on the device, reached through usbmuxd). +public actor LockdownClient { + public static let port: UInt16 = 62_078 + public static let label = "iOSDeveloperToolkit" + + private let messages: PlistMessageConnection + public private(set) var sessionID: String? + private let logger = ToolkitLog.deviceCommunication + + init(messages: PlistMessageConnection) { + self.messages = messages + } + + public static func connect(usbmux: USBMuxClient, device: USBMuxDevice) async throws -> LockdownClient { + let channel = try await usbmux.connect(to: device, port: port) + let client = LockdownClient(messages: PlistMessageConnection(channel: channel)) + do { + let reply = try await client.request("QueryType") + guard reply["Type"]?.stringValue == "com.apple.mobile.lockdown" else { + throw ToolkitError(.protocolViolation, message: "The device's lockdown service returned an unexpected type.", technicalDetail: reply.prettyJSONString()) + } + } catch { + await client.close() + throw error + } + return client + } + + /// Sends a lockdown request and returns the reply, converting lockdown `Error` strings into + /// actionable errors. + public func request(_ name: String, _ extra: [String: PlistValue] = [:], timeout: TimeInterval = 30) async throws -> PlistValue { + var body: [String: PlistValue] = ["Label": .string(Self.label), "Request": .string(name)] + for (key, value) in extra { body[key] = value } + let reply = try await messages.request(.dictionary(body), timeout: timeout) + if let error = reply["Error"]?.stringValue { + throw LockdownErrorInterpreter.interpret(error, request: name, detail: reply["ErrorDescription"]?.stringValue) + } + return reply + } + + public func getValue(domain: String? = nil, key: String? = nil) async throws -> PlistValue? { + var extra: [String: PlistValue] = [:] + if let domain { extra["Domain"] = .string(domain) } + if let key { extra["Key"] = .string(key) } + do { + return try await request("GetValue", extra)["Value"] + } catch let error as ToolkitError where error.technicalDetail?.contains("MissingValue") == true { + return nil + } + } + + /// Starts an authenticated session and upgrades the connection to TLS when requested. + public func startSession(pairRecord: PairRecord) async throws { + let reply = try await request("StartSession", [ + "HostID": .string(pairRecord.hostID), + "SystemBUID": .string(pairRecord.systemBUID), + ]) + guard let sessionID = reply["SessionID"]?.stringValue else { + throw ToolkitError(.protocolViolation, message: "The device did not start a lockdown session.") + } + self.sessionID = sessionID + if reply["EnableSessionSSL"]?.boolValue == true { + try await messages.channel.startTLS(pairRecord.tlsCredentials) + } + logger.info("Lockdown session started") + } + + public func startService(_ name: String, escrowBag: Data? = nil) async throws -> LockdownServiceDescriptor { + guard sessionID != nil else { + throw ToolkitError(.internalInconsistency, message: "A device service was requested before a session was established.") + } + var extra: [String: PlistValue] = ["Service": .string(name)] + if let escrowBag { extra["EscrowBag"] = .data(escrowBag) } + let reply = try await request("StartService", extra) + guard let port = reply["Port"]?.intValue, let validPort = UInt16(exactly: port), validPort > 0 else { + throw ToolkitError(.protocolViolation, message: "The device did not provide a port for \(name).") + } + logger.info("Started service \(name, privacy: .public)") + return LockdownServiceDescriptor(name: name, port: validPort, usesTLS: reply["EnableServiceSSL"]?.boolValue ?? false) + } + + public func stopSession() async { + guard let sessionID else { return } + _ = try? await request("StopSession", ["SessionID": .string(sessionID)], timeout: 5) + self.sessionID = nil + } + + public func close() async { + await messages.close() + } +} + +/// Maps lockdownd error strings to plain-language errors. +public enum LockdownErrorInterpreter { + public static func interpret(_ code: String, request: String, detail: String? = nil) -> ToolkitError { + let technical = "lockdown \(request) error: \(code)" + (detail.map { " — \($0)" } ?? "") + switch code { + case "PasswordProtected": + return ToolkitError(.deviceLocked, message: "The device is locked.", recovery: "Unlock the device with its passcode, then try again.", technicalDetail: technical) + case "PairingDialogResponsePending": + return ToolkitError(.pairingPending, message: "The device is waiting for you to tap Trust.", recovery: "Unlock the device and answer the “Trust This Computer?” prompt.", technicalDetail: technical) + case "UserDeniedPairing": + return ToolkitError(.notPaired, message: "Trust was declined on the device.", recovery: "Disconnect and reconnect the device, then tap Trust. If no prompt appears, reset Location & Privacy on the device.", technicalDetail: technical) + case "InvalidHostID", "InvalidPairRecord", "NoHostCertificate", "InvalidHostCertificate": + return ToolkitError(.notPaired, message: "The device no longer trusts this Mac.", recovery: "Disconnect and reconnect the device, unlock it, and tap Trust again.", technicalDetail: technical) + case "InvalidService", "ServiceProhibited": + return ToolkitError(.serviceUnavailable, message: "This service is not available on the selected device.", recovery: "The iOS version on the device may not offer it, or it may be restricted by device management.", technicalDetail: technical) + case "EscrowLocked", "DeviceLocked": + return ToolkitError(.deviceLocked, message: "The device must be unlocked at least once after restarting.", recovery: "Unlock the device, then try again.", technicalDetail: technical) + case "SessionInactive", "InvalidSessionID": + return ToolkitError(.deviceDisconnected, message: "The connection to the device was interrupted.", recovery: "Reconnect the device and try again.", technicalDetail: technical) + case "MissingValue": + return ToolkitError(.serviceUnavailable, message: "The device did not provide the requested value.", technicalDetail: technical) + default: + return ToolkitError(.commandFailed, message: "The device rejected the request.", recovery: "Make sure the device is unlocked, connected, and has trusted this Mac.", technicalDetail: technical) + } + } +} diff --git a/Sources/DeviceKit/Lockdown/PlistMessageConnection.swift b/Sources/DeviceKit/Lockdown/PlistMessageConnection.swift new file mode 100644 index 0000000..b8afeb6 --- /dev/null +++ b/Sources/DeviceKit/Lockdown/PlistMessageConnection.swift @@ -0,0 +1,45 @@ +import Foundation +import ToolkitCore + +/// Length-prefixed property-list messaging (4-byte big-endian length + plist), used by lockdown +/// and most lockdown services. +public struct PlistMessageConnection: Sendable { + public let channel: DeviceChannel + public let maximumMessageLength: Int + + public init(channel: DeviceChannel, maximumMessageLength: Int = 64 * 1024 * 1024) { + self.channel = channel + self.maximumMessageLength = maximumMessageLength + } + + public static func frame(_ value: PlistValue, format: PropertyListSerialization.PropertyListFormat = .xml) throws -> Data { + let body = try value.encoded(format: format) + var data = Data(capacity: body.count + 4) + data.appendBigEndian(UInt32(body.count)) + data.append(body) + return data + } + + public func send(_ value: PlistValue, format: PropertyListSerialization.PropertyListFormat = .xml) async throws { + try await channel.write(try Self.frame(value, format: format)) + } + + public func receive(timeout: TimeInterval? = 30) async throws -> PlistValue { + let header = try await channel.read(exactly: 4, timeout: timeout) + let length = Int(header.readBigEndianUInt32(at: 0)) + guard length > 0, length <= maximumMessageLength else { + throw ToolkitError(.protocolViolation, message: "The device sent a message with an invalid length.", technicalDetail: "length=\(length)") + } + let body = try await channel.read(exactly: length, timeout: timeout ?? 60) + return try PlistValue.decode(body) + } + + public func request(_ value: PlistValue, timeout: TimeInterval? = 30) async throws -> PlistValue { + try await send(value) + return try await receive(timeout: timeout) + } + + public func close() async { + await channel.close() + } +} diff --git a/Sources/DeviceKit/Models/Device.swift b/Sources/DeviceKit/Models/Device.swift new file mode 100644 index 0000000..dd2634d --- /dev/null +++ b/Sources/DeviceKit/Models/Device.swift @@ -0,0 +1,322 @@ +import Foundation +import ToolkitCore + +/// Physical hardware versus an Xcode simulator. The UI keeps these visibly separate. +public enum DeviceKind: String, Codable, Sendable, CaseIterable { + case physical + case simulator + case demo + + public var label: String { + switch self { + case .physical: return "Physical Device" + case .simulator: return "Simulator" + case .demo: return "Demo Device" + } + } +} + +public enum DeviceFamily: String, Codable, Sendable { + case iPhone, iPad, iPod, appleTV = "Apple TV", appleWatch = "Apple Watch", vision = "Apple Vision", mac = "Mac", unknown = "Device" + + public static func from(productType: String?, deviceType: String? = nil) -> DeviceFamily { + let value = (deviceType ?? productType ?? "").lowercased() + if value.hasPrefix("iphone") { return .iPhone } + if value.hasPrefix("ipad") { return .iPad } + if value.hasPrefix("ipod") { return .iPod } + if value.hasPrefix("appletv") || value.contains("apple tv") { return .appleTV } + if value.hasPrefix("watch") || value.contains("watch") { return .appleWatch } + if value.hasPrefix("reality") || value.contains("vision") { return .vision } + if value.hasPrefix("mac") { return .mac } + return .unknown + } + + public var symbolName: String { + switch self { + case .iPhone: return "iphone" + case .iPad: return "ipad" + case .iPod: return "ipodtouch" + case .appleTV: return "appletv" + case .appleWatch: return "applewatch" + case .vision: return "visionpro" + case .mac: return "desktopcomputer" + case .unknown: return "questionmark.square.dashed" + } + } +} + +/// How the Mac currently reaches a device. +public enum DeviceTransport: String, Codable, Sendable, CaseIterable, Comparable { + case usb + case network + case local + + public var label: String { + switch self { + case .usb: return "USB" + case .network: return "Wi-Fi / Network" + case .local: return "On this Mac" + } + } + + public static func < (lhs: DeviceTransport, rhs: DeviceTransport) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +public enum PairingState: String, Codable, Sendable { + case paired + case unpaired + case pairingInProgress + case unknown + case notApplicable + + public var label: String { + switch self { + case .paired: return "Trusted" + case .unpaired: return "Not trusted" + case .pairingInProgress: return "Waiting for Trust" + case .unknown: return "Unknown" + case .notApplicable: return "Not needed" + } + } + + public static func fromCoreDevice(_ value: String?) -> PairingState { + switch value?.lowercased() { + case "paired": return .paired + case "unpaired": return .unpaired + case "pairinginprogress", "pairing": return .pairingInProgress + default: return .unknown + } + } +} + +public enum DeveloperModeState: String, Codable, Sendable { + case enabled + case disabled + case unknown + case notApplicable + + public var label: String { + switch self { + case .enabled: return "On" + case .disabled: return "Off" + case .unknown: return "Unknown" + case .notApplicable: return "Not needed" + } + } + + public static func fromCoreDevice(_ value: String?) -> DeveloperModeState { + switch value?.lowercased() { + case "enabled": return .enabled + case "disabled": return .disabled + default: return .unknown + } + } +} + +public enum SimulatorState: String, Codable, Sendable { + case booted = "Booted" + case shutdown = "Shutdown" + case booting = "Booting" + case shuttingDown = "Shutting Down" + case creating = "Creating" + case unknown + + public init(simctlValue: String?) { + self = SimulatorState(rawValue: simctlValue ?? "") ?? .unknown + } + + public var label: String { + switch self { + case .booted: return "Running" + case .shutdown: return "Shut down" + case .booting: return "Starting" + case .shuttingDown: return "Shutting down" + case .creating: return "Being created" + case .unknown: return "Unknown" + } + } +} + +/// Where a device record came from. A physical device can be reported by both usbmuxd +/// (lockdown services, no Xcode needed) and CoreDevice (developer services, Xcode needed). +public enum DiscoverySource: String, Codable, Sendable, CaseIterable { + case usbmux + case coreDevice + case simctl + case demo +} + +/// A merged, display-ready device record. +public struct Device: Identifiable, Hashable, Sendable, Codable { + public var id: String { "\(kind.rawValue):\(udid)" } + + public var kind: DeviceKind + public var udid: String + public var name: String + public var productType: String? + public var marketingName: String? + public var family: DeviceFamily + public var osName: String? + public var osVersion: String? + public var buildVersion: String? + public var architecture: String? + public var hardwareModel: String? + public var serialNumber: String? + public var ecid: String? + public var transports: Set + public var pairingState: PairingState + public var developerMode: DeveloperModeState + public var ddiServicesAvailable: Bool? + public var tunnelState: String? + public var coreDeviceIdentifier: String? + public var usbmuxDeviceID: Int? + public var simulatorState: SimulatorState? + public var simulatorRuntime: String? + public var sources: Set + public var lastSeen: Date + + public init( + kind: DeviceKind, + udid: String, + name: String, + productType: String? = nil, + marketingName: String? = nil, + family: DeviceFamily? = nil, + osName: String? = nil, + osVersion: String? = nil, + buildVersion: String? = nil, + architecture: String? = nil, + hardwareModel: String? = nil, + serialNumber: String? = nil, + ecid: String? = nil, + transports: Set = [], + pairingState: PairingState = .unknown, + developerMode: DeveloperModeState = .unknown, + ddiServicesAvailable: Bool? = nil, + tunnelState: String? = nil, + coreDeviceIdentifier: String? = nil, + usbmuxDeviceID: Int? = nil, + simulatorState: SimulatorState? = nil, + simulatorRuntime: String? = nil, + sources: Set = [], + lastSeen: Date = Date() + ) { + self.kind = kind + self.udid = udid + self.name = name + self.productType = productType + self.marketingName = marketingName ?? productType.flatMap(ProductCatalog.marketingName(for:)) + self.family = family ?? DeviceFamily.from(productType: productType) + self.osName = osName + self.osVersion = osVersion + self.buildVersion = buildVersion + self.architecture = architecture + self.hardwareModel = hardwareModel + self.serialNumber = serialNumber + self.ecid = ecid + self.transports = transports + self.pairingState = pairingState + self.developerMode = developerMode + self.ddiServicesAvailable = ddiServicesAvailable + self.tunnelState = tunnelState + self.coreDeviceIdentifier = coreDeviceIdentifier + self.usbmuxDeviceID = usbmuxDeviceID + self.simulatorState = simulatorState + self.simulatorRuntime = simulatorRuntime + self.sources = sources + self.lastSeen = lastSeen + } + + public var displayModel: String { + marketingName ?? productType ?? family.rawValue + } + + public var osMajorVersion: Int? { + guard let osVersion, let major = osVersion.split(separator: ".").first else { return nil } + return Int(major) + } + + public var displayVersion: String { + let os = osName ?? (family == .iPad ? "iPadOS" : "iOS") + guard let osVersion else { return os } + if let buildVersion { return "\(os) \(osVersion) (\(buildVersion))" } + return "\(os) \(osVersion)" + } + + public var primaryTransport: DeviceTransport? { + if transports.contains(.usb) { return .usb } + if transports.contains(.network) { return .network } + if transports.contains(.local) { return .local } + return nil + } + + /// Whether lockdown (usbmuxd) services can be used right now. + public var supportsLockdownServices: Bool { + kind == .physical && usbmuxDeviceID != nil + } + + /// Whether CoreDevice (`devicectl`) knows this device. + public var supportsCoreDevice: Bool { + kind == .physical && sources.contains(.coreDevice) + } + + /// The immutable target captured when an operation starts. + public var target: DeviceTarget { + DeviceTarget( + kind: kind, + udid: udid, + name: name, + osVersion: osVersion, + usbmuxDeviceID: usbmuxDeviceID, + coreDeviceIdentifier: coreDeviceIdentifier, + transport: primaryTransport + ) + } +} + +/// An immutable snapshot of the device an operation was started against. Operations never read +/// the "currently selected" device after they start, so changing the selection (or a second +/// device attaching) cannot redirect an in-flight operation. +public struct DeviceTarget: Hashable, Sendable, Codable { + public let kind: DeviceKind + public let udid: String + public let name: String + public let osVersion: String? + public let usbmuxDeviceID: Int? + public let coreDeviceIdentifier: String? + public let transport: DeviceTransport? + + public init(kind: DeviceKind, udid: String, name: String, osVersion: String?, usbmuxDeviceID: Int?, coreDeviceIdentifier: String?, transport: DeviceTransport?) { + self.kind = kind + self.udid = udid + self.name = name + self.osVersion = osVersion + self.usbmuxDeviceID = usbmuxDeviceID + self.coreDeviceIdentifier = coreDeviceIdentifier + self.transport = transport + } + + /// The value passed to `devicectl --device`. The UDID is preferred; network-only devices + /// without a UDID fall back to the CoreDevice identifier. + public var coreDeviceSelector: String { + udid.isEmpty ? (coreDeviceIdentifier ?? udid) : udid + } + + /// The last six alphanumeric characters of the UDID, used in typed confirmations. + public var confirmationSuffix: String { + let cleaned = udid.uppercased().filter { $0.isLetter || $0.isNumber } + return String(cleaned.suffix(6)) + } + + /// Short label for logs and session activity: "Name (…ABC123)". + public var shortLabel: String { + "\(name) (…\(confirmationSuffix))" + } + + public var osMajorVersion: Int? { + guard let osVersion, let major = osVersion.split(separator: ".").first else { return nil } + return Int(major) + } +} diff --git a/Sources/DeviceKit/Models/ProductCatalog.swift b/Sources/DeviceKit/Models/ProductCatalog.swift new file mode 100644 index 0000000..eacdbd1 --- /dev/null +++ b/Sources/DeviceKit/Models/ProductCatalog.swift @@ -0,0 +1,140 @@ +import Foundation + +/// Marketing names for hardware identifiers. CoreDevice supplies `marketingName` directly, so +/// this table is only a fallback for devices seen through usbmuxd on a Mac without Xcode. +/// Unknown identifiers are shown as-is rather than guessed. +public enum ProductCatalog { + public static func marketingName(for productType: String) -> String? { + table[productType] + } + + static let table: [String: String] = [ + // iPhone + "iPhone11,2": "iPhone XS", "iPhone11,4": "iPhone XS Max", "iPhone11,6": "iPhone XS Max", "iPhone11,8": "iPhone XR", + "iPhone12,1": "iPhone 11", "iPhone12,3": "iPhone 11 Pro", "iPhone12,5": "iPhone 11 Pro Max", "iPhone12,8": "iPhone SE (2nd generation)", + "iPhone13,1": "iPhone 12 mini", "iPhone13,2": "iPhone 12", "iPhone13,3": "iPhone 12 Pro", "iPhone13,4": "iPhone 12 Pro Max", + "iPhone14,2": "iPhone 13 Pro", "iPhone14,3": "iPhone 13 Pro Max", "iPhone14,4": "iPhone 13 mini", "iPhone14,5": "iPhone 13", + "iPhone14,6": "iPhone SE (3rd generation)", "iPhone14,7": "iPhone 14", "iPhone14,8": "iPhone 14 Plus", + "iPhone15,2": "iPhone 14 Pro", "iPhone15,3": "iPhone 14 Pro Max", "iPhone15,4": "iPhone 15", "iPhone15,5": "iPhone 15 Plus", + "iPhone16,1": "iPhone 15 Pro", "iPhone16,2": "iPhone 15 Pro Max", + "iPhone17,1": "iPhone 16 Pro", "iPhone17,2": "iPhone 16 Pro Max", "iPhone17,3": "iPhone 16", "iPhone17,4": "iPhone 16 Plus", "iPhone17,5": "iPhone 16e", + "iPhone18,1": "iPhone 17 Pro", "iPhone18,2": "iPhone 17 Pro Max", "iPhone18,3": "iPhone 17", "iPhone18,4": "iPhone Air", + // iPad + "iPad11,1": "iPad mini (5th generation)", "iPad11,2": "iPad mini (5th generation)", + "iPad11,3": "iPad Air (3rd generation)", "iPad11,4": "iPad Air (3rd generation)", + "iPad11,6": "iPad (8th generation)", "iPad11,7": "iPad (8th generation)", + "iPad12,1": "iPad (9th generation)", "iPad12,2": "iPad (9th generation)", + "iPad13,1": "iPad Air (4th generation)", "iPad13,2": "iPad Air (4th generation)", + "iPad13,4": "iPad Pro 11-inch (3rd generation)", "iPad13,5": "iPad Pro 11-inch (3rd generation)", + "iPad13,6": "iPad Pro 11-inch (3rd generation)", "iPad13,7": "iPad Pro 11-inch (3rd generation)", + "iPad13,8": "iPad Pro 12.9-inch (5th generation)", "iPad13,9": "iPad Pro 12.9-inch (5th generation)", + "iPad13,10": "iPad Pro 12.9-inch (5th generation)", "iPad13,11": "iPad Pro 12.9-inch (5th generation)", + "iPad13,16": "iPad Air (5th generation)", "iPad13,17": "iPad Air (5th generation)", + "iPad13,18": "iPad (10th generation)", "iPad13,19": "iPad (10th generation)", + "iPad14,1": "iPad mini (6th generation)", "iPad14,2": "iPad mini (6th generation)", + "iPad14,3": "iPad Pro 11-inch (4th generation)", "iPad14,4": "iPad Pro 11-inch (4th generation)", + "iPad14,5": "iPad Pro 12.9-inch (6th generation)", "iPad14,6": "iPad Pro 12.9-inch (6th generation)", + "iPad14,8": "iPad Air 11-inch (M2)", "iPad14,9": "iPad Air 11-inch (M2)", + "iPad14,10": "iPad Air 13-inch (M2)", "iPad14,11": "iPad Air 13-inch (M2)", + "iPad15,3": "iPad Air 11-inch (M3)", "iPad15,4": "iPad Air 11-inch (M3)", + "iPad15,5": "iPad Air 13-inch (M3)", "iPad15,6": "iPad Air 13-inch (M3)", + "iPad15,7": "iPad (A16)", "iPad15,8": "iPad (A16)", + "iPad16,1": "iPad mini (A17 Pro)", "iPad16,2": "iPad mini (A17 Pro)", + "iPad16,3": "iPad Pro 11-inch (M4)", "iPad16,4": "iPad Pro 11-inch (M4)", + "iPad16,5": "iPad Pro 13-inch (M4)", "iPad16,6": "iPad Pro 13-inch (M4)", + // iPod + "iPod9,1": "iPod touch (7th generation)", + ] +} + +/// Plain-language explanations of technical fields, shown as help text next to each value. +public enum DeviceField: String, CaseIterable, Sendable, Identifiable { + case name + case model + case hardwareIdentifier + case hardwareModel + case udid + case osVersion + case buildNumber + case architecture + case connection + case pairing + case developerMode + case developerServices + case serialNumber + case ecid + case coreDeviceIdentifier + case simulatorRuntime + case simulatorState + + public var id: String { rawValue } + + public var title: String { + switch self { + case .name: return "Device name" + case .model: return "Model" + case .hardwareIdentifier: return "Hardware identifier" + case .hardwareModel: return "Board model" + case .udid: return "UDID" + case .osVersion: return "System version" + case .buildNumber: return "Build number" + case .architecture: return "Architecture" + case .connection: return "Connection" + case .pairing: return "Trust (pairing)" + case .developerMode: return "Developer Mode" + case .developerServices: return "Developer services" + case .serialNumber: return "Serial number" + case .ecid: return "ECID" + case .coreDeviceIdentifier: return "CoreDevice identifier" + case .simulatorRuntime: return "Simulator runtime" + case .simulatorState: return "Simulator state" + } + } + + public var explanation: String { + switch self { + case .name: + return "The name set in Settings › General › About. Anyone can change it, so it is not a reliable identifier." + case .model: + return "The product name Apple uses when selling the device, such as “iPhone 15 Pro”." + case .hardwareIdentifier: + return "Apple's internal model code, such as “iPhone16,1”. Tools and crash reports use this instead of the marketing name." + case .hardwareModel: + return "The logic-board identifier, such as “D83AP”. Useful when matching firmware or repair information." + case .udid: + return "Unique Device Identifier. Xcode, provisioning profiles, and every tool in this app use it to address exactly one device. Treat it as private." + case .osVersion: + return "The installed iOS or iPadOS version. Many developer features depend on it, for example Developer Mode exists only on iOS 16 and later." + case .buildNumber: + return "Apple's exact build of the system, such as “23A341”. Two devices on the same version can still run different builds." + case .architecture: + return "The processor instruction set. Modern iPhones and iPads use arm64e; apps must be built for it to run." + case .connection: + return "How this Mac reaches the device right now. USB is the most reliable. Network connections require the device to have been paired over USB first." + case .pairing: + return "Whether the device has trusted this Mac (the “Trust This Computer?” prompt). Nothing except basic detection works until the device trusts the Mac." + case .developerMode: + return "An iOS 16+ setting (Settings › Privacy & Security › Developer Mode) that allows development features such as running your own apps, location simulation, and developer services. Turning it on requires a restart." + case .developerServices: + return "Whether Apple's developer image (Developer Disk Image) is mounted on the device. It is required for screenshots, location simulation, launching apps, and Instruments. The Developer image card on the Device page shows its state and mounts it: iOS 17 and later use an image Apple personalizes for the device; iOS 16 and earlier use DeveloperDiskImage.dmg for that exact version." + case .serialNumber: + return "The hardware serial number printed on the device and box. It is personally identifying; avoid sharing it." + case .ecid: + return "Exclusive Chip ID, a unique number burned into the processor. Apple uses it to personalize firmware and developer images." + case .coreDeviceIdentifier: + return "The identifier Xcode's CoreDevice service assigns to this device on this Mac." + case .simulatorRuntime: + return "The iOS version of the simulated device. Simulator runtimes are installed through Xcode › Settings › Components." + case .simulatorState: + return "Whether the simulator is running. Most actions need a running (booted) simulator." + } + } + + /// Fields that should be hidden behind a disclosure because they are identifying. + public var isSensitive: Bool { + switch self { + case .udid, .serialNumber, .ecid, .coreDeviceIdentifier: return true + default: return false + } + } +} diff --git a/Sources/DeviceKit/Services/AFCClient.swift b/Sources/DeviceKit/Services/AFCClient.swift new file mode 100644 index 0000000..86ebe75 --- /dev/null +++ b/Sources/DeviceKit/Services/AFCClient.swift @@ -0,0 +1,270 @@ +import Foundation +import ToolkitCore + +/// Apple File Conduit (`com.apple.afc`, also used by `com.apple.crashreportcopymobile`). +/// +/// AFC exposes an Apple-defined view (the Media folder for `com.apple.afc`, crash reports for the +/// crash service). It is not access to the device's full file system. +public actor AFCClient { + public static let mediaServiceName = "com.apple.afc" + public static let crashReportServiceName = "com.apple.crashreportcopymobile" + public static let crashReportMoverServiceName = "com.apple.crashreportmover" + + enum Operation: UInt64 { + case status = 0x01 + case data = 0x02 + case readDirectory = 0x03 + case removePath = 0x08 + case makeDirectory = 0x09 + case getFileInfo = 0x0A + case fileOpen = 0x0D + case fileOpenResult = 0x0E + case fileRead = 0x0F + case fileWrite = 0x10 + case fileClose = 0x14 + case fileLock = 0x1B + } + + public enum OpenMode: UInt64, Sendable { + case readOnly = 1 + case readWrite = 2 + case writeTruncate = 3 + } + + public enum LockOperation: UInt64, Sendable { + case shared = 5 + case exclusive = 6 + case unlock = 12 + } + + public struct FileInfo: Sendable, Hashable { + public var size: Int64? + public var isDirectory: Bool + public var isSymbolicLink: Bool + public var modified: Date? + public var raw: [String: String] + } + + static let magic = Data("CFA6LPAA".utf8) + static let headerLength = 40 + static let maximumPacketLength = 64 * 1024 * 1024 + + private let connection: ServiceConnection + private var packetNumber: UInt64 = 0 + + public init(connection: ServiceConnection) { + self.connection = connection + } + + public static func openMedia(_ session: DeviceSession) async throws -> AFCClient { + AFCClient(connection: try await session.openService(mediaServiceName)) + } + + /// Asks the device to move pending crash reports into the copy area, then opens the crash + /// report AFC view. + public static func openCrashReports(_ session: DeviceSession) async throws -> AFCClient { + if let mover = try? await session.openService(crashReportMoverServiceName) { + _ = try? await mover.channel.read(exactly: 4, timeout: 30) + await mover.close() + } + return AFCClient(connection: try await session.openService(crashReportServiceName)) + } + + // MARK: Packets + + package static func encode(operation: UInt64, packetNumber: UInt64, header: Data, payload: Data) -> Data { + var data = Data(capacity: headerLength + header.count + payload.count) + data.append(magic) + data.appendLittleEndian(UInt64(headerLength + header.count + payload.count)) + data.appendLittleEndian(UInt64(headerLength + header.count)) + data.appendLittleEndian(packetNumber) + data.appendLittleEndian(operation) + data.append(header) + data.append(payload) + return data + } + + private func transact(_ operation: Operation, header: Data = Data(), payload: Data = Data()) async throws -> (operation: UInt64, body: Data) { + packetNumber &+= 1 + try await connection.channel.write(Self.encode(operation: operation.rawValue, packetNumber: packetNumber, header: header, payload: payload)) + let packetHeader = try await connection.channel.read(exactly: Self.headerLength, timeout: 60) + guard packetHeader.prefix(8) == Self.magic else { + throw ToolkitError(.protocolViolation, message: "The device's file service sent an invalid reply.") + } + let entireLength = Int(clamping: packetHeader.readLittleEndianUInt64(at: 8)) + let replyOperation = packetHeader.readLittleEndianUInt64(at: 32) + guard entireLength >= Self.headerLength, entireLength <= Self.maximumPacketLength else { + throw ToolkitError(.protocolViolation, message: "The device's file service sent an invalid length.", technicalDetail: "\(entireLength)") + } + let body = try await connection.channel.read(exactly: entireLength - Self.headerLength, timeout: 120) + if replyOperation == Operation.status.rawValue { + let code = body.count >= 8 ? body.readLittleEndianUInt64(at: 0) : 1 + if code != 0 { throw Self.error(code: code) } + } + return (replyOperation, body) + } + + static func error(code: UInt64) -> ToolkitError { + switch code { + case 8: return ToolkitError(.fileSystem, message: "The item does not exist on the device.", technicalDetail: "AFC error 8 (object not found)") + case 9: return ToolkitError(.fileSystem, message: "The item is a folder.", technicalDetail: "AFC error 9 (is a directory)") + case 10: return ToolkitError(.permissionDenied, message: "The device did not allow access to this item.", technicalDetail: "AFC error 10 (permission denied)") + case 7: return ToolkitError(.invalidInput, message: "The device rejected the file request.", technicalDetail: "AFC error 7 (invalid argument)") + default: return ToolkitError(.fileSystem, message: "The device's file service reported an error.", technicalDetail: "AFC error \(code)") + } + } + + static func path(_ path: String) -> Data { + var data = Data(path.utf8) + data.append(0) + return data + } + + static func nulSeparated(_ data: Data) -> [String] { + data.split(separator: 0, omittingEmptySubsequences: true).map { String(decoding: $0, as: UTF8.self) } + } + + // MARK: Operations + + public func listDirectory(_ path: String) async throws -> [String] { + let reply = try await transact(.readDirectory, header: Self.path(path)) + return Self.nulSeparated(reply.body).filter { $0 != "." && $0 != ".." }.sorted() + } + + public func fileInfo(_ path: String) async throws -> FileInfo { + let reply = try await transact(.getFileInfo, header: Self.path(path)) + let values = Self.nulSeparated(reply.body) + var raw: [String: String] = [:] + var index = 0 + while index + 1 < values.count { + raw[values[index]] = values[index + 1] + index += 2 + } + let modifiedNanoseconds = raw["st_mtime"].flatMap(Double.init) + return FileInfo( + size: raw["st_size"].flatMap(Int64.init), + isDirectory: raw["st_ifmt"] == "S_IFDIR", + isSymbolicLink: raw["st_ifmt"] == "S_IFLNK", + modified: modifiedNanoseconds.map { Date(timeIntervalSince1970: $0 / 1_000_000_000) }, + raw: raw + ) + } + + public func makeDirectory(_ path: String) async throws { + _ = try await transact(.makeDirectory, header: Self.path(path)) + } + + public func remove(_ path: String) async throws { + _ = try await transact(.removePath, header: Self.path(path)) + } + + public func open(_ path: String, mode: OpenMode) async throws -> UInt64 { + var header = Data() + header.appendLittleEndian(mode.rawValue) + header.append(Self.path(path)) + let reply = try await transact(.fileOpen, header: header) + guard reply.operation == Operation.fileOpenResult.rawValue, reply.body.count >= 8 else { + throw ToolkitError(.protocolViolation, message: "The device did not open the file.") + } + return reply.body.readLittleEndianUInt64(at: 0) + } + + public func read(handle: UInt64, count: Int) async throws -> Data { + var header = Data() + header.appendLittleEndian(handle) + header.appendLittleEndian(UInt64(count)) + return try await transact(.fileRead, header: header).body + } + + public func write(handle: UInt64, data: Data) async throws { + var header = Data() + header.appendLittleEndian(handle) + _ = try await transact(.fileWrite, header: header, payload: data) + } + + public func close(handle: UInt64) async throws { + var header = Data() + header.appendLittleEndian(handle) + _ = try await transact(.fileClose, header: header) + } + + public func lock(handle: UInt64, operation: LockOperation) async throws { + var header = Data() + header.appendLittleEndian(handle) + header.appendLittleEndian(operation.rawValue) + _ = try await transact(.fileLock, header: header) + } + + /// Copies a device file to a new local file (never overwriting). + public func download(_ path: String, to destination: URL, chunkSize: Int = 1 << 20) async throws -> Int64 { + try SecureFileIO.writeNewFile(Data(), to: destination) + guard let output = try? FileHandle(forWritingTo: destination) else { + throw ToolkitError.fileSystem("Could not create the local copy.", path: destination.path) + } + defer { try? output.close() } + let handle = try await open(path, mode: .readOnly) + var total: Int64 = 0 + do { + while true { + try Task.checkCancellation() + let chunk = try await read(handle: handle, count: chunkSize) + if chunk.isEmpty { break } + try output.write(contentsOf: chunk) + total += Int64(chunk.count) + } + try await close(handle: handle) + } catch { + try? await close(handle: handle) + try? FileManager.default.removeItem(at: destination) + throw error + } + return total + } + + /// Uploads a local file (used to stage an IPA in `PublicStaging`). + public func upload(_ source: URL, to path: String, chunkSize: Int = 1 << 20, progress: @Sendable (Int64, Int64) -> Void = { _, _ in }) async throws { + guard let input = try? FileHandle(forReadingFrom: source) else { + throw ToolkitError.fileSystem("Could not read the file to upload.", path: source.path) + } + defer { try? input.close() } + let totalSize = SecureFileIO.fileSize(source) ?? 0 + let handle = try await open(path, mode: .writeTruncate) + var sent: Int64 = 0 + do { + while let chunk = try input.read(upToCount: chunkSize), !chunk.isEmpty { + try Task.checkCancellation() + try await write(handle: handle, data: chunk) + sent += Int64(chunk.count) + progress(sent, totalSize) + } + try await close(handle: handle) + } catch { + try? await close(handle: handle) + throw error + } + } + + /// Recursively lists files beneath `path` (bounded to avoid unbounded traversal). + public func walk(_ path: String, maximumEntries: Int = 20_000) async throws -> [String] { + var results: [String] = [] + var queue = [path] + while let current = queue.first, results.count < maximumEntries { + queue.removeFirst() + for name in try await listDirectory(current) { + let child = current.hasSuffix("/") ? current + name : current + "/" + name + let info = try? await fileInfo(child) + if info?.isDirectory == true { + queue.append(child) + } else { + results.append(child) + } + if results.count >= maximumEntries { break } + } + } + return results + } + + public func close() async { + await connection.close() + } +} diff --git a/Sources/DeviceKit/Services/BluetoothPacketLogger.swift b/Sources/DeviceKit/Services/BluetoothPacketLogger.swift new file mode 100644 index 0000000..b0d6ef8 --- /dev/null +++ b/Sources/DeviceKit/Services/BluetoothPacketLogger.swift @@ -0,0 +1,120 @@ +import Foundation +import ToolkitCore + +/// One Apple PacketLogger record: a 4-byte big-endian length, seconds and microseconds (big-endian), +/// a record type, and the HCI payload. +public struct PacketLoggerRecord: Sendable, Hashable { + public static let headerSize = 13 + + public var seconds: UInt32 + public var microseconds: UInt32 + public var type: UInt8 + public var payload: Data + /// The record exactly as received; concatenated records form a `.pklg` file. + public var raw: Data + + public init?(_ raw: Data) { + let bytes = [UInt8](raw) + guard bytes.count >= Self.headerSize else { return nil } + func be32(_ offset: Int) -> UInt32 { + UInt32(bytes[offset]) << 24 | UInt32(bytes[offset + 1]) << 16 | UInt32(bytes[offset + 2]) << 8 | UInt32(bytes[offset + 3]) + } + seconds = be32(4) + microseconds = be32(8) + type = bytes[12] + payload = Data(bytes[Self.headerSize...]) + self.raw = raw + } + + public var typeLabel: String { Self.label(for: type) } + + public static func label(for type: UInt8) -> String { + switch type { + case 0x00: return "HCI command" + case 0x01: return "HCI event" + case 0x02: return "ACL data sent" + case 0x03: return "ACL data received" + case 0x08: return "SCO data sent" + case 0x09: return "SCO data received" + default: return "Other (0x" + String(type, radix: 16) + ")" + } + } +} + +/// `com.apple.bluetooth.BTPacketLogger`: live Bluetooth HCI traffic, over lockdown without Xcode. +/// +/// A private service that streams only after Apple's Bluetooth logging profile is installed on the +/// device. Each record arrives with a 2-byte little-endian length prefix (a zero length is a +/// keep-alive) and is an Apple PacketLogger record; written back to back, the records are a +/// `.pklg` file that PacketLogger (Additional Tools for Xcode) and Wireshark open. +public enum BluetoothPacketLogger { + public static let serviceName = "com.apple.bluetooth.BTPacketLogger" + static let maximumRecordSize = 64 * 1024 + + public static func records(_ session: DeviceSession) async throws -> AsyncThrowingStream { + let service: ServiceConnection + do { + service = try await session.openService(serviceName) + } catch let error as ToolkitError { + throw ToolkitError(.serviceUnavailable, message: "The device did not start Bluetooth logging.", recovery: "Install Apple's Bluetooth logging profile on the device (Apple Developer › Profiles and Logs › Bluetooth), restart Bluetooth, and try again.", technicalDetail: error.technicalDetail ?? error.message) + } + return AsyncThrowingStream { continuation in + let task = Task { + do { + while !Task.isCancelled, try await service.channel.hasMoreData() { + let prefix = [UInt8](try await service.channel.read(exactly: 2, timeout: nil)) + let length = Int(prefix[0]) | Int(prefix[1]) << 8 + if length == 0 { continue } + guard length >= PacketLoggerRecord.headerSize, length <= maximumRecordSize else { + throw ToolkitError(.protocolViolation, message: "The Bluetooth log stream lost synchronization.", technicalDetail: "record length \(length)") + } + let raw = try await service.channel.read(exactly: length, timeout: 60) + if let record = PacketLoggerRecord(raw) { continuation.yield(record) } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + await service.close() + } + continuation.onTermination = { _ in task.cancel() } + } + } +} + +/// Writes PacketLogger records to a new `.pklg` file (owner-only, never overwriting). +public final class PacketLoggerFileWriter: @unchecked Sendable { + public let url: URL + private let handle: FileHandle + private let lock = NSLock() + private var hasher = StreamingHasher() + public private(set) var recordCount = 0 + public private(set) var countsByType: [UInt8: Int] = [:] + + public init(creatingNewFileAt url: URL) throws { + try SecureFileIO.writeNewFile(Data(), to: url) + guard let handle = try? FileHandle(forWritingTo: url) else { + throw ToolkitError.fileSystem("Could not open the capture file.", path: url.path) + } + self.url = url + self.handle = handle + } + + public func write(_ record: PacketLoggerRecord) throws { + try lock.withLock { + try handle.write(contentsOf: record.raw) + hasher.update(record.raw) + recordCount += 1 + countsByType[record.type, default: 0] += 1 + } + } + + /// Closes the file and returns its SHA-256. + public func finish() throws -> String { + try lock.withLock { + try handle.synchronize() + try handle.close() + return hasher.finalizeHex() + } + } +} diff --git a/Sources/DeviceKit/Services/LegacyLocationSimulation.swift b/Sources/DeviceKit/Services/LegacyLocationSimulation.swift new file mode 100644 index 0000000..a67fa60 --- /dev/null +++ b/Sources/DeviceKit/Services/LegacyLocationSimulation.swift @@ -0,0 +1,123 @@ +import Foundation +import ToolkitCore + +/// `com.apple.dt.simulatelocation`, the location simulation service used by iOS 16 and earlier. +/// It is only available after a developer disk image has been mounted (for example by Xcode). +public enum LegacyLocationSimulation { + public static let serviceName = "com.apple.dt.simulatelocation" + + static func encodeSet(latitude: Double, longitude: Double) -> Data { + var data = Data() + data.appendBigEndian(0) + for value in [String(latitude), String(longitude)] { + data.appendBigEndian(UInt32(value.utf8.count)) + data.append(Data(value.utf8)) + } + return data + } + + static func encodeClear() -> Data { + var data = Data() + data.appendBigEndian(1) + return data + } + + public static func set(latitude: Double, longitude: Double, session: DeviceSession) async throws { + try Coordinate.validate(latitude: latitude, longitude: longitude) + let service = try await openService(session) + defer { Task { await service.close() } } + try await service.channel.write(encodeSet(latitude: latitude, longitude: longitude)) + } + + public static func clear(session: DeviceSession) async throws { + let service = try await openService(session) + defer { Task { await service.close() } } + try await service.channel.write(encodeClear()) + } + + static func openService(_ session: DeviceSession) async throws -> ServiceConnection { + do { + return try await session.openService(serviceName) + } catch let error as ToolkitError where error.kind == .serviceUnavailable { + throw ToolkitError( + .developerDiskImageUnavailable, + message: "Location simulation on this iOS version needs Xcode's developer disk image.", + recovery: "Connect the device to Xcode once (Window › Devices and Simulators) so it mounts the developer image, then try again.", + technicalDetail: error.technicalDetail + ) + } + } +} + +/// Routes location requests to the right Apple mechanism for the target: +/// simulators → simctl; iOS 17+ → CoreDevice; earlier iOS → the legacy lockdown service. +public struct LocationController: Sendable { + public let coreDevice: CoreDeviceClient + public let simulators: SimulatorClient + public let usbmux: USBMuxClient + + public init(coreDevice: CoreDeviceClient = CoreDeviceClient(), simulators: SimulatorClient = SimulatorClient(), usbmux: USBMuxClient = USBMuxClient()) { + self.coreDevice = coreDevice + self.simulators = simulators + self.usbmux = usbmux + } + + public enum Mechanism: String, Sendable { + case simulator = "simctl location" + case coreDevice = "CoreDevice (devicectl) location simulation" + case legacyService = "com.apple.dt.simulatelocation (iOS 16 and earlier)" + case unavailable = "Unavailable" + } + + public func mechanism(for target: DeviceTarget) -> Mechanism { + switch target.kind { + case .simulator: return .simulator + case .demo: return .unavailable + case .physical: + if let major = target.osMajorVersion, major < 17 { return .legacyService } + return .coreDevice + } + } + + public func set(latitude: Double, longitude: Double, on target: DeviceTarget) async throws { + switch mechanism(for: target) { + case .simulator: + try await simulators.setLocation(latitude: latitude, longitude: longitude, on: target) + case .coreDevice: + _ = try await coreDevice.setLocation(latitude: latitude, longitude: longitude, on: target) + case .legacyService: + try await DeviceSession.with(target, usbmux: usbmux) { session in + try await LegacyLocationSimulation.set(latitude: latitude, longitude: longitude, session: session) + } + case .unavailable: + throw ToolkitError(.unsupported, message: "Location simulation is not available for the demo device.") + } + } + + public func clear(on target: DeviceTarget) async throws { + switch mechanism(for: target) { + case .simulator: + try await simulators.clearLocation(on: target) + case .coreDevice: + _ = try await coreDevice.clearLocation(on: target) + case .legacyService: + try await DeviceSession.with(target, usbmux: usbmux) { session in + try await LegacyLocationSimulation.clear(session: session) + } + case .unavailable: + throw ToolkitError(.unsupported, message: "Location simulation is not available for the demo device.") + } + } + + /// Starts constant-speed movement handled by the device service itself. + public func startRoute(_ waypoints: [(latitude: Double, longitude: Double)], speedMetresPerSecond: Double, intervalSeconds: Double, on target: DeviceTarget) async throws { + switch mechanism(for: target) { + case .simulator: + try await simulators.simulateRoute(waypoints, speedMetresPerSecond: speedMetresPerSecond, updateIntervalSeconds: intervalSeconds, on: target) + case .coreDevice: + _ = try await coreDevice.simulateRoute(waypoints, speedMetresPerSecond: speedMetresPerSecond, updateIntervalSeconds: intervalSeconds, on: target) + case .legacyService, .unavailable: + throw ToolkitError(.unsupported, message: "Native route simulation needs iOS 17 or later.", recovery: "Use GPX playback instead; the toolkit will send each point in turn.") + } + } +} diff --git a/Sources/DeviceKit/Services/LogServices.swift b/Sources/DeviceKit/Services/LogServices.swift new file mode 100644 index 0000000..74f0dcf --- /dev/null +++ b/Sources/DeviceKit/Services/LogServices.swift @@ -0,0 +1,370 @@ +import Foundation +import ToolkitCore + +/// One decoded log line for display. `fields` carries structured values when the source has +/// them (Unified Logging); classic syslog lines only have `text`. +public struct LogLine: Sendable, Hashable { + public var timestamp: Date? + public var process: String? + public var pid: Int? + public var level: String? + public var subsystem: String? + public var category: String? + public var message: String + + public init(timestamp: Date? = nil, process: String? = nil, pid: Int? = nil, level: String? = nil, subsystem: String? = nil, category: String? = nil, message: String) { + self.timestamp = timestamp + self.process = process + self.pid = pid + self.level = level + self.subsystem = subsystem + self.category = category + self.message = message + } + + /// A single-line rendering used by the working view and filtered exports. + public var rendered: String { + var parts: [String] = [] + if let timestamp { parts.append(LogLine.timeFormatter.string(from: timestamp)) } + if let process { + parts.append(pid.map { "\(process)[\($0)]" } ?? process) + } + if let level { parts.append("<\(level)>") } + if let subsystem, !subsystem.isEmpty { + parts.append(category.map { "[\(subsystem):\($0)]" } ?? "[\(subsystem)]") + } + parts.append(message) + return parts.joined(separator: " ") + } + + /// A JSON object for the structured spool (one per line). + public var jsonLine: Data { + var object: [String: Any] = ["message": message] + if let timestamp { object["timestamp"] = ISO8601.string(timestamp) } + if let process { object["process"] = process } + if let pid { object["pid"] = pid } + if let level { object["level"] = level } + if let subsystem { object["subsystem"] = subsystem } + if let category { object["category"] = category } + var data = (try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys, .withoutEscapingSlashes])) ?? Data() + data.append(0x0A) + return data + } + + static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS" + return formatter + }() +} + +/// A chunk from a live log source: the bytes to spool verbatim plus decoded lines for display. +public struct LogChunk: Sendable { + public var spoolBytes: Data + public var lines: [LogLine] + + public init(spoolBytes: Data, lines: [LogLine]) { + self.spoolBytes = spoolBytes + self.lines = lines + } +} + +// MARK: - Classic syslog (com.apple.syslog_relay) + +/// Streams the classic syslog relay: NUL-terminated text records. The spool keeps the exact +/// bytes the device sent. +public enum SyslogRelay { + public static let serviceName = "com.apple.syslog_relay" + + public static func stream(_ session: DeviceSession) async throws -> AsyncThrowingStream { + let service = try await session.openService(serviceName) + return AsyncThrowingStream { continuation in + let task = Task { + var parser = SyslogRecordParser() + do { + while !Task.isCancelled, let data = try await service.channel.readSome() { + continuation.yield(LogChunk(spoolBytes: data, lines: parser.consume(data))) + } + let remainder = parser.flush() + if !remainder.isEmpty { continuation.yield(LogChunk(spoolBytes: Data(), lines: remainder)) } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + await service.close() + } + continuation.onTermination = { _ in task.cancel() } + } + } +} + +/// Splits the syslog relay byte stream into records and decodes the escaping it uses. +public struct SyslogRecordParser: Sendable { + private var pending = Data() + /// Upper bound for one record, so a missing terminator cannot grow memory without limit. + public var maximumRecordLength = 64 * 1024 + + public init() {} + + public mutating func consume(_ data: Data) -> [LogLine] { + pending.append(data) + var lines: [LogLine] = [] + while let terminator = pending.firstIndex(of: 0) { + let record = pending[pending.startIndex.. maximumRecordLength { + lines.append(contentsOf: Self.decode(pending)) + pending.removeAll() + } + return lines + } + + public mutating func flush() -> [LogLine] { + defer { pending.removeAll() } + return pending.isEmpty ? [] : Self.decode(pending) + } + + static func decode(_ record: Data) -> [LogLine] { + let text = unescape(String(decoding: record, as: UTF8.self)) + return text.split(separator: "\n", omittingEmptySubsequences: true).map { LogLine(message: String($0)) } + } + + /// The relay escapes non-printable bytes as `\\` sequences (e.g. `\M-^@`, `\^[`, `\134`). + /// Common sequences are restored; unknown ones are left visible rather than guessed. + static func unescape(_ text: String) -> String { + guard text.contains("\\") else { return text } + var result = text + result = result.replacingOccurrences(of: "\\134", with: "\\") + result = result.replacingOccurrences(of: "\\^[", with: "") + result = result.replacingOccurrences(of: "\\M-^@", with: "") + return result + } +} + +// MARK: - Unified Logging (com.apple.os_trace_relay) + +/// Streams structured Unified Logging records. The device sends a length-prefixed plist reply +/// followed by binary records (`0x02`, 32-bit little-endian length, payload). Each payload is +/// decoded into process, level, subsystem, category, and message; the spool stores the decoded +/// records as JSON lines. +public enum OSTraceRelay { + public static let serviceName = "com.apple.os_trace_relay" + + public static func stream(_ session: DeviceSession, pid: Int = -1) async throws -> AsyncThrowingStream { + let service = try await session.openService(serviceName) + let request: PlistValue = ["Request": "StartActivity", "MessageFilter": 65_535, "Pid": .integer(Int64(pid)), "StreamFlags": 60] + try await service.messages.send(request, format: .binary) + let status = try await readStartReply(service.channel) + guard status["Status"]?.stringValue == "RequestSuccessful" else { + await service.close() + throw ToolkitError(.serviceUnavailable, message: "The device declined to start the Unified Logging stream.", technicalDetail: status.prettyJSONString()) + } + return AsyncThrowingStream { continuation in + let task = Task { + do { + while !Task.isCancelled, try await service.channel.hasMoreData() { + let marker = try await service.channel.read(exactly: 1, timeout: nil) + guard marker.first == 0x02 else { + throw ToolkitError(.protocolViolation, message: "The Unified Logging stream lost synchronization.", technicalDetail: "marker=\(marker.first ?? 0)") + } + let length = Int(try await service.channel.read(exactly: 4, timeout: nil).readLittleEndianUInt32(at: 0)) + guard length > 0, length <= 1_048_576 else { + throw ToolkitError(.protocolViolation, message: "The device sent an invalid log record.", technicalDetail: "length=\(length)") + } + let payload = try await service.channel.read(exactly: length, timeout: 60) + let line = OSTraceRecordParser.parse(payload) + continuation.yield(LogChunk(spoolBytes: line.jsonLine, lines: [line])) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + await service.close() + } + continuation.onTermination = { _ in task.cancel() } + } + } + + /// The device's running processes (`PidList`). Works over lockdown on any trusted device, + /// without Xcode or a developer image. The reply is one leading byte, then a 4-byte big-endian + /// length and a plist whose `Payload` maps each process ID to details such as `ProcessName`. + public static func processList(_ session: DeviceSession) async throws -> [DeviceProcessInfo] { + let service = try await session.openService(serviceName) + defer { Task { await service.close() } } + try await service.messages.send(["Request": "PidList"]) + _ = try await service.channel.read(exactly: 1, timeout: 30) + let length = Int(try await service.channel.read(exactly: 4, timeout: 30).readBigEndianUInt32(at: 0)) + guard length > 0, length <= 16 << 20 else { + throw ToolkitError(.protocolViolation, message: "The device sent an invalid process list.", technicalDetail: "length=\(length)") + } + return try parseProcessList(PlistValue.decode(try await service.channel.read(exactly: length, timeout: 60))) + } + + static func parseProcessList(_ reply: PlistValue) throws -> [DeviceProcessInfo] { + guard let payload = reply["Payload"]?.dictionaryValue else { + throw ToolkitError(.serviceUnavailable, message: "The device did not return its process list.", recovery: "Unlock the device and try again.", technicalDetail: reply.prettyJSONString()) + } + return payload.compactMap { key, value -> DeviceProcessInfo? in + guard let pid = Int(key) else { return nil } + return DeviceProcessInfo(pid: pid, name: value["ProcessName"]?.stringValue ?? "PID \(pid)") + }.sorted { $0.pid < $1.pid } + } + + /// The start reply is a plist preceded by a 4-byte little-endian size-of-length field and + /// a little-endian length of that size. + static func readStartReply(_ channel: DeviceChannel) async throws -> PlistValue { + let lengthSize = Int(try await channel.read(exactly: 4).readLittleEndianUInt32(at: 0)) + guard (1...8).contains(lengthSize) else { + throw ToolkitError(.protocolViolation, message: "The Unified Logging service replied unexpectedly.", technicalDetail: "length size \(lengthSize)") + } + let lengthBytes = try await channel.read(exactly: lengthSize) + var length = 0 + for (index, byte) in lengthBytes.enumerated() { length |= Int(byte) << (8 * index) } + guard length > 0, length <= 1_048_576 else { + throw ToolkitError(.protocolViolation, message: "The Unified Logging service replied with an invalid length.") + } + return try PlistValue.decode(try await channel.read(exactly: length)) + } +} + +/// Decodes one os_trace_relay record. All offsets are bounds-checked; if a record does not have +/// the expected layout, the printable text inside it is returned so nothing is silently lost. +public enum OSTraceRecordParser { + static let pidOffset = 9 + static let secondsOffset = 55 + static let microsecondsOffset = 63 + static let levelOffset = 68 + static let imageNameSizeOffset = 107 + static let messageSizeOffset = 109 + static let subsystemSizeOffset = 117 + static let categorySizeOffset = 121 + static let stringsOffset = 129 + + public static func parse(_ record: Data) -> LogLine { + let bytes = [UInt8](record) + guard bytes.count > stringsOffset else { return fallback(bytes) } + let pid = Int(readUInt32(bytes, pidOffset)) + let seconds = TimeInterval(readUInt32(bytes, secondsOffset)) + let microseconds = TimeInterval(readUInt32(bytes, microsecondsOffset)) + let level = levelName(bytes[levelOffset]) + let imageNameSize = Int(readUInt16(bytes, imageNameSizeOffset)) + let messageSize = Int(readUInt16(bytes, messageSizeOffset)) + let subsystemSize = Int(readUInt32(bytes, subsystemSizeOffset)) + let categorySize = Int(readUInt32(bytes, categorySizeOffset)) + + var cursor = stringsOffset + guard let filename = readCString(bytes, &cursor), + let imageName = readSized(bytes, &cursor, imageNameSize), + let message = readSized(bytes, &cursor, messageSize) + else { return fallback(bytes) } + var subsystem: String? + var category: String? + if subsystemSize > 0 { + subsystem = readSized(bytes, &cursor, subsystemSize) + category = readSized(bytes, &cursor, categorySize) + } + let process = URL(fileURLWithPath: filename.isEmpty ? imageName : filename).lastPathComponent + let timestamp = seconds > 0 ? Date(timeIntervalSince1970: seconds + microseconds / 1_000_000) : nil + return LogLine(timestamp: timestamp, process: process.isEmpty ? nil : process, pid: pid, level: level, subsystem: subsystem, category: category, message: message) + } + + static func levelName(_ value: UInt8) -> String { + switch value { + case 0x00: return "Notice" + case 0x01: return "Info" + case 0x02: return "Debug" + case 0x03: return "User Action" + case 0x10: return "Error" + case 0x11: return "Fault" + default: return "Default" + } + } + + static func readUInt32(_ bytes: [UInt8], _ offset: Int) -> UInt32 { + guard offset + 4 <= bytes.count else { return 0 } + return UInt32(bytes[offset]) | UInt32(bytes[offset + 1]) << 8 | UInt32(bytes[offset + 2]) << 16 | UInt32(bytes[offset + 3]) << 24 + } + + static func readUInt16(_ bytes: [UInt8], _ offset: Int) -> UInt16 { + guard offset + 2 <= bytes.count else { return 0 } + return UInt16(bytes[offset]) | UInt16(bytes[offset + 1]) << 8 + } + + static func readCString(_ bytes: [UInt8], _ cursor: inout Int) -> String? { + guard cursor < bytes.count, let end = bytes[cursor...].firstIndex(of: 0) else { return nil } + let value = String(decoding: bytes[cursor.. String? { + guard size >= 0, cursor + size <= bytes.count else { return nil } + var slice = bytes[cursor..<(cursor + size)] + cursor += size + while let last = slice.last, last == 0 { slice = slice.dropLast() } + return String(decoding: slice, as: UTF8.self) + } + + static func fallback(_ bytes: [UInt8]) -> LogLine { + var runs: [String] = [] + var current: [UInt8] = [] + for byte in bytes { + if byte >= 0x20 && byte < 0x7F || byte >= 0x80 { + current.append(byte) + } else { + if current.count >= 4 { runs.append(String(decoding: current, as: UTF8.self)) } + current.removeAll() + } + } + if current.count >= 4 { runs.append(String(decoding: current, as: UTF8.self)) } + return LogLine(level: "Undecoded", message: runs.joined(separator: " ")) + } +} + +// MARK: - Simulator unified log (simctl spawn log stream --style ndjson) + +public enum SimulatorLogParser { + /// Parses NDJSON lines from `log stream --style ndjson`. Non-JSON lines (such as the + /// "Filtering the log data" banner) are kept as plain messages. + public static func parse(line: Substring) -> LogLine? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + guard trimmed.first == "{", let json = try? JSONValue.parse(Data(trimmed.utf8)) else { + return LogLine(message: trimmed) + } + let process = json["processImagePath"]?.string.map { URL(fileURLWithPath: $0).lastPathComponent } + let timestamp = json["timestamp"]?.string.flatMap(parseTimestamp) + return LogLine( + timestamp: timestamp, + process: process, + pid: json["processID"]?.int, + level: json["messageType"]?.string, + subsystem: json["subsystem"]?.nonEmptyString, + category: json["category"]?.nonEmptyString, + message: json["eventMessage"]?.string ?? "" + ) + } + + static func parseTimestamp(_ value: String) -> Date? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSSZ" + return formatter.date(from: value) + } +} + +/// A process reported by the device's `os_trace_relay` process list. +public struct DeviceProcessInfo: Sendable, Hashable, Codable, Identifiable { + public var id: Int { pid } + public var pid: Int + public var name: String + + public init(pid: Int, name: String) { + self.pid = pid + self.name = name + } +} diff --git a/Sources/DeviceKit/Services/MobileBackup2.swift b/Sources/DeviceKit/Services/MobileBackup2.swift new file mode 100644 index 0000000..d27196e --- /dev/null +++ b/Sources/DeviceKit/Services/MobileBackup2.swift @@ -0,0 +1,476 @@ +import Foundation +import OSLog +import ToolkitCore + +/// Progress and status reported during a backup. +public enum BackupEvent: Sendable, Equatable { + case status(String) + case progress(Double) + case encryption(Bool) + case bytesReceived(Int64) + case finished(URL) +} + +public struct BackupOptions: Sendable { + /// The folder that will contain `/` (the standard Finder/iTunes backup layout). + public var destinationRoot: URL + public var forceFullBackup: Bool + + public init(destinationRoot: URL, forceFullBackup: Bool) { + self.destinationRoot = destinationRoot + self.forceFullBackup = forceFullBackup + } +} + +/// A native client for `com.apple.mobilebackup2` (the DeviceLink protocol Finder uses). +/// +/// Every path the device names is resolved beneath the chosen backup folder with +/// `SecureFileIO.safeChild`, so a malformed or hostile request cannot read or write outside it. +public enum MobileBackup2 { + public static let serviceName = "com.apple.mobilebackup2" + static let logger = ToolkitLog.backup + + // DeviceLink file-transfer codes. + static let codeSuccess: UInt8 = 0x00 + static let codeErrorLocal: UInt8 = 0x06 + static let codeErrorRemote: UInt8 = 0x0B + static let codeFileData: UInt8 = 0x0C + static let emptyParameter = "___EmptyParameterString___" + + // MARK: Public operations + + /// Whether the device will encrypt local backups (a persistent device setting). + public static func isEncryptionEnabled(_ session: DeviceSession) async throws -> Bool { + guard let value = try await session.getValue(domain: "com.apple.mobile.backup", key: "WillEncrypt")?.boolValue else { + throw ToolkitError(.serviceUnavailable, message: "The device did not report its backup encryption setting.") + } + return value + } + + /// Runs a backup into `options.destinationRoot/`. + public static func backup(_ session: DeviceSession, options: BackupOptions, events: @escaping @Sendable (BackupEvent) -> Void) async throws -> URL { + let target = session.target + try SecureFileIO.createPrivateDirectory(at: options.destinationRoot) + let backupDirectory = try SecureFileIO.safeChild(of: options.destinationRoot, relativePath: target.udid) + try SecureFileIO.createPrivateDirectory(at: backupDirectory) + + let encrypted = try await isEncryptionEnabled(session) + events(.encryption(encrypted)) + + events(.status("Reading device information for the backup record…")) + let infoPlist = try await makeInfoPlist(session) + try SecureFileIO.writeAtomically(try infoPlist.encoded(format: .xml), to: backupDirectory.appendingPathComponent("Info.plist")) + + let isIncremental = !options.forceFullBackup && FileManager.default.fileExists(atPath: backupDirectory.appendingPathComponent("Status.plist").path) + events(.status(isIncremental ? "Starting an incremental backup…" : "Starting a full backup…")) + + let lock = try await SyncLock.acquire(session) + do { + let link = try await DeviceLink.open(session) + defer { Task { await link.close() } } + var backupOptions: [String: PlistValue] = [:] + if !isIncremental { backupOptions["ForceFullBackup"] = true } + try await link.processMessage([ + "MessageName": "Backup", + "TargetIdentifier": .string(target.udid), + "SourceIdentifier": .string(target.udid), + "Options": .dictionary(backupOptions), + ]) + try await link.runMessageLoop(root: options.destinationRoot, events: events) + await lock.release() + } catch { + await lock.release() + throw error + } + events(.progress(100)) + events(.finished(backupDirectory)) + logger.info("Backup completed") + return backupDirectory + } + + /// Turns on backup encryption with a new password. The password travels only inside the + /// encrypted lockdown service connection; it is never logged or written to disk. + public static func enableEncryption(_ session: DeviceSession, newPassword: String, events: @escaping @Sendable (BackupEvent) -> Void) async throws { + guard !newPassword.isEmpty else { throw ToolkitError.invalidInput("Enter a new backup password.") } + if try await isEncryptionEnabled(session) { + events(.status("Backup encryption is already on; the existing password was not changed.")) + return + } + let scratch = try SecureFileIO.makeTemporaryDirectory(prefix: "idt-backup-password") + defer { try? FileManager.default.removeItem(at: scratch) } + let link = try await DeviceLink.open(session) + defer { Task { await link.close() } } + events(.status("Asking the device to turn on backup encryption. Enter the device passcode on the device if asked.")) + try await link.processMessage([ + "MessageName": "ChangePassword", + "TargetIdentifier": .string(session.target.udid), + "Options": ["NewPassword": .string(newPassword)], + ]) + try await link.runMessageLoop(root: scratch, events: events) + guard try await isEncryptionEnabled(session) else { + throw ToolkitError(.commandFailed, message: "The device did not confirm that backup encryption is on.", recovery: "Unlock the device, enter the passcode if asked, and try again.") + } + events(.encryption(true)) + } + + // MARK: Info.plist + + static func makeInfoPlist(_ session: DeviceSession) async throws -> PlistValue { + let values = try await session.getValue() ?? .dictionary([:]) + let udid = session.target.udid + var info: [String: PlistValue] = [ + "Target Identifier": .string(udid), + "Unique Identifier": .string(udid.uppercased()), + "Target Type": "Device", + "GUID": .string(UUID().uuidString.replacingOccurrences(of: "-", with: "")), + "Last Backup Date": .date(Date()), + "iTunes Version": "12.13.0", + "Backup Tool": "iOS Developer Toolkit", + ] + let mapping = [ + "Device Name": "DeviceName", "Display Name": "DeviceName", "Product Type": "ProductType", + "Product Version": "ProductVersion", "Build Version": "BuildVersion", "Serial Number": "SerialNumber", + ] + for (infoKey, lockdownKey) in mapping { + if let value = values[lockdownKey]?.stringValue { info[infoKey] = .string(value) } + } + if let proxy = try? await InstallationProxy.open(session) { + let apps = (try? await proxy.browse(includeSizes: false, applicationType: "User")) ?? [] + await proxy.close() + info["Installed Applications"] = .array(apps.map { .string($0.bundleIdentifier) }) + } + return .dictionary(info) + } + + // MARK: Sync lock + + /// Prevents a concurrent sync while the backup runs, as Finder does. + struct SyncLock: Sendable { + let notifications: NotificationProxy? + let afc: AFCClient? + let handle: UInt64? + + static func acquire(_ session: DeviceSession) async throws -> SyncLock { + let notifications = try? await NotificationProxy.open(session) + try? await notifications?.post("com.apple.itunes-mobdev.syncWillStart") + guard let afc = try? await AFCClient.openMedia(session), + let handle = try? await afc.open("/com.apple.itunes.lock_sync", mode: .readWrite) + else { + return SyncLock(notifications: notifications, afc: nil, handle: nil) + } + try? await notifications?.post("com.apple.itunes-mobdev.syncLockRequest") + var locked = false + for _ in 0..<50 { + if (try? await afc.lock(handle: handle, operation: .exclusive)) != nil { + locked = true + break + } + try await Task.sleep(nanoseconds: 200_000_000) + } + guard locked else { + try? await afc.close(handle: handle) + await afc.close() + try? await notifications?.post("com.apple.itunes-mobdev.syncDidFinish") + await notifications?.close() + throw ToolkitError(.serviceUnavailable, message: "Another sync or backup is using the device.", recovery: "Wait for Finder or another tool to finish, then try again.") + } + try? await notifications?.post("com.apple.itunes-mobdev.syncDidStart") + return SyncLock(notifications: notifications, afc: afc, handle: handle) + } + + func release() async { + if let afc, let handle { + try? await afc.lock(handle: handle, operation: .unlock) + try? await afc.close(handle: handle) + await afc.close() + } + try? await notifications?.post("com.apple.itunes-mobdev.syncDidFinish") + await notifications?.close() + } + } +} + +/// The DeviceLink message loop shared by backup and password changes. +actor DeviceLink { + private let connection: ServiceConnection + private var messages: PlistMessageConnection { connection.messages } + private var channel: DeviceChannel { connection.channel } + + init(connection: ServiceConnection) { + self.connection = connection + } + + static func open(_ session: DeviceSession) async throws -> DeviceLink { + let connection = try await session.openService(MobileBackup2.serviceName, useEscrowBag: true) + let link = DeviceLink(connection: connection) + do { + try await link.handshake() + } catch { + await link.close() + throw error + } + return link + } + + func handshake() async throws { + let exchange = try await messages.receive(timeout: 30) + guard exchange[0]?.stringValue == "DLMessageVersionExchange", let major = exchange[1] else { + throw ToolkitError(.protocolViolation, message: "The backup service did not start correctly.", technicalDetail: exchange.prettyJSONString()) + } + try await messages.send(["DLMessageVersionExchange", "DLVersionsOk", major], format: .binary) + let ready = try await messages.receive(timeout: 30) + guard ready[0]?.stringValue == "DLMessageDeviceReady" else { + throw ToolkitError(.protocolViolation, message: "The backup service is not ready.", technicalDetail: ready.prettyJSONString()) + } + try await processMessage(["MessageName": "Hello", "SupportedProtocolVersions": [2.0, 2.1]]) + let hello = try await messages.receive(timeout: 30) + let code = hello[1]?["ErrorCode"]?.intValue ?? -1 + guard code == 0 else { + throw ToolkitError(.unsupported, message: "The device does not support this backup protocol version.", technicalDetail: hello.prettyJSONString()) + } + } + + func processMessage(_ body: PlistValue) async throws { + try await messages.send(["DLMessageProcessMessage", body], format: .binary) + } + + func sendStatus(_ code: Int, description: String? = nil, payload: PlistValue = .dictionary([:])) async throws { + try await messages.send(["DLMessageStatusResponse", .integer(Int64(code)), .string(description ?? MobileBackup2.emptyParameter), payload], format: .binary) + } + + /// Handles device requests until the final `DLMessageProcessMessage` or disconnect. + func runMessageLoop(root: URL, events: @escaping @Sendable (BackupEvent) -> Void) async throws { + var receivedBytes: Int64 = 0 + while true { + try Task.checkCancellation() + let message = try await messages.receive(timeout: 600) + guard let name = message[0]?.stringValue else { + throw ToolkitError(.protocolViolation, message: "The backup service sent an unreadable request.") + } + if let progress = Self.progress(in: message) { events(.progress(progress)) } + switch name { + case "DLMessageDownloadFiles": + try await sendFiles(message[1]?.arrayValue?.compactMap(\.stringValue) ?? [], root: root) + case "DLMessageUploadFiles": + let count = try await receiveFiles(root: root) + receivedBytes += count + events(.bytesReceived(receivedBytes)) + case "DLMessageGetFreeDiskSpace": + let values = try? root.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) + try await sendStatus(0, payload: .integer(values?.volumeAvailableCapacityForImportantUsage ?? 0)) + case "DLMessageContentsOfDirectory": + try await sendDirectoryContents(message[1]?.stringValue ?? "", root: root) + case "DLMessageCreateDirectory": + try await perform(root: root) { + let url = try SecureFileIO.safeChild(of: root, relativePath: message[1]?.stringValue ?? "") + try SecureFileIO.createPrivateDirectory(at: url) + } + case "DLMessageMoveFiles", "DLMessageMoveItems": + let moves = message[1]?.dictionaryValue ?? [:] + try await perform(root: root) { + for (source, destinationValue) in moves { + guard let destination = destinationValue.stringValue else { continue } + let from = try SecureFileIO.safeChild(of: root, relativePath: source) + let to = try SecureFileIO.safeChild(of: root, relativePath: destination) + if FileManager.default.fileExists(atPath: to.path) { try FileManager.default.removeItem(at: to) } + try SecureFileIO.createPrivateDirectory(at: to.deletingLastPathComponent()) + try FileManager.default.moveItem(at: from, to: to) + } + } + case "DLMessageRemoveFiles", "DLMessageRemoveItems": + let paths = message[1]?.arrayValue?.compactMap(\.stringValue) ?? [] + try await perform(root: root) { + for path in paths { + let url = try SecureFileIO.safeChild(of: root, relativePath: path) + if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) } + } + } + case "DLMessageCopyItem": + try await perform(root: root) { + let from = try SecureFileIO.safeChild(of: root, relativePath: message[1]?.stringValue ?? "") + let to = try SecureFileIO.safeChild(of: root, relativePath: message[2]?.stringValue ?? "") + if FileManager.default.fileExists(atPath: to.path) { try FileManager.default.removeItem(at: to) } + try FileManager.default.copyItem(at: from, to: to) + } + case "DLMessageDisconnect": + return + case "DLMessageProcessMessage": + let result = message[1] ?? .dictionary([:]) + let code = result["ErrorCode"]?.intValue ?? 0 + if code == 0 { return } + throw MobileBackup2Errors.interpret(code: code, description: result["ErrorDescription"]?.stringValue) + default: + MobileBackup2.logger.error("Unhandled DeviceLink message \(name, privacy: .public)") + try await sendStatus(-1, description: "Operation not supported") + } + } + } + + static func progress(in message: PlistValue) -> Double? { + guard let items = message.arrayValue, items.count > 2 else { return nil } + for index in [3, 2] where index < items.count { + if case .real(let value) = items[index], value.isFinite, (0...100).contains(value) { return value } + } + return nil + } + + private func perform(root: URL, _ body: () throws -> Void) async throws { + do { + try body() + try await sendStatus(0) + } catch let error as ToolkitError where error.kind == .protocolViolation { + MobileBackup2.logger.error("Rejected unsafe backup path") + try await sendStatus(-1, description: "Rejected unsafe path") + } catch { + try await sendStatus(Self.deviceErrorCode(error), description: error.localizedDescription) + } + } + + static func deviceErrorCode(_ error: Error) -> Int { + let nsError = error as NSError + let posix = (nsError.userInfo[NSUnderlyingErrorKey] as? NSError)?.code ?? nsError.code + switch Int32(truncatingIfNeeded: posix) { + case ENOENT: return -6 + case EEXIST: return -7 + case ENOTDIR: return -8 + case EISDIR: return -9 + case ELOOP: return -10 + case EIO: return -11 + case ENOSPC: return -15 + default: + if nsError.domain == NSCocoaErrorDomain, nsError.code == NSFileNoSuchFileError || nsError.code == NSFileReadNoSuchFileError { return -6 } + return -1 + } + } + + // MARK: File transfer + + private func writeLength(_ value: Int) async throws { + var data = Data() + data.appendBigEndian(UInt32(value)) + try await channel.write(data) + } + + /// Host → device: send the requested files (for incremental backups). + private func sendFiles(_ paths: [String], root: URL) async throws { + var errors: [String: PlistValue] = [:] + for path in paths { + try await writeLength(path.utf8.count) + try await channel.write(Data(path.utf8)) + let url = try? SecureFileIO.safeChild(of: root, relativePath: path) + guard let url, let handle = try? FileHandle(forReadingFrom: url) else { + let message = "No such file or directory" + try await writeLength(message.utf8.count + 1) + try await channel.write(Data([MobileBackup2.codeErrorLocal]) + Data(message.utf8)) + errors[path] = ["DLFileErrorString": .string(message), "DLFileErrorCode": -6] + continue + } + defer { try? handle.close() } + while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty { + try await writeLength(chunk.count + 1) + try await channel.write(Data([MobileBackup2.codeFileData]) + chunk) + } + try await writeLength(1) + try await channel.write(Data([MobileBackup2.codeSuccess])) + } + try await writeLength(0) + if errors.isEmpty { + try await sendStatus(0) + } else { + try await sendStatus(-13, description: "Multi status", payload: .dictionary(errors)) + } + } + + private func readLength() async throws -> Int { + Int(try await channel.read(exactly: 4, timeout: 600).readBigEndianUInt32(at: 0)) + } + + /// Device → host: receive files into the backup folder. Returns the number of bytes written. + private func receiveFiles(root: URL) async throws -> Int64 { + var total: Int64 = 0 + var errors: [String: PlistValue] = [:] + while true { + try Task.checkCancellation() + let deviceNameLength = try await readLength() + if deviceNameLength == 0 { break } + guard deviceNameLength < 4096 else { throw ToolkitError(.protocolViolation, message: "The backup service sent an invalid file name.") } + _ = try await channel.read(exactly: deviceNameLength, timeout: 600) + let hostNameLength = try await readLength() + guard hostNameLength > 0, hostNameLength < 4096 else { throw ToolkitError(.protocolViolation, message: "The backup service sent an invalid file name.") } + let hostName = String(decoding: try await channel.read(exactly: hostNameLength, timeout: 600), as: UTF8.self) + let destination = try SecureFileIO.safeChild(of: root, relativePath: hostName) + try SecureFileIO.createPrivateDirectory(at: destination.deletingLastPathComponent()) + if FileManager.default.fileExists(atPath: destination.path) { try FileManager.default.removeItem(at: destination) } + try SecureFileIO.writeNewFile(Data(), to: destination) + let output = try FileHandle(forWritingTo: destination) + defer { try? output.close() } + while true { + let blockLength = try await readLength() + let code = try await channel.read(exactly: 1, timeout: 600)[0] + let payloadLength = max(0, blockLength - 1) + if code == MobileBackup2.codeFileData { + let payload = try await channel.read(exactly: payloadLength, timeout: 600) + try output.write(contentsOf: payload) + total += Int64(payload.count) + } else if code == MobileBackup2.codeSuccess { + if payloadLength > 0 { _ = try await channel.read(exactly: payloadLength, timeout: 600) } + break + } else if code == MobileBackup2.codeErrorRemote || code == MobileBackup2.codeErrorLocal { + let message = payloadLength > 0 ? String(decoding: try await channel.read(exactly: payloadLength, timeout: 600), as: UTF8.self) : "Unknown error" + errors[hostName] = ["DLFileErrorString": .string(message), "DLFileErrorCode": -1] + try? FileManager.default.removeItem(at: destination) + break + } else { + throw ToolkitError(.protocolViolation, message: "The backup service sent an unexpected transfer code.", technicalDetail: "code=\(code)") + } + } + } + if errors.isEmpty { + try await sendStatus(0) + } else { + try await sendStatus(-13, description: "Multi status", payload: .dictionary(errors)) + } + return total + } + + private func sendDirectoryContents(_ path: String, root: URL) async throws { + guard let url = try? SecureFileIO.safeChild(of: root, relativePath: path.isEmpty ? "." : path) else { + try await sendStatus(-1, description: "Rejected unsafe path") + return + } + var entries: [String: PlistValue] = [:] + let names = (try? FileManager.default.contentsOfDirectory(atPath: url.path)) ?? [] + for name in names { + let child = url.appendingPathComponent(name) + let values = try? child.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey, .fileSizeKey, .contentModificationDateKey]) + let type = values?.isDirectory == true ? "DLFileTypeDirectory" : (values?.isRegularFile == true ? "DLFileTypeRegular" : "DLFileTypeUnknown") + entries[name] = [ + "DLFileType": .string(type), + "DLFileSize": .integer(Int64(values?.fileSize ?? 0)), + "DLFileModificationDate": .date(values?.contentModificationDate ?? Date(timeIntervalSince1970: 0)), + ] + } + try await sendStatus(0, payload: .dictionary(entries)) + } + + func close() async { + try? await messages.send(["DLMessageDisconnect", "___EmptyParameterString___"], format: .binary) + await connection.close() + } +} + +enum MobileBackup2Errors { + static func interpret(code: Int, description: String?) -> ToolkitError { + let detail = "MobileBackup2 error \(code): \(description ?? "")" + switch code { + case 208, -208: + return ToolkitError(.deviceLocked, message: "The device must stay unlocked during the backup.", recovery: "Unlock the device, keep it awake, and try again.", technicalDetail: detail) + case 105, -105: + return ToolkitError(.fileSystem, message: "There is not enough space on this Mac for the backup.", recovery: "Free up space or choose a different backup folder.", technicalDetail: detail) + case 207, -207: + return ToolkitError(.commandFailed, message: "The backup password was not accepted.", recovery: "Enter the passcode on the device when asked, or check the backup password.", technicalDetail: detail) + default: + return ToolkitError(.commandFailed, message: "The device ended the backup with an error.", recovery: "Keep the device unlocked and connected, then try again.", technicalDetail: detail) + } + } +} diff --git a/Sources/DeviceKit/Services/PacketCapture.swift b/Sources/DeviceKit/Services/PacketCapture.swift new file mode 100644 index 0000000..30e4bd5 --- /dev/null +++ b/Sources/DeviceKit/Services/PacketCapture.swift @@ -0,0 +1,169 @@ +import Foundation +import ToolkitCore + +/// One packet from `com.apple.pcapd`. +public struct CapturedPacket: Sendable, Hashable { + public var timestamp: Date + public var interfaceName: String + public var processName: String? + public var pid: Int? + public var isOutbound: Bool + public var protocolFamily: UInt32 + /// Link-layer frame ready to write to a DLT_EN10MB pcap file. + public var frame: Data +} + +/// Decodes pcapd records and writes classic libpcap files. +/// +/// pcapd delivers one plist per packet whose root is a data blob: a big-endian header (header +/// length, version, payload length, type, unit, direction, protocol family, frame pre/post +/// lengths, interface name, process identity, timestamp) followed by the packet. When the +/// packet has no link-layer header (pre-length 0), an Ethernet header is synthesized from the +/// protocol family so standard tools can open the file. +public enum PacketCaptureService { + public static let serviceName = "com.apple.pcapd" + + public static func stream(_ session: DeviceSession) async throws -> AsyncThrowingStream { + let service = try await session.openService(serviceName) + return AsyncThrowingStream { continuation in + let task = Task { + do { + while !Task.isCancelled, try await service.channel.hasMoreData() { + let message = try await service.messages.receive(timeout: nil) + guard let blob = message.dataValue else { continue } + if let packet = PcapdRecordParser.parse(blob) { + continuation.yield(packet) + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + await service.close() + } + continuation.onTermination = { _ in task.cancel() } + } + } +} + +public enum PcapdRecordParser { + public static let minimumHeaderLength = 95 + + public static func parse(_ blob: Data) -> CapturedPacket? { + let bytes = [UInt8](blob) + guard bytes.count >= 4 else { return nil } + let headerLength = Int(be32(bytes, 0)) + guard headerLength >= 23, headerLength <= bytes.count else { return nil } + let payloadLength = Int(be32(bytes, 5)) + let direction = bytes[12] + let family = be32(bytes, 13) + let preLength = Int(be32(bytes, 17)) + let interface = cString(bytes, 25, 16) + var pid: Int? + var process: String? + var timestamp = Date() + if headerLength >= minimumHeaderLength { + pid = Int(le32(bytes, 41)) + process = cString(bytes, 45, 17) + let seconds = TimeInterval(be32(bytes, 87)) + let microseconds = TimeInterval(be32(bytes, 91)) + if seconds > 0 { timestamp = Date(timeIntervalSince1970: seconds + microseconds / 1_000_000) } + } + let available = bytes.count - headerLength + let length = min(max(0, payloadLength), available) + let payload = Data(bytes[headerLength..<(headerLength + length)]) + let frame: Data + if preLength == 0 { + frame = ethernetHeader(family: family) + payload + } else { + frame = payload + } + return CapturedPacket( + timestamp: timestamp, + interfaceName: interface, + processName: process?.isEmpty == true ? nil : process, + pid: pid, + isOutbound: direction == 0x01, + protocolFamily: family, + frame: frame + ) + } + + static func ethernetHeader(family: UInt32) -> Data { + var header = Data(repeating: 0, count: 12) + switch family { + case 30: header.append(contentsOf: [0x86, 0xDD]) // AF_INET6 + default: header.append(contentsOf: [0x08, 0x00]) // AF_INET + } + return header + } + + static func be32(_ bytes: [UInt8], _ offset: Int) -> UInt32 { + guard offset + 4 <= bytes.count else { return 0 } + return UInt32(bytes[offset]) << 24 | UInt32(bytes[offset + 1]) << 16 | UInt32(bytes[offset + 2]) << 8 | UInt32(bytes[offset + 3]) + } + + static func le32(_ bytes: [UInt8], _ offset: Int) -> UInt32 { + guard offset + 4 <= bytes.count else { return 0 } + return UInt32(bytes[offset]) | UInt32(bytes[offset + 1]) << 8 | UInt32(bytes[offset + 2]) << 16 | UInt32(bytes[offset + 3]) << 24 + } + + static func cString(_ bytes: [UInt8], _ offset: Int, _ length: Int) -> String { + guard offset < bytes.count else { return "" } + let end = min(bytes.count, offset + length) + let slice = bytes[offset.. String { + try lock.withLock { + try handle.synchronize() + try handle.close() + return hasher.finalizeHex() + } + } +} diff --git a/Sources/DeviceKit/Services/PlistServices.swift b/Sources/DeviceKit/Services/PlistServices.swift new file mode 100644 index 0000000..3401099 --- /dev/null +++ b/Sources/DeviceKit/Services/PlistServices.swift @@ -0,0 +1,365 @@ +import Foundation +import ToolkitCore + +// MARK: - Diagnostics relay + +/// `com.apple.mobile.diagnostics_relay`: battery, IORegistry, MobileGestalt, and general +/// diagnostics. Restart/shutdown requests are intentionally not implemented. +public struct DiagnosticsRelay: Sendable { + public static let serviceName = "com.apple.mobile.diagnostics_relay" + let connection: ServiceConnection + + public static func open(_ session: DeviceSession) async throws -> DiagnosticsRelay { + DiagnosticsRelay(connection: try await session.openService(serviceName)) + } + + public func ioRegistry(entryClass: String? = nil, entryName: String? = nil, plane: String? = nil) async throws -> PlistValue { + var request: [String: PlistValue] = ["Request": "IORegistry"] + if let entryClass { request["EntryClass"] = .string(entryClass) } + if let entryName { request["EntryName"] = .string(entryName) } + if let plane { request["CurrentPlane"] = .string(plane) } + return try await diagnostics(.dictionary(request))["IORegistry"] ?? .dictionary([:]) + } + + /// A battery snapshot from the IOPMPowerSource registry entry. + public func battery() async throws -> PlistValue { + try await ioRegistry(entryClass: "IOPMPowerSource") + } + + public func mobileGestalt(keys: [String]) async throws -> PlistValue { + let reply = try await diagnostics(["Request": "MobileGestalt", "MobileGestaltKeys": .array(keys.map(PlistValue.string))]) + return reply["MobileGestalt"] ?? .dictionary([:]) + } + + public func all() async throws -> PlistValue { + try await diagnostics(["Request": "All"]) + } + + func diagnostics(_ request: PlistValue) async throws -> PlistValue { + let reply = try await connection.messages.request(request, timeout: 60) + guard reply["Status"]?.stringValue == "Success" else { + throw ToolkitError(.serviceUnavailable, message: "The device did not return diagnostics.", technicalDetail: "Status: \(reply["Status"]?.stringValue ?? "missing")") + } + return reply["Diagnostics"] ?? .dictionary([:]) + } + + public func close() async { + _ = try? await connection.messages.request(["Request": "Goodbye"], timeout: 5) + await connection.close() + } + + /// Keys that are useful and still answered on current iOS versions. + public static let defaultGestaltKeys = [ + "ActivationState", "BasebandFirmwareVersion", "BluetoothAddress", "BuildVersion", "CPUArchitecture", + "DeviceClass", "DeviceColor", "DeviceName", "DiskUsage", "HardwareModel", "HasBaseband", + "InternationalMobileEquipmentIdentity", "MLBSerialNumber", "ModelNumber", "PasswordProtected", + "ProductName", "ProductType", "ProductVersion", "RegionCode", "RegionInfo", "SerialNumber", + "UniqueChipID", "UniqueDeviceID", "WifiAddress", + ] +} + +/// Summarizes the IOPMPowerSource entry in plain language. +public struct BatterySummary: Sendable, Hashable { + public var percentage: Int? + public var isCharging: Bool? + public var externalConnected: Bool? + public var cycleCount: Int? + public var temperatureCelsius: Double? + public var designCapacity: Int? + public var maximumCapacity: Int? + + public init(registry: PlistValue) { + percentage = registry["CurrentCapacity"]?.intValue + isCharging = registry["IsCharging"]?.boolValue + externalConnected = registry["ExternalConnected"]?.boolValue + cycleCount = registry["CycleCount"]?.intValue + temperatureCelsius = registry["Temperature"]?.doubleValue.map { $0 / 100 } + designCapacity = registry["DesignCapacity"]?.intValue + maximumCapacity = registry["AppleRawMaxCapacity"]?.intValue ?? registry["NominalChargeCapacity"]?.intValue + } + + /// Estimated health as a percentage of design capacity, when both values are present. + public var healthPercentage: Int? { + guard let designCapacity, let maximumCapacity, designCapacity > 0 else { return nil } + return min(100, Int((Double(maximumCapacity) / Double(designCapacity) * 100).rounded())) + } +} + +// MARK: - Installation proxy + +public struct InstalledApplication: Sendable, Hashable, Identifiable { + public var id: String { bundleIdentifier } + public var bundleIdentifier: String + public var name: String + public var version: String? + public var build: String? + public var applicationType: String + public var staticBytes: Int64? + public var dynamicBytes: Int64? + public var path: String? + + public var totalBytes: Int64? { + let sizes = [staticBytes, dynamicBytes].compactMap { $0 } + return sizes.isEmpty ? nil : sizes.reduce(0, +) + } + + public var typeLabel: String { + switch applicationType { + case "User": return "Installed by user" + case "System": return "Built-in" + case "Hidden": return "Hidden system app" + default: return applicationType + } + } + + public init(bundleIdentifier: String, name: String, version: String?, build: String?, applicationType: String, staticBytes: Int64?, dynamicBytes: Int64?, path: String?) { + self.bundleIdentifier = bundleIdentifier + self.name = name + self.version = version + self.build = build + self.applicationType = applicationType + self.staticBytes = staticBytes + self.dynamicBytes = dynamicBytes + self.path = path + } + + public init?(plist: PlistValue) { + guard let identifier = plist["CFBundleIdentifier"]?.stringValue, !identifier.isEmpty else { return nil } + bundleIdentifier = identifier + name = plist["CFBundleDisplayName"]?.stringValue ?? plist["CFBundleName"]?.stringValue ?? identifier + version = plist["CFBundleShortVersionString"]?.stringValue + build = plist["CFBundleVersion"]?.stringValue + applicationType = plist["ApplicationType"]?.stringValue ?? "Unknown" + staticBytes = plist["StaticDiskUsage"]?.int64Value.flatMap { $0 >= 0 ? $0 : nil } + dynamicBytes = plist["DynamicDiskUsage"]?.int64Value.flatMap { $0 >= 0 ? $0 : nil } + path = plist["Path"]?.stringValue + } +} + +/// `com.apple.mobile.installation_proxy`. +public struct InstallationProxy: Sendable { + public static let serviceName = "com.apple.mobile.installation_proxy" + let connection: ServiceConnection + + public static func open(_ session: DeviceSession) async throws -> InstallationProxy { + InstallationProxy(connection: try await session.openService(serviceName)) + } + + public func browse(includeSizes: Bool, applicationType: String = "Any") async throws -> [InstalledApplication] { + var attributes = ["CFBundleIdentifier", "CFBundleDisplayName", "CFBundleName", "CFBundleShortVersionString", "CFBundleVersion", "ApplicationType", "Path"] + if includeSizes { attributes += ["StaticDiskUsage", "DynamicDiskUsage"] } + try await connection.messages.send([ + "Command": "Browse", + "ClientOptions": ["ApplicationType": .string(applicationType), "ReturnAttributes": .array(attributes.map(PlistValue.string))], + ]) + var apps: [InstalledApplication] = [] + while true { + let reply = try await connection.messages.receive(timeout: 120) + if let error = reply["Error"]?.stringValue { + throw ToolkitError(.serviceUnavailable, message: "The device could not list its apps.", technicalDetail: "\(error): \(reply["ErrorDescription"]?.stringValue ?? "")") + } + for item in reply["CurrentList"]?.arrayValue ?? [] { + if let app = InstalledApplication(plist: item) { apps.append(app) } + } + if reply["Status"]?.stringValue == "Complete" { break } + } + return apps.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + /// Uninstalls an app, reporting progress (0–100). + public func uninstall(bundleIdentifier: String, progress: @Sendable (Int) -> Void = { _ in }) async throws { + try BundleIdentifier.validate(bundleIdentifier) + try await connection.messages.send(["Command": "Uninstall", "ApplicationIdentifier": .string(bundleIdentifier)]) + try await awaitCompletion(operation: "Removing the app", progress: progress) + } + + /// Installs a package previously uploaded to `PublicStaging` with AFC. + public func install(stagedPackagePath: String, developerPackage: Bool, progress: @Sendable (Int) -> Void = { _ in }) async throws { + var options: [String: PlistValue] = [:] + if developerPackage { options["PackageType"] = "Developer" } + try await connection.messages.send(["Command": "Install", "PackagePath": .string(stagedPackagePath), "ClientOptions": .dictionary(options)]) + try await awaitCompletion(operation: "Installing the app", progress: progress) + } + + func awaitCompletion(operation: String, progress: @Sendable (Int) -> Void) async throws { + while true { + let reply = try await connection.messages.receive(timeout: 900) + if let error = reply["Error"]?.stringValue { + throw InstallationProxy.interpret(error: error, description: reply["ErrorDescription"]?.stringValue, operation: operation) + } + if let percent = reply["PercentComplete"]?.intValue { progress(max(0, min(100, percent))) } + if reply["Status"]?.stringValue == "Complete" { + progress(100) + return + } + } + } + + static func interpret(error: String, description: String?, operation: String) -> ToolkitError { + let detail = "\(error): \(description ?? "")" + switch error { + case "ApplicationVerificationFailed": + return ToolkitError(.commandFailed, message: "iOS rejected the app's signature.", recovery: "The app must be signed with a provisioning profile that includes this device. Re-sign or rebuild it in Xcode.", technicalDetail: detail) + case "DeviceOSVersionTooLow": + return ToolkitError(.unsupported, message: "The app requires a newer iOS version than the device has.", technicalDetail: detail) + case "APIInternalError", "InstallProhibited": + return ToolkitError(.commandFailed, message: "iOS did not allow the installation.", recovery: "Check device management restrictions and available storage, then try again.", technicalDetail: detail) + default: + return ToolkitError(.commandFailed, message: "\(operation) failed on the device.", recovery: "Review the technical details for the device's reason.", technicalDetail: detail) + } + } + + public func close() async { await connection.close() } +} + +// MARK: - Configuration profiles (MCInstall) + +/// An installed configuration profile, as reported by `com.apple.mobile.MCInstall`. +public struct InstalledConfigurationProfile: Sendable, Hashable, Codable, Identifiable { + public var id: String { identifier } + public var identifier: String + public var displayName: String? + public var organization: String? + public var description: String? + public var uuid: String? + public var version: Int? + public var removalDisallowed: Bool? + public var isActive: Bool? +} + +/// `com.apple.mobile.MCInstall`: lists installed configuration profiles (`GetProfileList`) over +/// lockdown, without Xcode. Read-only use only; the toolkit never installs or removes profiles. +public struct ConfigurationProfileService: Sendable { + public static let serviceName = "com.apple.mobile.MCInstall" + let connection: ServiceConnection + + public static func open(_ session: DeviceSession) async throws -> ConfigurationProfileService { + ConfigurationProfileService(connection: try await session.openService(serviceName)) + } + + public func profiles() async throws -> [InstalledConfigurationProfile] { + try Self.parse(try await connection.messages.request(["RequestType": "GetProfileList"], timeout: 60)) + } + + static func parse(_ reply: PlistValue) throws -> [InstalledConfigurationProfile] { + guard reply["Status"]?.stringValue == "Acknowledged" else { + throw ToolkitError(.serviceUnavailable, message: "The device did not list its configuration profiles.", recovery: "Unlock the device and try again.", technicalDetail: reply.prettyJSONString()) + } + let metadata = reply["ProfileMetadata"]?.dictionaryValue ?? [:] + let manifest = reply["ProfileManifest"]?.dictionaryValue ?? [:] + let ordered = (reply["OrderedIdentifiers"]?.arrayValue ?? []).compactMap(\.stringValue) + let identifiers = ordered + metadata.keys.filter { !ordered.contains($0) }.sorted() + return identifiers.map { identifier in + let item = metadata[identifier] + return InstalledConfigurationProfile( + identifier: identifier, + displayName: item?["PayloadDisplayName"]?.stringValue, + organization: item?["PayloadOrganization"]?.stringValue, + description: item?["PayloadDescription"]?.stringValue, + uuid: item?["PayloadUUID"]?.stringValue, + version: item?["PayloadVersion"]?.intValue, + removalDisallowed: item?["PayloadRemovalDisallowed"]?.boolValue, + isActive: manifest[identifier]?["IsActive"]?.boolValue + ) + } + } + + public func close() async { await connection.close() } +} + +// MARK: - Provisioning profiles (misagent) + +/// `com.apple.misagent`: installed provisioning profiles (CMS-signed payloads). +public struct ProvisioningProfileService: Sendable { + public static let serviceName = "com.apple.misagent" + let connection: ServiceConnection + + public static func open(_ session: DeviceSession) async throws -> ProvisioningProfileService { + ProvisioningProfileService(connection: try await session.openService(serviceName)) + } + + public func copyAll() async throws -> [Data] { + let reply = try await connection.messages.request(["MessageType": "CopyAll", "ProfileType": "Provisioning"], timeout: 60) + if let status = reply["Status"]?.intValue, status != 0 { + throw ToolkitError(.serviceUnavailable, message: "The device did not list provisioning profiles.", technicalDetail: "misagent status \(status)") + } + return (reply["Payload"]?.arrayValue ?? []).compactMap(\.dataValue) + } + + public func close() async { await connection.close() } +} + +// MARK: - SpringBoard services + +public struct SpringBoardServices: Sendable { + public static let serviceName = "com.apple.springboardservices" + let connection: ServiceConnection + + public static func open(_ session: DeviceSession) async throws -> SpringBoardServices { + SpringBoardServices(connection: try await session.openService(serviceName)) + } + + public enum Orientation: Int, Sendable { + case unknown = 0, portrait = 1, portraitUpsideDown = 2, landscapeLeft = 3, landscapeRight = 4 + + public var label: String { + switch self { + case .unknown: return "Unknown" + case .portrait: return "Portrait" + case .portraitUpsideDown: return "Portrait (upside down)" + case .landscapeLeft: return "Landscape (left)" + case .landscapeRight: return "Landscape (right)" + } + } + } + + public func interfaceOrientation() async throws -> Orientation { + let reply = try await connection.messages.request(["command": "getInterfaceOrientation"]) + return Orientation(rawValue: reply["interfaceOrientation"]?.intValue ?? 0) ?? .unknown + } + + public func homeScreenIconMetrics() async throws -> PlistValue { + try await connection.messages.request(["command": "getHomeScreenIconMetrics"]) + } + + public func iconPNG(bundleIdentifier: String) async throws -> Data? { + try BundleIdentifier.validate(bundleIdentifier) + return try await connection.messages.request(["command": "getIconPNGData", "bundleId": .string(bundleIdentifier)])["pngData"]?.dataValue + } + + public func close() async { await connection.close() } +} + +// MARK: - Notification proxy + +public struct NotificationProxy: Sendable { + public static let serviceName = "com.apple.mobile.notification_proxy" + let connection: ServiceConnection + + public static func open(_ session: DeviceSession) async throws -> NotificationProxy { + NotificationProxy(connection: try await session.openService(serviceName)) + } + + public func post(_ name: String) async throws { + try await connection.messages.send(["Command": "PostNotification", "Name": .string(name)]) + } + + public func observe(_ name: String) async throws { + try await connection.messages.send(["Command": "ObserveNotification", "Name": .string(name)]) + } + + /// Waits for the next relayed notification name. + public func nextNotification(timeout: TimeInterval?) async throws -> String? { + let message = try await connection.messages.receive(timeout: timeout) + if message["Command"]?.stringValue == "RelayNotification" { + return message["Name"]?.stringValue + } + if message["Command"]?.stringValue == "ProxyDeath" { return nil } + return message["Name"]?.stringValue + } + + public func close() async { + try? await connection.messages.send(["Command": "Shutdown"]) + await connection.close() + } +} diff --git a/Sources/DeviceKit/Services/WebInspector.swift b/Sources/DeviceKit/Services/WebInspector.swift new file mode 100644 index 0000000..5f7bd55 --- /dev/null +++ b/Sources/DeviceKit/Services/WebInspector.swift @@ -0,0 +1,182 @@ +import Foundation +import OSLog +import ToolkitCore + +/// An inspectable page (Safari tab, web view, JavaScript context) reported by Web Inspector. +public struct WebInspectorPage: Sendable, Hashable, Codable, Identifiable { + public var id: String + public var title: String? + public var url: String? + /// `WIRTypeWeb`, `WIRTypeWebPage`, `WIRTypeJavaScript`, `WIRTypeServiceWorker`, … + public var type: String + + public var kindLabel: String { + switch type { + case "WIRTypeWeb", "WIRTypeWebPage", "WIRTypePage": return "Web page" + case "WIRTypeJavaScript": return "JavaScript context" + case "WIRTypeServiceWorker": return "Service worker" + case "WIRTypeAutomation": return "Automation session" + case "WIRTypeITML": return "TVML page" + default: return type + } + } +} + +/// An application that exposes inspectable content, with its pages. +public struct WebInspectorApplication: Sendable, Hashable, Codable, Identifiable { + public var id: String + public var bundleIdentifier: String? + public var name: String? + public var isActive: Bool? + public var pages: [WebInspectorPage] +} + +/// `com.apple.webinspector`: lists the Safari tabs and web views that apps allow to be inspected, +/// over lockdown and without Xcode. +/// +/// This is Apple's private WebKit remote-inspector RPC (the one Safari's Develop menu uses). Messages +/// are plists `{__selector, __argument}`; the device answers `_rpc_reportConnectedApplicationList:` +/// and `_rpc_applicationSentListing:`. The device refuses a session by dropping the connection when +/// Web Inspector is off in Safari settings, when a new session starts less than about ten seconds +/// after the previous one, or while it is still starting up — so a refusal is retried until a +/// deadline before it is reported. Read-only: nothing is inspected, launched, or automated. +public enum WebInspector { + public static let serviceName = "com.apple.webinspector" + static let logger = ToolkitLog.logger(.deviceCommunication) + + public static func openPages( + on target: DeviceTarget, + usbmux: USBMuxClient = USBMuxClient(), + handshakeDeadline: Duration = .seconds(15), + retryInterval: Duration = .milliseconds(1500), + listingWindow: Duration = .seconds(2) + ) async throws -> [WebInspectorApplication] { + let clock = ContinuousClock() + let deadline = clock.now + handshakeDeadline + var lastError: Error? + while true { + do { + return try await DeviceSession.with(target, usbmux: usbmux) { session in + try await listPages(session, listingWindow: listingWindow) + } + } catch let error as HandshakeRefused { + lastError = error.underlying + } + if clock.now + retryInterval >= deadline { break } + try await Task.sleep(for: retryInterval) + } + throw ToolkitError( + .serviceUnavailable, + message: "Safari Web Inspector did not answer.", + recovery: "Turn on Web Inspector on the device: Settings › Apps › Safari › Advanced › Web Inspector (Settings › Safari › Advanced before iOS 18). If it is already on, wait ten seconds and try again — the device accepts a new inspection session only about every ten seconds.", + technicalDetail: lastError.map { String(describing: $0) } + ) + } + + /// A refusal during the handshake (retryable), as opposed to a failure after it. + struct HandshakeRefused: Error { + var underlying: Error + } + + static func listPages(_ session: DeviceSession, listingWindow: Duration) async throws -> [WebInspectorApplication] { + let connectionID = UUID().uuidString.uppercased() + let service: ServiceConnection + do { + service = try await session.openService(serviceName) + } catch { + throw HandshakeRefused(underlying: error) + } + defer { Task { await service.close() } } + let messages = service.messages + + func send(_ selector: String, _ argument: [String: PlistValue] = [:]) async throws { + var argument = argument + argument["WIRConnectionIdentifierKey"] = .string(connectionID) + try await messages.send(["__selector": .string(selector), "__argument": .dictionary(argument)]) + } + + var state = ListingState() + do { + try await send("_rpc_reportIdentifier:") + state.apply(try await messages.receive(timeout: 5)) + } catch { + throw HandshakeRefused(underlying: error) + } + try await send("_rpc_getConnectedApplications:") + let clock = ContinuousClock() + let end = clock.now + listingWindow + var requested: Set = [] + while clock.now < end { + for appID in state.applications.keys where !requested.contains(appID) { + try await send("_rpc_forwardGetListing:", ["WIRApplicationIdentifierKey": .string(appID)]) + requested.insert(appID) + } + let remaining = end - clock.now + let seconds = Double(remaining.components.seconds) + Double(remaining.components.attoseconds) / 1e18 + guard seconds > 0.05 else { break } + do { + state.apply(try await messages.receive(timeout: seconds)) + } catch let error as ToolkitError where error.kind == .timedOut { + break + } + } + logger.info("Web Inspector listed \(state.applications.count, privacy: .public) applications") + return state.result + } + + /// Accumulates the device's reports. + struct ListingState { + var applications: [String: WebInspectorApplication] = [:] + + mutating func apply(_ message: PlistValue) { + let argument = message["__argument"] ?? .dictionary([:]) + switch message["__selector"]?.stringValue { + case "_rpc_reportConnectedApplicationList:": + var updated: [String: WebInspectorApplication] = [:] + for (id, app) in argument["WIRApplicationDictionaryKey"]?.dictionaryValue ?? [:] { + updated[id] = Self.application(id: id, app, pages: applications[id]?.pages ?? []) + } + applications = updated + case "_rpc_applicationConnected:", "_rpc_applicationUpdated:": + if let id = argument["WIRApplicationIdentifierKey"]?.stringValue { + applications[id] = Self.application(id: id, argument, pages: applications[id]?.pages ?? []) + } + case "_rpc_applicationDisconnected:": + if let id = argument["WIRApplicationIdentifierKey"]?.stringValue { applications[id] = nil } + case "_rpc_applicationSentListing:": + guard let id = argument["WIRApplicationIdentifierKey"]?.stringValue else { return } + // A listing is the application's complete set of pages, not a delta. + let pages = (argument["WIRListingKey"]?.dictionaryValue ?? [:]).map { key, page in + WebInspectorPage( + id: page["WIRPageIdentifierKey"].map { $0.intValue.map(String.init) ?? $0.stringValue ?? key } ?? key, + title: page["WIRTitleKey"]?.stringValue, + url: page["WIRURLKey"]?.stringValue, + type: page["WIRTypeKey"]?.stringValue ?? "unknown" + ) + }.sorted { ($0.title ?? "", $0.id) < ($1.title ?? "", $1.id) } + var app = applications[id] ?? WebInspectorApplication(id: id, bundleIdentifier: nil, name: nil, isActive: nil, pages: []) + app.pages = pages + applications[id] = app + default: + break + } + } + + static func application(id: String, _ plist: PlistValue, pages: [WebInspectorPage]) -> WebInspectorApplication { + WebInspectorApplication( + id: id, + bundleIdentifier: plist["WIRApplicationBundleIdentifierKey"]?.stringValue, + name: plist["WIRApplicationNameKey"]?.stringValue, + isActive: plist["WIRIsApplicationActiveKey"]?.boolValue ?? plist["WIRIsApplicationActiveKey"]?.intValue.map { $0 != 0 }, + pages: pages + ) + } + + /// Applications with inspectable pages first, by name. + var result: [WebInspectorApplication] { + applications.values.sorted { + ($0.pages.isEmpty ? 1 : 0, $0.name ?? $0.bundleIdentifier ?? $0.id) < ($1.pages.isEmpty ? 1 : 0, $1.name ?? $1.bundleIdentifier ?? $1.id) + } + } + } +} diff --git a/Sources/DeviceKit/Simulator/SimulatorClient.swift b/Sources/DeviceKit/Simulator/SimulatorClient.swift new file mode 100644 index 0000000..d50d856 --- /dev/null +++ b/Sources/DeviceKit/Simulator/SimulatorClient.swift @@ -0,0 +1,338 @@ +import Foundation +import ToolkitCore + +public struct SimulatorRuntime: Sendable, Hashable, Identifiable { + public var id: String { identifier } + public var identifier: String + public var name: String + public var platform: String? + public var version: String? + public var buildVersion: String? + public var isAvailable: Bool +} + +public struct SimulatorRecord: Sendable, Hashable, Identifiable { + public var id: String { udid } + public var udid: String + public var name: String + public var state: SimulatorState + public var isAvailable: Bool + public var availabilityError: String? + public var deviceTypeIdentifier: String? + public var runtime: SimulatorRuntime? + public var runtimeIdentifier: String + public var dataPath: String? + public var logPath: String? + /// The device type's display name, such as "iPad Air 11-inch (M4)". + public var deviceTypeName: String? + /// The hardware identifier the simulator models, such as "iPhone18,1". + public var modelIdentifier: String? = nil + + /// Simulators run natively on the Mac's processor. + static var hostArchitecture: String { + #if arch(arm64) + return "arm64 (this Mac)" + #else + return "x86_64 (this Mac)" + #endif + } + + public var device: Device { + let typeName = deviceTypeName ?? deviceTypeIdentifier?.components(separatedBy: ".").last?.replacingOccurrences(of: "-", with: " ") + return Device( + kind: .simulator, + udid: udid, + name: name, + productType: modelIdentifier, + marketingName: typeName, + family: DeviceFamily.from(productType: typeName), + osName: runtime?.platform ?? SimulatorClient.platform(fromRuntimeIdentifier: runtimeIdentifier), + osVersion: runtime?.version ?? SimulatorClient.version(fromRuntimeIdentifier: runtimeIdentifier), + buildVersion: runtime?.buildVersion, + architecture: SimulatorRecord.hostArchitecture, + transports: [.local], + pairingState: .notApplicable, + developerMode: .notApplicable, + simulatorState: state, + simulatorRuntime: runtime?.name ?? runtimeIdentifier, + sources: [.simctl] + ) + } +} + +public struct SimulatorApp: Sendable, Hashable, Identifiable { + public var id: String { bundleIdentifier } + public var bundleIdentifier: String + public var name: String + public var version: String? + public var build: String? + public var applicationType: String? + public var bundlePath: String? +} + +/// Typed access to `xcrun simctl`. Every call targets an explicit simulator UDID. +public struct SimulatorClient: Sendable { + public let runner: CommandRunning + + public init(runner: CommandRunning = ProcessCommandRunner()) { + self.runner = runner + } + + // MARK: Discovery + + public func list() async throws -> [SimulatorRecord] { + async let devicesResult = run(["list", "devices", "--json"], timeout: 60, name: "simctl list devices") + async let runtimesResult = run(["list", "runtimes", "--json"], timeout: 60, name: "simctl list runtimes") + async let typesResult = run(["list", "devicetypes", "--json"], timeout: 60, name: "simctl list devicetypes") + let devicesJSON = try JSONValue.parse(try await devicesResult.standardOutput) + let runtimesJSON = (try? await runtimesResult).flatMap { try? JSONValue.parse($0.standardOutput) } + let typesJSON = (try? await typesResult).flatMap { try? JSONValue.parse($0.standardOutput) } + return Self.parse(devices: devicesJSON, runtimes: runtimesJSON, deviceTypes: typesJSON) + } + + public static func parse(devices: JSONValue, runtimes: JSONValue?, deviceTypes: JSONValue? = nil) -> [SimulatorRecord] { + var typeNames: [String: String] = [:] + var typeModels: [String: String] = [:] + for item in deviceTypes?["devicetypes"]?.array ?? [] { + guard let identifier = item["identifier"]?.nonEmptyString else { continue } + typeNames[identifier] = item["name"]?.nonEmptyString + typeModels[identifier] = item["modelIdentifier"]?.nonEmptyString + } + var runtimeTable: [String: SimulatorRuntime] = [:] + for item in runtimes?["runtimes"]?.array ?? [] { + guard let identifier = item["identifier"]?.nonEmptyString else { continue } + runtimeTable[identifier] = SimulatorRuntime( + identifier: identifier, + name: item["name"]?.nonEmptyString ?? identifier, + platform: item["platform"]?.nonEmptyString, + version: item["version"]?.nonEmptyString, + buildVersion: item["buildversion"]?.nonEmptyString, + isAvailable: item["isAvailable"]?.bool ?? true + ) + } + var records: [SimulatorRecord] = [] + for (runtimeIdentifier, list) in devices["devices"]?.object ?? [:] { + for item in list.array ?? [] { + guard let udid = item["udid"]?.nonEmptyString, let name = item["name"]?.nonEmptyString else { continue } + records.append(SimulatorRecord( + udid: udid, + name: name, + state: SimulatorState(simctlValue: item["state"]?.string), + isAvailable: item["isAvailable"]?.bool ?? true, + availabilityError: item["availabilityError"]?.nonEmptyString, + deviceTypeIdentifier: item["deviceTypeIdentifier"]?.nonEmptyString, + runtime: runtimeTable[runtimeIdentifier], + runtimeIdentifier: runtimeIdentifier, + dataPath: item["dataPath"]?.nonEmptyString, + logPath: item["logPath"]?.nonEmptyString, + deviceTypeName: item["deviceTypeIdentifier"]?.nonEmptyString.flatMap { typeNames[$0] }, + modelIdentifier: item["deviceTypeIdentifier"]?.nonEmptyString.flatMap { typeModels[$0] } + )) + } + } + return records.sorted { + if $0.state == .booted, $1.state != .booted { return true } + if $1.state == .booted, $0.state != .booted { return false } + if $0.runtimeIdentifier != $1.runtimeIdentifier { return $0.runtimeIdentifier > $1.runtimeIdentifier } + return $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + } + + static func platform(fromRuntimeIdentifier identifier: String) -> String? { + let last = identifier.components(separatedBy: ".").last ?? "" + return last.split(separator: "-").first.map(String.init) + } + + static func version(fromRuntimeIdentifier identifier: String) -> String? { + let last = identifier.components(separatedBy: ".").last ?? "" + let parts = last.split(separator: "-").dropFirst() + return parts.isEmpty ? nil : parts.joined(separator: ".") + } + + // MARK: Lifecycle + + /// Boots the simulator (if needed) and returns once iOS has finished starting. `simctl boot` + /// alone returns while the system is still coming up, and the first app launch can then take + /// minutes; `simctl bootstatus -b` waits for boot (and any data migration) to complete. + public func boot(_ target: DeviceTarget) async throws { + try requireSimulator(target) + let result = try await runner.run(try XcodeTool.simctl.request(["bootstatus", target.udid, "-b"], timeout: 600, displayName: "simctl bootstatus")) + guard result.succeeded else { + throw interpret(result, operation: "Starting the simulator") + } + } + + public func shutdown(_ target: DeviceTarget) async throws { + try requireSimulator(target) + let result = try await runner.run(try XcodeTool.simctl.request(["shutdown", target.udid], timeout: 120, displayName: "simctl shutdown")) + if !result.succeeded && !result.standardErrorText.contains("current state: Shutdown") { + throw interpret(result, operation: "Shutting down the simulator") + } + } + + /// Opens Simulator.app showing this simulator. + public func showInSimulatorApp(_ target: DeviceTarget) async throws { + try requireSimulator(target) + let open = try AppleTool.open.locate() + let result = try await runner.run(CommandRequest(executable: open, arguments: ["-a", "Simulator", "--args", "-CurrentDeviceUDID", target.udid], timeout: 30, displayName: "open Simulator")) + guard result.succeeded else { throw interpret(result, operation: "Opening Simulator") } + } + + public func erase(_ target: DeviceTarget) async throws { + try await simple(["erase", target.udid], target: target, timeout: 300, operation: "Erasing the simulator") + } + + // MARK: Apps + + public func apps(_ target: DeviceTarget) async throws -> [SimulatorApp] { + try requireSimulator(target) + let result = try await run(["listapps", target.udid], timeout: 60, name: "simctl listapps") + return try Self.parseApps(result.standardOutput) + } + + /// `simctl listapps` prints an (OpenStep or XML) property list keyed by bundle identifier. + public static func parseApps(_ data: Data) throws -> [SimulatorApp] { + let plist = try PlistValue.decode(data) + guard let dictionary = plist.dictionaryValue else { + throw ToolkitError(.protocolViolation, message: "simctl returned an unexpected app list.") + } + return dictionary.compactMap { key, value -> SimulatorApp? in + guard let info = value.dictionaryValue else { return nil } + let identifier = info["CFBundleIdentifier"]?.stringValue ?? key + return SimulatorApp( + bundleIdentifier: identifier, + name: info["CFBundleDisplayName"]?.stringValue ?? info["CFBundleName"]?.stringValue ?? identifier, + version: info["CFBundleShortVersionString"]?.stringValue, + build: info["CFBundleVersion"]?.stringValue, + applicationType: info["ApplicationType"]?.stringValue, + bundlePath: info["Path"]?.stringValue + ) + }.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + public func install(appAt path: URL, on target: DeviceTarget) async throws { + guard path.pathExtension == "app" else { + throw ToolkitError(.unsupported, message: "Simulators install .app bundles built for the simulator, not .ipa files.", recovery: "Build the app for an iOS Simulator destination in Xcode and choose the resulting .app bundle.") + } + try await simple(["install", target.udid, path.path], target: target, timeout: 600, operation: "Installing the app") + } + + public func uninstall(bundleIdentifier: String, on target: DeviceTarget) async throws { + try BundleIdentifier.validate(bundleIdentifier) + try await simple(["uninstall", target.udid, bundleIdentifier], target: target, timeout: 120, operation: "Removing the app") + } + + public func launch(bundleIdentifier: String, on target: DeviceTarget, terminateExisting: Bool) async throws -> String { + try BundleIdentifier.validate(bundleIdentifier) + try requireSimulator(target) + var arguments = ["launch"] + if terminateExisting { arguments.append("--terminate-running-process") } + arguments += [target.udid, bundleIdentifier] + let result = try await runner.run(try XcodeTool.simctl.request(arguments, timeout: 120, displayName: "simctl launch")) + guard result.succeeded else { throw interpret(result, operation: "Launching the app") } + return result.standardOutputText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + public func terminate(bundleIdentifier: String, on target: DeviceTarget) async throws { + try BundleIdentifier.validate(bundleIdentifier) + try await simple(["terminate", target.udid, bundleIdentifier], target: target, timeout: 60, operation: "Stopping the app") + } + + public func openURL(_ url: URL, on target: DeviceTarget) async throws { + guard url.scheme?.isEmpty == false else { + throw ToolkitError.invalidInput("Enter a complete URL including its scheme, for example https://example.com.") + } + try await simple(["openurl", target.udid, url.absoluteString], target: target, timeout: 60, operation: "Opening the URL") + } + + // MARK: Capture + + public func screenshot(_ target: DeviceTarget, to destination: URL) async throws { + guard destination.pathExtension.lowercased() == "png" else { + throw ToolkitError.invalidInput("Screenshots are saved as PNG files. Choose a file name ending in .png.") + } + try await simple(["io", target.udid, "screenshot", "--type=png", destination.path], target: target, timeout: 60, operation: "Taking the screenshot") + } + + /// The command for a live unified-log stream (consumed with `CommandRunning.stream`). + public func logStreamRequest(_ target: DeviceTarget, level: String = "info", predicate: String? = nil) throws -> CommandRequest { + try requireSimulator(target) + guard ["default", "info", "debug"].contains(level) else { throw ToolkitError.invalidInput("Unsupported log level \(level).") } + var arguments = ["spawn", target.udid, "log", "stream", "--style", "ndjson", "--level", level] + if let predicate, !predicate.isEmpty { arguments += ["--predicate", predicate] } + return try XcodeTool.simctl.request(arguments, timeout: nil, displayName: "simctl log stream", outputLimit: 1 << 20) + } + + // MARK: Location + + public func setLocation(latitude: Double, longitude: Double, on target: DeviceTarget) async throws { + try Coordinate.validate(latitude: latitude, longitude: longitude) + try await simple(["location", target.udid, "set", "\(Coordinate.format(latitude)),\(Coordinate.format(longitude))"], target: target, timeout: 60, operation: "Setting the simulated location") + } + + public func simulateRoute(_ waypoints: [(latitude: Double, longitude: Double)], speedMetresPerSecond: Double, updateIntervalSeconds: Double, on target: DeviceTarget) async throws { + guard waypoints.count >= 2 else { throw ToolkitError.invalidInput("A route needs at least two waypoints.") } + guard speedMetresPerSecond.isFinite, speedMetresPerSecond > 0 else { throw ToolkitError.invalidInput("Route speed must be positive.") } + guard updateIntervalSeconds.isFinite, updateIntervalSeconds > 0 else { throw ToolkitError.invalidInput("The update interval must be positive.") } + for point in waypoints { try Coordinate.validate(latitude: point.latitude, longitude: point.longitude) } + let pairs = waypoints.map { "\(Coordinate.format($0.latitude)),\(Coordinate.format($0.longitude))" } + try await simple( + ["location", target.udid, "start", "--speed=\(speedMetresPerSecond)", "--interval=\(updateIntervalSeconds)"] + pairs, + target: target, timeout: 60, operation: "Starting the simulated route" + ) + } + + public func clearLocation(on target: DeviceTarget) async throws { + try await simple(["location", target.udid, "clear"], target: target, timeout: 60, operation: "Clearing the simulated location") + } + + // MARK: Appearance + + public func setAppearance(dark: Bool, on target: DeviceTarget) async throws { + try await simple(["ui", target.udid, "appearance", dark ? "dark" : "light"], target: target, timeout: 60, operation: "Changing the appearance") + } + + // MARK: Help + + public func help(_ route: [String]) async throws -> String { + let result = try await runner.run(try XcodeTool.simctl.request(["help"] + route, timeout: 20, displayName: "simctl help")) + return result.standardOutputText.isEmpty ? result.standardErrorText : result.standardOutputText + } + + // MARK: Helpers + + private func requireSimulator(_ target: DeviceTarget) throws { + guard target.kind == .simulator else { + throw ToolkitError(.internalInconsistency, message: "This action is only available for simulators.", technicalDetail: "Target kind: \(target.kind.rawValue)") + } + } + + private func simple(_ arguments: [String], target: DeviceTarget, timeout: TimeInterval, operation: String) async throws { + try requireSimulator(target) + let result = try await runner.run(try XcodeTool.simctl.request(arguments, timeout: timeout, displayName: "simctl \(arguments.first ?? "")")) + guard result.succeeded else { throw interpret(result, operation: operation) } + } + + private func run(_ arguments: [String], timeout: TimeInterval, name: String) async throws -> CommandResult { + let result = try await runner.run(try XcodeTool.simctl.request(arguments, timeout: timeout, displayName: name)) + guard result.succeeded else { throw interpret(result, operation: name) } + return result + } + + func interpret(_ result: CommandResult, operation: String) -> ToolkitError { + let text = result.standardErrorText.lowercased() + if text.contains("unable to find utility") || text.contains("xcrun: error") { + return ToolkitError(.toolMissing, message: "Simulator tools are not available.", recovery: "Install Xcode and open it once to install its components.", technicalDetail: result.technicalSummary) + } + if text.contains("invalid device") || text.contains("no devices are booted") || text.contains("device not found") { + return ToolkitError(.deviceNotFound, message: "The selected simulator no longer exists.", recovery: "Refresh the device list and choose another simulator.", technicalDetail: result.technicalSummary) + } + if text.contains("current state: shutdown") || text.contains("unable to lookup in current state: shutdown") { + return ToolkitError(.serviceUnavailable, message: "The simulator is not running.", recovery: "Start the simulator, then try again.", technicalDetail: result.technicalSummary) + } + if text.contains("found nothing to terminate") { + return ToolkitError(.commandFailed, message: "The app is not running on the simulator.", technicalDetail: result.technicalSummary) + } + return ToolkitError(.commandFailed, message: "\(operation) failed on the simulator.", recovery: "Check that the simulator is running and try again.", technicalDetail: result.technicalSummary) + } +} diff --git a/Sources/DeviceKit/Transport/DeviceChannel.swift b/Sources/DeviceKit/Transport/DeviceChannel.swift new file mode 100644 index 0000000..a74a0ed --- /dev/null +++ b/Sources/DeviceKit/Transport/DeviceChannel.swift @@ -0,0 +1,359 @@ +import Foundation +import NIOCore +import NIOPosix +import NIOSSL +import NIOTLS +import Security +import ToolkitCore + +/// Credentials used to upgrade a lockdown or service connection to TLS. +public struct TLSCredentials: Sendable { + public let certificatePEM: Data + public let privateKeyPEM: Data + /// The device certificate from the pair record. The server certificate presented during + /// the handshake must match it (same certificate or same public key). + public let pinnedDeviceCertificatePEM: Data? + + public init(certificatePEM: Data, privateKeyPEM: Data, pinnedDeviceCertificatePEM: Data?) { + self.certificatePEM = certificatePEM + self.privateKeyPEM = privateKeyPEM + self.pinnedDeviceCertificatePEM = pinnedDeviceCertificatePEM + } +} + +/// A bidirectional byte stream to usbmuxd, a device port, or a test server, with +/// `read(exactly:)` semantics and an in-place TLS upgrade. +public final class DeviceChannel: Sendable { + package let channel: Channel + package let inbound: InboundBuffer + public let description: String + + package init(channel: Channel, inbound: InboundBuffer, description: String) { + self.channel = channel + self.inbound = inbound + self.description = description + } + + /// Connects to a Unix-domain socket (usbmuxd). + public static func connect(unixSocketPath path: String, description: String, timeout: TimeInterval = 10) async throws -> DeviceChannel { + let inbound = InboundBuffer() + let bootstrap = ClientBootstrap(group: MultiThreadedEventLoopGroup.singleton) + .connectTimeout(.milliseconds(Int64(timeout * 1000))) + .channelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler(inbound) + } + } + do { + let channel = try await bootstrap.connect(unixDomainSocketPath: path).get() + return DeviceChannel(channel: channel, inbound: inbound, description: description) + } catch { + throw ToolkitError( + .serviceUnavailable, + message: "The macOS device service (usbmuxd) is not reachable.", + recovery: "Reconnect the device. If the problem continues, restart the Mac; the toolkit never restarts system services itself.", + technicalDetail: "\(path): \(error)" + ) + } + } + + public var isActive: Bool { channel.isActive } + + public func write(_ data: Data) async throws { + var buffer = channel.allocator.buffer(capacity: data.count) + buffer.writeBytes(data) + do { + try await channel.writeAndFlush(buffer).get() + } catch { + throw ToolkitError.deviceCommunication(technicalDetail: "Write to \(description) failed: \(error)") + } + } + + /// Reads exactly `count` bytes or throws if the connection closes first. + public func read(exactly count: Int, timeout: TimeInterval? = 30) async throws -> Data { + guard count > 0 else { return Data() } + if let timeout { + let inbound = self.inbound + let description = self.description + return try await withTimeout(timeout, operation: "Reading from \(description)") { + try await inbound.read(exactly: count, source: description) + } + } + return try await inbound.read(exactly: count, source: description) + } + + /// Reads whatever is available (at least one byte), or returns nil at end of stream. + public func readSome(maximum: Int = 64 * 1024) async throws -> Data? { + try await inbound.readSome(maximum: maximum, source: description) + } + + /// Waits until at least one byte is available (true) or the peer has closed cleanly (false). + /// Streaming services use this so a close at a record boundary ends the stream normally. + public func hasMoreData() async throws -> Bool { + try await inbound.hasMoreData(source: description) + } + + /// Upgrades the established stream to TLS using the pair record's host identity. + public func startTLS(_ credentials: TLSCredentials, timeout: TimeInterval = 20) async throws { + let context: NIOSSLContext + do { + let certificates = try NIOSSLCertificate.fromPEMBytes(Array(credentials.certificatePEM)) + let key = try NIOSSLPrivateKey(bytes: Array(credentials.privateKeyPEM), format: .pem) + var configuration = TLSConfiguration.makeClientConfiguration() + configuration.certificateChain = certificates.map { .certificate($0) } + configuration.privateKey = .privateKey(key) + configuration.certificateVerification = .noHostnameVerification + configuration.minimumTLSVersion = .tlsv12 + context = try NIOSSLContext(configuration: configuration) + } catch { + throw ToolkitError( + .notPaired, + message: "The pairing record for this device could not be used.", + recovery: "Disconnect and reconnect the device, unlock it, and trust this Mac again.", + technicalDetail: "TLS configuration failed: \(error)" + ) + } + + let pinned = credentials.pinnedDeviceCertificatePEM + let inbound = self.inbound + inbound.prepareForTLS() + try await channel.eventLoop.submit { [channel] in + let handler = try NIOSSLClientHandler(context: context, serverHostname: nil) { presented, promise in + promise.succeed(CertificatePinning.verify(presented: presented, pinnedPEM: pinned) ? .certificateVerified : .failed) + } + try channel.pipeline.syncOperations.addHandler(handler, position: .first) + }.get() + + let description = self.description + try await withTimeout(timeout, operation: "Secure connection to \(description)") { + try await inbound.waitForTLSHandshake(source: description) + } + } + + public func close() async { + try? await channel.close().get() + } +} + +/// Compares the certificate the device presents with the one recorded at pairing time. +enum CertificatePinning { + static func verify(presented: [NIOSSLCertificate], pinnedPEM: Data?) -> Bool { + guard let pinnedPEM else { return true } + guard let leaf = presented.first, let leafDER = try? Data(leaf.toDERBytes()) else { return false } + guard let pinned = try? NIOSSLCertificate.fromPEMBytes(Array(pinnedPEM)).first, + let pinnedDER = try? Data(pinned.toDERBytes()) + else { return false } + if leafDER == pinnedDER { return true } + // A re-issued certificate for the same device key is also acceptable. + guard let leafKey = publicKey(fromDER: leafDER), let pinnedKey = publicKey(fromDER: pinnedDER) else { return false } + return leafKey == pinnedKey + } + + static func publicKey(fromDER der: Data) -> Data? { + guard let certificate = SecCertificateCreateWithData(nil, der as CFData), + let key = SecCertificateCopyKey(certificate), + let external = SecKeyCopyExternalRepresentation(key, nil) + else { return nil } + return external as Data + } +} + +/// Buffers inbound bytes and wakes a single waiting reader. +package final class InboundBuffer: ChannelInboundHandler, @unchecked Sendable { + package typealias InboundIn = ByteBuffer + + package init() {} + private enum Waiter { + case bytes(Int, CheckedContinuation) + case anyBytes(CheckedContinuation) + case tls(CheckedContinuation) + } + + private let lock = NSLock() + private var buffer = Data() + private var closed = false + private var failure: Error? + private var waiter: Waiter? + private var tlsCompleted = false + /// Hard ceiling on buffered, unread bytes to bound memory if a peer floods the stream. + private let maximumBufferedBytes = 256 * 1024 * 1024 + + package func channelRead(context: ChannelHandlerContext, data: NIOAny) { + var bytes = unwrapInboundIn(data) + guard let array = bytes.readBytes(length: bytes.readableBytes) else { return } + lock.lock() + buffer.append(contentsOf: array) + let overflow = buffer.count > maximumBufferedBytes + if overflow { + failure = ToolkitError(.protocolViolation, message: "The device sent more data than the toolkit can buffer.") + } + let resume = takeSatisfiedWaiter() + lock.unlock() + resume?() + if overflow { context.close(promise: nil) } + } + + package func channelInactive(context: ChannelHandlerContext) { + lock.lock() + closed = true + let resume = takeSatisfiedWaiter() + lock.unlock() + resume?() + context.fireChannelInactive() + } + + package func errorCaught(context: ChannelHandlerContext, error: Error) { + lock.lock() + if failure == nil { failure = error } + let resume = takeSatisfiedWaiter() + lock.unlock() + resume?() + context.close(promise: nil) + } + + package func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + if let tlsEvent = event as? TLSUserEvent, case .handshakeCompleted = tlsEvent { + lock.lock() + tlsCompleted = true + let resume = takeSatisfiedWaiter() + lock.unlock() + resume?() + } + context.fireUserInboundEventTriggered(event) + } + + package func prepareForTLS() { + lock.lock() + tlsCompleted = false + lock.unlock() + } + + /// Must be called with the lock held. Returns a closure that resumes the waiter outside it. + private func takeSatisfiedWaiter() -> (() -> Void)? { + guard let waiter else { return nil } + switch waiter { + case .bytes(let count, let continuation): + if buffer.count >= count { + self.waiter = nil + return { continuation.resume() } + } + if let failure { + self.waiter = nil + return { continuation.resume(throwing: failure) } + } + if closed { + self.waiter = nil + return { continuation.resume(throwing: InboundBuffer.closedError) } + } + case .anyBytes(let continuation): + if !buffer.isEmpty || closed || failure != nil { + self.waiter = nil + return { continuation.resume() } + } + case .tls(let continuation): + if tlsCompleted { + self.waiter = nil + return { continuation.resume() } + } + if let failure { + self.waiter = nil + return { continuation.resume(throwing: failure) } + } + if closed { + self.waiter = nil + return { continuation.resume(throwing: InboundBuffer.closedError) } + } + } + return nil + } + + private static let closedError = ToolkitError( + .deviceDisconnected, + message: "The connection to the device closed unexpectedly.", + recovery: "Make sure the device is unlocked, connected, and has trusted this Mac." + ) + + private func install(_ makeWaiter: (CheckedContinuation) -> Waiter) async throws { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + lock.lock() + if waiter != nil { + lock.unlock() + continuation.resume(throwing: ToolkitError(.internalInconsistency, message: "Two readers used one device connection.")) + return + } + waiter = makeWaiter(continuation) + let resume = takeSatisfiedWaiter() + lock.unlock() + resume?() + } + } onCancel: { + lock.lock() + let pending = waiter + waiter = nil + lock.unlock() + switch pending { + case .bytes(_, let continuation), .anyBytes(let continuation), .tls(let continuation): + continuation.resume(throwing: CancellationError()) + case .none: + break + } + } + } + + func read(exactly count: Int, source: String) async throws -> Data { + try await install { .bytes(count, $0) } + return try lock.withLock { + guard buffer.count >= count else { + throw failure.map { ToolkitError.deviceCommunication(technicalDetail: "\(source): \($0)") } ?? InboundBuffer.closedError + } + let chunk = buffer.prefix(count) + buffer.removeFirst(count) + return Data(chunk) + } + } + + func readSome(maximum: Int, source: String) async throws -> Data? { + try await install { .anyBytes($0) } + return try lock.withLock { + if !buffer.isEmpty { + let count = min(maximum, buffer.count) + let chunk = buffer.prefix(count) + buffer.removeFirst(count) + return Data(chunk) + } + if let failure { + throw ToolkitError.deviceCommunication(technicalDetail: "\(source): \(failure)") + } + return nil + } + } + + func hasMoreData(source: String) async throws -> Bool { + try await install { .anyBytes($0) } + return try lock.withLock { + if !buffer.isEmpty { return true } + if let failure { + throw ToolkitError.deviceCommunication(technicalDetail: "\(source): \(failure)") + } + return false + } + } + + package func waitForTLSHandshake(source: String) async throws { + do { + try await install { .tls($0) } + } catch let error as ToolkitError { + throw error + } catch is CancellationError { + throw CancellationError() + } catch { + throw ToolkitError( + .notPaired, + message: "A secure connection to the device could not be established.", + recovery: "Unlock the device and make sure it still trusts this Mac. If you recently reset the device or its trust settings, disconnect it and trust this Mac again.", + technicalDetail: "\(source): \(error)" + ) + } + } +} diff --git a/Sources/DeviceKit/USBMux/USBMuxClient.swift b/Sources/DeviceKit/USBMux/USBMuxClient.swift new file mode 100644 index 0000000..d5b576b --- /dev/null +++ b/Sources/DeviceKit/USBMux/USBMuxClient.swift @@ -0,0 +1,192 @@ +import Foundation +import OSLog +import ToolkitCore + +/// The pairing material macOS stores after a device trusts this Mac. It is read through +/// usbmuxd (`ReadPairRecord`), which does not require root, and is only held in memory. +public struct PairRecord: Sendable { + public let hostID: String + public let systemBUID: String + public let hostCertificatePEM: Data + public let hostPrivateKeyPEM: Data + public let deviceCertificatePEM: Data? + public let rootCertificatePEM: Data? + public let escrowBag: Data? + + public init(hostID: String, systemBUID: String, hostCertificatePEM: Data, hostPrivateKeyPEM: Data, deviceCertificatePEM: Data?, rootCertificatePEM: Data?, escrowBag: Data?) { + self.hostID = hostID + self.systemBUID = systemBUID + self.hostCertificatePEM = hostCertificatePEM + self.hostPrivateKeyPEM = hostPrivateKeyPEM + self.deviceCertificatePEM = deviceCertificatePEM + self.rootCertificatePEM = rootCertificatePEM + self.escrowBag = escrowBag + } + + public init(plist: PlistValue) throws { + guard let hostID = plist["HostID"]?.stringValue, + let systemBUID = plist["SystemBUID"]?.stringValue, + let certificate = plist["HostCertificate"]?.dataValue, + let key = plist["HostPrivateKey"]?.dataValue + else { + throw ToolkitError( + .notPaired, + message: "This Mac's pairing record for the device is incomplete.", + recovery: "Disconnect the device, reconnect it, and tap Trust when asked." + ) + } + self.init( + hostID: hostID, + systemBUID: systemBUID, + hostCertificatePEM: certificate, + hostPrivateKeyPEM: key, + deviceCertificatePEM: plist["DeviceCertificate"]?.dataValue, + rootCertificatePEM: plist["RootCertificate"]?.dataValue, + escrowBag: plist["EscrowBag"]?.dataValue + ) + } + + public var tlsCredentials: TLSCredentials { + TLSCredentials(certificatePEM: hostCertificatePEM, privateKeyPEM: hostPrivateKeyPEM, pinnedDeviceCertificatePEM: deviceCertificatePEM) + } +} + +/// A client for the macOS usbmuxd socket. +public struct USBMuxClient: Sendable { + public static let defaultSocketPath = "/var/run/usbmuxd" + public let socketPath: String + private let logger = ToolkitLog.deviceDiscovery + + public init(socketPath: String = USBMuxClient.defaultSocketPath) { + self.socketPath = socketPath + } + + public var isSocketPresent: Bool { + var info = stat() + return stat(socketPath, &info) == 0 && (info.st_mode & S_IFMT) == S_IFSOCK + } + + // MARK: Requests + + public func listDevices() async throws -> [USBMuxDevice] { + let reply = try await exchange(USBMuxProtocol.request("ListDevices"), operation: "listing devices") + guard let list = reply["DeviceList"]?.arrayValue else { + throw ToolkitError(.protocolViolation, message: "usbmuxd returned an unexpected device list.") + } + return list.compactMap(USBMuxDevice.init(message:)) + } + + public func readPairRecord(for device: USBMuxDevice) async throws -> PairRecord { + var lastError: Error? + for identifier in Array(Set([device.serialNumber, device.udid])).sorted() { + do { + let reply = try await exchange(USBMuxProtocol.request("ReadPairRecord", extra: ["PairRecordID": .string(identifier)]), operation: "reading the pairing record", interpretResult: false) + if let number = reply["Number"]?.intValue, number != 0 { + lastError = ToolkitError(.notPaired, message: "This device has not trusted this Mac.", technicalDetail: "ReadPairRecord result \(number)") + continue + } + guard let data = reply["PairRecordData"]?.dataValue else { + lastError = ToolkitError(.protocolViolation, message: "usbmuxd returned a pairing record without data.") + continue + } + return try PairRecord(plist: try PlistValue.decode(data)) + } catch { + lastError = error + } + } + if let toolkitError = lastError as? ToolkitError, toolkitError.kind != .notPaired { + throw toolkitError + } + throw ToolkitError( + .notPaired, + message: "This device has not trusted this Mac yet.", + recovery: "Unlock the device, connect it with a USB cable, and tap Trust when asked. You may need to enter the device passcode.", + technicalDetail: (lastError as? ToolkitError)?.technicalDetail + ) + } + + public func readSystemBUID() async throws -> String { + let reply = try await exchange(USBMuxProtocol.request("ReadBUID"), operation: "reading the host identifier") + guard let buid = reply["BUID"]?.stringValue else { + throw ToolkitError(.protocolViolation, message: "usbmuxd did not return the host identifier.") + } + return buid + } + + /// Opens a raw TCP tunnel to `port` on the device. The returned channel speaks the device + /// service's protocol directly. + public func connect(to device: USBMuxDevice, port: UInt16) async throws -> DeviceChannel { + let channel = try await DeviceChannel.connect(unixSocketPath: socketPath, description: "device port \(port)") + do { + let request = USBMuxProtocol.request("Connect", extra: [ + "DeviceID": .integer(Int64(device.deviceID)), + "PortNumber": .integer(Int64(USBMuxProtocol.networkOrderPort(port))), + ]) + try await channel.write(try USBMuxProtocol.encode(request, tag: 1)) + let reply = try await Self.readMessage(from: channel, timeout: 15) + guard reply["MessageType"]?.stringValue == "Result", let number = reply["Number"]?.intValue else { + throw ToolkitError(.protocolViolation, message: "usbmuxd sent an unexpected reply to a connection request.") + } + if let error = USBMuxProtocol.resultError(number, operation: "connecting to port \(port)") { + throw error + } + return channel + } catch { + await channel.close() + throw error + } + } + + /// Event-driven device notifications. The stream ends if usbmuxd closes the connection; + /// the discovery coordinator reconnects with back-off. + public func listen() -> AsyncThrowingStream { + let socketPath = self.socketPath + let logger = self.logger + return AsyncThrowingStream { continuation in + let task = Task { + do { + let channel = try await DeviceChannel.connect(unixSocketPath: socketPath, description: "usbmuxd listener") + defer { Task { await channel.close() } } + try await channel.write(try USBMuxProtocol.encode(USBMuxProtocol.request("Listen"), tag: 1)) + let reply = try await Self.readMessage(from: channel, timeout: 10) + if let number = reply["Number"]?.intValue, let error = USBMuxProtocol.resultError(number, operation: "listening for devices") { + throw error + } + logger.info("usbmuxd listener established") + while !Task.isCancelled { + let message = try await Self.readMessage(from: channel, timeout: nil) + if let event = USBMuxProtocol.event(from: message) { + continuation.yield(event) + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + // MARK: Wire helpers + + private func exchange(_ request: PlistValue, operation: String, interpretResult: Bool = true) async throws -> PlistValue { + let channel = try await DeviceChannel.connect(unixSocketPath: socketPath, description: "usbmuxd") + defer { Task { await channel.close() } } + try await channel.write(try USBMuxProtocol.encode(request, tag: 1)) + let reply = try await Self.readMessage(from: channel, timeout: 15) + if interpretResult, + reply["MessageType"]?.stringValue == "Result", + let number = reply["Number"]?.intValue, + let error = USBMuxProtocol.resultError(number, operation: operation) { + throw error + } + return reply + } + + package static func readMessage(from channel: DeviceChannel, timeout: TimeInterval?) async throws -> PlistValue { + let header = try USBMuxProtocol.decodeHeader(try await channel.read(exactly: USBMuxProtocol.headerLength, timeout: timeout)) + let payload = try await channel.read(exactly: header.payloadLength, timeout: timeout ?? 30) + return try PlistValue.decode(payload) + } +} diff --git a/Sources/DeviceKit/USBMux/USBMuxProtocol.swift b/Sources/DeviceKit/USBMux/USBMuxProtocol.swift new file mode 100644 index 0000000..71a6b8b --- /dev/null +++ b/Sources/DeviceKit/USBMux/USBMuxProtocol.swift @@ -0,0 +1,199 @@ +import Foundation +import ToolkitCore + +/// A device as reported by usbmuxd (`ListDevices` / `Attached`). +public struct USBMuxDevice: Sendable, Hashable, Identifiable { + public var id: Int { deviceID } + public var deviceID: Int + public var udid: String + /// The identifier exactly as usbmuxd reported it (used for pair-record lookups). + public var serialNumber: String + public var connectionType: String + public var productID: Int? + public var locationID: Int? + + public var transport: DeviceTransport { + connectionType.lowercased() == "usb" ? .usb : .network + } + + public init(deviceID: Int, udid: String, connectionType: String, productID: Int? = nil, locationID: Int? = nil) { + self.deviceID = deviceID + self.udid = USBMuxDevice.normalizedUDID(udid) + self.serialNumber = udid + self.connectionType = connectionType + self.productID = productID + self.locationID = locationID + } + + /// Parses an `Attached` message or a `DeviceList` entry. + public init?(message: PlistValue) { + let properties = message["Properties"] ?? message + guard let deviceID = (message["DeviceID"] ?? properties["DeviceID"])?.intValue, + let udid = properties["SerialNumber"]?.stringValue, !udid.isEmpty + else { return nil } + self.deviceID = deviceID + self.udid = USBMuxDevice.normalizedUDID(udid) + serialNumber = udid + connectionType = properties["ConnectionType"]?.stringValue ?? "USB" + productID = properties["ProductID"]?.intValue + locationID = properties["LocationID"]?.intValue + } + + /// usbmuxd reports some modern UDIDs without the hyphen (24 hex digits). CoreDevice and + /// Xcode use the hyphenated form, so normalize to it for merging. + public static func normalizedUDID(_ value: String) -> String { + if value.count == 24, !value.contains("-"), value.allSatisfy(\.isHexDigit) { + let index = value.index(value.startIndex, offsetBy: 8) + return String(value[.. Data { + let body = try payload.encoded(format: .xml) + var data = Data(capacity: headerLength + body.count) + data.appendLittleEndian(UInt32(headerLength + body.count)) + data.appendLittleEndian(plistVersion) + data.appendLittleEndian(plistMessageType) + data.appendLittleEndian(tag) + data.append(body) + return data + } + + public struct Header: Sendable, Hashable { + public let length: UInt32 + public let version: UInt32 + public let messageType: UInt32 + public let tag: UInt32 + + public var payloadLength: Int { Int(length) - USBMuxProtocol.headerLength } + } + + public static func decodeHeader(_ data: Data) throws -> Header { + guard data.count == headerLength else { + throw ToolkitError(.protocolViolation, message: "usbmuxd sent a truncated message header.") + } + let header = Header( + length: data.readLittleEndianUInt32(at: 0), + version: data.readLittleEndianUInt32(at: 4), + messageType: data.readLittleEndianUInt32(at: 8), + tag: data.readLittleEndianUInt32(at: 12) + ) + guard header.length >= UInt32(headerLength), header.payloadLength <= maximumMessageLength else { + throw ToolkitError(.protocolViolation, message: "usbmuxd sent a message with an invalid length.", technicalDetail: "length=\(header.length)") + } + return header + } + + public static func request(_ messageType: String, extra: [String: PlistValue] = [:]) -> PlistValue { + var dictionary: [String: PlistValue] = [ + "MessageType": .string(messageType), + "ClientVersionString": "iOSDeveloperToolkit", + "ProgName": "iOSDeveloperToolkit", + "kLibUSBMuxVersion": 3, + ] + for (key, value) in extra { dictionary[key] = value } + return .dictionary(dictionary) + } + + /// usbmuxd expects the TCP port in network byte order inside a host-order integer. + public static func networkOrderPort(_ port: UInt16) -> Int { + Int(port.bigEndian) + } + + public static func event(from message: PlistValue) -> USBMuxEvent? { + switch message["MessageType"]?.stringValue { + case "Attached": + return USBMuxDevice(message: message).map(USBMuxEvent.attached) + case "Detached": + return message["DeviceID"]?.intValue.map { USBMuxEvent.detached(deviceID: $0) } + case "Paired": + return message["DeviceID"]?.intValue.map { USBMuxEvent.paired(deviceID: $0) } + default: + return nil + } + } + + /// Maps a `Result` number to an actionable error (nil for success). + public static func resultError(_ number: Int, operation: String) -> ToolkitError? { + switch number { + case 0: + return nil + case 2: + return ToolkitError(.deviceDisconnected, message: "The device disconnected.", recovery: "Reconnect the device and try again.", technicalDetail: "usbmuxd result 2 (BadDevice) during \(operation)") + case 3: + return ToolkitError( + .serviceUnavailable, + message: "The device refused the connection.", + recovery: "Unlock the device. If it was just restarted, unlock it once, then try again.", + technicalDetail: "usbmuxd result 3 (ConnectionRefused) during \(operation)" + ) + default: + return ToolkitError(.protocolViolation, message: "The macOS device service rejected the request.", technicalDetail: "usbmuxd result \(number) during \(operation)") + } + } +} + +extension Data { + package mutating func appendLittleEndian(_ value: UInt32) { + var little = value.littleEndian + Swift.withUnsafeBytes(of: &little) { append(contentsOf: $0) } + } + + package mutating func appendBigEndian(_ value: UInt32) { + var big = value.bigEndian + Swift.withUnsafeBytes(of: &big) { append(contentsOf: $0) } + } + + package mutating func appendLittleEndian(_ value: UInt64) { + var little = value.littleEndian + Swift.withUnsafeBytes(of: &little) { append(contentsOf: $0) } + } + + package func readLittleEndianUInt32(at offset: Int) -> UInt32 { + var value: UInt32 = 0 + for index in 0..<4 { + value |= UInt32(self[startIndex + offset + index]) << (8 * UInt32(index)) + } + return value + } + + package func readBigEndianUInt32(at offset: Int) -> UInt32 { + var value: UInt32 = 0 + for index in 0..<4 { + value = (value << 8) | UInt32(self[startIndex + offset + index]) + } + return value + } + + package func readLittleEndianUInt64(at offset: Int) -> UInt64 { + var value: UInt64 = 0 + for index in 0..<8 { + value |= UInt64(self[startIndex + offset + index]) << (8 * UInt64(index)) + } + return value + } + + func readLittleEndianUInt16(at offset: Int) -> UInt16 { + UInt16(self[startIndex + offset]) | (UInt16(self[startIndex + offset + 1]) << 8) + } + + func readBigEndianUInt16(at offset: Int) -> UInt16 { + (UInt16(self[startIndex + offset]) << 8) | UInt16(self[startIndex + offset + 1]) + } +} diff --git a/Sources/ToolkitCore/AppleTools.swift b/Sources/ToolkitCore/AppleTools.swift new file mode 100644 index 0000000..824cd7b --- /dev/null +++ b/Sources/ToolkitCore/AppleTools.swift @@ -0,0 +1,162 @@ +import Foundation + +/// Fixed locations of the Apple tools the toolkit uses. Tools are never resolved through +/// `PATH`, so a same-named executable elsewhere cannot be substituted. +public enum AppleTool: String, CaseIterable, Sendable { + case xcrun + case xcodeSelect = "xcode-select" + case codesign + case security + case hdiutil + case open + case ditto + case rvictl + case swVers = "sw_vers" + + public var candidates: [URL] { + switch self { + case .xcrun: return [URL(fileURLWithPath: "/usr/bin/xcrun")] + case .xcodeSelect: return [URL(fileURLWithPath: "/usr/bin/xcode-select")] + case .codesign: return [URL(fileURLWithPath: "/usr/bin/codesign")] + case .security: return [URL(fileURLWithPath: "/usr/bin/security")] + case .hdiutil: return [URL(fileURLWithPath: "/usr/bin/hdiutil")] + case .open: return [URL(fileURLWithPath: "/usr/bin/open")] + case .ditto: return [URL(fileURLWithPath: "/usr/bin/ditto")] + case .rvictl: return [URL(fileURLWithPath: "/Library/Apple/usr/bin/rvictl"), URL(fileURLWithPath: "/usr/bin/rvictl")] + case .swVers: return [URL(fileURLWithPath: "/usr/bin/sw_vers")] + } + } + + /// Whether the tool ships with Xcode (as opposed to macOS itself). + public var requiresXcode: Bool { + switch self { + case .rvictl: return true + default: return false + } + } + + public func locate() throws -> URL { + for candidate in candidates where FileManager.default.isExecutableFile(atPath: candidate.path) { + return candidate + } + throw ToolkitError( + .toolMissing, + message: "\(rawValue) is not available on this Mac.", + recovery: requiresXcode + ? "Install Xcode from the App Store and open it once to finish installing its components." + : "Reinstall the macOS Command Line Tools with xcode-select --install.", + technicalDetail: "Checked: " + candidates.map(\.path).joined(separator: ", ") + ) + } +} + +/// Builders for `xcrun`-dispatched Xcode tools (`devicectl`, `simctl`, `xctrace`, `xed`). +public enum XcodeTool: String, Sendable, CaseIterable { + case devicectl + case simctl + case xctrace + case xed + + public func request( + _ arguments: [String], + timeout: TimeInterval? = 60, + displayName: String? = nil, + standardInput: Data? = nil, + outputLimit: Int = 32 * 1024 * 1024 + ) throws -> CommandRequest { + let xcrun = try AppleTool.xcrun.locate() + return CommandRequest( + executable: xcrun, + arguments: [rawValue] + arguments, + standardInput: standardInput, + timeout: timeout, + outputLimit: outputLimit, + displayName: displayName ?? CommandRequest.defaultDisplayName(tool: rawValue, arguments: arguments) + ) + } +} + +/// Describes which developer tooling is present on the host. +public struct DeveloperToolsStatus: Sendable, Hashable { + public enum Availability: Sendable, Hashable { + case available(version: String?) + case missing(reason: String) + /// The tool exists but did not answer in time (a busy Mac), which is not the same as + /// Xcode being missing. + case unresponsive(reason: String) + + public var isAvailable: Bool { + if case .available = self { return true } + return false + } + } + + public var developerDirectory: String? + public var xcodeVersion: String? + public var devicectl: Availability + public var simctl: Availability + public var xctrace: Availability + + public init(developerDirectory: String?, xcodeVersion: String?, devicectl: Availability, simctl: Availability, xctrace: Availability) { + self.developerDirectory = developerDirectory + self.xcodeVersion = xcodeVersion + self.devicectl = devicectl + self.simctl = simctl + self.xctrace = xctrace + } + + /// Only the Command Line Tools (no Xcode.app) are selected. + public var isCommandLineToolsOnly: Bool { + developerDirectory?.hasPrefix("/Library/Developer/CommandLineTools") ?? false + } + + public static func probe(runner: CommandRunning) async -> DeveloperToolsStatus { + var developerDirectory: String? + if let selectURL = try? AppleTool.xcodeSelect.locate(), + let result = try? await runner.run(CommandRequest(executable: selectURL, arguments: ["-p"], timeout: 10, displayName: "xcode-select -p")), + result.succeeded { + developerDirectory = result.standardOutputText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + async let devicectl = availability(runner: runner, tool: .devicectl, arguments: ["--version"]) + async let simctl = availability(runner: runner, tool: .simctl, arguments: ["help"]) + async let xctrace = availability(runner: runner, tool: .xctrace, arguments: ["version"]) + async let xcodeVersion = xcodeBuildVersion(runner: runner) + + return DeveloperToolsStatus( + developerDirectory: developerDirectory, + xcodeVersion: await xcodeVersion, + devicectl: await devicectl, + simctl: await simctl, + xctrace: await xctrace + ) + } + + private static func availability(runner: CommandRunning, tool: XcodeTool, arguments: [String]) async -> Availability { + do { + let result = try await runner.run(try tool.request(arguments, timeout: probeTimeout)) + guard result.succeeded else { + return .missing(reason: "xcrun could not run \(tool.rawValue) (exit \(result.exitCode ?? -1)). \(result.standardErrorText.prefix(300))") + } + let firstLine = (result.standardOutputText + result.standardErrorText) + .split(separator: "\n").first.map(String.init)? + .trimmingCharacters(in: .whitespaces) + return .available(version: tool == .simctl ? nil : firstLine) + } catch let error as ToolkitError where error.kind == .timedOut { + return .unresponsive(reason: error.message) + } catch { + return .missing(reason: (error as? ToolkitError)?.message ?? error.localizedDescription) + } + } + + /// Normally well under a second; a Mac busy booting a simulator can take much longer. + static let probeTimeout: TimeInterval = 45 + + private static func xcodeBuildVersion(runner: CommandRunning) async -> String? { + guard let xcrun = try? AppleTool.xcrun.locate(), + let result = try? await runner.run(CommandRequest(executable: xcrun, arguments: ["xcodebuild", "-version"], timeout: probeTimeout, displayName: "xcodebuild -version")), + result.succeeded + else { return nil } + return result.standardOutputText.split(separator: "\n").map(String.init).joined(separator: " · ") + } +} diff --git a/Sources/ToolkitCore/CommandRunner.swift b/Sources/ToolkitCore/CommandRunner.swift new file mode 100644 index 0000000..e6de9ad --- /dev/null +++ b/Sources/ToolkitCore/CommandRunner.swift @@ -0,0 +1,455 @@ +import Foundation +import OSLog + +/// A fully specified external command. Arguments are always passed as a vector; nothing is +/// ever interpreted by a shell. +public struct CommandRequest: Sendable, Hashable { + public var executable: URL + public var arguments: [String] + public var environment: [String: String] + public var workingDirectory: URL? + public var standardInput: Data? + /// `nil` means no time limit (used for explicitly stoppable streams). + public var timeout: TimeInterval? + /// Maximum bytes retained per output channel. Streams deliver every byte to the consumer + /// regardless of this limit; the limit only bounds what is buffered in memory. + public var outputLimit: Int + /// Human-readable name for logs and errors, e.g. "devicectl list devices". + public var displayName: String + /// Time between SIGTERM and SIGKILL when stopping. + public var terminationGracePeriod: TimeInterval + + public init( + executable: URL, + arguments: [String], + environment: [String: String] = CommandEnvironment.minimal(), + workingDirectory: URL? = nil, + standardInput: Data? = nil, + timeout: TimeInterval? = 60, + outputLimit: Int = 32 * 1024 * 1024, + displayName: String? = nil, + terminationGracePeriod: TimeInterval = 3 + ) { + self.executable = executable + self.arguments = arguments + self.environment = environment + self.workingDirectory = workingDirectory + self.standardInput = standardInput + self.timeout = timeout + self.outputLimit = outputLimit + self.displayName = displayName ?? Self.defaultDisplayName(tool: executable.lastPathComponent, arguments: arguments) + self.terminationGracePeriod = terminationGracePeriod + } + + /// The tool plus its leading subcommand words (at most three). Display names are logged + /// publicly, so the name stops at the first argument that is not a plain word: UDIDs, paths, + /// URLs, options, and values never appear in it. + public static func defaultDisplayName(tool: String, arguments: [String]) -> String { + let words = arguments.prefix(3).prefix { $0.range(of: "^[A-Za-z][A-Za-z-]*$", options: .regularExpression) != nil } + return ([tool] + words).joined(separator: " ") + } + + /// A copy-pasteable rendering of the argument vector (quoted for display only). + public var commandLine: String { + ([executable.path] + arguments).map(ShellQuoting.quote).joined(separator: " ") + } +} + +public enum ShellQuoting { + /// Quotes a single argument for *display*. The runner never executes this string. + public static func quote(_ argument: String) -> String { + if argument.isEmpty { return "''" } + let safe = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@%_+=:,./-") + if argument.unicodeScalars.allSatisfy({ safe.contains($0) }) { return argument } + return "'" + argument.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } +} + +public enum CommandEnvironment { + /// A minimal, predictable environment. Inherited variables such as `DYLD_*`, + /// credential tokens, or tool-specific target selectors are not passed to children. + public static func minimal(adding extra: [String: String] = [:]) -> [String: String] { + let parent = ProcessInfo.processInfo.environment + var environment: [String: String] = [ + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "LANG": "en_US.UTF-8", + "NSUnbufferedIO": "YES", + "NO_COLOR": "1", + ] + for key in ["HOME", "USER", "LOGNAME", "TMPDIR", "DEVELOPER_DIR"] { + if let value = parent[key], !value.isEmpty { environment[key] = value } + } + for (key, value) in extra { environment[key] = value } + return environment + } +} + +public struct CommandResult: Sendable, Hashable { + public enum Termination: Sendable, Hashable { + case exited(Int32) + case signaled(Int32) + } + + public let request: CommandRequest + public let termination: Termination + public let standardOutput: Data + public let standardError: Data + public let standardOutputTruncated: Bool + public let standardErrorTruncated: Bool + public let startedAt: Date + public let finishedAt: Date + + public init( + request: CommandRequest, + termination: Termination, + standardOutput: Data, + standardError: Data, + standardOutputTruncated: Bool = false, + standardErrorTruncated: Bool = false, + startedAt: Date, + finishedAt: Date + ) { + self.request = request + self.termination = termination + self.standardOutput = standardOutput + self.standardError = standardError + self.standardOutputTruncated = standardOutputTruncated + self.standardErrorTruncated = standardErrorTruncated + self.startedAt = startedAt + self.finishedAt = finishedAt + } + + public var succeeded: Bool { termination == .exited(0) } + public var exitCode: Int32? { + if case .exited(let code) = termination { return code } + return nil + } + public var duration: TimeInterval { finishedAt.timeIntervalSince(startedAt) } + public var standardOutputText: String { String(decoding: standardOutput, as: UTF8.self) } + public var standardErrorText: String { String(decoding: standardError, as: UTF8.self) } + + /// A compact technical summary (never shown as the primary error message). + public var technicalSummary: String { + var lines = ["Command: \(request.commandLine)"] + switch termination { + case .exited(let code): lines.append("Exit status: \(code)") + case .signaled(let signal): lines.append("Terminated by signal \(signal)") + } + let errorText = standardErrorText.trimmingCharacters(in: .whitespacesAndNewlines) + if !errorText.isEmpty { lines.append("stderr: \(String(errorText.suffix(4_000)))") } + return lines.joined(separator: "\n") + } +} + +public enum CommandStreamEvent: Sendable { + case standardOutput(Data) + case standardError(Data) + case finished(CommandResult) +} + +/// The abstraction every service depends on, so tests can substitute scripted results. +public protocol CommandRunning: Sendable { + /// Runs to completion. Non-zero exit codes are returned, not thrown; launch failure, + /// timeout, and cancellation throw `ToolkitError`. + func run(_ request: CommandRequest) async throws -> CommandResult + /// Streams output as it arrives and finishes with `.finished`. Cancelling the consuming + /// task stops the child process. + func stream(_ request: CommandRequest) -> AsyncThrowingStream +} + +/// The production runner. It is the only type in the code base that creates `Process`. +public struct ProcessCommandRunner: CommandRunning { + public init() {} + + public func run(_ request: CommandRequest) async throws -> CommandResult { + var final: CommandResult? + for try await event in stream(request) { + if case .finished(let result) = event { final = result } + } + if Task.isCancelled { + throw ToolkitError.cancelled(request.displayName) + } + guard let final else { + throw ToolkitError(.internalInconsistency, message: "\(request.displayName) ended without a result.") + } + return final + } + + public func stream(_ request: CommandRequest) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let execution = ProcessExecution(request: request, continuation: continuation) + continuation.onTermination = { termination in + if case .cancelled = termination { execution.cancel() } + } + execution.start() + } + } +} + +extension ProcessCommandRunner { + /// Starts an independent application (for example a separately installed forensic GUI) + /// that should keep running after the toolkit's operation ends. Output is discarded and the + /// process is not terminated when the toolkit quits. Returns the process identifier. + public func launchDetached(_ request: CommandRequest) throws -> Int32 { + try ExecutableValidator.validate(request.executable) + let process = Process() + process.executableURL = request.executable + process.arguments = request.arguments + process.environment = request.environment + if let directory = request.workingDirectory { process.currentDirectoryURL = directory } + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + throw ToolkitError(.toolMissing, message: "\(request.displayName) could not be started.", technicalDetail: "\(request.commandLine)\n\(error.localizedDescription)") + } + ToolkitLog.commands.info("Launched detached \(request.displayName, privacy: .public) pid=\(process.processIdentifier, privacy: .public)") + return process.processIdentifier + } +} + +/// Validates executables before they are launched. +public enum ExecutableValidator { + public static func validate(_ url: URL, requireSystemOwned: Bool = false) throws { + guard url.isFileURL, url.path.hasPrefix("/") else { + throw ToolkitError(.toolMissing, message: "The tool path must be absolute.", technicalDetail: url.absoluteString) + } + let path = url.path + var info = stat() + guard stat(path, &info) == 0 else { + throw ToolkitError( + .toolMissing, + message: "\(url.lastPathComponent) is not installed at the expected location.", + recovery: "Install Xcode or the Command Line Tools, then select them with xcode-select.", + technicalDetail: "Missing executable: \(path)" + ) + } + guard (info.st_mode & S_IFMT) == S_IFREG, access(path, X_OK) == 0 else { + throw ToolkitError(.toolMissing, message: "\(url.lastPathComponent) is not an executable file.", technicalDetail: path) + } + if (info.st_mode & S_IWOTH) != 0 { + throw ToolkitError( + .permissionDenied, + message: "\(url.lastPathComponent) is writable by every user, so it will not be run.", + recovery: "Restrict the file's permissions (for example chmod o-w) and try again.", + technicalDetail: path + ) + } + if requireSystemOwned && info.st_uid != 0 { + throw ToolkitError(.permissionDenied, message: "\(url.lastPathComponent) is not owned by the system.", technicalDetail: path) + } + } +} + +// MARK: - Process lifecycle + +private final class ProcessExecution: @unchecked Sendable { + private let request: CommandRequest + private let continuation: AsyncThrowingStream.Continuation + private let lock = NSLock() + private let process = Process() + private let outputPipe = Pipe() + private let errorPipe = Pipe() + private let inputPipe = Pipe() + private var stdoutBuffer = Data() + private var stderrBuffer = Data() + private var stdoutTruncated = false + private var stderrTruncated = false + private var stdoutClosed = false + private var stderrClosed = false + private var terminationStatus: CommandResult.Termination? + private var finished = false + private var cancelled = false + private var timedOut = false + private var startedAt = Date() + private static let logger = ToolkitLog.commands + + init(request: CommandRequest, continuation: AsyncThrowingStream.Continuation) { + self.request = request + self.continuation = continuation + } + + func start() { + do { + try ExecutableValidator.validate(request.executable) + } catch { + Self.logger.error("Refused to launch \(self.request.displayName, privacy: .public): \((error as? ToolkitError)?.kind.rawValue ?? "error", privacy: .public) \(String(describing: error), privacy: .private)") + continuation.finish(throwing: error) + return + } + + process.executableURL = request.executable + process.arguments = request.arguments + process.environment = request.environment + if let directory = request.workingDirectory { process.currentDirectoryURL = directory } + process.standardOutput = outputPipe + process.standardError = errorPipe + process.standardInput = request.standardInput == nil ? FileHandle.nullDevice : inputPipe + + outputPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + self?.consume(handle.availableData, isError: false) + } + errorPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + self?.consume(handle.availableData, isError: true) + } + process.terminationHandler = { [weak self] process in + let termination: CommandResult.Termination = process.terminationReason == .uncaughtSignal + ? .signaled(process.terminationStatus) + : .exited(process.terminationStatus) + self?.processTerminated(termination) + } + + startedAt = Date() + do { + try process.run() + } catch { + outputPipe.fileHandleForReading.readabilityHandler = nil + errorPipe.fileHandleForReading.readabilityHandler = nil + Self.logger.error("Launch failed for \(self.request.displayName, privacy: .public): \(error.localizedDescription, privacy: .private)") + continuation.finish(throwing: ToolkitError( + .toolMissing, + message: "\(request.executable.lastPathComponent) could not be started.", + recovery: "Confirm that Xcode or the Command Line Tools are installed and selected.", + technicalDetail: "\(request.commandLine)\n\(error.localizedDescription)" + )) + return + } + Self.logger.info("Started \(self.request.displayName, privacy: .public) pid=\(self.process.processIdentifier, privacy: .public) args=\(self.request.arguments.joined(separator: " "), privacy: .private)") + + if let input = request.standardInput { + let handle = inputPipe.fileHandleForWriting + DispatchQueue.global(qos: .userInitiated).async { + do { + try handle.write(contentsOf: input) + } catch { + Self.logger.error("Could not write standard input: \(error.localizedDescription, privacy: .private)") + } + try? handle.close() + } + } + + if let timeout = request.timeout { + DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { [weak self] in + self?.timeoutFired() + } + } + } + + func cancel() { + lock.lock() + let alreadyDone = finished || terminationStatus != nil + cancelled = true + lock.unlock() + guard !alreadyDone else { return } + stopProcess() + } + + private func timeoutFired() { + lock.lock() + let alreadyDone = finished || terminationStatus != nil + if !alreadyDone { timedOut = true } + lock.unlock() + guard !alreadyDone else { return } + Self.logger.error("\(self.request.displayName, privacy: .public) exceeded its time limit") + stopProcess() + } + + private func stopProcess() { + guard process.isRunning else { return } + let pid = process.processIdentifier + process.terminate() + let grace = request.terminationGracePeriod + DispatchQueue.global().asyncAfter(deadline: .now() + grace) { [weak self] in + guard let self, self.process.isRunning else { return } + Self.logger.error("Force-stopping pid \(pid, privacy: .public) after \(grace, privacy: .public)s grace period") + kill(pid, SIGKILL) + } + } + + private func consume(_ data: Data, isError: Bool) { + lock.lock() + if finished { + lock.unlock() + return + } + if data.isEmpty { + if isError { + stderrClosed = true + errorPipe.fileHandleForReading.readabilityHandler = nil + } else { + stdoutClosed = true + outputPipe.fileHandleForReading.readabilityHandler = nil + } + let ready = terminationStatus != nil && stdoutClosed && stderrClosed + lock.unlock() + if ready { finish() } + return + } + let limit = request.outputLimit + if isError { + let room = max(0, limit - stderrBuffer.count) + if room > 0 { stderrBuffer.append(data.prefix(room)) } + if data.count > room { stderrTruncated = true } + } else { + let room = max(0, limit - stdoutBuffer.count) + if room > 0 { stdoutBuffer.append(data.prefix(room)) } + if data.count > room { stdoutTruncated = true } + } + lock.unlock() + continuation.yield(isError ? .standardError(data) : .standardOutput(data)) + } + + private func processTerminated(_ termination: CommandResult.Termination) { + lock.lock() + terminationStatus = termination + let ready = stdoutClosed && stderrClosed + lock.unlock() + if ready { + finish() + } else { + // A grandchild can keep a pipe open after the child exits. Give the pipes a short + // window to reach EOF, then finish with what was captured. + DispatchQueue.global().asyncAfter(deadline: .now() + 2) { [weak self] in + self?.finish() + } + } + } + + private func finish() { + lock.lock() + guard !finished, let termination = terminationStatus else { + lock.unlock() + return + } + finished = true + let result = CommandResult( + request: request, + termination: termination, + standardOutput: stdoutBuffer, + standardError: stderrBuffer, + standardOutputTruncated: stdoutTruncated, + standardErrorTruncated: stderrTruncated, + startedAt: startedAt, + finishedAt: Date() + ) + let wasCancelled = cancelled + let wasTimedOut = timedOut + lock.unlock() + + outputPipe.fileHandleForReading.readabilityHandler = nil + errorPipe.fileHandleForReading.readabilityHandler = nil + + Self.logger.info("Finished \(self.request.displayName, privacy: .public) status=\(String(describing: termination), privacy: .public) duration=\(result.duration, format: .fixed(precision: 2), privacy: .public)s") + + if wasTimedOut, let timeout = request.timeout { + continuation.finish(throwing: ToolkitError.timedOut(request.displayName, after: timeout).appendingDetail(result.technicalSummary)) + } else if wasCancelled { + continuation.finish(throwing: ToolkitError.cancelled(request.displayName)) + } else { + continuation.yield(.finished(result)) + continuation.finish() + } + } +} diff --git a/Sources/ToolkitCore/Concurrency.swift b/Sources/ToolkitCore/Concurrency.swift new file mode 100644 index 0000000..1a696bf --- /dev/null +++ b/Sources/ToolkitCore/Concurrency.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Runs `operation`, failing with a `.timedOut` `ToolkitError` if it does not finish in time. +/// The operation is cancelled when the deadline passes. +public func withTimeout( + _ seconds: TimeInterval, + operation name: String, + _ body: @escaping @Sendable () async throws -> T +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await body() } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(max(0, seconds) * 1_000_000_000)) + throw ToolkitError.timedOut(name, after: seconds) + } + defer { group.cancelAll() } + guard let first = try await group.next() else { + throw ToolkitError(.internalInconsistency, message: "\(name) produced no result.") + } + return first + } +} + +/// A small lock-protected box for state shared with callbacks that are not async-aware. +public final class LockedValue: @unchecked Sendable { + private let lock = NSLock() + private var value: Value + + public init(_ value: Value) { + self.value = value + } + + public func withLock(_ body: (inout Value) throws -> Result) rethrows -> Result { + lock.lock() + defer { lock.unlock() } + return try body(&value) + } + + public var current: Value { + withLock { $0 } + } +} diff --git a/Sources/ToolkitCore/JSONValue.swift b/Sources/ToolkitCore/JSONValue.swift new file mode 100644 index 0000000..18253ec --- /dev/null +++ b/Sources/ToolkitCore/JSONValue.swift @@ -0,0 +1,139 @@ +import Foundation + +/// A `Sendable` JSON value for tool output whose schema is versioned by someone else +/// (CoreDevice, simctl). Accessors are optional so an unexpected shape never crashes. +public enum JSONValue: Sendable, Hashable { + case null + case bool(Bool) + /// Exact integers (identifiers such as ECIDs exceed Double's 53-bit precision). + case integer(Int64) + case number(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + public init(foundation object: Any) { + switch object { + case is NSNull: + self = .null + case let value as NSNumber: + if CFGetTypeID(value) == CFBooleanGetTypeID() { + self = .bool(value.boolValue) + } else if !CFNumberIsFloatType(value) { + self = .integer(value.int64Value) + } else { + self = .number(value.doubleValue) + } + case let value as String: + self = .string(value) + case let value as [Any]: + self = .array(value.map(JSONValue.init(foundation:))) + case let value as [String: Any]: + self = .object(value.mapValues(JSONValue.init(foundation:))) + default: + self = .string(String(describing: object)) + } + } + + public static func parse(_ data: Data) throws -> JSONValue { + do { + let object = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + return JSONValue(foundation: object) + } catch { + throw ToolkitError(.protocolViolation, message: "A developer tool returned output that is not valid JSON.", technicalDetail: String(describing: error)) + } + } + + public subscript(key: String) -> JSONValue? { + if case .object(let object) = self { return object[key] } + return nil + } + + public subscript(index: Int) -> JSONValue? { + if case .array(let array) = self, array.indices.contains(index) { return array[index] } + return nil + } + + /// Follows a dotted path such as "result.devices". + public func value(at path: String) -> JSONValue? { + var current: JSONValue? = self + for component in path.split(separator: ".") { + current = current?[String(component)] + } + return current + } + + public var string: String? { + switch self { + case .string(let value): return value + case .integer(let value): return String(value) + case .number(let value): + if value.rounded() == value, abs(value) < 1e15 { return String(Int64(value)) } + return String(value) + case .bool(let value): return value ? "true" : "false" + default: return nil + } + } + + public var nonEmptyString: String? { + guard let value = string?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } + return value + } + + public var bool: Bool? { + switch self { + case .bool(let value): return value + case .string(let value): + switch value.lowercased() { + case "true", "yes", "enabled": return true + case "false", "no", "disabled": return false + default: return nil + } + default: return nil + } + } + + public var double: Double? { + switch self { + case .number(let value): return value + case .integer(let value): return Double(value) + case .string(let value): return Double(value) + default: return nil + } + } + + public var int: Int? { + if case .integer(let value) = self { return Int(exactly: value) } + guard let value = double, value.isFinite, value.rounded() == value, abs(value) < 9e15 else { return nil } + return Int(value) + } + + public var array: [JSONValue]? { + if case .array(let value) = self { return value } + return nil + } + + public var object: [String: JSONValue]? { + if case .object(let value) = self { return value } + return nil + } + + public var foundationObject: Any { + switch self { + case .null: return NSNull() + case .bool(let value): return NSNumber(value: value) + case .integer(let value): return NSNumber(value: value) + case .number(let value): return NSNumber(value: value) + case .string(let value): return value + case .array(let value): return value.map(\.foundationObject) + case .object(let value): return value.mapValues(\.foundationObject) + } + } + + public func prettyString() -> String { + guard let data = try? JSONSerialization.data(withJSONObject: foundationObject, options: [.prettyPrinted, .sortedKeys, .fragmentsAllowed, .withoutEscapingSlashes]) else { + return String(describing: self) + } + return String(decoding: data, as: UTF8.self) + } +} diff --git a/Sources/ToolkitCore/Logging.swift b/Sources/ToolkitCore/Logging.swift new file mode 100644 index 0000000..56e4b5b --- /dev/null +++ b/Sources/ToolkitCore/Logging.swift @@ -0,0 +1,105 @@ +import Foundation +import OSLog + +/// Structured logging categories. Every log line goes through `Logger`, so it appears in +/// Console.app under the subsystem below and can be exported by the diagnostic log viewer. +/// +/// Privacy: device identifiers, names, paths, and command arguments are logged with +/// `privacy: .private` (redacted unless a logging profile is installed). Only coarse state +/// (counts, outcomes, durations, error kinds) is logged publicly. +public enum LogCategory: String, CaseIterable, Sendable { + case application = "Application" + case deviceDiscovery = "DeviceDiscovery" + case deviceCommunication = "DeviceCommunication" + case commands = "Commands" + case diagnostics = "Diagnostics" + case security = "Security" + case networking = "Networking" + case filesystem = "Filesystem" + case backup = "Backup" + case location = "Location" + case logs = "LiveLogs" + case evidence = "Evidence" +} + +public enum ToolkitLog { + public static let subsystem = "io.hideouts.iOSDeveloperToolkit" + + public static func logger(_ category: LogCategory) -> Logger { + Logger(subsystem: subsystem, category: category.rawValue) + } + + public static let application = logger(.application) + public static let deviceDiscovery = logger(.deviceDiscovery) + public static let deviceCommunication = logger(.deviceCommunication) + public static let commands = logger(.commands) + public static let diagnostics = logger(.diagnostics) + public static let security = logger(.security) + public static let networking = logger(.networking) + public static let filesystem = logger(.filesystem) + public static let backup = logger(.backup) + public static let location = logger(.location) + public static let logs = logger(.logs) + public static let evidence = logger(.evidence) +} + +/// One entry read back from the unified log for the in-app diagnostic viewer. +public struct DiagnosticLogEntry: Identifiable, Sendable, Hashable { + public let id: UUID + public let date: Date + public let category: String + public let level: String + public let message: String + + public init(id: UUID = UUID(), date: Date, category: String, level: String, message: String) { + self.id = id + self.date = date + self.category = category + self.level = level + self.message = message + } +} + +/// Reads this process's own log entries through `OSLogStore`. The current-process scope needs +/// no entitlement or elevated privilege. +public enum DiagnosticLogReader { + public static func recentEntries(since interval: TimeInterval = 3600, limit: Int = 5_000) throws -> [DiagnosticLogEntry] { + let store = try OSLogStore(scope: .currentProcessIdentifier) + let position = store.position(date: Date().addingTimeInterval(-interval)) + let predicate = NSPredicate(format: "subsystem == %@", ToolkitLog.subsystem) + var entries: [DiagnosticLogEntry] = [] + for case let entry as OSLogEntryLog in try store.getEntries(at: position, matching: predicate) { + entries.append( + DiagnosticLogEntry( + date: entry.date, + category: entry.category, + level: levelName(entry.level), + message: entry.composedMessage + ) + ) + if entries.count >= limit { break } + } + return entries + } + + static func levelName(_ level: OSLogEntryLog.Level) -> String { + switch level { + case .debug: return "Debug" + case .info: return "Info" + case .notice: return "Notice" + case .error: return "Error" + case .fault: return "Fault" + case .undefined: return "Default" + @unknown default: return "Default" + } + } + + /// Renders entries as plain text suitable for a support bundle after sanitization. + public static func render(_ entries: [DiagnosticLogEntry]) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return entries.map { entry in + "\(formatter.string(from: entry.date)) [\(entry.level)] \(entry.category): \(entry.message)" + }.joined(separator: "\n") + } +} diff --git a/Sources/ToolkitCore/OperationJournal.swift b/Sources/ToolkitCore/OperationJournal.swift new file mode 100644 index 0000000..28d5154 --- /dev/null +++ b/Sources/ToolkitCore/OperationJournal.swift @@ -0,0 +1,223 @@ +import Foundation + +/// How a device or host operation ended. +public enum OperationOutcome: String, Codable, Sendable, CaseIterable { + case succeeded + case failed + case cancelled + case timedOut = "timed-out" + case launchFailed = "launch-failed" + + public var label: String { + switch self { + case .succeeded: return "Succeeded" + case .failed: return "Failed" + case .cancelled: return "Cancelled" + case .timedOut: return "Timed out" + case .launchFailed: return "Could not start" + } + } + + public static func from(_ error: Error) -> OperationOutcome { + guard let toolkitError = error as? ToolkitError else { + return error is CancellationError ? .cancelled : .failed + } + switch toolkitError.kind { + case .cancelled: return .cancelled + case .timedOut: return .timedOut + case .toolMissing: return .launchFailed + default: return .failed + } + } +} + +/// One completed operation in the session journal ("Session Activity"). +/// +/// Raw output is never stored; only its size and SHA-256, so the journal can be exported +/// without embedding device content. +public struct OperationRecord: Identifiable, Codable, Sendable, Hashable { + public let id: String + public let title: String + public let workspace: String + public let target: String + public let transport: String + public let argv: [String] + public let startedAt: Date + public let finishedAt: Date + public let outcome: OperationOutcome + public let exitCode: Int32? + public let errorMessage: String? + public let outputBytes: Int + public let outputSHA256: String + public let errorOutputBytes: Int + public let errorOutputSHA256: String + public let prerequisites: [String] + public let outputPaths: [String] + + public var durationMilliseconds: Int { + max(0, Int((finishedAt.timeIntervalSince(startedAt) * 1000).rounded())) + } + + public init( + title: String, + workspace: String, + target: String, + transport: String, + argv: [String] = [], + startedAt: Date, + finishedAt: Date, + outcome: OperationOutcome, + exitCode: Int32? = nil, + errorMessage: String? = nil, + output: Data = Data(), + errorOutput: Data = Data(), + prerequisites: [String] = [], + outputPaths: [String] = [] + ) { + let identity = ([workspace, title, ISO8601.string(startedAt), ISO8601.string(finishedAt), UUID().uuidString] + argv).joined(separator: "\u{0}") + self.id = String(SecureFileIO.sha256(of: Data(identity.utf8)).prefix(16)) + self.title = title + self.workspace = workspace + self.target = target + self.transport = transport + self.argv = argv + self.startedAt = startedAt + self.finishedAt = max(finishedAt, startedAt) + self.outcome = outcome + self.exitCode = exitCode + self.errorMessage = errorMessage + self.outputBytes = output.count + self.outputSHA256 = SecureFileIO.sha256(of: output) + self.errorOutputBytes = errorOutput.count + self.errorOutputSHA256 = SecureFileIO.sha256(of: errorOutput) + self.prerequisites = prerequisites + self.outputPaths = outputPaths + } + + /// The explicit-export manifest. It never embeds raw output. + public func manifestJSON() throws -> Data { + let manifest: [String: Any] = [ + "schema_version": 2, + "operation_id": id, + "title": title, + "workspace": workspace, + "target": target, + "transport": transport, + "argv": argv, + "timing": [ + "started_at": ISO8601.string(startedAt), + "finished_at": ISO8601.string(finishedAt), + "duration_milliseconds": durationMilliseconds, + ], + "result": [ + "outcome": outcome.rawValue, + "exit_code": exitCode.map { NSNumber(value: $0) } ?? NSNull(), + "error_message": errorMessage ?? NSNull(), + ] as [String: Any], + "captured_output": [ + "stdout_bytes": outputBytes, + "stdout_sha256": outputSHA256, + "stderr_bytes": errorOutputBytes, + "stderr_sha256": errorOutputSHA256, + "raw_output_included": false, + ] as [String: Any], + "prerequisites": prerequisites, + "output_paths": outputPaths, + "privacy_notice": "This user-exported manifest omits raw output but may contain device identifiers, local paths, and other sensitive values from the argument vector and target label.", + ] + return try JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + } +} + +/// The in-memory session journal. Nothing is written to disk automatically. +public actor OperationJournal { + public static let defaultCapacity = 250 + private let capacity: Int + private var storage: [OperationRecord] = [] + private var observers: [UUID: AsyncStream<[OperationRecord]>.Continuation] = [:] + + public init(capacity: Int = OperationJournal.defaultCapacity) { + self.capacity = max(1, capacity) + } + + public var records: [OperationRecord] { storage } + + public func append(_ record: OperationRecord) { + guard !storage.contains(where: { $0.id == record.id }) else { return } + storage.append(record) + if storage.count > capacity { + storage.removeFirst(storage.count - capacity) + } + for observer in observers.values { observer.yield(storage) } + } + + public func clear() { + storage.removeAll() + for observer in observers.values { observer.yield(storage) } + } + + public func updates() -> AsyncStream<[OperationRecord]> { + let identifier = UUID() + let (stream, continuation) = AsyncStream<[OperationRecord]>.makeStream(bufferingPolicy: .bufferingNewest(1)) + continuation.yield(storage) + observers[identifier] = continuation + continuation.onTermination = { [weak self] _ in + Task { await self?.removeObserver(identifier) } + } + return stream + } + + private func removeObserver(_ identifier: UUID) { + observers[identifier] = nil + } +} + +public enum ISO8601 { + public static func string(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + } + + public static func compactUTC(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + return formatter.string(from: date) + } + + public static func parse(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} + +public enum ByteFormatting { + /// Decimal units, matching Finder. + public static func string(_ bytes: Int64?) -> String { + guard let bytes else { return "—" } + return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } +} + +public enum JSONOutput { + /// Deterministic pretty JSON for files the toolkit writes. + public static func encode(_ value: T) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .iso8601 + var data = try encoder.encode(value) + data.append(0x0A) + return data + } + + public static func decoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/Sources/ToolkitCore/PlistValue.swift b/Sources/ToolkitCore/PlistValue.swift new file mode 100644 index 0000000..ea24b25 --- /dev/null +++ b/Sources/ToolkitCore/PlistValue.swift @@ -0,0 +1,224 @@ +import Foundation + +/// A `Sendable`, strongly typed property-list value. +/// +/// Device protocols exchange property lists whose shape is controlled by the device, so they are +/// treated as untrusted input: every accessor is optional and nothing is force-cast. +public enum PlistValue: Sendable, Hashable { + case string(String) + case integer(Int64) + case unsignedInteger(UInt64) + case real(Double) + case boolean(Bool) + case date(Date) + case data(Data) + case array([PlistValue]) + case dictionary([String: PlistValue]) + + // MARK: Accessors + + public var stringValue: String? { + if case .string(let value) = self { return value } + return nil + } + + public var intValue: Int? { + switch self { + case .integer(let value): return Int(exactly: value) + case .unsignedInteger(let value): return Int(exactly: value) + case .real(let value): + guard value.isFinite, value.rounded() == value else { return nil } + return Int(exactly: value) + default: return nil + } + } + + public var int64Value: Int64? { + switch self { + case .integer(let value): return value + case .unsignedInteger(let value): return Int64(exactly: value) + default: return nil + } + } + + public var uint64Value: UInt64? { + switch self { + case .integer(let value): return UInt64(exactly: value) + case .unsignedInteger(let value): return value + default: return nil + } + } + + public var doubleValue: Double? { + switch self { + case .real(let value): return value + case .integer(let value): return Double(value) + case .unsignedInteger(let value): return Double(value) + default: return nil + } + } + + public var boolValue: Bool? { + if case .boolean(let value) = self { return value } + return nil + } + + public var dateValue: Date? { + if case .date(let value) = self { return value } + return nil + } + + public var dataValue: Data? { + if case .data(let value) = self { return value } + return nil + } + + public var arrayValue: [PlistValue]? { + if case .array(let value) = self { return value } + return nil + } + + public var dictionaryValue: [String: PlistValue]? { + if case .dictionary(let value) = self { return value } + return nil + } + + public subscript(key: String) -> PlistValue? { + dictionaryValue?[key] + } + + public subscript(index: Int) -> PlistValue? { + guard let array = arrayValue, array.indices.contains(index) else { return nil } + return array[index] + } + + // MARK: Conversion from Foundation + + public init?(foundation object: Any) { + switch object { + case let value as String: + self = .string(value) + case let value as NSNumber: + if CFGetTypeID(value) == CFBooleanGetTypeID() { + self = .boolean(value.boolValue) + } else if CFNumberIsFloatType(value) { + self = .real(value.doubleValue) + } else if value.int64Value < 0 { + self = .integer(value.int64Value) + } else if value.uint64Value > UInt64(Int64.max) { + self = .unsignedInteger(value.uint64Value) + } else { + self = .integer(value.int64Value) + } + case let value as Date: + self = .date(value) + case let value as Data: + self = .data(value) + case let value as [Any]: + var items: [PlistValue] = [] + items.reserveCapacity(value.count) + for item in value { + guard let converted = PlistValue(foundation: item) else { return nil } + items.append(converted) + } + self = .array(items) + case let value as [String: Any]: + var items: [String: PlistValue] = [:] + for (key, item) in value { + guard let converted = PlistValue(foundation: item) else { return nil } + items[key] = converted + } + self = .dictionary(items) + default: + // Keyed-archiver UIDs and other exotic CF types are preserved as a description so + // an unexpected value never crashes parsing. + self = .string(String(describing: object)) + } + } + + public var foundationObject: Any { + switch self { + case .string(let value): return value + case .integer(let value): return NSNumber(value: value) + case .unsignedInteger(let value): return NSNumber(value: value) + case .real(let value): return NSNumber(value: value) + case .boolean(let value): return NSNumber(value: value) + case .date(let value): return value + case .data(let value): return value + case .array(let value): return value.map(\.foundationObject) + case .dictionary(let value): return value.mapValues(\.foundationObject) + } + } + + // MARK: Serialization + + public static func decode(_ data: Data) throws -> PlistValue { + let object: Any + do { + object = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) + } catch { + throw ToolkitError( + .protocolViolation, + message: "The device returned data that is not a valid property list.", + technicalDetail: String(describing: error) + ) + } + guard let value = PlistValue(foundation: object) else { + throw ToolkitError(.protocolViolation, message: "The device returned an unsupported property-list value.") + } + return value + } + + public func encoded(format: PropertyListSerialization.PropertyListFormat = .xml) throws -> Data { + do { + return try PropertyListSerialization.data(fromPropertyList: foundationObject, format: format, options: 0) + } catch { + throw ToolkitError(.internalInconsistency, message: "Could not encode a property list.", technicalDetail: String(describing: error)) + } + } + + /// A readable, deterministic rendering for "raw details" views and snapshot files. + public func prettyJSONString() -> String { + let object = jsonCompatibleObject + guard JSONSerialization.isValidJSONObject(object), + let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]), + let text = String(data: data, encoding: .utf8) + else { + return String(describing: self) + } + return text + } + + public var jsonCompatibleObject: Any { + switch self { + case .string(let value): return value + case .integer(let value): return NSNumber(value: value) + case .unsignedInteger(let value): return NSNumber(value: value) + case .real(let value): return value.isFinite ? NSNumber(value: value) : String(value) + case .boolean(let value): return NSNumber(value: value) + case .date(let value): return ISO8601DateFormatter().string(from: value) + case .data(let value): + if value.count <= 64 { + return "" + } + return "" + case .array(let value): return value.map(\.jsonCompatibleObject) + case .dictionary(let value): return value.mapValues(\.jsonCompatibleObject) + } + } +} + +extension PlistValue: ExpressibleByStringLiteral, ExpressibleByIntegerLiteral, ExpressibleByBooleanLiteral, + ExpressibleByArrayLiteral, ExpressibleByDictionaryLiteral, ExpressibleByFloatLiteral +{ + public init(stringLiteral value: String) { self = .string(value) } + public init(integerLiteral value: Int64) { self = .integer(value) } + public init(booleanLiteral value: Bool) { self = .boolean(value) } + public init(floatLiteral value: Double) { self = .real(value) } + public init(arrayLiteral elements: PlistValue...) { self = .array(elements) } + public init(dictionaryLiteral elements: (String, PlistValue)...) { + var dictionary: [String: PlistValue] = [:] + for (key, value) in elements { dictionary[key] = value } + self = .dictionary(dictionary) + } +} diff --git a/Sources/ToolkitCore/Sanitizer.swift b/Sources/ToolkitCore/Sanitizer.swift new file mode 100644 index 0000000..bf5e52a --- /dev/null +++ b/Sources/ToolkitCore/Sanitizer.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Redacts identifying values from text that may leave the Mac (support bundles, exported +/// compatibility reports, exported diagnostic logs). +public enum Sanitizer { + private static let rules: [(NSRegularExpression, String)] = { + let patterns: [(String, String)] = [ + // Modern UDIDs (00008110-001234560ABC801E) and legacy 40-hex UDIDs. + (#"\b[0-9A-Fa-f]{8}-[0-9A-Fa-f]{16,}\b"#, ""), + (#"\b[0-9A-Fa-f]{40}\b"#, ""), + (#"\b[0-9A-Fa-f]{8}(?:-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}\b"#, ""), + (#"\b(?:\d{1,3}\.){3}\d{1,3}\b"#, ""), + (#"\b[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5}\b"#, ""), + (#"\b(?:[0-9A-Fa-f]{1,4}:){4,7}[0-9A-Fa-f]{1,4}\b"#, ""), + (#"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"#, ""), + (#"/(?:Users|private|var|Volumes|Library|Applications|System|opt|tmp)(?:/[^\s\\"']+)+"#, ""), + (#"~/[^\s\\"']+"#, ""), + ] + return patterns.compactMap { pattern, replacement in + (try? NSRegularExpression(pattern: pattern)).map { ($0, replacement) } + } + }() + + /// - Parameters: + /// - value: text to sanitize. + /// - redactions: additional literal values (device names, serials) to remove. + /// - limit: maximum returned length in characters. + public static func sanitize(_ value: String, redactions: [String] = [], limit: Int = 8_000) -> String { + var text = value + // Literal redactions first (longest first) so that a device name containing an + // identifier-like substring is removed as a whole. + for literal in redactions.filter({ !$0.isEmpty }).sorted(by: { $0.count > $1.count }) { + text = text.replacingOccurrences(of: literal, with: "") + } + for (expression, replacement) in rules { + let range = NSRange(text.startIndex.. limit { + text = String(text.prefix(limit)) + } + return text + } + + /// A one-way fingerprint for correlating observations of the same device locally without + /// storing its identifier. + public static func fingerprint(_ identifier: String, salt: String = "iOSDeveloperToolkit.v1") -> String { + String(SecureFileIO.sha256(of: Data((salt + ":" + identifier).utf8)).prefix(24)) + } +} diff --git a/Sources/ToolkitCore/SecureFileIO.swift b/Sources/ToolkitCore/SecureFileIO.swift new file mode 100644 index 0000000..c2f6553 --- /dev/null +++ b/Sources/ToolkitCore/SecureFileIO.swift @@ -0,0 +1,204 @@ +import CryptoKit +import Foundation + +/// File helpers that default to owner-only permissions, never follow symbolic links for new +/// files, and never overwrite unless explicitly asked to. +public enum SecureFileIO { + public static let privateFileMode: mode_t = 0o600 + public static let privateDirectoryMode: mode_t = 0o700 + + /// Creates a directory (and parents) with owner-only permissions. An existing directory is + /// accepted only if it is a real directory (not a symbolic link). + public static func createPrivateDirectory(at url: URL) throws { + let manager = FileManager.default + var isDirectory: ObjCBool = false + if manager.fileExists(atPath: url.path, isDirectory: &isDirectory) { + let attributes = try? manager.attributesOfItem(atPath: url.path) + if attributes?[.type] as? FileAttributeType == .typeSymbolicLink || !isDirectory.boolValue { + throw ToolkitError.fileSystem("The output location is not a regular folder.", path: url.path) + } + return + } + do { + try manager.createDirectory(at: url, withIntermediateDirectories: true, attributes: [.posixPermissions: NSNumber(value: privateDirectoryMode)]) + } catch { + throw ToolkitError.fileSystem("Could not create the folder.", path: url.path, underlying: error) + } + } + + /// Creates a new directory that must not already exist. + public static func createNewPrivateDirectory(at url: URL) throws { + let parent = url.deletingLastPathComponent() + try createPrivateDirectory(at: parent) + if mkdir(url.path, privateDirectoryMode) != 0 { + let code = errno + if code == EEXIST { + throw ToolkitError.fileSystem("A folder with this name already exists. Choose a new name.", path: url.path) + } + throw ToolkitError.fileSystem("Could not create the folder.", path: url.path, underlying: POSIXError(POSIXErrorCode(rawValue: code) ?? .EIO)) + } + } + + /// Writes a new file with owner-only permissions. Fails if anything exists at `url`. + public static func writeNewFile(_ data: Data, to url: URL, mode: mode_t = privateFileMode) throws { + let descriptor = open(url.path, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, mode) + guard descriptor >= 0 else { + let code = errno + if code == EEXIST { + throw ToolkitError.fileSystem("A file with this name already exists. Choose a new name; existing files are never overwritten.", path: url.path) + } + throw ToolkitError.fileSystem("Could not create the file.", path: url.path, underlying: POSIXError(POSIXErrorCode(rawValue: code) ?? .EIO)) + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + do { + try handle.write(contentsOf: data) + try handle.synchronize() + try handle.close() + } catch { + try? handle.close() + try? FileManager.default.removeItem(at: url) + throw ToolkitError.fileSystem("Could not write the file.", path: url.path, underlying: error) + } + } + + /// Replaces a file atomically (write to a unique sibling, fsync, rename). + public static func writeAtomically(_ data: Data, to url: URL, mode: mode_t = privateFileMode) throws { + let directory = url.deletingLastPathComponent() + try createPrivateDirectory(at: directory) + let temporary = directory.appendingPathComponent(".\(url.lastPathComponent).\(UUID().uuidString).tmp") + try writeNewFile(data, to: temporary, mode: mode) + if rename(temporary.path, url.path) != 0 { + let code = errno + try? FileManager.default.removeItem(at: temporary) + throw ToolkitError.fileSystem("Could not save the file.", path: url.path, underlying: POSIXError(POSIXErrorCode(rawValue: code) ?? .EIO)) + } + } + + /// Appends data to a file, creating it with owner-only permissions when needed. + public static func append(_ data: Data, to url: URL, synchronize: Bool = false) throws { + let descriptor = open(url.path, O_WRONLY | O_CREAT | O_APPEND | O_NOFOLLOW | O_CLOEXEC, privateFileMode) + guard descriptor >= 0 else { + throw ToolkitError.fileSystem("Could not open the file for appending.", path: url.path, underlying: POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)) + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + defer { try? handle.close() } + do { + try handle.write(contentsOf: data) + if synchronize { try handle.synchronize() } + } catch { + throw ToolkitError.fileSystem("Could not append to the file.", path: url.path, underlying: error) + } + } + + /// Creates a unique owner-only temporary directory (mkdtemp). + public static func makeTemporaryDirectory(prefix: String) throws -> URL { + let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let sanitizedPrefix = prefix.filter { $0.isLetter || $0.isNumber || $0 == "-" } + var template = Array(base.appendingPathComponent("\(sanitizedPrefix).XXXXXXXX").path.utf8CString) + let created = template.withUnsafeMutableBufferPointer { buffer -> UnsafeMutablePointer? in + mkdtemp(buffer.baseAddress) + } + guard created != nil else { + throw ToolkitError.fileSystem("Could not create a temporary folder.", path: base.path, underlying: POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)) + } + let path = template.withUnsafeBufferPointer { String(cString: $0.baseAddress!) } + return URL(fileURLWithPath: path, isDirectory: true) + } + + /// Resolves `relativePath` beneath `root`, rejecting absolute paths, `..` components, + /// NUL bytes, and anything that would escape `root` after normalization. + public static func safeChild(of root: URL, relativePath: String) throws -> URL { + guard !relativePath.isEmpty, !relativePath.contains("\0") else { + throw ToolkitError(.protocolViolation, message: "An unsafe file path was rejected.", technicalDetail: "Empty or NUL-containing path") + } + guard !relativePath.hasPrefix("/") else { + throw ToolkitError(.protocolViolation, message: "An unsafe file path was rejected.", technicalDetail: "Absolute path: \(relativePath)") + } + let components = relativePath.split(separator: "/", omittingEmptySubsequences: true) + guard !components.contains(where: { $0 == ".." }) else { + throw ToolkitError(.protocolViolation, message: "An unsafe file path was rejected.", technicalDetail: "Parent traversal: \(relativePath)") + } + let standardizedRoot = root.standardizedFileURL.path + var candidate = root.standardizedFileURL + for component in components where component != "." { + candidate.appendPathComponent(String(component)) + } + let resolved = candidate.standardizedFileURL.path + guard resolved == standardizedRoot || resolved.hasPrefix(standardizedRoot.hasSuffix("/") ? standardizedRoot : standardizedRoot + "/") else { + throw ToolkitError(.protocolViolation, message: "An unsafe file path was rejected.", technicalDetail: "Escapes root: \(relativePath)") + } + return candidate + } + + /// Streams a file through SHA-256. + public static func sha256(of url: URL) throws -> String { + guard let handle = try? FileHandle(forReadingFrom: url) else { + throw ToolkitError.fileSystem("Could not open the file to calculate its hash.", path: url.path) + } + defer { try? handle.close() } + var hasher = SHA256() + while true { + let chunk: Data? + do { + chunk = try handle.read(upToCount: 1 << 20) + } catch { + throw ToolkitError.fileSystem("Could not read the file to calculate its hash.", path: url.path, underlying: error) + } + guard let chunk, !chunk.isEmpty else { break } + hasher.update(data: chunk) + } + return hasher.finalize().hexString + } + + public static func sha256(of data: Data) -> String { + SHA256.hash(data: data).hexString + } + + /// Byte size of a regular file, or nil. + public static func fileSize(_ url: URL) -> Int64? { + (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)?.int64Value + } + + /// Lists regular files beneath `root` (not following symlinks), sorted by relative path. + public static func regularFiles(under root: URL) -> [(relativePath: String, url: URL)] { + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], + options: [], + errorHandler: { _, _ in true } + ) else { return [] } + let rootPath = root.standardizedFileURL.resolvingSymlinksInPath().path + var files: [(String, URL)] = [] + for case let url as URL in enumerator { + let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) + guard values?.isRegularFile == true, values?.isSymbolicLink != true else { continue } + let path = url.standardizedFileURL.resolvingSymlinksInPath().path + guard path.hasPrefix(rootPath + "/") else { continue } + files.append((String(path.dropFirst(rootPath.count + 1)), url)) + } + return files.sorted { $0.0 < $1.0 } + } +} + +extension Digest { + public var hexString: String { + map { String(format: "%02x", $0) }.joined() + } +} + +/// Incremental SHA-256 for data written progressively (live-log spools, PCAP files). +public struct StreamingHasher: Sendable { + private var hasher = SHA256() + public private(set) var byteCount: Int64 = 0 + + public init() {} + + public mutating func update(_ data: Data) { + hasher.update(data: data) + byteCount += Int64(data.count) + } + + public func finalizeHex() -> String { + hasher.finalize().hexString + } +} diff --git a/Sources/ToolkitCore/ToolkitError.swift b/Sources/ToolkitCore/ToolkitError.swift new file mode 100644 index 0000000..75bfcd4 --- /dev/null +++ b/Sources/ToolkitCore/ToolkitError.swift @@ -0,0 +1,114 @@ +import Foundation + +/// The single error type surfaced to the UI. +/// +/// `message` is written for someone who does not use Terminal; `recovery` says what to do next; +/// `technicalDetail` preserves the underlying cause (exit status, stderr, protocol error) for +/// advanced users and bug reports. +public struct ToolkitError: Error, LocalizedError, Sendable, Equatable { + public enum Kind: String, Sendable, Codable, CaseIterable { + case deviceNotFound + case deviceDisconnected + case deviceLocked + case notPaired + case pairingPending + case developerModeDisabled + case developerDiskImageUnavailable + case serviceUnavailable + case protocolViolation + case timedOut + case cancelled + case toolMissing + case commandFailed + case invalidInput + case fileSystem + case unsupported + case permissionDenied + case internalInconsistency + } + + public let kind: Kind + public let message: String + public let recovery: String? + public let technicalDetail: String? + + public init(_ kind: Kind, message: String, recovery: String? = nil, technicalDetail: String? = nil) { + self.kind = kind + self.message = message + self.recovery = recovery + self.technicalDetail = technicalDetail + } + + public var errorDescription: String? { message } + public var recoverySuggestion: String? { recovery } + public var failureReason: String? { technicalDetail } + + /// A compact title for alerts. + public var title: String { + switch kind { + case .deviceNotFound: return "Device Not Found" + case .deviceDisconnected: return "Device Disconnected" + case .deviceLocked: return "Device Is Locked" + case .notPaired: return "Device Has Not Trusted This Mac" + case .pairingPending: return "Waiting for Trust" + case .developerModeDisabled: return "Developer Mode Is Off" + case .developerDiskImageUnavailable: return "Developer Services Unavailable" + case .serviceUnavailable: return "Service Unavailable" + case .protocolViolation: return "Unexpected Device Response" + case .timedOut: return "Operation Timed Out" + case .cancelled: return "Operation Cancelled" + case .toolMissing: return "Developer Tool Missing" + case .commandFailed: return "Operation Failed" + case .invalidInput: return "Check the Entered Value" + case .fileSystem: return "File Error" + case .unsupported: return "Not Supported" + case .permissionDenied: return "Permission Denied" + case .internalInconsistency: return "Unexpected Error" + } + } + + /// Returns a copy with more technical context appended. + public func appendingDetail(_ detail: String) -> ToolkitError { + let combined = [technicalDetail, detail].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: "\n") + return ToolkitError(kind, message: message, recovery: recovery, technicalDetail: combined.isEmpty ? nil : combined) + } + + // MARK: Common constructors + + public static func cancelled(_ what: String = "The operation") -> ToolkitError { + ToolkitError(.cancelled, message: "\(what) was cancelled.", recovery: "Review any partial output before relying on it.") + } + + public static func timedOut(_ what: String, after seconds: TimeInterval) -> ToolkitError { + ToolkitError( + .timedOut, + message: "\(what) did not finish within \(Int(seconds.rounded())) seconds.", + recovery: "Make sure the device is unlocked and still connected, then try again." + ) + } + + public static func invalidInput(_ message: String) -> ToolkitError { + ToolkitError(.invalidInput, message: message) + } + + public static func fileSystem(_ message: String, path: String? = nil, underlying: Error? = nil) -> ToolkitError { + let detail = [path.map { "Path: \($0)" }, underlying.map { String(describing: $0) }].compactMap { $0 }.joined(separator: "\n") + return ToolkitError(.fileSystem, message: message, technicalDetail: detail.isEmpty ? nil : detail) + } + + public static func deviceCommunication(technicalDetail: String? = nil) -> ToolkitError { + ToolkitError( + .serviceUnavailable, + message: "Unable to communicate with the selected device.", + recovery: "Make sure the device is unlocked, connected, and has trusted this Mac.", + technicalDetail: technicalDetail + ) + } + + /// Wraps any error, preserving a `ToolkitError` untouched. + public static func wrap(_ error: Error, message: String, recovery: String? = nil) -> ToolkitError { + if let toolkitError = error as? ToolkitError { return toolkitError } + if error is CancellationError { return .cancelled() } + return ToolkitError(.internalInconsistency, message: message, recovery: recovery, technicalDetail: String(describing: error)) + } +} diff --git a/Sources/ToolkitCore/ToolkitVersion.swift b/Sources/ToolkitCore/ToolkitVersion.swift new file mode 100644 index 0000000..8e917f6 --- /dev/null +++ b/Sources/ToolkitCore/ToolkitVersion.swift @@ -0,0 +1,8 @@ +import Foundation + +/// The toolkit's marketing version. The release workflow checks that this matches the tag and +/// the app's Info.plist. +public enum ToolkitVersion { + public static let current = "1.0.0" + public static let applicationName = "iOS Developer Toolkit" +} diff --git a/Sources/ToolkitCore/ZipWriter.swift b/Sources/ToolkitCore/ZipWriter.swift new file mode 100644 index 0000000..3af5a34 --- /dev/null +++ b/Sources/ToolkitCore/ZipWriter.swift @@ -0,0 +1,165 @@ +import Compression +import Foundation + +/// Writes a standard ZIP archive (stored or deflated entries) for support bundles and exports. +public struct ZipWriter { + public enum Method: UInt16 { + case stored = 0 + case deflated = 8 + } + + private struct Record { + var name: Data + var method: Method + var crc: UInt32 + var compressedSize: UInt32 + var uncompressedSize: UInt32 + var offset: UInt32 + var dosTime: UInt16 + var dosDate: UInt16 + var mode: UInt32 + } + + private var body = Data() + private var records: [Record] = [] + private let date: Date + + public init(date: Date = Date()) { + self.date = date + } + + public mutating func add(name: String, data: Data, method: Method = .deflated, mode: UInt32 = 0o600) throws { + guard !name.isEmpty, !name.hasPrefix("/"), !name.split(separator: "/").contains(".."), !name.contains("\\") else { + throw ToolkitError.invalidInput("Invalid archive entry name: \(name)") + } + guard data.count < Int(UInt32.max) else { + throw ToolkitError.invalidInput("\(name) is too large for this archive format.") + } + var payload = data + var chosen = method + if method == .deflated { + if let compressed = Self.deflate(data), compressed.count < data.count { + payload = compressed + } else { + chosen = .stored + } + } + let (time, day) = Self.dosDateTime(date) + let record = Record( + name: Data(name.utf8), + method: chosen, + crc: CRC32.checksum(data), + compressedSize: UInt32(payload.count), + uncompressedSize: UInt32(data.count), + offset: UInt32(body.count), + dosTime: time, + dosDate: day, + mode: mode + ) + var header = Data() + header.appendLE32(0x0403_4B50) + header.appendLE16(20) + header.appendLE16(0x0800) // UTF-8 names + header.appendLE16(record.method.rawValue) + header.appendLE16(record.dosTime) + header.appendLE16(record.dosDate) + header.appendLE32(record.crc) + header.appendLE32(record.compressedSize) + header.appendLE32(record.uncompressedSize) + header.appendLE16(UInt16(record.name.count)) + header.appendLE16(0) + body.append(header) + body.append(record.name) + body.append(payload) + records.append(record) + } + + public func finalized() -> Data { + var archive = body + let directoryOffset = UInt32(archive.count) + var directory = Data() + for record in records { + directory.appendLE32(0x0201_4B50) + directory.appendLE16(0x0314) // made by Unix, spec 2.0 + directory.appendLE16(20) + directory.appendLE16(0x0800) + directory.appendLE16(record.method.rawValue) + directory.appendLE16(record.dosTime) + directory.appendLE16(record.dosDate) + directory.appendLE32(record.crc) + directory.appendLE32(record.compressedSize) + directory.appendLE32(record.uncompressedSize) + directory.appendLE16(UInt16(record.name.count)) + directory.appendLE16(0) + directory.appendLE16(0) + directory.appendLE16(0) + directory.appendLE16(0) + directory.appendLE32((0o100000 | record.mode) << 16) + directory.appendLE32(record.offset) + directory.append(record.name) + } + archive.append(directory) + var end = Data() + end.appendLE32(0x0605_4B50) + end.appendLE16(0) + end.appendLE16(0) + end.appendLE16(UInt16(records.count)) + end.appendLE16(UInt16(records.count)) + end.appendLE32(UInt32(directory.count)) + end.appendLE32(directoryOffset) + end.appendLE16(0) + archive.append(end) + return archive + } + + static func deflate(_ data: Data) -> Data? { + guard !data.isEmpty else { return nil } + let capacity = data.count + 1024 + let destination = UnsafeMutablePointer.allocate(capacity: capacity) + defer { destination.deallocate() } + let written = data.withUnsafeBytes { raw -> Int in + guard let source = raw.bindMemory(to: UInt8.self).baseAddress else { return 0 } + return compression_encode_buffer(destination, capacity, source, data.count, nil, COMPRESSION_ZLIB) + } + return written > 0 ? Data(bytes: destination, count: written) : nil + } + + static func dosDateTime(_ date: Date) -> (UInt16, UInt16) { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC") ?? .current + let parts = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) + let year = max(1980, parts.year ?? 1980) - 1980 + let time = UInt16(((parts.hour ?? 0) << 11) | ((parts.minute ?? 0) << 5) | ((parts.second ?? 0) / 2)) + let day = UInt16((year << 9) | ((parts.month ?? 1) << 5) | (parts.day ?? 1)) + return (time, day) + } +} + +public enum CRC32 { + private static let table: [UInt32] = (0..<256).map { index -> UInt32 in + var value = UInt32(index) + for _ in 0..<8 { + value = value & 1 == 1 ? 0xEDB8_8320 ^ (value >> 1) : value >> 1 + } + return value + } + + public static func checksum(_ data: Data) -> UInt32 { + var crc: UInt32 = 0xFFFF_FFFF + for byte in data { + crc = table[Int((crc ^ UInt32(byte)) & 0xFF)] ^ (crc >> 8) + } + return crc ^ 0xFFFF_FFFF + } +} + +extension Data { + mutating func appendLE16(_ value: UInt16) { + append(UInt8(value & 0xFF)) + append(UInt8(value >> 8)) + } + + mutating func appendLE32(_ value: UInt32) { + for shift in stride(from: 0, to: 32, by: 8) { append(UInt8((value >> UInt32(shift)) & 0xFF)) } + } +} diff --git a/Sources/ToolkitFeatures/Actions/ActionCatalog.swift b/Sources/ToolkitFeatures/Actions/ActionCatalog.swift new file mode 100644 index 0000000..0c924e7 --- /dev/null +++ b/Sources/ToolkitFeatures/Actions/ActionCatalog.swift @@ -0,0 +1,212 @@ +import DeviceKit +import Foundation +import ToolkitCore + +/// Prerequisites an action needs; each maps to a Capability Matrix row. +public enum ActionRequirement: String, Sendable, Hashable, CaseIterable { + case trustedDevice = "pairing-trust" + case lockdownConnection = "device-connection" + case coreDevice = "coredevice" + case developerMode = "developer-mode" + case developerServices = "developer-services" + case simulatorRunning = "simulator-running" + case xcode = "xcode-tools" + case instruments = "instruments" + + public var label: String { + switch self { + case .trustedDevice: return "Device trusts this Mac" + case .lockdownConnection: return "Connected by USB or Wi-Fi" + case .coreDevice: return "Xcode device service (CoreDevice)" + case .developerMode: return "Developer Mode on" + case .developerServices: return "Developer services (DDI) available" + case .simulatorRunning: return "Simulator running" + case .xcode: return "Xcode installed" + case .instruments: return "Instruments lists the device" + } + } +} + +public enum ActionParameterKind: String, Sendable, Hashable { + case bundleIdentifier, url, processIdentifier, latitude, longitude, devicePath, outputFile, outputDirectory, duration, template, text +} + +public struct ActionParameter: Sendable, Hashable, Identifiable { + public var id: String + public var label: String + public var help: String + public var kind: ActionParameterKind + public var defaultValue: String + public var choices: [String] + public var fileExtension: String? + + public init(id: String, label: String, help: String, kind: ActionParameterKind, defaultValue: String = "", choices: [String] = [], fileExtension: String? = nil) { + self.id = id + self.label = label + self.help = help + self.kind = kind + self.defaultValue = defaultValue + self.choices = choices + self.fileExtension = fileExtension + } + + /// Validates and normalizes one value before anything is launched. + public func validate(_ raw: String) throws -> String { + let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { throw ToolkitError.invalidInput("\(label) is required.") } + switch kind { + case .bundleIdentifier: + try BundleIdentifier.validate(value) + case .url: + guard let url = URL(string: value), let scheme = url.scheme?.lowercased(), !scheme.isEmpty, url.host != nil || scheme != "http" && scheme != "https" else { + throw ToolkitError.invalidInput("\(label) must be a complete URL such as https://example.com.") + } + case .processIdentifier: + guard let pid = Int(value), pid > 0 else { throw ToolkitError.invalidInput("\(label) must be a positive process number.") } + case .latitude, .longitude: + try Self.checkCoordinate(value, isLatitude: kind == .latitude) + case .devicePath: + guard value.hasPrefix("/"), !value.contains("\0") else { throw ToolkitError.invalidInput("\(label) must start with /.") } + case .outputFile: + let url = URL(fileURLWithPath: (value as NSString).expandingTildeInPath) + guard FileManager.default.fileExists(atPath: url.deletingLastPathComponent().path) else { + throw ToolkitError.invalidInput("The folder for \(label.lowercased()) does not exist.") + } + guard !FileManager.default.fileExists(atPath: url.path) else { + throw ToolkitError.invalidInput("\(url.lastPathComponent) already exists. Choose a new name; files are never overwritten.") + } + if let fileExtension, url.pathExtension.lowercased() != fileExtension { + throw ToolkitError.invalidInput("\(label) must end in .\(fileExtension).") + } + return url.path + case .outputDirectory: + let url = URL(fileURLWithPath: (value as NSString).expandingTildeInPath) + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), isDirectory.boolValue else { + throw ToolkitError.invalidInput("Choose an existing folder for \(label.lowercased()).") + } + return url.path + case .duration: + guard let seconds = Int(value), (1...3600).contains(seconds) else { throw ToolkitError.invalidInput("\(label) must be between 1 and 3600 seconds.") } + case .template: + guard choices.contains(value) else { throw ToolkitError.invalidInput("Choose one of the listed options for \(label).") } + case .text: + guard value.count <= 500 else { throw ToolkitError.invalidInput("\(label) is too long.") } + } + return value + } + + /// Range-checks one coordinate. Kept apart so that coordinate data never shares a flow path with + /// the other kinds of parameter this type validates (static analysis treats it as sensitive). + private static func checkCoordinate(_ text: String, isLatitude: Bool) throws { + if isLatitude { + _ = try LocationLab.validate(latitude: text, longitude: "0") + } else { + _ = try LocationLab.validate(latitude: "0", longitude: text) + } + } +} + +public struct ActionDescriptor: Sendable, Hashable, Identifiable { + public var id: String + public var title: String + public var category: String + public var summary: String + public var notes: String + public var risk: ActionRisk + public var kinds: Set + public var requirements: [ActionRequirement] + public var parameters: [ActionParameter] + public var mechanism: String + public var replacesLegacy: String? + + public func supports(_ kind: DeviceKind) -> Bool { kinds.contains(kind) } +} + +public enum ActionCatalog { + static func documents(_ name: String) -> String { + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Documents").appendingPathComponent(name).path + } + + public static let categories = ["Device Basics", "Apps & Files", "Developer Services", "Capture & Instruments", "Network & Discovery", "Device Actions", "Simulator"] + + public static let all: [ActionDescriptor] = [ + // Device Basics + ActionDescriptor(id: "device-details", title: "Device details (CoreDevice)", category: "Device Basics", summary: "Everything Xcode's device service knows about the device.", notes: "A modern, versioned record. Compare with the lockdown values when investigating differences.", risk: .readOnly, kinds: [.physical], requirements: [.xcode, .coreDevice], parameters: [], mechanism: "devicectl device info details", replacesLegacy: "developer core-device get-device-info"), + ActionDescriptor(id: "lockdown-values", title: "Lockdown values", category: "Device Basics", summary: "Identity and configuration values the device shares with a trusted Mac.", notes: "Lockdown is the gateway for most device services. Values describe exposed state, not unrestricted iOS internals.", risk: .readOnly, kinds: [.physical], requirements: [.lockdownConnection, .trustedDevice], parameters: [], mechanism: "lockdownd GetValue (native)", replacesLegacy: "lockdown info"), + ActionDescriptor(id: "activation-state", title: "Activation state", category: "Device Basics", summary: "Whether the device is activated with Apple.", notes: "Read-only. Activation changes are intentionally not offered.", risk: .readOnly, kinds: [.physical], requirements: [.trustedDevice], parameters: [], mechanism: "lockdownd GetValue ActivationState (native)", replacesLegacy: "activation state"), + ActionDescriptor(id: "developer-mode-status", title: "Developer Mode status", category: "Device Basics", summary: "Whether Developer Mode is on.", notes: "Developer Mode is needed for most developer services. It does not install developer services by itself.", risk: .readOnly, kinds: [.physical], requirements: [.trustedDevice], parameters: [], mechanism: "AMFI via lockdownd (native)", replacesLegacy: "amfi developer-mode-status"), + ActionDescriptor(id: "diagnostics", title: "Diagnostics overview", category: "Device Basics", summary: "Battery gauge, Wi-Fi, NAND, and HDMI diagnostics.", notes: "Available values vary by hardware and iOS version; a missing value is a coverage limit, not proof of absence.", risk: .readOnly, kinds: [.physical], requirements: [.trustedDevice], parameters: [], mechanism: "diagnostics_relay All (native)", replacesLegacy: "diagnostics info"), + ActionDescriptor(id: "battery", title: "Battery snapshot", category: "Device Basics", summary: "Charge, charging state, cycle count, temperature, and estimated health.", notes: "Readings come from the IOPMPowerSource registry entry and are device-dependent.", risk: .readOnly, kinds: [.physical], requirements: [.trustedDevice], parameters: [], mechanism: "diagnostics_relay IORegistry (native)", replacesLegacy: "diagnostics battery single"), + ActionDescriptor(id: "ioregistry", title: "IORegistry snapshot", category: "Device Basics", summary: "The device's hardware registry as exposed to diagnostics.", notes: "Large. This is the phone's registry view, not kernel access.", risk: .readOnly, kinds: [.physical], requirements: [.trustedDevice], parameters: [], mechanism: "diagnostics_relay IORegistry (native)", replacesLegacy: "diagnostics ioregistry"), + ActionDescriptor(id: "mobilegestalt", title: "MobileGestalt values", category: "Device Basics", summary: "Hardware and configuration answers for a known key set.", notes: "Recent iOS versions answer only some keys (others report MobileGestaltDeprecated).", risk: .readOnly, kinds: [.physical], requirements: [.trustedDevice], parameters: [], mechanism: "diagnostics_relay MobileGestalt (native)", replacesLegacy: "diagnostics mg"), + ActionDescriptor(id: "processes", title: "Running processes", category: "Device Basics", summary: "Process IDs and names running on the device.", notes: "A point-in-time view. Works over USB without Xcode; network-only devices use Xcode's device service.", risk: .readOnly, kinds: [.physical], requirements: [.trustedDevice], parameters: [], mechanism: "os_trace_relay PidList (native); devicectl device info processes for network-only devices", replacesLegacy: "processes ps / dvt proclist / core-device list-processes"), + ActionDescriptor(id: "lock-state", title: "Lock state", category: "Device Basics", summary: "Whether a passcode is currently required.", notes: "Reports service-visible state only; it never unlocks anything.", risk: .readOnly, kinds: [.physical], requirements: [.xcode, .coreDevice], parameters: [], mechanism: "devicectl device info lockState", replacesLegacy: "core-device get-lockstate"), + ActionDescriptor(id: "displays", title: "Displays", category: "Device Basics", summary: "Display identifiers and properties.", notes: "", risk: .readOnly, kinds: [.physical], requirements: [.xcode, .coreDevice], parameters: [], mechanism: "devicectl device info displays", replacesLegacy: "core-device get-display-info"), + ActionDescriptor(id: "configuration-profiles", title: "Configuration profiles", category: "Device Basics", summary: "Installed configuration (MDM, VPN, Wi-Fi…) profiles.", notes: "A listed profile shows configuration state, not who uses it. Installing and removing are not offered.", risk: .readOnly, kinds: [.physical], requirements: [.trustedDevice], parameters: [], mechanism: "MCInstall GetProfileList (native); devicectl device profile list for network-only devices", replacesLegacy: "profile list"), + ActionDescriptor(id: "provisioning-profiles", title: "Provisioning profiles", category: "Device Basics", summary: "Developer and enterprise provisioning profiles, decoded.", notes: "Profiles describe what apps may run; they are not evidence that an app ran.", risk: .readOnly, kinds: [.physical], requirements: [.trustedDevice], parameters: [], mechanism: "misagent CopyAll + CMS decoding (native)", replacesLegacy: "provision list"), + ActionDescriptor(id: "orientation", title: "Screen orientation", category: "Device Basics", summary: "Current interface orientation.", notes: "", risk: .readOnly, kinds: [.physical], requirements: [.trustedDevice], parameters: [], mechanism: "springboardservices (native)", replacesLegacy: "springboard orientation"), + ActionDescriptor(id: "icon-metrics", title: "Home Screen icon metrics", category: "Device Basics", summary: "Home Screen layout metrics.", notes: "Values vary by device class and display mode.", risk: .readOnly, kinds: [.physical], requirements: [.trustedDevice], parameters: [], mechanism: "springboardservices (native)", replacesLegacy: "springboard homescreen-icon-metrics"), + + // Apps & Files + ActionDescriptor(id: "app-query", title: "Query one app", category: "Apps & Files", summary: "All installation attributes for one bundle identifier.", notes: "The app must be visible to the installation service.", risk: .readOnly, kinds: [.physical], requirements: [.trustedDevice], parameters: [ActionParameter(id: "bundle", label: "Bundle identifier", help: "For example com.apple.mobilesafari", kind: .bundleIdentifier, defaultValue: "com.apple.mobilesafari")], mechanism: "installation_proxy Browse (native)", replacesLegacy: "apps query"), + ActionDescriptor(id: "media-list", title: "List Media folder", category: "Apps & Files", summary: "List a folder in the device's Media area (photos, downloads, recordings).", notes: "AFC is limited to /var/mobile/Media; it is not access to the whole file system or to app containers.", risk: .readOnly, kinds: [.physical], requirements: [.trustedDevice], parameters: [ActionParameter(id: "path", label: "Folder", help: "Path inside Media, for example /DCIM", kind: .devicePath, defaultValue: "/")], mechanism: "AFC (native)", replacesLegacy: "afc ls"), + ActionDescriptor(id: "crash-list", title: "Crash report inventory", category: "Apps & Files", summary: "Crash, hang, and diagnostic reports available on the device.", notes: "Availability depends on retention; a missing report is not proof an event did not happen.", risk: .readOnly, kinds: [.physical], requirements: [.trustedDevice], parameters: [], mechanism: "crashreportcopymobile (native AFC)", replacesLegacy: "crash ls"), + ActionDescriptor(id: "crash-pull", title: "Copy crash reports", category: "Apps & Files", summary: "Copy every available crash report into a new folder on this Mac.", notes: "Reports can contain personal information; review before sharing.", risk: .hostWrite, kinds: [.physical], requirements: [.trustedDevice], parameters: [ActionParameter(id: "folder", label: "Destination folder", help: "A new folder is created inside it.", kind: .outputDirectory, defaultValue: documents(""))], mechanism: "crashreportcopymobile (native AFC)", replacesLegacy: "crash pull"), + + // Developer Services + ActionDescriptor(id: "ddi-status", title: "Developer image status", category: "Developer Services", summary: "Whether a compatible developer image is mounted, ready on this Mac, or needs Apple's personalization.", notes: "Checks without changing anything. Reports the iOS version, build, model, chip, and board used to choose the image.", risk: .readOnly, kinds: [.physical], requirements: [.trustedDevice], parameters: [], mechanism: "mobile_image_mounter LookupImage + host image check (native)", replacesLegacy: "mounter lookup / mounter list"), + ActionDescriptor(id: "ddi-prepare", title: "Mount developer image", category: "Developer Services", summary: "Mount the developer image this device needs. Does nothing if a compatible one is already mounted.", notes: "iOS 17 and later: Apple personalizes the image for this device, which needs the internet and sends the device's chip, board, and ECID with a one-time nonce to Apple (as Xcode does). iOS 16 and earlier: uses DeveloperDiskImage.dmg for that exact version. Needs Developer Mode and an unlocked device.", risk: .deviceChange, kinds: [.physical], requirements: [.trustedDevice, .developerMode], parameters: [ActionParameter(id: "mechanism", label: "Mount with", help: "Automatic uses Xcode's device service when it can reach the device, otherwise the built-in client over USB.", kind: .template, defaultValue: DeveloperImageMechanism.automatic.label, choices: DeveloperImageMechanism.allCases.map(\.label))], mechanism: "Xcode device service (devicectl) or mobile_image_mounter (native) with Apple personalization", replacesLegacy: "mounter auto-mount / cryptex auto-install"), + ActionDescriptor(id: "mounted-images", title: "Mounted developer images", category: "Developer Services", summary: "Images the device reports as mounted.", notes: "", risk: .readOnly, kinds: [.physical], requirements: [.trustedDevice], parameters: [], mechanism: "mobile_image_mounter CopyDevices (native)", replacesLegacy: "mounter list"), + ActionDescriptor(id: "personalization", title: "Personalization identifiers", category: "Developer Services", summary: "Identifiers Apple uses to personalize developer images for this device.", notes: "Device-specific and sensitive.", risk: .readOnly, kinds: [.physical], requirements: [.trustedDevice], parameters: [], mechanism: "mobile_image_mounter (native)", replacesLegacy: "mounter query-personalization-identifiers"), + ActionDescriptor(id: "ddi-unmount", title: "Unmount developer image", category: "Developer Services", summary: "Remove the mounted developer image until it is needed again.", notes: "Restarting the device also removes it.", risk: .deviceChange, kinds: [.physical], requirements: [.trustedDevice], parameters: [], mechanism: "mobile_image_mounter UnmountImage /System/Developer or /Developer (native)", replacesLegacy: "mounter umount"), + ActionDescriptor(id: "host-ddis-update", title: "Update this Mac's developer images", category: "Developer Services", summary: "Refresh the Developer Disk Images Xcode keeps on this Mac.", notes: "Uses the images from the selected Xcode.", risk: .hostWrite, kinds: [.physical, .simulator], requirements: [.xcode], parameters: [], mechanism: "devicectl manage ddis update", replacesLegacy: "ios-local-ddi (local Xcode DDI)"), + ActionDescriptor(id: "preferred-ddi", title: "Preferred developer image", category: "Developer Services", summary: "Which developer image CoreDevice will use for iOS.", notes: "", risk: .readOnly, kinds: [.physical, .simulator], requirements: [.xcode], parameters: [], mechanism: "devicectl list preferredDDI", replacesLegacy: nil), + + // Capture & Instruments + ActionDescriptor(id: "screenshot", title: "Screenshot", category: "Capture & Instruments", summary: "Save the current screen as a PNG.", notes: "May contain notifications and other private content.", risk: .hostWrite, kinds: [.physical, .simulator], requirements: [.xcode], parameters: [ActionParameter(id: "output", label: "PNG file", help: "Where to save the screenshot.", kind: .outputFile, defaultValue: documents("device-screenshot.png"), fileExtension: "png")], mechanism: "devicectl device capture screenshot / simctl io screenshot", replacesLegacy: "developer dvt screenshot"), + ActionDescriptor(id: "sysdiagnose", title: "Sysdiagnose", category: "Capture & Instruments", summary: "Collect Apple's full diagnostic archive from the device (takes several minutes).", notes: "Contains extensive personal and system data.", risk: .hostWrite, kinds: [.physical], requirements: [.xcode, .coreDevice], parameters: [ActionParameter(id: "folder", label: "Destination folder", help: "The archive is saved inside this folder.", kind: .outputDirectory, defaultValue: documents(""))], mechanism: "devicectl device sysdiagnose", replacesLegacy: "syslog collect"), + ActionDescriptor(id: "instruments", title: "Instruments recording", category: "Capture & Instruments", summary: "Record performance, energy, network, or system activity for a fixed time.", notes: "Open the .trace in Instruments. Replaces the DVT telemetry streams (sysmon, energy, graphics, netstat, KDebug).", risk: .hostWrite, kinds: [.physical, .simulator], requirements: [.xcode, .developerMode, .instruments], parameters: [ActionParameter(id: "template", label: "Template", help: "What to record.", kind: .template, defaultValue: "Activity Monitor", choices: InstrumentsRecorder.templates.map(\.name)), ActionParameter(id: "duration", label: "Seconds", help: "Recording length (1–3600).", kind: .duration, defaultValue: "15"), ActionParameter(id: "output", label: "Trace file", help: "Where to save the recording.", kind: .outputFile, defaultValue: documents("recording.trace"), fileExtension: "trace")], mechanism: "xcrun xctrace record", replacesLegacy: "developer dvt sysmon/energy/graphics/netstat/core-profile-session"), + ActionDescriptor(id: "packet-capture", title: "Packet capture", category: "Capture & Instruments", summary: "Record the device's network packets for a fixed time into a .pcap file.", notes: "Open the file in Wireshark or with tcpdump -r. Encrypted traffic stays encrypted, but a capture still shows which hosts were contacted and when.", risk: .hostWrite, kinds: [.physical], requirements: [.trustedDevice], parameters: [ActionParameter(id: "duration", label: "Seconds", help: "Capture length (1–3600).", kind: .duration, defaultValue: "30"), ActionParameter(id: "output", label: "Capture file", help: "Where to save the capture.", kind: .outputFile, defaultValue: documents("capture.pcap"), fileExtension: "pcap")], mechanism: "com.apple.pcapd (native)", replacesLegacy: "pcap"), + ActionDescriptor(id: "bluetooth-capture", title: "Bluetooth capture", category: "Capture & Instruments", summary: "Record the device's Bluetooth HCI traffic for a fixed time into a .pklg file.", notes: "Needs Apple's Bluetooth logging profile on the device (Apple Developer › Profiles and Logs). Open the file in PacketLogger (Additional Tools for Xcode) or Wireshark. Captures can reveal nearby devices and accessories.", risk: .hostWrite, kinds: [.physical], requirements: [.trustedDevice], parameters: [ActionParameter(id: "duration", label: "Seconds", help: "Capture length (1–3600).", kind: .duration, defaultValue: "30"), ActionParameter(id: "output", label: "Capture file", help: "Where to save the capture.", kind: .outputFile, defaultValue: documents("bluetooth.pklg"), fileExtension: "pklg")], mechanism: "com.apple.bluetooth.BTPacketLogger (native)", replacesLegacy: "btlogger"), + + // Network & Discovery + ActionDescriptor(id: "web-tabs", title: "Safari and web view tabs", category: "Network & Discovery", summary: "Pages that Safari and other apps allow to be inspected: titles and addresses.", notes: "Needs Web Inspector turned on (Settings › Apps › Safari › Advanced). Titles and addresses can be private. The device accepts a new session about every ten seconds.", risk: .readOnly, kinds: [.physical], requirements: [.trustedDevice], parameters: [], mechanism: "com.apple.webinspector (native)", replacesLegacy: "webinspector opened-tabs"), + ActionDescriptor(id: "bonjour", title: "Discover devices on the network", category: "Network & Discovery", summary: "List Apple devices advertising pairing and developer services nearby.", notes: "Discovery shows advertisements only; it does not prove pairing or authorization.", risk: .readOnly, kinds: [.physical, .simulator], requirements: [], parameters: [], mechanism: "Network.framework Bonjour browsing", replacesLegacy: "bonjour rsd / remote browse"), + ActionDescriptor(id: "rvi", title: "Remote Virtual Interfaces", category: "Network & Discovery", summary: "List existing rvictl packet-capture interfaces.", notes: "Read-only; interfaces are not created or removed.", risk: .readOnly, kinds: [.physical, .simulator], requirements: [.xcode], parameters: [], mechanism: "rvictl -l", replacesLegacy: nil), + + // Device Actions + ActionDescriptor(id: "launch-app", title: "Launch app", category: "Device Actions", summary: "Start an app by bundle identifier (restarting it if already running).", notes: "Changes what is in the foreground.", risk: .deviceChange, kinds: [.physical, .simulator], requirements: [.xcode, .developerMode], parameters: [ActionParameter(id: "bundle", label: "Bundle identifier", help: "App to launch.", kind: .bundleIdentifier, defaultValue: "com.apple.mobilesafari")], mechanism: "devicectl device process launch / simctl launch", replacesLegacy: "developer dvt launch"), + ActionDescriptor(id: "terminate", title: "Stop a process", category: "Device Actions", summary: "Ask a process to stop (SIGTERM).", notes: "Process numbers are short-lived; confirm with a fresh process list first.", risk: .deviceChange, kinds: [.physical], requirements: [.xcode, .coreDevice, .developerMode], parameters: [ActionParameter(id: "pid", label: "Process ID", help: "From the Running processes action.", kind: .processIdentifier)], mechanism: "devicectl device process terminate", replacesLegacy: "developer dvt kill"), + ActionDescriptor(id: "open-url", title: "Open URL", category: "Device Actions", summary: "Open a URL (web page or app link) on the device.", notes: "Opening a web URL makes a network request from the device.", risk: .deviceChange, kinds: [.physical, .simulator], requirements: [.xcode], parameters: [ActionParameter(id: "url", label: "URL", help: "For example https://example.com", kind: .url, defaultValue: "https://example.com")], mechanism: "devicectl device process openURL / simctl openurl", replacesLegacy: "webinspector launch"), + ActionDescriptor(id: "set-location", title: "Set simulated location", category: "Device Actions", summary: "Report a fixed location to apps until cleared.", notes: "Does not change GPS hardware. Use Location Lab for routes and GPX playback.", risk: .deviceChange, kinds: [.physical, .simulator], requirements: [.developerMode], parameters: [ActionParameter(id: "latitude", label: "Latitude", help: "-90 to 90", kind: .latitude, defaultValue: "34.0522"), ActionParameter(id: "longitude", label: "Longitude", help: "-180 to 180", kind: .longitude, defaultValue: "-118.2437")], mechanism: "devicectl simulate location / simctl location", replacesLegacy: "developer dvt simulate-location set"), + ActionDescriptor(id: "clear-location", title: "Clear simulated location", category: "Device Actions", summary: "Return to the device's real location.", notes: "", risk: .deviceChange, kinds: [.physical, .simulator], requirements: [.developerMode], parameters: [], mechanism: "devicectl simulate location clear / simctl location clear", replacesLegacy: "developer dvt simulate-location clear"), + ActionDescriptor(id: "reboot", title: "Restart device", category: "Device Actions", summary: "Restart the device.", notes: "Interrupts everything running on the device.", risk: .highImpact, kinds: [.physical], requirements: [.xcode, .coreDevice], parameters: [], mechanism: "devicectl device reboot", replacesLegacy: "diagnostics restart (Advanced Mode only)"), + + // Simulator + ActionDescriptor(id: "sim-boot", title: "Start simulator", category: "Simulator", summary: "Boot the simulator.", notes: "", risk: .deviceChange, kinds: [.simulator], requirements: [.xcode], parameters: [], mechanism: "simctl boot", replacesLegacy: nil), + ActionDescriptor(id: "sim-open", title: "Show in Simulator app", category: "Simulator", summary: "Open Simulator.app on this simulator.", notes: "", risk: .readOnly, kinds: [.simulator], requirements: [.xcode], parameters: [], mechanism: "open -a Simulator", replacesLegacy: nil), + ActionDescriptor(id: "sim-shutdown", title: "Shut down simulator", category: "Simulator", summary: "Stop the simulator.", notes: "", risk: .deviceChange, kinds: [.simulator], requirements: [.xcode], parameters: [], mechanism: "simctl shutdown", replacesLegacy: nil), + ActionDescriptor(id: "sim-dark", title: "Switch to Dark appearance", category: "Simulator", summary: "Set the simulator to Dark Mode.", notes: "", risk: .deviceChange, kinds: [.simulator], requirements: [.xcode, .simulatorRunning], parameters: [], mechanism: "simctl ui appearance dark", replacesLegacy: nil), + ActionDescriptor(id: "sim-light", title: "Switch to Light appearance", category: "Simulator", summary: "Set the simulator to Light Mode.", notes: "", risk: .deviceChange, kinds: [.simulator], requirements: [.xcode, .simulatorRunning], parameters: [], mechanism: "simctl ui appearance light", replacesLegacy: nil), + ActionDescriptor(id: "sim-erase", title: "Erase simulator", category: "Simulator", summary: "Delete all content and settings in the simulator.", notes: "The simulator must be shut down.", risk: .highImpact, kinds: [.simulator], requirements: [.xcode], parameters: [], mechanism: "simctl erase", replacesLegacy: nil), + ] + + public static func descriptor(_ id: String) -> ActionDescriptor? { + all.first { $0.id == id } + } + + public static func actions(for kind: DeviceKind?) -> [ActionDescriptor] { + guard let kind else { return all.filter { $0.requirements.allSatisfy { $0 == .xcode } && $0.kinds.count > 1 } } + return all.filter { $0.supports(kind) } + } + + /// Validates all parameters for `action`. + public static func validate(_ action: ActionDescriptor, values: [String: String]) throws -> [String: String] { + var validated: [String: String] = [:] + for parameter in action.parameters { + validated[parameter.id] = try parameter.validate(values[parameter.id] ?? parameter.defaultValue) + } + return validated + } +} diff --git a/Sources/ToolkitFeatures/Actions/ActionExecutor.swift b/Sources/ToolkitFeatures/Actions/ActionExecutor.swift new file mode 100644 index 0000000..58e2b09 --- /dev/null +++ b/Sources/ToolkitFeatures/Actions/ActionExecutor.swift @@ -0,0 +1,495 @@ +import DeviceKit +import Foundation +import ToolkitCore + +/// The result of running an action. +public struct ActionResult: Sendable, Hashable { + public var actionID: String + public var title: String + public var target: DeviceTarget? + /// Plain-language headline for non-technical users. + public var summary: String + /// Readable detail lines ("key: value"). + public var details: [(String, String)] + /// Complete raw output (JSON/plist/text) for advanced users. + public var raw: String + public var outputFiles: [URL] + public var mechanism: String + public var argv: [String] + public var startedAt: Date + public var finishedAt: Date + + public static func == (lhs: ActionResult, rhs: ActionResult) -> Bool { + lhs.actionID == rhs.actionID && lhs.startedAt == rhs.startedAt && lhs.raw == rhs.raw + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(actionID) + hasher.combine(startedAt) + } +} + +/// Runs catalog actions. The target is captured when the action starts and passed explicitly to +/// every underlying call. +public struct ActionExecutor: Sendable { + public let runner: CommandRunning + public let coreDevice: CoreDeviceClient + public let simulators: SimulatorClient + public let usbmux: USBMuxClient + public let location: LocationController + public let developerImages: DeveloperImageManager + /// Folders the user chose that contain developer images (in addition to Xcode's). + public let developerImageFolders: [URL] + + public init(runner: CommandRunning = ProcessCommandRunner(), usbmux: USBMuxClient = USBMuxClient(), developerImageFolders: [URL] = [], personalization: PersonalizationTransport = AppleTSSTransport()) { + self.runner = runner + coreDevice = CoreDeviceClient(runner: runner) + simulators = SimulatorClient(runner: runner) + self.usbmux = usbmux + location = LocationController(coreDevice: coreDevice, simulators: simulators, usbmux: usbmux) + developerImages = DeveloperImageManager(usbmux: usbmux, coreDevice: coreDevice, transport: personalization) + self.developerImageFolders = developerImageFolders + } + + public func execute(_ action: ActionDescriptor, target: DeviceTarget?, values: [String: String]) async throws -> ActionResult { + let parameters = try ActionCatalog.validate(action, values: values) + if let target { + guard action.supports(target.kind) else { + throw ToolkitError(.unsupported, message: "“\(action.title)” is not available for \(target.kind.label.lowercased())s.") + } + guard target.kind != .demo else { + throw ToolkitError(.unsupported, message: "Demo Mode shows a simulated device; actions are disabled.") + } + } else if action.requirements.contains(where: { $0 != .xcode }) { + throw ToolkitError.invalidInput("Select a device first.") + } + let started = Date() + var result = try await perform(action, target: target, parameters: parameters, values: values) + result.startedAt = started + result.finishedAt = Date() + return result + } + + private func requireTarget(_ target: DeviceTarget?) throws -> DeviceTarget { + guard let target else { throw ToolkitError.invalidInput("Select a device first.") } + return target + } + + private func make(_ action: ActionDescriptor, _ target: DeviceTarget?, summary: String, details: [(String, String)] = [], raw: String = "", files: [URL] = [], argv: [String] = []) -> ActionResult { + ActionResult(actionID: action.id, title: action.title, target: target, summary: summary, details: details, raw: raw, outputFiles: files, mechanism: action.mechanism, argv: argv, startedAt: Date(), finishedAt: Date()) + } + + private func session(_ target: DeviceTarget, _ body: @Sendable (DeviceSession) async throws -> T) async throws -> T { + try await DeviceSession.with(target, usbmux: usbmux, body) + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + private func perform(_ action: ActionDescriptor, target: DeviceTarget?, parameters: [String: String], values: [String: String]) async throws -> ActionResult { + switch action.id { + case "device-details": + let target = try requireTarget(target) + let (record, response) = try await coreDevice.details(target) + var details: [(String, String)] = [] + if let record { + details = [("Name", record.name), ("Model", record.marketingName ?? "—"), ("Hardware identifier", record.productType ?? "—"), ("System", "\(record.platform ?? "iOS") \(record.osVersion ?? "") (\(record.buildVersion ?? ""))"), ("Developer Mode", record.developerMode.label), ("Developer services", record.ddiServicesAvailable.map { $0 ? "Available" : "Not mounted" } ?? "Unknown"), ("Connection", record.transportType ?? "—"), ("Trust", record.pairingState.label)] + } + return make(action, target, summary: record.map { "\($0.name) — \($0.marketingName ?? $0.productType ?? "device")" } ?? "Device details received.", details: details, raw: response.json.prettyString(), argv: response.command.request.arguments) + + case "lockdown-values": + let target = try requireTarget(target) + let values = try await session(target) { try await $0.getValue() } ?? .dictionary([:]) + let keys = ["DeviceName", "ProductType", "ProductVersion", "BuildVersion", "DeviceClass", "HardwareModel", "CPUArchitecture", "ActivationState", "TimeZone"] + return make(action, target, summary: "\(values.dictionaryValue?.count ?? 0) values reported.", details: keys.compactMap { key in values[key].map { (key, $0.stringValue ?? $0.prettyJSONString()) } }, raw: values.prettyJSONString()) + + case "activation-state": + let target = try requireTarget(target) + let state = try await session(target) { try await $0.getValue(key: "ActivationState") }?.stringValue ?? "Unknown" + let meaning = state == "Activated" ? "The device is activated." : "The device reports “\(state)”." + return make(action, target, summary: meaning, details: [("ActivationState", state)], raw: state) + + case "developer-mode-status": + let target = try requireTarget(target) + let enabled = try await session(target) { session -> Bool? in + if let value = try await session.developerModeEnabled() { return value } + let mounter = try await ImageMounter.open(session) + defer { Task { await mounter.close() } } + return try await mounter.developerModeStatus() + } + let summary: String + switch enabled { + case true?: summary = "Developer Mode is on." + case false?: summary = "Developer Mode is off. Turn it on in Settings › Privacy & Security › Developer Mode." + case nil: summary = "The device did not report Developer Mode (it may run iOS 15 or earlier, where it does not exist)." + } + return make(action, target, summary: summary, details: [("Developer Mode", enabled.map { $0 ? "On" : "Off" } ?? "Not reported")], raw: String(describing: enabled)) + + case "diagnostics", "battery", "ioregistry", "mobilegestalt": + let target = try requireTarget(target) + let id = action.id + let value = try await session(target) { session -> PlistValue in + let relay = try await DiagnosticsRelay.open(session) + defer { Task { await relay.close() } } + switch id { + case "battery": return try await relay.battery() + case "ioregistry": return try await relay.ioRegistry(plane: "IODeviceTree") + case "mobilegestalt": return try await relay.mobileGestalt(keys: DiagnosticsRelay.defaultGestaltKeys) + default: return try await relay.all() + } + } + if id == "battery" { + let battery = BatterySummary(registry: value) + let details: [(String, String)] = [ + ("Charge", battery.percentage.map { "\($0)%" } ?? "—"), + ("Charging", battery.isCharging.map { $0 ? "Yes" : "No" } ?? "—"), + ("Power connected", battery.externalConnected.map { $0 ? "Yes" : "No" } ?? "—"), + ("Cycle count", battery.cycleCount.map(String.init) ?? "—"), + ("Temperature", battery.temperatureCelsius.map { String(format: "%.1f °C", $0) } ?? "—"), + ("Estimated health", battery.healthPercentage.map { "\($0)% of design capacity" } ?? "—"), + ] + return make(action, target, summary: battery.percentage.map { "Battery at \($0)%\(battery.isCharging == true ? ", charging" : "")." } ?? "Battery information received.", details: details, raw: value.prettyJSONString()) + } + return make(action, target, summary: "\(action.title) received.", raw: value.prettyJSONString()) + + case "processes": + let target = try requireTarget(target) + if target.usbmuxDeviceID != nil { + let processes = try await session(target) { try await OSTraceRelay.processList($0) } + return make(action, target, summary: processes.count == 1 ? "1 process running." : "\(processes.count) processes running.", details: processes.prefix(500).map { ("\($0.pid)", $0.name) }, raw: processes.map { "\($0.pid)\t\($0.name)" }.joined(separator: "\n")) + } + let processes = try await coreDevice.processes(target) + return make(action, target, summary: "\(processes.count) processes running.", details: processes.prefix(500).map { ("\($0.pid)", $0.name) }, raw: processes.map { "\($0.pid)\t\($0.executablePath ?? "")" }.joined(separator: "\n")) + + case "lock-state": + let target = try requireTarget(target) + let state = try await coreDevice.lockState(target) + return make(action, target, summary: state.summary, details: [("Passcode required now", state.passcodeRequired.map { $0 ? "Yes" : "No" } ?? "—"), ("Unlocked since restart", state.unlockedSinceBoot.map { $0 ? "Yes" : "No" } ?? "—")], raw: state.summary) + + case "displays": + let response = try await coreDevice.displays(try requireTarget(target)) + return make(action, target, summary: "Display information received.", raw: response.json.prettyString()) + + case "configuration-profiles": + let target = try requireTarget(target) + if target.usbmuxDeviceID != nil { + let profiles = try await session(target) { session -> [InstalledConfigurationProfile] in + let service = try await ConfigurationProfileService.open(session) + defer { Task { await service.close() } } + return try await service.profiles() + } + return make(action, target, summary: profiles.isEmpty ? "No configuration profiles installed." : (profiles.count == 1 ? "1 configuration profile installed." : "\(profiles.count) configuration profiles installed."), details: profiles.map { ($0.displayName ?? $0.identifier, [$0.organization, $0.isActive == false ? "inactive" : nil, $0.removalDisallowed == true ? "cannot be removed by the user" : nil].compactMap { $0 }.joined(separator: " · ")) }, raw: String(decoding: (try? JSONOutput.encode(profiles)) ?? Data(), as: UTF8.self)) + } + let response = try await coreDevice.profiles(target, type: "configuration") + let profiles = response.result?["profiles"]?.array ?? [] + return make(action, target, summary: profiles.isEmpty ? "No configuration profiles reported." : "\(profiles.count) configuration profiles installed.", details: profiles.map { ($0["displayName"]?.string ?? $0["name"]?.string ?? "Profile", $0["identifier"]?.string ?? "") }, raw: response.json.prettyString()) + + case "provisioning-profiles": + let target = try requireTarget(target) + let payloads = try await session(target) { session -> [Data] in + let service = try await ProvisioningProfileService.open(session) + defer { Task { await service.close() } } + return try await service.copyAll() + } + let profiles = payloads.map(ProvisioningProfileDecoder.decode) + let formatter = ISO8601DateFormatter() + return make(action, target, summary: profiles.isEmpty ? "No provisioning profiles installed." : "\(profiles.count) provisioning profiles installed.", details: profiles.map { ($0.name ?? "Unnamed", "\($0.profileKind), expires \($0.expirationDate.map(formatter.string(from:)) ?? "—")\($0.isExpired ? " (expired)" : "")") }, raw: String(decoding: (try? JSONOutput.encode(profiles)) ?? Data(), as: UTF8.self)) + + case "orientation", "icon-metrics": + let target = try requireTarget(target) + let id = action.id + let text = try await session(target) { session -> (String, String) in + let springboard = try await SpringBoardServices.open(session) + defer { Task { await springboard.close() } } + if id == "orientation" { + let orientation = try await springboard.interfaceOrientation() + return (orientation.label, orientation.label) + } + return ("Icon metrics received.", try await springboard.homeScreenIconMetrics().prettyJSONString()) + } + return make(action, target, summary: text.0, raw: text.1) + + case "app-query": + let target = try requireTarget(target) + let bundle = parameters["bundle"] ?? "" + let apps = try await session(target) { session -> [InstalledApplication] in + let proxy = try await InstallationProxy.open(session) + defer { Task { await proxy.close() } } + return try await proxy.browse(includeSizes: true) + } + guard let app = apps.first(where: { $0.bundleIdentifier == bundle }) else { + throw ToolkitError(.commandFailed, message: "\(bundle) is not installed (or not visible to the installation service).") + } + return make(action, target, summary: "\(app.name) \(app.version ?? "") is installed.", details: [("Name", app.name), ("Bundle identifier", app.bundleIdentifier), ("Version", "\(app.version ?? "—") (\(app.build ?? "—"))"), ("Type", app.typeLabel), ("Size", ByteFormatting.string(app.totalBytes))], raw: "\(app)") + + case "media-list": + let target = try requireTarget(target) + let path = parameters["path"] ?? "/" + let entries = try await session(target) { session -> [String] in + let afc = try await AFCClient.openMedia(session) + defer { Task { await afc.close() } } + return try await afc.listDirectory(path) + } + return make(action, target, summary: "\(entries.count) items in \(path).", details: entries.map { ($0, "") }, raw: entries.joined(separator: "\n")) + + case "crash-list": + let target = try requireTarget(target) + let files = try await session(target) { session -> [String] in + let afc = try await AFCClient.openCrashReports(session) + defer { Task { await afc.close() } } + return try await afc.walk("/") + } + return make(action, target, summary: files.isEmpty ? "No crash reports are available." : "\(files.count) reports available.", details: files.prefix(1000).map { ($0, "") }, raw: files.joined(separator: "\n")) + + case "crash-pull": + let target = try requireTarget(target) + let parent = URL(fileURLWithPath: parameters["folder"] ?? "") + let folder = parent.appendingPathComponent("Crash Reports \(ISO8601.compactUTC(Date())) \(target.confirmationSuffix)") + try SecureFileIO.createNewPrivateDirectory(at: folder) + let copied = try await session(target) { session -> Int in + let afc = try await AFCClient.openCrashReports(session) + defer { Task { await afc.close() } } + var count = 0 + for path in try await afc.walk("/") { + let destination = try SecureFileIO.safeChild(of: folder, relativePath: String(path.drop { $0 == "/" })) + try SecureFileIO.createPrivateDirectory(at: destination.deletingLastPathComponent()) + _ = try await afc.download(path, to: destination) + count += 1 + } + return count + } + try HashManifest.write(for: folder) + return make(action, target, summary: "Copied \(copied) reports.", details: [("Folder", folder.path)], raw: folder.path, files: [folder]) + + case "ddi-status": + let target = try requireTarget(target) + let status = await developerImages.status(for: target, userFolders: developerImageFolders) + return make(action, target, summary: status.headline, details: status.detailRows, raw: status.explanation + (status.technicalDetail.map { "\n\n\($0)" } ?? "")) + + case "ddi-prepare": + let target = try requireTarget(target) + let mechanism = DeveloperImageMechanism.allCases.first { $0.label == parameters["mechanism"] } ?? .automatic + let status = try await developerImages.mount(target, mechanism: mechanism, userFolders: developerImageFolders) + return make(action, target, summary: status.headline, details: status.detailRows, raw: status.explanation) + + case "ddi-unmount": + let target = try requireTarget(target) + let status = try await developerImages.unmount(target, userFolders: developerImageFolders) + return make(action, target, summary: "The developer image is no longer mounted.", details: status.detailRows, raw: status.explanation) + + case "mounted-images", "personalization": + let target = try requireTarget(target) + let id = action.id + let output = try await session(target) { session -> (String, String) in + let mounter = try await ImageMounter.open(session) + defer { Task { await mounter.close() } } + if id == "mounted-images" { + let images = try await mounter.mountedImages() + return (images.isEmpty ? "No images are mounted." : "\(images.count) images mounted.", images.map { $0.raw.prettyJSONString() }.joined(separator: "\n")) + } + return ("Personalization identifiers received.", try await mounter.personalizationIdentifiers().prettyJSONString()) + } + return make(action, target, summary: output.0, raw: output.1) + + case "host-ddis-update": + let response = try await coreDevice.updateHostDDIs() + return make(action, target, summary: "This Mac's developer images are up to date.", raw: response.json.prettyString()) + + case "preferred-ddi": + let response = try await coreDevice.preferredDDI() + return make(action, target, summary: "Preferred developer image received.", raw: response.json.prettyString()) + + case "screenshot": + let target = try requireTarget(target) + let output = URL(fileURLWithPath: parameters["output"] ?? "") + if target.kind == .simulator { + try await simulators.screenshot(target, to: output) + } else { + _ = try await coreDevice.screenshot(target, to: output) + } + return make(action, target, summary: "Saved the screenshot.", details: [("File", output.path), ("SHA-256", (try? SecureFileIO.sha256(of: output)) ?? "—")], raw: output.path, files: [output]) + + case "sysdiagnose": + let target = try requireTarget(target) + let folder = URL(fileURLWithPath: parameters["folder"] ?? "") + let response = try await coreDevice.sysdiagnose(target, destination: folder, fullLogs: false) + return make(action, target, summary: "Sysdiagnose saved.", details: [("Folder", folder.path)], raw: response.json.prettyString(), files: [folder]) + + case "instruments": + let target = try requireTarget(target) + let output = URL(fileURLWithPath: parameters["output"] ?? "") + let request = try InstrumentsRecorder.request(template: parameters["template"] ?? "", target: target, durationSeconds: Int(parameters["duration"] ?? "") ?? 15, output: output) + let result = try await runner.run(request) + guard result.succeeded else { + throw ToolkitError(.commandFailed, message: "Instruments could not record from the device.", recovery: "Make sure Developer Mode is on, the device is unlocked, and developer services are prepared.", technicalDetail: result.technicalSummary) + } + return make(action, target, summary: "Recording saved. Open it in Instruments.", details: [("File", output.path)], raw: result.standardOutputText, files: [output], argv: request.arguments) + + case "bluetooth-capture": + let target = try requireTarget(target) + let output = URL(fileURLWithPath: parameters["output"] ?? "") + let seconds = Int(parameters["duration"] ?? "") ?? 30 + let writer = try PacketLoggerFileWriter(creatingNewFileAt: output) + let ending: String + do { + ending = try await session(target) { session -> String in + let records = try await BluetoothPacketLogger.records(session) + return try await withThrowingTaskGroup(of: String.self) { group in + group.addTask { + for try await record in records { try writer.write(record) } + return "The device ended the capture early." + } + group.addTask { + try await Task.sleep(for: .seconds(seconds)) + return "Captured for \(seconds) seconds." + } + let first = try await group.next() ?? "" + group.cancelAll() + return first + } + } + } catch { + _ = try? writer.finish() + throw error + } + let digest = try writer.finish() + let count = writer.recordCount + let summary = count == 0 + ? "\(ending) No Bluetooth packets arrived — check that the Bluetooth logging profile is installed and Bluetooth is in use." + : "\(ending) \(count == 1 ? "1 packet" : "\(count) packets") saved." + let byType = writer.countsByType.sorted { $0.key < $1.key }.map { (PacketLoggerRecord.label(for: $0.key), "\($0.value)") } + return make(action, target, summary: summary, details: [("File", output.path), ("Packets", "\(count)"), ("SHA-256", digest)] + byType, raw: output.path, files: [output]) + + case "packet-capture": + let target = try requireTarget(target) + let output = URL(fileURLWithPath: parameters["output"] ?? "") + let seconds = Int(parameters["duration"] ?? "") ?? 30 + let writer = try PcapFileWriter(creatingNewFileAt: output) + let ending: String + do { + ending = try await session(target) { session -> String in + try await withThrowingTaskGroup(of: String.self) { group in + group.addTask { + for try await packet in try await PacketCaptureService.stream(session) { + try writer.write(packet) + } + return "The device ended the capture early." + } + group.addTask { + try await Task.sleep(for: .seconds(seconds)) + return "Captured for \(seconds) seconds." + } + let first = try await group.next() ?? "" + group.cancelAll() + return first + } + } + } catch { + // Keep whatever was captured as a valid file, then report the failure. + _ = try? writer.finish() + throw error + } + let digest = try writer.finish() + return make(action, target, summary: "\(ending) \(writer.packetCount == 1 ? "1 packet" : "\(writer.packetCount) packets") saved.", details: [("File", output.path), ("Packets", "\(writer.packetCount)"), ("SHA-256", digest)], raw: output.path, files: [output]) + + case "web-tabs": + let target = try requireTarget(target) + let applications = try await WebInspector.openPages(on: target, usbmux: usbmux) + let pages = applications.flatMap { app in app.pages.map { (app, $0) } } + let summary = pages.isEmpty + ? (applications.isEmpty ? "No app currently allows inspection." : "\(applications.count) inspectable apps, no open pages.") + : (pages.count == 1 ? "1 inspectable page." : "\(pages.count) inspectable pages.") + return make(action, target, summary: summary, details: pages.prefix(500).map { app, page in (page.title.flatMap { $0.isEmpty ? nil : $0 } ?? "Untitled", [page.url, app.name ?? app.bundleIdentifier, page.kindLabel].compactMap { $0 }.joined(separator: " · ")) }, raw: String(decoding: (try? JSONOutput.encode(applications)) ?? Data(), as: UTF8.self)) + + case "bonjour": + let services = await NetworkServiceBrowser.browse() + return make(action, target, summary: services.isEmpty ? "No devices are advertising on this network." : "\(services.count) services found.", details: services.map { ($0.name, $0.meaning) }, raw: services.map { "\($0.type)\t\($0.name)\t\($0.interface ?? "")" }.joined(separator: "\n")) + + case "rvi": + let request = try XcodeHandoff.remoteVirtualInterfaces() + let result = try await runner.run(request) + return make(action, target, summary: result.succeeded ? "Remote Virtual Interfaces listed." : "rvictl reported a problem.", raw: result.standardOutputText + result.standardErrorText, argv: request.arguments) + + case "launch-app": + let target = try requireTarget(target) + let bundle = parameters["bundle"] ?? "" + if target.kind == .simulator { + let output = try await simulators.launch(bundleIdentifier: bundle, on: target, terminateExisting: true) + return make(action, target, summary: "Launched \(bundle).", raw: output) + } + let response = try await coreDevice.launch(bundleIdentifier: bundle, on: target, terminateExisting: true) + return make(action, target, summary: "Launched \(bundle).", raw: response.json.prettyString()) + + case "terminate": + let target = try requireTarget(target) + let response = try await coreDevice.terminate(pid: Int(parameters["pid"] ?? "") ?? 0, on: target, force: false) + return make(action, target, summary: "Asked process \(parameters["pid"] ?? "") to stop.", raw: response.json.prettyString()) + + case "open-url": + let target = try requireTarget(target) + // Validated on its own rather than read from the combined parameters, so no other + // parameter's data (for example coordinates) can flow into the URL handed to the device. + guard let urlParameter = action.parameters.first(where: { $0.id == "url" }), + let url = URL(string: try urlParameter.validate(values["url"] ?? urlParameter.defaultValue)) else { + throw ToolkitError.invalidInput("Enter a valid URL.") + } + if target.kind == .simulator { + try await simulators.openURL(url, on: target) + } else { + _ = try await coreDevice.openURL(url, on: target) + } + return make(action, target, summary: "Opened \(url.absoluteString).") + + case "set-location": + let target = try requireTarget(target) + let latitude = Double(parameters["latitude"] ?? "") ?? 0 + let longitude = Double(parameters["longitude"] ?? "") ?? 0 + try await location.set(latitude: latitude, longitude: longitude, on: target) + return make(action, target, summary: "Simulated location set to \(Coordinates(latitude: latitude, longitude: longitude).formatted).", details: [("Mechanism", location.mechanism(for: target).rawValue)]) + + case "clear-location": + let target = try requireTarget(target) + try await location.clear(on: target) + return make(action, target, summary: "Simulated location cleared.") + + case "reboot": + let target = try requireTarget(target) + _ = try await coreDevice.reboot(target) + return make(action, target, summary: "The device is restarting.") + + case "sim-boot": + let target = try requireTarget(target) + try await simulators.boot(target) + return make(action, target, summary: "\(target.name) is starting.") + case "sim-open": + let target = try requireTarget(target) + try await simulators.showInSimulatorApp(target) + return make(action, target, summary: "Opened Simulator.") + case "sim-shutdown": + let target = try requireTarget(target) + try await simulators.shutdown(target) + return make(action, target, summary: "\(target.name) is shut down.") + case "sim-dark", "sim-light": + let target = try requireTarget(target) + try await simulators.setAppearance(dark: action.id == "sim-dark", on: target) + return make(action, target, summary: "Appearance changed.") + case "sim-erase": + let target = try requireTarget(target) + try await simulators.erase(target) + return make(action, target, summary: "\(target.name) was erased.") + + default: + throw ToolkitError(.internalInconsistency, message: "The action “\(action.title)” is not implemented.") + } + } + + /// Splits, binds, and classifies an Advanced Mode devicectl command without running it, so + /// the UI can show the exact argument vector and ask for the matching confirmation. + public static func prepareAdvanced(_ text: String, target: DeviceTarget?) throws -> (arguments: [String], risk: ActionRisk) { + let arguments = try AdvancedCommandPolicy.bind(try ArgumentSplitter.split(text), to: target) + return (arguments, AdvancedCommandPolicy.risk(for: arguments)) + } + + /// Runs prepared Advanced Mode arguments (no time limit; stop with task cancellation). + public func runAdvanced(arguments: [String]) async throws -> CommandResult { + try await runner.run(try XcodeTool.devicectl.request(arguments, timeout: nil, displayName: "devicectl (Advanced Mode)")) + } +} diff --git a/Sources/ToolkitFeatures/Actions/ActionSafety.swift b/Sources/ToolkitFeatures/Actions/ActionSafety.swift new file mode 100644 index 0000000..5dab7a9 --- /dev/null +++ b/Sources/ToolkitFeatures/Actions/ActionSafety.swift @@ -0,0 +1,171 @@ +import DeviceKit +import Foundation +import ToolkitCore + +/// How much an action can change. Drives the confirmation the user must give. +public enum ActionRisk: String, Codable, Sendable, CaseIterable, Comparable { + case readOnly = "read-only" + case hostWrite = "host-write" + case deviceChange = "device-change" + case highImpact = "high-impact" + + public var label: String { + switch self { + case .readOnly: return "Read-only" + case .hostWrite: return "Saves files on this Mac" + case .deviceChange: return "Changes the device" + case .highImpact: return "High impact" + } + } + + public var explanation: String { + switch self { + case .readOnly: return "Only reads information. Nothing on the device or this Mac is changed." + case .hostWrite: return "Copies information from the device into a file you choose on this Mac. Review the file before sharing it." + case .deviceChange: return "Changes app, process, location, or developer state on the target device." + case .highImpact: return "Can restart the device or remove data. Make sure you have a current backup." + } + } + + public var symbolName: String { + switch self { + case .readOnly: return "eye" + case .hostWrite: return "square.and.arrow.down" + case .deviceChange: return "exclamationmark.triangle" + case .highImpact: return "exclamationmark.octagon" + } + } + + private var order: Int { + switch self { + case .readOnly: return 0 + case .hostWrite: return 1 + case .deviceChange: return 2 + case .highImpact: return 3 + } + } + + public static func < (lhs: ActionRisk, rhs: ActionRisk) -> Bool { lhs.order < rhs.order } +} + +/// The acknowledgement required before an action runs. +public struct ConfirmationRequirement: Sendable, Hashable { + public var risk: ActionRisk + /// The exact phrase to type, bound to the target's UDID suffix; nil when none is needed. + public var phrase: String? + public var requiresBackupAcknowledgement: Bool + public var requiresReview: Bool + + public static func make(for risk: ActionRisk, target: DeviceTarget?) -> ConfirmationRequirement { + let suffix = target?.confirmationSuffix ?? "LOCAL" + switch risk { + case .readOnly: + return ConfirmationRequirement(risk: risk, phrase: nil, requiresBackupAcknowledgement: false, requiresReview: false) + case .hostWrite: + return ConfirmationRequirement(risk: risk, phrase: nil, requiresBackupAcknowledgement: false, requiresReview: true) + case .deviceChange: + return ConfirmationRequirement(risk: risk, phrase: "RUN \(suffix)", requiresBackupAcknowledgement: false, requiresReview: true) + case .highImpact: + return ConfirmationRequirement(risk: risk, phrase: "IRREVERSIBLE \(suffix)", requiresBackupAcknowledgement: true, requiresReview: true) + } + } + + /// Whether the user's input satisfies the requirement. Comparison is exact apart from + /// surrounding whitespace, so a different device's suffix never matches. + public func isSatisfied(typedPhrase: String, backupAcknowledged: Bool) -> Bool { + if requiresBackupAcknowledgement && !backupAcknowledged { return false } + guard let phrase else { return true } + return typedPhrase.trimmingCharacters(in: .whitespacesAndNewlines) == phrase + } +} + +/// Splits an Advanced Mode argument string into a vector without invoking a shell. Supports +/// single quotes, double quotes, and backslash escapes; pipes, redirects, and substitutions are +/// treated as plain characters. +public enum ArgumentSplitter { + public static func split(_ text: String) throws -> [String] { + var arguments: [String] = [] + var current = "" + var inSingle = false + var inDouble = false + var escaping = false + var hasToken = false + for character in text { + if escaping { + current.append(character) + escaping = false + hasToken = true + continue + } + switch character { + case "\\" where !inSingle: + escaping = true + case "'" where !inDouble: + inSingle.toggle() + hasToken = true + case "\"" where !inSingle: + inDouble.toggle() + hasToken = true + case let whitespace where whitespace.isWhitespace && !inSingle && !inDouble: + if hasToken { + arguments.append(current) + current = "" + hasToken = false + } + default: + current.append(character) + hasToken = true + } + } + if escaping || inSingle || inDouble { + throw ToolkitError.invalidInput("The arguments have an unfinished quote or escape.") + } + if hasToken { arguments.append(current) } + return arguments + } +} + +/// Classifies free-form `devicectl` arguments for Advanced Mode and binds them to the selected +/// device. +public enum AdvancedCommandPolicy { + static let readOnlyPrefixes: [[String]] = [["list"], ["device", "info"], ["help"], ["device", "orientation", "get"], ["device", "profile", "list"], ["device", "pasteboard", "info"], ["device", "simulate", "location", "list"]] + static let hostWritePrefixes: [[String]] = [["device", "copy", "from"], ["device", "capture"], ["device", "sysdiagnose"], ["diagnose"]] + static let highImpactPrefixes: [[String]] = [ + ["device", "reboot"], ["device", "settings", "reset"], ["device", "uninstall"], ["device", "profile", "remove"], ["device", "profile", "install"], + ["device", "pairings", "unpair"], ["manage", "unpair"], ["manage", "ddis", "clean"], ["device", "rename"], + ] + + public static func risk(for arguments: [String]) -> ActionRisk { + let words = arguments.filter { !$0.hasPrefix("-") } + func matches(_ prefixes: [[String]]) -> Bool { prefixes.contains { Array(words.prefix($0.count)) == $0 } } + if matches(highImpactPrefixes) { return .highImpact } + if matches(hostWritePrefixes) { return .hostWrite } + if matches(readOnlyPrefixes) { return .readOnly } + return .deviceChange + } + + /// Adds `--device ` for device commands and refuses any attempt to address a + /// different device, or to redirect JSON output (the toolkit manages that). + public static func bind(_ arguments: [String], to target: DeviceTarget?) throws -> [String] { + guard let first = arguments.first else { throw ToolkitError.invalidInput("Enter a devicectl command, for example: device info apps") } + guard ["device", "list", "manage", "help", "diagnose"].contains(first) else { + throw ToolkitError.invalidInput("Advanced Mode runs devicectl subcommands only (device, list, manage, help, diagnose).") + } + for forbidden in ["--json-output", "-j", "--log-output", "-l"] where arguments.contains(forbidden) { + throw ToolkitError.invalidInput("\(forbidden) is managed by the toolkit and cannot be set in Advanced Mode.") + } + var bound = arguments + let deviceFlags = ["--device", "-d", "--devices"] + if let index = arguments.firstIndex(where: deviceFlags.contains) { + guard let target, index + 1 < arguments.count, arguments[index + 1] == target.coreDeviceSelector else { + throw ToolkitError.invalidInput("Advanced Mode always targets the selected device. Remove the --device option; the toolkit adds it for you.") + } + } else if first == "device" || (first == "manage" && arguments.count > 1 && arguments[1] != "ddis") { + guard let target, target.kind == .physical else { + throw ToolkitError.invalidInput("Select a physical device first; device commands always name the selected device.") + } + bound += ["--device", target.coreDeviceSelector] + } + return bound + } +} diff --git a/Sources/ToolkitFeatures/Actions/Instruments.swift b/Sources/ToolkitFeatures/Actions/Instruments.swift new file mode 100644 index 0000000..6a60340 --- /dev/null +++ b/Sources/ToolkitFeatures/Actions/Instruments.swift @@ -0,0 +1,104 @@ +import DeviceKit +import Foundation +import ToolkitCore + +/// Instruments recordings with `xcrun xctrace record`, the Apple-supported replacement for the +/// DVT telemetry streams (sysmon, energy, graphics, network activity, KDebug) of earlier versions. +public enum InstrumentsRecorder { + public struct Template: Sendable, Hashable, Identifiable { + public var id: String { name } + public var name: String + public var purpose: String + public var replaces: String + } + + public static let templates: [Template] = [ + Template(name: "Activity Monitor", purpose: "CPU, memory, disk, and network use per process.", replaces: "DVT sysmon system/process metrics"), + Template(name: "Network", purpose: "Network connections and traffic per process.", replaces: "DVT network activity (netstat)"), + Template(name: "Power Profiler", purpose: "Energy use by CPU, GPU, display, and networking.", replaces: "DVT energy monitor"), + Template(name: "Time Profiler", purpose: "Where processes spend CPU time.", replaces: "DVT process sampling"), + Template(name: "System Trace", purpose: "Threads, system calls, and scheduling (kernel trace).", replaces: "CoreProfile / KDebug tracing"), + Template(name: "Animation Hitches", purpose: "Frame timing and hitches.", replaces: "DVT graphics monitor"), + Template(name: "Logging", purpose: "os_log and signpost activity alongside other data.", replaces: "DVT OSLog stream"), + ] + + public static func request(template: String, target: DeviceTarget, durationSeconds: Int, output: URL) throws -> CommandRequest { + guard templates.contains(where: { $0.name == template }) else { + throw ToolkitError.invalidInput("Choose one of the listed Instruments templates.") + } + guard (1...3600).contains(durationSeconds) else { + throw ToolkitError.invalidInput("Recording length must be between 1 second and 1 hour.") + } + guard output.pathExtension == "trace" else { + throw ToolkitError.invalidInput("Instruments recordings are saved as .trace files.") + } + guard !FileManager.default.fileExists(atPath: output.path) else { + throw ToolkitError.invalidInput("A recording with that name already exists. Choose a new name.") + } + guard target.kind != .demo else { throw ToolkitError(.unsupported, message: "Recordings are not available for the demo device.") } + return try XcodeTool.xctrace.request( + ["record", "--template", template, "--device", target.udid, "--all-processes", "--time-limit", "\(durationSeconds)s", "--output", output.path, "--no-prompt"], + timeout: TimeInterval(durationSeconds + 180), + displayName: "xctrace record (\(template))" + ) + } +} + +/// Whether Instruments can record from a device, read from `xcrun xctrace list devices` (the +/// supported replacement for the DVT reachability check of earlier versions). +public enum InstrumentsDeviceList { + public enum Presence: Sendable, Equatable { + case available + /// Listed under an “Offline” heading: connected, but Instruments cannot record from it yet. + case offline + case notListed + } + + public static func request() throws -> CommandRequest { + try XcodeTool.xctrace.request(["list", "devices"], timeout: 60, displayName: "xctrace list devices") + } + + /// Finds the device by the identifier in parentheses at the end of its line, e.g. + /// `iPhone (26.3.1) (00008150-…)`, under the `== Devices ==`, `== Devices Offline ==`, and + /// `== Simulators ==` headings. + public static func presence(of udid: String, in output: String) -> Presence { + var offline = false + for rawLine in output.split(whereSeparator: \.isNewline) { + let line = rawLine.trimmingCharacters(in: .whitespaces) + if line.hasPrefix("=="), line.hasSuffix("==") { + offline = line.localizedCaseInsensitiveContains("offline") + continue + } + guard line.hasSuffix(")"), let open = line.lastIndex(of: "(") else { continue } + let identifier = line[line.index(after: open).. CommandRequest { + let name = url.lastPathComponent + guard ["xcodeproj", "xcworkspace"].contains(url.pathExtension) || name == "Package.swift" else { + throw ToolkitError.invalidInput("Choose an .xcodeproj, .xcworkspace, or Package.swift.") + } + guard FileManager.default.fileExists(atPath: url.path) else { throw ToolkitError.fileSystem("The project does not exist.", path: url.path) } + return try XcodeTool.xed.request([url.path], timeout: 60, displayName: "xed") + } + + public static func openResult(_ url: URL) throws -> CommandRequest { + guard ["xcresult", "trace"].contains(url.pathExtension) else { + throw ToolkitError.invalidInput("Choose an .xcresult or .trace bundle.") + } + guard FileManager.default.fileExists(atPath: url.path) else { throw ToolkitError.fileSystem("The bundle does not exist.", path: url.path) } + return CommandRequest(executable: try AppleTool.open.locate(), arguments: [url.path], timeout: 60, displayName: "open \(url.pathExtension)") + } + + public static func remoteVirtualInterfaces() throws -> CommandRequest { + CommandRequest(executable: try AppleTool.rvictl.locate(), arguments: ["-l"], timeout: 30, displayName: "rvictl -l") + } +} diff --git a/Sources/ToolkitFeatures/Evidence/CaseWorkflow.swift b/Sources/ToolkitFeatures/Evidence/CaseWorkflow.swift new file mode 100644 index 0000000..670ea5d --- /dev/null +++ b/Sources/ToolkitFeatures/Evidence/CaseWorkflow.swift @@ -0,0 +1,90 @@ +import DeviceKit +import Foundation +import ToolkitCore + +/// Local case metadata recorded before a collection. It documents the operator's stated +/// purpose and authorization; it is not a chain-of-custody record. +public struct CaseIntake: Codable, Sendable, Hashable { + public var title: String + public var purpose: String + public var targetUDID: String + public var targetName: String + public var authorizationAcknowledgedAt: Date + public var createdAt: Date + + enum CodingKeys: String, CodingKey { + case title, purpose + case targetUDID = "target_udid" + case targetName = "target_name" + case authorizationAcknowledgedAt = "authorization_acknowledged_at" + case createdAt = "created_at" + } +} + +public enum CaseWorkflow { + public static let intakeFileName = "case-intake.json" + public static let manifestFileName = "manifest.json" + public static let subfolders = ["snapshots", "streams", "artifacts"] + + public static func folderName(for target: DeviceTarget, at date: Date) -> String { + let fragment = String(target.udid.filter { $0.isLetter || $0.isNumber }.suffix(12)) + return "ios-case-\(ISO8601.compactUTC(date))-\(fragment.isEmpty ? "DEVICE" : fragment)" + } + + /// Creates a new case folder (owner-only permissions) with its standard subfolders. + public static func createCaseFolder(in root: URL, target: DeviceTarget, at date: Date = Date()) throws -> URL { + try SecureFileIO.createPrivateDirectory(at: root) + let folder = root.appendingPathComponent(folderName(for: target, at: date)) + do { + try SecureFileIO.createNewPrivateDirectory(at: folder) + } catch { + throw ToolkitError.fileSystem("A case for this device already exists for this second. Try again.", path: folder.path) + } + for name in subfolders { + try SecureFileIO.createNewPrivateDirectory(at: folder.appendingPathComponent(name)) + } + return folder + } + + public static func createGuidedCase(in root: URL, target: DeviceTarget, title: String, purpose: String, authorized: Bool) throws -> (URL, CaseIntake) { + guard authorized else { + throw ToolkitError.invalidInput("Confirm that you own the device or are authorized to examine it before creating a case.") + } + let normalizedTitle = title.split(whereSeparator: \.isWhitespace).joined(separator: " ") + guard !normalizedTitle.isEmpty else { throw ToolkitError.invalidInput("Enter a case title.") } + guard normalizedTitle.count <= 120 else { throw ToolkitError.invalidInput("The case title must be 120 characters or fewer.") } + let normalizedPurpose = purpose.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalizedPurpose.count <= 2_000 else { throw ToolkitError.invalidInput("The purpose must be 2,000 characters or fewer.") } + let now = Date() + let intake = CaseIntake(title: normalizedTitle, purpose: normalizedPurpose, targetUDID: target.udid, targetName: target.name, authorizationAcknowledgedAt: now, createdAt: now) + let folder = try createCaseFolder(in: root, target: target, at: now) + let document: [String: Any] = [ + "schema_version": 2, + "application": "iOS Developer Toolkit", + "case": try JSONSerialization.jsonObject(with: JSONOutput.encode(intake)), + "limitations": [ + "The intake records the operator acknowledgement; it does not establish chain of custody.", + "Hashes are written after collection and detect later changes to the finalized case files.", + ], + ] + try SecureFileIO.writeNewFile(try JSONSerialization.data(withJSONObject: document, options: [.prettyPrinted, .sortedKeys]), to: folder.appendingPathComponent(intakeFileName)) + return (folder, intake) + } + + /// Checks that a guided case belongs to `target`, is intact, and has not been finalized. + public static func validateForCollection(_ folder: URL, target: DeviceTarget) throws { + guard FileManager.default.fileExists(atPath: folder.path) else { throw ToolkitError.fileSystem("The case folder does not exist.", path: folder.path) } + guard !FileManager.default.fileExists(atPath: folder.appendingPathComponent(manifestFileName).path) else { + throw ToolkitError.invalidInput("This case has already been collected. Create a new case for another collection.") + } + guard let data = try? Data(contentsOf: folder.appendingPathComponent(intakeFileName)), let document = try? JSONValue.parse(data) else { + throw ToolkitError.invalidInput("The case intake file is missing or unreadable.") + } + guard document["case"]?["target_udid"]?.string == target.udid else { + throw ToolkitError.invalidInput("This case was created for a different device.") + } + for name in subfolders where !FileManager.default.fileExists(atPath: folder.appendingPathComponent(name).path) { + throw ToolkitError.invalidInput("The case folder is missing its \(name) folder.") + } + } +} diff --git a/Sources/ToolkitFeatures/Evidence/EvidenceCollector.swift b/Sources/ToolkitFeatures/Evidence/EvidenceCollector.swift new file mode 100644 index 0000000..973ef34 --- /dev/null +++ b/Sources/ToolkitFeatures/Evidence/EvidenceCollector.swift @@ -0,0 +1,428 @@ +import DeviceKit +import Foundation +import OSLog +import ToolkitCore + +public struct CollectionOptions: Codable, Sendable, Hashable { + /// Stream duration in seconds (0 = no streams). + public var durationSeconds: Int + public var includeClassicSyslog: Bool + public var includeUnifiedLogs: Bool + public var includePacketCapture: Bool + public var includeScreenshot: Bool + public var includeCrashReports: Bool + + public init(durationSeconds: Int = 60, includeClassicSyslog: Bool = false, includeUnifiedLogs: Bool = true, includePacketCapture: Bool = false, includeScreenshot: Bool = false, includeCrashReports: Bool = false) { + self.durationSeconds = durationSeconds + self.includeClassicSyslog = includeClassicSyslog + self.includeUnifiedLogs = includeUnifiedLogs + self.includePacketCapture = includePacketCapture + self.includeScreenshot = includeScreenshot + self.includeCrashReports = includeCrashReports + } + + public func validated() throws -> CollectionOptions { + guard (0...3600).contains(durationSeconds) else { throw ToolkitError.invalidInput("The stream duration must be between 0 and 3,600 seconds.") } + return self + } + + /// What a collection with these options needs: a trusted connection, and Xcode's device + /// service for the screenshot. + public var requirements: [ActionRequirement] { + [.trustedDevice] + (includeScreenshot ? [.coreDevice] : []) + } + + var hasStreams: Bool { durationSeconds > 0 && (includeClassicSyslog || includeUnifiedLogs || includePacketCapture) } +} + +public enum StepStatus: String, Codable, Sendable { + case succeeded, failed, unavailable, cancelled +} + +public struct CollectionStep: Codable, Sendable, Hashable, Identifiable { + public var id: String + public var title: String + public var mechanism: String + public var required: Bool + public var status: StepStatus + public var attempts: Int + public var startedAt: Date + public var finishedAt: Date + public var outputPath: String? + public var detail: String + + enum CodingKeys: String, CodingKey { + case id, title, mechanism, required, status, attempts, detail + case startedAt = "started_at" + case finishedAt = "finished_at" + case outputPath = "output_path" + } +} + +public enum CollectionOutcome: String, Codable, Sendable { + /// Every step succeeded. + case complete + /// Required identification succeeded; some optional coverage is missing. + case partial + /// Required identification failed. + case failed + + /// Exit codes kept from earlier releases' collector (0 complete, 2 partial, 1 failed). + public var exitCode: Int32 { + switch self { + case .complete: return 0 + case .partial: return 2 + case .failed: return 1 + } + } +} + +public struct CollectionManifest: Codable, Sendable { + public var schemaVersion = 2 + public var application = "iOS Developer Toolkit" + public var applicationVersion: String + public var targetUDID: String + public var targetName: String + public var targetModel: String? + public var targetOSVersion: String? + public var targetBuild: String? + public var options: CollectionOptions + public var startedAt: Date + public var finishedAt: Date + public var outcome: CollectionOutcome + public var stoppedEarly: Bool + public var steps: [CollectionStep] + public var limitations: [String] = [ + "A failed or unavailable step is a coverage gap, not proof of absence.", + "Hashes detect later changes to finalized files; they do not by themselves prove when, where, or by whom evidence was acquired.", + "Service views (lockdown, AFC, CoreDevice) are Apple-defined and are not a full file-system acquisition.", + ] + + enum CodingKeys: String, CodingKey { + case application, options, outcome, steps, limitations + case schemaVersion = "schema_version" + case applicationVersion = "application_version" + case targetUDID = "target_udid" + case targetName = "target_name" + case targetModel = "target_model" + case targetOSVersion = "target_os_version" + case targetBuild = "target_build" + case startedAt = "started_at" + case finishedAt = "finished_at" + case stoppedEarly = "stopped_early" + } +} + +public enum CollectionEvent: Sendable { + case stepStarted(String) + case stepFinished(CollectionStep) + case streaming(secondsRemaining: Int) + case finalizing +} + +/// Collects a bounded, hashed evidence case from one physical device. +public actor EvidenceCollector { + public nonisolated let device: Device + public nonisolated let caseFolder: URL + public nonisolated let options: CollectionOptions + private let runner: CommandRunning + private let usbmux: USBMuxClient + private let coreDevice: CoreDeviceClient + private var steps: [CollectionStep] = [] + private var stopRequested = false + private let logger = ToolkitLog.evidence + + public init(device: Device, caseFolder: URL, options: CollectionOptions, runner: CommandRunning = ProcessCommandRunner(), usbmux: USBMuxClient = USBMuxClient()) throws { + guard device.kind == .physical else { throw ToolkitError(.unsupported, message: "Evidence collection is available for physical devices.") } + self.device = device + self.caseFolder = caseFolder + self.options = try options.validated() + self.runner = runner + self.usbmux = usbmux + coreDevice = CoreDeviceClient(runner: runner) + } + + /// Ends timed streams early; collection still finalizes the manifest and hashes. + public func requestStop() { + stopRequested = true + } + + public func run(events: @escaping @Sendable (CollectionEvent) -> Void) async -> CollectionManifest { + let started = Date() + let target = device.target + logger.info("Evidence collection started") + + // 1. Required identification through an identity-verified lockdown session. + await snapshot("lockdown-values", "Lockdown device information", "lockdownd GetValue (native)", required: true, file: "snapshots/lockdown-info.json", events: events) { + try await DeviceSession.with(target, usbmux: self.usbmux) { try await $0.getValue() ?? .dictionary([:]) }.prettyJSONString() + } + try? SecureFileIO.writeNewFile(try JSONOutput.encode(device), to: caseFolder.appendingPathComponent("snapshots/device-record.json")) + + let identified = steps.first?.status == .succeeded + if identified { + await collectSnapshots(target, events: events) + if options.hasStreams && !stopRequested { await collectStreams(target, events: events) } + await collectArtifacts(target, events: events) + } + + events(.finalizing) + let succeeded = steps.allSatisfy { $0.status == .succeeded } + let outcome: CollectionOutcome = !identified ? .failed : (succeeded ? .complete : .partial) + let manifest = CollectionManifest(applicationVersion: ToolkitVersion.current, targetUDID: target.udid, targetName: target.name, targetModel: device.marketingName ?? device.productType, targetOSVersion: device.osVersion, targetBuild: device.buildVersion, options: options, startedAt: started, finishedAt: Date(), outcome: outcome, stoppedEarly: stopRequested, steps: steps) + do { + try SecureFileIO.writeNewFile(try JSONOutput.encode(manifest), to: caseFolder.appendingPathComponent(CaseWorkflow.manifestFileName)) + try HashManifest.write(for: caseFolder, fileName: "SHA256SUMS") + } catch { + logger.error("Could not finalize the case: \(error.localizedDescription, privacy: .private)") + } + logger.info("Evidence collection finished: \(outcome.rawValue, privacy: .public)") + return manifest + } + + // MARK: Snapshots + + private func collectSnapshots(_ target: DeviceTarget, events: @escaping @Sendable (CollectionEvent) -> Void) async { + let usbmux = self.usbmux + func lockdown(_ body: @escaping @Sendable (DeviceSession) async throws -> String) -> @Sendable () async throws -> String { + { try await DeviceSession.with(target, usbmux: usbmux, body) } + } + let coreDevice = self.coreDevice + let coreDeviceAvailable = device.supportsCoreDevice + + await snapshot("coredevice-details", "CoreDevice device information", "devicectl device info details", file: "snapshots/coredevice-details.json", unavailableUnless: coreDeviceAvailable, events: events) { + try await coreDevice.details(target).response.json.prettyString() + } + await snapshot("developer-images", "Mounted developer images", "mobile_image_mounter CopyDevices", file: "snapshots/developer-images.json", events: events, lockdown { session in + let mounter = try await ImageMounter.open(session) + defer { Task { await mounter.close() } } + return PlistValue.array(try await mounter.mountedImages().map(\.raw)).prettyJSONString() + }) + await snapshot("ddi-services", "Developer services status", "devicectl device info ddiServices --no-auto-mount-ddis", file: "snapshots/ddi-services.json", unavailableUnless: coreDeviceAvailable, events: events) { + try await coreDevice.ddiServices(target, autoMount: false).json.prettyString() + } + for (id, title, file, kind) in [("diagnostics", "Diagnostics overview", "snapshots/diagnostics.json", 0), ("mobilegestalt", "MobileGestalt values", "snapshots/mobilegestalt.json", 1), ("battery", "Battery snapshot", "snapshots/battery.json", 2), ("ioregistry", "IORegistry (device tree)", "snapshots/ioregistry.json", 3)] { + await snapshot(id, title, "diagnostics_relay", file: file, events: events, lockdown { session in + let relay = try await DiagnosticsRelay.open(session) + defer { Task { await relay.close() } } + switch kind { + case 0: return try await relay.all().prettyJSONString() + case 1: return try await relay.mobileGestalt(keys: DiagnosticsRelay.defaultGestaltKeys).prettyJSONString() + case 2: return try await relay.battery().prettyJSONString() + default: return try await relay.ioRegistry(plane: "IODeviceTree").prettyJSONString() + } + }) + } + await snapshot("apps", "Installed applications", "installation_proxy Browse", file: "snapshots/apps.json", events: events, lockdown { session in + let proxy = try await InstallationProxy.open(session) + defer { Task { await proxy.close() } } + let apps = try await proxy.browse(includeSizes: true) + return String(decoding: try JSONOutput.encode(apps.map { ["bundle_identifier": $0.bundleIdentifier, "name": $0.name, "version": $0.version ?? "", "build": $0.build ?? "", "type": $0.applicationType, "total_bytes": $0.totalBytes.map(String.init) ?? ""] }), as: UTF8.self) + }) + if device.supportsLockdownServices { + await snapshot("processes", "Running processes", "os_trace_relay PidList (native)", file: "snapshots/processes.txt", events: events, lockdown { session in + try await OSTraceRelay.processList(session).map { "\($0.pid)\t\($0.name)" }.joined(separator: "\n") + "\n" + }) + } else { + await snapshot("processes", "Running processes", "devicectl device info processes", file: "snapshots/processes.txt", unavailableUnless: coreDeviceAvailable, events: events) { + try await coreDevice.processes(target).map { "\($0.pid)\t\($0.executablePath ?? "")" }.joined(separator: "\n") + "\n" + } + } + if device.supportsLockdownServices { + await snapshot("configuration-profiles", "Configuration profiles", "MCInstall GetProfileList (native)", file: "snapshots/configuration-profiles.json", events: events, lockdown { session in + let service = try await ConfigurationProfileService.open(session) + defer { Task { await service.close() } } + return String(decoding: try JSONOutput.encode(try await service.profiles()), as: UTF8.self) + }) + } else { + await snapshot("configuration-profiles", "Configuration profiles", "devicectl device profile list", file: "snapshots/configuration-profiles.json", unavailableUnless: coreDeviceAvailable, events: events) { + try await coreDevice.profiles(target).json.prettyString() + } + } + await snapshot("provisioning-profiles", "Provisioning profiles", "misagent CopyAll", file: "snapshots/provisioning-profiles.json", events: events, lockdown { session in + let service = try await ProvisioningProfileService.open(session) + defer { Task { await service.close() } } + return String(decoding: try JSONOutput.encode(try await service.copyAll().map(ProvisioningProfileDecoder.decode)), as: UTF8.self) + }) + await snapshot("crash-inventory", "Crash report inventory", "crashreportcopymobile (AFC)", file: "snapshots/crash-list.txt", events: events, lockdown { session in + let afc = try await AFCClient.openCrashReports(session) + defer { Task { await afc.close() } } + return try await afc.walk("/").joined(separator: "\n") + "\n" + }) + await snapshot("media-root", "Media folder listing", "AFC", file: "snapshots/afc-root.txt", events: events, lockdown { session in + let afc = try await AFCClient.openMedia(session) + defer { Task { await afc.close() } } + return try await afc.listDirectory("/").joined(separator: "\n") + "\n" + }) + } + + /// Runs one snapshot, retrying once, and records the outcome. + private func snapshot(_ id: String, _ title: String, _ mechanism: String, required: Bool = false, file: String, unavailableUnless available: Bool = true, events: @escaping @Sendable (CollectionEvent) -> Void, _ body: @escaping @Sendable () async throws -> String) async { + events(.stepStarted(title)) + let started = Date() + guard available else { + record(CollectionStep(id: id, title: title, mechanism: mechanism, required: required, status: .unavailable, attempts: 0, startedAt: started, finishedAt: Date(), outputPath: nil, detail: "Needs Xcode's device service for this device."), events: events) + return + } + var lastError = "" + for attempt in 1...2 { + if stopRequested && !required { + record(CollectionStep(id: id, title: title, mechanism: mechanism, required: required, status: .cancelled, attempts: attempt - 1, startedAt: started, finishedAt: Date(), outputPath: nil, detail: "Stopped before this step ran."), events: events) + return + } + do { + let output = try await withTimeout(180, operation: title) { try await body() } + let url = caseFolder.appendingPathComponent(file) + try SecureFileIO.writeNewFile(Data(output.utf8), to: url) + record(CollectionStep(id: id, title: title, mechanism: mechanism, required: required, status: .succeeded, attempts: attempt, startedAt: started, finishedAt: Date(), outputPath: file, detail: ""), events: events) + return + } catch { + let toolkitError = error as? ToolkitError + lastError = [toolkitError?.message ?? error.localizedDescription, toolkitError?.technicalDetail].compactMap { $0 }.joined(separator: " — ") + } + } + try? SecureFileIO.writeNewFile(Data(lastError.utf8), to: caseFolder.appendingPathComponent(file + ".error.txt")) + record(CollectionStep(id: id, title: title, mechanism: mechanism, required: required, status: .failed, attempts: 2, startedAt: started, finishedAt: Date(), outputPath: file + ".error.txt", detail: lastError), events: events) + } + + private func record(_ step: CollectionStep, events: @Sendable (CollectionEvent) -> Void) { + steps.append(step) + events(.stepFinished(step)) + } + + // MARK: Streams + + private func collectStreams(_ target: DeviceTarget, events: @escaping @Sendable (CollectionEvent) -> Void) async { + let duration = options.durationSeconds + let folder = caseFolder + let usbmux = self.usbmux + var specs: [(id: String, title: String, mechanism: String, file: String, kind: Int)] = [] + if options.includeClassicSyslog { specs.append(("stream-syslog", "Classic syslog stream", SyslogRelay.serviceName, "streams/syslog.log", 0)) } + if options.includeUnifiedLogs { specs.append(("stream-unified", "Unified Logging stream", OSTraceRelay.serviceName, "streams/unified.jsonl", 1)) } + if options.includePacketCapture { specs.append(("stream-pcap", "Network packet capture", PacketCaptureService.serviceName, "streams/network.pcap", 2)) } + + let deadline = ContinuousClock.now + .seconds(duration) + let stopFlag = LockedValue(false) + let ticker = Task { + var remaining = duration + while remaining > 0 && !Task.isCancelled { + events(.streaming(secondsRemaining: remaining)) + try? await Task.sleep(for: .seconds(1)) + remaining = max(0, Int((deadline - ContinuousClock.now).components.seconds)) + if self.stopRequested { stopFlag.withLock { $0 = true } } + } + } + let results = await withTaskGroup(of: CollectionStep.self) { group in + for spec in specs { + group.addTask { + let started = Date() + let url = folder.appendingPathComponent(spec.file) + do { + let bytes = try await EvidenceCollector.captureStream(kind: spec.kind, target: target, usbmux: usbmux, url: url, deadline: deadline, stopFlag: stopFlag) + return CollectionStep(id: spec.id, title: spec.title, mechanism: spec.mechanism, required: false, status: .succeeded, attempts: 1, startedAt: started, finishedAt: Date(), outputPath: spec.file, detail: "\(bytes) bytes captured") + } catch { + let message = (error as? ToolkitError)?.message ?? error.localizedDescription + return CollectionStep(id: spec.id, title: spec.title, mechanism: spec.mechanism, required: false, status: .failed, attempts: 1, startedAt: started, finishedAt: Date(), outputPath: FileManager.default.fileExists(atPath: url.path) ? spec.file : nil, detail: message) + } + } + } + var collected: [CollectionStep] = [] + for await step in group { collected.append(step) } + return collected + } + ticker.cancel() + for step in results.sorted(by: { $0.id < $1.id }) { record(step, events: events) } + } + + static func captureStream(kind: Int, target: DeviceTarget, usbmux: USBMuxClient, url: URL, deadline: ContinuousClock.Instant, stopFlag: LockedValue) async throws -> Int64 { + try await DeviceSession.with(target, usbmux: usbmux) { session in + try await withThrowingTaskGroup(of: Int64.self) { group in + group.addTask { + if kind == 2 { + let writer = try PcapFileWriter(creatingNewFileAt: url) + var bytes: Int64 = 0 + do { + for try await packet in try await PacketCaptureService.stream(session) { + try writer.write(packet) + bytes += Int64(packet.frame.count) + } + } catch is CancellationError {} + _ = try? writer.finish() + return bytes + } + try SecureFileIO.writeNewFile(Data(), to: url) + let output = try FileHandle(forWritingTo: url) + defer { try? output.close() } + var bytes: Int64 = 0 + let stream = kind == 0 ? try await SyslogRelay.stream(session) : try await OSTraceRelay.stream(session) + do { + for try await chunk in stream { + try output.write(contentsOf: chunk.spoolBytes) + bytes += Int64(chunk.spoolBytes.count) + } + } catch is CancellationError {} + return bytes + } + group.addTask { + while ContinuousClock.now < deadline && !stopFlag.current { + try await Task.sleep(for: .milliseconds(250)) + } + return -1 + } + // The timer finishes first in the normal case; cancelling the capture then lets + // it flush and report its byte count. + var captured: Int64 = 0 + while let value = try await group.next() { + if value == -1 { + group.cancelAll() + } else { + captured = value + } + } + return captured + } + } + } + + // MARK: Artifacts + + private func collectArtifacts(_ target: DeviceTarget, events: @escaping @Sendable (CollectionEvent) -> Void) async { + let coreDevice = self.coreDevice + let usbmux = self.usbmux + let folder = caseFolder + if options.includeScreenshot { + await snapshotFile("screenshot", "Screenshot", "devicectl device capture screenshot", file: "artifacts/screen.png", unavailableUnless: device.supportsCoreDevice, events: events) { + _ = try await coreDevice.screenshot(target, to: folder.appendingPathComponent("artifacts/screen.png")) + } + } + if options.includeCrashReports { + await snapshotFile("crash-reports", "Crash reports", "crashreportcopymobile (AFC)", file: "artifacts/crashes", events: events) { + let destination = folder.appendingPathComponent("artifacts/crashes") + try SecureFileIO.createPrivateDirectory(at: destination) + try await DeviceSession.with(target, usbmux: usbmux) { session in + let afc = try await AFCClient.openCrashReports(session) + defer { Task { await afc.close() } } + for path in try await afc.walk("/") { + let local = try SecureFileIO.safeChild(of: destination, relativePath: String(path.drop { $0 == "/" })) + try SecureFileIO.createPrivateDirectory(at: local.deletingLastPathComponent()) + _ = try await afc.download(path, to: local) + } + } + } + } + } + + private func snapshotFile(_ id: String, _ title: String, _ mechanism: String, file: String, unavailableUnless available: Bool = true, events: @escaping @Sendable (CollectionEvent) -> Void, _ body: @escaping @Sendable () async throws -> Void) async { + events(.stepStarted(title)) + let started = Date() + guard available else { + record(CollectionStep(id: id, title: title, mechanism: mechanism, required: false, status: .unavailable, attempts: 0, startedAt: started, finishedAt: Date(), outputPath: nil, detail: "Needs Xcode's device service for this device."), events: events) + return + } + do { + try await withTimeout(900, operation: title) { try await body() } + record(CollectionStep(id: id, title: title, mechanism: mechanism, required: false, status: .succeeded, attempts: 1, startedAt: started, finishedAt: Date(), outputPath: file, detail: ""), events: events) + } catch { + record(CollectionStep(id: id, title: title, mechanism: mechanism, required: false, status: .failed, attempts: 1, startedAt: started, finishedAt: Date(), outputPath: nil, detail: (error as? ToolkitError)?.message ?? error.localizedDescription), events: events) + } + } +} diff --git a/Sources/ToolkitFeatures/ExternalTools/ExternalTools.swift b/Sources/ToolkitFeatures/ExternalTools/ExternalTools.swift new file mode 100644 index 0000000..ba594d1 --- /dev/null +++ b/Sources/ToolkitFeatures/ExternalTools/ExternalTools.swift @@ -0,0 +1,274 @@ +import Foundation +import ToolkitCore + +/// A user-selected external executable, pinned by SHA-256 at validation time. +public struct ValidatedExecutable: Sendable, Hashable, Codable { + public var path: String + public var sha256: String + public var version: String + + /// Confirms the file has not changed since it was validated. + public func revalidate() throws { + let url = URL(fileURLWithPath: path) + try ExecutableValidator.validate(url) + guard try SecureFileIO.sha256(of: url) == sha256 else { + throw ToolkitError(.permissionDenied, message: "\(url.lastPathComponent) changed after it was validated.", recovery: "Validate the installation again before running it.") + } + } +} + +enum ExternalToolSupport { + static func resolveExecutable(_ path: String, label: String) throws -> URL { + let expanded = (path as NSString).expandingTildeInPath + guard expanded.hasPrefix("/") else { throw ToolkitError.invalidInput("\(label) must be an absolute path.") } + let url = URL(fileURLWithPath: expanded).resolvingSymlinksInPath() + try ExecutableValidator.validate(url) + return url + } + + static func stripANSI(_ text: String) -> String { + text.replacingOccurrences(of: #"\u001B\[[0-?]*[ -/]*[@-~]"#, with: "", options: .regularExpression) + } + + static func discover(named name: String, extra: [String]) -> [String] { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let candidates = ["/opt/homebrew/bin/\(name)", "/usr/local/bin/\(name)", "\(home)/.local/bin/\(name)"] + extra + var seen = Set() + return candidates.compactMap { path in + guard FileManager.default.isExecutableFile(atPath: path) else { return nil } + let resolved = URL(fileURLWithPath: path).resolvingSymlinksInPath().path + return seen.insert(resolved).inserted ? resolved : nil + } + } +} + +// MARK: - MVT (Mobile Verification Toolkit) + +/// A consented, isolated handoff to a separately installed `mvt-ios`. +public enum MVTConnector { + public static let repositoryURL = URL(string: "https://github.com/mvt-project/mvt")! + public static let backupGuideURL = URL(string: "https://docs.mvt.re/en/latest/ios/backup/check/")! + public static let setupCommands = ["brew install python3 pipx sqlite3", "pipx ensurepath", "pipx install mvt"] + /// Variables that could supply a password, indicators, or an API key implicitly. + public static let environmentKeysToRemove = ["MVT_ANDROID_BACKUP_PASSWORD", "MVT_HASH_FILES", "MVT_IOS_BACKUP_PASSWORD", "MVT_PROFILE", "MVT_STIX2", "MVT_VT_API_KEY"] + + public static func discover() -> [String] { + ExternalToolSupport.discover(named: "mvt-ios", extra: []) + } + + public static func validate(executablePath: String, runner: CommandRunning) async throws -> ValidatedExecutable { + let url = try ExternalToolSupport.resolveExecutable(executablePath, label: "The mvt-ios path") + let result = try await runner.run(CommandRequest(executable: url, arguments: ["--disable-update-check", "--disable-indicator-update-check", "version"], environment: environment(configDirectory: nil, allowNetwork: false), timeout: 60, displayName: "mvt-ios version")) + guard result.succeeded else { + throw ToolkitError(.commandFailed, message: "mvt-ios did not report its version.", recovery: "Reinstall MVT with pipx and try again.", technicalDetail: result.technicalSummary) + } + return ValidatedExecutable(path: url.path, sha256: try SecureFileIO.sha256(of: url), version: try parseVersion(result.standardOutputText + result.standardErrorText)) + } + + public static func parseVersion(_ output: String) throws -> String { + let text = ExternalToolSupport.stripANSI(output) + guard let range = text.range(of: #"(?im)^\s*Version:\s*([A-Za-z0-9][A-Za-z0-9._+-]*)\s*$"#, options: .regularExpression) else { + throw ToolkitError(.commandFailed, message: "The mvt-ios version output was not recognized.") + } + return text[range].components(separatedBy: ":").last?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + + /// Resolves a decrypted iTunes-style backup (a folder with Manifest.db and Info.plist, or a + /// parent containing exactly one such folder). Encrypted backups are refused. + public static func resolveBackup(_ url: URL) throws -> URL { + func isBackup(_ folder: URL) -> Bool { + FileManager.default.fileExists(atPath: folder.appendingPathComponent("Manifest.db").path) + && FileManager.default.fileExists(atPath: folder.appendingPathComponent("Info.plist").path) + } + var backup = url.standardizedFileURL + if !isBackup(backup) { + let children = ((try? FileManager.default.contentsOfDirectory(at: backup, includingPropertiesForKeys: [.isDirectoryKey])) ?? []).filter(isBackup) + guard children.count == 1, let only = children.first else { + throw ToolkitError.invalidInput(children.isEmpty ? "No backup (a folder with Manifest.db and Info.plist) was found there." : "Several backups were found; choose one backup folder.") + } + backup = only + } + let manifest = backup.appendingPathComponent("Manifest.plist") + if let data = try? Data(contentsOf: manifest), let plist = try? PlistValue.decode(data), plist["IsEncrypted"]?.boolValue == true { + throw ToolkitError(.unsupported, message: "This backup is encrypted.", recovery: "Decrypt a protected working copy with MVT outside the toolkit (see MVT's backup guide), then choose that copy. The toolkit never asks for backup passwords.") + } + return backup + } + + public struct AnalysisRequest: Sendable { + public var executable: ValidatedExecutable + public var backup: URL + public var output: URL + public var indicatorFiles: [URL] + public var fast: Bool + public var hashes: Bool + public var allowNetwork: Bool + + public init(executable: ValidatedExecutable, backup: URL, output: URL, indicatorFiles: [URL], fast: Bool, hashes: Bool, allowNetwork: Bool) { + self.executable = executable + self.backup = backup + self.output = output + self.indicatorFiles = indicatorFiles + self.fast = fast + self.hashes = hashes + self.allowNetwork = allowNetwork + } + } + + public static func validate(_ request: AnalysisRequest) throws { + try request.executable.revalidate() + guard !FileManager.default.fileExists(atPath: request.output.path) else { + throw ToolkitError.invalidInput("The result folder already exists. Choose a new name so results cannot mix with an earlier run.") + } + let backupPath = request.backup.standardizedFileURL.path + guard !request.output.standardizedFileURL.path.hasPrefix(backupPath + "/") else { + throw ToolkitError.invalidInput("The result folder cannot be inside the source backup.") + } + for file in request.indicatorFiles { + guard ["json", "stix", "stix2"].contains(file.pathExtension.lowercased()), FileManager.default.isReadableFile(atPath: file.path) else { + throw ToolkitError.invalidInput("Indicator files must be readable .stix, .stix2, or .json files.") + } + } + } + + public static func arguments(for request: AnalysisRequest) -> [String] { + var arguments = ["--disable-update-check", "--disable-indicator-update-check", "check-backup", "--output", request.output.path] + if request.fast { arguments.append("--fast") } + if request.hashes { arguments.append("--hashes") } + for file in request.indicatorFiles { arguments += ["--iocs", file.path] } + arguments.append(request.backup.path) + return arguments + } + + public static func environment(configDirectory: URL?, allowNetwork: Bool) -> [String: String] { + var environment = CommandEnvironment.minimal() + for key in environmentKeysToRemove { environment[key] = nil } + if let configDirectory { environment["MVT_CONFIG_FOLDER"] = configDirectory.path } + environment["MVT_NETWORK_ACCESS_ALLOWED"] = allowNetwork ? "true" : "false" + environment["MVT_NETWORK_TIMEOUT"] = "15" + environment["PYTHONUNBUFFERED"] = "1" + return environment + } + + public static func analysisRequest(_ request: AnalysisRequest, configDirectory: URL) throws -> CommandRequest { + try validate(request) + return CommandRequest( + executable: URL(fileURLWithPath: request.executable.path), + arguments: arguments(for: request), + environment: environment(configDirectory: configDirectory, allowNetwork: request.allowNetwork), + timeout: nil, + outputLimit: 8 * 1024 * 1024, + displayName: "mvt-ios check-backup" + ) + } +} + +// MARK: - UFADE + +/// Launches a separately installed UFADE checkout (GPL-3.0) in its own Python environment. +/// The toolkit neither imports nor bundles UFADE. +public enum UFADEConnector { + public static let repositoryURL = URL(string: "https://github.com/prosch88/UFADE")! + public static let setupCommands = [ + "brew install python@3.11 python-tk@3.11", + "git clone --recurse-submodules https://github.com/prosch88/UFADE.git", + "cd UFADE", + "python3.11 -m venv .venv", + ".venv/bin/python -m pip install --upgrade pip", + ".venv/bin/python -m pip install -r requirements.txt", + ] + static let runtimeImports = ["tkinter", "customtkinter", "PIL", "pandas", "pymobiledevice3", "iOSbackup", "paramiko", "cryptography"] + + public struct Installation: Sendable, Hashable { + public var checkout: URL + public var python: ValidatedExecutable + public var ufadeVersion: String + /// Whether the checkout includes UFADE's developer-image submodule (`ufade_developer`). + public var developerImagesAvailable: Bool + } + + public static let submoduleCommand = "git submodule update --init --recursive" + + /// UFADE keeps developer images in a Git submodule; a clone without `--recurse-submodules` + /// leaves it empty. Logical acquisitions still work, but UFADE's Developer Options may not. + public static func developerImagesAvailable(in checkout: URL) -> Bool { + var isDirectory: ObjCBool = false + let folder = checkout.appendingPathComponent("ufade_developer/Developer", isDirectory: true) + return FileManager.default.fileExists(atPath: folder.path, isDirectory: &isDirectory) && isDirectory.boolValue + } + + public static func validate(checkout: URL, python: String, runner: CommandRunning) async throws -> Installation { + let script = checkout.appendingPathComponent("ufade.py") + for required in ["ufade.py", "LICENSE", "requirements.txt"] where !FileManager.default.fileExists(atPath: checkout.appendingPathComponent(required).path) { + throw ToolkitError.invalidInput("The UFADE folder is missing \(required).") + } + let license = (try? String(contentsOf: checkout.appendingPathComponent("LICENSE"), encoding: .utf8)) ?? "" + guard license.contains("GNU GENERAL PUBLIC LICENSE"), license.contains("Version 3") else { + throw ToolkitError.invalidInput("The folder does not contain UFADE's expected GPL-3.0 license.") + } + let source = (try? String(contentsOf: script, encoding: .utf8)) ?? "" + guard let versionRange = source.range(of: #"(?m)^u_version\s*=\s*["']([^"']+)["']"#, options: .regularExpression) else { + throw ToolkitError.invalidInput("UFADE's version declaration was not found in ufade.py.") + } + let version = source[versionRange].components(separatedBy: CharacterSet(charactersIn: "\"'"))[1] + let pythonURL = try ExternalToolSupport.resolveExecutable(python, label: "The UFADE Python path") + let versionResult = try await runner.run(CommandRequest(executable: pythonURL, arguments: ["-c", "import sys; print('.'.join(map(str, sys.version_info[:3])))"], timeout: 15, displayName: "UFADE Python version")) + let pythonVersion = versionResult.standardOutputText.trimmingCharacters(in: .whitespacesAndNewlines) + guard versionResult.succeeded, pythonVersion.hasPrefix("3.11.") else { + throw ToolkitError(.unsupported, message: "UFADE needs Python 3.11 (found \(pythonVersion.isEmpty ? "none" : pythonVersion)).", recovery: "Create UFADE's own Python 3.11 environment with the setup commands.") + } + let imports = try await runner.run(CommandRequest(executable: pythonURL, arguments: ["-c", "import " + runtimeImports.joined(separator: ", ")], workingDirectory: checkout, timeout: 60, displayName: "UFADE dependency check")) + guard imports.succeeded else { + throw ToolkitError(.commandFailed, message: "UFADE's Python environment is incomplete.", recovery: "Run the setup commands inside the UFADE folder, then validate again.", technicalDetail: imports.technicalSummary) + } + return Installation(checkout: checkout, python: ValidatedExecutable(path: pythonURL.path, sha256: try SecureFileIO.sha256(of: pythonURL), version: pythonVersion), ufadeVersion: version, developerImagesAvailable: developerImagesAvailable(in: checkout)) + } + + /// Starts UFADE's own window. It controls device selection, passwords, and output. + public static func launch(_ installation: Installation, workingDirectory: URL) throws -> Int32 { + try installation.python.revalidate() + return try ProcessCommandRunner().launchDetached(CommandRequest( + executable: URL(fileURLWithPath: installation.python.path), + arguments: [installation.checkout.appendingPathComponent("ufade.py").path], + environment: CommandEnvironment.minimal(adding: ["PYTHONUNBUFFERED": "1"]), + workingDirectory: workingDirectory, + timeout: nil, + displayName: "UFADE" + )) + } +} + +// MARK: - idb Companion + +/// Optional adapter for Meta's idb Companion: validation plus one read-only inventory probe. +public enum IDBCompanionConnector { + public static let repositoryURL = URL(string: "https://github.com/facebook/idb")! + public static let setupCommand = "brew install facebook/fb/idb-companion" + public static let environmentKeysToRemove = ["IDB_UDID", "IDB_COMPANION", "IDB_COMPANION_PATH"] + + public static func discover() -> [String] { + ExternalToolSupport.discover(named: "idb_companion", extra: []) + } + + public static func validate(executablePath: String, runner: CommandRunning) async throws -> ValidatedExecutable { + let url = try ExternalToolSupport.resolveExecutable(executablePath, label: "The idb_companion path") + let result = try await runner.run(CommandRequest(executable: url, arguments: ["--version"], environment: environment(), timeout: 30, displayName: "idb_companion --version")) + guard result.succeeded else { + throw ToolkitError(.commandFailed, message: "idb_companion did not report its version.", technicalDetail: result.technicalSummary) + } + let text = ExternalToolSupport.stripANSI(result.standardOutputText + result.standardErrorText) + let version = text.split(separator: "\n").first.map { String($0).trimmingCharacters(in: .whitespaces) } ?? "unknown" + return ValidatedExecutable(path: url.path, sha256: try SecureFileIO.sha256(of: url), version: version) + } + + public static func probeRequest(_ executable: ValidatedExecutable) throws -> CommandRequest { + try executable.revalidate() + return CommandRequest(executable: URL(fileURLWithPath: executable.path), arguments: ["--list", "1"], environment: environment(), timeout: 30, displayName: "idb_companion --list 1") + } + + static func environment() -> [String: String] { + var environment = CommandEnvironment.minimal() + for key in environmentKeysToRemove { environment[key] = nil } + return environment + } +} diff --git a/Sources/ToolkitFeatures/IPA/IPAInspector.swift b/Sources/ToolkitFeatures/IPA/IPAInspector.swift new file mode 100644 index 0000000..c6d812f --- /dev/null +++ b/Sources/ToolkitFeatures/IPA/IPAInspector.swift @@ -0,0 +1,130 @@ +import Foundation +import ToolkitCore + +public struct IPAInspection: Sendable, Hashable, Codable { + public var packagePath: String + public var packageSHA256: String + public var appName: String + public var bundleIdentifier: String + public var version: String + public var build: String + public var minimumOSVersion: String? + public var executableName: String + public var supportedPlatforms: [String] + public var provisioning: ProvisioningProfileSummary + public var signature: CodeSignatureSummary + + /// Whether the toolkit will offer installation. iOS still makes the final decision. + public var isInstallable: Bool { + signature.status == .valid && provisioning.status != .invalid + } + + public var installabilityExplanation: String { + switch signature.status { + case .missing: + return "The app is not signed, so iOS will refuse to install it. Sign it in Xcode with a profile that includes the device." + case .invalid: + return "The app's signature is broken (files were changed after signing), so iOS will refuse to install it." + case .valid: + if provisioning.status == .invalid { return "The embedded provisioning profile is damaged." } + if provisioning.isExpired { return "The signature is valid, but the provisioning profile has expired; iOS will likely refuse to launch the app." } + return "The signature is valid. iOS will still check that the provisioning profile covers the device." + } + } + + /// A readable report (also used by the command-line tool). + public var report: String { + let dateFormatter = ISO8601DateFormatter() + func text(_ value: String?) -> String { value ?? "not declared" } + return [ + "App: \(appName)", + "Bundle identifier: \(bundleIdentifier)", + "Version: \(version) (\(build))", + "Minimum iOS: \(text(minimumOSVersion))", + "Executable: \(executableName)", + "Package SHA-256: \(packageSHA256)", + "", + "Code signature: \(signature.status.rawValue)", + "Signing identifier: \(text(signature.identifier))", + "Signing team: \(text(signature.teamIdentifier))", + "Authorities: \(signature.authorities.isEmpty ? "not declared" : signature.authorities.joined(separator: " → "))", + "Verification detail: \(signature.detail)", + "", + "Provisioning profile: \(provisioning.status.rawValue)", + "Profile name: \(text(provisioning.name))", + "Profile type: \(provisioning.profileKind)", + "Application identifier: \(text(provisioning.applicationIdentifier))", + "Teams: \(provisioning.teamIdentifiers.isEmpty ? "not declared" : provisioning.teamIdentifiers.joined(separator: ", "))", + "Expires: \(provisioning.expirationDate.map(dateFormatter.string(from:)) ?? "not declared")", + "Provisioned devices: \(provisioning.provisionedDevices.count)", + "All devices: \(provisioning.provisionsAllDevices)", + "Debugging allowed (get-task-allow): \(provisioning.getTaskAllow.map(String.init) ?? "not declared")", + "Developer certificates: \(provisioning.allowedSignerCount)", + "", + "Assessment: \(installabilityExplanation)", + ].joined(separator: "\n") + } +} + +/// Inspects an `.ipa` package locally before any installation is offered. +public enum IPAInspector { + public static let maximumInfoPlistBytes: UInt64 = 10 * 1024 * 1024 + + public static func inspect(_ url: URL) throws -> IPAInspection { + guard url.pathExtension.lowercased() == "ipa" else { + throw ToolkitError.invalidInput("Choose an .ipa package.") + } + guard FileManager.default.isReadableFile(atPath: url.path) else { + throw ToolkitError.fileSystem("The package could not be read.", path: url.path) + } + let archive = try ZipArchive(url: url) + let infoCandidates = archive.entries.filter { + let parts = $0.name.split(separator: "/") + return parts.count == 3 && parts[0] == "Payload" && parts[1].hasSuffix(".app") && parts[2] == "Info.plist" + } + guard infoCandidates.count == 1, let infoEntry = infoCandidates.first else { + throw ToolkitError.invalidInput("The package must contain exactly one Payload/.app/Info.plist (found \(infoCandidates.count)).") + } + let info = try PlistValue.decode(try archive.data(for: infoEntry, limit: maximumInfoPlistBytes)) + guard info.dictionaryValue != nil else { throw ToolkitError.invalidInput("The app's Info.plist is not a dictionary.") } + func required(_ key: String) throws -> String { + guard let value = info[key]?.stringValue, !value.trimmingCharacters(in: .whitespaces).isEmpty else { + throw ToolkitError.invalidInput("The app's Info.plist is missing \(key).") + } + return value + } + let appRoot = String(infoEntry.name.dropLast("/Info.plist".count)) + let bundleIdentifier = try required("CFBundleIdentifier") + + let provisioning: ProvisioningProfileSummary + if let profileEntry = archive.entry(named: "\(appRoot)/embedded.mobileprovision") { + provisioning = ProvisioningProfileDecoder.decode(try archive.data(for: profileEntry, limit: UInt64(ProvisioningProfileDecoder.maximumProfileBytes))) + } else { + provisioning = .absent + } + + let signature: CodeSignatureSummary + if archive.entry(named: "\(appRoot)/_CodeSignature/CodeResources") == nil { + signature = CodeSignatureSummary(status: .missing, identifier: nil, teamIdentifier: nil, authorities: [], detail: "_CodeSignature/CodeResources is absent.") + } else { + let scratch = try SecureFileIO.makeTemporaryDirectory(prefix: "idt-ipa") + defer { try? FileManager.default.removeItem(at: scratch) } + try archive.extract(prefix: appRoot + "/", to: scratch) + signature = CodeSignatureInspector.inspect(bundle: try SecureFileIO.safeChild(of: scratch, relativePath: appRoot)) + } + + return IPAInspection( + packagePath: url.path, + packageSHA256: try SecureFileIO.sha256(of: url), + appName: try info["CFBundleDisplayName"]?.stringValue ?? required("CFBundleName"), + bundleIdentifier: bundleIdentifier, + version: try required("CFBundleShortVersionString"), + build: try required("CFBundleVersion"), + minimumOSVersion: info["MinimumOSVersion"]?.stringValue, + executableName: try required("CFBundleExecutable"), + supportedPlatforms: info["CFBundleSupportedPlatforms"]?.arrayValue?.compactMap(\.stringValue) ?? [], + provisioning: provisioning, + signature: signature + ) + } +} diff --git a/Sources/ToolkitFeatures/IPA/SigningInspection.swift b/Sources/ToolkitFeatures/IPA/SigningInspection.swift new file mode 100644 index 0000000..a00966d --- /dev/null +++ b/Sources/ToolkitFeatures/IPA/SigningInspection.swift @@ -0,0 +1,166 @@ +import Foundation +import Security +import ToolkitCore + +/// A decoded provisioning profile (`embedded.mobileprovision` or a profile listed on a device). +public struct ProvisioningProfileSummary: Sendable, Hashable, Codable { + public enum Status: String, Sendable, Codable { + case absent, decoded, invalid + } + + public var status: Status + public var name: String? + public var uuid: String? + public var teamIdentifiers: [String] + public var teamName: String? + public var applicationIdentifier: String? + public var creationDate: Date? + public var expirationDate: Date? + public var provisionedDevices: [String] + public var provisionsAllDevices: Bool + public var getTaskAllow: Bool? + /// How many developer certificates the profile allows to sign (`DeveloperCertificates`). Only a + /// count; the certificates themselves are not kept. + public var allowedSignerCount: Int + public var signatureVerified: Bool? + public var detail: String + + enum CodingKeys: String, CodingKey { + case status, name, uuid, teamIdentifiers, teamName, applicationIdentifier, creationDate, expirationDate + case provisionedDevices, provisionsAllDevices, getTaskAllow, signatureVerified, detail + /// The JSON key is unchanged so `idt inspect-ipa --json` output stays compatible. + case allowedSignerCount = "developerCertificateCount" + } + + public static let absent = ProvisioningProfileSummary(status: .absent, name: nil, uuid: nil, teamIdentifiers: [], teamName: nil, applicationIdentifier: nil, creationDate: nil, expirationDate: nil, provisionedDevices: [], provisionsAllDevices: false, getTaskAllow: nil, allowedSignerCount: 0, signatureVerified: nil, detail: "The package has no embedded provisioning profile.") + + public var isExpired: Bool { + guard let expirationDate else { return false } + return expirationDate < Date() + } + + public var profileKind: String { + if provisionsAllDevices { return "Enterprise (all devices)" } + if !provisionedDevices.isEmpty { return getTaskAllow == true ? "Development" : "Ad Hoc" } + return status == .decoded ? "App Store / distribution" : "—" + } + + /// Whether a device UDID is listed (ignoring hyphen differences). + public func includes(udid: String) -> Bool { + if provisionsAllDevices { return true } + let normalized = udid.replacingOccurrences(of: "-", with: "").uppercased() + return provisionedDevices.contains { $0.replacingOccurrences(of: "-", with: "").uppercased() == normalized } + } +} + +public enum ProvisioningProfileDecoder { + public static let maximumProfileBytes = 20 * 1024 * 1024 + + /// Decodes a CMS-signed profile without shelling out to `security cms`. + public static func decode(_ data: Data) -> ProvisioningProfileSummary { + guard !data.isEmpty, data.count <= maximumProfileBytes else { + return invalid("The profile is empty or larger than 20 MB.") + } + var decoderReference: CMSDecoder? + guard CMSDecoderCreate(&decoderReference) == errSecSuccess, let decoder = decoderReference else { + return invalid("The profile decoder could not start.") + } + let updateStatus = data.withUnsafeBytes { raw -> OSStatus in + guard let base = raw.baseAddress else { return errSecParam } + return CMSDecoderUpdateMessage(decoder, base, data.count) + } + guard updateStatus == errSecSuccess, CMSDecoderFinalizeMessage(decoder) == errSecSuccess else { + return invalid("The profile is not a valid signed (CMS) document.") + } + var contentReference: CFData? + guard CMSDecoderCopyContent(decoder, &contentReference) == errSecSuccess, let content = contentReference as Data? else { + return invalid("The profile has no content.") + } + var signerCount = 0 + CMSDecoderGetNumSigners(decoder, &signerCount) + var verified: Bool? + if signerCount > 0 { + var signerStatus = CMSSignerStatus.unsigned + var certificateStatus: OSStatus = 0 + let policy = SecPolicyCreateBasicX509() + if CMSDecoderCopySignerStatus(decoder, 0, policy, true, &signerStatus, nil, &certificateStatus) == errSecSuccess { + verified = signerStatus == .valid + } + } + return summarize(plist: content, signatureVerified: verified) + } + + static func summarize(plist data: Data, signatureVerified: Bool?) -> ProvisioningProfileSummary { + guard let plist = try? PlistValue.decode(data), plist.dictionaryValue != nil else { + return invalid("The profile content is not a property list.") + } + let entitlements = plist["Entitlements"] ?? .dictionary([:]) + return ProvisioningProfileSummary( + status: .decoded, + name: plist["Name"]?.stringValue, + uuid: plist["UUID"]?.stringValue, + teamIdentifiers: plist["TeamIdentifier"]?.arrayValue?.compactMap(\.stringValue) ?? [], + teamName: plist["TeamName"]?.stringValue, + applicationIdentifier: entitlements["application-identifier"]?.stringValue ?? entitlements["com.apple.application-identifier"]?.stringValue, + creationDate: plist["CreationDate"]?.dateValue, + expirationDate: plist["ExpirationDate"]?.dateValue, + provisionedDevices: plist["ProvisionedDevices"]?.arrayValue?.compactMap(\.stringValue) ?? [], + provisionsAllDevices: plist["ProvisionsAllDevices"]?.boolValue ?? false, + getTaskAllow: entitlements["get-task-allow"]?.boolValue, + allowedSignerCount: plist["DeveloperCertificates"]?.arrayValue?.count ?? 0, + signatureVerified: signatureVerified, + detail: "Decoded with Security.framework (CMS)." + ) + } + + static func invalid(_ detail: String) -> ProvisioningProfileSummary { + var summary = ProvisioningProfileSummary.absent + summary.status = .invalid + summary.detail = detail + return summary + } +} + +/// The result of verifying an app bundle's code signature. +public struct CodeSignatureSummary: Sendable, Hashable, Codable { + public enum Status: String, Sendable, Codable { + case valid, invalid, missing + } + + public var status: Status + public var identifier: String? + public var teamIdentifier: String? + public var authorities: [String] + public var detail: String +} + +public enum CodeSignatureInspector { + /// Verifies with `SecStaticCode` (the API `codesign --verify --deep --strict` uses). + public static func inspect(bundle url: URL) -> CodeSignatureSummary { + var codeReference: SecStaticCode? + guard SecStaticCodeCreateWithPath(url as CFURL, [], &codeReference) == errSecSuccess, let code = codeReference else { + return CodeSignatureSummary(status: .missing, identifier: nil, teamIdentifier: nil, authorities: [], detail: "The app bundle could not be opened for signature checking.") + } + var errors: Unmanaged? + let flags = SecCSFlags(rawValue: kSecCSCheckAllArchitectures | kSecCSStrictValidate | kSecCSCheckNestedCode) + let status = SecStaticCodeCheckValidityWithErrors(code, flags, nil, &errors) + + var informationReference: CFDictionary? + SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &informationReference) + let information = (informationReference as? [String: Any]) ?? [:] + let identifier = information[kSecCodeInfoIdentifier as String] as? String + let team = information[kSecCodeInfoTeamIdentifier as String] as? String + let certificates = (information[kSecCodeInfoCertificates as String] as? [SecCertificate]) ?? [] + let authorities = certificates.compactMap { SecCertificateCopySubjectSummary($0) as String? } + + switch status { + case errSecSuccess: + return CodeSignatureSummary(status: .valid, identifier: identifier, teamIdentifier: team, authorities: authorities, detail: "The signature is intact and every nested component is signed.") + case errSecCSUnsigned: + return CodeSignatureSummary(status: .missing, identifier: nil, teamIdentifier: nil, authorities: [], detail: "The app is not signed.") + default: + let message = errors.map { CFErrorCopyDescription($0.takeRetainedValue()) as String } ?? "OSStatus \(status)" + return CodeSignatureSummary(status: .invalid, identifier: identifier, teamIdentifier: team, authorities: authorities, detail: message) + } + } +} diff --git a/Sources/ToolkitFeatures/IPA/ZipArchive.swift b/Sources/ToolkitFeatures/IPA/ZipArchive.swift new file mode 100644 index 0000000..47bbfc0 --- /dev/null +++ b/Sources/ToolkitFeatures/IPA/ZipArchive.swift @@ -0,0 +1,299 @@ +import Compression +import Foundation +import ToolkitCore + +/// A read-only ZIP reader for inspecting IPA packages. +/// +/// The archive is treated as untrusted: member names are validated before anything is written, +/// symbolic links and encrypted members are refused, declared sizes are enforced while +/// inflating (so a "zip bomb" cannot exceed them), and total expansion is capped. +public final class ZipArchive: @unchecked Sendable { + public struct Entry: Sendable, Hashable { + public let name: String + public let compressionMethod: UInt16 + public let flags: UInt16 + public let compressedSize: UInt64 + public let uncompressedSize: UInt64 + public let localHeaderOffset: UInt64 + public let externalAttributes: UInt32 + public let versionMadeBy: UInt16 + + public var isDirectory: Bool { name.hasSuffix("/") } + public var isEncrypted: Bool { flags & 0x1 != 0 } + public var unixMode: UInt16 { UInt16(truncatingIfNeeded: externalAttributes >> 16) } + public var isSymbolicLink: Bool { (versionMadeBy >> 8) == 3 && (unixMode & 0o170000) == 0o120000 } + } + + public static let maximumTotalUncompressedBytes: UInt64 = 8 * 1024 * 1024 * 1024 + public static let maximumEntries = 500_000 + + public let url: URL + public let entries: [Entry] + private let handle: FileHandle + private let fileSize: UInt64 + + public init(url: URL) throws { + guard let handle = try? FileHandle(forReadingFrom: url) else { + throw ToolkitError.fileSystem("The package could not be opened.", path: url.path) + } + self.url = url + self.handle = handle + fileSize = (try? handle.seekToEnd()) ?? 0 + entries = try ZipArchive.readCentralDirectory(handle: handle, fileSize: fileSize) + try ZipArchive.validate(entries) + } + + deinit { + try? handle.close() + } + + // MARK: Validation + + public static func validate(_ entries: [Entry]) throws { + var seen = Set() + var total: UInt64 = 0 + for entry in entries { + guard seen.insert(entry.name).inserted else { + throw ToolkitError.invalidInput("The package contains a duplicate entry: \(entry.name)") + } + try validateName(entry.name) + if entry.isSymbolicLink { + throw ToolkitError.invalidInput("The package contains a symbolic link, which cannot be inspected safely: \(entry.name)") + } + if entry.isEncrypted { + throw ToolkitError.invalidInput("The package contains an encrypted entry: \(entry.name)") + } + guard entry.compressionMethod == 0 || entry.compressionMethod == 8 else { + throw ToolkitError.invalidInput("The package uses an unsupported compression method (\(entry.compressionMethod)).") + } + total += entry.uncompressedSize + if total > maximumTotalUncompressedBytes { + throw ToolkitError.invalidInput("The package expands beyond the 8 GB inspection limit.") + } + } + } + + static func validateName(_ name: String) throws { + let unsafe = name.isEmpty || name.hasPrefix("/") || name.contains("\\") || name.contains("\0") + || name.split(separator: "/", omittingEmptySubsequences: false).contains("..") + if unsafe { + throw ToolkitError.invalidInput("The package contains an unsafe path: \(name)") + } + } + + public func entry(named name: String) -> Entry? { + entries.first { $0.name == name } + } + + // MARK: Reading + + /// Reads a (small) entry fully into memory, enforcing `limit`. + public func data(for entry: Entry, limit: UInt64) throws -> Data { + guard entry.uncompressedSize <= limit else { + throw ToolkitError.invalidInput("\(entry.name) is larger than the \(limit / 1_048_576) MB inspection limit.") + } + var output = Data() + try stream(entry) { output.append($0) } + return output + } + + /// Extracts entries under `prefix` into `destination` (which must be a private folder). + /// File permissions are limited to 0755/0644 so no set-id bits survive extraction. + public func extract(prefix: String, to destination: URL) throws { + for entry in entries where entry.name == prefix || entry.name.hasPrefix(prefix) { + let target = try SecureFileIO.safeChild(of: destination, relativePath: entry.name) + if entry.isDirectory { + try SecureFileIO.createPrivateDirectory(at: target) + continue + } + try SecureFileIO.createPrivateDirectory(at: target.deletingLastPathComponent()) + let executable = entry.unixMode & 0o111 != 0 + try SecureFileIO.writeNewFile(Data(), to: target, mode: executable ? 0o755 : 0o644) + let output = try FileHandle(forWritingTo: target) + defer { try? output.close() } + try stream(entry) { try output.write(contentsOf: $0) } + } + } + + private func stream(_ entry: Entry, _ sink: (Data) throws -> Void) throws { + let dataOffset = try localDataOffset(for: entry) + try handle.seek(toOffset: dataOffset) + var remaining = entry.compressedSize + var produced: UInt64 = 0 + + func emit(_ chunk: Data) throws { + produced += UInt64(chunk.count) + guard produced <= entry.uncompressedSize else { + throw ToolkitError.invalidInput("\(entry.name) expands beyond its declared size; the package may be malicious.") + } + try sink(chunk) + } + + if entry.compressionMethod == 0 { + while remaining > 0 { + let count = Int(min(remaining, 1 << 20)) + guard let chunk = try handle.read(upToCount: count), !chunk.isEmpty else { break } + remaining -= UInt64(chunk.count) + try emit(chunk) + } + } else { + try inflate(compressedSize: entry.compressedSize, emit: emit) + } + guard produced == entry.uncompressedSize else { + throw ToolkitError.invalidInput("\(entry.name) is truncated or corrupt.") + } + } + + private func inflate(compressedSize: UInt64, emit: (Data) throws -> Void) throws { + let bufferSize = 1 << 16 + let destination = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { destination.deallocate() } + let streamPointer = UnsafeMutablePointer.allocate(capacity: 1) + defer { streamPointer.deallocate() } + guard compression_stream_init(streamPointer, COMPRESSION_STREAM_DECODE, COMPRESSION_ZLIB) == COMPRESSION_STATUS_OK else { + throw ToolkitError(.internalInconsistency, message: "Decompression could not start.") + } + defer { compression_stream_destroy(streamPointer) } + + var remaining = compressedSize + var input = Data() + var finished = false + while !finished { + if input.isEmpty && remaining > 0 { + let count = Int(min(remaining, 1 << 20)) + input = try handle.read(upToCount: count) ?? Data() + remaining -= UInt64(input.count) + if input.isEmpty { remaining = 0 } + } + let isLast = remaining == 0 + let status: compression_status = input.withUnsafeBytes { raw in + let base = raw.bindMemory(to: UInt8.self).baseAddress + streamPointer.pointee.src_ptr = base ?? UnsafePointer(destination) + streamPointer.pointee.src_size = input.count + streamPointer.pointee.dst_ptr = destination + streamPointer.pointee.dst_size = bufferSize + return compression_stream_process(streamPointer, isLast ? Int32(COMPRESSION_STREAM_FINALIZE.rawValue) : 0) + } + let consumed = input.count - streamPointer.pointee.src_size + input.removeFirst(consumed) + let producedCount = bufferSize - streamPointer.pointee.dst_size + if producedCount > 0 { try emit(Data(bytes: destination, count: producedCount)) } + switch status { + case COMPRESSION_STATUS_END: + finished = true + case COMPRESSION_STATUS_OK: + if isLast && input.isEmpty && producedCount == 0 { + throw ToolkitError.invalidInput("A compressed entry is truncated.") + } + default: + throw ToolkitError.invalidInput("A compressed entry is corrupt.") + } + } + } + + private func localDataOffset(for entry: Entry) throws -> UInt64 { + try handle.seek(toOffset: entry.localHeaderOffset) + guard let header = try handle.read(upToCount: 30), header.count == 30, header.readUInt32LE(0) == 0x0403_4B50 else { + throw ToolkitError.invalidInput("The package has a damaged entry header: \(entry.name)") + } + let nameLength = UInt64(header.readUInt16LE(26)) + let extraLength = UInt64(header.readUInt16LE(28)) + let offset = entry.localHeaderOffset + 30 + nameLength + extraLength + guard offset + entry.compressedSize <= fileSize else { + throw ToolkitError.invalidInput("The package is truncated.") + } + return offset + } + + // MARK: Central directory + + static func readCentralDirectory(handle: FileHandle, fileSize: UInt64) throws -> [Entry] { + guard fileSize >= 22 else { throw ToolkitError.invalidInput("The file is not a valid package (too small).") } + let tailLength = min(fileSize, 65_557) + try handle.seek(toOffset: fileSize - tailLength) + let tail = try handle.read(upToCount: Int(tailLength)) ?? Data() + guard let eocd = tail.lastRange(of: Data([0x50, 0x4B, 0x05, 0x06]))?.lowerBound else { + throw ToolkitError.invalidInput("The file is not a valid ZIP-based package.") + } + let eocdRelative = eocd - tail.startIndex + var entryCount = UInt64(tail.readUInt16LE(eocdRelative + 10)) + var directorySize = UInt64(tail.readUInt32LE(eocdRelative + 12)) + var directoryOffset = UInt64(tail.readUInt32LE(eocdRelative + 16)) + + if entryCount == 0xFFFF || directorySize == 0xFFFF_FFFF || directoryOffset == 0xFFFF_FFFF { + // ZIP64: locate the ZIP64 end-of-central-directory record. + guard eocdRelative >= 20, tail.readUInt32LE(eocdRelative - 20) == 0x0706_4B50 else { + throw ToolkitError.invalidInput("The package's ZIP64 directory is missing.") + } + let zip64Offset = tail.readUInt64LE(eocdRelative - 12) + try handle.seek(toOffset: zip64Offset) + guard let record = try handle.read(upToCount: 56), record.count == 56, record.readUInt32LE(0) == 0x0606_4B50 else { + throw ToolkitError.invalidInput("The package's ZIP64 directory is damaged.") + } + entryCount = record.readUInt64LE(32) + directorySize = record.readUInt64LE(40) + directoryOffset = record.readUInt64LE(48) + } + guard entryCount <= UInt64(maximumEntries), directoryOffset + directorySize <= fileSize, directorySize <= 512 * 1024 * 1024 else { + throw ToolkitError.invalidInput("The package directory is invalid or too large.") + } + try handle.seek(toOffset: directoryOffset) + let directory = try handle.read(upToCount: Int(directorySize)) ?? Data() + var entries: [Entry] = [] + var cursor = 0 + for _ in 0.. UInt16 { + let base = startIndex + offset + return UInt16(self[base]) | UInt16(self[base + 1]) << 8 + } + + func readUInt32LE(_ offset: Int) -> UInt32 { + let base = startIndex + offset + return UInt32(self[base]) | UInt32(self[base + 1]) << 8 | UInt32(self[base + 2]) << 16 | UInt32(self[base + 3]) << 24 + } + + func readUInt64LE(_ offset: Int) -> UInt64 { + UInt64(readUInt32LE(offset)) | UInt64(readUInt32LE(offset + 4)) << 32 + } +} diff --git a/Sources/ToolkitFeatures/LiveLogs/LiveLogSources.swift b/Sources/ToolkitFeatures/LiveLogs/LiveLogSources.swift new file mode 100644 index 0000000..195bde5 --- /dev/null +++ b/Sources/ToolkitFeatures/LiveLogs/LiveLogSources.swift @@ -0,0 +1,140 @@ +import DeviceKit +import Foundation +import ToolkitCore + +/// The log streams the toolkit can open. +public enum LogStreamKind: String, CaseIterable, Sendable, Codable, Identifiable { + case unified + case classic + case simulator + + public var id: String { rawValue } + + public var title: String { + switch self { + case .unified: return "Unified Logs" + case .classic: return "Classic Syslog" + case .simulator: return "Simulator Logs" + } + } + + public var summary: String { + switch self { + case .unified: return "Structured Unified Logging with process, level, subsystem, and category. Works on any trusted device; no developer image needed." + case .classic: return "The older plain-text syslog relay. Useful for tools that expect traditional syslog lines." + case .simulator: return "The simulator's Unified Log, streamed with `log stream`." + } + } + + public var isStructured: Bool { self != .classic } + public var spoolExtension: String { isStructured ? "jsonl" : "log" } + + public var serviceDescription: String { + switch self { + case .unified: return "com.apple.os_trace_relay (lockdown)" + case .classic: return "com.apple.syslog_relay (lockdown)" + case .simulator: return "xcrun simctl spawn log stream --style ndjson" + } + } + + public static func available(for kind: DeviceKind) -> [LogStreamKind] { + switch kind { + case .physical: return [.unified, .classic] + case .simulator: return [.simulator] + case .demo: return [] + } + } +} + +/// Opens a live log stream for a target. +public enum LiveLogSource { + public static func open(_ kind: LogStreamKind, target: DeviceTarget, runner: CommandRunning = ProcessCommandRunner(), usbmux: USBMuxClient = USBMuxClient()) async throws -> AsyncThrowingStream { + switch kind { + case .unified, .classic: + guard target.kind == .physical else { throw ToolkitError(.unsupported, message: "\(kind.title) are only available for physical devices.") } + let session = try await DeviceSession.open(target: target, usbmux: usbmux) + let upstream: AsyncThrowingStream + do { + upstream = kind == .unified ? try await OSTraceRelay.stream(session) : try await SyslogRelay.stream(session) + } catch { + await session.close() + throw error + } + return relay(upstream) { await session.close() } + case .simulator: + let request = try SimulatorClient(runner: runner).logStreamRequest(target) + let events = runner.stream(request) + return AsyncThrowingStream { continuation in + let task = Task { + var splitter = LineSplitter() + do { + for try await event in events { + switch event { + case .standardOutput(let data): + let lines = splitter.consume(data).compactMap(SimulatorLogParser.parse(line:)) + continuation.yield(LogChunk(spoolBytes: data, lines: lines)) + case .standardError(let data): + let text = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { continuation.yield(LogChunk(spoolBytes: Data(), lines: [LogLine(level: "stderr", message: text)])) } + case .finished(let result): + if !result.succeeded, !Task.isCancelled { + throw ToolkitError(.commandFailed, message: "The simulator log stream stopped.", recovery: "Make sure the simulator is running, then start the stream again.", technicalDetail: result.technicalSummary) + } + } + } + let remainder = splitter.flush().compactMap(SimulatorLogParser.parse(line:)) + if !remainder.isEmpty { continuation.yield(LogChunk(spoolBytes: Data(), lines: remainder)) } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + } + + /// Forwards chunks and runs `cleanup` when the consumer stops or the stream ends. + static func relay(_ upstream: AsyncThrowingStream, cleanup: @escaping @Sendable () async -> Void) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + for try await chunk in upstream { continuation.yield(chunk) } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + await cleanup() + } + continuation.onTermination = { _ in task.cancel() } + } + } +} + +/// Splits a byte stream into UTF-8 lines, keeping partial lines between chunks. +public struct LineSplitter: Sendable { + private var pending = Data() + public var maximumLineLength = 1 << 20 + + public init() {} + + public mutating func consume(_ data: Data) -> [Substring] { + pending.append(data) + var lines: [Substring] = [] + while let newline = pending.firstIndex(of: 0x0A) { + let line = String(decoding: pending[pending.startIndex.. maximumLineLength { + lines.append(Substring(String(decoding: pending, as: UTF8.self))) + pending.removeAll() + } + return lines + } + + public mutating func flush() -> [Substring] { + defer { pending.removeAll() } + return pending.isEmpty ? [] : [Substring(String(decoding: pending, as: UTF8.self))] + } +} diff --git a/Sources/ToolkitFeatures/LiveLogs/LogCapture.swift b/Sources/ToolkitFeatures/LiveLogs/LogCapture.swift new file mode 100644 index 0000000..bd04381 --- /dev/null +++ b/Sources/ToolkitFeatures/LiveLogs/LogCapture.swift @@ -0,0 +1,380 @@ +import DeviceKit +import Foundation +import ToolkitCore + +/// A text filter for the working view: literal or regular expression, optionally case-sensitive. +public struct LogFilter: Sendable, Hashable, Codable { + public var text: String + public var isRegularExpression: Bool + public var isCaseSensitive: Bool + + public init(text: String = "", isRegularExpression: Bool = false, isCaseSensitive: Bool = false) { + self.text = text + self.isRegularExpression = isRegularExpression + self.isCaseSensitive = isCaseSensitive + } + + public var isEmpty: Bool { text.isEmpty } + + /// Returns a matcher, or throws an actionable error for an invalid expression. + public func matcher() throws -> @Sendable (String) -> Bool { + guard !text.isEmpty else { return { _ in true } } + if isRegularExpression { + let expression: NSRegularExpression + do { + expression = try NSRegularExpression(pattern: text, options: isCaseSensitive ? [] : [.caseInsensitive]) + } catch { + throw ToolkitError.invalidInput("The regular expression is not valid.") + } + let box = UncheckedSendable(expression) + return { line in box.value.firstMatch(in: line, range: NSRange(line.startIndex..: @unchecked Sendable { + let value: Value + init(_ value: Value) { self.value = value } +} + +public enum FindingAssessment: String, Codable, Sendable, CaseIterable, Identifiable { + case observation + case lead + case needsCorroboration = "needs-corroboration" + + public var id: String { rawValue } + + public var label: String { + switch self { + case .observation: return "Observation" + case .lead: return "Lead to correlate" + case .needsCorroboration: return "Needs corroboration" + } + } +} + +/// An analyst annotation on selected log text. It is never mixed into the raw capture. +public struct LiveLogFinding: Codable, Sendable, Hashable, Identifiable { + public var id: String { createdAt.timeIntervalSince1970.description + note } + public var createdAt: Date + public var note: String + public var selectedText: String + public var stream: String + public var deviceIdentifier: String + public var rawBytesObserved: Int64 + public var filterExpression: String + public var filterIsRegex: Bool + public var filterCaseSensitive: Bool + public var assessment: FindingAssessment + public var tags: [String] + + enum CodingKeys: String, CodingKey { + case note, stream, assessment, tags + case createdAt = "created_at" + case selectedText = "selected_text" + case deviceIdentifier = "device_identifier" + case rawBytesObserved = "raw_bytes_observed" + case filterExpression = "filter_expression" + case filterIsRegex = "filter_is_regex" + case filterCaseSensitive = "filter_case_sensitive" + } + + public static let maximumSelectedTextLength = 20_000 + public static let maximumTags = 12 + public static let maximumTagLength = 48 + + public static func parseTags(_ text: String) throws -> [String] { + var tags: [String] = [] + for raw in text.split(separator: ",") { + let tag = raw.trimmingCharacters(in: .whitespaces).lowercased() + if tag.isEmpty { continue } + guard tag.count <= maximumTagLength else { throw ToolkitError.invalidInput("Tags must be \(maximumTagLength) characters or fewer.") } + guard tag.range(of: #"^[a-z0-9][a-z0-9_-]*$"#, options: .regularExpression) != nil else { + throw ToolkitError.invalidInput("Tags may use lowercase letters, numbers, hyphens, and underscores, and must start with a letter or number.") + } + if !tags.contains(tag) { tags.append(tag) } + } + guard tags.count <= maximumTags else { throw ToolkitError.invalidInput("A finding can have at most \(maximumTags) tags.") } + return tags + } + + public static func make(note: String, selectedText: String, stream: LogStreamKind, target: DeviceTarget, rawBytesObserved: Int64, filter: LogFilter, assessment: FindingAssessment, tags: [String]) throws -> LiveLogFinding { + let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedSelection = selectedText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedNote.isEmpty else { throw ToolkitError.invalidInput("A finding needs an analyst note.") } + guard !trimmedSelection.isEmpty else { throw ToolkitError.invalidInput("Select one or more log lines before marking a finding.") } + guard trimmedSelection.count <= maximumSelectedTextLength else { + throw ToolkitError.invalidInput("The selected text must be \(maximumSelectedTextLength) characters or fewer.") + } + return LiveLogFinding(createdAt: Date(), note: trimmedNote, selectedText: trimmedSelection, stream: stream.rawValue, deviceIdentifier: target.udid, rawBytesObserved: rawBytesObserved, filterExpression: filter.text, filterIsRegex: filter.isRegularExpression, filterCaseSensitive: filter.isCaseSensitive, assessment: assessment, tags: tags) + } +} + +/// Sidecar metadata written next to each raw spool. +public struct LogCaptureMetadata: Codable, Sendable, Hashable { + public var schemaVersion = 2 + public var stream: String + public var streamTitle: String + public var structured: Bool + public var source: String + public var deviceIdentifier: String + public var deviceName: String + public var deviceKind: String + public var startedAt: Date + public var finishedAt: Date? + public var endReason: String? + public var rawBytes: Int64 + public var decodedLines: Int + public var rawPath: String + public var rawSHA256: String? + public var findingsPath: String? + public var findingsCount: Int + public var investigationReference: String + public var interpretationBoundary = "Findings are analyst annotations, not device-generated facts or proof of causality." + + enum CodingKeys: String, CodingKey { + case stream, structured, source + case schemaVersion = "schema_version" + case streamTitle = "stream_title" + case deviceIdentifier = "device_identifier" + case deviceName = "device_name" + case deviceKind = "device_kind" + case startedAt = "started_at" + case finishedAt = "finished_at" + case endReason = "end_reason" + case rawBytes = "raw_bytes" + case decodedLines = "decoded_lines" + case rawPath = "raw_path" + case rawSHA256 = "raw_sha256" + case findingsPath = "findings_path" + case findingsCount = "findings_count" + case investigationReference = "investigation_reference" + case interpretationBoundary = "interpretation_boundary" + } +} + +/// Spools every byte of a live stream to disk (independently of what the view shows), keeps an +/// incremental SHA-256, and manages findings and exports. +public actor LogCapture { + public nonisolated let kind: LogStreamKind + public nonisolated let target: DeviceTarget + public nonisolated let spoolURL: URL + public nonisolated let metadataURL: URL + public nonisolated let findingsURL: URL + private let output: FileHandle + private var hasher = StreamingHasher() + private var metadata: LogCaptureMetadata + private var findings: [LiveLogFinding] = [] + private var closed = false + + public static func defaultDirectory(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL { + home.appendingPathComponent("Library/Caches/iOS Developer Toolkit/Live Logs") + } + + public init(kind: LogStreamKind, target: DeviceTarget, directory: URL = LogCapture.defaultDirectory(), now: Date = Date()) throws { + try SecureFileIO.createPrivateDirectory(at: directory) + let fragment = String(target.udid.filter { $0.isLetter || $0.isNumber || $0 == "-" }.prefix(40)) + let base = "\(ISO8601.compactUTC(now))-\(fragment.isEmpty ? "device" : fragment)-\(kind.rawValue)-\(UUID().uuidString.prefix(6))" + spoolURL = directory.appendingPathComponent("\(base).\(kind.spoolExtension)") + metadataURL = directory.appendingPathComponent("\(base).meta.json") + findingsURL = directory.appendingPathComponent("\(base).\(kind.spoolExtension).findings.jsonl") + try SecureFileIO.writeNewFile(Data(), to: spoolURL) + guard let handle = try? FileHandle(forWritingTo: spoolURL) else { + throw ToolkitError.fileSystem("The log spool could not be opened.", path: spoolURL.path) + } + output = handle + self.kind = kind + self.target = target + let initial = LogCaptureMetadata(stream: kind.rawValue, streamTitle: kind.title, structured: kind.isStructured, source: kind.serviceDescription, deviceIdentifier: target.udid, deviceName: target.name, deviceKind: target.kind.rawValue, startedAt: now, rawBytes: 0, decodedLines: 0, rawPath: spoolURL.path, findingsCount: 0, investigationReference: "") + metadata = initial + try SecureFileIO.writeAtomically(try JSONOutput.encode(initial), to: metadataURL) + } + + public var currentMetadata: LogCaptureMetadata { metadata } + public var allFindings: [LiveLogFinding] { findings } + public var isClosed: Bool { closed } + + /// The findings register as it stands now: capture facts and every finding, as Markdown. + public var findingsRegister: String { + Self.renderReport(metadata: metadata, rawFilename: spoolURL.lastPathComponent, rawSHA256: metadata.rawSHA256, findings: findings) + } + + public func append(_ chunk: LogChunk) throws { + guard !closed else { return } + if !chunk.spoolBytes.isEmpty { + try output.write(contentsOf: chunk.spoolBytes) + hasher.update(chunk.spoolBytes) + metadata.rawBytes += Int64(chunk.spoolBytes.count) + } + metadata.decodedLines += chunk.lines.count + } + + public func setInvestigationReference(_ reference: String) throws { + metadata.investigationReference = String(reference.trimmingCharacters(in: .whitespacesAndNewlines).prefix(200)) + try writeMetadata() + } + + /// Stops spooling, records why, and finalizes the hash. Safe to call more than once. + public func finish(reason: String) throws { + guard !closed else { return } + closed = true + try? output.synchronize() + try? output.close() + metadata.finishedAt = Date() + metadata.endReason = reason + metadata.rawSHA256 = hasher.finalizeHex() + try writeMetadata() + } + + public func addFinding(_ finding: LiveLogFinding) throws { + var line = try JSONOutput.encode(finding) + if line.last == 0x0A { line.removeLast() } + line = Data(String(decoding: line, as: UTF8.self).replacingOccurrences(of: "\n", with: "").utf8) + Data([0x0A]) + try SecureFileIO.append(line, to: findingsURL, synchronize: true) + findings.append(finding) + metadata.findingsPath = findingsURL.path + metadata.findingsCount = findings.count + try writeMetadata() + } + + private func writeMetadata() throws { + try SecureFileIO.writeAtomically(try JSONOutput.encode(metadata), to: metadataURL) + } + + // MARK: Exports + + /// Saves the complete raw capture plus metadata and findings (never overwriting). + @discardableResult + public func exportRaw(to destination: URL) throws -> String { + try? output.synchronize() + let data = try Data(contentsOf: spoolURL) + try SecureFileIO.writeNewFile(data, to: destination) + var saved = metadata + saved.rawPath = destination.path + saved.rawSHA256 = SecureFileIO.sha256(of: data) + if FileManager.default.fileExists(atPath: findingsURL.path) { + let findingsDestination = destination.appendingPathExtension("findings.jsonl") + try SecureFileIO.writeNewFile(try Data(contentsOf: findingsURL), to: findingsDestination) + saved.findingsPath = findingsDestination.path + } else { + saved.findingsPath = nil + } + try SecureFileIO.writeNewFile(try JSONOutput.encode(saved), to: destination.appendingPathExtension("meta.json")) + return saved.rawSHA256 ?? "" + } + + /// Saves only lines that match `filter`, rendered as text. + public func exportFiltered(to destination: URL, filter: LogFilter) throws -> Int { + try? output.synchronize() + let matches = try filter.matcher() + let data = try Data(contentsOf: spoolURL) + var lines: [String] = [] + switch kind { + case .classic: + var parser = SyslogRecordParser() + lines = (parser.consume(data) + parser.flush()).map(\.message) + case .unified, .simulator: + var splitter = LineSplitter() + let raw = splitter.consume(data) + splitter.flush() + lines = raw.compactMap { line -> String? in + if kind == .simulator { return SimulatorLogParser.parse(line: line)?.rendered } + guard let json = try? JSONValue.parse(Data(line.utf8)) else { return String(line) } + return LogLine(timestamp: json["timestamp"]?.string.flatMap(ISO8601.parse), process: json["process"]?.string, pid: json["pid"]?.int, level: json["level"]?.string, subsystem: json["subsystem"]?.string, category: json["category"]?.string, message: json["message"]?.string ?? "").rendered + } + } + let kept = lines.filter(matches) + try SecureFileIO.writeNewFile(Data((kept.joined(separator: "\n") + (kept.isEmpty ? "" : "\n")).utf8), to: destination) + return kept.count + } + + /// Writes raw capture, metadata, findings, a readable report, and SHA256SUMS.txt into a new + /// folder inside `parent`. + public func exportEvidenceBundle(into parent: URL) throws -> URL { + let folder = parent.appendingPathComponent(spoolURL.deletingPathExtension().lastPathComponent + "-investigation") + try SecureFileIO.createNewPrivateDirectory(at: folder) + let rawDestination = folder.appendingPathComponent(spoolURL.lastPathComponent) + let rawHash = try exportRaw(to: rawDestination) + let report = Self.renderReport(metadata: metadata, rawFilename: rawDestination.lastPathComponent, rawSHA256: rawHash, findings: findings) + try SecureFileIO.writeNewFile(Data(report.utf8), to: folder.appendingPathComponent("investigation-report.md")) + try HashManifest.write(for: folder) + return folder + } + + public static func renderReport(metadata: LogCaptureMetadata, rawFilename: String, rawSHA256: String?, findings: [LiveLogFinding]) -> String { + var lines = [ + "# \(metadata.streamTitle) investigation report", + "", + "## Capture facts", + "", + "- Stream: `\(metadata.stream)` (\(metadata.source))", + "- Device: `\(metadata.deviceName)` (`\(metadata.deviceIdentifier)`, \(metadata.deviceKind))", + "- Capture started: `\(ISO8601.string(metadata.startedAt))`", + "- Capture finished: `\(metadata.finishedAt.map(ISO8601.string) ?? "not finalized at export")`", + "- Raw artifact: `\(rawFilename)`", + "- Raw SHA-256: `\(rawSHA256 ?? "not finalized at export")`", + "- Raw bytes observed: `\(metadata.rawBytes)`", + "- Decoded lines observed: `\(metadata.decodedLines)`", + "- Investigation reference: \(metadata.investigationReference.isEmpty ? "not provided" : metadata.investigationReference)", + "", + "## Analyst findings", + "", + "The records below are analyst annotations. They are not device-generated facts, proof of causality, or proof that a selected text fragment represents the complete event.", + "", + ] + guard !findings.isEmpty else { + lines.append("No analyst findings were recorded for this capture.") + return lines.joined(separator: "\n") + "\n" + } + for (index, finding) in findings.enumerated() { + lines += [ + "### Finding \(index + 1): \(finding.assessment.label)", + "", + "- Recorded: `\(ISO8601.string(finding.createdAt))`", + "- Tags: \(finding.tags.isEmpty ? "none" : finding.tags.joined(separator: ", "))", + "- Capture position: `\(finding.rawBytesObserved)` raw bytes observed", + "- View filter: `\(finding.filterExpression.isEmpty ? "none" : finding.filterExpression)`; regex=`\(finding.filterIsRegex)`; case-sensitive=`\(finding.filterCaseSensitive)`", + "- Analyst note: \(finding.note)", + "", + "Selected visible text:", + "```text", + finding.selectedText, + "```", + "", + ] + } + return lines.joined(separator: "\n") + "\n" + } +} + +/// Writes `SHA256SUMS.txt` for every regular file in a folder (recursively). +public enum HashManifest { + public static let fileName = "SHA256SUMS.txt" + + @discardableResult + public static func write(for folder: URL, fileName: String = HashManifest.fileName) throws -> URL { + let manifest = folder.appendingPathComponent(fileName) + let lines = try SecureFileIO.regularFiles(under: folder) + .filter { $0.relativePath != fileName } + .map { "\(try SecureFileIO.sha256(of: $0.url)) \($0.relativePath)" } + try SecureFileIO.writeAtomically(Data((lines.joined(separator: "\n") + "\n").utf8), to: manifest) + return manifest + } + + /// Verifies a manifest; returns the relative paths that do not match. + public static func verify(folder: URL, fileName: String = HashManifest.fileName) throws -> [String] { + let text = try String(contentsOf: folder.appendingPathComponent(fileName), encoding: .utf8) + var mismatches: [String] = [] + for line in text.split(separator: "\n") { + let parts = line.split(separator: " ", maxSplits: 1) + guard parts.count == 2 else { continue } + let path = parts[1].trimmingCharacters(in: .whitespaces) + let url = try SecureFileIO.safeChild(of: folder, relativePath: path) + if (try? SecureFileIO.sha256(of: url)) != String(parts[0]) { mismatches.append(path) } + } + return mismatches + } +} diff --git a/Sources/ToolkitFeatures/Location/GPXPlayback.swift b/Sources/ToolkitFeatures/Location/GPXPlayback.swift new file mode 100644 index 0000000..6d52fd4 --- /dev/null +++ b/Sources/ToolkitFeatures/Location/GPXPlayback.swift @@ -0,0 +1,98 @@ +import DeviceKit +import Foundation +import ToolkitCore + +/// How recorded GPX timing is replayed. +public enum PlaybackTiming: Sendable, Hashable { + /// Follows the file's timestamps, with optional ± jitter in milliseconds. + case recorded(jitterMilliseconds: Int) + /// Ignores timestamps and moves one point per interval. + case fixedInterval(seconds: Double) +} + +public enum PlaybackPlanner { + /// Offsets (seconds from start) for each point. Points without timestamps follow the + /// previous point by one second. Offsets never decrease and are at least 0.5 s apart so the + /// device service is not flooded. + public static func offsets(for points: [GPXPoint], timing: PlaybackTiming, random: @Sendable (ClosedRange) -> Double = { Double.random(in: $0) }) throws -> [TimeInterval] { + guard !points.isEmpty else { return [] } + switch timing { + case .fixedInterval(let seconds): + guard seconds.isFinite, seconds >= 0.5, seconds <= 3600 else { + throw ToolkitError.invalidInput("The playback interval must be between 0.5 seconds and 1 hour.") + } + return points.indices.map { Double($0) * seconds } + case .recorded(let jitter): + guard (0...60_000).contains(jitter) else { + throw ToolkitError.invalidInput("Timing randomness must be between 0 and 60,000 milliseconds.") + } + let start = points.first(where: { $0.time != nil })?.time + var offsets: [TimeInterval] = [] + var previous: TimeInterval = -0.5 + for point in points { + var offset: TimeInterval + if let time = point.time, let start { + offset = time.timeIntervalSince(start) + } else { + offset = previous + 1 + } + if jitter > 0 && !offsets.isEmpty { + offset += random(-Double(jitter)...Double(jitter)) / 1000 + } + offset = max(offset, previous + 0.5) + offsets.append(offset) + previous = offset + } + return offsets + } + } +} + +/// Replays GPX points on one target by sending each point at its scheduled time. +public actor GPXPlayback { + public enum State: Sendable, Equatable { + case idle + case playing(index: Int, total: Int) + case finished + case stopped + case failed(String) + } + + public nonisolated let target: DeviceTarget + private let controller: LocationController + private let points: [GPXPoint] + private let offsets: [TimeInterval] + private(set) public var state: State = .idle + + public init(points: [GPXPoint], timing: PlaybackTiming, target: DeviceTarget, controller: LocationController) throws { + self.points = points + self.offsets = try PlaybackPlanner.offsets(for: points, timing: timing) + self.target = target + self.controller = controller + } + + public var expectedDuration: TimeInterval { offsets.last ?? 0 } + + /// Plays to completion or until the task is cancelled. Progress reports (index, total). + public func run(progress: @escaping @Sendable (Int, Int) -> Void) async throws { + let start = ContinuousClock.now + for (index, point) in points.enumerated() { + let due = start + .milliseconds(Int64(offsets[index] * 1000)) + do { + try await Task.sleep(until: due, clock: .continuous) + } catch { + state = .stopped + throw ToolkitError.cancelled("GPX playback") + } + do { + try await controller.set(latitude: point.coordinates.latitude, longitude: point.coordinates.longitude, on: target) + } catch { + state = .failed((error as? ToolkitError)?.message ?? error.localizedDescription) + throw error + } + state = .playing(index: index + 1, total: points.count) + progress(index + 1, points.count) + } + state = .finished + } +} diff --git a/Sources/ToolkitFeatures/Location/LocationLab.swift b/Sources/ToolkitFeatures/Location/LocationLab.swift new file mode 100644 index 0000000..0c61115 --- /dev/null +++ b/Sources/ToolkitFeatures/Location/LocationLab.swift @@ -0,0 +1,416 @@ +import Foundation +import ToolkitCore + +/// Validation, parsing, and geometry for Location Lab. Nothing here contacts a network +/// service: map links are parsed only when their coordinates are visible in the URL. +public enum LocationLab { + public static let maximumGPXBytes: Int64 = 64 * 1024 * 1024 + public static let maximumRoutePoints = 100_000 + public static let savedLocationsVersion = 1 + + // MARK: Validation + + public static func validate(latitude: String, longitude: String) throws -> Coordinates { + Coordinates( + latitude: try validateComponent(latitude, label: "Latitude", range: -90...90), + longitude: try validateComponent(longitude, label: "Longitude", range: -180...180) + ) + } + + public static func validate(_ coordinates: Coordinates) throws -> Coordinates { + try validate(latitude: String(coordinates.latitude), longitude: String(coordinates.longitude)) + } + + static func validateComponent(_ text: String, label: String, range: ClosedRange) throws -> Double { + let trimmed = text.trimmingCharacters(in: .whitespaces) + guard let value = Double(trimmed) else { + throw ToolkitError.invalidInput("\(label) must be a decimal number, for example 34.0522.") + } + guard value.isFinite else { throw ToolkitError.invalidInput("\(label) must be a finite number.") } + guard range.contains(value) else { + throw ToolkitError.invalidInput("\(label) must be between \(Int(range.lowerBound)) and \(Int(range.upperBound)).") + } + return value + } + + // MARK: Input parsing + + private static let number = #"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"# + private static let pairExpression = try! NSRegularExpression(pattern: #"^\s*\(?\s*("# + number + #")\s*,\s*("# + number + #")\s*\)?\s*$"#) + private static let googlePathExpression = try! NSRegularExpression(pattern: "/@(" + number + "),(" + number + ")(?:[,/]|$)") + + static func parsePair(_ text: String) throws -> Coordinates? { + let decoded = text.removingPercentEncoding ?? text + let range = NSRange(decoded.startIndex.. Coordinates { + let text = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + throw ToolkitError.invalidInput("Enter latitude,longitude or paste an Apple Maps, Google Maps, or geo: link.") + } + if let direct = try parsePair(text) { return direct } + guard let components = URLComponents(string: text), let scheme = components.scheme?.lowercased() else { + throw unsupportedInput() + } + if scheme == "geo" { + let body = String(text.dropFirst(4)).split(separator: ";").first.map(String.init) ?? "" + if let coordinates = try parsePair(body.split(separator: "?").first.map(String.init) ?? body) { return coordinates } + throw unsupportedInput() + } + guard scheme == "http" || scheme == "https" else { throw unsupportedInput() } + let items = components.queryItems ?? [] + for key in ["ll", "coordinate", "query", "q", "destination", "daddr", "center", "sll"] { + for item in items where item.name == key { + if let value = item.value, let coordinates = try parsePair(value) { return coordinates } + } + } + for item in items where item.name == "cp" { + if let value = item.value, let coordinates = try parsePair(value.replacingOccurrences(of: "~", with: ",")) { return coordinates } + } + let path = components.percentEncodedPath.removingPercentEncoding ?? components.path + let range = NSRange(path.startIndex.. ToolkitError { + ToolkitError.invalidInput("Enter latitude,longitude or an Apple Maps, Google Maps, or geo: link that contains coordinates.") + } + + /// The waypoint list with one more line for `coordinates` (trailing blank lines dropped). + public static func appendingWaypoint(_ coordinates: Coordinates, to text: String) -> String { + var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + while let last = lines.last, last.trimmingCharacters(in: .whitespaces).isEmpty { lines.removeLast() } + lines.append(String(format: "%.6f,%.6f", coordinates.latitude, coordinates.longitude)) + return lines.joined(separator: "\n") + } + + public static func parseRouteWaypoints(_ text: String) throws -> [Coordinates] { + var points: [Coordinates] = [] + for (index, rawLine) in text.split(separator: "\n", omittingEmptySubsequences: false).enumerated() { + let line = rawLine.trimmingCharacters(in: .whitespaces) + if line.isEmpty { continue } + let parts = line.split(separator: ",", omittingEmptySubsequences: false).map { $0.trimmingCharacters(in: .whitespaces) } + guard parts.count == 2 else { + throw ToolkitError.invalidInput("Waypoint line \(index + 1) must contain latitude,longitude.") + } + do { + points.append(try validate(latitude: parts[0], longitude: parts[1])) + } catch let error as ToolkitError { + throw ToolkitError.invalidInput("Waypoint line \(index + 1): \(error.message)") + } + } + guard points.count >= 2 else { throw ToolkitError.invalidInput("A route needs at least two latitude,longitude waypoints.") } + return points + } + + // MARK: Offline map projection (equirectangular) + + public static func mapFractions(for coordinates: Coordinates) -> (x: Double, y: Double) { + ((coordinates.longitude + 180) / 360, (90 - coordinates.latitude) / 180) + } + + public static func coordinates(forMapFractionX x: Double, y: Double) throws -> Coordinates { + guard x.isFinite, y.isFinite, (0...1).contains(x), (0...1).contains(y) else { + throw ToolkitError.invalidInput("Choose a point inside the map.") + } + return Coordinates(latitude: 90 - y * 180, longitude: x * 360 - 180) + } + + // MARK: Routes + + public static func buildRoute(waypoints: [Coordinates], speedKmh: Double, intervalSeconds: Int, traversalCount: Int, startTime: Date) throws -> GeneratedRoute { + guard waypoints.count >= 2 else { throw ToolkitError.invalidInput("A route needs at least two waypoints.") } + guard speedKmh.isFinite, speedKmh > 0, speedKmh <= 300 else { throw ToolkitError.invalidInput("Route speed must be greater than 0 and at most 300 km/h.") } + guard (1...60).contains(intervalSeconds) else { throw ToolkitError.invalidInput("The point interval must be between 1 and 60 seconds.") } + guard (1...20).contains(traversalCount) else { throw ToolkitError.invalidInput("The number of traversals must be between 1 and 20.") } + let validated = try waypoints.map(validate) + let metresPerStep = speedKmh * 1000 / 3600 * Double(intervalSeconds) + var points: [Coordinates] = [] + var distance = 0.0 + for traversal in 0.. maximumRoutePoints { + throw ToolkitError.invalidInput("The route would exceed \(maximumRoutePoints) points. Increase the speed or interval, or reduce the traversals.") + } + sampled += Geodesy.interpolate(from: start, to: end, steps: steps) + distance += segment + } + points += points.isEmpty ? sampled : Array(sampled.dropFirst()) + } + let duration = max(0, points.count - 1) * intervalSeconds + return GeneratedRoute( + points: points, + distanceMetres: distance, + durationSeconds: duration, + speedKmh: speedKmh, + intervalSeconds: intervalSeconds, + traversalCount: traversalCount, + gpxDocument: gpx(points: points, start: startTime, interval: intervalSeconds) + ) + } + + static func gpx(points: [Coordinates], start: Date, interval: Int) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + var lines = [ + #""#, + #""#, + " Toolkit QA route", + ] + for (index, point) in points.enumerated() { + let time = formatter.string(from: start.addingTimeInterval(TimeInterval(index * interval))) + lines.append(String(format: #" "#, point.latitude, point.longitude, time)) + } + lines += [" ", ""] + return lines.joined(separator: "\n") + "\n" + } + + // MARK: GPX inspection + + public static func inspectGPX(at url: URL) throws -> GPXInspection { + guard url.pathExtension.lowercased() == "gpx" else { + throw ToolkitError.invalidInput("Route files must use the .gpx extension.") + } + guard let size = SecureFileIO.fileSize(url) else { + throw ToolkitError.fileSystem("The GPX file could not be read.", path: url.path) + } + guard size > 0 else { throw ToolkitError.invalidInput("The GPX file is empty.") } + guard size <= maximumGPXBytes else { throw ToolkitError.invalidInput("The GPX file is larger than the 64 MB limit.") } + let data: Data + do { data = try Data(contentsOf: url) } catch { throw ToolkitError.fileSystem("The GPX file could not be read.", path: url.path, underlying: error) } + let points = try parseGPX(data) + return GPXInspection(url: url, sizeBytes: size, points: points, sha256: SecureFileIO.sha256(of: data)) + } + + public static func parseGPX(_ data: Data) throws -> [GPXPoint] { + let prefix = String(decoding: data.prefix(16_384), as: UTF8.self).uppercased() + guard !prefix.contains(" URL { + home.appendingPathComponent("Library/Application Support/iOS Developer Toolkit/locations.json") + } + + public static func validateLocationName(_ name: String) throws -> String { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw ToolkitError.invalidInput("Enter a name for the saved location.") } + guard trimmed.count <= 80 else { throw ToolkitError.invalidInput("Saved location names must be 80 characters or fewer.") } + guard !trimmed.unicodeScalars.contains(where: { $0.value < 32 }) else { + throw ToolkitError.invalidInput("Saved location names cannot contain control characters.") + } + return trimmed + } + + public static func parseSavedLocations(_ data: Data) throws -> [SavedLocation] { + let document: JSONValue + do { document = try JSONValue.parse(data) } catch { throw ToolkitError.invalidInput("The saved locations file is not valid JSON.") } + guard document["version"]?.int == savedLocationsVersion else { + throw ToolkitError.invalidInput("The saved locations file uses an unsupported version.") + } + guard let records = document["locations"]?.array else { + throw ToolkitError.invalidInput("The saved locations file has no locations list.") + } + var names = Set() + return try records.map { record in + guard let rawName = record["name"]?.string else { throw ToolkitError.invalidInput("Each saved location needs a name.") } + let name = try validateLocationName(rawName) + guard names.insert(name.lowercased()).inserted else { throw ToolkitError.invalidInput("Saved location names must be unique: \(name).") } + guard let latitude = numeric(record["latitude"]), let longitude = numeric(record["longitude"]) else { + throw ToolkitError.invalidInput("Saved location \(name) needs numeric latitude and longitude.") + } + return SavedLocation(name: name, coordinates: try validate(Coordinates(latitude: latitude, longitude: longitude))) + } + } + + /// Accepts JSON numbers only (a quoted "1.5" is rejected, as in earlier releases). + private static func numeric(_ value: JSONValue?) -> Double? { + switch value { + case .number(let number)?: return number + case .integer(let number)?: return Double(number) + default: return nil + } + } + + public static func encodeSavedLocations(_ locations: [SavedLocation]) throws -> Data { + let document: [String: Any] = [ + "version": savedLocationsVersion, + "locations": locations.map { ["name": $0.name, "latitude": $0.coordinates.latitude, "longitude": $0.coordinates.longitude] }, + ] + var data = try JSONSerialization.data(withJSONObject: document, options: [.prettyPrinted, .sortedKeys]) + data.append(0x0A) + return data + } + + public static func adding(_ name: String, _ coordinates: Coordinates, to locations: [SavedLocation]) throws -> [SavedLocation] { + let validated = try validateLocationName(name) + guard !locations.contains(where: { $0.name.lowercased() == validated.lowercased() }) else { + throw ToolkitError.invalidInput("A saved location named “\(validated)” already exists.") + } + return locations + [SavedLocation(name: validated, coordinates: try validate(coordinates))] + } + + public static func loadSavedLocations(from url: URL = savedLocationsURL()) throws -> [SavedLocation] { + guard FileManager.default.fileExists(atPath: url.path) else { return [] } + let data: Data + do { data = try Data(contentsOf: url) } catch { throw ToolkitError.fileSystem("Saved locations could not be read.", path: url.path, underlying: error) } + return try parseSavedLocations(data) + } + + public static func storeSavedLocations(_ locations: [SavedLocation], to url: URL = savedLocationsURL()) throws { + try SecureFileIO.writeAtomically(try encodeSavedLocations(locations), to: url) + } + + // MARK: Evidence + + public static func eventsURL(in directory: URL) -> URL { + directory.appendingPathComponent("location-events.jsonl") + } + + public static func append(_ event: LocationEvidenceEvent, to directory: URL) throws { + try SecureFileIO.createPrivateDirectory(at: directory) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .iso8601 + var line = try encoder.encode(event) + line.append(0x0A) + try SecureFileIO.append(line, to: eventsURL(in: directory), synchronize: true) + } +} + +/// Great-circle math. +public enum Geodesy { + public static let earthRadiusMetres = 6_371_008.8 + + public static func distance(_ start: Coordinates, _ end: Coordinates) -> Double { + let lat1 = start.latitude * .pi / 180 + let lat2 = end.latitude * .pi / 180 + let deltaLat = lat2 - lat1 + let deltaLon = (end.longitude - start.longitude) * .pi / 180 + let a = pow(sin(deltaLat / 2), 2) + cos(lat1) * cos(lat2) * pow(sin(deltaLon / 2), 2) + return 2 * earthRadiusMetres * asin(min(1, sqrt(a))) + } + + /// Moves `origin` by `distance` metres along `bearing` degrees (clockwise from north). + public static func move(_ origin: Coordinates, bearing: Double, distance: Double) throws -> Coordinates { + guard bearing.isFinite else { throw ToolkitError.invalidInput("The direction must be a finite number.") } + guard distance.isFinite, distance > 0, distance <= 100_000 else { + throw ToolkitError.invalidInput("The nudge distance must be greater than 0 and at most 100,000 metres.") + } + let angular = distance / earthRadiusMetres + let theta = bearing * .pi / 180 + let lat1 = origin.latitude * .pi / 180 + let lon1 = origin.longitude * .pi / 180 + let lat2 = asin(sin(lat1) * cos(angular) + cos(lat1) * sin(angular) * cos(theta)) + let lon2 = lon1 + atan2(sin(theta) * sin(angular) * cos(lat1), cos(angular) - sin(lat1) * sin(lat2)) + let normalized = (lon2 * 180 / .pi + 540).truncatingRemainder(dividingBy: 360) - 180 + return Coordinates(latitude: lat2 * 180 / .pi, longitude: normalized) + } + + static func interpolate(from start: Coordinates, to end: Coordinates, steps: Int) -> [Coordinates] { + let deltaLongitude = (end.longitude - start.longitude + 540).truncatingRemainder(dividingBy: 360) - 180 + return (1...steps).map { index in + let fraction = Double(index) / Double(steps) + let longitude = (start.longitude + deltaLongitude * fraction + 540).truncatingRemainder(dividingBy: 360) - 180 + return Coordinates(latitude: start.latitude + (end.latitude - start.latitude) * fraction, longitude: longitude) + } + } +} + +private final class GPXParserDelegate: NSObject, XMLParserDelegate { + var points: [GPXPoint] = [] + var error: ToolkitError? + var sawGPXRoot = false + private var depth = 0 + private var current: Coordinates? + private var currentTime: Date? + private var readingTime = false + private var timeText = "" + private let formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + private let fractionalFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String] = [:]) { + depth += 1 + if depth == 1 { sawGPXRoot = elementName == "gpx" } + if elementName == "trkpt" { + guard let latitude = attributes["lat"], let longitude = attributes["lon"] else { + error = ToolkitError.invalidInput("Every GPX track point needs lat and lon attributes.") + parser.abortParsing() + return + } + do { + current = try LocationLab.validate(latitude: latitude, longitude: longitude) + currentTime = nil + } catch let validationError as ToolkitError { + error = ToolkitError.invalidInput("GPX track point \(points.count + 1): \(validationError.message)") + parser.abortParsing() + } catch {} + } else if elementName == "time", current != nil { + readingTime = true + timeText = "" + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + if readingTime { timeText += string } + } + + func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName: String?) { + depth -= 1 + if elementName == "time", readingTime { + readingTime = false + let text = timeText.trimmingCharacters(in: .whitespacesAndNewlines) + currentTime = formatter.date(from: text) ?? fractionalFormatter.date(from: text) + } else if elementName == "trkpt", let current { + points.append(GPXPoint(coordinates: current, time: currentTime)) + self.current = nil + if points.count > LocationLab.maximumRoutePoints * 10 { + error = ToolkitError.invalidInput("The GPX file has too many points to replay.") + parser.abortParsing() + } + } + } +} diff --git a/Sources/ToolkitFeatures/Location/LocationModels.swift b/Sources/ToolkitFeatures/Location/LocationModels.swift new file mode 100644 index 0000000..6491356 --- /dev/null +++ b/Sources/ToolkitFeatures/Location/LocationModels.swift @@ -0,0 +1,158 @@ +import Foundation +import ToolkitCore + +public struct Coordinates: Codable, Sendable, Hashable { + public var latitude: Double + public var longitude: Double + + public init(latitude: Double, longitude: Double) { + self.latitude = latitude + self.longitude = longitude + } + + public var formatted: String { + String(format: "%.6f, %.6f", latitude, longitude) + } +} + +public struct SavedLocation: Codable, Sendable, Hashable, Identifiable { + public var id: String { name.lowercased() } + public var name: String + public var coordinates: Coordinates + + public init(name: String, coordinates: Coordinates) { + self.name = name + self.coordinates = coordinates + } +} + +public enum CompassDirection: String, CaseIterable, Sendable, Identifiable { + case north = "N", northEast = "NE", east = "E", southEast = "SE", south = "S", southWest = "SW", west = "W", northWest = "NW" + + public var id: String { rawValue } + + public var bearing: Double { + switch self { + case .north: return 0 + case .northEast: return 45 + case .east: return 90 + case .southEast: return 135 + case .south: return 180 + case .southWest: return 225 + case .west: return 270 + case .northWest: return 315 + } + } + + public var symbolName: String { + switch self { + case .north: return "arrow.up" + case .northEast: return "arrow.up.right" + case .east: return "arrow.right" + case .southEast: return "arrow.down.right" + case .south: return "arrow.down" + case .southWest: return "arrow.down.left" + case .west: return "arrow.left" + case .northWest: return "arrow.up.left" + } + } +} + +/// Speed presets for generated routes (km/h). +public enum TravelPreset: String, CaseIterable, Sendable, Identifiable { + case walk = "Walk", run = "Run", bicycle = "Bicycle", urbanDrive = "Urban drive", highway = "Highway", custom = "Custom" + + public var id: String { rawValue } + + public var speedKmh: Double? { + switch self { + case .walk: return 5 + case .run: return 10 + case .bicycle: return 18 + case .urbanDrive: return 40 + case .highway: return 100 + case .custom: return nil + } + } +} + +public struct GeneratedRoute: Sendable, Hashable { + public var points: [Coordinates] + public var distanceMetres: Double + public var durationSeconds: Int + public var speedKmh: Double + public var intervalSeconds: Int + public var traversalCount: Int + public var gpxDocument: String +} + +public struct GPXPoint: Sendable, Hashable { + public var coordinates: Coordinates + public var time: Date? +} + +public struct GPXInspection: Sendable, Hashable { + public var url: URL + public var sizeBytes: Int64 + public var points: [GPXPoint] + public var sha256: String + + public var trackPointCount: Int { points.count } + public var timedPointCount: Int { points.filter { $0.time != nil }.count } + public var firstPoint: Coordinates? { points.first?.coordinates } + public var lastPoint: Coordinates? { points.last?.coordinates } + + public var distanceMetres: Double { + zip(points, points.dropFirst()).reduce(0) { $0 + Geodesy.distance($1.0.coordinates, $1.1.coordinates) } + } + + /// Duration from the first to the last timestamp, when the track has times. + public var recordedDuration: TimeInterval? { + let times = points.compactMap(\.time) + guard let first = times.first, let last = times.last, last > first else { return nil } + return last.timeIntervalSince(first) + } +} + +/// One structured Location Lab event appended to `location-events.jsonl`. +public struct LocationEvidenceEvent: Codable, Sendable, Hashable { + public var event: String + public var status: String + public var timestamp: Date + public var deviceIdentifier: String + public var deviceName: String + public var deviceKind: String + public var osVersion: String? + public var mechanism: String + public var latitude: Double? + public var longitude: Double? + public var gpxPath: String? + public var gpxSHA256: String? + public var detail: String + + public init(event: String, status: String, timestamp: Date = Date(), deviceIdentifier: String, deviceName: String, deviceKind: String, osVersion: String?, mechanism: String, latitude: Double? = nil, longitude: Double? = nil, gpxPath: String? = nil, gpxSHA256: String? = nil, detail: String) { + self.event = event + self.status = status + self.timestamp = timestamp + self.deviceIdentifier = deviceIdentifier + self.deviceName = deviceName + self.deviceKind = deviceKind + self.osVersion = osVersion + self.mechanism = mechanism + self.latitude = latitude + self.longitude = longitude + self.gpxPath = gpxPath + self.gpxSHA256 = gpxSHA256 + self.detail = detail + } + + enum CodingKeys: String, CodingKey { + case event, status, timestamp, detail, latitude, longitude, mechanism + case deviceIdentifier = "device_identifier" + case deviceName = "device_name" + case deviceKind = "device_kind" + case osVersion = "ios_version" + case gpxPath = "gpx_path" + case gpxSHA256 = "gpx_sha256" + } +} diff --git a/Sources/ToolkitFeatures/Readiness/CapabilityMatrix.swift b/Sources/ToolkitFeatures/Readiness/CapabilityMatrix.swift new file mode 100644 index 0000000..dae511f --- /dev/null +++ b/Sources/ToolkitFeatures/Readiness/CapabilityMatrix.swift @@ -0,0 +1,442 @@ +import DeviceKit +import Foundation +import ToolkitCore + +public enum CapabilityState: String, Codable, Sendable, CaseIterable { + case ready + case attention + case unavailable + case blocked + case notTested = "not-tested" + case notApplicable = "not-applicable" + + public var label: String { + switch self { + case .ready: return "Ready" + case .attention: return "Needs attention" + case .unavailable: return "Unavailable" + case .blocked: return "Blocked" + case .notTested: return "Not tested" + case .notApplicable: return "Not applicable" + } + } + + public var symbolName: String { + switch self { + case .ready: return "checkmark.circle.fill" + case .attention: return "exclamationmark.triangle.fill" + case .unavailable: return "xmark.octagon.fill" + case .blocked: return "slash.circle" + case .notTested: return "circle.dashed" + case .notApplicable: return "minus.circle" + } + } +} + +public struct CapabilityResult: Codable, Sendable, Hashable, Identifiable { + public var id: String + public var layer: String + public var title: String + public var state: CapabilityState + public var summary: String + public var evidence: String + public var remediation: String + + public init(id: String, layer: String, title: String, state: CapabilityState, summary: String, evidence: String = "", remediation: String = "") { + self.id = id + self.layer = layer + self.title = title + self.state = state + self.summary = summary + self.evidence = evidence + self.remediation = remediation + } +} + +/// The rows of the matrix, in display order. +public enum CapabilityRow: String, CaseIterable, Sendable { + case host = "host" + case xcodeTools = "xcode-tools" + case usbmux = "usbmuxd" + case deviceConnection = "device-connection" + case pairingTrust = "pairing-trust" + case developerMode = "developer-mode" + case coreDevice = "coredevice" + case developerServices = "developer-services" + case lockState = "lock-state" + case lockdownServices = "lockdown-services" + case backupService = "backup-service" + case webInspector = "web-inspector" + case instruments = "instruments" + case simulatorRuntime = "simulator-runtime" + case simulatorRunning = "simulator-running" + + public var layer: String { + switch self { + case .host, .xcodeTools, .usbmux: return "This Mac" + case .deviceConnection, .pairingTrust: return "Connection" + case .developerMode, .coreDevice, .developerServices, .instruments: return "Developer readiness" + case .lockState, .lockdownServices, .backupService, .webInspector: return "Device services" + case .simulatorRuntime, .simulatorRunning: return "Simulator" + } + } + + public var title: String { + switch self { + case .host: return "macOS" + case .xcodeTools: return "Xcode developer tools" + case .usbmux: return "Device connection service (usbmuxd)" + case .deviceConnection: return "Selected device is connected" + case .pairingTrust: return "Device trusts this Mac" + case .developerMode: return "Developer Mode" + case .coreDevice: return "Xcode device service (CoreDevice)" + case .developerServices: return "Developer services (DDI)" + case .lockState: return "Device unlocked" + case .lockdownServices: return "Logging and diagnostics services" + case .backupService: return "Backup service" + case .webInspector: return "Safari Web Inspector" + case .instruments: return "Instruments (xctrace)" + case .simulatorRuntime: return "Simulator runtime installed" + case .simulatorRunning: return "Simulator running" + } + } + + public var remediation: String { + switch self { + case .host: return "" + case .xcodeTools: return "Install Xcode from the App Store, open it once to finish installing components, and select it in Xcode › Settings › Locations. Features that use USB services still work without Xcode." + case .usbmux: return "Reconnect the device. If macOS's device service still does not respond, restart the Mac." + case .deviceConnection: return "Connect the device with a data-capable USB cable, unlock it, and wait a few seconds." + case .pairingTrust: return "Unlock the device and tap Trust when asked. If no prompt appears, disconnect and reconnect the cable." + case .developerMode: return "On the device open Settings › Privacy & Security › Developer Mode, turn it on, restart, and confirm. If the setting is missing, open Xcode › Window › Devices and Simulators once with the device connected." + case .coreDevice: return "Open Xcode › Window › Devices and Simulators with the device connected and unlocked, and wait for Xcode to finish preparing it." + case .developerServices: return "Use “Mount Developer Image” on the Device page. Keep the device unlocked; on iOS 17 and later this Mac must be online so Apple can personalize the image." + case .lockState: return "Unlock the device and keep it awake while working." + case .lockdownServices: return "Unlock the device; if it was just restarted, unlock it once." + case .backupService: return "Unlock the device and make sure no other backup (Finder) is running." + case .webInspector: return "Only needed to list Safari and web view tabs. Turn on Settings › Apps › Safari › Advanced › Web Inspector (Settings › Safari › Advanced before iOS 18)." + case .instruments: return "Only needed for Instruments recordings. Keep the device unlocked with Developer Mode on; if Instruments still lists it as offline, open Xcode › Window › Devices and Simulators with the device connected and wait for Xcode to finish preparing it." + case .simulatorRuntime: return "Install the simulator runtime in Xcode › Settings › Components." + case .simulatorRunning: return "Start the simulator from the Simulator actions or from Xcode." + } + } + + public static func rows(for kind: DeviceKind) -> [CapabilityRow] { + switch kind { + case .physical: return [.host, .xcodeTools, .usbmux, .deviceConnection, .pairingTrust, .developerMode, .coreDevice, .developerServices, .instruments, .lockState, .lockdownServices, .backupService, .webInspector] + case .simulator: return [.host, .xcodeTools, .simulatorRuntime, .simulatorRunning, .instruments] + case .demo: return [.host] + } + } + + public func result(_ state: CapabilityState, _ summary: String, evidence: String = "") -> CapabilityResult { + CapabilityResult(id: rawValue, layer: layer, title: title, state: state, summary: summary, evidence: evidence, remediation: state == .ready || state == .notApplicable ? "" : remediation) + } + + public var untested: CapabilityResult { + result(.notTested, "Run the check to test this.") + } +} + +/// Runs the readiness probes. Nothing here changes device state: no image is mounted, no +/// setting is changed, and no service other than a short-lived read is started. +public struct CapabilityProbe: Sendable { + public let runner: CommandRunning + public let coreDevice: CoreDeviceClient + public let usbmux: USBMuxClient + public let simulators: SimulatorClient + public let developerImageFolders: [URL] + + public init(runner: CommandRunning = ProcessCommandRunner(), usbmux: USBMuxClient = USBMuxClient(), developerImageFolders: [URL] = []) { + self.runner = runner + coreDevice = CoreDeviceClient(runner: runner) + simulators = SimulatorClient(runner: runner) + self.usbmux = usbmux + self.developerImageFolders = developerImageFolders + } + + /// The readiness row for a natively evaluated developer-image state. + static func developerImageResult(_ status: DeveloperImageStatus) -> CapabilityResult { + let state: CapabilityState + switch status.state { + case .mounted: state = .ready + case .notRequired: state = .notApplicable + case .available, .personalizationRequired, .blocked: state = .attention + case .missing, .incompatible, .failed: state = .unavailable + } + // The row's own state already says “Needs attention”, so a blocked image shows only why. + let summary: String + switch status.state { + case .mounted: summary = "Mounted." + case .blocked: summary = status.headline + default: summary = "\(status.state.label): \(status.headline)" + } + var result = CapabilityRow.developerServices.result(state, summary, evidence: ([status.explanation] + (status.technicalDetail.map { [$0] } ?? [])).joined(separator: " ")) + // The image check knows the specific fix (for example “turn on Developer Mode” rather than + // the row's generic “Mount Developer Image”). + if !result.remediation.isEmpty, let specific = status.remediation, !specific.isEmpty { + result.remediation = specific + } + return result + } + + public func run(for device: Device, progress: @Sendable (CapabilityResult) -> Void = { _ in }) async -> [CapabilityResult] { + var results: [CapabilityRow: CapabilityResult] = [:] + func record(_ row: CapabilityRow, _ result: CapabilityResult) { + results[row] = result + progress(result) + } + let target = device.target + let info = ProcessInfo.processInfo + #if arch(arm64) + let architecture = "Apple silicon" + #else + let architecture = "Intel" + #endif + record(.host, CapabilityRow.host.result(.ready, "macOS \(info.operatingSystemVersionString), \(architecture)")) + + let tools = await DeveloperToolsStatus.probe(runner: runner) + let hasCoreDevice = tools.devicectl.isAvailable + if tools.devicectl.isAvailable { + record(.xcodeTools, CapabilityRow.xcodeTools.result(.ready, tools.xcodeVersion ?? "Xcode is installed.", evidence: tools.developerDirectory ?? "")) + } else if case .unresponsive(let reason) = tools.devicectl { + record(.xcodeTools, CapabilityRow.xcodeTools.result(.attention, "Xcode's tools did not answer in time. The Mac may be busy; run the check again.", evidence: reason)) + } else if tools.isCommandLineToolsOnly { + record(.xcodeTools, CapabilityRow.xcodeTools.result(.attention, "Only the Command Line Tools are selected; developer services and simulators need Xcode.", evidence: tools.developerDirectory ?? "")) + } else { + record(.xcodeTools, CapabilityRow.xcodeTools.result(.unavailable, "Xcode is not installed or not selected.", evidence: { + if case .missing(let reason) = tools.devicectl { return reason } + return "" + }())) + } + + guard !Task.isCancelled else { return ordered(results, device.kind) } + + switch device.kind { + case .demo: + break + case .simulator: + guard hasCoreDevice || tools.simctl.isAvailable else { + let reason: String + if case .unresponsive = tools.simctl { reason = "Xcode's tools did not answer in time." } else { reason = "Needs Xcode." } + record(.simulatorRuntime, CapabilityRow.simulatorRuntime.result(.blocked, reason)) + record(.simulatorRunning, CapabilityRow.simulatorRunning.result(.blocked, reason)) + record(.instruments, CapabilityRow.instruments.result(.blocked, reason)) + break + } + let records = (try? await simulators.list()) ?? [] + guard let simulator = records.first(where: { $0.udid == target.udid }) else { + record(.simulatorRuntime, CapabilityRow.simulatorRuntime.result(.unavailable, "The simulator no longer exists.")) + record(.simulatorRunning, CapabilityRow.simulatorRunning.result(.blocked, "The simulator no longer exists.")) + record(.instruments, CapabilityRow.instruments.result(.blocked, "The simulator no longer exists.")) + break + } + record(.simulatorRuntime, simulator.isAvailable ? CapabilityRow.simulatorRuntime.result(.ready, simulator.runtime?.name ?? simulator.runtimeIdentifier) : CapabilityRow.simulatorRuntime.result(.unavailable, simulator.availabilityError ?? "The runtime is not installed.")) + record(.simulatorRunning, simulator.state == .booted ? CapabilityRow.simulatorRunning.result(.ready, "Running") : CapabilityRow.simulatorRunning.result(.attention, "The simulator is \(simulator.state.label.lowercased()).")) + record(.instruments, await instrumentsResult(for: target, tools: tools)) + case .physical: + await probePhysical(device, hasCoreDevice: hasCoreDevice, record: record) + record(.instruments, await instrumentsResult(for: target, tools: tools)) + } + return ordered(results, device.kind) + } + + /// Whether Instruments lists the device, from `xctrace list devices` (read-only). + func instrumentsResult(for target: DeviceTarget, tools: DeveloperToolsStatus) async -> CapabilityResult { + let row = CapabilityRow.instruments + guard tools.xctrace.isAvailable else { return row.result(.blocked, "Needs Xcode.") } + do { + let result = try await runner.run(try InstrumentsDeviceList.request()) + guard result.succeeded else { + return row.result(.unavailable, "Instruments could not list devices.", evidence: result.standardErrorText.trimmingCharacters(in: .whitespacesAndNewlines)) + } + switch InstrumentsDeviceList.presence(of: target.udid, in: result.standardOutputText) { + case .available: + return row.result(.ready, target.kind == .simulator ? "Instruments lists this simulator." : "Instruments lists the device as available.") + case .offline: + return row.result(.attention, "Instruments lists the device as offline.") + case .notListed: + return row.result(.attention, "Instruments does not list the device.") + } + } catch { + return row.result(.unavailable, (error as? ToolkitError)?.message ?? error.localizedDescription) + } + } + + private func probePhysical(_ device: Device, hasCoreDevice: Bool, record: (CapabilityRow, CapabilityResult) -> Void) async { + let target = device.target + // usbmuxd and connection + var muxDevice: USBMuxDevice? + do { + let devices = try await usbmux.listDevices() + record(.usbmux, CapabilityRow.usbmux.result(.ready, "Responding")) + muxDevice = devices.first { $0.udid.caseInsensitiveCompare(target.udid) == .orderedSame } + } catch { + record(.usbmux, CapabilityRow.usbmux.result(.unavailable, (error as? ToolkitError)?.message ?? error.localizedDescription)) + } + if let muxDevice { + record(.deviceConnection, CapabilityRow.deviceConnection.result(.ready, "Connected over \(muxDevice.transport.label).")) + } else if device.supportsCoreDevice { + record(.deviceConnection, CapabilityRow.deviceConnection.result(.attention, "Reachable only through Xcode's network connection; USB services (logs, backup, diagnostics) need a USB cable or Wi-Fi sync.")) + } else { + record(.deviceConnection, CapabilityRow.deviceConnection.result(.unavailable, "The device is not connected.")) + } + + // Trust and lockdown-based checks + var developerModeFromLockdown: Bool? + var lockdownReady = false + if muxDevice != nil { + do { + let probe = try await DeviceSession.with(target, usbmux: usbmux) { session -> (developerMode: Bool?, willEncrypt: Bool?) in + let developerMode = try? await session.developerModeEnabled() + let willEncrypt = try? await session.getValue(domain: "com.apple.mobile.backup", key: "WillEncrypt")?.boolValue + let syslog = try await session.openService(SyslogRelay.serviceName) + await syslog.close() + return (developerMode ?? nil, willEncrypt ?? nil) + } + developerModeFromLockdown = probe.developerMode + record(.backupService, probe.willEncrypt.map { CapabilityRow.backupService.result(.ready, $0 ? "Available; backups are encrypted." : "Available; backup encryption is off.") } ?? CapabilityRow.backupService.result(.attention, "The device did not report its backup setting.")) + lockdownReady = true + record(.pairingTrust, CapabilityRow.pairingTrust.result(.ready, "Trusted; a secure session was established.")) + record(.lockdownServices, CapabilityRow.lockdownServices.result(.ready, "Syslog relay started and closed successfully.")) + } catch let error as ToolkitError { + let state: CapabilityState = [.deviceLocked, .pairingPending].contains(error.kind) ? .attention : .unavailable + record(.pairingTrust, CapabilityRow.pairingTrust.result(state, error.message, evidence: error.technicalDetail ?? "")) + record(.lockdownServices, CapabilityRow.lockdownServices.result(.blocked, "Needs a trusted connection.")) + record(.backupService, CapabilityRow.backupService.result(.blocked, "Needs a trusted connection.")) + } catch { + record(.pairingTrust, CapabilityRow.pairingTrust.result(.unavailable, error.localizedDescription)) + } + } else { + let pairing = device.pairingState + record(.pairingTrust, pairing == .paired ? CapabilityRow.pairingTrust.result(.ready, "Trusted (reported by Xcode).") : CapabilityRow.pairingTrust.result(.blocked, "Needs a connected device.")) + record(.lockdownServices, CapabilityRow.lockdownServices.result(.blocked, "Needs a USB or Wi-Fi sync connection.")) + record(.backupService, CapabilityRow.backupService.result(.blocked, "Needs a USB or Wi-Fi sync connection.")) + } + + // Web Inspector: a short probe, because it is off on most devices and a refusal only shows as + // a dropped connection. “Not answering” is attention, not a failure. + if lockdownReady { + do { + let applications = try await WebInspector.openPages(on: target, usbmux: usbmux, handshakeDeadline: .seconds(4), listingWindow: .seconds(1)) + let pages = applications.reduce(0) { $0 + $1.pages.count } + record(.webInspector, CapabilityRow.webInspector.result(.ready, pages == 1 ? "Answering (1 inspectable page)." : "Answering (\(pages) inspectable pages).")) + } catch { + record(.webInspector, CapabilityRow.webInspector.result(.attention, "Not answering — Web Inspector is probably off.", evidence: (error as? ToolkitError)?.technicalDetail ?? error.localizedDescription)) + } + } else { + record(.webInspector, CapabilityRow.webInspector.result(.blocked, "Needs a trusted USB connection.")) + } + + // With a trusted USB session the developer-image row comes from the native check below. + func recordImageFallback(_ result: CapabilityResult) { + if !lockdownReady { record(.developerServices, result) } + } + + // CoreDevice checks + var coreDeviceRecord: CoreDeviceRecord? + if !hasCoreDevice { + record(.coreDevice, CapabilityRow.coreDevice.result(.blocked, "Needs Xcode.")) + recordImageFallback(CapabilityRow.developerServices.result(.blocked, "Needs Xcode.")) + record(.lockState, CapabilityRow.lockState.result(.blocked, "Needs Xcode.")) + } else { + do { + coreDeviceRecord = try await coreDevice.details(target).record + record(.coreDevice, CapabilityRow.coreDevice.result(.ready, "Connected (tunnel: \(coreDeviceRecord?.tunnelState ?? "unknown")).")) + } catch let error as ToolkitError { + record(.coreDevice, CapabilityRow.coreDevice.result(error.kind == .deviceLocked ? .attention : .unavailable, error.message, evidence: error.technicalDetail ?? "")) + } catch { + record(.coreDevice, CapabilityRow.coreDevice.result(.unavailable, error.localizedDescription)) + } + if coreDeviceRecord != nil { + do { + let lock = try await coreDevice.lockState(target) + record(.lockState, CapabilityRow.lockState.result(lock.passcodeRequired == true ? .attention : .ready, lock.summary)) + } catch { + record(.lockState, CapabilityRow.lockState.result(.unavailable, (error as? ToolkitError)?.message ?? error.localizedDescription)) + } + do { + _ = try await coreDevice.ddiServices(target, autoMount: false) + let available = coreDeviceRecord?.ddiServicesAvailable + recordImageFallback(available == false ? CapabilityRow.developerServices.result(.attention, "Not mounted yet.") : CapabilityRow.developerServices.result(.ready, "Available.")) + } catch let error as ToolkitError { + recordImageFallback(CapabilityRow.developerServices.result(.attention, error.message, evidence: error.technicalDetail ?? "")) + } catch { + recordImageFallback(CapabilityRow.developerServices.result(.attention, error.localizedDescription)) + } + } else { + record(.lockState, CapabilityRow.lockState.result(.blocked, "Needs the Xcode device service.")) + recordImageFallback(CapabilityRow.developerServices.result(.blocked, "Needs the Xcode device service.")) + } + } + + // With a trusted USB session, the native developer-image check is more precise than + // CoreDevice's: it reads what is mounted and whether this Mac has a compatible image. + if lockdownReady { + let status = await DeveloperImageManager(usbmux: usbmux, coreDevice: coreDevice).status(for: target, userFolders: developerImageFolders) + record(.developerServices, Self.developerImageResult(status)) + } + + // Developer Mode from whichever source answered. + let developerMode: DeveloperModeState = developerModeFromLockdown.map { $0 ? .enabled : .disabled } ?? coreDeviceRecord?.developerMode ?? device.developerMode + switch developerMode { + case .enabled: record(.developerMode, CapabilityRow.developerMode.result(.ready, "On")) + case .disabled: record(.developerMode, CapabilityRow.developerMode.result(.attention, "Off")) + case .notApplicable: record(.developerMode, CapabilityRow.developerMode.result(.notApplicable, "Not used by this iOS version.")) + case .unknown: + if let major = device.osMajorVersion, major < 16 { + record(.developerMode, CapabilityRow.developerMode.result(.notApplicable, "iOS \(major) has no Developer Mode setting.")) + } else { + record(.developerMode, CapabilityRow.developerMode.result(lockdownReady || coreDeviceRecord != nil ? .attention : .blocked, "The device did not report Developer Mode.")) + } + } + } + + func ordered(_ results: [CapabilityRow: CapabilityResult], _ kind: DeviceKind) -> [CapabilityResult] { + CapabilityRow.rows(for: kind).map { results[$0] ?? $0.untested } + } +} + +/// Whether one action's prerequisites are met according to the latest matrix. +public enum ActionReadiness: Sendable, Equatable { + case ready + case notTested + case needsAttention([String]) + + public static func evaluate(_ action: ActionDescriptor, results: [CapabilityResult], device: Device?) -> ActionReadiness { + evaluate(requirements: action.requirements, results: results, device: device) + } + + /// Whether a set of prerequisites (an action's, or an evidence collection's) is met. + public static func evaluate(requirements: [ActionRequirement], results: [CapabilityResult], device: Device?) -> ActionReadiness { + guard device != nil else { return .notTested } + let table = Dictionary(results.map { ($0.id, $0) }, uniquingKeysWith: { $1 }) + var problems: [String] = [] + var untested = false + for requirement in requirements { + let id: String + switch requirement { + case .trustedDevice: id = CapabilityRow.pairingTrust.rawValue + case .lockdownConnection: id = CapabilityRow.deviceConnection.rawValue + case .coreDevice: id = CapabilityRow.coreDevice.rawValue + case .developerMode: id = CapabilityRow.developerMode.rawValue + case .developerServices: id = CapabilityRow.developerServices.rawValue + case .simulatorRunning: id = CapabilityRow.simulatorRunning.rawValue + case .xcode: id = CapabilityRow.xcodeTools.rawValue + case .instruments: id = CapabilityRow.instruments.rawValue + } + guard let result = table[id] else { + if device?.kind == .simulator && [.trustedDevice, .lockdownConnection, .coreDevice, .developerMode, .developerServices].contains(requirement) { continue } + untested = true + continue + } + switch result.state { + case .ready, .notApplicable: continue + case .notTested: untested = true + default: problems.append("\(requirement.label): \(result.summary)") + } + } + if !problems.isEmpty { return .needsAttention(problems) } + return untested ? .notTested : .ready + } +} diff --git a/Sources/ToolkitFeatures/Readiness/CompatibilityStore.swift b/Sources/ToolkitFeatures/Readiness/CompatibilityStore.swift new file mode 100644 index 0000000..4283dee --- /dev/null +++ b/Sources/ToolkitFeatures/Readiness/CompatibilityStore.swift @@ -0,0 +1,115 @@ +import DeviceKit +import Foundation +import ToolkitCore + +/// One completed Readiness Check against a real device, stored locally with a one-way +/// fingerprint instead of the device's identifier or name. +public struct CompatibilityObservation: Codable, Sendable, Hashable, Identifiable { + public var id: String { fingerprint + ISO8601.string(observedAt) } + public var fingerprint: String + public var observedAt: Date + public var model: String + public var productType: String? + public var osVersion: String? + public var buildVersion: String? + public var connection: String + public var toolkitVersion: String + public var states: [String: CapabilityState] + + enum CodingKeys: String, CodingKey { + case fingerprint, model, connection, states + case observedAt = "observed_at" + case productType = "product_type" + case osVersion = "os_version" + case buildVersion = "build_version" + case toolkitVersion = "toolkit_version" + } + + public init(device: Device, results: [CapabilityResult], observedAt: Date = Date()) { + fingerprint = Sanitizer.fingerprint(device.udid) + self.observedAt = observedAt + model = device.marketingName ?? device.productType ?? device.family.rawValue + productType = device.productType + osVersion = device.osVersion + buildVersion = device.buildVersion + connection = device.primaryTransport?.label ?? "Unknown" + toolkitVersion = ToolkitVersion.current + states = Dictionary(results.map { ($0.id, $0.state) }, uniquingKeysWith: { $1 }) + } +} + +/// Append-only local history at ~/Library/Application Support/iOS Developer Toolkit/Compatibility. +public struct CompatibilityStore: Sendable { + public let url: URL + + public init(url: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support/iOS Developer Toolkit/Compatibility/observations-v2.jsonl")) { + self.url = url + } + + public func append(_ observation: CompatibilityObservation) throws { + try SecureFileIO.createPrivateDirectory(at: url.deletingLastPathComponent()) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + var line = try encoder.encode(observation) + line.append(0x0A) + try SecureFileIO.append(line, to: url, synchronize: true) + } + + /// Loads observations, skipping malformed lines rather than failing the whole history. + public func load() -> [CompatibilityObservation] { + guard let text = try? String(contentsOf: url, encoding: .utf8) else { return [] } + let decoder = JSONOutput.decoder() + return text.split(separator: "\n").compactMap { try? decoder.decode(CompatibilityObservation.self, from: Data($0.utf8)) } + } + + /// The newest observation for each device. + public static func latest(_ observations: [CompatibilityObservation]) -> [CompatibilityObservation] { + var newest: [String: CompatibilityObservation] = [:] + for observation in observations where (newest[observation.fingerprint]?.observedAt ?? .distantPast) <= observation.observedAt { + newest[observation.fingerprint] = observation + } + return newest.values.sorted { ($0.model, $0.osVersion ?? "") < ($1.model, $1.osVersion ?? "") } + } + + /// A sanitized, shareable report. Fingerprints, names, and identifiers are omitted. + public static func renderJSON(_ observations: [CompatibilityObservation]) throws -> Data { + let rows: [[String: Any]] = latest(observations).map { observation in + [ + "model": observation.model, + "product_type": observation.productType ?? NSNull(), + "os_version": observation.osVersion ?? NSNull(), + "build_version": observation.buildVersion ?? NSNull(), + "connection": observation.connection, + "observed_at": ISO8601.string(observation.observedAt), + "toolkit_version": observation.toolkitVersion, + "states": observation.states.mapValues(\.rawValue), + ] + } + let document: [String: Any] = [ + "schema_version": 2, + "generated_at": ISO8601.string(Date()), + "toolkit_version": ToolkitVersion.current, + "notice": "Observed readiness on locally tested devices. Not a prediction for untested hardware or builds. Device names, identifiers, and fingerprints are omitted.", + "devices": rows, + ] + return try JSONSerialization.data(withJSONObject: document, options: [.prettyPrinted, .sortedKeys]) + } + + public static func renderMarkdown(_ observations: [CompatibilityObservation]) -> String { + let rows = CapabilityRow.rows(for: .physical) + var lines = [ + "# Real-device readiness report", + "", + "Generated \(ISO8601.string(Date())) by iOS Developer Toolkit \(ToolkitVersion.current). Observed results only; untested devices and builds are not predicted. Names, identifiers, and fingerprints are omitted.", + "", + "| Model | iOS | Build | Connection | " + rows.map(\.title).joined(separator: " | ") + " |", + "|" + String(repeating: "---|", count: 4 + rows.count), + ] + for observation in latest(observations) { + let cells = rows.map { observation.states[$0.rawValue]?.label ?? "—" } + lines.append("| \(observation.model) | \(observation.osVersion ?? "—") | \(observation.buildVersion ?? "—") | \(observation.connection) | " + cells.joined(separator: " | ") + " |") + } + return lines.joined(separator: "\n") + "\n" + } +} diff --git a/ios_developer_toolkit/assets/location-world-map.png b/Sources/ToolkitFeatures/Resources/location-world-map.png similarity index 100% rename from ios_developer_toolkit/assets/location-world-map.png rename to Sources/ToolkitFeatures/Resources/location-world-map.png diff --git a/Sources/ToolkitFeatures/Shared/KeyboardShortcuts.swift b/Sources/ToolkitFeatures/Shared/KeyboardShortcuts.swift new file mode 100644 index 0000000..5e61f25 --- /dev/null +++ b/Sources/ToolkitFeatures/Shared/KeyboardShortcuts.swift @@ -0,0 +1,57 @@ +import Foundation + +extension Workspace { + /// The workspace after this one in sidebar order, wrapping around (⌥⌘→). + public var next: Workspace { + let all = Workspace.allCases + return all[(all.firstIndex(of: self)! + 1) % all.count] + } + + /// The workspace before this one in sidebar order, wrapping around (⌥⌘←). + public var previous: Workspace { + let all = Workspace.allCases + return all[(all.firstIndex(of: self)! + all.count - 1) % all.count] + } + + /// Workspaces with a ⌘-number shortcut, in order. + public static var numbered: [Workspace] { Array(allCases.prefix(9)) } +} + +/// The keyboard shortcut reference (⌘/). The app's menu commands use the same keys; a test keeps +/// the numbered workspaces in step with `Workspace.numbered`. +public enum KeyboardShortcutReference { + public struct Entry: Sendable, Hashable, Identifiable { + public var id: String { keys + title } + public let keys: String + public let title: String + } + + public struct Section: Sendable, Hashable, Identifiable { + public var id: String { title } + public let title: String + public let entries: [Entry] + } + + public static var sections: [Section] { + [ + Section(title: "Navigate", entries: [ + Entry(keys: "⌘K", title: "Command palette: search workspaces, actions, and devices"), + Entry(keys: "⌥⌘←", title: "Previous workspace"), + Entry(keys: "⌥⌘→", title: "Next workspace"), + Entry(keys: "⌃⌘S", title: "Show or hide the sidebar"), + ] + Workspace.numbered.enumerated().map { index, workspace in + Entry(keys: "⌘\(index + 1)", title: workspace.title) + }), + Section(title: "Devices", entries: [ + Entry(keys: "⌘R", title: "Refresh devices"), + Entry(keys: "⇧⌘R", title: "Run the Readiness Check for the selected device"), + ]), + Section(title: "General", entries: [ + Entry(keys: "⌘,", title: "Settings (folders, workspace profiles, developer tools)"), + Entry(keys: "⌘/", title: "This list of keyboard shortcuts"), + Entry(keys: "Return", title: "Confirm the default button in a sheet"), + Entry(keys: "Esc", title: "Cancel or close a sheet"), + ]), + ] + } +} diff --git a/Sources/ToolkitFeatures/Shared/LegacyWorkspaceProfile.swift b/Sources/ToolkitFeatures/Shared/LegacyWorkspaceProfile.swift new file mode 100644 index 0000000..2881ed0 --- /dev/null +++ b/Sources/ToolkitFeatures/Shared/LegacyWorkspaceProfile.swift @@ -0,0 +1,196 @@ +import DeviceKit +import Foundation +import ToolkitCore + +/// Workspace profiles exported by the 0.3.x Python app (`schema_version` 1), translated into the +/// current profile. The file is checked as strictly as 0.3.x checked it; settings with no exact +/// counterpart are mapped to the closest one and explained in `WorkspaceProfile.Import.notes`. +enum LegacyWorkspaceProfile { + static let schemaVersion = 1 + + struct File: Decodable { + var schemaVersion: Int + var createdWithVersion: String + var name: String + var description: String + var defaultWorkspace: String + var settings: Settings + + struct Settings: Decodable { + var ddiSource: String + var command: Command + var appWorkflow: AppWorkflow + var backupWorkflow: BackupWorkflow + var evidenceWorkflow: EvidenceWorkflow + var locationWorkflow: LocationWorkflow + + enum CodingKeys: String, CodingKey { + case ddiSource = "ddi_source", command + case appWorkflow = "app_workflow", backupWorkflow = "backup_workflow" + case evidenceWorkflow = "evidence_workflow", locationWorkflow = "location_workflow" + } + } + + struct Command: Decodable { + var category: String + var preset: String + } + + struct AppWorkflow: Decodable { + var calculateAppSizes: Bool + var installAsDeveloperPackage: Bool + enum CodingKeys: String, CodingKey { + case calculateAppSizes = "calculate_app_sizes", installAsDeveloperPackage = "install_as_developer_package" + } + } + + struct BackupWorkflow: Decodable { + var forceFullBackup: Bool + var requireEncryption: Bool + enum CodingKeys: String, CodingKey { + case forceFullBackup = "force_full_backup", requireEncryption = "require_encryption" + } + } + + struct EvidenceWorkflow: Decodable { + var captureDurationSeconds: Int + var includeSyslog: Bool + var includeOSLog: Bool + var includePcap: Bool + var includeScreenshot: Bool + var includeCrashPull: Bool + enum CodingKeys: String, CodingKey { + case captureDurationSeconds = "capture_duration_seconds", includeSyslog = "include_syslog", includeOSLog = "include_oslog" + case includePcap = "include_pcap", includeScreenshot = "include_screenshot", includeCrashPull = "include_crash_pull" + } + } + + struct LocationWorkflow: Decodable { + var timingRandomnessMilliseconds: Int + var ignoreTimingDelays: Bool + var routeSpeedPresetKmh: Int + var routeSpeedKmh: Int + var routeIntervalSeconds: Int + var routeTraversals: Int + enum CodingKeys: String, CodingKey { + case timingRandomnessMilliseconds = "timing_randomness_ms", ignoreTimingDelays = "ignore_timing_delays" + case routeSpeedPresetKmh = "route_speed_preset_kmh", routeSpeedKmh = "route_speed_kmh" + case routeIntervalSeconds = "route_interval_seconds", routeTraversals = "route_traversals" + } + } + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version", createdWithVersion = "created_with_version", name, description + case defaultWorkspace = "default_workspace", settings + } + } + + /// 0.3.x workspace names. + static let workspaces: [String: Workspace] = [ + "Home": .overview, + "Device & DDI": .device, + "Capability Matrix": .readiness, + "Location Lab": .location, + "Live Logs": .liveLogs, + "Command Center": .actions, + "Installed Apps": .apps, + "Backup": .backup, + "Sideload IPA": .installApp, + "Evidence Capture": .evidence, + "Ecosystem Tools": .externalTools, + "Man Pages": .help, + "Scope & Safety": .safety, + ] + + static let categories = ["Device Basics", "Apps & Files", "Logging & Capture", "Developer & DVT", "Web & Discovery", "Device Actions"] + + /// Each 0.3.x Command Center preset and the action that replaces it (MIGRATION.md §9.1). + /// `nil` means the preset moved to a workspace or has no replacement. + static let presets: [String: String?] = [ + "devices": nil, "lockdown": "lockdown-values", "activation": "activation-state", "developer-mode": "developer-mode-status", + "diagnostics": "diagnostics", "battery": "battery", "ioregistry": "ioregistry", "mobilegestalt": "mobilegestalt", + "processes": "processes", "profiles": "configuration-profiles", "provisioning": "provisioning-profiles", + "orientation": "orientation", "icon-metrics": "icon-metrics", + "apps-list": nil, "apps-query": "app-query", "afc-list": "media-list", "dvt-list": nil, + "crash-list": "crash-list", "crash-pull": "crash-pull", + "syslog": nil, "oslog": nil, "pcap": "packet-capture", "btlogger": "bluetooth-capture", + "dvt-device": "device-details", "dvt-proclist": "processes", "dvt-applist": nil, "dvt-netstat": "instruments", + "dvt-pid-check": "processes", "dvt-energy": "instruments", "sysmon-system": "instruments", "sysmon-process": "instruments", + "graphics": "instruments", "notifications": nil, "core-profile": "instruments", "screenshot": "screenshot", + "core-device-info": "device-details", "core-display": "displays", "core-lock": "lock-state", "core-processes": "processes", + "core-apps": nil, "mounted-images": "mounted-images", "personalization": "personalization", + "bonjour-rsd": "bonjour", "remote-browse": nil, "web-tabs": "web-tabs", + "launch-app": "launch-app", "open-url": "open-url", "location-set": "set-location", "location-clear": "clear-location", + ] + + /// Where presets without an action went. + static let movedPresets: [String: String] = [ + "devices": "Devices are discovered automatically.", + "apps-list": "The app inventory is on the Apps page.", + "core-apps": "The app inventory is on the Apps page.", + "dvt-applist": "The app inventory is on the Apps page.", + "syslog": "Classic syslog is on the Live Logs page.", + "oslog": "Unified Logging is on the Live Logs page.", + ] + + static func translate(_ data: Data) throws -> WorkspaceProfile.Import { + let file: File + do { + file = try JSONDecoder().decode(File.self, from: data) + } catch { + throw ToolkitError.invalidInput("The file looks like a 0.3.x workspace profile but is incomplete or damaged.") + } + guard file.schemaVersion == schemaVersion else { + throw ToolkitError.invalidInput("This profile was made by an unsupported version (schema \(file.schemaVersion)).") + } + guard let workspace = workspaces[file.defaultWorkspace] else { + throw ToolkitError.invalidInput("The profile names an unknown 0.3.x workspace.") + } + let settings = file.settings + guard settings.command.category == "All categories" || categories.contains(settings.command.category) else { + throw ToolkitError.invalidInput("The profile names an unknown 0.3.x command category.") + } + guard let replacement = presets[settings.command.preset] else { + throw ToolkitError.invalidInput("The profile names an unknown 0.3.x command preset.") + } + guard ["personalized", "local-xcode"].contains(settings.ddiSource) else { + throw ToolkitError.invalidInput("The profile names an unknown 0.3.x developer-image source.") + } + let evidence = settings.evidenceWorkflow + // 0.3.x required at least 10 seconds of capture. + guard (10...3600).contains(evidence.captureDurationSeconds) else { + throw ToolkitError.invalidInput("Capture duration must be 10–3,600 seconds.") + } + guard [5, 10, 20, 40, 100].contains(settings.locationWorkflow.routeSpeedPresetKmh) else { + throw ToolkitError.invalidInput("The profile names an unknown 0.3.x route speed preset.") + } + + var notes: [String] = [] + let action = replacement.flatMap(ActionCatalog.descriptor) + let actionCategory = settings.command.category == "All categories" ? "All" : (action?.category ?? "All") + if let action { + notes.append("Command Center preset “\(settings.command.preset)” → action “\(action.title)” (\(action.category)).") + } else { + notes.append("Command Center preset “\(settings.command.preset)” has no action here. \(movedPresets[settings.command.preset] ?? "See MIGRATION.md §9.1 for why.")") + } + notes.append("Developer-image source “\(settings.ddiSource)” → \(DeveloperImageMechanism.native.label). Images come from Xcode or a folder you add; nothing is downloaded.") + if evidence.includeOSLog { + notes.append("DVT OSLog → Unified Logging (no developer image needed).") + } + + var profile = WorkspaceProfile( + name: file.name, + description: file.description, + defaultWorkspace: workspace, + actionCategory: actionCategory, + selectedAction: action?.id, + developerImageMechanism: .native, + apps: .init(calculateSizes: settings.appWorkflow.calculateAppSizes, includeSystemApps: false, installAsDeveloperPackage: settings.appWorkflow.installAsDeveloperPackage), + backup: .init(forceFullBackup: settings.backupWorkflow.forceFullBackup, requireEncryption: settings.backupWorkflow.requireEncryption), + evidence: CollectionOptions(durationSeconds: evidence.captureDurationSeconds, includeClassicSyslog: evidence.includeSyslog, includeUnifiedLogs: evidence.includeOSLog, includePacketCapture: evidence.includePcap, includeScreenshot: evidence.includeScreenshot, includeCrashReports: evidence.includeCrashPull), + location: .init(timingJitterMilliseconds: settings.locationWorkflow.timingRandomnessMilliseconds, ignoreRecordedTiming: settings.locationWorkflow.ignoreTimingDelays, routeSpeedKmh: settings.locationWorkflow.routeSpeedKmh, routeIntervalSeconds: settings.locationWorkflow.routeIntervalSeconds, routeTraversals: settings.locationWorkflow.routeTraversals) + ) + profile.createdWithVersion = file.createdWithVersion + return WorkspaceProfile.Import(profile: try profile.validated(), legacyVersion: try WorkspaceProfile.text(file.createdWithVersion, label: "version", maximum: 40, allowEmpty: false), notes: notes) + } +} diff --git a/Sources/ToolkitFeatures/Shared/ReconnectGuide.swift b/Sources/ToolkitFeatures/Shared/ReconnectGuide.swift new file mode 100644 index 0000000..188c5ac --- /dev/null +++ b/Sources/ToolkitFeatures/Shared/ReconnectGuide.swift @@ -0,0 +1,60 @@ +import DeviceKit +import Foundation + +/// The guided reconnect (0.3.x “Reconnect & Retry…”): step-by-step instructions, then a window +/// in which the app watches macOS's device service for a USB device to appear. Discovery is +/// event-driven, so nothing is polled or restarted; this only interprets what arrives. +public enum ReconnectGuide { + public static let window: Duration = .seconds(30) + + public static let steps = [ + "Unlock the iPhone or iPad and keep it on the Home Screen.", + "If iOS asks, allow the accessory to connect. Where available, check Settings › Privacy & Security › Wired Accessories if the Mac is not allowed.", + "Disconnect the cable and reconnect it directly to the Mac with a data-capable cable. Avoid hubs.", + "Click Allow if macOS asks to connect the accessory, then tap Trust on the device and enter its passcode.", + ] + + /// What the app never does while reconnecting. + public static let boundary = "The toolkit never uses sudo, deletes pairing records, restarts macOS's device service, or changes the device." + + public enum Outcome: Equatable, Sendable { + case waiting + /// A trusted device is connected over USB. + case connected(name: String) + /// A device is connected but has not trusted this Mac yet. + case awaitingTrust(name: String) + case timedOut + + public var headline: String { + switch self { + case .waiting: return "Watching for the device…" + case .connected(let name): return "\(name) is connected and trusts this Mac." + case .awaitingTrust(let name): return "\(name) is connected. Tap Trust on the device." + case .timedOut: return "No device appeared." + } + } + + public var nextStep: String? { + switch self { + case .waiting, .connected: return nil + case .awaitingTrust: return "Unlock the device and tap Trust, then enter its passcode. You can also select the device in the Finder sidebar and click Trust." + case .timedOut: return "Unlock the device, try another data-capable cable or Mac port without a hub, and complete Trust in the Finder sidebar. Connection diagnostics on the Device page shows whether macOS's device service is answering; if it is not, restart the Mac." + } + } + + public var isFinished: Bool { self != .waiting } + } + + /// Interprets the devices currently seen over USB. A device already connected and trusted when + /// the window opened counts as connected; `timeElapsed` ends the window. + public static func evaluate(devices: [Device], timeElapsed: Bool) -> Outcome { + let usb = devices.filter { $0.kind == .physical && $0.transports.contains(.usb) } + if let trusted = usb.first(where: { $0.pairingState == .paired }) { + return .connected(name: trusted.name) + } + if let untrusted = usb.first { + return timeElapsed || untrusted.pairingState == .unpaired ? .awaitingTrust(name: untrusted.name) : .waiting + } + return timeElapsed ? .timedOut : .waiting + } +} diff --git a/Sources/ToolkitFeatures/Shared/SupportBundle.swift b/Sources/ToolkitFeatures/Shared/SupportBundle.swift new file mode 100644 index 0000000..cde05af --- /dev/null +++ b/Sources/ToolkitFeatures/Shared/SupportBundle.swift @@ -0,0 +1,99 @@ +import Foundation +import ToolkitCore + +/// What goes into a sanitized support bundle. Only aggregate, non-identifying state. +public struct SupportBundleContext: Sendable { + public var workspace: String + public var detectedDeviceCount: Int + public var selectedDeviceKind: String? + public var discoveryStatus: [String: String] + public var capabilityCounts: [String: Int] + public var toolchainReport: String + public var developerTools: [String: String] + public var statuses: [String: String] + /// Literal values (device names, serials) to remove wherever they appear. + public var redactions: [String] + public var diagnosticLog: [DiagnosticLogEntry] + + public init(workspace: String, detectedDeviceCount: Int, selectedDeviceKind: String?, discoveryStatus: [String: String], capabilityCounts: [String: Int], toolchainReport: String, developerTools: [String: String], statuses: [String: String], redactions: [String], diagnosticLog: [DiagnosticLogEntry]) { + self.workspace = workspace + self.detectedDeviceCount = detectedDeviceCount + self.selectedDeviceKind = selectedDeviceKind + self.discoveryStatus = discoveryStatus + self.capabilityCounts = capabilityCounts + self.toolchainReport = toolchainReport + self.developerTools = developerTools + self.statuses = statuses + self.redactions = redactions + self.diagnosticLog = diagnosticLog + } +} + +/// Builds a local, reviewable ZIP for bug reports. Nothing is uploaded. +public enum SupportBundle { + public static let readme = """ + iOS Developer Toolkit sanitized support bundle + + This archive was generated locally and is never uploaded by the application. It contains app and + macOS version information, aggregate readiness states, sanitized status summaries, the toolchain + check, and a sanitized copy of the app's own diagnostic log. + + It intentionally excludes device names, UDIDs, serial numbers, pairing records, backups, evidence + cases, screenshots, packet captures, raw device logs, crash reports, app packages, passwords, and + anything you typed. Common identifiers, paths, network addresses, and email addresses are redacted. + Review the archive before sharing it. + + """ + + public static func entries(for context: SupportBundleContext, now: Date = Date()) throws -> [(String, Data)] { + func clean(_ text: String) -> String { Sanitizer.sanitize(text, redactions: context.redactions, limit: 200_000) } + func json(_ object: Any) throws -> Data { + var data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + data.append(0x0A) + return data + } + let info = ProcessInfo.processInfo + #if arch(arm64) + let architecture = "arm64" + #else + let architecture = "x86_64" + #endif + let environment: [String: Any] = [ + "toolkit_version": ToolkitVersion.current, + "created_at": ISO8601.string(now), + "macos_version": info.operatingSystemVersionString, + "architecture": architecture, + "developer_tools": context.developerTools.mapValues(clean), + ] + let summary: [String: Any] = [ + "workspace": context.workspace, + "detected_device_count": context.detectedDeviceCount, + "selected_device_kind": context.selectedDeviceKind ?? NSNull(), + "discovery_status": context.discoveryStatus.mapValues(clean), + "capability_state_counts": context.capabilityCounts, + "statuses": context.statuses.mapValues { clean($0).prefix(2_000) }.mapValues(String.init), + ] + let log = context.diagnosticLog.isEmpty ? "No diagnostic log entries were available.\n" : clean(DiagnosticLogReader.render(context.diagnosticLog)) + "\n" + var entries: [(String, Data)] = [ + ("README.txt", Data(readme.utf8)), + ("environment.json", try json(environment)), + ("context.json", try json(summary)), + ("toolchain-check.txt", Data((context.toolchainReport.isEmpty ? "The toolchain check has not been run in this session.\n" : clean(context.toolchainReport)).utf8)), + ("diagnostic-log.txt", Data(log.utf8)), + ] + let hashes = Dictionary(entries.map { ($0.0, SecureFileIO.sha256(of: $0.1)) }, uniquingKeysWith: { $1 }) + entries.append(("SHA256SUMS.json", try json(["created_at": ISO8601.string(now), "entries": hashes]))) + return entries + } + + /// Writes the bundle to a new `.zip` file (never overwriting). Returns the entry names. + @discardableResult + public static func write(to destination: URL, context: SupportBundleContext) throws -> [String] { + guard destination.pathExtension.lowercased() == "zip" else { throw ToolkitError.invalidInput("Support bundles are saved as .zip files.") } + var writer = ZipWriter() + let entries = try entries(for: context) + for (name, data) in entries { try writer.add(name: name, data: data) } + try SecureFileIO.writeNewFile(writer.finalized(), to: destination) + return entries.map(\.0) + } +} diff --git a/Sources/ToolkitFeatures/Shared/ToolchainCheck.swift b/Sources/ToolkitFeatures/Shared/ToolchainCheck.swift new file mode 100644 index 0000000..af80b3b --- /dev/null +++ b/Sources/ToolkitFeatures/Shared/ToolchainCheck.swift @@ -0,0 +1,205 @@ +import DeviceKit +import Foundation +import ToolkitCore + +/// Verifies that every Apple command-line route the app depends on exists in the installed +/// Xcode, with the options the app passes. It runs only `help` commands and never contacts a +/// device. (This replaces the earlier "Guided Command Drift" check for pymobiledevice3.) +public enum ToolchainCheck { + public struct Route: Sendable, Hashable, Identifiable { + public var id: String { "\(tool.rawValue) \(path.joined(separator: " "))" } + public var tool: XcodeTool + public var path: [String] + public var requiredOptions: [String] + public var usedFor: String + /// Present in Xcode 27 but not in Xcode 26.6 (seen on the CI runner): older Xcode versions + /// lack the command or option, so the feature needs a newer Xcode rather than being broken. + public var needsRecentXcode = false + } + + public enum State: String, Sendable { + case available = "Available" + case changed = "Changed" + case missing = "Missing" + case failed = "Could not check" + case needsNewerXcode = "Needs a newer Xcode" + } + + public struct Result: Sendable, Hashable, Identifiable { + public var id: String { route.id } + public var route: Route + public var state: State + public var detail: String + } + + public static let routes: [Route] = [ + Route(tool: .devicectl, path: ["list", "devices"], requiredOptions: ["--json-output", "--timeout"], usedFor: "Device discovery"), + Route(tool: .devicectl, path: ["device", "info", "details"], requiredOptions: ["--device"], usedFor: "Device details, Readiness Check"), + Route(tool: .devicectl, path: ["device", "info", "apps"], requiredOptions: ["--device", "--include-default-apps"], usedFor: "Apps"), + Route(tool: .devicectl, path: ["device", "info", "processes"], requiredOptions: ["--device"], usedFor: "Running processes, Evidence"), + Route(tool: .devicectl, path: ["device", "info", "lockState"], requiredOptions: ["--device"], usedFor: "Readiness Check"), + Route(tool: .devicectl, path: ["device", "info", "ddiServices"], requiredOptions: ["--auto-mount-ddis", "--no-auto-mount-ddis"], usedFor: "Developer services"), + Route(tool: .devicectl, path: ["device", "info", "displays"], requiredOptions: ["--device"], usedFor: "Displays action"), + Route(tool: .devicectl, path: ["device", "profile", "list"], requiredOptions: ["--type"], usedFor: "Configuration profiles", needsRecentXcode: true), + Route(tool: .devicectl, path: ["device", "capture", "screenshot"], requiredOptions: ["--destination"], usedFor: "Screenshot", needsRecentXcode: true), + Route(tool: .devicectl, path: ["device", "sysdiagnose"], requiredOptions: ["--destination"], usedFor: "Sysdiagnose"), + Route(tool: .devicectl, path: ["device", "install", "app"], requiredOptions: ["--device"], usedFor: "Install App"), + Route(tool: .devicectl, path: ["device", "uninstall", "app"], requiredOptions: ["--device"], usedFor: "Remove app"), + Route(tool: .devicectl, path: ["device", "process", "launch"], requiredOptions: ["--terminate-existing"], usedFor: "Launch app"), + Route(tool: .devicectl, path: ["device", "process", "terminate"], requiredOptions: ["--pid"], usedFor: "Stop a process"), + Route(tool: .devicectl, path: ["device", "process", "openURL"], requiredOptions: ["--device"], usedFor: "Open URL", needsRecentXcode: true), + Route(tool: .devicectl, path: ["device", "simulate", "location", "coordinate"], requiredOptions: ["--latitude", "--longitude"], usedFor: "Location Lab", needsRecentXcode: true), + Route(tool: .devicectl, path: ["device", "simulate", "location", "route"], requiredOptions: ["--route-file"], usedFor: "Location Lab routes", needsRecentXcode: true), + Route(tool: .devicectl, path: ["device", "simulate", "location", "clear"], requiredOptions: ["--device"], usedFor: "Location Lab", needsRecentXcode: true), + Route(tool: .devicectl, path: ["device", "reboot"], requiredOptions: ["--device"], usedFor: "Restart device"), + Route(tool: .devicectl, path: ["manage", "ddis", "update"], requiredOptions: [], usedFor: "Update developer images"), + Route(tool: .devicectl, path: ["list", "preferredDDI"], requiredOptions: ["--platform"], usedFor: "Preferred developer image"), + Route(tool: .simctl, path: ["list"], requiredOptions: ["-j"], usedFor: "Simulator discovery"), + Route(tool: .simctl, path: ["location"], requiredOptions: ["start", "clear", "set"], usedFor: "Simulator location"), + Route(tool: .simctl, path: ["io"], requiredOptions: ["screenshot"], usedFor: "Simulator screenshot"), + Route(tool: .simctl, path: ["bootstatus"], requiredOptions: ["-b"], usedFor: "Start simulator"), + Route(tool: .simctl, path: ["launch"], requiredOptions: ["--terminate-running-process"], usedFor: "Simulator launch"), + Route(tool: .simctl, path: ["spawn"], requiredOptions: [], usedFor: "Simulator logs"), + Route(tool: .xctrace, path: ["record"], requiredOptions: ["--template", "--device", "--time-limit", "--all-processes", "--no-prompt"], usedFor: "Instruments recordings"), + ] + + public static func helpRequest(_ route: Route) throws -> CommandRequest { + switch route.tool { + case .devicectl, .simctl: return try route.tool.request(["help"] + route.path, timeout: 15, displayName: "\(route.tool.rawValue) help \(route.path.joined(separator: " "))") + case .xctrace: return try route.tool.request(["help"] + route.path, timeout: 15, displayName: "xctrace help \(route.path.joined(separator: " "))") + case .xed: return try route.tool.request(["--help"], timeout: 15) + } + } + + public static func evaluate(_ route: Route, helpText: String, succeeded: Bool) -> Result { + let text = helpText.lowercased() + let newerXcode = "Not in this Xcode; update Xcode to use \(route.usedFor) through the Xcode device service." + if !succeeded || text.contains("unknown subcommand") || text.contains("error: unexpected argument") || text.contains("unrecognized subcommand") { + if route.needsRecentXcode { return Result(route: route, state: .needsNewerXcode, detail: newerXcode) } + return Result(route: route, state: .missing, detail: "The installed \(route.tool.rawValue) does not offer this command.") + } + let missing = route.requiredOptions.filter { !helpText.contains($0) } + if !missing.isEmpty { + if route.needsRecentXcode { return Result(route: route, state: .needsNewerXcode, detail: newerXcode + " (missing option(s): \(missing.joined(separator: ", ")))") } + return Result(route: route, state: .changed, detail: "Missing option(s): \(missing.joined(separator: ", "))") + } + return Result(route: route, state: .available, detail: "") + } + + /// Why a tool cannot be checked at all (Xcode missing or not selected), or nil when it runs. + static func unavailableReason(_ tool: XcodeTool, in status: DeveloperToolsStatus) -> String? { + let availability: DeveloperToolsStatus.Availability + switch tool { + case .devicectl: availability = status.devicectl + case .simctl: availability = status.simctl + case .xctrace: availability = status.xctrace + case .xed: return nil + } + if case .unresponsive(let reason) = availability { + return "\(tool.rawValue) did not answer in time: \(reason) The Mac may be busy; run the check again." + } + guard case .missing(let reason) = availability else { return nil } + if status.developerDirectory == nil || status.isCommandLineToolsOnly { + return "Xcode is not installed or not selected (only the Command Line Tools are available). Install Xcode and open it once." + } + return "\(tool.rawValue) could not run: \(reason)" + } + + public static func run(runner: CommandRunning, progress: @Sendable (Int, Int) -> Void = { _, _ in }) async -> [Result] { + let tools = await DeveloperToolsStatus.probe(runner: runner) + var results: [Result] = [] + for (index, route) in routes.enumerated() { + if Task.isCancelled { break } + if let reason = unavailableReason(route.tool, in: tools) { + results.append(Result(route: route, state: .missing, detail: reason)) + progress(index + 1, routes.count) + continue + } + do { + let output = try await runner.run(try helpRequest(route)) + results.append(evaluate(route, helpText: output.standardOutputText + output.standardErrorText, succeeded: output.succeeded || !output.standardOutputText.isEmpty)) + } catch { + results.append(Result(route: route, state: .failed, detail: (error as? ToolkitError)?.message ?? error.localizedDescription)) + } + progress(index + 1, routes.count) + } + return results + } + + public static func render(_ results: [Result]) -> String { + var lines = ["Toolchain check — \(ISO8601.string(Date()))", ""] + for state in [State.missing, .changed, .failed, .needsNewerXcode, .available] { + let matching = results.filter { $0.state == state } + guard !matching.isEmpty else { continue } + lines.append("\(state.rawValue) (\(matching.count)):") + lines += matching.map { " \($0.route.id) — \($0.route.usedFor)\($0.detail.isEmpty ? "" : " — \($0.detail)")" } + lines.append("") + } + return lines.joined(separator: "\n") + } +} + +/// Built-in reference for the Apple tools the app uses. +public enum ToolReference { + public struct Topic: Sendable, Hashable, Identifiable { + public var id: String { "\(tool.rawValue) \(path.joined(separator: " "))" } + public var tool: XcodeTool + public var path: [String] + public var title: String { path.isEmpty ? tool.rawValue : "\(tool.rawValue) \(path.joined(separator: " "))" } + } + + /// The Advanced Mode command line for a `devicectl` topic (Advanced Mode runs only + /// `devicectl`); `nil` for other tools and for the tool itself. + public static func advancedModeCommand(for topic: Topic) -> String? { + guard topic.tool == .devicectl, !topic.path.isEmpty else { return nil } + return topic.path.joined(separator: " ") + } + + public static let roots: [Topic] = [ + Topic(tool: .devicectl, path: []), + Topic(tool: .simctl, path: []), + Topic(tool: .xctrace, path: []), + ] + + public static func helpText(_ topic: Topic, runner: CommandRunning) async throws -> String { + let arguments: [String] + switch topic.tool { + case .xed: arguments = ["--help"] + default: arguments = ["help"] + topic.path + } + let result = try await runner.run(try topic.tool.request(arguments, timeout: 15, displayName: topic.title + " help")) + let text = result.standardOutputText.isEmpty ? result.standardErrorText : result.standardOutputText + guard !text.isEmpty else { + throw ToolkitError(.commandFailed, message: "No help is available for \(topic.title).", technicalDetail: result.technicalSummary) + } + return text + } + + /// Parses the subcommand list from a help page (devicectl's SUBCOMMANDS section, simctl's + /// "Subcommands:" list, or xctrace's command list). + public static func children(of topic: Topic, helpText: String) -> [Topic] { + var names: [String] = [] + var inSection = false + for rawLine in helpText.split(separator: "\n", omittingEmptySubsequences: false) { + let line = String(rawLine) + let trimmed = line.trimmingCharacters(in: .whitespaces) + if ["SUBCOMMANDS:", "Subcommands:", "commands:"].contains(trimmed) { + inSection = true + continue + } + guard inSection else { continue } + if trimmed.isEmpty { continue } + if trimmed.hasPrefix("See ") || (!line.hasPrefix(" ") && !line.hasPrefix("\t")) { + inSection = false + continue + } + let indent = line.prefix { $0 == " " || $0 == "\t" }.count + guard indent <= 8, let first = trimmed.split(separator: " ").first else { continue } + let name = String(first) + if name.range(of: #"^[A-Za-z][A-Za-z0-9-]*$"#, options: .regularExpression) != nil, name != "help", !names.contains(name) { + names.append(name) + } + } + return names.map { Topic(tool: topic.tool, path: topic.path + [$0]) } + } +} diff --git a/Sources/ToolkitFeatures/Shared/ToolkitResources.swift b/Sources/ToolkitFeatures/Shared/ToolkitResources.swift new file mode 100644 index 0000000..ae31fe9 --- /dev/null +++ b/Sources/ToolkitFeatures/Shared/ToolkitResources.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Resources bundled with the feature library. +public enum ToolkitResources { + /// Public-domain Natural Earth 1:110m land map, equirectangular (2:1). + public static var worldMapURL: URL? { + Bundle.module.url(forResource: "location-world-map", withExtension: "png") + } +} diff --git a/Sources/ToolkitFeatures/Shared/Workspace.swift b/Sources/ToolkitFeatures/Shared/Workspace.swift new file mode 100644 index 0000000..c43240b --- /dev/null +++ b/Sources/ToolkitFeatures/Shared/Workspace.swift @@ -0,0 +1,135 @@ +import DeviceKit +import Foundation + +/// The app's navigation destinations. Shared by the UI, workspace profiles, and the support +/// bundle so their identifiers cannot drift apart. +public enum Workspace: String, CaseIterable, Codable, Sendable, Identifiable { + case overview + case device + case readiness + case apps + case installApp + case location + case liveLogs + case actions + case backup + case evidence + case externalTools + case activity + case help + case safety + + public var id: String { rawValue } + + public var title: String { + switch self { + case .overview: return "Overview" + case .device: return "Device" + case .readiness: return "Readiness Check" + case .apps: return "Apps" + case .installApp: return "Install App" + case .location: return "Location Lab" + case .liveLogs: return "Live Logs" + case .actions: return "Actions" + case .backup: return "Backup" + case .evidence: return "Evidence Capture" + case .externalTools: return "External Tools" + case .activity: return "Session Activity" + case .help: return "Tool Reference" + case .safety: return "Scope & Safety" + } + } + + public var symbolName: String { + switch self { + case .overview: return "square.grid.2x2" + case .device: return "iphone" + case .readiness: return "checklist" + case .apps: return "app.badge" + case .installApp: return "square.and.arrow.down.on.square" + case .location: return "location" + case .liveLogs: return "text.alignleft" + case .actions: return "bolt" + case .backup: return "externaldrive.badge.timemachine" + case .evidence: return "archivebox" + case .externalTools: return "wrench.and.screwdriver" + case .activity: return "clock.arrow.circlepath" + case .help: return "book" + case .safety: return "shield.lefthalf.filled" + } + } + + public var subtitle: String { + switch self { + case .overview: return "What you can do and where to start" + case .device: return "Identity, trust, Developer Mode, and developer services" + case .readiness: return "Check every prerequisite before you start" + case .apps: return "Installed apps: search, sizes, launch, and remove" + case .installApp: return "Inspect an .ipa or .app, then install it" + case .location: return "Simulate coordinates, routes, and GPX tracks" + case .liveLogs: return "Stream Unified Logs or syslog with filters and findings" + case .actions: return "Guided device and developer actions" + case .backup: return "Encrypted local backups and forensic handoffs" + case .evidence: return "Hashed, documented evidence collection" + case .externalTools: return "Optional tools you install separately" + case .activity: return "Everything this session has run" + case .help: return "Built-in help for the Apple tools the app uses" + case .safety: return "What the app can and cannot do" + } + } + + public enum Group: String, CaseIterable, Sendable { + case start = "Start" + case device = "Device" + case develop = "Develop" + case data = "Data & Evidence" + case reference = "Reference" + } + + public var group: Group { + switch self { + case .overview: return .start + case .device, .readiness, .apps, .installApp: return .device + case .location, .liveLogs, .actions: return .develop + case .backup, .evidence, .externalTools: return .data + case .activity, .help, .safety: return .reference + } + } + + /// Whether the workspace can be used with this kind of device. + public func supports(_ kind: DeviceKind?) -> Bool { + switch self { + case .backup, .evidence: return kind == .physical || kind == nil || kind == .demo + default: return true + } + } +} + +/// A clearly labelled simulated device for walkthroughs, screenshots, and UI tests. No device +/// operation can run against it. +public enum DemoMode { + public static let identifier = "DEMO-IPHONE-17-PRO" + public static let banner = "Demo Mode — this is a simulated iPhone for walkthroughs and screenshots. No device is connected and device actions are disabled." + + public static var device: Device { + Device( + kind: .demo, + udid: identifier, + name: "Demo iPhone (simulated)", + productType: "iPhone18,1", + marketingName: "iPhone 17 Pro", + family: .iPhone, + osName: "iOS", + osVersion: "26.0", + buildVersion: "23A341", + architecture: "arm64e", + hardwareModel: "D93AP", + transports: [.usb], + pairingState: .paired, + developerMode: .enabled, + ddiServicesAvailable: true, + tunnelState: "connected", + sources: [.demo] + ) + } +} diff --git a/Sources/ToolkitFeatures/Shared/WorkspaceProfile.swift b/Sources/ToolkitFeatures/Shared/WorkspaceProfile.swift new file mode 100644 index 0000000..51f1648 --- /dev/null +++ b/Sources/ToolkitFeatures/Shared/WorkspaceProfile.swift @@ -0,0 +1,178 @@ +import DeviceKit +import Foundation +import ToolkitCore + +/// Shareable workflow defaults. A profile never contains device identity, credentials, paths, +/// coordinates, action parameters, case text, or captured output, and importing one only changes +/// control defaults — it never runs anything. +public struct WorkspaceProfile: Codable, Sendable, Hashable { + public struct AppPreferences: Codable, Sendable, Hashable { + public var calculateSizes: Bool + public var includeSystemApps: Bool + public var installAsDeveloperPackage: Bool + public init(calculateSizes: Bool = true, includeSystemApps: Bool = false, installAsDeveloperPackage: Bool = false) { + self.calculateSizes = calculateSizes + self.includeSystemApps = includeSystemApps + self.installAsDeveloperPackage = installAsDeveloperPackage + } + } + + public struct BackupPreferences: Codable, Sendable, Hashable { + public var forceFullBackup: Bool + public var requireEncryption: Bool + public init(forceFullBackup: Bool = false, requireEncryption: Bool = true) { + self.forceFullBackup = forceFullBackup + self.requireEncryption = requireEncryption + } + } + + public struct LocationPreferences: Codable, Sendable, Hashable { + public var timingJitterMilliseconds: Int + public var ignoreRecordedTiming: Bool + public var routeSpeedKmh: Int + public var routeIntervalSeconds: Int + public var routeTraversals: Int + public init(timingJitterMilliseconds: Int = 0, ignoreRecordedTiming: Bool = false, routeSpeedKmh: Int = 5, routeIntervalSeconds: Int = 2, routeTraversals: Int = 1) { + self.timingJitterMilliseconds = timingJitterMilliseconds + self.ignoreRecordedTiming = ignoreRecordedTiming + self.routeSpeedKmh = routeSpeedKmh + self.routeIntervalSeconds = routeIntervalSeconds + self.routeTraversals = routeTraversals + } + } + + public var schemaVersion: Int + public var createdWithVersion: String + public var name: String + public var description: String + public var defaultWorkspace: Workspace + public var actionCategory: String + /// The action selected on the Actions page (an `ActionCatalog` identifier). + public var selectedAction: String? + /// How the Device page mounts the developer image. + public var developerImageMechanism: DeveloperImageMechanism? + public var apps: AppPreferences + public var backup: BackupPreferences + public var evidence: CollectionOptions + public var location: LocationPreferences + + public static let currentSchemaVersion = 2 + public static let maximumFileBytes = 64 * 1024 + + public init(name: String, description: String = "", defaultWorkspace: Workspace = .overview, actionCategory: String = "All", selectedAction: String? = nil, developerImageMechanism: DeveloperImageMechanism? = nil, apps: AppPreferences = AppPreferences(), backup: BackupPreferences = BackupPreferences(), evidence: CollectionOptions = CollectionOptions(), location: LocationPreferences = LocationPreferences()) { + schemaVersion = Self.currentSchemaVersion + createdWithVersion = ToolkitVersion.current + self.name = name + self.description = description + self.defaultWorkspace = defaultWorkspace + self.actionCategory = actionCategory + self.selectedAction = selectedAction + self.developerImageMechanism = developerImageMechanism + self.apps = apps + self.backup = backup + self.evidence = evidence + self.location = location + } + + /// Validates every field; returns a normalized copy. + public func validated() throws -> WorkspaceProfile { + var copy = self + guard schemaVersion == Self.currentSchemaVersion else { throw ToolkitError.invalidInput("This profile was made by an unsupported version (schema \(schemaVersion)).") } + copy.name = try Self.text(name, label: "name", maximum: 100, allowEmpty: false) + copy.description = try Self.text(description, label: "description", maximum: 500, allowEmpty: true) + copy.createdWithVersion = try Self.text(createdWithVersion, label: "version", maximum: 40, allowEmpty: false) + guard actionCategory == "All" || ActionCatalog.categories.contains(actionCategory) else { + throw ToolkitError.invalidInput("The profile names an unknown action category.") + } + if let selectedAction { + guard let action = ActionCatalog.descriptor(selectedAction) else { + throw ToolkitError.invalidInput("The profile names an unknown action.") + } + guard actionCategory == "All" || action.category == actionCategory else { + throw ToolkitError.invalidInput("The profile's selected action is not in its action category.") + } + } + copy.evidence = try evidence.validated() + guard (0...60_000).contains(location.timingJitterMilliseconds) else { throw ToolkitError.invalidInput("Timing randomness must be 0–60,000 ms.") } + guard (1...300).contains(location.routeSpeedKmh) else { throw ToolkitError.invalidInput("Route speed must be 1–300 km/h.") } + guard (1...60).contains(location.routeIntervalSeconds) else { throw ToolkitError.invalidInput("Route interval must be 1–60 seconds.") } + guard (1...20).contains(location.routeTraversals) else { throw ToolkitError.invalidInput("Route traversals must be 1–20.") } + return copy + } + + static func text(_ value: String, label: String, maximum: Int, allowEmpty: Bool) throws -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard allowEmpty || !trimmed.isEmpty else { throw ToolkitError.invalidInput("The profile \(label) is required.") } + guard trimmed.count <= maximum else { throw ToolkitError.invalidInput("The profile \(label) must be \(maximum) characters or fewer.") } + guard !trimmed.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { + throw ToolkitError.invalidInput("The profile \(label) contains control characters.") + } + guard Sanitizer.sanitize(trimmed, limit: Int.max) == trimmed else { + throw ToolkitError.invalidInput("The profile \(label) looks like it contains a path, account, device, or network identifier. Remove it before sharing.") + } + return trimmed + } + + public func encoded() throws -> Data { + try JSONOutput.encode(try validated()) + } + + public static func decode(_ data: Data) throws -> WorkspaceProfile { + guard data.count <= maximumFileBytes else { throw ToolkitError.invalidInput("The profile file is too large.") } + do { + return try JSONOutput.decoder().decode(WorkspaceProfile.self, from: data).validated() + } catch let error as ToolkitError { + throw error + } catch { + throw ToolkitError.invalidInput("The file is not a valid workspace profile.") + } + } + + /// A profile read from a file, with notes on anything that was translated. + public struct Import: Sendable { + public var profile: WorkspaceProfile + /// Set when the file was exported by the 0.3.x Python app. + public var legacyVersion: String? + /// Plain-language notes on how 0.3.x settings were translated. + public var notes: [String] + } + + /// Reads a profile exported by this app or by the 0.3.x Python app (schema 1). + public static func importing(_ data: Data) throws -> Import { + guard data.count <= maximumFileBytes else { throw ToolkitError.invalidInput("The profile file is too large.") } + struct Schema: Decodable { + var schemaVersion: Int? + var legacySchemaVersion: Int? + enum CodingKeys: String, CodingKey { + case schemaVersion + case legacySchemaVersion = "schema_version" + } + } + guard let schema = try? JSONDecoder().decode(Schema.self, from: data) else { + throw ToolkitError.invalidInput("The file is not a valid workspace profile.") + } + if schema.schemaVersion == nil, let legacy = schema.legacySchemaVersion { + guard legacy == LegacyWorkspaceProfile.schemaVersion else { + throw ToolkitError.invalidInput("This profile was made by an unsupported version (schema \(legacy)).") + } + return try LegacyWorkspaceProfile.translate(data) + } + return Import(profile: try decode(data), legacyVersion: nil, notes: []) + } + + /// The exact, human-readable preview shown before import or export. + public var preview: String { + [ + "Profile: \(name)", + description.isEmpty ? nil : "Description: \(description)", + "Opens to: \(defaultWorkspace.title)", + "Actions category: \(actionCategory)", + selectedAction.flatMap(ActionCatalog.descriptor).map { "Selected action: \($0.title)" }, + developerImageMechanism.map { "Developer image: mount with \($0.label)" }, + "Apps: sizes \(apps.calculateSizes ? "on" : "off"), system apps \(apps.includeSystemApps ? "shown" : "hidden"), developer package installs \(apps.installAsDeveloperPackage ? "on" : "off")", + "Backup: \(backup.forceFullBackup ? "always full" : "incremental when possible"), encryption \(backup.requireEncryption ? "required" : "optional")", + "Evidence: \(evidence.durationSeconds)s streams; syslog \(evidence.includeClassicSyslog ? "on" : "off"), unified logs \(evidence.includeUnifiedLogs ? "on" : "off"), packet capture \(evidence.includePacketCapture ? "on" : "off"), screenshot \(evidence.includeScreenshot ? "on" : "off"), crash reports \(evidence.includeCrashReports ? "on" : "off")", + "Location: route \(location.routeSpeedKmh) km/h every \(location.routeIntervalSeconds)s × \(location.routeTraversals); GPX timing \(location.ignoreRecordedTiming ? "ignored" : "kept") with ±\(location.timingJitterMilliseconds) ms", + ].compactMap { $0 }.joined(separator: "\n") + } +} diff --git a/Sources/idt/IDT.swift b/Sources/idt/IDT.swift new file mode 100644 index 0000000..050fdba --- /dev/null +++ b/Sources/idt/IDT.swift @@ -0,0 +1,313 @@ +import ArgumentParser +import DeviceKit +import Foundation +import ToolkitCore +import ToolkitFeatures + +@main +struct IDT: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "idt", + abstract: "iOS Developer Toolkit command-line tools.", + discussion: "Device identifiers are always explicit: commands that touch a device require --udid.", + version: ToolkitVersion.current, + subcommands: [Devices.self, Collect.self, InspectIPA.self, Readiness.self, Toolchain.self, DDI.self] + ) +} + +/// Maps errors to plain messages and exit codes. +func report(_ error: Error) -> ExitCode { + if let toolkitError = error as? ToolkitError { + var message = "error: \(toolkitError.message)" + if let recovery = toolkitError.recovery { message += "\n \(recovery)" } + if let detail = toolkitError.technicalDetail { message += "\n details: \(detail.replacingOccurrences(of: "\n", with: "\n "))" } + FileHandle.standardError.write(Data((message + "\n").utf8)) + } else { + FileHandle.standardError.write(Data("error: \(error.localizedDescription)\n".utf8)) + } + return ExitCode.failure +} + +/// Waits for one discovery pass and returns the merged device list. +func discoverDevices(includeSimulators: Bool) async -> DiscoverySnapshot { + let discovery = DeviceDiscovery(configuration: .init(simulators: includeSimulators ? SimulatorClient() : nil)) + await discovery.refreshAll() + let usbmux = await discovery.current.usbmux + if usbmux.isAvailable { + // Give lockdown enrichment a moment to fill in names and trust state. + try? await Task.sleep(nanoseconds: 1_500_000_000) + } + return await discovery.current +} + +func resolve(udid: String) async throws -> Device { + let snapshot = await discoverDevices(includeSimulators: true) + let normalized = USBMuxDevice.normalizedUDID(udid) + guard let device = snapshot.devices.first(where: { $0.udid.caseInsensitiveCompare(normalized) == .orderedSame }) else { + throw ToolkitError(.deviceNotFound, message: "No connected device or simulator has UDID \(udid).", recovery: "Run `idt devices` to list what is connected.") + } + return device +} + +struct Devices: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "List connected devices and simulators.") + + @Flag(help: "Print JSON.") var json = false + @Flag(help: "Include simulators.") var simulators = false + + func run() async throws { + let snapshot = await discoverDevices(includeSimulators: simulators) + if json { + FileHandle.standardOutput.write(try JSONOutput.encode(snapshot.devices)) + return + } + print("usbmuxd: \(snapshot.usbmux.summary) · CoreDevice: \(snapshot.coreDevice.summary)\(simulators ? " · Simulators: \(snapshot.simulators.summary)" : "")") + if snapshot.devices.isEmpty { + print("No devices found. Connect a device with USB, unlock it, and tap Trust.") + } + for device in snapshot.devices { + let transport = device.transports.map(\.label).sorted().joined(separator: "+") + print("\(device.kind == .simulator ? "SIM" : "DEV") \(device.udid) \(device.name) \(device.displayModel) \(device.displayVersion) [\(transport)] trust: \(device.pairingState.label) developer mode: \(device.developerMode.label)") + } + } +} + +struct Collect: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Collect a hashed evidence case from a physical device.", discussion: "Exit status: 0 complete, 2 finished with coverage gaps, 1 identification failed.") + + @Option(help: "UDID of the device to collect from.") var udid: String + @Option(help: "Folder in which a new case folder is created.") var outputRoot: String? + @Option(help: "Existing guided case folder (created in the app) to collect into.") var caseDirectory: String? + @Option(help: "Stream duration in seconds (0–3600).") var duration = 60 + @Flag(help: "Capture the classic syslog stream.") var includeSyslog = false + @Flag(help: "Capture the Unified Logging stream.") var includeUnifiedLogs = false + /// The 0.3.x name of --include-unified-logs, still accepted so existing scripts keep working. + @Flag(name: .customLong("include-oslog"), help: .hidden) var includeOSLog = false + @Flag(help: "Capture network packets (PCAP).") var includePcap = false + @Flag(help: "Save a screenshot (needs Xcode).") var includeScreenshot = false + @Flag(help: "Copy crash reports.") var includeCrashPull = false + + func validate() throws { + guard (outputRoot == nil) != (caseDirectory == nil) else { + throw ValidationError("Use exactly one of --output-root or --case-directory.") + } + } + + func run() async throws { + do { + let device = try await resolve(udid: udid) + let folder: URL + if let caseDirectory { + folder = URL(fileURLWithPath: (caseDirectory as NSString).expandingTildeInPath) + try CaseWorkflow.validateForCollection(folder, target: device.target) + } else { + folder = try CaseWorkflow.createCaseFolder(in: URL(fileURLWithPath: ((outputRoot ?? "") as NSString).expandingTildeInPath), target: device.target) + } + let options = CollectionOptions(durationSeconds: duration, includeClassicSyslog: includeSyslog, includeUnifiedLogs: includeUnifiedLogs || includeOSLog, includePacketCapture: includePcap, includeScreenshot: includeScreenshot, includeCrashReports: includeCrashPull) + let collector = try EvidenceCollector(device: device, caseFolder: folder, options: options) + print("Collecting from \(device.name) into \(folder.path)") + let manifest = await collector.run { event in + switch event { + case .stepFinished(let step): print(" [\(step.status.rawValue)] \(step.title)\(step.detail.isEmpty ? "" : " — \(step.detail)")") + case .streaming(let remaining) where remaining % 10 == 0: print(" streaming… \(remaining)s left") + case .finalizing: print(" writing manifest and hashes") + default: break + } + } + print("Outcome: \(manifest.outcome.rawValue)") + throw ExitCode(manifest.outcome.exitCode) + } catch let exit as ExitCode { + throw exit + } catch { + throw report(error) + } + } +} + +struct InspectIPA: AsyncParsableCommand { + static let configuration = CommandConfiguration(commandName: "inspect-ipa", abstract: "Inspect an .ipa package's contents, provisioning, and signature.") + + @Argument(help: "Path to the .ipa file.") var path: String + @Flag(help: "Print JSON.") var json = false + + func run() async throws { + do { + let inspection = try IPAInspector.inspect(URL(fileURLWithPath: (path as NSString).expandingTildeInPath)) + if json { + FileHandle.standardOutput.write(try JSONOutput.encode(inspection)) + } else { + print(inspection.report) + } + } catch { + throw report(error) + } + } +} + +struct Readiness: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Run the read-only Readiness Check for a device or simulator.") + + @Option(help: "UDID of the device or simulator.") var udid: String + + func run() async throws { + do { + let device = try await resolve(udid: udid) + let results = await CapabilityProbe().run(for: device) + for result in results { + print("\(result.state.label.padding(toLength: 16, withPad: " ", startingAt: 0)) \(result.title): \(result.summary)") + if !result.remediation.isEmpty { print(" → \(result.remediation)") } + } + } catch { + throw report(error) + } + } +} + +struct Toolchain: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Check that the installed Xcode provides every command the toolkit uses.") + + func run() async throws { + let results = await ToolchainCheck.run(runner: ProcessCommandRunner()) + print(ToolchainCheck.render(results)) + // Features that only need a newer Xcode are reported but are not an error. + if results.contains(where: { $0.state != .available && $0.state != .needsNewerXcode }) { throw ExitCode(2) } + } +} + +struct DDI: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Check, mount, and unmount the developer image (Developer Disk Image).", + discussion: "iOS 17 and later use an image Apple personalizes for each device (from Xcode's /Library/Developer/DeveloperDiskImages or --image-folder); iOS 16 and earlier use DeveloperDiskImage.dmg and its signature for the exact version.", + subcommands: [Status.self, Mount.self, Prepare.self, Unmount.self, UpdateHost.self] + ) + + enum MechanismOption: String, ExpressibleByArgument, CaseIterable { + case automatic + case coreDevice = "core-device" + case native + + var mechanism: DeveloperImageMechanism { + switch self { + case .automatic: return .automatic + case .coreDevice: return .coreDevice + case .native: return .native + } + } + } + + struct Report: Encodable { + var state: String + var headline: String + var explanation: String + var remediation: String? + var imageNeeded: String? + var facts: DeveloperImageDeviceFacts? + var mountedAt: [String] + var imageOnThisMac: String? + var mechanism: String? + + init(_ status: DeveloperImageStatus) { + state = status.state.rawValue + headline = status.headline + explanation = status.explanation + remediation = status.remediation + imageNeeded = status.requiredKind?.rawValue + facts = status.facts + mountedAt = status.mountedImages.filter(\.isDeveloperImage).compactMap(\.mountPath) + imageOnThisMac = status.hostImage + mechanism = status.state.canMount ? status.recommendedMechanism?.rawValue : nil + } + } + + static func print(_ status: DeveloperImageStatus, json: Bool) throws { + if json { + FileHandle.standardOutput.write(try JSONOutput.encode(Report(status))) + Swift.print() + } else { + Swift.print(status.headline) + Swift.print(status.explanation) + for (label, value) in status.detailRows { Swift.print(" \(label): \(value)") } + } + } + + static func confirm(_ phrase: String, for device: Device) throws { + let requirement = ConfirmationRequirement.make(for: .deviceChange, target: device.target) + guard requirement.isSatisfied(typedPhrase: phrase, backupAcknowledged: false) else { + throw ToolkitError.invalidInput("Confirmation must be exactly “\(requirement.phrase ?? "")”.") + } + } + + struct Status: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Show the developer-image state without changing anything.", discussion: "Exit status: 0 mounted (or not required), 2 not mounted but a compatible image can be mounted, 1 anything else.") + @Option(help: "Device UDID.") var udid: String + @Option(name: .customLong("image-folder"), help: "Folder containing a developer image (repeatable).") var imageFolders: [String] = [] + @Flag(help: "Print JSON.") var json = false + func run() async throws { + let status: DeveloperImageStatus + do { + let device = try await resolve(udid: udid) + status = await DeveloperImageManager().status(for: device.target, userFolders: imageFolders.map { URL(fileURLWithPath: ($0 as NSString).expandingTildeInPath) }) + try DDI.print(status, json: json) + } catch { throw report(error) } + switch status.state { + case .mounted, .notRequired: return + case .available, .personalizationRequired: throw ExitCode(2) + default: throw ExitCode(1) + } + } + } + + struct Mount: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Mount the developer image the device needs (does nothing if one is already mounted).", discussion: "On iOS 17 and later the image is personalized by Apple for this device: this needs the internet and sends the device's chip, board, and ECID with a one-time nonce to Apple, as Xcode does.") + @Option(help: "Device UDID.") var udid: String + @Option(help: "Type RUN followed by the last six characters of the UDID to confirm.") var confirm: String + @Option(help: "automatic, core-device (Xcode's devicectl), or native (built-in, over USB).") var mechanism: MechanismOption = .automatic + @Option(name: .customLong("image-folder"), help: "Folder containing a developer image (repeatable).") var imageFolders: [String] = [] + @Flag(help: "Print JSON.") var json = false + func run() async throws { + do { + let device = try await resolve(udid: udid) + try DDI.confirm(confirm, for: device) + let status = try await DeveloperImageManager().mount(device.target, mechanism: mechanism.mechanism, userFolders: imageFolders.map { URL(fileURLWithPath: ($0 as NSString).expandingTildeInPath) }) { progress in + if !json { FileHandle.standardError.write(Data("\(progress.step)…\n".utf8)) } + } + try DDI.print(status, json: json) + } catch { throw report(error) } + } + } + + /// Kept for scripts written for earlier versions; same as `mount`. + struct Prepare: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Same as `mount`.", shouldDisplay: false) + @Option(help: "Device UDID.") var udid: String + @Option(help: "Type RUN followed by the last six characters of the UDID to confirm.") var confirm: String + func run() async throws { + let mount = try Mount.parse(["--udid", udid, "--confirm", confirm]) + try await mount.run() + } + } + + struct Unmount: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Unmount the developer image (restarting the device has the same effect).") + @Option(help: "Device UDID.") var udid: String + @Option(help: "Type RUN followed by the last six characters of the UDID to confirm.") var confirm: String + func run() async throws { + do { + let device = try await resolve(udid: udid) + try DDI.confirm(confirm, for: device) + let status = try await DeveloperImageManager().unmount(device.target) + Swift.print("The developer image is no longer mounted on \(device.name). State now: \(status.state.label).") + } catch { throw report(error) } + } + } + + struct UpdateHost: AsyncParsableCommand { + static let configuration = CommandConfiguration(commandName: "update-host", abstract: "Refresh this Mac's developer images from the selected Xcode.") + func run() async throws { + do { + _ = try await CoreDeviceClient().updateHostDDIs() + Swift.print("This Mac's developer images are up to date.") + } catch { throw report(error) } + } + } +} diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 9b2903d..b66782b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,29 +1,43 @@ # Third-party software notices -iOS Developer Toolkit's original source code is distributed under the repository's [MIT License](LICENSE). That license does not replace or override the licenses of third-party software used to build or run the application. +iOS Developer Toolkit's own source code is distributed under the repository's +[MIT License](LICENSE). That license does not replace the licenses of the third-party software +used to build or run the application. -The prebuilt macOS application contains or is built from the following release-critical components: +## Swift packages linked into the app and `idt` -| Component | Pinned release | Role | Declared license | Source and license information | -|---|---:|---|---|---| -| [pymobiledevice3](https://github.com/doronz88/pymobiledevice3) | 11.15.1 | Bundled Apple-device protocol implementation and command surface | GPL-3.0-or-later | [Source for 11.15.1](https://github.com/doronz88/pymobiledevice3/tree/v11.15.1) and [license](https://github.com/doronz88/pymobiledevice3/blob/v11.15.1/LICENSE) | -| [PySide6 Essentials](https://doc.qt.io/qtforpython-6/) and Shiboken6 | 6.9.3 | Bundled Qt Core, GUI, Widgets, deployment tooling, and Python bindings | LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only, as declared by the installed wheels | [Qt for Python source](https://code.qt.io/cgit/pyside/pyside-setup.git/tag/?h=v6.9.3) and [Qt licensing](https://www.qt.io/licensing/open-source-lgpl-obligations) | -| [Nuitka](https://github.com/Nuitka/Nuitka) | 4.2.1 | Release compiler; generated applications contain separately licensed Nuitka runtime material | Compiler: GNU AGPL v3; runtime terms are supplied by Nuitka in `LICENSE-RUNTIME.txt` | [Source for 4.2.1](https://github.com/Nuitka/Nuitka/tree/4.2.1) | -| [CPython](https://github.com/python/cpython) | GitHub runner's Python 3.13 patch release | Bundled Python runtime | Python Software Foundation License Version 2 | [Source and license](https://github.com/python/cpython/blob/3.13/LICENSE) | +Versions are pinned in [`Package.resolved`](Package.resolved). -Each architecture-specific release also contains: +| Component | Version | Role | License | +|---|---:|---|---| +| [swift-nio](https://github.com/apple/swift-nio) | 2.103.0 | Networking for the lockdown connection | Apache-2.0 | +| [swift-nio-ssl](https://github.com/apple/swift-nio-ssl) | 2.37.5 | TLS for the lockdown connection | Apache-2.0; contains [BoringSSL](https://boringssl.googlesource.com/boringssl/) under the ISC and OpenSSL licenses | +| [swift-argument-parser](https://github.com/apple/swift-argument-parser) | 1.8.2 | Command-line parsing for `idt` | Apache-2.0 | +| [swift-atomics](https://github.com/apple/swift-atomics) | 1.3.1 | Dependency of swift-nio | Apache-2.0 | +| [swift-collections](https://github.com/apple/swift-collections) | 1.7.1 | Dependency of swift-nio | Apache-2.0 | +| [swift-system](https://github.com/apple/swift-system) | 1.8.1 | Dependency of swift-nio | Apache-2.0 | -- `iOS-Developer-Toolkit-vVERSION-macOS-ARCH.cdx.json`, a CycloneDX SBOM generated from the exact pinned runtime environment plus the Nuitka compiler/runtime component; -- `Contents/Resources/Licenses/THIRD_PARTY_PACKAGES.md`, generated from the package metadata installed during that native build; -- any license or notice files supplied inside those installed Python wheels; -- this notice, the repository MIT license, [source-availability statement](SOURCE_AVAILABILITY.md), and a copy of the release SBOM inside the `.app` bundle. +Each release app contains the exact `LICENSE` and `NOTICE` files of these packages in +`Contents/Resources/Licenses/`, and each release has an SPDX SBOM generated from `Package.resolved` +(see [docs/release-verification.md](docs/release-verification.md)). -The generated package inventory is intentionally more detailed than this summary and includes transitive Python dependencies. A package whose wheel does not contain a license text is identified as such in the inventory and linked to its declared project source when available. +## Data -UFADE, MVT, go-ios, idb, and ipsw are optional, separately installed external providers. The toolkit does not bundle those projects. MVT remains subject to the [MVT License](https://license.mvt.re/1.1/) and its consent and interpretation boundaries. [go-ios](https://github.com/danielpaulus/go-ios), [idb](https://github.com/facebook/idb), and [ipsw](https://github.com/blacktop/ipsw) each declare the MIT License in their upstream repositories. Their adapter only validates and launches a user-selected executable; their source and binary remain outside this project and its release SBOM. Other projects named in the README as design references are not copied, imported, or linked unless the README explicitly says otherwise. +The Location Lab world map is derived from [Natural Earth](https://www.naturalearthdata.com) +1:110m land data, which is in the public domain. -The documentation workflow uses pinned [Material for MkDocs](https://github.com/squidfunk/mkdocs-material) 9.7.7 under its MIT license. It is a site-build dependency only and is not bundled in the macOS application or application SBOM. +## Optional external tools -See [SOURCE_AVAILABILITY.md](SOURCE_AVAILABILITY.md) for the project source location, matching tagged source, and upstream source locations for bundled third-party components. +[MVT](https://github.com/mvt-project/mvt), [UFADE](https://github.com/prosch88/UFADE), and +[idb Companion](https://github.com/facebook/idb) can be launched from the External Tools page if +you install them yourself. They are not bundled, linked, or included in the release SBOM, and they +remain under their own licenses (MVT under the [MVT License 1.1](https://license.mvt.re/1.1/), +idb under MIT). The app records the path and SHA-256 of the executable it launches. -These notices document the shipped dependency boundary; they are not legal advice. Anyone redistributing a modified or repackaged application remains responsible for satisfying every applicable component license, including source-availability and relinking obligations where they apply. +## Apple tools + +The app uses `devicectl`, `simctl`, `xctrace`, `xed`, and `rvictl` from the user's own Xcode +installation and macOS. They are not redistributed. + +These notices describe the shipped dependency boundary and are not legal advice. Anyone who +redistributes a modified app is responsible for meeting every applicable license. diff --git a/Tests/DeviceKitTests/BackupAndAFCTests.swift b/Tests/DeviceKitTests/BackupAndAFCTests.swift new file mode 100644 index 0000000..5995b4c --- /dev/null +++ b/Tests/DeviceKitTests/BackupAndAFCTests.swift @@ -0,0 +1,288 @@ +import Foundation +import Testing +@testable import DeviceKit +import DeviceTestSupport +import ToolkitCore + +private func withFakeDevice(_ configure: (FakeDeviceServer) -> Void, _ body: (FakeDeviceServer) async throws -> Void) async throws { + let server = try FakeDeviceServer() + configure(server) + try await server.start() + do { + try await body(server) + } catch { + await server.stop() + throw error + } + await server.stop() +} + +/// Scripted DeviceLink peer. +struct FakeDeviceLink { + let channel: DeviceChannel + var messages: PlistMessageConnection { PlistMessageConnection(channel: channel) } + + func handshake() async throws -> PlistValue { + try await messages.send(["DLMessageVersionExchange", 300, 0], format: .binary) + let versions = try await messages.receive(timeout: 5) + #expect(versions[1]?.stringValue == "DLVersionsOk") + try await messages.send(["DLMessageDeviceReady"], format: .binary) + let hello = try await messages.receive(timeout: 5) + #expect(hello[1]?["MessageName"]?.stringValue == "Hello") + try await messages.send(["DLMessageProcessMessage", ["MessageName": "Response", "ErrorCode": 0, "ProtocolVersion": 2.1]], format: .binary) + return try await messages.receive(timeout: 5) + } + + func expectStatus(_ code: Int) async throws -> PlistValue { + let status = try await messages.receive(timeout: 5) + #expect(status[0]?.stringValue == "DLMessageStatusResponse") + #expect(status[1]?.intValue == code) + return status + } + + func length(_ value: Int) -> Data { + var data = Data() + data.appendBigEndian(UInt32(value)) + return data + } + + func upload(_ files: [(String, Data)]) async throws { + for (name, content) in files { + try await channel.write(length(name.utf8.count) + Data(name.utf8)) + try await channel.write(length(name.utf8.count) + Data(name.utf8)) + if !content.isEmpty { + try await channel.write(length(content.count + 1) + Data([0x0C]) + content) + } + try await channel.write(length(1) + Data([0x00])) + } + try await channel.write(length(0)) + } + + /// Reads the host's reply to DLMessageDownloadFiles. + func receiveDownloads() async throws -> [String: Data?] { + var results: [String: Data?] = [:] + while true { + let nameLength = Int(try await channel.read(exactly: 4, timeout: 5).readBigEndianUInt32(at: 0)) + if nameLength == 0 { break } + let name = String(decoding: try await channel.read(exactly: nameLength, timeout: 5), as: UTF8.self) + var content = Data() + var failed = false + while true { + let blockLength = Int(try await channel.read(exactly: 4, timeout: 5).readBigEndianUInt32(at: 0)) + let code = try await channel.read(exactly: 1, timeout: 5)[0] + let payload = blockLength > 1 ? try await channel.read(exactly: blockLength - 1, timeout: 5) : Data() + if code == 0x0C { content.append(payload); continue } + failed = code != 0x00 + break + } + results[name] = failed ? nil : content + } + return results + } +} + +@Suite("AFC and MobileBackup2 (fake device)", .serialized) +struct BackupAndAFCTests { + @Test func afcListsReadsAndWrites() async throws { + let fileSystem = FakeAFCFileSystem(files: ["/DCIM/100APPLE/IMG_0001.JPG": Data("jpeg".utf8), "/Downloads/readme.txt": Data(repeating: 0x61, count: 3000)]) + try await withFakeDevice({ _ in }) { server in + server.register(service: AFCClient.mediaServiceName) { channel in + try await fileSystem.serve(channel) + } + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "afc-test") + defer { try? FileManager.default.removeItem(at: directory) } + try await DeviceSession.with(server.target, usbmux: server.client) { session in + let afc = try await AFCClient.openMedia(session) + #expect(try await afc.listDirectory("/") == ["DCIM", "Downloads"]) + let info = try await afc.fileInfo("/Downloads/readme.txt") + #expect(info.size == 3000) + #expect(!info.isDirectory) + #expect(try await afc.fileInfo("/DCIM").isDirectory) + let destination = directory.appendingPathComponent("readme.txt") + #expect(try await afc.download("/Downloads/readme.txt", to: destination, chunkSize: 1024) == 3000) + #expect(try Data(contentsOf: destination).count == 3000) + let source = directory.appendingPathComponent("upload.ipa") + try SecureFileIO.writeNewFile(Data(repeating: 7, count: 2500), to: source) + try await afc.makeDirectory("/PublicStaging") + try await afc.upload(source, to: "/PublicStaging/upload.ipa", chunkSize: 1000) + let walk = try await afc.walk("/") + #expect(walk.contains("/PublicStaging/upload.ipa")) + do { + _ = try await afc.fileInfo("/missing") + Issue.record("expected not found") + } catch let error as ToolkitError { + #expect(error.kind == .fileSystem) + #expect(error.message.contains("does not exist")) + } + await afc.close() + } + #expect(await fileSystem.files["/PublicStaging/upload.ipa"]?.count == 2500) + } + } + + @Test func fullBackupConversation() async throws { + let statusPlist = try PlistValue(dictionaryLiteral: ("SnapshotState", "finished")).encoded() + try await withFakeDevice({ $0.domainValues["com.apple.mobile.backup"] = ["WillEncrypt": true] }) { server in + let udid = server.udid + server.register(service: MobileBackup2.serviceName) { channel in + let link = FakeDeviceLink(channel: channel) + let request = try await link.handshake() + #expect(request[1]?["MessageName"]?.stringValue == "Backup") + #expect(request[1]?["TargetIdentifier"]?.stringValue == udid) + #expect(request[1]?["Options"]?["ForceFullBackup"]?.boolValue == true) + + try await link.messages.send(["DLMessageCreateDirectory", .string("\(udid)/Snapshot"), 0, 5.0], format: .binary) + _ = try await link.expectStatus(0) + + try await link.messages.send(["DLMessageUploadFiles", [], 0, 40.0], format: .binary) + try await link.upload([ + ("\(udid)/Snapshot/Status.plist", statusPlist), + ("\(udid)/Snapshot/ab/ab12cd", Data(repeating: 0x42, count: 70_000)), + ("\(udid)/Snapshot/empty", Data()), + ]) + _ = try await link.expectStatus(0) + + try await link.messages.send(["DLMessageMoveFiles", .dictionary(["\(udid)/Snapshot/Status.plist": .string("\(udid)/Status.plist")]), 0, 70.0], format: .binary) + _ = try await link.expectStatus(0) + + // A path that tries to leave the backup folder is refused, not followed. + try await link.messages.send(["DLMessageCreateDirectory", "../../escape"], format: .binary) + _ = try await link.expectStatus(-1) + + try await link.messages.send(["DLMessageGetFreeDiskSpace"], format: .binary) + let space = try await link.expectStatus(0) + #expect((space[3]?.int64Value ?? 0) > 0) + + try await link.messages.send(["DLMessageContentsOfDirectory", .string(udid)], format: .binary) + let contents = try await link.expectStatus(0) + #expect(contents[3]?["Status.plist"]?["DLFileType"]?.stringValue == "DLFileTypeRegular") + + try await link.messages.send(["DLMessageDownloadFiles", [.string("\(udid)/Status.plist"), .string("\(udid)/missing")], 0, 90.0], format: .binary) + let downloads = try await link.receiveDownloads() + #expect(downloads["\(udid)/Status.plist"] == .some(statusPlist)) + #expect(downloads["\(udid)/missing"] == .some(nil)) + let multi = try await link.expectStatus(-13) + #expect(multi[3]?["\(udid)/missing"] != nil) + + try await link.messages.send(["DLMessageRemoveFiles", [.string("\(udid)/Snapshot/empty")], 0, 95.0], format: .binary) + _ = try await link.expectStatus(0) + + try await link.messages.send(["DLMessageProcessMessage", ["ErrorCode": 0]], format: .binary) + _ = try? await link.messages.receive(timeout: 2) + } + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "backup-test") + defer { try? FileManager.default.removeItem(at: root.deletingLastPathComponent().appendingPathComponent("escape")) } + defer { try? FileManager.default.removeItem(at: root) } + let events = LockedValue<[BackupEvent]>([]) + let result = try await DeviceSession.with(server.target, usbmux: server.client) { session in + try await MobileBackup2.backup(session, options: BackupOptions(destinationRoot: root, forceFullBackup: true)) { event in + events.withLock { $0.append(event) } + } + } + #expect(result.lastPathComponent == udid) + let backup = root.appendingPathComponent(udid) + #expect(FileManager.default.fileExists(atPath: backup.appendingPathComponent("Status.plist").path)) + #expect(FileManager.default.fileExists(atPath: backup.appendingPathComponent("Info.plist").path)) + #expect(try Data(contentsOf: backup.appendingPathComponent("Snapshot/ab/ab12cd")).count == 70_000) + #expect(!FileManager.default.fileExists(atPath: backup.appendingPathComponent("Snapshot/empty").path)) + #expect(!FileManager.default.fileExists(atPath: root.deletingLastPathComponent().appendingPathComponent("escape").path)) + let info = try PlistValue.decode(try Data(contentsOf: backup.appendingPathComponent("Info.plist"))) + #expect(info["Target Identifier"]?.stringValue == udid) + #expect(info["Device Name"]?.stringValue == "Test iPhone") + let recorded = events.current + #expect(recorded.contains(.encryption(true))) + #expect(recorded.contains(.progress(40))) + #expect(recorded.contains(.bytesReceived(Int64(statusPlist.count + 70_000)))) + guard case .finished(let finishedURL)? = recorded.last else { + Issue.record("expected a finished event last") + return + } + #expect(finishedURL.standardizedFileURL.path == backup.standardizedFileURL.path) + } + } + + @Test func maliciousUploadPathAbortsBackup() async throws { + try await withFakeDevice({ $0.domainValues["com.apple.mobile.backup"] = ["WillEncrypt": false] }) { server in + server.register(service: MobileBackup2.serviceName) { channel in + let link = FakeDeviceLink(channel: channel) + _ = try await link.handshake() + try await link.messages.send(["DLMessageUploadFiles", [], 0, 10.0], format: .binary) + try await link.upload([("../../evil.txt", Data("x".utf8))]) + _ = try? await link.messages.receive(timeout: 2) + } + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "backup-evil") + defer { try? FileManager.default.removeItem(at: root) } + do { + _ = try await DeviceSession.with(server.target, usbmux: server.client) { session in + try await MobileBackup2.backup(session, options: BackupOptions(destinationRoot: root, forceFullBackup: true)) { _ in } + } + Issue.record("expected the backup to stop") + } catch let error as ToolkitError { + #expect(error.kind == .protocolViolation) + } + #expect(!FileManager.default.fileExists(atPath: root.deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("evil.txt").path)) + } + } + + @Test func deviceReportedErrorsAreActionable() async throws { + try await withFakeDevice({ $0.domainValues["com.apple.mobile.backup"] = ["WillEncrypt": false] }) { server in + server.register(service: MobileBackup2.serviceName) { channel in + let link = FakeDeviceLink(channel: channel) + _ = try await link.handshake() + try await link.messages.send(["DLMessageProcessMessage", ["ErrorCode": 208, "ErrorDescription": "Device locked"]], format: .binary) + _ = try? await link.messages.receive(timeout: 2) + } + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "backup-locked") + defer { try? FileManager.default.removeItem(at: root) } + do { + _ = try await DeviceSession.with(server.target, usbmux: server.client) { session in + try await MobileBackup2.backup(session, options: BackupOptions(destinationRoot: root, forceFullBackup: false)) { _ in } + } + Issue.record("expected failure") + } catch let error as ToolkitError { + #expect(error.kind == .deviceLocked) + } + } + } + + @Test func syncLockUsesNotificationsAndAFCLock() async throws { + let fileSystem = FakeAFCFileSystem(files: [:]) + let notifications = LockedValue<[String]>([]) + try await withFakeDevice({ $0.domainValues["com.apple.mobile.backup"] = ["WillEncrypt": false] }) { server in + server.register(service: AFCClient.mediaServiceName) { channel in try await fileSystem.serve(channel) } + server.register(service: NotificationProxy.serviceName) { channel in + let messages = PlistMessageConnection(channel: channel) + while let message = try? await messages.receive(timeout: 5) { + if let name = message["Name"]?.stringValue { notifications.withLock { $0.append(name) } } + if message["Command"]?.stringValue == "Shutdown" { break } + } + } + server.register(service: MobileBackup2.serviceName) { channel in + let link = FakeDeviceLink(channel: channel) + _ = try await link.handshake() + try await link.messages.send(["DLMessageProcessMessage", ["ErrorCode": 0]], format: .binary) + _ = try? await link.messages.receive(timeout: 2) + } + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "backup-lock") + defer { try? FileManager.default.removeItem(at: root) } + _ = try await DeviceSession.with(server.target, usbmux: server.client) { session in + try await MobileBackup2.backup(session, options: BackupOptions(destinationRoot: root, forceFullBackup: false)) { _ in } + } + try await Task.sleep(for: .milliseconds(200)) + #expect(await fileSystem.lockOperations == [AFCClient.LockOperation.exclusive.rawValue, AFCClient.LockOperation.unlock.rawValue]) + #expect(notifications.current == [ + "com.apple.itunes-mobdev.syncWillStart", + "com.apple.itunes-mobdev.syncLockRequest", + "com.apple.itunes-mobdev.syncDidStart", + "com.apple.itunes-mobdev.syncDidFinish", + ]) + } + } + + @Test func progressExtraction() { + #expect(DeviceLink.progress(in: ["DLMessageUploadFiles", [], 0, 42.5]) == 42.5) + #expect(DeviceLink.progress(in: ["DLMessageUploadFiles", [], 12.0]) == 12.0) + #expect(DeviceLink.progress(in: ["DLMessageUploadFiles", [], 0, 500.0]) == nil) + #expect(DeviceLink.progress(in: ["DLMessageDisconnect"]) == nil) + } +} diff --git a/Tests/DeviceKitTests/CoreDeviceAndSimulatorTests.swift b/Tests/DeviceKitTests/CoreDeviceAndSimulatorTests.swift new file mode 100644 index 0000000..5c81945 --- /dev/null +++ b/Tests/DeviceKitTests/CoreDeviceAndSimulatorTests.swift @@ -0,0 +1,290 @@ +import Foundation +import Testing +@testable import DeviceKit +import DeviceTestSupport +import ToolkitCore + +@Suite("CoreDevice JSON") +struct CoreDeviceParsingTests { + @Test func parsesClassicAndFlattenedDeviceRecords() throws { + let devices = CoreDeviceClient.parseDevices(Fixture.json("coredevice/list-devices.json")) + #expect(devices.count == 3) + + let phone = devices[0] + #expect(phone.udid == "00008110-001234560ABC801E") + #expect(phone.name == "Test iPhone") + #expect(phone.marketingName == "iPhone 15 Pro") + #expect(phone.architecture == "arm64e") + #expect(phone.osVersion == "26.0") + #expect(phone.buildVersion == "23A341") + #expect(phone.developerMode == .enabled) + #expect(phone.pairingState == .paired) + #expect(phone.transport == .usb) + #expect(phone.ddiServicesAvailable == true) + #expect(phone.ecid == "1234567890123456") + #expect(phone.capabilities.contains("Install Application")) + + let pad = devices[1] + #expect(pad.udid == nil) + #expect(pad.transport == .network) + #expect(pad.pairingState == .unpaired) + #expect(pad.developerMode == .disabled) + let padDevice = pad.device() + #expect(padDevice.udid == "9F7A5E30-1111-4222-8333-444444444444") + #expect(padDevice.family == .iPad) + #expect(padDevice.target.coreDeviceSelector == "9F7A5E30-1111-4222-8333-444444444444") + + let modern = devices[2] + #expect(modern.udid == "00008120-000000000000AAAA") + #expect(modern.name == "New Schema iPhone") + #expect(modern.osVersion == "27.0") + #expect(modern.transport == .usb) + #expect(modern.visibilityClass == "default") + } + + @Test func mergedDeviceExplainsItself() { + let device = CoreDeviceClient.parseDevices(Fixture.json("coredevice/list-devices.json"))[0].device() + #expect(device.kind == .physical) + #expect(device.displayModel == "iPhone 15 Pro") + #expect(device.displayVersion == "iOS 26.0 (23A341)") + #expect(device.osMajorVersion == 26) + #expect(device.target.confirmationSuffix == "BC801E") + #expect(device.target.shortLabel == "Test iPhone (…BC801E)") + #expect(device.supportsCoreDevice) + #expect(!device.supportsLockdownServices) + } + + @Test func parsesAppsProcessesAndLockState() { + let apps = CoreDeviceClient.parseApps(Fixture.json("coredevice/apps.json")) + #expect(apps.map(\.name) == ["Demo", "Safari"]) + #expect(apps[0].isBuiltByDeveloper == true) + #expect(apps[1].isRemovable == false) + + let processes = CoreDeviceClient.parseProcesses(Fixture.json("coredevice/processes.json")) + #expect(processes.map(\.pid) == [1, 118, 912]) + #expect(processes[0].name == "launchd") + #expect(processes[2].name == "My App") + + let lock = CoreDeviceClient.parseLockState(Fixture.json("coredevice/lockstate.json")) + #expect(lock.passcodeRequired == true) + #expect(lock.summary.hasPrefix("Locked")) + } + + @Test func malformedJSONNeverCrashes() { + #expect(CoreDeviceClient.parseDevices(.array([])).isEmpty) + #expect(CoreDeviceClient.parseDevices(["result": ["devices": "nope"]]).isEmpty) + #expect(CoreDeviceClient.parseApps(.null).isEmpty) + #expect(CoreDeviceClient.parseProcesses(["result": ["runningProcesses": [["processIdentifier": 1.5]]]]).isEmpty) + #expect(CoreDeviceRecord(json: ["identifier": ""]) == nil) + } +} + +extension JSONValue: ExpressibleByDictionaryLiteral, ExpressibleByArrayLiteral, ExpressibleByStringLiteral, ExpressibleByFloatLiteral, ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int64) { self = .integer(value) } + public init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .object(Dictionary(elements, uniquingKeysWith: { $1 })) + } + public init(arrayLiteral elements: JSONValue...) { self = .array(elements) } + public init(stringLiteral value: String) { self = .string(value) } + public init(floatLiteral value: Double) { self = .number(value) } +} + +@Suite("CoreDevice client") +struct CoreDeviceClientTests { + func target() -> DeviceTarget { + DeviceTarget(kind: .physical, udid: "00008110-001234560ABC801E", name: "Test iPhone", osVersion: "26.0", usbmuxDeviceID: nil, coreDeviceIdentifier: "3C0B", transport: .usb) + } + + @Test func invokesDevicectlWithExplicitTargetAndJSON() async throws { + let runner = ScriptedCommandRunner { _ in .init(jsonFile: Fixture.data("coredevice/apps.json")) } + let client = CoreDeviceClient(runner: runner) + let apps = try await client.apps(target()) + #expect(apps.count == 2) + let request = try #require(runner.recorded.first) + #expect(request.executable.path == "/usr/bin/xcrun") + #expect(Array(request.arguments.prefix(4)) == ["devicectl", "device", "info", "apps"]) + let deviceIndex = try #require(request.arguments.firstIndex(of: "--device")) + #expect(request.arguments[deviceIndex + 1] == "00008110-001234560ABC801E") + #expect(request.arguments.contains("--json-output")) + #expect(request.arguments.contains("--timeout")) + } + + @Test(arguments: [ + ("coredevice/error-not-found.json", ToolkitError.Kind.deviceNotFound), + ("coredevice/error-developer-mode.json", ToolkitError.Kind.developerModeDisabled), + ("coredevice/error-locked.json", ToolkitError.Kind.deviceLocked), + ]) + func failuresBecomeActionableErrors(fixture: String, kind: ToolkitError.Kind) async throws { + let runner = ScriptedCommandRunner { _ in .init(exitCode: 1, standardError: Data("ERROR: failed".utf8), jsonFile: Fixture.data(fixture)) } + let client = CoreDeviceClient(runner: runner) + do { + _ = try await client.lockState(target()) + Issue.record("expected failure") + } catch let error as ToolkitError { + #expect(error.kind == kind) + #expect(error.recovery != nil) + #expect(error.technicalDetail?.contains("Exit status: 1") == true) + #expect(!error.message.contains("exit")) + } + } + + /// An older Xcode rejects newer subcommands or options before doing anything (no JSON output). + @Test(arguments: [ + "Error: Unknown option '--destination'\nUsage: devicectl device capture screenshot --device ", + "Error: Unexpected argument 'simulate'\nUsage: devicectl device ", + ]) + func olderXcodeSyntaxErrorsSayXcodeIsTooOld(stderr: String) async throws { + let runner = ScriptedCommandRunner { _ in .init(exitCode: 64, standardError: Data(stderr.utf8)) } + do { + _ = try await CoreDeviceClient(runner: runner).lockState(target()) + Issue.record("expected failure") + } catch let error as ToolkitError { + #expect(error.kind == .unsupported) + #expect(error.message.contains("does not support this command")) + #expect(error.recovery?.contains("newer Xcode") == true) + } + // A device error that happens to mention an option still comes from the JSON envelope. + let locked = ScriptedCommandRunner { _ in .init(exitCode: 1, standardError: Data("Unknown option".utf8), jsonFile: Fixture.data("coredevice/error-locked.json")) } + do { + _ = try await CoreDeviceClient(runner: locked).lockState(target()) + Issue.record("expected failure") + } catch let error as ToolkitError { + #expect(error.kind == .deviceLocked) + } + } + + @Test func missingJSONWithFailureStillExplains() async throws { + let runner = ScriptedCommandRunner { _ in .init(exitCode: 72, standardError: Data("xcrun: error: unable to find utility \"devicectl\"".utf8)) } + do { + _ = try await CoreDeviceClient(runner: runner).listDevices() + Issue.record("expected failure") + } catch let error as ToolkitError { + #expect(error.kind == .toolMissing) + #expect(error.recovery?.contains("Xcode") == true) + } + } + + @Test func inputValidationHappensBeforeLaunching() async throws { + let runner = ScriptedCommandRunner { _ in .init() } + let client = CoreDeviceClient(runner: runner) + await #expect(throws: ToolkitError.self) { _ = try await client.uninstall(bundleIdentifier: "not a bundle; rm -rf", on: target()) } + await #expect(throws: ToolkitError.self) { _ = try await client.setLocation(latitude: 91, longitude: 0, on: target()) } + await #expect(throws: ToolkitError.self) { _ = try await client.setLocation(latitude: .nan, longitude: 0, on: target()) } + await #expect(throws: ToolkitError.self) { _ = try await client.terminate(pid: 0, on: target(), force: false) } + await #expect(throws: ToolkitError.self) { _ = try await client.screenshot(target(), to: URL(fileURLWithPath: "/tmp/x.jpg")) } + #expect(runner.recorded.isEmpty) + } + + @Test func locationUsesFixedPrecisionArguments() async throws { + let runner = ScriptedCommandRunner { _ in .init(jsonFile: Data(#"{"info":{"outcome":"success"},"result":{}}"#.utf8)) } + _ = try await CoreDeviceClient(runner: runner).setLocation(latitude: 34.0522, longitude: -118.2437, on: target()) + let arguments = try #require(runner.recorded.first).arguments + #expect(arguments.contains("34.05220000")) + #expect(arguments.contains("-118.24370000")) + } +} + +@Suite("simctl") +struct SimulatorClientTests { + @Test func parsesDevicesAndRuntimes() throws { + let devices: JSONValue = [ + "devices": [ + "com.apple.CoreSimulator.SimRuntime.iOS-26-3": [ + ["udid": "780C6431-EBAF-4AAE-AA6C-E8886DD4D415", "name": "iPhone 17 Pro", "state": "Shutdown", "isAvailable": .bool(true), "deviceTypeIdentifier": "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro"], + ["udid": "B2", "name": "iPad Pro 13-inch (M5)", "state": "Booted", "isAvailable": .bool(true), "deviceTypeIdentifier": "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5"], + ], + "com.apple.CoreSimulator.SimRuntime.watchOS-12-0": [], + ], + ] + let runtimes: JSONValue = ["runtimes": [["identifier": "com.apple.CoreSimulator.SimRuntime.iOS-26-3", "name": "iOS 26.3", "version": "26.3.1", "buildversion": "23D8133", "platform": "iOS", "isAvailable": .bool(true)]]] + let records = SimulatorClient.parse(devices: devices, runtimes: runtimes) + #expect(records.count == 2) + #expect(records[0].state == .booted) + #expect(records[0].device.family == .iPad) + let phone = records[1].device + #expect(phone.kind == .simulator) + #expect(phone.osVersion == "26.3.1") + #expect(phone.simulatorRuntime == "iOS 26.3") + #expect(phone.pairingState == .notApplicable) + #expect(phone.transports == [.local]) + } + + @Test func runtimeFallbacksWhenListIsMissing() { + let devices: JSONValue = ["devices": ["com.apple.CoreSimulator.SimRuntime.iOS-18-4": [["udid": "C3", "name": "iPhone 16", "state": "Weird"]]]] + let record = SimulatorClient.parse(devices: devices, runtimes: nil)[0] + #expect(record.state == .unknown) + #expect(record.device.osVersion == "18.4") + #expect(record.device.osName == "iOS") + } + + @Test func parsesOpenStepAppList() throws { + let text = """ + { + "com.apple.mobilesafari" = { + ApplicationType = System; + CFBundleDisplayName = Safari; + CFBundleIdentifier = "com.apple.mobilesafari"; + CFBundleShortVersionString = "26.3"; + }; + "com.example.demo" = { + ApplicationType = User; + CFBundleName = Demo; + CFBundleIdentifier = "com.example.demo"; + }; + } + """ + let apps = try SimulatorClient.parseApps(Data(text.utf8)) + #expect(apps.map(\.name) == ["Demo", "Safari"]) + #expect(apps[1].version == "26.3") + } + + @Test func refusesPhysicalTargets() async throws { + let runner = ScriptedCommandRunner { _ in .init() } + let physical = DeviceTarget(kind: .physical, udid: "X", name: "Phone", osVersion: nil, usbmuxDeviceID: nil, coreDeviceIdentifier: nil, transport: .usb) + await #expect(throws: ToolkitError.self) { try await SimulatorClient(runner: runner).boot(physical) } + #expect(runner.recorded.isEmpty) + } + + @Test func simulatorRejectsIPAInstall() async throws { + let simulator = DeviceTarget(kind: .simulator, udid: "S", name: "Sim", osVersion: nil, usbmuxDeviceID: nil, coreDeviceIdentifier: nil, transport: .local) + do { + try await SimulatorClient(runner: ScriptedCommandRunner { _ in .init() }).install(appAt: URL(fileURLWithPath: "/tmp/App.ipa"), on: simulator) + Issue.record("expected unsupported") + } catch let error as ToolkitError { + #expect(error.kind == .unsupported) + } + } + + @Test func errorsAreInterpreted() async throws { + let runner = ScriptedCommandRunner { _ in .init(exitCode: 164, standardError: Data("Invalid device: S".utf8)) } + let simulator = DeviceTarget(kind: .simulator, udid: "S", name: "Sim", osVersion: nil, usbmuxDeviceID: nil, coreDeviceIdentifier: nil, transport: .local) + do { + try await SimulatorClient(runner: runner).shutdown(simulator) + Issue.record("expected failure") + } catch let error as ToolkitError { + #expect(error.kind == .deviceNotFound) + } + } + + /// Runs against the real simctl on this Mac (no simulator is booted by the test). + @Test(.enabled(if: FileManager.default.isExecutableFile(atPath: "/usr/bin/xcrun"))) + func listsRealSimulators() async throws { + let records = try await SimulatorClient().list() + for record in records { + #expect(!record.udid.isEmpty) + #expect(record.device.kind == .simulator) + } + } + + @Test func ndjsonLogParsing() throws { + let line = #"{"timestamp":"2026-09-26 10:00:00.123456-0700","messageType":"Error","eventMessage":"Something failed","processImagePath":"/Applications/Demo.app/Demo","processID":42,"subsystem":"com.example","category":"net"}"# + let parsed = try #require(SimulatorLogParser.parse(line: Substring(line))) + #expect(parsed.process == "Demo") + #expect(parsed.pid == 42) + #expect(parsed.level == "Error") + #expect(parsed.timestamp != nil) + #expect(parsed.rendered.contains("[com.example:net]")) + #expect(SimulatorLogParser.parse(line: "Filtering the log data using ...")?.message.hasPrefix("Filtering") == true) + #expect(SimulatorLogParser.parse(line: " ") == nil) + } +} diff --git a/Tests/DeviceKitTests/DeveloperImageTests.swift b/Tests/DeviceKitTests/DeveloperImageTests.swift new file mode 100644 index 0000000..c4ba99e --- /dev/null +++ b/Tests/DeviceKitTests/DeveloperImageTests.swift @@ -0,0 +1,524 @@ +import CryptoKit +import DeviceTestSupport +import Foundation +import Testing +@testable import DeviceKit +import ToolkitCore + +// MARK: - Fixtures + +/// A personalized image folder in Xcode's layout (Restore/BuildManifest.plist, image, trust cache). +struct PersonalizedFixture { + static let chipID = 0xFFF1 + static let boardID = 0x0A + let root: URL + let image = Data((0..<3_000_000).map { UInt8(truncatingIfNeeded: $0 &* 31) }) + let trustCache = Data("trust-cache".utf8) + + init(productTypes: [String] = ["iPhone99,1"], flatLayout: Bool = false) throws { + root = try SecureFileIO.makeTemporaryDirectory(prefix: "ddi-personalized") + let restore = flatLayout ? root : root.appendingPathComponent("Restore", isDirectory: true) + try FileManager.default.createDirectory(at: restore.appendingPathComponent("Firmware"), withIntermediateDirectories: true) + let imageName = flatLayout ? "Image.dmg" : "001-00001-001.dmg" + let trustName = flatLayout ? "Image.dmg.trustcache" : "Firmware/001-00001-001.dmg.trustcache" + try image.write(to: restore.appendingPathComponent(imageName)) + try trustCache.write(to: restore.appendingPathComponent(trustName)) + let rules: PlistValue = [ + ["Actions": ["EPRO": false], "Conditions": ["ApCurrentProductionMode": false, "ApRequiresImage4": true]], + ["Actions": ["EPRO": true], "Conditions": ["ApCurrentProductionMode": true, "ApRequiresImage4": true]], + ["Actions": ["ESEC": true, "Ignored": 255], "Conditions": ["ApRawSecurityMode": true]], + ] + func entry(_ path: String, trusted: Bool) -> PlistValue { + ["Digest": .data(Data(repeating: 0xAB, count: 48)), "Trusted": .boolean(trusted), "Info": ["Path": .string(path), "RestoreRequestRules": rules]] + } + // In the flat layout the manifest still names Xcode's file names; the library falls back to Image.dmg. + let manifestImagePath = flatLayout ? "renamed.dmg" : imageName + let manifestTrustPath = flatLayout ? "Firmware/renamed.trustcache" : trustName + let identity: PlistValue = [ + "ApChipID": .string("0x" + String(Self.chipID, radix: 16)), + "ApBoardID": .string("0x" + String(Self.boardID, radix: 16)), + "Ap,ProductType": "iPhone99,1", + "Info": ["Variant": "Customer iOS Developer PDI"], + "Manifest": [ + "LoadableTrustCache": entry(manifestTrustPath, trusted: true), + "PersonalizedDMG": entry(manifestImagePath, trusted: true), + "Untrusted": entry("x", trusted: false), + ], + ] + let other: PlistValue = [ + "ApChipID": "0x1", "ApBoardID": "0x2", + "Manifest": ["LoadableTrustCache": entry(trustName, trusted: true), "PersonalizedDMG": entry(imageName, trusted: true)], + ] + let cryptexOnly: PlistValue = ["Manifest": ["Cryptex1,GenericDmg": entry("c.dmg", trusted: true)]] + let manifest: PlistValue = [ + "ProductBuildVersion": "99A1", + "SupportedProductTypes": .array(productTypes.map { .string($0) }), + "BuildIdentities": [identity, other, cryptexOnly], + ] + try manifest.encoded(format: .xml).write(to: restore.appendingPathComponent("BuildManifest.plist")) + } + + func remove() { try? FileManager.default.removeItem(at: root) } +} + +/// A folder of legacy images in Xcode's DeviceSupport layout. +struct LegacyFixture { + let root: URL + let image = Data(repeating: 0x5A, count: 1_500_000) + let signature = Data("legacy-signature".utf8) + + init(versions: [String]) throws { + root = try SecureFileIO.makeTemporaryDirectory(prefix: "ddi-legacy") + for version in versions { + let folder = root.appendingPathComponent(version, isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try image.write(to: folder.appendingPathComponent("DeveloperDiskImage.dmg")) + try signature.write(to: folder.appendingPathComponent("DeveloperDiskImage.dmg.signature")) + } + } + + func remove() { try? FileManager.default.removeItem(at: root) } +} + +/// A stateful image mounter: it behaves like the device service, validating uploads and mounts. +final class FakeImageMounter: @unchecked Sendable { + let lock = NSLock() + var mounted: [DeveloperImageKind: Data] = [:] + var storedManifests: [Data: Data] = [:] + var uploads: [(kind: String, size: Int, signature: Data)] = [] + var mountRequests: [PlistValue] = [] + var mountError: (String, String)? + let nonce = Data(repeating: 0x11, count: 32) + + func register(on server: FakeDeviceServer) { + server.register(service: ImageMounter.serviceName) { [self] channel in + let messages = PlistMessageConnection(channel: channel) + while let request = try? await messages.receive(timeout: 5) { + guard let reply = try await handle(request, channel: channel, messages: messages) else { return } + try await messages.send(reply) + } + } + } + + func state(_ body: () -> T) -> T { lock.withLock(body) } + + func handle(_ request: PlistValue, channel: DeviceChannel, messages: PlistMessageConnection) async throws -> PlistValue? { + switch request["Command"]?.stringValue { + case "Hangup": + return nil + case "LookupImage": + let kind = DeveloperImageKind(rawValue: request["ImageType"]?.stringValue ?? "") + let signature = state { kind.flatMap { mounted[$0] } } + return ["ImageSignature": .array(signature.map { [.data($0)] } ?? [])] + case "CopyDevices": + let entries = state { mounted.keys.map { kind -> PlistValue in ["MountPath": .string(kind.mountPath), "ImageType": .string(kind.rawValue)] } } + return ["EntryList": .array(entries)] + case "QueryPersonalizationIdentifiers": + return ["PersonalizationIdentifiers": [ + "ChipID": .integer(Int64(PersonalizedFixture.chipID)), + "BoardId": .integer(Int64(PersonalizedFixture.boardID)), + "UniqueChipID": .integer(0x1234_5678_9ABC), + "Ap,OSLongVersion": "99.0", + ]] + case "QueryNonce": + return ["PersonalizationNonce": .data(nonce)] + case "QueryPersonalizationManifest": + let digest = request["ImageSignature"]?.dataValue ?? Data() + if let manifest = state({ storedManifests[digest] }) { return ["ImageSignature": .data(manifest)] } + return ["Error": "MissingManifest"] + case "ReceiveBytes": + let size = request["ImageSize"]?.intValue ?? 0 + try await messages.send(["Status": "ReceiveBytesAck"]) + _ = try await channel.read(exactly: size, timeout: 10) + state { uploads.append((request["ImageType"]?.stringValue ?? "", size, request["ImageSignature"]?.dataValue ?? Data())) } + return ["Status": "Complete"] + case "MountImage": + if let (error, detail) = state({ mountError }) { return ["Error": .string(error), "DetailedError": .string(detail)] } + guard let kind = DeveloperImageKind(rawValue: request["ImageType"]?.stringValue ?? ""), let signature = request["ImageSignature"]?.dataValue else { + return ["Error": "MissingImageType"] + } + let uploaded = state { uploads.last } + guard uploaded?.signature == signature else { return ["Error": "ImageMountFailed", "DetailedError": "signature mismatch"] } + if kind == .personalized && request["ImageTrustCache"]?.dataValue == nil { return ["Error": "ImageMountFailed", "DetailedError": "missing trust cache"] } + if state({ mounted[kind] != nil }) { return ["Error": "ImageMountFailed", "DetailedError": "Image is already mounted"] } + state { + mounted[kind] = signature + mountRequests.append(request) + } + return ["Status": "Complete"] + case "UnmountImage": + let path = request["MountPath"]?.stringValue + let removed: Bool = state { + guard let kind = mounted.keys.first(where: { $0.mountPath == path }) else { return false } + mounted[kind] = nil + return true + } + return removed ? ["Status": "Complete"] : ["Error": "InternalError", "DetailedError": .string("There is no matching entry in the device map for \(path ?? "")")] + default: + return ["Error": "UnknownCommand"] + } + } +} + +/// Records personalization requests and answers like Apple's server. +final class FakeTSS: PersonalizationTransport, @unchecked Sendable { + let lock = NSLock() + var requests: [PlistValue] = [] + let ticket = Data("apple-img4-ticket".utf8) + var reply: String? + + func send(_ body: Data) async throws -> Data { + lock.withLock { requests.append((try? PlistValue.decode(body)) ?? .dictionary([:])) } + if let reply { return Data(reply.utf8) } + let plist = try PlistValue.dictionary(["ApImg4Ticket": .data(ticket)]).encoded(format: .xml) + return Data("STATUS=0&MESSAGE=SUCCESS&REQUEST_STRING=".utf8) + plist + } +} + +/// Host locations with nothing installed, so tests see only their fixtures (CI runners have older +/// Xcode versions whose legacy images would otherwise be found first). +let isolatedHostLocations = DeveloperImageHostLocations( + xcodePersonalizedImage: URL(fileURLWithPath: "/nonexistent/idt-tests/iOS_DDI", isDirectory: true), + applications: URL(fileURLWithPath: "/nonexistent/idt-tests", isDirectory: true) +) + +private func withDevice(version: String, developerMode: Bool = true, _ body: (FakeDeviceServer, FakeImageMounter) async throws -> Void) async throws { + let server = try FakeDeviceServer() + server.lockdownValues = [ + "ProductVersion": .string(version), "BuildVersion": "99A1", "ProductType": "iPhone99,1", + "CPUArchitecture": "arm64e", "HardwareModel": "D99AP", + "ChipID": .integer(Int64(PersonalizedFixture.chipID)), "BoardId": .integer(Int64(PersonalizedFixture.boardID)), + ] + server.domainValues["com.apple.security.mac.amfi"] = ["DeveloperModeStatus": .boolean(developerMode)] + let mounter = FakeImageMounter() + mounter.register(on: server) + try await server.start() + do { try await body(server, mounter) } catch { + await server.stop() + throw error + } + await server.stop() +} + +// MARK: - Tests + +@Suite("Developer image library") +struct DeveloperImageLibraryTests { + @Test func readsPersonalizedImagesInXcodeAndFlatLayouts() throws { + for flat in [false, true] { + let fixture = try PersonalizedFixture(flatLayout: flat) + defer { fixture.remove() } + let source = try DeveloperImageLibrary.personalizedSource(at: fixture.root, origin: .userFolder) + #expect(source.buildVersion == "99A1") + #expect(source.identities.count == 2, "the Cryptex-only identity is not a personalized DMG") + let identity = try #require(source.identity(chipID: PersonalizedFixture.chipID, boardID: PersonalizedFixture.boardID)) + #expect(source.supports(productType: "iPhone99,1") == true) + #expect(source.supports(productType: "iPhone1,1") == false) + let files = try source.files(for: identity) + #expect(try Data(contentsOf: files.image) == fixture.image) + #expect(try Data(contentsOf: files.trustCache) == fixture.trustCache) + } + } + + @Test func rejectsFoldersWithoutAnImageAndSymbolicLinks() throws { + let empty = try SecureFileIO.makeTemporaryDirectory(prefix: "ddi-empty") + defer { try? FileManager.default.removeItem(at: empty) } + #expect(throws: ToolkitError.self) { try DeveloperImageLibrary.personalizedSource(at: empty, origin: .userFolder) } + let target = empty.appendingPathComponent("real") + try Data("x".utf8).write(to: target) + let link = empty.appendingPathComponent("link") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + #expect(throws: ToolkitError.self) { try DeveloperImageLibrary.readValidatedFile(link, limit: 10) } + #expect(throws: ToolkitError.self) { try DeveloperImageLibrary.readValidatedFile(target, limit: 0) } + } + + @Test func findsLegacyImagesByExactVersion() throws { + let fixture = try LegacyFixture(versions: ["15.5", "16.4 (20E247)", "not-a-version"]) + defer { fixture.remove() } + let sources = DeveloperImageLibrary.legacySources(userFolders: [fixture.root], applications: fixture.root) + #expect(Set(sources.map(\.version)) == ["15.5", "16.4"]) + #expect(DeveloperImageLibrary.legacyImage(forVersion: "16.4.1", in: sources)?.version == "16.4") + #expect(DeveloperImageLibrary.legacyImage(forVersion: "16.3", in: sources) == nil) + // A folder that holds the image directly takes its version from the folder name. + let direct = DeveloperImageLibrary.legacySources(userFolders: [fixture.root.appendingPathComponent("15.5")], applications: fixture.root) + #expect(direct.map(\.version) == ["15.5"]) + #expect(DeveloperImageLibrary.majorMinor("16") == "16.0") + #expect(DeveloperImageLibrary.majorMinor("iPhone") == nil) + } + + /// Legacy images shipped by older Xcode versions installed on this machine (for example on CI + /// runners) are found with their version, and their files are readable. + @Test(.enabled(if: !DeveloperImageLibrary.legacySources().isEmpty)) + func installedLegacyImagesAreUsable() throws { + let sources = DeveloperImageLibrary.legacySources() + for source in sources { + #expect(DeveloperImageLibrary.majorMinor(source.version) == source.version) + #expect(source.origin == .xcode) + #expect(try DeveloperImageLibrary.readImage(source.image).count > 1_000_000) + #expect(try DeveloperImageLibrary.readSmallFile(source.signature).count > 0) + } + let first = try #require(sources.first) + #expect(DeveloperImageLibrary.legacyImage(forVersion: first.version + ".1", in: sources)?.version == first.version) + } + + /// The image Xcode installs on this Mac has a build identity for current iPhones. + @Test(.enabled(if: FileManager.default.fileExists(atPath: DeveloperImageLibrary.xcodePersonalizedImage.appendingPathComponent("Restore/BuildManifest.plist").path))) + func xcodeInstalledImageIsUsable() throws { + let source = try DeveloperImageLibrary.personalizedSource(at: DeveloperImageLibrary.xcodePersonalizedImage, origin: .xcode) + #expect(source.identities.count > 50) + #expect(source.supportedProductTypes.contains("iPhone18,1")) + let identity = try #require(source.identities.first { $0.productType == "iPhone18,1" }) + let files = try source.files(for: identity) + #expect(try DeveloperImageLibrary.readImage(files.image).count > 1_000_000) + #expect(try DeveloperImageLibrary.readSmallFile(files.trustCache).count > 0) + let request = ImagePersonalization.request(identity: identity, identifiers: PersonalizationIdentifiers(chipID: identity.chipID, boardID: identity.boardID, ecid: 1), nonce: Data(count: 32)) + #expect(request["PersonalizedDMG"]?["Digest"]?.dataValue?.count == 48) + #expect(request["LoadableTrustCache"] != nil) + } +} + +@Suite("Developer image personalization") +struct ImagePersonalizationTests { + @Test func buildsTheSigningRequest() throws { + let fixture = try PersonalizedFixture() + defer { fixture.remove() } + let source = try DeveloperImageLibrary.personalizedSource(at: fixture.root, origin: .userFolder) + let identity = try #require(source.identity(chipID: PersonalizedFixture.chipID, boardID: PersonalizedFixture.boardID)) + let identifiers = PersonalizationIdentifiers(chipID: PersonalizedFixture.chipID, boardID: PersonalizedFixture.boardID, ecid: 0xDEAD_BEEF, additional: ["Ap,OSLongVersion": "99.0"]) + let nonce = Data(repeating: 7, count: 32) + let request = ImagePersonalization.request(identity: identity, identifiers: identifiers, nonce: nonce, requestID: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!) + #expect(request["ApChipID"]?.intValue == PersonalizedFixture.chipID) + #expect(request["ApBoardID"]?.intValue == PersonalizedFixture.boardID) + #expect(request["ApECID"]?.intValue == 0xDEAD_BEEF) + #expect(request["ApNonce"]?.dataValue == nonce) + #expect(request["SepNonce"]?.dataValue == Data(count: 20)) + #expect(request["@ApImg4Ticket"]?.boolValue == true) + #expect(request["@UUID"]?.stringValue == "00000000-0000-0000-0000-000000000001") + #expect(request["Ap,OSLongVersion"]?.stringValue == "99.0") + #expect(request["Untrusted"] == nil, "untrusted components are not signed") + let dmg = try #require(request["PersonalizedDMG"]) + #expect(dmg["Info"] == nil) + #expect(dmg["Digest"]?.dataValue?.count == 48) + #expect(dmg["EPRO"]?.boolValue == true, "production-mode rule applied") + #expect(dmg["ESEC"]?.boolValue == true, "security-mode rule applied") + #expect(dmg["Ignored"] == nil, "255 means leave unchanged") + } + + @Test func parsesAppleRepliesIntoTicketsOrClearErrors() throws { + let plist = try PlistValue.dictionary(["ApImg4Ticket": .data(Data([1, 2, 3]))]).encoded(format: .xml) + #expect(try ImagePersonalization.ticket(fromResponse: Data("STATUS=0&MESSAGE=SUCCESS&REQUEST_STRING=".utf8) + plist) == Data([1, 2, 3])) + do { + _ = try ImagePersonalization.ticket(fromResponse: Data("STATUS=94&MESSAGE=This device isn't eligible for the requested build.".utf8)) + Issue.record("expected a rejection") + } catch let error as ToolkitError { + #expect(error.message.contains("would not sign")) + #expect(error.technicalDetail?.contains("STATUS=94") == true) + } + #expect(throws: ToolkitError.self) { try ImagePersonalization.ticket(fromResponse: Data("".utf8)) } + let noTicket = try PlistValue.dictionary(["Other": "x"]).encoded(format: .xml) + #expect(throws: ToolkitError.self) { try ImagePersonalization.ticket(fromResponse: Data("STATUS=0&MESSAGE=SUCCESS&REQUEST_STRING=".utf8) + noTicket) } + } + + @Test func identifiersRequireChipBoardAndECID() throws { + #expect(throws: ToolkitError.self) { try PersonalizationIdentifiers(["ChipID": 1]) } + let parsed = try PersonalizationIdentifiers(["ChipID": 0x8150, "BoardId": 4, "UniqueChipID": 99, "Ap,X": "y", "Other": 1]) + #expect(parsed.chipID == 0x8150 && parsed.boardID == 4 && parsed.ecid == 99) + #expect(Array(parsed.additional.keys) == ["Ap,X"]) + } +} + +@Suite("Developer image state") +struct DeveloperImageEvaluatorTests { + let modern = DeveloperImageDeviceFacts(productVersion: "26.1", productType: "iPhone99,1", chipID: PersonalizedFixture.chipID, boardID: PersonalizedFixture.boardID, developerModeEnabled: true) + let legacy = DeveloperImageDeviceFacts(productVersion: "15.5", productType: "iPhone99,1") + + func host(personalized: Bool = false, productTypes: [String] = ["iPhone99,1"], legacyVersions: [String] = [], coreDevice: Bool = false) throws -> (DeveloperImageHostInventory, [() -> Void]) { + var cleanups: [() -> Void] = [] + var sources: [PersonalizedImageSource] = [] + if personalized { + let fixture = try PersonalizedFixture(productTypes: productTypes) + cleanups.append(fixture.remove) + sources.append(try DeveloperImageLibrary.personalizedSource(at: fixture.root, origin: .userFolder)) + } + var legacySources: [LegacyImageSource] = [] + if !legacyVersions.isEmpty { + let fixture = try LegacyFixture(versions: legacyVersions) + cleanups.append(fixture.remove) + legacySources = DeveloperImageLibrary.legacySources(userFolders: [fixture.root], applications: fixture.root) + } + return (DeveloperImageHostInventory(personalized: sources, legacy: legacySources, coreDeviceAvailable: coreDevice), cleanups) + } + + func state(_ facts: DeveloperImageDeviceFacts, _ observation: DeveloperImageObservation = DeveloperImageObservation(), _ host: DeveloperImageHostInventory) -> DeveloperImageState { + DeveloperImageEvaluator.evaluate(facts: facts, observation: observation, host: host).state + } + + @Test func coversEveryState() throws { + let (withImage, c1) = try host(personalized: true) + let (unlisted, c2) = try host(personalized: true, productTypes: ["iPhone1,1"]) + let (withLegacy, c3) = try host(legacyVersions: ["15.5"]) + let (otherLegacy, c4) = try host(legacyVersions: ["14.8", "16.4"]) + defer { (c1 + c2 + c3 + c4).forEach { $0() } } + let empty = DeveloperImageHostInventory() + + #expect(state(modern, DeveloperImageObservation(mountedSignatures: [Data([1])]), empty) == .mounted) + #expect(state(modern, DeveloperImageObservation(otherKindMounted: true), withImage) == .incompatible) + var off = modern + off.developerModeEnabled = false + #expect(state(off, DeveloperImageObservation(), withImage) == .blocked) + #expect(state(modern, DeveloperImageObservation(manifestOnDevice: true), withImage) == .available) + #expect(state(modern, DeveloperImageObservation(manifestOnDevice: false), withImage) == .personalizationRequired) + #expect(state(modern, DeveloperImageObservation(), unlisted) == .incompatible) + var otherChip = modern + otherChip.chipID = 0x1234 + #expect(state(otherChip, DeveloperImageObservation(), withImage) == .incompatible) + #expect(state(modern, DeveloperImageObservation(), DeveloperImageHostInventory(coreDeviceAvailable: true)) == .personalizationRequired) + #expect(state(modern, DeveloperImageObservation(), empty) == .missing) + #expect(state(legacy, DeveloperImageObservation(), withLegacy) == .available) + #expect(state(legacy, DeveloperImageObservation(), otherLegacy) == .incompatible) + #expect(state(legacy, DeveloperImageObservation(), empty) == .missing) + // iOS 15 has no Developer Mode; an unknown value must not block it. + #expect(legacy.developerModeEnabled == nil) + + let missing = DeveloperImageEvaluator.evaluate(facts: modern, observation: DeveloperImageObservation(), host: empty) + #expect(missing.remediation?.contains("Install Xcode") == true) + let personalization = DeveloperImageEvaluator.evaluate(facts: modern, observation: DeveloperImageObservation(), host: withImage) + #expect(personalization.recommendedMechanism == .native) + #expect(personalization.explanation.contains("internet")) + #expect(DeveloperImageKind.required(forMajorVersion: 16) == .legacy) + #expect(DeveloperImageKind.required(forMajorVersion: 17) == .personalized) + #expect(DeveloperImageKind.required(forMajorVersion: nil) == .personalized) + } + + @Test func mapsMounterErrorsToPlainLanguage() { + func classify(_ error: String, _ detail: String = "") -> ImageMounter.Failure { + ImageMounter.classify(["Error": .string(error), "DetailedError": .string(detail)]) + } + #expect(classify("DeviceLocked") == .deviceLocked) + #expect(classify("ImageMountFailed", "Developer mode is not enabled.") == .developerModeDisabled) + #expect(classify("ImageMountFailed", "Image is already mounted") == .alreadyMounted) + #expect(classify("InternalError", "There is no matching entry in the device map") == .notMounted) + #expect(classify("UnknownCommand") == .unsupported) + #expect(classify("ImageMountFailed", "Failed to verify the personalization manifest") == .signatureRejected) + #expect(classify("SomethingElse") == .other) + let error = ImageMounter.error(for: .deviceLocked, reply: ["Error": "DeviceLocked"], operation: "mount the developer image") + #expect(error.kind == .deviceLocked) + #expect(error.message == "The device is locked.") + #expect(error.technicalDetail?.contains("DeviceLocked") == true) + #expect(!ImageMounter.error(for: .other, reply: ["Error": "X"], operation: "mount the developer image").message.contains("X")) + } +} + +@Suite("Developer image mounting (fake device)", .serialized) +struct DeveloperImageMountTests { + @Test func personalizesUploadsAndMountsOnceOnIOS17AndLater() async throws { + let fixture = try PersonalizedFixture() + defer { fixture.remove() } + try await withDevice(version: "26.1") { server, mounter in + let tss = FakeTSS() + let manager = DeveloperImageManager(usbmux: server.client, transport: tss, locations: isolatedHostLocations) + let folders = [fixture.root] + + let before = await manager.status(for: server.target, userFolders: folders) + #expect(before.state == .personalizationRequired, "\(before.headline) \(before.technicalDetail ?? "")") + #expect(before.facts?.chipID == PersonalizedFixture.chipID) + #expect(before.facts?.architecture == "arm64e") + #expect(before.recommendedMechanism == .native) + + let after = try await manager.mount(server.target, userFolders: folders) + #expect(after.state == .mounted) + let request = try #require(tss.requests.first) + #expect(request["ApNonce"]?.dataValue == mounter.nonce) + #expect(request["ApECID"]?.intValue == 0x1234_5678_9ABC) + #expect(mounter.uploads.count == 1) + #expect(mounter.uploads.first?.kind == "Personalized") + #expect(mounter.uploads.first?.size == fixture.image.count) + #expect(mounter.uploads.first?.signature == tss.ticket) + #expect(mounter.mountRequests.first?["ImageTrustCache"]?.dataValue == fixture.trustCache) + + // Already mounted: nothing is uploaded or signed again. + let again = try await manager.mount(server.target, userFolders: folders) + #expect(again.state == .mounted) + #expect(mounter.uploads.count == 1 && tss.requests.count == 1) + + // Unmount, then mount again using the manifest the device kept: no Apple request. + let unmounted = try await manager.unmount(server.target, userFolders: folders) + #expect(unmounted.state == .personalizationRequired) + mounter.state { mounter.storedManifests[Data(SHA384.hash(data: fixture.image))] = tss.ticket } + #expect(await manager.status(for: server.target, userFolders: folders).state == .available) + _ = try await manager.mount(server.target, userFolders: folders) + #expect(tss.requests.count == 1, "the stored personalization was reused") + #expect(mounter.uploads.count == 2) + } + } + + @Test func mountsLegacyImagesWithoutPersonalization() async throws { + let fixture = try LegacyFixture(versions: ["15.5"]) + defer { fixture.remove() } + try await withDevice(version: "15.5.1") { server, mounter in + let tss = FakeTSS() + let manager = DeveloperImageManager(usbmux: server.client, transport: tss, locations: isolatedHostLocations) + let status = await manager.status(for: server.target, userFolders: [fixture.root]) + #expect(status.state == .available) + #expect(status.requiredKind == .legacy) + let mounted = try await manager.mount(server.target, userFolders: [fixture.root]) + #expect(mounted.state == .mounted) + #expect(mounter.uploads.first?.kind == "Developer") + #expect(mounter.uploads.first?.signature == fixture.signature) + #expect(tss.requests.isEmpty) + _ = try await manager.unmount(server.target, userFolders: [fixture.root]) + #expect(mounter.state { mounter.mounted.isEmpty }) + } + } + + @Test func reportsBlockedMissingAndDeviceErrors() async throws { + let fixture = try PersonalizedFixture() + defer { fixture.remove() } + try await withDevice(version: "26.1", developerMode: false) { server, mounter in + let manager = DeveloperImageManager(usbmux: server.client, transport: FakeTSS(), locations: isolatedHostLocations) + let status = await manager.status(for: server.target, userFolders: [fixture.root]) + #expect(status.state == .blocked) + #expect(status.headline == "Developer Mode is off.") + await #expect(throws: ToolkitError.self) { _ = try await manager.mount(server.target, userFolders: [fixture.root]) } + #expect(mounter.uploads.isEmpty) + } + try await withDevice(version: "16.4") { server, _ in + let manager = DeveloperImageManager(usbmux: server.client, transport: FakeTSS(), locations: isolatedHostLocations) + let status = await manager.status(for: server.target, userFolders: []) + // This Mac may have Xcode's modern image, but never a 16.4 legacy image in these folders. + #expect(status.state == .missing || status.state == .incompatible) + await #expect(throws: ToolkitError.self) { _ = try await manager.mount(server.target, userFolders: []) } + } + try await withDevice(version: "26.1") { server, mounter in + mounter.mountError = ("DeviceLocked", "The device is locked") + let manager = DeveloperImageManager(usbmux: server.client, transport: FakeTSS(), locations: isolatedHostLocations) + do { + _ = try await manager.mount(server.target, userFolders: [fixture.root]) + Issue.record("expected a locked-device error") + } catch let error as ToolkitError { + #expect(error.kind == .deviceLocked) + #expect(error.recovery?.contains("Unlock") == true) + } + } + } + + @Test func usesImageMounterIdentifiersWhenLockdownOmitsThem() async throws { + let fixture = try PersonalizedFixture() + defer { fixture.remove() } + try await withDevice(version: "26.1") { server, _ in + server.lockdownValues["ChipID"] = nil + server.lockdownValues["BoardId"] = nil + let status = await DeveloperImageManager(usbmux: server.client, transport: FakeTSS(), locations: isolatedHostLocations).status(for: server.target, userFolders: [fixture.root]) + #expect(status.state == .personalizationRequired) + #expect(status.facts?.chipID == PersonalizedFixture.chipID) + #expect(status.facts?.boardID == PersonalizedFixture.boardID) + } + } + + @Test func simulatorsDoNotNeedAnImage() async { + let simulator = DeviceTarget(kind: .simulator, udid: "SIM", name: "Sim", osVersion: "26.0", usbmuxDeviceID: nil, coreDeviceIdentifier: nil, transport: .local) + #expect(await DeveloperImageManager().status(for: simulator).state == .notRequired) + let networkOnly = DeviceTarget(kind: .physical, udid: "X", name: "Phone", osVersion: "26.0", usbmuxDeviceID: nil, coreDeviceIdentifier: nil, transport: .network) + #expect(await DeveloperImageManager().status(for: networkOnly).state == .blocked) + } +} diff --git a/Tests/DeviceKitTests/DiscoveryTests.swift b/Tests/DeviceKitTests/DiscoveryTests.swift new file mode 100644 index 0000000..be9194b --- /dev/null +++ b/Tests/DeviceKitTests/DiscoveryTests.swift @@ -0,0 +1,98 @@ +import Foundation +import Testing +@testable import DeviceKit +import DeviceTestSupport +import ToolkitCore + +@Suite("Device discovery") +struct DiscoveryTests { + let coreDevice = CoreDeviceClient.parseDevices(Fixture.json("coredevice/list-devices.json")) + + @Test func mergesUSBMuxAndCoreDeviceByUDID() { + let mux = USBMuxDevice(deviceID: 4, udid: "00008110001234560ABC801E", connectionType: "USB") + let devices = DeviceMerger.merge(usbmux: [mux], enrichment: [:], coreDevice: coreDevice, simulators: []) + let phone = try! #require(devices.first { $0.udid == "00008110-001234560ABC801E" }) + #expect(phone.usbmuxDeviceID == 4) + #expect(phone.sources == [.usbmux, .coreDevice]) + #expect(phone.name == "Test iPhone") + #expect(phone.supportsLockdownServices && phone.supportsCoreDevice) + #expect(devices.filter { $0.udid == "00008110-001234560ABC801E" }.count == 1) + } + + @Test func usbmuxOnlyDevicesUseLockdownEnrichment() { + let mux = USBMuxDevice(deviceID: 9, udid: "00008030000000000000002E", connectionType: "USB") + let enrichment = LockdownEnrichment(name: "Lab iPhone", productType: "iPhone14,5", productVersion: "17.5", buildVersion: "21F79", pairingState: .paired, developerMode: .disabled) + let devices = DeviceMerger.merge(usbmux: [mux], enrichment: [DeviceMerger.key(mux.udid): enrichment], coreDevice: [], simulators: []) + #expect(devices.count == 1) + #expect(devices[0].name == "Lab iPhone") + #expect(devices[0].marketingName == "iPhone 13") + #expect(devices[0].developerMode == .disabled) + #expect(devices[0].sources == [.usbmux]) + #expect(!devices[0].supportsCoreDevice) + } + + @Test func unpairedLockdownOverridesUnknownPairing() { + let mux = USBMuxDevice(deviceID: 9, udid: "00008030000000000000002E", connectionType: "USB") + let devices = DeviceMerger.merge(usbmux: [mux], enrichment: [DeviceMerger.key(mux.udid): LockdownEnrichment(pairingState: .unpaired)], coreDevice: [], simulators: []) + #expect(devices[0].pairingState == .unpaired) + } + + @Test func offlineCoreDeviceEntriesAreHidden() { + var offline = coreDevice[0] + offline.transportType = nil + let devices = DeviceMerger.merge(usbmux: [], enrichment: [:], coreDevice: [offline], simulators: []) + #expect(devices.isEmpty) + } + + @Test func multipleDevicesStaySeparateAndSimulatorsFollowPhysical() { + let muxA = USBMuxDevice(deviceID: 1, udid: "00008110001234560ABC801E", connectionType: "USB") + let muxB = USBMuxDevice(deviceID: 2, udid: "00008030000000000000002E", connectionType: "USB") + let muxBWifi = USBMuxDevice(deviceID: 3, udid: "00008030000000000000002E", connectionType: "Network") + let simulator = SimulatorRecord(udid: "SIM-1", name: "iPhone 17 Pro", state: .booted, isAvailable: true, availabilityError: nil, deviceTypeIdentifier: nil, runtime: nil, runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.iOS-26-3", dataPath: nil, logPath: nil, deviceTypeName: nil) + let unavailable = SimulatorRecord(udid: "SIM-2", name: "Old", state: .shutdown, isAvailable: false, availabilityError: "runtime missing", deviceTypeIdentifier: nil, runtime: nil, runtimeIdentifier: "x", dataPath: nil, logPath: nil, deviceTypeName: nil) + let devices = DeviceMerger.merge(usbmux: [muxA, muxB, muxBWifi], enrichment: [:], coreDevice: [], simulators: [simulator, unavailable]) + #expect(devices.count == 3) + #expect(devices.last?.kind == .simulator) + let second = try! #require(devices.first { $0.udid == "00008030-000000000000002E" }) + #expect(second.transports == [.usb, .network]) + #expect(second.usbmuxDeviceID == 2) + #expect(Set(devices.map(\.id)).count == 3) + } + + @Test func coordinatorPublishesAttachedDeviceWithLockdownDetails() async throws { + let server = try FakeDeviceServer() + server.listenSendsDetach = false + server.domainValues["com.apple.security.mac.amfi"] = ["DeveloperModeStatus": true] + try await server.start() + defer { Task { await server.stop() } } + let discovery = DeviceDiscovery(configuration: .init(usbmux: server.client, coreDevice: nil, simulators: nil)) + await discovery.start() + var found: Device? + for await snapshot in await discovery.updates() { + if let device = snapshot.physicalDevices.first, device.pairingState == .paired { + found = device + break + } + } + await discovery.stop() + let device = try #require(found) + #expect(device.name == "Test iPhone") + #expect(device.marketingName == "iPhone 15 Pro") + #expect(device.developerMode == .enabled) + #expect(device.osVersion == "18.2") + #expect(device.architecture == "arm64e") + } + + @Test func coordinatorReportsMissingDaemon() async throws { + let discovery = DeviceDiscovery(configuration: .init(usbmux: USBMuxClient(socketPath: "/tmp/nope-\(UUID().uuidString)"), coreDevice: nil, simulators: nil)) + await discovery.start() + var status: SourceStatus = .notChecked + for await snapshot in await discovery.updates() where snapshot.usbmux != .notChecked { + status = snapshot.usbmux + break + } + await discovery.stop() + #expect(!status.isAvailable) + #expect(status.summary.contains("usbmuxd")) + } +} diff --git a/Tests/DeviceKitTests/Fixtures/coredevice/apps.json b/Tests/DeviceKitTests/Fixtures/coredevice/apps.json new file mode 100644 index 0000000..7ed4fde --- /dev/null +++ b/Tests/DeviceKitTests/Fixtures/coredevice/apps.json @@ -0,0 +1,11 @@ +{ + "info" : { "outcome" : "success", "jsonVersion" : 5 }, + "result" : { + "apps" : [ + { "appClip" : false, "builtByDeveloper" : true, "bundleIdentifier" : "com.example.Demo", "bundleVersion" : "42", "defaultApp" : false, "hidden" : false, "internalApp" : false, "name" : "Demo", "removable" : true, "url" : "file:///private/var/containers/Bundle/Application/X/Demo.app/", "version" : "1.2" }, + { "appClip" : false, "builtByDeveloper" : false, "bundleIdentifier" : "com.apple.mobilesafari", "bundleVersion" : "8621", "defaultApp" : true, "hidden" : false, "internalApp" : false, "name" : "Safari", "removable" : false, "url" : "file:///Applications/MobileSafari.app/", "version" : "26.0" }, + { "name" : "No identifier" } + ], + "defaultAppsIncluded" : true + } +} diff --git a/Tests/DeviceKitTests/Fixtures/coredevice/error-developer-mode.json b/Tests/DeviceKitTests/Fixtures/coredevice/error-developer-mode.json new file mode 100644 index 0000000..b2fbe53 --- /dev/null +++ b/Tests/DeviceKitTests/Fixtures/coredevice/error-developer-mode.json @@ -0,0 +1,17 @@ +{ + "error" : { + "code" : 10002, + "domain" : "com.apple.dt.CoreDeviceError", + "userInfo" : { + "NSLocalizedDescription" : { "string" : "The operation couldn't be completed." }, + "NSUnderlyingError" : { + "error" : { + "code" : 3, + "domain" : "com.apple.mobile.notification", + "userInfo" : { "NSLocalizedDescription" : { "string" : "Developer Mode is disabled on this device." } } + } + } + } + }, + "info" : { "outcome" : "failed" } +} diff --git a/Tests/DeviceKitTests/Fixtures/coredevice/error-locked.json b/Tests/DeviceKitTests/Fixtures/coredevice/error-locked.json new file mode 100644 index 0000000..b93c222 --- /dev/null +++ b/Tests/DeviceKitTests/Fixtures/coredevice/error-locked.json @@ -0,0 +1 @@ +{ "error" : { "code" : 4, "domain" : "com.apple.dt.CoreDeviceError", "userInfo" : { "NSLocalizedDescription" : { "string" : "The device is passcode locked." } } }, "info" : { "outcome" : "failed" } } diff --git a/Tests/DeviceKitTests/Fixtures/coredevice/error-not-found.json b/Tests/DeviceKitTests/Fixtures/coredevice/error-not-found.json new file mode 100644 index 0000000..7ffbf6c --- /dev/null +++ b/Tests/DeviceKitTests/Fixtures/coredevice/error-not-found.json @@ -0,0 +1,10 @@ +{ + "error" : { + "code" : 1000, + "domain" : "com.apple.dt.CoreDeviceError", + "userInfo" : { + "NSLocalizedDescription" : { "string" : "No devices matched the specified criteria." } + } + }, + "info" : { "outcome" : "failed", "jsonVersion" : 5 } +} diff --git a/Tests/DeviceKitTests/Fixtures/coredevice/list-devices.json b/Tests/DeviceKitTests/Fixtures/coredevice/list-devices.json new file mode 100644 index 0000000..76eff28 --- /dev/null +++ b/Tests/DeviceKitTests/Fixtures/coredevice/list-devices.json @@ -0,0 +1,95 @@ +{ + "info" : { + "arguments" : ["devicectl", "list", "devices", "--json-output", "/tmp/x.json"], + "commandType" : "devicectl.list.devices", + "environment" : {}, + "jsonVersion" : 5, + "outcome" : "success", + "version" : "642.16" + }, + "result" : { + "devices" : [ + { + "capabilities" : [ + { "featureIdentifier" : "com.apple.coredevice.feature.connectdevice", "name" : "Connect to Device" }, + { "featureIdentifier" : "com.apple.coredevice.feature.installapp", "name" : "Install Application" } + ], + "connectionProperties" : { + "authenticationType" : "manualPairing", + "isMobileDeviceOnly" : false, + "lastConnectionDate" : "2026-09-25T18:04:11.000Z", + "localHostnames" : ["Test-iPhone.coredevice.local"], + "pairingState" : "paired", + "potentialHostnames" : ["00008110-001234560ABC801E.coredevice.local"], + "transportType" : "wired", + "tunnelIPAddress" : "fd2b:1d4c:5b33::1", + "tunnelState" : "connected", + "tunnelTransportProtocol" : "tcp" + }, + "deviceProperties" : { + "bootState" : "booted", + "bootedFromSnapshot" : true, + "bootedSnapshotName" : "com.apple.os.update-ABCDEF", + "ddiServicesAvailable" : true, + "developerModeStatus" : "enabled", + "hasInternalOSBuild" : false, + "name" : "Test iPhone", + "osBuildUpdate" : "23A341", + "osVersionNumber" : "26.0", + "rootFileSystemIsWritable" : false + }, + "hardwareProperties" : { + "cpuType" : { "name" : "arm64e", "subType" : 2, "type" : 16777228 }, + "deviceType" : "iPhone", + "ecid" : 1234567890123456, + "hardwareModel" : "D83AP", + "internalStorageCapacity" : 256000000000, + "isProductionFused" : true, + "marketingName" : "iPhone 15 Pro", + "platform" : "iOS", + "productType" : "iPhone16,1", + "reality" : "physical", + "serialNumber" : "FAKESERIAL01", + "supportedCPUTypes" : [{ "name" : "arm64e", "subType" : 2, "type" : 16777228 }], + "supportedDeviceFamilies" : [1], + "thinningProductType" : "iPhone16,1", + "udid" : "00008110-001234560ABC801E" + }, + "identifier" : "3C0B2B0A-6E1D-4F55-9F0B-111111111111", + "tags" : [], + "visibilityClass" : "default" + }, + { + "connectionProperties" : { + "pairingState" : "unpaired", + "transportType" : "localNetwork", + "tunnelState" : "unavailable" + }, + "deviceProperties" : { + "developerModeStatus" : "disabled", + "name" : "Office iPad", + "osVersionNumber" : "18.6" + }, + "hardwareProperties" : { + "deviceType" : "iPad", + "marketingName" : "iPad Air 11-inch (M2)", + "platform" : "iOS", + "productType" : "iPad14,8", + "reality" : "physical" + }, + "identifier" : "9F7A5E30-1111-4222-8333-444444444444", + "visibilityClass" : "default" + }, + { + "properties" : { + "hardware" : { "udid" : "00008120-000000000000AAAA", "productType" : "iPhone17,3", "marketingName" : "iPhone 16", "platform" : "iOS", "reality" : "physical", "deviceType" : "iPhone" }, + "device" : { "name" : "New Schema iPhone", "osVersionNumber" : "27.0", "osBuildUpdate" : "24A100", "developerModeStatus" : "enabled" }, + "connection" : { "transportType" : "wired", "pairingState" : "paired", "tunnelState" : "disconnected" }, + "state" : { "visibilityClass" : "default" } + }, + "identifier" : "AAAAAAAA-0000-4000-8000-000000000003" + }, + { "not" : "a device" } + ] + } +} diff --git a/Tests/DeviceKitTests/Fixtures/coredevice/lockstate.json b/Tests/DeviceKitTests/Fixtures/coredevice/lockstate.json new file mode 100644 index 0000000..437d1e6 --- /dev/null +++ b/Tests/DeviceKitTests/Fixtures/coredevice/lockstate.json @@ -0,0 +1 @@ +{ "info" : { "outcome" : "success" }, "result" : { "passcodeRequired" : true, "unlockedSinceBoot" : true } } diff --git a/Tests/DeviceKitTests/Fixtures/coredevice/processes.json b/Tests/DeviceKitTests/Fixtures/coredevice/processes.json new file mode 100644 index 0000000..2d17328 --- /dev/null +++ b/Tests/DeviceKitTests/Fixtures/coredevice/processes.json @@ -0,0 +1,11 @@ +{ + "info" : { "outcome" : "success" }, + "result" : { + "runningProcesses" : [ + { "executable" : "file:///usr/libexec/locationd", "processIdentifier" : 118 }, + { "executable" : "file:///sbin/launchd", "processIdentifier" : 1 }, + { "executable" : "file:///private/var/containers/Bundle/Application/X/My%20App.app/My%20App", "processIdentifier" : 912 }, + { "processIdentifier" : "bogus" } + ] + } +} diff --git a/Tests/DeviceKitTests/LockdownStackTests.swift b/Tests/DeviceKitTests/LockdownStackTests.swift new file mode 100644 index 0000000..565ae60 --- /dev/null +++ b/Tests/DeviceKitTests/LockdownStackTests.swift @@ -0,0 +1,280 @@ +import Foundation +import Testing +@testable import DeviceKit +import DeviceTestSupport +import ToolkitCore + +@Suite("usbmuxd + lockdown stack (fake device)", .serialized) +struct LockdownStackTests { + func withServer(_ configure: (FakeDeviceServer) -> Void = { _ in }, _ body: (FakeDeviceServer) async throws -> Void) async throws { + let server = try FakeDeviceServer() + configure(server) + try await server.start() + do { + try await body(server) + } catch { + await server.stop() + throw error + } + await server.stop() + } + + @Test func listsDevicesWithNormalizedUDID() async throws { + try await withServer { server in + let devices = try await server.client.listDevices() + #expect(devices.count == 1) + #expect(devices[0].udid == "00008110-001234560ABC801E") + #expect(devices[0].serialNumber == server.serial) + #expect(devices[0].transport == .usb) + #expect(devices[0].deviceID == 7) + } + } + + @Test func readsPairRecord() async throws { + try await withServer { server in + let device = try await server.client.listDevices()[0] + let record = try await server.client.readPairRecord(for: device) + #expect(record.hostID == TestPairing.hostID) + #expect(record.systemBUID == TestPairing.systemBUID) + #expect(record.escrowBag == Data([0xE5, 0xC0])) + } + } + + @Test func missingPairRecordIsNotPaired() async throws { + try await withServer({ $0.pairRecordAvailable = false }) { server in + let device = try await server.client.listDevices()[0] + do { + _ = try await server.client.readPairRecord(for: device) + Issue.record("expected notPaired") + } catch let error as ToolkitError { + #expect(error.kind == .notPaired) + #expect(error.recovery?.contains("Trust") == true) + } + } + } + + @Test func opensTLSSessionAndVerifiesIdentity() async throws { + try await withServer { server in + let session = try await DeviceSession.open(target: server.target, usbmux: server.client) + let name = try await session.getValue(key: "DeviceName") + #expect(name?.stringValue == "Test iPhone") + let all = try await session.getValue() + #expect(all?["ProductType"]?.stringValue == "iPhone16,1") + await session.close() + } + } + + @Test func refusesWhenAnotherDeviceAnswers() async throws { + try await withServer({ $0.reportedUDID = "00008110-00FFFFFFFFFFFFFF" }) { server in + do { + _ = try await DeviceSession.open(target: server.target, usbmux: server.client) + Issue.record("expected identity mismatch") + } catch let error as ToolkitError { + #expect(error.kind == .internalInconsistency) + #expect(error.message.contains("not the one you selected")) + } + } + } + + @Test func rejectsUnpinnedDeviceCertificate() async throws { + try await withServer({ $0.serverCertificate = "other" }) { server in + do { + _ = try await DeviceSession.open(target: server.target, usbmux: server.client) + Issue.record("expected TLS pinning failure") + } catch let error as ToolkitError { + #expect(error.kind == .notPaired || error.kind == .serviceUnavailable || error.kind == .deviceDisconnected) + } + } + } + + @Test func missingDeviceIsActionable() async throws { + try await withServer { server in + let other = DeviceTarget(kind: .physical, udid: "00008030-000000000000002E", name: "Other iPad", osVersion: nil, usbmuxDeviceID: 99, coreDeviceIdentifier: nil, transport: .usb) + do { + _ = try await DeviceSession.open(target: other, usbmux: server.client) + Issue.record("expected deviceNotFound") + } catch let error as ToolkitError { + #expect(error.kind == .deviceNotFound) + #expect(error.message.contains("Other iPad")) + } + } + } + + @Test func lockdownErrorsAreTranslated() async throws { + try await withServer({ $0.lockdownErrors["StartSession"] = "PasswordProtected" }) { server in + do { + _ = try await DeviceSession.open(target: server.target, usbmux: server.client) + Issue.record("expected locked") + } catch let error as ToolkitError { + #expect(error.kind == .deviceLocked) + #expect(error.message == "The device is locked.") + } + } + } + + @Test func opensServicesWithAndWithoutTLS() async throws { + for useTLS in [false, true] { + try await withServer({ $0.serviceTLS = useTLS }) { server in + server.register(service: "com.example.echo") { channel in + let bytes = try await channel.read(exactly: 4, timeout: 5) + try await channel.write(bytes + Data("!".utf8)) + } + try await DeviceSession.with(server.target, usbmux: server.client) { session in + let service = try await session.openService("com.example.echo") + try await service.channel.write(Data("ping".utf8)) + let reply = try await service.channel.read(exactly: 5, timeout: 5) + #expect(String(decoding: reply, as: UTF8.self) == "ping!") + } + } + } + } + + @Test func unknownServiceIsUnavailable() async throws { + try await withServer { server in + do { + try await DeviceSession.with(server.target, usbmux: server.client) { session in + _ = try await session.openService("com.apple.not.a.service") + } + Issue.record("expected failure") + } catch let error as ToolkitError { + #expect(error.kind == .serviceUnavailable) + } + } + } + + @Test func escrowBagIsSentOnlyWhenRequested() async throws { + try await withServer { server in + server.register(service: "com.apple.mobilebackup2") { _ in } + server.register(service: "com.apple.syslog_relay") { _ in } + try await DeviceSession.with(server.target, usbmux: server.client) { session in + _ = try await session.openService("com.apple.syslog_relay") + _ = try await session.openService("com.apple.mobilebackup2", useEscrowBag: true) + } + let requests = server.receivedServiceRequests + #expect(requests.count == 2) + #expect(requests[0]["EscrowBag"] == nil) + #expect(requests[1]["EscrowBag"]?.dataValue == Data([0xE5, 0xC0])) + } + } + + @Test func listenDeliversAttachAndDetachEvents() async throws { + try await withServer { server in + var events: [USBMuxEvent] = [] + for try await event in server.client.listen() { + events.append(event) + if events.count == 2 { break } + } + guard case .attached(let device) = events[0] else { + Issue.record("expected attached") + return + } + #expect(device.udid == server.udid) + #expect(events[1] == .detached(deviceID: 7)) + } + } + + @Test func absentSocketIsReportedClearly() async throws { + let client = USBMuxClient(socketPath: "/tmp/definitely-not-usbmuxd-\(UUID().uuidString)") + #expect(!client.isSocketPresent) + do { + _ = try await client.listDevices() + Issue.record("expected failure") + } catch let error as ToolkitError { + #expect(error.kind == .serviceUnavailable) + #expect(error.message.contains("usbmuxd")) + } + } +} + +@Suite("usbmuxd wire format") +struct USBMuxProtocolTests { + @Test func headerRoundTrip() throws { + let data = try USBMuxProtocol.encode(USBMuxProtocol.request("ListDevices"), tag: 42) + let header = try USBMuxProtocol.decodeHeader(data.prefix(16)) + #expect(Int(header.length) == data.count) + #expect(header.version == 1) + #expect(header.messageType == 8) + #expect(header.tag == 42) + let payload = try PlistValue.decode(data.dropFirst(16)) + #expect(payload["MessageType"]?.stringValue == "ListDevices") + #expect(payload["kLibUSBMuxVersion"]?.intValue == 3) + } + + @Test func rejectsOversizedOrTruncatedHeaders() { + var oversized = Data() + oversized.appendLittleEndian(UInt32(100 * 1024 * 1024)) + oversized.appendLittleEndian(UInt32(1)) + oversized.appendLittleEndian(UInt32(8)) + oversized.appendLittleEndian(UInt32(0)) + #expect(throws: ToolkitError.self) { try USBMuxProtocol.decodeHeader(oversized) } + #expect(throws: ToolkitError.self) { try USBMuxProtocol.decodeHeader(Data([1, 2, 3])) } + var tooSmall = Data() + tooSmall.appendLittleEndian(UInt32(4)) + tooSmall.appendLittleEndian(UInt32(1)) + tooSmall.appendLittleEndian(UInt32(8)) + tooSmall.appendLittleEndian(UInt32(0)) + #expect(throws: ToolkitError.self) { try USBMuxProtocol.decodeHeader(tooSmall) } + } + + @Test func portIsNetworkOrder() { + #expect(USBMuxProtocol.networkOrderPort(62078) == 0x7EF2) + } + + @Test func udidNormalization() { + #expect(USBMuxDevice.normalizedUDID("00008110001234560ABC801E") == "00008110-001234560ABC801E") + #expect(USBMuxDevice.normalizedUDID("00008110-001234560ABC801E") == "00008110-001234560ABC801E") + let legacy = String(repeating: "a", count: 40) + #expect(USBMuxDevice.normalizedUDID(legacy) == legacy) + } + + @Test func eventsParse() { + let attached: PlistValue = ["MessageType": "Attached", "DeviceID": 3, "Properties": ["SerialNumber": "abc", "ConnectionType": "Network", "DeviceID": 3]] + guard case .attached(let device)? = USBMuxProtocol.event(from: attached) else { + Issue.record("expected attached") + return + } + #expect(device.transport == .network) + #expect(USBMuxProtocol.event(from: ["MessageType": "Detached", "DeviceID": 3]) == .detached(deviceID: 3)) + #expect(USBMuxProtocol.event(from: ["MessageType": "Paired", "DeviceID": 3]) == .paired(deviceID: 3)) + #expect(USBMuxProtocol.event(from: ["MessageType": "Bogus"]) == nil) + #expect(USBMuxProtocol.event(from: ["MessageType": "Attached"]) == nil) + } + + @Test func resultNumbersMapToErrors() { + #expect(USBMuxProtocol.resultError(0, operation: "x") == nil) + #expect(USBMuxProtocol.resultError(2, operation: "x")?.kind == .deviceDisconnected) + #expect(USBMuxProtocol.resultError(3, operation: "x")?.kind == .serviceUnavailable) + #expect(USBMuxProtocol.resultError(6, operation: "x")?.kind == .protocolViolation) + } + + @Test func lockdownErrorInterpretation() { + #expect(LockdownErrorInterpreter.interpret("PasswordProtected", request: "x").kind == .deviceLocked) + #expect(LockdownErrorInterpreter.interpret("InvalidHostID", request: "x").kind == .notPaired) + #expect(LockdownErrorInterpreter.interpret("PairingDialogResponsePending", request: "x").kind == .pairingPending) + #expect(LockdownErrorInterpreter.interpret("InvalidService", request: "x").kind == .serviceUnavailable) + #expect(LockdownErrorInterpreter.interpret("SomethingNew", request: "x").kind == .commandFailed) + } +} + +@Suite("Real usbmuxd on this Mac") +struct RealUSBMuxTests { + /// Verifies wire compatibility with the actual macOS daemon. Passes with zero devices. + @Test(.enabled(if: USBMuxClient().isSocketPresent)) + func listDevicesAgainstSystemDaemon() async throws { + let devices = try await USBMuxClient().listDevices() + for device in devices { + #expect(!device.udid.isEmpty) + } + } + + @Test(.enabled(if: USBMuxClient().isSocketPresent)) + func listenHandshakeAgainstSystemDaemon() async throws { + let stream = USBMuxClient().listen() + let task = Task { + for try await _ in stream { break } + } + try await Task.sleep(for: .milliseconds(500)) + task.cancel() + _ = await task.result + } +} diff --git a/Tests/DeviceKitTests/ServiceTests.swift b/Tests/DeviceKitTests/ServiceTests.swift new file mode 100644 index 0000000..1732d96 --- /dev/null +++ b/Tests/DeviceKitTests/ServiceTests.swift @@ -0,0 +1,409 @@ +import Foundation +import Testing +@testable import DeviceKit +import DeviceTestSupport +import ToolkitCore + +private func runWithServer(_ configure: (FakeDeviceServer) -> Void, _ body: (FakeDeviceServer) async throws -> Void) async throws { + let server = try FakeDeviceServer() + configure(server) + try await server.start() + do { + try await body(server) + } catch { + await server.stop() + throw error + } + await server.stop() +} + +/// Builds an os_trace_relay record with the documented layout. +func makeTraceRecord(pid: UInt32, seconds: UInt32, microseconds: UInt32, level: UInt8, filename: String, image: String, message: String, subsystem: String?, category: String?) -> Data { + var bytes = [UInt8](repeating: 0, count: 129) + func put32(_ value: UInt32, _ offset: Int) { + for index in 0..<4 { bytes[offset + index] = UInt8((value >> (8 * UInt32(index))) & 0xFF) } + } + func put16(_ value: UInt16, _ offset: Int) { + bytes[offset] = UInt8(value & 0xFF) + bytes[offset + 1] = UInt8(value >> 8) + } + put32(pid, 9) + put32(seconds, 55) + put32(microseconds, 63) + bytes[68] = level + let imageBytes = Array(image.utf8) + [0] + let messageBytes = Array(message.utf8) + [0] + put16(UInt16(imageBytes.count), 107) + put16(UInt16(messageBytes.count), 109) + let subsystemBytes: [UInt8] = subsystem.map { Array($0.utf8) + [0] } ?? [] + let categoryBytes: [UInt8] = category.map { Array($0.utf8) + [0] } ?? [] + put32(UInt32(subsystemBytes.count), 117) + put32(UInt32(categoryBytes.count), 121) + bytes += Array(filename.utf8) + [0] + bytes += imageBytes + messageBytes + subsystemBytes + categoryBytes + return Data(bytes) +} + +@Suite("Lockdown services (fake device)", .serialized) +struct ServiceTests { + @Test func syslogRelaySplitsRecordsAndKeepsRawBytes() async throws { + let raw = Data("Sep 26 10:00:00 iPhone kernel[0] : one\u{0}Sep 26 10:00:01 iPhone SpringBoard[55] : two\nthree\u{0}".utf8) + try await runWithServer({ _ in }) { server in + server.register(service: SyslogRelay.serviceName) { channel in + try await channel.write(raw.prefix(20)) + try await channel.write(raw.dropFirst(20)) + } + try await DeviceSession.with(server.target, usbmux: server.client) { session in + var spooled = Data() + var lines: [String] = [] + for try await chunk in try await SyslogRelay.stream(session) { + spooled.append(chunk.spoolBytes) + lines += chunk.lines.map(\.message) + } + #expect(spooled == raw) + #expect(lines.count == 3) + #expect(lines[0].hasSuffix(": one")) + #expect(lines[2] == "three") + } + } + } + + @Test func syslogParserBoundsUnterminatedRecords() { + var parser = SyslogRecordParser() + parser.maximumRecordLength = 10 + let lines = parser.consume(Data(repeating: 0x41, count: 25)) + #expect(lines.count == 1) + #expect(parser.flush().isEmpty) + } + + @Test func osTraceRelayDecodesStructuredRecords() async throws { + let record = makeTraceRecord(pid: 321, seconds: 1_700_000_000, microseconds: 250_000, level: 0x10, filename: "/usr/libexec/locationd", image: "CoreLocation", message: "Location updated", subsystem: "com.apple.locationd", category: "Core") + try await runWithServer({ _ in }) { server in + server.register(service: OSTraceRelay.serviceName) { channel in + let request = try await PlistMessageConnection(channel: channel).receive(timeout: 5) + #expect(request["Request"]?.stringValue == "StartActivity") + let reply = try PlistValue(dictionaryLiteral: ("Status", "RequestSuccessful")).encoded(format: .xml) + var header = Data() + header.appendLittleEndian(UInt32(4)) + header.appendLittleEndian(UInt32(reply.count)) + try await channel.write(header + reply) + var frame = Data([0x02]) + frame.appendLittleEndian(UInt32(record.count)) + try await channel.write(frame + record) + } + try await DeviceSession.with(server.target, usbmux: server.client) { session in + var lines: [LogLine] = [] + for try await chunk in try await OSTraceRelay.stream(session) { + lines += chunk.lines + #expect(!chunk.spoolBytes.isEmpty) + } + #expect(lines.count == 1) + let line = try #require(lines.first) + #expect(line.pid == 321) + #expect(line.process == "locationd") + #expect(line.level == "Error") + #expect(line.subsystem == "com.apple.locationd") + #expect(line.category == "Core") + #expect(line.message == "Location updated") + #expect(line.timestamp == Date(timeIntervalSince1970: 1_700_000_000.25)) + } + } + } + + @Test func osTraceParserFallsBackOnMalformedRecords() { + let line = OSTraceRecordParser.parse(Data("short but readable text".utf8)) + #expect(line.level == "Undecoded") + #expect(line.message.contains("readable")) + let truncated = makeTraceRecord(pid: 1, seconds: 1, microseconds: 0, level: 0, filename: "x", image: "img", message: "hello", subsystem: nil, category: nil).prefix(135) + #expect(OSTraceRecordParser.parse(truncated).level == "Undecoded") + } + + @Test func processListParsesPidListReplies() throws { + let processes = try OSTraceRelay.parseProcessList(["Status": "RequestSuccessful", "Payload": ["250": ["ProcessName": "SpringBoard"], "1": ["ProcessName": "launchd"], "7": [:], "not-a-pid": ["ProcessName": "x"]]]) + #expect(processes.map(\.pid) == [1, 7, 250]) + #expect(processes.map(\.name) == ["launchd", "PID 7", "SpringBoard"]) + #expect(throws: ToolkitError.self) { try OSTraceRelay.parseProcessList(["Status": "RequestFailed"]) } + } + + @Test func configurationProfileListParsesMCInstallReplies() throws { + let profiles = try ConfigurationProfileService.parse([ + "Status": "Acknowledged", + "OrderedIdentifiers": ["b.profile", "a.profile"], + "ProfileMetadata": [ + "a.profile": ["PayloadDisplayName": "A", "PayloadVersion": 1], + "b.profile": ["PayloadDisplayName": "B", "PayloadRemovalDisallowed": true, "PayloadUUID": "U"], + "c.profile": ["PayloadDisplayName": "C"], + ], + "ProfileManifest": ["b.profile": ["IsActive": false]], + ]) + // Device order first, then any profile missing from the order, alphabetically. + #expect(profiles.map(\.identifier) == ["b.profile", "a.profile", "c.profile"]) + #expect(profiles[0].removalDisallowed == true && profiles[0].isActive == false && profiles[0].uuid == "U") + #expect(profiles[1].version == 1 && profiles[1].isActive == nil) + #expect(throws: ToolkitError.self) { try ConfigurationProfileService.parse(["Status": "Error"]) } + } + + @Test func webInspectorListsPagesAfterRetryingRefusals() async throws { + // The first session is dropped (as when a session starts too soon); the retry succeeds. + let inspector = FakeWebInspector(refusals: 1) + try await runWithServer({ inspector.register(on: $0) }) { server in + let applications = try await WebInspector.openPages(on: server.target, usbmux: server.client, retryInterval: .milliseconds(100), listingWindow: .milliseconds(500)) + let safari = try #require(applications.first) + #expect(safari.name == "Safari" && safari.bundleIdentifier == "com.apple.mobilesafari" && safari.isActive == true) + #expect(safari.pages.map(\.title) == [nil, "Example Domain"]) + #expect(safari.pages.map(\.kindLabel) == ["JavaScript context", "Web page"]) + #expect(safari.pages.last?.url == "https://example.com/") + #expect(inspector.sessions == 2) + #expect(inspector.connectionIdentifiers.count == 1, "every message carries the same connection identifier") + #expect(inspector.selectors.prefix(3) == ["_rpc_reportIdentifier:", "_rpc_getConnectedApplications:", "_rpc_forwardGetListing:"]) + } + } + + @Test func webInspectorExplainsHowToTurnItOnWhenRefused() async throws { + let inspector = FakeWebInspector(refusals: 100) + try await runWithServer({ inspector.register(on: $0) }) { server in + do { + _ = try await WebInspector.openPages(on: server.target, usbmux: server.client, handshakeDeadline: .milliseconds(600), retryInterval: .milliseconds(100)) + Issue.record("expected a refusal") + } catch let error as ToolkitError { + #expect(error.message == "Safari Web Inspector did not answer.") + #expect(error.recovery?.contains("Settings › Apps › Safari › Advanced › Web Inspector") == true) + #expect(error.recovery?.contains("ten seconds") == true) + } + #expect(inspector.sessions > 2, "refusals are retried until the deadline") + } + } + + @Test func webInspectorListingStateFollowsTheDevice() { + var state = WebInspector.ListingState() + state.apply(["__selector": "_rpc_reportConnectedApplicationList:", "__argument": ["WIRApplicationDictionaryKey": ["A": ["WIRApplicationNameKey": "Safari"], "B": ["WIRApplicationNameKey": "Mail"]]]]) + state.apply(["__selector": "_rpc_applicationSentListing:", "__argument": ["WIRApplicationIdentifierKey": "A", "WIRListingKey": ["1": ["WIRTitleKey": "One", "WIRTypeKey": "WIRTypeWeb"], "2": ["WIRTitleKey": "Two", "WIRTypeKey": "WIRTypeWeb"]]]]) + // A new listing replaces the old one: closed tabs disappear. + state.apply(["__selector": "_rpc_applicationSentListing:", "__argument": ["WIRApplicationIdentifierKey": "A", "WIRListingKey": ["2": ["WIRTitleKey": "Two", "WIRTypeKey": "WIRTypeWeb"]]]]) + state.apply(["__selector": "_rpc_applicationDisconnected:", "__argument": ["WIRApplicationIdentifierKey": "B"]]) + state.apply(["__selector": "_rpc_unknownSelector:", "__argument": [:]]) + #expect(state.result.map(\.name) == ["Safari"]) + #expect(state.result.first?.pages.map(\.title) == ["Two"]) + } + + static func packetLoggerRecord(type: UInt8, seconds: UInt32, payload: [UInt8]) -> Data { + var data = Data() + data.appendBigEndian(UInt32(8 + 1 + payload.count)) + data.appendBigEndian(seconds) + data.appendBigEndian(UInt32(250)) + data.append(type) + data.append(contentsOf: payload) + return data + } + + static func serviceFrame(_ record: Data) -> Data { + Data([UInt8(record.count & 0xFF), UInt8(record.count >> 8)]) + record + } + + @Test func bluetoothRecordsBecomeAPacketLoggerFile() async throws { + let command = Self.packetLoggerRecord(type: 0x00, seconds: 1_700_000_000, payload: [0x03, 0x0C, 0x00]) + let event = Self.packetLoggerRecord(type: 0x01, seconds: 1_700_000_001, payload: [0x0E, 0x04, 0x01, 0x03, 0x0C, 0x00]) + #expect(PacketLoggerRecord(Data([0, 1, 2])) == nil) + let parsed = try #require(PacketLoggerRecord(command)) + #expect(parsed.seconds == 1_700_000_000 && parsed.microseconds == 250 && parsed.typeLabel == "HCI command" && parsed.payload == Data([0x03, 0x0C, 0x00])) + try await runWithServer({ _ in }) { server in + server.register(service: BluetoothPacketLogger.serviceName) { channel in + try await channel.write(Data([0, 0]) + Self.serviceFrame(command) + Self.serviceFrame(event)) + } + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "bt") + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("capture.pklg") + let writer = try PacketLoggerFileWriter(creatingNewFileAt: file) + try await DeviceSession.with(server.target, usbmux: server.client) { session in + for try await record in try await BluetoothPacketLogger.records(session) { try writer.write(record) } + } + let digest = try writer.finish() + let written = try Data(contentsOf: file) + #expect(written == command + event, "a .pklg file is the records back to back") + #expect(digest == SecureFileIO.sha256(of: written)) + #expect(writer.countsByType == [0x00: 1, 0x01: 1]) + } + } + + @Test func bluetoothStreamRejectsDesynchronizedRecordsAndExplainsMissingProfile() async throws { + try await runWithServer({ _ in }) { server in + server.register(service: BluetoothPacketLogger.serviceName) { channel in + try await channel.write(Data([5, 0, 1, 2, 3, 4, 5])) + } + await #expect(throws: ToolkitError.self) { + try await DeviceSession.with(server.target, usbmux: server.client) { session in + for try await _ in try await BluetoothPacketLogger.records(session) {} + } + } + } + try await runWithServer({ _ in }) { server in + do { + try await DeviceSession.with(server.target, usbmux: server.client) { session in + _ = try await BluetoothPacketLogger.records(session) + } + Issue.record("expected the service to be unavailable") + } catch let error as ToolkitError { + #expect(error.message == "The device did not start Bluetooth logging.") + #expect(error.recovery?.contains("Bluetooth logging profile") == true) + } + } + } + + @Test func springBoardServicesAnswerQueries() async throws { + let png = Data([0x89, 0x50, 0x4E, 0x47]) + try await runWithServer({ _ in }) { server in + server.register(service: SpringBoardServices.serviceName) { channel in + let messages = PlistMessageConnection(channel: channel) + while let request = try? await messages.receive(timeout: 5) { + switch request["command"]?.stringValue { + case "getInterfaceOrientation": try await messages.send(["interfaceOrientation": 3]) + case "getHomeScreenIconMetrics": try await messages.send(["homeScreenIconColumns": 4]) + case "getIconPNGData": try await messages.send(["pngData": .data(png)]) + default: return + } + } + } + try await DeviceSession.with(server.target, usbmux: server.client) { session in + let springboard = try await SpringBoardServices.open(session) + #expect(try await springboard.interfaceOrientation() == .landscapeLeft) + #expect(try await springboard.homeScreenIconMetrics()["homeScreenIconColumns"]?.intValue == 4) + #expect(try await springboard.iconPNG(bundleIdentifier: "com.apple.mobilesafari") == png) + await #expect(throws: ToolkitError.self) { _ = try await springboard.iconPNG(bundleIdentifier: "bad id!") } + await springboard.close() + } + } + } + + @Test func pcapdPacketsBecomeAValidPcapFile() async throws { + var header = [UInt8](repeating: 0, count: 95) + let payload: [UInt8] = [0x45, 0x00, 0x00, 0x14] + [UInt8](repeating: 0xAB, count: 16) + func be32(_ value: UInt32, _ offset: Int) { + header[offset] = UInt8(value >> 24); header[offset + 1] = UInt8((value >> 16) & 0xFF) + header[offset + 2] = UInt8((value >> 8) & 0xFF); header[offset + 3] = UInt8(value & 0xFF) + } + be32(95, 0) + be32(UInt32(payload.count), 5) + header[12] = 0x01 + be32(2, 13) + for (index, byte) in Array("en0".utf8).enumerated() { header[25 + index] = byte } + header[41] = 42 + for (index, byte) in Array("Safari".utf8).enumerated() { header[45 + index] = byte } + be32(1_700_000_000, 87) + be32(5, 91) + let blob = Data(header + payload) + + let packet = try #require(PcapdRecordParser.parse(blob)) + #expect(packet.interfaceName == "en0") + #expect(packet.processName == "Safari") + #expect(packet.pid == 42) + #expect(packet.isOutbound) + #expect(packet.frame.count == 14 + payload.count) + #expect(packet.frame[12] == 0x08) + + try await runWithServer({ _ in }) { server in + server.register(service: PacketCaptureService.serviceName) { channel in + try await PlistMessageConnection(channel: channel).send(.data(blob), format: .binary) + } + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "pcap-test") + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("capture.pcap") + let writer = try PcapFileWriter(creatingNewFileAt: file) + try await DeviceSession.with(server.target, usbmux: server.client) { session in + for try await packet in try await PacketCaptureService.stream(session) { + try writer.write(packet) + } + } + let digest = try writer.finish() + let data = try Data(contentsOf: file) + #expect(data.readLittleEndianUInt32(at: 0) == 0xA1B2_C3D4) + #expect(data.readLittleEndianUInt32(at: 20) == 1) + #expect(data.readLittleEndianUInt32(at: 24) == 1_700_000_000) + #expect(Int(data.readLittleEndianUInt32(at: 32)) == 14 + payload.count) + #expect(writer.packetCount == 1) + #expect(digest == (try SecureFileIO.sha256(of: file))) + } + } + + @Test func diagnosticsBatteryAndFailures() async throws { + try await runWithServer({ _ in }) { server in + server.register(service: DiagnosticsRelay.serviceName) { channel in + let messages = PlistMessageConnection(channel: channel) + let request = try await messages.receive(timeout: 5) + #expect(request["EntryClass"]?.stringValue == "IOPMPowerSource") + try await messages.send(["Status": "Success", "Diagnostics": ["IORegistry": ["CurrentCapacity": 81, "IsCharging": true, "CycleCount": 312, "Temperature": 3050, "DesignCapacity": 3000, "AppleRawMaxCapacity": 2700]]]) + _ = try await messages.receive(timeout: 5) + try await messages.send(["Status": "Failure"]) + } + try await DeviceSession.with(server.target, usbmux: server.client) { session in + let relay = try await DiagnosticsRelay.open(session) + let summary = BatterySummary(registry: try await relay.battery()) + #expect(summary.percentage == 81) + #expect(summary.isCharging == true) + #expect(summary.cycleCount == 312) + #expect(summary.temperatureCelsius == 30.5) + #expect(summary.healthPercentage == 90) + await #expect(throws: ToolkitError.self) { _ = try await relay.all() } + } + } + } + + @Test func installationProxyBrowsesInBatches() async throws { + try await runWithServer({ _ in }) { server in + server.register(service: InstallationProxy.serviceName) { channel in + let messages = PlistMessageConnection(channel: channel) + let request = try await messages.receive(timeout: 5) + #expect(request["Command"]?.stringValue == "Browse") + try await messages.send(["Status": "BrowsingApplications", "CurrentList": [ + ["CFBundleIdentifier": "com.example.zeta", "CFBundleDisplayName": "Zeta", "ApplicationType": "User", "StaticDiskUsage": 1000, "DynamicDiskUsage": 500], + ]]) + try await messages.send(["Status": "BrowsingApplications", "CurrentList": [ + ["CFBundleIdentifier": "com.apple.mobilesafari", "CFBundleName": "Safari", "ApplicationType": "System", "CFBundleShortVersionString": "18.2"], + ["CFBundleName": "Missing identifier"], + ]]) + try await messages.send(["Status": "Complete"]) + } + try await DeviceSession.with(server.target, usbmux: server.client) { session in + let proxy = try await InstallationProxy.open(session) + let apps = try await proxy.browse(includeSizes: true) + #expect(apps.map(\.bundleIdentifier) == ["com.apple.mobilesafari", "com.example.zeta"]) + #expect(apps[1].totalBytes == 1500) + #expect(apps[0].totalBytes == nil) + #expect(apps[0].typeLabel == "Built-in") + } + } + } + + @Test func installationProxyErrorsAreActionable() { + let error = InstallationProxy.interpret(error: "ApplicationVerificationFailed", description: "bad sig", operation: "Installing") + #expect(error.message.contains("signature")) + #expect(error.recovery?.contains("provisioning profile") == true) + } + + @Test func imageMounterQueries() async throws { + try await runWithServer({ _ in }) { server in + server.register(service: ImageMounter.serviceName) { channel in + let messages = PlistMessageConnection(channel: channel) + while let request = try? await messages.receive(timeout: 5) { + switch request["Command"]?.stringValue { + case "CopyDevices": + try await messages.send(["EntryList": [["MountPath": "/System/Developer", "DiskImageType": "Personalized", "IsMounted": true]]]) + case "QueryDeveloperModeStatus": + try await messages.send(["DeveloperModeStatus": true]) + case "UnmountImage": + try await messages.send(["Error": "UnmountImageFailed", "DetailedError": "image not mounted"]) + default: + try await messages.send(["Status": "Complete"]) + } + } + } + try await DeviceSession.with(server.target, usbmux: server.client) { session in + let mounter = try await ImageMounter.open(session) + let images = try await mounter.mountedImages() + #expect(images.first?.mountPath == "/System/Developer") + #expect(images.first?.isMounted == true) + #expect(try await mounter.developerModeStatus() == true) + try await mounter.unmountDeveloperImage() + } + } + } +} diff --git a/Tests/DeviceKitTests/Support/ScriptedCommandRunner.swift b/Tests/DeviceKitTests/Support/ScriptedCommandRunner.swift new file mode 100644 index 0000000..b107714 --- /dev/null +++ b/Tests/DeviceKitTests/Support/ScriptedCommandRunner.swift @@ -0,0 +1,62 @@ +import Foundation +@testable import DeviceKit +import ToolkitCore + +/// A `CommandRunning` double that records requests and replays scripted results. When a +/// request carries `--json-output `, the scripted JSON is written there, the way +/// devicectl does. +final class ScriptedCommandRunner: CommandRunning, @unchecked Sendable { + struct Reply: Sendable { + var exitCode: Int32 = 0 + var standardOutput = Data() + var standardError = Data() + var jsonFile: Data? + var error: ToolkitError? + } + + private let lock = NSLock() + private var handler: @Sendable (CommandRequest) -> Reply + private(set) var requests: [CommandRequest] = [] + + init(_ handler: @escaping @Sendable (CommandRequest) -> Reply) { + self.handler = handler + } + + var recorded: [CommandRequest] { lock.withLock { requests } } + + func run(_ request: CommandRequest) async throws -> CommandResult { + lock.withLock { requests.append(request) } + let reply = handler(request) + if let error = reply.error { throw error } + if let json = reply.jsonFile, let index = request.arguments.firstIndex(of: "--json-output"), index + 1 < request.arguments.count { + try json.write(to: URL(fileURLWithPath: request.arguments[index + 1])) + } + return CommandResult(request: request, termination: .exited(reply.exitCode), standardOutput: reply.standardOutput, standardError: reply.standardError, startedAt: Date(), finishedAt: Date()) + } + + func stream(_ request: CommandRequest) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + do { + let result = try await self.run(request) + if !result.standardOutput.isEmpty { continuation.yield(.standardOutput(result.standardOutput)) } + continuation.yield(.finished(result)) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } +} + +enum Fixture { + static func data(_ path: String) -> Data { + let url = Bundle.module.url(forResource: "Fixtures/\(path)", withExtension: nil)! + return try! Data(contentsOf: url) + } + + static func json(_ path: String) -> JSONValue { + try! JSONValue.parse(data(path)) + } +} diff --git a/Tests/DeviceTestSupport/FakeAFC.swift b/Tests/DeviceTestSupport/FakeAFC.swift new file mode 100644 index 0000000..a87b1f0 --- /dev/null +++ b/Tests/DeviceTestSupport/FakeAFC.swift @@ -0,0 +1,118 @@ +import Foundation +import DeviceKit +import ToolkitCore + +/// An in-memory AFC server for tests. +public actor FakeAFCFileSystem { + public var files: [String: Data] + var directories: Set + var handles: [UInt64: (path: String, offset: Int)] = [:] + var nextHandle: UInt64 = 1 + public var lockOperations: [UInt64] = [] + + public init(files: [String: Data], directories: Set = ["/"]) { + self.files = files + var all = directories + for path in files.keys { + var parent = (path as NSString).deletingLastPathComponent + while !parent.isEmpty && parent != "/" { + all.insert(parent) + parent = (parent as NSString).deletingLastPathComponent + } + } + all.insert("/") + self.directories = all + } + + func children(of path: String) -> [String] { + let prefix = path == "/" ? "/" : path + "/" + let names = (Array(files.keys) + Array(directories)).compactMap { entry -> String? in + guard entry.hasPrefix(prefix), entry != path else { return nil } + let rest = entry.dropFirst(prefix.count) + guard !rest.isEmpty, !rest.contains("/") else { return nil } + return String(rest) + } + return Array(Set(names)).sorted() + } + + public func serve(_ channel: DeviceChannel) async throws { + while try await channel.hasMoreData() { + let header = try await channel.read(exactly: 40, timeout: 5) + let entire = Int(header.readLittleEndianUInt64(at: 8)) + let this = Int(header.readLittleEndianUInt64(at: 16)) + let number = header.readLittleEndianUInt64(at: 24) + let operation = header.readLittleEndianUInt64(at: 32) + let headerData = try await channel.read(exactly: this - 40, timeout: 5) + let payload = try await channel.read(exactly: entire - this, timeout: 5) + let reply = handle(operation: operation, header: headerData, payload: payload) + try await channel.write(AFCClient.encode(operation: reply.operation, packetNumber: number, header: reply.header, payload: reply.payload)) + } + } + + private func status(_ code: UInt64) -> (operation: UInt64, header: Data, payload: Data) { + var data = Data() + data.appendLittleEndian(code) + return (0x01, data, Data()) + } + + private func path(_ data: Data) -> String { + String(decoding: data.prefix { $0 != 0 }, as: UTF8.self) + } + + private func handle(operation: UInt64, header: Data, payload: Data) -> (operation: UInt64, header: Data, payload: Data) { + switch operation { + case 0x03: + let directory = path(header) + guard directories.contains(directory) else { return status(8) } + let names = [".", ".."] + children(of: directory) + return (0x02, Data(), Data(names.map { $0 + "\u{0}" }.joined().utf8)) + case 0x0A: + let target = path(header) + if let data = files[target] { + return (0x02, Data(), Data("st_size\u{0}\(data.count)\u{0}st_ifmt\u{0}S_IFREG\u{0}st_mtime\u{0}1700000000000000000\u{0}".utf8)) + } + if directories.contains(target) { + return (0x02, Data(), Data("st_size\u{0}0\u{0}st_ifmt\u{0}S_IFDIR\u{0}".utf8)) + } + return status(8) + case 0x0D: + let mode = header.readLittleEndianUInt64(at: 0) + let target = path(header.dropFirst(8)) + if mode == 1 && files[target] == nil { return status(8) } + if mode == 3 { files[target] = Data() } + if mode == 2 && files[target] == nil { files[target] = Data() } + let handle = nextHandle + nextHandle += 1 + handles[handle] = (target, 0) + var data = Data() + data.appendLittleEndian(handle) + return (0x0E, data, Data()) + case 0x0F: + let handle = header.readLittleEndianUInt64(at: 0) + let size = Int(header.readLittleEndianUInt64(at: 8)) + guard let state = handles[handle], let data = files[state.path] else { return status(7) } + let chunk = data.dropFirst(state.offset).prefix(size) + handles[handle] = (state.path, state.offset + chunk.count) + return (0x02, Data(), Data(chunk)) + case 0x10: + let handle = header.readLittleEndianUInt64(at: 0) + guard let state = handles[handle] else { return status(7) } + files[state.path, default: Data()].append(payload) + return status(0) + case 0x14: + handles[header.readLittleEndianUInt64(at: 0)] = nil + return status(0) + case 0x1B: + lockOperations.append(header.readLittleEndianUInt64(at: 8)) + return status(0) + case 0x09: + directories.insert(path(header)) + return status(0) + case 0x08: + files[path(header)] = nil + return status(0) + default: + return status(2) + } + } +} diff --git a/Tests/DeviceTestSupport/FakeDeviceServer.swift b/Tests/DeviceTestSupport/FakeDeviceServer.swift new file mode 100644 index 0000000..45ffac8 --- /dev/null +++ b/Tests/DeviceTestSupport/FakeDeviceServer.swift @@ -0,0 +1,265 @@ +import Foundation +import NIOCore +import NIOPosix +import NIOSSL +import DeviceKit +import ToolkitCore + +/// Test-only pairing material (generated for this test suite; not real device keys). +public enum TestPairing { + public static func fixture(_ name: String) -> Data { + let url = Bundle.module.url(forResource: "Fixtures/pairing/\(name)", withExtension: nil)! + return try! Data(contentsOf: url) + } + + public static let hostID = "5F6C1AF2-0000-4000-8000-00000000C0DE" + public static let systemBUID = "B0D5E3A1-0000-4000-8000-0000000B01D0" + + public static var pairRecord: PlistValue { + [ + "HostID": .string(hostID), + "SystemBUID": .string(systemBUID), + "HostCertificate": .data(fixture("host.pem")), + "HostPrivateKey": .data(fixture("host.key")), + "DeviceCertificate": .data(fixture("device.pem")), + "RootCertificate": .data(fixture("root.pem")), + "EscrowBag": .data(Data([0xE5, 0xC0])), + ] + } +} + +/// A scripted usbmuxd + lockdownd + service implementation over a temporary Unix socket. +/// +/// It speaks the same wire formats as the real services so the production client code +/// (framing, pairing, TLS with client certificates and pinning, identity checks, services) +/// is exercised end to end without hardware. +public final class FakeDeviceServer: @unchecked Sendable { + public typealias ServiceHandler = @Sendable (DeviceChannel) async throws -> Void + + public let socketPath: String + public let directory: URL + public let serial = "00008110001234560ABC801E" + public var udid: String { USBMuxDevice.normalizedUDID(serial) } + public let deviceID = 7 + + public var reportedUDID: String? + public var pairRecordAvailable = true + public var serverCertificate = "device" + public var serviceTLS = false + public var listenSendsDetach = true + public var lockdownValues: [String: PlistValue] = [:] + public var domainValues: [String: [String: PlistValue]] = [:] + public var lockdownErrors: [String: String] = [:] + private var services: [String: ServiceHandler] = [:] + private var servicePorts: [UInt16: String] = [:] + private var nextPort: UInt16 = 49_152 + private let lock = NSLock() + private var serverChannel: Channel? + public private(set) var receivedServiceRequests: [PlistValue] = [] + + public init() throws { + directory = try SecureFileIO.makeTemporaryDirectory(prefix: "fake-usbmuxd") + socketPath = directory.appendingPathComponent("usbmuxd").path + lockdownValues = [ + "DeviceName": "Test iPhone", + "ProductType": "iPhone16,1", + "ProductVersion": "18.2", + "BuildVersion": "22C152", + "DeviceClass": "iPhone", + "HardwareModel": "D83AP", + "CPUArchitecture": "arm64e", + ] + } + + public var client: USBMuxClient { USBMuxClient(socketPath: socketPath) } + + public var target: DeviceTarget { + DeviceTarget(kind: .physical, udid: udid, name: "Test iPhone", osVersion: "18.2", usbmuxDeviceID: deviceID, coreDeviceIdentifier: nil, transport: .usb) + } + + public func register(service name: String, handler: @escaping ServiceHandler) { + lock.withLock { services[name] = handler } + } + + public func start() async throws { + let bootstrap = ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton) + .childChannelInitializer { [self] child in + child.eventLoop.makeCompletedFuture { + let inbound = InboundBuffer() + try child.pipeline.syncOperations.addHandler(inbound) + let connection = DeviceChannel(channel: child, inbound: inbound, description: "fake peer") + Task { await self.handleUSBMux(connection) } + } + } + serverChannel = try await bootstrap.bind(unixDomainSocketPath: socketPath).get() + } + + public func stop() async { + try? await serverChannel?.close().get() + try? FileManager.default.removeItem(at: directory) + } + + // MARK: usbmuxd + + private var attachedMessage: PlistValue { + [ + "MessageType": "Attached", + "DeviceID": .integer(Int64(deviceID)), + "Properties": [ + "SerialNumber": .string(serial), + "ConnectionType": "USB", + "DeviceID": .integer(Int64(deviceID)), + "ProductID": 4776, + "LocationID": 1, + ], + ] + } + + private func send(_ value: PlistValue, on connection: DeviceChannel) async throws { + try await connection.write(try USBMuxProtocol.encode(value, tag: 1)) + } + + private func handleUSBMux(_ connection: DeviceChannel) async { + do { + let message = try await USBMuxClient.readMessage(from: connection, timeout: 10) + switch message["MessageType"]?.stringValue { + case "ListDevices": + try await send(["DeviceList": [attachedMessage]], on: connection) + case "ReadPairRecord": + if pairRecordAvailable, message["PairRecordID"]?.stringValue == serial { + try await send(["PairRecordData": .data(try TestPairing.pairRecord.encoded(format: .binary))], on: connection) + } else { + try await send(["MessageType": "Result", "Number": 2], on: connection) + } + case "ReadBUID": + try await send(["BUID": .string(TestPairing.systemBUID)], on: connection) + case "Listen": + try await send(["MessageType": "Result", "Number": 0], on: connection) + try await send(attachedMessage, on: connection) + if listenSendsDetach { + try await send(["MessageType": "Detached", "DeviceID": .integer(Int64(deviceID))], on: connection) + } + _ = try? await connection.readSome() + case "Connect": + let networkPort = UInt16(truncatingIfNeeded: message["PortNumber"]?.intValue ?? 0) + let port = UInt16(bigEndian: networkPort) + guard message["DeviceID"]?.intValue == deviceID else { + try await send(["MessageType": "Result", "Number": 2], on: connection) + return + } + let serviceHandler = port == LockdownClient.port ? nil : lock.withLock { servicePorts[port].flatMap { services[$0] } } + let connected = try USBMuxProtocol.encode(["MessageType": "Result", "Number": 0], tag: 1) + if serviceHandler != nil, serviceTLS { + // The client starts TLS as soon as it reads the reply, so the reply and the TLS + // handler go in together (see replyThenStartTLS); otherwise, under load, the + // ClientHello could reach the plaintext reader first and the handshake would stall. + try await writeThenStartTLS(connected, on: connection, source: "fake service") + } else { + try await connection.write(connected) + } + if port == LockdownClient.port { + try await handleLockdown(connection) + } else if let handler = serviceHandler { + try await handler(connection) + } + default: + try await send(["MessageType": "Result", "Number": 1], on: connection) + } + } catch { + // Client closed or test finished. + } + await connection.close() + } + + // MARK: lockdownd + + private func serverTLSContext() throws -> NIOSSLContext { + let certificate = try NIOSSLCertificate.fromPEMBytes(Array(TestPairing.fixture("\(serverCertificate).pem"))) + let key = try NIOSSLPrivateKey(bytes: Array(TestPairing.fixture("\(serverCertificate).key")), format: .pem) + var configuration = TLSConfiguration.makeServerConfiguration(certificateChain: certificate.map { .certificate($0) }, privateKey: .privateKey(key)) + configuration.certificateVerification = .noHostnameVerification + configuration.trustRoots = .certificates(try NIOSSLCertificate.fromPEMBytes(Array(TestPairing.fixture("root.pem")))) + return try NIOSSLContext(configuration: configuration) + } + + /// Writes the plaintext reply and inserts the TLS handler in the same event-loop tick, so + /// the client's ClientHello cannot be read before TLS is in place. + private func replyThenStartTLS(_ reply: PlistValue, on connection: DeviceChannel) async throws { + try await writeThenStartTLS(try PlistMessageConnection.frame(reply), on: connection, source: "fake server") + } + + private func writeThenStartTLS(_ frame: Data, on connection: DeviceChannel, source: String) async throws { + let context = try serverTLSContext() + connection.inbound.prepareForTLS() + let channel = connection.channel + try await channel.eventLoop.submit { + var buffer = channel.allocator.buffer(capacity: frame.count) + buffer.writeBytes(frame) + channel.writeAndFlush(buffer, promise: nil) + try channel.pipeline.syncOperations.addHandler(NIOSSLServerHandler(context: context), position: .first) + }.get() + try await connection.inbound.waitForTLSHandshake(source: source) + } + + private func handleLockdown(_ connection: DeviceChannel) async throws { + let messages = PlistMessageConnection(channel: connection) + var sessionStarted = false + while true { + let request = try await messages.receive(timeout: nil) + let name = request["Request"]?.stringValue ?? "" + if let error = lockdownErrors[name] { + try await messages.send(["Request": .string(name), "Error": .string(error)]) + continue + } + switch name { + case "QueryType": + try await messages.send(["Request": "QueryType", "Type": "com.apple.mobile.lockdown"]) + case "GetValue": + var values = lockdownValues + values["UniqueDeviceID"] = .string(reportedUDID ?? udid) + if let domain = request["Domain"]?.stringValue { + values = domainValues[domain] ?? [:] + } + if let key = request["Key"]?.stringValue { + if let value = values[key] { + try await messages.send(["Request": "GetValue", "Key": .string(key), "Value": value]) + } else { + try await messages.send(["Request": "GetValue", "Error": "MissingValue"]) + } + } else { + try await messages.send(["Request": "GetValue", "Value": .dictionary(values)]) + } + case "StartSession": + guard request["HostID"]?.stringValue == TestPairing.hostID else { + try await messages.send(["Request": "StartSession", "Error": "InvalidHostID"]) + continue + } + sessionStarted = true + try await replyThenStartTLS(["Request": "StartSession", "SessionID": "SESSION-1", "EnableSessionSSL": true], on: connection) + case "StartService": + guard sessionStarted else { + try await messages.send(["Request": "StartService", "Error": "SessionInactive"]) + continue + } + lock.withLock { receivedServiceRequests.append(request) } + let service = request["Service"]?.stringValue ?? "" + let known = lock.withLock { services[service] != nil } + guard known else { + try await messages.send(["Request": "StartService", "Error": "InvalidService"]) + continue + } + let port: UInt16 = lock.withLock { + let port = nextPort + nextPort += 1 + servicePorts[port] = service + return port + } + try await messages.send(["Request": "StartService", "Service": .string(service), "Port": .integer(Int64(port)), "EnableServiceSSL": .boolean(serviceTLS)]) + case "StopSession": + try await messages.send(["Request": "StopSession"]) + default: + try await messages.send(["Request": .string(name), "Error": "UnknownRequest"]) + } + } + } +} diff --git a/Tests/DeviceTestSupport/FakeWebInspector.swift b/Tests/DeviceTestSupport/FakeWebInspector.swift new file mode 100644 index 0000000..aa71a1a --- /dev/null +++ b/Tests/DeviceTestSupport/FakeWebInspector.swift @@ -0,0 +1,59 @@ +import DeviceKit +import Foundation +import ToolkitCore + +/// A scripted `webinspectord`: answers the identifier handshake, reports one Safari application, +/// and sends a two-page listing when asked. It can refuse the first sessions by dropping them, as +/// the device does when Web Inspector is off or a session starts too soon after the previous one. +public final class FakeWebInspector: @unchecked Sendable { + private let lock = NSLock() + private var refusalsLeft: Int + private var _sessions = 0 + private var _connectionIdentifiers: Set = [] + private var _selectors: [String] = [] + + public init(refusals: Int = 0) { + refusalsLeft = refusals + } + + public var sessions: Int { lock.withLock { _sessions } } + public var connectionIdentifiers: Set { lock.withLock { _connectionIdentifiers } } + public var selectors: [String] { lock.withLock { _selectors } } + + public func register(on server: FakeDeviceServer) { + server.register(service: WebInspector.serviceName) { [self] channel in + let refuse: Bool = lock.withLock { + _sessions += 1 + if refusalsLeft > 0 { refusalsLeft -= 1; return true } + return false + } + if refuse { return } + let messages = PlistMessageConnection(channel: channel) + while let request = try? await messages.receive(timeout: 5) { + let selector = request["__selector"]?.stringValue ?? "" + lock.withLock { + _selectors.append(selector) + if let id = request["__argument"]?["WIRConnectionIdentifierKey"]?.stringValue { _connectionIdentifiers.insert(id) } + } + switch selector { + case "_rpc_reportIdentifier:": + try await messages.send(["__selector": "_rpc_reportCurrentState:", "__argument": ["WIRAutomationAvailabilityKey": "WIRAutomationAvailabilityNotAvailable"]]) + case "_rpc_getConnectedApplications:": + try await messages.send(["__selector": "_rpc_reportConnectedApplicationList:", "__argument": ["WIRApplicationDictionaryKey": [ + "PID:120": ["WIRApplicationIdentifierKey": "PID:120", "WIRApplicationBundleIdentifierKey": "com.apple.mobilesafari", "WIRApplicationNameKey": "Safari", "WIRIsApplicationActiveKey": 1], + ]]]) + case "_rpc_forwardGetListing:": + try await messages.send(["__selector": "_rpc_applicationSentListing:", "__argument": [ + "WIRApplicationIdentifierKey": "PID:120", + "WIRListingKey": [ + "1": ["WIRPageIdentifierKey": 1, "WIRTitleKey": "Example Domain", "WIRURLKey": "https://example.com/", "WIRTypeKey": "WIRTypeWebPage"], + "2": ["WIRPageIdentifierKey": 2, "WIRTypeKey": "WIRTypeJavaScript"], + ], + ]]) + default: + break + } + } + } + } +} diff --git a/Tests/DeviceTestSupport/Fixtures/pairing/device.key b/Tests/DeviceTestSupport/Fixtures/pairing/device.key new file mode 100644 index 0000000..81eb4b3 --- /dev/null +++ b/Tests/DeviceTestSupport/Fixtures/pairing/device.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAs7xxZBeSb7LFdA2ZP8OEgGrvwWkT9vs/3msSVr8wi+TpChn0 +o/gNFnf3woHApbQdpAonzbjtfyy74Wkvv7EUurBt88uHCG87ETp9wNIpO8Sqn3ig +b+K5pj/XJzRGsXxiWy4NKdm9k2tUzR0Xft9X7Wvj60zk/OqUSMux6NkcGvY3t7zp +OyGgAOBKTsG8ExHRmAhsTtM7RjmL6VHzOeeNssddo8w4BFeA14pcXnWT5O86ixRy +yr0wekNGwF1pc0W99CV6dYTjkTcg8pzd/TjMUkdZdMxW/EBqJ4D0XLDXQkbg5EiU +spv1y/56D7oXFu9+KcFU+AxsZ41NX9R8BZ0RpwIDAQABAoIBAQCYLeiQB8lL28CF +B3KG44NPu7suNkraJlcoed24Jv7yo6mQjPNu1Xo3WPIk9PlpE3y5RIP5Eie8MuMM +8qOkGUnk27bs4Zl+UbdgWfbZiNeyrEyO43Jr6TqpAYqovgFRC3C3pOEuWxbBN/JA +DO601GO9OUlsE9oaLn/Itvb2oMTEKeI1pWDN78siC5VGeN8PzQLuT4JD6rYLB2up +4OFUBG9CBl/zCVdLhc6M4iA2dwkjhppYY+/ofDPDyL6RWnc5HSuARWUnDfCxZ6Ib +yOVEDCsPi1nxKEs+9R/r5LaaZq4Y0GgeJGtlTBfKseq2JeCgACFWMmDe1dC5xcYS +R6s07erhAoGBAOc38Z3qYlmoiW5f9URoaqerrCrpg2YOqChRU32lKFtuMOR/kNEu +N/Bnlxa3gv2t/dhyZZz1RDBtWamsSe+0sXX/NHEC8gpvDs+7IlHOhp03H0+f+qoB +fu1OUhtWlnVbYnH9JOWkDqJ1s8oJOUv3yoV94Gqoos49/VMad8++5Ql3AoGBAMb/ +81jFiS0eek2pHYUhYbs+R97HltzotO7dkW1G9GTHgL+L8oi8vA4BTjkDUYj1FgPj +YtzrFiZNHwu7wSrmteIXu44Q4Izkr3MfzCWVGufs4E9Fk8F1MnWtyjOMyNFNb1Lh +5Vyfp4oICwYO40UtfS9giVA1S/3lGsa7pRIfKEVRAoGAQU+8IC8kuM6yg0r3rOsR +tWr4puZvB1T8iubt/n+4dpsZlHG0JmI3yuB3/SK5DY8oNQubSEq/iC5bNWJFQEB6 +ICqGVaXPWMMmG+dwOktsGtyHK6a013C0ubN13eMHfP7jP++RjIxa1ekJb/hLBvpn +am20Bmy8ECXSeLbxQO1bJOECgYEAkSqlmlx/dSqGjaCQqE1xveXxB9MRepVHZ/i5 +3b2lCt9+TD0K8DS9+nl5Vp3VHNOs+6kktYPI5KfdvfF5OJKUnBoz967YH++zDYhk +pAXhWs6x0yIuZzHXXYEpv/EzjXuvegbQCoFs9oRnhkPWBuDoMWd6w8gdZ1l57v9z +q4SPHAECgYA6jMxtxMSObKUQ1MS3HgxAO3l7CCr6JS6/bGUuhvh2arUiMz0TQR4F +l0EDF/3x4BhWKj1YLRFOfdO8XV3GBljCPkuH9phq7RknRVNkBglpD/HiboNl5FFc +ptFCWtlVWRK24YiUuziLVjOLuGKeCvFnvwLmAQMJ1oW6N6AwPte0oQ== +-----END RSA PRIVATE KEY----- diff --git a/Tests/DeviceTestSupport/Fixtures/pairing/device.pem b/Tests/DeviceTestSupport/Fixtures/pairing/device.pem new file mode 100644 index 0000000..c34f02a --- /dev/null +++ b/Tests/DeviceTestSupport/Fixtures/pairing/device.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDAzCCAeugAwIBAgIJALYZhDVD9UDuMA0GCSqGSIb3DQEBCwUAMCwxKjAoBgNV +BAMMIVRlc3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAgFw0yNjA5MjYw +OTQxNDJaGA8yMTI2MDkwMjA5NDE0MlowFjEUMBIGA1UEAwwLVGVzdCBkZXZpY2Uw +ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCzvHFkF5JvssV0DZk/w4SA +au/BaRP2+z/eaxJWvzCL5OkKGfSj+A0Wd/fCgcCltB2kCifNuO1/LLvhaS+/sRS6 +sG3zy4cIbzsROn3A0ik7xKqfeKBv4rmmP9cnNEaxfGJbLg0p2b2Ta1TNHRd+31ft +a+PrTOT86pRIy7Ho2Rwa9je3vOk7IaAA4EpOwbwTEdGYCGxO0ztGOYvpUfM5542y +x12jzDgEV4DXilxedZPk7zqLFHLKvTB6Q0bAXWlzRb30JXp1hOORNyDynN39OMxS +R1l0zFb8QGongPRcsNdCRuDkSJSym/XL/noPuhcW734pwVT4DGxnjU1f1HwFnRGn +AgMBAAGjPDA6MAwGA1UdEwEB/wQCMAAwCwYDVR0PBAQDAgWgMB0GA1UdJQQWMBQG +CCsGAQUFBwMBBggrBgEFBQcDAjANBgkqhkiG9w0BAQsFAAOCAQEAwPGMuw8EUMWQ +E8rK6EPD87wJUl29hDZFfyCWBRtKwgv6/Cb2tRnjLw7hUAJpVloOApMcONmiUH/t +16jRNT8LqtuTPh3IoWccRk3RiVxhxEvx5JNZ8/azalLAQYUwHX9Ww0e/52/N8Cty ++Qi5heJC1dx5YjsYG0RiUsP6W51/HOQYhH7Ve//poltf5xXzrOQoRcypjAaqCs1f +/Qb888DiYBGcNufWY6vG83Q0NGVtW0v3Er1EEGO6YrpeOcDGXhw2QXDXA5xEXy76 +NCxaxb2dqC2BB++aNDZzRDDzl1oy30w/lupRWZg6WPeZLAJ4D1//CIMpbPYBGN/y +YlZi+mHh5w== +-----END CERTIFICATE----- diff --git a/Tests/DeviceTestSupport/Fixtures/pairing/host.key b/Tests/DeviceTestSupport/Fixtures/pairing/host.key new file mode 100644 index 0000000..ede7aca --- /dev/null +++ b/Tests/DeviceTestSupport/Fixtures/pairing/host.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAxQ3WGsB/3dTfcNHXo34sMUHjAaIyWf/84zFOXhb+1wqnp67x +JLo14kcpEtd3a8kV34tLC8PIfj6NIR9LECnjeSjj9LsF8IvWFUFene00ZsIoaabo +VSRc85q8c5VzHp/K8YAFKkvJ1kIAPzobi5zPqgf+RvREBrRUMr/eZc0MrQiWpGaZ +8bMMLjlwMzAuCzXZv5iIVVqfoKyzLpITr62v16SeJXa8k0dFYaMAMZv7tFLu70XY +kKp7c3PYx/muOfRd+HN4upYBrvV9D0KsJPyHUdCNQYYNQVDrrUaPbJfNRJNM3nld +FKBBPThabe9Ev2gWsMH1a+Sz6MF5kQN3D4uzkQIDAQABAoIBAEBDc+jDkK/KpZgI +h1fFZSonP54fxGX/ivyY7uLWgwOkcOrJrdD5fWsvI+EGLecT7DM8HRxYHEG3qpT7 +vKIn1wyDXlxIxfYd1JKYAABnBGjitwVFro1dz4aOlxjmy2VYXXoG8yhJESjz21rD +W4pbeIoOE0oWjTzWgIMQebofdhvTl46Fiy51cd36PSpty2kEgimdNLgwyBXRkW3n +gvB7gYyZX/RHQTX7aSFHfXQ7JRTi8QcZW5TkiwpKZf6pSVK+EKG33pmD3bnxduN2 +tb4NDSC5E/JR0PqcwUWTi/YJf5x8qrhnRNYAfvaFg41uM/22TcsIHFCbCqbmQHwt ++MHRKEECgYEA6ygsANNVBVdzCpMBYDjG9AIcxh+edKQHfcZMuxbj1qHtJf3goRdP +IusyiUdxrAzsO3A5BENiuQL2kw3XXrbFV8JFhhsnq0j5PANanWlzcX7q71LnFbBN +XvOOAwEbikD0LPKsU4mTkwNAV0wOwrpVnPne6YGNiKqH8EAYYbufAdkCgYEA1oUX +WemAh9iVFTw0iAGN9yqSefSbKn0WMQqwiWHXXvbAK6gzYY0XCSteEeXbrHHrm2iZ +Z1yGxnEkkukt3onETFgFxUhPuDLl59lmtSRa5aDNua4EFFgD2b0BRVZHgQwG0DuA +OKLnus2HWUz/7rHb/qOjzMvRJaNguRDtg/sJ9HkCgYAe/MAvq6l8drWWIyD/SPwO +k2ivQuNig2rpTAQ4cx+63MOfSgEbuTKYa13RcTStc1Z9zp/0y9xqfNV5M026EWOX +2OXIcjOcApW1Lsv2CjtILW1P9CC3XvATHw0LoE28vipu8dt7dpmXucn0tvUK+/rk +IJkbNBMueypGomuUFWEyoQKBgQCNgafAsM4PTmgxZNCGMvhR5exPtHIymebsnM+N +IFRwmTZ01fRm0ioy6z+XcNfyrgcsMdiXnSpudq4YluaiGKC7d4j+9U9ZA4Iy6W7d +6MhpFyT96fNY9CI+HHZdJafvUV6hN87CDXvs9qzk1G0zKHIlTA6+BmjFejtQeQEk +gZG0iQKBgFJbNRASM7jVKYeFbbzrl0khunmJ2Ad9LreOBl0Ph99O0iuVj7o141vZ +2U5TXHKIcgFuTdhQ9dfpMuqtjl05dQA151EC69KZzhSx6tfJtsVjd9rbkxTHb+X6 +JvzDbFKQFyVjo+A/aMGeNt3ctM6UOge8SNtru4EoflxY8GqVpxEJ +-----END RSA PRIVATE KEY----- diff --git a/Tests/DeviceTestSupport/Fixtures/pairing/host.pem b/Tests/DeviceTestSupport/Fixtures/pairing/host.pem new file mode 100644 index 0000000..ed1eb05 --- /dev/null +++ b/Tests/DeviceTestSupport/Fixtures/pairing/host.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDATCCAemgAwIBAgIJALYZhDVD9UDtMA0GCSqGSIb3DQEBCwUAMCwxKjAoBgNV +BAMMIVRlc3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAgFw0yNjA5MjYw +OTQxNDJaGA8yMTI2MDkwMjA5NDE0MlowFDESMBAGA1UEAwwJVGVzdCBob3N0MIIB +IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxQ3WGsB/3dTfcNHXo34sMUHj +AaIyWf/84zFOXhb+1wqnp67xJLo14kcpEtd3a8kV34tLC8PIfj6NIR9LECnjeSjj +9LsF8IvWFUFene00ZsIoaaboVSRc85q8c5VzHp/K8YAFKkvJ1kIAPzobi5zPqgf+ +RvREBrRUMr/eZc0MrQiWpGaZ8bMMLjlwMzAuCzXZv5iIVVqfoKyzLpITr62v16Se +JXa8k0dFYaMAMZv7tFLu70XYkKp7c3PYx/muOfRd+HN4upYBrvV9D0KsJPyHUdCN +QYYNQVDrrUaPbJfNRJNM3nldFKBBPThabe9Ev2gWsMH1a+Sz6MF5kQN3D4uzkQID +AQABozwwOjAMBgNVHRMBAf8EAjAAMAsGA1UdDwQEAwIFoDAdBgNVHSUEFjAUBggr +BgEFBQcDAQYIKwYBBQUHAwIwDQYJKoZIhvcNAQELBQADggEBAJLuSw5skWliFZ3t +oQVI/crqtDXp7BepsTTLvh2ralFJ2IN2htkN1ubaU0shQ29yoOi5XPMG2eanrw7q +qZqnnjpv4hjGyrjeKLeJ9WAaWBeMPBeDjFx0g9dGeW3QE23Jwhwj/gSdYhGsWYuN +DlNhmbf+ua7/Em7uItKaULG5NX5o4omsDjK6CAuGHYPTjnbqjBqRF3eDIGCU8mwh +ilZF6bEUdW3kkXo+KfkKV2LozuKdXD/jVxl2wPCBY+2BW/jmN+tX+vVUNACBtWym +S2BLSSLdv55U6A2Q9XrbII8VV54XKiIsimQht0TSB7GydHl2OgVgkJyDuF2AxPsH +vwxTyPw= +-----END CERTIFICATE----- diff --git a/Tests/DeviceTestSupport/Fixtures/pairing/other.key b/Tests/DeviceTestSupport/Fixtures/pairing/other.key new file mode 100644 index 0000000..acbb539 --- /dev/null +++ b/Tests/DeviceTestSupport/Fixtures/pairing/other.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA00u4LoKT/v3EmM0Qe/bCy3y7E3GGiM+Wdz09IydkmVu6iuJU +DPcMOhPQBvnnxJXRtebBuhYtYEYpDRNp4elY6kYJQgASS5jDu+jn5u2aD/cNm/kj +SduTf1eQNZkn9E+N+qphwgmvehNPfO/VaMTWg5SpZHRDN75BnlYLRc59vDzUp1wR +9tRq3Eq3N6Vh14A7STaly/HtEQogLxdVb5pWzoc8bvbVuaL2N78zoRUnontqgAzY +Kaz/MMZxX00x0pFkedVSHy2jJ+0XYi5KU+46XyhZ3BHucbRGH2Y4h74VepPQTke+ +zArK1nEE1I43uvtlhKN/I0p6HZ+JTLGozwfiWQIDAQABAoIBAEIyUr2fgyY78r+8 +XxG3OUfvB90LkA6Emqs4sFK3ILk7F7IzPgnhzB7V3jKRUJ8yIMVQShk2oXisAmhq +UJ3KMWUUFAptY03zyj6wtRaQEdg2mjjnULru48Ldd/ySwwWzdtO1VXMo/aTNL/YA +XWe5Y0tN49Po6LYqEPy+EEIKczHpevI0/BdNpNgEsKqM5sezBAXqB5Zjk2KKw8W1 +tJdguZqT04Fcp9wGcrPHqqXDsFYhYoLE/6XJXfLPPTKGad/df6ZnO4e4FdsYpdML +6SpAeR89YoV5W3dKVKNOfKx/01TDpE7Jfm+51pyH18R87EVwQBdr8df2swwhmcCs +qqr8tAECgYEA9c65k5K/8/wo4FoLiAX5/x7l08B/rIzJumdIBbWXktKmiIZsWTrP +QtUXe6PnleFMVlLmCGxWJpSL3jDAPwBXX91RKB5Q9uHzXIreCQdgHp+EYxS5xQMp +CnUhhqt7zTaaHJs/Xk4cy4/oEhyZGUHp+FWkNvwpBLlTPt9Ko7hQK3kCgYEA3A6m +AY3m7ZpwoXXxz5ZAnYGrEpNclblKPl/nuH8+WhyG55y7P0rUg7qAozlL0Pir52Ne +vUBxlOFJ4s6/qi1aZFHR5H4y1aDpzwspAIlUj/UGCv3bhD4+epW7PndPGhNP0eRv +TOBgU+GPhxZ1e85x3M3krv4tik2lvXTz+GAW1eECgYAY5Kecj1cLMXU+S0AxtVE9 +CfcCe9MyOUvQqdb8ZQiFoPmmh95/JtqIjSYEqnOwlRFzCJAtODN9Urvbc5p0hudS +WQp4AqH2bqmDrLiRt4J/d24jzpyFnC1S1svfZIx0Gtx+myZOuaBm6Rr6gpTqnuc/ +9AReYSkSRmZszpYzgF32GQKBgQDV8jP5zOHY3k9k20vnHFjkCZ6uZ7EbLFN/gzTB +zoxrhdKvOlVeer1U+S16BAmvOSXAyH7iltYW/lYcehAkPwXkBRyA/yUjR9gp4NrT +V7FsaLegViFnWDDcc9jhKZQxcNco5ORFJNzikKk/mFlOrm9LUq5kaGEUVtut8BgG +wj1RYQKBgQDkC3WNn277DY9dDmtWhjyUICZbmA1AF2lmDZMjYruXi7RfxqhfpaNX +3Ly4jWo/svBHZ7HVaulFK4YiVm4UiWugGiwPDkoLZg9ljtrPmj8eaHg7N3xW6ocN +yN5n4ilmJE8U4NNflIscPeOKvBw4HM6GVI14Kt5vR1PydnQsU5yTPw== +-----END RSA PRIVATE KEY----- diff --git a/Tests/DeviceTestSupport/Fixtures/pairing/other.pem b/Tests/DeviceTestSupport/Fixtures/pairing/other.pem new file mode 100644 index 0000000..1fc8e22 --- /dev/null +++ b/Tests/DeviceTestSupport/Fixtures/pairing/other.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDAjCCAeqgAwIBAgIJALYZhDVD9UDvMA0GCSqGSIb3DQEBCwUAMCwxKjAoBgNV +BAMMIVRlc3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAgFw0yNjA5MjYw +OTQxNDJaGA8yMTI2MDkwMjA5NDE0MlowFTETMBEGA1UEAwwKVGVzdCBvdGhlcjCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNLuC6Ck/79xJjNEHv2wst8 +uxNxhojPlnc9PSMnZJlbuoriVAz3DDoT0Ab558SV0bXmwboWLWBGKQ0TaeHpWOpG +CUIAEkuYw7vo5+btmg/3DZv5I0nbk39XkDWZJ/RPjfqqYcIJr3oTT3zv1WjE1oOU +qWR0Qze+QZ5WC0XOfbw81KdcEfbUatxKtzelYdeAO0k2pcvx7REKIC8XVW+aVs6H +PG721bmi9je/M6EVJ6J7aoAM2Cms/zDGcV9NMdKRZHnVUh8toyftF2IuSlPuOl8o +WdwR7nG0Rh9mOIe+FXqT0E5HvswKytZxBNSON7r7ZYSjfyNKeh2fiUyxqM8H4lkC +AwEAAaM8MDowDAYDVR0TAQH/BAIwADALBgNVHQ8EBAMCBaAwHQYDVR0lBBYwFAYI +KwYBBQUHAwEGCCsGAQUFBwMCMA0GCSqGSIb3DQEBCwUAA4IBAQBODTOWaV9R4xQK +MsN2ymniQFXthFH41si8QTNsr4GCxUhpcg2AdTJE5jhCgAJwn+VPtedajuw6TH/K +wxBZsauP9LRdD2/uHFV9G2erdyqYI+hpwlSuSs7msBuXxmKBtrPNZgJtpWyyqRfD +bByl31IPbrxKGiTIGUOmrZE+YBPk24+kxUtQ1mqexfnSAIcTbNF5FD2IbLp7ezd8 +VQrkXhaV5ChZv+NIec2QwAzEO1tebk+cgQBOfX+7Y+FEo+6X7KsN3Z+EiS2Qw/P2 +VESiSxZeYcqV520Ad1QlQvXHBNhgDvL2u4oseTQFS8jnNNoJuqF2lFNduE/V2wsC +eifWnueK +-----END CERTIFICATE----- diff --git a/Tests/DeviceTestSupport/Fixtures/pairing/root.pem b/Tests/DeviceTestSupport/Fixtures/pairing/root.pem new file mode 100644 index 0000000..07faf52 --- /dev/null +++ b/Tests/DeviceTestSupport/Fixtures/pairing/root.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIC/TCCAeWgAwIBAgIJAJKWqwTzcKYtMA0GCSqGSIb3DQEBCwUAMCwxKjAoBgNV +BAMMIVRlc3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAgFw0yNjA5MjYw +OTQxNDJaGA8yMTI2MDkwMjA5NDE0MlowLDEqMCgGA1UEAwwhVGVzdCBSb290IENl +cnRpZmljYXRpb24gQXV0aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA3fLGbCmdoqs7cfYaX45BkLtzpsTBYypX1JHJgcQiRYO6zz4jCQcYrGJ/ +pyLIPecUhe0iLpc8h3thPwMmKHrdY+aevnKq/BlrpXIPEVHqBnibGAxIxjQsIzX6 +bRA1RI+AET9jgWY3IZIhsQggkjOeIacr9d7LtOilqdVTFImBMTrr/etBF6QHaofX +tSp1PeSRr1tm2j/ZUgyRMMumVJRtonYkbVf8Sw6UyrSsI6jSHxjhmVOl7GnJEf4S +4qCGxD5tZVpyQLFy2BUqlSzgwxPEhFmhwfp1mImO/Ad6L1mXEL+dCE8YDpRlA5hI +D+znS2zcDN7zuctSJb1scr5ofIv9HwIDAQABoyAwHjAPBgNVHRMBAf8EBTADAQH/ +MAsGA1UdDwQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAQEAbNIuD51mhZJiwXrf11m3 +4rjP0GTjcyg2Tg49oPSw89dcJwTI97eKlM93Ayikfv+SehDTBhfWFbfFGEuxh2uW +Q2hP9cse3KXc3mX0/eRWvVREBUyRjHG4nhkq7dWx9DarwWreiBPWNh/B/Rz5cu4K +Aw2bjnfFURklyHB8LL31PPF9WEzjri/UgGeyEHBEu6r7YZQdKhBhA+scRlatk+MM +neA3fgKloPwWFF2CxmhhL+VC231KQ5UCYwpOr8rSTyZiDr+c1NBr5zBuyKItS59x +ECpdmrGiIw+53kvWTaNbIhflp7ZB0HvFxkSl+pkMMbDeyosG6HqcILlWI1lp4wB+ +nQ== +-----END CERTIFICATE----- diff --git a/Tests/ToolkitCoreTests/CommandRunnerTests.swift b/Tests/ToolkitCoreTests/CommandRunnerTests.swift new file mode 100644 index 0000000..614bce3 --- /dev/null +++ b/Tests/ToolkitCoreTests/CommandRunnerTests.swift @@ -0,0 +1,155 @@ +import Foundation +import Testing +@testable import ToolkitCore + +@Suite("CommandRunner") +struct CommandRunnerTests { + let runner = ProcessCommandRunner() + + func shell(_ script: String, timeout: TimeInterval? = 10, input: Data? = nil, limit: Int = 1 << 20) -> CommandRequest { + CommandRequest( + executable: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", script], + standardInput: input, + timeout: timeout, + outputLimit: limit, + displayName: "test shell", + terminationGracePeriod: 1 + ) + } + + /// Display names are logged publicly, so identifiers, paths, and values must never appear. + @Test func defaultDisplayNamesContainNoIdentifiers() throws { + #expect(CommandRequest.defaultDisplayName(tool: "simctl", arguments: ["boot", "780C6431-EBAF-4AAE-AA6C-E8886DD4D415"]) == "simctl boot") + #expect(CommandRequest.defaultDisplayName(tool: "devicectl", arguments: ["device", "info", "details", "--device", "00008110-001234560ABC801E"]) == "devicectl device info details") + #expect(CommandRequest.defaultDisplayName(tool: "simctl", arguments: ["openurl", "booted", "https://example.com"]) == "simctl openurl booted") + #expect(CommandRequest.defaultDisplayName(tool: "open", arguments: ["/Users/someone/Case.trace"]) == "open") + #expect(CommandRequest.defaultDisplayName(tool: "devicectl", arguments: ["list", "devices", "--json-output", "/tmp/x.json"]) == "devicectl list devices") + let request = try XcodeTool.simctl.request(["shutdown", "780C6431-EBAF-4AAE-AA6C-E8886DD4D415"]) + #expect(request.displayName == "simctl shutdown") + } + + @Test func capturesStandardOutputAndError() async throws { + let result = try await runner.run(shell("printf out; printf err 1>&2")) + #expect(result.succeeded) + #expect(result.standardOutputText == "out") + #expect(result.standardErrorText == "err") + #expect(result.exitCode == 0) + } + + @Test func returnsNonZeroExitWithoutThrowing() async throws { + let result = try await runner.run(shell("echo nope 1>&2; exit 7")) + #expect(!result.succeeded) + #expect(result.exitCode == 7) + #expect(result.technicalSummary.contains("Exit status: 7")) + #expect(result.technicalSummary.contains("nope")) + } + + @Test func argumentsAreNotInterpretedByAShell() async throws { + let request = CommandRequest( + executable: URL(fileURLWithPath: "/bin/echo"), + arguments: ["$(whoami)", "; rm -rf /", "`id`"], + timeout: 10 + ) + let result = try await runner.run(request) + #expect(result.standardOutputText == "$(whoami) ; rm -rf / `id`\n") + } + + @Test func passesStandardInput() async throws { + let result = try await runner.run(shell("cat", input: Data("secret-password\n".utf8))) + #expect(result.standardOutputText == "secret-password\n") + } + + @Test func timeoutThrowsTimedOut() async throws { + let start = Date() + await #expect(throws: ToolkitError.self) { + _ = try await runner.run(shell("sleep 30", timeout: 0.5)) + } + #expect(Date().timeIntervalSince(start) < 10) + do { + _ = try await runner.run(shell("sleep 30", timeout: 0.3)) + Issue.record("expected timeout") + } catch let error as ToolkitError { + #expect(error.kind == .timedOut) + } + } + + @Test func cancellationStopsTheProcess() async throws { + let task = Task { + try await runner.run(shell("sleep 30", timeout: nil)) + } + try await Task.sleep(for: .milliseconds(300)) + task.cancel() + let start = Date() + do { + _ = try await task.value + Issue.record("expected cancellation") + } catch let error as ToolkitError { + #expect(error.kind == .cancelled) + } + #expect(Date().timeIntervalSince(start) < 8) + } + + @Test func missingExecutableIsActionable() async throws { + let request = CommandRequest(executable: URL(fileURLWithPath: "/nonexistent/tool"), arguments: []) + do { + _ = try await runner.run(request) + Issue.record("expected failure") + } catch let error as ToolkitError { + #expect(error.kind == .toolMissing) + #expect(error.recovery != nil) + } + } + + @Test func relativeExecutableIsRejected() async throws { + do { + _ = try await runner.run(CommandRequest(executable: URL(fileURLWithPath: "tool", relativeTo: nil), arguments: [])) + } catch let error as ToolkitError { + #expect(error.kind == .toolMissing || error.kind == .permissionDenied) + } + } + + @Test func outputLimitTruncatesBufferButStreamsEverything() async throws { + var streamed = 0 + var final: CommandResult? + for try await event in runner.stream(shell("head -c 200000 /dev/zero", limit: 1000)) { + switch event { + case .standardOutput(let data): streamed += data.count + case .standardError: break + case .finished(let result): final = result + } + } + #expect(streamed == 200_000) + #expect(final?.standardOutput.count == 1000) + #expect(final?.standardOutputTruncated == true) + } + + @Test func minimalEnvironmentDropsInheritedSecrets() async throws { + setenv("TOOLKIT_TEST_SECRET", "leak", 1) + defer { unsetenv("TOOLKIT_TEST_SECRET") } + let result = try await runner.run(shell("echo \"[$TOOLKIT_TEST_SECRET]\"; echo $PATH")) + #expect(result.standardOutputText.hasPrefix("[]\n")) + #expect(result.standardOutputText.contains("/usr/bin:/bin:/usr/sbin:/sbin")) + } + + @Test func worldWritableExecutableIsRefused() async throws { + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "runner-test") + defer { try? FileManager.default.removeItem(at: directory) } + let tool = directory.appendingPathComponent("tool") + try Data("#!/bin/sh\necho hi\n".utf8).write(to: tool) + chmod(tool.path, 0o777) + do { + _ = try await runner.run(CommandRequest(executable: tool, arguments: [])) + Issue.record("expected refusal") + } catch let error as ToolkitError { + #expect(error.kind == .permissionDenied) + } + } + + @Test func displayQuotingIsSafe() { + #expect(ShellQuoting.quote("simple") == "simple") + #expect(ShellQuoting.quote("has space") == "'has space'") + #expect(ShellQuoting.quote("it's") == "'it'\"'\"'s'") + #expect(ShellQuoting.quote("") == "''") + } +} diff --git a/Tests/ToolkitCoreTests/CoreUtilityTests.swift b/Tests/ToolkitCoreTests/CoreUtilityTests.swift new file mode 100644 index 0000000..8d92bea --- /dev/null +++ b/Tests/ToolkitCoreTests/CoreUtilityTests.swift @@ -0,0 +1,184 @@ +import Foundation +import Testing +@testable import ToolkitCore + +@Suite("SecureFileIO") +struct SecureFileIOTests { + @Test func writeNewFileRefusesToOverwrite() throws { + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "secure-io") + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("report.json") + try SecureFileIO.writeNewFile(Data("one".utf8), to: file) + #expect(throws: ToolkitError.self) { + try SecureFileIO.writeNewFile(Data("two".utf8), to: file) + } + #expect(try String(contentsOf: file, encoding: .utf8) == "one") + let permissions = try FileManager.default.attributesOfItem(atPath: file.path)[.posixPermissions] as? NSNumber + #expect(permissions?.intValue == 0o600) + } + + @Test func writeNewFileDoesNotFollowSymlinks() throws { + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "secure-io") + defer { try? FileManager.default.removeItem(at: directory) } + let target = directory.appendingPathComponent("target") + let link = directory.appendingPathComponent("link") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + #expect(throws: ToolkitError.self) { + try SecureFileIO.writeNewFile(Data("x".utf8), to: link) + } + #expect(!FileManager.default.fileExists(atPath: target.path)) + } + + @Test func temporaryDirectoryIsPrivate() throws { + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "secure-io") + defer { try? FileManager.default.removeItem(at: directory) } + let permissions = try FileManager.default.attributesOfItem(atPath: directory.path)[.posixPermissions] as? NSNumber + #expect(permissions?.intValue == 0o700) + } + + @Test(arguments: ["../escape", "/etc/passwd", "a/../../b", "", "a\u{0}b", "Snapshots/../../x"]) + func safeChildRejectsTraversal(_ path: String) throws { + let root = URL(fileURLWithPath: "/tmp/root", isDirectory: true) + #expect(throws: ToolkitError.self) { + _ = try SecureFileIO.safeChild(of: root, relativePath: path) + } + } + + @Test func safeChildAcceptsNestedPaths() throws { + let root = URL(fileURLWithPath: "/tmp/root", isDirectory: true) + let child = try SecureFileIO.safeChild(of: root, relativePath: "Snapshot/a/./b.plist") + #expect(child.path == "/tmp/root/Snapshot/a/b.plist") + } + + @Test func sha256MatchesKnownVector() throws { + #expect(SecureFileIO.sha256(of: Data("abc".utf8)) == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "secure-io") + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("abc") + try SecureFileIO.writeNewFile(Data("abc".utf8), to: file) + #expect(try SecureFileIO.sha256(of: file) == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + var hasher = StreamingHasher() + hasher.update(Data("a".utf8)) + hasher.update(Data("bc".utf8)) + #expect(hasher.finalizeHex() == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + #expect(hasher.byteCount == 3) + } + + @Test func atomicWriteReplacesContent() throws { + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "secure-io") + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("state.json") + try SecureFileIO.writeAtomically(Data("1".utf8), to: file) + try SecureFileIO.writeAtomically(Data("2".utf8), to: file) + #expect(try String(contentsOf: file, encoding: .utf8) == "2") + let leftovers = try FileManager.default.contentsOfDirectory(atPath: directory.path).filter { $0.hasSuffix(".tmp") } + #expect(leftovers.isEmpty) + } +} + +@Suite("Sanitizer") +struct SanitizerTests { + @Test func redactsIdentifiersAndPaths() { + let input = """ + device 00008110-001234560ABC801E at 192.168.1.20 mac aa:bb:cc:dd:ee:ff + user me@example.com path /Users/alice/Documents/case.zip uuid 123E4567-E89B-12D3-A456-426614174000 + """ + let output = Sanitizer.sanitize(input, redactions: ["Alice's iPhone"]) + #expect(!output.contains("00008110")) + #expect(!output.contains("192.168")) + #expect(!output.contains("aa:bb")) + #expect(!output.contains("example.com")) + #expect(!output.contains("/Users/alice")) + #expect(!output.contains("123E4567")) + #expect(output.contains("")) + #expect(output.contains("")) + } + + @Test func redactsLiteralNamesAndLimitsLength() { + let output = Sanitizer.sanitize("Alice's iPhone connected", redactions: ["Alice's iPhone"], limit: 12) + #expect(output == " c") + } + + @Test func fingerprintIsStableAndOneWay() { + let first = Sanitizer.fingerprint("00008110-001234560ABC801E") + #expect(first == Sanitizer.fingerprint("00008110-001234560ABC801E")) + #expect(first != Sanitizer.fingerprint("00008110-001234560ABC801F")) + #expect(!first.contains("00008110")) + #expect(first.count == 24) + } +} + +@Suite("PlistValue") +struct PlistValueTests { + @Test func roundTripsThroughXMLAndBinary() throws { + let value: PlistValue = [ + "Name": "iPhone", + "Count": 3, + "Enabled": true, + "Ratio": 0.5, + "Blob": .data(Data([1, 2, 3])), + "Items": ["a", 1], + ] + for format: PropertyListSerialization.PropertyListFormat in [.xml, .binary] { + let decoded = try PlistValue.decode(try value.encoded(format: format)) + #expect(decoded == value) + } + } + + @Test func distinguishesBooleansFromIntegers() throws { + let data = Data("ab1".utf8) + let decoded = try PlistValue.decode(data) + #expect(decoded["a"] == .boolean(true)) + #expect(decoded["b"] == .integer(1)) + #expect(decoded["a"]?.intValue == nil) + } + + @Test func malformedInputThrowsProtocolViolation() { + do { + _ = try PlistValue.decode(Data("not a plist".utf8)) + Issue.record("expected failure") + } catch let error as ToolkitError { + #expect(error.kind == .protocolViolation) + } catch { + Issue.record("unexpected error \(error)") + } + } + + @Test func accessorsNeverTrap() { + let value: PlistValue = ["list": [1, 2]] + #expect(value["missing"] == nil) + #expect(value["list"]?[5] == nil) + #expect(value["list"]?[1]?.intValue == 2) + #expect(PlistValue.real(.nan).intValue == nil) + #expect(PlistValue.unsignedInteger(UInt64.max).intValue == nil) + } +} + +@Suite("OperationJournal") +struct OperationJournalTests { + func record(_ title: String) -> OperationRecord { + OperationRecord(title: title, workspace: "Apps", target: "Test iPhone", transport: "CoreDevice", argv: ["devicectl", "x"], startedAt: Date(), finishedAt: Date().addingTimeInterval(1), outcome: .succeeded, exitCode: 0, output: Data("out".utf8)) + } + + @Test func boundsCapacity() async { + let journal = OperationJournal(capacity: 3) + for index in 0..<5 { await journal.append(record("op \(index)")) } + let titles = await journal.records.map(\.title) + #expect(titles == ["op 2", "op 3", "op 4"]) + } + + @Test func manifestOmitsRawOutput() throws { + let manifest = try record("x").manifestJSON() + let text = String(decoding: manifest, as: UTF8.self) + #expect(text.contains("\"raw_output_included\" : false")) + #expect(!text.contains("\"out\"")) + #expect(text.contains(SecureFileIO.sha256(of: Data("out".utf8)))) + } + + @Test func outcomeMapping() { + #expect(OperationOutcome.from(ToolkitError.cancelled()) == .cancelled) + #expect(OperationOutcome.from(ToolkitError.timedOut("x", after: 1)) == .timedOut) + #expect(OperationOutcome.from(CancellationError()) == .cancelled) + #expect(OperationOutcome.from(ToolkitError(.toolMissing, message: "")) == .launchFailed) + } +} diff --git a/Tests/ToolkitFeaturesTests/ActionSafetyTests.swift b/Tests/ToolkitFeaturesTests/ActionSafetyTests.swift new file mode 100644 index 0000000..45e20ab --- /dev/null +++ b/Tests/ToolkitFeaturesTests/ActionSafetyTests.swift @@ -0,0 +1,152 @@ +import Foundation +import Testing +import DeviceKit +@testable import ToolkitFeatures +import ToolkitCore + +@Suite("Action safety") +struct ActionSafetyTests { + let target = DeviceTarget(kind: .physical, udid: "00008110-001234560ABC801E", name: "Phone", osVersion: "26.0", usbmuxDeviceID: 1, coreDeviceIdentifier: nil, transport: .usb) + + @Test func confirmationsAreBoundToTheTargetDevice() { + let read = ConfirmationRequirement.make(for: .readOnly, target: target) + #expect(read.phrase == nil && !read.requiresReview) + #expect(read.isSatisfied(typedPhrase: "", backupAcknowledged: false)) + + let write = ConfirmationRequirement.make(for: .hostWrite, target: target) + #expect(write.phrase == nil && write.requiresReview) + + let change = ConfirmationRequirement.make(for: .deviceChange, target: target) + #expect(change.phrase == "RUN BC801E") + #expect(change.isSatisfied(typedPhrase: " RUN BC801E ", backupAcknowledged: false)) + #expect(!change.isSatisfied(typedPhrase: "run bc801e", backupAcknowledged: false)) + #expect(!change.isSatisfied(typedPhrase: "RUN FFFFFF", backupAcknowledged: false)) + + let high = ConfirmationRequirement.make(for: .highImpact, target: target) + #expect(high.phrase == "IRREVERSIBLE BC801E") + #expect(!high.isSatisfied(typedPhrase: "IRREVERSIBLE BC801E", backupAcknowledged: false)) + #expect(high.isSatisfied(typedPhrase: "IRREVERSIBLE BC801E", backupAcknowledged: true)) + } + + @Test func argumentSplittingNeverUsesAShell() throws { + #expect(try ArgumentSplitter.split("device info apps") == ["device", "info", "apps"]) + #expect(try ArgumentSplitter.split(#"device process openURL "https://a.example/x y" 'it''s'"#) == ["device", "process", "openURL", "https://a.example/x y", "its"]) + #expect(try ArgumentSplitter.split(#"a\ b "c\"d" ''"#) == ["a b", "c\"d", ""]) + #expect(try ArgumentSplitter.split("list devices | rm -rf / ; $(id)") == ["list", "devices", "|", "rm", "-rf", "/", ";", "$(id)"]) + #expect(throws: ToolkitError.self) { try ArgumentSplitter.split(#"unterminated "quote"#) } + #expect(throws: ToolkitError.self) { try ArgumentSplitter.split(#"trailing\"#) } + } + + @Test func advancedModeClassifiesAndBinds() throws { + #expect(AdvancedCommandPolicy.risk(for: ["list", "devices"]) == .readOnly) + #expect(AdvancedCommandPolicy.risk(for: ["device", "info", "apps"]) == .readOnly) + #expect(AdvancedCommandPolicy.risk(for: ["device", "copy", "from"]) == .hostWrite) + #expect(AdvancedCommandPolicy.risk(for: ["device", "process", "launch"]) == .deviceChange) + #expect(AdvancedCommandPolicy.risk(for: ["device", "reboot"]) == .highImpact) + #expect(AdvancedCommandPolicy.risk(for: ["manage", "ddis", "clean"]) == .highImpact) + + #expect(try AdvancedCommandPolicy.bind(["device", "info", "apps"], to: target) == ["device", "info", "apps", "--device", target.udid]) + #expect(try AdvancedCommandPolicy.bind(["device", "info", "apps", "--device", target.udid], to: target) == ["device", "info", "apps", "--device", target.udid]) + #expect(try AdvancedCommandPolicy.bind(["list", "devices"], to: nil) == ["list", "devices"]) + #expect(throws: ToolkitError.self) { try AdvancedCommandPolicy.bind(["device", "reboot", "--device", "OTHER-DEVICE"], to: target) } + #expect(throws: ToolkitError.self) { try AdvancedCommandPolicy.bind(["device", "info", "apps"], to: nil) } + #expect(throws: ToolkitError.self) { try AdvancedCommandPolicy.bind(["list", "devices", "--json-output", "/tmp/x"], to: target) } + #expect(throws: ToolkitError.self) { try AdvancedCommandPolicy.bind(["/bin/sh", "-c", "id"], to: target) } + let (arguments, risk) = try ActionExecutor.prepareAdvanced("device reboot", target: target) + #expect(arguments.suffix(2) == ["--device", target.udid]) + #expect(risk == .highImpact) + } + + @Test func catalogIsCompleteAndConsistent() throws { + let ids = ActionCatalog.all.map(\.id) + #expect(Set(ids).count == ids.count) + for action in ActionCatalog.all { + #expect(ActionCatalog.categories.contains(action.category), "\(action.id)") + #expect(!action.kinds.isEmpty) + #expect(!action.summary.isEmpty) + if action.risk == .hostWrite { + #expect(action.parameters.contains { $0.kind == .outputFile || $0.kind == .outputDirectory } || action.id == "host-ddis-update", "\(action.id) should ask where to save") + } + } + #expect(ActionCatalog.all.filter { $0.risk == .highImpact }.map(\.id).sorted() == ["reboot", "sim-erase"]) + #expect(ActionCatalog.actions(for: .simulator).allSatisfy { $0.supports(.simulator) }) + #expect(!ActionCatalog.all.contains { $0.mechanism.localizedCaseInsensitiveContains("go-ios") || $0.mechanism.localizedCaseInsensitiveContains("ipsw") }) + } + + @Test func parameterValidation() throws { + let file = ActionParameter(id: "o", label: "PNG file", help: "", kind: .outputFile, fileExtension: "png") + #expect(throws: ToolkitError.self) { try file.validate("/nonexistent-folder/x.png") } + #expect(throws: ToolkitError.self) { try file.validate(NSTemporaryDirectory() + "x.jpg") } + #expect(try file.validate(NSTemporaryDirectory() + "unique-\(UUID().uuidString).png").hasSuffix(".png")) + let url = ActionParameter(id: "u", label: "URL", help: "", kind: .url) + #expect(try url.validate("https://example.com") == "https://example.com") + #expect(try url.validate("myapp://open/item") == "myapp://open/item") + #expect(throws: ToolkitError.self) { try url.validate("https://") } + #expect(throws: ToolkitError.self) { try url.validate("not a url") } + let pid = ActionParameter(id: "p", label: "Process ID", help: "", kind: .processIdentifier) + #expect(throws: ToolkitError.self) { try pid.validate("-4") } + let template = ActionParameter(id: "t", label: "Template", help: "", kind: .template, choices: ["A"]) + #expect(throws: ToolkitError.self) { try template.validate("B") } + } + + @Test func instrumentsRequestsAreBounded() throws { + let output = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("rec-\(UUID().uuidString).trace") + let request = try InstrumentsRecorder.request(template: "Activity Monitor", target: target, durationSeconds: 5, output: output) + #expect(request.arguments.starts(with: ["xctrace", "record"])) + #expect(request.arguments.contains(target.udid)) + #expect(request.arguments.contains("5s")) + #expect(throws: ToolkitError.self) { try InstrumentsRecorder.request(template: "Unknown", target: target, durationSeconds: 5, output: output) } + #expect(throws: ToolkitError.self) { try InstrumentsRecorder.request(template: "Network", target: target, durationSeconds: 0, output: output) } + } + + @Test func mvtArgumentsAndEnvironmentAreIsolated() throws { + #expect(try MVTConnector.parseVersion("\u{1B}[1mMVT\u{1B}[0m\nVersion: 2.6.1\n") == "2.6.1") + #expect(throws: ToolkitError.self) { try MVTConnector.parseVersion("nothing") } + setenv("MVT_VT_API_KEY", "secret", 1) + defer { unsetenv("MVT_VT_API_KEY") } + let environment = MVTConnector.environment(configDirectory: URL(fileURLWithPath: "/tmp/cfg"), allowNetwork: false) + #expect(environment["MVT_VT_API_KEY"] == nil) + #expect(environment["MVT_NETWORK_ACCESS_ALLOWED"] == "false") + #expect(environment["MVT_CONFIG_FOLDER"] == "/tmp/cfg") + let request = MVTConnector.AnalysisRequest(executable: ValidatedExecutable(path: "/x/mvt-ios", sha256: "a", version: "1"), backup: URL(fileURLWithPath: "/b/backup"), output: URL(fileURLWithPath: "/b/out"), indicatorFiles: [URL(fileURLWithPath: "/i.stix2")], fast: true, hashes: false, allowNetwork: false) + #expect(MVTConnector.arguments(for: request) == ["--disable-update-check", "--disable-indicator-update-check", "check-backup", "--output", "/b/out", "--fast", "--iocs", "/i.stix2", "/b/backup"]) + } + + @Test func mvtBackupResolutionRefusesEncryptedBackups() throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "mvt") + defer { try? FileManager.default.removeItem(at: root) } + let backup = root.appendingPathComponent("00008110-X") + try FileManager.default.createDirectory(at: backup, withIntermediateDirectories: true) + try Data().write(to: backup.appendingPathComponent("Manifest.db")) + try Data().write(to: backup.appendingPathComponent("Info.plist")) + #expect(try MVTConnector.resolveBackup(root).lastPathComponent == "00008110-X") + try PlistValue(dictionaryLiteral: ("IsEncrypted", true)).encoded().write(to: backup.appendingPathComponent("Manifest.plist")) + #expect(throws: ToolkitError.self) { try MVTConnector.resolveBackup(backup) } + #expect(throws: ToolkitError.self) { try MVTConnector.resolveBackup(root.appendingPathComponent("missing")) } + } +} + +@Suite("UFADE connector (stand-in checkout)") +struct UFADEConnectorTests { + @Test func validationReportsTheDeveloperImageSubmodule() async throws { + let checkout = try SecureFileIO.makeTemporaryDirectory(prefix: "ufade") + defer { try? FileManager.default.removeItem(at: checkout) } + try Data("import sys\nu_version = \"0.9.8\"\n".utf8).write(to: checkout.appendingPathComponent("ufade.py")) + try Data("GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007\n".utf8).write(to: checkout.appendingPathComponent("LICENSE")) + try Data("pymobiledevice3\n".utf8).write(to: checkout.appendingPathComponent("requirements.txt")) + // A stand-in interpreter: the runner answers for it, nothing is executed. + let runner = ScriptedRunner() + runner.reply = { request in request.displayName == "UFADE Python version" ? (0, "3.11.9\n") : (0, "") } + + // Cloned without --recurse-submodules: the submodule folder exists but is empty. + try FileManager.default.createDirectory(at: checkout.appendingPathComponent("ufade_developer"), withIntermediateDirectories: true) + let withoutImages = try await UFADEConnector.validate(checkout: checkout, python: "/usr/bin/true", runner: runner) + #expect(withoutImages.ufadeVersion == "0.9.8") + #expect(withoutImages.python.version == "3.11.9") + #expect(!withoutImages.developerImagesAvailable) + + try FileManager.default.createDirectory(at: checkout.appendingPathComponent("ufade_developer/Developer"), withIntermediateDirectories: true) + #expect(try await UFADEConnector.validate(checkout: checkout, python: "/usr/bin/true", runner: runner).developerImagesAvailable) + #expect(UFADEConnector.submoduleCommand == "git submodule update --init --recursive") + } +} diff --git a/Tests/ToolkitFeaturesTests/EvidenceAndReadinessTests.swift b/Tests/ToolkitFeaturesTests/EvidenceAndReadinessTests.swift new file mode 100644 index 0000000..8ead766 --- /dev/null +++ b/Tests/ToolkitFeaturesTests/EvidenceAndReadinessTests.swift @@ -0,0 +1,365 @@ +import DeviceTestSupport +import Foundation +import Testing +@testable import DeviceKit +@testable import ToolkitFeatures +import ToolkitCore + +/// Registers the lockdown services used by collection and readiness checks on a fake device. +func registerStandardServices(_ server: FakeDeviceServer, afc: FakeAFCFileSystem, crashes: FakeAFCFileSystem) { + FakeWebInspector().register(on: server) + server.register(service: ConfigurationProfileService.serviceName) { channel in + let messages = PlistMessageConnection(channel: channel) + guard (try await messages.receive(timeout: 5))["RequestType"]?.stringValue == "GetProfileList" else { return } + try await messages.send([ + "Status": "Acknowledged", + "OrderedIdentifiers": ["com.example.wifi"], + "ProfileMetadata": ["com.example.wifi": ["PayloadDisplayName": "Office Wi-Fi", "PayloadOrganization": "Example Corp", "PayloadRemovalDisallowed": false]], + "ProfileManifest": ["com.example.wifi": ["IsActive": true]], + ]) + } + server.register(service: OSTraceRelay.serviceName) { channel in + // PidList: one leading byte, then a big-endian length and the plist. + let request = try await PlistMessageConnection(channel: channel).receive(timeout: 5) + guard request["Request"]?.stringValue == "PidList" else { return } + let reply = try PlistValue.dictionary(["Status": "RequestSuccessful", "Payload": ["1": ["ProcessName": "launchd"], "250": ["ProcessName": "SpringBoard"]]]).encoded(format: .binary) + var header = Data([0x01]) + header.appendBigEndian(UInt32(reply.count)) + try await channel.write(header + reply) + } + server.domainValues["com.apple.security.mac.amfi"] = ["DeveloperModeStatus": true] + server.domainValues["com.apple.mobile.backup"] = ["WillEncrypt": false] + server.register(service: DiagnosticsRelay.serviceName) { channel in + let messages = PlistMessageConnection(channel: channel) + while let request = try? await messages.receive(timeout: 5) { + if request["Request"]?.stringValue == "Goodbye" { break } + try await messages.send(["Status": "Success", "Diagnostics": ["IORegistry": ["CurrentCapacity": 50], "MobileGestalt": ["ProductType": "iPhone16,1"], "GasGauge": ["CycleCount": 10]]]) + } + } + server.register(service: InstallationProxy.serviceName) { channel in + let messages = PlistMessageConnection(channel: channel) + _ = try await messages.receive(timeout: 5) + try await messages.send(["Status": "BrowsingApplications", "CurrentList": [["CFBundleIdentifier": "com.example.demo", "CFBundleName": "Demo", "ApplicationType": "User"]]]) + try await messages.send(["Status": "Complete"]) + } + server.register(service: ImageMounter.serviceName) { channel in + let messages = PlistMessageConnection(channel: channel) + while let request = try? await messages.receive(timeout: 5) { + if request["Command"]?.stringValue == "Hangup" { break } + try await messages.send(["EntryList": []]) + } + } + server.register(service: ProvisioningProfileService.serviceName) { channel in + let messages = PlistMessageConnection(channel: channel) + _ = try await messages.receive(timeout: 5) + try await messages.send(["Status": 0, "Payload": []]) + } + server.register(service: AFCClient.crashReportMoverServiceName) { channel in + try await channel.write(Data("ping".utf8)) + } + server.register(service: AFCClient.crashReportServiceName) { channel in try await crashes.serve(channel) } + server.register(service: AFCClient.mediaServiceName) { channel in try await afc.serve(channel) } + server.register(service: SyslogRelay.serviceName) { channel in + try await channel.write(Data("kernel: collected line\u{0}".utf8)) + while (try? await channel.hasMoreData()) == true { _ = try? await channel.readSome() } + } +} + +@Suite("Evidence capture (fake device)", .serialized) +struct EvidenceTests { + func physicalDevice(_ server: FakeDeviceServer) -> Device { + Device(kind: .physical, udid: server.udid, name: "Test iPhone", productType: "iPhone16,1", osVersion: "18.2", buildVersion: "22C152", transports: [.usb], pairingState: .paired, developerMode: .enabled, usbmuxDeviceID: server.deviceID, sources: [.usbmux]) + } + + @Test func guidedCaseIntakeAndValidation() throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "cases") + defer { try? FileManager.default.removeItem(at: root) } + let target = DeviceTarget(kind: .physical, udid: "00008110-001234560ABC801E", name: "Phone", osVersion: nil, usbmuxDeviceID: 1, coreDeviceIdentifier: nil, transport: .usb) + #expect(throws: ToolkitError.self) { try CaseWorkflow.createGuidedCase(in: root, target: target, title: "x", purpose: "", authorized: false) } + #expect(throws: ToolkitError.self) { try CaseWorkflow.createGuidedCase(in: root, target: target, title: " ", purpose: "", authorized: true) } + let (folder, intake) = try CaseWorkflow.createGuidedCase(in: root, target: target, title: " Lost phone ", purpose: "Authorized review", authorized: true) + #expect(intake.title == "Lost phone") + #expect(folder.lastPathComponent.hasPrefix("ios-case-")) + #expect(folder.lastPathComponent.hasSuffix("001234560ABC801E".suffix(12))) + try CaseWorkflow.validateForCollection(folder, target: target) + let other = DeviceTarget(kind: .physical, udid: "OTHER-UDID-000000", name: "Other", osVersion: nil, usbmuxDeviceID: 2, coreDeviceIdentifier: nil, transport: .usb) + #expect(throws: ToolkitError.self) { try CaseWorkflow.validateForCollection(folder, target: other) } + try SecureFileIO.writeNewFile(Data("{}".utf8), to: folder.appendingPathComponent("manifest.json")) + #expect(throws: ToolkitError.self) { try CaseWorkflow.validateForCollection(folder, target: target) } + let permissions = try FileManager.default.attributesOfItem(atPath: folder.path)[.posixPermissions] as? NSNumber + #expect(permissions?.intValue == 0o700) + } + + @Test func collectsSnapshotsStreamsAndHashes() async throws { + let server = try FakeDeviceServer() + registerStandardServices(server, afc: FakeAFCFileSystem(files: ["/DCIM/100APPLE/IMG_0001.JPG": Data("x".utf8)]), crashes: FakeAFCFileSystem(files: ["/JetsamEvent-2026.ips": Data("jetsam".utf8)])) + try await server.start() + defer { Task { await server.stop() } } + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "collect") + defer { try? FileManager.default.removeItem(at: root) } + let device = physicalDevice(server) + let folder = try CaseWorkflow.createCaseFolder(in: root, target: device.target) + let options = CollectionOptions(durationSeconds: 1, includeClassicSyslog: true, includeUnifiedLogs: false, includePacketCapture: false, includeScreenshot: true, includeCrashReports: true) + let collector = try EvidenceCollector(device: device, caseFolder: folder, options: options, usbmux: server.client) + let events = LockedValue<[String]>([]) + let manifest = await collector.run { event in + if case .stepFinished(let step) = event { events.withLock { $0.append("\(step.id):\(step.status.rawValue)") } } + } + #expect(manifest.outcome == .partial, "\(manifest.steps.map { "\($0.id)=\($0.status.rawValue) \($0.detail)" })") + let status = Dictionary(manifest.steps.map { ($0.id, $0.status) }, uniquingKeysWith: { $1 }) + #expect(status["lockdown-values"] == .succeeded) + #expect(status["apps"] == .succeeded) + #expect(status["battery"] == .succeeded) + #expect(status["crash-inventory"] == .succeeded) + #expect(status["media-root"] == .succeeded) + #expect(status["stream-syslog"] == .succeeded) + #expect(status["crash-reports"] == .succeeded) + #expect(status["processes"] == .succeeded, "processes are read natively, without Xcode") + #expect(status["configuration-profiles"] == .succeeded, "configuration profiles are read natively, without Xcode") + #expect(status["coredevice-details"] == .unavailable) + #expect(status["screenshot"] == .unavailable) + #expect(FileManager.default.fileExists(atPath: folder.appendingPathComponent("artifacts/crashes/JetsamEvent-2026.ips").path)) + #expect(try String(contentsOf: folder.appendingPathComponent("streams/syslog.log"), encoding: .utf8).contains("collected line")) + #expect(try String(contentsOf: folder.appendingPathComponent("snapshots/crash-list.txt"), encoding: .utf8).contains("JetsamEvent")) + #expect(try String(contentsOf: folder.appendingPathComponent("snapshots/processes.txt"), encoding: .utf8).contains("250\tSpringBoard")) + #expect(try HashManifest.verify(folder: folder, fileName: "SHA256SUMS").isEmpty) + let manifestJSON = try JSONValue.parse(Data(contentsOf: folder.appendingPathComponent("manifest.json"))) + #expect(manifestJSON["target_udid"]?.string == server.udid) + #expect(manifestJSON["outcome"]?.string == "partial") + #expect(events.current.count == manifest.steps.count) + #expect(manifest.outcome.exitCode == 2) + } + + @Test func failsWhenTheDeviceCannotBeIdentified() async throws { + let server = try FakeDeviceServer() + server.pairRecordAvailable = false + try await server.start() + defer { Task { await server.stop() } } + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "collect-fail") + defer { try? FileManager.default.removeItem(at: root) } + let device = physicalDevice(server) + let folder = try CaseWorkflow.createCaseFolder(in: root, target: device.target) + let manifest = try await EvidenceCollector(device: device, caseFolder: folder, options: CollectionOptions(durationSeconds: 0), usbmux: server.client).run { _ in } + #expect(manifest.outcome == .failed) + #expect(manifest.outcome.exitCode == 1) + #expect(manifest.steps.count == 1) + #expect(manifest.steps[0].status == .failed) + #expect(manifest.steps[0].attempts == 2) + #expect(FileManager.default.fileExists(atPath: folder.appendingPathComponent("snapshots/lockdown-info.json.error.txt").path)) + #expect(FileManager.default.fileExists(atPath: folder.appendingPathComponent("SHA256SUMS").path)) + } + + @Test func rejectsSimulatorsAndInvalidDurations() throws { + let simulator = Device(kind: .simulator, udid: "SIM", name: "Sim") + #expect(throws: ToolkitError.self) { try EvidenceCollector(device: simulator, caseFolder: URL(fileURLWithPath: "/tmp"), options: CollectionOptions()) } + #expect(throws: ToolkitError.self) { try CollectionOptions(durationSeconds: 7200).validated() } + } +} + +@Suite("Readiness and actions (fake device)", .serialized) +struct ReadinessAndActionTests { + /// A runner where every Xcode tool is missing, as on a Mac without Xcode. + final class NoXcodeRunner: CommandRunning, @unchecked Sendable { + func run(_ request: CommandRequest) async throws -> CommandResult { + CommandResult(request: request, termination: .exited(72), standardOutput: Data(), standardError: Data("xcrun: error: unable to find utility".utf8), startedAt: Date(), finishedAt: Date()) + } + func stream(_ request: CommandRequest) -> AsyncThrowingStream { + AsyncThrowingStream { $0.finish(throwing: ToolkitError(.toolMissing, message: "missing")) } + } + } + + @Test func matrixWithoutXcodeUsesNativeServices() async throws { + let server = try FakeDeviceServer() + registerStandardServices(server, afc: FakeAFCFileSystem(files: [:]), crashes: FakeAFCFileSystem(files: [:])) + try await server.start() + defer { Task { await server.stop() } } + let device = Device(kind: .physical, udid: server.udid, name: "Test iPhone", osVersion: "18.2", transports: [.usb], usbmuxDeviceID: server.deviceID, sources: [.usbmux]) + let probe = CapabilityProbe(runner: NoXcodeRunner(), usbmux: server.client) + let progress = LockedValue(0) + let results = await probe.run(for: device) { _ in progress.withLock { $0 += 1 } } + let states = Dictionary(results.map { ($0.id, $0.state) }, uniquingKeysWith: { $1 }) + #expect(results.map(\.id) == CapabilityRow.rows(for: .physical).map(\.rawValue)) + #expect(states["host"] == .ready) + #expect(states["xcode-tools"] == .unavailable) + #expect(states["usbmuxd"] == .ready) + #expect(states["device-connection"] == .ready) + #expect(states["pairing-trust"] == .ready) + #expect(states["developer-mode"] == .ready) + #expect(states["lockdown-services"] == .ready) + #expect(states["backup-service"] == .ready) + #expect(states["web-inspector"] == .ready) + #expect(states["instruments"] == .blocked) + #expect(states["coredevice"] == .blocked) + // Without Xcode the native check still reads the device; with no image on this Mac for it, the state is Missing. + #expect(states["developer-services"] == .unavailable) + #expect(results.first { $0.id == "developer-services" }?.summary.hasPrefix("Missing") == true) + #expect(progress.current == results.count) + #expect(results.first { $0.id == "xcode-tools" }?.remediation.contains("App Store") == true) + + let battery = try #require(ActionCatalog.descriptor("battery")) + #expect(ActionReadiness.evaluate(battery, results: results, device: device) == .ready) + // The process list no longer needs Xcode (it did in 1.0 before the parity audit); lock state still does. + #expect(ActionReadiness.evaluate(try #require(ActionCatalog.descriptor("processes")), results: results, device: device) == .ready) + let lockState = try #require(ActionCatalog.descriptor("lock-state")) + guard case .needsAttention(let problems) = ActionReadiness.evaluate(lockState, results: results, device: device) else { + Issue.record("expected attention") + return + } + #expect(!problems.isEmpty) + } + + @Test func instrumentsRowFollowsXctraceDeviceList() async throws { + let server = try FakeDeviceServer() + registerStandardServices(server, afc: FakeAFCFileSystem(files: [:]), crashes: FakeAFCFileSystem(files: [:])) + try await server.start() + defer { Task { await server.stop() } } + let device = Device(kind: .physical, udid: server.udid, name: "Test iPhone", osVersion: "18.2", transports: [.usb], usbmuxDeviceID: server.deviceID, sources: [.usbmux]) + let listing = LockedValue("") + let runner = ScriptedRunner() + runner.reply = { request in + request.arguments.suffix(2) == ["list", "devices"] ? (0, listing.current) : (0, "") + } + func instrumentsRow() async -> CapabilityResult? { + await CapabilityProbe(runner: runner, usbmux: server.client).run(for: device).first { $0.id == "instruments" } + } + listing.withLock { $0 = "== Devices ==\nThis Mac (AAAA-BBBB)\nTest iPhone (18.2) (\(server.udid))\n\n== Simulators ==\n" } + #expect(await instrumentsRow()?.state == .ready) + listing.withLock { $0 = "== Devices ==\nThis Mac (AAAA-BBBB)\n\n== Devices Offline ==\nTest iPhone (18.2) (\(server.udid.lowercased()))\n" } + let offline = await instrumentsRow() + #expect(offline?.state == .attention) + #expect(offline?.summary == "Instruments lists the device as offline.") + #expect(offline?.remediation.contains("Devices and Simulators") == true) + listing.withLock { $0 = "== Devices ==\nThis Mac (AAAA-BBBB)\n" } + #expect(await instrumentsRow()?.summary == "Instruments does not list the device.") + #expect(runner.requests.current.contains { $0.arguments.suffix(3) == ["xctrace", "list", "devices"] }) + + // The recording action waits for this row. + let action = try #require(ActionCatalog.descriptor("instruments")) + let results = [CapabilityRow.xcodeTools.result(.ready, "Xcode"), CapabilityRow.developerMode.result(.ready, "On"), try #require(offline)] + #expect(ActionReadiness.evaluate(action, results: results, device: device) == .needsAttention(["Instruments lists the device: Instruments lists the device as offline."])) + } + + @Test func developerImageRowDoesNotRepeatItsState() { + let blocked = CapabilityProbe.developerImageResult(DeveloperImageStatus(state: .blocked, headline: "Developer Mode is off.", explanation: "Turn it on.", remediation: "Turn on Settings › Privacy & Security › Developer Mode.")) + #expect(blocked.state == .attention) + #expect(blocked.summary == "Developer Mode is off.") + // The specific fix replaces the row's generic “Mount Developer Image” advice. + #expect(blocked.remediation == "Turn on Settings › Privacy & Security › Developer Mode.") + let generic = CapabilityProbe.developerImageResult(DeveloperImageStatus(state: .available, headline: "Ready to mount.", explanation: "")) + #expect(generic.remediation.contains("Mount Developer Image")) + #expect(CapabilityProbe.developerImageResult(DeveloperImageStatus(state: .mounted, headline: "x", explanation: "", remediation: "unused")).remediation.isEmpty) + let personalization = CapabilityProbe.developerImageResult(DeveloperImageStatus(state: .personalizationRequired, headline: "Apple must sign the image.", explanation: "")) + #expect(personalization.summary == "Personalization required: Apple must sign the image.") + #expect(CapabilityProbe.developerImageResult(DeveloperImageStatus(state: .mounted, headline: "x", explanation: "")).summary == "Mounted.") + } + + @Test func xctraceDeviceListParsing() { + let output = """ + == Devices == + Someone’s MacBook Pro (11111111-2222-3333-4444-555555555555) + iPad (17.4) (00008103-000A11112222001E) + + == Devices Offline == + Someone’s iPhone (26.3.1) (00008150-000B33334444002E) + + == Simulators == + iPhone 17 Pro (26.3.1) (AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE) + """ + #expect(InstrumentsDeviceList.presence(of: "00008103-000A11112222001E", in: output) == .available) + #expect(InstrumentsDeviceList.presence(of: "00008150-000b33334444002e", in: output) == .offline) + #expect(InstrumentsDeviceList.presence(of: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", in: output) == .available) + #expect(InstrumentsDeviceList.presence(of: "00008150", in: output) == .notListed) + #expect(InstrumentsDeviceList.presence(of: "anything", in: "") == .notListed) + } + + @Test func matrixReportsUntrustedDevices() async throws { + let server = try FakeDeviceServer() + server.pairRecordAvailable = false + try await server.start() + defer { Task { await server.stop() } } + let device = Device(kind: .physical, udid: server.udid, name: "Test iPhone", transports: [.usb], usbmuxDeviceID: server.deviceID, sources: [.usbmux]) + let results = await CapabilityProbe(runner: NoXcodeRunner(), usbmux: server.client).run(for: device) + let states = Dictionary(results.map { ($0.id, $0.state) }, uniquingKeysWith: { $1 }) + #expect(states["pairing-trust"] == .unavailable) + #expect(states["lockdown-services"] == .blocked) + #expect(results.first { $0.id == "pairing-trust" }?.remediation.contains("Trust") == true) + } + + @Test func nativeActionsRunAgainstTheCapturedTarget() async throws { + let server = try FakeDeviceServer() + registerStandardServices(server, afc: FakeAFCFileSystem(files: ["/Downloads/a.txt": Data("a".utf8)]), crashes: FakeAFCFileSystem(files: [:])) + try await server.start() + defer { Task { await server.stop() } } + let executor = ActionExecutor(runner: NoXcodeRunner(), usbmux: server.client) + let target = server.target + + let battery = try await executor.execute(try #require(ActionCatalog.descriptor("battery")), target: target, values: [:]) + #expect(battery.summary == "Battery at 50%.") + let values = try await executor.execute(try #require(ActionCatalog.descriptor("lockdown-values")), target: target, values: [:]) + #expect(values.details.contains { $0.0 == "DeviceName" && $0.1 == "Test iPhone" }) + let developerMode = try await executor.execute(try #require(ActionCatalog.descriptor("developer-mode-status")), target: target, values: [:]) + #expect(developerMode.summary == "Developer Mode is on.") + let media = try await executor.execute(try #require(ActionCatalog.descriptor("media-list")), target: target, values: ["path": "/"]) + #expect(media.details.map(\.0) == ["Downloads"]) + let query = try await executor.execute(try #require(ActionCatalog.descriptor("app-query")), target: target, values: ["bundle": "com.example.demo"]) + #expect(query.summary.hasPrefix("Demo")) + let processes = try await executor.execute(try #require(ActionCatalog.descriptor("processes")), target: target, values: [:]) + #expect(processes.summary == "2 processes running.") + #expect(processes.details.map(\.1) == ["launchd", "SpringBoard"]) + let profiles = try await executor.execute(try #require(ActionCatalog.descriptor("configuration-profiles")), target: target, values: [:]) + #expect(profiles.summary == "1 configuration profile installed.") + #expect(profiles.details.first?.0 == "Office Wi-Fi") + #expect(profiles.details.first?.1 == "Example Corp") + let tabs = try await executor.execute(try #require(ActionCatalog.descriptor("web-tabs")), target: target, values: [:]) + #expect(tabs.summary == "2 inspectable pages.") + #expect(tabs.details.contains { $0.0 == "Example Domain" && $0.1.contains("https://example.com/") && $0.1.contains("Safari") }) + + // A pcapd record with no link-layer header: 95-byte header + a 20-byte IPv4 packet. + var record = [UInt8](repeating: 0, count: 95) + record[3] = 95 + record[8] = 20 + record[16] = 2 + let blob = Data(record + [0x45] + [UInt8](repeating: 0, count: 19)) + server.register(service: PacketCaptureService.serviceName) { channel in + try await PlistMessageConnection(channel: channel).send(.data(blob), format: .binary) + } + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "action-pcap") + defer { try? FileManager.default.removeItem(at: directory) } + let capture = directory.appendingPathComponent("capture.pcap") + let pcap = try await executor.execute(try #require(ActionCatalog.descriptor("packet-capture")), target: target, values: ["duration": "5", "output": capture.path]) + #expect(pcap.summary == "The device ended the capture early. 1 packet saved.") + #expect(try Data(contentsOf: capture).count == 24 + 16 + 14 + 20) + await #expect(throws: ToolkitError.self) { + _ = try await executor.execute(try #require(ActionCatalog.descriptor("packet-capture")), target: target, values: ["duration": "5", "output": capture.path]) + } + + // Bluetooth: one PacketLogger record (2-byte little-endian length prefix), then the device ends. + var btRecord = Data() + btRecord.appendBigEndian(UInt32(9 + 3)) + btRecord.appendBigEndian(UInt32(1_700_000_000)) + btRecord.appendBigEndian(UInt32(0)) + btRecord.append(contentsOf: [0x01, 0x0E, 0x01, 0x00]) + let framed = Data([UInt8(btRecord.count), 0]) + btRecord + server.register(service: BluetoothPacketLogger.serviceName) { channel in try await channel.write(framed) } + let bluetoothFile = directory.appendingPathComponent("bt.pklg") + let bluetooth = try await executor.execute(try #require(ActionCatalog.descriptor("bluetooth-capture")), target: target, values: ["duration": "5", "output": bluetoothFile.path]) + #expect(bluetooth.summary == "The device ended the capture early. 1 packet saved.") + #expect(bluetooth.details.contains { $0.0 == "HCI event" && $0.1 == "1" }) + #expect(try Data(contentsOf: bluetoothFile) == btRecord) + server.register(service: BluetoothPacketLogger.serviceName) { _ in } + let empty = try await executor.execute(try #require(ActionCatalog.descriptor("bluetooth-capture")), target: target, values: ["duration": "5", "output": directory.appendingPathComponent("empty.pklg").path]) + #expect(empty.summary.contains("No Bluetooth packets arrived")) + + await #expect(throws: ToolkitError.self) { + _ = try await executor.execute(try #require(ActionCatalog.descriptor("app-query")), target: target, values: ["bundle": "not valid!"]) + } + let simulatorTarget = DeviceTarget(kind: .simulator, udid: "SIM", name: "Sim", osVersion: nil, usbmuxDeviceID: nil, coreDeviceIdentifier: nil, transport: .local) + await #expect(throws: ToolkitError.self) { + _ = try await executor.execute(try #require(ActionCatalog.descriptor("battery")), target: simulatorTarget, values: [:]) + } + let demo = DeviceTarget(kind: .demo, udid: "DEMO-IPHONE", name: "Demo", osVersion: nil, usbmuxDeviceID: nil, coreDeviceIdentifier: nil, transport: nil) + await #expect(throws: ToolkitError.self) { + _ = try await executor.execute(try #require(ActionCatalog.descriptor("screenshot")), target: demo, values: [:]) + } + } +} diff --git a/Tests/ToolkitFeaturesTests/Fixtures/.keep b/Tests/ToolkitFeaturesTests/Fixtures/.keep new file mode 100644 index 0000000..e69de29 diff --git a/Tests/ToolkitFeaturesTests/IPAInspectorTests.swift b/Tests/ToolkitFeaturesTests/IPAInspectorTests.swift new file mode 100644 index 0000000..20a8c84 --- /dev/null +++ b/Tests/ToolkitFeaturesTests/IPAInspectorTests.swift @@ -0,0 +1,186 @@ +import Foundation +import Testing +@testable import ToolkitFeatures +import ToolkitCore + +@Suite("IPA inspection") +struct IPAInspectorTests { + func infoPlist(bundle: String = "com.example.demo") throws -> Data { + let plist: PlistValue = [ + "CFBundleIdentifier": .string(bundle), + "CFBundleName": "Demo", + "CFBundleShortVersionString": "1.2.3", + "CFBundleVersion": "45", + "CFBundleExecutable": "Demo", + "MinimumOSVersion": "17.0", + "CFBundleSupportedPlatforms": ["iPhoneOS"], + ] + return try plist.encoded(format: .xml) + } + + func makeIPA(_ entries: [(String, Data)], in directory: URL, name: String = "Demo.ipa") throws -> URL { + var writer = ZipWriter() + for (entryName, data) in entries { try writer.add(name: entryName, data: data) } + let url = directory.appendingPathComponent(name) + try SecureFileIO.writeNewFile(writer.finalized(), to: url) + return url + } + + func run(_ executable: String, _ arguments: [String], in directory: URL) async throws -> CommandResult { + try await ProcessCommandRunner().run(CommandRequest(executable: URL(fileURLWithPath: executable), arguments: arguments, workingDirectory: directory, timeout: 60)) + } + + @Test func unsignedPackageIsInspectedButNotInstallable() throws { + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "ipa") + defer { try? FileManager.default.removeItem(at: directory) } + let ipa = try makeIPA([("Payload/Demo.app/Info.plist", try infoPlist()), ("Payload/Demo.app/Demo", Data(repeating: 1, count: 4096))], in: directory) + let inspection = try IPAInspector.inspect(ipa) + #expect(inspection.appName == "Demo") + #expect(inspection.bundleIdentifier == "com.example.demo") + #expect(inspection.version == "1.2.3") + #expect(inspection.build == "45") + #expect(inspection.minimumOSVersion == "17.0") + #expect(inspection.signature.status == .missing) + #expect(inspection.provisioning.status == .absent) + #expect(!inspection.isInstallable) + #expect(inspection.installabilityExplanation.contains("not signed")) + #expect(inspection.packageSHA256.count == 64) + #expect(inspection.report.contains("Bundle identifier: com.example.demo")) + } + + @Test(arguments: ["Payload/../../evil", "/Payload/Demo.app/Info.plist", "Payload\\Demo.app\\x"]) + func unsafeEntryNamesAreRejected(name: String) throws { + #expect(throws: ToolkitError.self) { try ZipArchive.validateName(name) } + } + + @Test func requiresExactlyOneAppInfoPlist() throws { + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "ipa") + defer { try? FileManager.default.removeItem(at: directory) } + let none = try makeIPA([("Payload/readme.txt", Data("x".utf8))], in: directory, name: "none.ipa") + #expect(throws: ToolkitError.self) { try IPAInspector.inspect(none) } + let two = try makeIPA([("Payload/A.app/Info.plist", try infoPlist()), ("Payload/B.app/Info.plist", try infoPlist())], in: directory, name: "two.ipa") + #expect(throws: ToolkitError.self) { try IPAInspector.inspect(two) } + let notZip = directory.appendingPathComponent("fake.ipa") + try SecureFileIO.writeNewFile(Data("not a zip at all, just text".utf8), to: notZip) + #expect(throws: ToolkitError.self) { try IPAInspector.inspect(notZip) } + #expect(throws: ToolkitError.self) { try IPAInspector.inspect(directory.appendingPathComponent("file.zip")) } + } + + @Test func entriesThatExceedTheirDeclaredSizeAreStopped() throws { + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "ipa") + defer { try? FileManager.default.removeItem(at: directory) } + var writer = ZipWriter() + try writer.add(name: "Payload/Demo.app/Info.plist", data: try infoPlist()) + try writer.add(name: "Payload/Demo.app/big", data: Data(repeating: 0, count: 100_000)) + var archive = writer.finalized() + // Lie about the uncompressed size of "big" in both the local and central headers. + for signature in [Data([0x50, 0x4B, 0x03, 0x04]), Data([0x50, 0x4B, 0x01, 0x02])] { + var searchStart = archive.startIndex + while let range = archive.range(of: signature, in: searchStart.. GPS lost")) + } + + @Test func lineSplitterKeepsPartialLines() { + var splitter = LineSplitter() + #expect(splitter.consume(Data("one\ntw".utf8)) == ["one"]) + #expect(splitter.consume(Data("o\nthree".utf8)) == ["two"]) + #expect(splitter.flush() == ["three"]) + #expect(splitter.flush().isEmpty) + } + + @Test func streamKindsMatchDeviceKinds() { + #expect(LogStreamKind.available(for: .physical) == [.unified, .classic]) + #expect(LogStreamKind.available(for: .simulator) == [.simulator]) + #expect(LogStreamKind.available(for: .demo).isEmpty) + } +} diff --git a/Tests/ToolkitFeaturesTests/LocationLabTests.swift b/Tests/ToolkitFeaturesTests/LocationLabTests.swift new file mode 100644 index 0000000..d8fa9b6 --- /dev/null +++ b/Tests/ToolkitFeaturesTests/LocationLabTests.swift @@ -0,0 +1,224 @@ +import Foundation +import Testing +@testable import DeviceKit +@testable import ToolkitFeatures +import ToolkitCore + +@Suite("Location Lab") +struct LocationLabTests { + @Test(arguments: [ + ("34.0522,-118.2437", 34.0522, -118.2437), + ("(34.0522, -118.2437)", 34.0522, -118.2437), + ("geo:37.3349,-122.0090", 37.3349, -122.0090), + ("https://maps.apple.com/?ll=51.5007%2C-0.1246", 51.5007, -0.1246), + ("https://www.google.com/maps/@35.6586,139.7454,15z", 35.6586, 139.7454), + ("https://www.google.com/maps/search/?api=1&query=-33.8568%2C151.2153", -33.8568, 151.2153), + ("https://www.bing.com/maps?cp=47.6~-122.3", 47.6, -122.3), + ]) + func parsesCoordinatesAndFullMapLinks(input: String, latitude: Double, longitude: Double) throws { + let parsed = try LocationLab.parseLocationInput(input) + #expect(abs(parsed.latitude - latitude) < 1e-9) + #expect(abs(parsed.longitude - longitude) < 1e-9) + } + + @Test(arguments: ["https://maps.app.goo.gl/short", "https://maps.apple.com/?q=Coffee", "ftp://x/1,2", "", "hello", "91,0"]) + func rejectsLinksWithoutVisibleCoordinates(input: String) { + #expect(throws: ToolkitError.self) { try LocationLab.parseLocationInput(input) } + } + + @Test func mapFractionsRoundTrip() throws { + let original = Coordinates(latitude: 34.0522, longitude: -118.2437) + let fractions = LocationLab.mapFractions(for: original) + let restored = try LocationLab.coordinates(forMapFractionX: fractions.x, y: fractions.y) + #expect(abs(restored.latitude - original.latitude) < 1e-12) + #expect(abs(restored.longitude - original.longitude) < 1e-12) + #expect(throws: ToolkitError.self) { try LocationLab.coordinates(forMapFractionX: 1.1, y: 0.5) } + } + + @Test(arguments: [("nan", "0"), ("91", "0"), ("0", "-181"), ("abc", "1"), ("inf", "0")]) + func rejectsInvalidCoordinates(latitude: String, longitude: String) { + #expect(throws: ToolkitError.self) { try LocationLab.validate(latitude: latitude, longitude: longitude) } + } + + @Test func inspectsTrackPointsAndHashes() throws { + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "gpx") + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("route.gpx") + try SecureFileIO.writeNewFile(Data(""" + + + + + + + + + """.utf8), to: file) + let inspection = try LocationLab.inspectGPX(at: file) + #expect(inspection.trackPointCount == 3) + #expect(inspection.timedPointCount == 2) + #expect(inspection.firstPoint == Coordinates(latitude: 34.0522, longitude: -118.2437)) + #expect(inspection.sha256.count == 64) + #expect(inspection.recordedDuration == 10.5) + #expect(inspection.distanceMetres > 20) + } + + @Test(arguments: [ + #""#, + #"]>"#, + #""#, + #""#, + #""#, + "not xml", + ]) + func rejectsUnsafeOrUselessGPX(document: String) { + #expect(throws: ToolkitError.self) { try LocationLab.parseGPX(Data(document.utf8)) } + } + + @Test func savedLocationSchemaIsStrictAndCompatible() throws { + let locations = try LocationLab.parseSavedLocations(Data(#"{"version": 1, "locations": [{"name": "Lab", "latitude": 1.5, "longitude": 2}]}"#.utf8)) + #expect(locations == [SavedLocation(name: "Lab", coordinates: Coordinates(latitude: 1.5, longitude: 2))]) + #expect(throws: ToolkitError.self) { try LocationLab.adding("lab", Coordinates(latitude: 3, longitude: 4), to: locations) } + #expect(throws: ToolkitError.self) { try LocationLab.parseSavedLocations(Data(#"{"version": 1, "locations": [{"name": "Broken", "latitude": "1", "longitude": 2}]}"#.utf8)) } + #expect(throws: ToolkitError.self) { try LocationLab.parseSavedLocations(Data(#"{"version": 2, "locations": []}"#.utf8)) } + #expect(throws: ToolkitError.self) { try LocationLab.adding("bad\u{7}name", Coordinates(latitude: 0, longitude: 0), to: []) } + + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "saved") + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("locations.json") + let updated = try LocationLab.adding("Home", Coordinates(latitude: 10, longitude: 20), to: locations) + try LocationLab.storeSavedLocations(updated, to: file) + #expect(try LocationLab.loadSavedLocations(from: file) == updated) + #expect(try LocationLab.loadSavedLocations(from: directory.appendingPathComponent("missing.json")).isEmpty) + } + + @Test func buildsBoundedTimestampedPingPongRoute() throws { + let waypoints = try LocationLab.parseRouteWaypoints("34.0522,-118.2437\n\n34.0523,-118.2436") + let start = ISO8601DateFormatter().date(from: "2026-08-24T00:00:00Z")! + let route = try LocationLab.buildRoute(waypoints: waypoints, speedKmh: 5, intervalSeconds: 2, traversalCount: 2, startTime: start) + #expect(route.points.count > 2) + #expect(abs(route.points.first!.latitude - route.points.last!.latitude) < 1e-9) + #expect(route.gpxDocument.contains("2026-08-24T00:00:00Z")) + #expect(route.gpxDocument.components(separatedBy: " origin.longitude) + #expect(abs(Geodesy.distance(origin, moved) - 100) < 0.5) + let wrapped = try Geodesy.move(Coordinates(latitude: 0, longitude: 179.9999), bearing: 90, distance: 1000) + #expect(wrapped.longitude < -179) + #expect(throws: ToolkitError.self) { try Geodesy.move(origin, bearing: 0, distance: 200_000) } + } + + @Test func evidenceEventsAppendAsJSONLines() throws { + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "events") + defer { try? FileManager.default.removeItem(at: directory) } + for status in ["started", "completed"] { + try LocationLab.append(LocationEvidenceEvent(event: "set", status: status, deviceIdentifier: "UDID", deviceName: "Phone", deviceKind: "physical", osVersion: "26.0", mechanism: "CoreDevice", latitude: 1, longitude: 2, detail: "ok"), to: directory) + } + let lines = try String(contentsOf: LocationLab.eventsURL(in: directory), encoding: .utf8).split(separator: "\n") + #expect(lines.count == 2) + let first = try JSONValue.parse(Data(lines[0].utf8)) + #expect(first["device_identifier"]?.string == "UDID") + #expect(first["ios_version"]?.string == "26.0") + } +} + +@Suite("GPX playback") +struct PlaybackTests { + func point(_ offset: TimeInterval?) -> GPXPoint { + GPXPoint(coordinates: Coordinates(latitude: 1, longitude: 1), time: offset.map { Date(timeIntervalSince1970: 1_000 + $0) }) + } + + @Test func recordedTimingIsMonotonicAndSpaced() throws { + let offsets = try PlaybackPlanner.offsets(for: [point(0), point(5), point(5.1), point(nil), point(3)], timing: .recorded(jitterMilliseconds: 0)) + #expect(offsets == [0, 5, 5.5, 6.5, 7]) + } + + @Test func jitterIsBoundedAndNeverReorders() throws { + let offsets = try PlaybackPlanner.offsets(for: (0..<20).map { point(Double($0)) }, timing: .recorded(jitterMilliseconds: 400), random: { $0.upperBound }) + #expect(offsets[0] == 0) + #expect(zip(offsets, offsets.dropFirst()).allSatisfy { $1 >= $0 + 0.5 }) + #expect(throws: ToolkitError.self) { try PlaybackPlanner.offsets(for: [point(0)], timing: .recorded(jitterMilliseconds: 70_000)) } + } + + @Test func fixedInterval() throws { + #expect(try PlaybackPlanner.offsets(for: [point(nil), point(nil), point(nil)], timing: .fixedInterval(seconds: 2)) == [0, 2, 4]) + #expect(throws: ToolkitError.self) { try PlaybackPlanner.offsets(for: [point(nil)], timing: .fixedInterval(seconds: 0.1)) } + } + + @Test func playsEveryPointOnTheCapturedTargetOnly() async throws { + let runner = ScriptedRunner() + let controller = LocationController(coreDevice: CoreDeviceClient(runner: runner), simulators: SimulatorClient(runner: runner)) + let simulator = DeviceTarget(kind: .simulator, udid: "SIM-A", name: "Sim", osVersion: "26.3", usbmuxDeviceID: nil, coreDeviceIdentifier: nil, transport: .local) + let points = [GPXPoint(coordinates: Coordinates(latitude: 1, longitude: 2), time: nil), GPXPoint(coordinates: Coordinates(latitude: 3, longitude: 4), time: nil)] + let playback = try GPXPlayback(points: points, timing: .fixedInterval(seconds: 0.5), target: simulator, controller: controller) + let progress = LockedValue<[Int]>([]) + try await playback.run { index, _ in progress.withLock { $0.append(index) } } + #expect(progress.current == [1, 2]) + #expect(await playback.state == .finished) + let requests = runner.requests.current + #expect(requests.count == 2) + #expect(requests.allSatisfy { $0.arguments.contains("SIM-A") && $0.arguments.contains("location") }) + } + + @Test func mechanismSelection() { + let controller = LocationController() + let modern = DeviceTarget(kind: .physical, udid: "A", name: "A", osVersion: "17.0", usbmuxDeviceID: 1, coreDeviceIdentifier: nil, transport: .usb) + let legacy = DeviceTarget(kind: .physical, udid: "B", name: "B", osVersion: "16.7.10", usbmuxDeviceID: 1, coreDeviceIdentifier: nil, transport: .usb) + let simulator = DeviceTarget(kind: .simulator, udid: "C", name: "C", osVersion: "26.3", usbmuxDeviceID: nil, coreDeviceIdentifier: nil, transport: .local) + #expect(controller.mechanism(for: modern) == .coreDevice) + #expect(controller.mechanism(for: legacy) == .legacyService) + #expect(controller.mechanism(for: simulator) == .simulator) + #expect(LegacyLocationSimulation.encodeSet(latitude: 1.5, longitude: -2).count == 4 + 4 + 3 + 4 + 4) + #expect(LegacyLocationSimulation.encodeClear() == Data([0, 0, 0, 1])) + } +} + +/// Minimal recording runner for feature tests. +final class ScriptedRunner: CommandRunning, @unchecked Sendable { + let requests = LockedValue<[CommandRequest]>([]) + var reply: @Sendable (CommandRequest) -> (Int32, String) = { _ in (0, "") } + + func run(_ request: CommandRequest) async throws -> CommandResult { + requests.withLock { $0.append(request) } + let (code, output) = reply(request) + if let index = request.arguments.firstIndex(of: "--json-output") { + try Data(#"{"info":{"outcome":"success"},"result":{}}"#.utf8).write(to: URL(fileURLWithPath: request.arguments[index + 1])) + } + return CommandResult(request: request, termination: .exited(code), standardOutput: Data(output.utf8), standardError: Data(), startedAt: Date(), finishedAt: Date()) + } + + func stream(_ request: CommandRequest) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + do { + continuation.yield(.finished(try await self.run(request))) + continuation.finish() + } catch { continuation.finish(throwing: error) } + } + } + } +} diff --git a/Tests/ToolkitFeaturesTests/RealDeviceTests.swift b/Tests/ToolkitFeaturesTests/RealDeviceTests.swift new file mode 100644 index 0000000..0bade71 --- /dev/null +++ b/Tests/ToolkitFeaturesTests/RealDeviceTests.swift @@ -0,0 +1,166 @@ +import Foundation +import Testing +@testable import DeviceKit +@testable import ToolkitFeatures +import ToolkitCore + +/// Read-only checks against a real iPhone or iPad connected by USB. Opt in with +/// IDT_DEVICE_TESTS=1. Nothing here changes the device: captures go to temporary files that are +/// deleted afterwards. Output lines (prefixed “[device]”) record what the device reported, without +/// identifiers. +@Suite("Real device (opt-in, read-only)", .serialized, .enabled(if: ProcessInfo.processInfo.environment["IDT_DEVICE_TESTS"] == "1")) +struct RealDeviceTests { + func connectedTarget() async throws -> DeviceTarget { + let device = try #require(try await USBMuxClient().listDevices().first { $0.transport == .usb }, "Connect an iPhone or iPad by USB") + let version = try await DeviceSession.with(DeviceTarget(kind: .physical, udid: device.udid, name: "device", osVersion: nil, usbmuxDeviceID: device.deviceID, coreDeviceIdentifier: nil, transport: .usb)) { session in + try await session.getValue(key: "ProductVersion")?.stringValue + } + return DeviceTarget(kind: .physical, udid: device.udid, name: "device", osVersion: version, usbmuxDeviceID: device.deviceID, coreDeviceIdentifier: nil, transport: .usb) + } + + func report(_ text: String) { print("[device] \(text)") } + + @Test(.timeLimit(.minutes(5))) + func lockdownServicesAnswer() async throws { + let target = try await connectedTarget() + try await DeviceSession.with(target) { session in + let values = try #require(try await session.getValue()) + let product = values["ProductType"]?.stringValue ?? "?" + report("lockdown: \(product), iOS \(values["ProductVersion"]?.stringValue ?? "?") (\(values["BuildVersion"]?.stringValue ?? "?")), \(values["CPUArchitecture"]?.stringValue ?? "?"), chip \(values["ChipID"]?.intValue.map { String($0, radix: 16) } ?? "?") board \(values["BoardId"]?.intValue.map(String.init) ?? "?")") + #expect(values["UniqueDeviceID"]?.stringValue?.caseInsensitiveCompare(target.udid) == .orderedSame) + + let processes = try await OSTraceRelay.processList(session) + report("process list (PidList): \(processes.count) processes; includes launchd: \(processes.contains { $0.pid == 1 })") + #expect(processes.count > 20) + #expect(processes.contains { $0.pid == 1 }) + + let profiles = try await { () async throws -> [InstalledConfigurationProfile] in + let service = try await ConfigurationProfileService.open(session) + defer { Task { await service.close() } } + return try await service.profiles() + }() + report("configuration profiles (MCInstall): \(profiles.count)") + + let provisioning = try await { () async throws -> [Data] in + let service = try await ProvisioningProfileService.open(session) + defer { Task { await service.close() } } + return try await service.copyAll() + }() + report("provisioning profiles (misagent): \(provisioning.count)") + + let apps = try await { () async throws -> [InstalledApplication] in + let proxy = try await InstallationProxy.open(session) + defer { Task { await proxy.close() } } + return try await proxy.browse(includeSizes: false) + }() + report("installed apps (installation_proxy): \(apps.count)") + #expect(!apps.isEmpty) + + let diagnostics = try await { () async throws -> PlistValue in + let relay = try await DiagnosticsRelay.open(session) + defer { Task { await relay.close() } } + return try await relay.all() + }() + report("diagnostics (diagnostics_relay): \((diagnostics.dictionaryValue ?? [:]).keys.sorted().joined(separator: ", "))") + + let mounter = try await ImageMounter.open(session) + let images = try await mounter.mountedImages() + let personalized = try await mounter.lookup(.personalized) + await mounter.close() + report("image mounter: \(images.count) mounted entries; personalized developer image mounted: \(!personalized.isEmpty)") + } + } + + @Test(.timeLimit(.minutes(5))) + func developerImageStatusIsEvaluated() async throws { + let target = try await connectedTarget() + let status = await DeveloperImageManager().status(for: target) + report("developer image: \(status.state.rawValue) — \(status.headline)") + for (label, value) in status.detailRows where label != "Next step" { report(" \(label): \(value)") } + #expect(status.state != .failed, "\(status.technicalDetail ?? "")") + #expect(status.facts?.productVersion == target.osVersion) + // Whether this Mac's images fit the device once Developer Mode is on (evaluation only). + if var facts = status.facts, facts.developerModeEnabled == false { + facts.developerModeEnabled = true + let host = DeveloperImageHostInventory.discover(userFolders: [], coreDeviceAvailable: false) + let assumed = DeveloperImageEvaluator.evaluate(facts: facts, observation: DeveloperImageObservation(), host: host) + report("with Developer Mode on, this Mac's images would give: \(assumed.state.rawValue) — \(assumed.headline) [\(assumed.hostImage ?? "no host image")]") + if let match = host.personalizedMatch(chipID: facts.chipID, boardID: facts.boardID) { + report(" build identity: \(match.identity.productType ?? "?") \(match.identity.variant ?? "")") + } + } + } + + /// Starts the Bluetooth logger for a few seconds. Without Apple's logging profile the device + /// may refuse the service or send nothing; the outcome is reported, not asserted. + @Test(.timeLimit(.minutes(2))) + func bluetoothLoggerBehaviour() async throws { + let target = try await connectedTarget() + do { + let count = try await DeviceSession.with(target) { session -> Int in + let records = try await BluetoothPacketLogger.records(session) + return try await withThrowingTaskGroup(of: Int.self) { group in + group.addTask { + var count = 0 + do { for try await _ in records { count += 1 } } catch is CancellationError {} + return count + } + try await Task.sleep(for: .seconds(4)) + group.cancelAll() + return try await group.next() ?? 0 + } + } + report("bluetooth logger: service started, \(count) records in 4 s") + } catch let error as ToolkitError { + report("bluetooth logger: \(error.message) (\(error.technicalDetail ?? ""))") + } + } + + @Test(.timeLimit(.minutes(5))) + func streamsDeliverData() async throws { + let target = try await connectedTarget() + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "device-streams") + defer { try? FileManager.default.removeItem(at: directory) } + + func collect(_ open: @escaping @Sendable (DeviceSession) async throws -> AsyncThrowingStream, seconds: Double) async throws -> (lines: Int, bytes: Int) { + try await DeviceSession.with(target) { session in + let stream = try await open(session) + return try await withThrowingTaskGroup(of: (Int, Int).self) { group in + group.addTask { + var lines = 0, bytes = 0 + do { + for try await chunk in stream { lines += chunk.lines.count; bytes += chunk.spoolBytes.count } + } catch is CancellationError {} + return (lines, bytes) + } + try await Task.sleep(for: .seconds(seconds)) + group.cancelAll() + return try await group.next() ?? (0, 0) + } + } + } + let syslog = try await collect({ try await SyslogRelay.stream($0) }, seconds: 4) + report("classic syslog (syslog_relay): \(syslog.lines) lines, \(syslog.bytes) bytes in 4 s") + #expect(syslog.bytes > 0) + let unified = try await collect({ try await OSTraceRelay.stream($0) }, seconds: 4) + report("unified logging (os_trace_relay): \(unified.lines) records, \(unified.bytes) bytes in 4 s") + #expect(unified.lines > 0) + + let pcapFile = directory.appendingPathComponent("capture.pcap") + let writer = try PcapFileWriter(creatingNewFileAt: pcapFile) + try await DeviceSession.with(target) { session in + let stream = try await PacketCaptureService.stream(session) + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + do { for try await packet in stream { try writer.write(packet) } } catch is CancellationError {} + } + try await Task.sleep(for: .seconds(5)) + group.cancelAll() + } + } + let digest = try writer.finish() + let size = try Data(contentsOf: pcapFile).count + report("packet capture (pcapd): \(writer.packetCount) packets, \(size) bytes in 5 s, sha256 \(digest.prefix(12))…") + #expect(size >= 24, "a pcap file has at least its global header") + } +} diff --git a/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift b/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift new file mode 100644 index 0000000..5dec2c2 --- /dev/null +++ b/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift @@ -0,0 +1,123 @@ +import Foundation +import Testing +@testable import DeviceKit +@testable import ToolkitFeatures +import ToolkitCore + +/// End-to-end checks against a real simulator. Opt in with IDT_SIMULATOR_TESTS=1 because +/// booting a simulator takes time and changes local simulator state. +@Suite("Real simulator (opt-in)", .serialized, .enabled(if: ProcessInfo.processInfo.environment["IDT_SIMULATOR_TESTS"] == "1")) +struct RealSimulatorTests { + // A cold first boot on a CI runner can take several minutes. + @Test(.timeLimit(.minutes(20))) + func simulatorWorkflowEndToEnd() async throws { + let clock = ContinuousClock() + let start = clock.now + func step(_ name: String) { print("[simulator e2e] \(clock.now - start): \(name)") } + let client = SimulatorClient() + let records = try await client.list() + let record = try #require(records.first { $0.isAvailable && $0.device.family == .iPhone }, "No available iPhone simulator") + let target = record.device.target + let wasBooted = record.state == .booted + step("booting") + try await client.boot(target) + step("booted") + + // Wait for the boot to settle. + var booted = false + for _ in 0..<60 { + if try await client.list().first(where: { $0.udid == target.udid })?.state == .booted { booted = true; break } + try await Task.sleep(for: .seconds(1)) + } + #expect(booted) + + step("location") + let location = LocationController(simulators: client) + try await location.set(latitude: 51.5007, longitude: -0.1246, on: target) + try await location.startRoute([(51.5007, -0.1246), (51.5010, -0.1200)], speedMetresPerSecond: 5, intervalSeconds: 1, on: target) + try await location.clear(on: target) + + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "sim-e2e") + defer { try? FileManager.default.removeItem(at: directory) } + let screenshot = directory.appendingPathComponent("screen.png") + try await client.screenshot(target, to: screenshot) + let png = try Data(contentsOf: screenshot) + #expect(png.starts(with: [0x89, 0x50, 0x4E, 0x47])) + + let apps = try await client.apps(target) + #expect(apps.contains { $0.bundleIdentifier == "com.apple.mobilesafari" }) + + // Live logs: open the stream, collect for a few seconds, then stop. + step("logs") + let capture = try LogCapture(kind: .simulator, target: target, directory: directory.appendingPathComponent("logs")) + let stream = try await LiveLogSource.open(.simulator, target: target) + let collector = Task { () -> Int in + var lines = 0 + for try await chunk in stream { + try await capture.append(chunk) + lines += chunk.lines.count + } + return lines + } + try await Task.sleep(for: .seconds(2)) + _ = try await client.launch(bundleIdentifier: "com.apple.Preferences", on: target, terminateExisting: true) + try await Task.sleep(for: .seconds(4)) + collector.cancel() + let lines = (try? await collector.value) ?? 0 + try await capture.finish(reason: "test complete") + let metadata = await capture.currentMetadata + #expect(metadata.rawBytes > 0, "no log bytes captured (\(lines) lines)") + #expect(metadata.rawSHA256?.count == 64) + + let executor = ActionExecutor() + let openURL = try await executor.execute(try #require(ActionCatalog.descriptor("open-url")), target: target, values: ["url": "https://example.com"]) + #expect(openURL.summary.contains("example.com")) + + let readiness = await CapabilityProbe().run(for: record.device) + let running = readiness.first { $0.id == "simulator-running" } + let xcode = readiness.first { $0.id == "xcode-tools" } + #expect(running?.state == .ready, "\(running?.summary ?? "") \(running?.evidence ?? "")") + #expect(xcode?.state == .ready, "\(xcode?.summary ?? "") \(xcode?.evidence ?? "")") + + // Install, list, launch, and remove a real (minimal) simulator app. + step("building fixture app") + let fixture = try await Self.buildFixtureApp(in: directory) + step("installing fixture app") + try await client.install(appAt: fixture, on: target) + #expect(try await client.apps(target).contains { $0.bundleIdentifier == Self.fixtureBundleID }) + let launched = try await client.launch(bundleIdentifier: Self.fixtureBundleID, on: target, terminateExisting: true) + #expect(launched.contains(Self.fixtureBundleID)) + try await client.uninstall(bundleIdentifier: Self.fixtureBundleID, on: target) + #expect(try await !client.apps(target).contains { $0.bundleIdentifier == Self.fixtureBundleID }) + + step("done") + if !wasBooted { try await client.shutdown(target) } + } + + static let fixtureBundleID = "io.hideouts.idt.simulator-fixture" + + /// Compiles a minimal iOS-simulator app with Xcode's swiftc and signs it ad hoc. + static func buildFixtureApp(in directory: URL) async throws -> URL { + let runner = ProcessCommandRunner() + let app = directory.appendingPathComponent("Fixture.app", isDirectory: true) + try FileManager.default.createDirectory(at: app, withIntermediateDirectories: true) + let source = directory.appendingPathComponent("main.swift") + try Data("import Foundation\nwhile true { sleep(1) }\n".utf8).write(to: source) + #if arch(arm64) + let triple = "arm64-apple-ios17.0-simulator" + #else + let triple = "x86_64-apple-ios17.0-simulator" + #endif + let compile = try await runner.run(CommandRequest(executable: try AppleTool.xcrun.locate(), arguments: ["--sdk", "iphonesimulator", "swiftc", "-target", triple, "-o", app.appendingPathComponent("Fixture").path, source.path], timeout: 300)) + try #require(compile.succeeded, "swiftc failed: \(compile.standardErrorText)") + let info: PlistValue = [ + "CFBundleIdentifier": .string(fixtureBundleID), "CFBundleExecutable": "Fixture", "CFBundleName": "Fixture", + "CFBundlePackageType": "APPL", "CFBundleVersion": "1", "CFBundleShortVersionString": "1.0", + "MinimumOSVersion": "17.0", "CFBundleSupportedPlatforms": ["iPhoneSimulator"], "UIDeviceFamily": [1, 2], + ] + try info.encoded(format: .xml).write(to: app.appendingPathComponent("Info.plist")) + let sign = try await runner.run(CommandRequest(executable: try AppleTool.codesign.locate(), arguments: ["--force", "--sign", "-", app.path], timeout: 60)) + try #require(sign.succeeded, "codesign failed: \(sign.standardErrorText)") + return app + } +} diff --git a/Tests/ToolkitFeaturesTests/SharedFeatureTests.swift b/Tests/ToolkitFeaturesTests/SharedFeatureTests.swift new file mode 100644 index 0000000..93b4721 --- /dev/null +++ b/Tests/ToolkitFeaturesTests/SharedFeatureTests.swift @@ -0,0 +1,366 @@ +import Foundation +import Testing +@testable import DeviceKit +@testable import ToolkitFeatures +import ToolkitCore + +let xcodeAvailable = FileManager.default.isExecutableFile(atPath: "/usr/bin/xcrun") && FileManager.default.fileExists(atPath: "/Library/Developer/PrivateFrameworks/CoreDevice.framework") + +@Suite("Toolchain and reference") +struct ToolchainTests { + /// Every devicectl/simctl/xctrace route and option the app relies on exists in this Xcode. + @Test(.enabled(if: xcodeAvailable), .timeLimit(.minutes(3))) + func installedXcodeProvidesEveryRoute() async throws { + let results = await ToolchainCheck.run(runner: ProcessCommandRunner()) + #expect(results.count == ToolchainCheck.routes.count) + // Routes every supported Xcode must have are required; routes newer Xcode versions add are + // reported as “needs a newer Xcode” (for example on the Xcode 26.6 CI runner). + let problems = results.filter { $0.state != .available && $0.state != .needsNewerXcode } + #expect(problems.isEmpty, "\(problems.map { "\($0.route.id): \($0.state.rawValue) \($0.detail)" })") + let unexpectedNewer = results.filter { $0.state == .needsNewerXcode && !$0.route.needsRecentXcode } + #expect(unexpectedNewer.isEmpty) + } + + @Test func evaluationDetectsMissingOptionsAndRoutes() { + let route = ToolchainCheck.Route(tool: .devicectl, path: ["device", "x"], requiredOptions: ["--alpha", "--beta"], usedFor: "test") + #expect(ToolchainCheck.evaluate(route, helpText: "USAGE --alpha --beta", succeeded: true).state == .available) + #expect(ToolchainCheck.evaluate(route, helpText: "USAGE --alpha", succeeded: true).state == .changed) + #expect(ToolchainCheck.evaluate(route, helpText: "Error: Unknown subcommand", succeeded: false).state == .missing) + var newer = route + newer.needsRecentXcode = true + #expect(ToolchainCheck.evaluate(newer, helpText: "USAGE --alpha", succeeded: true).state == .needsNewerXcode) + #expect(ToolchainCheck.evaluate(newer, helpText: "Error: Unknown subcommand", succeeded: false).state == .needsNewerXcode) + #expect(ToolchainCheck.evaluate(newer, helpText: "USAGE --alpha --beta", succeeded: true).state == .available) + let report = ToolchainCheck.render([ToolchainCheck.evaluate(route, helpText: "--alpha", succeeded: true)]) + #expect(report.contains("Changed (1)")) + #expect(report.contains("--beta")) + } + + @Test func missingXcodeIsReportedAsTheCause() { + let missing = DeveloperToolsStatus.Availability.missing(reason: "xcrun: error: unable to find utility \"devicectl\"") + let commandLineToolsOnly = DeveloperToolsStatus(developerDirectory: "/Library/Developer/CommandLineTools", xcodeVersion: nil, devicectl: missing, simctl: missing, xctrace: missing) + #expect(ToolchainCheck.unavailableReason(.devicectl, in: commandLineToolsOnly)?.hasPrefix("Xcode is not installed") == true) + #expect(ToolchainCheck.unavailableReason(.xed, in: commandLineToolsOnly) == nil) + + let brokenTool = DeveloperToolsStatus(developerDirectory: "/Applications/Xcode.app/Contents/Developer", xcodeVersion: "Xcode 27.0", devicectl: missing, simctl: .available(version: nil), xctrace: .available(version: nil)) + #expect(ToolchainCheck.unavailableReason(.devicectl, in: brokenTool)?.hasPrefix("devicectl could not run") == true) + #expect(ToolchainCheck.unavailableReason(.simctl, in: brokenTool) == nil) + + // A busy Mac is not a missing Xcode. + let busy = DeveloperToolsStatus(developerDirectory: "/Applications/Xcode.app/Contents/Developer", xcodeVersion: nil, devicectl: .unresponsive(reason: "devicectl did not finish within 45 seconds."), simctl: .available(version: nil), xctrace: .available(version: nil)) + #expect(ToolchainCheck.unavailableReason(.devicectl, in: busy)?.contains("did not answer in time") == true) + } + + @Test func slowXcodeToolsAreNotReportedAsMissing() async { + final class TimingOutRunner: CommandRunning, @unchecked Sendable { + func run(_ request: CommandRequest) async throws -> CommandResult { + throw ToolkitError.timedOut(request.displayName, after: 45) + } + func stream(_ request: CommandRequest) -> AsyncThrowingStream { + AsyncThrowingStream { $0.finish(throwing: ToolkitError.timedOut(request.displayName, after: 45)) } + } + } + let tools = await DeveloperToolsStatus.probe(runner: TimingOutRunner()) + guard case .unresponsive = tools.devicectl else { + Issue.record("expected unresponsive, got \(tools.devicectl)") + return + } + let simulator = Device(kind: .simulator, udid: "SIM", name: "iPhone", transports: [.local], sources: [.simctl]) + let results = await CapabilityProbe(runner: TimingOutRunner()).run(for: simulator) + let xcode = results.first { $0.id == "xcode-tools" } + #expect(xcode?.state == .attention) + #expect(xcode?.summary.contains("did not answer in time") == true) + #expect(results.first { $0.id == "simulator-running" }?.summary == "Xcode's tools did not answer in time.") + } + + @Test(.enabled(if: xcodeAvailable)) + func referenceDiscoversDevicectlSubcommands() async throws { + let root = ToolReference.roots[0] + let text = try await ToolReference.helpText(root, runner: ProcessCommandRunner()) + let children = ToolReference.children(of: root, helpText: text).map { $0.path.last ?? "" } + #expect(children.contains("device")) + #expect(children.contains("list")) + #expect(children.contains("manage")) + let device = try #require(ToolReference.children(of: root, helpText: text).first { $0.path == ["device"] }) + let deviceChildren = ToolReference.children(of: device, helpText: try await ToolReference.helpText(device, runner: ProcessCommandRunner())) + #expect(deviceChildren.contains { $0.path == ["device", "info"] }) + #expect(deviceChildren.contains { $0.path == ["device", "process"] }) + } +} + +@Suite("Profiles, support bundle, and compatibility") +struct SharedFeatureTests { + @Test func workspaceProfileRoundTripAndValidation() throws { + var profile = WorkspaceProfile(name: "QA lab", description: "Defaults for release testing", defaultWorkspace: .location, actionCategory: "Device Actions") + profile.location.routeSpeedKmh = 40 + let decoded = try WorkspaceProfile.decode(try profile.encoded()) + #expect(decoded == profile) + #expect(decoded.preview.contains("Opens to: Location Lab")) + + var leaky = profile + leaky.description = "Copied from /Users/alice/Desktop" + #expect(throws: ToolkitError.self) { try leaky.validated() } + leaky.description = "UDID 00008110-001234560ABC801E" + #expect(throws: ToolkitError.self) { try leaky.validated() } + var bad = profile + bad.location.routeTraversals = 99 + #expect(throws: ToolkitError.self) { try bad.validated() } + bad = profile + bad.actionCategory = "Unknown" + #expect(throws: ToolkitError.self) { try bad.validated() } + #expect(throws: ToolkitError.self) { try WorkspaceProfile.decode(Data("{\"name\":1}".utf8)) } + #expect(throws: ToolkitError.self) { try WorkspaceProfile.decode(Data(repeating: 0x20, count: 70_000)) } + } + + @Test func workspaceProfileCarriesActionAndDeveloperImageChoices() throws { + let profile = WorkspaceProfile(name: "Capture lab", actionCategory: "Capture & Instruments", selectedAction: "bluetooth-capture", developerImageMechanism: .coreDevice) + let decoded = try WorkspaceProfile.importing(try profile.encoded()) + #expect(decoded.profile == profile) + #expect(decoded.legacyVersion == nil && decoded.notes.isEmpty) + #expect(profile.preview.contains("Selected action: Bluetooth capture")) + #expect(profile.preview.contains("Developer image: mount with Xcode device service (devicectl)")) + var bad = profile + bad.selectedAction = "no-such-action" + #expect(throws: ToolkitError.self) { try bad.validated() } + bad.selectedAction = "battery" // Device Basics, not Capture & Instruments + #expect(throws: ToolkitError.self) { try bad.validated() } + // Profiles exported before these fields existed still import. + let older = try JSONSerialization.jsonObject(with: try WorkspaceProfile(name: "Older").encoded()) as? [String: Any] + var trimmed = try #require(older) + trimmed["selectedAction"] = nil + trimmed["developerImageMechanism"] = nil + #expect(try WorkspaceProfile.importing(try JSONSerialization.data(withJSONObject: trimmed)).profile.selectedAction == nil) + } + + /// Written by 0.3.4's own `render_workspace_profile_json` (ios_developer_toolkit/workspace_profile.py). + static let legacyProfile = """ + { + "created_with_version": "0.3.4", + "default_workspace": "Command Center", + "description": "Shared settings for the device lab", + "name": "Lab defaults", + "privacy": { + "schema_excludes": ["device identity and targets", "credentials and authorization acknowledgements", "local paths and coordinates", "command parameters", "case text and capture output"], + "user_supplied_text_fields": ["name", "description"], + "warning": "Review the user-supplied name and description before sharing." + }, + "schema_version": 1, + "settings": { + "app_workflow": {"calculate_app_sizes": false, "install_as_developer_package": true}, + "backup_workflow": {"force_full_backup": true, "require_encryption": true}, + "command": {"category": "Logging & Capture", "preset": "btlogger"}, + "ddi_source": "local-xcode", + "evidence_workflow": {"capture_duration_seconds": 120, "include_crash_pull": true, "include_oslog": true, "include_pcap": false, "include_screenshot": false, "include_syslog": true}, + "location_workflow": {"ignore_timing_delays": true, "route_interval_seconds": 3, "route_speed_kmh": 35, "route_speed_preset_kmh": 20, "route_traversals": 4, "timing_randomness_ms": 250} + } + } + """ + + @Test func importsPythonWorkspaceProfiles() throws { + let imported = try WorkspaceProfile.importing(Data(Self.legacyProfile.utf8)) + let profile = imported.profile + #expect(imported.legacyVersion == "0.3.4") + #expect(profile.schemaVersion == WorkspaceProfile.currentSchemaVersion) + #expect(profile.name == "Lab defaults" && profile.description == "Shared settings for the device lab") + #expect(profile.defaultWorkspace == .actions) + #expect(profile.actionCategory == "Capture & Instruments") + #expect(profile.selectedAction == "bluetooth-capture") + #expect(profile.developerImageMechanism == .native) + #expect(profile.apps == .init(calculateSizes: false, includeSystemApps: false, installAsDeveloperPackage: true)) + #expect(profile.backup == .init(forceFullBackup: true, requireEncryption: true)) + #expect(profile.evidence == CollectionOptions(durationSeconds: 120, includeClassicSyslog: true, includeUnifiedLogs: true, includePacketCapture: false, includeScreenshot: false, includeCrashReports: true)) + #expect(profile.location == .init(timingJitterMilliseconds: 250, ignoreRecordedTiming: true, routeSpeedKmh: 35, routeIntervalSeconds: 3, routeTraversals: 4)) + #expect(imported.notes.contains { $0.contains("“btlogger” → action “Bluetooth capture”") }) + #expect(imported.notes.contains { $0.contains("nothing is downloaded") }) + #expect(imported.notes.contains { $0.contains("DVT OSLog → Unified Logging") }) + // Once imported it is an ordinary profile. + #expect(try WorkspaceProfile.decode(try profile.encoded()) == profile) + + func variant(_ edit: (inout [String: Any]) -> Void) throws -> WorkspaceProfile.Import { + var object = try #require(try JSONSerialization.jsonObject(with: Data(Self.legacyProfile.utf8)) as? [String: Any]) + edit(&object) + return try WorkspaceProfile.importing(try JSONSerialization.data(withJSONObject: object)) + } + func setting(_ path: [String], _ value: Any) -> (inout [String: Any]) -> Void { + { object in + var settings = object["settings"] as! [String: Any] + if path.count == 1 { settings[path[0]] = value } else { + var inner = settings[path[0]] as! [String: Any] + inner[path[1]] = value + settings[path[0]] = inner + } + object["settings"] = settings + } + } + // A preset that moved to a workspace, with “All categories”. + let moved = try variant { object in + setting(["command", "category"], "All categories")(&object) + setting(["command", "preset"], "syslog")(&object) + } + #expect(moved.profile.actionCategory == "All" && moved.profile.selectedAction == nil) + #expect(moved.notes.contains { $0.contains("Live Logs page") }) + #expect(try variant { $0["default_workspace"] = "Man Pages" }.profile.defaultWorkspace == .help) + + // Checked as strictly as 0.3.x checked it. + #expect(throws: ToolkitError.self) { try variant { $0["default_workspace"] = "Nowhere" } } + #expect(throws: ToolkitError.self) { try variant(setting(["command", "preset"], "rm-rf")) } + #expect(throws: ToolkitError.self) { try variant(setting(["ddi_source"], "download")) } + #expect(throws: ToolkitError.self) { try variant(setting(["evidence_workflow", "capture_duration_seconds"], 5)) } + #expect(throws: ToolkitError.self) { try variant(setting(["evidence_workflow", "include_pcap"], "yes")) } + #expect(throws: ToolkitError.self) { try variant(setting(["location_workflow", "route_speed_preset_kmh"], 7)) } + #expect(throws: ToolkitError.self) { try variant { $0["description"] = "From /Users/alice/Desktop" } } + #expect(throws: ToolkitError.self) { try variant { $0["schema_version"] = 3 } } + #expect(throws: ToolkitError.self) { try variant { $0["settings"] = nil } } + } + + @Test func everyPythonPresetHasATranslation() throws { + // The 49 Command Center presets of 0.3.4 (command_catalog.py). + #expect(LegacyWorkspaceProfile.presets.count == 49) + for case let (preset, action?) in LegacyWorkspaceProfile.presets { + #expect(ActionCatalog.descriptor(action) != nil, "\(preset) → \(action)") + } + for (preset, action) in LegacyWorkspaceProfile.presets where action == nil && LegacyWorkspaceProfile.movedPresets[preset] == nil { + #expect(["dvt-list", "notifications", "remote-browse"].contains(preset), "\(preset) has neither an action nor a destination") + } + } + + @Test func supportBundleIsSanitizedAndHashed() throws { + let context = SupportBundleContext( + workspace: "Device", + detectedDeviceCount: 2, + selectedDeviceKind: "physical", + discoveryStatus: ["usbmux": "1 device", "coreDevice": "Alice's iPhone at 192.168.1.4"], + capabilityCounts: ["ready": 5, "attention": 1], + toolchainReport: "Missing: devicectl device x — /Users/alice/Library", + developerTools: ["xcode": "Xcode 27.0"], + statuses: ["device": "Connected to Alice's iPhone 00008110-001234560ABC801E"], + redactions: ["Alice's iPhone"], + diagnosticLog: [DiagnosticLogEntry(date: Date(), category: "Commands", level: "Info", message: "Started devicectl for alice@example.com")] + ) + let entries = try SupportBundle.entries(for: context) + let combined = entries.map { String(decoding: $0.1, as: UTF8.self) }.joined(separator: "\n") + for secret in ["Alice's iPhone", "192.168.1.4", "00008110-001234560ABC801E", "/Users/alice", "alice@example.com"] { + #expect(!combined.contains(secret), "\(secret) leaked") + } + #expect(entries.map(\.0) == ["README.txt", "environment.json", "context.json", "toolchain-check.txt", "diagnostic-log.txt", "SHA256SUMS.json"]) + let hashes = try JSONValue.parse(entries.last!.1) + #expect(hashes["entries"]?["context.json"]?.string == SecureFileIO.sha256(of: entries[2].1)) + + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "bundle") + defer { try? FileManager.default.removeItem(at: directory) } + let zip = directory.appendingPathComponent("support.zip") + try SupportBundle.write(to: zip, context: context) + let archive = try ZipArchive(url: zip) + #expect(archive.entries.count == 6) + #expect(throws: ToolkitError.self) { try SupportBundle.write(to: zip, context: context) } + #expect(throws: ToolkitError.self) { try SupportBundle.write(to: directory.appendingPathComponent("x.txt"), context: context) } + } + + @Test func compatibilityHistoryIsFingerprintedAndSanitized() throws { + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "compat") + defer { try? FileManager.default.removeItem(at: directory) } + let store = CompatibilityStore(url: directory.appendingPathComponent("observations.jsonl")) + let device = Device(kind: .physical, udid: "00008110-001234560ABC801E", name: "Alice's iPhone", productType: "iPhone16,1", osVersion: "26.0", buildVersion: "23A341", transports: [.usb]) + let results = [CapabilityRow.pairingTrust.result(.ready, "ok"), CapabilityRow.developerMode.result(.attention, "Off")] + try store.append(CompatibilityObservation(device: device, results: results, observedAt: Date(timeIntervalSince1970: 1_000))) + try store.append(CompatibilityObservation(device: device, results: [CapabilityRow.developerMode.result(.ready, "On")], observedAt: Date(timeIntervalSince1970: 2_000))) + try SecureFileIO.append(Data("not json\n".utf8), to: store.url) + let loaded = store.load() + #expect(loaded.count == 2) + let latest = CompatibilityStore.latest(loaded) + #expect(latest.count == 1) + #expect(latest[0].states["developer-mode"] == .ready) + let raw = try String(contentsOf: store.url, encoding: .utf8) + #expect(!raw.contains("00008110") && !raw.contains("Alice")) + let json = String(decoding: try CompatibilityStore.renderJSON(loaded), as: UTF8.self) + #expect(!json.contains(latest[0].fingerprint)) + #expect(json.contains("iPhone 15 Pro")) + let markdown = CompatibilityStore.renderMarkdown(loaded) + #expect(markdown.contains("| iPhone 15 Pro | 26.0 | 23A341 | USB |")) + } + + @Test func workspacesAndDemoMode() { + #expect(Set(Workspace.allCases.map(\.id)).count == Workspace.allCases.count) + #expect(Workspace.allCases.allSatisfy { !$0.title.isEmpty && !$0.symbolName.isEmpty }) + #expect(!Workspace.evidence.supports(.simulator)) + #expect(Workspace.location.supports(.simulator)) + let demo = DemoMode.device + #expect(demo.kind == .demo) + #expect(demo.name.contains("simulated")) + #expect(LogStreamKind.available(for: demo.kind).isEmpty) + } +} + +@Suite("Guided reconnect") +struct ReconnectGuideTests { + func device(_ name: String, kind: DeviceKind = .physical, transports: Set = [.usb], pairing: PairingState) -> Device { + Device(kind: kind, udid: name, name: name, transports: transports, pairingState: pairing) + } + + @Test func interpretsWhatDiscoverySees() { + #expect(ReconnectGuide.evaluate(devices: [], timeElapsed: false) == .waiting) + #expect(ReconnectGuide.evaluate(devices: [], timeElapsed: true) == .timedOut) + // Only USB-connected physical devices count; simulators and network-only devices do not. + #expect(ReconnectGuide.evaluate(devices: [device("Sim", kind: .simulator, transports: [.local], pairing: .notApplicable), device("Net", transports: [.network], pairing: .paired)], timeElapsed: true) == .timedOut) + #expect(ReconnectGuide.evaluate(devices: [device("Phone", pairing: .paired)], timeElapsed: false) == .connected(name: "Phone")) + #expect(ReconnectGuide.evaluate(devices: [device("Phone", pairing: .unpaired)], timeElapsed: false) == .awaitingTrust(name: "Phone")) + // Pairing not known yet: keep waiting until the window ends. + #expect(ReconnectGuide.evaluate(devices: [device("Phone", pairing: .unknown)], timeElapsed: false) == .waiting) + #expect(ReconnectGuide.evaluate(devices: [device("Phone", pairing: .unknown)], timeElapsed: true) == .awaitingTrust(name: "Phone")) + // A trusted device wins over an untrusted one. + #expect(ReconnectGuide.evaluate(devices: [device("A", pairing: .unpaired), device("B", pairing: .paired)], timeElapsed: false) == .connected(name: "B")) + #expect(ReconnectGuide.Outcome.timedOut.nextStep?.contains("restart the Mac") == true) + #expect(ReconnectGuide.boundary.contains("never uses sudo")) + } +} + +@Suite("Keyboard navigation") +struct KeyboardNavigationTests { + @Test func workspacesCycleInSidebarOrder() { + #expect(Workspace.overview.next == .device) + #expect(Workspace.device.previous == .overview) + #expect(Workspace.allCases.last?.next == Workspace.allCases.first) + #expect(Workspace.overview.previous == Workspace.allCases.last) + for workspace in Workspace.allCases { + #expect(workspace.next.previous == workspace) + } + } + + @Test func referenceListsTheMenuShortcuts() { + let entries = KeyboardShortcutReference.sections.flatMap(\.entries) + #expect(Set(entries.map(\.id)).count == entries.count) + for (index, workspace) in Workspace.numbered.enumerated() { + #expect(entries.contains { $0.keys == "⌘\(index + 1)" && $0.title == workspace.title }) + } + #expect(Workspace.numbered.count == 9) + for keys in ["⌘K", "⌥⌘←", "⌥⌘→", "⌘R", "⇧⌘R", "⌘/"] { + #expect(entries.contains { $0.keys == keys }, "\(keys)") + } + } +} + +@Suite("Readiness shortcuts and Advanced Mode") +struct ReadinessShortcutTests { + @Test func evidenceCollectionsHaveTheirOwnPrerequisites() { + let device = Device(kind: .physical, udid: "u", name: "Phone", transports: [.usb], sources: [.usbmux]) + var options = CollectionOptions() + #expect(options.requirements == [.trustedDevice]) + options.includeScreenshot = true + #expect(options.requirements == [.trustedDevice, .coreDevice]) + let trusted = CapabilityRow.pairingTrust.result(.ready, "Trusted") + let noXcode = CapabilityRow.coreDevice.result(.blocked, "Needs Xcode.") + #expect(ActionReadiness.evaluate(requirements: CollectionOptions().requirements, results: [trusted, noXcode], device: device) == .ready) + #expect(ActionReadiness.evaluate(requirements: options.requirements, results: [trusted, noXcode], device: device) == .needsAttention(["Xcode device service (CoreDevice): Needs Xcode."])) + #expect(ActionReadiness.evaluate(requirements: options.requirements, results: [], device: device) == .notTested) + } + + @Test func toolReferenceTopicsFillInAdvancedMode() throws { + #expect(ToolReference.advancedModeCommand(for: .init(tool: .devicectl, path: ["device", "info", "apps"])) == "device info apps") + #expect(ToolReference.advancedModeCommand(for: .init(tool: .devicectl, path: [])) == nil) + #expect(ToolReference.advancedModeCommand(for: .init(tool: .simctl, path: ["list"])) == nil) + // What is filled in is still checked by Advanced Mode's own policy before anything runs. + let target = DeviceTarget(kind: .physical, udid: "00008150-000B33334444002E", name: "Phone", osVersion: "26.0", usbmuxDeviceID: 1, coreDeviceIdentifier: nil, transport: .usb) + let prepared = try ActionExecutor.prepareAdvanced(try #require(ToolReference.advancedModeCommand(for: .init(tool: .devicectl, path: ["device", "info", "apps"]))), target: target) + #expect(prepared.risk == .readOnly) + } +} diff --git a/docs/PHYSICAL_DEVICE_TEST_PROTOCOL.md b/docs/PHYSICAL_DEVICE_TEST_PROTOCOL.md index f59706a..727d978 100644 --- a/docs/PHYSICAL_DEVICE_TEST_PROTOCOL.md +++ b/docs/PHYSICAL_DEVICE_TEST_PROTOCOL.md @@ -1,70 +1,91 @@ # Physical-device test protocol -Use this protocol only with an iPhone or iPad that you own or are authorized to test. Keep raw UDIDs, device names, logs, captures, backups, screenshots, coordinates, and case evidence out of issues, pull requests, and compatibility reports. - -## Required test context - -Record these non-secret facts locally before testing: - -* toolkit commit and application version; -* source or packaged build and native architecture; -* macOS, Xcode, Python, and `pymobiledevice3` versions; -* iPhone or iPad model, iOS version, build, and USB or network connection; -* whether Developer Mode, a DDI, and an RSD tunnel are expected for the tested workflow. - -Do not record the raw UDID in a shared report. The application's Real-Device Compatibility view stores a one-way device fingerprint for local comparisons. - -## Stage 1 — connection readiness - -1. Connect the unlocked device directly with a known data-capable cable. Avoid hubs for the first test. -2. Accept **Allow accessory to connect** on macOS when shown. -3. Tap **Trust** on the device and enter its passcode when shown. -4. Confirm that Finder or Xcode lists the device. -5. Run `pymobiledevice3 usbmux list` in the project environment. Expect one JSON device record. -6. Run `xcrun devicectl list devices`. Expect the same device to be `available (paired)`. -7. Open the toolkit. Expect the physical device in the picker, an **Authorized device connected** banner, and a **devices-available** Connection diagnostic. -8. Disconnect and reconnect once. Expect the picker and banner to recover without restarting `usbmuxd`, deleting pairing records, or requiring `sudo`. - -Failure boundaries: - -* absent from the macOS USB tree: cable, port, lock state, or accessory-authorization problem; -* present in USB but absent from usbmux: pairing or Apple Mobile Device service problem; -* present in usbmux but absent from CoreDevice: Xcode/CoreDevice state problem; -* present in both CLIs but absent from the toolkit: application discovery regression; create a sanitized support bundle. - -## Stage 2 — read-only application checks - -1. Run the full **Capability Matrix** and save a local compatibility observation. -2. Verify that each row distinguishes ready, needs attention, unavailable, blocked, not tested, and not applicable. -3. Run finite read-only Command Center presets for device information, battery, date, mounted images, and installed applications. -4. Open Unified Log, syslog, and DVT OSLog separately. Confirm that each stream starts, receives data, pauses, filters, stops, and offers an explicit raw-save decision. -5. Run app inventory and local IPA inspection without installing or uninstalling an app. -6. Create a sanitized support bundle. Inspect the ZIP and confirm that it contains no raw device identity, command output, capture, log, credential, or user-entered value. - -Expected result: every command either completes with bounded output or remains visibly identified as a stream with an enabled Stop control. No read-only check changes device state. - -## Stage 3 — developer-service checks - -Perform this stage only when Developer Mode is intentionally enabled. - -1. Enable Developer Mode through iOS Settings and complete the required restart. -2. Mount the appropriate personalized DDI or use the Xcode candidate DDI when the selected workflow explicitly calls for it. -3. Re-run only the Developer Mode, DDI, RSD, DVT, and CoreDevice capability rows. -4. Verify DVT directory listing, application listing, and one bounded developer-service snapshot. -5. Start and stop DVT network activity. Confirm that the UI treats it as a stream rather than a finite snapshot. - -Expected result: readiness changes are attributed to the correct layer. A DDI success does not imply that an RSD tunnel or every DVT service is available. - -## Stage 4 — opt-in state-changing checks - -These checks are not required for merge readiness. Run only when their device effect is acceptable and the displayed target is correct. - -* **Location Lab:** set a harmless test coordinate, verify the visible simulated state, then use Clear and confirm that no simulation process remains. -* **IPA sideload:** inspect an eligible development-signed IPA first, install it with the device-bound acknowledgement, verify inventory, then uninstall only if planned. -* **Encrypted backup:** use protected local storage, verify the existing encryption state, understand that enabling backup encryption persists on the device, and confirm the resulting backup independently. -* **PCAP/RVI:** capture a short authorized trace, stop cleanly, open the file in an independent packet analyzer, and document encrypted-payload limitations. -* **Evidence case:** create a disposable case, collect one bounded artifact, finalize it, and independently verify its SHA-256 manifest. - -## Completion record - -Mark each stage as **passed**, **failed**, **not applicable**, or **not tested**. For failures, record the exact layer, command or button, exit status, sanitized error, and whether the failure reproduces in both the source and packaged app. Never convert **not tested** into a compatibility claim. +The native lockdown services in version 1.0 are verified against a protocol-accurate simulated +device and macOS's real device service. Their read-only protocol layer has been checked on one +iPhone (see MIGRATION.md §5.4); the steps below have not yet been run end to end. This protocol is +how to verify them. Use it only with an iPhone or iPad you own or are authorized to test. + +Before the GUI steps, the read-only protocol checks can be run from a source checkout with the +device connected by USB and trusted (nothing on the device is changed): + +```bash +IDT_DEVICE_TESTS=1 swift test --filter RealDeviceTests +``` + +The lines prefixed `[device]` summarize what the device reported, without identifiers. + +Keep UDIDs, device names, logs, captures, backups, screenshots, coordinates, and case evidence out +of issues and pull requests. Report results as **passed**, **failed**, **not applicable**, or +**not tested** per step — never turn “not tested” into a compatibility claim. + +## Record first (locally) + +- App version (**iOS Developer Toolkit › About**) and whether it is a release or a source build. +- Mac model and architecture, macOS version, Xcode version (or “no Xcode”). +- Device model, iOS version and build, USB or network connection. +- Developer Mode on or off. + +## Stage 1 — discovery and trust (no Xcode needed) + +1. Connect the unlocked device directly with a data cable; approve **Allow accessory** on the Mac. +2. Tap **Trust** and enter the passcode on the device. +3. Expect the device under **Physical Devices** within a few seconds, without pressing Refresh. +4. Disconnect it. Expect it to disappear (or show as network-only if Wi-Fi sync is on) without a + restart. Reconnect and expect it back. +5. Connect a second device. Expect both listed separately; switching the selection must never + redirect an operation that is already running. +6. Before trusting a new device, expect the Device page to say it is not trusted, with steps. + +## Stage 2 — read-only checks + +1. **Readiness Check**: every row shows ready, attention, unavailable, or not applicable, with a + next step. +2. **Device** page: name, model, iOS version, build, Developer Mode status, and connection match + *Settings › General › About*. +3. **Live Logs**: start **Unified** and **Classic syslog** separately. Each receives lines, pauses, + filters (literal and regex), stops, and exports raw and filtered logs. Mark a finding and export + an evidence bundle; verify it with `shasum -a 256 -c SHA256SUMS.txt`. +4. **Apps**: the list includes sizes. **Actions**: battery, diagnostics, IORegistry, + provisioning profiles, crash report list, Media folder listing, mounted images. +5. **Actions › Packet capture**: capture 30 seconds and open the `.pcap` in Wireshark or with + `tcpdump -r`. +6. **Actions › Safari and web view tabs** with Web Inspector on and a page open in Safari: the + page title and address appear. With Web Inspector off: “Safari Web Inspector did not answer”. +7. **Actions › Bluetooth capture** (with Apple's Bluetooth logging profile installed) for 30 seconds + while using a Bluetooth accessory; open the `.pklg` in PacketLogger or Wireshark. +8. **Create Support Bundle…**: unzip it and confirm it has no names, identifiers, paths, or + captured content. + +## Stage 3 — with Xcode and Developer Mode + +1. **Developer image** card: note the state and details (iOS, build, model, chip/board). Expect + *Personalization required* or *Available* on iOS 17+, *Available* or *Missing* on iOS 16 and earlier. +2. **Mount Developer Image** with *Mount with: Built-in*. Expect *Mounted*. Record whether Apple + personalization was needed. Then **Mount Developer Image** again: nothing should be uploaded. +3. **Unmount**, then mount again with *Xcode device service*. Expect *Mounted*. +4. Lock the device and mount: expect “The device is locked.” With Developer Mode off: expect + *Needs attention* and no upload. +5. iOS 16 or earlier: add a folder with the matching `DeveloperDiskImage.dmg` and `.signature`, + mount, and confirm *Mounted* at `/Developer`. +6. Screenshot, running processes, lock state, launch an app, open a URL. +7. An Instruments recording of 10 seconds; open the `.trace` in Instruments. +8. On iOS 16 or earlier (after mounting the developer image): set and clear a location through + the legacy service. + +## Stage 4 — changes (opt in, one at a time) + +- **Location Lab**: set a harmless coordinate, confirm it in Maps, then **Clear**. Quit the app with + a location set and confirm it is cleared. +- **Install App**: inspect a development-signed `.ipa`, install it (`RUN` confirmation), confirm it + launches, remove it (`IRREVERSIBLE` confirmation). +- **Backup**: an encrypted backup to an empty folder, then an incremental one to the same folder. + Confirm it with Finder's backup list or a separate tool. Enabling encryption changes the device + setting permanently until turned off with the same password. +- **Evidence Capture**: a 60-second collection with Unified Logs and packet capture; verify the + manifest and `SHA256SUMS.txt`. + +## Report + +For each failure, record the step, the exact message, the technical details from **Help › +Diagnostic Log**, and whether it reproduces after reconnecting. Open an issue with the sanitized +details, or add a row to the compatibility table in [MIGRATION.md](../MIGRATION.md#5-test-results). diff --git a/docs/PRODUCT_AUDIT_2026-09-21.md b/docs/PRODUCT_AUDIT_2026-09-21.md deleted file mode 100644 index d4f7c9f..0000000 --- a/docs/PRODUCT_AUDIT_2026-09-21.md +++ /dev/null @@ -1,185 +0,0 @@ -# Product audit — 2026-09-21 - -## Executive assessment - -iOS Developer Toolkit has a stronger foundation than its small version number suggests. It is a macOS PySide6 desktop application that turns a deliberately curated subset of `pymobiledevice3`, Xcode/CoreDevice, Developer Disk Image, RVI, backup, and evidence-preservation workflows into guided operations. Its differentiators are its explicit authorization boundaries, local-first evidence handling, typed acknowledgement for device-changing work, capability matrix, device compatibility observations, investigation-oriented live-log windows, and release artifacts with SBOMs and provenance. - -Its primary product risk was reliability at the first screen. At audit start, the application imported the MobileBackup2 transport implementation while constructing the desktop UI, so a slow or damaged third-party transport import could prevent the interface from becoming available even though backup was not being used. Separately, `DeviceScanner` only consumed `QProcess` output from readiness signals and did not consume bytes still available when the child exited. That created a confirmed race: a packaged build could successfully run `pymobiledevice3 usbmux list` but parse an empty discovery buffer. The P0 implementation delivered with this audit moves transport imports into the backup worker and drains completion output before parsing; it also adds a deterministic fast-exit regression test. - -The correct next investment is therefore a **reliable startup and device-discovery foundation**, not another device command. It makes the existing workbench usable for beginners, gives experts dependable process semantics, and establishes the abstraction needed before further QProcess-heavy workflows are added. - -## What exists today - -The product has thirteen workspaces: Home, Device & DDI, Capability Matrix, Location Lab, Live Logs, Command Center, Installed Apps, Backup, Sideload IPA, Evidence Capture, Ecosystem Tools, Man Pages, and Scope & Safety. It currently provides 49 declarative guided command presets, a live-help/command-drift check, DDI mounting, RSD/CoreDevice/DVT checks, GPX location simulation with cleanup, separate Unified/syslog/oslog windows, installed app inventory, encrypted MobileBackup2 workflow, isolated UFADE launch, guided external MVT analysis, provenance-checked go-ios/idb/ipsw adapters, IPA inspection and installation, RVI/PCAP and artifact collection, guided case intake, support bundles, compatibility history, and keyboard-first navigation. - -The repository is a Python 3.10+ PySide6 project with a bundled `pymobiledevice3` runtime model. `ios_developer_toolkit/app.py` is a 5,600+ line `MainWindow`, while domain modules cover capability probing, collectors, live logs, location testing, IPA inspection, support bundles, and device compatibility. CI runs unit tests, compile checks, CLI help checks, and a headless GUI smoke test on macOS. Tagged release CI produces Apple Silicon and Intel bundles, CycloneDX SBOMs, checksums, and GitHub attestations. The app is ad-hoc signed, not Developer ID signed or notarized. - -## Strengths worth protecting - -* The command catalog is declarative, reviewed, parameter-validated, and avoids feeding guided fields into a shell. -* The capability matrix makes the iOS developer stack legible: trust, Developer Mode, DDI, RSD, CoreDevice, DVT, lock state, and Web Inspector are distinguished rather than collapsed into “device failed.” -* The safety model appropriately classifies host writes, device changes, and high-impact operations, and binds acknowledgement phrases to the selected target. -* Live Logs is notably better than a terminal wrapper: it separates raw capture from rendered filtering, supports annotations as analyst claims rather than facts, preserves hashes, and explains capture boundaries. -* Evidence cases use restrictive local permissions, store a local authorization acknowledgement, and state their chain-of-custody limits plainly. -* The sanitized support bundle intentionally excludes identifiers, pairing material, raw captures, and user-entered values. -* The product already has a real-device compatibility observation format that fingerprints a device rather than retaining its raw UDID. -* Release engineering is unusually good for a young desktop project: dual architecture builds, SBOMs, third-party notices, checksums, and build provenance are present. - -## Weaknesses and user impact - -| Finding | User impact | Priority | -| --- | --- | --- | -| GUI startup imported `pymobiledevice3.lockdown` through `backup_worker` before Backup was opened. | A failure in one optional subsystem could block all workflows. Fixed in this audit by moving transport imports to the worker execution path. | Resolved P0 | -| `DeviceScanner` did not drain final `QProcess` stdout/stderr in its completion handler. | A connected device could be invisible in the packaged UI despite the bundled CLI returning valid JSON. Fixed with completion-time draining and a real fast-exit QProcess regression test. | Resolved P0 | -| The repository pinned `pymobiledevice3==10.11.0` while a clean Dependabot PR existed for 11.12.4 and upstream had newer releases. | The app missed modern iOS tunnel fixes and could present stale command assumptions. Fixed with a validated upgrade to 11.15.1: the full test suite, GUI smoke test, CLI discovery, and all 49 live-help routes passed. | Resolved P0 | -| `MainWindow` owns dozens of process/buffer/timer lifecycles. | Completion, cancellation, timeout, and output handling can diverge across workspaces; the scanner defect is evidence of that risk. | P1 | -| Release-only packaging was previously validated only after a tag was pushed. | Dual-native pull-request smoke now builds and inspects the entire frozen app before release. | Resolved P1 | -| Source `macos/Info.plist` previously exposed an older version than `pyproject.toml`. | Source, packaging, citation, and bundle metadata are synchronized and covered by tests. | Resolved P1 | -| Coverage remains weighted toward pure functions and host-only integration. | Fast-exit discovery, process lifecycle, real live help, the complete 49-route drift UI, and both frozen architectures are covered; device-service behavior remains deliberately opt-in through the physical protocol. | P1 | -| The first-run experience assumes familiarity with DDI, RSD, and CoreDevice. | Beginners receive good instructions, but not a single coherent “make my device ready” decision flow. | P1 | -| The README is extensive but is the dominant documentation surface. | It is difficult to keep operational recipes, scope boundaries, architecture, release verification, and contributor guidance discoverable. | P2 | - -## Beginner UX audit - -The first screen has strong visual hierarchy and a useful six-step map, but it asks a new user to understand multiple Apple service layers before confirming the one prerequisite that matters: “Can this Mac see and trust my device?” A first-run assistant should remain optional, but should reduce the path to: connect → unlock/trust → verify connection → enable Developer Mode if needed → choose whether a task needs a DDI → run a safe first action. - -The toolkit should keep its advanced vocabulary, but display it progressively. “RSD tunnel” is useful evidence for an expert; for a beginner it should be introduced as the iOS 17+ developer connection path, with the exact observed status and a one-click non-destructive recheck. The current reconnect guidance is careful not to restart SIP-protected/root-owned services, which is correct and should remain a hard boundary. - -## Expert UX audit - -Experts need less prose and better state correlation. The next UI layer should expose a compact operation record for every command: target, transport, exact argv, start/end time, exit status, timeout/cancel reason, output paths, hashes, and prerequisite states. Existing Live Logs and Evidence Capture show the right pattern, but it is not shared by Command Center, DDI, app, and backup operations. Experts also need a clear distinction between an upstream command being available in live help, a device service being advertised, and a particular operation having completed successfully. - -## Missing product categories - -The toolkit intentionally does not need to become an IDE, jailbreak suite, MDM, spyware scanner, signing service, or remote device farm. It can, however, become more useful in five bounded areas: - -1. A shared diagnostic/remediation engine that maps an operation to explicit prerequisites and reruns only the checks relevant to that operation. -2. A centralized operation lifecycle service for QProcess/subprocess work, with start, final-drain, cancellation, timeout, structured result, and copyable support record semantics. -3. A project-oriented developer workflow that can hand off to Xcode tools for test destinations, `.xcresult` inspection, and selected `devicectl` operations without pretending to replace Xcode. -4. A scoped ecosystem handoff layer: MVT for consented backup analysis, `ipsw` for firmware research, and configurable external tool adapters rather than bundled forks. -5. A device-lab/compatibility contribution path that can export redacted, opt-in capability observations and reproduce upstream `pymobiledevice3` bugs with a standard report. - -## Architecture and maintainability audit - -The project has good domain modules, immutable data classes, clear validation errors, and a runtime wrapper that makes frozen builds invoke internal workers safely. The central weakness is orchestration concentration. `MainWindow` manages process ownership, byte buffers, timers, error mapping, UI enablement, and output rendering for many unrelated workflows. That makes process behavior difficult to test and encourages near-duplicate cleanup logic. - -The target architecture is not a wholesale framework rewrite. Keep PySide6 and the current declarative catalog. Introduce small domain-level operation records and a reusable Qt process controller, then migrate one workflow at a time. UI builders should consume typed readiness and operation results rather than parse child-process bytes. The first change in this direction is to ensure GUI import paths do not import transport-specific worker dependencies and that discovery always consumes terminal output. - -## Reliability, testing, and release audit - -The existing CI/release pipeline is a substantial strength. Its gap is placement: tagged releases build the frozen app, but ordinary pull requests only test source. Add a scheduled or opt-in release-smoke workflow that builds one native frozen artifact and verifies the internal CLI, worker, GUI smoke path, bundle metadata, license inventory, and SBOM. Keep both full architecture builds for releases. - -The highest-value test additions are deterministic process-lifecycle tests: a process that writes valid discovery JSON and exits before readiness delivery; non-zero process errors with stderr only available at exit; cancellation while an operation is active; and clean relaunch without inheriting stale state. A physical-device matrix should remain opt-in, explicitly labeled, and never required to merge a change. - -## Security, privacy, and distribution audit - -The app’s local-first posture is credible: no analytics, no cloud account, and support bundles are reviewed for data minimization. Improve it by surfacing a privacy inventory in the UI, documenting retention paths by workflow, and requiring review before any future export/upload integration. Do not collect telemetry by default. - -Distribution remains the largest trust hurdle. A Developer ID certificate and notarization are unavailable without an Apple Developer Program membership, so the correct present posture is transparent ad-hoc signing, dual architecture artifacts, checksums, SBOMs, provenance, source reproducibility, and precise Gatekeeper instructions. Do not imply that ad-hoc signing makes the app generally trusted. When a signing identity becomes available, add notarized Developer ID releases and an automated post-notarization assessment step. - -## Ecosystem map and integration strategy - -| Project/tool | What it offers | Recommendation | -| --- | --- | --- | -| `pymobiledevice3` | Core cross-platform protocol library/CLI: discovery, tunnels, DDI/DVT, logs, PCAP, backups, apps, Web Inspector. | Primary dependency. Upgrade deliberately, keep live-help drift checks, and contribute minimal reproducible protocol or CLI fixes upstream. | -| Xcode `devicectl`, `simctl`, `xctrace`, `rvictl` | Apple-supported macOS device, simulator, trace, and RVI tooling. | Prefer for macOS-native actions; show exact preconditions and hand off rather than reimplementing Xcode. | -| `libimobiledevice` | Mature cross-platform device library/CLIs for backup, syslog, crash reports, screenshot, pairing, and image mounting. | Optional external adapter only. It overlaps with the current core and adds LGPL/GPL packaging complexity. | -| `go-ios` | Cross-platform static CLI/library, JSON output, app/UI test and accessibility tooling, optional REST API. | Learn from its JSON and device-lab design. Evaluate a user-configured adapter after a stable operation framework; do not bundle a second protocol stack now. | -| Facebook `idb` | Simulator/device automation via a macOS companion and remote client. | Do not embed. Offer documented interoperability for teams already using it; its private-framework and companion model is a separate product surface. | -| MVT | Consented mobile-forensics analysis of iOS backups and IOC checking with its own forensic scope/license. | Add a guided handoff/export later, not an embedded scanner. Do not make “clean” claims or weaken its warning model. | -| `blacktop/ipsw` | Firmware/OTA research, device database, kernel/dyld analysis. | Document as an external firmware-research companion. Do not turn this GUI into an IPSW reverse-engineering suite. | - -Upstream contribution candidates are concrete: report the fast-exit scanner packaging behavior as a Qt application lifecycle pattern if it reproduces outside this project; test the current `pymobiledevice3` upgrade against the toolkit command catalog; and offer redacted iOS/macOS compatibility findings to its issue tracker when a command/service regression is isolated. - -## Competitive positioning - -| Need | Toolkit position | Better companion | Product response | -| --- | --- | --- | --- | -| Developer readiness | Strong guided DDI/RSD/DVT visibility | Xcode Device Hub | Make connection and prerequisites dependable first. | -| Raw protocol coverage | Strong through `pymobiledevice3` | `pymobiledevice3`, `go-ios`, `libimobiledevice` | Do not duplicate every CLI command; curate and expose evidence. | -| Simulator/device automation at scale | Limited | `idb`, Xcode, Appium/WDA ecosystems | Add safe handoffs, not a competing farm. | -| Backup forensics | Bounded acquisition/evidence support | MVT | Build consented MVT handoff with limitations, not a compromise verdict. | -| Firmware research | Minimal | `ipsw` | Offer links/recipes and artifact provenance only. | -| Network capture | Strong macOS RVI workflow | `rvictl` + tcpdump/Wireshark | Continue to clarify encrypted-payload and whole-stack limits. | - -## Prioritized roadmap - -### P0 — make the existing product dependable - -* Remove eager transport imports from desktop startup; load backup transport only in the backup worker. -* Drain final QProcess output for device discovery and add a deterministic fast-exit test. -* Keep the pinned `pymobiledevice3` runtime current through isolated upgrade checks, full tests, GUI smoke testing, and command-catalog live-help validation. The audit implementation validates and pins 11.15.1. -* Add a connection diagnostic record that reports whether discovery failed to launch, returned malformed data, returned zero devices, or returned a selectable device. The audit implementation now provides this record in Device & DDI and the sanitized support bundle without raw discovery output or device identity. - -### P1 — turn diagnostics into a coherent workbench - -* Maintain typed subprocess lifecycles and `OperationResult` across the workbench. Device discovery, Man Pages, sequential command drift, DDI/developer-image actions, Installed Apps inventory/uninstall, and IPA inspection/install use the finite-operation controller. Command Center uses a typed interactive controller that retains explicit Stop controls without an arbitrary runtime limit. Backup preserves private stdin requests and validated progress events without an arbitrary completion timeout. Evidence Capture reassembles validated JSON-line events and reserves a graceful finalization window for partial artifacts, coverage, manifests, and hashes. Long-running live-log streams retain their purpose-built lifecycle and explicit Stop controls. -* Make a contextual readiness pane for the selected action, with one-click scoped rechecks and copyable remediation. -* Maintain the opt-in physical-device compatibility protocol and its explicit USB, usbmux, CoreDevice, developer-service, privacy, and state-changing test boundaries. A pre-release dual-architecture frozen-artifact smoke workflow is now present. The release builder rejects any bundled Mach-O whose minimum macOS version is newer than the advertised 13.0 floor or lacks the native release architecture. -* Generate concise changelog/release notes from tested behavior. Source, bundle, citation, packaging, and third-party-source metadata drift is now covered by automated tests. -* Maintain bounded Xcode project/device handoffs: selected-device `devicectl` details, RVI status, and native `.xcresult`/Instruments trace opening without parsing or reimplementing Apple's formats. - -### P2 — deepen expert workflows without scope creep - -* Maintain the session-local typed-operation journal, explicit structured JSON manifests, and universal Action Palette that exposes only eligible operations. -* Maintain the guided MVT backup-analysis handoff with explicit consent, no password persistence, output isolation, and no “clean device” conclusion. -* Maintain optional user-configured adapters for `go-ios`, `idb`, and `ipsw`, each with executable provenance, version/build display, bounded read-only probes, and an explicit independent-target boundary. -* Maintain the focused documentation site split into quick start, architecture, safety, troubleshooting, release verification, contributor, physical-device testing, and product-audit paths. - -### P3 — ecosystem growth and scale - -* Maintain the opt-in sanitized compatibility export with an exact local preview, owner-only JSON and Markdown files, host/toolchain context, tested device family and build metadata, and no automatic upload. -* Maintain local team/workspace profile import-export with a strict versioned schema, exact preview, owner-only export, active-operation guard, and no targets, paths, credentials, coordinates, parameters, case text, or output. -* **Blocked externally:** notarized Developer ID distribution requires an eligible Apple Developer signing identity, which is not available for this project. The existing release remains explicitly ad-hoc signed and unnotarized. -* **Deferred by design:** no device-lab service or account is in project scope. Add a provider-specific, optional adapter only after a concrete service, authentication model, data boundary, target-selection contract, and test environment are selected; do not add a speculative cloud abstraction. - -### Do not build - -* Jailbreak, passcode bypass, root filesystem acquisition, code-signing circumvention, or credential/profile theft features. -* A permanent or stealth location-changing service. Location testing must remain explicit, visibly tracked, and clearable. -* A general “run any destructive command” button or an automated recovery/restore/erase path. -* An embedded MVT-like compromise verdict or claims that lack of findings proves a device is safe. -* A cloud telemetry/sync system for device identifiers, logs, captures, backups, or case records. -* A second bundled iOS protocol stack merely for feature-count parity. - -## Audit implementation status - -The original single best next build—reliable startup and lossless device discovery—is complete. Backup protocol parsing is isolated from desktop startup, terminal discovery output is drained before evaluation, deterministic fast-exit tests exist, the connection diagnostic exposes failure layers without raw identity, and the project pins the validated `pymobiledevice3` 11.15.1 runtime. - -The repository-side P0, P1, P2, compatibility-export, and local workspace-profile work is implemented on the audit branch and recorded below. The remaining P3 items are intentionally not represented as unfinished local code: notarization is blocked by the absent signing identity, and device-lab integration is deferred until a specific optional provider and data contract exist. CI and native frozen-artifact checks remain the acceptance authority for each pushed revision. - -## Continuous improvement log - -| Date | Improvement | Verification | Follow-up boundary | -| --- | --- | --- | --- | -| 2026-09-21 | Moved backup transport imports out of desktop startup; fixed terminal output draining for usbmux discovery; added privacy-safe connection diagnostics. | 75 tests, headless GUI smoke, source launcher verification, and deterministic QProcess tests passed. | Real-device discovery remains separately opt-in and time-specific. | -| 2026-09-21 | Upgraded the pinned `pymobiledevice3` runtime to 11.15.1 and reconciled source, bundle, citation, packaging, and third-party source metadata. | CLI version reports 11.15.1; 94 tests and all 49 catalog live-help routes passed locally. | The next packaged artifact must be built by CI before distribution. | -| 2026-09-21 | Added a dual-architecture frozen-artifact smoke workflow, CI command-catalog verification, and a native Mach-O minimum-version gate. | A clean local build passed its full 77-test suite and produced a signed arm64 app; the host's Homebrew Python targets macOS 26, so the new 13.0 gate correctly stopped that incompatible local artifact before ZIP creation. | GitHub Actions runs with `MACOSX_DEPLOYMENT_TARGET=13.0`; its first Apple Silicon and Intel runs remain required before distribution. | -| 2026-09-21 | Added a reusable typed finite-process controller and migrated device discovery to it. | Real child-process tests cover terminal stdout/stderr, fast completion, launch failure, cancellation, timeout, and one-result semantics; the full suite now contains 81 tests. | Migrate other finite QProcess workflows incrementally; long-running streams retain their separate lifecycle. | -| 2026-09-21 | Made live-help drift checks accept successful help emitted on either standard output or standard error. | A clean GitHub runner exposed two false option mismatches while the same pinned CLI passed locally; the channel-specific regression test now preserves strict option matching without assuming a help stream. | Re-run CI on a clean runner and retain failure for genuinely absent routes or options. | -| 2026-09-21 | Added an opt-in physical-device protocol with staged read-only, developer-service, and state-changing checks. | The current host check found no Apple mobile USB device, no usbmux device, and no CoreDevice result, so no physical compatibility claim was made. | Run the protocol with an authorized connected device and retain identifiers and raw evidence locally. | -| 2026-09-21 | Added contextual readiness for every guided command and corrected support-bundle capability aggregation. | Command-specific tests cover untested, ready, not-applicable, and attention states; the GUI smoke verifies the new control by stable object ID. | Readiness remains a point-in-time local probe and never substitutes for an actual command result. | -| 2026-09-21 | Migrated Man Pages live help to the shared finite-operation controller and normalized styled CLI help for command-drift checks. | The GUI smoke now completes a real live-help request; controller relaunch tests reject stale output, and ANSI-split option tokens remain strictly verifiable. | Sequential command drift and other finite workflows remain incremental migrations. | -| 2026-09-21 | Corrected the macOS compatibility gate and bounded native-build timing. | The first clean dual-architecture run proved arm64 produced a macOS 11-compatible executable, which is compatible with the advertised macOS 13 floor; Intel exceeded the original 45-minute job limit. | Re-run both native builders with reusable Nuitka caches and a 90-minute cap before merging. | -| 2026-09-21 | Expanded compatibility validation from the launcher to every bundled Mach-O and pinned a genuinely compatible Qt line. | PySide6 6.11.2 wheel filenames advertise macOS 13, but direct `otool` inspection found Shiboken load commands requiring macOS 15; PySide6 6.9.3 Shiboken binaries declare macOS 12. | The dual-native CI build must pass the full-bundle architecture and deployment-floor scan before release. | -| 2026-09-21 | Migrated sequential command-drift probes to the shared finite-operation controller. | A clean Python 3.13 environment passed the 94-test suite and the GUI smoke now runs the entire 49-route drift check through the real asynchronous UI path. | DDI, backup, app, and capture operations remain incremental controller migrations. | -| 2026-09-22 | Migrated DDI and Developer Mode actions to the shared finite-operation controller. | The 95-test suite and 90-action GUI smoke passed; the smoke test now executes a real bounded host-only command through the migrated path. | Backup, app, and capture operations remain incremental controller migrations. | -| 2026-09-22 | Migrated Installed Apps inventory and uninstall operations to the shared finite-operation controller. | The 95-test suite and 90-action GUI smoke passed; the smoke test now renders a synthetic inventory through the migrated asynchronous result path. | Backup, IPA inspection/install, and capture operations remain incremental controller migrations. | -| 2026-09-22 | Migrated local IPA inspection and device installation to the shared finite-operation controller with explicit five- and 15-minute limits. | The 95-test suite and 90-action GUI smoke passed; the smoke test now validates typed inspection metadata, streamed installation output, and structured completion. | Backup and evidence capture retain specialized worker lifecycles pending deliberate migration. | -| 2026-09-22 | Replaced the Backup workspace's blocking, hand-buffered process path with a password-safe typed controller. | 98 tests and the 90-action GUI smoke passed; real child-process tests cover stdin-only credentials, validated streamed events, malformed-protocol termination, and one-result cancellation. | Evidence capture still needs a lifecycle designed around partial-artifact finalization rather than a generic finite command. | -| 2026-09-22 | Added a typed Evidence Capture controller and close-safe graceful finalization. | 102 tests and the 90-action GUI smoke passed; real child-process tests cover fragmented JSON events, final-drain parsing, cancellation through `case-finished`, malformed-protocol finalization, and forced stop after the finalization deadline. | Physical-device collection remains opt-in; review each case manifest and hash inventory before relying on it. | -| 2026-09-22 | Added native Apple developer-tool handoffs for selected-device CoreDevice details, RVI status, Xcode projects, test results, and Instruments traces. | 106 tests and a 94-action GUI smoke passed; tests validate the exact selected-device and RVI commands and reject missing or unrelated local targets. | The toolkit displays native output and opens native formats; it does not claim a stable schema for human `devicectl` output or reimplement Xcode. | -| 2026-09-22 | Migrated Command Center guided, advanced, finite, and streaming commands to a typed interactive-process lifecycle. | 109 tests and the 94-action GUI smoke passed; real child-process tests cover final stdout/stderr draining, launch failure, idempotent cancellation, and no arbitrary runtime limit. | Command output remains session-local unless the user explicitly preserves it through a task-specific evidence workflow. | -| 2026-09-22 | Added a session-local operation journal and explicit per-operation JSON manifests across the primary typed workflows. | 113 tests and a 95-action GUI smoke passed; tests cover immutable bounded history, exact argument retention, output hashing without raw-output embedding, owner-only export, and overwrite refusal. | Capability Matrix, Location Lab, Live Logs, and Command Drift retain their stronger workflow-specific records rather than duplicating raw or high-volume events into this journal. | -| 2026-09-22 | Added a keyboard-first Action Palette computed from current device and process eligibility. | 116 tests and a 96-action GUI smoke passed; smoke coverage verifies disconnected-state preset filtering, host-preset access, search behavior, stable control identity, and the `⌘ K` shortcut. | Guided presets are selected for review rather than executed, and eligibility is checked again at activation. | -| 2026-09-22 | Added a guided external MVT handoff for consented decrypted-backup analysis. | 121 tests and a 110-action GUI smoke passed; tests cover executable provenance, secret-environment removal, backup structure/encryption checks, isolated output, explicit IOC arguments, offline defaults, version validation, and an end-to-end synthetic analysis process. | MVT stays separately installed; the toolkit accepts no password and never translates completion or absent findings into a clean-device verdict. | -| 2026-09-22 | Added separately installed go-ios, idb Companion, and ipsw adapters with provenance validation. | 126 tests and a 134-action GUI smoke passed; tests cover catalog identity, discovery, executable hashing, changed-binary rejection, upstream version/build formats, secret and target-routing removal, and a synthetic validate/probe lifecycle. | These tools keep their own discovery, pairing, tunnel, target, network, licensing, and support models; only bounded inventory probes are exposed. | -| 2026-09-22 | Added a focused Material for MkDocs documentation site and pull-request/push workflow. | `mkdocs build --strict --clean` passes locally; the site routes beginners, developers, investigators, release verifiers, and contributors to canonical repository material without copying the complete README. | GitHub Pages publication occurs only after a documentation change reaches `main`; the site-build dependency is not part of the application bundle. | -| 2026-09-22 | Added previewed, sanitized JSON and Markdown export for real-device compatibility observations. | The 129-test suite and 136-action GUI smoke passed; focused tests cover removal of device identity and stored fingerprints, common path/email redaction, owner-only files, overwrite refusal, empty-history rejection, and both report formats. | Exports remain manual and local; model/build/connection metadata can still identify a small fleet, so the exact payload is previewed before saving and never uploaded. | -| 2026-09-22 | Added local team/workspace profile import and export for reviewed non-sensitive control defaults. | The 133-test suite and 138-action GUI smoke passed; tests cover strict parsing, known workspaces and presets, bounds, forward-compatible extra fields, owner-only files, overwrite refusal, size limits, round trips, and synthetic GUI application. | Profiles never contain targets, paths, credentials, coordinates, command parameters, case text, or output; import changes controls only and is blocked while operations are active. | - -## Research sources - -* Apple: [Developer Mode guidance](https://developer.apple.com/documentation/xcode/enabling-developer-mode-on-a-device), [Xcode command-line tools](https://developer.apple.com/documentation/xcode/xcode-command-line-tool-reference), and [RVI packet capture](https://developer.apple.com/documentation/network/recording-a-packet-trace). -* `pymobiledevice3`: [repository and documentation](https://github.com/doronz88/pymobiledevice3), [iOS 17+ tunnel guide](https://github.com/doronz88/pymobiledevice3/blob/master/docs/guides/ios17-tunnels.md), and [protocol-layer overview](https://github.com/doronz88/pymobiledevice3/blob/master/misc/understanding_idevice_protocol_layers.md). -* Complementary tools: [libimobiledevice](https://github.com/libimobiledevice/libimobiledevice), [go-ios](https://github.com/danielpaulus/go-ios), [Facebook idb](https://github.com/facebook/idb), [MVT](https://github.com/mvt-project/mvt), and [ipsw](https://github.com/blacktop/ipsw). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..1d608e9 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,12 @@ +# Documentation + +| Document | Contents | +|---|---| +| [../README.md](../README.md) | What the app does, requirements, installation, first steps, building | +| [architecture.md](architecture.md) | Modules, how the app reaches devices, processes, errors, and logging | +| [safety.md](safety.md) | Authorization, confirmation levels, evidence interpretation | +| [troubleshooting.md](troubleshooting.md) | Finding the failing layer and collecting details for a report | +| [release-verification.md](release-verification.md) | What a release contains and how to verify it | +| [PHYSICAL_DEVICE_TEST_PROTOCOL.md](PHYSICAL_DEVICE_TEST_PROTOCOL.md) | Step-by-step verification on a real iPhone or iPad | +| [../MIGRATION.md](../MIGRATION.md) | The Python → Swift migration record, test results, and known limitations | +| [screenshots/](screenshots/) | Screenshots rendered by the app (Demo Mode and a simulator) | diff --git a/docs/architecture.md b/docs/architecture.md index db03e3e..780ac79 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,33 +1,94 @@ # Architecture -## Service layers +iOS Developer Toolkit is a SwiftUI app on top of a Swift package. All device, process, and file +logic lives in the package, so it builds and tests with `swift test` without Xcode's UI tooling. -The toolkit keeps Apple service boundaries visible instead of reducing every failure to “device not connected.” +``` +App/iOSDeveloperToolkit (SwiftUI views and view state) Sources/idt (command-line tool) + │ │ + └──────────────► ToolkitFeatures ◄────────────────────┘ + Location Lab, IPA inspection, live-log capture and findings, + actions and their safety policy, readiness, evidence + collection, external tools, profiles, support bundle + │ + DeviceKit + usbmuxd client · lockdown client and services · + CoreDevice (devicectl) · simulators (simctl) · discovery + │ + ToolkitCore + CommandRunner · ToolkitError · OSLog categories · + SecureFileIO · Sanitizer · OperationJournal · ZipWriter +``` -| Layer | Typical role | What readiness does not prove | +## How the app reaches a device + +| Path | Used for | Needs | |---|---|---| -| USB / Wi-Fi and usbmux | Host discovery and transport | Trust, Developer Mode, or service access | -| Lockdown and paired services | Device information, apps, backup, diagnostics, AFC, classic syslog | Root access or unrestricted files | -| RemoteXPC / RSD | Modern service discovery and transport | That every advertised service accepts a request | -| Developer Mode and DDI | Enables compatible developer-service payloads | Jailbreak, bypass, or compromise | -| CoreDevice / DVT | Apple development and Instruments-style telemetry | Complete or stable forensic coverage | +| **usbmuxd** (`/var/run/usbmuxd`) | Discovery events, pairing records, connections to device services | A trusted device over USB or Wi-Fi sync | +| **Lockdown** (TLS, swift-nio-ssl) | Identity, Developer Mode status, syslog and os_trace relays, pcapd, MobileBackup2, diagnostics, installation proxy, AFC, misagent, image mounter (check, personalize, upload, mount, unmount developer images), legacy location | Trust; nothing from Xcode except the developer image files it installs | +| **CoreDevice** (`xcrun devicectl`, JSON output) | Developer services (personalized DDI), screenshots, location on iOS 17+, processes, launch, profiles, sysdiagnose, network-only devices | Xcode | +| **Simulators** (`xcrun simctl`) | Everything in the Simulators section | Xcode | +| **Instruments** (`xcrun xctrace`) | Recordings | Xcode | + +Each lockdown session pins the device certificate from the pairing record and checks that the +device answering has the requested UDID. Pairing records come from usbmuxd's `ReadPairRecord`, +which macOS allows without administrator rights. The app never reads `/var/db/lockdown` and never +creates pairing records. + +## Developer images + +Developer-image support lives in `Sources/DeviceKit/DeveloperImage` and is the only code that +talks to the private `com.apple.mobile.mobile_image_mounter` service or to Apple's signing server: + +| Type | Role | +|---|---| +| `ImageMounter` | The image-mounter protocol (lookup, list, nonce, personalization identifiers and manifest, upload, mount, unmount) and the mapping of its errors to plain language | +| `DeveloperImageLibrary` | Images on this Mac: Xcode's `/Library/Developer/DeveloperDiskImages/iOS_DDI`, user folders, legacy `DeveloperDiskImage.dmg` + `.signature`; build-identity selection by chip and board | +| `ImagePersonalization` | Builds the TSS request and reads the ticket; the network call is behind `PersonalizationTransport` so tests never reach Apple | +| `DeveloperImageManager` | Reads device facts, evaluates the state (a pure function), and mounts or unmounts — through Xcode's `devicectl` or the built-in client | + +The rest of the app sees only `DeveloperImageStatus` (state, explanation, next step, details). +The private service and TSS formats follow the open-source implementations the 0.3.x app used; +they are covered by tests against a stateful fake image mounter, and still need verification on +physical devices ([PHYSICAL_DEVICE_TEST_PROTOCOL.md](PHYSICAL_DEVICE_TEST_PROTOCOL.md), Stage 3). + +## Processes -The [README service-layer diagram](https://github.com/hideouts-io/iOS-Developer-Toolkit#how-the-service-layers-fit-together) is the canonical operational explanation. +`CommandRunner` in ToolkitCore is the only place that creates a `Process`. A request is an +executable URL, an argument vector, a minimal environment, an optional timeout, and a display +name for the Session Activity log. The runner never uses a shell. Every run can be cancelled, +drains output without deadlocks, and returns a typed result. Timeouts, non-zero exits, and +cancellation surface as `ToolkitError`. External tools (MVT, UFADE, idb Companion) are +validated by path and SHA-256 before they run. -## Process model +## Errors and logging -The GUI launches argument vectors directly rather than evaluating shell pipelines or substitutions. Finite operations use a shared controller with explicit timeout, terminal output draining, cancellation, and one typed result. Long-running streams use explicit Stop controls. Backup and evidence collection retain purpose-built protocols because password input and partial-artifact finalization have different safety requirements. +`ToolkitError` carries a plain-language message, a recovery suggestion, and a technical detail +kept separate for the diagnostic log. The app logs to the unified log under the subsystem +`io.hideouts.iOSDeveloperToolkit` with the categories Application, DeviceDiscovery, +DeviceCommunication, Commands, Diagnostics, Security, Networking, Filesystem, Backup, Location, +LiveLogs, and Evidence. Identifiers, names, and paths are logged as private. **Help › Diagnostic +Log** shows this session's entries. -The key implementation surfaces are: +```bash +log stream --predicate 'subsystem == "io.hideouts.iOSDeveloperToolkit"' --level info +``` -- [`runtime.py`](https://github.com/hideouts-io/iOS-Developer-Toolkit/blob/main/ios_developer_toolkit/runtime.py) for packaged/source command resolution; -- [`qt_process.py`](https://github.com/hideouts-io/iOS-Developer-Toolkit/blob/main/ios_developer_toolkit/qt_process.py) and [`interactive_process.py`](https://github.com/hideouts-io/iOS-Developer-Toolkit/blob/main/ios_developer_toolkit/interactive_process.py) for typed process lifecycles; -- [`command_catalog.py`](https://github.com/hideouts-io/iOS-Developer-Toolkit/blob/main/ios_developer_toolkit/command_catalog.py) for reviewed guided commands; -- [`operation_history.py`](https://github.com/hideouts-io/iOS-Developer-Toolkit/blob/main/ios_developer_toolkit/operation_history.py) for session-local operation records; -- [`collector.py`](https://github.com/hideouts-io/iOS-Developer-Toolkit/blob/main/ios_developer_toolkit/collector.py) for evidence coverage, manifests, and hashes. +## Targets and concurrency -## External-provider boundary +- Swift 6 language mode with strict concurrency checking; the project builds with zero warnings. +- Every operation takes an immutable `DeviceTarget` captured when it starts, so a change of + selection never redirects a running operation. +- Discovery is event-driven: usbmuxd attach and detach events trigger refreshes. A slow timer + refreshes only network-only CoreDevice devices. -UFADE and MVT remain isolated external providers. go-ios, idb Companion, and ipsw are optional executable adapters with path, SHA-256, and version/build validation. Their dependencies, licenses, target selection, output semantics, and update cycles are not merged into the packaged application. +## Project files -See the current [product audit and roadmap](PRODUCT_AUDIT_2026-09-21.md) for the evidence behind these boundaries. +| Path | Contents | +|---|---| +| `Package.swift` | ToolkitCore, DeviceKit, ToolkitFeatures, idt, DeviceTestSupport, test targets | +| `project.yml` | XcodeGen description of the app and UI-test targets (`iOSDeveloperToolkit.xcodeproj` is generated from it and committed) | +| `App/iOSDeveloperToolkit` | SwiftUI app: `Model/` (view state, screenshot harness), `Views/`, `Components/` | +| `App/UITests` | XCUITest smoke tests (Demo Mode) | +| `Tests/` | Package tests, including an in-process fake usbmuxd and lockdownd device | +| `scripts/build-release.sh` | Universal, ad-hoc-signed release build with checksums and SBOM | diff --git a/docs/contributing.md b/docs/contributing.md deleted file mode 100644 index 4fa8329..0000000 --- a/docs/contributing.md +++ /dev/null @@ -1,16 +0,0 @@ -# Contributing - -## Pick the right path - -- Use [Discussions](https://github.com/hideouts-io/iOS-Developer-Toolkit/discussions) for setup, compatibility, and workflow questions. -- Use a [bug report](https://github.com/hideouts-io/iOS-Developer-Toolkit/issues/new?template=bug_report.yml) for a reproducible defect. -- Use a [feature request](https://github.com/hideouts-io/iOS-Developer-Toolkit/issues/new?template=feature_request.yml) for a bounded workflow proposal. -- Use [private vulnerability reporting](https://github.com/hideouts-io/iOS-Developer-Toolkit/security/advisories/new) for security issues. - -The repository's [CONTRIBUTING.md](https://github.com/hideouts-io/iOS-Developer-Toolkit/blob/main/CONTRIBUTING.md) is the canonical setup, style, validation, privacy, and pull-request guide. The [Code of Conduct](https://github.com/hideouts-io/iOS-Developer-Toolkit/blob/main/CODE_OF_CONDUCT.md) applies to all project spaces. - -## Evidence expected in a pull request - -Describe the problem, affected layer, behavior change, tests run, device coverage, privacy impact, and known limitations. Use synthetic or sanitized screenshots and logs. Changes to process handling should exercise real short-lived child processes where practical; packaging changes should validate the frozen application and both release architectures. - -The [product audit](PRODUCT_AUDIT_2026-09-21.md) records current architecture risks, ecosystem boundaries, and prioritized work. diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 993a5cb..0000000 --- a/docs/index.md +++ /dev/null @@ -1,70 +0,0 @@ -# iOS Developer Toolkit - -
- -## One guided macOS workbench for Apple-device services - -iOS Developer Toolkit makes authorized iPhone and iPad development, diagnostics, backup, logging, package inspection, and evidence-preservation workflows visible without hiding their prerequisites or interpretation limits. - -[Start with a device](quick-start.md){ .md-button .md-button--primary } -[Review the safety boundary](safety.md){ .md-button } - -
- -![The Home workspace](screenshots/home.png) - -## Choose your path - -
- -
- -### First-time operator - -Connect one unlocked device, establish trust, and use the one-click Capability Matrix before choosing a workflow. - -[Open the quick start](quick-start.md) - -
- -
- -### iOS developer - -Understand the Lockdown, RemoteXPC, DDI, CoreDevice, and DVT layers, then use guided presets or native Xcode handoffs. - -[Read the architecture guide](architecture.md) - -
- -
- -### Investigator - -Preserve raw logs and bounded collection results, review coverage gaps, and separate tool output from analyst conclusions. - -[Review safety and privacy](safety.md) - -
- -
- -### Contributor or verifier - -Use the public tests, dual-architecture packaging checks, SBOMs, checksums, and GitHub attestations. - -[Verify a release](release-verification.md) - -
- -
- -## Current product map - -The application contains 13 workspaces spanning connection and DDI readiness, location testing, live logs, guided commands, app inventory, backup providers, IPA inspection, evidence capture, optional ecosystem tools, installed-command help, and visible scope boundaries. Local workspace profiles can move reviewed control defaults between team members without carrying targets, paths, credentials, coordinates, case text, parameters, or output. - -The [canonical README](https://github.com/hideouts-io/iOS-Developer-Toolkit#readme) remains the complete feature reference and screenshot walkthrough. This site separates the most common audience paths so setup, architecture, safety, troubleshooting, release verification, and contribution material are easier to find without maintaining a second copy of every command. - -!!! note "Independent community project" - - This project is not affiliated with or endorsed by Apple, the pymobiledevice3 maintainers, or the maintainers of optional external tools. diff --git a/docs/quick-start.md b/docs/quick-start.md deleted file mode 100644 index 2c240bb..0000000 --- a/docs/quick-start.md +++ /dev/null @@ -1,25 +0,0 @@ -# Quick start - -## Before connecting a device - -Use macOS 13 or later, choose the release matching the Mac architecture, and work only with a device you own or are explicitly authorized to use. The [README installation section](https://github.com/hideouts-io/iOS-Developer-Toolkit#installation) is the canonical source for clone, source-launch, release-download, Gatekeeper, and removal instructions. - -!!! warning "Keep one intended device connected" - - External providers such as UFADE, MVT, go-ios, idb, and ipsw use their own target-selection rules. A device selected in the toolkit does not constrain an external program. - -## First session - -1. Connect the unlocked device directly with a data-capable cable. -2. Approve the macOS accessory prompt and the iOS **Trust** prompt if shown. -3. Select the intended physical device in the top-right picker. -4. Open **Device & DDI** and check Developer Mode only if the intended workflow needs developer services. -5. Run **Capability Matrix** before mounting, tunneling, streaming, or changing state. -6. Choose a workspace and review its prerequisite, target, exact argument vector, and safety classification. -7. Stop streams, clear simulated location, finalize evidence, and unmount temporary developer support when finished. - -The complete [first-device walkthrough](https://github.com/hideouts-io/iOS-Developer-Toolkit#first-device-walkthrough) explains each state and the expected failure indicators. For a controlled real-device validation, use the [physical-device test protocol](PHYSICAL_DEVICE_TEST_PROTOCOL.md). - -## Learn without a physical device - -Use **Demo Mode** for a visibly simulated interface walkthrough. It never exposes a fake device to operational code, and device actions remain disabled. The Action Palette (`⌘ K`) and keyboard reference (`⌘ /`) remain available for navigation. diff --git a/docs/release-verification.md b/docs/release-verification.md index 9b3ab60..323337b 100644 --- a/docs/release-verification.md +++ b/docs/release-verification.md @@ -1,19 +1,45 @@ # Release verification -## What a published release contains +## What a release contains -Apple Silicon and Intel applications are built separately on native GitHub-hosted runners. Each ZIP is accompanied by a CycloneDX SBOM, a release-wide SHA-256 inventory, and GitHub build-provenance and SBOM attestations. The application is ad-hoc signed and is not Apple-notarized. +Each tagged release is built by `.github/workflows/release.yml` with `scripts/build-release.sh` on +a GitHub-hosted macOS runner: -## Verification order +| File | Contents | +|---|---| +| `iOS-Developer-Toolkit-VERSION-macOS-universal.zip` | The app (arm64 + x86_64), ad-hoc signed with the hardened runtime, with `idt` in `Contents/MacOS` and dependency licenses in `Contents/Resources/Licenses` | +| `SHA256SUMS.txt` | SHA-256 of every release file | +| `iOS-Developer-Toolkit-VERSION.spdx.json` | SPDX 2.3 SBOM of the Swift package dependencies, generated from `Package.resolved` | -1. Download the archive matching the Mac architecture from the [latest release](https://github.com/hideouts-io/iOS-Developer-Toolkit/releases/latest). -2. Verify the archive against `SHA256SUMS.txt` before extracting it. -3. Verify GitHub build provenance for that exact archive. -4. Inspect the architecture label and embedded SBOM. -5. After extraction, inspect the ad-hoc signature and apply the documented Gatekeeper procedure only if the provenance is acceptable. +GitHub build-provenance and SBOM attestations are published for the ZIP. The app is **not** +notarized by Apple. -The canonical commands and current signing caveats live in the [README release section](https://github.com/hideouts-io/iOS-Developer-Toolkit#release-model) and [security policy](https://github.com/hideouts-io/iOS-Developer-Toolkit/security/policy). Source and bundled-component boundaries are recorded in [SOURCE_AVAILABILITY.md](https://github.com/hideouts-io/iOS-Developer-Toolkit/blob/main/SOURCE_AVAILABILITY.md) and [THIRD_PARTY_NOTICES.md](https://github.com/hideouts-io/iOS-Developer-Toolkit/blob/main/THIRD_PARTY_NOTICES.md). +## Verify -!!! danger "Do not infer notarization" +```bash +shasum -a 256 -c SHA256SUMS.txt --ignore-missing +gh attestation verify iOS-Developer-Toolkit-VERSION-macOS-universal.zip --repo hideouts-io/iOS-Developer-Toolkit +``` - A valid checksum, ad-hoc signature, SBOM, or GitHub attestation does not make the bundle Apple-notarized. Each mechanism answers a different provenance or integrity question. +After unzipping: + +```bash +codesign --verify --deep --strict --verbose=2 "iOS Developer Toolkit.app" +codesign --display --verbose=2 "iOS Developer Toolkit.app" # expect: Signature=adhoc, flags=0x10002(adhoc,runtime) +lipo -archs "iOS Developer Toolkit.app/Contents/MacOS/iOS Developer Toolkit" # expect: x86_64 arm64 +``` + +Then open the app with Control-click › **Open** (or **Open Anyway** in *System Settings › Privacy & +Security*). Do not disable Gatekeeper. + +## Build it yourself and compare + +```bash +scripts/build-release.sh 1.0.0 +``` + +Builds are not bit-for-bit reproducible (signatures and timestamps differ), but the SBOM and the +source commit can be compared with a release. + +> A checksum, ad-hoc signature, SBOM, or attestation does not make the app notarized. Each answers +> a different question about where the file came from and whether it changed. diff --git a/docs/safety.md b/docs/safety.md index 691136c..2fb62ed 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -2,25 +2,34 @@ ## Authorization comes first -Use the toolkit only on devices and data you own or are explicitly authorized to develop against, administer, test, back up, or examine. A DDI, trust relationship, profile, entitlement, or available service does not establish authorization. +Use the toolkit only on devices and data you own or are explicitly authorized to develop against, +administer, test, back up, or examine. Trust, Developer Mode, a developer disk image, a profile, +or an available service does not establish authorization. -The canonical [Scope and Safety workspace guide](https://github.com/hideouts-io/iOS-Developer-Toolkit#scope-and-safety) lists the product's technical limits. The [security policy](https://github.com/hideouts-io/iOS-Developer-Toolkit/security/policy) explains private vulnerability reporting and the data that must never be placed in a public issue. +The app's **Scope & Safety** page lists its technical limits. The [security policy](../SECURITY.md) +explains private vulnerability reporting and what must never go into a public issue. -## Action classes +## Confirmation levels -| Class | Examples | Review boundary | +| Level | Examples | Before it runs | |---|---|---| -| Read-oriented | Discovery, status, inventory, help | Exact target and command remain visible | -| Host write | Export, capture, backup, analysis output | Destination and sensitive-output warning | -| Device change | Install, uninstall, mount, launch, location | Device-bound typed acknowledgement | -| High impact | Restore, erase, activation, restart, shutdown | Backup acknowledgement plus irreversible phrase | +| Read-only | Device details, battery, lock state, app list | Runs immediately; the target is always visible | +| Saves files on this Mac | Screenshot, crash reports, backup, capture | Review sheet with the destination; files are never overwritten | +| Changes the device | Install or launch an app, set a location, mount or unmount the developer image | Type `RUN` and the last six characters of the target's UDID | +| High impact | Restart, remove an app, erase a simulator | Confirm a current backup, then type `IRREVERSIBLE` and the same six characters | -The Action Palette exposes only operations currently eligible in the visible state and rechecks eligibility at activation. +The Command Palette and Actions list only what is available for the selected target and +re-check eligibility when you run it. Advanced Mode classifies `devicectl` subcommands the same +way, treats anything it does not recognize as a device change, always adds the selected device, +and rejects attempts to address a different one. ## Evidence and interpretation -Raw logs, packet captures, backups, app inventories, screenshots, profiles, crash reports, MVT results, and external-tool inventories can contain sensitive device, account, application, location, or network data. Store them outside a public checkout on access-controlled storage. +Logs, packet captures, backups, app lists, screenshots, profiles, crash reports, and MVT results +can contain sensitive device, account, app, location, and network data. Keep them on +access-controlled storage, outside any public repository. The app writes them with owner-only +permissions. -Hashes detect later changes; they do not prove acquisition time, custody, authorship, completeness, or truth. Empty output is not proof of absence. A successful command is not proof that its view is complete. Analyst annotations remain separate from raw capture facts. - -For collection structure and retention guidance, use the [canonical evidence-case reference](https://github.com/hideouts-io/iOS-Developer-Toolkit#evidence-case-contents). +Hashes detect later changes; they do not prove when something was collected, by whom, or that +it is complete or true. Empty output is not proof of absence. Findings and notes are recorded +separately from the raw capture they refer to. diff --git a/docs/screenshots/action-palette.png b/docs/screenshots/action-palette.png deleted file mode 100644 index 4527429..0000000 Binary files a/docs/screenshots/action-palette.png and /dev/null differ diff --git a/docs/screenshots/actions.png b/docs/screenshots/actions.png new file mode 100644 index 0000000..a12993c Binary files /dev/null and b/docs/screenshots/actions.png differ diff --git a/docs/screenshots/apps.png b/docs/screenshots/apps.png new file mode 100644 index 0000000..0427356 Binary files /dev/null and b/docs/screenshots/apps.png differ diff --git a/docs/screenshots/backup.png b/docs/screenshots/backup.png index d6279df..01e72b5 100644 Binary files a/docs/screenshots/backup.png and b/docs/screenshots/backup.png differ diff --git a/docs/screenshots/capability-matrix.png b/docs/screenshots/capability-matrix.png deleted file mode 100644 index 07475a7..0000000 Binary files a/docs/screenshots/capability-matrix.png and /dev/null differ diff --git a/docs/screenshots/developer-image.png b/docs/screenshots/developer-image.png new file mode 100644 index 0000000..48b0e42 Binary files /dev/null and b/docs/screenshots/developer-image.png differ diff --git a/docs/screenshots/developer-mode-guide.png b/docs/screenshots/developer-mode-guide.png deleted file mode 100644 index 2fba115..0000000 Binary files a/docs/screenshots/developer-mode-guide.png and /dev/null differ diff --git a/docs/screenshots/device-and-ddi.png b/docs/screenshots/device-and-ddi.png deleted file mode 100644 index e0bbfd2..0000000 Binary files a/docs/screenshots/device-and-ddi.png and /dev/null differ diff --git a/docs/screenshots/device-simulator.png b/docs/screenshots/device-simulator.png new file mode 100644 index 0000000..72f3db4 Binary files /dev/null and b/docs/screenshots/device-simulator.png differ diff --git a/docs/screenshots/ecosystem-tools.png b/docs/screenshots/ecosystem-tools.png deleted file mode 100644 index c66e0e5..0000000 Binary files a/docs/screenshots/ecosystem-tools.png and /dev/null differ diff --git a/docs/screenshots/evidence-capture.png b/docs/screenshots/evidence-capture.png new file mode 100644 index 0000000..53f5a68 Binary files /dev/null and b/docs/screenshots/evidence-capture.png differ diff --git a/docs/screenshots/evidence-collection.png b/docs/screenshots/evidence-collection.png deleted file mode 100644 index 090d418..0000000 Binary files a/docs/screenshots/evidence-collection.png and /dev/null differ diff --git a/docs/screenshots/external-tools.png b/docs/screenshots/external-tools.png new file mode 100644 index 0000000..8bfbdde Binary files /dev/null and b/docs/screenshots/external-tools.png differ diff --git a/docs/screenshots/home.png b/docs/screenshots/home.png deleted file mode 100644 index b3eff20..0000000 Binary files a/docs/screenshots/home.png and /dev/null differ diff --git a/docs/screenshots/install-app.png b/docs/screenshots/install-app.png new file mode 100644 index 0000000..63bc734 Binary files /dev/null and b/docs/screenshots/install-app.png differ diff --git a/docs/screenshots/installed-apps.png b/docs/screenshots/installed-apps.png deleted file mode 100644 index 98d055f..0000000 Binary files a/docs/screenshots/installed-apps.png and /dev/null differ diff --git a/docs/screenshots/live-log-window.png b/docs/screenshots/live-log-window.png deleted file mode 100644 index b98139d..0000000 Binary files a/docs/screenshots/live-log-window.png and /dev/null differ diff --git a/docs/screenshots/live-logs.png b/docs/screenshots/live-logs.png index 0970601..5b71b10 100644 Binary files a/docs/screenshots/live-logs.png and b/docs/screenshots/live-logs.png differ diff --git a/docs/screenshots/local-xcode-ddi.png b/docs/screenshots/local-xcode-ddi.png deleted file mode 100644 index 295e689..0000000 Binary files a/docs/screenshots/local-xcode-ddi.png and /dev/null differ diff --git a/docs/screenshots/location-lab.png b/docs/screenshots/location-lab.png index 65dcf99..8ab98ff 100644 Binary files a/docs/screenshots/location-lab.png and b/docs/screenshots/location-lab.png differ diff --git a/docs/screenshots/man-pages.png b/docs/screenshots/man-pages.png deleted file mode 100644 index 1203adf..0000000 Binary files a/docs/screenshots/man-pages.png and /dev/null differ diff --git a/docs/screenshots/mvt-analysis.png b/docs/screenshots/mvt-analysis.png deleted file mode 100644 index 5513164..0000000 Binary files a/docs/screenshots/mvt-analysis.png and /dev/null differ diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png new file mode 100644 index 0000000..04a09f9 Binary files /dev/null and b/docs/screenshots/overview.png differ diff --git a/docs/screenshots/pymobiledevice3-console.png b/docs/screenshots/pymobiledevice3-console.png deleted file mode 100644 index 280fca1..0000000 Binary files a/docs/screenshots/pymobiledevice3-console.png and /dev/null differ diff --git a/docs/screenshots/readiness-check.png b/docs/screenshots/readiness-check.png new file mode 100644 index 0000000..f56acff Binary files /dev/null and b/docs/screenshots/readiness-check.png differ diff --git a/docs/screenshots/scope-and-safety.png b/docs/screenshots/scope-and-safety.png index 29bc1b9..418fecf 100644 Binary files a/docs/screenshots/scope-and-safety.png and b/docs/screenshots/scope-and-safety.png differ diff --git a/docs/screenshots/session-activity.png b/docs/screenshots/session-activity.png deleted file mode 100644 index 70658d1..0000000 Binary files a/docs/screenshots/session-activity.png and /dev/null differ diff --git a/docs/screenshots/sideload-ipa.png b/docs/screenshots/sideload-ipa.png deleted file mode 100644 index b9cc7b7..0000000 Binary files a/docs/screenshots/sideload-ipa.png and /dev/null differ diff --git a/docs/screenshots/support-bundle.png b/docs/screenshots/support-bundle.png deleted file mode 100644 index 9cefc80..0000000 Binary files a/docs/screenshots/support-bundle.png and /dev/null differ diff --git a/docs/screenshots/ufade-backup.png b/docs/screenshots/ufade-backup.png deleted file mode 100644 index 7b256f1..0000000 Binary files a/docs/screenshots/ufade-backup.png and /dev/null differ diff --git a/docs/screenshots/xcode-handoff.png b/docs/screenshots/xcode-handoff.png deleted file mode 100644 index 2942e86..0000000 Binary files a/docs/screenshots/xcode-handoff.png and /dev/null differ diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css deleted file mode 100644 index b4886be..0000000 --- a/docs/stylesheets/extra.css +++ /dev/null @@ -1,36 +0,0 @@ -:root { - --md-primary-fg-color: #111827; - --md-accent-fg-color: #c1121f; -} - -.md-header { - border-bottom: 3px solid #c1121f; -} - -.toolkit-hero { - border: 1px solid var(--md-default-fg-color--lightest); - border-radius: 0.8rem; - padding: 1.25rem; - background: linear-gradient(135deg, rgba(193, 18, 31, 0.1), rgba(17, 24, 39, 0.04)); -} - -.toolkit-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); - gap: 0.8rem; - margin: 1rem 0; -} - -.toolkit-card { - border: 1px solid var(--md-default-fg-color--lightest); - border-radius: 0.65rem; - padding: 0.9rem 1rem; -} - -.toolkit-card > :first-child { - margin-top: 0; -} - -.toolkit-card > :last-child { - margin-bottom: 0; -} diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0d78cab..de79c76 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,24 +1,50 @@ # Troubleshooting -## Start with the failing layer +## Find the failing layer -| Visible symptom | First check | Next reference | +| Symptom | Layer | What to check | |---|---|---| -| No phone in the picker | Cable, unlock state, macOS accessory approval, Finder visibility, Trust | [Device not detected](https://github.com/hideouts-io/iOS-Developer-Toolkit#device-not-detected) | -| Paired but developer command fails | Developer Mode, DDI compatibility, tunnel, service-specific matrix row | [Capability Matrix](https://github.com/hideouts-io/iOS-Developer-Toolkit#device-capability-matrix) | -| Man Pages appears busy | Cancel the bounded request and retry from the current project environment | [Man Pages](https://github.com/hideouts-io/iOS-Developer-Toolkit#man-pages) | -| Stream has no lines | Confirm the correct stream family, prerequisites, app activity, and raw spool state | [Live Logs](https://github.com/hideouts-io/iOS-Developer-Toolkit#live-logs) | -| Backup fails | Encryption state, free space, destination freshness, unlock state | [Backup](https://github.com/hideouts-io/iOS-Developer-Toolkit#backup) | -| Optional adapter fails | Exact executable path/hash, reported version/build, upstream requirements, independent target state | [Ecosystem Tools](https://github.com/hideouts-io/iOS-Developer-Toolkit#ecosystem-tools) | +| Not in Finder either | USB / cable | Data-capable cable, direct port (no hub), device unlocked, **Allow accessory** approved on the Mac | +| In Finder, not in the app | macOS device service | **Connection diagnostics** on the Device page. If the device service is not answering, restart the Mac — the app never restarts system services | +| Listed, but “not trusted” | Pairing | Unlock, reconnect, tap **Trust** (**Device › Reconnect a Device…** walks through this and watches for the device for 30 seconds). If no prompt appears: *Settings › General › Transfer or Reset › Reset › Reset Location & Privacy* | +| Developer features unavailable | Developer Mode | *Settings › Privacy & Security › Developer Mode*. If the switch is missing, connect the device to Xcode once | +| “Needs Xcode” | Xcode | Install Xcode, open it once, and select it in *Xcode › Settings › Locations › Command Line Tools*. Run **Tool Reference › Toolchain Check** | +| Developer image not mounted | Developer image | The **Developer image** card on the Device page names the problem and the fix. Keep the device unlocked and on USB; on iOS 17 and later keep the Mac online (Apple personalizes the image). If one route fails, try the other under **Options › Mount with** | +| Developer image “Missing” or “Incompatible” | Host image | iOS 17+: update Xcode and open it once (it installs `/Library/Developer/DeveloperDiskImages/iOS_DDI`). iOS 16 and earlier: add a folder with `DeveloperDiskImage.dmg` and `.signature` for the exact version (**Options › Add Image Folder…**) | +| Network device missing | CoreDevice | Pair it with Xcode over USB first, keep it on the same network, and refresh (⌘R) | +| Simulator missing | simctl | Install a simulator runtime in *Xcode › Settings › Components* | +| “Safari Web Inspector did not answer” | Web Inspector | Turn on *Settings › Apps › Safari › Advanced › Web Inspector* (Settings › Safari › Advanced before iOS 18). If it is on, wait ten seconds: the device accepts a new inspection session only about every ten seconds | +| “The device did not start Bluetooth logging” or no packets | Bluetooth logging profile | Install Apple's Bluetooth logging profile on the device (Apple Developer › Profiles and Logs), toggle Bluetooth off and on, and use a Bluetooth accessory during the capture | +| Location simulation fails on iOS 16 or earlier | Legacy service | Mount the developer image for that exact iOS version first (see the developer-image rows above) | -## Use the built-in diagnostics +The **Readiness Check** runs these checks in order and stops at the first one that fails. -1. Run **Retry Scan** for one immediate usbmux check. -2. Use **Reconnect & Retry…** for the guided 30-second physical reconnection window. -3. Run **Capability Matrix** and inspect the first non-ready prerequisite. -4. Use **Check Command Drift** when a guided command may no longer match the installed CLI. -5. Create a **Sanitized Support Bundle**, review it locally, and attach it only when appropriate. +## Specific problems -The toolkit does not attempt to restart SIP-protected Apple services, delete pairing records, use `sudo`, or hide a failed prerequisite behind automatic recovery. +**Live Logs stay empty.** Check that the right stream was started (Unified or classic syslog for +devices, simulator log for simulators) and that the device is unlocked and in use. Filters change +only the view. The capture counter shows whether bytes are arriving. -For support, use [GitHub Discussions](https://github.com/hideouts-io/iOS-Developer-Toolkit/discussions). Follow the [support policy](https://github.com/hideouts-io/iOS-Developer-Toolkit/blob/main/SUPPORT.md) before sharing any output. +**A backup stops.** Keep the device unlocked and awake, check free space on the Mac, and choose an +empty or previous backup folder for this device. If encryption is on, the backup password is the +one set on the device; the app cannot recover it. + +**An `.ipa` will not install.** The inspection must show a valid signature, and the provisioning +profile must include the device (development and ad hoc profiles) or be an enterprise profile. + +**A command times out.** Every external command has a timeout. Its message names the command; +the technical details are in **Help › Diagnostic Log**. + +## Collect details for a report + +1. Run the **Readiness Check** and copy its report. +2. **iOS Developer Toolkit › Create Support Bundle…** writes a sanitized ZIP (no names, + identifiers, paths, addresses, or captured content). Open it and review it before sharing. +3. The unified log: + + ```bash + log show --last 10m --predicate 'subsystem == "io.hideouts.iOSDeveloperToolkit"' --info + ``` + +Ask in [GitHub Discussions](https://github.com/hideouts-io/iOS-Developer-Toolkit/discussions) +and follow [SUPPORT.md](../SUPPORT.md) before sharing any output. diff --git a/iOSDeveloperToolkit.xcodeproj/project.pbxproj b/iOSDeveloperToolkit.xcodeproj/project.pbxproj new file mode 100644 index 0000000..3e9ad4b --- /dev/null +++ b/iOSDeveloperToolkit.xcodeproj/project.pbxproj @@ -0,0 +1,604 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 0046784D38773E756D16B381 /* DeviceViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 192082EDB2AA8A31B4DE9A43 /* DeviceViews.swift */; }; + 0E51C7116150795B48ED38A0 /* ReadinessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7E525F882B8476846CFA326 /* ReadinessView.swift */; }; + 1623B3509BB855616EA203F0 /* iOSDeveloperToolkitApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FC5D72518E6E2101A0B339B /* iOSDeveloperToolkitApp.swift */; }; + 16410705637BF3C36BD60ADB /* LocationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC558873395BD2D10988B49F /* LocationViews.swift */; }; + 1BEAAB3DA748A2FF696A37BE /* LiveLogsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1BCA4BFD074C07318C65CC /* LiveLogsModel.swift */; }; + 244023633FC5B7270C45C371 /* ActionsViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64CADCDAC59205AFA532EF87 /* ActionsViews.swift */; }; + 25D059CF99AA7F74E66147F4 /* SmokeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2095C064BD8BE67389318E84 /* SmokeUITests.swift */; }; + 26019A7B916688FEE3A23585 /* LogViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B81EECBCB7D517436001EFF8 /* LogViews.swift */; }; + 296F9F5D4685AB10A7B27CA6 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 791078D9EF3EC6308839270F /* AppModel.swift */; }; + 42E12E11EB7BA75B9D50C3E8 /* ReconnectGuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B26E835D20074A0489176A15 /* ReconnectGuideView.swift */; }; + 58CE6E900B85D91761091C12 /* ShortcutReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A5A2B1435E385D65A1EA31 /* ShortcutReferenceView.swift */; }; + 743B753A384188F88D118565 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6641462093807C7726C77E6 /* ContentView.swift */; }; + 74E2D8771554772F6F5085DB /* DeveloperImageModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A42E01F498D8AE9BAE52EA83 /* DeveloperImageModel.swift */; }; + 795941F1191B8EF4997DF250 /* AppsViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF4B82A9EAB2FEBF4FBE2FA2 /* AppsViews.swift */; }; + 8B67289B01733ABC7CEAE819 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F724AF45C9EBAFFC75A5710 /* Components.swift */; }; + 932BE70EA34036FB92844B1C /* LocationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585ECC84A09B869053F28246 /* LocationModel.swift */; }; + 99273540613D8AD2D5573319 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = 1A39B4EB7A1BB72ED4812024 /* DeviceKit */; }; + 9F456A81A2AF3E4B9BA9E1A0 /* FeatureModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D34F678B9113D08044E08644 /* FeatureModels.swift */; }; + AB27D70470F4FE0BAAC682EA /* ToolkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = CE5B03128B0835E13440AFA5 /* ToolkitCore */; }; + ACAA69DD86A135192765E423 /* DataViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37EBEC3A6576895CA18665A /* DataViews.swift */; }; + B6183EF29AF4682E68572B41 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9FD5D4AAA78961F517FD87B2 /* Assets.xcassets */; }; + C11140FE6CD2D8612115AF8F /* ReferenceViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 117839C38E19BA74C82D8382 /* ReferenceViews.swift */; }; + E0A3673F60ACD7FFF3801DFC /* ScreenshotHarness.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10F7407E0C32BF6634D85B5 /* ScreenshotHarness.swift */; }; + E374C2EAE2467F2B1D01215E /* ToolkitFeatures in Frameworks */ = {isa = PBXBuildFile; productRef = 529398E755DBC14015ADEA9B /* ToolkitFeatures */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 7D859C2B12ECAB79034F33D7 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8F9521A1C32BAC67A0066494 /* Project object */; + proxyType = 1; + remoteGlobalIDString = FE597393124482775C8D00A3; + remoteInfo = iOSDeveloperToolkit; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 117839C38E19BA74C82D8382 /* ReferenceViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceViews.swift; sourceTree = ""; }; + 192082EDB2AA8A31B4DE9A43 /* DeviceViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceViews.swift; sourceTree = ""; }; + 2095C064BD8BE67389318E84 /* SmokeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmokeUITests.swift; sourceTree = ""; }; + 50A5A2B1435E385D65A1EA31 /* ShortcutReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutReferenceView.swift; sourceTree = ""; }; + 585ECC84A09B869053F28246 /* LocationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationModel.swift; sourceTree = ""; }; + 599C314C6B21A9A615B04C7A /* iOSDeveloperToolkit.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iOSDeveloperToolkit.entitlements; sourceTree = ""; }; + 5FC5D72518E6E2101A0B339B /* iOSDeveloperToolkitApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSDeveloperToolkitApp.swift; sourceTree = ""; }; + 64CADCDAC59205AFA532EF87 /* ActionsViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionsViews.swift; sourceTree = ""; }; + 791078D9EF3EC6308839270F /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; + 7F724AF45C9EBAFFC75A5710 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = ""; }; + 8C6560689EC51FF96E5F71E7 /* iOSDeveloperToolkit.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iOSDeveloperToolkit.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 9FD5D4AAA78961F517FD87B2 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A42E01F498D8AE9BAE52EA83 /* DeveloperImageModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperImageModel.swift; sourceTree = ""; }; + B1FF773D9D443C79240CC926 /* iOSDeveloperToolkitUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = iOSDeveloperToolkitUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + B26E835D20074A0489176A15 /* ReconnectGuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReconnectGuideView.swift; sourceTree = ""; }; + B37EBEC3A6576895CA18665A /* DataViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataViews.swift; sourceTree = ""; }; + B81EECBCB7D517436001EFF8 /* LogViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViews.swift; sourceTree = ""; }; + C28F7F2B73F9F0983878D44D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + C6641462093807C7726C77E6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + CD1BCA4BFD074C07318C65CC /* LiveLogsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLogsModel.swift; sourceTree = ""; }; + D34F678B9113D08044E08644 /* FeatureModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureModels.swift; sourceTree = ""; }; + E7E525F882B8476846CFA326 /* ReadinessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadinessView.swift; sourceTree = ""; }; + EC558873395BD2D10988B49F /* LocationViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationViews.swift; sourceTree = ""; }; + EF4B82A9EAB2FEBF4FBE2FA2 /* AppsViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppsViews.swift; sourceTree = ""; }; + F10F7407E0C32BF6634D85B5 /* ScreenshotHarness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotHarness.swift; sourceTree = ""; }; + F4A6CCABAC76B07D54F044FC /* iOS-Developer-Toolkit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "iOS-Developer-Toolkit"; path = .; sourceTree = SOURCE_ROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 31B9C83311C44A75CE1F24EC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AB27D70470F4FE0BAAC682EA /* ToolkitCore in Frameworks */, + 99273540613D8AD2D5573319 /* DeviceKit in Frameworks */, + E374C2EAE2467F2B1D01215E /* ToolkitFeatures in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0A25233343671F0FF7CF7291 /* Model */ = { + isa = PBXGroup; + children = ( + 791078D9EF3EC6308839270F /* AppModel.swift */, + A42E01F498D8AE9BAE52EA83 /* DeveloperImageModel.swift */, + D34F678B9113D08044E08644 /* FeatureModels.swift */, + CD1BCA4BFD074C07318C65CC /* LiveLogsModel.swift */, + 585ECC84A09B869053F28246 /* LocationModel.swift */, + F10F7407E0C32BF6634D85B5 /* ScreenshotHarness.swift */, + ); + path = Model; + sourceTree = ""; + }; + 0D77E5FFEF8F321AA250224D /* App */ = { + isa = PBXGroup; + children = ( + 9109251F77F5B44367D0DC4B /* iOSDeveloperToolkit */, + 60C4D44F4E03EAEE6FC8028C /* UITests */, + ); + path = App; + sourceTree = ""; + }; + 51157DE22846FB157AD4E91D = { + isa = PBXGroup; + children = ( + 0D77E5FFEF8F321AA250224D /* App */, + 9F007AA80ECE5A10D372536D /* Packages */, + B0EC3E2F6413FB2930A0B63E /* Products */, + ); + sourceTree = ""; + }; + 60C4D44F4E03EAEE6FC8028C /* UITests */ = { + isa = PBXGroup; + children = ( + 2095C064BD8BE67389318E84 /* SmokeUITests.swift */, + ); + path = UITests; + sourceTree = ""; + }; + 88DEFDE8BA686F0DD875A98C /* Views */ = { + isa = PBXGroup; + children = ( + 64CADCDAC59205AFA532EF87 /* ActionsViews.swift */, + EF4B82A9EAB2FEBF4FBE2FA2 /* AppsViews.swift */, + C6641462093807C7726C77E6 /* ContentView.swift */, + B37EBEC3A6576895CA18665A /* DataViews.swift */, + 192082EDB2AA8A31B4DE9A43 /* DeviceViews.swift */, + EC558873395BD2D10988B49F /* LocationViews.swift */, + B81EECBCB7D517436001EFF8 /* LogViews.swift */, + E7E525F882B8476846CFA326 /* ReadinessView.swift */, + B26E835D20074A0489176A15 /* ReconnectGuideView.swift */, + 117839C38E19BA74C82D8382 /* ReferenceViews.swift */, + 50A5A2B1435E385D65A1EA31 /* ShortcutReferenceView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 904BBC696C3A9B098EE68EE4 /* Components */ = { + isa = PBXGroup; + children = ( + 7F724AF45C9EBAFFC75A5710 /* Components.swift */, + ); + path = Components; + sourceTree = ""; + }; + 9109251F77F5B44367D0DC4B /* iOSDeveloperToolkit */ = { + isa = PBXGroup; + children = ( + 904BBC696C3A9B098EE68EE4 /* Components */, + 0A25233343671F0FF7CF7291 /* Model */, + 88DEFDE8BA686F0DD875A98C /* Views */, + 9FD5D4AAA78961F517FD87B2 /* Assets.xcassets */, + C28F7F2B73F9F0983878D44D /* Info.plist */, + 599C314C6B21A9A615B04C7A /* iOSDeveloperToolkit.entitlements */, + 5FC5D72518E6E2101A0B339B /* iOSDeveloperToolkitApp.swift */, + ); + path = iOSDeveloperToolkit; + sourceTree = ""; + }; + 9F007AA80ECE5A10D372536D /* Packages */ = { + isa = PBXGroup; + children = ( + F4A6CCABAC76B07D54F044FC /* iOS-Developer-Toolkit */, + ); + name = Packages; + sourceTree = ""; + }; + B0EC3E2F6413FB2930A0B63E /* Products */ = { + isa = PBXGroup; + children = ( + 8C6560689EC51FF96E5F71E7 /* iOSDeveloperToolkit.app */, + B1FF773D9D443C79240CC926 /* iOSDeveloperToolkitUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + C94E9FC9726C7F28D33DAAA6 /* iOSDeveloperToolkitUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 70865C801CBA83232B5FBACD /* Build configuration list for PBXNativeTarget "iOSDeveloperToolkitUITests" */; + buildPhases = ( + 6ACF5677B377FA5CFAEB5501 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 45C17D893A44643B0E6CFF1F /* PBXTargetDependency */, + ); + name = iOSDeveloperToolkitUITests; + packageProductDependencies = ( + ); + productName = iOSDeveloperToolkitUITests; + productReference = B1FF773D9D443C79240CC926 /* iOSDeveloperToolkitUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + FE597393124482775C8D00A3 /* iOSDeveloperToolkit */ = { + isa = PBXNativeTarget; + buildConfigurationList = B72A0BAA09AF835CE593F962 /* Build configuration list for PBXNativeTarget "iOSDeveloperToolkit" */; + buildPhases = ( + 488940A04D4B7D5484BF1BF5 /* Sources */, + B142682F4800FD134D687DD7 /* Resources */, + 31B9C83311C44A75CE1F24EC /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = iOSDeveloperToolkit; + packageProductDependencies = ( + CE5B03128B0835E13440AFA5 /* ToolkitCore */, + 1A39B4EB7A1BB72ED4812024 /* DeviceKit */, + 529398E755DBC14015ADEA9B /* ToolkitFeatures */, + ); + productName = iOSDeveloperToolkit; + productReference = 8C6560689EC51FF96E5F71E7 /* iOSDeveloperToolkit.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 8F9521A1C32BAC67A0066494 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + C94E9FC9726C7F28D33DAAA6 = { + DevelopmentTeam = ""; + ProvisioningStyle = Manual; + TestTargetID = FE597393124482775C8D00A3; + }; + FE597393124482775C8D00A3 = { + DevelopmentTeam = ""; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 6B061FE82399C983728CD096 /* Build configuration list for PBXProject "iOSDeveloperToolkit" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 51157DE22846FB157AD4E91D; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 2B8F887BB1855CE3EB4A6B49 /* XCLocalSwiftPackageReference "." */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = B0EC3E2F6413FB2930A0B63E /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + FE597393124482775C8D00A3 /* iOSDeveloperToolkit */, + C94E9FC9726C7F28D33DAAA6 /* iOSDeveloperToolkitUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + B142682F4800FD134D687DD7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B6183EF29AF4682E68572B41 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 488940A04D4B7D5484BF1BF5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 244023633FC5B7270C45C371 /* ActionsViews.swift in Sources */, + 296F9F5D4685AB10A7B27CA6 /* AppModel.swift in Sources */, + 795941F1191B8EF4997DF250 /* AppsViews.swift in Sources */, + 8B67289B01733ABC7CEAE819 /* Components.swift in Sources */, + 743B753A384188F88D118565 /* ContentView.swift in Sources */, + ACAA69DD86A135192765E423 /* DataViews.swift in Sources */, + 74E2D8771554772F6F5085DB /* DeveloperImageModel.swift in Sources */, + 0046784D38773E756D16B381 /* DeviceViews.swift in Sources */, + 9F456A81A2AF3E4B9BA9E1A0 /* FeatureModels.swift in Sources */, + 1BEAAB3DA748A2FF696A37BE /* LiveLogsModel.swift in Sources */, + 932BE70EA34036FB92844B1C /* LocationModel.swift in Sources */, + 16410705637BF3C36BD60ADB /* LocationViews.swift in Sources */, + 26019A7B916688FEE3A23585 /* LogViews.swift in Sources */, + 0E51C7116150795B48ED38A0 /* ReadinessView.swift in Sources */, + 42E12E11EB7BA75B9D50C3E8 /* ReconnectGuideView.swift in Sources */, + C11140FE6CD2D8612115AF8F /* ReferenceViews.swift in Sources */, + E0A3673F60ACD7FFF3801DFC /* ScreenshotHarness.swift in Sources */, + 58CE6E900B85D91761091C12 /* ShortcutReferenceView.swift in Sources */, + 1623B3509BB855616EA203F0 /* iOSDeveloperToolkitApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ACF5677B377FA5CFAEB5501 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 25D059CF99AA7F74E66147F4 /* SmokeUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 45C17D893A44643B0E6CFF1F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = FE597393124482775C8D00A3 /* iOSDeveloperToolkit */; + targetProxy = 7D859C2B12ECAB79034F33D7 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 0BD6C2736BB62B4D85C6113B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.hideouts.iOSDeveloperToolkitUITests; + SDKROOT = macosx; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + TEST_TARGET_NAME = iOSDeveloperToolkit; + }; + name = Release; + }; + 3C99A8225A5D8AE95C27B9C8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = "$(ARCHS_STANDARD)"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_ENTITLEMENTS = App/iOSDeveloperToolkit/iOSDeveloperToolkit.entitlements; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = App/iOSDeveloperToolkit/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + "ONLY_ACTIVE_ARCH[config=Debug]" = YES; + PRODUCT_BUNDLE_IDENTIFIER = io.hideouts.iOSDeveloperToolkit; + PRODUCT_MODULE_NAME = iOSDeveloperToolkitApp; + PRODUCT_NAME = "iOS Developer Toolkit"; + SDKROOT = macosx; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + }; + name = Release; + }; + 4D0BEA2325533C972D0ED3C4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = "$(ARCHS_STANDARD)"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_ENTITLEMENTS = App/iOSDeveloperToolkit/iOSDeveloperToolkit.entitlements; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = App/iOSDeveloperToolkit/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + "ONLY_ACTIVE_ARCH[config=Debug]" = YES; + PRODUCT_BUNDLE_IDENTIFIER = io.hideouts.iOSDeveloperToolkit; + PRODUCT_MODULE_NAME = iOSDeveloperToolkitApp; + PRODUCT_NAME = "iOS Developer Toolkit"; + SDKROOT = macosx; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + }; + name = Debug; + }; + 9E7571E39A44000D901788E0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.hideouts.iOSDeveloperToolkitUITests; + SDKROOT = macosx; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + TEST_TARGET_NAME = iOSDeveloperToolkit; + }; + name = Debug; + }; + E9B9355F675A34C3D7B63B9D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + FC0A400902D95188818944CB /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 6B061FE82399C983728CD096 /* Build configuration list for PBXProject "iOSDeveloperToolkit" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E9B9355F675A34C3D7B63B9D /* Debug */, + FC0A400902D95188818944CB /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 70865C801CBA83232B5FBACD /* Build configuration list for PBXNativeTarget "iOSDeveloperToolkitUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9E7571E39A44000D901788E0 /* Debug */, + 0BD6C2736BB62B4D85C6113B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + B72A0BAA09AF835CE593F962 /* Build configuration list for PBXNativeTarget "iOSDeveloperToolkit" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4D0BEA2325533C972D0ED3C4 /* Debug */, + 3C99A8225A5D8AE95C27B9C8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 2B8F887BB1855CE3EB4A6B49 /* XCLocalSwiftPackageReference "." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = .; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 1A39B4EB7A1BB72ED4812024 /* DeviceKit */ = { + isa = XCSwiftPackageProductDependency; + productName = DeviceKit; + }; + 529398E755DBC14015ADEA9B /* ToolkitFeatures */ = { + isa = XCSwiftPackageProductDependency; + productName = ToolkitFeatures; + }; + CE5B03128B0835E13440AFA5 /* ToolkitCore */ = { + isa = XCSwiftPackageProductDependency; + productName = ToolkitCore; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 8F9521A1C32BAC67A0066494 /* Project object */; +} diff --git a/iOSDeveloperToolkit.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/iOSDeveloperToolkit.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/iOSDeveloperToolkit.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/iOSDeveloperToolkit.xcodeproj/xcshareddata/xcschemes/iOSDeveloperToolkit.xcscheme b/iOSDeveloperToolkit.xcodeproj/xcshareddata/xcschemes/iOSDeveloperToolkit.xcscheme new file mode 100644 index 0000000..51801d4 --- /dev/null +++ b/iOSDeveloperToolkit.xcodeproj/xcshareddata/xcschemes/iOSDeveloperToolkit.xcscheme @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios_developer_toolkit/__init__.py b/ios_developer_toolkit/__init__.py deleted file mode 100644 index d5afa17..0000000 --- a/ios_developer_toolkit/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""iOS Developer Toolkit package.""" - -APP_VERSION = "0.3.4" diff --git a/ios_developer_toolkit/__main__.py b/ios_developer_toolkit/__main__.py deleted file mode 100644 index 5491ec2..0000000 --- a/ios_developer_toolkit/__main__.py +++ /dev/null @@ -1,16 +0,0 @@ -import sys - -from ios_developer_toolkit.entrypoint import dispatch_internal - - -def main() -> int: - internal_result = dispatch_internal(sys.argv[1:]) - if internal_result is not None: - return internal_result - from ios_developer_toolkit.app import main as application_main - - return application_main() - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/ios_developer_toolkit/action_palette.py b/ios_developer_toolkit/action_palette.py deleted file mode 100644 index f1d7027..0000000 --- a/ios_developer_toolkit/action_palette.py +++ /dev/null @@ -1,165 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QDialog, - QDialogButtonBox, - QLabel, - QLineEdit, - QListWidget, - QListWidgetItem, - QVBoxLayout, - QWidget, -) - - -class ActionPaletteError(ValueError): - """Raised when an eligible action palette cannot be represented safely.""" - - -@dataclass(frozen=True) -class ActionPaletteEntry: - identifier: str - title: str - category: str - summary: str - keywords: tuple[str, ...] - - -def action_palette_entry( - identifier: str, - title: str, - category: str, - summary: str, - keywords: tuple[str, ...], -) -> ActionPaletteEntry: - normalized_values = tuple(value.strip() for value in (identifier, title, category, summary)) - if any(not value for value in normalized_values): - raise ActionPaletteError("Action palette identifier, title, category, and summary must be non-empty") - normalized_keywords = tuple(keyword.strip() for keyword in keywords) - if any(not keyword for keyword in normalized_keywords): - raise ActionPaletteError("Action palette keywords cannot contain empty values") - return ActionPaletteEntry(*normalized_values, normalized_keywords) - - -def validate_action_palette(entries: tuple[ActionPaletteEntry, ...]) -> tuple[ActionPaletteEntry, ...]: - identifiers = tuple(entry.identifier for entry in entries) - if len(set(identifiers)) != len(identifiers): - duplicates = sorted(identifier for identifier in set(identifiers) if identifiers.count(identifier) > 1) - raise ActionPaletteError(f"Action palette identifiers must be unique: {', '.join(duplicates)}") - return entries - - -def filter_action_palette( - entries: tuple[ActionPaletteEntry, ...], - query: str, -) -> tuple[ActionPaletteEntry, ...]: - terms = tuple(term for term in query.strip().casefold().split() if term) - matching: list[tuple[int, str, str, ActionPaletteEntry]] = [] - for entry in entries: - title = entry.title.casefold() - haystack = " ".join((entry.title, entry.category, entry.summary, *entry.keywords)).casefold() - if not all(term in haystack for term in terms): - continue - rank = 0 if not terms or title.startswith(terms[0]) else 1 if any(term in title for term in terms) else 2 - matching.append((rank, entry.category.casefold(), title, entry)) - return tuple(item[3] for item in sorted(matching, key=lambda item: item[:3])) - - -class ActionPaletteDialog(QDialog): - """Search and return one action from the caller-provided eligible set.""" - - def __init__(self, entries: tuple[ActionPaletteEntry, ...], parent: QWidget | None) -> None: - super().__init__(parent) - self._entries = validate_action_palette(entries) - self._selected_identifier: str | None = None - self.setObjectName("actionPaletteDialog") - self.setWindowTitle("Action Palette") - self.resize(720, 520) - layout = QVBoxLayout(self) - heading = QLabel("Run or open an action that is eligible in the current app state") - heading.setWordWrap(True) - layout.addWidget(heading) - self.search = QLineEdit() - self.search.setObjectName("actionPaletteSearch") - self.search.setPlaceholderText("Search workspaces, commands, diagnostics, and utilities") - self.search.setAccessibleName("Search eligible actions") - self.search.textChanged.connect(self._filter_entries) - self.search.returnPressed.connect(self._accept_current) - layout.addWidget(self.search) - self.results = QListWidget() - self.results.setObjectName("actionPaletteResults") - self.results.setAccessibleName("Eligible action results") - self.results.currentItemChanged.connect(self._selection_changed) - self.results.itemActivated.connect(self._item_activated) - layout.addWidget(self.results, 1) - self.summary = QLabel() - self.summary.setObjectName("actionPaletteSummary") - self.summary.setWordWrap(True) - layout.addWidget(self.summary) - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Open | QDialogButtonBox.StandardButton.Cancel) - buttons.setObjectName("actionPaletteButtons") - open_button = buttons.button(QDialogButtonBox.StandardButton.Open) - cancel_button = buttons.button(QDialogButtonBox.StandardButton.Cancel) - open_button.setObjectName("actionPaletteOpenButton") - cancel_button.setObjectName("actionPaletteCancelButton") - open_button.setAccessibleName("Open selected eligible action") - cancel_button.setAccessibleName("Close action palette") - buttons.accepted.connect(self._accept_current) - buttons.rejected.connect(self.reject) - layout.addWidget(buttons) - self._open_button = open_button - self._filter_entries() - self.search.setFocus(Qt.FocusReason.ShortcutFocusReason) - - def selected_identifier(self) -> str: - if self._selected_identifier is None: - raise ActionPaletteError("Action palette closed without selecting an eligible action") - return self._selected_identifier - - def _filter_entries(self) -> None: - matching = filter_action_palette(self._entries, self.search.text()) - self.results.blockSignals(True) - self.results.clear() - for entry in matching: - item = QListWidgetItem(f"{entry.title} · {entry.category}") - item.setData(Qt.ItemDataRole.UserRole, entry.identifier) - item.setToolTip(entry.summary) - self.results.addItem(item) - if matching: - self.results.setCurrentRow(0) - self.summary.setText(matching[0].summary) - else: - self.summary.setText("No eligible action matches this search in the current app state.") - self._open_button.setEnabled(bool(matching)) - self.results.blockSignals(False) - - def _selection_changed(self, current: QListWidgetItem | None, previous: QListWidgetItem | None) -> None: - del previous - if current is None: - self._open_button.setEnabled(False) - return - identifier = current.data(Qt.ItemDataRole.UserRole) - if not isinstance(identifier, str): - raise ActionPaletteError("Selected action palette row has no string identifier") - entry = next((candidate for candidate in self._entries if candidate.identifier == identifier), None) - if entry is None: - raise ActionPaletteError(f"Selected action palette entry is unavailable: {identifier}") - self.summary.setText(entry.summary) - self._open_button.setEnabled(True) - - def _item_activated(self, item: QListWidgetItem) -> None: - self.results.setCurrentItem(item) - self._accept_current() - - def _accept_current(self) -> None: - current = self.results.currentItem() - if current is None: - return - identifier = current.data(Qt.ItemDataRole.UserRole) - if not isinstance(identifier, str): - raise ActionPaletteError("Selected action palette row has no string identifier") - self._selected_identifier = identifier - self.accept() diff --git a/ios_developer_toolkit/action_safety.py b/ios_developer_toolkit/action_safety.py deleted file mode 100644 index f10cb80..0000000 --- a/ios_developer_toolkit/action_safety.py +++ /dev/null @@ -1,114 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Literal - -from ios_developer_toolkit.catalog import is_potentially_mutating -from ios_developer_toolkit.command_catalog import RiskLevel - - -ActionSafetyLevel = Literal["read-only", "host-write", "device-change", "high-impact"] - - -@dataclass(frozen=True) -class ActionSafetyProfile: - """The confirmation boundary for a command before it reaches a device or local filesystem.""" - - level: ActionSafetyLevel - impact: str - requires_typed_acknowledgement: bool - requires_backup_acknowledgement: bool - - -def guided_action_safety(risk: RiskLevel) -> ActionSafetyProfile: - """Return the execution safety profile for a reviewed guided preset.""" - - if risk == "read-only": - return ActionSafetyProfile("read-only", "This command is categorized as read-oriented.", False, False) - if risk == "host-write": - return ActionSafetyProfile( - "host-write", - "This command writes device-derived data to a local path on the Mac.", - False, - False, - ) - return ActionSafetyProfile( - "device-change", - "This command changes device application, UI, location, process, or mounted-image state.", - True, - False, - ) - - -def advanced_action_safety(arguments: tuple[str, ...]) -> ActionSafetyProfile: - """Classify an arbitrary pymobiledevice3 argument vector before execution.""" - - if _has_prefix(arguments, _HIGH_IMPACT_PREFIXES): - return ActionSafetyProfile( - "high-impact", - "This command can erase data, restore firmware, alter activation, reboot, shut down, or make another high-impact device change.", - True, - True, - ) - if _has_prefix(arguments, _HOST_WRITE_PREFIXES): - return ActionSafetyProfile( - "host-write", - "This command writes device-derived data to a local path on the Mac.", - False, - False, - ) - if is_potentially_mutating(arguments): - return ActionSafetyProfile( - "device-change", - "This command is not in the read-only allowlist and may change device or host state.", - True, - False, - ) - return ActionSafetyProfile("read-only", "This command is in the read-only allowlist.", False, False) - - -def confirmation_phrase(profile: ActionSafetyProfile, device_identifier: str | None) -> str: - """Create the exact acknowledgement phrase needed for state-changing actions.""" - - if not profile.requires_typed_acknowledgement: - raise ValueError("A typed acknowledgement was requested for an action that does not require one") - target = _target_suffix(device_identifier) - if profile.level == "high-impact": - return f"IRREVERSIBLE {target}" - return f"RUN {target}" - - -def _has_prefix(arguments: tuple[str, ...], prefixes: tuple[tuple[str, ...], ...]) -> bool: - return any(arguments[: len(prefix)] == prefix for prefix in prefixes) - - -def _target_suffix(device_identifier: str | None) -> str: - if device_identifier is None: - return "LOCAL" - cleaned = "".join(character for character in device_identifier.upper() if character.isalnum()) - if len(cleaned) < 6: - raise ValueError("The selected device identifier is too short to create a confirmation phrase") - return cleaned[-6:] - - -_HIGH_IMPACT_PREFIXES: tuple[tuple[str, ...], ...] = ( - ("restore",), - ("profile", "erase-device"), - ("profile", "supervise"), - ("backup2", "erase-device"), - ("backup2", "restore"), - ("diagnostics", "restart"), - ("diagnostics", "shutdown"), - ("activation", "activate"), - ("activation", "deactivate"), - ("mounter", "roll-personalization-nonce"), - ("mounter", "roll-cryptex-nonce"), -) - -_HOST_WRITE_PREFIXES: tuple[tuple[str, ...], ...] = ( - ("pcap",), - ("btlogger",), - ("crash", "pull"), - ("afc", "pull"), - ("developer", "dvt", "screenshot"), -) diff --git a/ios_developer_toolkit/app.py b/ios_developer_toolkit/app.py deleted file mode 100644 index abfb732..0000000 --- a/ios_developer_toolkit/app.py +++ /dev/null @@ -1,7795 +0,0 @@ -from __future__ import annotations - -import json -import os -import shlex -import subprocess -import sys -import tempfile -from collections.abc import Callable -from datetime import datetime, timezone -from pathlib import Path -from typing import Literal, Mapping - -from PySide6.QtCore import QObject, QProcess, QProcessEnvironment, QRect, QTimer, QUrl, Qt, Signal -from PySide6.QtGui import ( - QBrush, - QCloseEvent, - QColor, - QDesktopServices, - QFont, - QIcon, - QMouseEvent, - QPaintEvent, - QPainter, - QPen, - QPixmap, - QKeySequence, - QShortcut, - QTextCursor, -) -from PySide6.QtWidgets import ( - QApplication, - QCheckBox, - QComboBox, - QDialog, - QDialogButtonBox, - QFileDialog, - QFormLayout, - QFrame, - QGridLayout, - QGroupBox, - QHeaderView, - QHBoxLayout, - QInputDialog, - QLabel, - QLineEdit, - QListWidget, - QListWidgetItem, - QMainWindow, - QMessageBox, - QPlainTextEdit, - QProgressBar, - QPushButton, - QRadioButton, - QScrollArea, - QSpinBox, - QSplitter, - QStackedWidget, - QTableWidget, - QTableWidgetItem, - QTabWidget, - QTextBrowser, - QVBoxLayout, - QWidget, -) - -from ios_developer_toolkit import APP_VERSION -from ios_developer_toolkit.action_safety import ( - ActionSafetyProfile, - advanced_action_safety, - confirmation_phrase, - guided_action_safety, -) -from ios_developer_toolkit.action_palette import ( - ActionPaletteDialog, - ActionPaletteEntry, - action_palette_entry, - validate_action_palette, -) -from ios_developer_toolkit.backup_process import BackupProcessController -from ios_developer_toolkit.backup_protocol import BackupAction, BackupEvent, BackupRequest, BackupRequestError -from ios_developer_toolkit.case_workflow import CaseWorkflowError, create_guided_case -from ios_developer_toolkit.capability_matrix import ( - CapabilityMatrixError, - CapabilityResult, - CapabilityState, - CapabilityWorkerCompleted, - CapabilityWorkerStarted, - capability_definitions, - capability_state_counts, - capability_state_label, - evaluate_preset_readiness, - parse_capability_worker_event, - untested_capability_results, -) -from ios_developer_toolkit.connection_diagnostics import ( - ConnectionDiagnostic, - devices_connection_diagnostic, - failed_connection_diagnostic, - initial_connection_diagnostic, - launch_failed_connection_diagnostic, - malformed_output_connection_diagnostic, - process_error_connection_diagnostic, - timed_out_connection_diagnostic, -) -from ios_developer_toolkit.collection_process import CollectionProcessController -from ios_developer_toolkit.collection_protocol import CollectionEvent -from ios_developer_toolkit.device_compatibility import ( - CompatibilityReport, - DeviceCompatibilityError, - DeviceCompatibilityObservation, - append_observation, - compatibility_history_path, - create_compatibility_report, - create_observation, - current_report_environment, - latest_observations, - load_observations, - render_compatibility_json, - render_compatibility_markdown, - write_compatibility_json_report, - write_compatibility_markdown_report, -) -from ios_developer_toolkit.demo_mode import demo_connection_banner, demo_device -from ios_developer_toolkit.external_tools import ( - ExternalToolExecutable, - ExternalToolIdentifier, - ExternalToolInstallation, - ExternalToolSpec, - ExternalToolValidationError, - discover_external_tool_executables, - external_tool_command, - external_tool_environment, - external_tool_spec, - external_tool_specs, - inspect_external_tool_executable, - parse_external_tool_version, - validate_external_tool_installation, -) -from ios_developer_toolkit.gui_pages import ( - build_home_page, - build_live_logs_page, - build_safety_page, - toolkit_stylesheet, -) -from ios_developer_toolkit.command_catalog import ( - CommandCatalogError, - CommandPreset, - ManPageEntry, - ParameterSpec, - command_presets, - manpage_entries, - preset_categories, - render_preset_arguments, - risk_title, -) -from ios_developer_toolkit.command_drift import ( - HelpRouteProbe, - evaluate_command_drift, - help_routes_for_presets, - render_command_drift_report, -) -from ios_developer_toolkit.installed_apps import ( - InstalledApp, - InstalledAppsDataError, - format_byte_count, - parse_installed_apps_json, -) -from ios_developer_toolkit.interactive_process import InteractiveProcessController -from ios_developer_toolkit.ipa_inspector import ( - IPAInspection, - IPAInspectionError, - format_inspection, - parse_inspection_json, - validate_bundle_identifier, -) -from ios_developer_toolkit.location_lab import ( - Coordinates, - GPXInspection, - LocationEvidenceEvent, - LocationLabError, - SavedLocation, - add_saved_location, - append_evidence_event, - build_route, - clear_location_arguments, - coordinates_to_map_fractions, - inspect_gpx, - load_saved_locations, - map_fractions_to_coordinates, - move_coordinates, - parse_location_input, - parse_route_waypoints, - parse_ios_major, - play_location_arguments, - remove_saved_location, - save_saved_locations, - saved_locations_path, - set_location_arguments, - utc_now, - validate_coordinates, -) -from ios_developer_toolkit.live_logs import LiveLogError, LiveLogWindow, log_stream_specs, stream_spec -from ios_developer_toolkit.models import DeviceDataError, IOSDevice, parse_devices_json -from ios_developer_toolkit.mvt_connector import ( - MVT_BACKUP_GUIDE_URL, - MVT_INSTALLATION_URL, - MVT_REPOSITORY_URL, - MVTAnalysisRequest, - MVTExecutable, - MVTInstallation, - MVTValidationError, - create_mvt_analysis_request, - discover_mvt_executables, - inspect_mvt_executable, - mvt_analysis_arguments, - mvt_command, - mvt_environment, - mvt_setup_commands, - mvt_version_arguments, - parse_mvt_version_output, -) -from ios_developer_toolkit.operation_history import ( - OperationContext, - OperationHistoryDialog, - OperationRecord, - append_operation_record, - operation_context, - operation_record, - with_output_paths, -) -from ios_developer_toolkit.qt_process import ( - FiniteProcessController, - OperationResult, - finite_process_request, -) -from ios_developer_toolkit.runtime import ( - ExecutableCommand, - command_arguments, - command_argv, - command_text, - device_environment, - is_frozen_runtime, - pymobiledevice3_command, - worker_command, -) -from ios_developer_toolkit.support_bundle import ( - SupportBundleContext, - SupportBundleError, - SupportStatus, - create_sanitized_support_bundle, -) -from ios_developer_toolkit.ufade_connector import ( - UFADE_INSTALLATION_URL, - UFADE_REPOSITORY_URL, - UFADE_USAGE_URL, - UFADEInstallation, - UFADEValidationError, - checkout_python_path, - inspect_ufade_installation, - macos_setup_commands, -) -from ios_developer_toolkit.validation import output_indicates_failure -from ios_developer_toolkit.workspace_profile import ( - AppWorkflowPreferences, - BackupWorkflowPreferences, - EvidenceWorkflowPreferences, - LocationWorkflowPreferences, - WorkspaceProfile, - WorkspaceProfileError, - load_workspace_profile, - render_workspace_profile_json, - render_workspace_profile_preview, - validate_workspace_profile, - write_workspace_profile, -) -from ios_developer_toolkit.xcode_handoff import ( - XcodeHandoffError, - coredevice_details_handoff, - rvi_list_handoff, - validated_xcode_artifact, - xcode_project_handoff, -) - - -XCODE_CANDIDATE_DDI = Path("/Library/Developer/CoreDevice/CandidateDDIs/iOS_DDI.dmg") -DEVELOPER_DISK_IMAGE_REPOSITORY = "https://github.com/doronz88/DeveloperDiskImage" -MANPAGE_HELP_TIMEOUT_MS = 15_000 -MANPAGE_HELP_KILL_DELAY_MS = 1_500 -COMMAND_DRIFT_HELP_TIMEOUT_MS = 5_000 -RECONNECT_TIMEOUT_MS = 30_000 -DEVICE_SCAN_TIMEOUT_MS = 10_000 -DDI_ACTION_TIMEOUT_MS = 15 * 60_000 -APPS_ACTION_TIMEOUT_MS = 10 * 60_000 -IPA_INSPECTION_TIMEOUT_MS = 5 * 60_000 -IPA_INSTALL_TIMEOUT_MS = 15 * 60_000 -COLLECTION_FINALIZATION_TIMEOUT_MS = 2 * 60_000 -XCODE_HANDOFF_TIMEOUT_MS = 60_000 -PROCESS_TERMINATE_GRACE_MS = 1_500 -EXTERNAL_TOOL_TIMEOUT_MS = 30_000 -MAX_SESSION_OPERATION_RECORDS = 250 -EXTERNAL_TOOL_OBJECT_SUFFIXES: Mapping[ExternalToolIdentifier, str] = { - "go-ios": "GoIos", - "idb": "Idb", - "ipsw": "Ipsw", -} - - -def application_icon_path() -> Path: - icon_path = Path(__file__).resolve().parent / "assets" / "iosdevtoolkit.png" - if not icon_path.is_file(): - raise FileNotFoundError(f"Application icon is missing: {icon_path}") - return icon_path - - -def location_map_asset_path() -> Path: - map_path = Path(__file__).resolve().parent / "assets" / "location-world-map.png" - if not map_path.is_file(): - raise FileNotFoundError(f"Location Lab map asset is missing: {map_path}") - return map_path - - -def qprocess_environment(values: Mapping[str, str]) -> QProcessEnvironment: - environment = QProcessEnvironment.systemEnvironment() - for key, value in values.items(): - environment.insert(key, value) - return environment - - -def base_environment() -> Mapping[str, str]: - environment = dict(os.environ) - environment["PYTHONUNBUFFERED"] = "1" - environment["NO_COLOR"] = "1" - return environment - - -class DeviceScanner(QObject): - devices_changed = Signal(object) - scan_error = Signal(str) - diagnostic_changed = Signal(object) - - def __init__(self, executable: ExecutableCommand) -> None: - super().__init__() - self._executable = executable - self._timer = QTimer(self) - self._timer.setInterval(3000) - self._timer.timeout.connect(self.scan) - self._controller = FiniteProcessController(self) - self._controller.completed.connect(self._completed) - self._stopping = False - - def start(self) -> None: - self._stopping = False - self.scan() - self._timer.start() - - def stop(self) -> None: - self._stopping = True - self._timer.stop() - self._controller.shutdown(3000, 1000) - - def scan(self) -> None: - if self._stopping: - return - if self._controller.is_running(): - return - request = finite_process_request( - self._executable, - ("usbmux", "list"), - base_environment(), - DEVICE_SCAN_TIMEOUT_MS, - PROCESS_TERMINATE_GRACE_MS, - ) - self._controller.start(request) - - def _completed(self, result_object: object) -> None: - if self._stopping: - return - if not isinstance(result_object, OperationResult): - raise TypeError(f"Expected OperationResult, received {type(result_object).__name__}") - if result_object.outcome == "launch-failed": - diagnostic = launch_failed_connection_diagnostic() - self.diagnostic_changed.emit(diagnostic) - self.scan_error.emit(diagnostic.detail) - return - if result_object.outcome == "timed-out": - diagnostic = timed_out_connection_diagnostic() - self.diagnostic_changed.emit(diagnostic) - self.scan_error.emit(diagnostic.detail) - return - if result_object.outcome in ("crashed", "cancelled"): - diagnostic = process_error_connection_diagnostic() - self.diagnostic_changed.emit(diagnostic) - self.scan_error.emit(diagnostic.detail) - return - if result_object.outcome == "failed": - if result_object.exit_code is None: - raise RuntimeError("Failed device discovery did not provide an exit code") - diagnostic = failed_connection_diagnostic(result_object.exit_code) - self.diagnostic_changed.emit(diagnostic) - self.scan_error.emit(diagnostic.detail) - return - if result_object.outcome != "succeeded": - raise RuntimeError(f"Unsupported device discovery outcome: {result_object.outcome}") - try: - devices = parse_devices_json(result_object.stdout.decode("utf-8")) - except (DeviceDataError, json.JSONDecodeError, UnicodeDecodeError): - diagnostic = malformed_output_connection_diagnostic() - self.diagnostic_changed.emit(diagnostic) - self.scan_error.emit(diagnostic.detail) - return - self.diagnostic_changed.emit(devices_connection_diagnostic(len(devices))) - self.devices_changed.emit(devices) - - -class LocationMapWidget(QWidget): - coordinate_selected = Signal(float, float) - - def __init__(self, marker: Coordinates) -> None: - super().__init__() - self._map = QPixmap(str(location_map_asset_path())) - if self._map.isNull(): - raise FileNotFoundError(f"Could not load Location Lab map asset: {location_map_asset_path()}") - self._marker = marker - self.setMinimumSize(360, 190) - self.setAccessibleName("Offline world map coordinate picker") - self.setToolTip("Click the offline map to fill the coordinate fields. The device is not changed until confirmation.") - - def set_marker(self, marker: Coordinates) -> None: - self._marker = marker - self.update() - - def _map_rectangle(self) -> QRect: - available_width = max(1, self.width()) - available_height = max(1, self.height()) - map_width = available_width - map_height = max(1, map_width // 2) - if map_height > available_height: - map_height = available_height - map_width = max(1, map_height * 2) - return QRect( - (available_width - map_width) // 2, - (available_height - map_height) // 2, - map_width, - map_height, - ) - - def paintEvent(self, event: QPaintEvent) -> None: - del event - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) - rectangle = self._map_rectangle() - painter.drawPixmap(rectangle, self._map) - horizontal, vertical = coordinates_to_map_fractions(self._marker) - marker_x = rectangle.left() + horizontal * rectangle.width() - marker_y = rectangle.top() + vertical * rectangle.height() - painter.setPen(QPen(QColor("#ffffff"), 2.0)) - painter.setBrush(QBrush(QColor("#e43b3b"))) - painter.drawEllipse(int(marker_x) - 6, int(marker_y) - 6, 12, 12) - painter.end() - - def mousePressEvent(self, event: QMouseEvent) -> None: - if event.button() != Qt.MouseButton.LeftButton: - event.ignore() - return - rectangle = self._map_rectangle() - position = event.position() - if not rectangle.contains(int(position.x()), int(position.y())): - event.ignore() - return - coordinates = map_fractions_to_coordinates( - (position.x() - rectangle.left()) / rectangle.width(), - (position.y() - rectangle.top()) / rectangle.height(), - ) - self.set_marker(coordinates) - self.coordinate_selected.emit(coordinates.latitude, coordinates.longitude) - event.accept() - - -class DeveloperModeDialog(QDialog): - def __init__(self) -> None: - super().__init__() - self.setWindowTitle("Enable Developer Mode on the iPhone or iPad") - self.setMinimumWidth(600) - layout = QVBoxLayout(self) - heading = QLabel("Complete these steps on the connected device") - heading.setObjectName("developerModeHeading") - heading.setFont(QFont(heading.font().family(), 17, QFont.Weight.DemiBold)) - layout.addWidget(heading) - instructions = QTextBrowser() - instructions.setOpenExternalLinks(True) - instructions.setHtml( - """ -
    -
  1. Unlock the iPhone or iPad, connect it by USB, and tap Trust if prompted.
  2. -
  3. Open Settings → Privacy & Security → Developer Mode.
  4. -
  5. Turn Developer Mode on and tap Restart.
  6. -
  7. After restart, unlock the device, tap Turn On or Enable, and enter the device passcode.
  8. -
  9. Reconnect and trust the Mac again if iOS asks.
  10. -
-

If Developer Mode is missing, first pair the device in Xcode using - Window → Devices and Simulators, then return to Settings.

-

Security note: Developer Mode deliberately exposes development services. - Turn it off and restart the device when the investigation is finished if you no longer need it.

- """ - ) - instructions.setMinimumHeight(310) - layout.addWidget(instructions) - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) - buttons.accepted.connect(self.accept) - layout.addWidget(buttons) - - -class UFADEGuideDialog(QDialog): - def __init__(self) -> None: - super().__init__() - self.setWindowTitle("Install and Run UFADE on macOS") - self.resize(820, 690) - layout = QVBoxLayout(self) - heading = QLabel("UFADE setup and acquisition walkthrough") - heading.setObjectName("ufadeGuideHeading") - heading.setFont(QFont(heading.font().family(), 18, QFont.Weight.DemiBold)) - layout.addWidget(heading) - instructions = QTextBrowser() - instructions.setObjectName("ufadeGuideContent") - instructions.setOpenExternalLinks(True) - instructions.setHtml( - f""" -

1. Install UFADE in a separate Python 3.11 environment

-

Click Copy Setup Commands in the Backup workspace, paste the commands into Terminal, and wait for - every command to finish. The recommended clone includes UFADE's developer-image submodule. Do not install - UFADE's older pinned dependencies into the iOS Developer Toolkit environment.

- -

2. Point the toolkit at that installation

-
    -
  1. UFADE checkout: choose the cloned UFADE folder containing - ufade.py, requirements.txt, and LICENSE.
  2. -
  3. Python 3.11: click Use Checkout .venv, or choose - UFADE/.venv/bin/python manually.
  4. -
  5. Working/output folder: choose protected local storage with enough free space.
  6. -
  7. Click Validate Installation. Fix every reported missing file, Python-version, or import error - before launching.
  8. -
- -

3. Connect the device

-

Connect one authorized iPhone or iPad by USB, unlock it, tap Trust, and keep it unlocked while - UFADE discovers it. The toolkit's selected device is shown for confirmation, but UFADE performs its own - device discovery and selection.

- -

4. Launch and operate UFADE

-
    -
  1. Click Launch UFADE and review the confirmation showing the checkout, Python, device, and - working directory.
  2. -
  3. In UFADE's window, confirm or change the output directory. The toolkit starts UFADE in the selected - working directory, which becomes its initial default.
  4. -
  5. Choose Save device information, a Backup Option, Collect Unified Logs, - Developer Options, or Advanced Options inside UFADE.
  6. -
  7. Answer password and acquisition prompts only in UFADE. The toolkit never receives those values.
  8. -
  9. Use UFADE's own progress and stop controls. Closing this toolkit does not stop UFADE.
  10. -
- -

5. Developer options and completion

-

UFADE Developer Options may require Developer Mode, a compatible developer image, and the populated - ufade_developer submodule. If the checkout was cloned without submodules, run - git submodule update --init --recursive in the UFADE folder and validate again.

-

After acquisition, wait for UFADE to report completion, review the output folder, preserve hashes and - custody records separately, and protect the output before sharing it. A successful launch is not proof - that every selected acquisition source completed.

- -

Official UFADE installation instructions · - Official UFADE usage guide

- """ - ) - layout.addWidget(instructions, 1) - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) - buttons.accepted.connect(self.accept) - layout.addWidget(buttons) - - -class MVTGuideDialog(QDialog): - def __init__(self) -> None: - super().__init__() - self.setWindowTitle("Analyze a Backup with MVT") - self.resize(820, 690) - layout = QVBoxLayout(self) - heading = QLabel("Consent-based external MVT backup analysis") - heading.setObjectName("mvtGuideHeading") - heading.setFont(QFont(heading.font().family(), 18, QFont.Weight.DemiBold)) - layout.addWidget(heading) - instructions = QTextBrowser() - instructions.setObjectName("mvtGuideContent") - instructions.setOpenExternalLinks(True) - instructions.setHtml( - f""" -

1. Install MVT separately

-

Use Copy Setup Commands in the MVT Analysis tab, run the commands in Terminal, then choose the - resulting mvt-ios executable. The toolkit does not bundle, import, update, or modify MVT.

- -

2. Prepare a consented backup copy

-

Choose one iTunes-style backup folder containing Manifest.db and Info.plist. - MVT analyzes a decrypted backup. If the source is encrypted, decrypt a protected working copy outside this - toolkit using MVT's official instructions. Do not paste a password into this application: it has no backup - password field and removes inherited MVT password variables from the child process.

- -

3. Isolate the results

-

Choose a new output path that does not exist and is outside the source backup. The toolkit refuses an - existing path so a new run cannot mix with earlier results. MVT creates JSON records and its own command log - in that folder. Optional input hashes can substantially increase runtime on a large backup.

- -

4. Decide whether to supply indicators or network access

-

STIX2/JSON indicator files are opt-in. Network access is off by default, which prevents shortened-URL - resolution and other MVT network requests during the run. Enable it only after reviewing the selected - indicators and the privacy implications. Automatic version and indicator update checks remain disabled for - a reproducible handoff.

- -

5. Interpret the output carefully

-

MVT extracts forensic records and can identify matches against supplied indicators. A completed run, - zero alerts, or no *_detected.json files does not establish that a device is clean, safe, - uncompromised, or never targeted. Public indicators can be incomplete or stale. Preserve the original backup, - record tool and indicator versions, and seek qualified forensic assistance for high-risk cases.

- -

Official MVT installation · - Official iOS backup-analysis guide · - MVT source repository

- """ - ) - layout.addWidget(instructions, 1) - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) - buttons.setObjectName("mvtGuideButtons") - buttons.accepted.connect(self.accept) - layout.addWidget(buttons) - - -class MainWindow(QMainWindow): - def __init__(self) -> None: - super().__init__() - self.setWindowTitle(f"iOS Developer Toolkit {APP_VERSION}") - self.setAccessibleName("iOS Developer Toolkit main window") - self.setAccessibleDescription( - "Keyboard-first workspace for authorized iPhone and iPad development, diagnostics, backup, and evidence collection." - ) - self.setWindowIcon(QIcon(str(application_icon_path()))) - self.resize(1280, 840) - self._pmd3 = pymobiledevice3_command() - self._devices: tuple[IOSDevice, ...] = () - self._connection_diagnostic = initial_connection_diagnostic() - self._demo_mode = False - self._demo_device = demo_device() - self._active_device_identifier: str | None = None - self._guided_udids: set[str] = set() - self._reconnect_active = False - self._reconnect_timeout_timer = QTimer(self) - self._reconnect_timeout_timer.setSingleShot(True) - self._reconnect_timeout_timer.timeout.connect(self._reconnect_timed_out) - self._action_controller = FiniteProcessController(self) - self._action_controller.stdout_received.connect(self._append_action_output) - self._action_controller.stderr_received.connect(self._append_action_output) - self._action_controller.completed.connect(self._action_completed) - self._action_context = "" - self._capability_process: QProcess | None = None - self._capability_stdout_buffer = bytearray() - self._capability_stderr = bytearray() - self._capability_results: dict[str, CapabilityResult] = { - result.identifier: result for result in untested_capability_results() - } - self._capability_completed_at: str | None = None - self._capability_cancel_reason: str | None = None - self._capability_worker_completed = False - self._compatibility_history_path = compatibility_history_path(Path.home()) - self._compatibility_observations: tuple[DeviceCompatibilityObservation, ...] = () - self._compatibility_history_error: str | None = None - try: - self._compatibility_observations = load_observations(self._compatibility_history_path) - except DeviceCompatibilityError as error: - self._compatibility_history_error = str(error) - self._collection_controller = CollectionProcessController(self) - self._collection_controller.stdout_received.connect(self._append_collection_output) - self._collection_controller.stderr_received.connect(self._append_collection_output) - self._collection_controller.event_received.connect(self._collection_event_received) - self._collection_controller.completed.connect(self._collection_completed) - self._collection_case_finished = False - self._close_after_collection = False - self._ipa_inspection_controller = FiniteProcessController(self) - self._ipa_inspection_controller.completed.connect(self._ipa_inspection_completed) - self._ipa_inspection: IPAInspection | None = None - self._selected_ipa: Path | None = None - self._sideload_controller = FiniteProcessController(self) - self._sideload_controller.stdout_received.connect(self._append_sideload_output) - self._sideload_controller.stderr_received.connect(self._append_sideload_output) - self._sideload_controller.completed.connect(self._sideload_completed) - self._sideload_context = "" - self._apps_controller = FiniteProcessController(self) - self._apps_controller.stderr_received.connect(self._append_apps_stderr) - self._apps_controller.completed.connect(self._apps_completed) - self._apps_context = "" - self._installed_apps: tuple[InstalledApp, ...] = () - self._backup_controller = BackupProcessController(self) - self._backup_controller.event_received.connect(self._handle_backup_event) - self._backup_controller.stderr_received.connect(self._append_backup_stderr) - self._backup_controller.completed.connect(self._backup_completed) - self._backup_action: BackupAction | None = None - self._backup_encryption_state: bool | None = None - self._last_backup_path: Path | None = None - self._ufade_installation: UFADEInstallation | None = None - self._mvt_controller = InteractiveProcessController(self) - self._mvt_controller.stdout_received.connect(self._append_mvt_output) - self._mvt_controller.stderr_received.connect(self._append_mvt_output) - self._mvt_controller.completed.connect(self._mvt_completed) - self._mvt_installation: MVTInstallation | None = None - self._mvt_pending_executable: MVTExecutable | None = None - self._mvt_operation = "" - self._mvt_request: MVTAnalysisRequest | None = None - self._mvt_ioc_paths: tuple[Path, ...] = () - self._mvt_temporary_config: tempfile.TemporaryDirectory[str] | None = None - self._external_tool_controller = FiniteProcessController(self) - self._external_tool_controller.completed.connect(self._external_tool_completed) - self._external_tool_installations: dict[ExternalToolIdentifier, ExternalToolInstallation] = {} - self._external_tool_pending_executable: ExternalToolExecutable | None = None - self._external_tool_active_identifier: ExternalToolIdentifier | None = None - self._external_tool_operation: Literal["validate", "probe"] | None = None - self._external_tool_fields: dict[ExternalToolIdentifier, QLineEdit] = {} - self._external_tool_statuses: dict[ExternalToolIdentifier, QLabel] = {} - self._external_tool_outputs: dict[ExternalToolIdentifier, QPlainTextEdit] = {} - self._external_tool_validate_buttons: dict[ExternalToolIdentifier, QPushButton] = {} - self._external_tool_probe_buttons: dict[ExternalToolIdentifier, QPushButton] = {} - self._external_tool_stop_buttons: dict[ExternalToolIdentifier, QPushButton] = {} - self._external_tool_path_buttons: dict[ExternalToolIdentifier, tuple[QPushButton, QPushButton]] = {} - self._location_process: QProcess | None = None - self._location_operation = "" - self._location_arguments: tuple[str, ...] = () - self._location_buffer = bytearray() - self._location_started_at = "" - self._location_device_identifier: str | None = None - self._location_device_name = "" - self._location_device_version = "" - self._location_coordinates: Coordinates | None = None - self._selected_location_gpx: GPXInspection | None = None - self._location_operation_gpx: GPXInspection | None = None - self._location_clear_after_stop = False - self._location_may_be_simulated = False - self._location_log_path: Path | None = None - self._live_log_windows: set[LiveLogWindow] = set() - self._saved_locations_path = saved_locations_path(Path.home()) - self._saved_locations: tuple[SavedLocation, ...] = () - self._saved_locations_error: str | None = None - try: - self._saved_locations = load_saved_locations(self._saved_locations_path) - except LocationLabError as error: - self._saved_locations_error = str(error) - self._console_controller = InteractiveProcessController(self) - self._console_controller.stdout_received.connect(self._append_console_output) - self._console_controller.stderr_received.connect(self._append_console_output) - self._console_controller.completed.connect(self._console_completed) - self._presets = command_presets() - self._current_preset: CommandPreset | None = None - self._preset_parameter_fields: dict[str, QLineEdit] = {} - self._manpages = manpage_entries() - self._manpage_controller = FiniteProcessController(self) - self._manpage_controller.completed.connect(self._manpage_completed) - self._manpage_cache: dict[tuple[str, ...], str] = {} - self._manpage_active_path: tuple[str, ...] | None = None - self._command_drift_controller = FiniteProcessController(self) - self._command_drift_controller.completed.connect(self._command_drift_probe_completed) - self._command_drift_paths: tuple[tuple[str, ...], ...] = () - self._command_drift_index = 0 - self._command_drift_active_path: tuple[str, ...] | None = None - self._command_drift_session_active = False - self._command_drift_cancelled = False - self._command_drift_probes: dict[tuple[str, ...], HelpRouteProbe] = {} - self._last_case_path: Path | None = None - self._active_case_path: Path | None = None - self._operation_records: tuple[OperationRecord, ...] = () - self._pending_operation_contexts: dict[str, OperationContext] = {} - self._keyboard_shortcuts: list[QShortcut] = [] - self._build_ui() - self._configure_accessibility() - self._configure_keyboard_shortcuts() - self._scanner = DeviceScanner(self._pmd3) - self._scanner.devices_changed.connect(self._devices_changed) - self._scanner.scan_error.connect(self._scan_error) - self._scanner.diagnostic_changed.connect(self._connection_diagnostic_changed) - self._scanner.start() - - def _build_ui(self) -> None: - root = QWidget() - root_layout = QVBoxLayout(root) - root_layout.setContentsMargins(22, 18, 22, 18) - root_layout.setSpacing(14) - - header_layout = QHBoxLayout() - logo = QLabel() - logo.setObjectName("appLogo") - logo_pixmap = QPixmap(str(application_icon_path())) - if logo_pixmap.isNull(): - raise RuntimeError(f"Could not load application logo: {application_icon_path()}") - logo.setPixmap( - logo_pixmap.scaled( - 66, - 66, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation, - ) - ) - logo.setFixedSize(66, 66) - logo.setAccessibleName("iOS Developer Toolkit logo") - header_layout.addWidget(logo) - title_block = QVBoxLayout() - title = QLabel("iOS Developer Toolkit") - title.setObjectName("appTitle") - title.setFont(QFont(title.font().family(), 24, QFont.Weight.Bold)) - subtitle = QLabel("iOS developer workbench • pymobiledevice3 • diagnostics • evidence") - subtitle.setObjectName("appSubtitle") - title_block.addWidget(title) - title_block.addWidget(subtitle) - header_layout.addLayout(title_block) - header_layout.addStretch() - self.device_combo = QComboBox() - self.device_combo.setObjectName("devicePicker") - self.device_combo.setMinimumWidth(390) - self.device_combo.currentIndexChanged.connect(self._device_selected) - header_layout.addWidget(self.device_combo) - self.demo_mode_button = QPushButton("Demo Mode") - self.demo_mode_button.setObjectName("demoModeButton") - self.demo_mode_button.setToolTip("Show a clearly simulated iPhone without connecting to a device") - self.demo_mode_button.clicked.connect(self.toggle_demo_mode) - header_layout.addWidget(self.demo_mode_button) - self.refresh_devices_button = QPushButton("Retry Scan") - self.refresh_devices_button.setObjectName("refreshDevicesButton") - self.refresh_devices_button.clicked.connect(self._scanner_scan) - header_layout.addWidget(self.refresh_devices_button) - self.reconnect_device_button = QPushButton("Reconnect & Retry…") - self.reconnect_device_button.setObjectName("reconnectDeviceButton") - self.reconnect_device_button.setToolTip( - "Guide a physical reconnection and retry USB device discovery for 30 seconds." - ) - self.reconnect_device_button.clicked.connect(self._reconnect_device) - header_layout.addWidget(self.reconnect_device_button) - self.keyboard_shortcuts_button = QPushButton("Keyboard Shortcuts") - self.keyboard_shortcuts_button.setObjectName("keyboardShortcutsButton") - self.keyboard_shortcuts_button.setToolTip("Show keyboard shortcuts (⌘/)") - self.keyboard_shortcuts_button.clicked.connect(self.show_keyboard_shortcuts) - header_layout.addWidget(self.keyboard_shortcuts_button) - self.support_bundle_button = QPushButton("Create Support Bundle…") - self.support_bundle_button.setObjectName("createSupportBundleButton") - self.support_bundle_button.setToolTip("Create a local sanitized ZIP for a support request") - self.support_bundle_button.clicked.connect(self.create_support_bundle) - header_layout.addWidget(self.support_bundle_button) - root_layout.addLayout(header_layout) - - self.connection_banner = QLabel("Waiting for an unlocked and trusted iPhone or iPad over USB…") - self.connection_banner.setObjectName("connectionBanner") - self.connection_banner.setWordWrap(True) - root_layout.addWidget(self.connection_banner) - - workspace = QSplitter(Qt.Orientation.Horizontal) - workspace.setObjectName("workspaceSplitter") - sidebar = QFrame() - sidebar.setObjectName("workspaceSidebar") - sidebar.setMinimumWidth(205) - sidebar.setMaximumWidth(250) - sidebar_layout = QVBoxLayout(sidebar) - sidebar_layout.setContentsMargins(10, 12, 10, 12) - section_label = QLabel("WORKSPACE") - section_label.setObjectName("sidebarSectionLabel") - sidebar_layout.addWidget(section_label) - self.navigation_list = QListWidget() - self.navigation_list.setObjectName("workspaceNavigation") - self.navigation_list.setSpacing(2) - sidebar_layout.addWidget(self.navigation_list, 1) - self.action_palette_button = QPushButton("Action Palette (⌘K)") - self.action_palette_button.setObjectName("actionPaletteButton") - self.action_palette_button.setToolTip("Search workspaces, guided commands, and currently eligible actions") - self.action_palette_button.clicked.connect(self.show_action_palette) - sidebar_layout.addWidget(self.action_palette_button) - self.session_activity_button = QPushButton("Session Activity (0)") - self.session_activity_button.setObjectName("sessionActivityButton") - self.session_activity_button.setToolTip( - "Review completed typed operations from this session and explicitly export a structured manifest" - ) - self.session_activity_button.clicked.connect(self.show_session_activity) - sidebar_layout.addWidget(self.session_activity_button) - self.export_workspace_profile_button = QPushButton("Export Workspace…") - self.export_workspace_profile_button.setObjectName("exportWorkspaceProfileButton") - self.export_workspace_profile_button.setToolTip( - "Export reviewed control defaults without device identity, paths, credentials, coordinates, or output" - ) - self.export_workspace_profile_button.clicked.connect(self.export_workspace_profile) - sidebar_layout.addWidget(self.export_workspace_profile_button) - self.import_workspace_profile_button = QPushButton("Import Workspace…") - self.import_workspace_profile_button.setObjectName("importWorkspaceProfileButton") - self.import_workspace_profile_button.setToolTip( - "Preview and apply a local workspace profile without running any command" - ) - self.import_workspace_profile_button.clicked.connect(self.import_workspace_profile) - sidebar_layout.addWidget(self.import_workspace_profile_button) - version_note = QLabel(f"Toolkit {APP_VERSION}\npymobiledevice3 11.15.1") - version_note.setObjectName("sidebarVersion") - version_note.setWordWrap(True) - sidebar_layout.addWidget(version_note) - workspace.addWidget(sidebar) - - self.page_stack = QStackedWidget() - self.page_stack.setObjectName("workspacePages") - pages = ( - ("Home", self._build_home_page()), - ("Device & DDI", self._build_overview_tab()), - ("Capability Matrix", self._build_capability_matrix_page()), - ("Location Lab", self._build_location_lab_page()), - ("Live Logs", self._build_live_logs_page()), - ("Command Center", self._build_command_center_page()), - ("Installed Apps", self._build_installed_apps_tab()), - ("Backup", self._build_backup_tab()), - ("Sideload IPA", self._build_sideload_tab()), - ("Evidence Capture", self._build_collection_tab()), - ("Ecosystem Tools", self._build_external_tools_page()), - ("Man Pages", self._build_manpages_page()), - ("Scope & Safety", self._build_safety_tab()), - ) - self._page_indices: dict[str, int] = {} - for name, page in pages: - self._page_indices[name] = self.page_stack.addWidget(page) - self.navigation_list.addItem(QListWidgetItem(name)) - self.navigation_list.currentRowChanged.connect(self.page_stack.setCurrentIndex) - self.navigation_list.setCurrentRow(0) - workspace.addWidget(self.page_stack) - workspace.setStretchFactor(0, 0) - workspace.setStretchFactor(1, 1) - root_layout.addWidget(workspace, 1) - self.setCentralWidget(root) - self._apply_style() - - def _configure_accessibility(self) -> None: - self.device_combo.setAccessibleName("Selected iPhone or iPad") - self.device_combo.setAccessibleDescription( - "Choose a detected, trusted device. Use Command L to focus the workspace list instead." - ) - self.demo_mode_button.setAccessibleName("Toggle simulated device demo mode") - self.demo_mode_button.setAccessibleDescription( - "Show or hide a clearly labeled simulated iPhone. Demo mode never runs device-affecting actions." - ) - self.refresh_devices_button.setAccessibleName("Retry device scan") - self.refresh_devices_button.setAccessibleDescription("Immediately refresh the usbmux device inventory. Shortcut: Command R.") - self.reconnect_device_button.setAccessibleName("Reconnect device and retry scan") - self.reconnect_device_button.setAccessibleDescription( - "Open a guided USB reconnection and trust workflow without restarting macOS services." - ) - self.keyboard_shortcuts_button.setAccessibleName("Keyboard shortcuts") - self.keyboard_shortcuts_button.setAccessibleDescription("Open the keyboard shortcut reference. Shortcut: Command Slash.") - self.support_bundle_button.setAccessibleName("Create sanitized support bundle") - self.support_bundle_button.setAccessibleDescription( - "Create a local ZIP that excludes device content and sensitive artifacts. The application never uploads it." - ) - self.session_activity_button.setAccessibleName("Session activity") - self.session_activity_button.setAccessibleDescription( - "Review completed typed operations and explicitly export a selected structured manifest." - ) - self.export_workspace_profile_button.setAccessibleName("Export workspace profile") - self.export_workspace_profile_button.setAccessibleDescription( - "Preview and save non-sensitive workflow control defaults without running a command." - ) - self.import_workspace_profile_button.setAccessibleName("Import workspace profile") - self.import_workspace_profile_button.setAccessibleDescription( - "Preview and apply validated workflow control defaults without running a command." - ) - self.action_palette_button.setAccessibleName("Action palette") - self.action_palette_button.setAccessibleDescription( - "Search workspaces, guided commands, and actions eligible in the current app state. Shortcut: Command K." - ) - self.connection_banner.setAccessibleName("Device connection status") - self.connection_banner.setAccessibleDescription( - "Reports whether a trusted iPhone or iPad is currently available to the toolkit." - ) - self.navigation_list.setAccessibleName("Workspace navigation") - self.navigation_list.setAccessibleDescription( - "Use Up and Down Arrow to choose a workspace, then Tab to enter its controls. Shortcut: Command L." - ) - self.navigation_list.setFocusPolicy(Qt.FocusPolicy.StrongFocus) - self.page_stack.setAccessibleName("Active workspace") - self.page_stack.setAccessibleDescription("Contains the controls for the selected workspace.") - self.page_stack.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.command_search_field.setAccessibleName("Search guided commands") - self.command_search_field.setAccessibleDescription("Filters guided command presets by title, category, summary, or command arguments.") - self.command_preset_list.setAccessibleName("Guided command presets") - self.command_preset_list.setAccessibleDescription( - "Use Up and Down Arrow to choose a preset. Tab reaches its validated values and action buttons." - ) - self.command_preview.setAccessibleName("Guided command preview") - self.command_preview.setAccessibleDescription("Read-only exact pymobiledevice3 argument vector for the selected preset.") - self.command_drift_output.setAccessibleName("Command drift report") - self.command_drift_output.setAccessibleDescription( - "Read-only report of installed live-help route and option compatibility for guided presets." - ) - self.manpage_search_field.setAccessibleName("Search live help topics") - self.manpage_search_field.setAccessibleDescription("Filters the local index of pymobiledevice3 command routes.") - self.manpage_list.setAccessibleName("Live help topic list") - self.manpage_list.setAccessibleDescription("Use Up and Down Arrow to choose a command route, then Tab to live-help actions.") - self.manpage_output.setAccessibleName("Live help output") - self.manpage_output.setAccessibleDescription("Read-only help output from the installed project-local pymobiledevice3 executable.") - self.app_filter_field.setAccessibleName("Filter installed applications") - self.app_filter_field.setAccessibleDescription("Filters the installed application inventory without changing the device.") - self.installed_apps_table.setAccessibleName("Installed application inventory") - self.installed_apps_table.setAccessibleDescription("Use Arrow keys to select an application. Actions require explicit confirmation.") - self.capability_table.setAccessibleName("Capability Matrix results") - self.capability_table.setAccessibleDescription("Use Arrow keys to select a capability and read its detailed evidence below.") - self.compatibility_history_table.setAccessibleName("Real-device compatibility history") - self.compatibility_history_table.setAccessibleDescription("Local compatibility observations with no raw device identifiers.") - self.location_map.setAccessibleDescription( - "Offline mouse coordinate picker. For keyboard-first location entry, use the coordinate importer, latitude, and longitude fields." - ) - self.location_map.setFocusPolicy(Qt.FocusPolicy.NoFocus) - for spec in external_tool_specs(): - self._external_tool_fields[spec.identifier].setAccessibleName(f"{spec.title} executable path") - self._external_tool_fields[spec.identifier].setAccessibleDescription( - f"Absolute path to the separately installed {spec.executable_name} executable." - ) - self._external_tool_outputs[spec.identifier].setAccessibleName(f"{spec.title} adapter output") - self._external_tool_outputs[spec.identifier].setAccessibleDescription( - "Session-local raw version validation or read-only probe output from the external tool." - ) - QWidget.setTabOrder(self.device_combo, self.demo_mode_button) - QWidget.setTabOrder(self.demo_mode_button, self.refresh_devices_button) - QWidget.setTabOrder(self.refresh_devices_button, self.reconnect_device_button) - QWidget.setTabOrder(self.reconnect_device_button, self.keyboard_shortcuts_button) - QWidget.setTabOrder(self.keyboard_shortcuts_button, self.support_bundle_button) - QWidget.setTabOrder(self.support_bundle_button, self.navigation_list) - QWidget.setTabOrder(self.navigation_list, self.action_palette_button) - QWidget.setTabOrder(self.action_palette_button, self.session_activity_button) - - def _configure_keyboard_shortcuts(self) -> None: - self._add_application_shortcut("Meta+R", self._scanner_scan, "shortcutRetryDeviceScan") - self._add_application_shortcut("Meta+K", self.show_action_palette, "shortcutShowActionPalette") - self._add_application_shortcut("Meta+L", self.focus_workspace_navigation, "shortcutFocusWorkspaceNavigation") - self._add_application_shortcut("Meta+F", self.focus_workspace_search, "shortcutFocusWorkspaceSearch") - self._add_application_shortcut("Meta+/", self.show_keyboard_shortcuts, "shortcutShowKeyboardReference") - self._add_application_shortcut("Meta+Alt+Left", self.navigate_previous_workspace, "shortcutPreviousWorkspace") - self._add_application_shortcut("Meta+Alt+Right", self.navigate_next_workspace, "shortcutNextWorkspace") - workspace_shortcuts = ( - ("Meta+1", "Home"), - ("Meta+2", "Device & DDI"), - ("Meta+3", "Capability Matrix"), - ("Meta+4", "Location Lab"), - ("Meta+5", "Live Logs"), - ("Meta+6", "Command Center"), - ("Meta+7", "Installed Apps"), - ("Meta+8", "Backup"), - ("Meta+9", "Sideload IPA"), - ("Meta+0", "Evidence Capture"), - ("Meta+Shift+E", "Ecosystem Tools"), - ("Meta+Shift+M", "Man Pages"), - ("Meta+Shift+S", "Scope & Safety"), - ) - for sequence, page_name in workspace_shortcuts: - identifier = f"shortcutOpen{page_name.replace(' ', '').replace('&', 'And')}" - self._add_application_shortcut( - sequence, - self._workspace_shortcut_handler(page_name), - identifier, - ) - - def _add_application_shortcut(self, sequence: str, callback: Callable[[], None], identifier: str) -> None: - shortcut = QShortcut(QKeySequence(sequence), self) - shortcut.setObjectName(identifier) - shortcut.setContext(Qt.ShortcutContext.ApplicationShortcut) - shortcut.activated.connect(callback) - self._keyboard_shortcuts.append(shortcut) - - def _workspace_shortcut_handler(self, page_name: str) -> Callable[[], None]: - def navigate() -> None: - self.navigate_to_page_and_focus(page_name) - - return navigate - - def navigate_to_page_and_focus(self, name: str) -> None: - self.navigate_to_page(name) - focus_targets: Mapping[str, QWidget] = { - "Home": self.findChild(QPushButton, "homeOpenDevice&DDIButton"), - "Device & DDI": self.refresh_devices_button, - "Capability Matrix": self.refresh_capabilities_button, - "Location Lab": self.location_input_field, - "Live Logs": self.findChild(QPushButton, "openUnifiedLogButton"), - "Command Center": self.command_search_field, - "Installed Apps": self.app_filter_field, - "Backup": self.backup_destination_field, - "Sideload IPA": self.ipa_path_field, - "Evidence Capture": self.case_title_field, - "Ecosystem Tools": self._external_tool_fields["go-ios"], - "Man Pages": self.manpage_search_field, - "Scope & Safety": self.navigation_list, - } - target = focus_targets.get(name) - if target is None: - raise RuntimeError(f"Workspace focus target is missing: {name}") - target.setFocus(Qt.FocusReason.ShortcutFocusReason) - if isinstance(target, QLineEdit): - target.selectAll() - - def focus_workspace_navigation(self) -> None: - self.navigation_list.setFocus(Qt.FocusReason.ShortcutFocusReason) - - def focus_workspace_search(self) -> None: - current_item = self.navigation_list.currentItem() - if current_item is None: - raise RuntimeError("Cannot focus a workspace search without a selected workspace") - focus_targets: Mapping[str, QLineEdit] = { - "Command Center": self.command_search_field, - "Man Pages": self.manpage_search_field, - "Installed Apps": self.app_filter_field, - "Location Lab": self.location_input_field, - } - target = focus_targets.get(current_item.text()) - if target is None: - self.focus_workspace_navigation() - return - target.setFocus(Qt.FocusReason.ShortcutFocusReason) - target.selectAll() - - def navigate_previous_workspace(self) -> None: - count = self.navigation_list.count() - if count == 0: - raise RuntimeError("Workspace navigation contains no pages") - self.navigation_list.setCurrentRow((self.navigation_list.currentRow() - 1) % count) - self.focus_workspace_navigation() - - def navigate_next_workspace(self) -> None: - count = self.navigation_list.count() - if count == 0: - raise RuntimeError("Workspace navigation contains no pages") - self.navigation_list.setCurrentRow((self.navigation_list.currentRow() + 1) % count) - self.focus_workspace_navigation() - - def show_keyboard_shortcuts(self) -> None: - dialog = QDialog(self) - dialog.setObjectName("keyboardShortcutsDialog") - dialog.setWindowTitle("Keyboard Shortcuts") - dialog.setAccessibleName("Keyboard shortcut reference") - dialog.setAccessibleDescription("Lists application-wide keyboard shortcuts for workspace navigation and search.") - dialog.setMinimumWidth(650) - layout = QVBoxLayout(dialog) - heading = QLabel("Keyboard-first navigation") - heading.setFont(QFont(heading.font().family(), 18, QFont.Weight.DemiBold)) - layout.addWidget(heading) - reference = QTextBrowser() - reference.setAccessibleName("Keyboard shortcut list") - reference.setHtml( - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "
ShortcutAction
⌘ RRetry device scan
⌘ KOpen the eligible Action Palette
⌘ LFocus workspace navigation
⌘ FFocus search in Command Center, Man Pages, Installed Apps, or Location Lab
⌘ ⌥ ← / ⌘ ⌥ →Previous / next workspace
⌘ 1–0Home through Evidence Capture
⌘ ⇧ EEcosystem Tools
⌘ ⇧ MMan Pages
⌘ ⇧ SScope & Safety
⌘ /Open this reference
Tab / Shift TabMove through controls
Space / ReturnActivate the focused control
Arrow keysMove through lists and tables
" - "

Shortcuts navigate or focus only. Commands that write files or change device state still require the " - "existing review and typed acknowledgements.

" - ) - layout.addWidget(reference) - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) - buttons.setAccessibleName("Close keyboard shortcut reference") - buttons.rejected.connect(dialog.reject) - buttons.accepted.connect(dialog.accept) - layout.addWidget(buttons) - dialog.exec() - - def show_action_palette(self) -> None: - dialog = ActionPaletteDialog(self._eligible_action_palette_entries(), self) - if dialog.exec() != QDialog.DialogCode.Accepted: - return - self._execute_action_palette_entry(dialog.selected_identifier()) - - def _eligible_action_palette_entries(self) -> tuple[ActionPaletteEntry, ...]: - workspace_summaries = { - "Home": "Open the guided workflow overview.", - "Device & DDI": "Review the selected device, Developer Mode, DDI, and Apple tool handoffs.", - "Capability Matrix": "Inspect bounded connection and developer-service readiness evidence.", - "Location Lab": "Prepare explicit, clearable location simulation for app testing.", - "Live Logs": "Open independent raw-spooling log windows.", - "Command Center": "Choose a validated guided command or explicit advanced arguments.", - "Installed Apps": "Inspect the service-visible app inventory.", - "Backup": "Prepare MobileBackup2, external UFADE acquisition, or consented MVT analysis.", - "Sideload IPA": "Inspect a local IPA before an eligible installation attempt.", - "Evidence Capture": "Prepare a scoped case and bounded evidence collection.", - "Ecosystem Tools": "Validate optional go-ios, idb, and ipsw adapters and run bounded read-only probes.", - "Man Pages": "Browse version-matched command routes and live help.", - "Scope & Safety": "Review authorization, privacy, and interpretation boundaries.", - } - entries: list[ActionPaletteEntry] = [ - action_palette_entry( - f"navigate:{workspace}", - f"Open {workspace}", - "Workspace", - workspace_summaries[workspace], - ("navigate", "workspace", workspace), - ) - for workspace in self._page_indices - ] - entries.extend( - ( - action_palette_entry( - "utility:session-activity", - "Open Session Activity", - "Utility", - "Review completed typed operations and explicitly export a selected JSON manifest.", - ("history", "journal", "manifest", "operations"), - ), - action_palette_entry( - "utility:keyboard-shortcuts", - "Open Keyboard Shortcuts", - "Utility", - "Review keyboard-first navigation without running a device action.", - ("accessibility", "keyboard", "hotkeys"), - ), - action_palette_entry( - "utility:export-workspace-profile", - "Export Workspace Profile", - "Utility", - "Preview and save non-sensitive workflow control defaults for local or team reuse.", - ("team", "workspace", "profile", "configuration", "export"), - ), - action_palette_entry( - "utility:import-workspace-profile", - "Import Workspace Profile", - "Utility", - "Preview and apply validated workflow control defaults without running a command.", - ("team", "workspace", "profile", "configuration", "import"), - ), - ) - ) - if self.refresh_devices_button.isEnabled() and not self._demo_mode: - entries.append( - action_palette_entry( - "action:retry-device-scan", - "Retry Device Scan", - "Eligible read action", - "Run one usbmux discovery refresh without restarting macOS services.", - ("connect", "detect", "usbmux", "iphone", "ipad"), - ) - ) - eligible_actions = ( - ( - "action:developer-mode-status", - "Check Developer Mode", - "Query the selected device's current Developer Mode status.", - ("developer", "amfi", "ddi"), - self.selected_device() is not None and not self._action_controller.is_running(), - ), - ( - "action:list-developer-images", - "List Developer Images", - "List mounted or installed developer support for the selected device.", - ("ddi", "mounter", "cryptex"), - self.selected_device() is not None and not self._action_controller.is_running(), - ), - ( - "action:coredevice-details", - "Show CoreDevice Details", - "Run bounded Apple devicectl details for the selected device.", - ("xcode", "devicectl", "coredevice"), - self.coredevice_details_button.isEnabled(), - ), - ( - "action:rvi-status", - "List RVI Interfaces", - "List current Apple Remote Virtual Interfaces without changing them.", - ("network", "pcap", "rvictl"), - self.rvi_status_button.isEnabled(), - ), - ( - "action:capability-matrix", - "Run Device Readiness Check", - "Run the bounded read-only Capability Matrix for the selected device.", - ("readiness", "trust", "ddi", "rsd", "dvt"), - self.refresh_capabilities_button.isEnabled(), - ), - ( - "action:refresh-installed-apps", - "Refresh Installed Apps", - "Load the service-visible application inventory for the selected device.", - ("apps", "inventory", "bundle"), - self.refresh_apps_button.isEnabled(), - ), - ( - "action:backup-encryption-status", - "Check Backup Encryption", - "Read the selected device's MobileBackup2 encryption state.", - ("backup", "mobilebackup2", "encrypted"), - self.check_encryption_button.isEnabled(), - ), - ( - "action:command-drift", - "Check Guided Command Drift", - "Verify every guided route against the installed CLI help without contacting a device.", - ("help", "syntax", "pymobiledevice3", "presets"), - self.command_drift_check_button.isEnabled(), - ), - ( - "action:refresh-live-help", - "Refresh Selected Live Help", - "Load live help for the currently selected Man Pages route.", - ("manpage", "documentation", "syntax"), - self.refresh_manpage_button.isEnabled(), - ), - ( - "action:export-compatibility-json", - "Export Sanitized Compatibility JSON", - "Export the latest locally observed real-device capability evidence without stable device identity.", - ("compatibility", "matrix", "json", "report", "sanitized"), - self._compatibility_export_is_available(), - ), - ( - "action:export-compatibility-markdown", - "Export Sanitized Compatibility Markdown", - "Export a readable real-device capability report without stable device identity.", - ("compatibility", "matrix", "markdown", "report", "sanitized"), - self._compatibility_export_is_available(), - ), - ) - entries.extend( - action_palette_entry(identifier, title, "Eligible read action", summary, keywords) - for identifier, title, summary, keywords, eligible in eligible_actions - if eligible - ) - entries.extend( - action_palette_entry( - f"action:external-tool:{spec.identifier}", - spec.probe_title, - "Eligible external read action", - f"Run the validated {spec.title} adapter probe after reviewing its independent target boundary.", - ("external", "adapter", spec.identifier, "inventory", "provenance"), - ) - for spec in external_tool_specs() - if self._external_tool_probe_buttons[spec.identifier].isEnabled() - ) - if not self._console_controller.is_running(): - device_available = self.selected_device() is not None - entries.extend( - action_palette_entry( - f"preset:{preset.identifier}", - f"Choose {preset.title}", - "Guided command", - f"Open this reviewed preset in Command Center without running it. {preset.summary}", - (preset.category, *preset.argument_template, "preset", preset.risk), - ) - for preset in self._presets - if not preset.requires_device or device_available - ) - return validate_action_palette(tuple(entries)) - - def _execute_action_palette_entry(self, identifier: str) -> None: - if identifier.startswith("navigate:"): - self.navigate_to_page_and_focus(identifier.removeprefix("navigate:")) - return - if identifier.startswith("preset:"): - self._select_palette_preset(identifier.removeprefix("preset:")) - return - if identifier.startswith("action:external-tool:"): - current_identifiers = {entry.identifier for entry in self._eligible_action_palette_entries()} - if identifier not in current_identifiers: - QMessageBox.information( - self, - "Action No Longer Eligible", - "The validated external tool or process state changed while the palette was open.", - ) - return - tool_identifier = identifier.removeprefix("action:external-tool:") - matching = tuple(spec.identifier for spec in external_tool_specs() if spec.identifier == tool_identifier) - if len(matching) != 1: - raise KeyError(f"Unknown external-tool action palette entry: {identifier}") - self.run_external_tool_probe(matching[0]) - return - actions: Mapping[str, Callable[[], None]] = { - "utility:session-activity": self.show_session_activity, - "utility:keyboard-shortcuts": self.show_keyboard_shortcuts, - "utility:export-workspace-profile": self.export_workspace_profile, - "utility:import-workspace-profile": self.import_workspace_profile, - "action:retry-device-scan": self._scanner_scan, - "action:developer-mode-status": self.check_developer_mode, - "action:list-developer-images": self.list_mounted_images, - "action:coredevice-details": self.show_coredevice_details, - "action:rvi-status": self.list_rvi_interfaces, - "action:capability-matrix": self.refresh_capability_matrix, - "action:refresh-installed-apps": self.refresh_app_inventory, - "action:backup-encryption-status": self.check_backup_encryption, - "action:command-drift": self.start_command_drift_check, - "action:refresh-live-help": self.refresh_selected_manpage, - "action:export-compatibility-json": self.export_compatibility_json, - "action:export-compatibility-markdown": self.export_compatibility_markdown, - } - action = actions.get(identifier) - if action is None: - raise KeyError(f"Unknown action palette entry: {identifier}") - current_identifiers = {entry.identifier for entry in self._eligible_action_palette_entries()} - if identifier not in current_identifiers: - QMessageBox.information( - self, - "Action No Longer Eligible", - "The device or operation state changed while the palette was open. Reopen the palette to refresh it.", - ) - return - action() - - def _select_palette_preset(self, identifier: str) -> None: - matching = tuple(preset for preset in self._presets if preset.identifier == identifier) - if len(matching) != 1: - raise CommandCatalogError(f"Expected one action-palette preset for {identifier!r}, found {len(matching)}") - preset = matching[0] - if self._console_controller.is_running() or (preset.requires_device and self.selected_device() is None): - QMessageBox.information( - self, - "Preset No Longer Eligible", - "The selected preset is no longer eligible in the current device or operation state.", - ) - return - self.navigate_to_page("Command Center") - self.command_category_combo.setCurrentText("All categories") - self.command_search_field.clear() - for row in range(self.command_preset_list.count()): - item = self.command_preset_list.item(row) - if item.data(Qt.ItemDataRole.UserRole) == preset.identifier: - self.command_preset_list.setCurrentRow(row) - self.preset_run_button.setFocus(Qt.FocusReason.ShortcutFocusReason) - return - raise CommandCatalogError(f"Eligible action-palette preset is missing from Command Center: {identifier}") - - def show_session_activity(self) -> None: - dialog = OperationHistoryDialog(self._operation_records, self) - dialog.exec() - - def _request_workspace_profile_metadata(self) -> tuple[str, str] | None: - dialog = QDialog(self) - dialog.setObjectName("workspaceProfileMetadataDialog") - dialog.setWindowTitle("Describe Workspace Profile") - layout = QVBoxLayout(dialog) - explanation = QLabel( - "The profile contains reviewed control defaults only. Device identity, paths, credentials, coordinates, " - "case text, command parameters, and output are excluded by schema." - ) - explanation.setWordWrap(True) - layout.addWidget(explanation) - form = QFormLayout() - name_field = QLineEdit("Team workflow") - name_field.setObjectName("workspaceProfileName") - form.addRow("Name", name_field) - description_field = QLineEdit() - description_field.setObjectName("workspaceProfileDescription") - description_field.setPlaceholderText("Purpose or expected use; do not enter sensitive data") - form.addRow("Description", description_field) - layout.addLayout(form) - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel) - buttons.setObjectName("workspaceProfileMetadataButtons") - buttons.accepted.connect(dialog.accept) - buttons.rejected.connect(dialog.reject) - layout.addWidget(buttons) - name_field.selectAll() - name_field.setFocus(Qt.FocusReason.OtherFocusReason) - if dialog.exec() != QDialog.DialogCode.Accepted: - return None - return name_field.text(), description_field.text() - - def _workspace_profile_from_controls(self, name: str, description: str) -> WorkspaceProfile: - current_item = self.navigation_list.currentItem() - if current_item is None: - raise WorkspaceProfileError("Cannot export a profile without a selected workspace") - preset = self._current_preset - if preset is None: - raise WorkspaceProfileError("Cannot export a profile without a selected guided command preset") - speed_preset = self.location_route_speed_preset.currentData() - if not isinstance(speed_preset, int): - raise WorkspaceProfileError("Cannot export a profile without a valid location speed preset") - return validate_workspace_profile( - WorkspaceProfile( - created_with_version=APP_VERSION, - name=name, - description=description, - default_workspace=current_item.text(), - ddi_source="local-xcode" if self.local_radio.isChecked() else "personalized", - command_category=self.command_category_combo.currentText(), - command_preset=preset.identifier, - app_workflow=AppWorkflowPreferences( - self.calculate_app_sizes_checkbox.isChecked(), - self.developer_package_checkbox.isChecked(), - ), - backup_workflow=BackupWorkflowPreferences( - self.full_backup_checkbox.isChecked(), - self.require_encryption_checkbox.isChecked(), - ), - evidence_workflow=EvidenceWorkflowPreferences( - self.capture_duration.value(), - self.include_syslog.isChecked(), - self.include_oslog.isChecked(), - self.include_pcap.isChecked(), - self.include_screenshot.isChecked(), - self.include_crash_pull.isChecked(), - ), - location_workflow=LocationWorkflowPreferences( - self.location_timing_randomness.value(), - self.location_disable_sleep.isChecked(), - speed_preset, - self.location_route_speed.value(), - self.location_route_interval.value(), - self.location_route_traversals.value(), - ), - ) - ) - - def _review_workspace_profile( - self, - title: str, - explanation_text: str, - content: str, - accept_label: str, - ) -> bool: - dialog = QDialog(self) - dialog.setObjectName("workspaceProfilePreviewDialog") - dialog.setWindowTitle(title) - dialog.resize(820, 650) - layout = QVBoxLayout(dialog) - explanation = QLabel(explanation_text) - explanation.setWordWrap(True) - layout.addWidget(explanation) - preview = QPlainTextEdit() - preview.setObjectName("workspaceProfilePreview") - preview.setReadOnly(True) - preview.setPlainText(content) - layout.addWidget(preview, 1) - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel) - buttons.setObjectName("workspaceProfilePreviewButtons") - buttons.addButton(accept_label, QDialogButtonBox.ButtonRole.AcceptRole) - buttons.accepted.connect(dialog.accept) - buttons.rejected.connect(dialog.reject) - layout.addWidget(buttons) - return dialog.exec() == QDialog.DialogCode.Accepted - - def export_workspace_profile(self) -> None: - metadata = self._request_workspace_profile_metadata() - if metadata is None: - return - try: - profile = self._workspace_profile_from_controls(*metadata) - except WorkspaceProfileError as error: - QMessageBox.critical(self, "Could Not Prepare Workspace Profile", str(error)) - return - if not self._review_workspace_profile( - "Review Workspace Profile Export", - "Review the exact JSON before saving. The application never uploads the file.", - render_workspace_profile_json(profile), - "Save Profile", - ): - return - timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%SZ") - suggested = Path.home() / f"iOSDeveloperToolkit-workspace-{timestamp}.json" - selected, _ = QFileDialog.getSaveFileName( - self, - "Save Workspace Profile", - str(suggested), - "JSON (*.json)", - ) - if not selected: - return - destination = Path(selected) - if destination.suffix.casefold() != ".json": - destination = destination.with_suffix(".json") - try: - path = write_workspace_profile(destination, profile) - except WorkspaceProfileError as error: - QMessageBox.critical(self, "Could Not Export Workspace Profile", str(error)) - return - QMessageBox.information( - self, - "Workspace Profile Created", - f"Created owner-only local profile:\n{path}\n\nReview it before sharing.", - ) - - def _workspace_profile_import_is_available(self) -> bool: - finite_controllers = ( - self._action_controller, - self._ipa_inspection_controller, - self._sideload_controller, - self._apps_controller, - self._external_tool_controller, - self._manpage_controller, - self._command_drift_controller, - ) - stream_controllers = ( - self._collection_controller, - self._backup_controller, - self._mvt_controller, - self._console_controller, - ) - return ( - self._capability_process is None - and self._location_process is None - and all(not controller.is_running() for controller in finite_controllers) - and all(not controller.is_running() for controller in stream_controllers) - ) - - def import_workspace_profile(self) -> None: - if not self._workspace_profile_import_is_available(): - QMessageBox.information( - self, - "Workspace Profile Import Unavailable", - "Stop or wait for active operations before changing workflow controls.", - ) - return - selected, _ = QFileDialog.getOpenFileName( - self, - "Open Workspace Profile", - str(Path.home()), - "JSON (*.json)", - ) - if not selected: - return - try: - profile = load_workspace_profile(Path(selected)) - except WorkspaceProfileError as error: - QMessageBox.critical(self, "Invalid Workspace Profile", str(error)) - return - if not self._review_workspace_profile( - "Review Workspace Profile Import", - "Review every control change. Applying this profile never runs a command or starts a device operation.", - render_workspace_profile_preview(profile), - "Apply Profile", - ): - return - try: - self._apply_workspace_profile(profile) - except WorkspaceProfileError as error: - QMessageBox.critical(self, "Could Not Apply Workspace Profile", str(error)) - return - QMessageBox.information( - self, - "Workspace Profile Applied", - f"Applied {profile.name!r}. No command or device operation was started.", - ) - - def _apply_workspace_profile(self, profile: WorkspaceProfile) -> None: - validated = validate_workspace_profile(profile) - if not self._workspace_profile_import_is_available(): - raise WorkspaceProfileError("An operation started while the workspace profile was being reviewed") - self.personalized_radio.setChecked(validated.ddi_source == "personalized") - self.local_radio.setChecked(validated.ddi_source == "local-xcode") - self.command_category_combo.setCurrentText(validated.command_category) - self.command_search_field.clear() - matching_rows = tuple( - row - for row in range(self.command_preset_list.count()) - if self.command_preset_list.item(row).data(Qt.ItemDataRole.UserRole) == validated.command_preset - ) - if len(matching_rows) != 1: - raise WorkspaceProfileError( - f"Validated preset is not visible in its configured category: {validated.command_preset!r}" - ) - self.command_preset_list.setCurrentRow(matching_rows[0]) - self.calculate_app_sizes_checkbox.setChecked(validated.app_workflow.calculate_app_sizes) - self.developer_package_checkbox.setChecked(validated.app_workflow.install_as_developer_package) - self.full_backup_checkbox.setChecked(validated.backup_workflow.force_full_backup) - self.require_encryption_checkbox.setChecked(validated.backup_workflow.require_encryption) - evidence = validated.evidence_workflow - self.capture_duration.setValue(evidence.capture_duration_seconds) - self.include_syslog.setChecked(evidence.include_syslog) - self.include_oslog.setChecked(evidence.include_oslog) - self.include_pcap.setChecked(evidence.include_pcap) - self.include_screenshot.setChecked(evidence.include_screenshot) - self.include_crash_pull.setChecked(evidence.include_crash_pull) - location = validated.location_workflow - self.location_timing_randomness.setValue(location.timing_randomness_ms) - self.location_disable_sleep.setChecked(location.ignore_timing_delays) - speed_index = self.location_route_speed_preset.findData(location.route_speed_preset_kmh) - if speed_index < 0: - raise WorkspaceProfileError( - f"Validated location speed preset is unavailable: {location.route_speed_preset_kmh}" - ) - self.location_route_speed_preset.setCurrentIndex(speed_index) - self.location_route_speed.setValue(location.route_speed_kmh) - self.location_route_interval.setValue(location.route_interval_seconds) - self.location_route_traversals.setValue(location.route_traversals) - self.navigate_to_page(validated.default_workspace) - - def _host_operation_context( - self, - title: str, - workspace: str, - transport: str, - output_paths: tuple[str, ...], - ) -> OperationContext: - return operation_context( - title, - workspace, - "Local Mac", - transport, - ("device:not-required",), - output_paths, - ) - - def _device_operation_context( - self, - title: str, - workspace: str, - transport: str, - device: IOSDevice, - output_paths: tuple[str, ...], - ) -> OperationContext: - identifier = "".join(character for character in device.identifier.upper() if character.isalnum()) - suffix = identifier[-6:] if len(identifier) >= 6 else "unknown" - target = ( - f"{device.product_type} • iOS {device.product_version} • {device.connection_type} • " - f"identifier ending {suffix}" - ) - observed_capabilities = tuple( - f"{capability_identifier}:{result.state}" - for capability_identifier, result in sorted(self._capability_results.items()) - if result.state != "not-tested" - ) - capability_snapshot = observed_capabilities or ("capabilities:not-tested",) - return operation_context( - title, - workspace, - target, - transport, - ("device:selected", *capability_snapshot), - output_paths, - ) - - def _begin_operation(self, slot: str, context: OperationContext) -> None: - normalized_slot = slot.strip() - if not normalized_slot: - raise ValueError("Operation slot cannot be empty") - if normalized_slot in self._pending_operation_contexts: - raise RuntimeError(f"Operation slot is already active: {normalized_slot}") - self._pending_operation_contexts[normalized_slot] = context - - def _update_operation_output_paths(self, slot: str, output_paths: tuple[str, ...]) -> None: - context = self._pending_operation_contexts.get(slot) - if context is None: - raise RuntimeError(f"Cannot update output paths for inactive operation slot: {slot}") - self._pending_operation_contexts[slot] = with_output_paths(context, output_paths) - - def _complete_operation(self, slot: str, result: OperationResult) -> None: - context = self._pending_operation_contexts.pop(slot, None) - if context is None: - raise RuntimeError(f"Cannot complete inactive operation slot: {slot}") - record = operation_record(context, result) - self._operation_records = append_operation_record( - self._operation_records, - record, - MAX_SESSION_OPERATION_RECORDS, - ) - self.session_activity_button.setText(f"Session Activity ({len(self._operation_records)})") - - def create_support_bundle(self) -> None: - message = ( - "Create a local sanitized support ZIP?\n\n" - "Included: toolkit and dependency versions, macOS/Python metadata, aggregate readiness counts, selected " - "workspace, sanitized status summaries, and the command-drift report.\n\n" - "Excluded: device identities, pairing records, backups, cases, captures, screenshots, raw logs, PCAPs, " - "crash reports, IPA files, command output, passwords, and user-entered values.\n\n" - "The application will not upload the ZIP. Review it before sharing." - ) - if not self._confirm("Create Sanitized Support Bundle", message): - return - timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%SZ") - suggested_path = Path.home() / f"iOSDeveloperToolkit-support-{timestamp}.zip" - selected, _ = QFileDialog.getSaveFileName( - self, - "Save Sanitized Support Bundle", - str(suggested_path), - "ZIP archive (*.zip)", - ) - if not selected: - return - destination = Path(selected) - if destination.suffix.casefold() != ".zip": - destination = destination.with_suffix(".zip") - try: - result = create_sanitized_support_bundle(destination.resolve(), self._support_bundle_context()) - except SupportBundleError as error: - QMessageBox.critical(self, "Could Not Create Support Bundle", str(error)) - return - QMessageBox.information( - self, - "Sanitized Support Bundle Created", - f"Created locally:\n{result.path}\n\nContains {len(result.entries)} reviewed support files. Review the ZIP before sharing.", - ) - - def _support_bundle_context(self) -> SupportBundleContext: - current_item = self.navigation_list.currentItem() - if current_item is None: - raise RuntimeError("Cannot create a support bundle without a selected workspace") - capability_counts = capability_state_counts(self._capability_results.values()) - statuses = ( - SupportStatus("connection", self.connection_banner.text()), - SupportStatus("connection_diagnostic", self._connection_diagnostic.report()), - SupportStatus("developer_mode", self.developer_mode_status.text()), - SupportStatus("capability_matrix", self.capability_status.text()), - SupportStatus("command_drift", self.command_drift_status.text()), - *( - SupportStatus( - f"external_tool_{spec.identifier.replace('-', '_')}", - self._external_tool_statuses[spec.identifier].text(), - ) - for spec in external_tool_specs() - ), - ) - redactions = tuple( - value - for device in self._devices - for value in (device.identifier, device.name) - if value - ) - return SupportBundleContext( - APP_VERSION, - current_item.text(), - len(self._devices), - self.selected_device() is not None, - capability_counts, - self.command_drift_output.toPlainText(), - statuses, - redactions, - is_frozen_runtime(), - ) - - def _build_home_page(self) -> QWidget: - return build_home_page(len(self._presets), len(self._manpages), self.navigate_to_page) - - def navigate_to_page(self, name: str) -> None: - index = self._page_indices.get(name) - if index is None: - raise KeyError(f"Unknown workspace page: {name}") - self.navigation_list.setCurrentRow(index) - - def _navigation_handler(self, name: str) -> Callable[[bool], None]: - def navigate(checked: bool) -> None: - del checked - self.navigate_to_page(name) - - return navigate - - def _open_log_presets(self) -> None: - self.command_category_combo.setCurrentText("Logging & Capture") - self.command_search_field.clear() - self.navigate_to_page("Command Center") - - def open_live_log_window(self, identifier: str) -> None: - device = self.selected_device() - if device is None: - self._show_no_device() - return - try: - specification = stream_spec(identifier) - window = LiveLogWindow( - self._pmd3, - specification, - device.identifier, - device.name, - dict(device_environment(device.identifier)), - application_icon_path(), - ) - except LiveLogError as error: - QMessageBox.critical(self, "Could Not Open Live Log", str(error)) - return - window.closed.connect(self._live_log_window_closed) - self._live_log_windows.add(window) - window.show() - window.raise_() - window.activateWindow() - - def _live_log_window_closed(self, window_object: object) -> None: - if not isinstance(window_object, LiveLogWindow): - raise TypeError(f"Expected a LiveLogWindow close signal, received {type(window_object).__name__}") - self._live_log_windows.discard(window_object) - - def _build_overview_tab(self) -> QWidget: - tab = QWidget() - layout = QVBoxLayout(tab) - layout.setSpacing(14) - - diagnostic_group = QGroupBox("Connection diagnostic") - diagnostic_layout = QVBoxLayout(diagnostic_group) - self.connection_diagnostic_value = QLabel(self._connection_diagnostic.report()) - self.connection_diagnostic_value.setObjectName("connectionDiagnosticValue") - self.connection_diagnostic_value.setWordWrap(True) - self.connection_diagnostic_value.setAccessibleName("Connection discovery diagnostic") - self.connection_diagnostic_value.setAccessibleDescription( - "Reports the most recent usbmux discovery result without including device identity or raw command output." - ) - diagnostic_layout.addWidget(self.connection_diagnostic_value) - layout.addWidget(diagnostic_group) - - device_group = QGroupBox("Connected device") - device_layout = QGridLayout(device_group) - self.device_name_value = QLabel("No device") - self.device_version_value = QLabel("—") - self.device_model_value = QLabel("—") - self.device_udid_value = QLabel("—") - self.device_udid_value.setTextInteractionFlags(self.device_udid_value.textInteractionFlags()) - device_layout.addWidget(QLabel("Name"), 0, 0) - device_layout.addWidget(self.device_name_value, 0, 1) - device_layout.addWidget(QLabel("iOS / build"), 0, 2) - device_layout.addWidget(self.device_version_value, 0, 3) - device_layout.addWidget(QLabel("Model"), 1, 0) - device_layout.addWidget(self.device_model_value, 1, 1) - device_layout.addWidget(QLabel("UDID"), 1, 2) - device_layout.addWidget(self.device_udid_value, 1, 3) - layout.addWidget(device_group) - - developer_group = QGroupBox("1. Developer Mode") - developer_layout = QHBoxLayout(developer_group) - self.developer_mode_status = QLabel("Status not checked") - self.developer_mode_status.setObjectName("developerModeStatus") - developer_layout.addWidget(self.developer_mode_status, 1) - guide_button = QPushButton("Show Steps") - guide_button.setObjectName("developerModeGuideButton") - guide_button.clicked.connect(self.show_developer_mode_guide) - developer_layout.addWidget(guide_button) - check_button = QPushButton("Check Status") - check_button.setObjectName("developerModeCheckButton") - check_button.clicked.connect(self.check_developer_mode) - developer_layout.addWidget(check_button) - layout.addWidget(developer_group) - - ddi_group = QGroupBox("2. Choose a Developer Disk Image source") - ddi_layout = QVBoxLayout(ddi_group) - self.personalized_radio = QRadioButton("Downloaded personalized DDI — recommended for iOS 17+") - self.personalized_radio.setObjectName("personalizedDDIRadio") - self.personalized_radio.setChecked(True) - self.local_radio = QRadioButton(f"Local Apple/Xcode DDI — {XCODE_CANDIDATE_DDI}") - self.local_radio.setObjectName("localDDIRadio") - self.personalized_radio.toggled.connect(self._ddi_source_changed) - ddi_layout.addWidget(self.personalized_radio) - ddi_layout.addWidget(self.local_radio) - self.ddi_description = QTextBrowser() - self.ddi_description.setObjectName("ddiDescription") - self.ddi_description.setOpenExternalLinks(True) - self.ddi_description.setMaximumHeight(145) - ddi_layout.addWidget(self.ddi_description) - button_layout = QHBoxLayout() - self.mount_button = QPushButton("Mount Personalized DDI") - self.mount_button.setObjectName("mountDDIButton") - self.mount_button.clicked.connect(self.mount_selected_ddi) - button_layout.addWidget(self.mount_button) - status_button = QPushButton("List Mounted Images") - status_button.setObjectName("listMountedImagesButton") - status_button.clicked.connect(self.list_mounted_images) - button_layout.addWidget(status_button) - self.remove_button = QPushButton("Unmount Personalized DDI") - self.remove_button.setObjectName("removeDDIButton") - self.remove_button.clicked.connect(self.remove_selected_ddi) - button_layout.addWidget(self.remove_button) - button_layout.addStretch() - ddi_layout.addLayout(button_layout) - layout.addWidget(ddi_group) - - xcode_group = QGroupBox("3. Apple developer-tool handoff") - xcode_layout = QVBoxLayout(xcode_group) - xcode_explanation = QLabel( - "Use Apple's installed tools for CoreDevice visibility, RVI status, projects, test results, and " - "Instruments traces. The toolkit shows exact command output but does not reinterpret proprietary " - "Xcode formats." - ) - xcode_explanation.setWordWrap(True) - xcode_layout.addWidget(xcode_explanation) - xcode_buttons = QHBoxLayout() - self.coredevice_details_button = QPushButton("CoreDevice Details") - self.coredevice_details_button.setObjectName("coreDeviceDetailsButton") - self.coredevice_details_button.clicked.connect(self.show_coredevice_details) - xcode_buttons.addWidget(self.coredevice_details_button) - self.rvi_status_button = QPushButton("List RVI Interfaces") - self.rvi_status_button.setObjectName("listRVIInterfacesButton") - self.rvi_status_button.clicked.connect(self.list_rvi_interfaces) - xcode_buttons.addWidget(self.rvi_status_button) - self.open_xcode_project_button = QPushButton("Open Xcode Project…") - self.open_xcode_project_button.setObjectName("openXcodeProjectButton") - self.open_xcode_project_button.clicked.connect(self.open_xcode_project) - xcode_buttons.addWidget(self.open_xcode_project_button) - open_artifact_button = QPushButton("Open Result / Trace…") - open_artifact_button.setObjectName("openXcodeArtifactButton") - open_artifact_button.clicked.connect(self.open_xcode_artifact) - xcode_buttons.addWidget(open_artifact_button) - xcode_buttons.addStretch() - xcode_layout.addLayout(xcode_buttons) - layout.addWidget(xcode_group) - - self.action_output = QPlainTextEdit() - self.action_output.setObjectName("ddiActionOutput") - self.action_output.setReadOnly(True) - self.action_output.setMaximumBlockCount(3000) - self.action_output.setPlaceholderText("DDI, Developer Mode, CoreDevice, and RVI command output appears here.") - layout.addWidget(self.action_output, 1) - self._ddi_source_changed() - return tab - - def _build_capability_matrix_page(self) -> QWidget: - page = QWidget() - layout = QVBoxLayout(page) - layout.setSpacing(12) - - heading = QLabel("Device Capability Matrix") - heading.setObjectName("pageTitle") - heading.setFont(QFont(heading.font().family(), 20, QFont.Weight.Bold)) - layout.addWidget(heading) - explanation = QLabel( - "Run bounded, read-only probes against the selected device. The matrix separates host readiness, trust, " - "Developer Mode, the mounted DDI, iOS 17+ tunneling, developer services, and optional Web Inspector access." - ) - explanation.setWordWrap(True) - layout.addWidget(explanation) - - controls = QHBoxLayout() - self.refresh_capabilities_button = QPushButton("Run Capability Matrix") - self.refresh_capabilities_button.setObjectName("runCapabilityMatrixButton") - self.refresh_capabilities_button.clicked.connect(self.refresh_capability_matrix) - controls.addWidget(self.refresh_capabilities_button) - self.cancel_capabilities_button = QPushButton("Cancel") - self.cancel_capabilities_button.setObjectName("cancelCapabilityMatrixButton") - self.cancel_capabilities_button.clicked.connect(self.cancel_capability_matrix) - controls.addWidget(self.cancel_capabilities_button) - self.copy_capabilities_button = QPushButton("Copy Report") - self.copy_capabilities_button.setObjectName("copyCapabilityMatrixButton") - self.copy_capabilities_button.clicked.connect(self.copy_capability_report) - controls.addWidget(self.copy_capabilities_button) - open_device_button = QPushButton("Open Device && DDI") - open_device_button.setObjectName("capabilityOpenDeviceDDIButton") - open_device_button.clicked.connect(self._navigation_handler("Device & DDI")) - controls.addWidget(open_device_button) - controls.addStretch() - layout.addLayout(controls) - - self.capability_status = QLabel("Select a device and run the matrix. No probe runs automatically.") - self.capability_status.setObjectName("capabilityMatrixStatus") - self.capability_status.setWordWrap(True) - layout.addWidget(self.capability_status) - self.capability_progress = QProgressBar() - self.capability_progress.setObjectName("capabilityMatrixProgress") - self.capability_progress.setTextVisible(True) - self.capability_progress.setRange(0, len(capability_definitions())) - self.capability_progress.setValue(0) - layout.addWidget(self.capability_progress) - - matrix_tabs = QTabWidget() - matrix_tabs.setObjectName("capabilityMatrixTabs") - - current_matrix_page = QWidget() - current_matrix_layout = QVBoxLayout(current_matrix_page) - current_matrix_layout.setContentsMargins(0, 0, 0, 0) - current_matrix_layout.setSpacing(10) - self.capability_table = QTableWidget(0, 4) - self.capability_table.setObjectName("capabilityMatrixTable") - self.capability_table.setHorizontalHeaderLabels(("Layer", "Capability", "State", "Result")) - self.capability_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) - self.capability_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) - self.capability_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - self.capability_table.setAlternatingRowColors(True) - self.capability_table.verticalHeader().setVisible(False) - self.capability_table.itemSelectionChanged.connect(self._capability_selection_changed) - header = self.capability_table.horizontalHeader() - header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) - current_matrix_layout.addWidget(self.capability_table, 1) - - detail_group = QGroupBox("Selected capability evidence and next step") - detail_layout = QVBoxLayout(detail_group) - self.capability_detail = QTextBrowser() - self.capability_detail.setObjectName("capabilityMatrixDetail") - self.capability_detail.setOpenExternalLinks(True) - self.capability_detail.setMaximumHeight(155) - detail_layout.addWidget(self.capability_detail) - current_matrix_layout.addWidget(detail_group) - matrix_tabs.addTab(current_matrix_page, "Current Device") - - compatibility_page = QWidget() - compatibility_layout = QVBoxLayout(compatibility_page) - compatibility_layout.setContentsMargins(0, 0, 0, 0) - compatibility_layout.setSpacing(10) - compatibility_explanation = QLabel( - "This is a local comparison of completed Capability Matrix probes from physically connected devices. " - "It displays the latest result per device fingerprint and never predicts support for an untested model or build." - ) - compatibility_explanation.setWordWrap(True) - compatibility_layout.addWidget(compatibility_explanation) - compatibility_controls = QHBoxLayout() - refresh_history_button = QPushButton("Refresh History") - refresh_history_button.setObjectName("refreshCompatibilityHistoryButton") - refresh_history_button.clicked.connect(self.refresh_compatibility_history) - compatibility_controls.addWidget(refresh_history_button) - copy_history_button = QPushButton("Copy Compatibility Matrix") - copy_history_button.setObjectName("copyCompatibilityMatrixButton") - copy_history_button.clicked.connect(self.copy_compatibility_matrix) - compatibility_controls.addWidget(copy_history_button) - export_json_button = QPushButton("Export Sanitized JSON…") - export_json_button.setObjectName("exportCompatibilityJsonButton") - export_json_button.clicked.connect(self.export_compatibility_json) - compatibility_controls.addWidget(export_json_button) - export_markdown_button = QPushButton("Export Sanitized Markdown…") - export_markdown_button.setObjectName("exportCompatibilityMarkdownButton") - export_markdown_button.clicked.connect(self.export_compatibility_markdown) - compatibility_controls.addWidget(export_markdown_button) - compatibility_controls.addStretch() - compatibility_layout.addLayout(compatibility_controls) - self.compatibility_history_status = QLabel() - self.compatibility_history_status.setObjectName("compatibilityHistoryStatus") - self.compatibility_history_status.setWordWrap(True) - compatibility_layout.addWidget(self.compatibility_history_status) - self.compatibility_history_table = QTableWidget(0, 0) - self.compatibility_history_table.setObjectName("realDeviceCompatibilityTable") - self.compatibility_history_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - self.compatibility_history_table.setAlternatingRowColors(True) - self.compatibility_history_table.verticalHeader().setVisible(False) - compatibility_layout.addWidget(self.compatibility_history_table, 1) - matrix_tabs.addTab(compatibility_page, "Real-Device Compatibility") - layout.addWidget(matrix_tabs, 1) - - privacy = QLabel( - "The probes do not mount images, change settings, start captures, or write device data. Results describe " - "service reachability at one moment; a Ready state is not a guarantee that every downstream command will work." - ) - privacy.setObjectName("capabilityMatrixBoundary") - privacy.setWordWrap(True) - layout.addWidget(privacy) - self._populate_capability_matrix() - self._populate_compatibility_history() - self._update_capability_controls() - return page - - def _build_location_lab_page(self) -> QWidget: - page = QWidget() - layout = QVBoxLayout(page) - layout.setSpacing(12) - - heading = QLabel("Location Lab") - heading.setObjectName("pageTitle") - heading.setFont(QFont(heading.font().family(), 20, QFont.Weight.Bold)) - layout.addWidget(heading) - explanation = QLabel( - "Use a private offline map, imported map-link coordinates, saved places, or validated GPX routes with " - "Apple developer-service location simulation. Nothing is sent to a mapping provider, and clicking the " - "map changes only these fields until you explicitly confirm a device action." - ) - explanation.setWordWrap(True) - layout.addWidget(explanation) - - status_group = QGroupBox("Toolkit-known state") - status_layout = QGridLayout(status_group) - self.location_state_value = QLabel( - "No simulated location is tracked by this toolkit. External changes cannot be detected automatically." - ) - self.location_state_value.setObjectName("locationStateValue") - self.location_state_value.setWordWrap(True) - status_layout.addWidget(QLabel("State"), 0, 0) - status_layout.addWidget(self.location_state_value, 0, 1, 1, 3) - self.location_target_value = QLabel("Selected device will be used") - self.location_target_value.setObjectName("locationTargetValue") - self.location_target_value.setWordWrap(True) - status_layout.addWidget(QLabel("Target"), 1, 0) - status_layout.addWidget(self.location_target_value, 1, 1, 1, 3) - layout.addWidget(status_group) - - workflow = QSplitter(Qt.Orientation.Horizontal) - workflow.setObjectName("locationWorkflowSplitter") - - coordinate_group = QGroupBox("Fixed location and saved places") - coordinate_layout = QVBoxLayout(coordinate_group) - map_heading = QLabel("Offline click-to-select map") - map_heading.setObjectName("locationMapHeading") - map_heading.setFont(QFont(map_heading.font().family(), 13, QFont.Weight.DemiBold)) - coordinate_layout.addWidget(map_heading) - self.location_map = LocationMapWidget(Coordinates(latitude=34.0522, longitude=-118.2437)) - self.location_map.setObjectName("locationOfflineMap") - self.location_map.coordinate_selected.connect(self._map_location_selected) - coordinate_layout.addWidget(self.location_map) - import_row = QHBoxLayout() - self.location_input_field = QLineEdit() - self.location_input_field.setObjectName("locationCoordinateImporter") - self.location_input_field.setPlaceholderText("latitude,longitude or full Apple Maps / Google Maps / geo: link") - self.location_input_field.returnPressed.connect(self.import_location_coordinates) - import_row.addWidget(self.location_input_field, 1) - self.import_location_button = QPushButton("Import") - self.import_location_button.setObjectName("importLocationCoordinatesButton") - self.import_location_button.clicked.connect(self.import_location_coordinates) - import_row.addWidget(self.import_location_button) - coordinate_layout.addLayout(import_row) - map_note = QLabel( - "Natural Earth map data is bundled locally. Text-only or shortened map links are not resolved over the network." - ) - map_note.setObjectName("locationMapPrivacyNote") - map_note.setWordWrap(True) - coordinate_layout.addWidget(map_note) - coordinate_form = QFormLayout() - saved_row = QHBoxLayout() - self.saved_location_combo = QComboBox() - self.saved_location_combo.setObjectName("savedLocationPicker") - self.saved_location_combo.currentIndexChanged.connect(self._saved_location_selected) - saved_row.addWidget(self.saved_location_combo, 1) - self.remove_saved_location_button = QPushButton("Remove") - self.remove_saved_location_button.setObjectName("removeSavedLocationButton") - self.remove_saved_location_button.clicked.connect(self.remove_selected_saved_location) - saved_row.addWidget(self.remove_saved_location_button) - coordinate_form.addRow("Saved place", saved_row) - self.location_latitude_field = QLineEdit("34.0522") - self.location_latitude_field.setObjectName("locationLatitude") - self.location_latitude_field.setPlaceholderText("-90 to 90") - self.location_latitude_field.editingFinished.connect(self.sync_location_map_from_fields) - coordinate_form.addRow("Latitude", self.location_latitude_field) - self.location_longitude_field = QLineEdit("-118.2437") - self.location_longitude_field.setObjectName("locationLongitude") - self.location_longitude_field.setPlaceholderText("-180 to 180") - self.location_longitude_field.editingFinished.connect(self.sync_location_map_from_fields) - coordinate_form.addRow("Longitude", self.location_longitude_field) - coordinate_layout.addLayout(coordinate_form) - coordinate_buttons = QHBoxLayout() - self.save_location_button = QPushButton("Save Current…") - self.save_location_button.setObjectName("saveLocationButton") - self.save_location_button.clicked.connect(self.save_current_location) - coordinate_buttons.addWidget(self.save_location_button) - self.set_location_button = QPushButton("Set Simulated Location…") - self.set_location_button.setObjectName("setSimulatedLocationButton") - self.set_location_button.clicked.connect(self.set_simulated_location) - coordinate_buttons.addWidget(self.set_location_button) - coordinate_layout.addLayout(coordinate_buttons) - nudge_grid = QGridLayout() - self.location_nudge_distance = QSpinBox() - self.location_nudge_distance.setObjectName("locationNudgeDistance") - self.location_nudge_distance.setRange(1, 100000) - self.location_nudge_distance.setValue(10) - self.location_nudge_distance.setSuffix(" m") - nudge_grid.addWidget(QLabel("Nudge coordinate fields"), 0, 0, 1, 2) - nudge_grid.addWidget(self.location_nudge_distance, 0, 2, 1, 2) - directions = (("N", 0.0), ("NE", 45.0), ("E", 90.0), ("SE", 135.0), ("S", 180.0), ("SW", 225.0), ("W", 270.0), ("NW", 315.0)) - for position, (label, bearing) in enumerate(directions): - button = QPushButton(label) - button.setObjectName(f"nudgeLocation{label}Button") - button.setToolTip(f"Move the coordinate fields {label} without changing the device") - button.clicked.connect(lambda checked=False, selected_bearing=bearing: self.nudge_location_fields(selected_bearing)) - nudge_grid.addWidget(button, 1 + position // 4, position % 4) - coordinate_layout.addLayout(nudge_grid) - saved_note = QLabel( - f"Saved places stay local in {self._saved_locations_path}. They are never synchronized by this project." - ) - saved_note.setWordWrap(True) - coordinate_layout.addWidget(saved_note) - coordinate_layout.addStretch() - workflow.addWidget(coordinate_group) - - route_group = QGroupBox("GPX route playback") - route_layout = QVBoxLayout(route_group) - route_row = QHBoxLayout() - self.location_gpx_field = QLineEdit() - self.location_gpx_field.setObjectName("locationGPXPath") - self.location_gpx_field.setReadOnly(True) - self.location_gpx_field.setPlaceholderText("Choose a local GPX track") - route_row.addWidget(self.location_gpx_field, 1) - self.choose_location_gpx_button = QPushButton("Choose GPX…") - self.choose_location_gpx_button.setObjectName("chooseLocationGPXButton") - self.choose_location_gpx_button.clicked.connect(self.choose_location_gpx) - route_row.addWidget(self.choose_location_gpx_button) - route_layout.addLayout(route_row) - self.location_gpx_summary = QLabel( - "The toolkit requires GPX track points, validates every coordinate, and records the file's SHA-256." - ) - self.location_gpx_summary.setObjectName("locationGPXSummary") - self.location_gpx_summary.setWordWrap(True) - route_layout.addWidget(self.location_gpx_summary) - route_options = QFormLayout() - self.location_timing_randomness = QSpinBox() - self.location_timing_randomness.setObjectName("locationTimingRandomness") - self.location_timing_randomness.setRange(0, 60000) - self.location_timing_randomness.setValue(0) - self.location_timing_randomness.setSuffix(" ms") - route_options.addRow("Timing randomness", self.location_timing_randomness) - self.location_disable_sleep = QCheckBox("Ignore GPX timing delays") - self.location_disable_sleep.setObjectName("locationDisableSleep") - route_options.addRow("Fast playback", self.location_disable_sleep) - route_layout.addLayout(route_options) - self.play_location_gpx_button = QPushButton("Play Validated GPX…") - self.play_location_gpx_button.setObjectName("playLocationGPXButton") - self.play_location_gpx_button.clicked.connect(self.play_location_gpx) - route_layout.addWidget(self.play_location_gpx_button) - route_layout.addStretch() - workflow.addWidget(route_group) - workflow.setSizes([430, 430]) - workflow.setStretchFactor(0, 1) - workflow.setStretchFactor(1, 1) - layout.addWidget(workflow) - - builder_group = QGroupBox("Local QA route builder") - builder_layout = QHBoxLayout(builder_group) - self.location_route_waypoints = QPlainTextEdit() - self.location_route_waypoints.setObjectName("locationRouteWaypoints") - self.location_route_waypoints.setPlaceholderText( - "One latitude,longitude waypoint per line\n34.052200,-118.243700\n34.053000,-118.242000" - ) - self.location_route_waypoints.setMaximumHeight(110) - builder_layout.addWidget(self.location_route_waypoints, 2) - builder_options = QFormLayout() - self.location_route_speed_preset = QComboBox() - self.location_route_speed_preset.setObjectName("locationRouteSpeedPreset") - for name, value in (("Walk", 5), ("Run", 10), ("Bicycle", 20), ("Urban drive", 40), ("Highway", 100)): - self.location_route_speed_preset.addItem(f"{name} — {value} km/h", value) - self.location_route_speed_preset.currentIndexChanged.connect(self.apply_location_speed_preset) - builder_options.addRow("Speed preset", self.location_route_speed_preset) - self.location_route_speed = QSpinBox() - self.location_route_speed.setObjectName("locationRouteSpeed") - self.location_route_speed.setRange(1, 300) - self.location_route_speed.setValue(5) - self.location_route_speed.setSuffix(" km/h") - builder_options.addRow("Speed", self.location_route_speed) - self.location_route_interval = QSpinBox() - self.location_route_interval.setObjectName("locationRouteInterval") - self.location_route_interval.setRange(1, 60) - self.location_route_interval.setValue(1) - self.location_route_interval.setSuffix(" s") - builder_options.addRow("Point interval", self.location_route_interval) - self.location_route_traversals = QSpinBox() - self.location_route_traversals.setObjectName("locationRouteTraversals") - self.location_route_traversals.setRange(1, 20) - self.location_route_traversals.setValue(1) - self.location_route_traversals.setToolTip("Additional traversals alternate direction instead of teleporting to the start") - builder_options.addRow("Traversals", self.location_route_traversals) - builder_layout.addLayout(builder_options, 1) - builder_buttons = QVBoxLayout() - add_waypoint = QPushButton("Add Current Coordinate") - add_waypoint.setObjectName("addCurrentRouteWaypointButton") - add_waypoint.clicked.connect(self.add_current_route_waypoint) - builder_buttons.addWidget(add_waypoint) - build_button = QPushButton("Build, Validate && Load GPX") - build_button.setObjectName("buildLocationRouteButton") - build_button.clicked.connect(self.build_location_route) - builder_buttons.addWidget(build_button) - self.location_route_summary = QLabel("Generated routes stay local and use timestamped GPX points.") - self.location_route_summary.setObjectName("locationRouteSummary") - self.location_route_summary.setWordWrap(True) - builder_buttons.addWidget(self.location_route_summary) - builder_layout.addLayout(builder_buttons, 2) - layout.addWidget(builder_group) - - evidence_group = QGroupBox("Cleanup and evidence log") - evidence_layout = QGridLayout(evidence_group) - self.location_log_directory_field = QLineEdit( - str(Path.home() / "Documents" / "iOS Developer Toolkit Location Logs") - ) - self.location_log_directory_field.setObjectName("locationLogDirectory") - evidence_layout.addWidget(QLabel("Log directory"), 0, 0) - evidence_layout.addWidget(self.location_log_directory_field, 0, 1) - choose_log = QPushButton("Choose…") - choose_log.setObjectName("chooseLocationLogDirectoryButton") - choose_log.clicked.connect(self.choose_location_log_directory) - evidence_layout.addWidget(choose_log, 0, 2) - self.open_location_log_button = QPushButton("Open Log Folder") - self.open_location_log_button.setObjectName("openLocationLogButton") - self.open_location_log_button.clicked.connect(self.open_location_log_directory) - evidence_layout.addWidget(self.open_location_log_button, 0, 3) - self.stop_clear_location_button = QPushButton("Stop Playback / Set && Clear") - self.stop_clear_location_button.setObjectName("stopAndClearLocationButton") - self.stop_clear_location_button.clicked.connect(self.stop_location_and_clear) - evidence_layout.addWidget(self.stop_clear_location_button, 1, 1) - self.clear_location_button = QPushButton("Clear Simulated Location…") - self.clear_location_button.setObjectName("clearSimulatedLocationButton") - self.clear_location_button.clicked.connect(self.clear_simulated_location) - evidence_layout.addWidget(self.clear_location_button, 1, 2) - open_help = QPushButton("Open Live Location Help") - open_help.setObjectName("openLocationHelpButton") - open_help.clicked.connect(self.open_location_help) - evidence_layout.addWidget(open_help, 1, 3) - layout.addWidget(evidence_group) - - privacy = QLabel( - "Location simulation changes device state and may affect participating apps. Coordinates, the device UDID, " - "GPX path/hash, commands, timestamps, and results are sensitive and are appended to location-events.jsonl. " - "The status above reflects only actions started here; always clear the simulation when testing ends." - ) - privacy.setObjectName("locationPrivacyWarning") - privacy.setWordWrap(True) - layout.addWidget(privacy) - - self.location_output = QPlainTextEdit() - self.location_output.setObjectName("locationOutput") - self.location_output.setReadOnly(True) - self.location_output.setMaximumBlockCount(4000) - self.location_output.setPlaceholderText("Location commands, service output, and evidence-log status appear here.") - layout.addWidget(self.location_output, 1) - self._populate_saved_locations() - self._update_location_controls() - page.setMinimumHeight(1050) - scroll_area = QScrollArea() - scroll_area.setObjectName("locationLabScrollArea") - scroll_area.setWidgetResizable(True) - scroll_area.setFrameShape(QFrame.Shape.NoFrame) - scroll_area.setWidget(page) - return scroll_area - - def _build_live_logs_page(self) -> QWidget: - return build_live_logs_page( - log_stream_specs(), - self.open_live_log_window, - self._open_log_presets, - self.navigate_to_page, - ) - - def _build_collection_tab(self) -> QWidget: - tab = QWidget() - layout = QVBoxLayout(tab) - layout.setSpacing(14) - - intake_group = QGroupBox("1. Guided case intake") - intake_layout = QFormLayout(intake_group) - self.case_title_field = QLineEdit() - self.case_title_field.setObjectName("caseTitle") - self.case_title_field.setPlaceholderText("Example: Pre-release device validation") - intake_layout.addRow("Case title", self.case_title_field) - self.case_purpose_field = QPlainTextEdit() - self.case_purpose_field.setObjectName("casePurpose") - self.case_purpose_field.setPlaceholderText("Optional local note about the authorized purpose and scope.") - self.case_purpose_field.setMaximumHeight(72) - intake_layout.addRow("Purpose / scope", self.case_purpose_field) - self.case_authorization_checkbox = QCheckBox("I own this device or am authorized to examine it.") - self.case_authorization_checkbox.setObjectName("caseAuthorizationAcknowledgement") - intake_layout.addRow("Authorization", self.case_authorization_checkbox) - case_actions = QHBoxLayout() - self.case_readiness_button = QPushButton("Run Device Readiness Check") - self.case_readiness_button.setObjectName("guidedCaseReadinessButton") - self.case_readiness_button.clicked.connect(self.run_guided_case_readiness_check) - self.case_readiness_button.setToolTip( - "Run the bounded, read-only Capability Matrix for the selected device before creating or collecting a case." - ) - case_actions.addWidget(self.case_readiness_button) - self.create_case_button = QPushButton("Create Guided Case") - self.create_case_button.setObjectName("createGuidedCaseButton") - self.create_case_button.clicked.connect(self.create_guided_case) - case_actions.addWidget(self.create_case_button) - self.case_status = QLabel("No active case. Create one before collection to retain intake and scope metadata.") - self.case_status.setObjectName("guidedCaseStatus") - self.case_status.setWordWrap(True) - case_actions.addWidget(self.case_status, 1) - intake_layout.addRow(case_actions) - layout.addWidget(intake_group) - - destination_group = QGroupBox("Evidence case") - destination_layout = QFormLayout(destination_group) - output_row = QHBoxLayout() - self.output_root = QLineEdit(str(Path.home() / "Documents" / "iOS Developer Toolkit Cases")) - self.output_root.setObjectName("evidenceOutputRoot") - output_row.addWidget(self.output_root, 1) - browse_button = QPushButton("Choose…") - browse_button.setObjectName("chooseEvidenceFolderButton") - browse_button.clicked.connect(self.choose_output_root) - output_row.addWidget(browse_button) - destination_layout.addRow("Store case folders in", output_row) - self.capture_duration = QSpinBox() - self.capture_duration.setObjectName("captureDurationSeconds") - self.capture_duration.setRange(10, 3600) - self.capture_duration.setValue(300) - self.capture_duration.setSuffix(" seconds") - destination_layout.addRow("Live capture duration", self.capture_duration) - layout.addWidget(destination_group) - - options_group = QGroupBox("Collection coverage") - options_layout = QGridLayout(options_group) - self.include_syslog = QCheckBox("Classic syslog stream") - self.include_syslog.setChecked(True) - self.include_oslog = QCheckBox("DVT structured Unified Logging stream") - self.include_oslog.setChecked(True) - self.include_pcap = QCheckBox("Network PCAP with process metadata") - self.include_pcap.setChecked(True) - self.include_screenshot = QCheckBox("Capture current screen") - self.include_crash_pull = QCheckBox("Pull all crash reports") - for checkbox, name in ( - (self.include_syslog, "includeSyslog"), - (self.include_oslog, "includeDVTOSLog"), - (self.include_pcap, "includePCAP"), - (self.include_screenshot, "includeScreenshot"), - (self.include_crash_pull, "includeCrashPull"), - ): - checkbox.setObjectName(name) - options_layout.addWidget(self.include_syslog, 0, 0) - options_layout.addWidget(self.include_oslog, 0, 1) - options_layout.addWidget(self.include_pcap, 1, 0) - options_layout.addWidget(self.include_screenshot, 1, 1) - options_layout.addWidget(self.include_crash_pull, 2, 0) - coverage_note = QLabel( - "Every run also inventories lockdown, mounted images, diagnostics, MobileGestalt, IORegistry, battery, apps, " - "processes, profiles, provisioning, AFC, crash names, DVT device data, DVT sysmon, and the DVT root listing." - ) - coverage_note.setWordWrap(True) - options_layout.addWidget(coverage_note, 3, 0, 1, 2) - layout.addWidget(options_group) - - privacy = QLabel( - "PCAP, logs, screenshots, UDIDs, app lists, and crash reports can contain private information. " - "The output stays in the selected local folder; review and sanitize it before sharing." - ) - privacy.setObjectName("collectionPrivacyWarning") - privacy.setWordWrap(True) - layout.addWidget(privacy) - - controls = QHBoxLayout() - self.start_collection_button = QPushButton("Start Evidence Collection") - self.start_collection_button.setObjectName("startCollectionButton") - self.start_collection_button.clicked.connect(self.start_collection) - controls.addWidget(self.start_collection_button) - self.stop_collection_button = QPushButton("Stop & Finalize") - self.stop_collection_button.setObjectName("stopCollectionButton") - self.stop_collection_button.setEnabled(False) - self.stop_collection_button.clicked.connect(self.stop_collection) - controls.addWidget(self.stop_collection_button) - self.open_case_button = QPushButton("Open Last Case") - self.open_case_button.setObjectName("openLastCaseButton") - self.open_case_button.setEnabled(False) - self.open_case_button.clicked.connect(self.open_last_case) - controls.addWidget(self.open_case_button) - controls.addStretch() - layout.addLayout(controls) - - self.collection_output = QPlainTextEdit() - self.collection_output.setObjectName("collectionOutput") - self.collection_output.setReadOnly(True) - self.collection_output.setMaximumBlockCount(7000) - layout.addWidget(self.collection_output, 1) - return tab - - def _build_sideload_tab(self) -> QWidget: - tab = QWidget() - layout = QVBoxLayout(tab) - layout.setSpacing(12) - - package_group = QGroupBox("IPA package and local verification") - package_layout = QVBoxLayout(package_group) - package_row = QHBoxLayout() - self.ipa_path_field = QLineEdit() - self.ipa_path_field.setObjectName("ipaPathField") - self.ipa_path_field.setReadOnly(True) - self.ipa_path_field.setPlaceholderText("Choose a locally stored .ipa package") - package_row.addWidget(self.ipa_path_field, 1) - self.choose_ipa_button = QPushButton("Choose IPA…") - self.choose_ipa_button.setObjectName("chooseIPAButton") - self.choose_ipa_button.clicked.connect(self.choose_ipa) - package_row.addWidget(self.choose_ipa_button) - package_layout.addLayout(package_row) - - self.ipa_inspection_progress = QProgressBar() - self.ipa_inspection_progress.setObjectName("ipaInspectionProgress") - self.ipa_inspection_progress.setRange(0, 0) - self.ipa_inspection_progress.setVisible(False) - package_layout.addWidget(self.ipa_inspection_progress) - - self.ipa_inspection_summary = QPlainTextEdit() - self.ipa_inspection_summary.setObjectName("ipaInspectionSummary") - self.ipa_inspection_summary.setReadOnly(True) - self.ipa_inspection_summary.setMaximumBlockCount(500) - self.ipa_inspection_summary.setMaximumHeight(245) - self.ipa_inspection_summary.setPlaceholderText( - "The toolkit will validate the IPA archive, decode embedded provisioning metadata, " - "extract it into a temporary directory, and ask macOS codesign to verify the app bundle." - ) - package_layout.addWidget(self.ipa_inspection_summary) - layout.addWidget(package_group) - - install_group = QGroupBox("Install on the selected device") - install_layout = QHBoxLayout(install_group) - self.developer_package_checkbox = QCheckBox("Install as developer package") - self.developer_package_checkbox.setObjectName("developerPackageCheckbox") - install_layout.addWidget(self.developer_package_checkbox) - install_layout.addStretch() - self.install_ipa_button = QPushButton("Install Verified IPA") - self.install_ipa_button.setObjectName("installIPAButton") - self.install_ipa_button.setEnabled(False) - self.install_ipa_button.clicked.connect(self.install_selected_ipa) - install_layout.addWidget(self.install_ipa_button) - self.stop_sideload_button = QPushButton("Stop") - self.stop_sideload_button.setObjectName("stopSideloadButton") - self.stop_sideload_button.setEnabled(False) - self.stop_sideload_button.clicked.connect(self.stop_sideload_action) - install_layout.addWidget(self.stop_sideload_button) - layout.addWidget(install_group) - - self.sideload_status = QLabel( - "Only correctly signed and provisioned packages can run on stock iOS. " - "The DDI does not sign an IPA or bypass Apple installation policy." - ) - self.sideload_status.setObjectName("sideloadStatus") - self.sideload_status.setWordWrap(True) - layout.addWidget(self.sideload_status) - - self.sideload_activity_progress = QProgressBar() - self.sideload_activity_progress.setObjectName("sideloadActivityProgress") - self.sideload_activity_progress.setRange(0, 0) - self.sideload_activity_progress.setVisible(False) - layout.addWidget(self.sideload_activity_progress) - - self.sideload_output = QPlainTextEdit() - self.sideload_output.setObjectName("sideloadOutput") - self.sideload_output.setReadOnly(True) - self.sideload_output.setMaximumBlockCount(7000) - layout.addWidget(self.sideload_output, 1) - return tab - - def _build_installed_apps_tab(self) -> QWidget: - tab = QWidget() - layout = QVBoxLayout(tab) - layout.setSpacing(12) - - controls = QHBoxLayout() - self.app_filter_field = QLineEdit() - self.app_filter_field.setObjectName("installedAppsFilter") - self.app_filter_field.setPlaceholderText("Filter by app name, bundle ID, version, or type") - self.app_filter_field.textChanged.connect(self._filter_installed_apps) - controls.addWidget(self.app_filter_field, 1) - self.calculate_app_sizes_checkbox = QCheckBox("Calculate sizes") - self.calculate_app_sizes_checkbox.setObjectName("calculateInstalledAppSizes") - controls.addWidget(self.calculate_app_sizes_checkbox) - self.refresh_apps_button = QPushButton("Refresh") - self.refresh_apps_button.setObjectName("refreshInstalledAppsButton") - self.refresh_apps_button.clicked.connect(self.refresh_app_inventory) - controls.addWidget(self.refresh_apps_button) - layout.addLayout(controls) - - self.installed_apps_table = QTableWidget(0, 6) - self.installed_apps_table.setObjectName("installedAppsTable") - self.installed_apps_table.setHorizontalHeaderLabels( - ("Name", "Bundle ID", "Version", "Build", "Type", "Size") - ) - self.installed_apps_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) - self.installed_apps_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) - self.installed_apps_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - self.installed_apps_table.setAlternatingRowColors(True) - self.installed_apps_table.setSortingEnabled(True) - self.installed_apps_table.itemSelectionChanged.connect(self._installed_app_selection_changed) - header = self.installed_apps_table.horizontalHeader() - header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) - for column in range(2, 6): - header.setSectionResizeMode(column, QHeaderView.ResizeMode.ResizeToContents) - layout.addWidget(self.installed_apps_table, 1) - - action_row = QHBoxLayout() - self.apps_status = QLabel("Connect a trusted device, then refresh the inventory.") - self.apps_status.setObjectName("installedAppsStatus") - self.apps_status.setWordWrap(True) - action_row.addWidget(self.apps_status, 1) - self.copy_bundle_id_button = QPushButton("Copy Bundle ID") - self.copy_bundle_id_button.setObjectName("copyInstalledAppBundleID") - self.copy_bundle_id_button.clicked.connect(self.copy_selected_bundle_identifier) - action_row.addWidget(self.copy_bundle_id_button) - self.uninstall_app_button = QPushButton("Uninstall Selected…") - self.uninstall_app_button.setObjectName("uninstallSelectedAppButton") - self.uninstall_app_button.clicked.connect(self.uninstall_selected_application) - action_row.addWidget(self.uninstall_app_button) - self.stop_apps_button = QPushButton("Stop") - self.stop_apps_button.setObjectName("stopInstalledAppsOperationButton") - self.stop_apps_button.clicked.connect(self.stop_apps_action) - action_row.addWidget(self.stop_apps_button) - layout.addLayout(action_row) - - privacy = QLabel( - "The inventory can reveal sensitive app usage. It stays in memory unless you include app inventory in an evidence collection." - ) - privacy.setObjectName("installedAppsPrivacyWarning") - privacy.setWordWrap(True) - layout.addWidget(privacy) - - self.apps_output = QPlainTextEdit() - self.apps_output.setObjectName("installedAppsOutput") - self.apps_output.setReadOnly(True) - self.apps_output.setMaximumBlockCount(3000) - self.apps_output.setMaximumHeight(145) - layout.addWidget(self.apps_output) - self._update_apps_controls() - return tab - - def _build_backup_tab(self) -> QWidget: - page = QWidget() - layout = QVBoxLayout(page) - layout.setSpacing(12) - - heading = QLabel("Backup providers") - heading.setObjectName("pageTitle") - heading.setFont(QFont(heading.font().family(), 20, QFont.Weight.Bold)) - layout.addWidget(heading) - explanation = QLabel( - "Create a MobileBackup2 backup, launch a separately installed UFADE acquisition, or hand a decrypted " - "backup to an independently installed MVT analysis. The providers use isolated runtimes and do not share " - "passwords or dependencies." - ) - explanation.setWordWrap(True) - layout.addWidget(explanation) - - provider_tabs = QTabWidget() - provider_tabs.setObjectName("backupProviderTabs") - provider_tabs.addTab(self._build_mobilebackup_page(), "MobileBackup2") - provider_tabs.addTab(self._build_ufade_backup_page(), "UFADE External") - provider_tabs.addTab(self._build_mvt_analysis_page(), "MVT Analysis") - provider_tabs.setTabToolTip(0, "Toolkit-managed full or incremental iTunes-style backup") - provider_tabs.setTabToolTip(1, "Launch an independently installed UFADE acquisition environment") - provider_tabs.setTabToolTip(2, "Analyze a consented decrypted backup with an independently installed MVT CLI") - layout.addWidget(provider_tabs, 1) - return page - - def _build_mobilebackup_page(self) -> QWidget: - tab = QWidget() - layout = QVBoxLayout(tab) - layout.setSpacing(12) - - destination_group = QGroupBox("Local backup destination") - destination_layout = QFormLayout(destination_group) - destination_row = QHBoxLayout() - self.backup_destination_field = QLineEdit(str(Path.home() / "Documents" / "iOS Developer Toolkit Backups")) - self.backup_destination_field.setObjectName("backupDestination") - destination_row.addWidget(self.backup_destination_field, 1) - choose_destination_button = QPushButton("Choose…") - choose_destination_button.setObjectName("chooseBackupDestinationButton") - choose_destination_button.clicked.connect(self.choose_backup_destination) - destination_row.addWidget(choose_destination_button) - self.open_backup_button = QPushButton("Open Folder") - self.open_backup_button.setObjectName("openBackupFolderButton") - self.open_backup_button.clicked.connect(self.open_backup_folder) - destination_row.addWidget(self.open_backup_button) - destination_layout.addRow("Store backups in", destination_row) - self.full_backup_checkbox = QCheckBox("Force a full backup instead of reusing valid incremental state") - self.full_backup_checkbox.setObjectName("forceFullBackup") - destination_layout.addRow("Backup mode", self.full_backup_checkbox) - layout.addWidget(destination_group) - - encryption_group = QGroupBox("Backup encryption") - encryption_layout = QFormLayout(encryption_group) - encryption_status_row = QHBoxLayout() - self.backup_encryption_status = QLabel("Encryption state not checked for this device") - self.backup_encryption_status.setObjectName("backupEncryptionStatus") - encryption_status_row.addWidget(self.backup_encryption_status, 1) - self.check_encryption_button = QPushButton("Check Status") - self.check_encryption_button.setObjectName("checkBackupEncryptionButton") - self.check_encryption_button.clicked.connect(self.check_backup_encryption) - encryption_status_row.addWidget(self.check_encryption_button) - encryption_layout.addRow("Device setting", encryption_status_row) - self.require_encryption_checkbox = QCheckBox("Require encrypted backup (enable persistent encryption if needed)") - self.require_encryption_checkbox.setObjectName("requireEncryptedBackup") - self.require_encryption_checkbox.setChecked(True) - self.require_encryption_checkbox.toggled.connect(self._backup_encryption_choice_changed) - encryption_layout.addRow("Policy", self.require_encryption_checkbox) - self.backup_password_field = QLineEdit() - self.backup_password_field.setObjectName("newBackupEncryptionPassword") - self.backup_password_field.setEchoMode(QLineEdit.EchoMode.Password) - self.backup_password_field.setPlaceholderText("New password, only if encryption is currently off") - encryption_layout.addRow("New password", self.backup_password_field) - self.backup_password_confirmation_field = QLineEdit() - self.backup_password_confirmation_field.setObjectName("confirmBackupEncryptionPassword") - self.backup_password_confirmation_field.setEchoMode(QLineEdit.EchoMode.Password) - self.backup_password_confirmation_field.setPlaceholderText("Enter the new password again") - encryption_layout.addRow("Confirm password", self.backup_password_confirmation_field) - warning = QLabel( - "Encryption is a persistent device backup setting. The password is sent only through a private helper input stream and is never logged or saved. " - "Store it safely: previous encrypted backups cannot be restored without their password. This toolkit never disables encryption automatically." - ) - warning.setObjectName("backupEncryptionWarning") - warning.setWordWrap(True) - encryption_layout.addRow(warning) - layout.addWidget(encryption_group) - - controls = QHBoxLayout() - self.start_backup_button = QPushButton("Start Backup…") - self.start_backup_button.setObjectName("startDeviceBackupButton") - self.start_backup_button.clicked.connect(self.start_backup) - controls.addWidget(self.start_backup_button) - self.stop_backup_button = QPushButton("Stop") - self.stop_backup_button.setObjectName("stopDeviceBackupButton") - self.stop_backup_button.clicked.connect(self.stop_backup) - controls.addWidget(self.stop_backup_button) - controls.addStretch() - layout.addLayout(controls) - - self.backup_progress = QProgressBar() - self.backup_progress.setObjectName("deviceBackupProgress") - self.backup_progress.setRange(0, 100) - self.backup_progress.setValue(0) - layout.addWidget(self.backup_progress) - - self.backup_output = QPlainTextEdit() - self.backup_output.setObjectName("deviceBackupOutput") - self.backup_output.setReadOnly(True) - self.backup_output.setMaximumBlockCount(7000) - layout.addWidget(self.backup_output, 1) - self._backup_encryption_choice_changed(self.require_encryption_checkbox.isChecked()) - self._update_backup_controls() - return tab - - def _build_ufade_backup_page(self) -> QWidget: - page = QWidget() - layout = QVBoxLayout(page) - layout.setSpacing(12) - - overview = QLabel( - "UFADE (Universal Forensic Apple Device Extractor) is an independent GPL-3.0 application. " - "This toolkit validates and launches a user-managed UFADE checkout; it does not vendor, import, modify, " - "or redistribute UFADE and does not read UFADE passwords or acquisition output." - ) - overview.setObjectName("ufadeProviderExplanation") - overview.setWordWrap(True) - layout.addWidget(overview) - - quick_start_group = QGroupBox("How to install and run UFADE") - quick_start_layout = QVBoxLayout(quick_start_group) - quick_start = QLabel( - "1. Copy and run the macOS setup commands in Terminal. 2. Choose the cloned checkout, its .venv Python, " - "and a protected output folder. 3. Validate the installation. 4. Connect, unlock, and trust one intended " - "device. 5. Launch UFADE, choose the acquisition inside its window, and use UFADE's own progress and stop controls." - ) - quick_start.setObjectName("ufadeQuickStart") - quick_start.setWordWrap(True) - quick_start_layout.addWidget(quick_start) - guide_controls = QHBoxLayout() - guide_button = QPushButton("Open Full Walkthrough") - guide_button.setObjectName("openUFADEGuideButton") - guide_button.clicked.connect(self.show_ufade_guide) - guide_controls.addWidget(guide_button) - setup_button = QPushButton("Copy Setup Commands") - setup_button.setObjectName("copyUFADESetupButton") - setup_button.clicked.connect(self.copy_ufade_setup_commands) - guide_controls.addWidget(setup_button) - official_guide_button = QPushButton("Open Official Guide") - official_guide_button.setObjectName("openUFADEOfficialGuideButton") - official_guide_button.clicked.connect(self.open_ufade_installation_guide) - guide_controls.addWidget(official_guide_button) - guide_controls.addStretch() - quick_start_layout.addLayout(guide_controls) - layout.addWidget(quick_start_group) - - types_group = QGroupBox("Acquisition types selected inside UFADE") - types_layout = QVBoxLayout(types_group) - types = QLabel( - "• Logical — iTunes-style MobileBackup2 acquisition.\n" - "• Logical+ — backup plus AFC media, shared app folders, crash reports, and optional Unified Logs.\n" - "• Logical+ UFD — advanced logical ZIP with a UFD descriptor for compatible forensic tooling.\n" - "• PRFS — decrypted, filesystem-shaped logical archive assembled from service-visible data.\n" - "• Full filesystem — only for a device that is already jailbroken; UFADE does not provide a bypass." - ) - types.setWordWrap(True) - types_layout.addWidget(types) - layout.addWidget(types_group) - - setup_group = QGroupBox("Separate UFADE installation") - setup_layout = QFormLayout(setup_group) - - checkout_row = QHBoxLayout() - self.ufade_checkout_field = QLineEdit() - self.ufade_checkout_field.setObjectName("ufadeCheckout") - self.ufade_checkout_field.setPlaceholderText("Absolute path to a cloned prosch88/UFADE checkout") - self.ufade_checkout_field.textChanged.connect(self._invalidate_ufade_validation) - checkout_row.addWidget(self.ufade_checkout_field, 1) - choose_checkout = QPushButton("Choose…") - choose_checkout.setObjectName("chooseUFADECheckoutButton") - choose_checkout.clicked.connect(self.choose_ufade_checkout) - checkout_row.addWidget(choose_checkout) - setup_layout.addRow("UFADE checkout", checkout_row) - - python_row = QHBoxLayout() - self.ufade_python_field = QLineEdit() - self.ufade_python_field.setObjectName("ufadePythonExecutable") - self.ufade_python_field.setPlaceholderText("UFADE's separate Python 3.11 virtual-environment executable") - self.ufade_python_field.textChanged.connect(self._invalidate_ufade_validation) - python_row.addWidget(self.ufade_python_field, 1) - choose_python = QPushButton("Choose…") - choose_python.setObjectName("chooseUFADEPythonButton") - choose_python.clicked.connect(self.choose_ufade_python) - python_row.addWidget(choose_python) - use_checkout_python = QPushButton("Use Checkout .venv") - use_checkout_python.setObjectName("useUFADECheckoutVenvButton") - use_checkout_python.clicked.connect(self.use_checkout_ufade_python) - python_row.addWidget(use_checkout_python) - setup_layout.addRow("Python 3.11", python_row) - - output_row = QHBoxLayout() - self.ufade_output_field = QLineEdit(str(Path.home() / "Documents" / "UFADE Acquisitions")) - self.ufade_output_field.setObjectName("ufadeOutputDirectory") - output_row.addWidget(self.ufade_output_field, 1) - choose_output = QPushButton("Choose…") - choose_output.setObjectName("chooseUFADEOutputButton") - choose_output.clicked.connect(self.choose_ufade_output_directory) - output_row.addWidget(choose_output) - self.open_ufade_output_button = QPushButton("Open Folder") - self.open_ufade_output_button.setObjectName("openUFADEOutputButton") - self.open_ufade_output_button.clicked.connect(self.open_ufade_output_directory) - output_row.addWidget(self.open_ufade_output_button) - setup_layout.addRow("Working/output folder", output_row) - - self.ufade_validation_status = QLabel("UFADE installation has not been validated") - self.ufade_validation_status.setObjectName("ufadeValidationStatus") - self.ufade_validation_status.setWordWrap(True) - setup_layout.addRow("Status", self.ufade_validation_status) - layout.addWidget(setup_group) - - controls = QHBoxLayout() - validate_button = QPushButton("Validate Installation") - validate_button.setObjectName("validateUFADEButton") - validate_button.clicked.connect(self.validate_ufade_from_ui) - controls.addWidget(validate_button) - copy_launch_button = QPushButton("Copy Manual Launch") - copy_launch_button.setObjectName("copyUFADEManualLaunchButton") - copy_launch_button.clicked.connect(self.copy_ufade_manual_launch_command) - controls.addWidget(copy_launch_button) - repository_button = QPushButton("Open UFADE Repository") - repository_button.setObjectName("openUFADERepositoryButton") - repository_button.clicked.connect(self.open_ufade_repository) - controls.addWidget(repository_button) - self.launch_ufade_button = QPushButton("Launch UFADE…") - self.launch_ufade_button.setObjectName("launchUFADEButton") - self.launch_ufade_button.clicked.connect(self.launch_ufade) - controls.addWidget(self.launch_ufade_button) - controls.addStretch() - layout.addLayout(controls) - - warning = QLabel( - "UFADE runs as a separate process with its own UI, dependency versions, device selection, password handling, " - "stop controls, and output formats. Keep only the intended device connected and review UFADE's own prompts." - ) - warning.setObjectName("ufadeBoundaryWarning") - warning.setWordWrap(True) - layout.addWidget(warning) - - self.ufade_output = QPlainTextEdit() - self.ufade_output.setObjectName("ufadeProviderOutput") - self.ufade_output.setReadOnly(True) - self.ufade_output.setMaximumBlockCount(1000) - self.ufade_output.setMinimumHeight(150) - layout.addWidget(self.ufade_output, 1) - self._update_backup_controls() - scroll = QScrollArea() - scroll.setObjectName("ufadeBackupScrollArea") - scroll.setWidgetResizable(True) - scroll.setFrameShape(QFrame.Shape.NoFrame) - scroll.setWidget(page) - return scroll - - def _build_mvt_analysis_page(self) -> QWidget: - page = QWidget() - layout = QVBoxLayout(page) - layout.setSpacing(12) - - overview = QLabel( - "MVT (Mobile Verification Toolkit) is an independent forensic research tool with its own license and " - "warning model. This guided handoff validates and runs a user-installed mvt-ios executable against a " - "decrypted backup; it does not bundle MVT, accept backup passwords, or declare a device clean." - ) - overview.setObjectName("mvtProviderExplanation") - overview.setWordWrap(True) - layout.addWidget(overview) - - guide_group = QGroupBox("Install, prepare, and interpret") - guide_layout = QVBoxLayout(guide_group) - guide_text = QLabel( - "1. Install MVT separately. 2. Validate its executable and version. 3. Select one authorized, decrypted " - "iTunes-style backup. 4. Choose a new isolated output path and optional STIX2 files. 5. Review consent, " - "network, and interpretation boundaries before starting." - ) - guide_text.setObjectName("mvtQuickStart") - guide_text.setWordWrap(True) - guide_layout.addWidget(guide_text) - guide_controls = QHBoxLayout() - guide_button = QPushButton("Open Full Walkthrough") - guide_button.setObjectName("openMVTGuideButton") - guide_button.clicked.connect(self.show_mvt_guide) - guide_controls.addWidget(guide_button) - setup_button = QPushButton("Copy Setup Commands") - setup_button.setObjectName("copyMVTSetupButton") - setup_button.clicked.connect(self.copy_mvt_setup_commands) - guide_controls.addWidget(setup_button) - official_button = QPushButton("Open Official Guide") - official_button.setObjectName("openMVTOfficialGuideButton") - official_button.clicked.connect(self.open_mvt_official_guide) - guide_controls.addWidget(official_button) - repository_button = QPushButton("Open MVT Repository") - repository_button.setObjectName("openMVTRepositoryButton") - repository_button.clicked.connect(self.open_mvt_repository) - guide_controls.addWidget(repository_button) - guide_controls.addStretch() - guide_layout.addLayout(guide_controls) - layout.addWidget(guide_group) - - setup_group = QGroupBox("External MVT executable") - setup_layout = QFormLayout(setup_group) - executable_row = QHBoxLayout() - discovered = discover_mvt_executables(Path.home(), os.environ.get("PATH", "")) - self.mvt_executable_field = QLineEdit(str(discovered[0]) if discovered else "") - self.mvt_executable_field.setObjectName("mvtExecutable") - self.mvt_executable_field.setPlaceholderText("Absolute path to an independently installed mvt-ios executable") - self.mvt_executable_field.textChanged.connect(self._invalidate_mvt_validation) - executable_row.addWidget(self.mvt_executable_field, 1) - self.choose_mvt_executable_button = QPushButton("Choose…") - self.choose_mvt_executable_button.setObjectName("chooseMVTExecutableButton") - self.choose_mvt_executable_button.clicked.connect(self.choose_mvt_executable) - executable_row.addWidget(self.choose_mvt_executable_button) - self.find_mvt_executable_button = QPushButton("Find Installed") - self.find_mvt_executable_button.setObjectName("findMVTExecutableButton") - self.find_mvt_executable_button.clicked.connect(self.find_mvt_executable) - executable_row.addWidget(self.find_mvt_executable_button) - setup_layout.addRow("mvt-ios", executable_row) - self.mvt_validation_status = QLabel("MVT installation has not been validated") - self.mvt_validation_status.setObjectName("mvtValidationStatus") - self.mvt_validation_status.setWordWrap(True) - setup_layout.addRow("Status", self.mvt_validation_status) - self.validate_mvt_button = QPushButton("Validate Installation") - self.validate_mvt_button.setObjectName("validateMVTButton") - self.validate_mvt_button.clicked.connect(self.validate_mvt_from_ui) - setup_layout.addRow(self.validate_mvt_button) - layout.addWidget(setup_group) - - paths_group = QGroupBox("Analysis input and isolated output") - paths_layout = QFormLayout(paths_group) - backup_row = QHBoxLayout() - self.mvt_backup_field = QLineEdit() - self.mvt_backup_field.setObjectName("mvtBackupPath") - self.mvt_backup_field.setPlaceholderText("Decrypted backup folder containing Manifest.db and Info.plist") - self.mvt_backup_field.textChanged.connect(self._update_mvt_controls) - backup_row.addWidget(self.mvt_backup_field, 1) - self.choose_mvt_backup_button = QPushButton("Choose…") - self.choose_mvt_backup_button.setObjectName("chooseMVTBackupButton") - self.choose_mvt_backup_button.clicked.connect(self.choose_mvt_backup) - backup_row.addWidget(self.choose_mvt_backup_button) - paths_layout.addRow("Decrypted backup", backup_row) - output_row = QHBoxLayout() - default_output = ( - Path.home() - / "Documents" - / "MVT Analyses" - / datetime.now(timezone.utc).strftime("mvt-analysis-%Y%m%d-%H%M%S") - ) - self.mvt_output_field = QLineEdit(str(default_output)) - self.mvt_output_field.setObjectName("mvtOutputPath") - self.mvt_output_field.setPlaceholderText("A new path that does not already exist") - self.mvt_output_field.textChanged.connect(self._update_mvt_controls) - output_row.addWidget(self.mvt_output_field, 1) - self.choose_mvt_output_button = QPushButton("Choose Parent…") - self.choose_mvt_output_button.setObjectName("chooseMVTOutputButton") - self.choose_mvt_output_button.clicked.connect(self.choose_mvt_output_parent) - output_row.addWidget(self.choose_mvt_output_button) - self.open_mvt_output_button = QPushButton("Open Results") - self.open_mvt_output_button.setObjectName("openMVTOutputButton") - self.open_mvt_output_button.clicked.connect(self.open_mvt_output_directory) - output_row.addWidget(self.open_mvt_output_button) - paths_layout.addRow("New result path", output_row) - layout.addWidget(paths_group) - - indicator_group = QGroupBox("Optional indicators and processing") - indicator_layout = QFormLayout(indicator_group) - indicator_row = QHBoxLayout() - self.mvt_ioc_status = QLabel("No STIX2/JSON indicator files selected") - self.mvt_ioc_status.setObjectName("mvtIOCStatus") - self.mvt_ioc_status.setWordWrap(True) - indicator_row.addWidget(self.mvt_ioc_status, 1) - self.choose_mvt_iocs_button = QPushButton("Choose IOC Files…") - self.choose_mvt_iocs_button.setObjectName("chooseMVTIOCFilesButton") - self.choose_mvt_iocs_button.clicked.connect(self.choose_mvt_ioc_files) - indicator_row.addWidget(self.choose_mvt_iocs_button) - self.clear_mvt_iocs_button = QPushButton("Clear") - self.clear_mvt_iocs_button.setObjectName("clearMVTIOCFilesButton") - self.clear_mvt_iocs_button.clicked.connect(self.clear_mvt_ioc_files) - indicator_row.addWidget(self.clear_mvt_iocs_button) - indicator_layout.addRow("Indicators", indicator_row) - self.mvt_fast_checkbox = QCheckBox("Fast mode: skip time- or resource-intensive features") - self.mvt_fast_checkbox.setObjectName("mvtFastMode") - indicator_layout.addRow(self.mvt_fast_checkbox) - self.mvt_hashes_checkbox = QCheckBox("Ask MVT to hash processed input and result files (may be slow)") - self.mvt_hashes_checkbox.setObjectName("mvtHashFiles") - indicator_layout.addRow(self.mvt_hashes_checkbox) - self.mvt_network_checkbox = QCheckBox( - "Allow MVT network requests, including shortened-URL resolution during IOC checks" - ) - self.mvt_network_checkbox.setObjectName("mvtAllowNetwork") - self.mvt_network_checkbox.setChecked(False) - indicator_layout.addRow(self.mvt_network_checkbox) - layout.addWidget(indicator_group) - - consent_group = QGroupBox("Required consent and interpretation boundary") - consent_layout = QVBoxLayout(consent_group) - self.mvt_authorization_checkbox = QCheckBox( - "I own this backup or have explicit authorization and consent to analyze it with MVT." - ) - self.mvt_authorization_checkbox.setObjectName("mvtAuthorizationAcknowledgement") - self.mvt_authorization_checkbox.toggled.connect(self._update_mvt_controls) - consent_layout.addWidget(self.mvt_authorization_checkbox) - self.mvt_interpretation_checkbox = QCheckBox( - "I understand that a successful run or no findings does not prove the device is clean, safe, or uncompromised." - ) - self.mvt_interpretation_checkbox.setObjectName("mvtInterpretationAcknowledgement") - self.mvt_interpretation_checkbox.toggled.connect(self._update_mvt_controls) - consent_layout.addWidget(self.mvt_interpretation_checkbox) - layout.addWidget(consent_group) - - controls = QHBoxLayout() - self.run_mvt_button = QPushButton("Run MVT Backup Analysis…") - self.run_mvt_button.setObjectName("runMVTAnalysisButton") - self.run_mvt_button.clicked.connect(self.run_mvt_analysis) - controls.addWidget(self.run_mvt_button) - self.stop_mvt_button = QPushButton("Stop") - self.stop_mvt_button.setObjectName("stopMVTAnalysisButton") - self.stop_mvt_button.clicked.connect(self.stop_mvt_analysis) - controls.addWidget(self.stop_mvt_button) - controls.addStretch() - layout.addLayout(controls) - self.mvt_status = QLabel( - "Validate MVT, select a decrypted backup and new output path, then acknowledge both boundaries." - ) - self.mvt_status.setObjectName("mvtAnalysisStatus") - self.mvt_status.setWordWrap(True) - layout.addWidget(self.mvt_status) - self.mvt_output = QPlainTextEdit() - self.mvt_output.setObjectName("mvtAnalysisOutput") - self.mvt_output.setReadOnly(True) - self.mvt_output.setMaximumBlockCount(7000) - self.mvt_output.setMinimumHeight(180) - layout.addWidget(self.mvt_output, 1) - self._update_mvt_controls() - scroll = QScrollArea() - scroll.setObjectName("mvtAnalysisScrollArea") - scroll.setWidgetResizable(True) - scroll.setFrameShape(QFrame.Shape.NoFrame) - scroll.setWidget(page) - return scroll - - def _build_command_center_page(self) -> QWidget: - page = QWidget() - layout = QVBoxLayout(page) - layout.setSpacing(12) - - heading = QLabel("Command Center") - heading.setObjectName("pageTitle") - heading.setFont(QFont(heading.font().family(), 20, QFont.Weight.Bold)) - layout.addWidget(heading) - explanation = QLabel( - "Choose a guided preset for the pinned pymobiledevice3 syntax. Parameters are validated and the exact " - "argument vector is shown before execution. Advanced mode remains available without invoking a shell." - ) - explanation.setWordWrap(True) - layout.addWidget(explanation) - - browser_splitter = QSplitter(Qt.Orientation.Horizontal) - browser_splitter.setObjectName("commandBrowserSplitter") - browser_splitter.setMaximumHeight(470) - - preset_browser = QFrame() - preset_browser.setObjectName("commandPresetBrowser") - preset_browser.setMinimumWidth(300) - preset_browser_layout = QVBoxLayout(preset_browser) - self.command_category_combo = QComboBox() - self.command_category_combo.setObjectName("commandCategory") - self.command_category_combo.addItem("All categories") - self.command_category_combo.addItems(preset_categories()) - self.command_category_combo.currentIndexChanged.connect(self._filter_command_presets) - preset_browser_layout.addWidget(self.command_category_combo) - self.command_search_field = QLineEdit() - self.command_search_field.setObjectName("commandPresetSearch") - self.command_search_field.setPlaceholderText("Search guided commands") - self.command_search_field.textChanged.connect(self._filter_command_presets) - preset_browser_layout.addWidget(self.command_search_field) - self.command_preset_list = QListWidget() - self.command_preset_list.setObjectName("commandPresetList") - self.command_preset_list.currentItemChanged.connect(self._command_preset_selected) - preset_browser_layout.addWidget(self.command_preset_list, 1) - browser_splitter.addWidget(preset_browser) - - detail_frame = QFrame() - detail_frame.setObjectName("commandPresetDetail") - detail_layout = QVBoxLayout(detail_frame) - title_row = QHBoxLayout() - self.command_preset_title = QLabel("Choose a preset") - self.command_preset_title.setObjectName("commandPresetTitle") - self.command_preset_title.setFont(QFont(self.command_preset_title.font().family(), 16, QFont.Weight.DemiBold)) - title_row.addWidget(self.command_preset_title, 1) - self.command_risk_badge = QLabel("—") - self.command_risk_badge.setObjectName("commandRiskBadge") - title_row.addWidget(self.command_risk_badge) - detail_layout.addLayout(title_row) - self.command_summary = QLabel("Select a command to see capability, prerequisites, and interpretation limits.") - self.command_summary.setWordWrap(True) - detail_layout.addWidget(self.command_summary) - self.command_advanced_notes = QLabel("") - self.command_advanced_notes.setObjectName("commandAdvancedNotes") - self.command_advanced_notes.setWordWrap(True) - detail_layout.addWidget(self.command_advanced_notes) - self.command_prerequisites = QLabel("") - self.command_prerequisites.setObjectName("commandPrerequisites") - self.command_prerequisites.setWordWrap(True) - detail_layout.addWidget(self.command_prerequisites) - - readiness_group = QGroupBox("Selected command readiness") - readiness_layout = QHBoxLayout(readiness_group) - self.command_readiness_status = QLabel("Choose a preset to evaluate its device requirements.") - self.command_readiness_status.setObjectName("commandReadinessStatus") - self.command_readiness_status.setWordWrap(True) - self.command_readiness_status.setAccessibleName("Selected command readiness") - readiness_layout.addWidget(self.command_readiness_status, 1) - self.command_readiness_button = QPushButton("Run Device Readiness Check") - self.command_readiness_button.setObjectName("runCommandReadinessButton") - self.command_readiness_button.setAccessibleDescription( - "Runs the bounded read-only Capability Matrix for the selected physical device." - ) - self.command_readiness_button.clicked.connect(self.run_selected_command_readiness_check) - readiness_layout.addWidget(self.command_readiness_button) - detail_layout.addWidget(readiness_group) - - self.preset_parameters_group = QGroupBox("Required values") - self.preset_parameters_layout = QFormLayout(self.preset_parameters_group) - detail_layout.addWidget(self.preset_parameters_group) - self.command_preview = QLineEdit() - self.command_preview.setObjectName("guidedCommandPreview") - self.command_preview.setReadOnly(True) - detail_layout.addWidget(self.command_preview) - button_row = QHBoxLayout() - self.preset_run_button = QPushButton("Run Guided Command") - self.preset_run_button.setObjectName("runGuidedCommandButton") - self.preset_run_button.clicked.connect(self.run_selected_preset) - button_row.addWidget(self.preset_run_button) - self.console_stop_button = QPushButton("Stop") - self.console_stop_button.setObjectName("stopConsoleCommandButton") - self.console_stop_button.clicked.connect(self.stop_console_command) - button_row.addWidget(self.console_stop_button) - self.preset_help_button = QPushButton("Open Live Help") - self.preset_help_button.setObjectName("openPresetManPageButton") - self.preset_help_button.clicked.connect(self.open_selected_preset_help) - button_row.addWidget(self.preset_help_button) - button_row.addStretch() - detail_layout.addLayout(button_row) - browser_splitter.addWidget(detail_frame) - browser_splitter.setSizes([330, 800]) - browser_splitter.setStretchFactor(0, 1) - browser_splitter.setStretchFactor(1, 2) - layout.addWidget(browser_splitter) - - drift_group = QGroupBox("Command-drift detection") - drift_layout = QVBoxLayout(drift_group) - drift_explanation = QLabel( - "Read-only: checks each guided preset's installed pymobiledevice3 --help route and expected option flags. " - "It never runs a preset or contacts a connected device." - ) - drift_explanation.setWordWrap(True) - drift_layout.addWidget(drift_explanation) - drift_actions = QHBoxLayout() - self.command_drift_check_button = QPushButton("Check Guided Command Drift") - self.command_drift_check_button.setObjectName("checkCommandDriftButton") - self.command_drift_check_button.clicked.connect(self.start_command_drift_check) - drift_actions.addWidget(self.command_drift_check_button) - self.command_drift_cancel_button = QPushButton("Cancel Drift Check") - self.command_drift_cancel_button.setObjectName("cancelCommandDriftButton") - self.command_drift_cancel_button.clicked.connect(self.cancel_command_drift_check) - drift_actions.addWidget(self.command_drift_cancel_button) - self.command_drift_copy_button = QPushButton("Copy Drift Report") - self.command_drift_copy_button.setObjectName("copyCommandDriftReportButton") - self.command_drift_copy_button.clicked.connect(self.copy_command_drift_report) - drift_actions.addWidget(self.command_drift_copy_button) - drift_actions.addStretch() - drift_layout.addLayout(drift_actions) - self.command_drift_status = QLabel("Not checked in this app session.") - self.command_drift_status.setObjectName("commandDriftStatus") - self.command_drift_status.setWordWrap(True) - drift_layout.addWidget(self.command_drift_status) - self.command_drift_output = QPlainTextEdit() - self.command_drift_output.setObjectName("commandDriftOutput") - self.command_drift_output.setReadOnly(True) - self.command_drift_output.setMaximumBlockCount(250) - self.command_drift_output.setMaximumHeight(130) - self.command_drift_output.setPlaceholderText("The drift report will identify unavailable help routes or missing expected option flags.") - drift_layout.addWidget(self.command_drift_output) - layout.addWidget(drift_group) - - advanced_group = QGroupBox("Advanced arguments") - advanced_layout = QHBoxLayout(advanced_group) - self.console_input = QLineEdit() - self.console_input.setObjectName("consoleCommandInput") - self.console_input.setPlaceholderText("Example: developer dvt device-information --userspace") - self.console_input.returnPressed.connect(self.run_console_command) - self.console_input.textChanged.connect(self._update_command_controls) - advanced_layout.addWidget(self.console_input, 1) - self.console_run_button = QPushButton("Run Advanced") - self.console_run_button.setObjectName("runConsoleCommandButton") - self.console_run_button.clicked.connect(self.run_console_command) - advanced_layout.addWidget(self.console_run_button) - layout.addWidget(advanced_group) - self.advanced_safety_note = QLabel( - "Advanced commands are classified before execution. State-changing commands require a typed acknowledgement." - ) - self.advanced_safety_note.setObjectName("advancedCommandSafetyNote") - self.advanced_safety_note.setWordWrap(True) - layout.addWidget(self.advanced_safety_note) - - self.console_output = QPlainTextEdit() - self.console_output.setObjectName("consoleOutput") - self.console_output.setReadOnly(True) - self.console_output.setMaximumBlockCount(12000) - self.console_output.setPlaceholderText("Command output appears here. Long-running streams continue until Stop is pressed.") - layout.addWidget(self.console_output, 1) - self._filter_command_presets() - self._update_command_controls() - self._update_command_drift_controls() - return page - - def _build_external_tools_page(self) -> QWidget: - page = QWidget() - layout = QVBoxLayout(page) - layout.setSpacing(12) - - heading = QLabel("Ecosystem Tools") - heading.setObjectName("pageTitle") - heading.setFont(QFont(heading.font().family(), 20, QFont.Weight.Bold)) - layout.addWidget(heading) - explanation = QLabel( - "Connect optional third-party tools without bundling or silently trusting them. Each adapter records the " - "resolved executable, SHA-256, and version or build identity before enabling one bounded read-only probe." - ) - explanation.setWordWrap(True) - layout.addWidget(explanation) - boundary = QLabel( - "These tools use their own discovery, pairing, tunnel, simulator, device, network, and support models. " - "Their output is not merged into toolkit capability claims. Choosing an executable authorizes third-party " - "code to run locally only after the displayed path and hash are confirmed." - ) - boundary.setObjectName("externalToolsBoundary") - boundary.setWordWrap(True) - layout.addWidget(boundary) - - self.external_tool_tabs = QTabWidget() - self.external_tool_tabs.setObjectName("externalToolTabs") - for spec in external_tool_specs(): - self.external_tool_tabs.addTab(self._build_external_tool_tab(spec), spec.title) - layout.addWidget(self.external_tool_tabs, 1) - self._update_external_tool_controls() - return page - - def _build_external_tool_tab(self, spec: ExternalToolSpec) -> QWidget: - suffix = EXTERNAL_TOOL_OBJECT_SUFFIXES[spec.identifier] - tab = QWidget() - layout = QVBoxLayout(tab) - layout.setSpacing(10) - - scope = QLabel( - f"{spec.title} · {spec.license_name} · separately installed
{spec.scope}" - ) - scope.setWordWrap(True) - layout.addWidget(scope) - - path_layout = QHBoxLayout() - path_field = QLineEdit() - path_field.setObjectName(f"external{suffix}ExecutablePath") - path_field.setPlaceholderText(f"Absolute path to {spec.executable_name}") - path_field.textChanged.connect(self._external_tool_text_handler(spec.identifier)) - path_layout.addWidget(path_field, 1) - choose_button = QPushButton("Choose…") - choose_button.setObjectName(f"external{suffix}ChooseButton") - choose_button.clicked.connect(self._external_tool_button_handler(spec.identifier, self.choose_external_tool)) - path_layout.addWidget(choose_button) - find_button = QPushButton("Find Installed") - find_button.setObjectName(f"external{suffix}FindButton") - find_button.clicked.connect(self._external_tool_button_handler(spec.identifier, self.find_external_tool)) - path_layout.addWidget(find_button) - layout.addLayout(path_layout) - - status = QLabel("Not validated. The toolkit has not executed this optional tool.") - status.setObjectName(f"external{suffix}Status") - status.setWordWrap(True) - layout.addWidget(status) - - actions = QHBoxLayout() - validate_button = QPushButton("Validate Version && SHA-256") - validate_button.setObjectName(f"external{suffix}ValidateButton") - validate_button.clicked.connect(self._external_tool_button_handler(spec.identifier, self.validate_external_tool)) - actions.addWidget(validate_button) - probe_button = QPushButton(spec.probe_title) - probe_button.setObjectName(f"external{suffix}ProbeButton") - probe_button.clicked.connect(self._external_tool_button_handler(spec.identifier, self.run_external_tool_probe)) - actions.addWidget(probe_button) - stop_button = QPushButton("Stop") - stop_button.setObjectName(f"external{suffix}StopButton") - stop_button.clicked.connect(self._external_tool_button_handler(spec.identifier, self.stop_external_tool)) - actions.addWidget(stop_button) - actions.addStretch() - layout.addLayout(actions) - - resources = QHBoxLayout() - setup_button = QPushButton("Copy Setup Command") - setup_button.setObjectName(f"external{suffix}SetupButton") - setup_button.clicked.connect(self._external_tool_button_handler(spec.identifier, self.copy_external_tool_setup)) - resources.addWidget(setup_button) - documentation_button = QPushButton("Official Documentation") - documentation_button.setObjectName(f"external{suffix}DocumentationButton") - documentation_button.clicked.connect( - self._external_tool_button_handler(spec.identifier, self.open_external_tool_documentation) - ) - resources.addWidget(documentation_button) - repository_button = QPushButton("Source Repository") - repository_button.setObjectName(f"external{suffix}RepositoryButton") - repository_button.clicked.connect( - self._external_tool_button_handler(spec.identifier, self.open_external_tool_repository) - ) - resources.addWidget(repository_button) - resources.addStretch() - layout.addLayout(resources) - - output = QPlainTextEdit() - output.setObjectName(f"external{suffix}Output") - output.setReadOnly(True) - output.setMaximumBlockCount(12000) - output.setPlaceholderText( - "Version validation and probe output appears here. It remains session-local unless you explicitly preserve it." - ) - layout.addWidget(output, 1) - - self._external_tool_fields[spec.identifier] = path_field - self._external_tool_statuses[spec.identifier] = status - self._external_tool_outputs[spec.identifier] = output - self._external_tool_validate_buttons[spec.identifier] = validate_button - self._external_tool_probe_buttons[spec.identifier] = probe_button - self._external_tool_stop_buttons[spec.identifier] = stop_button - self._external_tool_path_buttons[spec.identifier] = (choose_button, find_button) - return tab - - def _external_tool_button_handler( - self, - identifier: ExternalToolIdentifier, - action: Callable[[ExternalToolIdentifier], None], - ) -> Callable[[bool], None]: - def handle(checked: bool) -> None: - del checked - action(identifier) - - return handle - - def _external_tool_text_handler( - self, - identifier: ExternalToolIdentifier, - ) -> Callable[[str], None]: - def handle(value: str) -> None: - self._invalidate_external_tool(identifier, value) - - return handle - - def _invalidate_external_tool(self, identifier: ExternalToolIdentifier, value: str) -> None: - del value - self._external_tool_installations.pop(identifier, None) - self._external_tool_statuses[identifier].setText( - "Not validated. The toolkit has not executed this optional tool." - ) - self._update_external_tool_controls() - - def external_tool_path(self, identifier: ExternalToolIdentifier) -> Path: - value = self._external_tool_fields[identifier].text().strip() - if not value: - spec = external_tool_spec(identifier) - raise ExternalToolValidationError(f"Choose an absolute path to {spec.executable_name}") - return Path(value) - - def choose_external_tool(self, identifier: ExternalToolIdentifier) -> None: - spec = external_tool_spec(identifier) - selected, _ = QFileDialog.getOpenFileName( - self, - f"Choose separately installed {spec.executable_name}", - self._external_tool_fields[identifier].text() or str(Path.home()), - ) - if selected: - self._external_tool_fields[identifier].setText(selected) - - def find_external_tool(self, identifier: ExternalToolIdentifier) -> None: - spec = external_tool_spec(identifier) - candidates = discover_external_tool_executables(spec, Path.home(), os.environ.get("PATH", "")) - if not candidates: - QMessageBox.information( - self, - f"{spec.title} Not Found", - f"No executable named {spec.executable_name!r} was found in PATH, ~/.local/bin, " - "/opt/homebrew/bin, or /usr/local/bin. Use the official setup command or choose a reviewed path.", - ) - return - self._external_tool_fields[identifier].setText(str(candidates[0])) - self._external_tool_statuses[identifier].setText( - f"Found {len(candidates)} candidate(s). Validate the selected executable before probing." - ) - - def copy_external_tool_setup(self, identifier: ExternalToolIdentifier) -> None: - spec = external_tool_spec(identifier) - QApplication.clipboard().setText("\n".join(spec.setup_commands)) - self._external_tool_outputs[identifier].appendPlainText( - "Copied official setup command for manual review and execution in Terminal:\n" - + "\n".join(spec.setup_commands) - ) - - def open_external_tool_documentation(self, identifier: ExternalToolIdentifier) -> None: - spec = external_tool_spec(identifier) - if not QDesktopServices.openUrl(QUrl(spec.documentation_url)): - QMessageBox.critical( - self, - "Could Not Open Documentation", - f"macOS could not open the official {spec.title} documentation:\n{spec.documentation_url}", - ) - - def open_external_tool_repository(self, identifier: ExternalToolIdentifier) -> None: - spec = external_tool_spec(identifier) - if not QDesktopServices.openUrl(QUrl(spec.repository_url)): - QMessageBox.critical( - self, - "Could Not Open Repository", - f"macOS could not open the official {spec.title} repository:\n{spec.repository_url}", - ) - - def validate_external_tool(self, identifier: ExternalToolIdentifier) -> None: - if self._external_tool_controller.is_running(): - QMessageBox.warning(self, "External Tool Running", "Stop or wait for the active external tool first.") - return - spec = external_tool_spec(identifier) - try: - executable = inspect_external_tool_executable(spec, self.external_tool_path(identifier)) - except (ExternalToolValidationError, OSError) as error: - QMessageBox.critical(self, f"Invalid {spec.title} Executable", str(error)) - self._external_tool_statuses[identifier].setText(f"Validation rejected: {error}") - return - warning = ( - f"Run this separately installed {spec.title} executable to read its version or build identity?\n\n" - f"Path: {executable.path}\n" - f"SHA-256: {executable.sha256}\n" - f"Arguments: {shlex.join(spec.version_arguments)}\n\n" - "This executes third-party code on the Mac. The toolkit does not install, update, sandbox, endorse, or " - "redistribute it. Confirm only if you recognize and trust the exact path and hash." - ) - if not self._confirm(f"Validate {spec.title}", warning): - return - try: - self._start_external_tool_process(identifier, "validate", executable, spec.version_arguments) - except (ExternalToolValidationError, OSError) as error: - QMessageBox.critical(self, f"Could Not Start {spec.title}", str(error)) - self._external_tool_statuses[identifier].setText(f"Validation did not start: {error}") - - def run_external_tool_probe(self, identifier: ExternalToolIdentifier) -> None: - if self._external_tool_controller.is_running(): - QMessageBox.warning(self, "External Tool Running", "Stop or wait for the active external tool first.") - return - spec = external_tool_spec(identifier) - installation = self._external_tool_installations.get(identifier) - if installation is None: - QMessageBox.critical(self, f"{spec.title} Not Validated", "Validate the selected executable first.") - return - try: - validated = validate_external_tool_installation(spec, installation) - except (ExternalToolValidationError, OSError) as error: - self._external_tool_installations.pop(identifier, None) - self._external_tool_statuses[identifier].setText(f"Probe rejected: {error}") - self._update_external_tool_controls() - QMessageBox.critical(self, f"Could Not Run {spec.title}", str(error)) - return - warning = ( - f"Run the bounded read-only {spec.title} probe?\n\n" - f"Identity: {validated.version_or_build}\n" - f"Path: {validated.executable.path}\n" - f"SHA-256: {validated.executable.sha256}\n" - f"Arguments: {shlex.join(spec.probe_arguments)}\n\n" - f"{spec.scope}\n\n" - "The external tool chooses its own visible targets and does not use the toolkit's selected-device state. " - "Its output may contain device or simulator identifiers and remains session-local unless you preserve it." - ) - if not self._confirm(f"Run {spec.probe_title}", warning): - return - try: - self._start_external_tool_process(identifier, "probe", validated.executable, spec.probe_arguments) - except (ExternalToolValidationError, OSError) as error: - QMessageBox.critical(self, f"Could Not Start {spec.title}", str(error)) - self._external_tool_statuses[identifier].setText(f"Probe did not start: {error}") - - def _start_external_tool_process( - self, - identifier: ExternalToolIdentifier, - operation: Literal["validate", "probe"], - executable: ExternalToolExecutable, - arguments: tuple[str, ...], - ) -> None: - if self._external_tool_controller.is_running(): - raise RuntimeError("Cannot start an external tool while another adapter process is running") - spec = external_tool_spec(identifier) - current = inspect_external_tool_executable(spec, executable.path) - if current.sha256 != executable.sha256: - raise ExternalToolValidationError( - f"{spec.title} executable changed after review; inspect and validate it again" - ) - self._external_tool_active_identifier = identifier - self._external_tool_operation = operation - self._external_tool_pending_executable = executable if operation == "validate" else None - output = self._external_tool_outputs[identifier] - output.clear() - output.appendPlainText( - f"$ {executable.path} {shlex.join(arguments)}\n" - f"Executable SHA-256: {executable.sha256}\n" - "Inherited tool-routing and secret environment variables are removed for this adapter.\n" - ) - title = f"Validate {spec.title}" if operation == "validate" else spec.probe_title - self._begin_operation( - "external-tool", - self._host_operation_context(title, "Ecosystem Tools", f"external {spec.title} CLI", ()), - ) - self._external_tool_statuses[identifier].setText(f"{title} is running with a 30-second deadline…") - request = finite_process_request( - external_tool_command(spec, executable), - arguments, - external_tool_environment(base_environment(), spec), - EXTERNAL_TOOL_TIMEOUT_MS, - PROCESS_TERMINATE_GRACE_MS, - ) - self._external_tool_controller.start(request) - self._update_external_tool_controls() - - def _external_tool_completed(self, result_object: object) -> None: - if not isinstance(result_object, OperationResult): - raise TypeError(f"Unexpected external-tool result type: {type(result_object).__name__}") - identifier = self._external_tool_active_identifier - operation = self._external_tool_operation - if identifier is None or operation is None: - raise RuntimeError("External tool completed without active adapter state") - spec = external_tool_spec(identifier) - output = self._external_tool_outputs[identifier] - combined = (result_object.stdout + result_object.stderr).decode("utf-8", errors="replace") - if combined: - output.appendPlainText(combined.rstrip()) - if result_object.error_message: - output.appendPlainText(f"Process error: {result_object.error_message}") - output.appendPlainText( - f"Outcome: {result_object.outcome}; exit: " - f"{'unavailable' if result_object.exit_code is None else result_object.exit_code}" - ) - self._complete_operation("external-tool", result_object) - if result_object.outcome == "succeeded" and operation == "validate": - pending = self._external_tool_pending_executable - if pending is None: - raise RuntimeError("External tool validation completed without a pending executable") - try: - identity = parse_external_tool_version(spec, combined) - except ExternalToolValidationError as error: - self._external_tool_installations.pop(identifier, None) - self._external_tool_statuses[identifier].setText(f"Version validation failed: {error}") - else: - self._external_tool_installations[identifier] = ExternalToolInstallation(pending, identity) - self._external_tool_statuses[identifier].setText( - f"Validated {spec.title} {identity}; SHA-256 {pending.sha256}. Read-only probe is enabled." - ) - elif result_object.outcome == "succeeded" and operation == "probe": - self._external_tool_statuses[identifier].setText( - f"{spec.probe_title} completed. Review the raw third-party output; it is not a toolkit capability verdict." - ) - else: - self._external_tool_statuses[identifier].setText( - f"{spec.title} {operation} {result_object.outcome}; review the complete output." - ) - if operation == "validate": - self._external_tool_installations.pop(identifier, None) - self._external_tool_active_identifier = None - self._external_tool_operation = None - self._external_tool_pending_executable = None - self._update_external_tool_controls() - - def stop_external_tool(self, identifier: ExternalToolIdentifier) -> None: - if self._external_tool_active_identifier != identifier or not self._external_tool_controller.is_running(): - return - spec = external_tool_spec(identifier) - self._external_tool_statuses[identifier].setText(f"Stopping {spec.title}…") - self._external_tool_controller.cancel() - - def _update_external_tool_controls(self) -> None: - running = self._external_tool_controller.is_running() - active = self._external_tool_active_identifier - for spec in external_tool_specs(): - identifier = spec.identifier - has_path = bool(self._external_tool_fields[identifier].text().strip()) - self._external_tool_fields[identifier].setEnabled(not running) - choose_button, find_button = self._external_tool_path_buttons[identifier] - choose_button.setEnabled(not running) - find_button.setEnabled(not running) - self._external_tool_validate_buttons[identifier].setEnabled(not running and has_path) - self._external_tool_probe_buttons[identifier].setEnabled( - not running and identifier in self._external_tool_installations - ) - self._external_tool_stop_buttons[identifier].setEnabled(running and active == identifier) - - def _build_manpages_page(self) -> QWidget: - page = QWidget() - layout = QVBoxLayout(page) - layout.setSpacing(12) - - heading = QLabel("Man Pages & Possibilities") - heading.setObjectName("pageTitle") - heading.setFont(QFont(heading.font().family(), 20, QFont.Weight.Bold)) - layout.addWidget(heading) - explanation = QLabel( - "Browse the command map instantly, then request version-matched help from the installed pymobiledevice3 " - "when needed. Live help can be cancelled and stops automatically after 15 seconds." - ) - explanation.setWordWrap(True) - layout.addWidget(explanation) - - splitter = QSplitter(Qt.Orientation.Horizontal) - splitter.setObjectName("manpageSplitter") - index_frame = QFrame() - index_frame.setMinimumWidth(300) - index_layout = QVBoxLayout(index_frame) - self.manpage_search_field = QLineEdit() - self.manpage_search_field.setObjectName("manpageSearch") - self.manpage_search_field.setPlaceholderText("Search services and command paths") - self.manpage_search_field.textChanged.connect(self._filter_manpages) - index_layout.addWidget(self.manpage_search_field) - self.manpage_list = QListWidget() - self.manpage_list.setObjectName("manpageList") - self.manpage_list.currentItemChanged.connect(self._manpage_selected) - index_layout.addWidget(self.manpage_list, 1) - splitter.addWidget(index_frame) - - content_frame = QFrame() - content_layout = QVBoxLayout(content_frame) - self.manpage_title = QLabel("Select a help topic") - self.manpage_title.setObjectName("manpageTitle") - self.manpage_title.setFont(QFont(self.manpage_title.font().family(), 16, QFont.Weight.DemiBold)) - content_layout.addWidget(self.manpage_title) - self.manpage_command = QLineEdit() - self.manpage_command.setObjectName("manpageCommand") - self.manpage_command.setReadOnly(True) - content_layout.addWidget(self.manpage_command) - manpage_actions = QHBoxLayout() - self.refresh_manpage_button = QPushButton("Refresh Live Help") - self.refresh_manpage_button.setObjectName("refreshManpageButton") - self.refresh_manpage_button.clicked.connect(self.refresh_selected_manpage) - manpage_actions.addWidget(self.refresh_manpage_button) - self.cancel_manpage_button = QPushButton("Cancel Loading") - self.cancel_manpage_button.setObjectName("cancelManpageButton") - self.cancel_manpage_button.clicked.connect(self.cancel_manpage_load) - manpage_actions.addWidget(self.cancel_manpage_button) - self.copy_manpage_command_button = QPushButton("Copy Command Prefix") - self.copy_manpage_command_button.setObjectName("copyManpageCommandButton") - self.copy_manpage_command_button.clicked.connect(self.copy_selected_manpage_command) - manpage_actions.addWidget(self.copy_manpage_command_button) - self.use_manpage_command_button = QPushButton("Use in Advanced Mode") - self.use_manpage_command_button.setObjectName("useManpageCommandButton") - self.use_manpage_command_button.clicked.connect(self.use_selected_manpage_command) - manpage_actions.addWidget(self.use_manpage_command_button) - manpage_actions.addStretch() - content_layout.addLayout(manpage_actions) - self.manpage_output = QPlainTextEdit() - self.manpage_output.setObjectName("manpageOutput") - self.manpage_output.setReadOnly(True) - self.manpage_output.setMaximumBlockCount(12000) - content_layout.addWidget(self.manpage_output, 1) - splitter.addWidget(content_frame) - splitter.setSizes([330, 850]) - splitter.setStretchFactor(0, 1) - splitter.setStretchFactor(1, 3) - layout.addWidget(splitter, 1) - self._filter_manpages() - self._update_manpage_controls() - return page - - def _build_safety_tab(self) -> QWidget: - return build_safety_page(DEVELOPER_DISK_IMAGE_REPOSITORY) - - def _apply_style(self) -> None: - self.setStyleSheet(toolkit_stylesheet()) - - def selected_device(self) -> IOSDevice | None: - if self._demo_mode: - return None - index = self.device_combo.currentIndex() - if index < 0 or index >= len(self._devices): - return None - return self._devices[index] - - def _scanner_scan(self) -> None: - if self._demo_mode: - return - self._scanner.scan() - - def _reconnect_device(self) -> None: - if self._demo_mode: - return - if self._reconnect_active: - return - confirmed = self._confirm( - "Start a 30-second reconnect window?", - "1. Unlock the iPhone or iPad and keep it on the Home Screen.\n" - "2. In iOS Settings → Privacy & Security → Wired Accessories, allow the connection while unlocked.\n" - "3. Disconnect and reconnect it directly to the Mac with a known data-capable cable. Avoid a hub.\n" - "4. Click Allow if macOS asks to connect the accessory. Then tap Trust on the device and enter its " - "passcode if prompted. You can also select the device in the Finder sidebar and click Trust.\n\n" - "The toolkit will retry usbmux discovery for 30 seconds. It will not use sudo, delete pairing records, " - "restart SIP-protected Apple agents, restart the root-owned usbmuxd service, or change the iOS device.\n\n" - "Start retrying?", - ) - if not confirmed: - return - self._reconnect_active = True - self.reconnect_device_button.setEnabled(False) - self.connection_banner.setText( - "Reconnect window active. Unlock the device, reconnect its data cable directly, and tap Trust if asked; " - "retrying usbmux discovery for up to 30 seconds…" - ) - self._reconnect_timeout_timer.start(RECONNECT_TIMEOUT_MS) - self._scanner.start() - - def _finish_reconnect_success(self, device_count: int) -> None: - self._reconnect_timeout_timer.stop() - self._reconnect_active = False - self.refresh_devices_button.setEnabled(True) - self.reconnect_device_button.setEnabled(True) - self.connection_banner.setText( - f"Reconnected: usbmux detected {device_count} trusted iOS device(s). " - "Keep the selected device unlocked while starting developer streams." - ) - - def _reconnect_timed_out(self) -> None: - if not self._reconnect_active: - return - self._reconnect_active = False - self.refresh_devices_button.setEnabled(True) - self.reconnect_device_button.setEnabled(True) - self.connection_banner.setText( - "usbmux found no device during the 30-second reconnect window. Unlock the phone, try another " - "data-capable cable or Mac port, reconnect directly without a hub, and use Finder to complete Trust. " - "Retry Scan afterward. The toolkit intentionally did not restart SIP-protected Apple agents or the " - "root-owned usbmuxd service." - ) - - def _devices_changed(self, devices_object: object) -> None: - if not isinstance(devices_object, tuple) or not all(isinstance(item, IOSDevice) for item in devices_object): - self.connection_banner.setText("Device scanner returned an unexpected result type.") - return - devices = tuple(devices_object) - previous_identifier = self.selected_device().identifier if self.selected_device() is not None else None - changed = devices != self._devices - self._devices = devices - if self._demo_mode: - return - if changed: - self._render_device_picker(devices, previous_identifier) - if not devices: - if self._reconnect_active: - self.connection_banner.setText( - "Reconnect window active. Waiting for usbmux to see an unlocked device; reconnect the " - "data cable and tap Trust if prompted…" - ) - else: - self.connection_banner.setText( - "No usbmux device detected. Unlock the iPhone or iPad, reconnect a data-capable cable, and tap " - "Trust if prompted. Use Reconnect & Retry for a guided 30-second detection window." - ) - self._update_device_fields(None) - return - if self._reconnect_active: - self._finish_reconnect_success(len(devices)) - else: - self.connection_banner.setText( - f"Detected {len(devices)} trusted iOS device(s). Select the intended target before mounting or collecting." - ) - self._update_device_fields(self.selected_device()) - selected = self.selected_device() - if selected is not None and selected.identifier not in self._guided_udids: - self._guided_udids.add(selected.identifier) - QTimer.singleShot(350, self.show_developer_mode_guide) - - def _scan_error(self, message: str) -> None: - if self._demo_mode: - return - if self._reconnect_active: - self.connection_banner.setText( - f"Reconnect is still retrying after a usbmux discovery error: {message} Keep the device unlocked " - "and reconnect its data cable." - ) - return - self.connection_banner.setText( - f"Device discovery error: {message} Use Retry Scan first. If it repeats, use Reconnect & Retry and " - "complete the cable, unlock, and Finder Trust checks." - ) - - def _connection_diagnostic_changed(self, diagnostic_object: object) -> None: - if not isinstance(diagnostic_object, ConnectionDiagnostic): - self._connection_diagnostic = malformed_output_connection_diagnostic() - else: - self._connection_diagnostic = diagnostic_object - self.connection_diagnostic_value.setText(self._connection_diagnostic.report()) - - def _device_selected(self, index: int) -> None: - del index - self._update_device_fields(self._displayed_device()) - self.developer_mode_status.setText("Status not checked for this device") - self._update_location_controls() - - def toggle_demo_mode(self) -> None: - if self._demo_mode: - self._exit_demo_mode() - return - self._enter_demo_mode() - - def _enter_demo_mode(self) -> None: - if self.selected_device() is not None: - QMessageBox.information( - self, - "Demo Mode Requires No Selected Device", - "Disconnect the physical device before starting the simulated walkthrough. " - "Demo Mode never replaces a real connected device.", - ) - return - self._demo_mode = True - self._reconnect_timeout_timer.stop() - self._reconnect_active = False - self.device_combo.blockSignals(True) - self.device_combo.clear() - self.device_combo.addItem(self._demo_device.display_name(), self._demo_device.identifier) - self.device_combo.setCurrentIndex(0) - self.device_combo.blockSignals(False) - self.device_combo.setEnabled(False) - self.demo_mode_button.setText("Exit Demo") - self.refresh_devices_button.setEnabled(False) - self.reconnect_device_button.setEnabled(False) - self.connection_banner.setText(demo_connection_banner()) - self._update_device_fields(self._demo_device) - self.developer_mode_status.setText("Demo Mode: not checked; no device service was contacted") - - def _exit_demo_mode(self) -> None: - self._demo_mode = False - self.device_combo.setEnabled(True) - self.demo_mode_button.setText("Demo Mode") - self.refresh_devices_button.setEnabled(True) - self.reconnect_device_button.setEnabled(True) - self._render_device_picker(self._devices, None) - self._devices_changed(self._devices) - self._scanner.scan() - - def _render_device_picker( - self, - devices: tuple[IOSDevice, ...], - previous_identifier: str | None, - ) -> None: - self.device_combo.blockSignals(True) - self.device_combo.clear() - for device in devices: - self.device_combo.addItem(device.display_name(), device.identifier) - if previous_identifier is not None: - matching_index = next( - (index for index, device in enumerate(devices) if device.identifier == previous_identifier), - 0, - ) - self.device_combo.setCurrentIndex(matching_index) - self.device_combo.blockSignals(False) - - def _displayed_device(self) -> IOSDevice | None: - if self._demo_mode: - return self._demo_device - return self.selected_device() - - def _update_device_fields(self, device: IOSDevice | None) -> None: - identifier = device.identifier if device is not None else None - if identifier != self._active_device_identifier: - if self._active_case_path is not None and not self._collection_controller.is_running(): - previous_case_path = self._active_case_path - self._active_case_path = None - self.case_status.setText( - f"Selected device changed. Guided case remains at {previous_case_path}; create a case for the new device." - ) - if self._capability_process is not None: - self._discard_capability_process_for_device_change() - self._active_device_identifier = identifier - self._backup_encryption_state = None - self._reset_capability_matrix(device) - self.backup_encryption_status.setText("Encryption state not checked for this device") - self._installed_apps = () - self._populate_installed_apps(()) - self.apps_status.setText( - "Refresh to load apps from the selected device." - if device is not None - else "Connect a trusted device, then refresh the inventory." - ) - enabled = device is not None and not self._demo_mode - action_available = enabled and not self._action_controller.is_running() - self.mount_button.setEnabled(action_available) - self.remove_button.setEnabled(action_available) - self.coredevice_details_button.setEnabled(action_available) - self.rvi_status_button.setEnabled(not self._demo_mode and not self._action_controller.is_running()) - self.open_xcode_project_button.setEnabled( - not self._demo_mode and not self._action_controller.is_running() - ) - self.start_collection_button.setEnabled(enabled and not self._collection_controller.is_running()) - self.create_case_button.setEnabled( - enabled and not self._collection_controller.is_running() and self._active_case_path is None - ) - self.case_readiness_button.setEnabled(enabled and self._capability_process is None) - self._update_live_log_controls() - self._update_apps_controls() - self._update_backup_controls() - self._update_command_controls() - self._update_location_controls() - if device is None: - self.device_name_value.setText("No device") - self.device_version_value.setText("—") - self.device_model_value.setText("—") - self.device_udid_value.setText("—") - self._update_sideload_controls() - return - self.device_name_value.setText(device.name) - self.device_version_value.setText(f"{device.product_version} / {device.build_version}") - self.device_model_value.setText(device.product_type) - self.device_udid_value.setText(device.identifier) - self._update_sideload_controls() - - def _update_live_log_controls(self) -> None: - for specification in log_stream_specs(): - identifier = f"open{specification.identifier.replace('-', '').title()}LogButton" - button = self.findChild(QPushButton, identifier) - if button is None: - raise RuntimeError(f"Live log action is missing: {identifier}") - button.setEnabled(not self._demo_mode) - - def _reset_capability_matrix(self, device: IOSDevice | None) -> None: - self._capability_results = {result.identifier: result for result in untested_capability_results()} - self._capability_completed_at = None - self._capability_worker_completed = False - self.capability_progress.setRange(0, len(capability_definitions())) - self.capability_progress.setValue(0) - self.capability_status.setText( - "Run the matrix to test the selected device. No probe runs automatically." - if device is not None - else "Connect and select a trusted device before running the matrix." - ) - self._populate_capability_matrix() - self._update_capability_controls() - self._update_selected_command_readiness() - - def _capability_state_brush(self, state: CapabilityState) -> QBrush: - colors: Mapping[CapabilityState, str] = { - "ready": "#dff3e4", - "attention": "#fff0c7", - "unavailable": "#ffdeda", - "blocked": "#eceff4", - "not-tested": "#f2f3f6", - "not-applicable": "#e8eef7", - } - color = colors.get(state) - if color is None: - raise CapabilityMatrixError(f"No matrix color is defined for capability state: {state}") - return QBrush(QColor(color)) - - def _populate_capability_matrix(self) -> None: - selected_identifier: str | None = None - selected_rows = self.capability_table.selectionModel().selectedRows() - if len(selected_rows) == 1: - selected_item = self.capability_table.item(selected_rows[0].row(), 0) - if selected_item is not None: - candidate = selected_item.data(Qt.ItemDataRole.UserRole) - if isinstance(candidate, str): - selected_identifier = candidate - definitions = capability_definitions() - self.capability_table.setRowCount(len(definitions)) - selected_row = 0 - for row, definition in enumerate(definitions): - result = self._capability_results[definition.identifier] - values = ( - result.layer, - result.title, - capability_state_label(result.state), - result.summary, - ) - for column, value in enumerate(values): - item = QTableWidgetItem(value) - item.setData(Qt.ItemDataRole.UserRole, result.identifier) - if column == 2: - item.setBackground(self._capability_state_brush(result.state)) - item.setFont(QFont(item.font().family(), item.font().pointSize(), QFont.Weight.DemiBold)) - self.capability_table.setItem(row, column, item) - if result.identifier == selected_identifier: - selected_row = row - if definitions: - self.capability_table.selectRow(selected_row) - self._capability_selection_changed() - - def _populate_compatibility_history(self) -> None: - if self._compatibility_history_error is not None: - self.compatibility_history_status.setText( - f"Compatibility history is unavailable: {self._compatibility_history_error}" - ) - self.compatibility_history_table.setRowCount(0) - self.compatibility_history_table.setColumnCount(0) - return - observations = latest_observations(self._compatibility_observations) - displayed_observations = observations[-8:] - self.compatibility_history_status.setText( - f"{len(observations)} locally observed physical device(s); showing the latest {len(displayed_observations)}. " - f"Raw UDIDs are not retained. History: {self._compatibility_history_path}" - ) - definitions = capability_definitions() - self.compatibility_history_table.setRowCount(len(definitions)) - self.compatibility_history_table.setColumnCount(len(displayed_observations) + 1) - headers = ["Capability"] - for observation in displayed_observations: - headers.append( - f"{observation.product_type}\niOS {observation.product_version} ({observation.build_version})\n" - f"{observation.connection_type} • {observation.device_fingerprint}" - ) - self.compatibility_history_table.setHorizontalHeaderLabels(headers) - for row, definition in enumerate(definitions): - title_item = QTableWidgetItem(definition.title) - title_item.setData(Qt.ItemDataRole.UserRole, definition.identifier) - self.compatibility_history_table.setItem(row, 0, title_item) - for column, observation in enumerate(displayed_observations, start=1): - results = {result.identifier: result for result in observation.results} - result = results.get(definition.identifier) - if result is None: - item = QTableWidgetItem("Not observed") - item.setBackground(self._capability_state_brush("not-tested")) - else: - item = QTableWidgetItem(capability_state_label(result.state)) - item.setToolTip( - f"Observed: {observation.recorded_at}\n\nResult: {result.summary}\n\nEvidence: {result.evidence}" - ) - item.setBackground(self._capability_state_brush(result.state)) - self.compatibility_history_table.setItem(row, column, item) - header = self.compatibility_history_table.horizontalHeader() - header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) - for column in range(1, len(displayed_observations) + 1): - header.setSectionResizeMode(column, QHeaderView.ResizeMode.ResizeToContents) - - def refresh_compatibility_history(self) -> None: - try: - self._compatibility_observations = load_observations(self._compatibility_history_path) - except DeviceCompatibilityError as error: - self._compatibility_history_error = str(error) - else: - self._compatibility_history_error = None - self._populate_compatibility_history() - - def _record_compatibility_observation(self, device: IOSDevice) -> None: - try: - observation = create_observation( - self._capability_completed_at or datetime.now(timezone.utc).isoformat(), - device, - tuple(self._capability_results.values()), - ) - append_observation(self._compatibility_history_path, observation) - except DeviceCompatibilityError as error: - self._compatibility_history_error = str(error) - self._populate_compatibility_history() - return - self._compatibility_history_error = None - self._compatibility_observations = (*self._compatibility_observations, observation) - self._populate_compatibility_history() - - def copy_compatibility_matrix(self) -> None: - if self._compatibility_history_error is not None: - QMessageBox.warning(self, "Compatibility History Unavailable", self._compatibility_history_error) - return - observations = latest_observations(self._compatibility_observations) - if not observations: - QMessageBox.information( - self, - "No Real-Device Observations", - "Complete a Capability Matrix run against a connected device before copying compatibility results.", - ) - return - lines = [ - "iOS Developer Toolkit — Real-Device Compatibility Matrix", - "Only completed local Capability Matrix observations are included. Raw UDIDs are not retained.", - "", - ] - for observation in observations: - lines.append( - f"{observation.product_type}; iOS {observation.product_version}; build {observation.build_version}; " - f"{observation.connection_type}; device fingerprint {observation.device_fingerprint}; observed {observation.recorded_at}" - ) - for result in observation.results: - lines.append(f" [{capability_state_label(result.state)}] {result.title}: {result.summary}") - lines.append("") - QApplication.clipboard().setText("\n".join(lines).rstrip() + "\n") - self.compatibility_history_status.setText("Copied local real-device compatibility observations to the clipboard.") - - def _compatibility_export_is_available(self) -> bool: - return self._compatibility_history_error is None and bool( - latest_observations(self._compatibility_observations) - ) - - def _compatibility_report(self) -> CompatibilityReport: - return create_compatibility_report( - datetime.now(timezone.utc).isoformat(), - current_report_environment(APP_VERSION, is_frozen_runtime()), - self._compatibility_observations, - ) - - def _review_compatibility_export(self, title: str, content: str) -> bool: - dialog = QDialog(self) - dialog.setObjectName("compatibilityExportPreviewDialog") - dialog.setWindowTitle(title) - dialog.resize(900, 650) - layout = QVBoxLayout(dialog) - explanation = QLabel( - "Review the exact sanitized content before saving. Device names, raw identifiers, stored fingerprints, " - "and local paths are excluded or redacted. Device model, iOS version/build, connection type, " - "host/toolchain versions, and sanitized capability evidence remain. The app never uploads this report." - ) - explanation.setWordWrap(True) - layout.addWidget(explanation) - preview = QPlainTextEdit() - preview.setObjectName("compatibilityExportPreview") - preview.setReadOnly(True) - preview.setPlainText(content) - layout.addWidget(preview, 1) - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel) - buttons.setObjectName("compatibilityExportPreviewButtons") - buttons.accepted.connect(dialog.accept) - buttons.rejected.connect(dialog.reject) - layout.addWidget(buttons) - return dialog.exec() == QDialog.DialogCode.Accepted - - def export_compatibility_json(self) -> None: - if not self._compatibility_export_is_available(): - QMessageBox.information( - self, - "No Real-Device Observations", - "Complete a Capability Matrix run against a connected device before exporting compatibility evidence.", - ) - return - try: - report = self._compatibility_report() - except DeviceCompatibilityError as error: - QMessageBox.critical(self, "Could Not Prepare Compatibility Report", str(error)) - return - if not self._review_compatibility_export( - "Review Sanitized Compatibility JSON", - render_compatibility_json(report), - ): - return - timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%SZ") - suggested = Path.home() / f"iOSDeveloperToolkit-compatibility-{timestamp}.json" - selected, _ = QFileDialog.getSaveFileName( - self, - "Save Sanitized Compatibility JSON", - str(suggested), - "JSON (*.json)", - ) - if not selected: - return - destination = Path(selected) - if destination.suffix.casefold() != ".json": - destination = destination.with_suffix(".json") - try: - path = write_compatibility_json_report(destination, report) - except DeviceCompatibilityError as error: - QMessageBox.critical(self, "Could Not Export Compatibility Report", str(error)) - return - self.compatibility_history_status.setText(f"Created sanitized compatibility JSON: {path}") - - def export_compatibility_markdown(self) -> None: - if not self._compatibility_export_is_available(): - QMessageBox.information( - self, - "No Real-Device Observations", - "Complete a Capability Matrix run against a connected device before exporting compatibility evidence.", - ) - return - try: - report = self._compatibility_report() - except DeviceCompatibilityError as error: - QMessageBox.critical(self, "Could Not Prepare Compatibility Report", str(error)) - return - if not self._review_compatibility_export( - "Review Sanitized Compatibility Markdown", - render_compatibility_markdown(report), - ): - return - timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%SZ") - suggested = Path.home() / f"iOSDeveloperToolkit-compatibility-{timestamp}.md" - selected, _ = QFileDialog.getSaveFileName( - self, - "Save Sanitized Compatibility Markdown", - str(suggested), - "Markdown (*.md)", - ) - if not selected: - return - destination = Path(selected) - if destination.suffix.casefold() != ".md": - destination = destination.with_suffix(".md") - try: - path = write_compatibility_markdown_report(destination, report) - except DeviceCompatibilityError as error: - QMessageBox.critical(self, "Could Not Export Compatibility Report", str(error)) - return - self.compatibility_history_status.setText(f"Created sanitized compatibility Markdown: {path}") - - def _capability_selection_changed(self) -> None: - selected_rows = self.capability_table.selectionModel().selectedRows() - if len(selected_rows) != 1: - self.capability_detail.setPlainText("Select a capability to view its evidence and next step.") - return - item = self.capability_table.item(selected_rows[0].row(), 0) - identifier = item.data(Qt.ItemDataRole.UserRole) if item is not None else None - if not isinstance(identifier, str) or identifier not in self._capability_results: - raise CapabilityMatrixError("Capability matrix selection does not identify a catalog result") - result = self._capability_results[identifier] - self.capability_detail.setPlainText( - f"{result.title} — {capability_state_label(result.state)}\n\n" - f"Evidence\n{result.evidence}\n\n" - f"Next step\n{result.remediation}" - ) - - def refresh_capability_matrix(self) -> None: - device = self.selected_device() - if device is None: - self._show_no_device() - return - if self._capability_process is not None: - QMessageBox.warning(self, "Capability Refresh Running", "Cancel or wait for the current matrix refresh.") - return - self._capability_results = {result.identifier: result for result in untested_capability_results()} - self._capability_completed_at = None - self._capability_cancel_reason = None - self._capability_worker_completed = False - self._capability_stdout_buffer.clear() - self._capability_stderr.clear() - self.capability_progress.setRange(0, len(capability_definitions())) - self.capability_progress.setValue(0) - self.capability_status.setText( - f"Testing {device.display_name()} with bounded, read-only service probes…" - ) - self._populate_capability_matrix() - worker = worker_command("capability") - process = QProcess(self) - process.setProgram(str(worker.program)) - process.setArguments( - list(command_arguments(worker, ( - "--identifier", - device.identifier, - "--name", - device.name, - "--product-type", - device.product_type, - "--product-version", - device.product_version, - "--build-version", - device.build_version, - "--connection-type", - device.connection_type, - ))) - ) - process.setProcessEnvironment(qprocess_environment(base_environment())) - process.readyReadStandardOutput.connect(self._read_capability_stdout) - process.readyReadStandardError.connect(self._read_capability_stderr) - process.finished.connect(self._capability_finished) - process.errorOccurred.connect(self._capability_error) - self._capability_process = process - self.case_readiness_button.setEnabled(False) - self._update_capability_controls() - process.start() - - def _read_capability_stdout(self) -> None: - process = self._capability_process - if process is None: - return - self._capability_stdout_buffer.extend(bytes(process.readAllStandardOutput())) - while b"\n" in self._capability_stdout_buffer: - line, remainder = self._capability_stdout_buffer.split(b"\n", 1) - self._capability_stdout_buffer = bytearray(remainder) - if line.strip(): - self._handle_capability_event(line.decode("utf-8")) - - def _read_capability_stderr(self) -> None: - if self._capability_process is not None: - self._capability_stderr.extend(bytes(self._capability_process.readAllStandardError())) - - def _handle_capability_event(self, payload: str) -> None: - event = parse_capability_worker_event(payload) - if isinstance(event, CapabilityWorkerStarted): - if event.total != len(capability_definitions()): - raise CapabilityMatrixError( - f"Capability worker expected {event.total} results, but the UI catalog has {len(capability_definitions())}" - ) - self.capability_progress.setRange(0, event.total) - return - if isinstance(event, CapabilityResult): - self._capability_results[event.identifier] = event - completed = sum(result.state != "not-tested" for result in self._capability_results.values()) - self.capability_progress.setValue(completed) - self.capability_status.setText( - f"Completed {completed} of {len(capability_definitions())}: {event.title} — " - f"{capability_state_label(event.state)}" - ) - self._populate_capability_matrix() - self._update_selected_command_readiness() - return - if isinstance(event, CapabilityWorkerCompleted): - self._capability_worker_completed = True - return - raise CapabilityMatrixError(f"Unsupported capability event type: {type(event).__name__}") - - def _capability_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: - del exit_status - self._read_capability_stdout() - self._read_capability_stderr() - if self._capability_stdout_buffer.strip(): - self._handle_capability_event(self._capability_stdout_buffer.decode("utf-8")) - self._capability_stdout_buffer.clear() - tested = sum(result.state != "not-tested" for result in self._capability_results.values()) - if self._capability_cancel_reason is not None: - self.capability_status.setText(f"{self._capability_cancel_reason} Preserved {tested} completed results.") - elif exit_code == 0 and self._capability_worker_completed: - self._capability_completed_at = datetime.now(timezone.utc).isoformat() - ready = sum(result.state == "ready" for result in self._capability_results.values()) - attention = sum(result.state in ("attention", "unavailable", "blocked") for result in self._capability_results.values()) - self.capability_status.setText( - f"Capability refresh completed: {ready} ready, {attention} requiring attention, " - f"{len(self._capability_results) - ready - attention} informational." - ) - device = self.selected_device() - if device is None: - raise CapabilityMatrixError("Capability worker completed without a selected device") - self._record_compatibility_observation(device) - else: - stderr = self._capability_stderr.decode("utf-8", errors="replace").strip() - detail = stderr[-800:] if stderr else "The worker exited without a diagnostic message." - self.capability_status.setText(f"Capability refresh failed with exit code {exit_code}: {detail}") - self._capability_process = None - self._capability_cancel_reason = None - self.case_readiness_button.setEnabled(self.selected_device() is not None) - if self._active_case_path is not None: - ready = sum(result.state == "ready" for result in self._capability_results.values()) - attention = sum( - result.state in ("attention", "unavailable", "blocked") - for result in self._capability_results.values() - ) - self.case_status.setText( - f"Readiness check completed: {ready} ready, {attention} requiring attention. Review Capability Matrix before collection." - ) - self._populate_capability_matrix() - self._update_capability_controls() - self._update_selected_command_readiness() - - def _capability_error(self, process_error: QProcess.ProcessError) -> None: - if self._capability_process is None: - return - self.capability_status.setText(f"Capability worker error: {self._capability_process.errorString()}") - if process_error == QProcess.ProcessError.FailedToStart: - self._capability_process = None - self.case_readiness_button.setEnabled(self.selected_device() is not None) - self._update_capability_controls() - self._update_selected_command_readiness() - - def cancel_capability_matrix(self) -> None: - self._stop_capability_process("Capability refresh was cancelled.") - - def _stop_capability_process(self, reason: str) -> None: - process = self._capability_process - if process is None: - return - self._capability_cancel_reason = reason - self.capability_status.setText(f"{reason} Stopping the active probe…") - self._terminate_capability_children(process) - process.terminate() - QTimer.singleShot(1500, self._kill_capability_after_cancel) - self._update_capability_controls() - - def _terminate_capability_children(self, process: QProcess) -> None: - process_identifier = process.processId() - if process_identifier <= 0 or not Path("/usr/bin/pkill").is_file(): - return - subprocess.run( - ["/usr/bin/pkill", "-TERM", "-P", str(process_identifier)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - timeout=2, - ) - - def _discard_capability_process_for_device_change(self) -> None: - process = self._capability_process - if process is None: - return - process.readyReadStandardOutput.disconnect(self._read_capability_stdout) - process.readyReadStandardError.disconnect(self._read_capability_stderr) - process.finished.disconnect(self._capability_finished) - process.errorOccurred.disconnect(self._capability_error) - self._terminate_capability_children(process) - process.terminate() - if not process.waitForFinished(2500): - process.kill() - process.waitForFinished(1000) - self._capability_process = None - self._capability_cancel_reason = None - self._capability_stdout_buffer.clear() - self._capability_stderr.clear() - - def _kill_capability_after_cancel(self) -> None: - process = self._capability_process - if process is not None and process.state() != QProcess.ProcessState.NotRunning: - process.kill() - - def _update_capability_controls(self) -> None: - running = self._capability_process is not None - tested = any(result.state != "not-tested" for result in self._capability_results.values()) - self.refresh_capabilities_button.setEnabled(self.selected_device() is not None and not running) - self.cancel_capabilities_button.setEnabled(running) - self.copy_capabilities_button.setEnabled(tested and not running) - - def copy_capability_report(self) -> None: - device = self.selected_device() - if device is None: - self._show_no_device() - return - tested = tuple( - self._capability_results[definition.identifier] - for definition in capability_definitions() - if self._capability_results[definition.identifier].state != "not-tested" - ) - if not tested: - QMessageBox.information(self, "No Capability Results", "Run the capability matrix before copying a report.") - return - lines = [ - "iOS Developer Toolkit — Device Capability Matrix", - f"Target: {device.name}; {device.product_type}; iOS {device.product_version}; build {device.build_version}; {device.connection_type}", - f"Completed at: {self._capability_completed_at or 'incomplete or cancelled run'}", - "", - ] - for result in tested: - lines.extend( - ( - f"[{capability_state_label(result.state)}] {result.layer} / {result.title}", - f"Result: {result.summary}", - f"Evidence: {result.evidence}", - f"Next step: {result.remediation}", - "", - ) - ) - QApplication.clipboard().setText("\n".join(lines).rstrip() + "\n") - self.capability_status.setText("Copied the current capability report to the clipboard.") - - def show_developer_mode_guide(self) -> None: - DeveloperModeDialog().exec() - - def check_developer_mode(self) -> None: - self._run_pmd3_action(("mounter", "query-developer-mode-status"), "developer-mode-status") - - def _ddi_source_changed(self) -> None: - if self.personalized_radio.isChecked(): - self.mount_button.setText("Mount Personalized DDI") - self.remove_button.setText("Unmount Personalized DDI") - self.ddi_description.setHtml( - f"Downloaded personalized image: fetches the APFS image, BuildManifest, and trust cache from " - f"DeveloperDiskImage, caches them under " - "~/.pymobiledevice3/Xcode_iOS_DDI_Personalized, requests an Apple TSS ticket, and mounts " - "the result at /System/Developer. This is the simplest current path." - ) - else: - exists_text = "available" if XCODE_CANDIDATE_DDI.is_file() else "not found" - self.mount_button.setText("Install Local Xcode DDI Cryptex") - self.remove_button.setText("Uninstall Local DDI Cryptex") - self.ddi_description.setHtml( - f"Local Apple/Xcode image ({exists_text}): read-only attaches the outer candidate at " - f"{XCODE_CANDIDATE_DDI}, uses its Restore payload, personalizes it through " - "Apple TSS, installs it as com.apple.MobileAsset.DDI, then detaches the Mac-side image. " - "The outer DMG itself is never sent directly to iOS." - ) - - def mount_selected_ddi(self) -> None: - device = self.selected_device() - if device is None: - self._show_no_device() - return - profile = guided_action_safety("device-change") - if self.personalized_radio.isChecked(): - prompt = ( - "Mount the downloaded personalized Developer Disk Image?\n\n" - "This downloads files from GitHub, sends personalization identifiers and a nonce to Apple TSS, " - "uploads the image, and changes the device's mounted state." - ) - if not self._confirm_action("Mount Personalized DDI", prompt, profile, device.identifier): - return - self._record_action_approval(self.action_output, "Mount Personalized DDI", profile) - self._run_pmd3_action(("mounter", "auto-mount"), "mount-personalized") - return - if not XCODE_CANDIDATE_DDI.is_file(): - QMessageBox.critical(self, "Local DDI Missing", f"The Xcode candidate DDI was not found:\n{XCODE_CANDIDATE_DDI}") - return - prompt = ( - "Install the local Xcode DDI as a personalized Cryptex?\n\n" - "The Apple DMG is attached read-only on this Mac, its Restore payload is personalized through Apple TSS, " - "and com.apple.MobileAsset.DDI is installed on the selected device." - ) - if not self._confirm_action("Install Local Xcode DDI", prompt, profile, device.identifier): - return - self._record_action_approval(self.action_output, "Install Local Xcode DDI", profile) - self._start_action( - worker_command("local-ddi"), - ("--candidate", str(XCODE_CANDIDATE_DDI), "--udid", device.identifier), - base_environment(), - "mount-local-cryptex", - DDI_ACTION_TIMEOUT_MS, - self._device_operation_context( - "Install Local Xcode DDI", - "Device & DDI", - "local DDI worker, Apple TSS, and CoreDevice Cryptex service", - device, - (), - ), - ) - - def remove_selected_ddi(self) -> None: - device = self.selected_device() - if device is None: - self._show_no_device() - return - profile = guided_action_safety("device-change") - if self.personalized_radio.isChecked(): - if self._confirm_action( - "Unmount Personalized DDI", - "Unmount the personalized image from /System/Developer?", - profile, - device.identifier, - ): - self._record_action_approval(self.action_output, "Unmount Personalized DDI", profile) - self._run_pmd3_action(("mounter", "umount-personalized"), "unmount-personalized") - return - if self._confirm_action( - "Uninstall Local DDI Cryptex", - "Uninstall com.apple.MobileAsset.DDI from the selected device?", - profile, - device.identifier, - ): - self._record_action_approval(self.action_output, "Uninstall Local DDI Cryptex", profile) - self._run_pmd3_action(("cryptex", "uninstall", "com.apple.MobileAsset.DDI"), "uninstall-local-cryptex") - - def list_mounted_images(self) -> None: - arguments = ("mounter", "list") if self.personalized_radio.isChecked() else ("cryptex", "list") - self._run_pmd3_action(arguments, "list-images") - - def show_coredevice_details(self) -> None: - device = self.selected_device() - if device is None: - self._show_no_device() - return - try: - command, arguments = coredevice_details_handoff(device.identifier) - except XcodeHandoffError as error: - QMessageBox.critical(self, "CoreDevice Tool Unavailable", str(error)) - return - self._start_action( - command, - arguments, - base_environment(), - "coredevice-details", - XCODE_HANDOFF_TIMEOUT_MS, - self._device_operation_context( - "CoreDevice Details", - "Device & DDI", - "Apple devicectl", - device, - (), - ), - ) - - def list_rvi_interfaces(self) -> None: - try: - command, arguments = rvi_list_handoff() - except XcodeHandoffError as error: - QMessageBox.critical(self, "RVI Tool Unavailable", str(error)) - return - self._start_action( - command, - arguments, - base_environment(), - "rvi-status", - XCODE_HANDOFF_TIMEOUT_MS, - self._host_operation_context("List RVI Interfaces", "Device & DDI", "Apple rvictl", ()), - ) - - def open_xcode_project(self) -> None: - selected, _ = QFileDialog.getOpenFileName( - self, - "Open Xcode project, workspace, or Swift package", - str(Path.home()), - "Xcode projects (*.xcodeproj *.xcworkspace);;Swift package (Package.swift)", - ) - if not selected: - return - try: - command, arguments = xcode_project_handoff(Path(selected)) - except XcodeHandoffError as error: - QMessageBox.critical(self, "Invalid Xcode Project", str(error)) - return - self._start_action( - command, - arguments, - base_environment(), - "open-xcode-project", - XCODE_HANDOFF_TIMEOUT_MS, - self._host_operation_context("Open Xcode Project", "Device & DDI", "Apple xed", ()), - ) - - def open_xcode_artifact(self) -> None: - selected, _ = QFileDialog.getOpenFileName( - self, - "Open Xcode result or Instruments trace", - str(Path.home()), - "Xcode and Instruments artifacts (*.xcresult *.trace)", - ) - if not selected: - return - try: - target = validated_xcode_artifact(Path(selected)) - except XcodeHandoffError as error: - QMessageBox.critical(self, "Invalid Xcode Artifact", str(error)) - return - if not QDesktopServices.openUrl(QUrl.fromLocalFile(str(target))): - QMessageBox.critical(self, "Could Not Open Artifact", f"macOS could not open the selected target:\n{target}") - - def _run_pmd3_action(self, arguments: tuple[str, ...], context: str) -> None: - device = self.selected_device() - if device is None: - self._show_no_device() - return - titles = { - "developer-mode-status": "Check Developer Mode", - "mount-personalized": "Mount Personalized DDI", - "unmount-personalized": "Unmount Personalized DDI", - "uninstall-local-cryptex": "Uninstall Local DDI Cryptex", - "list-images": "List Developer Images", - } - title = titles.get(context) - if title is None: - raise KeyError(f"Unknown Device & DDI operation context: {context}") - self._start_action( - self._pmd3, - arguments, - device_environment(device.identifier), - context, - DDI_ACTION_TIMEOUT_MS, - self._device_operation_context( - title, - "Device & DDI", - "pymobiledevice3 selected-device transport", - device, - (), - ), - ) - - def _start_action( - self, - program: ExecutableCommand, - arguments: tuple[str, ...], - environment: Mapping[str, str], - context: str, - timeout_milliseconds: int, - history_context: OperationContext, - ) -> None: - if self._action_controller.is_running(): - QMessageBox.warning(self, "Action Running", "Wait for the current Device & DDI action to finish.") - return - self.action_output.appendPlainText(f"$ {command_text(program, arguments)}") - self._action_context = context - self._begin_operation("device-and-ddi", history_context) - self.mount_button.setEnabled(False) - self.remove_button.setEnabled(False) - self.coredevice_details_button.setEnabled(False) - self.rvi_status_button.setEnabled(False) - self.open_xcode_project_button.setEnabled(False) - self._action_controller.start( - finite_process_request( - program, - arguments, - environment, - timeout_milliseconds, - PROCESS_TERMINATE_GRACE_MS, - ) - ) - - def _append_action_output(self, output: bytes) -> None: - self.action_output.moveCursor(QTextCursor.MoveOperation.End) - self.action_output.insertPlainText(output.decode("utf-8", errors="replace")) - - def _action_completed(self, result_object: object) -> None: - if not isinstance(result_object, OperationResult): - raise TypeError(f"Expected OperationResult, received {type(result_object).__name__}") - self._complete_operation("device-and-ddi", result_object) - context = self._action_context - combined_output = result_object.stdout + result_object.stderr - semantic_failure = output_indicates_failure(combined_output) - succeeded = result_object.outcome == "succeeded" and not semantic_failure - exit_label = "not available" if result_object.exit_code is None else str(result_object.exit_code) - self.action_output.appendPlainText( - f"\n[finished: {result_object.outcome}; exit {exit_label}]\n" - ) - if result_object.error_message: - self.action_output.appendPlainText(f"Process error: {result_object.error_message}") - if result_object.outcome == "timed-out": - self.action_output.appendPlainText( - "The action exceeded its safety limit and was stopped." - ) - if context == "developer-mode-status": - if succeeded and b"true" in combined_output.lower(): - self.developer_mode_status.setText("Developer Mode is enabled") - elif succeeded: - self.developer_mode_status.setText("Developer Mode appears disabled — follow the on-device steps") - else: - self.developer_mode_status.setText("Could not query Developer Mode; see command output") - elif succeeded and context.startswith("mount"): - self.developer_mode_status.setText("Developer image operation completed successfully") - self._action_context = "" - self._update_device_fields(self.selected_device()) - - def _apply_location_coordinates(self, coordinates: Coordinates) -> None: - self.location_latitude_field.setText(format(coordinates.latitude, ".12g")) - self.location_longitude_field.setText(format(coordinates.longitude, ".12g")) - self.location_map.set_marker(coordinates) - self.location_map.setToolTip( - f"Selected {coordinates.latitude:.6f}, {coordinates.longitude:.6f}. " - "The device is unchanged until Set Simulated Location is confirmed." - ) - - def _map_location_selected(self, latitude: float, longitude: float) -> None: - coordinates = validate_coordinates(str(latitude), str(longitude)) - self._apply_location_coordinates(coordinates) - - def import_location_coordinates(self) -> None: - try: - coordinates = parse_location_input(self.location_input_field.text()) - except LocationLabError as error: - QMessageBox.critical(self, "Could Not Import Location", str(error)) - return - self._apply_location_coordinates(coordinates) - self.location_input_field.clear() - - def sync_location_map_from_fields(self) -> None: - try: - coordinates = validate_coordinates( - self.location_latitude_field.text(), - self.location_longitude_field.text(), - ) - except LocationLabError as error: - self.location_map.setToolTip(f"Map marker not updated: {error}") - return - self.location_map.set_marker(coordinates) - - def _populate_saved_locations(self) -> None: - self.saved_location_combo.blockSignals(True) - self.saved_location_combo.clear() - if self._saved_locations_error is not None: - self.saved_location_combo.addItem(f"Unavailable: {self._saved_locations_error}") - self.saved_location_combo.setEnabled(False) - else: - self.saved_location_combo.addItem("Choose a saved place…", None) - for location in self._saved_locations: - self.saved_location_combo.addItem( - f"{location.name} — {location.coordinates.latitude:.6f}, {location.coordinates.longitude:.6f}", - location.name, - ) - self.saved_location_combo.setEnabled(True) - self.saved_location_combo.setCurrentIndex(0) - self.saved_location_combo.blockSignals(False) - self._update_location_controls() - - def _saved_location_selected(self, index: int) -> None: - if index <= 0 or self._saved_locations_error is not None: - self._update_location_controls() - return - name = self.saved_location_combo.itemData(index) - matching = tuple(location for location in self._saved_locations if location.name == name) - if len(matching) != 1: - raise LocationLabError(f"Expected one saved location for selection {name!r}, found {len(matching)}") - location = matching[0] - self._apply_location_coordinates(location.coordinates) - self._update_location_controls() - - def save_current_location(self) -> None: - if self._saved_locations_error is not None: - QMessageBox.critical(self, "Saved Locations Unavailable", self._saved_locations_error) - return - try: - coordinates = validate_coordinates( - self.location_latitude_field.text(), - self.location_longitude_field.text(), - ) - except LocationLabError as error: - QMessageBox.critical(self, "Invalid Coordinates", str(error)) - return - name, accepted = QInputDialog.getText(self, "Save Location", "Location name") - if not accepted: - return - try: - updated = add_saved_location(self._saved_locations, name, coordinates) - save_saved_locations(self._saved_locations_path, updated) - except LocationLabError as error: - QMessageBox.critical(self, "Could Not Save Location", str(error)) - return - self._saved_locations = updated - self._populate_saved_locations() - matching_index = self.saved_location_combo.findData(name.strip()) - if matching_index >= 0: - self.saved_location_combo.setCurrentIndex(matching_index) - - def remove_selected_saved_location(self) -> None: - name = self.saved_location_combo.currentData() - if not isinstance(name, str): - QMessageBox.information(self, "No Saved Location", "Choose a saved location to remove.") - return - if not self._confirm("Remove Saved Location", f"Remove the local saved location {name!r}?"): - return - try: - updated = remove_saved_location(self._saved_locations, name) - save_saved_locations(self._saved_locations_path, updated) - except LocationLabError as error: - QMessageBox.critical(self, "Could Not Remove Location", str(error)) - return - self._saved_locations = updated - self._populate_saved_locations() - - def choose_location_gpx(self) -> None: - selected_path, _ = QFileDialog.getOpenFileName( - self, - "Choose GPX track", - str(Path.home()), - "GPS Exchange Format (*.gpx)", - ) - if not selected_path: - return - try: - inspection = inspect_gpx(Path(selected_path)) - except LocationLabError as error: - self._selected_location_gpx = None - self.location_gpx_field.clear() - self.location_gpx_summary.setText(f"GPX validation failed: {error}") - self._update_location_controls() - QMessageBox.critical(self, "Invalid GPX Track", str(error)) - return - self._selected_location_gpx = inspection - self.location_gpx_field.setText(str(inspection.path)) - self.location_gpx_summary.setText( - f"Validated {inspection.track_point_count} track points ({inspection.timed_point_count} timed). " - f"Start: {inspection.first_point.latitude:.6f}, {inspection.first_point.longitude:.6f} • " - f"End: {inspection.last_point.latitude:.6f}, {inspection.last_point.longitude:.6f} • " - f"SHA-256: {inspection.sha256}" - ) - self._update_location_controls() - - def nudge_location_fields(self, bearing_degrees: float) -> None: - try: - current = validate_coordinates( - self.location_latitude_field.text(), - self.location_longitude_field.text(), - ) - nudged = move_coordinates(current, bearing_degrees, float(self.location_nudge_distance.value())) - except LocationLabError as error: - QMessageBox.critical(self, "Could Not Nudge Coordinate", str(error)) - return - self._apply_location_coordinates(nudged) - - def apply_location_speed_preset(self, index: int) -> None: - speed = self.location_route_speed_preset.itemData(index) - if not isinstance(speed, int): - raise LocationLabError(f"Route speed preset must contain an integer, received {speed!r}") - self.location_route_speed.setValue(speed) - - def add_current_route_waypoint(self) -> None: - try: - coordinates = validate_coordinates( - self.location_latitude_field.text(), - self.location_longitude_field.text(), - ) - except LocationLabError as error: - QMessageBox.critical(self, "Invalid Route Waypoint", str(error)) - return - existing = self.location_route_waypoints.toPlainText().rstrip() - waypoint = f"{coordinates.latitude:.9f},{coordinates.longitude:.9f}" - self.location_route_waypoints.setPlainText(f"{existing}\n{waypoint}".lstrip()) - self.location_route_waypoints.moveCursor(QTextCursor.MoveOperation.End) - - def build_location_route(self) -> None: - try: - waypoints = parse_route_waypoints(self.location_route_waypoints.toPlainText()) - route = build_route( - waypoints, - float(self.location_route_speed.value()), - self.location_route_interval.value(), - self.location_route_traversals.value(), - datetime.now(timezone.utc), - ) - directory = self.location_log_directory() / "Generated Routes" - directory.mkdir(parents=True, exist_ok=True) - timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ") - path = directory / f"qa-route-{timestamp}.gpx" - with path.open("x", encoding="utf-8") as output: - output.write(route.gpx_document) - inspection = inspect_gpx(path) - except (LocationLabError, OSError) as error: - QMessageBox.critical(self, "Could Not Build Route", str(error)) - return - self._selected_location_gpx = inspection - self.location_gpx_field.setText(str(path)) - self.location_gpx_summary.setText( - f"Validated generated GPX: {inspection.track_point_count} timed points • SHA-256: {inspection.sha256}" - ) - self.location_route_summary.setText( - f"Loaded {len(route.points):,} points • {route.distance_metres / 1000.0:.3f} km • " - f"{route.duration_seconds // 60}m {route.duration_seconds % 60}s • {route.traversal_count} traversal(s)." - ) - self.location_output.appendPlainText( - f"Generated and validated local QA route: {path}\n" - f"Distance: {route.distance_metres:.1f} m; duration: {route.duration_seconds} s; " - f"speed: {route.speed_kmh:g} km/h; SHA-256: {inspection.sha256}" - ) - self._update_location_controls() - - def choose_location_log_directory(self) -> None: - selected = QFileDialog.getExistingDirectory( - self, - "Choose Location Lab evidence directory", - self.location_log_directory_field.text(), - ) - if selected: - self.location_log_directory_field.setText(selected) - - def location_log_directory(self) -> Path: - value = self.location_log_directory_field.text().strip() - if not value: - raise LocationLabError("Choose a non-empty Location Lab evidence directory") - directory = Path(value).expanduser() - if not directory.is_absolute(): - raise LocationLabError(f"Location Lab evidence directory must be an absolute path: {directory}") - return directory.resolve() - - def open_location_log_directory(self) -> None: - try: - directory = self.location_log_directory() - except LocationLabError as error: - QMessageBox.critical(self, "Invalid Evidence Directory", str(error)) - return - if not directory.is_dir(): - QMessageBox.information(self, "Evidence Directory Not Found", f"The folder does not exist yet:\n{directory}") - return - QDesktopServices.openUrl(QUrl.fromLocalFile(str(directory))) - - def _record_location_event( - self, - operation: str, - status: str, - device_identifier: str, - device_name: str, - ios_version: str, - arguments: tuple[str, ...], - coordinates: Coordinates | None, - gpx: GPXInspection | None, - exit_code: int | None, - detail: str, - ) -> None: - event = LocationEvidenceEvent( - event=operation, - status=status, - timestamp=utc_now(), - device_identifier=device_identifier, - device_name=device_name, - ios_version=ios_version, - command=("pymobiledevice3", *arguments), - latitude=coordinates.latitude if coordinates is not None else None, - longitude=coordinates.longitude if coordinates is not None else None, - gpx_path=str(gpx.path) if gpx is not None else None, - gpx_sha256=gpx.sha256 if gpx is not None else None, - exit_code=exit_code, - detail=detail, - ) - self._location_log_path = append_evidence_event(self.location_log_directory(), event) - - def _start_location_process( - self, - operation: str, - device_identifier: str, - device_name: str, - ios_version: str, - arguments: tuple[str, ...], - coordinates: Coordinates | None, - gpx: GPXInspection | None, - ) -> None: - if self._location_process is not None: - QMessageBox.warning(self, "Location Operation Running", "Stop and clear the active location operation first.") - return - try: - self._record_location_event( - operation, - "requested", - device_identifier, - device_name, - ios_version, - arguments, - coordinates, - gpx, - None, - "User confirmed the device-state change.", - ) - except LocationLabError as error: - QMessageBox.critical(self, "Could Not Write Evidence Log", str(error)) - return - self._location_operation = operation - self._location_arguments = arguments - self._location_buffer.clear() - self._location_started_at = utc_now() - self._location_device_identifier = device_identifier - self._location_device_name = device_name - self._location_device_version = ios_version - self._location_coordinates = coordinates - self._location_operation_gpx = gpx - process = QProcess(self) - process.setProgram(str(self._pmd3.program)) - process.setArguments(list(command_arguments(self._pmd3, arguments))) - process.setWorkingDirectory(str(Path.home())) - process.setProcessEnvironment(qprocess_environment(device_environment(device_identifier))) - process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels) - process.started.connect(self._location_started) - process.readyReadStandardOutput.connect(self._read_location_output) - process.finished.connect(self._location_finished) - process.errorOccurred.connect(self._location_error) - self._location_process = process - self.location_output.appendPlainText(f"\n$ pymobiledevice3 {shlex.join(arguments)}") - self.location_state_value.setText(f"Starting {operation} for {device_name}…") - self._update_location_controls() - process.start() - - def _location_started(self) -> None: - operation = self._location_operation - if operation in ("set", "play"): - self._location_may_be_simulated = True - self.location_state_value.setText( - f"{operation.title()} request is running for {self._location_device_name}. " - "The host process started; device-side location is not independently verified." - ) - self.location_output.appendPlainText( - f"[started {self._location_started_at}; target {self._location_device_identifier}]" - ) - try: - self._record_location_event( - operation, - "started", - self._location_device_identifier or "", - self._location_device_name, - self._location_device_version, - self._location_arguments, - self._location_coordinates, - self._location_operation_gpx, - None, - "The host command process started; device-side effect is not independently verified.", - ) - except LocationLabError as error: - self.location_output.appendPlainText(f"Evidence log error: {error}") - QMessageBox.critical(self, "Could Not Update Evidence Log", str(error)) - self._update_location_controls() - - def _read_location_output(self) -> None: - if self._location_process is None: - return - data = bytes(self._location_process.readAllStandardOutput()) - self._location_buffer.extend(data) - self.location_output.moveCursor(QTextCursor.MoveOperation.End) - self.location_output.insertPlainText(data.decode("utf-8", errors="replace")) - - def _location_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: - del exit_status - self._read_location_output() - operation = self._location_operation - user_stopped = self._location_clear_after_stop and operation in ("set", "play") - semantic_failure = output_indicates_failure(bytes(self._location_buffer)) - succeeded = exit_code == 0 and not semantic_failure - if user_stopped: - status = "stopped" - detail = "The user stopped the active process; an explicit clear follows." - elif succeeded: - status = "completed" - detail = "The command completed without a detected CLI or semantic error." - else: - status = "failed" - detail = "The command failed or emitted device/service error output; clearing remains recommended." - self.location_output.appendPlainText(f"\n[location {operation} finished: exit {exit_code}; {status}]") - try: - self._record_location_event( - operation, - status, - self._location_device_identifier or "", - self._location_device_name, - self._location_device_version, - self._location_arguments, - self._location_coordinates, - self._location_operation_gpx, - exit_code, - detail, - ) - except LocationLabError as error: - self.location_output.appendPlainText(f"Evidence log error: {error}") - QMessageBox.critical(self, "Could Not Finalize Evidence Log", str(error)) - self._location_process = None - if operation == "clear" and succeeded: - self._location_may_be_simulated = False - self._location_device_identifier = None - self._location_device_name = "" - self._location_device_version = "" - self._location_coordinates = None - self._location_operation_gpx = None - self.location_state_value.setText( - "Clear completed without a detected error. External location state is not independently observable." - ) - elif operation == "clear": - self._location_may_be_simulated = True - self.location_state_value.setText("Clear failed; the tracked device may still report a simulated location.") - elif not user_stopped: - self._location_may_be_simulated = True - self.location_state_value.setText( - f"{operation.title()} process ended; the tracked device may remain simulated until Clear succeeds." - ) - if user_stopped: - self._location_clear_after_stop = False - QTimer.singleShot(0, self._start_tracked_location_clear) - else: - self._update_location_controls() - - def _location_error(self, process_error: QProcess.ProcessError) -> None: - process = self._location_process - if process is None: - return - message = process.errorString() - self.location_output.appendPlainText(f"\nLocation process error: {message}") - if process_error != QProcess.ProcessError.FailedToStart: - return - operation = self._location_operation - try: - self._record_location_event( - operation, - "failed-to-start", - self._location_device_identifier or "", - self._location_device_name, - self._location_device_version, - self._location_arguments, - self._location_coordinates, - self._location_operation_gpx, - None, - message, - ) - except LocationLabError as error: - self.location_output.appendPlainText(f"Evidence log error: {error}") - if operation in ("set", "play"): - self._location_may_be_simulated = False - self._location_device_identifier = None - self._location_device_name = "" - self._location_device_version = "" - self._location_coordinates = None - self._location_operation_gpx = None - self._location_process = None - self.location_state_value.setText(f"Location command could not start: {message}") - self._update_location_controls() - - def _update_location_controls(self) -> None: - if not hasattr(self, "set_location_button"): - return - device = self.selected_device() - running = self._location_process is not None - available_for_new_simulation = device is not None and not running and not self._location_may_be_simulated - self.set_location_button.setEnabled(available_for_new_simulation) - self.play_location_gpx_button.setEnabled( - available_for_new_simulation and self._selected_location_gpx is not None - ) - self.clear_location_button.setEnabled(not running and (device is not None or self._location_device_identifier is not None)) - self.stop_clear_location_button.setEnabled( - running and self._location_operation in ("set", "play") and not self._location_clear_after_stop - ) - self.choose_location_gpx_button.setEnabled(not running) - self.location_timing_randomness.setEnabled(not running) - self.location_disable_sleep.setEnabled(not running) - self.location_route_waypoints.setEnabled(not running) - self.location_route_speed_preset.setEnabled(not running) - self.location_route_speed.setEnabled(not running) - self.location_route_interval.setEnabled(not running) - self.location_route_traversals.setEnabled(not running) - self.location_nudge_distance.setEnabled(not running) - self.location_map.setEnabled(not running) - self.location_input_field.setEnabled(not running) - self.import_location_button.setEnabled(not running) - saved_available = self._saved_locations_error is None and not running - self.save_location_button.setEnabled(saved_available) - self.remove_saved_location_button.setEnabled( - saved_available and isinstance(self.saved_location_combo.currentData(), str) - ) - if self._location_device_identifier is not None: - selected_note = "" - if device is not None and device.identifier != self._location_device_identifier: - selected_note = f" Selected picker now shows {device.identifier}; cleanup will still target the tracked device." - self.location_target_value.setText( - f"Tracked target: {self._location_device_name} — iOS {self._location_device_version} " - f"({self._location_device_identifier}).{selected_note}" - ) - elif device is not None: - self.location_target_value.setText(f"Selected target: {device.display_name()} ({device.identifier})") - else: - self.location_target_value.setText("No connected target is selected") - - def set_simulated_location(self) -> None: - device = self.selected_device() - if device is None: - self._show_no_device() - return - if self._location_may_be_simulated: - QMessageBox.warning(self, "Clear Existing Simulation", "Clear the tracked simulated location before setting another one.") - return - try: - coordinates = validate_coordinates( - self.location_latitude_field.text(), - self.location_longitude_field.text(), - ) - arguments = set_location_arguments(device.product_version, coordinates) - log_directory = self.location_log_directory() - except LocationLabError as error: - QMessageBox.critical(self, "Invalid Location Request", str(error)) - return - warning = ( - f"Set a simulated location on {device.display_name()}?\n\n" - f"Latitude: {coordinates.latitude}\nLongitude: {coordinates.longitude}\n" - f"Evidence log: {log_directory / 'location-events.jsonl'}\n\n" - "This changes device-visible location for participating software. Keep this window open and use Stop & Clear " - "when testing ends. The toolkit cannot independently verify what every app reports." - ) - if not self._confirm_action( - "Set Simulated Location", - warning, - guided_action_safety("device-change"), - device.identifier, - ): - return - self._start_location_process( - "set", - device.identifier, - device.name, - device.product_version, - arguments, - coordinates, - None, - ) - - def play_location_gpx(self) -> None: - device = self.selected_device() - inspection = self._selected_location_gpx - if device is None: - self._show_no_device() - return - if inspection is None: - QMessageBox.information(self, "No Validated GPX", "Choose and validate a GPX track first.") - return - if self._location_may_be_simulated: - QMessageBox.warning(self, "Clear Existing Simulation", "Clear the tracked simulated location before replaying a route.") - return - try: - arguments = play_location_arguments( - device.product_version, - inspection.path, - self.location_timing_randomness.value(), - self.location_disable_sleep.isChecked(), - ) - log_directory = self.location_log_directory() - except LocationLabError as error: - QMessageBox.critical(self, "Invalid GPX Request", str(error)) - return - warning = ( - f"Replay the validated GPX track on {device.display_name()}?\n\n" - f"Track points: {inspection.track_point_count}\nSHA-256: {inspection.sha256}\n" - f"Evidence log: {log_directory / 'location-events.jsonl'}\n\n" - "GPX timestamps control pacing unless fast playback is selected. Stop & Clear restores normal location " - "after the route or when you stop it early." - ) - if not self._confirm_action( - "Play GPX Route", - warning, - guided_action_safety("device-change"), - device.identifier, - ): - return - self._start_location_process( - "play", - device.identifier, - device.name, - device.product_version, - arguments, - None, - inspection, - ) - - def clear_simulated_location(self) -> None: - if self._location_process is not None: - self.stop_location_and_clear() - return - target = self._tracked_or_selected_location_target() - if target is None: - self._show_no_device() - return - identifier, name, version = target - try: - arguments = clear_location_arguments(version) - log_directory = self.location_log_directory() - except LocationLabError as error: - QMessageBox.critical(self, "Invalid Clear Request", str(error)) - return - warning = ( - f"Clear simulated location on {name} — iOS {version} ({identifier})?\n\n" - f"Evidence log: {log_directory / 'location-events.jsonl'}\n\n" - "This requests restoration of normal location sources. A successful command is not an independent reading " - "of every app's location state." - ) - if not self._confirm("Clear Simulated Location", warning): - return - self._start_location_process("clear", identifier, name, version, arguments, None, None) - - def _tracked_or_selected_location_target(self) -> tuple[str, str, str] | None: - if self._location_device_identifier is not None: - return ( - self._location_device_identifier, - self._location_device_name, - self._location_device_version, - ) - device = self.selected_device() - if device is None: - return None - return device.identifier, device.name, device.product_version - - def stop_location_and_clear(self) -> None: - process = self._location_process - if process is None or self._location_operation not in ("set", "play"): - QMessageBox.information(self, "No Active Simulation", "No active set or GPX process is available to stop.") - return - self._location_clear_after_stop = True - self.location_state_value.setText("Stopping the active location process; an explicit clear will follow…") - self.location_output.appendPlainText("\nRequesting location process stop before clear…") - self._update_location_controls() - process.terminate() - QTimer.singleShot(5000, self._kill_location_after_stop_timeout) - - def _kill_location_after_stop_timeout(self) -> None: - process = self._location_process - if process is not None and self._location_clear_after_stop and process.state() != QProcess.ProcessState.NotRunning: - self.location_output.appendPlainText("Location process did not terminate in 5 seconds; killing it before clear.") - process.kill() - - def _start_tracked_location_clear(self) -> None: - target = self._tracked_or_selected_location_target() - if target is None: - self.location_state_value.setText("The active process stopped, but no target remains available for clear.") - self._location_may_be_simulated = True - self._update_location_controls() - return - identifier, name, version = target - try: - arguments = clear_location_arguments(version) - except LocationLabError as error: - self.location_state_value.setText(f"Could not build the cleanup command: {error}") - self._location_may_be_simulated = True - self._update_location_controls() - return - self._start_location_process("clear", identifier, name, version, arguments, None, None) - - def open_location_help(self) -> None: - target = self._tracked_or_selected_location_target() - version = target[2] if target is not None else "17" - try: - path = ( - ("developer", "dvt", "simulate-location") - if parse_ios_major(version) >= 17 - else ("developer", "simulate-location") - ) - except LocationLabError as error: - QMessageBox.critical(self, "Unknown iOS Version", str(error)) - return - self.navigate_to_page("Man Pages") - self.select_manpage_path(path) - - def choose_output_root(self) -> None: - selected = QFileDialog.getExistingDirectory(self, "Choose evidence destination", self.output_root.text()) - if selected: - self.output_root.setText(selected) - - def create_guided_case(self) -> None: - device = self.selected_device() - if device is None: - self._show_no_device() - return - if self._collection_controller.is_running(): - QMessageBox.warning(self, "Collection Running", "Wait for the active collection to finish before creating another case.") - return - if self._active_case_path is not None: - QMessageBox.warning(self, "Guided Case Active", "This case is ready for collection. Start it or create a new case after it is finalized.") - return - try: - case_path, _ = create_guided_case( - Path(self.output_root.text()), - device.identifier, - self.case_title_field.text(), - self.case_purpose_field.toPlainText(), - self.case_authorization_checkbox.isChecked(), - ) - except CaseWorkflowError as error: - QMessageBox.warning(self, "Unable to Create Guided Case", str(error)) - return - self._active_case_path = case_path - self._last_case_path = case_path - self.open_case_button.setEnabled(True) - self.create_case_button.setEnabled(False) - self.case_status.setText(f"Active case: {case_path}. Configure coverage, then start collection.") - self.collection_output.setPlainText(f"Guided case created:\n{case_path}\n\nCollection will attach to this case and finalize it once.") - - def run_guided_case_readiness_check(self) -> None: - device = self.selected_device() - if device is None: - self._show_no_device() - return - if self._capability_process is not None: - QMessageBox.warning(self, "Readiness Check Running", "Cancel or wait for the current Device Capability Matrix check.") - return - self.case_status.setText( - f"Running a bounded, read-only readiness check for {device.display_name()}. Results are shown in Capability Matrix." - ) - self.navigate_to_page("Capability Matrix") - self.refresh_capability_matrix() - - def start_collection(self) -> None: - device = self.selected_device() - if device is None: - self._show_no_device() - return - if self._collection_controller.is_running(): - QMessageBox.warning(self, "Collection Running", "A collection is already running.") - return - selected_streams = self.include_syslog.isChecked() or self.include_oslog.isChecked() or self.include_pcap.isChecked() - if not selected_streams: - QMessageBox.information(self, "Snapshot Only", "No live streams are selected; the app will collect snapshots only.") - warning = ( - f"Collect evidence from {device.display_name()}?\n\n" - "The case will contain identifiers and potentially sensitive device data. " - "PCAP does not decrypt TLS, but unencrypted payloads may be recorded." - ) - if not self._confirm_action( - "Start Evidence Collection", - warning, - guided_action_safety("host-write"), - device.identifier, - ): - return - arguments = ["--udid", device.identifier] - if self._active_case_path is None: - arguments.extend(("--output-root", self.output_root.text())) - else: - arguments.extend(("--case-directory", str(self._active_case_path))) - arguments.extend(("--duration", str(self.capture_duration.value()))) - for enabled, flag in ( - (self.include_syslog.isChecked(), "--include-syslog"), - (self.include_oslog.isChecked(), "--include-oslog"), - (self.include_pcap.isChecked(), "--include-pcap"), - (self.include_screenshot.isChecked(), "--include-screenshot"), - (self.include_crash_pull.isChecked(), "--include-crash-pull"), - ): - if enabled: - arguments.append(flag) - worker = worker_command("collector") - self.collection_output.clear() - self._record_action_approval( - self.collection_output, - "Start Evidence Collection", - guided_action_safety("host-write"), - ) - self._collection_case_finished = False - self.start_collection_button.setEnabled(False) - self.stop_collection_button.setEnabled(True) - initial_output_paths = () if self._active_case_path is None else (str(self._active_case_path),) - self._begin_operation( - "evidence-collection", - self._device_operation_context( - "Collect and Finalize Evidence", - "Evidence Capture", - "typed evidence collector worker", - device, - initial_output_paths, - ), - ) - self._collection_controller.start( - worker, - arguments, - base_environment(), - COLLECTION_FINALIZATION_TIMEOUT_MS, - ) - - def _append_collection_output(self, output: bytes) -> None: - self.collection_output.moveCursor(QTextCursor.MoveOperation.End) - self.collection_output.insertPlainText(output.decode("utf-8", errors="replace")) - - def _collection_event_received(self, event_object: object) -> None: - if not isinstance(event_object, CollectionEvent): - raise TypeError(f"Expected CollectionEvent, received {type(event_object).__name__}") - event = event_object - if event.event in ("case-created", "case-attached") and event.path is not None: - self._last_case_path = event.path - self.open_case_button.setEnabled(True) - self._update_operation_output_paths("evidence-collection", (str(event.path),)) - if event.event == "case-finished": - self._collection_case_finished = True - if event.path is not None: - self._last_case_path = event.path - self.open_case_button.setEnabled(True) - self._update_operation_output_paths("evidence-collection", (str(event.path),)) - - def _collection_completed(self, result_object: object) -> None: - if not isinstance(result_object, OperationResult): - raise TypeError(f"Expected OperationResult, received {type(result_object).__name__}") - self._complete_operation("evidence-collection", result_object) - exit_label = "not available" if result_object.exit_code is None else str(result_object.exit_code) - self.collection_output.appendPlainText( - f"\nCollection process finished: {result_object.outcome}; exit {exit_label}." - ) - if result_object.error_message: - self.collection_output.appendPlainText(f"Process error: {result_object.error_message}") - if self._active_case_path is not None: - if self._collection_case_finished: - self.case_status.setText( - f"Guided case finalized at {self._active_case_path}. Create a new case before another collection." - ) - self._active_case_path = None - else: - self.case_status.setText( - f"Finalization was not confirmed for {self._active_case_path}. Review the directory; the guided case remains active for retry." - ) - self.start_collection_button.setEnabled(self.selected_device() is not None) - self.create_case_button.setEnabled( - self.selected_device() is not None and self._active_case_path is None - ) - self.stop_collection_button.setEnabled(False) - if self._close_after_collection: - QTimer.singleShot(0, self.close) - - def stop_collection(self) -> None: - if self._collection_controller.is_running(): - self.collection_output.appendPlainText("\nRequesting a clean stop and evidence finalization…") - self.stop_collection_button.setEnabled(False) - self.case_status.setText( - "Stop requested. Waiting for the collector to finalize its manifest and hashes." - ) - self._collection_controller.cancel() - - def open_last_case(self) -> None: - if self._last_case_path is None or not self._last_case_path.is_dir(): - QMessageBox.warning(self, "Case Not Available", "The last case directory is not available.") - return - QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._last_case_path))) - - def choose_ipa(self) -> None: - selected_path, _ = QFileDialog.getOpenFileName( - self, - "Choose an iOS IPA package", - str(Path.home()), - "iOS application packages (*.ipa)", - ) - if not selected_path: - return - self._selected_ipa = Path(selected_path).expanduser().resolve() - self.ipa_path_field.setText(str(self._selected_ipa)) - self._start_ipa_inspection() - - def _start_ipa_inspection(self) -> None: - selected_ipa = self._selected_ipa - if selected_ipa is None: - raise IPAInspectionError("No IPA path was selected for inspection") - if self._ipa_inspection_controller.is_running(): - QMessageBox.warning(self, "Inspection Running", "Wait for the current IPA inspection to finish.") - return - self._ipa_inspection = None - self.ipa_inspection_summary.clear() - self.ipa_inspection_summary.setPlainText("Inspecting archive, provisioning profile, and code signature…") - self.ipa_inspection_progress.setVisible(True) - worker = worker_command("ipa-inspector") - self._begin_operation( - "ipa-inspection", - self._host_operation_context( - "Inspect IPA", - "Sideload IPA", - "local IPA inspection worker and macOS codesign", - (), - ), - ) - self._ipa_inspection_controller.start( - finite_process_request( - worker, - (str(selected_ipa),), - base_environment(), - IPA_INSPECTION_TIMEOUT_MS, - PROCESS_TERMINATE_GRACE_MS, - ) - ) - self._update_sideload_controls() - - def _ipa_inspection_completed(self, result_object: object) -> None: - if not isinstance(result_object, OperationResult): - raise TypeError(f"Expected OperationResult, received {type(result_object).__name__}") - self._complete_operation("ipa-inspection", result_object) - stderr_text = result_object.stderr.decode("utf-8", errors="replace").strip() - if result_object.outcome != "succeeded": - exit_label = "not available" if result_object.exit_code is None else str(result_object.exit_code) - message = stderr_text or result_object.error_message or f"IPA inspector exited with status {exit_label}" - self.ipa_inspection_summary.setPlainText(message) - if result_object.outcome == "timed-out": - self.sideload_status.setText("IPA inspection exceeded the five-minute safety limit and was stopped.") - else: - self.sideload_status.setText("IPA inspection failed. Correct the package error before installation.") - else: - try: - inspection = parse_inspection_json(result_object.stdout.decode("utf-8")) - except (IPAInspectionError, UnicodeDecodeError) as error: - self.ipa_inspection_summary.setPlainText(f"IPA inspection output validation failed: {error}") - self.sideload_status.setText("IPA inspection failed. The inspector returned malformed data.") - else: - self._ipa_inspection = inspection - self.ipa_inspection_summary.setPlainText(format_inspection(inspection)) - if inspection.signature.status == "valid": - self.sideload_status.setText( - "macOS codesign verified the extracted bundle. Confirm that its provisioning method permits the selected device." - ) - else: - self.sideload_status.setText( - f"Installation is disabled because the extracted bundle signature is {inspection.signature.status}." - ) - self.ipa_inspection_progress.setVisible(False) - self._update_sideload_controls() - - def _update_sideload_controls(self) -> None: - device_available = self.selected_device() is not None - action_running = self._sideload_controller.is_running() - inspection_running = self._ipa_inspection_controller.is_running() - signature_valid = self._ipa_inspection is not None and self._ipa_inspection.signature.status == "valid" - self.choose_ipa_button.setEnabled(not inspection_running and not action_running) - self.install_ipa_button.setEnabled(device_available and signature_valid and not action_running and not inspection_running) - self.stop_sideload_button.setEnabled(action_running) - - def install_selected_ipa(self) -> None: - device = self.selected_device() - inspection = self._ipa_inspection - selected_ipa = self._selected_ipa - if device is None: - self._show_no_device() - return - if inspection is None or selected_ipa is None: - QMessageBox.warning(self, "IPA Not Inspected", "Choose and successfully inspect an IPA first.") - return - if inspection.signature.status != "valid": - QMessageBox.critical( - self, - "Invalid IPA Signature", - f"macOS codesign reported signature status: {inspection.signature.status}. Installation is blocked.", - ) - return - developer_install = self.developer_package_checkbox.isChecked() - install_mode = "developer package" if developer_install else "standard package" - warning = ( - f"Install {inspection.app_name} on {device.display_name()}?\n\n" - f"IPA: {selected_ipa}\n" - f"Bundle ID: {inspection.bundle_identifier}\n" - f"Version: {inspection.version} ({inspection.build})\n" - f"Mode: {install_mode}\n\n" - "This changes device state. iOS will still enforce provisioning, signing, Developer Mode, and trust policy." - ) - if not self._confirm_action( - "Install IPA", - warning, - guided_action_safety("device-change"), - device.identifier, - ): - return - self._record_action_approval(self.sideload_output, "Install IPA", guided_action_safety("device-change")) - arguments = ["apps", "install"] - if developer_install: - arguments.append("--developer") - arguments.append(str(selected_ipa)) - self._start_sideload_action(tuple(arguments), "install") - - def _start_sideload_action(self, arguments: tuple[str, ...], context: str) -> None: - device = self.selected_device() - if device is None: - self._show_no_device() - return - if self._sideload_controller.is_running(): - QMessageBox.warning(self, "App Operation Running", "Stop or wait for the active app operation first.") - return - self._sideload_context = context - self.sideload_output.appendPlainText(f"\n$ pymobiledevice3 {shlex.join(arguments)}\n") - self.sideload_status.setText(f"Running {context} operation on {device.display_name()}…") - self.sideload_activity_progress.setVisible(True) - self._begin_operation( - "sideload-ipa", - self._device_operation_context( - "Install IPA" if context == "install" else context.replace("-", " ").title(), - "Sideload IPA", - "pymobiledevice3 selected-device transport", - device, - (), - ), - ) - self._sideload_controller.start( - finite_process_request( - self._pmd3, - arguments, - device_environment(device.identifier), - IPA_INSTALL_TIMEOUT_MS, - PROCESS_TERMINATE_GRACE_MS, - ) - ) - self._update_sideload_controls() - - def _append_sideload_output(self, output: bytes) -> None: - self.sideload_output.moveCursor(QTextCursor.MoveOperation.End) - self.sideload_output.insertPlainText(output.decode("utf-8", errors="replace")) - - def _sideload_completed(self, result_object: object) -> None: - if not isinstance(result_object, OperationResult): - raise TypeError(f"Expected OperationResult, received {type(result_object).__name__}") - self._complete_operation("sideload-ipa", result_object) - context = self._sideload_context - semantic_failure = output_indicates_failure(result_object.stdout + result_object.stderr) - succeeded = result_object.outcome == "succeeded" and not semantic_failure - exit_label = "not available" if result_object.exit_code is None else str(result_object.exit_code) - self.sideload_output.appendPlainText( - f"\n[finished: {result_object.outcome}; exit {exit_label}]\n" - ) - if result_object.error_message: - self.sideload_output.appendPlainText(f"Process error: {result_object.error_message}") - if succeeded: - self.sideload_status.setText(f"{context.capitalize()} completed successfully.") - elif result_object.outcome == "timed-out": - self.sideload_status.setText("IPA installation exceeded the 15-minute safety limit and was stopped.") - elif result_object.outcome == "cancelled": - self.sideload_status.setText("IPA installation was cancelled; verify device state before retrying.") - else: - self.sideload_status.setText(f"{context.capitalize()} failed; review the complete command output above.") - self._sideload_context = "" - self.sideload_activity_progress.setVisible(False) - self._update_sideload_controls() - if succeeded and context == "install" and not self._apps_controller.is_running(): - QTimer.singleShot(0, self.refresh_app_inventory) - - def stop_sideload_action(self) -> None: - if self._sideload_controller.is_running(): - self.sideload_output.appendPlainText("\nRequesting app operation stop…") - self._sideload_controller.cancel() - - def refresh_app_inventory(self) -> None: - device = self.selected_device() - if device is None: - self._show_no_device() - return - arguments = ["apps", "list", "--type", "Any"] - if self.calculate_app_sizes_checkbox.isChecked(): - arguments.append("--calculate-sizes") - self._start_apps_action(tuple(arguments), "inventory") - - def _start_apps_action(self, arguments: tuple[str, ...], context: str) -> None: - device = self.selected_device() - if device is None: - self._show_no_device() - return - if self._apps_controller.is_running(): - QMessageBox.warning(self, "App Operation Running", "Stop or wait for the active app operation first.") - return - self._apps_context = context - self.apps_output.appendPlainText(f"\n$ pymobiledevice3 {shlex.join(arguments)}\n") - self.apps_status.setText(f"Running {context} operation on {device.display_name()}…") - titles = {"inventory": "Refresh Installed Apps", "uninstall": "Uninstall Application"} - title = titles.get(context) - if title is None: - raise KeyError(f"Unknown Installed Apps operation context: {context}") - self._begin_operation( - "installed-apps", - self._device_operation_context( - title, - "Installed Apps", - "pymobiledevice3 selected-device transport", - device, - (), - ), - ) - self._apps_controller.start( - finite_process_request( - self._pmd3, - arguments, - device_environment(device.identifier), - APPS_ACTION_TIMEOUT_MS, - PROCESS_TERMINATE_GRACE_MS, - ) - ) - self._update_apps_controls() - - def _append_apps_stderr(self, output: bytes) -> None: - self.apps_output.moveCursor(QTextCursor.MoveOperation.End) - self.apps_output.insertPlainText(output.decode("utf-8", errors="replace")) - - def _apps_completed(self, result_object: object) -> None: - if not isinstance(result_object, OperationResult): - raise TypeError(f"Expected OperationResult, received {type(result_object).__name__}") - self._complete_operation("installed-apps", result_object) - context = self._apps_context - combined_output = result_object.stdout + result_object.stderr - succeeded = result_object.outcome == "succeeded" and not output_indicates_failure(combined_output) - if succeeded and context == "inventory": - try: - apps = parse_installed_apps_json(result_object.stdout.decode("utf-8")) - except (InstalledAppsDataError, json.JSONDecodeError, UnicodeDecodeError) as error: - succeeded = False - self.apps_output.appendPlainText(f"Inventory validation failed: {error}") - else: - self._installed_apps = apps - self._populate_installed_apps(apps) - self.apps_status.setText(f"Loaded {len(apps)} installed applications.") - elif succeeded and context == "uninstall": - self.apps_status.setText("Application uninstalled successfully. Refreshing inventory…") - if not succeeded: - stdout_text = result_object.stdout.decode("utf-8", errors="replace").strip() - if stdout_text: - self.apps_output.appendPlainText(stdout_text) - if result_object.outcome == "timed-out": - self.apps_status.setText("App operation exceeded the 10-minute safety limit and was stopped.") - elif result_object.outcome == "cancelled": - self.apps_status.setText("App operation was cancelled.") - else: - self.apps_status.setText(f"{context.capitalize()} failed; review the output below.") - if result_object.error_message: - self.apps_output.appendPlainText(f"Process error: {result_object.error_message}") - exit_label = "not available" if result_object.exit_code is None else str(result_object.exit_code) - self.apps_output.appendPlainText(f"[finished: {result_object.outcome}; exit {exit_label}]\n") - self._apps_context = "" - self._update_apps_controls() - if succeeded and context == "uninstall": - QTimer.singleShot(0, self.refresh_app_inventory) - - def _populate_installed_apps(self, apps: tuple[InstalledApp, ...]) -> None: - self.installed_apps_table.setSortingEnabled(False) - self.installed_apps_table.setRowCount(len(apps)) - for row, app in enumerate(apps): - values = ( - app.name, - app.bundle_identifier, - app.version or "—", - app.build or "—", - app.application_type, - format_byte_count(app.total_bytes), - ) - for column, value in enumerate(values): - item = QTableWidgetItem(value) - if column == 5 and app.total_bytes is not None: - item.setData(Qt.ItemDataRole.UserRole, app.total_bytes) - self.installed_apps_table.setItem(row, column, item) - self.installed_apps_table.setSortingEnabled(True) - self._filter_installed_apps(self.app_filter_field.text()) - self._update_apps_controls() - - def _filter_installed_apps(self, value: str) -> None: - needle = value.strip().casefold() - for row in range(self.installed_apps_table.rowCount()): - searchable = " ".join( - self.installed_apps_table.item(row, column).text() - for column in range(self.installed_apps_table.columnCount()) - if self.installed_apps_table.item(row, column) is not None - ).casefold() - self.installed_apps_table.setRowHidden(row, bool(needle) and needle not in searchable) - - def selected_installed_bundle_identifier(self) -> str | None: - selected_rows = self.installed_apps_table.selectionModel().selectedRows() - if len(selected_rows) != 1: - return None - item = self.installed_apps_table.item(selected_rows[0].row(), 1) - return item.text() if item is not None else None - - def _installed_app_selection_changed(self) -> None: - self._update_apps_controls() - - def _update_apps_controls(self) -> None: - running = self._apps_controller.is_running() - device_available = self.selected_device() is not None - selected = self.selected_installed_bundle_identifier() is not None - self.refresh_apps_button.setEnabled(device_available and not running) - self.calculate_app_sizes_checkbox.setEnabled(not running) - self.copy_bundle_id_button.setEnabled(selected and not running) - self.uninstall_app_button.setEnabled(device_available and selected and not running) - self.stop_apps_button.setEnabled(running) - - def copy_selected_bundle_identifier(self) -> None: - bundle_identifier = self.selected_installed_bundle_identifier() - if bundle_identifier is None: - QMessageBox.information(self, "No App Selected", "Select one application row first.") - return - QApplication.clipboard().setText(bundle_identifier) - self.apps_status.setText(f"Copied {bundle_identifier} to the clipboard.") - - def uninstall_selected_application(self) -> None: - device = self.selected_device() - if device is None: - self._show_no_device() - return - selected_identifier = self.selected_installed_bundle_identifier() - if selected_identifier is None: - QMessageBox.information(self, "No App Selected", "Select one application row first.") - return - try: - bundle_identifier = validate_bundle_identifier(selected_identifier) - except IPAInspectionError as error: - QMessageBox.critical(self, "Invalid Bundle Identifier", str(error)) - return - warning = ( - f"Uninstall {bundle_identifier} from {device.display_name()}?\n\n" - "This removes the application and may remove its local app data. This action cannot be undone by the toolkit." - ) - if self._confirm_action( - "Uninstall Application", - warning, - guided_action_safety("device-change"), - device.identifier, - ): - self._record_action_approval(self.apps_output, "Uninstall Application", guided_action_safety("device-change")) - self._start_apps_action(("apps", "uninstall", bundle_identifier), "uninstall") - - def stop_apps_action(self) -> None: - if self._apps_controller.is_running(): - self.apps_output.appendPlainText("Requesting app operation stop…") - self._apps_controller.cancel() - - def choose_backup_destination(self) -> None: - selected = QFileDialog.getExistingDirectory( - self, - "Choose local backup destination", - self.backup_destination_field.text(), - ) - if selected: - self.backup_destination_field.setText(selected) - - def backup_destination(self) -> Path: - raw_destination = self.backup_destination_field.text().strip() - if not raw_destination: - raise BackupRequestError("Choose a non-empty local backup destination") - return Path(raw_destination).expanduser().resolve() - - def check_backup_encryption(self) -> None: - device = self.selected_device() - if device is None: - self._show_no_device() - return - try: - destination = self.backup_destination() - except BackupRequestError as error: - QMessageBox.critical(self, "Invalid Backup Destination", str(error)) - return - request = BackupRequest(device.identifier, destination, False, "", False) - self._start_backup_worker("status", request) - - def _backup_encryption_choice_changed(self, checked: bool) -> None: - needs_new_password = checked and self._backup_encryption_state is not True - controls_enabled = ( - needs_new_password - and not self._backup_controller.is_running() - and self.selected_device() is not None - ) - self.backup_password_field.setEnabled(controls_enabled) - self.backup_password_confirmation_field.setEnabled(controls_enabled) - - def start_backup(self) -> None: - device = self.selected_device() - if device is None: - self._show_no_device() - return - try: - destination = self.backup_destination() - except BackupRequestError as error: - QMessageBox.critical(self, "Invalid Backup Destination", str(error)) - return - require_encryption = self.require_encryption_checkbox.isChecked() - password = self.backup_password_field.text() - confirmation = self.backup_password_confirmation_field.text() - if require_encryption and self._backup_encryption_state is not True: - if not password: - QMessageBox.critical( - self, - "Encryption Password Required", - "Enter a new backup password because encryption is off or has not yet been checked.", - ) - return - if password != confirmation: - QMessageBox.critical(self, "Passwords Do Not Match", "Enter the same new backup password twice.") - return - else: - password = "" - if require_encryption and self._backup_encryption_state is False: - encryption_summary = "Persistent local-backup encryption will be enabled before this backup." - elif require_encryption and self._backup_encryption_state is True: - encryption_summary = "The existing encrypted-backup setting and password will be preserved." - elif require_encryption: - encryption_summary = "The helper will verify encryption and enable it with the new password only if needed." - else: - encryption_summary = "The device's current encryption setting will be preserved; encryption will not be disabled." - backup_mode = "Full" if self.full_backup_checkbox.isChecked() else "Incremental when valid local state exists" - warning = ( - f"Back up {device.display_name()}?\n\n" - f"Destination: {destination / device.identifier}\n" - f"Mode: {backup_mode}\n" - f"Encryption: {encryption_summary}\n\n" - "Backups can contain messages, account data, Health data when encrypted, identifiers, and other private information. " - "Keep the destination protected." - ) - profile = ( - guided_action_safety("device-change") - if require_encryption and self._backup_encryption_state is not True - else guided_action_safety("host-write") - ) - if not self._confirm_action("Start Device Backup", warning, profile, device.identifier): - return - self._record_action_approval(self.backup_output, "Start Device Backup", profile) - request = BackupRequest( - device.identifier, - destination, - require_encryption, - password, - self.full_backup_checkbox.isChecked(), - ) - self._start_backup_worker("backup", request) - self.backup_password_field.clear() - self.backup_password_confirmation_field.clear() - - def _start_backup_worker(self, action: BackupAction, request: BackupRequest) -> None: - if self._backup_controller.is_running(): - QMessageBox.warning(self, "Backup Operation Running", "Stop or wait for the active backup operation first.") - return - self._backup_action = action - worker = worker_command("backup") - self.backup_output.appendPlainText( - "Checking backup encryption…" if action == "status" else "Starting device backup…" - ) - if action == "backup": - self.backup_progress.setValue(0) - device = self.selected_device() - if device is None or device.identifier != request.udid: - raise RuntimeError("Backup operation target does not match the selected device") - output_paths = (str(request.destination / request.udid),) if action == "backup" else () - self._begin_operation( - "backup", - self._device_operation_context( - "Check Backup Encryption" if action == "status" else "Create Device Backup", - "Backup", - "pymobiledevice3 MobileBackup2 worker", - device, - output_paths, - ), - ) - self._backup_controller.start( - worker, - action, - request, - base_environment(), - PROCESS_TERMINATE_GRACE_MS, - ) - self._update_backup_controls() - - def _handle_backup_event(self, event_object: object) -> None: - if not isinstance(event_object, BackupEvent): - raise TypeError(f"Expected BackupEvent, received {type(event_object).__name__}") - event = event_object - self.backup_output.appendPlainText(event.message) - if event.percent is not None: - self.backup_progress.setValue(max(0, min(100, event.percent))) - if event.encrypted is not None: - self._backup_encryption_state = event.encrypted - state = "enabled" if event.encrypted else "disabled" - self.backup_encryption_status.setText(f"Backup encryption is {state} on this device") - self._backup_encryption_choice_changed(self.require_encryption_checkbox.isChecked()) - if event.path is not None: - self._last_backup_path = event.path - self._update_operation_output_paths("backup", (str(event.path),)) - - def _append_backup_stderr(self, output: bytes) -> None: - self.backup_output.moveCursor(QTextCursor.MoveOperation.End) - self.backup_output.insertPlainText(output.decode("utf-8", errors="replace")) - - def _backup_completed(self, result_object: object) -> None: - if not isinstance(result_object, OperationResult): - raise TypeError(f"Expected OperationResult, received {type(result_object).__name__}") - self._complete_operation("backup", result_object) - action = self._backup_action - if result_object.outcome == "succeeded": - self.backup_output.appendPlainText( - "Encryption status check completed." if action == "status" else "Backup operation completed successfully." - ) - elif result_object.outcome == "cancelled": - self.backup_output.appendPlainText( - "Encryption status check stopped." - if action == "status" - else "Backup operation stopped. Any partial destination remains incomplete and must be reviewed before reuse." - ) - else: - action_label = "Backup" if action is None else action.capitalize() - exit_label = "not available" if result_object.exit_code is None else str(result_object.exit_code) - self.backup_output.appendPlainText( - f"{action_label} failed ({result_object.outcome}; exit {exit_label})." - ) - if result_object.error_message: - self.backup_output.appendPlainText(f"Process error: {result_object.error_message}") - self._backup_action = None - self._update_backup_controls() - self._backup_encryption_choice_changed(self.require_encryption_checkbox.isChecked()) - - def _update_backup_controls(self) -> None: - running = self._backup_controller.is_running() - device_available = self.selected_device() is not None - self.start_backup_button.setEnabled(device_available and not running) - self.check_encryption_button.setEnabled(device_available and not running) - self.stop_backup_button.setEnabled(running) - self.full_backup_checkbox.setEnabled(not running) - self.require_encryption_checkbox.setEnabled(not running) - self.backup_destination_field.setEnabled(not running) - self.open_backup_button.setEnabled(not running) - if hasattr(self, "launch_ufade_button"): - self.launch_ufade_button.setEnabled(device_available and not running) - self._update_mvt_controls() - self._backup_encryption_choice_changed(self.require_encryption_checkbox.isChecked()) - - def stop_backup(self) -> None: - if self._backup_controller.is_running(): - if self._backup_action == "status": - self.backup_output.appendPlainText("Stopping the encryption status check…") - else: - self.backup_output.appendPlainText( - "Stopping the backup. The partial destination may be incomplete and will not be treated as valid incremental state." - ) - self._backup_controller.cancel() - - def open_backup_folder(self) -> None: - try: - destination = self.backup_destination() - except BackupRequestError as error: - QMessageBox.critical(self, "Invalid Backup Destination", str(error)) - return - target = self._last_backup_path if self._last_backup_path is not None else destination - if not target.is_dir(): - QMessageBox.information(self, "Backup Folder Not Found", f"The folder does not exist yet:\n{target}") - return - QDesktopServices.openUrl(QUrl.fromLocalFile(str(target))) - - def _invalidate_ufade_validation(self, value: str) -> None: - del value - self._ufade_installation = None - self.ufade_validation_status.setText("UFADE installation has not been validated") - - def choose_ufade_checkout(self) -> None: - selected = QFileDialog.getExistingDirectory( - self, - "Choose cloned UFADE checkout", - self.ufade_checkout_field.text(), - ) - if selected: - self.ufade_checkout_field.setText(selected) - - def choose_ufade_python(self) -> None: - selected, _ = QFileDialog.getOpenFileName( - self, - "Choose UFADE Python 3.11 executable", - self.ufade_python_field.text(), - "Executable (*)", - ) - if selected: - self.ufade_python_field.setText(selected) - - def use_checkout_ufade_python(self) -> None: - try: - checkout = self.ufade_checkout() - except UFADEValidationError as error: - QMessageBox.critical(self, "Choose UFADE Checkout First", str(error)) - return - python = checkout_python_path(checkout) - if not python.is_file(): - QMessageBox.critical( - self, - "UFADE .venv Not Found", - f"The expected Python executable does not exist:\n{python}\n\n" - "Click Copy Setup Commands, run every command in Terminal, then try again.", - ) - return - self.ufade_python_field.setText(str(python)) - - def choose_ufade_output_directory(self) -> None: - selected = QFileDialog.getExistingDirectory( - self, - "Choose UFADE working and output directory", - self.ufade_output_field.text(), - ) - if selected: - self.ufade_output_field.setText(selected) - - def ufade_checkout(self) -> Path: - value = self.ufade_checkout_field.text().strip() - if not value: - raise UFADEValidationError("Choose the root of a cloned UFADE checkout") - return Path(value).expanduser() - - def ufade_python(self) -> Path: - value = self.ufade_python_field.text().strip() - if not value: - raise UFADEValidationError("Choose the Python executable from UFADE's separate Python 3.11 environment") - return Path(value).expanduser() - - def ufade_output_directory(self) -> Path: - value = self.ufade_output_field.text().strip() - if not value: - raise UFADEValidationError("Choose a non-empty UFADE working and output directory") - destination = Path(value).expanduser() - if not destination.is_absolute(): - raise UFADEValidationError(f"UFADE output directory must be an absolute path: {destination}") - return destination.resolve() - - def validate_ufade_from_ui(self) -> UFADEInstallation | None: - self.ufade_validation_status.setText("Validating UFADE checkout, Python 3.11, and runtime imports…") - QApplication.processEvents() - try: - installation = inspect_ufade_installation(self.ufade_checkout(), self.ufade_python()) - self.ufade_output_directory() - except (UFADEValidationError, OSError, subprocess.SubprocessError) as error: - self._ufade_installation = None - self.ufade_validation_status.setText(f"Validation failed: {error}") - self.ufade_output.appendPlainText(f"UFADE validation failed: {error}") - return None - self._ufade_installation = installation - developer_status = ( - "Developer-image submodule is populated." - if installation.developer_images_available - else "Developer-image submodule is not populated; logical acquisitions can still run, but UFADE Developer Options may be limited." - ) - message = ( - f"Validated UFADE {installation.ufade_version} with Python {installation.python_version}. " - f"{developer_status} The GPL application will remain a separate process." - ) - self.ufade_validation_status.setText(message) - self.ufade_output.appendPlainText(message) - return installation - - def copy_ufade_setup_commands(self) -> None: - commands = "\n".join(macos_setup_commands()) - QApplication.clipboard().setText(commands) - self.ufade_output.appendPlainText( - "Copied macOS UFADE setup commands to the clipboard. Run them in Terminal, then choose the UFADE checkout " - "and click Use Checkout .venv." - ) - - def copy_ufade_manual_launch_command(self) -> None: - installation = self.validate_ufade_from_ui() - if installation is None: - QMessageBox.critical( - self, - "UFADE Validation Failed", - "Correct the UFADE checkout or Python 3.11 environment before copying a launch command.", - ) - return - try: - destination = self.ufade_output_directory() - except UFADEValidationError as error: - QMessageBox.critical(self, "Invalid UFADE Output Directory", str(error)) - return - command = "\n".join( - ( - f"cd {shlex.quote(str(destination))}", - shlex.join((str(installation.python), str(installation.script))), - ) - ) - QApplication.clipboard().setText(command) - self.ufade_output.appendPlainText( - "Copied a manual UFADE launch command. It uses the validated Python and starts in the selected output folder." - ) - - def show_ufade_guide(self) -> None: - UFADEGuideDialog().exec() - - def open_ufade_installation_guide(self) -> None: - if not QDesktopServices.openUrl(QUrl(UFADE_INSTALLATION_URL)): - QMessageBox.critical( - self, - "Could Not Open UFADE Guide", - f"macOS could not open the official UFADE installation guide:\n{UFADE_INSTALLATION_URL}", - ) - - def open_ufade_repository(self) -> None: - if not QDesktopServices.openUrl(QUrl(UFADE_REPOSITORY_URL)): - QMessageBox.critical( - self, - "Could Not Open UFADE Repository", - f"macOS could not open the UFADE repository:\n{UFADE_REPOSITORY_URL}", - ) - - def launch_ufade(self) -> None: - device = self.selected_device() - if device is None: - self._show_no_device() - return - installation = self.validate_ufade_from_ui() - if installation is None: - QMessageBox.critical( - self, - "UFADE Validation Failed", - "Correct the UFADE checkout or Python 3.11 environment before launching it.", - ) - return - try: - destination = self.ufade_output_directory() - except UFADEValidationError as error: - QMessageBox.critical(self, "Invalid UFADE Output Directory", str(error)) - return - warning = ( - f"Launch UFADE {installation.ufade_version} for an independent forensic acquisition?\n\n" - f"Toolkit-selected device: {device.display_name()} ({device.identifier})\n" - f"Working/output folder: {destination}\n" - f"Python: {installation.python}\n\n" - f"Developer-image submodule: {'available' if installation.developer_images_available else 'not populated'}\n\n" - "UFADE performs its own device discovery and will ask you to choose Logical, Logical+, UFD, PRFS, or other " - "operations in its own window. Keep only the intended device connected. UFADE may create decrypted copies, " - "archives, logs, or reports containing highly sensitive data. Use UFADE's own stop controls; closing this " - "toolkit will not stop the separate UFADE process." - ) - if not self._confirm("Launch External UFADE", warning): - return - try: - destination.mkdir(parents=True, exist_ok=True) - except OSError as error: - QMessageBox.critical( - self, - "Could Not Create UFADE Output Directory", - f"Could not create {destination}: {error}", - ) - return - started, process_identifier = QProcess.startDetached( - str(installation.python), - [str(installation.script)], - str(destination), - ) - if not started: - QMessageBox.critical( - self, - "Could Not Launch UFADE", - f"The external process did not start with {installation.python}", - ) - return - self.ufade_output.appendPlainText( - f"Launched external UFADE {installation.ufade_version} as process {process_identifier}. " - f"Working directory: {destination}" - ) - - def open_ufade_output_directory(self) -> None: - try: - destination = self.ufade_output_directory() - except UFADEValidationError as error: - QMessageBox.critical(self, "Invalid UFADE Output Directory", str(error)) - return - if not destination.is_dir(): - QMessageBox.information( - self, - "UFADE Output Folder Not Found", - f"The folder does not exist yet:\n{destination}", - ) - return - QDesktopServices.openUrl(QUrl.fromLocalFile(str(destination))) - - def _invalidate_mvt_validation(self, value: str) -> None: - del value - self._mvt_installation = None - self._mvt_pending_executable = None - self.mvt_validation_status.setText("MVT installation has not been validated") - self._update_mvt_controls() - - def mvt_executable_path(self) -> Path: - value = self.mvt_executable_field.text().strip() - if not value: - raise MVTValidationError("Choose an independently installed mvt-ios executable") - return Path(value).expanduser() - - def mvt_backup_path(self) -> Path: - value = self.mvt_backup_field.text().strip() - if not value: - raise MVTValidationError("Choose a decrypted iTunes-style backup folder") - return Path(value).expanduser() - - def mvt_output_path(self) -> Path: - value = self.mvt_output_field.text().strip() - if not value: - raise MVTValidationError("Choose a new, non-empty MVT result path") - return Path(value).expanduser() - - def choose_mvt_executable(self) -> None: - selected, _ = QFileDialog.getOpenFileName( - self, - "Choose external mvt-ios executable", - self.mvt_executable_field.text(), - "Executable (*)", - ) - if selected: - self.mvt_executable_field.setText(selected) - - def find_mvt_executable(self) -> None: - candidates = discover_mvt_executables(Path.home(), os.environ.get("PATH", "")) - if not candidates: - QMessageBox.information( - self, - "MVT Not Found", - "No executable mvt-ios was found in PATH, ~/.local/bin, /opt/homebrew/bin, or /usr/local/bin. " - "Use Copy Setup Commands or choose the executable manually.", - ) - return - self.mvt_executable_field.setText(str(candidates[0])) - self.mvt_status.setText(f"Found {len(candidates)} MVT executable candidate(s); validate the selected path.") - - def choose_mvt_backup(self) -> None: - selected = QFileDialog.getExistingDirectory( - self, - "Choose decrypted iTunes-style backup", - self.mvt_backup_field.text() or str(Path.home()), - ) - if selected: - self.mvt_backup_field.setText(selected) - - def choose_mvt_output_parent(self) -> None: - current_value = self.mvt_output_field.text().strip() - current = Path(current_value).expanduser() if current_value else Path.home() / "Documents" / "MVT Analyses" - selected = QFileDialog.getExistingDirectory( - self, - "Choose parent folder for a new MVT analysis", - str(current.parent), - ) - if not selected: - return - destination = Path(selected) / datetime.now(timezone.utc).strftime("mvt-analysis-%Y%m%d-%H%M%S") - self.mvt_output_field.setText(str(destination)) - - def choose_mvt_ioc_files(self) -> None: - selected, _ = QFileDialog.getOpenFileNames( - self, - "Choose MVT STIX2 indicator files", - str(Path.home()), - "MVT indicators (*.stix *.stix2 *.json)", - ) - if not selected: - return - self._mvt_ioc_paths = tuple(Path(path) for path in selected) - self._refresh_mvt_ioc_status() - - def clear_mvt_ioc_files(self) -> None: - self._mvt_ioc_paths = () - self._refresh_mvt_ioc_status() - - def _refresh_mvt_ioc_status(self) -> None: - if not self._mvt_ioc_paths: - self.mvt_ioc_status.setText("No STIX2/JSON indicator files selected") - else: - names = ", ".join(path.name for path in self._mvt_ioc_paths) - self.mvt_ioc_status.setText(f"{len(self._mvt_ioc_paths)} selected: {names}") - - def copy_mvt_setup_commands(self) -> None: - QApplication.clipboard().setText("\n".join(mvt_setup_commands())) - self.mvt_output.appendPlainText( - "Copied official-style macOS pipx setup commands. Run them in Terminal, reopen the app if PATH changed, " - "then click Find Installed and Validate Installation." - ) - - def show_mvt_guide(self) -> None: - MVTGuideDialog().exec() - - def open_mvt_official_guide(self) -> None: - if not QDesktopServices.openUrl(QUrl(MVT_BACKUP_GUIDE_URL)): - QMessageBox.critical( - self, - "Could Not Open MVT Guide", - f"macOS could not open the official MVT backup-analysis guide:\n{MVT_BACKUP_GUIDE_URL}", - ) - - def open_mvt_repository(self) -> None: - if not QDesktopServices.openUrl(QUrl(MVT_REPOSITORY_URL)): - QMessageBox.critical( - self, - "Could Not Open MVT Repository", - f"macOS could not open the MVT repository:\n{MVT_REPOSITORY_URL}", - ) - - def _prepare_mvt_environment(self, allow_network: bool) -> Mapping[str, str]: - if self._mvt_temporary_config is not None: - raise RuntimeError("MVT temporary configuration already exists for an active operation") - temporary_config = tempfile.TemporaryDirectory(prefix="ios-developer-toolkit-mvt-") - self._mvt_temporary_config = temporary_config - return mvt_environment(base_environment(), Path(temporary_config.name), allow_network) - - def _clear_mvt_temporary_config(self) -> None: - temporary_config = self._mvt_temporary_config - self._mvt_temporary_config = None - if temporary_config is not None: - temporary_config.cleanup() - - def validate_mvt_from_ui(self) -> None: - if self._mvt_controller.is_running(): - QMessageBox.warning(self, "MVT Operation Running", "Stop or wait for the active MVT operation first.") - return - try: - executable = inspect_mvt_executable(self.mvt_executable_path()) - environment = self._prepare_mvt_environment(False) - except (MVTValidationError, OSError) as error: - self._clear_mvt_temporary_config() - self.mvt_validation_status.setText(f"Validation failed: {error}") - self.mvt_output.appendPlainText(f"MVT validation failed: {error}") - return - self._mvt_operation = "validate" - self._mvt_request = None - self._mvt_pending_executable = executable - arguments = mvt_version_arguments() - self.mvt_output.appendPlainText( - f"\n$ {executable.path} {shlex.join(arguments)}\n" - f"Executable SHA-256: {executable.sha256}" - ) - self.mvt_validation_status.setText("Validating the external MVT version without update or network checks…") - self._begin_operation( - "mvt", - self._host_operation_context( - "Validate MVT Installation", - "Backup", - "external MVT CLI with isolated temporary configuration", - (), - ), - ) - self._mvt_controller.start( - mvt_command(executable), - arguments, - environment, - Path.home(), - PROCESS_TERMINATE_GRACE_MS, - ) - self._update_mvt_controls() - - def run_mvt_analysis(self) -> None: - if self._mvt_controller.is_running(): - QMessageBox.warning(self, "MVT Operation Running", "Stop or wait for the active MVT operation first.") - return - installation = self._mvt_installation - if installation is None: - QMessageBox.critical(self, "MVT Not Validated", "Validate the selected MVT installation first.") - return - if not self.mvt_authorization_checkbox.isChecked() or not self.mvt_interpretation_checkbox.isChecked(): - QMessageBox.critical( - self, - "Acknowledgements Required", - "Confirm both the authorization/consent and interpretation boundaries before running MVT.", - ) - return - try: - request = create_mvt_analysis_request( - installation, - self.mvt_backup_path(), - self.mvt_output_path(), - self._mvt_ioc_paths, - self.mvt_fast_checkbox.isChecked(), - self.mvt_hashes_checkbox.isChecked(), - self.mvt_network_checkbox.isChecked(), - ) - except (MVTValidationError, OSError) as error: - QMessageBox.critical(self, "Invalid MVT Analysis Request", str(error)) - self.mvt_status.setText(f"MVT analysis request was rejected: {error}") - return - indicator_summary = ( - "none" if not request.ioc_files else ", ".join(path.name for path in request.ioc_files) - ) - warning = ( - f"Run external MVT {request.installation.version} backup analysis?\n\n" - f"Executable: {request.installation.executable.path}\n" - f"Executable SHA-256: {request.installation.executable.sha256}\n" - f"Backup: {request.backup.path}\n" - f"New output: {request.output}\n" - f"Indicators: {indicator_summary}\n" - f"Network access: {'allowed' if request.allow_network else 'blocked'}\n" - f"Fast mode: {'on' if request.fast else 'off'}\n" - f"Hash files: {'on' if request.hashes else 'off'}\n\n" - "The output can contain sensitive device, account, communication, browsing, and application records. " - "No findings does not prove the device is clean, safe, or uncompromised." - ) - profile = guided_action_safety("host-write") - if not self._confirm_action("Run External MVT Analysis", warning, profile, None): - return - self._start_mvt_analysis_request(request) - - def _start_mvt_analysis_request(self, request: MVTAnalysisRequest) -> None: - if self._mvt_controller.is_running(): - raise RuntimeError("Cannot start MVT analysis while another MVT process is running") - try: - request = create_mvt_analysis_request( - request.installation, - request.backup.path, - request.output, - request.ioc_files, - request.fast, - request.hashes, - request.allow_network, - ) - request.output.parent.mkdir(parents=True, exist_ok=True) - environment = self._prepare_mvt_environment(request.allow_network) - except (MVTValidationError, OSError) as error: - self._clear_mvt_temporary_config() - QMessageBox.critical( - self, - "Could Not Prepare MVT Analysis", - f"The confirmed MVT request changed or could not be prepared: {error}", - ) - self.mvt_status.setText(f"MVT analysis did not start: {error}") - return - arguments = mvt_analysis_arguments(request) - self._mvt_operation = "analyze" - self._mvt_request = request - self.mvt_output.appendPlainText( - f"\n[safety approval: host-write; authorization and interpretation acknowledged]\n" - f"$ {request.installation.executable.path} {shlex.join(arguments)}\n" - f"Network access: {'allowed' if request.allow_network else 'blocked'}" - ) - self.mvt_status.setText("MVT backup analysis is running. Use Stop to request termination.") - self._begin_operation( - "mvt", - self._host_operation_context( - "Analyze Backup with MVT", - "Backup", - "external MVT CLI with isolated temporary configuration", - (str(request.output),), - ), - ) - self._mvt_controller.start( - mvt_command(request.installation.executable), - arguments, - environment, - request.output.parent, - PROCESS_TERMINATE_GRACE_MS, - ) - self._update_mvt_controls() - - def _append_mvt_output(self, output: bytes) -> None: - self.mvt_output.moveCursor(QTextCursor.MoveOperation.End) - self.mvt_output.insertPlainText(output.decode("utf-8", errors="replace")) - - def _mvt_completed(self, result_object: object) -> None: - if not isinstance(result_object, OperationResult): - raise TypeError(f"Expected OperationResult, received {type(result_object).__name__}") - self._complete_operation("mvt", result_object) - operation = self._mvt_operation - request = self._mvt_request - self._mvt_operation = "" - self._mvt_request = None - self._clear_mvt_temporary_config() - combined = (result_object.stdout + result_object.stderr).decode("utf-8", errors="replace") - exit_label = "not available" if result_object.exit_code is None else str(result_object.exit_code) - if operation == "validate" and result_object.outcome == "succeeded": - pending_executable = self._mvt_pending_executable - if pending_executable is None: - raise RuntimeError("MVT validation completed without a pending executable") - try: - version = parse_mvt_version_output(combined) - except MVTValidationError as error: - self._mvt_installation = None - self.mvt_validation_status.setText(f"Validation failed: {error}") - self.mvt_status.setText("MVT installation validation failed; review the complete output.") - else: - self._mvt_installation = MVTInstallation(pending_executable, version) - self.mvt_validation_status.setText( - f"Validated external MVT {version}; executable SHA-256 {pending_executable.sha256}." - ) - self.mvt_status.setText("MVT is validated. Select and review the analysis request before running it.") - elif operation == "analyze" and result_object.outcome == "succeeded" and request is not None: - self.mvt_status.setText( - f"MVT completed and wrote its results under {request.output}. Review its logs and structured records; " - "absence of alerts or detected files does not prove the device is clean or uncompromised." - ) - else: - if operation == "validate": - self._mvt_installation = None - self.mvt_validation_status.setText( - f"Validation {result_object.outcome.replace('-', ' ')}; exit {exit_label}." - ) - elif operation == "analyze": - self.mvt_status.setText( - f"MVT analysis {result_object.outcome.replace('-', ' ')}; exit {exit_label}. " - "The isolated output may be partial and must not be treated as a completed analysis." - ) - else: - raise RuntimeError(f"MVT process completed with unknown operation: {operation!r}") - if operation == "analyze": - self.mvt_authorization_checkbox.setChecked(False) - self.mvt_interpretation_checkbox.setChecked(False) - self._mvt_pending_executable = None - if result_object.error_message: - self.mvt_output.appendPlainText(f"\nProcess error: {result_object.error_message}") - self.mvt_output.appendPlainText( - f"\n[finished: {result_object.outcome}; exit {exit_label}]\n" - ) - self._update_mvt_controls() - - def stop_mvt_analysis(self) -> None: - if not self._mvt_controller.is_running(): - return - self.mvt_status.setText("Stopping the external MVT process; any analysis output remains partial.") - self._mvt_controller.cancel() - - def _update_mvt_controls(self) -> None: - if not hasattr(self, "run_mvt_button"): - return - running = self._mvt_controller.is_running() - validated = self._mvt_installation is not None - acknowledged = ( - self.mvt_authorization_checkbox.isChecked() - and self.mvt_interpretation_checkbox.isChecked() - ) - request_paths_present = bool( - self.mvt_backup_field.text().strip() and self.mvt_output_field.text().strip() - ) - self.validate_mvt_button.setEnabled(not running and bool(self.mvt_executable_field.text().strip())) - self.run_mvt_button.setEnabled(not running and validated and acknowledged and request_paths_present) - self.stop_mvt_button.setEnabled(running) - for control in ( - self.mvt_executable_field, - self.mvt_backup_field, - self.mvt_output_field, - self.mvt_fast_checkbox, - self.mvt_hashes_checkbox, - self.mvt_network_checkbox, - self.mvt_authorization_checkbox, - self.mvt_interpretation_checkbox, - self.choose_mvt_executable_button, - self.find_mvt_executable_button, - self.choose_mvt_backup_button, - self.choose_mvt_output_button, - self.choose_mvt_iocs_button, - self.clear_mvt_iocs_button, - ): - control.setEnabled(not running) - self.open_mvt_output_button.setEnabled(not running) - - def open_mvt_output_directory(self) -> None: - try: - destination = self.mvt_output_path() - except MVTValidationError as error: - QMessageBox.critical(self, "Invalid MVT Output Path", str(error)) - return - if not destination.is_dir(): - QMessageBox.information( - self, - "MVT Result Folder Not Found", - f"The result folder does not exist yet:\n{destination}", - ) - return - QDesktopServices.openUrl(QUrl.fromLocalFile(str(destination))) - - def _filter_command_presets(self) -> None: - selected_identifier = self._current_preset.identifier if self._current_preset is not None else None - category = self.command_category_combo.currentText() - query = self.command_search_field.text().strip().casefold() - matching = tuple( - preset - for preset in self._presets - if (category == "All categories" or preset.category == category) - and ( - not query - or query in preset.title.casefold() - or query in preset.summary.casefold() - or query in preset.category.casefold() - or query in " ".join(preset.argument_template).casefold() - ) - ) - self.command_preset_list.blockSignals(True) - self.command_preset_list.clear() - selected_row = 0 - for row, preset in enumerate(matching): - item = QListWidgetItem(preset.title) - item.setToolTip(preset.summary) - item.setData(Qt.ItemDataRole.UserRole, preset.identifier) - self.command_preset_list.addItem(item) - if preset.identifier == selected_identifier: - selected_row = row - if matching: - self.command_preset_list.setCurrentRow(selected_row) - self.command_preset_list.blockSignals(False) - if matching: - self._show_command_preset(matching[selected_row]) - else: - self._current_preset = None - self.command_preset_title.setText("No matching presets") - self.command_summary.setText("Change the category or search text.") - self.command_advanced_notes.clear() - self.command_preview.clear() - self._clear_preset_parameters() - self._update_command_controls() - - def _command_preset_selected( - self, - current: QListWidgetItem | None, - previous: QListWidgetItem | None, - ) -> None: - del previous - if current is None: - return - identifier = current.data(Qt.ItemDataRole.UserRole) - if not isinstance(identifier, str): - raise CommandCatalogError("selected preset is missing its string identifier") - matching = tuple(preset for preset in self._presets if preset.identifier == identifier) - if len(matching) != 1: - raise CommandCatalogError(f"expected one selected preset for {identifier!r}, found {len(matching)}") - self._show_command_preset(matching[0]) - - def _show_command_preset(self, preset: CommandPreset) -> None: - self._current_preset = preset - self.command_preset_title.setText(preset.title) - self.command_risk_badge.setText(risk_title(preset.risk)) - self.command_risk_badge.setProperty("risk", preset.risk) - self.command_risk_badge.style().unpolish(self.command_risk_badge) - self.command_risk_badge.style().polish(self.command_risk_badge) - self.command_summary.setText(preset.summary) - self.command_advanced_notes.setText(preset.advanced_notes) - prerequisites = [] - prerequisites.append("Connected and trusted device" if preset.requires_device else "No selected USB device required") - if preset.requires_developer_services: - prerequisites.append("Developer Mode + mounted DDI + iOS 17+ userspace/RSD tunnel when needed") - if preset.long_running: - prerequisites.append("Runs until Stop or service completion") - self.command_prerequisites.setText("Prerequisites: " + " • ".join(prerequisites)) - self._clear_preset_parameters() - for spec in preset.parameters: - field = QLineEdit(spec.initial_value) - field.setObjectName(f"presetParameter_{spec.identifier}") - field.setPlaceholderText(spec.description) - field.setAccessibleName(spec.label) - field.setAccessibleDescription(spec.description) - field.textChanged.connect(self._update_command_preview) - self._preset_parameter_fields[spec.identifier] = field - if spec.kind in ("local-directory", "output-file"): - row = QWidget() - row_layout = QHBoxLayout(row) - row_layout.setContentsMargins(0, 0, 0, 0) - row_layout.addWidget(field, 1) - choose_button = QPushButton("Choose…") - choose_button.setAccessibleName(f"Choose {spec.label}") - choose_button.setAccessibleDescription(spec.description) - choose_button.clicked.connect(self._preset_path_handler(spec)) - row_layout.addWidget(choose_button) - self.preset_parameters_layout.addRow(spec.label, row) - else: - self.preset_parameters_layout.addRow(spec.label, field) - self.preset_parameters_group.setVisible(bool(preset.parameters)) - self._update_command_preview() - - def _clear_preset_parameters(self) -> None: - while self.preset_parameters_layout.rowCount() > 0: - self.preset_parameters_layout.removeRow(0) - self._preset_parameter_fields.clear() - - def _preset_path_handler(self, spec: ParameterSpec) -> Callable[[bool], None]: - def choose(checked: bool) -> None: - del checked - field = self._preset_parameter_fields.get(spec.identifier) - if field is None: - raise CommandCatalogError(f"missing field for preset parameter {spec.identifier}") - if spec.kind == "local-directory": - selected = QFileDialog.getExistingDirectory(self, spec.label, field.text()) - else: - selected, _ = QFileDialog.getSaveFileName(self, spec.label, field.text(), "All files (*)") - if selected: - field.setText(selected) - - return choose - - def _preset_values(self) -> Mapping[str, str]: - return {identifier: field.text() for identifier, field in self._preset_parameter_fields.items()} - - def _update_command_preview(self) -> None: - preset = self._current_preset - if preset is None: - self.command_preview.clear() - self._update_command_controls() - return - try: - arguments = render_preset_arguments(preset, self._preset_values()) - except CommandCatalogError as error: - self.command_preview.setText(f"Incomplete: {error}") - else: - self.command_preview.setText(f"pymobiledevice3 {shlex.join(arguments)}") - self._update_command_controls() - - def _update_command_controls(self) -> None: - running = self._console_controller.is_running() - preset = self._current_preset - preset_valid = False - if preset is not None: - try: - render_preset_arguments(preset, self._preset_values()) - except CommandCatalogError: - preset_valid = False - else: - preset_valid = not preset.requires_device or self.selected_device() is not None - self.preset_run_button.setEnabled(preset_valid and not running) - self.preset_help_button.setEnabled(preset is not None and not running) - self.console_run_button.setEnabled(bool(self.console_input.text().strip()) and not running) - self.console_stop_button.setEnabled(running) - self.command_preset_list.setEnabled(not running) - self.command_category_combo.setEnabled(not running) - self.command_search_field.setEnabled(not running) - self.preset_parameters_group.setEnabled(not running) - self.console_input.setEnabled(not running) - if preset is not None: - self.preset_run_button.setText( - "Run Guided Command" if preset.risk == "read-only" else "Review && Run Guided Command" - ) - self._update_selected_command_readiness() - self._update_advanced_safety_note() - - def _update_selected_command_readiness(self) -> None: - preset = self._current_preset - if preset is None: - self.command_readiness_status.setText("Choose a preset to evaluate its device requirements.") - self.command_readiness_button.setEnabled(False) - return - if preset.requires_device and self.selected_device() is None: - self.command_readiness_status.setText( - "Blocked — connect, unlock, trust, and select the intended physical device first." - ) - self.command_readiness_button.setEnabled(False) - return - readiness = evaluate_preset_readiness(preset, self._capability_results) - next_steps = " ".join(readiness.remediation) - suffix = f" Next step: {next_steps}" if next_steps else "" - labels = { - "ready": "Ready", - "not-tested": "Not checked", - "needs-attention": "Needs attention", - } - self.command_readiness_status.setText(f"{labels[readiness.state]} — {readiness.summary}{suffix}") - self.command_readiness_button.setEnabled( - preset.requires_device and self.selected_device() is not None and self._capability_process is None - ) - - def run_selected_command_readiness_check(self) -> None: - preset = self._current_preset - if preset is None: - raise CommandCatalogError("Cannot run command readiness without a selected preset") - if not preset.requires_device: - raise CommandCatalogError("The selected preset does not require a device readiness check") - if self.selected_device() is None: - self._show_no_device() - return - self.navigate_to_page("Capability Matrix") - self.refresh_capability_matrix() - - def _update_advanced_safety_note(self) -> None: - raw_arguments = self.console_input.text().strip() - if not raw_arguments: - self.advanced_safety_note.setText( - "Advanced commands are classified before execution. State-changing commands require a typed acknowledgement." - ) - return - try: - arguments = tuple(shlex.split(raw_arguments)) - except ValueError as error: - self.advanced_safety_note.setText(f"Correct command quoting before safety classification: {error}") - return - if arguments and Path(arguments[0]).name == "pymobiledevice3": - arguments = arguments[1:] - if not arguments: - self.advanced_safety_note.setText("Enter pymobiledevice3 arguments to classify the action.") - return - profile = advanced_action_safety(arguments) - acknowledgement = "typed acknowledgement required" if profile.requires_typed_acknowledgement else "review confirmation required" - self.advanced_safety_note.setText( - f"Safety: {profile.level.replace('-', ' ')} — {profile.impact} {acknowledgement.capitalize()}." - ) - - def run_selected_preset(self) -> None: - preset = self._current_preset - if preset is None: - QMessageBox.information(self, "No Preset", "Choose a guided command first.") - return - try: - arguments = render_preset_arguments(preset, self._preset_values()) - except CommandCatalogError as error: - QMessageBox.critical(self, "Invalid Preset Value", str(error)) - return - if preset.requires_device and self.selected_device() is None: - self._show_no_device() - return - profile = guided_action_safety(preset.risk) - if profile.level != "read-only": - warning = ( - f"Run {preset.title}?\n\n{profile.impact}\n\n" - f"pymobiledevice3 {shlex.join(arguments)}\n\n" - "Review the selected target and destination before continuing." - ) - device = self.selected_device() - if not self._confirm_action("Confirm Guided Command", warning, profile, device.identifier if device else None): - return - self._run_console_arguments(arguments, preset.title, preset.requires_device, profile) - - def open_selected_preset_help(self) -> None: - preset = self._current_preset - if preset is None: - return - self.navigate_to_page("Man Pages") - self.select_manpage_path(preset.manpage_path) - - def run_console_command(self) -> None: - if self._console_controller.is_running(): - QMessageBox.warning(self, "Command Running", "Stop the active console command first.") - return - try: - parsed = tuple(shlex.split(self.console_input.text())) - except ValueError as error: - QMessageBox.critical(self, "Invalid Command", str(error)) - return - if parsed and Path(parsed[0]).name == "pymobiledevice3": - parsed = parsed[1:] - if not parsed: - QMessageBox.information(self, "No Command", "Enter pymobiledevice3 arguments to run.") - return - requires_device = not self._advanced_command_can_run_without_device(parsed) - if requires_device and self.selected_device() is None: - self._show_no_device() - return - profile = advanced_action_safety(parsed) - if profile.level != "read-only": - warning = ( - f"{profile.impact}\n\n" - f"pymobiledevice3 {shlex.join(parsed)}\n\n" - "The exact command above will run without a shell. Review its target and local output path before continuing." - ) - device = self.selected_device() - if not self._confirm_action("Confirm Advanced Command", warning, profile, device.identifier if device else None): - return - self._run_console_arguments(parsed, "Advanced command", requires_device, profile) - - def _run_console_arguments( - self, - arguments: tuple[str, ...], - title: str, - requires_device: bool, - profile: ActionSafetyProfile, - ) -> None: - if self._console_controller.is_running(): - QMessageBox.warning(self, "Command Running", "Stop the active console command first.") - return - device = self.selected_device() - if requires_device and device is None: - self._show_no_device() - return - approval = "" if profile.level == "read-only" else f"\n[safety approval: {profile.level}; acknowledgement accepted]" - self.console_output.appendPlainText(f"\n[{title}]{approval}\n$ pymobiledevice3 {shlex.join(arguments)}\n") - environment = base_environment() if device is None else device_environment(device.identifier) - history_context = ( - self._host_operation_context(title, "Command Center", "pymobiledevice3 host command", ()) - if device is None - else self._device_operation_context( - title, - "Command Center", - "pymobiledevice3 selected-device transport", - device, - (), - ) - ) - self._begin_operation("command-center", history_context) - self._console_controller.start( - self._pmd3, - arguments, - environment, - Path.home(), - PROCESS_TERMINATE_GRACE_MS, - ) - self._update_command_controls() - - def _advanced_command_can_run_without_device(self, arguments: tuple[str, ...]) -> bool: - prefixes = ( - ("usbmux", "list"), - ("bonjour",), - ("remote", "browse"), - ("version",), - ) - return any(arguments[: len(prefix)] == prefix for prefix in prefixes) - - def _append_console_output(self, output: bytes) -> None: - self.console_output.moveCursor(QTextCursor.MoveOperation.End) - self.console_output.insertPlainText(output.decode("utf-8", errors="replace")) - - def _console_completed(self, result_object: object) -> None: - if not isinstance(result_object, OperationResult): - raise TypeError(f"Expected OperationResult, received {type(result_object).__name__}") - self._complete_operation("command-center", result_object) - exit_label = "not available" if result_object.exit_code is None else str(result_object.exit_code) - self.console_output.appendPlainText(f"\n[finished: {result_object.outcome}; exit {exit_label}]") - if result_object.error_message: - self.console_output.appendPlainText(f"Process error: {result_object.error_message}") - self._update_command_controls() - - - def stop_console_command(self) -> None: - if self._console_controller.is_running(): - self.console_output.appendPlainText("\nRequesting command stop…") - self._console_controller.cancel() - - def start_command_drift_check(self) -> None: - if self._command_drift_session_active: - QMessageBox.information(self, "Command Drift Check", "The live-help drift check is already running.") - return - self._command_drift_paths = help_routes_for_presets(self._presets) - self._command_drift_index = 0 - self._command_drift_active_path = None - self._command_drift_session_active = True - self._command_drift_cancelled = False - self._command_drift_probes = {} - self.command_drift_output.clear() - self.command_drift_status.setText( - f"Checking {len(self._command_drift_paths)} live-help routes without contacting a device…" - ) - self._update_command_drift_controls() - self._start_next_command_drift_probe() - - def _start_next_command_drift_probe(self) -> None: - if self._command_drift_cancelled: - self._finish_command_drift_check() - return - if self._command_drift_index >= len(self._command_drift_paths): - self._finish_command_drift_check() - return - command_path = self._command_drift_paths[self._command_drift_index] - self._command_drift_active_path = command_path - self.command_drift_status.setText( - f"Checking {self._command_drift_index + 1}/{len(self._command_drift_paths)}: " - f"pymobiledevice3 {shlex.join(command_path)} --help" - ) - self._command_drift_controller.start( - finite_process_request( - self._pmd3, - (*command_path, "--help"), - base_environment(), - COMMAND_DRIFT_HELP_TIMEOUT_MS, - PROCESS_TERMINATE_GRACE_MS, - ) - ) - self._update_command_drift_controls() - - def _command_drift_probe_completed(self, result_object: object) -> None: - if not isinstance(result_object, OperationResult): - raise TypeError(f"Expected OperationResult, received {type(result_object).__name__}") - command_path = self._command_drift_active_path - if command_path is None: - raise CommandCatalogError("Live-help drift probe completed without an active command path") - if result_object.outcome == "timed-out": - error = f"Live help exceeded the {COMMAND_DRIFT_HELP_TIMEOUT_MS // 1000}-second per-route limit." - elif result_object.outcome == "cancelled": - error = "Live-help drift check was cancelled by the user." - elif result_object.outcome == "launch-failed": - detail = result_object.error_message or "the operating system did not provide an error" - error = f"Could not start live help: {detail}" - elif result_object.outcome == "crashed": - error = "Live help terminated unexpectedly before returning a complete result." - else: - error = None - self._command_drift_probes[command_path] = HelpRouteProbe( - command_path, - result_object.exit_code, - result_object.stdout.decode("utf-8", errors="replace"), - result_object.stderr.decode("utf-8", errors="replace"), - error, - ) - self._command_drift_active_path = None - self._command_drift_index += 1 - self._update_command_drift_controls() - QTimer.singleShot(0, self._start_next_command_drift_probe) - - def cancel_command_drift_check(self) -> None: - if not self._command_drift_session_active: - return - self._command_drift_cancelled = True - self.command_drift_status.setText("Cancelling the current live-help check…") - self._command_drift_controller.cancel() - - def _finish_command_drift_check(self) -> None: - self._command_drift_session_active = False - results = evaluate_command_drift(self._presets, tuple(self._command_drift_probes.values())) - report = render_command_drift_report(results) - self.command_drift_output.setPlainText(report) - checked_count = len(self._command_drift_probes) - if self._command_drift_cancelled: - self.command_drift_status.setText( - f"Cancelled after checking {checked_count}/{len(self._command_drift_paths)} live-help routes." - ) - else: - issue_count = sum(result.state != "verified" for result in results) - self.command_drift_status.setText( - f"Completed {checked_count} live-help routes; {issue_count} preset result(s) need review." - ) - self._update_command_drift_controls() - - def _update_command_drift_controls(self) -> None: - running = self._command_drift_session_active - self.command_drift_check_button.setEnabled(not running) - self.command_drift_cancel_button.setEnabled(running) - self.command_drift_copy_button.setEnabled(bool(self.command_drift_output.toPlainText().strip()) and not running) - - def copy_command_drift_report(self) -> None: - report = self.command_drift_output.toPlainText().strip() - if report: - QApplication.clipboard().setText(report) - - def _filter_manpages(self) -> None: - selected_path = self.selected_manpage_entry().command_path if self.selected_manpage_entry() is not None else None - query = self.manpage_search_field.text().strip().casefold() - matching = tuple( - (index, entry) - for index, entry in enumerate(self._manpages) - if not query - or query in entry.title.casefold() - or query in entry.category.casefold() - or query in " ".join(entry.command_path).casefold() - ) - self.manpage_list.blockSignals(True) - self.manpage_list.clear() - selected_row = 0 - for row, (source_index, entry) in enumerate(matching): - item = QListWidgetItem(entry.display_name()) - item.setData(Qt.ItemDataRole.UserRole, source_index) - item.setToolTip(entry.category) - self.manpage_list.addItem(item) - if entry.command_path == selected_path: - selected_row = row - if matching: - self.manpage_list.setCurrentRow(selected_row) - self.manpage_list.blockSignals(False) - if matching: - self._show_manpage_entry(matching[selected_row][1]) - - def selected_manpage_entry(self) -> ManPageEntry | None: - item = self.manpage_list.currentItem() - if item is None: - return None - index = item.data(Qt.ItemDataRole.UserRole) - if not isinstance(index, int) or index < 0 or index >= len(self._manpages): - raise CommandCatalogError("selected man page has an invalid source index") - return self._manpages[index] - - def _manpage_selected( - self, - current: QListWidgetItem | None, - previous: QListWidgetItem | None, - ) -> None: - del current, previous - entry = self.selected_manpage_entry() - if entry is not None: - self._show_manpage_entry(entry) - - def _show_manpage_entry(self, entry: ManPageEntry) -> None: - self.manpage_title.setText(entry.title) - prefix = "pymobiledevice3" if not entry.command_path else f"pymobiledevice3 {shlex.join(entry.command_path)}" - self.manpage_command.setText(prefix) - cached_help = self._manpage_cache.get(entry.command_path) - if cached_help is not None: - self.manpage_output.setPlainText(cached_help) - elif not self._manpage_controller.is_running(): - self.manpage_output.setPlainText( - f"{entry.title}\n\nCommand prefix: {prefix}\nCategory: {entry.category}\n\n" - "Click Refresh Live Help to query the installed pymobiledevice3 executable. Selection alone never " - "contacts a device. Loading can be cancelled and is stopped automatically after 15 seconds." - ) - - def select_manpage_path(self, command_path: tuple[str, ...]) -> None: - matching_index = next( - (index for index, entry in enumerate(self._manpages) if entry.command_path == command_path), - None, - ) - if matching_index is None: - broader_paths = tuple( - entry.command_path - for entry in self._manpages - if command_path[: len(entry.command_path)] == entry.command_path - ) - if not broader_paths: - raise CommandCatalogError(f"No man page entry covers command path: {command_path}") - best_path = max(broader_paths, key=len) - matching_index = next( - index for index, entry in enumerate(self._manpages) if entry.command_path == best_path - ) - self.manpage_search_field.clear() - for row in range(self.manpage_list.count()): - item = self.manpage_list.item(row) - if item.data(Qt.ItemDataRole.UserRole) == matching_index: - self.manpage_list.setCurrentRow(row) - return - raise CommandCatalogError(f"Man page path was indexed but not visible: {command_path}") - - def refresh_selected_manpage(self) -> None: - entry = self.selected_manpage_entry() - if entry is None: - QMessageBox.information(self, "No Help Topic", "Select a command family first.") - return - if self._manpage_controller.is_running(): - QMessageBox.warning(self, "Help Loading", "Wait for the current help page to finish loading.") - return - self._manpage_active_path = entry.command_path - self.manpage_output.setPlainText( - "Loading live help from the installed pymobiledevice3…\n\n" - "This can take several seconds on the first Python import. Use Cancel Loading to stop immediately." - ) - self._begin_operation( - "manpage", - self._host_operation_context( - f"Load Live Help: {entry.display_name()}", - "Man Pages", - "pymobiledevice3 host help route", - (), - ), - ) - self._manpage_controller.start( - finite_process_request( - self._pmd3, - (*entry.command_path, "--help"), - base_environment(), - MANPAGE_HELP_TIMEOUT_MS, - MANPAGE_HELP_KILL_DELAY_MS, - ) - ) - self._update_manpage_controls() - - def _manpage_completed(self, result_object: object) -> None: - if not isinstance(result_object, OperationResult): - raise TypeError(f"Expected OperationResult, received {type(result_object).__name__}") - self._complete_operation("manpage", result_object) - stdout = result_object.stdout.decode("utf-8", errors="replace") - stderr = result_object.stderr.decode("utf-8", errors="replace") - output = stdout or stderr - if result_object.outcome == "cancelled": - suffix = f"\n\nPartial output:\n{output}" if output else "" - self.manpage_output.setPlainText(f"Live help loading was cancelled by the user.{suffix}") - elif result_object.outcome == "timed-out": - suffix = f"\n\nPartial output:\n{output}" if output else "" - self.manpage_output.setPlainText( - "Live help exceeded the 15-second limit and was stopped. The installed CLI did not return " - f"promptly; the Man Pages browser remains available.{suffix}" - ) - elif result_object.outcome == "succeeded" and output: - if self._manpage_active_path is None: - raise CommandCatalogError("Live help completed without an active command path") - self._manpage_cache[self._manpage_active_path] = output - self.manpage_output.setPlainText(output) - elif result_object.outcome == "launch-failed": - error_detail = result_object.error_message or "QProcess did not provide an operating-system error" - self.manpage_output.setPlainText( - "Could not start live help. Verify the project runtime exists and is executable:\n" - f"{result_object.argv[0]}\n\nSystem error: {error_detail}" - ) - else: - exit_detail = "unavailable" if result_object.exit_code is None else str(result_object.exit_code) - self.manpage_output.setPlainText( - f"Live help {result_object.outcome.replace('-', ' ')} with exit code {exit_detail}.\n\n{output}" - ) - self._manpage_active_path = None - self._update_manpage_controls() - - def cancel_manpage_load(self) -> None: - if not self._manpage_controller.is_running(): - return - self.manpage_output.setPlainText("Live help cancellation requested.\n\nStopping the help process…") - self._manpage_controller.cancel() - - def _update_manpage_controls(self) -> None: - running = self._manpage_controller.is_running() - selected = self.selected_manpage_entry() is not None - self.manpage_list.setEnabled(not running) - self.manpage_search_field.setEnabled(not running) - self.refresh_manpage_button.setEnabled(selected and not running) - self.cancel_manpage_button.setEnabled(running) - self.copy_manpage_command_button.setEnabled(selected and not running) - self.use_manpage_command_button.setEnabled(selected and not running) - - def copy_selected_manpage_command(self) -> None: - if not self.manpage_command.text(): - return - QApplication.clipboard().setText(self.manpage_command.text()) - - def use_selected_manpage_command(self) -> None: - entry = self.selected_manpage_entry() - if entry is None: - return - self.console_input.setText(shlex.join(entry.command_path)) - self.navigate_to_page("Command Center") - - def _confirm(self, title: str, message: str) -> bool: - answer = QMessageBox.question( - self, - title, - message, - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - return answer == QMessageBox.StandardButton.Yes - - def _confirm_action( - self, - title: str, - message: str, - profile: ActionSafetyProfile, - device_identifier: str | None, - ) -> bool: - if not profile.requires_typed_acknowledgement: - return self._confirm(title, message) - phrase = confirmation_phrase(profile, device_identifier) - dialog = QDialog(self) - dialog.setObjectName("actionSafetyConfirmationDialog") - dialog.setWindowTitle(title) - dialog.setMinimumWidth(560) - layout = QVBoxLayout(dialog) - warning = QLabel(message) - warning.setWordWrap(True) - layout.addWidget(warning) - typed_instruction = QLabel( - f"Type {phrase} exactly to authorize this action. The acknowledgement phrase is not retained." - ) - typed_instruction.setWordWrap(True) - layout.addWidget(typed_instruction) - acknowledgement_field = QLineEdit() - acknowledgement_field.setObjectName("actionSafetyAcknowledgement") - acknowledgement_field.setPlaceholderText(phrase) - layout.addWidget(acknowledgement_field) - backup_acknowledgement: QCheckBox | None = None - if profile.requires_backup_acknowledgement: - backup_acknowledgement = QCheckBox( - "I have current verified backup coverage and exact authorization for this high-impact action." - ) - backup_acknowledgement.setObjectName("highImpactBackupAcknowledgement") - backup_acknowledgement.setWordWrap(True) - layout.addWidget(backup_acknowledgement) - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok) - buttons.setObjectName("actionSafetyConfirmationButtons") - approve_button = buttons.button(QDialogButtonBox.StandardButton.Ok) - if approve_button is None: - raise RuntimeError("Action safety confirmation dialog is missing its approval button") - approve_button.setText("Authorize Action") - approve_button.setEnabled(False) - - def update_approval_button(value: str) -> None: - backup_acknowledged = backup_acknowledgement is None or backup_acknowledgement.isChecked() - approve_button.setEnabled(value == phrase and backup_acknowledged) - - def update_backup_acknowledgement(checked: bool) -> None: - del checked - update_approval_button(acknowledgement_field.text()) - - acknowledgement_field.textChanged.connect(update_approval_button) - if backup_acknowledgement is not None: - backup_acknowledgement.toggled.connect(update_backup_acknowledgement) - buttons.accepted.connect(dialog.accept) - buttons.rejected.connect(dialog.reject) - layout.addWidget(buttons) - return dialog.exec() == QDialog.DialogCode.Accepted - - def _record_action_approval( - self, - output: QPlainTextEdit, - title: str, - profile: ActionSafetyProfile, - ) -> None: - output.appendPlainText(f"[safety approval: {profile.level}; acknowledgement accepted for {title}]") - - def _show_no_device(self) -> None: - QMessageBox.warning(self, "No Device", "Connect, unlock, and trust an iPhone or iPad first.") - - def _prepare_location_for_close(self) -> bool: - active = self._location_process is not None - if not active and not self._location_may_be_simulated: - return True - message = QMessageBox(self) - message.setIcon(QMessageBox.Icon.Warning) - message.setWindowTitle("Location Cleanup Before Closing") - message.setText("Location Lab may still be changing the tracked device's reported location.") - message.setInformativeText( - "Stop the active process and request Clear before closing. Choose Close Without Clearing only when you " - "intentionally want the simulated location to remain or the device is unavailable." - ) - clear_button = message.addButton("Stop, Clear && Close", QMessageBox.ButtonRole.AcceptRole) - close_button = message.addButton("Close Without Clearing", QMessageBox.ButtonRole.DestructiveRole) - cancel_button = message.addButton(QMessageBox.StandardButton.Cancel) - message.setDefaultButton(cancel_button) - message.exec() - clicked = message.clickedButton() - if clicked == cancel_button: - return False - if clicked == close_button: - return True - if clicked != clear_button: - raise RuntimeError("Location cleanup dialog returned an unknown button") - return self._clear_location_synchronously_for_close() - - def _clear_location_synchronously_for_close(self) -> bool: - target = self._tracked_or_selected_location_target() - if target is None: - QMessageBox.critical( - self, - "Could Not Clear Location", - "No tracked or selected device is available for the cleanup request.", - ) - return False - identifier, name, version = target - process = self._location_process - if process is not None and process.state() != QProcess.ProcessState.NotRunning: - self._location_clear_after_stop = False - process.terminate() - if not process.waitForFinished(5000): - process.kill() - process.waitForFinished(3000) - self._location_process = None - try: - arguments = clear_location_arguments(version) - self._record_location_event( - "clear-on-close", - "requested", - identifier, - name, - version, - arguments, - None, - None, - None, - "The user chose Stop, Clear & Close.", - ) - except LocationLabError as error: - QMessageBox.critical(self, "Could Not Prepare Location Cleanup", str(error)) - return False - last_detail = "" - final_exit_code: int | None = None - for attempt in (1, 2): - try: - completed = subprocess.run( - command_argv(self._pmd3, arguments), - env=device_environment(identifier), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - check=False, - timeout=30, - ) - except (OSError, subprocess.SubprocessError) as error: - last_detail = f"Cleanup attempt {attempt} could not run: {error}" - final_exit_code = None - else: - final_exit_code = completed.returncode - output = completed.stdout.strip() - if output: - self.location_output.appendPlainText(f"\n[close cleanup attempt {attempt}]\n{output}") - if completed.returncode == 0 and not output_indicates_failure(completed.stdout): - try: - self._record_location_event( - "clear-on-close", - "completed", - identifier, - name, - version, - arguments, - None, - None, - completed.returncode, - f"Location clear completed on attempt {attempt} before application close.", - ) - except LocationLabError as error: - QMessageBox.critical(self, "Could Not Finalize Location Evidence", str(error)) - return False - self._location_may_be_simulated = False - self._location_device_identifier = None - self.location_state_value.setText("Location clear completed before close.") - return True - last_detail = ( - f"Cleanup attempt {attempt} exited {completed.returncode}: " - f"{output or 'no diagnostic output'}" - ) - if attempt == 1: - self.location_output.appendPlainText(f"\nWarning: {last_detail}\nRetrying location clear once…") - try: - self._record_location_event( - "clear-on-close", - "failed", - identifier, - name, - version, - arguments, - None, - None, - final_exit_code, - last_detail, - ) - except LocationLabError as error: - self.location_output.appendPlainText(f"Evidence log error: {error}") - QMessageBox.critical( - self, - "Location Cleanup Failed", - f"The toolkit did not confirm a successful clear after two attempts.\n\n{last_detail}\n\n" - "The application will remain open. Reconnect the tracked device and use Clear, or explicitly choose " - "Close Without Clearing.", - ) - return False - - def closeEvent(self, event: QCloseEvent) -> None: - if not self._prepare_location_for_close(): - event.ignore() - return - for window in tuple(self._live_log_windows): - window.close() - if window.isVisible(): - event.ignore() - return - action_running = self._action_controller.is_running() - apps_running = self._apps_controller.is_running() - ipa_inspection_running = self._ipa_inspection_controller.is_running() - sideload_running = self._sideload_controller.is_running() - backup_running = self._backup_controller.is_running() - collection_running = self._collection_controller.is_running() - mvt_running = self._mvt_controller.is_running() - external_tool_running = self._external_tool_controller.is_running() - critical_processes = tuple( - process - for process in (self._location_process,) - if process is not None and process.state() != QProcess.ProcessState.NotRunning - ) - active_operations = ( - action_running - or apps_running - or ipa_inspection_running - or sideload_running - or backup_running - or collection_running - or mvt_running - or external_tool_running - or self._console_controller.is_running() - or critical_processes - ) - if active_operations and not self._close_after_collection: - should_close = self._confirm( - "Stop Active Operations?", - "A DDI, evidence, app, backup, MVT, ecosystem-tool, Location Lab, or Command Center operation is still running. " - "Stop it, allow cleanup/finalization, and close the app?", - ) - if not should_close: - event.ignore() - return - if collection_running: - if not self._close_after_collection: - self._close_after_collection = True - self.case_status.setText( - "Closing is waiting for evidence finalization. The collector has up to two minutes to write its manifest and hashes." - ) - self.stop_collection() - event.ignore() - return - self._scanner.stop() - self._reconnect_timeout_timer.stop() - self._manpage_controller.shutdown(3000, 1000) - self._command_drift_controller.shutdown(3000, 1000) - self._console_controller.shutdown(10000, 3000) - self._action_controller.shutdown(10000, 3000) - self._apps_controller.shutdown(10000, 3000) - self._ipa_inspection_controller.shutdown(10000, 3000) - self._sideload_controller.shutdown(10000, 3000) - self._backup_controller.shutdown(10000, 3000) - self._mvt_controller.shutdown(10000, 3000) - self._clear_mvt_temporary_config() - self._external_tool_controller.shutdown(10000, 3000) - self._collection_controller.shutdown(10000, 3000) - capability_process = self._capability_process - if capability_process is not None and capability_process.state() != QProcess.ProcessState.NotRunning: - self._terminate_capability_children(capability_process) - capability_process.terminate() - if not capability_process.waitForFinished(2500): - capability_process.kill() - capability_process.waitForFinished(1000) - self._capability_process = None - for process in critical_processes: - process.terminate() - if not process.waitForFinished(10000): - process.kill() - process.waitForFinished(3000) - event.accept() - - -def main() -> int: - application = QApplication(sys.argv) - application.setApplicationName("iOS Developer Toolkit") - application.setOrganizationName("hideouts.io") - application_icon = QIcon(str(application_icon_path())) - if application_icon.isNull(): - raise RuntimeError(f"Could not load application icon: {application_icon_path()}") - application.setWindowIcon(application_icon) - window = MainWindow() - window.show() - window.raise_() - window.activateWindow() - return application.exec() - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/ios_developer_toolkit/backup_process.py b/ios_developer_toolkit/backup_process.py deleted file mode 100644 index 650c51f..0000000 --- a/ios_developer_toolkit/backup_process.py +++ /dev/null @@ -1,234 +0,0 @@ -from __future__ import annotations - -import json -from datetime import datetime, timezone -from typing import Literal, Mapping - -from PySide6.QtCore import QObject, QProcess, QProcessEnvironment, QTimer, Signal - -from ios_developer_toolkit.backup_protocol import ( - BackupAction, - BackupEvent, - BackupRequest, - BackupRequestError, - parse_backup_event, - serialize_backup_request, -) -from ios_developer_toolkit.qt_process import OperationResult, ProcessOutcome -from ios_developer_toolkit.runtime import ExecutableCommand, command_arguments, command_argv - - -class BackupProcessController(QObject): - """Own one backup worker while keeping credentials out of process arguments.""" - - event_received = Signal(object) - stderr_received = Signal(bytes) - completed = Signal(object) - - def __init__(self, parent: QObject) -> None: - super().__init__(parent) - self._process: QProcess | None = None - self._command: ExecutableCommand | None = None - self._arguments: tuple[str, ...] = () - self._terminate_grace_milliseconds = 0 - self._request_payload = b"" - self._stdout = bytearray() - self._stdout_line = bytearray() - self._stderr = bytearray() - self._started_at = "" - self._error_message: str | None = None - self._stop_outcome: Literal["cancelled"] | None = None - self._protocol_failed = False - self._completed = False - self._kill_timer = QTimer(self) - self._kill_timer.setSingleShot(True) - self._kill_timer.timeout.connect(self._kill) - - def is_running(self) -> bool: - return self._process is not None - - def start( - self, - command: ExecutableCommand, - action: BackupAction, - request: BackupRequest, - environment: Mapping[str, str], - terminate_grace_milliseconds: int, - ) -> None: - if self.is_running(): - raise RuntimeError("Cannot start a backup process while another backup process is running") - if action not in ("status", "backup"): - raise ValueError(f"Unsupported backup action: {action}") - if terminate_grace_milliseconds <= 0: - raise ValueError(f"Backup termination grace period must be positive: {terminate_grace_milliseconds}") - self._command = command - self._arguments = (action,) - self._terminate_grace_milliseconds = terminate_grace_milliseconds - self._request_payload = serialize_backup_request(request) - self._stdout.clear() - self._stdout_line.clear() - self._stderr.clear() - self._started_at = datetime.now(timezone.utc).isoformat() - self._error_message = None - self._stop_outcome = None - self._protocol_failed = False - self._completed = False - - process = QProcess(self) - process.setProgram(str(command.program)) - process.setArguments(list(command_arguments(command, self._arguments))) - process_environment = QProcessEnvironment.systemEnvironment() - for key, value in sorted(environment.items()): - process_environment.insert(key, value) - process.setProcessEnvironment(process_environment) - process.started.connect(self._write_request) - process.readyReadStandardOutput.connect(self._drain_output) - process.readyReadStandardError.connect(self._drain_output) - process.errorOccurred.connect(self._process_error) - process.finished.connect(self._finished) - self._process = process - process.start() - - def cancel(self) -> None: - process = self._process - if process is None or process.state() == QProcess.ProcessState.NotRunning: - return - self._stop_outcome = "cancelled" - self._terminate() - - def shutdown(self, terminate_timeout_milliseconds: int, kill_timeout_milliseconds: int) -> None: - if terminate_timeout_milliseconds <= 0: - raise ValueError(f"Shutdown termination timeout must be positive: {terminate_timeout_milliseconds}") - if kill_timeout_milliseconds <= 0: - raise ValueError(f"Shutdown kill timeout must be positive: {kill_timeout_milliseconds}") - process = self._process - if process is None: - return - if process.state() == QProcess.ProcessState.NotRunning: - self._finish_once("cancelled", process.exitCode()) - return - self._stop_outcome = "cancelled" - self._kill_timer.stop() - process.terminate() - if not process.waitForFinished(terminate_timeout_milliseconds): - process.kill() - if not process.waitForFinished(kill_timeout_milliseconds): - raise RuntimeError(f"Backup process did not stop after terminate and kill: {process.program()}") - - def _write_request(self) -> None: - process = self._process - if process is None: - raise RuntimeError("Backup process started without an active process") - accepted_bytes = process.write(self._request_payload) - if accepted_bytes != len(self._request_payload): - self._protocol_failure( - f"Backup helper accepted {accepted_bytes} of {len(self._request_payload)} request bytes" - ) - return - process.closeWriteChannel() - self._request_payload = b"" - - def _drain_output(self) -> None: - process = self._process - if process is None: - return - stdout = bytes(process.readAllStandardOutput()) - stderr = bytes(process.readAllStandardError()) - if stdout: - self._stdout.extend(stdout) - self._stdout_line.extend(stdout) - self._consume_complete_lines() - if stderr: - self._stderr.extend(stderr) - self.stderr_received.emit(stderr) - - def _consume_complete_lines(self) -> None: - while b"\n" in self._stdout_line and not self._protocol_failed: - line, _, remainder = self._stdout_line.partition(b"\n") - self._stdout_line = bytearray(remainder) - if line.strip(): - self._consume_event_line(line) - - def _consume_event_line(self, line: bytes) -> None: - try: - event = parse_backup_event(line.decode("utf-8")) - except (BackupRequestError, json.JSONDecodeError, UnicodeDecodeError) as error: - self._protocol_failure(f"Invalid backup helper event: {error}") - return - self.event_received.emit(event) - - def _protocol_failure(self, message: str) -> None: - if self._protocol_failed: - return - self._protocol_failed = True - self._error_message = message - process = self._process - if process is not None and process.state() != QProcess.ProcessState.NotRunning: - self._terminate() - - def _process_error(self, process_error: QProcess.ProcessError) -> None: - process = self._process - if process is None: - raise RuntimeError("Backup process reported an error without an active process") - if self._error_message is None: - self._error_message = process.errorString() - if process_error == QProcess.ProcessError.FailedToStart: - self._finish_once("launch-failed", None) - - def _finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: - self._drain_output() - if self._stdout_line.strip() and not self._protocol_failed: - line = bytes(self._stdout_line) - self._stdout_line.clear() - self._consume_event_line(line) - if self._stop_outcome is not None: - outcome: ProcessOutcome = self._stop_outcome - elif self._protocol_failed: - outcome = "failed" - elif exit_status == QProcess.ExitStatus.CrashExit: - outcome = "crashed" - elif exit_code == 0: - outcome = "succeeded" - else: - outcome = "failed" - self._finish_once(outcome, exit_code) - - def _terminate(self) -> None: - process = self._process - if process is None: - raise RuntimeError("Cannot terminate a backup process without an active process") - if self._terminate_grace_milliseconds <= 0: - raise RuntimeError("Backup process has no valid termination grace period") - process.terminate() - self._kill_timer.start(self._terminate_grace_milliseconds) - - def _kill(self) -> None: - process = self._process - if process is not None and process.state() != QProcess.ProcessState.NotRunning: - process.kill() - - def _finish_once(self, outcome: ProcessOutcome, exit_code: int | None) -> None: - if self._completed: - return - command = self._command - if command is None: - raise RuntimeError("Backup process completed without a command") - self._drain_output() - self._completed = True - self._kill_timer.stop() - self._request_payload = b"" - result = OperationResult( - command_argv(command, self._arguments), - outcome, - self._started_at, - datetime.now(timezone.utc).isoformat(), - exit_code, - self._error_message, - bytes(self._stdout), - bytes(self._stderr), - ) - process = self._process - self._process = None - if process is not None: - process.deleteLater() - self.completed.emit(result) diff --git a/ios_developer_toolkit/backup_protocol.py b/ios_developer_toolkit/backup_protocol.py deleted file mode 100644 index 510354f..0000000 --- a/ios_developer_toolkit/backup_protocol.py +++ /dev/null @@ -1,122 +0,0 @@ -from __future__ import annotations - -import json -from dataclasses import dataclass -from pathlib import Path -from typing import Literal - - -BackupAction = Literal["status", "backup"] - - -class BackupRequestError(ValueError): - """Raised when a backup-worker request or event has an invalid schema.""" - - -@dataclass(frozen=True) -class BackupRequest: - udid: str - destination: Path - require_encryption: bool - new_password: str - full: bool - - -@dataclass(frozen=True) -class BackupEvent: - event: str - message: str - percent: int | None - encrypted: bool | None - path: Path | None - - -def serialize_backup_request(request: BackupRequest) -> bytes: - if not isinstance(request.udid, str) or not request.udid.strip(): - raise BackupRequestError("udid must be a non-empty string") - if not isinstance(request.destination, Path): - raise BackupRequestError("destination must be a Path") - if not request.destination.is_absolute(): - raise BackupRequestError("destination must be an absolute path") - if not isinstance(request.require_encryption, bool): - raise BackupRequestError("require_encryption must be a boolean") - if not isinstance(request.new_password, str): - raise BackupRequestError("new_password must be a string") - if not isinstance(request.full, bool): - raise BackupRequestError("full must be a boolean") - return json.dumps( - { - "udid": request.udid, - "destination": str(request.destination), - "require_encryption": request.require_encryption, - "new_password": request.new_password, - "full": request.full, - }, - separators=(",", ":"), - sort_keys=True, - ).encode("utf-8") - - -def required_string(value: object, field_name: str) -> str: - if not isinstance(value, str) or not value.strip(): - raise BackupRequestError(f"{field_name} must be a non-empty string") - return value.strip() - - -def required_boolean(value: object, field_name: str) -> bool: - if not isinstance(value, bool): - raise BackupRequestError(f"{field_name} must be a boolean") - return value - - -def optional_integer(value: object, field_name: str) -> int | None: - if value is None: - return None - if not isinstance(value, int) or isinstance(value, bool): - raise BackupRequestError(f"{field_name} must be an integer when present") - return value - - -def optional_boolean(value: object, field_name: str) -> bool | None: - if value is None: - return None - if not isinstance(value, bool): - raise BackupRequestError(f"{field_name} must be a boolean when present") - return value - - -def parse_backup_request(payload: str) -> BackupRequest: - raw: object = json.loads(payload) - if not isinstance(raw, dict): - raise BackupRequestError("backup request must be a JSON object") - request: dict[object, object] = raw - destination = Path(required_string(request.get("destination"), "destination")).expanduser() - if not destination.is_absolute(): - raise BackupRequestError("destination must be an absolute path") - password_value = request.get("new_password") - if not isinstance(password_value, str): - raise BackupRequestError("new_password must be a string") - return BackupRequest( - udid=required_string(request.get("udid"), "udid"), - destination=destination.resolve(), - require_encryption=required_boolean(request.get("require_encryption"), "require_encryption"), - new_password=password_value, - full=required_boolean(request.get("full"), "full"), - ) - - -def parse_backup_event(payload: str) -> BackupEvent: - raw: object = json.loads(payload) - if not isinstance(raw, dict): - raise BackupRequestError("backup event must be a JSON object") - event: dict[object, object] = raw - path_value = event.get("path") - if path_value is not None and not isinstance(path_value, str): - raise BackupRequestError("path must be a string when present") - return BackupEvent( - event=required_string(event.get("event"), "event"), - message=required_string(event.get("message"), "message"), - percent=optional_integer(event.get("percent"), "percent"), - encrypted=optional_boolean(event.get("encrypted"), "encrypted"), - path=Path(path_value) if isinstance(path_value, str) else None, - ) diff --git a/ios_developer_toolkit/backup_worker.py b/ios_developer_toolkit/backup_worker.py deleted file mode 100644 index ca0fce1..0000000 --- a/ios_developer_toolkit/backup_worker.py +++ /dev/null @@ -1,138 +0,0 @@ -from __future__ import annotations - -import argparse -import asyncio -import json -import sys -from typing import TYPE_CHECKING, TextIO - -from ios_developer_toolkit.backup_protocol import BackupAction, BackupRequest, BackupRequestError, parse_backup_request - -if TYPE_CHECKING: - from pymobiledevice3.lockdown import LockdownClient - - -def emit_event( - event: str, - message: str, - percent: int | None, - encrypted: bool | None, - path: Path | None, -) -> None: - record: dict[str, str | int | bool] = {"event": event, "message": message} - if percent is not None: - record["percent"] = percent - if encrypted is not None: - record["encrypted"] = encrypted - if path is not None: - record["path"] = str(path) - print(json.dumps(record, sort_keys=True), flush=True) - - -async def read_encryption_state(lockdown: LockdownClient) -> bool: - value: object = await lockdown.get_value("com.apple.mobile.backup", "WillEncrypt") - if not isinstance(value, bool): - raise BackupRequestError( - "the device did not return a boolean com.apple.mobile.backup/WillEncrypt value" - ) - return value - - -async def execute_request(action: BackupAction, request: BackupRequest) -> None: - from pymobiledevice3.lockdown import create_using_usbmux - from pymobiledevice3.services.mobilebackup2 import Mobilebackup2Service - - lockdown = await create_using_usbmux(serial=request.udid) - try: - async with Mobilebackup2Service(lockdown) as backup_client: - encrypted = await read_encryption_state(lockdown) - encryption_enabled_now = False - if action == "status": - state = "enabled" if encrypted else "disabled" - emit_event("encryption-state", f"Backup encryption is {state} on this device.", None, encrypted, None) - return - - request.destination.mkdir(parents=True, exist_ok=True) - if request.require_encryption and not encrypted: - if not request.new_password: - raise BackupRequestError( - "backup encryption is disabled and a non-empty new password was not provided" - ) - emit_event( - "encryption-change", - "Enabling persistent local-backup encryption on the device before backup.", - None, - None, - None, - ) - await backup_client.change_password(request.destination, old="", new=request.new_password) - encrypted = await read_encryption_state(lockdown) - if not encrypted: - raise BackupRequestError("the device did not report encryption enabled after the password change") - encryption_enabled_now = True - elif encrypted: - emit_event( - "encryption-state", - "The device already requires encrypted local backups; the existing password was not requested or changed.", - None, - True, - None, - ) - - backup_path = request.destination / request.udid - effective_full = request.full or encryption_enabled_now - if encryption_enabled_now and not request.full: - emit_event( - "backup-mode", - "Encryption was just enabled, so this run is forced to a full backup instead of reusing unencrypted local state.", - None, - True, - backup_path, - ) - emit_event("backup-started", f"Writing the device backup under {backup_path}", 0, encrypted, backup_path) - - def update_progress(percentage: float) -> None: - bounded_percent = max(0, min(100, round(percentage))) - emit_event("progress", f"Backup progress: {bounded_percent}%", bounded_percent, None, None) - - await backup_client.backup( - full=effective_full, - backup_directory=request.destination, - progress_callback=update_progress, - filter_callback=None, - password="", - unback=False, - patch_manifest=False, - ) - emit_event("backup-complete", f"Backup completed under {backup_path}", 100, encrypted, backup_path) - finally: - await lockdown.close() - - -def parse_action(value: str) -> BackupAction: - if value == "status": - return "status" - if value == "backup": - return "backup" - raise BackupRequestError(f"unsupported backup action: {value}") - - -def read_request(stream: TextIO) -> BackupRequest: - payload = stream.read() - if not payload: - raise BackupRequestError("backup worker requires one JSON request on standard input") - return parse_backup_request(payload) - - -def main() -> int: - parser = argparse.ArgumentParser(description="Run a device backup without exposing passwords in process arguments.") - parser.add_argument("action", choices=("status", "backup")) - arguments = parser.parse_args() - action = parse_action(arguments.action) - request = read_request(sys.stdin) - asyncio.run(execute_request(action, request)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/ios_developer_toolkit/capability_matrix.py b/ios_developer_toolkit/capability_matrix.py deleted file mode 100644 index eb37f0a..0000000 --- a/ios_developer_toolkit/capability_matrix.py +++ /dev/null @@ -1,697 +0,0 @@ -from __future__ import annotations - -import json -import os -import re -import shutil -import subprocess -from dataclasses import dataclass -from pathlib import Path -from typing import Iterable, Literal, Mapping - -from ios_developer_toolkit.command_catalog import CommandPreset -from ios_developer_toolkit.models import IOSDevice -from ios_developer_toolkit.runtime import ExecutableCommand, command_argv, command_text, device_environment -from ios_developer_toolkit.validation import output_indicates_failure - - -CapabilityState = Literal["ready", "attention", "unavailable", "blocked", "not-tested", "not-applicable"] -CAPABILITY_STATES: tuple[CapabilityState, ...] = ( - "ready", - "attention", - "unavailable", - "blocked", - "not-tested", - "not-applicable", -) - - -class CapabilityMatrixError(ValueError): - """Raised when a capability probe or worker response is malformed.""" - - -@dataclass(frozen=True) -class CapabilityDefinition: - identifier: str - layer: str - title: str - remediation: str - - -@dataclass(frozen=True) -class CapabilityResult: - identifier: str - layer: str - title: str - state: CapabilityState - summary: str - evidence: str - remediation: str - - def to_mapping(self) -> Mapping[str, object]: - return { - "identifier": self.identifier, - "layer": self.layer, - "title": self.title, - "state": self.state, - "summary": self.summary, - "evidence": self.evidence, - "remediation": self.remediation, - } - - -@dataclass(frozen=True) -class CapabilityWorkerStarted: - total: int - - -@dataclass(frozen=True) -class CapabilityWorkerCompleted: - pass - - -CapabilityWorkerEvent = CapabilityWorkerStarted | CapabilityResult | CapabilityWorkerCompleted - - -PresetReadinessState = Literal["ready", "not-tested", "needs-attention"] - - -@dataclass(frozen=True) -class PresetReadiness: - state: PresetReadinessState - summary: str - remediation: tuple[str, ...] - - -@dataclass(frozen=True) -class CommandOutcome: - arguments: tuple[str, ...] - exit_code: int | None - stdout: str - stderr: str - timed_out: bool - - -def capability_definitions() -> tuple[CapabilityDefinition, ...]: - return ( - CapabilityDefinition( - "pymobiledevice3", - "Host", - "pymobiledevice3 runtime", - "Repair the project virtual environment, then relaunch the toolkit.", - ), - CapabilityDefinition( - "xcode-tools", - "Host", - "Apple developer tools", - "Install Xcode and select it with xcode-select. The pymobiledevice3-only workflows remain separate.", - ), - CapabilityDefinition( - "device-connection", - "Connection", - "Selected device", - "Connect the intended device, unlock it, tap Trust, and refresh device discovery.", - ), - CapabilityDefinition( - "pairing-trust", - "Connection", - "Pairing and Lockdown", - "Unlock the device, accept the Trust prompt, reconnect USB, and refresh the matrix.", - ), - CapabilityDefinition( - "developer-mode", - "Developer readiness", - "Developer Mode", - "Enable Settings > Privacy & Security > Developer Mode, restart, confirm on-device, and reconnect.", - ), - CapabilityDefinition( - "developer-image", - "Developer readiness", - "Developer Disk Image", - "Open Device & DDI and mount the compatible personalized image before using developer services.", - ), - CapabilityDefinition( - "rsd-tunnel", - "Developer readiness", - "iOS 17+ RSD tunnel path", - "Start or repair the pymobiledevice3 tunnel path, then retry a CoreDevice request.", - ), - CapabilityDefinition( - "coredevice", - "Developer services", - "CoreDevice service", - "Confirm Developer Mode, the mounted DDI, and the iOS 17+ tunnel path.", - ), - CapabilityDefinition( - "device-lockstate", - "Developer services", - "Lock-state service", - "Unlock the device and keep it awake while performing developer operations.", - ), - CapabilityDefinition( - "dvt", - "Developer services", - "DVT instrumentation", - "Confirm Developer Mode and the mounted DDI; on iOS 17+, also confirm the tunnel path.", - ), - CapabilityDefinition( - "webinspector", - "Optional services", - "Safari Web Inspector", - "Enable Settings > Apps > Safari > Advanced > Web Inspector, then retry with Safari open.", - ), - ) - - -def definition_by_identifier(identifier: str) -> CapabilityDefinition: - matches = tuple(item for item in capability_definitions() if item.identifier == identifier) - if len(matches) != 1: - raise CapabilityMatrixError(f"Expected one capability definition for {identifier!r}, found {len(matches)}") - return matches[0] - - -def capability_state_label(state: CapabilityState) -> str: - labels: Mapping[CapabilityState, str] = { - "ready": "Ready", - "attention": "Needs attention", - "unavailable": "Unavailable", - "blocked": "Blocked", - "not-tested": "Not tested", - "not-applicable": "Not applicable", - } - return labels[state] - - -def untested_capability_results() -> tuple[CapabilityResult, ...]: - return tuple( - CapabilityResult( - definition.identifier, - definition.layer, - definition.title, - "not-tested", - "Run the matrix to test this capability.", - "No probe has run for the selected device.", - definition.remediation, - ) - for definition in capability_definitions() - ) - - -def capability_state_counts(results: Iterable[CapabilityResult]) -> tuple[tuple[CapabilityState, int], ...]: - materialized = tuple(results) - return tuple((state, sum(result.state == state for result in materialized)) for state in CAPABILITY_STATES) - - -def preset_capability_identifiers(preset: CommandPreset) -> tuple[str, ...]: - identifiers: list[str] = [] - if preset.requires_device: - identifiers.extend(("device-connection", "pairing-trust")) - if preset.requires_developer_services: - identifiers.extend(("developer-mode", "developer-image", "rsd-tunnel")) - if preset.argument_template[:2] == ("developer", "core-device"): - identifiers.append("coredevice") - else: - identifiers.append("dvt") - if preset.argument_template and preset.argument_template[0] == "webinspector": - identifiers.append("webinspector") - return tuple(dict.fromkeys(identifiers)) - - -def evaluate_preset_readiness( - preset: CommandPreset, - results: Mapping[str, CapabilityResult], -) -> PresetReadiness: - identifiers = preset_capability_identifiers(preset) - if not identifiers: - return PresetReadiness("ready", "No device capability check is required for this preset.", ()) - missing = tuple(identifier for identifier in identifiers if identifier not in results) - if missing: - raise CapabilityMatrixError(f"Capability results are missing required identifiers: {', '.join(missing)}") - required = tuple(results[identifier] for identifier in identifiers) - not_tested = tuple(result for result in required if result.state == "not-tested") - if not_tested: - titles = ", ".join(result.title for result in not_tested) - return PresetReadiness( - "not-tested", - f"Readiness not checked for: {titles}.", - ("Run the one-click Device Readiness Check before this command.",), - ) - attention = tuple(result for result in required if result.state in ("attention", "unavailable", "blocked")) - if attention: - summary = "; ".join( - f"{result.title}: {capability_state_label(result.state)}" for result in attention - ) - return PresetReadiness( - "needs-attention", - summary, - tuple(dict.fromkeys(result.remediation for result in attention)), - ) - return PresetReadiness( - "ready", - "Every tested requirement is ready or not applicable for the selected device.", - (), - ) - - -def result_for( - identifier: str, - state: CapabilityState, - summary: str, - evidence: str, -) -> CapabilityResult: - definition = definition_by_identifier(identifier) - return CapabilityResult( - definition.identifier, - definition.layer, - definition.title, - state, - summary, - evidence, - definition.remediation, - ) - - -def _required_string(record: Mapping[str, object], key: str) -> str: - value = record.get(key) - if not isinstance(value, str) or not value: - raise CapabilityMatrixError(f"Capability result is missing required string field: {key}") - return value - - -def parse_capability_result(record: Mapping[str, object]) -> CapabilityResult: - state_value = _required_string(record, "state") - if state_value not in CAPABILITY_STATES: - raise CapabilityMatrixError(f"Capability result has unsupported state: {state_value}") - state: CapabilityState = state_value - result = CapabilityResult( - _required_string(record, "identifier"), - _required_string(record, "layer"), - _required_string(record, "title"), - state, - _required_string(record, "summary"), - _required_string(record, "evidence"), - _required_string(record, "remediation"), - ) - definition = definition_by_identifier(result.identifier) - if result.layer != definition.layer or result.title != definition.title: - raise CapabilityMatrixError(f"Capability result metadata does not match the catalog: {result.identifier}") - return result - - -def parse_capability_worker_event(payload: str) -> CapabilityWorkerEvent: - try: - parsed: object = json.loads(payload) - except json.JSONDecodeError as error: - raise CapabilityMatrixError(f"Capability worker emitted invalid JSON: {error}") from error - if not isinstance(parsed, dict): - raise CapabilityMatrixError("Capability worker event must be a JSON object") - event = parsed.get("event") - if event == "started": - total = parsed.get("total") - if not isinstance(total, int) or isinstance(total, bool) or total <= 0: - raise CapabilityMatrixError("Capability worker started event has an invalid total") - return CapabilityWorkerStarted(total) - if event == "result": - result = parsed.get("result") - if not isinstance(result, dict): - raise CapabilityMatrixError("Capability worker result event is missing a result object") - return parse_capability_result(result) - if event == "completed": - return CapabilityWorkerCompleted() - raise CapabilityMatrixError(f"Capability worker emitted an unsupported event: {event!r}") - - -def _decode_timeout_output(value: str | bytes | None) -> str: - if value is None: - return "" - if isinstance(value, bytes): - return value.decode("utf-8", errors="replace") - return value - - -def run_command( - program: ExecutableCommand, - arguments: tuple[str, ...], - environment: Mapping[str, str], - timeout_seconds: int, -) -> CommandOutcome: - try: - completed = subprocess.run( - command_argv(program, arguments), - env=dict(environment), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - timeout=timeout_seconds, - ) - except subprocess.TimeoutExpired as error: - return CommandOutcome( - arguments, - None, - _decode_timeout_output(error.stdout), - _decode_timeout_output(error.stderr), - True, - ) - except OSError as error: - raise CapabilityMatrixError(f"Could not execute {program}: {error}") from error - return CommandOutcome(arguments, completed.returncode, completed.stdout, completed.stderr, False) - - -def command_succeeded(outcome: CommandOutcome) -> bool: - combined = f"{outcome.stdout}\n{outcome.stderr}".encode("utf-8", errors="replace") - return outcome.exit_code == 0 and not outcome.timed_out and not output_indicates_failure(combined) - - -def compact_command_detail(outcome: CommandOutcome, device_identifier: str) -> str: - if outcome.timed_out: - return f"Timed out while running: pymobiledevice3 {' '.join(outcome.arguments)}" - combined = " ".join(f"{outcome.stdout}\n{outcome.stderr}".split()) - redacted = combined.replace(device_identifier, "") - if not redacted: - redacted = f"Command exited with code {outcome.exit_code} without diagnostic output." - return redacted[:500] - - -def _json_output(outcome: CommandOutcome) -> object: - try: - return json.loads(outcome.stdout) - except json.JSONDecodeError as error: - raise CapabilityMatrixError(f"Command returned malformed JSON: {error}") from error - - -def parse_ios_major(product_version: str) -> int | None: - match = re.match(r"\s*(\d+)", product_version) - return int(match.group(1)) if match is not None else None - - -def mounted_image_summary(payload: object) -> tuple[bool, str]: - if not isinstance(payload, list): - raise CapabilityMatrixError("Mounted image response must be a JSON array") - if not payload: - return False, "The image mounter returned an empty mounted-image list." - image_types: list[str] = [] - developer_image_found = False - for item in payload: - if not isinstance(item, dict): - raise CapabilityMatrixError("Mounted image entries must be JSON objects") - candidate = item.get("PersonalizedImageType", item.get("ImageType", "Developer image")) - image_type = candidate if isinstance(candidate, str) and candidate else "Unknown image type" - image_types.append(image_type) - normalized = image_type.casefold() - if "developer" in normalized or normalized == "ddi" or normalized.endswith(".ddi"): - developer_image_found = True - unique_types = tuple(dict.fromkeys(image_types)) - evidence = f"Mounted image records: {', '.join(unique_types)}." - if not developer_image_found: - evidence += " No record was identifiable as a Developer Disk Image." - return developer_image_found, evidence - - -def lock_state_from_payload(payload: object) -> str | None: - if isinstance(payload, dict): - for key, value in payload.items(): - normalized_key = str(key).replace("_", "").replace("-", "").casefold() - if normalized_key in ("locked", "islocked", "deviceislocked") and isinstance(value, bool): - return "locked" if value else "unlocked" - if "lockstate" in normalized_key and isinstance(value, str): - normalized_value = value.casefold() - if normalized_value in ("locked", "unlocked"): - return normalized_value - for value in payload.values(): - nested = lock_state_from_payload(value) - if nested is not None: - return nested - if isinstance(payload, list): - for value in payload: - nested = lock_state_from_payload(value) - if nested is not None: - return nested - return None - - -def unavailable_from_outcome(identifier: str, outcome: CommandOutcome, device_identifier: str) -> CapabilityResult: - return result_for( - identifier, - "attention", - "The capability probe did not complete successfully.", - compact_command_detail(outcome, device_identifier), - ) - - -def _probe_xcode_tools() -> CapabilityResult: - xcrun = shutil.which("xcrun") - if xcrun is None: - return result_for( - "xcode-tools", - "unavailable", - "Xcode command-line tools were not found.", - "xcrun is not available on PATH.", - ) - environment = dict(os.environ) - resolved: list[str] = [] - missing: list[str] = [] - for tool in ("devicectl", "xctrace"): - outcome = run_command(ExecutableCommand(Path(xcrun), ()), ("--find", tool), environment, 5) - if command_succeeded(outcome) and outcome.stdout.strip(): - resolved.append(tool) - else: - missing.append(tool) - if missing: - return result_for( - "xcode-tools", - "attention", - "Some Apple developer tools are unavailable.", - f"Found: {', '.join(resolved) or 'none'}; missing: {', '.join(missing)}.", - ) - return result_for( - "xcode-tools", - "ready", - "Apple's device and Instruments command-line tools are available.", - "xcrun resolved devicectl and xctrace.", - ) - - -def probe_capabilities(pymobiledevice3: ExecutableCommand, device: IOSDevice) -> Iterable[CapabilityResult]: - environment = device_environment(device.identifier) - version_outcome = run_command(pymobiledevice3, ("version",), dict(os.environ), 8) - if command_succeeded(version_outcome) and version_outcome.stdout.strip(): - yield result_for( - "pymobiledevice3", - "ready", - f"pymobiledevice3 {version_outcome.stdout.strip()} is executable.", - command_text(pymobiledevice3, ()), - ) - else: - yield unavailable_from_outcome("pymobiledevice3", version_outcome, device.identifier) - - yield _probe_xcode_tools() - yield result_for( - "device-connection", - "ready", - f"{device.name} is selected over {device.connection_type}.", - f"{device.product_type}; iOS {device.product_version}; build {device.build_version}.", - ) - - lockdown_outcome = run_command(pymobiledevice3, ("lockdown", "info"), environment, 10) - if command_succeeded(lockdown_outcome): - yield result_for( - "pairing-trust", - "ready", - "Lockdown returned the selected device's information record.", - "The current pairing record and trusted connection were accepted.", - ) - else: - yield unavailable_from_outcome("pairing-trust", lockdown_outcome, device.identifier) - - developer_mode_outcome = run_command( - pymobiledevice3, - ("mounter", "query-developer-mode-status"), - environment, - 10, - ) - if command_succeeded(developer_mode_outcome): - try: - developer_mode = _json_output(developer_mode_outcome) - except CapabilityMatrixError as error: - yield result_for("developer-mode", "attention", "Developer Mode returned an unreadable response.", str(error)) - else: - if developer_mode is True: - yield result_for("developer-mode", "ready", "Developer Mode is enabled.", "The image mounter returned true.") - elif developer_mode is False: - yield result_for("developer-mode", "attention", "Developer Mode is disabled.", "The image mounter returned false.") - else: - yield result_for( - "developer-mode", - "attention", - "Developer Mode returned an unexpected value.", - json.dumps(developer_mode, ensure_ascii=False)[:500], - ) - else: - yield unavailable_from_outcome("developer-mode", developer_mode_outcome, device.identifier) - - mounted_outcome = run_command(pymobiledevice3, ("mounter", "list"), environment, 10) - image_is_mounted = False - if command_succeeded(mounted_outcome): - try: - image_is_mounted, image_evidence = mounted_image_summary(_json_output(mounted_outcome)) - except CapabilityMatrixError as error: - yield result_for("developer-image", "attention", "Mounted-image state was unreadable.", str(error)) - else: - yield result_for( - "developer-image", - "ready" if image_is_mounted else "attention", - "A developer image is mounted." if image_is_mounted else "No mounted developer image was reported.", - image_evidence, - ) - else: - yield unavailable_from_outcome("developer-image", mounted_outcome, device.identifier) - - ios_major = parse_ios_major(device.product_version) - coredevice_ready = False - if ios_major is not None and ios_major < 17: - yield result_for( - "rsd-tunnel", - "not-applicable", - "The RSD tunnel path is not required for this iOS version.", - f"Selected device reports iOS {device.product_version}.", - ) - yield result_for( - "coredevice", - "not-applicable", - "CoreDevice is an iOS 17+ developer-service path.", - f"Selected device reports iOS {device.product_version}.", - ) - elif not image_is_mounted: - yield result_for( - "rsd-tunnel", - "blocked", - "Tunnel readiness was not inferred because the DDI is not ready.", - "CoreDevice was not contacted after the DDI prerequisite failed.", - ) - yield result_for( - "coredevice", - "blocked", - "CoreDevice was not tested because its DDI prerequisite is not ready.", - "Mount a compatible developer image, then refresh the matrix.", - ) - else: - coredevice_outcome = run_command( - pymobiledevice3, - ("developer", "core-device", "get-device-info"), - environment, - 15, - ) - coredevice_ready = command_succeeded(coredevice_outcome) - if coredevice_ready: - yield result_for( - "rsd-tunnel", - "ready", - "The iOS 17+ RSD/tunnel route reached CoreDevice.", - "A CoreDevice information request completed through the selected device path.", - ) - yield result_for( - "coredevice", - "ready", - "CoreDevice information is available.", - "com.apple.coredevice.deviceinfo responded successfully.", - ) - else: - failure_detail = compact_command_detail(coredevice_outcome, device.identifier) - yield result_for( - "rsd-tunnel", - "attention", - "The RSD/tunnel route did not complete a CoreDevice request.", - failure_detail, - ) - yield result_for( - "coredevice", - "attention", - "CoreDevice did not respond successfully.", - failure_detail, - ) - - if coredevice_ready: - lock_outcome = run_command( - pymobiledevice3, - ("developer", "core-device", "get-lockstate"), - environment, - 10, - ) - if command_succeeded(lock_outcome): - try: - lock_payload = _json_output(lock_outcome) - except CapabilityMatrixError as error: - yield result_for("device-lockstate", "attention", "The lock-state response was unreadable.", str(error)) - else: - lock_state = lock_state_from_payload(lock_payload) - if lock_state == "locked": - yield result_for( - "device-lockstate", - "attention", - "The device reports a locked state.", - "The CoreDevice lock-state service responded successfully.", - ) - elif lock_state == "unlocked": - yield result_for( - "device-lockstate", - "ready", - "The device reports an unlocked state.", - "The CoreDevice lock-state service responded successfully.", - ) - else: - yield result_for( - "device-lockstate", - "ready", - "The CoreDevice lock-state service is reachable.", - json.dumps(lock_payload, ensure_ascii=False)[:500], - ) - else: - yield unavailable_from_outcome("device-lockstate", lock_outcome, device.identifier) - else: - yield result_for( - "device-lockstate", - "blocked" if ios_major is None or ios_major >= 17 else "not-applicable", - "Lock-state service was not tested through CoreDevice.", - "CoreDevice must be reachable before this service can be queried.", - ) - - if not image_is_mounted: - yield result_for( - "dvt", - "blocked", - "DVT was not tested because its DDI prerequisite is not ready.", - "Mount a compatible developer image, then refresh the matrix.", - ) - else: - dvt_outcome = run_command( - pymobiledevice3, - ("developer", "dvt", "device-information"), - environment, - 15, - ) - if command_succeeded(dvt_outcome): - yield result_for( - "dvt", - "ready", - "DVT instrumentation is reachable.", - "The device-information channel responded successfully.", - ) - else: - yield unavailable_from_outcome("dvt", dvt_outcome, device.identifier) - - webinspector_outcome = run_command( - pymobiledevice3, - ("webinspector", "opened-tabs", "--timeout", "3"), - environment, - 8, - ) - if command_succeeded(webinspector_outcome): - yield result_for( - "webinspector", - "ready", - "Safari Web Inspector responded.", - "The opened-tabs request completed; an empty list simply means no inspectable page was open.", - ) - else: - yield unavailable_from_outcome("webinspector", webinspector_outcome, device.identifier) diff --git a/ios_developer_toolkit/capability_matrix_worker.py b/ios_developer_toolkit/capability_matrix_worker.py deleted file mode 100644 index e465a8b..0000000 --- a/ios_developer_toolkit/capability_matrix_worker.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import sys -from typing import Sequence - -from ios_developer_toolkit.capability_matrix import capability_definitions, probe_capabilities -from ios_developer_toolkit.models import IOSDevice -from ios_developer_toolkit.runtime import pymobiledevice3_command - - -def emit(payload: object) -> None: - print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), flush=True) - - -def build_argument_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Run bounded iOS Developer Toolkit capability probes") - parser.add_argument("--identifier", required=True) - parser.add_argument("--name", required=True) - parser.add_argument("--product-type", required=True) - parser.add_argument("--product-version", required=True) - parser.add_argument("--build-version", required=True) - parser.add_argument("--connection-type", required=True) - return parser - - -def main(arguments: Sequence[str] | None) -> int: - parsed = build_argument_parser().parse_args(arguments) - device = IOSDevice( - identifier=parsed.identifier, - name=parsed.name, - product_type=parsed.product_type, - product_version=parsed.product_version, - build_version=parsed.build_version, - connection_type=parsed.connection_type, - ) - emit({"event": "started", "total": len(capability_definitions())}) - for result in probe_capabilities(pymobiledevice3_command(), device): - emit({"event": "result", "result": result.to_mapping()}) - emit({"event": "completed"}) - return 0 - - -if __name__ == "__main__": - sys.exit(main(None)) diff --git a/ios_developer_toolkit/case_workflow.py b/ios_developer_toolkit/case_workflow.py deleted file mode 100644 index f681c14..0000000 --- a/ios_developer_toolkit/case_workflow.py +++ /dev/null @@ -1,129 +0,0 @@ -from __future__ import annotations - -import json -from dataclasses import asdict, dataclass -from datetime import datetime, timezone -from pathlib import Path - - -class CaseWorkflowError(ValueError): - """Raised when guided case intake cannot be created or used safely.""" - - -@dataclass(frozen=True) -class CaseIntake: - """Local case metadata captured before a bounded collection begins.""" - - title: str - purpose: str - target_udid: str - authorization_acknowledged_at: str - created_at: str - - -def utc_now() -> str: - return datetime.now(timezone.utc).isoformat() - - -def safe_udid_fragment(udid: str) -> str: - allowed = "".join(character for character in udid if character.isalnum()) - if not allowed: - raise CaseWorkflowError("UDID does not contain any usable alphanumeric characters") - return allowed[-12:] - - -def normalized_case_title(title: str) -> str: - normalized = " ".join(title.split()) - if not normalized: - raise CaseWorkflowError("Case title is required") - if len(normalized) > 120: - raise CaseWorkflowError("Case title must be 120 characters or fewer") - return normalized - - -def normalized_case_purpose(purpose: str) -> str: - normalized = purpose.strip() - if len(normalized) > 2_000: - raise CaseWorkflowError("Case purpose must be 2,000 characters or fewer") - return normalized - - -def case_directory_path(output_root: Path, udid: str, created_at: datetime) -> Path: - timestamp = created_at.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ") - return output_root.expanduser().resolve() / f"ios-case-{timestamp}-{safe_udid_fragment(udid)}" - - -def create_case_directory(output_root: Path, udid: str, created_at: datetime) -> Path: - root = output_root.expanduser().resolve() - root.mkdir(mode=0o700, parents=True, exist_ok=True) - directory = case_directory_path(root, udid, created_at) - try: - directory.mkdir(mode=0o700, parents=False, exist_ok=False) - except FileExistsError as error: - raise CaseWorkflowError( - f"A case already exists for this device and second: {directory}. Create the case again." - ) from error - for name in ("snapshots", "streams", "artifacts"): - (directory / name).mkdir(mode=0o700) - return directory - - -def create_guided_case( - output_root: Path, - udid: str, - title: str, - purpose: str, - authorization_acknowledged: bool, -) -> tuple[Path, CaseIntake]: - if not authorization_acknowledged: - raise CaseWorkflowError("Confirm that you own the device or are authorized to examine it before creating a case") - created = datetime.now(timezone.utc) - intake = CaseIntake( - title=normalized_case_title(title), - purpose=normalized_case_purpose(purpose), - target_udid=udid, - authorization_acknowledged_at=created.isoformat(), - created_at=created.isoformat(), - ) - directory = create_case_directory(output_root, udid, created) - intake_path = directory / "case-intake.json" - payload: dict[str, object] = { - "schema_version": 1, - "application": "iOS Developer Toolkit", - "case": asdict(intake), - "limitations": [ - "The intake records the operator acknowledgement; it does not establish chain of custody.", - "Hashes are written after collection and detect later changes to the finalized case files.", - ], - } - try: - intake_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") - intake_path.chmod(0o600) - except OSError as error: - raise CaseWorkflowError(f"Unable to write the case intake at {intake_path}: {error}") from error - return directory, intake - - -def validate_collection_case(case_directory: Path, udid: str) -> Path: - directory = case_directory.expanduser().resolve() - intake_path = directory / "case-intake.json" - if not directory.is_dir(): - raise CaseWorkflowError(f"Guided case directory does not exist: {directory}") - if (directory / "manifest.json").exists(): - raise CaseWorkflowError(f"Guided case is already finalized: {directory}. Create a new case for another collection.") - if not intake_path.is_file(): - raise CaseWorkflowError(f"Guided case intake is missing: {intake_path}") - try: - payload = json.loads(intake_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - raise CaseWorkflowError(f"Guided case intake is unreadable: {intake_path}: {error}") from error - if not isinstance(payload, dict): - raise CaseWorkflowError(f"Guided case intake must contain a JSON object: {intake_path}") - case_data = payload.get("case") - if not isinstance(case_data, dict) or case_data.get("target_udid") != udid: - raise CaseWorkflowError("Guided case target does not match the selected device") - for name in ("snapshots", "streams", "artifacts"): - child = directory / name - if not child.is_dir(): - raise CaseWorkflowError(f"Guided case directory is missing required folder: {child}") - return directory diff --git a/ios_developer_toolkit/catalog.py b/ios_developer_toolkit/catalog.py deleted file mode 100644 index 075913b..0000000 --- a/ios_developer_toolkit/catalog.py +++ /dev/null @@ -1,94 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from ios_developer_toolkit.models import CommandSpec - - -def snapshot_commands(include_screenshot: bool, include_crash_pull: bool) -> tuple[CommandSpec, ...]: - commands: list[CommandSpec] = [ - CommandSpec("usbmux", "Connected device inventory", ("usbmux", "list"), Path("snapshots/usbmux.json"), True, 30), - CommandSpec("lockdown", "Lockdown device information", ("lockdown", "info"), Path("snapshots/lockdown-info.json"), True, 45), - CommandSpec("mounter", "Mounted developer images", ("mounter", "list"), Path("snapshots/mounter-list.json"), False, 45), - CommandSpec("cryptex", "Installed Cryptex inventory", ("cryptex", "list"), Path("snapshots/cryptex-list.json"), False, 90), - CommandSpec("diagnostics", "Diagnostics service information", ("diagnostics", "info"), Path("snapshots/diagnostics-info.json"), False, 45), - CommandSpec("mobilegestalt", "Known MobileGestalt values", ("diagnostics", "mg"), Path("snapshots/mobilegestalt.json"), False, 90), - CommandSpec("ioregistry", "Device IORegistry", ("diagnostics", "ioregistry"), Path("snapshots/ioregistry.json"), False, 90), - CommandSpec("battery", "Battery snapshot", ("diagnostics", "battery", "single"), Path("snapshots/battery.json"), False, 45), - CommandSpec("apps", "Installed application inventory", ("apps", "list"), Path("snapshots/apps.json"), False, 90), - CommandSpec("processes", "Diagnostics process inventory", ("processes", "ps"), Path("snapshots/processes.txt"), False, 60), - CommandSpec("profiles", "Installed configuration profiles", ("profile", "list"), Path("snapshots/profiles.json"), False, 60), - CommandSpec("provisioning", "Installed provisioning profiles", ("provision", "list"), Path("snapshots/provisioning.txt"), False, 60), - CommandSpec("crashes", "Crash report inventory", ("crash", "ls"), Path("snapshots/crash-list.txt"), False, 60), - CommandSpec("afc", "AFC media root listing", ("afc", "ls", "/"), Path("snapshots/afc-root.txt"), False, 60), - CommandSpec("dvt-device", "DVT extended device information", ("developer", "dvt", "device-information"), Path("snapshots/dvt-device-information.json"), False, 120), - CommandSpec("dvt-processes", "DVT detailed process snapshot", ("developer", "dvt", "sysmon", "process", "single"), Path("snapshots/dvt-sysmon-processes.txt"), False, 120), - CommandSpec("dvt-filesystem", "DVT root filesystem listing", ("developer", "dvt", "ls", "/"), Path("snapshots/dvt-root-listing.txt"), False, 120), - ] - if include_screenshot: - commands.append( - CommandSpec( - "screenshot", - "Current device screenshot", - ("developer", "dvt", "screenshot", "artifacts/screen.png"), - Path("snapshots/screenshot-command.txt"), - False, - 120, - ) - ) - if include_crash_pull: - commands.append( - CommandSpec( - "crash-pull", - "Pull crash reports", - ("crash", "pull", "artifacts/crashes"), - Path("snapshots/crash-pull.txt"), - False, - 600, - ) - ) - return tuple(commands) - - -def is_potentially_mutating(arguments: tuple[str, ...]) -> bool: - if not arguments: - return False - safe_prefixes: tuple[tuple[str, ...], ...] = ( - ("version",), - ("usbmux", "list"), - ("bonjour",), - ("remote", "browse"), - ("lockdown", "info"), - ("mounter", "list"), - ("mounter", "lookup"), - ("mounter", "query-developer-mode-status"), - ("mounter", "query-nonce"), - ("mounter", "query-personalization-identifiers"), - ("cryptex", "list"), - ("cryptex", "personalization-identifiers"), - ("cryptex", "nonce"), - ("syslog", "live"), - ("pcap",), - ("diagnostics", "info"), - ("diagnostics", "ioregistry"), - ("diagnostics", "mg"), - ("diagnostics", "battery"), - ("apps", "list"), - ("apps", "query"), - ("crash", "ls"), - ("crash", "pull"), - ("crash", "watch"), - ("processes", "ps"), - ("processes", "pgrep"), - ("profile", "list"), - ("provision", "list"), - ("provision", "dump"), - ("afc", "ls"), - ("afc", "pull"), - ("developer", "dvt", "ls"), - ("developer", "dvt", "device-information"), - ("developer", "dvt", "sysmon"), - ("developer", "dvt", "oslog"), - ("developer", "dvt", "screenshot"), - ) - return not any(arguments[: len(prefix)] == prefix for prefix in safe_prefixes) diff --git a/ios_developer_toolkit/collection_process.py b/ios_developer_toolkit/collection_process.py deleted file mode 100644 index 4842f12..0000000 --- a/ios_developer_toolkit/collection_process.py +++ /dev/null @@ -1,221 +0,0 @@ -from __future__ import annotations - -import json -from datetime import datetime, timezone -from typing import Literal, Mapping, Sequence - -from PySide6.QtCore import QObject, QProcess, QProcessEnvironment, QTimer, Signal - -from ios_developer_toolkit.collection_protocol import ( - CollectionEvent, - CollectionProtocolError, - parse_collection_event, -) -from ios_developer_toolkit.qt_process import OperationResult, ProcessOutcome -from ios_developer_toolkit.runtime import ExecutableCommand, command_arguments, command_argv - - -class CollectionProcessController(QObject): - """Own one evidence collector and preserve its graceful finalization window.""" - - stdout_received = Signal(bytes) - stderr_received = Signal(bytes) - event_received = Signal(object) - completed = Signal(object) - - def __init__(self, parent: QObject) -> None: - super().__init__(parent) - self._process: QProcess | None = None - self._command: ExecutableCommand | None = None - self._arguments: tuple[str, ...] = () - self._stdout = bytearray() - self._stdout_line = bytearray() - self._stderr = bytearray() - self._started_at = "" - self._error_message: str | None = None - self._stop_outcome: Literal["cancelled", "timed-out"] | None = None - self._protocol_failed = False - self._completed = False - self._finalization_timeout_milliseconds = 0 - self._finalization_timer = QTimer(self) - self._finalization_timer.setSingleShot(True) - self._finalization_timer.timeout.connect(self._force_stop_after_finalization_timeout) - - def is_running(self) -> bool: - return self._process is not None - - def start( - self, - command: ExecutableCommand, - arguments: Sequence[str], - environment: Mapping[str, str], - finalization_timeout_milliseconds: int, - ) -> None: - if self.is_running(): - raise RuntimeError("Cannot start an evidence collection while another collection is running") - if finalization_timeout_milliseconds <= 0: - raise ValueError( - f"Collection finalization timeout must be positive: {finalization_timeout_milliseconds}" - ) - self._command = command - self._arguments = tuple(arguments) - self._stdout.clear() - self._stdout_line.clear() - self._stderr.clear() - self._started_at = datetime.now(timezone.utc).isoformat() - self._error_message = None - self._stop_outcome = None - self._protocol_failed = False - self._completed = False - self._finalization_timeout_milliseconds = finalization_timeout_milliseconds - - process = QProcess(self) - process.setProgram(str(command.program)) - process.setArguments(list(command_arguments(command, self._arguments))) - process_environment = QProcessEnvironment.systemEnvironment() - for key, value in sorted(environment.items()): - process_environment.insert(key, value) - process.setProcessEnvironment(process_environment) - process.readyReadStandardOutput.connect(self._drain_output) - process.readyReadStandardError.connect(self._drain_output) - process.errorOccurred.connect(self._process_error) - process.finished.connect(self._finished) - self._process = process - process.start() - - def cancel(self) -> None: - process = self._process - if process is None or process.state() == QProcess.ProcessState.NotRunning: - return - if self._stop_outcome == "cancelled": - return - self._stop_outcome = "cancelled" - self._request_graceful_stop() - - def shutdown(self, terminate_timeout_milliseconds: int, kill_timeout_milliseconds: int) -> None: - if terminate_timeout_milliseconds <= 0: - raise ValueError(f"Shutdown termination timeout must be positive: {terminate_timeout_milliseconds}") - if kill_timeout_milliseconds <= 0: - raise ValueError(f"Shutdown kill timeout must be positive: {kill_timeout_milliseconds}") - process = self._process - if process is None: - return - self._stop_outcome = "cancelled" - self._finalization_timer.stop() - if process.state() != QProcess.ProcessState.NotRunning: - process.terminate() - if not process.waitForFinished(terminate_timeout_milliseconds): - process.kill() - if not process.waitForFinished(kill_timeout_milliseconds): - raise RuntimeError(f"Collector did not stop after terminate and kill: {process.program()}") - else: - self._finish_once("cancelled", process.exitCode()) - - def _drain_output(self) -> None: - process = self._process - if process is None: - return - stdout = bytes(process.readAllStandardOutput()) - stderr = bytes(process.readAllStandardError()) - if stdout: - self._stdout.extend(stdout) - self._stdout_line.extend(stdout) - self.stdout_received.emit(stdout) - self._consume_complete_lines() - if stderr: - self._stderr.extend(stderr) - self.stderr_received.emit(stderr) - - def _consume_complete_lines(self) -> None: - while b"\n" in self._stdout_line: - line, _, remainder = self._stdout_line.partition(b"\n") - self._stdout_line = bytearray(remainder) - if line.strip(): - self._consume_event_line(line) - - def _consume_event_line(self, line: bytes) -> None: - try: - event = parse_collection_event(line.decode("utf-8")) - except (CollectionProtocolError, json.JSONDecodeError, UnicodeDecodeError) as error: - self._protocol_failure(f"Invalid collector event: {error}") - return - self.event_received.emit(event) - - def _protocol_failure(self, message: str) -> None: - if self._protocol_failed: - return - self._protocol_failed = True - self._error_message = message - process = self._process - if process is not None and process.state() != QProcess.ProcessState.NotRunning: - self._request_graceful_stop() - - def _request_graceful_stop(self) -> None: - process = self._process - if process is None: - raise RuntimeError("Cannot stop evidence collection without an active process") - if process.state() == QProcess.ProcessState.NotRunning: - return - process.terminate() - self._finalization_timer.start(self._finalization_timeout_milliseconds) - - def _force_stop_after_finalization_timeout(self) -> None: - process = self._process - if process is None or process.state() == QProcess.ProcessState.NotRunning: - return - if not self._protocol_failed: - self._stop_outcome = "timed-out" - self._error_message = "Collector did not finish evidence finalization before the safety deadline" - process.kill() - - def _process_error(self, process_error: QProcess.ProcessError) -> None: - process = self._process - if process is None: - raise RuntimeError("Collector reported an error without an active process") - if self._error_message is None: - self._error_message = process.errorString() - if process_error == QProcess.ProcessError.FailedToStart: - self._finish_once("launch-failed", None) - - def _finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: - self._drain_output() - if self._stdout_line.strip(): - line = bytes(self._stdout_line) - self._stdout_line.clear() - self._consume_event_line(line) - if self._protocol_failed: - outcome: ProcessOutcome = "failed" - elif self._stop_outcome is not None: - outcome = self._stop_outcome - elif exit_status == QProcess.ExitStatus.CrashExit: - outcome = "crashed" - elif exit_code == 0: - outcome = "succeeded" - else: - outcome = "failed" - self._finish_once(outcome, exit_code) - - def _finish_once(self, outcome: ProcessOutcome, exit_code: int | None) -> None: - if self._completed: - return - command = self._command - if command is None: - raise RuntimeError("Collector completed without a command") - self._drain_output() - self._completed = True - self._finalization_timer.stop() - result = OperationResult( - command_argv(command, self._arguments), - outcome, - self._started_at, - datetime.now(timezone.utc).isoformat(), - exit_code, - self._error_message, - bytes(self._stdout), - bytes(self._stderr), - ) - process = self._process - self._process = None - if process is not None: - process.deleteLater() - self.completed.emit(result) diff --git a/ios_developer_toolkit/collection_protocol.py b/ios_developer_toolkit/collection_protocol.py deleted file mode 100644 index 6bb89e7..0000000 --- a/ios_developer_toolkit/collection_protocol.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -import json -from dataclasses import dataclass -from pathlib import Path -from typing import Mapping - - -class CollectionProtocolError(ValueError): - """Raised when a collector event does not match the documented JSON-line schema.""" - - -@dataclass(frozen=True) -class CollectionEvent: - event: str - message: str - timestamp: str - path: Path | None - status: str | None - failures: int | None - - -def required_string(record: Mapping[str, object], field_name: str) -> str: - value = record.get(field_name) - if not isinstance(value, str) or not value.strip(): - raise CollectionProtocolError(f"{field_name} must be a non-empty string") - return value.strip() - - -def optional_string(record: Mapping[str, object], field_name: str) -> str | None: - value = record.get(field_name) - if value is None: - return None - if not isinstance(value, str) or not value.strip(): - raise CollectionProtocolError(f"{field_name} must be a non-empty string when present") - return value.strip() - - -def optional_nonnegative_integer(record: Mapping[str, object], field_name: str) -> int | None: - value = record.get(field_name) - if value is None: - return None - if not isinstance(value, int) or isinstance(value, bool) or value < 0: - raise CollectionProtocolError(f"{field_name} must be a non-negative integer when present") - return value - - -def parse_collection_event(payload: str) -> CollectionEvent: - raw: object = json.loads(payload) - if not isinstance(raw, dict) or not all(isinstance(key, str) for key in raw): - raise CollectionProtocolError("collector event must be a string-keyed JSON object") - record: Mapping[str, object] = raw - path_text = optional_string(record, "path") - path = Path(path_text) if path_text is not None else None - if path is not None and not path.is_absolute(): - raise CollectionProtocolError("collector event path must be absolute") - return CollectionEvent( - event=required_string(record, "event"), - message=required_string(record, "message"), - timestamp=required_string(record, "timestamp"), - path=path, - status=optional_string(record, "status"), - failures=optional_nonnegative_integer(record, "failures"), - ) diff --git a/ios_developer_toolkit/collector.py b/ios_developer_toolkit/collector.py deleted file mode 100644 index b692c66..0000000 --- a/ios_developer_toolkit/collector.py +++ /dev/null @@ -1,466 +0,0 @@ -from __future__ import annotations - -import argparse -import hashlib -import importlib.metadata -import json -import os -import signal -import subprocess -import sys -import threading -import time -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import IO, Mapping, Sequence - -from ios_developer_toolkit import APP_VERSION -from ios_developer_toolkit.case_workflow import ( - CaseWorkflowError, - create_case_directory, - safe_udid_fragment, - validate_collection_case, -) -from ios_developer_toolkit.catalog import snapshot_commands -from ios_developer_toolkit.models import CommandResult, CommandSpec -from ios_developer_toolkit.models import DeviceDataError, parse_devices_json -from ios_developer_toolkit.runtime import ( - ExecutableCommand, - command_argv, - command_text, - device_environment, - pymobiledevice3_command, -) -from ios_developer_toolkit.validation import output_indicates_failure - - -class CollectionError(RuntimeError): - """Base error for evidence collection failures.""" - - -class RequiredCommandError(CollectionError): - """Raised when a command required to identify the target fails.""" - - -class PartialCollectionError(CollectionError): - """Raised after artifacts are finalized when one or more optional commands failed.""" - - -@dataclass(frozen=True) -class StreamSpec: - identifier: str - title: str - arguments: tuple[str, ...] - log_path: Path - artifact_path: Path | None - - -@dataclass -class StreamHandle: - spec: StreamSpec - process: subprocess.Popen[bytes] - output_file: IO[bytes] - started_at: str - ended_early: bool - - -def utc_now() -> str: - return datetime.now(timezone.utc).isoformat() - - -def emit(event: str, message: str, fields: Mapping[str, object]) -> None: - record: dict[str, object] = {"event": event, "message": message, "timestamp": utc_now()} - record.update(fields) - print(json.dumps(record, sort_keys=True), flush=True) - - -def run_snapshot( - executable: ExecutableCommand, - environment: Mapping[str, str], - case_directory: Path, - spec: CommandSpec, - attempts_limit: int, - stop_requested: threading.Event, - target_udid: str, -) -> CommandResult: - output_path = case_directory / spec.output_path - output_path.parent.mkdir(parents=True, exist_ok=True) - started_at = utc_now() - final_exit_code = 1 - attempts = 0 - combined_output = bytearray() - final_output = b"" - for attempt in range(1, attempts_limit + 1): - attempts = attempt - emit("step-start", spec.title, {"command": command_text(executable, spec.arguments), "attempt": attempt}) - process = subprocess.Popen( - command_argv(executable, spec.arguments), - cwd=case_directory, - env=environment, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - start_new_session=True, - ) - deadline = time.monotonic() + spec.timeout_seconds - timed_out = False - cancelled = False - while process.poll() is None: - if stop_requested.is_set(): - cancelled = True - os.killpg(process.pid, signal.SIGINT) - break - if time.monotonic() >= deadline: - timed_out = True - os.killpg(process.pid, signal.SIGKILL) - break - time.sleep(0.2) - try: - stdout, _ = process.communicate(timeout=8) - except subprocess.TimeoutExpired: - os.killpg(process.pid, signal.SIGKILL) - stdout, _ = process.communicate(timeout=5) - final_exit_code = process.returncode if process.returncode is not None else 125 - final_output = stdout - if timed_out: - final_exit_code = 124 - combined_output.extend(f"\n--- attempt {attempt} ---\n".encode()) - combined_output.extend(stdout) - if timed_out: - combined_output.extend(f"\nCommand timed out after {spec.timeout_seconds}s.\n".encode()) - if cancelled: - combined_output.extend(b"\nCommand cancelled by user request.\n") - validation_error = snapshot_validation_error(spec, stdout, target_udid, case_directory) - if final_exit_code == 0 and validation_error is not None: - final_exit_code = 70 - combined_output.extend(f"\nSemantic validation failed: {validation_error}\n".encode()) - if final_exit_code == 0 or cancelled: - break - if attempt < attempts_limit: - emit("warning", f"{spec.title} failed; retrying", {"exit_code": final_exit_code, "attempt": attempt}) - time.sleep(1) - output_path.write_bytes(final_output) - command_log_path = output_path.with_suffix(output_path.suffix + ".command.log") - command_log_path.write_bytes(bytes(combined_output)) - ended_at = utc_now() - status = "cancelled" if stop_requested.is_set() else ("completed" if final_exit_code == 0 else "failed") - result = CommandResult( - identifier=spec.identifier, - title=spec.title, - arguments=spec.arguments, - output_path=str(spec.output_path), - started_at=started_at, - ended_at=ended_at, - exit_code=final_exit_code, - attempts=attempts, - status=status, - ) - emit("step-finish", spec.title, {"status": status, "exit_code": final_exit_code, "output": str(output_path)}) - return result - - -def snapshot_validation_error( - spec: CommandSpec, - output: bytes, - target_udid: str, - case_directory: Path, -) -> str | None: - if output_indicates_failure(output): - return "pymobiledevice3 logged a device or service error" - if spec.identifier == "usbmux": - try: - devices = parse_devices_json(output.decode("utf-8")) - except (DeviceDataError, json.JSONDecodeError, UnicodeDecodeError) as error: - return f"usbmux output is not valid device JSON: {error}" - if not any(device.identifier == target_udid for device in devices): - return f"selected UDID is not present in the connected-device inventory: {target_udid}" - if spec.identifier == "screenshot" and not (case_directory / "artifacts/screen.png").is_file(): - return "screenshot command exited without creating artifacts/screen.png" - if spec.identifier == "crash-pull" and not (case_directory / "artifacts/crashes").is_dir(): - return "crash pull exited without creating artifacts/crashes" - return None - - -def stream_specs(include_syslog: bool, include_oslog: bool, include_pcap: bool) -> tuple[StreamSpec, ...]: - specs: list[StreamSpec] = [] - if include_syslog: - specs.append(StreamSpec("syslog", "Classic syslog stream", ("syslog", "live"), Path("streams/syslog.txt"), None)) - if include_oslog: - specs.append( - StreamSpec( - "dvt-oslog", - "DVT structured unified logging stream", - ("developer", "dvt", "oslog"), - Path("streams/dvt-oslog.txt"), - None, - ) - ) - if include_pcap: - specs.append( - StreamSpec( - "pcap", - "Device network packet capture", - ("pcap", "--out", "streams/network.pcap"), - Path("streams/pcap-metadata.txt"), - Path("streams/network.pcap"), - ) - ) - return tuple(specs) - - -def start_stream( - executable: ExecutableCommand, - environment: Mapping[str, str], - case_directory: Path, - spec: StreamSpec, -) -> StreamHandle: - output_path = case_directory / spec.log_path - output_path.parent.mkdir(parents=True, exist_ok=True) - output_file = output_path.open("wb") - emit("stream-start", spec.title, {"command": command_text(executable, spec.arguments), "output": str(output_path)}) - try: - process = subprocess.Popen( - command_argv(executable, spec.arguments), - cwd=case_directory, - env=environment, - stdout=output_file, - stderr=subprocess.STDOUT, - start_new_session=True, - ) - except OSError: - output_file.close() - raise - return StreamHandle(spec=spec, process=process, output_file=output_file, started_at=utc_now(), ended_early=False) - - -def stop_stream(handle: StreamHandle) -> CommandResult: - process = handle.process - if process.poll() is None: - os.killpg(process.pid, signal.SIGINT) - try: - process.wait(timeout=8) - except subprocess.TimeoutExpired: - os.killpg(process.pid, signal.SIGKILL) - process.wait(timeout=5) - handle.output_file.close() - exit_code = process.returncode if process.returncode is not None else 125 - artifact_missing = False - if handle.spec.artifact_path is not None: - artifact = Path(handle.output_file.name).parent.parent / handle.spec.artifact_path - artifact_missing = not artifact.is_file() or artifact.stat().st_size <= 24 - status = "completed" if exit_code in (0, -signal.SIGINT) and not handle.ended_early and not artifact_missing else "failed" - emit("stream-finish", handle.spec.title, {"status": status, "exit_code": exit_code}) - return CommandResult( - identifier=handle.spec.identifier, - title=handle.spec.title, - arguments=handle.spec.arguments, - output_path=str(handle.spec.log_path), - started_at=handle.started_at, - ended_at=utc_now(), - exit_code=exit_code, - attempts=1, - status=status, - ) - - -def collect_streams( - executable: ExecutableCommand, - environment: Mapping[str, str], - case_directory: Path, - duration_seconds: int, - include_syslog: bool, - include_oslog: bool, - include_pcap: bool, - stop_requested: threading.Event, -) -> tuple[CommandResult, ...]: - handles: list[StreamHandle] = [] - try: - for spec in stream_specs(include_syslog, include_oslog, include_pcap): - handles.append(start_stream(executable, environment, case_directory, spec)) - except OSError: - for handle in handles: - stop_stream(handle) - raise - deadline = time.monotonic() + duration_seconds - while time.monotonic() < deadline and not stop_requested.is_set(): - for handle in handles: - exit_code = handle.process.poll() - if exit_code is not None: - if not handle.ended_early: - handle.ended_early = True - emit( - "warning", - f"{handle.spec.title} exited before the capture timer finished", - {"exit_code": exit_code}, - ) - time.sleep(0.5) - return tuple(stop_stream(handle) for handle in handles) - - -def sha256_file(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as source: - for chunk in iter(lambda: source.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def write_hashes(case_directory: Path) -> None: - candidates = sorted( - path for path in case_directory.rglob("*") if path.is_file() and path.name != "SHA256SUMS.txt" - ) - lines = [f"{sha256_file(path)} {path.relative_to(case_directory)}" for path in candidates] - (case_directory / "SHA256SUMS.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") - - -def write_manifest( - case_directory: Path, - udid: str, - duration_seconds: int, - started_at: str, - results: Sequence[CommandResult], -) -> None: - manifest: Mapping[str, object] = { - "schema_version": 1, - "application": "iOS Developer Toolkit", - "application_version": APP_VERSION, - "pymobiledevice3_version": importlib.metadata.version("pymobiledevice3"), - "developer_disk_image_version": importlib.metadata.version("developer-disk-image"), - "target_udid": udid, - "started_at": started_at, - "ended_at": utc_now(), - "requested_stream_duration_seconds": duration_seconds, - "limitations": [ - "Developer services do not provide unrestricted access to all app containers or protected data.", - "PCAP attribution is limited to packets visible through pcapd and does not decrypt TLS.", - "A failed command is a coverage gap, not proof that the corresponding data is absent.", - ], - "commands": [result.to_mapping() for result in results], - } - (case_directory / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") - - -def run_collection( - udid: str, - output_root: Path | None, - existing_case_directory: Path | None, - duration_seconds: int, - include_syslog: bool, - include_oslog: bool, - include_pcap: bool, - include_screenshot: bool, - include_crash_pull: bool, - stop_requested: threading.Event, -) -> Path: - if duration_seconds < 1: - raise ValueError("Capture duration must be at least one second") - executable = pymobiledevice3_command() - environment = device_environment(udid) - if existing_case_directory is None: - if output_root is None: - raise ValueError("An output root is required when a guided case directory is not provided") - case_directory = create_case_directory(output_root, udid, datetime.now(timezone.utc)) - emit("case-created", "Created evidence case directory", {"path": str(case_directory)}) - else: - case_directory = validate_collection_case(existing_case_directory, udid) - emit("case-attached", "Attached to guided evidence case directory", {"path": str(case_directory)}) - started_at = utc_now() - results: list[CommandResult] = [] - required_error: RequiredCommandError | None = None - for spec in snapshot_commands(include_screenshot, include_crash_pull): - result = run_snapshot(executable, environment, case_directory, spec, 2, stop_requested, udid) - results.append(result) - if spec.required and result.status == "failed": - required_error = RequiredCommandError( - f"Required command failed after {result.attempts} attempts: " - f"{command_text(executable, spec.arguments)}. See {case_directory / spec.output_path}" - ) - break - if stop_requested.is_set(): - break - if not stop_requested.is_set() and required_error is None: - results.extend( - collect_streams( - executable, - environment, - case_directory, - duration_seconds, - include_syslog, - include_oslog, - include_pcap, - stop_requested, - ) - ) - write_manifest(case_directory, udid, duration_seconds, started_at, results) - write_hashes(case_directory) - failures = [result for result in results if result.status == "failed"] - if stop_requested.is_set(): - case_status = "cancelled" - elif failures: - case_status = "partial" - else: - case_status = "completed" - emit( - "case-finished", - "Evidence collection finished", - {"path": str(case_directory), "failures": len(failures), "status": case_status}, - ) - if required_error is not None: - raise required_error - if failures and not stop_requested.is_set(): - raise PartialCollectionError( - f"Collection finalized with {len(failures)} coverage gap(s). Review manifest.json and the command outputs in {case_directory}" - ) - return case_directory - - -def parse_args(arguments: Sequence[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Collect read-oriented iOS diagnostics through pymobiledevice3") - parser.add_argument("--udid", required=True) - destination = parser.add_mutually_exclusive_group(required=True) - destination.add_argument("--output-root", type=Path) - destination.add_argument("--case-directory", type=Path) - parser.add_argument("--duration", required=True, type=int) - parser.add_argument("--include-syslog", action="store_true") - parser.add_argument("--include-oslog", action="store_true") - parser.add_argument("--include-pcap", action="store_true") - parser.add_argument("--include-screenshot", action="store_true") - parser.add_argument("--include-crash-pull", action="store_true") - return parser.parse_args(arguments) - - -def main() -> int: - options = parse_args(sys.argv[1:]) - stop_requested = threading.Event() - - def request_stop(signum: int, frame: object) -> None: - del signum, frame - stop_requested.set() - emit("stop-requested", "Stopping after the current operation", {}) - - signal.signal(signal.SIGINT, request_stop) - signal.signal(signal.SIGTERM, request_stop) - try: - run_collection( - options.udid, - options.output_root, - options.case_directory, - options.duration, - options.include_syslog, - options.include_oslog, - options.include_pcap, - options.include_screenshot, - options.include_crash_pull, - stop_requested, - ) - except PartialCollectionError as error: - emit("partial", str(error), {"error_type": type(error).__name__}) - return 2 - except (CollectionError, OSError, ValueError) as error: - emit("fatal", str(error), {"error_type": type(error).__name__}) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/ios_developer_toolkit/command_catalog.py b/ios_developer_toolkit/command_catalog.py deleted file mode 100644 index 05aee8f..0000000 --- a/ios_developer_toolkit/command_catalog.py +++ /dev/null @@ -1,269 +0,0 @@ -from __future__ import annotations - -import math -import re -from dataclasses import dataclass -from pathlib import Path -from typing import Literal, Mapping -from urllib.parse import urlparse - - -RiskLevel = Literal["read-only", "host-write", "device-change"] -ParameterKind = Literal[ - "bundle-id", - "float-latitude", - "float-longitude", - "local-directory", - "output-file", - "pid", - "remote-path", - "url", -] - - -class CommandCatalogError(ValueError): - pass - - -@dataclass(frozen=True) -class ParameterSpec: - identifier: str - label: str - description: str - kind: ParameterKind - initial_value: str - - -@dataclass(frozen=True) -class CommandPreset: - identifier: str - title: str - category: str - summary: str - advanced_notes: str - argument_template: tuple[str, ...] - parameters: tuple[ParameterSpec, ...] - risk: RiskLevel - requires_device: bool - requires_developer_services: bool - long_running: bool - manpage_path: tuple[str, ...] - - -@dataclass(frozen=True) -class ManPageEntry: - title: str - category: str - command_path: tuple[str, ...] - - def display_name(self) -> str: - command = "pymobiledevice3" if not self.command_path else " ".join(self.command_path) - return f"{self.title} · {command}" - - -def parameter( - identifier: str, - label: str, - description: str, - kind: ParameterKind, - initial_value: str, -) -> ParameterSpec: - return ParameterSpec(identifier, label, description, kind, initial_value) - - -def command_presets() -> tuple[CommandPreset, ...]: - documents = Path.home() / "Documents" - captures = documents - return ( - CommandPreset("devices", "Connected devices", "Device Basics", "List USB and Wi-Fi devices known to usbmuxd.", "This is the best first check. It does not prove the device is unlocked or that every service is available.", ("usbmux", "list"), (), "read-only", False, False, False, ("usbmux", "list")), - CommandPreset("lockdown", "Lockdown overview", "Device Basics", "Read pairing-visible device identity and configuration values.", "Lockdown is the gateway used to start many other device services. Returned keys describe exposed state, not unrestricted iOS internals.", ("lockdown", "info"), (), "read-only", True, False, False, ("lockdown", "info")), - CommandPreset("activation", "Activation state", "Device Basics", "Query the current Apple activation state.", "This preset only reads state. Activation and deactivation commands are intentionally left in Man Pages because they change device state.", ("activation", "state"), (), "read-only", True, False, False, ("activation", "state")), - CommandPreset("developer-mode", "Developer Mode status", "Device Basics", "Query whether Developer Mode is enabled.", "Developer Mode is required for most DVT and CoreDevice commands but does not itself mount the Developer Disk Image.", ("amfi", "developer-mode-status"), (), "read-only", True, False, False, ("amfi", "developer-mode-status")), - CommandPreset("diagnostics", "Diagnostics overview", "Device Basics", "Read the diagnostics relay overview.", "Available values vary by hardware and iOS build. A missing field is a coverage limit, not proof of absence.", ("diagnostics", "info"), (), "read-only", True, False, False, ("diagnostics", "info")), - CommandPreset("battery", "Battery snapshot", "Device Basics", "Read a point-in-time battery diagnostics record.", "Battery current, voltage, temperature, and charging fields are device/build dependent.", ("diagnostics", "battery", "single"), (), "read-only", True, False, False, ("diagnostics", "battery", "single")), - CommandPreset("ioregistry", "IORegistry snapshot", "Device Basics", "Read the IORegistry view exposed by diagnostics relay.", "This can be large. It is not the Mac IORegistry and does not imply kernel-level access to the phone.", ("diagnostics", "ioregistry"), (), "read-only", True, False, False, ("diagnostics", "ioregistry")), - CommandPreset("mobilegestalt", "MobileGestalt values", "Device Basics", "Query the known MobileGestalt key set.", "Only keys supported by the pinned client and permitted by the device are returned.", ("diagnostics", "mg"), (), "read-only", True, False, False, ("diagnostics", "mg")), - CommandPreset("processes", "Process list", "Device Basics", "List processes through diagnosticsd.", "For richer start times and metrics, compare with the DVT process presets after mounting developer support.", ("processes", "ps"), (), "read-only", True, False, False, ("processes", "ps")), - CommandPreset("profiles", "Configuration profiles", "Device Basics", "List profiles exposed by the profile service.", "A listed profile shows configuration state, not who actively uses it. Install, removal, supervision, and erase commands are not one-click presets.", ("profile", "list"), (), "read-only", True, False, False, ("profile", "list")), - CommandPreset("provisioning", "Provisioning profiles", "Device Basics", "List installed developer provisioning profiles.", "Provisioning metadata describes possible app authorization. It is not evidence that a provisioned app executed.", ("provision", "list"), (), "read-only", True, False, False, ("provision", "list")), - CommandPreset("orientation", "Screen orientation", "Device Basics", "Read the current SpringBoard screen orientation.", "This uses SpringBoardServices and does not capture screen content.", ("springboard", "orientation"), (), "read-only", True, False, False, ("springboard", "orientation")), - CommandPreset("icon-metrics", "Home Screen icon metrics", "Device Basics", "Read SpringBoard Home Screen spacing and layout metrics.", "Metrics vary by device class, display mode, and iOS version.", ("springboard", "homescreen-icon-metrics"), (), "read-only", True, False, False, ("springboard", "homescreen-icon-metrics")), - CommandPreset("apps-list", "Installed app inventory", "Apps & Files", "List apps through Installation Proxy.", "Use the dedicated Installed Apps page for filtering, sizes, copying bundle IDs, and confirmed uninstall.", ("apps", "list"), (), "read-only", True, False, False, ("apps", "list")), - CommandPreset("apps-query", "Query one app", "Apps & Files", "Read detailed metadata for one bundle identifier.", "The bundle must be visible to Installation Proxy.", ("apps", "query", "{bundle_id}"), (parameter("bundle_id", "Bundle identifier", "Example: com.apple.mobilesafari", "bundle-id", "com.apple.mobilesafari"),), "read-only", True, False, False, ("apps", "query")), - CommandPreset("afc-list", "List AFC directory", "Apps & Files", "List a path under the AFC media root.", "AFC is rooted at /var/mobile/Media on a stock device. It is not unrestricted access to / or private app containers.", ("afc", "ls", "{remote_path}"), (parameter("remote_path", "AFC path", "Path relative to the AFC service root.", "remote-path", "/"),), "read-only", True, False, False, ("afc", "ls")), - CommandPreset("dvt-list", "List DVT path", "Apps & Files", "Ask the DVT developer service to list a path.", "The service performs this operation with Apple-defined privileges. Seeing / does not mean the host has root or raw filesystem access.", ("developer", "dvt", "ls", "{remote_path}"), (parameter("remote_path", "Device path", "Absolute path in the DVT service view.", "remote-path", "/"),), "read-only", True, True, False, ("developer", "dvt", "ls")), - CommandPreset("crash-list", "Crash report inventory", "Apps & Files", "List crash, panic, Jetsam, and diagnostic reports exposed by the crash service.", "Availability depends on retention and the service view. A missing report is not proof that an event did not occur.", ("crash", "ls"), (), "read-only", True, False, False, ("crash", "ls")), - CommandPreset("crash-pull", "Pull crash reports", "Apps & Files", "Copy all available crash reports into a local folder.", "This writes sensitive device artifacts to the Mac. Review and sanitize before sharing.", ("crash", "pull", "{directory}"), (parameter("directory", "Destination folder", "Existing parent or new crash-report folder.", "local-directory", str(captures / "iOS Crash Reports")),), "host-write", True, False, False, ("crash", "pull")), - CommandPreset("syslog", "Live syslog", "Logging & Capture", "Stream the standard device syslog until stopped.", "Use device-side log messages to explain client failures. Logs can contain identifiers and private content.", ("syslog", "live"), (), "read-only", True, False, True, ("syslog", "live")), - CommandPreset("oslog", "DVT Unified Logging", "Logging & Capture", "Stream the richer DVT OS log until stopped.", "This path can expose more detail than classic syslog but is less stable and requires developer services.", ("developer", "dvt", "oslog"), (), "read-only", True, True, True, ("developer", "dvt", "oslog")), - CommandPreset("pcap", "Network PCAP", "Logging & Capture", "Capture device packets into a local PCAP file.", "TLS, QUIC, VPN, and other encryption remain encrypted. Process metadata and endpoints are clues, not proof of purpose.", ("pcap", "--out", "{output_file}"), (parameter("output_file", "PCAP output", "Local capture file opened by Wireshark or tcpdump.", "output-file", str(captures / "ios-device-network.pcap")),), "host-write", True, False, True, ("pcap",)), - CommandPreset("btlogger", "Bluetooth HCI capture", "Logging & Capture", "Capture Apple Bluetooth HCI logging as pcapng.", "This is device-service logging, not a generic over-the-air Bluetooth sniffer. Availability is build dependent.", ("btlogger", "--format", "pcapng", "{output_file}"), (parameter("output_file", "PCAPNG output", "Local Bluetooth capture file.", "output-file", str(captures / "ios-device-bluetooth.pcapng")),), "host-write", True, False, True, ("btlogger",)), - CommandPreset("dvt-device", "DVT device information", "Developer & DVT", "Read the developer instrumentation device-information record.", "Compare this with Lockdown overview; each protocol exposes a different view.", ("developer", "dvt", "device-information"), (), "read-only", True, True, False, ("developer", "dvt", "device-information")), - CommandPreset("dvt-proclist", "DVT process list", "Developer & DVT", "List processes and start times through DVT.", "This is a developer instrumentation view and may differ from diagnosticsd process output.", ("developer", "dvt", "proclist"), (), "read-only", True, True, False, ("developer", "dvt", "proclist")), - CommandPreset("dvt-applist", "DVT application list", "Developer & DVT", "List applications through DVT instrumentation.", "Compare with Installation Proxy when investigating differences between service views.", ("developer", "dvt", "applist"), (), "read-only", True, True, False, ("developer", "dvt", "applist")), - CommandPreset("dvt-netstat", "DVT network activity", "Developer & DVT", "Stream DVT network-connection events until stopped.", "This is an event stream, not a point-in-time snapshot or packet capture. Use Stop when the observation window ends, then correlate events with PCAP and logs for attribution.", ("developer", "dvt", "netstat"), (), "read-only", True, True, True, ("developer", "dvt", "netstat")), - CommandPreset("dvt-pid-check", "Check process identifier", "Developer & DVT", "Ask DVT whether a specific PID is currently running.", "Process identifiers are short-lived. Correlate the result with a fresh DVT process list before attribution.", ("developer", "dvt", "is-running-pid", "{pid}"), (parameter("pid", "Process ID", "Positive numeric PID from a current process inventory.", "pid", "1"),), "read-only", True, True, False, ("developer", "dvt", "is-running-pid")), - CommandPreset("dvt-energy", "Process energy monitor", "Developer & DVT", "Stream DVT energy telemetry for one PID.", "Energy readings are developer instrumentation estimates and should be interpreted with workload and foreground state.", ("developer", "dvt", "energy", "{pid}"), (parameter("pid", "Process ID", "Positive numeric PID from a current process inventory.", "pid", "1"),), "read-only", True, True, True, ("developer", "dvt", "energy")), - CommandPreset("sysmon-system", "System metrics snapshot", "Developer & DVT", "Read a point-in-time DVT system metrics record.", "Useful for load context before starting a longer process monitor.", ("developer", "dvt", "sysmon", "system"), (), "read-only", True, True, False, ("developer", "dvt", "sysmon", "system")), - CommandPreset("sysmon-process", "Process metrics snapshot", "Developer & DVT", "Read a detailed point-in-time process metrics table.", "Includes richer CPU and memory fields than the diagnostics process list.", ("developer", "dvt", "sysmon", "process", "single"), (), "read-only", True, True, False, ("developer", "dvt", "sysmon", "process", "single")), - CommandPreset("graphics", "Graphics monitor", "Developer & DVT", "Stream graphics and frame-related instrumentation until stopped.", "Interpret changes in context of foreground activity and display state.", ("developer", "dvt", "graphics"), (), "read-only", True, True, True, ("developer", "dvt", "graphics")), - CommandPreset("notifications", "DVT notifications", "Developer & DVT", "Stream developer memory and application notifications.", "This is distinct from the top-level Darwin notification proxy.", ("developer", "dvt", "notifications"), (), "read-only", True, True, True, ("developer", "dvt", "notifications")), - CommandPreset("core-profile", "KDebug trace parser", "Developer & DVT", "Stream and parse CoreProfile/KDebug events.", "Advanced and high-volume. It is strace-like instrumentation, not an entitlement or security-boundary bypass.", ("developer", "dvt", "core-profile-session", "parse-live"), (), "read-only", True, True, True, ("developer", "dvt", "core-profile-session", "parse-live")), - CommandPreset("screenshot", "Device screenshot", "Developer & DVT", "Capture the current screen to a local PNG.", "The screenshot may contain highly sensitive visible content and notifications.", ("developer", "dvt", "screenshot", "{output_file}"), (parameter("output_file", "PNG output", "Local screenshot path.", "output-file", str(captures / "ios-device-screen.png")),), "host-write", True, True, False, ("developer", "dvt", "screenshot")), - CommandPreset("core-device-info", "CoreDevice information", "Developer & DVT", "Read device information through the modern CoreDevice service.", "CoreDevice uses the iOS 17+ RemoteXPC/RSD architecture and may expose a different record from Lockdown or DVT.", ("developer", "core-device", "get-device-info"), (), "read-only", True, True, False, ("developer", "core-device", "get-device-info")), - CommandPreset("core-display", "CoreDevice display info", "Developer & DVT", "Read current display-service information.", "Service advertisement and fields vary by iOS build.", ("developer", "core-device", "get-display-info"), (), "read-only", True, True, False, ("developer", "core-device", "get-display-info")), - CommandPreset("core-lock", "CoreDevice lock state", "Developer & DVT", "Read the device lock-state service.", "This reports service-visible state and does not bypass the passcode.", ("developer", "core-device", "get-lockstate"), (), "read-only", True, True, False, ("developer", "core-device", "get-lockstate")), - CommandPreset("core-processes", "CoreDevice processes", "Developer & DVT", "List processes through CoreDevice.", "Compare this protocol view with diagnosticsd and DVT before interpreting a difference as anomalous.", ("developer", "core-device", "list-processes"), (), "read-only", True, True, False, ("developer", "core-device", "list-processes")), - CommandPreset("core-apps", "CoreDevice applications", "Developer & DVT", "Request applications through the CoreDevice app stream.", "iOS 26-era DDIs may keep the underlying stream open instead of returning a finite snapshot. Use Stop if service completion does not arrive, and compare with Installation Proxy and DVT application inventory.", ("developer", "core-device", "list-apps"), (), "read-only", True, True, True, ("developer", "core-device", "list-apps")), - CommandPreset("mounted-images", "Mounted developer images", "Developer & DVT", "List Developer Disk Images currently known to the image mounter.", "Use this to verify the result of a mount rather than repeatedly mounting.", ("mounter", "list"), (), "read-only", True, False, False, ("mounter", "list")), - CommandPreset("personalization", "Personalization identifiers", "Developer & DVT", "Query identifiers used for personalized developer images.", "These values are device-specific and sensitive. Querying them does not itself mount an image.", ("mounter", "query-personalization-identifiers"), (), "read-only", True, False, False, ("mounter", "query-personalization-identifiers")), - CommandPreset("bonjour-rsd", "Discover RSD devices", "Web & Discovery", "Browse for Remote Service Discovery devices over Bonjour.", "Discovery shows advertised peers; it does not prove pairing or service authorization.", ("bonjour", "rsd"), (), "read-only", False, False, True, ("bonjour", "rsd")), - CommandPreset("remote-browse", "Browse RemoteXPC", "Web & Discovery", "Browse RemoteXPC-capable devices and tunnel endpoints.", "This is discovery only. Pairing and tunnel commands are documented separately because they create state.", ("remote", "browse"), (), "read-only", False, False, True, ("remote", "browse")), - CommandPreset("web-tabs", "Safari and WebView tabs", "Web & Discovery", "List pages visible to iOS Web Inspector.", "Web Inspector and Remote Automation must be enabled in Safari settings. Private pages may be exposed.", ("webinspector", "opened-tabs"), (), "read-only", True, False, False, ("webinspector", "opened-tabs")), - CommandPreset("launch-app", "Launch application", "Device Actions", "Launch an application by bundle identifier through DVT.", "The command changes foreground/process state and defaults to killing an existing instance before launch.", ("developer", "dvt", "launch", "{bundle_id}"), (parameter("bundle_id", "Bundle identifier", "Application to launch.", "bundle-id", "com.apple.mobilesafari"),), "device-change", True, True, False, ("developer", "dvt", "launch")), - CommandPreset("open-url", "Open URL in Safari", "Device Actions", "Launch a URL through Web Inspector automation.", "Requires Web Inspector and Remote Automation. This changes device UI and can make a network request to the entered destination.", ("webinspector", "launch", "{url}"), (parameter("url", "URL", "HTTP or HTTPS URL to open on the device.", "url", "https://example.com"),), "device-change", True, False, False, ("webinspector", "launch")), - CommandPreset("location-set", "Set simulated location", "Device Actions", "Set a DVT-simulated latitude and longitude on iOS 17+.", "Simulation changes location reported to participating software until cleared or the relevant service state ends. It does not alter GPS hardware.", ("developer", "dvt", "simulate-location", "set", "--", "{latitude}", "{longitude}"), (parameter("latitude", "Latitude", "Decimal degrees from -90 to 90.", "float-latitude", "34.0522"), parameter("longitude", "Longitude", "Decimal degrees from -180 to 180.", "float-longitude", "-118.2437")), "device-change", True, True, False, ("developer", "dvt", "simulate-location", "set")), - CommandPreset("location-clear", "Clear simulated location", "Device Actions", "Clear the current DVT location simulation.", "Use this after location testing so later observations use normal location sources.", ("developer", "dvt", "simulate-location", "clear"), (), "device-change", True, True, False, ("developer", "dvt", "simulate-location", "clear")), - ) - - -def preset_categories() -> tuple[str, ...]: - return tuple(dict.fromkeys(preset.category for preset in command_presets())) - - -def preset_by_identifier(identifier: str) -> CommandPreset: - matching = tuple(preset for preset in command_presets() if preset.identifier == identifier) - if len(matching) != 1: - raise CommandCatalogError(f"expected one preset for {identifier!r}, found {len(matching)}") - return matching[0] - - -def validate_parameter(spec: ParameterSpec, raw_value: str) -> str: - value = raw_value.strip() - if not value: - raise CommandCatalogError(f"{spec.label} is required") - if spec.kind == "bundle-id": - if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.-]{0,254}", value) is None or "." not in value: - raise CommandCatalogError(f"{spec.label} must be a valid bundle identifier") - return value - if spec.kind == "pid": - if not value.isdecimal() or int(value) <= 0: - raise CommandCatalogError(f"{spec.label} must be a positive process identifier") - return value - if spec.kind == "remote-path": - if not value.startswith("/") or "\x00" in value: - raise CommandCatalogError(f"{spec.label} must be an absolute device-service path") - return value - if spec.kind == "url": - parsed = urlparse(value) - if parsed.scheme not in ("http", "https") or not parsed.netloc: - raise CommandCatalogError(f"{spec.label} must be an HTTP or HTTPS URL with a host") - return value - if spec.kind == "float-latitude": - return validate_coordinate(value, spec.label, -90.0, 90.0) - if spec.kind == "float-longitude": - return validate_coordinate(value, spec.label, -180.0, 180.0) - path = Path(value).expanduser() - if spec.kind == "local-directory": - parent = path if path.exists() else path.parent - if not parent.is_dir(): - raise CommandCatalogError(f"{spec.label} parent directory does not exist: {parent}") - return str(path.resolve()) - if spec.kind == "output-file": - if not path.parent.is_dir(): - raise CommandCatalogError(f"{spec.label} parent directory does not exist: {path.parent}") - return str(path.resolve()) - raise CommandCatalogError(f"unsupported parameter kind: {spec.kind}") - - -def validate_coordinate(value: str, label: str, minimum: float, maximum: float) -> str: - try: - coordinate = float(value) - except ValueError as error: - raise CommandCatalogError(f"{label} must be a decimal number") from error - if not math.isfinite(coordinate): - raise CommandCatalogError(f"{label} must be a finite decimal number") - if coordinate < minimum or coordinate > maximum: - raise CommandCatalogError(f"{label} must be between {minimum:g} and {maximum:g}") - return value - - -def render_preset_arguments(preset: CommandPreset, raw_values: Mapping[str, str]) -> tuple[str, ...]: - expected_keys = {spec.identifier for spec in preset.parameters} - received_keys = set(raw_values) - if received_keys != expected_keys: - missing = sorted(expected_keys - received_keys) - unexpected = sorted(received_keys - expected_keys) - raise CommandCatalogError(f"parameter mismatch; missing={missing}, unexpected={unexpected}") - values = {spec.identifier: validate_parameter(spec, raw_values[spec.identifier]) for spec in preset.parameters} - arguments: list[str] = [] - for token in preset.argument_template: - placeholder = re.fullmatch(r"\{([a-z_]+)\}", token) - arguments.append(values[placeholder.group(1)] if placeholder is not None else token) - return tuple(arguments) - - -def risk_title(risk: RiskLevel) -> str: - if risk == "read-only": - return "READ-ORIENTED" - if risk == "host-write": - return "WRITES LOCAL FILES" - return "CHANGES DEVICE STATE" - - -def command_part_title(value: str) -> str: - abbreviations = { - "afc": "AFC", - "amfi": "AMFI", - "dvt": "DVT", - "hid": "HID", - "idam": "IDAM", - "pcap": "PCAP", - "rsd": "RSD", - "wda": "WDA", - } - return abbreviations.get(value, value.replace("-", " ").title()) - - -def manpage_entries() -> tuple[ManPageEntry, ...]: - top_level = ( - "activation", "afc", "amfi", "apps", "backup2", "btlogger", "bonjour", "companion", - "crash", "cryptex", "developer", "diagnostics", "idam", "lockdown", "mounter", - "notification", "pcap", "power-assertion", "processes", "profile", "provision", "remote", - "restore", "springboard", "syslog", "usbmux", "webinspector", "version", - ) - entries: list[ManPageEntry] = [ManPageEntry("All commands", "Overview", ())] - entries.extend(ManPageEntry(command_part_title(group), "Top-level groups", (group,)) for group in top_level) - developer_paths = ( - ("developer", "dvt"), - ("developer", "dvt", "sysmon"), - ("developer", "dvt", "sysmon", "process"), - ("developer", "dvt", "sysmon", "process", "monitor"), - ("developer", "dvt", "core-profile-session"), - ("developer", "dvt", "simulate-location"), - ("developer", "simulate-location"), - ("developer", "dvt", "condition"), - ("developer", "core-device"), - ("developer", "core-device", "display"), - ("developer", "core-device", "hid"), - ("developer", "core-device", "location"), - ("developer", "debugserver"), - ("developer", "accessibility"), - ("developer", "wda"), - ) - entries.extend( - ManPageEntry(" › ".join(command_part_title(part) for part in path), "Developer services", path) - for path in developer_paths - ) - workflow_paths = ( - ("apps", "list"), ("apps", "query"), ("apps", "install"), ("apps", "uninstall"), - ("backup2", "backup"), ("backup2", "restore"), ("backup2", "encryption"), - ("crash", "pull"), ("diagnostics", "battery"), ("mounter", "auto-mount"), - ("pcap",), ("syslog", "live"), ("syslog", "collect"), - ("webinspector", "launch"), ("webinspector", "opened-tabs"), - ) - entries.extend( - ManPageEntry(" › ".join(command_part_title(part) for part in path), "Common leaf commands", path) - for path in workflow_paths - ) - return tuple(entries) diff --git a/ios_developer_toolkit/command_drift.py b/ios_developer_toolkit/command_drift.py deleted file mode 100644 index 6d022dd..0000000 --- a/ios_developer_toolkit/command_drift.py +++ /dev/null @@ -1,139 +0,0 @@ -from __future__ import annotations - -import re -from dataclasses import dataclass -from typing import Literal - -from ios_developer_toolkit.command_catalog import CommandPreset - - -CommandDriftState = Literal["verified", "route-missing", "option-mismatch", "check-failed", "not-checked"] -ANSI_CONTROL_SEQUENCE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") - - -@dataclass(frozen=True) -class HelpRouteProbe: - command_path: tuple[str, ...] - exit_code: int | None - stdout: str - stderr: str - error: str | None - - -@dataclass(frozen=True) -class CommandDriftResult: - preset_identifier: str - preset_title: str - command_path: tuple[str, ...] - expected_options: tuple[str, ...] - state: CommandDriftState - detail: str - - -def help_routes_for_presets(presets: tuple[CommandPreset, ...]) -> tuple[tuple[str, ...], ...]: - return tuple(dict.fromkeys(preset.manpage_path for preset in presets)) - - -def expected_option_tokens(preset: CommandPreset) -> tuple[str, ...]: - return tuple(dict.fromkeys(token for token in preset.argument_template if token.startswith("--") and token != "--")) - - -def help_includes_option(help_text: str, option: str) -> bool: - pattern = rf"(? tuple[CommandDriftResult, ...]: - probes_by_path = {probe.command_path: probe for probe in probes} - return tuple(_evaluate_preset(preset, probes_by_path.get(preset.manpage_path)) for preset in presets) - - -def _evaluate_preset(preset: CommandPreset, probe: HelpRouteProbe | None) -> CommandDriftResult: - expected_options = expected_option_tokens(preset) - if probe is None: - return CommandDriftResult( - preset.identifier, - preset.title, - preset.manpage_path, - expected_options, - "not-checked", - "The live-help route was not checked.", - ) - if probe.error is not None: - return CommandDriftResult( - preset.identifier, - preset.title, - preset.manpage_path, - expected_options, - "check-failed", - probe.error, - ) - if probe.exit_code != 0: - output = probe.stderr or probe.stdout - suffix = f" Output: {output.strip()}" if output.strip() else "" - return CommandDriftResult( - preset.identifier, - preset.title, - preset.manpage_path, - expected_options, - "route-missing", - f"Live help exited with status {probe.exit_code}.{suffix}", - ) - help_text = "\n".join(output for output in (probe.stdout, probe.stderr) if output.strip()) - if not help_text: - return CommandDriftResult( - preset.identifier, - preset.title, - preset.manpage_path, - expected_options, - "check-failed", - "Live help exited successfully but returned no output.", - ) - missing_options = tuple(option for option in expected_options if not help_includes_option(help_text, option)) - if missing_options: - return CommandDriftResult( - preset.identifier, - preset.title, - preset.manpage_path, - expected_options, - "option-mismatch", - f"The installed help did not advertise: {', '.join(missing_options)}.", - ) - return CommandDriftResult( - preset.identifier, - preset.title, - preset.manpage_path, - expected_options, - "verified", - "Route and expected option flags match the installed live help.", - ) - - -def render_command_drift_report(results: tuple[CommandDriftResult, ...]) -> str: - counts = {state: sum(result.state == state for result in results) for state in _command_drift_states()} - lines = ( - "Command-drift report", - ( - f"Verified: {counts['verified']} | Route missing: {counts['route-missing']} | " - f"Option mismatch: {counts['option-mismatch']} | Check failed: {counts['check-failed']} | " - f"Not checked: {counts['not-checked']}" - ), - "", - ) - details = tuple( - f"[{result.state.upper()}] {result.preset_title} — pymobiledevice3 {' '.join(result.command_path)}\n" - f" {result.detail}" - for result in results - if result.state != "verified" - ) - if not details: - return "\n".join((*lines, "All guided preset routes and expected option flags matched live help.")) - return "\n".join((*lines, *details)) - - -def _command_drift_states() -> tuple[CommandDriftState, ...]: - return ("verified", "route-missing", "option-mismatch", "check-failed", "not-checked") diff --git a/ios_developer_toolkit/connection_diagnostics.py b/ios_developer_toolkit/connection_diagnostics.py deleted file mode 100644 index 73788e3..0000000 --- a/ios_developer_toolkit/connection_diagnostics.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from datetime import datetime, timezone -from typing import Literal - - -ConnectionDiagnosticState = Literal[ - "not-scanned", - "launch-failed", - "discovery-failed", - "discovery-timed-out", - "malformed-output", - "no-devices", - "devices-available", -] - - -@dataclass(frozen=True) -class ConnectionDiagnostic: - """A privacy-safe result for one usbmux discovery attempt.""" - - state: ConnectionDiagnosticState - checked_at: str - device_count: int - detail: str - - def report(self) -> str: - device_count = f" Devices available: {self.device_count}." if self.state == "devices-available" else "" - return f"{self.detail}{device_count} Checked: {self.checked_at}." - - -def initial_connection_diagnostic() -> ConnectionDiagnostic: - return _diagnostic("not-scanned", 0, "Discovery has not run yet") - - -def launch_failed_connection_diagnostic() -> ConnectionDiagnostic: - return _diagnostic("launch-failed", 0, "The usbmux discovery process could not start") - - -def failed_connection_diagnostic(exit_code: int) -> ConnectionDiagnostic: - return _diagnostic("discovery-failed", 0, f"usbmux discovery exited with status {exit_code}") - - -def process_error_connection_diagnostic() -> ConnectionDiagnostic: - return _diagnostic("discovery-failed", 0, "The usbmux discovery process stopped before returning a device list") - - -def timed_out_connection_diagnostic() -> ConnectionDiagnostic: - return _diagnostic("discovery-timed-out", 0, "usbmux discovery exceeded its 10-second limit") - - -def malformed_output_connection_diagnostic() -> ConnectionDiagnostic: - return _diagnostic("malformed-output", 0, "usbmux returned output that was not a valid device list") - - -def devices_connection_diagnostic(device_count: int) -> ConnectionDiagnostic: - if device_count < 0: - raise ValueError(f"Device count cannot be negative: {device_count}") - if device_count == 0: - return _diagnostic("no-devices", 0, "usbmux completed successfully but found no devices") - return _diagnostic("devices-available", device_count, "usbmux completed successfully") - - -def _diagnostic(state: ConnectionDiagnosticState, device_count: int, detail: str) -> ConnectionDiagnostic: - return ConnectionDiagnostic(state, datetime.now(timezone.utc).isoformat(), device_count, detail) diff --git a/ios_developer_toolkit/demo_mode.py b/ios_developer_toolkit/demo_mode.py deleted file mode 100644 index 9179e4e..0000000 --- a/ios_developer_toolkit/demo_mode.py +++ /dev/null @@ -1,27 +0,0 @@ -from __future__ import annotations - -from ios_developer_toolkit.models import IOSDevice - - -DEMO_DEVICE_IDENTIFIER = "DEMO-IPHONE-15-PRO" - - -def demo_device() -> IOSDevice: - """Return the clearly simulated device shown by the local demo interface.""" - return IOSDevice( - identifier=DEMO_DEVICE_IDENTIFIER, - name="Demo iPhone 15 Pro (simulated)", - product_type="iPhone16,1", - product_version="26.3.1", - build_version="23D123", - connection_type="Demo", - ) - - -def demo_connection_banner() -> str: - """Describe the demo boundary without implying a physical device connection.""" - return ( - "DEMO MODE — Showing a simulated iPhone for walkthroughs and screenshots. " - "No physical device is connected; mounts, device commands, logs, backups, location changes, " - "sideloading, and evidence collection remain disabled." - ) diff --git a/ios_developer_toolkit/device_compatibility.py b/ios_developer_toolkit/device_compatibility.py deleted file mode 100644 index 71977a0..0000000 --- a/ios_developer_toolkit/device_compatibility.py +++ /dev/null @@ -1,373 +0,0 @@ -from __future__ import annotations - -import hashlib -import html -import json -import os -import platform -from dataclasses import asdict, dataclass -from pathlib import Path -from sys import version as python_runtime_version -from typing import Mapping, Sequence - -from ios_developer_toolkit.capability_matrix import CapabilityMatrixError, CapabilityResult, parse_capability_result -from ios_developer_toolkit.models import IOSDevice -from ios_developer_toolkit.support_bundle import installed_package_version, sanitize_support_text - - -class DeviceCompatibilityError(ValueError): - """Raised when local real-device compatibility history is invalid or cannot be preserved.""" - - -@dataclass(frozen=True) -class DeviceCompatibilityObservation: - """A completed capability probe observed against one locally connected physical device.""" - - recorded_at: str - device_fingerprint: str - product_type: str - product_version: str - build_version: str - connection_type: str - results: tuple[CapabilityResult, ...] - - -@dataclass(frozen=True) -class CompatibilityReportEnvironment: - """Host and toolchain metadata that explains one exported compatibility report.""" - - toolkit_version: str - macos_version: str - architecture: str - python_version: str - runtime: str - pymobiledevice3_version: str - pyside6_version: str - - -@dataclass(frozen=True) -class CompatibilityReport: - """A shareable report that deliberately omits stable device identity.""" - - generated_at: str - environment: CompatibilityReportEnvironment - observations: tuple[DeviceCompatibilityObservation, ...] - - -def compatibility_history_path(home: Path) -> Path: - return ( - home.expanduser().resolve() - / "Library" - / "Application Support" - / "iOS Developer Toolkit" - / "Compatibility" - / "real-device-observations.jsonl" - ) - - -def device_fingerprint(identifier: str) -> str: - if not identifier: - raise DeviceCompatibilityError("Cannot create a compatibility observation without a device identifier") - return hashlib.sha256(identifier.encode("utf-8")).hexdigest()[:16] - - -def _redact_identifier(value: str, identifier: str) -> str: - return value.replace(identifier, "") - - -def _redacted_result(result: CapabilityResult, identifier: str) -> CapabilityResult: - return CapabilityResult( - identifier=result.identifier, - layer=result.layer, - title=result.title, - state=result.state, - summary=_redact_identifier(result.summary, identifier), - evidence=_redact_identifier(result.evidence, identifier), - remediation=_redact_identifier(result.remediation, identifier), - ) - - -def create_observation( - recorded_at: str, - device: IOSDevice, - results: Sequence[CapabilityResult], -) -> DeviceCompatibilityObservation: - if not results: - raise DeviceCompatibilityError("Cannot record a compatibility observation without capability results") - identifiers = tuple(result.identifier for result in results) - if len(set(identifiers)) != len(identifiers): - raise DeviceCompatibilityError("Capability observation contains duplicate result identifiers") - redacted_results = tuple(_redacted_result(result, device.identifier) for result in results) - return DeviceCompatibilityObservation( - recorded_at=recorded_at, - device_fingerprint=device_fingerprint(device.identifier), - product_type=device.product_type, - product_version=device.product_version, - build_version=device.build_version, - connection_type=device.connection_type, - results=redacted_results, - ) - - -def observation_mapping(observation: DeviceCompatibilityObservation) -> dict[str, object]: - return { - "schema_version": 1, - "recorded_at": observation.recorded_at, - "device_fingerprint": observation.device_fingerprint, - "product_type": observation.product_type, - "product_version": observation.product_version, - "build_version": observation.build_version, - "connection_type": observation.connection_type, - "results": [asdict(result) for result in observation.results], - } - - -def _required_string(record: Mapping[str, object], key: str) -> str: - value = record.get(key) - if not isinstance(value, str) or not value: - raise DeviceCompatibilityError(f"Compatibility observation is missing required string field: {key}") - return value - - -def parse_observation(record: Mapping[str, object]) -> DeviceCompatibilityObservation: - if record.get("schema_version") != 1: - raise DeviceCompatibilityError("Compatibility observation has an unsupported schema version") - raw_results = record.get("results") - if not isinstance(raw_results, list) or not raw_results: - raise DeviceCompatibilityError("Compatibility observation must contain one or more results") - results: list[CapabilityResult] = [] - for raw_result in raw_results: - if not isinstance(raw_result, dict): - raise DeviceCompatibilityError("Compatibility observation results must be JSON objects") - try: - results.append(parse_capability_result(raw_result)) - except CapabilityMatrixError as error: - raise DeviceCompatibilityError(f"Compatibility observation contains an invalid result: {error}") from error - fingerprint = _required_string(record, "device_fingerprint") - if len(fingerprint) != 16 or any(character not in "0123456789abcdef" for character in fingerprint): - raise DeviceCompatibilityError("Compatibility observation has an invalid device fingerprint") - return DeviceCompatibilityObservation( - recorded_at=_required_string(record, "recorded_at"), - device_fingerprint=fingerprint, - product_type=_required_string(record, "product_type"), - product_version=_required_string(record, "product_version"), - build_version=_required_string(record, "build_version"), - connection_type=_required_string(record, "connection_type"), - results=tuple(results), - ) - - -def append_observation(path: Path, observation: DeviceCompatibilityObservation) -> None: - record = json.dumps(observation_mapping(observation), sort_keys=True) + "\n" - try: - path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - with path.open("a", encoding="utf-8") as output: - output.write(record) - output.flush() - os.fsync(output.fileno()) - path.chmod(0o600) - except OSError as error: - raise DeviceCompatibilityError(f"Could not preserve compatibility observation at {path}: {error}") from error - - -def load_observations(path: Path) -> tuple[DeviceCompatibilityObservation, ...]: - if not path.exists(): - return () - try: - lines = path.read_text(encoding="utf-8").splitlines() - except OSError as error: - raise DeviceCompatibilityError(f"Could not read compatibility history at {path}: {error}") from error - observations: list[DeviceCompatibilityObservation] = [] - for line_number, line in enumerate(lines, start=1): - if not line.strip(): - continue - try: - payload = json.loads(line) - except json.JSONDecodeError as error: - raise DeviceCompatibilityError(f"Compatibility history line {line_number} is not valid JSON: {error}") from error - if not isinstance(payload, dict): - raise DeviceCompatibilityError(f"Compatibility history line {line_number} must be a JSON object") - observations.append(parse_observation(payload)) - return tuple(observations) - - -def latest_observations( - observations: Sequence[DeviceCompatibilityObservation], -) -> tuple[DeviceCompatibilityObservation, ...]: - latest_by_device: dict[str, DeviceCompatibilityObservation] = {} - for observation in observations: - existing = latest_by_device.get(observation.device_fingerprint) - if existing is None or observation.recorded_at > existing.recorded_at: - latest_by_device[observation.device_fingerprint] = observation - return tuple(sorted(latest_by_device.values(), key=lambda item: item.recorded_at)) - - -def current_report_environment(toolkit_version: str, frozen_runtime: bool) -> CompatibilityReportEnvironment: - if not toolkit_version.strip(): - raise DeviceCompatibilityError("Toolkit version is required for a compatibility report") - return CompatibilityReportEnvironment( - toolkit_version=toolkit_version, - macos_version=platform.mac_ver()[0] or "unavailable", - architecture=platform.machine() or "unavailable", - python_version=platform.python_version() or python_runtime_version.split()[0], - runtime="frozen-app" if frozen_runtime else "source-python", - pymobiledevice3_version=installed_package_version("pymobiledevice3"), - pyside6_version=installed_package_version("PySide6"), - ) - - -def create_compatibility_report( - generated_at: str, - environment: CompatibilityReportEnvironment, - observations: Sequence[DeviceCompatibilityObservation], -) -> CompatibilityReport: - if not generated_at.strip(): - raise DeviceCompatibilityError("Compatibility report generation time is required") - environment_values = asdict(environment) - missing_environment_fields = tuple( - key for key, value in environment_values.items() if not isinstance(value, str) or not value.strip() - ) - if missing_environment_fields: - raise DeviceCompatibilityError( - f"Compatibility report environment fields must be non-empty: {missing_environment_fields}" - ) - latest = latest_observations(observations) - if not latest: - raise DeviceCompatibilityError("Cannot export a compatibility report without completed device observations") - return CompatibilityReport(generated_at, environment, latest) - - -def compatibility_report_mapping(report: CompatibilityReport) -> dict[str, object]: - devices: list[dict[str, object]] = [] - for index, observation in enumerate(report.observations, start=1): - devices.append( - { - "report_device": f"device-{index}", - "observed_at": observation.recorded_at, - "product_type": observation.product_type, - "product_version": observation.product_version, - "build_version": observation.build_version, - "connection_type": observation.connection_type, - "capabilities": [ - { - "identifier": result.identifier, - "layer": result.layer, - "title": result.title, - "state": result.state, - "summary": sanitize_support_text(result.summary, ()), - "evidence": sanitize_support_text(result.evidence, ()), - "remediation": sanitize_support_text(result.remediation, ()), - } - for result in observation.results - ], - } - ) - return { - "schema_version": 1, - "generated_at": report.generated_at, - "environment": asdict(report.environment), - "privacy": { - "raw_device_identifiers_included": False, - "device_names_included": False, - "device_fingerprints_included": False, - "local_paths_redacted": True, - "warning": ( - "Device model, iOS version and build, connection type, host/toolchain versions, and sanitized " - "capability evidence remain in this report. Review it before sharing." - ), - }, - "devices": devices, - } - - -def render_compatibility_json(report: CompatibilityReport) -> str: - return json.dumps(compatibility_report_mapping(report), indent=2, sort_keys=True) + "\n" - - -def _markdown_cell(value: str) -> str: - sanitized = sanitize_support_text(value, ()) - return html.escape(sanitized, quote=False).replace("|", "\\|").replace("\n", "
") - - -def render_compatibility_markdown(report: CompatibilityReport) -> str: - environment = report.environment - lines = [ - "# iOS Developer Toolkit compatibility report", - "", - f"Generated: `{report.generated_at}`", - "", - "> This sanitized export omits device names, raw identifiers, and stored device fingerprints. It retains device " - "model, iOS version/build, connection type, host/toolchain versions, and sanitized capability evidence. Review " - "it before sharing.", - "", - "## Host and toolchain", - "", - "| Item | Value |", - "|---|---|", - f"| Toolkit | {_markdown_cell(environment.toolkit_version)} |", - f"| macOS | {_markdown_cell(environment.macos_version)} |", - f"| Architecture | {_markdown_cell(environment.architecture)} |", - f"| Runtime | {_markdown_cell(environment.runtime)} |", - f"| Python | {_markdown_cell(environment.python_version)} |", - f"| pymobiledevice3 | {_markdown_cell(environment.pymobiledevice3_version)} |", - f"| PySide6 | {_markdown_cell(environment.pyside6_version)} |", - "", - ] - for index, observation in enumerate(report.observations, start=1): - lines.extend( - ( - f"## Observed device {index}", - "", - "| Item | Value |", - "|---|---|", - f"| Observed at | {_markdown_cell(observation.recorded_at)} |", - f"| Product type | {_markdown_cell(observation.product_type)} |", - f"| iOS | {_markdown_cell(observation.product_version)} |", - f"| Build | {_markdown_cell(observation.build_version)} |", - f"| Connection | {_markdown_cell(observation.connection_type)} |", - "", - "| State | Layer | Capability | Summary | Evidence | Next step |", - "|---|---|---|---|---|---|", - ) - ) - for result in observation.results: - lines.append( - f"| {_markdown_cell(result.state)} | {_markdown_cell(result.layer)} | " - f"{_markdown_cell(result.title)} | {_markdown_cell(result.summary)} | " - f"{_markdown_cell(result.evidence)} | {_markdown_cell(result.remediation)} |" - ) - lines.append("") - return "\n".join(lines).rstrip() + "\n" - - -def _write_private_report(destination: Path, expected_suffix: str, content: str) -> Path: - path = destination.expanduser().resolve() - if path.suffix.casefold() != expected_suffix: - raise DeviceCompatibilityError( - f"Compatibility report destination must end in {expected_suffix}: {path}" - ) - if not path.parent.is_dir(): - raise DeviceCompatibilityError(f"Compatibility report parent directory does not exist: {path.parent}") - try: - descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - except FileExistsError as error: - raise DeviceCompatibilityError(f"Refusing to overwrite existing compatibility report: {path}") from error - except OSError as error: - raise DeviceCompatibilityError(f"Could not create compatibility report at {path}: {error}") from error - try: - with os.fdopen(descriptor, "wb") as output: - output.write(content.encode("utf-8")) - output.flush() - os.fsync(output.fileno()) - except OSError as error: - path.unlink(missing_ok=True) - raise DeviceCompatibilityError(f"Could not write compatibility report at {path}: {error}") from error - return path - - -def write_compatibility_json_report(destination: Path, report: CompatibilityReport) -> Path: - return _write_private_report(destination, ".json", render_compatibility_json(report)) - - -def write_compatibility_markdown_report(destination: Path, report: CompatibilityReport) -> Path: - return _write_private_report(destination, ".md", render_compatibility_markdown(report)) diff --git a/ios_developer_toolkit/entrypoint.py b/ios_developer_toolkit/entrypoint.py deleted file mode 100644 index 997c2e3..0000000 --- a/ios_developer_toolkit/entrypoint.py +++ /dev/null @@ -1,600 +0,0 @@ -from __future__ import annotations - -import os -import sys -import tempfile -import time -from collections.abc import Callable, Sequence -from pathlib import Path - -from ios_developer_toolkit.qt_process import finite_process_request - -from ios_developer_toolkit.runtime import ( - ExecutableCommand, - INTERNAL_PYMOBILEDEVICE3_FLAG, - INTERNAL_SMOKE_TEST_FLAG, - INTERNAL_WORKER_FLAG, - ToolkitWorker, - pymobiledevice3_command, -) - - -def normalized_exit_code(value: int | None) -> int: - return 0 if value is None else value - - -def invoke_argv_main(label: str, arguments: Sequence[str], main_function: Callable[[], int | None]) -> int: - original_arguments = tuple(sys.argv) - sys.argv = [label, *arguments] - try: - return normalized_exit_code(main_function()) - finally: - sys.argv = list(original_arguments) - - -def parsed_worker(value: str) -> ToolkitWorker: - if value == "backup": - return "backup" - if value == "capability": - return "capability" - if value == "collector": - return "collector" - if value == "ipa-inspector": - return "ipa-inspector" - if value == "local-ddi": - return "local-ddi" - raise ValueError(f"Unsupported internal worker: {value}") - - -def run_worker(worker: ToolkitWorker, arguments: Sequence[str]) -> int: - if worker == "backup": - from ios_developer_toolkit.backup_worker import main - - return invoke_argv_main("ios-developer-toolkit-backup", arguments, main) - if worker == "capability": - from ios_developer_toolkit.capability_matrix_worker import main - - return main(arguments) - if worker == "collector": - from ios_developer_toolkit.collector import main - - return invoke_argv_main("ios-developer-toolkit-collector", arguments, main) - if worker == "ipa-inspector": - from ios_developer_toolkit.ipa_inspector import main - - return invoke_argv_main("ios-developer-toolkit-ipa-inspector", arguments, main) - if worker == "local-ddi": - from ios_developer_toolkit.local_ddi import main - - return invoke_argv_main("ios-developer-toolkit-local-ddi", arguments, main) - raise ValueError(f"Unsupported internal worker: {worker}") - - -def run_pymobiledevice3(arguments: Sequence[str]) -> int: - from pymobiledevice3.__main__ import main - - return invoke_argv_main("pymobiledevice3", arguments, main) - - -def run_smoke_test(arguments: Sequence[str]) -> int: - if arguments: - raise ValueError(f"Internal smoke test does not accept arguments: {tuple(arguments)}") - os.environ["QT_QPA_PLATFORM"] = "offscreen" - from PySide6.QtCore import SIGNAL, Qt - from PySide6.QtWidgets import QApplication, QLabel, QPlainTextEdit, QPushButton, QTableWidget - - from ios_developer_toolkit.action_palette import ActionPaletteDialog - from ios_developer_toolkit.app import MainWindow - from ios_developer_toolkit.backup_protocol import BackupRequest - from ios_developer_toolkit.external_tools import external_tool_spec, inspect_external_tool_executable - from ios_developer_toolkit.mvt_connector import create_mvt_analysis_request - from ios_developer_toolkit.operation_history import OperationHistoryDialog - - application = QApplication(["ios-developer-toolkit-smoke-test"]) - window = MainWindow() - window._scanner.stop() - window._devices_changed(()) - application.processEvents() - buttons = tuple(window.findChildren(QPushButton)) - missing_identifiers = tuple(button.text() for button in buttons if not button.objectName()) - disconnected = tuple( - button.objectName() for button in buttons if button.receivers(SIGNAL("clicked(bool)")) == 0 - ) - if missing_identifiers: - raise RuntimeError(f"GUI buttons are missing stable identifiers: {missing_identifiers}") - if disconnected: - raise RuntimeError(f"GUI buttons are missing click handlers: {disconnected}") - for button_name in ( - "coreDeviceDetailsButton", - "listRVIInterfacesButton", - "openXcodeProjectButton", - "openXcodeArtifactButton", - ): - button = window.findChild(QPushButton, button_name) - if button is None: - raise RuntimeError(f"GUI Xcode handoff action is missing: {button_name}") - coredevice_button = window.findChild(QPushButton, "coreDeviceDetailsButton") - rvi_button = window.findChild(QPushButton, "listRVIInterfacesButton") - if coredevice_button is None or coredevice_button.isEnabled(): - raise RuntimeError("GUI CoreDevice handoff must require a selected device") - if rvi_button is None or not rvi_button.isEnabled(): - raise RuntimeError("GUI RVI status handoff should be available without a selected device") - navigation_actions = { - "homeOpenDevice&DDIButton": "Device & DDI", - "homeOpenCapabilityMatrixButton": "Capability Matrix", - "homeOpenLocationLabButton": "Location Lab", - "homeOpenCommandCenterButton": "Command Center", - "homeOpenEvidenceCaptureButton": "Evidence Capture", - "homeOpenManPagesButton": "Man Pages", - "openLogPresetsButton": "Command Center", - "openEvidenceCaptureButton": "Evidence Capture", - } - for button_name, destination in navigation_actions.items(): - button = window.findChild(QPushButton, button_name) - if button is None: - raise RuntimeError(f"GUI navigation action is missing: {button_name}") - button.click() - application.processEvents() - selected_item = window.navigation_list.currentItem() - if selected_item is None or selected_item.text() != destination: - actual_destination = None if selected_item is None else selected_item.text() - raise RuntimeError( - f"GUI navigation action {button_name} reached {actual_destination!r}, expected {destination!r}" - ) - for button_name in ("cancelCommandDriftButton", "copyCommandDriftReportButton"): - button = window.findChild(QPushButton, button_name) - if button is None: - raise RuntimeError(f"GUI command-drift action is missing: {button_name}") - if button.isEnabled(): - raise RuntimeError(f"GUI command-drift action should be disabled before a drift check: {button_name}") - command_drift_button = window.findChild(QPushButton, "checkCommandDriftButton") - command_drift_copy_button = window.findChild(QPushButton, "copyCommandDriftReportButton") - if command_drift_button is None or command_drift_copy_button is None: - raise RuntimeError("GUI command-drift controls are incomplete") - command_drift_button.click() - command_drift_deadline = time.monotonic() + 180 - while not command_drift_copy_button.isEnabled() and time.monotonic() < command_drift_deadline: - application.processEvents() - time.sleep(0.001) - application.processEvents() - if not command_drift_copy_button.isEnabled(): - window.cancel_command_drift_check() - raise RuntimeError("GUI command-drift check did not complete within its bounded smoke-test window") - command_drift_report = window.command_drift_output.toPlainText() - if "Verified: 49" not in command_drift_report or "All guided preset routes" not in command_drift_report: - raise RuntimeError(f"GUI command-drift check reported incompatible guidance: {command_drift_report}") - command_readiness = window.findChild(QLabel, "commandReadinessStatus") - if command_readiness is None or not command_readiness.text().strip(): - raise RuntimeError("GUI selected-command readiness has no visible state") - command_readiness_button = window.findChild(QPushButton, "runCommandReadinessButton") - if command_readiness_button is None: - raise RuntimeError("GUI selected-command readiness action is missing") - if command_readiness_button.isEnabled(): - raise RuntimeError("GUI selected-command readiness must remain disabled without a selected device") - window.navigate_to_page("Man Pages") - refresh_manpage_button = window.findChild(QPushButton, "refreshManpageButton") - if refresh_manpage_button is None or not refresh_manpage_button.isEnabled(): - raise RuntimeError("GUI live-help action is unavailable") - selected_manpage = window.selected_manpage_entry() - if selected_manpage is None: - raise RuntimeError("GUI live-help index has no selected route") - refresh_manpage_button.click() - live_help_deadline = time.monotonic() + 20 - while window._manpage_controller.is_running() and time.monotonic() < live_help_deadline: - application.processEvents() - application.processEvents() - if window._manpage_controller.is_running(): - raise RuntimeError("GUI live-help action did not complete within its bounded smoke-test window") - if selected_manpage.command_path not in window._manpage_cache: - raise RuntimeError(f"GUI live-help action did not cache successful output: {window.manpage_output.toPlainText()}") - action_palette_button = window.findChild(QPushButton, "actionPaletteButton") - if action_palette_button is None: - raise RuntimeError("GUI action-palette launcher is missing") - action_palette_entries = window._eligible_action_palette_entries() - action_palette_identifiers = {entry.identifier for entry in action_palette_entries} - if "preset:devices" not in action_palette_identifiers: - raise RuntimeError("GUI action palette omitted the host-only devices preset") - device_only_presets = { - f"preset:{preset.identifier}" for preset in window._presets if preset.requires_device - } - exposed_device_only_presets = device_only_presets & action_palette_identifiers - if exposed_device_only_presets: - raise RuntimeError( - f"GUI action palette exposed device-only presets without a selected device: " - f"{sorted(exposed_device_only_presets)}" - ) - action_palette_dialog = ActionPaletteDialog(action_palette_entries, window) - action_palette_dialog.search.setText("session manifest") - application.processEvents() - if action_palette_dialog.results.count() != 1: - raise RuntimeError("GUI action-palette search did not isolate the session-manifest utility") - action_palette_item = action_palette_dialog.results.item(0) - if action_palette_item.data(Qt.ItemDataRole.UserRole) != "utility:session-activity": - raise RuntimeError("GUI action-palette search selected an unexpected entry") - action_palette_dialog.close() - with tempfile.TemporaryDirectory() as mvt_temporary_directory: - mvt_root = Path(mvt_temporary_directory) - mvt_executable = mvt_root / "mvt-ios" - mvt_executable.write_text( - "#!/bin/sh\n" - "for argument in \"$@\"; do\n" - " if [ \"$argument\" = \"version\" ]; then\n" - " printf \"MVT - Mobile Verification Toolkit\\nVersion: 2026.9.21\\n\"\n" - " exit 0\n" - " fi\n" - "done\n" - "output=\"\"\n" - "while [ \"$#\" -gt 0 ]; do\n" - " if [ \"$1\" = \"--output\" ]; then\n" - " shift\n" - " output=\"$1\"\n" - " fi\n" - " shift\n" - "done\n" - "mkdir -p \"$output\"\n" - "printf \"{\\\"synthetic\\\":true}\\n\" > \"$output/info.json\"\n" - "printf \"Synthetic MVT analysis completed\\n\"\n", - encoding="utf-8", - ) - mvt_executable.chmod(0o700) - mvt_backup = mvt_root / "backup" - mvt_backup.mkdir() - (mvt_backup / "Manifest.db").write_bytes(b"synthetic manifest") - (mvt_backup / "Info.plist").write_bytes(b"synthetic info") - mvt_output = mvt_root / "analysis" - window.mvt_executable_field.setText(str(mvt_executable)) - window.validate_mvt_from_ui() - mvt_validation_deadline = time.monotonic() + 10 - while window._mvt_controller.is_running() and time.monotonic() < mvt_validation_deadline: - application.processEvents() - time.sleep(0.001) - application.processEvents() - if window._mvt_controller.is_running() or window._mvt_installation is None: - raise RuntimeError(f"GUI MVT validation did not complete: {window.mvt_output.toPlainText()}") - if window._mvt_installation.version != "2026.9.21": - raise RuntimeError("GUI MVT validation retained an unexpected version") - request = create_mvt_analysis_request( - window._mvt_installation, - mvt_backup, - mvt_output, - (), - False, - False, - False, - ) - window._start_mvt_analysis_request(request) - mvt_analysis_deadline = time.monotonic() + 10 - while window._mvt_controller.is_running() and time.monotonic() < mvt_analysis_deadline: - application.processEvents() - time.sleep(0.001) - application.processEvents() - if window._mvt_controller.is_running(): - window._mvt_controller.cancel() - raise RuntimeError("GUI MVT analysis did not complete within its bounded smoke-test window") - if not (mvt_output / "info.json").is_file(): - raise RuntimeError(f"GUI MVT analysis did not create isolated output: {window.mvt_output.toPlainText()}") - if "does not prove" not in window.mvt_status.text(): - raise RuntimeError("GUI MVT completion omitted the no-clean-device interpretation boundary") - with tempfile.TemporaryDirectory() as external_tool_temporary_directory: - external_root = Path(external_tool_temporary_directory) - go_ios_path = external_root / "ios" - go_ios_path.write_text( - "#!/bin/sh\n" - "if [ \"$1\" = \"--version\" ]; then\n" - " printf '{\"version\":\"1.3.2-smoke\"}\\n'\n" - " exit 0\n" - "fi\n" - "printf '{\"deviceList\":[{\"name\":\"Synthetic iPhone\",\"udid\":\"REDACTED\"}]}\\n'\n", - encoding="utf-8", - ) - go_ios_path.chmod(0o700) - go_ios_spec = external_tool_spec("go-ios") - go_ios_executable = inspect_external_tool_executable(go_ios_spec, go_ios_path) - window._external_tool_fields["go-ios"].setText(str(go_ios_path)) - window._start_external_tool_process( - "go-ios", - "validate", - go_ios_executable, - go_ios_spec.version_arguments, - ) - external_validation_deadline = time.monotonic() + 10 - while window._external_tool_controller.is_running() and time.monotonic() < external_validation_deadline: - application.processEvents() - time.sleep(0.001) - application.processEvents() - installation = window._external_tool_installations.get("go-ios") - if window._external_tool_controller.is_running() or installation is None: - raise RuntimeError( - f"GUI go-ios validation did not complete: {window._external_tool_outputs['go-ios'].toPlainText()}" - ) - if installation.version_or_build != "1.3.2-smoke": - raise RuntimeError("GUI go-ios adapter retained an unexpected version") - window._start_external_tool_process( - "go-ios", - "probe", - installation.executable, - go_ios_spec.probe_arguments, - ) - external_probe_deadline = time.monotonic() + 10 - while window._external_tool_controller.is_running() and time.monotonic() < external_probe_deadline: - application.processEvents() - time.sleep(0.001) - application.processEvents() - external_output = window._external_tool_outputs["go-ios"].toPlainText() - if window._external_tool_controller.is_running() or "Synthetic iPhone" not in external_output: - raise RuntimeError(f"GUI go-ios probe did not preserve output: {external_output}") - if "not a toolkit capability verdict" not in window._external_tool_statuses["go-ios"].text(): - raise RuntimeError("GUI external-tool probe omitted the interpretation boundary") - expected_shortcuts = { - "shortcutRetryDeviceScan", - "shortcutShowActionPalette", - "shortcutFocusWorkspaceNavigation", - "shortcutFocusWorkspaceSearch", - "shortcutShowKeyboardReference", - "shortcutPreviousWorkspace", - "shortcutNextWorkspace", - "shortcutOpenCommandCenter", - "shortcutOpenEcosystemTools", - "shortcutOpenManPages", - "shortcutOpenScopeAndSafety", - } - actual_shortcuts = {shortcut.objectName() for shortcut in window._keyboard_shortcuts} - missing_shortcuts = expected_shortcuts - actual_shortcuts - if missing_shortcuts: - raise RuntimeError(f"GUI keyboard shortcuts are missing: {sorted(missing_shortcuts)}") - connection_diagnostic = window.findChild(QLabel, "connectionDiagnosticValue") - if connection_diagnostic is None: - raise RuntimeError("GUI connection diagnostic is missing") - if not connection_diagnostic.text().strip(): - raise RuntimeError("GUI connection diagnostic has no visible state") - support_bundle_button = window.findChild(QPushButton, "createSupportBundleButton") - if support_bundle_button is None: - raise RuntimeError("GUI support-bundle action is missing") - for button_name in ("exportWorkspaceProfileButton", "importWorkspaceProfileButton"): - if window.findChild(QPushButton, button_name) is None: - raise RuntimeError(f"GUI workspace-profile action is missing: {button_name}") - workspace_profile = window._workspace_profile_from_controls( - "Smoke profile", - "Synthetic non-sensitive control defaults", - ) - window._apply_workspace_profile(workspace_profile) - if window.navigation_list.currentItem() is None: - raise RuntimeError("GUI workspace-profile application lost the selected workspace") - demo_mode_button = window.findChild(QPushButton, "demoModeButton") - if demo_mode_button is None: - raise RuntimeError("GUI demo-mode action is missing") - demo_mode_button.click() - application.processEvents() - if window.selected_device() is not None: - raise RuntimeError("Demo mode must not expose a simulated device to operational actions") - if not window.connection_banner.text().startswith("DEMO MODE"): - raise RuntimeError("Demo mode must visibly identify the simulated connection") - if window.mount_button.isEnabled(): - raise RuntimeError("Demo mode must disable device-affecting actions") - live_log_button = window.findChild(QPushButton, "openUnifiedLogButton") - if live_log_button is None or live_log_button.isEnabled(): - raise RuntimeError("Demo mode must disable live-device log collection") - demo_mode_button.click() - application.processEvents() - window._start_action( - pymobiledevice3_command(), - ("version",), - {}, - "smoke", - 20_000, - window._host_operation_context("Smoke Device Action", "Device & DDI", "pymobiledevice3 host command", ()), - ) - action_deadline = time.monotonic() + 20 - while window._action_controller.is_running() and time.monotonic() < action_deadline: - application.processEvents() - time.sleep(0.001) - application.processEvents() - if window._action_controller.is_running(): - window._action_controller.cancel() - raise RuntimeError("GUI DDI action controller did not complete within its bounded smoke-test window") - action_output = window.action_output.toPlainText() - if "[finished: succeeded; exit 0]" not in action_output: - raise RuntimeError(f"GUI DDI action controller failed its host-only smoke command: {action_output}") - window.console_input.setText("version") - window.console_run_button.click() - console_deadline = time.monotonic() + 20 - while window._console_controller.is_running() and time.monotonic() < console_deadline: - application.processEvents() - time.sleep(0.001) - application.processEvents() - if window._console_controller.is_running(): - window._console_controller.cancel() - raise RuntimeError("GUI Command Center controller did not complete within its smoke-test window") - console_output = window.console_output.toPlainText() - if "[finished: succeeded; exit 0]" not in console_output: - raise RuntimeError(f"GUI Command Center controller failed its host-only smoke command: {console_output}") - synthetic_inventory = ( - '{"com.example.toolkit-smoke": {' - '"CFBundleIdentifier": "com.example.toolkit-smoke", ' - '"CFBundleDisplayName": "Toolkit Smoke", ' - '"ApplicationType": "User"}}' - ) - window._apps_context = "inventory" - window._begin_operation( - "installed-apps", - window._host_operation_context("Smoke App Inventory", "Installed Apps", "synthetic smoke process", ()), - ) - window._apps_controller.start( - finite_process_request( - ExecutableCommand(Path("/usr/bin/printf"), ()), - (synthetic_inventory,), - {}, - 5_000, - 500, - ) - ) - apps_deadline = time.monotonic() + 10 - while window._apps_controller.is_running() and time.monotonic() < apps_deadline: - application.processEvents() - time.sleep(0.001) - application.processEvents() - if window._apps_controller.is_running(): - window._apps_controller.cancel() - raise RuntimeError("GUI installed-apps controller did not complete within its bounded smoke-test window") - if window.installed_apps_table.rowCount() != 1 or "Loaded 1 installed" not in window.apps_status.text(): - raise RuntimeError( - f"GUI installed-apps controller did not render its synthetic inventory: {window.apps_status.text()}" - ) - synthetic_inspection = ( - '{"ipa_path":"/tmp/ToolkitSmoke.ipa","app_name":"Toolkit Smoke",' - '"bundle_identifier":"com.example.toolkit-smoke","version":"1.0","build":"1",' - '"minimum_os_version":"17.0","executable_name":"ToolkitSmoke",' - '"provisioning":{"status":"present","name":"Toolkit Smoke Profile","uuid":"smoke-uuid",' - '"team_identifiers":["SMOKETEAM"],' - '"application_identifier":"SMOKETEAM.com.example.toolkit-smoke",' - '"expiration":"2030-01-01T00:00:00+00:00","provisioned_device_count":1,' - '"provisions_all_devices":false,"get_task_allow":true,' - '"developer_certificate_count":1,"detail":"Synthetic smoke-test profile"},' - '"signature":{"status":"valid","identifier":"com.example.toolkit-smoke",' - '"team_identifier":"SMOKETEAM","authorities":["Toolkit Smoke Authority"],' - '"detail":"Synthetic smoke-test signature"}}' - ) - window._begin_operation( - "ipa-inspection", - window._host_operation_context("Smoke IPA Inspection", "Sideload IPA", "synthetic smoke process", ()), - ) - window._ipa_inspection_controller.start( - finite_process_request( - ExecutableCommand(Path("/usr/bin/printf"), ()), - (synthetic_inspection,), - {}, - 5_000, - 500, - ) - ) - inspection_deadline = time.monotonic() + 10 - while window._ipa_inspection_controller.is_running() and time.monotonic() < inspection_deadline: - application.processEvents() - time.sleep(0.001) - application.processEvents() - if window._ipa_inspection_controller.is_running(): - window._ipa_inspection_controller.cancel() - raise RuntimeError("GUI IPA inspection controller did not complete within its bounded smoke-test window") - if window._ipa_inspection is None or window._ipa_inspection.signature.status != "valid": - raise RuntimeError(f"GUI IPA inspection controller rejected typed metadata: {window.sideload_status.text()}") - window._sideload_context = "smoke" - window._begin_operation( - "sideload-ipa", - window._host_operation_context("Smoke IPA Operation", "Sideload IPA", "synthetic smoke process", ()), - ) - window._sideload_controller.start( - finite_process_request( - ExecutableCommand(Path("/usr/bin/printf"), ()), - ("Synthetic IPA operation output",), - {}, - 5_000, - 500, - ) - ) - sideload_deadline = time.monotonic() + 10 - while window._sideload_controller.is_running() and time.monotonic() < sideload_deadline: - application.processEvents() - time.sleep(0.001) - application.processEvents() - if window._sideload_controller.is_running(): - window._sideload_controller.cancel() - raise RuntimeError("GUI IPA installation controller did not complete within its bounded smoke-test window") - sideload_output = window.sideload_output.toPlainText() - if "Synthetic IPA operation output" not in sideload_output or "[finished: succeeded; exit 0]" not in sideload_output: - raise RuntimeError(f"GUI IPA installation controller did not preserve its output: {sideload_output}") - if window.sideload_status.text() != "Smoke completed successfully.": - raise RuntimeError(f"GUI IPA installation controller reported the wrong state: {window.sideload_status.text()}") - backup_smoke_program = ( - 'BEGIN { delete ARGV[1] } END { print "{\\"event\\":\\"encryption-state\\",' - '\\"message\\":\\"Synthetic encryption status.\\",\\"encrypted\\":true}" }' - ) - window._backup_action = "status" - window._begin_operation( - "backup", - window._host_operation_context("Smoke Backup Status", "Backup", "synthetic smoke process", ()), - ) - window._backup_controller.start( - ExecutableCommand(Path("/usr/bin/awk"), (backup_smoke_program,)), - "status", - BackupRequest("toolkit-smoke-device", Path("/tmp"), False, "", False), - {}, - 500, - ) - backup_deadline = time.monotonic() + 10 - while window._backup_controller.is_running() and time.monotonic() < backup_deadline: - application.processEvents() - time.sleep(0.001) - application.processEvents() - if window._backup_controller.is_running(): - window._backup_controller.cancel() - raise RuntimeError("GUI backup controller did not complete within its bounded smoke-test window") - if window._backup_encryption_state is not True: - raise RuntimeError(f"GUI backup controller did not apply its typed event: {window.backup_output.toPlainText()}") - if "Encryption status check completed." not in window.backup_output.toPlainText(): - raise RuntimeError(f"GUI backup controller reported the wrong completion: {window.backup_output.toPlainText()}") - synthetic_collection_event = ( - '{"event":"case-finished","message":"Synthetic evidence finalization.",' - '"timestamp":"2026-09-22T00:00:00+00:00","path":"/tmp/toolkit-smoke-case",' - '"status":"completed","failures":0}' - ) - window._collection_case_finished = False - window._begin_operation( - "evidence-collection", - window._host_operation_context( - "Smoke Evidence Collection", - "Evidence Capture", - "synthetic smoke process", - (), - ), - ) - window._collection_controller.start( - ExecutableCommand(Path("/usr/bin/printf"), ()), - (synthetic_collection_event,), - {}, - 5_000, - ) - collection_deadline = time.monotonic() + 10 - while window._collection_controller.is_running() and time.monotonic() < collection_deadline: - application.processEvents() - time.sleep(0.001) - application.processEvents() - if window._collection_controller.is_running(): - window._collection_controller.cancel() - raise RuntimeError("GUI evidence controller did not complete within its bounded smoke-test window") - if not window._collection_case_finished or window._last_case_path != Path("/tmp/toolkit-smoke-case"): - raise RuntimeError(f"GUI evidence controller did not apply finalization: {window.collection_output.toPlainText()}") - if "Collection process finished: succeeded; exit 0." not in window.collection_output.toPlainText(): - raise RuntimeError(f"GUI evidence controller reported the wrong completion: {window.collection_output.toPlainText()}") - if len(window._operation_records) < 8: - raise RuntimeError(f"GUI session activity did not correlate typed operations: {len(window._operation_records)}") - activity_button = window.findChild(QPushButton, "sessionActivityButton") - if activity_button is None or f"({len(window._operation_records)})" not in activity_button.text(): - raise RuntimeError("GUI session activity count did not update after typed operations") - activity_dialog = OperationHistoryDialog(window._operation_records, window) - activity_table = activity_dialog.findChild(QTableWidget, "sessionActivityTable") - activity_preview = activity_dialog.findChild(QPlainTextEdit, "sessionActivityManifestPreview") - if activity_table is None or activity_table.rowCount() != len(window._operation_records): - raise RuntimeError("GUI session activity dialog did not render every typed operation") - if activity_preview is None or '"raw_output_included": false' not in activity_preview.toPlainText(): - raise RuntimeError("GUI session activity manifest preview did not preserve its raw-output boundary") - activity_dialog.close() - window.close() - application.processEvents() - print(f"GUI smoke test passed with {len(buttons)} action buttons", flush=True) - return 0 - - -def dispatch_internal(arguments: Sequence[str]) -> int | None: - if not arguments: - return None - mode = arguments[0] - remaining = arguments[1:] - if mode == INTERNAL_PYMOBILEDEVICE3_FLAG: - return run_pymobiledevice3(remaining) - if mode == INTERNAL_SMOKE_TEST_FLAG: - return run_smoke_test(remaining) - if mode == INTERNAL_WORKER_FLAG: - if not remaining: - raise ValueError(f"{INTERNAL_WORKER_FLAG} requires a worker name") - return run_worker(parsed_worker(remaining[0]), remaining[1:]) - return None diff --git a/ios_developer_toolkit/external_tools.py b/ios_developer_toolkit/external_tools.py deleted file mode 100644 index ef1d17c..0000000 --- a/ios_developer_toolkit/external_tools.py +++ /dev/null @@ -1,247 +0,0 @@ -from __future__ import annotations - -import json -import os -import re -from dataclasses import dataclass -from pathlib import Path -from typing import Literal, Mapping - -from ios_developer_toolkit.file_integrity import sha256_file -from ios_developer_toolkit.runtime import ExecutableCommand - - -ExternalToolIdentifier = Literal["go-ios", "idb", "ipsw"] - - -class ExternalToolValidationError(ValueError): - """Raised when an optional external-tool adapter cannot be used safely.""" - - -@dataclass(frozen=True) -class ExternalToolSpec: - identifier: ExternalToolIdentifier - title: str - executable_name: str - repository_url: str - documentation_url: str - license_name: str - setup_commands: tuple[str, ...] - version_arguments: tuple[str, ...] - probe_arguments: tuple[str, ...] - probe_title: str - scope: str - environment_keys_to_remove: tuple[str, ...] - - -@dataclass(frozen=True) -class ExternalToolExecutable: - spec_identifier: ExternalToolIdentifier - path: Path - sha256: str - - -@dataclass(frozen=True) -class ExternalToolInstallation: - executable: ExternalToolExecutable - version_or_build: str - - -def external_tool_specs() -> tuple[ExternalToolSpec, ...]: - return ( - ExternalToolSpec( - "go-ios", - "go-ios", - "ios", - "https://github.com/danielpaulus/go-ios", - "https://github.com/danielpaulus/go-ios#readme", - "MIT", - ("npm install -g go-ios",), - ("--version",), - ("list", "--details"), - "List devices with go-ios", - "A separate cross-platform iOS protocol stack. The probe asks go-ios to enumerate devices using its own pairing and tunnel state.", - ("GO_IOS_DEVICEKIT_URL", "GO_IOS_WDA_URL", "P12_PASSWORD"), - ), - ExternalToolSpec( - "idb", - "Meta idb Companion", - "idb_companion", - "https://github.com/facebook/idb", - "https://fbidb.io/", - "MIT", - ("brew install facebook/fb/idb",), - ("--version",), - ("--list", "1"), - "List idb targets", - "The macOS companion for idb simulator and device automation. The probe lists targets visible to the companion without starting its server mode.", - ("IDB_COMPANION", "IDB_COMPANION_TLS", "IDB_UDID"), - ), - ExternalToolSpec( - "ipsw", - "blacktop ipsw", - "ipsw", - "https://github.com/blacktop/ipsw", - "https://blacktop.github.io/ipsw/", - "MIT", - ("brew install blacktop/tap/ipsw",), - ("version",), - ("idev", "list"), - "List devices with ipsw idev", - "A firmware and Apple-platform research suite. The probe uses its optional idev surface only to enumerate locally visible devices.", - ( - "GITHUB_TOKEN", - "GH_TOKEN", - "IPSW_APPSTORE_API_KEY", - "IPSW_APPSTORE_API_SECRET", - ), - ), - ) - - -def external_tool_spec(identifier: ExternalToolIdentifier) -> ExternalToolSpec: - matches = tuple(spec for spec in external_tool_specs() if spec.identifier == identifier) - if len(matches) != 1: - raise ExternalToolValidationError( - f"Expected one external tool specification for {identifier!r}, found {len(matches)}" - ) - return matches[0] - - -def discover_external_tool_executables( - spec: ExternalToolSpec, - home: Path, - path_environment: str, -) -> tuple[Path, ...]: - path_entries = tuple(Path(entry) for entry in path_environment.split(os.pathsep) if entry) - locations = ( - *(entry / spec.executable_name for entry in path_entries), - home.expanduser() / ".local" / "bin" / spec.executable_name, - Path("/opt/homebrew/bin") / spec.executable_name, - Path("/usr/local/bin") / spec.executable_name, - ) - candidates: list[Path] = [] - for location in locations: - expanded = location.expanduser() - if expanded.is_file() and os.access(expanded, os.X_OK): - resolved = expanded.resolve() - if resolved not in candidates: - candidates.append(resolved) - return tuple(candidates) - - -def inspect_external_tool_executable( - spec: ExternalToolSpec, - path: Path, -) -> ExternalToolExecutable: - expanded = path.expanduser() - if not expanded.is_absolute(): - raise ExternalToolValidationError( - f"{spec.title} executable path must be absolute: {expanded}" - ) - resolved = expanded.resolve() - if not resolved.is_file(): - raise ExternalToolValidationError( - f"{spec.title} executable does not exist: {resolved}" - ) - if not os.access(resolved, os.X_OK): - raise ExternalToolValidationError( - f"{spec.title} path is not executable: {resolved}" - ) - return ExternalToolExecutable(spec.identifier, resolved, sha256_file(resolved)) - - -def validate_external_tool_installation( - spec: ExternalToolSpec, - installation: ExternalToolInstallation, -) -> ExternalToolInstallation: - if installation.executable.spec_identifier != spec.identifier: - raise ExternalToolValidationError( - f"Validated executable belongs to {installation.executable.spec_identifier}, not {spec.identifier}" - ) - current = inspect_external_tool_executable(spec, installation.executable.path) - if current.sha256 != installation.executable.sha256: - raise ExternalToolValidationError( - f"{spec.title} executable changed after validation; validate it again before running a probe" - ) - return installation - - -def external_tool_command( - spec: ExternalToolSpec, - executable: ExternalToolExecutable, -) -> ExecutableCommand: - if executable.spec_identifier != spec.identifier: - raise ExternalToolValidationError( - f"Cannot run {executable.spec_identifier} executable as {spec.identifier}" - ) - environment_arguments = tuple( - argument - for key in spec.environment_keys_to_remove - for argument in ("-u", key) - ) - return ExecutableCommand( - Path("/usr/bin/env"), - (*environment_arguments, str(executable.path)), - ) - - -def external_tool_environment(base: Mapping[str, str], spec: ExternalToolSpec) -> Mapping[str, str]: - environment = { - key: value - for key, value in base.items() - if key not in spec.environment_keys_to_remove - } - environment["NO_COLOR"] = "1" - environment["PYTHONUNBUFFERED"] = "1" - return environment - - -def parse_external_tool_version(spec: ExternalToolSpec, output: str) -> str: - without_ansi = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", output).strip() - if spec.identifier == "go-ios": - return _parse_go_ios_version(without_ansi) - if spec.identifier == "idb": - return _parse_idb_build(without_ansi) - if spec.identifier == "ipsw": - return _parse_ipsw_version(without_ansi) - raise ExternalToolValidationError(f"Unsupported external tool identifier: {spec.identifier}") - - -def _parse_go_ios_version(output: str) -> str: - for payload in _json_object_lines(output): - version = payload.get("version") - if isinstance(version, str) and version.strip(): - return version.strip() - match = re.search(r"(?im)^\s*(?:go-ios\s+)?([A-Za-z0-9][A-Za-z0-9._+-]*)\s*$", output) - if match is None: - raise ExternalToolValidationError("go-ios version output was not recognized") - return match.group(1) - - -def _parse_idb_build(output: str) -> str: - for payload in _json_object_lines(output): - build_date = payload.get("build_date") - build_time = payload.get("build_time") - if isinstance(build_date, str) and build_date.strip() and isinstance(build_time, str) and build_time.strip(): - return f"build {build_date.strip()} {build_time.strip()}" - raise ExternalToolValidationError("idb companion build output did not contain build_date and build_time") - - -def _parse_ipsw_version(output: str) -> str: - match = re.search(r"(?im)^\s*Version:\s*([^,\s]+)", output) - if match is None: - raise ExternalToolValidationError("ipsw version output did not contain a recognizable Version line") - return match.group(1) - - -def _json_object_lines(output: str) -> tuple[dict[str, object], ...]: - objects: list[dict[str, object]] = [] - for line in output.splitlines(): - try: - payload = json.loads(line) - except json.JSONDecodeError: - continue - if isinstance(payload, dict) and all(isinstance(key, str) for key in payload): - objects.append(payload) - return tuple(objects) diff --git a/ios_developer_toolkit/file_integrity.py b/ios_developer_toolkit/file_integrity.py deleted file mode 100644 index e2acddb..0000000 --- a/ios_developer_toolkit/file_integrity.py +++ /dev/null @@ -1,12 +0,0 @@ -from __future__ import annotations - -import hashlib -from pathlib import Path - - -def sha256_file(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as input_file: - for block in iter(lambda: input_file.read(1024 * 1024), b""): - digest.update(block) - return digest.hexdigest() diff --git a/ios_developer_toolkit/gui_pages.py b/ios_developer_toolkit/gui_pages.py deleted file mode 100644 index 7bce10d..0000000 --- a/ios_developer_toolkit/gui_pages.py +++ /dev/null @@ -1,256 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Sequence - -from PySide6.QtGui import QFont -from PySide6.QtWidgets import ( - QFrame, - QGridLayout, - QGroupBox, - QHBoxLayout, - QLabel, - QPushButton, - QTextBrowser, - QVBoxLayout, - QWidget, -) - -from ios_developer_toolkit.live_logs import LogStreamSpec - - -Navigate = Callable[[str], None] -OpenLiveLog = Callable[[str], None] -OpenAction = Callable[[], None] - - -def build_home_page(preset_count: int, manpage_count: int, navigate: Navigate) -> QWidget: - """Build the static landing workspace and delegate navigation to the main window.""" - page = QWidget() - layout = QVBoxLayout(page) - layout.setSpacing(16) - - hero = QFrame() - hero.setObjectName("homeHero") - hero_layout = QVBoxLayout(hero) - heading = QLabel("One trusted connection. Many Apple device services.") - heading.setObjectName("pageTitle") - heading.setFont(QFont(heading.font().family(), 22, QFont.Weight.Bold)) - hero_layout.addWidget(heading) - description = QLabel( - "Use guided workflows for common work, Command Center for one-click pymobiledevice3 presets, " - "and live Man Pages when you need the exact syntax supported by the installed version." - ) - description.setWordWrap(True) - hero_layout.addWidget(description) - stats = QLabel( - f"{preset_count} guided commands • {manpage_count} live help topics • direct execution without a shell" - ) - stats.setObjectName("homeStats") - hero_layout.addWidget(stats) - layout.addWidget(hero) - - workflow_grid = QGridLayout() - cards = ( - ("1", "Connect && prepare", "Trust the device, enable Developer Mode, and mount the correct personalized DDI.", "Device & DDI"), - ("2", "Verify capabilities", "Test host tools, trust, Developer Mode, the DDI, tunnel, CoreDevice, DVT, and Web Inspector.", "Capability Matrix"), - ("3", "Test location", "Set a fixed coordinate or replay a validated GPX route, then explicitly clear the simulated state.", "Location Lab"), - ("4", "Run guided commands", "Choose a category and preset; the GUI validates any required fields and shows the exact command.", "Command Center"), - ("5", "Collect && preserve", "Create a bounded evidence case, encrypted backup, app inventory, PCAP, logs, and crash-report set.", "Evidence Capture"), - ("6", "Learn advanced services", "Browse current help for DVT, CoreDevice, RemoteXPC, Web Inspector, restore, profiles, and more.", "Man Pages"), - ) - for position, (number, title, body, destination) in enumerate(cards): - card = QGroupBox(f"{number}. {title}") - card_layout = QVBoxLayout(card) - body_label = QLabel(body) - body_label.setWordWrap(True) - card_layout.addWidget(body_label, 1) - open_button = QPushButton(f"Open {destination.replace('&', '&&')}") - open_button.setObjectName(f"homeOpen{destination.replace(' ', '')}Button") - open_button.setAccessibleName(f"Open {destination} workspace") - open_button.setAccessibleDescription(f"Navigate to the {destination} workspace.") - open_button.clicked.connect(lambda checked=False, name=destination: navigate(name)) - card_layout.addWidget(open_button) - workflow_grid.addWidget(card, position // 2, position % 2) - layout.addLayout(workflow_grid) - - stack_group = QGroupBox("How the command families fit together") - stack_layout = QVBoxLayout(stack_group) - stack = QLabel( - "USB / Wi-Fi pairing → usbmuxd → lockdownd → AFC, apps, backups, diagnostics, syslog\n" - "iOS 17+ RemoteXPC / RSD → Developer Disk Image → CoreDevice and DVT instrumentation\n" - "Correlate service views: logs + packets + processes + crash reports + backups; no single command is complete evidence." - ) - stack.setObjectName("protocolStackSummary") - stack.setWordWrap(True) - stack_layout.addWidget(stack) - layout.addWidget(stack_group) - layout.addStretch() - return page - - -def build_live_logs_page( - specifications: Sequence[LogStreamSpec], - open_live_log: OpenLiveLog, - open_log_presets: OpenAction, - navigate: Navigate, -) -> QWidget: - """Build the static log-launch workspace and delegate stateful actions to the main window.""" - page = QWidget() - layout = QVBoxLayout(page) - layout.setSpacing(14) - - heading = QLabel("Live Logs") - heading.setObjectName("pageTitle") - heading.setFont(QFont(heading.font().family(), 20, QFont.Weight.Bold)) - layout.addWidget(heading) - explanation = QLabel( - "Open independent scrolling log windows for the selected device. Each window continuously spools the " - "complete raw byte stream to a private local cache while its visible view can be paused, searched, or " - "filtered. Add an optional investigation reference, select visible lines to record classified analyst " - "findings, then export a hashed evidence bundle. Closing a window asks you to save or explicitly discard " - "the capture." - ) - explanation.setWordWrap(True) - layout.addWidget(explanation) - - stream_grid = QGridLayout() - for position, specification in enumerate(specifications): - group = QGroupBox(specification.title) - group_layout = QVBoxLayout(group) - summary = QLabel(specification.summary) - summary.setWordWrap(True) - group_layout.addWidget(summary, 1) - requirement = QLabel( - "Needs Developer Mode + mounted DDI/tunnel" - if specification.requires_developer_services - else "Uses the trusted lockdown connection; no DDI required" - ) - requirement.setObjectName("liveLogRequirement") - requirement.setWordWrap(True) - group_layout.addWidget(requirement) - open_button = QPushButton(f"Pop Out {specification.title}") - open_button.setObjectName(f"open{specification.identifier.replace('-', '').title()}LogButton") - open_button.setAccessibleName(f"Open {specification.title} live log") - open_button.setAccessibleDescription( - "Open an independent log window for the selected trusted device." - ) - open_button.clicked.connect(lambda checked=False, identifier=specification.identifier: open_live_log(identifier)) - group_layout.addWidget(open_button) - stream_grid.addWidget(group, 0, position) - layout.addLayout(stream_grid) - - integrity_group = QGroupBox("Capture integrity") - integrity_layout = QVBoxLayout(integrity_group) - integrity_text = QLabel( - "Pause affects only rendering: device output continues into the raw spool. Filters affect only the current " - "view and filtered export. Save Raw copies the complete stream and a metadata sidecar containing the exact " - "command, target UDID, timestamps, byte/line counts, exit code, and process error. The view retains the newest " - "50,000 decoded lines to stay responsive; the raw spool is not truncated by that limit. Mark Finding stores " - "a selected excerpt, classification, tags, and analyst note separately from the raw stream. Review Findings " - "keeps annotations distinct from raw output. Export Evidence Bundle creates a local folder with raw capture, " - "metadata, findings, investigation report, and SHA-256 inventory. Findings are annotations, not proof of " - "device activity or causality." - ) - integrity_text.setWordWrap(True) - integrity_layout.addWidget(integrity_text) - layout.addWidget(integrity_group) - - archive_group = QGroupBox("Stored log archive and deeper analysis") - archive_layout = QHBoxLayout(archive_group) - archive_note = QLabel( - "For retained device logs, use the Syslog → collect preset in Command Center to pull a .logarchive for " - "Console.app or the macOS log command. Evidence Capture remains the bounded multi-source workflow." - ) - archive_note.setWordWrap(True) - archive_layout.addWidget(archive_note, 1) - command_button = QPushButton("Open Log Presets") - command_button.setObjectName("openLogPresetsButton") - command_button.setAccessibleName("Open logging and capture presets") - command_button.clicked.connect(open_log_presets) - archive_layout.addWidget(command_button) - evidence_button = QPushButton("Open Evidence Capture") - evidence_button.setObjectName("openEvidenceCaptureButton") - evidence_button.setAccessibleName("Open Evidence Capture workspace") - evidence_button.clicked.connect(lambda checked=False: navigate("Evidence Capture")) - archive_layout.addWidget(evidence_button) - layout.addWidget(archive_group) - layout.addStretch() - return page - - -def build_safety_page(developer_disk_image_repository: str) -> QWidget: - """Build the static scope-and-safety reference workspace.""" - tab = QWidget() - layout = QVBoxLayout(tab) - browser = QTextBrowser() - browser.setOpenExternalLinks(True) - browser.setHtml( - f""" -

What this app does

-

It is a guided macOS workbench for pymobiledevice3: pairing-visible device inspection, - apps and AFC, backups, diagnostics, logging, packet capture, crash reports, Web Inspector, RemoteXPC, - Developer Disk Images, location simulation and GPX testing, CoreDevice, DVT instrumentation, and - evidence-oriented collection.

-

Command Center minimizes typing with validated presets. Man Pages runs the installed binary's - --help, so exact syntax and service availability remain version-specific and reviewable.

-

What a personalized DDI is

-

For iOS 17 and later, the image is an APFS payload plus BuildManifest.plist and a trust cache. - Apple TSS personalizes it for the device ECID and nonce. It is mounted at /System/Developer.

-

Important limits

-
    -
  • This is not a jailbreak and does not bypass the passcode, Secure Enclave, sandbox, or entitlements.
  • -
  • developer dvt ls / is a developer-service view, not unrestricted raw filesystem acquisition.
  • -
  • TLS remains encrypted in PCAP. A hostname, owner, or DNS answer is not proof of application purpose.
  • -
  • A failed or empty command is a coverage gap, not proof that data or activity is absent.
  • -
  • Mounting a DDI and enabling Developer Mode change device state and create timestamps.
  • -
  • Simulated location is a developer-service override, not a GPS hardware change. Clear it after testing; - some apps may ignore it or prohibit its use.
  • -
  • Restore, erase, activation, supervision, reboot, shutdown, and nonce-roll commands can be high impact. - They are documented in Man Pages but are not promoted as guided presets.
  • -
  • A command existing in pymobiledevice3 does not guarantee the selected iOS build advertises its Apple service.
  • -
-

Sources

-

DeveloperDiskImage repository
- pymobiledevice3 documentation
- Apple Developer Mode guidance

- """ - ) - layout.addWidget(browser) - return tab - - -def toolkit_stylesheet() -> str: - """Return the shared application stylesheet applied by the main window.""" - return """ - QWidget { color: #1d2633; } - QMainWindow { background: #f4f6fa; } - QGroupBox { background: white; border: 1px solid #d9dee8; border-radius: 10px; margin-top: 12px; padding: 12px; font-weight: 600; } - QGroupBox::title { subcontrol-origin: margin; left: 14px; padding: 0 5px; } - QPushButton { min-height: 30px; padding: 3px 12px; border: 1px solid #c7ceda; border-radius: 7px; background: white; } - QPushButton:hover { background: #eef4ff; border-color: #7aa7ef; } - QPushButton:disabled { color: #9299a5; background: #eef0f4; } - QLineEdit, QComboBox, QSpinBox, QPlainTextEdit, QTextBrowser, QTableWidget, QListWidget { border: 1px solid #cfd5df; border-radius: 7px; background: white; padding: 5px; } - #workspaceSidebar { background: #172033; border: 1px solid #25314a; border-radius: 11px; } - #workspaceSidebar QLabel { color: #dbe7ff; } - #sidebarSectionLabel { color: #89a8dc; font-size: 11px; font-weight: 700; padding: 3px 7px; } - #sidebarVersion { color: #91a2be; font-size: 11px; padding: 8px; } - #workspaceNavigation { background: transparent; border: none; color: #dce6f7; outline: none; } - #workspaceNavigation::item { min-height: 34px; border-radius: 7px; padding: 4px 9px; } - #workspaceNavigation::item:hover { background: #24324b; } - #workspaceNavigation::item:selected { background: #3567b7; color: white; } - #homeHero { background: #e8f1ff; border: 1px solid #a9c9f6; border-radius: 12px; padding: 12px; } - #homeStats { color: #315f9e; font-weight: 600; } - #commandPresetBrowser, #commandPresetDetail { background: white; border: 1px solid #d9dee8; border-radius: 10px; } - #commandPresetTitle, #manpageTitle { color: #162033; } - #commandAdvancedNotes, #commandPrerequisites { color: #566176; } - #commandRiskBadge { border-radius: 8px; padding: 5px 9px; font-size: 11px; font-weight: 700; } - #commandRiskBadge[risk="read-only"] { background: #e4f6e9; color: #236b36; } - #commandRiskBadge[risk="host-write"] { background: #fff1ce; color: #765400; } - #commandRiskBadge[risk="device-change"] { background: #ffe2df; color: #8b2d24; } - #protocolStackSummary { font-family: Menlo; color: #34435a; } - #connectionBanner { background: #e9f2ff; border: 1px solid #afcff8; border-radius: 8px; padding: 10px; } - #collectionPrivacyWarning { background: #fff5df; border: 1px solid #e7c36a; border-radius: 8px; padding: 10px; } - #installedAppsPrivacyWarning, #backupEncryptionWarning, #locationPrivacyWarning, #capabilityMatrixBoundary, #externalToolsBoundary { background: #fff5df; border: 1px solid #e7c36a; border-radius: 8px; padding: 10px; } - #capabilityMatrixStatus { background: #e9f2ff; border: 1px solid #afcff8; border-radius: 8px; padding: 9px; } - #appSubtitle { color: #596273; } - """ diff --git a/ios_developer_toolkit/installed_apps.py b/ios_developer_toolkit/installed_apps.py deleted file mode 100644 index 023b03c..0000000 --- a/ios_developer_toolkit/installed_apps.py +++ /dev/null @@ -1,92 +0,0 @@ -from __future__ import annotations - -import json -from dataclasses import dataclass - - -class InstalledAppsDataError(ValueError): - pass - - -@dataclass(frozen=True) -class InstalledApp: - name: str - bundle_identifier: str - version: str - build: str - application_type: str - static_bytes: int | None - dynamic_bytes: int | None - - @property - def total_bytes(self) -> int | None: - sizes = tuple(size for size in (self.static_bytes, self.dynamic_bytes) if size is not None) - return sum(sizes) if sizes else None - - -def required_string(value: object, field_name: str) -> str: - if not isinstance(value, str) or not value.strip(): - raise InstalledAppsDataError(f"{field_name} must be a non-empty string") - return value.strip() - - -def optional_string(value: object, field_name: str) -> str: - if value is None: - return "" - if not isinstance(value, str): - raise InstalledAppsDataError(f"{field_name} must be a string when present") - return value.strip() - - -def optional_nonnegative_integer(value: object, field_name: str) -> int | None: - if value is None: - return None - if not isinstance(value, int) or isinstance(value, bool) or value < 0: - raise InstalledAppsDataError(f"{field_name} must be a non-negative integer when present") - return value - - -def parse_installed_app(bundle_key: str, raw_metadata: object) -> InstalledApp: - if not isinstance(raw_metadata, dict): - raise InstalledAppsDataError(f"metadata for {bundle_key!r} must be an object") - metadata: dict[object, object] = raw_metadata - bundle_identifier = required_string(metadata.get("CFBundleIdentifier", bundle_key), "CFBundleIdentifier") - if bundle_identifier != bundle_key: - raise InstalledAppsDataError( - f"app dictionary key {bundle_key!r} does not match CFBundleIdentifier {bundle_identifier!r}" - ) - display_name = metadata.get("CFBundleDisplayName") or metadata.get("CFBundleName") or bundle_identifier - return InstalledApp( - name=required_string(display_name, "CFBundleDisplayName"), - bundle_identifier=bundle_identifier, - version=optional_string(metadata.get("CFBundleShortVersionString"), "CFBundleShortVersionString"), - build=optional_string(metadata.get("CFBundleVersion"), "CFBundleVersion"), - application_type=optional_string(metadata.get("ApplicationType"), "ApplicationType") or "Unknown", - static_bytes=optional_nonnegative_integer(metadata.get("StaticDiskUsage"), "StaticDiskUsage"), - dynamic_bytes=optional_nonnegative_integer(metadata.get("DynamicDiskUsage"), "DynamicDiskUsage"), - ) - - -def parse_installed_apps_json(payload: str) -> tuple[InstalledApp, ...]: - raw: object = json.loads(payload) - if not isinstance(raw, dict): - raise InstalledAppsDataError("installed-app output must be a JSON object keyed by bundle identifier") - apps = tuple( - parse_installed_app(required_string(bundle_key, "bundle identifier key"), raw_metadata) - for bundle_key, raw_metadata in raw.items() - ) - return tuple(sorted(apps, key=lambda app: (app.name.casefold(), app.bundle_identifier.casefold()))) - - -def format_byte_count(byte_count: int | None) -> str: - if byte_count is None: - return "—" - units = ("B", "KB", "MB", "GB", "TB") - value = float(byte_count) - unit = units[0] - for candidate in units: - unit = candidate - if value < 1000 or candidate == units[-1]: - break - value /= 1000 - return f"{value:.1f} {unit}" if unit != "B" else f"{byte_count} B" diff --git a/ios_developer_toolkit/interactive_process.py b/ios_developer_toolkit/interactive_process.py deleted file mode 100644 index 60a6c73..0000000 --- a/ios_developer_toolkit/interactive_process.py +++ /dev/null @@ -1,171 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone -from pathlib import Path -from typing import Literal, Mapping, Sequence - -from PySide6.QtCore import QObject, QProcess, QProcessEnvironment, QTimer, Signal - -from ios_developer_toolkit.qt_process import OperationResult, ProcessOutcome -from ios_developer_toolkit.runtime import ExecutableCommand, command_arguments, command_argv - - -class InteractiveProcessController(QObject): - """Own one user-stoppable process without imposing an arbitrary runtime limit.""" - - stdout_received = Signal(bytes) - stderr_received = Signal(bytes) - completed = Signal(object) - - def __init__(self, parent: QObject) -> None: - super().__init__(parent) - self._process: QProcess | None = None - self._command: ExecutableCommand | None = None - self._arguments: tuple[str, ...] = () - self._terminate_grace_milliseconds = 0 - self._stdout = bytearray() - self._stderr = bytearray() - self._started_at = "" - self._error_message: str | None = None - self._stop_outcome: Literal["cancelled"] | None = None - self._completed = False - self._kill_timer = QTimer(self) - self._kill_timer.setSingleShot(True) - self._kill_timer.timeout.connect(self._kill) - - def is_running(self) -> bool: - return self._process is not None - - def start( - self, - command: ExecutableCommand, - arguments: Sequence[str], - environment: Mapping[str, str], - working_directory: Path, - terminate_grace_milliseconds: int, - ) -> None: - if self.is_running(): - raise RuntimeError("Cannot start an interactive process while another process is running") - if terminate_grace_milliseconds <= 0: - raise ValueError( - f"Interactive process termination grace period must be positive: {terminate_grace_milliseconds}" - ) - resolved_working_directory = working_directory.expanduser().resolve() - if not resolved_working_directory.is_dir(): - raise ValueError(f"Interactive process working directory does not exist: {resolved_working_directory}") - self._command = command - self._arguments = tuple(arguments) - self._terminate_grace_milliseconds = terminate_grace_milliseconds - self._stdout.clear() - self._stderr.clear() - self._started_at = datetime.now(timezone.utc).isoformat() - self._error_message = None - self._stop_outcome = None - self._completed = False - - process = QProcess(self) - process.setProgram(str(command.program)) - process.setArguments(list(command_arguments(command, self._arguments))) - process.setWorkingDirectory(str(resolved_working_directory)) - process_environment = QProcessEnvironment.systemEnvironment() - for key, value in sorted(environment.items()): - process_environment.insert(key, value) - process.setProcessEnvironment(process_environment) - process.readyReadStandardOutput.connect(self._drain_output) - process.readyReadStandardError.connect(self._drain_output) - process.errorOccurred.connect(self._process_error) - process.finished.connect(self._finished) - self._process = process - process.start() - - def cancel(self) -> None: - process = self._process - if process is None or process.state() == QProcess.ProcessState.NotRunning: - return - if self._stop_outcome == "cancelled": - return - self._stop_outcome = "cancelled" - process.terminate() - self._kill_timer.start(self._terminate_grace_milliseconds) - - def shutdown(self, terminate_timeout_milliseconds: int, kill_timeout_milliseconds: int) -> None: - if terminate_timeout_milliseconds <= 0: - raise ValueError(f"Shutdown termination timeout must be positive: {terminate_timeout_milliseconds}") - if kill_timeout_milliseconds <= 0: - raise ValueError(f"Shutdown kill timeout must be positive: {kill_timeout_milliseconds}") - process = self._process - if process is None: - return - self._stop_outcome = "cancelled" - self._kill_timer.stop() - if process.state() != QProcess.ProcessState.NotRunning: - process.terminate() - if not process.waitForFinished(terminate_timeout_milliseconds): - process.kill() - if not process.waitForFinished(kill_timeout_milliseconds): - raise RuntimeError(f"Interactive process did not stop after terminate and kill: {process.program()}") - else: - self._finish_once("cancelled", process.exitCode()) - - def _drain_output(self) -> None: - process = self._process - if process is None: - return - stdout = bytes(process.readAllStandardOutput()) - stderr = bytes(process.readAllStandardError()) - if stdout: - self._stdout.extend(stdout) - self.stdout_received.emit(stdout) - if stderr: - self._stderr.extend(stderr) - self.stderr_received.emit(stderr) - - def _process_error(self, process_error: QProcess.ProcessError) -> None: - process = self._process - if process is None: - raise RuntimeError("Interactive process reported an error without an active process") - self._error_message = process.errorString() - if process_error == QProcess.ProcessError.FailedToStart: - self._finish_once("launch-failed", None) - - def _finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: - self._drain_output() - if self._stop_outcome is not None: - outcome: ProcessOutcome = self._stop_outcome - elif exit_status == QProcess.ExitStatus.CrashExit: - outcome = "crashed" - elif exit_code == 0: - outcome = "succeeded" - else: - outcome = "failed" - self._finish_once(outcome, exit_code) - - def _kill(self) -> None: - process = self._process - if process is not None and process.state() != QProcess.ProcessState.NotRunning: - process.kill() - - def _finish_once(self, outcome: ProcessOutcome, exit_code: int | None) -> None: - if self._completed: - return - command = self._command - if command is None: - raise RuntimeError("Interactive process completed without a command") - self._drain_output() - self._completed = True - self._kill_timer.stop() - result = OperationResult( - command_argv(command, self._arguments), - outcome, - self._started_at, - datetime.now(timezone.utc).isoformat(), - exit_code, - self._error_message, - bytes(self._stdout), - bytes(self._stderr), - ) - process = self._process - self._process = None - if process is not None: - process.deleteLater() - self.completed.emit(result) diff --git a/ios_developer_toolkit/ipa_inspector.py b/ios_developer_toolkit/ipa_inspector.py deleted file mode 100644 index b434b8e..0000000 --- a/ios_developer_toolkit/ipa_inspector.py +++ /dev/null @@ -1,515 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import plistlib -import re -import shutil -import stat -import subprocess -import sys -import tempfile -import zipfile -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path, PurePosixPath -from typing import Mapping, Sequence - - -MAX_ARCHIVE_UNCOMPRESSED_BYTES = 8 * 1024 * 1024 * 1024 -MAX_INFO_PLIST_BYTES = 10 * 1024 * 1024 -MAX_PROVISIONING_PROFILE_BYTES = 20 * 1024 * 1024 -BUNDLE_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$") - - -class IPAInspectionError(RuntimeError): - """Raised when an IPA cannot be inspected safely or completely.""" - - -@dataclass(frozen=True) -class ArchiveMetadata: - ipa_path: Path - app_root: str - app_name: str - bundle_identifier: str - version: str - build: str - minimum_os_version: str | None - executable_name: str - provisioning_member: str | None - has_code_resources: bool - - -@dataclass(frozen=True) -class ProvisioningSummary: - status: str - name: str | None - uuid: str | None - team_identifiers: tuple[str, ...] - application_identifier: str | None - expiration: str | None - provisioned_device_count: int - provisions_all_devices: bool - get_task_allow: bool | None - developer_certificate_count: int - detail: str - - -@dataclass(frozen=True) -class SignatureSummary: - status: str - identifier: str | None - team_identifier: str | None - authorities: tuple[str, ...] - detail: str - - -@dataclass(frozen=True) -class IPAInspection: - ipa_path: Path - app_name: str - bundle_identifier: str - version: str - build: str - minimum_os_version: str | None - executable_name: str - provisioning: ProvisioningSummary - signature: SignatureSummary - - def to_mapping(self) -> Mapping[str, object]: - return { - "ipa_path": str(self.ipa_path), - "app_name": self.app_name, - "bundle_identifier": self.bundle_identifier, - "version": self.version, - "build": self.build, - "minimum_os_version": self.minimum_os_version, - "executable_name": self.executable_name, - "provisioning": { - "status": self.provisioning.status, - "name": self.provisioning.name, - "uuid": self.provisioning.uuid, - "team_identifiers": list(self.provisioning.team_identifiers), - "application_identifier": self.provisioning.application_identifier, - "expiration": self.provisioning.expiration, - "provisioned_device_count": self.provisioning.provisioned_device_count, - "provisions_all_devices": self.provisioning.provisions_all_devices, - "get_task_allow": self.provisioning.get_task_allow, - "developer_certificate_count": self.provisioning.developer_certificate_count, - "detail": self.provisioning.detail, - }, - "signature": { - "status": self.signature.status, - "identifier": self.signature.identifier, - "team_identifier": self.signature.team_identifier, - "authorities": list(self.signature.authorities), - "detail": self.signature.detail, - }, - } - - -def validate_bundle_identifier(value: str) -> str: - normalized = value.strip() - if not BUNDLE_IDENTIFIER_PATTERN.fullmatch(normalized): - raise IPAInspectionError( - "Bundle identifier must contain at least two dot-separated alphanumeric or hyphen components" - ) - return normalized - - -def format_inspection(inspection: IPAInspection) -> str: - provisioning = inspection.provisioning - signature = inspection.signature - minimum_os = inspection.minimum_os_version or "not declared" - profile_name = provisioning.name or "not declared" - profile_expiration = provisioning.expiration or "not declared" - application_identifier = provisioning.application_identifier or "not declared" - team_identifiers = ", ".join(provisioning.team_identifiers) or "not declared" - authority_text = ", ".join(signature.authorities) or "not declared" - return "\n".join( - ( - f"App: {inspection.app_name}", - f"Bundle identifier: {inspection.bundle_identifier}", - f"Version: {inspection.version} ({inspection.build})", - f"Minimum iOS: {minimum_os}", - f"Executable: {inspection.executable_name}", - "", - f"Code signature: {signature.status}", - f"Signing identifier: {signature.identifier or 'not declared'}", - f"Signing team: {signature.team_identifier or 'not declared'}", - f"Authorities: {authority_text}", - f"Verification detail: {signature.detail}", - "", - f"Provisioning profile: {provisioning.status}", - f"Profile name: {profile_name}", - f"Application identifier: {application_identifier}", - f"Profile teams: {team_identifiers}", - f"Expiration: {profile_expiration}", - f"Provisioned devices: {provisioning.provisioned_device_count}", - f"All devices: {provisioning.provisions_all_devices}", - f"Debug entitlement: {provisioning.get_task_allow}", - f"Developer certificates: {provisioning.developer_certificate_count}", - f"Provisioning detail: {provisioning.detail}", - ) - ) - - -def required_string(mapping: Mapping[str, object], key: str, source: str) -> str: - value = mapping.get(key) - if not isinstance(value, str) or not value.strip(): - raise IPAInspectionError(f"{source} is missing required string field {key}") - return value - - -def optional_string(mapping: Mapping[str, object], key: str, source: str) -> str | None: - value = mapping.get(key) - if value is None: - return None - if not isinstance(value, str): - raise IPAInspectionError(f"{source} field {key} must be a string when present") - return value - - -def validate_archive_members(infos: Sequence[zipfile.ZipInfo]) -> None: - observed_names: set[str] = set() - total_size = 0 - for info in infos: - name = info.filename - if name in observed_names: - raise IPAInspectionError(f"IPA contains a duplicate archive member: {name}") - observed_names.add(name) - path = PurePosixPath(name) - if path.is_absolute() or ".." in path.parts or "\\" in name: - raise IPAInspectionError(f"IPA contains an unsafe archive path: {name}") - unix_mode = info.external_attr >> 16 - if stat.S_ISLNK(unix_mode): - raise IPAInspectionError(f"IPA contains a symbolic link that cannot be inspected safely: {name}") - if info.flag_bits & 0x1: - raise IPAInspectionError(f"IPA contains an encrypted archive member: {name}") - total_size += info.file_size - if total_size > MAX_ARCHIVE_UNCOMPRESSED_BYTES: - raise IPAInspectionError( - f"IPA expands beyond the {MAX_ARCHIVE_UNCOMPRESSED_BYTES // (1024 ** 3)} GiB inspection limit" - ) - - -def parse_plist_mapping(payload: bytes, source: str) -> Mapping[str, object]: - try: - parsed: object = plistlib.loads(payload) - except plistlib.InvalidFileException as error: - raise IPAInspectionError(f"{source} is not a valid property list: {error}") from error - if not isinstance(parsed, dict) or not all(isinstance(key, str) for key in parsed): - raise IPAInspectionError(f"{source} does not contain a string-keyed property-list dictionary") - return parsed - - -def read_archive_metadata(ipa_path: Path) -> ArchiveMetadata: - resolved_path = ipa_path.expanduser().resolve() - if not resolved_path.is_file(): - raise FileNotFoundError(f"IPA file does not exist: {resolved_path}") - if resolved_path.suffix.lower() != ".ipa": - raise IPAInspectionError(f"Expected a .ipa package, received: {resolved_path.name}") - try: - with zipfile.ZipFile(resolved_path) as archive: - infos = archive.infolist() - validate_archive_members(infos) - info_plists = sorted( - info.filename - for info in infos - if len(PurePosixPath(info.filename).parts) == 3 - and info.filename.startswith("Payload/") - and info.filename.endswith(".app/Info.plist") - ) - if len(info_plists) != 1: - raise IPAInspectionError( - f"IPA must contain exactly one Payload/.app/Info.plist; found {len(info_plists)}" - ) - info_plist_member = info_plists[0] - info_record = archive.getinfo(info_plist_member) - if info_record.file_size > MAX_INFO_PLIST_BYTES: - raise IPAInspectionError(f"IPA Info.plist exceeds the {MAX_INFO_PLIST_BYTES} byte limit") - info_plist = parse_plist_mapping(archive.read(info_record), info_plist_member) - app_root = info_plist_member.removesuffix("/Info.plist") - provisioning_member = f"{app_root}/embedded.mobileprovision" - member_names = {info.filename for info in infos} - if provisioning_member not in member_names: - provisioning_member = None - return ArchiveMetadata( - ipa_path=resolved_path, - app_root=app_root, - app_name=optional_string(info_plist, "CFBundleDisplayName", info_plist_member) - or required_string(info_plist, "CFBundleName", info_plist_member), - bundle_identifier=required_string(info_plist, "CFBundleIdentifier", info_plist_member), - version=required_string(info_plist, "CFBundleShortVersionString", info_plist_member), - build=required_string(info_plist, "CFBundleVersion", info_plist_member), - minimum_os_version=optional_string(info_plist, "MinimumOSVersion", info_plist_member), - executable_name=required_string(info_plist, "CFBundleExecutable", info_plist_member), - provisioning_member=provisioning_member, - has_code_resources=f"{app_root}/_CodeSignature/CodeResources" in member_names, - ) - except zipfile.BadZipFile as error: - raise IPAInspectionError(f"IPA is not a valid ZIP archive: {resolved_path}") from error - - -def optional_profile_string(mapping: Mapping[str, object], key: str) -> str | None: - value = mapping.get(key) - return value if isinstance(value, str) else None - - -def string_tuple(value: object, field: str) -> tuple[str, ...]: - if value is None: - return () - if not isinstance(value, list) or not all(isinstance(item, str) for item in value): - raise IPAInspectionError(f"Provisioning profile field {field} must be a string array") - return tuple(value) - - -def provisioning_summary(profile_path: Path | None) -> ProvisioningSummary: - if profile_path is None: - return ProvisioningSummary("absent", None, None, (), None, None, 0, False, None, 0, "No embedded.mobileprovision") - completed = subprocess.run( - ["/usr/bin/security", "cms", "-D", "-i", str(profile_path)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, - ) - if completed.returncode != 0: - detail = completed.stderr.decode("utf-8", errors="replace").strip() - return ProvisioningSummary("invalid", None, None, (), None, None, 0, False, None, 0, detail) - profile = parse_plist_mapping(completed.stdout, "embedded.mobileprovision") - entitlements_object = profile.get("Entitlements") - if not isinstance(entitlements_object, dict) or not all(isinstance(key, str) for key in entitlements_object): - raise IPAInspectionError("Provisioning profile is missing a string-keyed Entitlements dictionary") - entitlements: Mapping[str, object] = entitlements_object - application_identifier = optional_profile_string(entitlements, "application-identifier") - if application_identifier is None: - application_identifier = optional_profile_string(entitlements, "com.apple.application-identifier") - expiration_object = profile.get("ExpirationDate") - if expiration_object is not None and not isinstance(expiration_object, datetime): - raise IPAInspectionError("Provisioning profile ExpirationDate must be a date") - devices = string_tuple(profile.get("ProvisionedDevices"), "ProvisionedDevices") - certificates_object = profile.get("DeveloperCertificates") - if certificates_object is None: - certificate_count = 0 - elif isinstance(certificates_object, list) and all(isinstance(item, bytes) for item in certificates_object): - certificate_count = len(certificates_object) - else: - raise IPAInspectionError("Provisioning profile DeveloperCertificates must be a data array") - get_task_allow_object = entitlements.get("get-task-allow") - if get_task_allow_object is not None and not isinstance(get_task_allow_object, bool): - raise IPAInspectionError("Provisioning entitlement get-task-allow must be a boolean") - provisions_all_devices_object = profile.get("ProvisionsAllDevices", False) - if not isinstance(provisions_all_devices_object, bool): - raise IPAInspectionError("Provisioning profile ProvisionsAllDevices must be a boolean") - return ProvisioningSummary( - status="decoded", - name=optional_profile_string(profile, "Name"), - uuid=optional_profile_string(profile, "UUID"), - team_identifiers=string_tuple(profile.get("TeamIdentifier"), "TeamIdentifier"), - application_identifier=application_identifier, - expiration=expiration_object.isoformat() if expiration_object is not None else None, - provisioned_device_count=len(devices), - provisions_all_devices=provisions_all_devices_object, - get_task_allow=get_task_allow_object, - developer_certificate_count=certificate_count, - detail="CMS payload decoded", - ) - - -def extract_app(archive: zipfile.ZipFile, metadata: ArchiveMetadata, destination: Path) -> Path: - app_destination = destination / PurePosixPath(metadata.app_root) - prefix = f"{metadata.app_root}/" - for info in archive.infolist(): - if info.filename != metadata.app_root and not info.filename.startswith(prefix): - continue - relative_path = PurePosixPath(info.filename) - target = destination.joinpath(*relative_path.parts) - if info.is_dir(): - target.mkdir(parents=True, exist_ok=True) - continue - target.parent.mkdir(parents=True, exist_ok=True) - with archive.open(info) as source, target.open("wb") as output: - shutil.copyfileobj(source, output) - unix_mode = (info.external_attr >> 16) & 0o777 - if unix_mode: - target.chmod(unix_mode) - if not app_destination.is_dir(): - raise IPAInspectionError(f"IPA extraction did not create the expected app bundle: {metadata.app_root}") - return app_destination - - -def parse_codesign_details(output: str) -> tuple[str | None, str | None, tuple[str, ...]]: - identifier: str | None = None - team_identifier: str | None = None - authorities: list[str] = [] - for line in output.splitlines(): - if line.startswith("Identifier="): - identifier = line.removeprefix("Identifier=") - elif line.startswith("TeamIdentifier="): - team_identifier = line.removeprefix("TeamIdentifier=") - elif line.startswith("Authority="): - authorities.append(line.removeprefix("Authority=")) - return identifier, team_identifier, tuple(authorities) - - -def signature_summary(app_path: Path, has_code_resources: bool) -> SignatureSummary: - if not has_code_resources: - return SignatureSummary("missing", None, None, (), "_CodeSignature/CodeResources is absent") - verification = subprocess.run( - ["/usr/bin/codesign", "--verify", "--deep", "--strict", "--verbose=2", str(app_path)], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - check=False, - ) - details = subprocess.run( - ["/usr/bin/codesign", "--display", "--verbose=4", str(app_path)], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - check=False, - ) - identifier, team_identifier, authorities = parse_codesign_details(details.stdout) - verification_detail = verification.stdout.strip() or "codesign verification produced no diagnostic text" - status = "valid" if verification.returncode == 0 else "invalid" - return SignatureSummary(status, identifier, team_identifier, authorities, verification_detail) - - -def inspect_ipa(ipa_path: Path) -> IPAInspection: - metadata = read_archive_metadata(ipa_path) - with tempfile.TemporaryDirectory(prefix="ios-developer-toolkit-ipa-") as temporary_directory: - temporary_root = Path(temporary_directory) - profile_path: Path | None = None - with zipfile.ZipFile(metadata.ipa_path) as archive: - validate_archive_members(archive.infolist()) - if metadata.provisioning_member is not None: - profile_info = archive.getinfo(metadata.provisioning_member) - if profile_info.file_size > MAX_PROVISIONING_PROFILE_BYTES: - raise IPAInspectionError( - f"embedded.mobileprovision exceeds the {MAX_PROVISIONING_PROFILE_BYTES} byte limit" - ) - profile_path = temporary_root / "embedded.mobileprovision" - profile_path.write_bytes(archive.read(profile_info)) - provisioning = provisioning_summary(profile_path) - if metadata.has_code_resources: - app_path = extract_app(archive, metadata, temporary_root) - signature = signature_summary(app_path, metadata.has_code_resources) - else: - signature = signature_summary(temporary_root, metadata.has_code_resources) - return IPAInspection( - ipa_path=metadata.ipa_path, - app_name=metadata.app_name, - bundle_identifier=metadata.bundle_identifier, - version=metadata.version, - build=metadata.build, - minimum_os_version=metadata.minimum_os_version, - executable_name=metadata.executable_name, - provisioning=provisioning, - signature=signature, - ) - - -def required_nonnegative_integer(mapping: Mapping[str, object], key: str, source: str) -> int: - value = mapping.get(key) - if type(value) is not int or value < 0: - raise IPAInspectionError(f"{source} field {key} must be a nonnegative integer") - return value - - -def required_boolean(mapping: Mapping[str, object], key: str, source: str) -> bool: - value = mapping.get(key) - if type(value) is not bool: - raise IPAInspectionError(f"{source} field {key} must be a boolean") - return value - - -def nullable_string(mapping: Mapping[str, object], key: str, source: str) -> str | None: - value = mapping.get(key) - if value is None or isinstance(value, str): - return value - raise IPAInspectionError(f"{source} field {key} must be a string or null") - - -def parsed_string_tuple(mapping: Mapping[str, object], key: str, source: str) -> tuple[str, ...]: - value = mapping.get(key) - if not isinstance(value, list) or not all(isinstance(item, str) for item in value): - raise IPAInspectionError(f"{source} field {key} must be a string array") - return tuple(value) - - -def parse_inspection_json(payload: str) -> IPAInspection: - try: - parsed: object = json.loads(payload) - except json.JSONDecodeError as error: - raise IPAInspectionError(f"IPA inspector returned invalid JSON: {error}") from error - if not isinstance(parsed, dict) or not all(isinstance(key, str) for key in parsed): - raise IPAInspectionError("IPA inspector JSON root must be a string-keyed object") - provisioning_object = parsed.get("provisioning") - signature_object = parsed.get("signature") - if not isinstance(provisioning_object, dict) or not all(isinstance(key, str) for key in provisioning_object): - raise IPAInspectionError("IPA inspector provisioning field must be a string-keyed object") - if not isinstance(signature_object, dict) or not all(isinstance(key, str) for key in signature_object): - raise IPAInspectionError("IPA inspector signature field must be a string-keyed object") - provisioning: Mapping[str, object] = provisioning_object - signature: Mapping[str, object] = signature_object - get_task_allow_object = provisioning.get("get_task_allow") - if get_task_allow_object is not None and not isinstance(get_task_allow_object, bool): - raise IPAInspectionError("IPA inspector get_task_allow must be a boolean or null") - return IPAInspection( - ipa_path=Path(required_string(parsed, "ipa_path", "IPA inspector")), - app_name=required_string(parsed, "app_name", "IPA inspector"), - bundle_identifier=required_string(parsed, "bundle_identifier", "IPA inspector"), - version=required_string(parsed, "version", "IPA inspector"), - build=required_string(parsed, "build", "IPA inspector"), - minimum_os_version=nullable_string(parsed, "minimum_os_version", "IPA inspector"), - executable_name=required_string(parsed, "executable_name", "IPA inspector"), - provisioning=ProvisioningSummary( - status=required_string(provisioning, "status", "IPA inspector provisioning"), - name=nullable_string(provisioning, "name", "IPA inspector provisioning"), - uuid=nullable_string(provisioning, "uuid", "IPA inspector provisioning"), - team_identifiers=parsed_string_tuple(provisioning, "team_identifiers", "IPA inspector provisioning"), - application_identifier=nullable_string( - provisioning, "application_identifier", "IPA inspector provisioning" - ), - expiration=nullable_string(provisioning, "expiration", "IPA inspector provisioning"), - provisioned_device_count=required_nonnegative_integer( - provisioning, "provisioned_device_count", "IPA inspector provisioning" - ), - provisions_all_devices=required_boolean( - provisioning, "provisions_all_devices", "IPA inspector provisioning" - ), - get_task_allow=get_task_allow_object, - developer_certificate_count=required_nonnegative_integer( - provisioning, "developer_certificate_count", "IPA inspector provisioning" - ), - detail=required_string(provisioning, "detail", "IPA inspector provisioning"), - ), - signature=SignatureSummary( - status=required_string(signature, "status", "IPA inspector signature"), - identifier=nullable_string(signature, "identifier", "IPA inspector signature"), - team_identifier=nullable_string(signature, "team_identifier", "IPA inspector signature"), - authorities=parsed_string_tuple(signature, "authorities", "IPA inspector signature"), - detail=required_string(signature, "detail", "IPA inspector signature"), - ), - ) - - -def parse_args(arguments: Sequence[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Inspect an iOS IPA signature and provisioning metadata") - parser.add_argument("ipa", type=Path) - return parser.parse_args(arguments) - - -def main() -> int: - options = parse_args(sys.argv[1:]) - try: - inspection = inspect_ipa(options.ipa) - except (FileNotFoundError, IPAInspectionError, OSError, zipfile.BadZipFile) as error: - print(f"ERROR: {error}", file=sys.stderr) - return 1 - print(json.dumps(inspection.to_mapping(), indent=2, sort_keys=True)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/ios_developer_toolkit/live_logs.py b/ios_developer_toolkit/live_logs.py deleted file mode 100644 index e6ed090..0000000 --- a/ios_developer_toolkit/live_logs.py +++ /dev/null @@ -1,953 +0,0 @@ -from __future__ import annotations - -import codecs -import hashlib -import json -import os -import re -import shutil -from collections import deque -from dataclasses import asdict, dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import BinaryIO, Literal, Pattern - -from PySide6.QtCore import QProcess, QProcessEnvironment, Qt, Signal -from PySide6.QtGui import QCloseEvent, QFont, QIcon, QTextCursor -from PySide6.QtWidgets import ( - QApplication, - QCheckBox, - QDialog, - QFileDialog, - QHBoxLayout, - QInputDialog, - QLabel, - QLineEdit, - QMainWindow, - QMessageBox, - QPlainTextEdit, - QPushButton, - QVBoxLayout, - QWidget, -) - -from ios_developer_toolkit.runtime import ExecutableCommand, command_arguments, command_argv - - -MAX_RECENT_LINES = 50_000 -MAX_VISIBLE_BLOCKS = 20_000 -MAX_FINDING_TEXT_LENGTH = 20_000 -MAX_FINDING_TAGS = 12 -MAX_FINDING_TAG_LENGTH = 48 - -MetadataValue = str | int | bool | None | list[str] -FindingAssessment = Literal["observation", "lead", "needs-corroboration"] - - -class LiveLogError(RuntimeError): - pass - - -@dataclass(frozen=True) -class LogStreamSpec: - identifier: str - title: str - summary: str - arguments: tuple[str, ...] - requires_developer_services: bool - structured: bool - - -@dataclass(frozen=True) -class LiveLogFinding: - """An analyst annotation tied to selected text in a local log working view.""" - - created_at: str - note: str - selected_text: str - stream: str - device_identifier: str - raw_bytes_observed: int - filter_expression: str - filter_is_regex: bool - filter_case_sensitive: bool - assessment: FindingAssessment - tags: tuple[str, ...] - - -@dataclass(frozen=True) -class LiveLogInvestigationReport: - """A self-contained summary that distinguishes capture facts from analyst annotations.""" - - stream: str - stream_title: str - device_name: str - device_identifier: str - started_at: str - finished_at: str | None - raw_filename: str - raw_sha256: str | None - raw_bytes: int - decoded_lines: int - investigation_reference: str - - -def parse_finding_tags(value: str) -> tuple[str, ...]: - """Validate a comma-separated set of short local investigation tags.""" - - tags: list[str] = [] - for raw_tag in value.split(","): - tag = raw_tag.strip().lower() - if not tag: - continue - if len(tag) > MAX_FINDING_TAG_LENGTH: - raise LiveLogError( - f"Finding tags must be {MAX_FINDING_TAG_LENGTH} characters or fewer: {raw_tag!r}" - ) - if re.fullmatch(r"[a-z0-9][a-z0-9_-]*", tag) is None: - raise LiveLogError( - "Finding tags may use lowercase letters, numbers, hyphens, and underscores and must start with a letter or number" - ) - if tag not in tags: - tags.append(tag) - if len(tags) > MAX_FINDING_TAGS: - raise LiveLogError(f"A finding may have at most {MAX_FINDING_TAGS} tags") - return tuple(tags) - - -def assessment_for_label(label: str) -> FindingAssessment: - """Map the investigator-facing assessment label to its stable stored value.""" - - if label == "Observation": - return "observation" - if label == "Lead to correlate": - return "lead" - if label == "Needs corroboration": - return "needs-corroboration" - raise LiveLogError(f"Unsupported investigation assessment label: {label!r}") - - -def render_investigation_report( - report: LiveLogInvestigationReport, - findings: tuple[LiveLogFinding, ...], -) -> str: - """Render a portable Markdown index for one raw capture and its analyst findings.""" - - lines = [ - f"# {report.stream_title} investigation report", - "", - "## Capture facts", - "", - f"- Stream: `{report.stream}`", - f"- Device: `{report.device_name}` (`{report.device_identifier}`)", - f"- Capture started: `{report.started_at}`", - f"- Capture finished: `{report.finished_at or 'not finalized at export'}`", - f"- Raw artifact: `{report.raw_filename}`", - f"- Raw SHA-256: `{report.raw_sha256 or 'not finalized at export'}`", - f"- Raw bytes observed: `{report.raw_bytes}`", - f"- Decoded lines observed: `{report.decoded_lines}`", - f"- Investigation reference: {report.investigation_reference or 'not provided'}", - "", - "## Analyst findings", - "", - "The records below are analyst annotations. They are not device-generated facts, proof of causality, or proof " - "that a selected text fragment represents the complete event.", - "", - ] - if not findings: - lines.append("No analyst findings were recorded for this capture.") - return "\n".join(lines) + "\n" - for number, finding in enumerate(findings, start=1): - tags = ", ".join(finding.tags) if finding.tags else "none" - lines.extend( - ( - f"### Finding {number}: {finding.assessment}", - "", - f"- Recorded: `{finding.created_at}`", - f"- Tags: {tags}", - f"- Capture position: `{finding.raw_bytes_observed}` raw bytes observed", - f"- View filter: `{finding.filter_expression or 'none'}`; regex=`{finding.filter_is_regex}`; case-sensitive=`{finding.filter_case_sensitive}`", - f"- Analyst note: {finding.note}", - "", - "Selected visible text:", - "```text", - finding.selected_text, - "```", - "", - ) - ) - return "\n".join(lines) + "\n" - - -def write_investigation_report( - path: Path, - report: LiveLogInvestigationReport, - findings: tuple[LiveLogFinding, ...], -) -> None: - """Write the investigation report beside exported evidence artifacts.""" - - try: - path.write_text(render_investigation_report(report, findings), encoding="utf-8") - except OSError as error: - raise LiveLogError(f"Could not write live-log investigation report to {path}: {error}") from error - - -def log_stream_specs() -> tuple[LogStreamSpec, ...]: - return ( - LogStreamSpec( - identifier="unified", - title="Unified Logs", - summary="Structured os_trace_relay stream with subsystem, category, process, and level fields.", - arguments=("syslog", "live", "--format", "json", "--label"), - requires_developer_services=False, - structured=True, - ), - LogStreamSpec( - identifier="classic", - title="Classic Syslog", - summary="Raw compatibility stream from the older Apple syslog relay service.", - arguments=("syslog", "live-old"), - requires_developer_services=False, - structured=False, - ), - LogStreamSpec( - identifier="dvt-oslog", - title="DVT OSLog", - summary="Developer-service OSLog stream; requires Developer Mode, a mounted DDI, and the tunnel when applicable.", - arguments=("developer", "dvt", "oslog", "--format", "json"), - requires_developer_services=True, - structured=True, - ), - ) - - -def stream_spec(identifier: str) -> LogStreamSpec: - matches = tuple(specification for specification in log_stream_specs() if specification.identifier == identifier) - if len(matches) != 1: - raise LiveLogError(f"Expected one live-log stream named {identifier!r}, found {len(matches)}") - return matches[0] - - -def compile_line_filter(expression: str, regex: bool, case_sensitive: bool) -> Pattern[str] | None: - if not expression: - return None - flags = 0 if case_sensitive else re.IGNORECASE - pattern = expression if regex else re.escape(expression) - try: - return re.compile(pattern, flags) - except re.error as error: - raise LiveLogError(f"Invalid regular expression: {error}") from error - - -def line_matches(line: str, pattern: Pattern[str] | None) -> bool: - return pattern is None or pattern.search(line) is not None - - -def live_log_cache_directory(home: Path) -> Path: - return home.expanduser().resolve() / "Library" / "Caches" / "iOS Developer Toolkit" / "Live Logs" - - -def safe_log_fragment(value: str) -> str: - cleaned = re.sub(r"[^A-Za-z0-9_-]+", "-", value).strip("-") - return cleaned[:80] or "device" - - -def create_spool_paths(directory: Path, specification: LogStreamSpec, device_identifier: str) -> tuple[Path, Path]: - resolved_directory = directory.expanduser().resolve() - timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") - suffix = ".jsonl" if specification.structured else ".log" - basename = f"{timestamp}-{safe_log_fragment(device_identifier)}-{specification.identifier}" - return resolved_directory / f"{basename}{suffix}", resolved_directory / f"{basename}.meta.json" - - -def annotation_path_for(raw_path: Path) -> Path: - return raw_path.with_name(f"{raw_path.name}.findings.jsonl") - - -def sha256_file(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as source: - for chunk in iter(lambda: source.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def create_finding( - note: str, - selected_text: str, - stream: str, - device_identifier: str, - raw_bytes_observed: int, - filter_expression: str, - filter_is_regex: bool, - filter_case_sensitive: bool, - assessment: FindingAssessment, - tags: tuple[str, ...], -) -> LiveLogFinding: - normalized_note = note.strip() - normalized_selection = selected_text.strip() - if not normalized_note: - raise LiveLogError("A finding requires an analyst note") - if not normalized_selection: - raise LiveLogError("Select one or more visible log lines before marking a finding") - if len(normalized_selection) > MAX_FINDING_TEXT_LENGTH: - raise LiveLogError(f"Selected finding text must be {MAX_FINDING_TEXT_LENGTH:,} characters or fewer") - if assessment not in ("observation", "lead", "needs-corroboration"): - raise LiveLogError(f"Unsupported investigation assessment: {assessment!r}") - if len(tags) > MAX_FINDING_TAGS: - raise LiveLogError(f"A finding may have at most {MAX_FINDING_TAGS} tags") - for tag in tags: - if re.fullmatch(r"[a-z0-9][a-z0-9_-]*", tag) is None: - raise LiveLogError(f"Invalid normalized finding tag: {tag!r}") - return LiveLogFinding( - created_at=datetime.now(timezone.utc).isoformat(), - note=normalized_note, - selected_text=normalized_selection, - stream=stream, - device_identifier=device_identifier, - raw_bytes_observed=raw_bytes_observed, - filter_expression=filter_expression, - filter_is_regex=filter_is_regex, - filter_case_sensitive=filter_case_sensitive, - assessment=assessment, - tags=tags, - ) - - -def append_finding(path: Path, finding: LiveLogFinding) -> None: - record = json.dumps(asdict(finding), sort_keys=True) + "\n" - try: - with path.open("a", encoding="utf-8") as output: - output.write(record) - output.flush() - os.fsync(output.fileno()) - except OSError as error: - raise LiveLogError(f"Could not append live-log finding to {path}: {error}") from error - - -def write_metadata(path: Path, metadata: dict[str, MetadataValue]) -> None: - temporary_path = path.with_name(f".{path.name}.{os.getpid()}.new") - try: - path.parent.mkdir(parents=True, exist_ok=True) - temporary_path.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8") - temporary_path.replace(path) - except OSError as error: - try: - temporary_path.unlink(missing_ok=True) - except OSError as cleanup_error: - raise LiveLogError( - f"Could not write live-log metadata to {path}: {error}; cleanup also failed: {cleanup_error}" - ) from error - raise LiveLogError(f"Could not write live-log metadata to {path}: {error}") from error - - -class LiveLogWindow(QMainWindow): - closed = Signal(object) - - def __init__( - self, - executable: ExecutableCommand, - specification: LogStreamSpec, - device_identifier: str, - device_name: str, - environment: dict[str, str], - icon_path: Path, - ) -> None: - super().__init__() - self._executable = executable - self._specification = specification - self._device_identifier = device_identifier - self._device_name = device_name - self._environment = environment - self._recent_lines: deque[str] = deque(maxlen=MAX_RECENT_LINES) - self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") - self._partial_line = "" - self._raw_file: BinaryIO | None = None - self._process: QProcess | None = None - self._total_bytes = 0 - self._total_lines = 0 - self._view_paused = False - self._unsaved = False - self._closing = False - self._started_at = datetime.now(timezone.utc).isoformat() - self._finished_at: str | None = None - self._exit_code: int | None = None - self._process_error: str | None = None - self._raw_sha256: str | None = None - self._findings_count = 0 - self._findings: list[LiveLogFinding] = [] - self._spool_path, self._metadata_path = create_spool_paths( - live_log_cache_directory(Path.home()), - specification, - device_identifier, - ) - self._findings_path = annotation_path_for(self._spool_path) - self.setWindowTitle(f"{specification.title} — {device_name}") - self.setWindowIcon(QIcon(str(icon_path))) - self.resize(1120, 720) - self._build_ui() - self._start() - - @property - def spool_path(self) -> Path: - return self._spool_path - - def _build_ui(self) -> None: - root = QWidget() - layout = QVBoxLayout(root) - heading = QLabel(f"{self._specification.title} — {self._device_name}") - heading.setFont(QFont(heading.font().family(), 18, QFont.Weight.Bold)) - layout.addWidget(heading) - summary = QLabel(self._specification.summary) - summary.setWordWrap(True) - layout.addWidget(summary) - - investigation_row = QHBoxLayout() - investigation_label = QLabel("Investigation reference") - investigation_row.addWidget(investigation_label) - self.investigation_reference_field = QLineEdit() - self.investigation_reference_field.setObjectName("liveLogInvestigationReference") - self.investigation_reference_field.setPlaceholderText("Optional local case or ticket reference") - self.investigation_reference_field.editingFinished.connect(self._record_investigation_reference) - investigation_row.addWidget(self.investigation_reference_field, 1) - layout.addLayout(investigation_row) - - filter_row = QHBoxLayout() - self.filter_field = QLineEdit() - self.filter_field.setObjectName("liveLogFilter") - self.filter_field.setPlaceholderText("Filter the visible view; raw capture remains unchanged") - self.filter_field.textChanged.connect(self._refresh_view) - filter_row.addWidget(self.filter_field, 1) - self.regex_checkbox = QCheckBox("Regex") - self.regex_checkbox.setObjectName("liveLogRegex") - self.regex_checkbox.toggled.connect(self._refresh_view) - filter_row.addWidget(self.regex_checkbox) - self.case_checkbox = QCheckBox("Case sensitive") - self.case_checkbox.setObjectName("liveLogCaseSensitive") - self.case_checkbox.toggled.connect(self._refresh_view) - filter_row.addWidget(self.case_checkbox) - layout.addLayout(filter_row) - - button_row = QHBoxLayout() - self.pause_button = QPushButton("Pause View") - self.pause_button.setObjectName("pauseLiveLogViewButton") - self.pause_button.clicked.connect(self._toggle_pause) - button_row.addWidget(self.pause_button) - self.follow_checkbox = QCheckBox("Follow tail") - self.follow_checkbox.setObjectName("followLiveLogTail") - self.follow_checkbox.setChecked(True) - button_row.addWidget(self.follow_checkbox) - stop_button = QPushButton("Stop Capture") - stop_button.setObjectName("stopLiveLogCaptureButton") - stop_button.clicked.connect(self.stop_capture) - button_row.addWidget(stop_button) - mark_finding_button = QPushButton("Mark Finding…") - mark_finding_button.setObjectName("markLiveLogFindingButton") - mark_finding_button.setToolTip("Select visible log lines, then add an analyst note without changing the raw stream.") - mark_finding_button.clicked.connect(self._mark_finding) - button_row.addWidget(mark_finding_button) - self.review_findings_button = QPushButton("Review Findings (0)") - self.review_findings_button.setObjectName("reviewLiveLogFindingsButton") - self.review_findings_button.setToolTip("Review the local analyst annotations separately from the raw device output.") - self.review_findings_button.clicked.connect(self._review_findings) - button_row.addWidget(self.review_findings_button) - button_row.addStretch() - copy_button = QPushButton("Copy Visible") - copy_button.setObjectName("copyVisibleLiveLogButton") - copy_button.clicked.connect(self._copy_visible) - button_row.addWidget(copy_button) - save_filtered_button = QPushButton("Save Filtered As…") - save_filtered_button.setObjectName("saveFilteredLiveLogButton") - save_filtered_button.clicked.connect(self._save_filtered_as) - button_row.addWidget(save_filtered_button) - save_raw_button = QPushButton("Save Raw As…") - save_raw_button.setObjectName("saveRawLiveLogButton") - save_raw_button.clicked.connect(self._save_raw_as) - button_row.addWidget(save_raw_button) - export_bundle_button = QPushButton("Export Evidence Bundle…") - export_bundle_button.setObjectName("exportLiveLogEvidenceBundleButton") - export_bundle_button.clicked.connect(self._export_evidence_bundle) - button_row.addWidget(export_bundle_button) - layout.addLayout(button_row) - - self.output = QPlainTextEdit() - self.output.setObjectName("liveLogOutput") - self.output.setReadOnly(True) - self.output.setMaximumBlockCount(MAX_VISIBLE_BLOCKS) - self.output.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) - self.output.verticalScrollBar().valueChanged.connect(self._viewport_moved) - layout.addWidget(self.output, 1) - self.status_label = QLabel() - self.status_label.setObjectName("liveLogStatus") - self.status_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - layout.addWidget(self.status_label) - self.setCentralWidget(root) - self._update_status("Preparing capture") - - def _start(self) -> None: - try: - self._spool_path.parent.mkdir(parents=True, exist_ok=True) - self._raw_file = self._spool_path.open("xb") - self._write_metadata() - except (OSError, LiveLogError) as error: - self._close_raw_file() - try: - self._spool_path.unlink(missing_ok=True) - self._metadata_path.unlink(missing_ok=True) - except OSError as cleanup_error: - raise LiveLogError( - f"Could not initialize live-log spool {self._spool_path}: {error}; cleanup also failed: {cleanup_error}" - ) from error - raise LiveLogError(f"Could not initialize live-log spool {self._spool_path}: {error}") from error - process = QProcess(self) - process.setProgram(str(self._executable.program)) - process.setArguments(list(command_arguments(self._executable, self._specification.arguments))) - process_environment = QProcessEnvironment.systemEnvironment() - for key, value in self._environment.items(): - process_environment.insert(key, value) - process.setProcessEnvironment(process_environment) - process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels) - process.readyReadStandardOutput.connect(self._read_output) - process.finished.connect(self._finished) - process.errorOccurred.connect(self._process_failed) - self._process = process - process.start() - self._update_status("Starting") - - def _read_output(self) -> None: - process = self._process - if process is None: - return - payload = bytes(process.readAllStandardOutput()) - if not payload: - return - if self._raw_file is None: - raise LiveLogError("Live-log output arrived after the raw spool was closed") - try: - self._raw_file.write(payload) - self._raw_file.flush() - except OSError as error: - self.stop_capture() - QMessageBox.critical(self, "Raw Capture Write Failed", f"Could not write {self._spool_path}: {error}") - return - self._unsaved = True - self._total_bytes += len(payload) - decoded = self._partial_line + self._decoder.decode(payload) - lines = decoded.splitlines(keepends=True) - self._partial_line = "" - if lines and not lines[-1].endswith(("\n", "\r")): - self._partial_line = lines.pop() - for line in lines: - self._append_line(line.rstrip("\r\n")) - self._update_status("Capturing") - - def _append_line(self, line: str) -> None: - self._recent_lines.append(line) - self._total_lines += 1 - if self._view_paused: - return - pattern = self._current_pattern(show_error=False) - if line_matches(line, pattern): - self.output.appendPlainText(line) - if self.follow_checkbox.isChecked(): - self.output.moveCursor(QTextCursor.MoveOperation.End) - - def _current_pattern(self, show_error: bool) -> Pattern[str] | None: - try: - pattern = compile_line_filter( - self.filter_field.text(), - self.regex_checkbox.isChecked(), - self.case_checkbox.isChecked(), - ) - except LiveLogError as error: - self.filter_field.setStyleSheet("border: 1px solid #d65a5a;") - if show_error: - QMessageBox.critical(self, "Invalid Log Filter", str(error)) - return None - self.filter_field.setStyleSheet("") - return pattern - - def _refresh_view(self) -> None: - if self._view_paused: - return - pattern = self._current_pattern(show_error=False) - self.output.setPlainText("\n".join(line for line in self._recent_lines if line_matches(line, pattern))) - if self.follow_checkbox.isChecked(): - self.output.moveCursor(QTextCursor.MoveOperation.End) - self._update_status(self._state_text()) - - def _toggle_pause(self) -> None: - self._view_paused = not self._view_paused - self.pause_button.setText("Resume View" if self._view_paused else "Pause View") - if not self._view_paused: - self._refresh_view() - self._update_status(self._state_text()) - - def _viewport_moved(self, value: int) -> None: - scrollbar = self.output.verticalScrollBar() - if value < scrollbar.maximum(): - self.follow_checkbox.setChecked(False) - - def _state_text(self) -> str: - process = self._process - if process is not None and process.state() != QProcess.ProcessState.NotRunning: - return "Capturing; view paused" if self._view_paused else "Capturing" - return "Stopped" - - def _update_status(self, state: str) -> None: - visible_lines = self.output.document().blockCount() if self.output.toPlainText() else 0 - retained_note = "" - if self._total_lines > MAX_RECENT_LINES: - retained_note = f" • view retains latest {MAX_RECENT_LINES:,}" - reference_note = self.investigation_reference_field.text().strip() - if reference_note: - reference_note = f" • reference {reference_note}" - self.status_label.setText( - f"{state} • raw {self._total_bytes:,} bytes / {self._total_lines:,} lines • " - f"visible {visible_lines:,} • findings {self._findings_count}{retained_note}{reference_note} • spool {self._spool_path}" - ) - - def stop_capture(self) -> None: - process = self._process - if process is not None and process.state() != QProcess.ProcessState.NotRunning: - process.terminate() - if not process.waitForFinished(3000): - process.kill() - process.waitForFinished(2000) - - def _finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: - del exit_status - self._read_output() - remainder = self._partial_line + self._decoder.decode(b"", final=True) - self._partial_line = "" - if remainder: - self._append_line(remainder) - self._exit_code = exit_code - self._finished_at = datetime.now(timezone.utc).isoformat() - self._close_raw_file() - self._write_metadata_or_report() - self._update_status(f"Stopped with exit code {exit_code}") - - def _process_failed(self, process_error: QProcess.ProcessError) -> None: - process = self._process - self._process_error = process.errorString() if process is not None else "Unknown QProcess error" - if process_error == QProcess.ProcessError.FailedToStart: - self._finished_at = datetime.now(timezone.utc).isoformat() - self._close_raw_file() - self._write_metadata_or_report() - self._update_status(f"Process error: {self._process_error}") - - def _close_raw_file(self) -> None: - if self._raw_file is not None: - try: - self._raw_file.flush() - self._raw_file.close() - except OSError as error: - self._process_error = f"Could not finalize raw spool: {error}" - self._raw_file = None - if self._spool_path.is_file() and self._raw_file is None: - try: - self._raw_sha256 = sha256_file(self._spool_path) - except OSError as error: - self._process_error = f"Could not hash raw spool: {error}" - - def _metadata(self) -> dict[str, MetadataValue]: - return { - "schema_version": 1, - "stream": self._specification.identifier, - "stream_title": self._specification.title, - "structured": self._specification.structured, - "requires_developer_services": self._specification.requires_developer_services, - "device_identifier": self._device_identifier, - "device_name": self._device_name, - "command": list(command_argv(self._executable, self._specification.arguments)), - "started_at": self._started_at, - "finished_at": self._finished_at, - "exit_code": self._exit_code, - "process_error": self._process_error, - "raw_bytes": self._total_bytes, - "decoded_lines": self._total_lines, - "raw_path": str(self._spool_path), - "raw_sha256": self._raw_sha256, - "findings_path": str(self._findings_path), - "findings_count": self._findings_count, - "investigation_reference": self.investigation_reference_field.text().strip(), - "interpretation_boundary": "Findings are analyst annotations, not device-generated facts or proof of causality.", - } - - def _write_metadata(self) -> None: - write_metadata(self._metadata_path, self._metadata()) - - def _write_metadata_or_report(self) -> None: - try: - self._write_metadata() - except LiveLogError as error: - self._process_error = str(error) - QMessageBox.critical(self, "Could Not Write Capture Metadata", str(error)) - - def _copy_visible(self) -> None: - QApplication.clipboard().setText(self.output.toPlainText()) - - def _record_investigation_reference(self) -> None: - self._unsaved = True - self._write_metadata_or_report() - self._update_status(self._state_text()) - - def _investigation_report(self, raw_filename: str, raw_sha256: str | None) -> LiveLogInvestigationReport: - return LiveLogInvestigationReport( - stream=self._specification.identifier, - stream_title=self._specification.title, - device_name=self._device_name, - device_identifier=self._device_identifier, - started_at=self._started_at, - finished_at=self._finished_at, - raw_filename=raw_filename, - raw_sha256=raw_sha256, - raw_bytes=self._total_bytes, - decoded_lines=self._total_lines, - investigation_reference=self.investigation_reference_field.text().strip(), - ) - - def _review_findings(self) -> None: - dialog = QDialog(self) - dialog.setWindowTitle(f"Findings — {self._specification.title}") - dialog.resize(860, 620) - layout = QVBoxLayout(dialog) - explanation = QLabel( - "This register contains analyst annotations. It is separate from the unmodified raw capture and does not establish causality." - ) - explanation.setWordWrap(True) - layout.addWidget(explanation) - register = QPlainTextEdit() - register.setObjectName("liveLogFindingRegister") - register.setReadOnly(True) - register.setPlainText( - render_investigation_report( - self._investigation_report(self._spool_path.name, self._raw_sha256), - tuple(self._findings), - ) - ) - layout.addWidget(register, 1) - controls = QHBoxLayout() - copy_button = QPushButton("Copy Register") - copy_button.setObjectName("copyLiveLogFindingRegisterButton") - copy_button.clicked.connect(lambda: QApplication.clipboard().setText(register.toPlainText())) - controls.addWidget(copy_button) - controls.addStretch() - close_button = QPushButton("Close") - close_button.setObjectName("closeLiveLogFindingRegisterButton") - close_button.clicked.connect(dialog.accept) - controls.addWidget(close_button) - layout.addLayout(controls) - dialog.exec() - - def _mark_finding(self) -> None: - selected_text = self.output.textCursor().selectedText().replace("\u2029", "\n") - if not selected_text.strip(): - QMessageBox.information(self, "Select Log Lines", "Select one or more visible log lines before marking a finding.") - return - selected_label, accepted = QInputDialog.getItem( - self, - "Classify Investigation Finding", - "Assessment (your analytical judgment, not a device fact):", - ("Observation", "Lead to correlate", "Needs corroboration"), - 0, - False, - ) - if not accepted: - return - tag_text, accepted = QInputDialog.getText( - self, - "Tag Investigation Finding", - "Optional comma-separated tags (for example: auth, network, crash):", - ) - if not accepted: - return - note, accepted = QInputDialog.getMultiLineText( - self, - "Mark Investigation Finding", - "Analyst note (stored locally beside the raw spool):", - ) - if not accepted: - return - try: - finding = create_finding( - note, - selected_text, - self._specification.identifier, - self._device_identifier, - self._total_bytes, - self.filter_field.text(), - self.regex_checkbox.isChecked(), - self.case_checkbox.isChecked(), - assessment_for_label(selected_label), - parse_finding_tags(tag_text), - ) - append_finding(self._findings_path, finding) - except LiveLogError as error: - QMessageBox.critical(self, "Could Not Mark Finding", str(error)) - return - self._findings_count += 1 - self._findings.append(finding) - self._unsaved = True - self.review_findings_button.setText(f"Review Findings ({self._findings_count})") - self._write_metadata_or_report() - self._update_status(self._state_text()) - - def _copy_raw_and_findings(self, raw_destination: Path, metadata_destination: Path) -> str: - if self._raw_file is not None: - self._raw_file.flush() - shutil.copyfile(self._spool_path, raw_destination) - saved_metadata = dict(self._metadata()) - saved_metadata["raw_path"] = str(raw_destination) - raw_sha256 = sha256_file(raw_destination) - saved_metadata["raw_sha256"] = raw_sha256 - findings_destination = annotation_path_for(raw_destination) - if self._findings_path.is_file(): - shutil.copyfile(self._findings_path, findings_destination) - saved_metadata["findings_path"] = str(findings_destination) - else: - saved_metadata["findings_path"] = None - write_metadata(metadata_destination, saved_metadata) - return raw_sha256 - - def _write_hash_manifest(self, directory: Path) -> Path: - manifest_path = directory / "SHA256SUMS.txt" - candidates = sorted(path for path in directory.iterdir() if path.is_file() and path != manifest_path) - lines = [f"{sha256_file(path)} {path.name}" for path in candidates] - try: - manifest_path.write_text("\n".join(lines) + "\n", encoding="utf-8") - except OSError as error: - raise LiveLogError(f"Could not write evidence bundle hashes to {manifest_path}: {error}") from error - return manifest_path - - def _save_raw_as(self) -> None: - suffix = ".jsonl" if self._specification.structured else ".log" - destination, _ = QFileDialog.getSaveFileName( - self, - "Save complete raw capture", - str(Path.home() / "Documents" / f"{self._specification.identifier}{suffix}"), - "JSON Lines (*.jsonl)" if self._specification.structured else "Log files (*.log)", - ) - if not destination: - return - if self._raw_file is not None: - self._raw_file.flush() - destination_path = Path(destination).expanduser().resolve() - try: - metadata_destination = destination_path.with_name(f"{destination_path.name}.meta.json") - self._copy_raw_and_findings(destination_path, metadata_destination) - except (OSError, LiveLogError) as error: - QMessageBox.critical(self, "Could Not Save Raw Capture", str(error)) - return - self._unsaved = False - QMessageBox.information( - self, - "Raw Capture Saved", - f"Saved the complete unfiltered byte stream to:\n{destination_path}\n\nMetadata and any local findings were saved beside it.", - ) - - def _export_evidence_bundle(self) -> None: - destination = QFileDialog.getExistingDirectory( - self, - "Choose folder for live-log evidence bundle", - str(Path.home() / "Documents"), - ) - if not destination: - return - parent = Path(destination).expanduser().resolve() - bundle_directory = parent / f"{self._spool_path.stem}-investigation" - try: - bundle_directory.mkdir(mode=0o700, parents=False, exist_ok=False) - raw_destination = bundle_directory / self._spool_path.name - metadata_destination = bundle_directory / self._metadata_path.name - raw_sha256 = self._copy_raw_and_findings(raw_destination, metadata_destination) - report_destination = bundle_directory / "investigation-report.md" - write_investigation_report( - report_destination, - self._investigation_report(raw_destination.name, raw_sha256), - tuple(self._findings), - ) - hashes_path = self._write_hash_manifest(bundle_directory) - except (OSError, LiveLogError) as error: - QMessageBox.critical(self, "Could Not Export Evidence Bundle", str(error)) - return - self._unsaved = False - QMessageBox.information( - self, - "Evidence Bundle Exported", - f"Saved raw capture, metadata, findings, investigation report, and SHA-256 inventory to:\n{bundle_directory}\n\nHashes:\n{hashes_path}", - ) - - def _save_filtered_as(self) -> None: - pattern = self._current_pattern(show_error=True) - if self.filter_field.text() and pattern is None: - return - destination, _ = QFileDialog.getSaveFileName( - self, - "Save filtered text view", - str(Path.home() / "Documents" / f"{self._specification.identifier}-filtered.log"), - "Log files (*.log)", - ) - if not destination: - return - if self._raw_file is not None: - self._raw_file.flush() - destination_path = Path(destination).expanduser().resolve() - try: - with self._spool_path.open("r", encoding="utf-8", errors="replace") as source: - with destination_path.open("x", encoding="utf-8") as output: - for line in source: - if line_matches(line, pattern): - output.write(line) - except FileExistsError: - QMessageBox.critical(self, "Could Not Save Filtered View", f"Destination already exists: {destination_path}") - return - except OSError as error: - QMessageBox.critical(self, "Could Not Save Filtered View", f"Could not write {destination_path}: {error}") - return - QMessageBox.information(self, "Filtered View Saved", f"Saved filtered text to:\n{destination_path}") - - def closeEvent(self, event: QCloseEvent) -> None: - if self._closing: - event.accept() - return - if self._unsaved: - message = QMessageBox(self) - message.setIcon(QMessageBox.Icon.Warning) - message.setWindowTitle("Save Live Log Before Closing?") - message.setText("This window has a complete raw capture in its temporary spool.") - message.setInformativeText("Save it, explicitly discard it, or keep the window open.") - save_button = message.addButton("Save Raw…", QMessageBox.ButtonRole.AcceptRole) - discard_button = message.addButton("Discard", QMessageBox.ButtonRole.DestructiveRole) - cancel_button = message.addButton(QMessageBox.StandardButton.Cancel) - message.setDefaultButton(cancel_button) - message.exec() - clicked = message.clickedButton() - if clicked == cancel_button: - event.ignore() - return - if clicked == save_button: - self._save_raw_as() - if self._unsaved: - event.ignore() - return - elif clicked == discard_button: - self._unsaved = False - else: - raise LiveLogError("Live-log close dialog returned an unknown button") - self._closing = True - self.stop_capture() - self._close_raw_file() - if not self._unsaved: - try: - self._spool_path.unlink(missing_ok=True) - self._metadata_path.unlink(missing_ok=True) - self._findings_path.unlink(missing_ok=True) - except OSError as error: - QMessageBox.critical(self, "Could Not Remove Temporary Capture", str(error)) - self._closing = False - event.ignore() - return - self.closed.emit(self) - event.accept() diff --git a/ios_developer_toolkit/local_ddi.py b/ios_developer_toolkit/local_ddi.py deleted file mode 100644 index 7c078f7..0000000 --- a/ios_developer_toolkit/local_ddi.py +++ /dev/null @@ -1,192 +0,0 @@ -from __future__ import annotations - -import argparse -import os -import plistlib -import selectors -import signal -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Mapping, Sequence - -from ios_developer_toolkit.runtime import ExecutableCommand, command_argv, device_environment, pymobiledevice3_command -from ios_developer_toolkit.validation import output_indicates_failure - - -class LocalDDIError(RuntimeError): - """Base error for failures in the local Xcode DDI workflow.""" - - -class DDIAttachError(LocalDDIError): - """Raised when the outer Xcode DDI cannot be attached read-only.""" - - -class DDILayoutError(LocalDDIError): - """Raised when the attached DDI does not contain an expected Restore directory.""" - - -class DDIDetachError(LocalDDIError): - """Raised when the temporary Mac-side DDI mount cannot be detached.""" - - -@dataclass(frozen=True) -class AttachedImage: - device_entry: str - mount_point: Path - - -def parse_attached_image(payload: bytes) -> AttachedImage: - parsed: object = plistlib.loads(payload) - if not isinstance(parsed, dict): - raise DDIAttachError("hdiutil returned a property list with an unexpected root type") - entities = parsed.get("system-entities") - if not isinstance(entities, list): - raise DDIAttachError("hdiutil output did not contain system-entities") - for entity in entities: - if not isinstance(entity, dict): - continue - mount_point = entity.get("mount-point") - device_entry = entity.get("dev-entry") - if isinstance(mount_point, str) and isinstance(device_entry, str): - return AttachedImage(device_entry=device_entry, mount_point=Path(mount_point)) - raise DDIAttachError("hdiutil did not report a mounted filesystem") - - -def attach_candidate(candidate: Path) -> AttachedImage: - if not candidate.is_file(): - raise FileNotFoundError(f"The Xcode candidate DDI does not exist: {candidate}") - completed = subprocess.run( - ["/usr/bin/hdiutil", "attach", "-readonly", "-nobrowse", "-noautoopen", "-plist", str(candidate)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, - ) - if completed.returncode != 0: - message = completed.stderr.decode("utf-8", errors="replace").strip() - raise DDIAttachError(f"Failed to attach {candidate} read-only: {message}") - return parse_attached_image(completed.stdout) - - -def detach_candidate(attached: AttachedImage) -> None: - completed = subprocess.run( - ["/usr/bin/hdiutil", "detach", attached.device_entry], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - check=False, - ) - if completed.returncode != 0: - message = completed.stdout.decode("utf-8", errors="replace").strip() - raise DDIDetachError(f"Failed to detach temporary DDI mount {attached.device_entry}: {message}") - - -class CryptexInstaller: - """Coordinates the external hdiutil and pymobiledevice3 processes.""" - - def __init__(self, candidate: Path, udid: str, command: ExecutableCommand) -> None: - self._candidate = candidate - self._udid = udid - self._command = command - self._stop_requested = False - self._active_process: subprocess.Popen[bytes] | None = None - - def request_stop(self, signum: int, frame: object) -> None: - del signum, frame - self._stop_requested = True - active_process = self._active_process - if active_process is not None and active_process.poll() is None: - os.killpg(active_process.pid, signal.SIGINT) - - def install(self) -> None: - print(f"Attaching Apple Xcode DDI read-only: {self._candidate}", flush=True) - attached = attach_candidate(self._candidate) - primary_error: LocalDDIError | subprocess.SubprocessError | None = None - try: - restore_directory = attached.mount_point / "Restore" - manifest_path = restore_directory / "BuildManifest.plist" - if not restore_directory.is_dir() or not manifest_path.is_file(): - raise DDILayoutError( - f"The mounted candidate does not contain Restore/BuildManifest.plist: {attached.mount_point}" - ) - print(f"Using local Restore payload: {restore_directory}", flush=True) - environment: Mapping[str, str] = device_environment(self._udid) - self._active_process = subprocess.Popen( - command_argv( - self._command, - ("cryptex", "auto-install", "--restore-dir", str(restore_directory)), - ), - env=environment, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - start_new_session=True, - ) - if self._active_process.stdout is None: - raise LocalDDIError("Could not open the pymobiledevice3 output stream") - captured_output = bytearray() - selector = selectors.DefaultSelector() - selector.register(self._active_process.stdout, selectors.EVENT_READ) - while self._active_process.poll() is None: - if self._stop_requested: - try: - self._active_process.wait(timeout=8) - except subprocess.TimeoutExpired: - os.killpg(self._active_process.pid, signal.SIGKILL) - self._active_process.wait(timeout=5) - break - for key, _ in selector.select(timeout=0.2): - chunk = key.fileobj.read1(4096) - if chunk: - captured_output.extend(chunk) - print(chunk.decode("utf-8", errors="replace"), end="", flush=True) - remainder = self._active_process.stdout.read() - if remainder: - captured_output.extend(remainder) - print(remainder.decode("utf-8", errors="replace"), end="", flush=True) - selector.close() - if self._stop_requested: - raise LocalDDIError("Local Xcode DDI installation was cancelled") - if self._active_process.returncode != 0 or output_indicates_failure(bytes(captured_output)): - raise LocalDDIError( - f"pymobiledevice3 cryptex installation failed with exit code {self._active_process.returncode}; " - "review the preceding device-service error" - ) - except (LocalDDIError, subprocess.SubprocessError) as error: - primary_error = error - finally: - self._active_process = None - print(f"Detaching temporary Mac-side image: {attached.device_entry}", flush=True) - try: - detach_candidate(attached) - except DDIDetachError as detach_error: - if primary_error is not None: - raise DDIDetachError(f"{primary_error}; cleanup also failed: {detach_error}") from detach_error - raise - if primary_error is not None: - raise primary_error - print("Local Apple DDI Cryptex installed successfully at /System/Developer.", flush=True) - - -def parse_args(arguments: Sequence[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Install a local Xcode candidate DDI as a personalized Cryptex") - parser.add_argument("--candidate", required=True, type=Path) - parser.add_argument("--udid", required=True) - return parser.parse_args(arguments) - - -def main() -> int: - options = parse_args(sys.argv[1:]) - try: - command = pymobiledevice3_command() - installer = CryptexInstaller(options.candidate.expanduser().resolve(), options.udid, command) - signal.signal(signal.SIGINT, installer.request_stop) - signal.signal(signal.SIGTERM, installer.request_stop) - installer.install() - except (LocalDDIError, FileNotFoundError, OSError) as error: - print(f"ERROR: {error}", file=sys.stderr, flush=True) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/ios_developer_toolkit/location_lab.py b/ios_developer_toolkit/location_lab.py deleted file mode 100644 index bdab709..0000000 --- a/ios_developer_toolkit/location_lab.py +++ /dev/null @@ -1,585 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import math -import os -import re -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Mapping, Sequence -from urllib.parse import parse_qs, unquote, urlparse -from xml.etree import ElementTree - - -MAX_GPX_BYTES = 64 * 1024 * 1024 -SAVED_LOCATIONS_VERSION = 1 -COORDINATE_NUMBER_PATTERN = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?" -COORDINATE_PAIR_PATTERN = re.compile( - rf"^\s*\(?\s*({COORDINATE_NUMBER_PATTERN})\s*,\s*({COORDINATE_NUMBER_PATTERN})\s*\)?\s*$" -) -GOOGLE_MAP_PATH_PATTERN = re.compile( - rf"/@({COORDINATE_NUMBER_PATTERN}),({COORDINATE_NUMBER_PATTERN})(?:[,/]|$)" -) - - -class LocationLabError(ValueError): - pass - - -@dataclass(frozen=True) -class Coordinates: - latitude: float - longitude: float - - -@dataclass(frozen=True) -class SavedLocation: - name: str - coordinates: Coordinates - - -@dataclass(frozen=True) -class GPXInspection: - path: Path - size_bytes: int - track_point_count: int - timed_point_count: int - first_point: Coordinates - last_point: Coordinates - sha256: str - - -@dataclass(frozen=True) -class GeneratedRoute: - points: tuple[Coordinates, ...] - distance_metres: float - duration_seconds: int - speed_kmh: float - interval_seconds: int - traversal_count: int - gpx_document: str - - -@dataclass(frozen=True) -class LocationEvidenceEvent: - event: str - status: str - timestamp: str - device_identifier: str - device_name: str - ios_version: str - command: tuple[str, ...] - latitude: float | None - longitude: float | None - gpx_path: str | None - gpx_sha256: str | None - exit_code: int | None - detail: str - - def to_mapping(self) -> Mapping[str, str | int | float | None | list[str]]: - return { - "event": self.event, - "status": self.status, - "timestamp": self.timestamp, - "device_identifier": self.device_identifier, - "device_name": self.device_name, - "ios_version": self.ios_version, - "command": list(self.command), - "latitude": self.latitude, - "longitude": self.longitude, - "gpx_path": self.gpx_path, - "gpx_sha256": self.gpx_sha256, - "exit_code": self.exit_code, - "detail": self.detail, - } - - -def utc_now() -> str: - return datetime.now(timezone.utc).isoformat() - - -def validate_coordinate(value: str, label: str, minimum: float, maximum: float) -> float: - try: - coordinate = float(value.strip()) - except ValueError as error: - raise LocationLabError(f"{label} must be a decimal number") from error - if not math.isfinite(coordinate): - raise LocationLabError(f"{label} must be a finite decimal number") - if coordinate < minimum or coordinate > maximum: - raise LocationLabError(f"{label} must be between {minimum:g} and {maximum:g}") - return coordinate - - -def validate_coordinates(latitude: str, longitude: str) -> Coordinates: - return Coordinates( - latitude=validate_coordinate(latitude, "Latitude", -90.0, 90.0), - longitude=validate_coordinate(longitude, "Longitude", -180.0, 180.0), - ) - - -def _parse_coordinate_pair(value: str) -> Coordinates | None: - match = COORDINATE_PAIR_PATTERN.fullmatch(unquote(value)) - if match is None: - return None - return validate_coordinates(match.group(1), match.group(2)) - - -def parse_location_input(value: str) -> Coordinates: - location = value.strip() - if not location: - raise LocationLabError("Enter latitude,longitude or paste an Apple Maps, Google Maps, or geo: link") - direct_coordinates = _parse_coordinate_pair(location) - if direct_coordinates is not None: - return direct_coordinates - parsed = urlparse(location) - if parsed.scheme.casefold() == "geo": - geo_coordinates = _parse_coordinate_pair(parsed.path) - if geo_coordinates is not None: - return geo_coordinates - if parsed.scheme.casefold() not in ("http", "https"): - raise LocationLabError( - "Location input must be latitude,longitude or an Apple Maps, Google Maps, or geo: link containing coordinates" - ) - query = parse_qs(parsed.query, keep_blank_values=False) - for key in ("ll", "coordinate", "query", "q", "destination", "daddr", "center"): - for candidate in query.get(key, ()): - coordinates = _parse_coordinate_pair(candidate) - if coordinates is not None: - return coordinates - for candidate in query.get("cp", ()): - coordinates = _parse_coordinate_pair(candidate.replace("~", ",", 1)) - if coordinates is not None: - return coordinates - path_match = GOOGLE_MAP_PATH_PATTERN.search(unquote(parsed.path)) - if path_match is not None: - return validate_coordinates(path_match.group(1), path_match.group(2)) - raise LocationLabError( - "The map link does not contain visible coordinates. Expand shortened links in a browser, then copy a full " - "Apple Maps or Google Maps URL, or paste latitude,longitude directly." - ) - - -def coordinates_to_map_fractions(coordinates: Coordinates) -> tuple[float, float]: - validated = validate_coordinates(str(coordinates.latitude), str(coordinates.longitude)) - return ( - (validated.longitude + 180.0) / 360.0, - (90.0 - validated.latitude) / 180.0, - ) - - -def map_fractions_to_coordinates(horizontal: float, vertical: float) -> Coordinates: - if not math.isfinite(horizontal) or not math.isfinite(vertical): - raise LocationLabError("Map position must be finite") - if horizontal < 0.0 or horizontal > 1.0 or vertical < 0.0 or vertical > 1.0: - raise LocationLabError("Map position must be inside the displayed world map") - return Coordinates( - latitude=90.0 - vertical * 180.0, - longitude=horizontal * 360.0 - 180.0, - ) - - -def parse_route_waypoints(payload: str) -> tuple[Coordinates, ...]: - points: list[Coordinates] = [] - for line_number, raw_line in enumerate(payload.splitlines(), start=1): - line = raw_line.strip() - if not line: - continue - values = tuple(value.strip() for value in line.split(",")) - if len(values) != 2: - raise LocationLabError( - f"Route waypoint line {line_number} must contain latitude,longitude; received {raw_line!r}" - ) - try: - points.append(validate_coordinates(values[0], values[1])) - except LocationLabError as error: - raise LocationLabError(f"Route waypoint line {line_number}: {error}") from error - if len(points) < 2: - raise LocationLabError("A generated route requires at least two latitude,longitude waypoints") - return tuple(points) - - -def haversine_distance_metres(start: Coordinates, end: Coordinates) -> float: - earth_radius_metres = 6_371_008.8 - start_latitude = math.radians(start.latitude) - end_latitude = math.radians(end.latitude) - latitude_delta = end_latitude - start_latitude - longitude_delta = math.radians(end.longitude - start.longitude) - haversine = ( - math.sin(latitude_delta / 2.0) ** 2 - + math.cos(start_latitude) * math.cos(end_latitude) * math.sin(longitude_delta / 2.0) ** 2 - ) - return 2.0 * earth_radius_metres * math.asin(min(1.0, math.sqrt(haversine))) - - -def move_coordinates(origin: Coordinates, bearing_degrees: float, distance_metres: float) -> Coordinates: - if not math.isfinite(bearing_degrees): - raise LocationLabError("Nudge bearing must be finite") - if not math.isfinite(distance_metres) or distance_metres <= 0.0 or distance_metres > 100_000.0: - raise LocationLabError("Nudge distance must be greater than 0 and at most 100000 metres") - earth_radius_metres = 6_371_008.8 - angular_distance = distance_metres / earth_radius_metres - bearing = math.radians(bearing_degrees) - latitude = math.radians(origin.latitude) - longitude = math.radians(origin.longitude) - destination_latitude = math.asin( - math.sin(latitude) * math.cos(angular_distance) - + math.cos(latitude) * math.sin(angular_distance) * math.cos(bearing) - ) - destination_longitude = longitude + math.atan2( - math.sin(bearing) * math.sin(angular_distance) * math.cos(latitude), - math.cos(angular_distance) - math.sin(latitude) * math.sin(destination_latitude), - ) - normalized_longitude = (math.degrees(destination_longitude) + 540.0) % 360.0 - 180.0 - return Coordinates(latitude=math.degrees(destination_latitude), longitude=normalized_longitude) - - -def _shortest_longitude_delta(start: float, end: float) -> float: - return (end - start + 540.0) % 360.0 - 180.0 - - -def _interpolate_segment(start: Coordinates, end: Coordinates, step_count: int) -> tuple[Coordinates, ...]: - if step_count <= 0: - raise LocationLabError("Route interpolation step count must be positive") - longitude_delta = _shortest_longitude_delta(start.longitude, end.longitude) - return tuple( - Coordinates( - latitude=start.latitude + (end.latitude - start.latitude) * (index / step_count), - longitude=(start.longitude + longitude_delta * (index / step_count) + 540.0) % 360.0 - 180.0, - ) - for index in range(1, step_count + 1) - ) - - -def _route_traversal( - waypoints: Sequence[Coordinates], - metres_per_step: float, - maximum_points: int, -) -> tuple[Coordinates, ...]: - points: list[Coordinates] = [waypoints[0]] - for start, end in zip(waypoints, waypoints[1:]): - distance = haversine_distance_metres(start, end) - step_count = max(1, math.ceil(distance / metres_per_step)) - if len(points) + step_count > maximum_points: - raise LocationLabError( - "Generated route exceeds 100000 points; increase speed/interval or reduce traversals" - ) - points.extend(_interpolate_segment(start, end, step_count)) - return tuple(points) - - -def build_route( - waypoints: Sequence[Coordinates], - speed_kmh: float, - interval_seconds: int, - traversal_count: int, - start_time: datetime, -) -> GeneratedRoute: - if len(waypoints) < 2: - raise LocationLabError("A generated route requires at least two waypoints") - if not math.isfinite(speed_kmh) or speed_kmh <= 0.0 or speed_kmh > 300.0: - raise LocationLabError("Route speed must be greater than 0 and at most 300 km/h") - if interval_seconds < 1 or interval_seconds > 60: - raise LocationLabError("Route interval must be between 1 and 60 seconds") - if traversal_count < 1 or traversal_count > 20: - raise LocationLabError("Route traversal count must be between 1 and 20") - if start_time.tzinfo is None: - raise LocationLabError("Route start time must include timezone information") - validated_waypoints = tuple( - Coordinates( - latitude=validate_coordinate(str(point.latitude), "Latitude", -90.0, 90.0), - longitude=validate_coordinate(str(point.longitude), "Longitude", -180.0, 180.0), - ) - for point in waypoints - ) - metres_per_step = speed_kmh * 1000.0 / 3600.0 * interval_seconds - all_points: list[Coordinates] = [] - total_distance = 0.0 - for traversal_index in range(traversal_count): - traversal_waypoints = validated_waypoints if traversal_index % 2 == 0 else tuple(reversed(validated_waypoints)) - sampled = _route_traversal(traversal_waypoints, metres_per_step, 100_000 - len(all_points)) - if all_points: - sampled = sampled[1:] - all_points.extend(sampled) - total_distance += sum( - haversine_distance_metres(start, end) - for start, end in zip(traversal_waypoints, traversal_waypoints[1:]) - ) - if len(all_points) > 100_000: - raise LocationLabError( - "Generated route exceeds 100000 points; increase speed/interval or reduce traversals" - ) - duration_seconds = max(0, len(all_points) - 1) * interval_seconds - lines = [ - '', - '', - " Toolkit QA route", - ] - normalized_start = start_time.astimezone(timezone.utc) - for index, point in enumerate(all_points): - timestamp = (normalized_start + timedelta(seconds=index * interval_seconds)).isoformat().replace("+00:00", "Z") - lines.append( - f' ' - ) - lines.extend((" ", "")) - return GeneratedRoute( - points=tuple(all_points), - distance_metres=total_distance, - duration_seconds=duration_seconds, - speed_kmh=speed_kmh, - interval_seconds=interval_seconds, - traversal_count=traversal_count, - gpx_document="\n".join(lines) + "\n", - ) - - -def validate_location_name(value: str) -> str: - name = value.strip() - if not name: - raise LocationLabError("Saved location name is required") - if len(name) > 80: - raise LocationLabError("Saved location name must be 80 characters or fewer") - if any(ord(character) < 32 for character in name): - raise LocationLabError("Saved location name cannot contain control characters") - return name - - -def parse_ios_major(version: str) -> int: - match = re.match(r"^(\d+)(?:\.|$)", version.strip()) - if match is None: - raise LocationLabError(f"Could not determine the iOS major version from {version!r}") - return int(match.group(1)) - - -def set_location_arguments(version: str, coordinates: Coordinates) -> tuple[str, ...]: - values = (format(coordinates.latitude, ".12g"), format(coordinates.longitude, ".12g")) - if parse_ios_major(version) >= 17: - return ("developer", "dvt", "simulate-location", "set", "--", *values) - return ("developer", "simulate-location", "set", "--", *values) - - -def clear_location_arguments(version: str) -> tuple[str, ...]: - if parse_ios_major(version) >= 17: - return ("developer", "dvt", "simulate-location", "clear") - return ("developer", "simulate-location", "clear") - - -def play_location_arguments( - version: str, - gpx_path: Path, - timing_randomness_milliseconds: int, - disable_sleep: bool, -) -> tuple[str, ...]: - if timing_randomness_milliseconds < 0 or timing_randomness_milliseconds > 60000: - raise LocationLabError("Timing randomness must be between 0 and 60000 milliseconds") - resolved_path = gpx_path.expanduser().resolve() - if parse_ios_major(version) >= 17: - arguments = [ - "developer", - "dvt", - "simulate-location", - "play", - str(resolved_path), - "--timing-randomness-range", - str(timing_randomness_milliseconds), - ] - else: - arguments = [ - "developer", - "simulate-location", - "play", - str(resolved_path), - str(timing_randomness_milliseconds), - ] - if disable_sleep: - arguments.append("--disable-sleep") - return tuple(arguments) - - -def sha256_file(path: Path) -> str: - digest = hashlib.sha256() - try: - with path.open("rb") as source: - for chunk in iter(lambda: source.read(1024 * 1024), b""): - digest.update(chunk) - except OSError as error: - raise LocationLabError(f"Could not hash GPX file {path}: {error}") from error - return digest.hexdigest() - - -def _local_name(tag: str) -> str: - return tag.rsplit("}", 1)[-1] - - -def inspect_gpx(path: Path) -> GPXInspection: - resolved_path = path.expanduser().resolve() - if not resolved_path.is_file(): - raise LocationLabError(f"GPX file does not exist: {resolved_path}") - if resolved_path.suffix.casefold() != ".gpx": - raise LocationLabError(f"Route file must use the .gpx extension: {resolved_path}") - try: - size_bytes = resolved_path.stat().st_size - except OSError as error: - raise LocationLabError(f"Could not read GPX file metadata for {resolved_path}: {error}") from error - if size_bytes <= 0: - raise LocationLabError(f"GPX file is empty: {resolved_path}") - if size_bytes > MAX_GPX_BYTES: - raise LocationLabError(f"GPX file exceeds the {MAX_GPX_BYTES // (1024 * 1024)} MiB validation limit") - try: - raw_xml = resolved_path.read_bytes() - except OSError as error: - raise LocationLabError(f"Could not read GPX file {resolved_path}: {error}") from error - upper_prefix = raw_xml[:16384].upper() - if b" Path: - resolved_home = home.expanduser().resolve() - return resolved_home / "Library" / "Application Support" / "iOS Developer Toolkit" / "locations.json" - - -def _required_saved_number(record: Mapping[str, object], key: str, label: str, minimum: float, maximum: float) -> float: - value = record.get(key) - if not isinstance(value, (int, float)) or isinstance(value, bool): - raise LocationLabError(f"Saved location {label} must be numeric") - return validate_coordinate(str(value), label, minimum, maximum) - - -def parse_saved_locations(payload: str) -> tuple[SavedLocation, ...]: - try: - decoded: object = json.loads(payload) - except json.JSONDecodeError as error: - raise LocationLabError(f"Saved locations JSON is malformed: {error}") from error - if not isinstance(decoded, dict): - raise LocationLabError("Saved locations document must be a JSON object") - version = decoded.get("version") - if version != SAVED_LOCATIONS_VERSION: - raise LocationLabError(f"Unsupported saved locations version: {version!r}") - records = decoded.get("locations") - if not isinstance(records, list): - raise LocationLabError("Saved locations document must contain a locations array") - locations: list[SavedLocation] = [] - names: set[str] = set() - for record in records: - if not isinstance(record, dict): - raise LocationLabError("Each saved location must be a JSON object") - raw_name = record.get("name") - if not isinstance(raw_name, str): - raise LocationLabError("Each saved location must contain a string name") - name = validate_location_name(raw_name) - normalized_name = name.casefold() - if normalized_name in names: - raise LocationLabError(f"Saved location names must be unique: {name}") - names.add(normalized_name) - coordinates = Coordinates( - latitude=_required_saved_number(record, "latitude", "Latitude", -90.0, 90.0), - longitude=_required_saved_number(record, "longitude", "Longitude", -180.0, 180.0), - ) - locations.append(SavedLocation(name=name, coordinates=coordinates)) - return tuple(locations) - - -def load_saved_locations(path: Path) -> tuple[SavedLocation, ...]: - resolved_path = path.expanduser().resolve() - if not resolved_path.exists(): - return () - if not resolved_path.is_file(): - raise LocationLabError(f"Saved locations path is not a file: {resolved_path}") - try: - payload = resolved_path.read_text(encoding="utf-8") - except OSError as error: - raise LocationLabError(f"Could not read saved locations from {resolved_path}: {error}") from error - return parse_saved_locations(payload) - - -def add_saved_location( - locations: Sequence[SavedLocation], - name: str, - coordinates: Coordinates, -) -> tuple[SavedLocation, ...]: - validated_name = validate_location_name(name) - if any(location.name.casefold() == validated_name.casefold() for location in locations): - raise LocationLabError(f"A saved location named {validated_name!r} already exists") - return (*locations, SavedLocation(name=validated_name, coordinates=coordinates)) - - -def remove_saved_location( - locations: Sequence[SavedLocation], - name: str, -) -> tuple[SavedLocation, ...]: - matching = tuple(location for location in locations if location.name == name) - if len(matching) != 1: - raise LocationLabError(f"Expected one saved location named {name!r}, found {len(matching)}") - return tuple(location for location in locations if location.name != name) - - -def save_saved_locations(path: Path, locations: Sequence[SavedLocation]) -> None: - resolved_path = path.expanduser().resolve() - document = { - "version": SAVED_LOCATIONS_VERSION, - "locations": [ - { - "name": location.name, - "latitude": location.coordinates.latitude, - "longitude": location.coordinates.longitude, - } - for location in locations - ], - } - temporary_path = resolved_path.with_name(f".{resolved_path.name}.{os.getpid()}.new") - try: - resolved_path.parent.mkdir(parents=True, exist_ok=True) - temporary_path.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8") - temporary_path.replace(resolved_path) - except OSError as error: - try: - temporary_path.unlink(missing_ok=True) - except OSError as cleanup_error: - raise LocationLabError( - f"Could not save locations to {resolved_path}: {error}; " - f"temporary-file cleanup also failed: {cleanup_error}" - ) from error - raise LocationLabError(f"Could not save locations to {resolved_path}: {error}") from error - - -def append_evidence_event(directory: Path, event: LocationEvidenceEvent) -> Path: - resolved_directory = directory.expanduser().resolve() - log_path = resolved_directory / "location-events.jsonl" - try: - resolved_directory.mkdir(parents=True, exist_ok=True) - with log_path.open("a", encoding="utf-8") as output: - output.write(json.dumps(event.to_mapping(), sort_keys=True) + "\n") - except OSError as error: - raise LocationLabError(f"Could not append Location Lab evidence to {log_path}: {error}") from error - return log_path diff --git a/ios_developer_toolkit/models.py b/ios_developer_toolkit/models.py deleted file mode 100644 index 5a2002d..0000000 --- a/ios_developer_toolkit/models.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -import json -from dataclasses import dataclass -from pathlib import Path -from typing import Mapping - - -class DeviceDataError(ValueError): - """Raised when usbmux returns malformed or incomplete device data.""" - - -def _required_string(record: Mapping[str, object], key: str) -> str: - value = record.get(key) - if not isinstance(value, str) or not value: - raise DeviceDataError(f"Device record is missing required string field: {key}") - return value - - -def _optional_string(record: Mapping[str, object], key: str) -> str: - value = record.get(key) - if value is None: - return "Unknown" - if not isinstance(value, str): - raise DeviceDataError(f"Device field {key} must be a string when present") - return value - - -@dataclass(frozen=True) -class IOSDevice: - identifier: str - name: str - product_type: str - product_version: str - build_version: str - connection_type: str - - @classmethod - def from_mapping(cls, record: Mapping[str, object]) -> IOSDevice: - return cls( - identifier=_required_string(record, "Identifier"), - name=_optional_string(record, "DeviceName"), - product_type=_optional_string(record, "ProductType"), - product_version=_optional_string(record, "ProductVersion"), - build_version=_optional_string(record, "BuildVersion"), - connection_type=_optional_string(record, "ConnectionType"), - ) - - def display_name(self) -> str: - return f"{self.name} — iOS {self.product_version} ({self.connection_type})" - - -def parse_devices_json(payload: str) -> tuple[IOSDevice, ...]: - parsed: object = json.loads(payload) - if not isinstance(parsed, list): - raise DeviceDataError("usbmux output must be a JSON array") - devices: list[IOSDevice] = [] - for item in parsed: - if not isinstance(item, dict): - raise DeviceDataError("Each usbmux device entry must be a JSON object") - devices.append(IOSDevice.from_mapping(item)) - return tuple(devices) - - -@dataclass(frozen=True) -class CommandSpec: - identifier: str - title: str - arguments: tuple[str, ...] - output_path: Path - required: bool - timeout_seconds: int - - -@dataclass(frozen=True) -class CommandResult: - identifier: str - title: str - arguments: tuple[str, ...] - output_path: str - started_at: str - ended_at: str - exit_code: int - attempts: int - status: str - - def to_mapping(self) -> Mapping[str, object]: - return { - "identifier": self.identifier, - "title": self.title, - "arguments": list(self.arguments), - "output_path": self.output_path, - "started_at": self.started_at, - "ended_at": self.ended_at, - "exit_code": self.exit_code, - "attempts": self.attempts, - "status": self.status, - } diff --git a/ios_developer_toolkit/mvt_connector.py b/ios_developer_toolkit/mvt_connector.py deleted file mode 100644 index dab8e3a..0000000 --- a/ios_developer_toolkit/mvt_connector.py +++ /dev/null @@ -1,269 +0,0 @@ -from __future__ import annotations - -import os -import plistlib -import re -from dataclasses import dataclass -from pathlib import Path -from typing import Mapping - -from ios_developer_toolkit.runtime import ExecutableCommand -from ios_developer_toolkit.file_integrity import sha256_file - - -MVT_REPOSITORY_URL = "https://github.com/mvt-project/mvt" -MVT_INSTALLATION_URL = "https://docs.mvt.re/en/latest/install/" -MVT_BACKUP_GUIDE_URL = "https://docs.mvt.re/en/latest/ios/backup/check/" -MVT_ENVIRONMENT_KEYS_TO_REMOVE = ( - "MVT_ANDROID_BACKUP_PASSWORD", - "MVT_HASH_FILES", - "MVT_IOS_BACKUP_PASSWORD", - "MVT_PROFILE", - "MVT_STIX2", - "MVT_VT_API_KEY", -) - - -class MVTValidationError(ValueError): - """Raised when an external MVT analysis request is unsafe or incomplete.""" - - -@dataclass(frozen=True) -class MVTExecutable: - path: Path - sha256: str - - -@dataclass(frozen=True) -class MVTInstallation: - executable: MVTExecutable - version: str - - -@dataclass(frozen=True) -class MVTBackup: - path: Path - encrypted: bool | None - - -@dataclass(frozen=True) -class MVTAnalysisRequest: - installation: MVTInstallation - backup: MVTBackup - output: Path - ioc_files: tuple[Path, ...] - fast: bool - hashes: bool - allow_network: bool - - -def mvt_setup_commands() -> tuple[str, ...]: - return ( - "brew install python3 pipx sqlite3", - "pipx ensurepath", - "pipx install mvt", - ) - - -def discover_mvt_executables(home: Path, path_environment: str) -> tuple[Path, ...]: - candidates: list[Path] = [] - path_entries = tuple(Path(entry) for entry in path_environment.split(os.pathsep) if entry) - locations = ( - *(entry / "mvt-ios" for entry in path_entries), - home.expanduser() / ".local" / "bin" / "mvt-ios", - Path("/opt/homebrew/bin/mvt-ios"), - Path("/usr/local/bin/mvt-ios"), - ) - for location in locations: - expanded = location.expanduser() - if expanded.is_file() and os.access(expanded, os.X_OK): - resolved = expanded.resolve() - if resolved not in candidates: - candidates.append(resolved) - return tuple(candidates) - - -def inspect_mvt_executable(path: Path) -> MVTExecutable: - expanded = path.expanduser() - if not expanded.is_absolute(): - raise MVTValidationError(f"MVT executable must be an absolute path: {expanded}") - resolved = expanded.resolve() - if not resolved.is_file(): - raise MVTValidationError(f"MVT executable does not exist: {resolved}") - if not os.access(resolved, os.X_OK): - raise MVTValidationError(f"MVT executable is not executable: {resolved}") - return MVTExecutable(resolved, sha256_file(resolved)) - - -def mvt_command(executable: MVTExecutable) -> ExecutableCommand: - environment_arguments = tuple( - argument - for key in MVT_ENVIRONMENT_KEYS_TO_REMOVE - for argument in ("-u", key) - ) - return ExecutableCommand(Path("/usr/bin/env"), (*environment_arguments, str(executable.path))) - - -def mvt_version_arguments() -> tuple[str, ...]: - return ("--disable-update-check", "--disable-indicator-update-check", "version") - - -def parse_mvt_version_output(output: str) -> str: - without_ansi = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", output) - match = re.search(r"(?im)^\s*Version:\s*([A-Za-z0-9][A-Za-z0-9._+-]*)\s*$", without_ansi) - if match is None: - raise MVTValidationError("MVT version output did not contain a recognizable 'Version:' line") - return match.group(1) - - -def _is_backup_folder(path: Path) -> bool: - return (path / "Manifest.db").is_file() and (path / "Info.plist").is_file() - - -def _resolved_backup_folder(path: Path) -> Path: - expanded = path.expanduser() - if not expanded.is_absolute(): - raise MVTValidationError(f"MVT backup path must be absolute: {expanded}") - resolved = expanded.resolve() - if not resolved.is_dir(): - raise MVTValidationError(f"MVT backup directory does not exist: {resolved}") - if _is_backup_folder(resolved): - return resolved - candidates = tuple( - candidate - for candidate in sorted(resolved.iterdir()) - if candidate.is_dir() and _is_backup_folder(candidate) - ) - if len(candidates) == 1: - return candidates[0] - if len(candidates) > 1: - raise MVTValidationError( - f"Multiple iTunes-style backups were found under {resolved}; choose one folder containing Manifest.db and Info.plist" - ) - raise MVTValidationError( - f"No iTunes-style backup was found at {resolved}; expected Manifest.db and Info.plist" - ) - - -def _backup_encryption_state(backup: Path) -> bool | None: - manifest_path = backup / "Manifest.plist" - if not manifest_path.is_file(): - return None - try: - with manifest_path.open("rb") as manifest_file: - manifest = plistlib.load(manifest_file) - except (OSError, plistlib.InvalidFileException) as error: - raise MVTValidationError(f"Could not read backup encryption metadata at {manifest_path}: {error}") from error - if not isinstance(manifest, Mapping): - raise MVTValidationError(f"Backup Manifest.plist root is not a dictionary: {manifest_path}") - encrypted = manifest.get("IsEncrypted") - if encrypted is None: - return None - if not isinstance(encrypted, bool): - raise MVTValidationError(f"Backup Manifest.plist has a non-boolean IsEncrypted value: {manifest_path}") - return encrypted - - -def inspect_mvt_backup(path: Path) -> MVTBackup: - resolved = _resolved_backup_folder(path) - encrypted = _backup_encryption_state(resolved) - if encrypted is True: - raise MVTValidationError( - "The selected backup is encrypted. Decrypt a protected working copy with MVT outside this toolkit, then select that copy. " - "The toolkit does not request, retain, transmit, or place backup passwords in command arguments." - ) - return MVTBackup(resolved, encrypted) - - -def validate_mvt_output(path: Path, backup: MVTBackup) -> Path: - expanded = path.expanduser() - if not expanded.is_absolute(): - raise MVTValidationError(f"MVT output path must be absolute: {expanded}") - resolved = expanded.resolve(strict=False) - if resolved == Path(resolved.anchor): - raise MVTValidationError(f"MVT output cannot be a filesystem root: {resolved}") - if resolved.exists(): - raise MVTValidationError( - f"MVT output already exists: {resolved}. Choose a new empty analysis path so results cannot mix with an earlier run." - ) - if resolved.is_relative_to(backup.path): - raise MVTValidationError( - f"MVT output cannot be inside the source backup: {resolved}" - ) - return resolved - - -def validate_mvt_ioc_files(paths: tuple[Path, ...]) -> tuple[Path, ...]: - supported_suffixes = {".json", ".stix", ".stix2"} - validated: list[Path] = [] - for path in paths: - expanded = path.expanduser() - if not expanded.is_absolute(): - raise MVTValidationError(f"MVT IOC path must be absolute: {expanded}") - resolved = expanded.resolve() - if not resolved.is_file(): - raise MVTValidationError(f"MVT IOC file does not exist: {resolved}") - if resolved.suffix.casefold() not in supported_suffixes: - raise MVTValidationError( - f"MVT IOC file must use .stix, .stix2, or .json: {resolved}" - ) - if resolved not in validated: - validated.append(resolved) - return tuple(validated) - - -def create_mvt_analysis_request( - installation: MVTInstallation, - backup_path: Path, - output_path: Path, - ioc_paths: tuple[Path, ...], - fast: bool, - hashes: bool, - allow_network: bool, -) -> MVTAnalysisRequest: - current_executable = inspect_mvt_executable(installation.executable.path) - if current_executable.sha256 != installation.executable.sha256: - raise MVTValidationError("The MVT executable changed after validation; validate the installation again") - backup = inspect_mvt_backup(backup_path) - output = validate_mvt_output(output_path, backup) - ioc_files = validate_mvt_ioc_files(ioc_paths) - return MVTAnalysisRequest(installation, backup, output, ioc_files, fast, hashes, allow_network) - - -def mvt_analysis_arguments(request: MVTAnalysisRequest) -> tuple[str, ...]: - arguments = [ - "--disable-update-check", - "--disable-indicator-update-check", - "check-backup", - "--output", - str(request.output), - ] - if request.fast: - arguments.append("--fast") - if request.hashes: - arguments.append("--hashes") - for ioc_file in request.ioc_files: - arguments.extend(("--iocs", str(ioc_file))) - arguments.append(str(request.backup.path)) - return tuple(arguments) - - -def mvt_environment( - base: Mapping[str, str], - config_directory: Path, - allow_network: bool, -) -> Mapping[str, str]: - resolved_config = config_directory.expanduser().resolve() - if not resolved_config.is_dir(): - raise MVTValidationError(f"MVT temporary configuration directory does not exist: {resolved_config}") - environment = { - key: value - for key, value in base.items() - if key not in MVT_ENVIRONMENT_KEYS_TO_REMOVE - } - environment["MVT_CONFIG_FOLDER"] = str(resolved_config) - environment["MVT_NETWORK_ACCESS_ALLOWED"] = "true" if allow_network else "false" - environment["MVT_NETWORK_TIMEOUT"] = "15" - environment["NO_COLOR"] = "1" - environment["PYTHONUNBUFFERED"] = "1" - return environment diff --git a/ios_developer_toolkit/operation_history.py b/ios_developer_toolkit/operation_history.py deleted file mode 100644 index 9963e64..0000000 --- a/ios_developer_toolkit/operation_history.py +++ /dev/null @@ -1,347 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import os -from dataclasses import dataclass, replace -from datetime import datetime -from pathlib import Path -from typing import TypeAlias - -from PySide6.QtCore import Qt -from PySide6.QtGui import QGuiApplication -from PySide6.QtWidgets import ( - QAbstractItemView, - QDialog, - QDialogButtonBox, - QFileDialog, - QHeaderView, - QLabel, - QMessageBox, - QPlainTextEdit, - QPushButton, - QTableWidget, - QTableWidgetItem, - QVBoxLayout, - QWidget, -) - -from ios_developer_toolkit.qt_process import OperationResult, ProcessOutcome - - -JsonScalar: TypeAlias = str | int | bool | None -JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"] - - -class OperationHistoryError(ValueError): - """Raised when an operation record or explicit manifest export is invalid.""" - - -@dataclass(frozen=True) -class OperationContext: - title: str - workspace: str - target: str - transport: str - prerequisites: tuple[str, ...] - output_paths: tuple[str, ...] - - -@dataclass(frozen=True) -class OperationRecord: - identifier: str - title: str - workspace: str - target: str - transport: str - argv: tuple[str, ...] - started_at: str - finished_at: str - duration_milliseconds: int - outcome: ProcessOutcome - exit_code: int | None - error_message: str | None - stdout_bytes: int - stdout_sha256: str - stderr_bytes: int - stderr_sha256: str - prerequisites: tuple[str, ...] - output_paths: tuple[str, ...] - - -def operation_context( - title: str, - workspace: str, - target: str, - transport: str, - prerequisites: tuple[str, ...], - output_paths: tuple[str, ...], -) -> OperationContext: - required_values = { - "title": title.strip(), - "workspace": workspace.strip(), - "target": target.strip(), - "transport": transport.strip(), - } - empty_fields = tuple(name for name, value in required_values.items() if not value) - if empty_fields: - raise OperationHistoryError(f"Operation context fields cannot be empty: {', '.join(empty_fields)}") - normalized_prerequisites = tuple(value.strip() for value in prerequisites) - if any(not value for value in normalized_prerequisites): - raise OperationHistoryError("Operation prerequisites cannot contain empty values") - normalized_paths = _normalized_output_paths(output_paths) - return OperationContext( - required_values["title"], - required_values["workspace"], - required_values["target"], - required_values["transport"], - normalized_prerequisites, - normalized_paths, - ) - - -def with_output_paths(context: OperationContext, output_paths: tuple[str, ...]) -> OperationContext: - normalized_paths = _normalized_output_paths(output_paths) - return replace(context, output_paths=normalized_paths) - - -def operation_record(context: OperationContext, result: OperationResult) -> OperationRecord: - if not result.argv or not result.argv[0]: - raise OperationHistoryError("Operation result must contain a non-empty argument vector") - started = _parse_timestamp(result.started_at, "started_at") - finished = _parse_timestamp(result.finished_at, "finished_at") - duration_milliseconds = round((finished - started).total_seconds() * 1000) - if duration_milliseconds < 0: - raise OperationHistoryError("Operation finish time cannot precede its start time") - identity_payload = "\0".join( - (context.workspace, context.title, result.started_at, result.finished_at, *result.argv) - ).encode("utf-8") - identifier = hashlib.sha256(identity_payload).hexdigest()[:16] - return OperationRecord( - identifier, - context.title, - context.workspace, - context.target, - context.transport, - result.argv, - result.started_at, - result.finished_at, - duration_milliseconds, - result.outcome, - result.exit_code, - result.error_message, - len(result.stdout), - hashlib.sha256(result.stdout).hexdigest(), - len(result.stderr), - hashlib.sha256(result.stderr).hexdigest(), - context.prerequisites, - context.output_paths, - ) - - -def append_operation_record( - records: tuple[OperationRecord, ...], - record: OperationRecord, - maximum_records: int, -) -> tuple[OperationRecord, ...]: - if maximum_records <= 0: - raise OperationHistoryError(f"Operation history limit must be positive: {maximum_records}") - if any(existing.identifier == record.identifier for existing in records): - raise OperationHistoryError(f"Operation record identifier is duplicated: {record.identifier}") - return (*records, record)[-maximum_records:] - - -def operation_manifest(record: OperationRecord) -> dict[str, JsonValue]: - return { - "schema_version": 1, - "operation_id": record.identifier, - "title": record.title, - "workspace": record.workspace, - "target": record.target, - "transport": record.transport, - "argv": list(record.argv), - "timing": { - "started_at": record.started_at, - "finished_at": record.finished_at, - "duration_milliseconds": record.duration_milliseconds, - }, - "result": { - "outcome": record.outcome, - "exit_code": record.exit_code, - "error_message": record.error_message, - }, - "captured_output": { - "stdout_bytes": record.stdout_bytes, - "stdout_sha256": record.stdout_sha256, - "stderr_bytes": record.stderr_bytes, - "stderr_sha256": record.stderr_sha256, - "raw_output_included": False, - }, - "prerequisites": list(record.prerequisites), - "output_paths": list(record.output_paths), - "privacy_notice": ( - "This user-exported manifest omits raw command output but may contain device identifiers, " - "local paths, and other sensitive values from the exact argument vector and target label." - ), - } - - -def render_operation_manifest(record: OperationRecord) -> str: - return json.dumps(operation_manifest(record), indent=2, sort_keys=True) + "\n" - - -def write_operation_manifest(path: Path, record: OperationRecord) -> Path: - destination = path.expanduser().resolve() - if destination.suffix.casefold() != ".json": - raise OperationHistoryError(f"Operation manifest destination must end in .json: {destination}") - if not destination.parent.is_dir(): - raise OperationHistoryError(f"Operation manifest parent directory does not exist: {destination.parent}") - try: - descriptor = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - except FileExistsError as error: - raise OperationHistoryError(f"Refusing to overwrite existing operation manifest: {destination}") from error - except OSError as error: - raise OperationHistoryError(f"Could not create operation manifest at {destination}: {error}") from error - try: - payload = render_operation_manifest(record).encode("utf-8") - with os.fdopen(descriptor, "wb") as stream: - stream.write(payload) - stream.flush() - os.fsync(stream.fileno()) - except OSError as error: - destination.unlink(missing_ok=True) - raise OperationHistoryError(f"Could not write operation manifest at {destination}: {error}") from error - return destination - - -class OperationHistoryDialog(QDialog): - """Present session-only operation records and explicit manifest export controls.""" - - def __init__(self, records: tuple[OperationRecord, ...], parent: QWidget | None) -> None: - super().__init__(parent) - self._records = records - self.setObjectName("sessionActivityDialog") - self.setWindowTitle("Session Activity") - self.resize(980, 680) - layout = QVBoxLayout(self) - explanation = QLabel( - "Completed typed operations from this app session appear here. Nothing is saved automatically. " - "An exported JSON manifest omits raw output but can contain device identifiers and local paths." - ) - explanation.setWordWrap(True) - layout.addWidget(explanation) - - self.table = QTableWidget(len(records), 5) - self.table.setObjectName("sessionActivityTable") - self.table.setHorizontalHeaderLabels(("Finished", "Workspace", "Operation", "Target", "Outcome")) - self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) - self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) - self.table.setAlternatingRowColors(True) - self.table.verticalHeader().setVisible(False) - for row, record in enumerate(reversed(records)): - values = (record.finished_at, record.workspace, record.title, record.target, record.outcome) - for column, value in enumerate(values): - item = QTableWidgetItem(value) - item.setData(Qt.ItemDataRole.UserRole, record.identifier) - self.table.setItem(row, column, item) - header = self.table.horizontalHeader() - header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) - header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) - header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) - self.table.itemSelectionChanged.connect(self._selection_changed) - layout.addWidget(self.table, 1) - - self.detail = QPlainTextEdit() - self.detail.setObjectName("sessionActivityManifestPreview") - self.detail.setReadOnly(True) - self.detail.setMaximumBlockCount(4000) - layout.addWidget(self.detail, 1) - - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) - self.copy_button = QPushButton("Copy Selected Manifest") - self.copy_button.setObjectName("copySessionActivityManifestButton") - self.copy_button.clicked.connect(self._copy_selected) - buttons.addButton(self.copy_button, QDialogButtonBox.ButtonRole.ActionRole) - self.save_button = QPushButton("Save Selected Manifest…") - self.save_button.setObjectName("saveSessionActivityManifestButton") - self.save_button.clicked.connect(self._save_selected) - buttons.addButton(self.save_button, QDialogButtonBox.ButtonRole.ActionRole) - buttons.rejected.connect(self.reject) - layout.addWidget(buttons) - if records: - self.table.selectRow(0) - else: - self.detail.setPlainText("No typed operations have completed in this session.") - self.copy_button.setEnabled(False) - self.save_button.setEnabled(False) - - def _selected_record(self) -> OperationRecord: - selected_items = self.table.selectedItems() - if not selected_items: - raise OperationHistoryError("Select an operation before copying or saving its manifest") - identifier = selected_items[0].data(Qt.ItemDataRole.UserRole) - if not isinstance(identifier, str): - raise OperationHistoryError("Selected operation has no valid record identifier") - record = next((candidate for candidate in self._records if candidate.identifier == identifier), None) - if record is None: - raise OperationHistoryError(f"Selected operation record is unavailable: {identifier}") - return record - - def _selection_changed(self) -> None: - try: - record = self._selected_record() - except OperationHistoryError: - self.detail.clear() - self.copy_button.setEnabled(False) - self.save_button.setEnabled(False) - return - self.detail.setPlainText(render_operation_manifest(record)) - self.copy_button.setEnabled(True) - self.save_button.setEnabled(True) - - def _copy_selected(self) -> None: - try: - record = self._selected_record() - except OperationHistoryError as error: - QMessageBox.warning(self, "No Operation Selected", str(error)) - return - QGuiApplication.clipboard().setText(render_operation_manifest(record)) - - def _save_selected(self) -> None: - try: - record = self._selected_record() - except OperationHistoryError as error: - QMessageBox.warning(self, "No Operation Selected", str(error)) - return - suggested = str(Path.home() / f"ios-toolkit-operation-{record.identifier}.json") - selected, _ = QFileDialog.getSaveFileName(self, "Save operation manifest", suggested, "JSON (*.json)") - if not selected: - return - try: - destination = write_operation_manifest(Path(selected), record) - except OperationHistoryError as error: - QMessageBox.critical(self, "Could Not Save Manifest", str(error)) - return - QMessageBox.information(self, "Manifest Saved", f"Saved operation manifest to:\n{destination}") - - -def _parse_timestamp(value: str, field_name: str) -> datetime: - try: - parsed = datetime.fromisoformat(value) - except ValueError as error: - raise OperationHistoryError(f"Operation {field_name} is not a valid ISO-8601 timestamp: {value}") from error - if parsed.tzinfo is None or parsed.utcoffset() is None: - raise OperationHistoryError(f"Operation {field_name} must include a timezone: {value}") - return parsed - - -def _normalized_output_paths(output_paths: tuple[str, ...]) -> tuple[str, ...]: - normalized: list[str] = [] - for value in output_paths: - if not value.strip(): - raise OperationHistoryError("Operation output paths cannot contain empty values") - normalized.append(str(Path(value).expanduser().resolve())) - return tuple(normalized) diff --git a/ios_developer_toolkit/qt_process.py b/ios_developer_toolkit/qt_process.py deleted file mode 100644 index 0243850..0000000 --- a/ios_developer_toolkit/qt_process.py +++ /dev/null @@ -1,208 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from datetime import datetime, timezone -from typing import Literal, Mapping, Sequence - -from PySide6.QtCore import QObject, QProcess, QProcessEnvironment, QTimer, Signal - -from ios_developer_toolkit.runtime import ExecutableCommand, command_arguments, command_argv - - -ProcessOutcome = Literal["succeeded", "failed", "crashed", "launch-failed", "timed-out", "cancelled"] - - -@dataclass(frozen=True) -class FiniteProcessRequest: - command: ExecutableCommand - arguments: tuple[str, ...] - environment: tuple[tuple[str, str], ...] - timeout_milliseconds: int - terminate_grace_milliseconds: int - - -@dataclass(frozen=True) -class OperationResult: - argv: tuple[str, ...] - outcome: ProcessOutcome - started_at: str - finished_at: str - exit_code: int | None - error_message: str | None - stdout: bytes - stderr: bytes - - -def finite_process_request( - command: ExecutableCommand, - arguments: Sequence[str], - environment: Mapping[str, str], - timeout_milliseconds: int, - terminate_grace_milliseconds: int, -) -> FiniteProcessRequest: - if timeout_milliseconds <= 0: - raise ValueError(f"Process timeout must be positive: {timeout_milliseconds}") - if terminate_grace_milliseconds <= 0: - raise ValueError(f"Process termination grace period must be positive: {terminate_grace_milliseconds}") - return FiniteProcessRequest( - command, - tuple(arguments), - tuple(sorted(environment.items())), - timeout_milliseconds, - terminate_grace_milliseconds, - ) - - -class FiniteProcessController(QObject): - """Own one bounded QProcess and emit one terminal typed result.""" - - stdout_received = Signal(bytes) - stderr_received = Signal(bytes) - completed = Signal(object) - - def __init__(self, parent: QObject) -> None: - super().__init__(parent) - self._process: QProcess | None = None - self._request: FiniteProcessRequest | None = None - self._stdout = bytearray() - self._stderr = bytearray() - self._started_at = "" - self._error_message: str | None = None - self._stop_outcome: Literal["timed-out", "cancelled"] | None = None - self._completed = False - self._timeout_timer = QTimer(self) - self._timeout_timer.setSingleShot(True) - self._timeout_timer.timeout.connect(self._timeout) - self._kill_timer = QTimer(self) - self._kill_timer.setSingleShot(True) - self._kill_timer.timeout.connect(self._kill) - - def is_running(self) -> bool: - process = self._process - return process is not None and process.state() != QProcess.ProcessState.NotRunning - - def start(self, request: FiniteProcessRequest) -> None: - if self.is_running(): - raise RuntimeError("Cannot start a finite process while another process is running") - self._request = request - self._stdout.clear() - self._stderr.clear() - self._started_at = datetime.now(timezone.utc).isoformat() - self._error_message = None - self._stop_outcome = None - self._completed = False - - process = QProcess(self) - process.setProgram(str(request.command.program)) - process.setArguments(list(command_arguments(request.command, request.arguments))) - process_environment = QProcessEnvironment.systemEnvironment() - for key, value in request.environment: - process_environment.insert(key, value) - process.setProcessEnvironment(process_environment) - process.readyReadStandardOutput.connect(self._drain_output) - process.readyReadStandardError.connect(self._drain_output) - process.errorOccurred.connect(self._process_error) - process.finished.connect(self._finished) - self._process = process - self._timeout_timer.start(request.timeout_milliseconds) - process.start() - - def cancel(self) -> None: - if not self.is_running(): - return - self._stop_process("cancelled") - - def shutdown(self, terminate_timeout_milliseconds: int, kill_timeout_milliseconds: int) -> None: - if terminate_timeout_milliseconds <= 0: - raise ValueError(f"Shutdown termination timeout must be positive: {terminate_timeout_milliseconds}") - if kill_timeout_milliseconds <= 0: - raise ValueError(f"Shutdown kill timeout must be positive: {kill_timeout_milliseconds}") - process = self._process - if process is None or process.state() == QProcess.ProcessState.NotRunning: - return - self._stop_outcome = "cancelled" - self._timeout_timer.stop() - self._kill_timer.stop() - process.terminate() - if not process.waitForFinished(terminate_timeout_milliseconds): - process.kill() - if not process.waitForFinished(kill_timeout_milliseconds): - raise RuntimeError(f"Process did not stop after terminate and kill: {process.program()}") - - def _drain_output(self) -> None: - process = self._process - if process is None: - return - stdout = bytes(process.readAllStandardOutput()) - stderr = bytes(process.readAllStandardError()) - if stdout: - self._stdout.extend(stdout) - self.stdout_received.emit(stdout) - if stderr: - self._stderr.extend(stderr) - self.stderr_received.emit(stderr) - - def _process_error(self, process_error: QProcess.ProcessError) -> None: - process = self._process - if process is None: - raise RuntimeError("Finite process reported an error without an active process") - self._error_message = process.errorString() - if process_error == QProcess.ProcessError.FailedToStart: - self._finish_once("launch-failed", None) - - def _finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: - self._drain_output() - if self._stop_outcome is not None: - outcome: ProcessOutcome = self._stop_outcome - elif exit_status == QProcess.ExitStatus.CrashExit: - outcome = "crashed" - elif exit_code == 0: - outcome = "succeeded" - else: - outcome = "failed" - self._finish_once(outcome, exit_code) - - def _timeout(self) -> None: - if self.is_running(): - self._stop_process("timed-out") - - def _stop_process(self, outcome: Literal["timed-out", "cancelled"]) -> None: - process = self._process - request = self._request - if process is None or request is None: - raise RuntimeError("Cannot stop a finite process without an active request") - self._stop_outcome = outcome - self._timeout_timer.stop() - process.terminate() - self._kill_timer.start(request.terminate_grace_milliseconds) - - def _kill(self) -> None: - process = self._process - if process is not None and process.state() != QProcess.ProcessState.NotRunning: - process.kill() - - def _finish_once(self, outcome: ProcessOutcome, exit_code: int | None) -> None: - if self._completed: - return - request = self._request - if request is None: - raise RuntimeError("Finite process completed without a request") - self._drain_output() - self._completed = True - self._timeout_timer.stop() - self._kill_timer.stop() - result = OperationResult( - command_argv(request.command, request.arguments), - outcome, - self._started_at, - datetime.now(timezone.utc).isoformat(), - exit_code, - self._error_message, - bytes(self._stdout), - bytes(self._stderr), - ) - process = self._process - self._process = None - if process is not None: - process.deleteLater() - self.completed.emit(result) diff --git a/ios_developer_toolkit/runtime.py b/ios_developer_toolkit/runtime.py deleted file mode 100644 index cf4f9ca..0000000 --- a/ios_developer_toolkit/runtime.py +++ /dev/null @@ -1,148 +0,0 @@ -from __future__ import annotations - -import os -import plistlib -import shlex -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Literal, Mapping, Sequence - - -INTERNAL_PYMOBILEDEVICE3_FLAG = "--toolkit-internal-pymobiledevice3" -INTERNAL_WORKER_FLAG = "--toolkit-internal-worker" -INTERNAL_SMOKE_TEST_FLAG = "--toolkit-internal-smoke-test" - -ToolkitWorker = Literal["backup", "capability", "collector", "ipa-inspector", "local-ddi"] - - -@dataclass(frozen=True) -class ExecutableCommand: - program: Path - prefix_arguments: tuple[str, ...] - - -class FrozenExecutableError(RuntimeError): - """Raised when a packaged runtime cannot locate its bundle launcher.""" - - -def is_frozen_runtime() -> bool: - if "__compiled__" in globals(): - return True - frozen_marker = getattr(sys, "frozen", False) - return frozen_marker is True - - -def _bundle_contents_directories(anchors: Sequence[Path]) -> tuple[Path, ...]: - directories: list[Path] = [] - for anchor in anchors: - resolved_anchor = anchor.expanduser().resolve(strict=False) - for candidate in (resolved_anchor, *resolved_anchor.parents): - if candidate.name == "Contents" and candidate not in directories: - directories.append(candidate) - return tuple(directories) - - -def macos_bundle_executable(argument_zero: Path, module_path: Path) -> Path: - contents_directories = _bundle_contents_directories((argument_zero, module_path)) - plist_paths = tuple(directory / "Info.plist" for directory in contents_directories) - existing_plists = tuple(path for path in plist_paths if path.is_file()) - if not existing_plists: - checked = ", ".join(str(path) for path in plist_paths) or "no enclosing .app Contents directory" - raise FrozenExecutableError(f"Packaged macOS runtime could not find Info.plist; checked: {checked}") - - plist_path = existing_plists[0] - try: - with plist_path.open("rb") as plist_file: - plist = plistlib.load(plist_file) - except (OSError, plistlib.InvalidFileException) as error: - raise FrozenExecutableError(f"Packaged macOS runtime could not read {plist_path}: {error}") from error - if not isinstance(plist, Mapping): - raise FrozenExecutableError( - f"Packaged macOS runtime expected a dictionary at the root of {plist_path}, " - f"received {type(plist).__name__}" - ) - - executable_name = plist.get("CFBundleExecutable") - if not isinstance(executable_name, str) or not executable_name or Path(executable_name).name != executable_name: - raise FrozenExecutableError( - f"Packaged macOS runtime has an invalid CFBundleExecutable in {plist_path}: {executable_name!r}" - ) - executable_path = plist_path.parent / "MacOS" / executable_name - if not executable_path.is_file(): - raise FrozenExecutableError( - f"Packaged macOS runtime launcher named by {plist_path} does not exist: {executable_path}" - ) - if not os.access(executable_path, os.X_OK): - raise FrozenExecutableError( - f"Packaged macOS runtime launcher is not executable: {executable_path}" - ) - return executable_path - - -def frozen_executable_path( - argument_zero: Path, - module_path: Path, - interpreter_path: Path, - platform_name: str, -) -> Path: - if platform_name == "darwin": - return macos_bundle_executable(argument_zero, module_path) - if not interpreter_path.is_file() or not os.access(interpreter_path, os.X_OK): - raise FrozenExecutableError(f"Packaged runtime executable is missing or not executable: {interpreter_path}") - return interpreter_path - - -def active_frozen_executable() -> Path: - return frozen_executable_path(Path(sys.argv[0]), Path(__file__), Path(sys.executable), sys.platform) - - -def command_arguments(command: ExecutableCommand, arguments: Sequence[str]) -> tuple[str, ...]: - return (*command.prefix_arguments, *arguments) - - -def command_argv(command: ExecutableCommand, arguments: Sequence[str]) -> tuple[str, ...]: - return (str(command.program), *command_arguments(command, arguments)) - - -def command_text(command: ExecutableCommand, arguments: Sequence[str]) -> str: - return shlex.join(command_argv(command, arguments)) - - -def pymobiledevice3_command() -> ExecutableCommand: - if is_frozen_runtime(): - return ExecutableCommand(active_frozen_executable(), (INTERNAL_PYMOBILEDEVICE3_FLAG,)) - candidate = Path(sys.executable).with_name("pymobiledevice3") - if not candidate.is_file(): - raise FileNotFoundError( - f"pymobiledevice3 executable was not found next to the active Python interpreter: {candidate}" - ) - return ExecutableCommand(candidate, ()) - - -def worker_module(worker: ToolkitWorker) -> str: - if worker == "backup": - return "ios_developer_toolkit.backup_worker" - if worker == "capability": - return "ios_developer_toolkit.capability_matrix_worker" - if worker == "collector": - return "ios_developer_toolkit.collector" - if worker == "ipa-inspector": - return "ios_developer_toolkit.ipa_inspector" - if worker == "local-ddi": - return "ios_developer_toolkit.local_ddi" - raise ValueError(f"Unsupported toolkit worker: {worker}") - - -def worker_command(worker: ToolkitWorker) -> ExecutableCommand: - if is_frozen_runtime(): - return ExecutableCommand(active_frozen_executable(), (INTERNAL_WORKER_FLAG, worker)) - return ExecutableCommand(Path(sys.executable), ("-m", worker_module(worker))) - - -def device_environment(udid: str) -> Mapping[str, str]: - environment = dict(os.environ) - environment["PYMOBILEDEVICE3_UDID"] = udid - environment["PYTHONUNBUFFERED"] = "1" - environment["NO_COLOR"] = "1" - return environment diff --git a/ios_developer_toolkit/support_bundle.py b/ios_developer_toolkit/support_bundle.py deleted file mode 100644 index 087eb54..0000000 --- a/ios_developer_toolkit/support_bundle.py +++ /dev/null @@ -1,181 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import platform -import re -import zipfile -from dataclasses import dataclass -from datetime import datetime, timezone -from importlib.metadata import PackageNotFoundError, version -from pathlib import Path - - -JsonDocumentValue = str | int | bool | list[str] | dict[str, str] | dict[str, int] - - -class SupportBundleError(ValueError): - pass - - -@dataclass(frozen=True) -class SupportStatus: - identifier: str - value: str - - -@dataclass(frozen=True) -class SupportBundleContext: - app_version: str - workspace: str - device_count: int - selected_device_present: bool - capability_state_counts: tuple[tuple[str, int], ...] - command_drift_report: str - statuses: tuple[SupportStatus, ...] - redactions: tuple[str, ...] - frozen_runtime: bool - - -@dataclass(frozen=True) -class SupportBundleResult: - path: Path - entries: tuple[str, ...] - - -def create_sanitized_support_bundle(destination: Path, context: SupportBundleContext) -> SupportBundleResult: - _validate_destination(destination) - _validate_context(context) - entries = _support_entries(context) - try: - with zipfile.ZipFile(destination, mode="x", compression=zipfile.ZIP_DEFLATED) as archive: - for name, content in entries: - archive.writestr(name, content) - except OSError as error: - raise SupportBundleError(f"Could not create support bundle at {destination}: {error}") from error - except zipfile.BadZipFile as error: - raise SupportBundleError(f"Could not write a valid ZIP support bundle at {destination}: {error}") from error - return SupportBundleResult(destination, tuple(name for name, _ in entries)) - - -def sanitize_support_text(value: str, redactions: tuple[str, ...]) -> str: - sanitized = value - sanitized = re.sub(r"\b[0-9A-Fa-f]{8}-[0-9A-Fa-f]{16,}\b", "", sanitized) - sanitized = re.sub(r"\b[0-9A-Fa-f]{8}(?:-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}\b", "", sanitized) - sanitized = re.sub(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", "", sanitized) - sanitized = re.sub(r"\b[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5}\b", "", sanitized) - sanitized = re.sub(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", "", sanitized) - sanitized = re.sub(r"/(?:Users|private|var|Volumes|Library|Applications|System|opt|tmp)(?:/[^\s\\\"']+)+", "", sanitized) - for redaction in tuple(sorted((item for item in redactions if item), key=len, reverse=True)): - sanitized = sanitized.replace(redaction, "") - return sanitized[:8_000] - - -def _support_entries(context: SupportBundleContext) -> tuple[tuple[str, str], ...]: - created_at = datetime.now(timezone.utc).isoformat() - status_values = { - status.identifier: sanitize_support_text(status.value, context.redactions) - for status in context.statuses - } - capability_counts = {state: count for state, count in context.capability_state_counts} - environment: dict[str, JsonDocumentValue] = { - "app_version": context.app_version, - "created_at": created_at, - "operating_system": platform.system(), - "operating_system_release": platform.release(), - "macos_version": platform.mac_ver()[0], - "architecture": platform.machine(), - "python_implementation": platform.python_implementation(), - "python_version": platform.python_version(), - "runtime": "frozen-app" if context.frozen_runtime else "source-python", - "pymobiledevice3_version": installed_package_version("pymobiledevice3"), - "pyside6_version": installed_package_version("PySide6"), - } - context_document: dict[str, JsonDocumentValue] = { - "workspace": context.workspace, - "detected_device_count": context.device_count, - "selected_device_present": context.selected_device_present, - "capability_state_counts": capability_counts, - "active_statuses": status_values, - "included": [ - "toolkit and dependency versions", - "macOS and Python version metadata", - "workspace name and device count without device identity", - "capability state counts without evidence payloads", - "sanitized UI status summaries", - "sanitized command-drift summary", - ], - "excluded": [ - "device names, UDIDs, serial numbers, and pairing records", - "backups, cases, captures, screenshots, raw logs, PCAPs, crash reports, and IPA files", - "command output, live-log payloads, passwords, and user-entered values", - "host name, user name, home directory, full filesystem paths, network addresses, and email addresses", - ], - } - command_drift = sanitize_support_text(context.command_drift_report, context.redactions) - pre_manifest_entries = ( - ("README.txt", _support_readme()), - ("environment.json", _json_document(environment)), - ("context.json", _json_document(context_document)), - ("command-drift.txt", command_drift or "No command-drift check has been completed in this app session.\n"), - ) - manifest: dict[str, JsonDocumentValue] = { - "created_at": created_at, - "entries": { - name: hashlib.sha256(content.encode("utf-8")).hexdigest() - for name, content in pre_manifest_entries - }, - } - return (*pre_manifest_entries, ("SHA256SUMS.json", _json_document(manifest))) - - -def _support_readme() -> str: - return ( - "iOS Developer Toolkit sanitized support bundle\n" - "\n" - "This archive is generated locally and is never uploaded by the application. It contains application and " - "environment metadata, aggregate readiness states, sanitized UI status summaries, and a sanitized command-drift report.\n" - "\n" - "It intentionally excludes device identity, pairing material, backups, evidence cases, screenshots, IPA files, " - "raw logs, packets, crash reports, command output, passwords, and user-entered values. Review this ZIP before sharing.\n" - ) - - -def _json_document(value: dict[str, JsonDocumentValue]) -> str: - return json.dumps(value, indent=2, sort_keys=True) + "\n" - - -def installed_package_version(package: str) -> str: - if not package.strip(): - raise SupportBundleError("Package name is required when reading installed version metadata") - try: - return version(package) - except PackageNotFoundError: - return "not-installed" - - -def _validate_destination(destination: Path) -> None: - if not destination.is_absolute(): - raise SupportBundleError(f"Support bundle destination must be absolute: {destination}") - if destination.suffix.casefold() != ".zip": - raise SupportBundleError(f"Support bundle destination must have a .zip extension: {destination}") - if not destination.parent.is_dir(): - raise SupportBundleError(f"Support bundle destination parent does not exist: {destination.parent}") - if destination.exists(): - raise SupportBundleError(f"Support bundle destination already exists and will not be overwritten: {destination}") - - -def _validate_context(context: SupportBundleContext) -> None: - if not context.app_version.strip(): - raise SupportBundleError("Support bundle app version is required") - if not context.workspace.strip(): - raise SupportBundleError("Support bundle workspace is required") - if context.device_count < 0: - raise SupportBundleError(f"Support bundle device count cannot be negative: {context.device_count}") - identifiers = tuple(status.identifier for status in context.statuses) - if len(set(identifiers)) != len(identifiers): - raise SupportBundleError("Support bundle status identifiers must be unique") - if any(not identifier.strip() for identifier in identifiers): - raise SupportBundleError("Support bundle status identifiers must be non-empty") - if any(count < 0 for _, count in context.capability_state_counts): - raise SupportBundleError("Support bundle capability counts cannot be negative") diff --git a/ios_developer_toolkit/ufade_connector.py b/ios_developer_toolkit/ufade_connector.py deleted file mode 100644 index 40a1fd4..0000000 --- a/ios_developer_toolkit/ufade_connector.py +++ /dev/null @@ -1,161 +0,0 @@ -from __future__ import annotations - -import re -import subprocess -from dataclasses import dataclass -from pathlib import Path - - -UFADE_REPOSITORY_URL = "https://github.com/prosch88/UFADE" -UFADE_INSTALLATION_URL = f"{UFADE_REPOSITORY_URL}#installation" -UFADE_USAGE_URL = f"{UFADE_REPOSITORY_URL}#usage" -UFADE_REQUIRED_PYTHON = (3, 11) -UFADE_RUNTIME_IMPORTS = ( - "tkinter", - "customtkinter", - "PIL", - "pandas", - "pyarrow", - "pymobiledevice3", - "iOSbackup", - "pyiosbackup", - "paramiko", - "simpleaudio", - "tkcalendar", - "crossfiledialog", - "exifread", - "pdfme", - "imagehash", - "numpy", - "cryptography", -) - - -class UFADEValidationError(ValueError): - pass - - -@dataclass(frozen=True) -class UFADEInstallation: - checkout: Path - script: Path - python: Path - ufade_version: str - python_version: str - developer_images_available: bool - - -def macos_setup_commands() -> tuple[str, ...]: - return ( - "brew install python@3.11 python-tk@3.11", - "git clone --recurse-submodules https://github.com/prosch88/UFADE.git", - "cd UFADE", - "python3.11 -m venv .venv", - ".venv/bin/python -m pip install --upgrade pip", - ".venv/bin/python -m pip install -r requirements.txt", - ) - - -def checkout_python_path(checkout: Path) -> Path: - return checkout.expanduser().resolve() / ".venv" / "bin" / "python" - - -def developer_images_are_available(checkout: Path) -> bool: - return (checkout.expanduser().resolve() / "ufade_developer" / "Developer").is_dir() - - -def required_absolute_path(value: Path, label: str) -> Path: - expanded = value.expanduser() - if not expanded.is_absolute(): - raise UFADEValidationError(f"{label} must be an absolute path: {expanded}") - return expanded.resolve() - - -def read_ufade_version(script: Path) -> str: - source = script.read_text(encoding="utf-8") - match = re.search(r'^u_version\s*=\s*["\']([^"\']+)["\']\s*$', source, flags=re.MULTILINE) - if match is None: - raise UFADEValidationError(f"Could not find UFADE's u_version declaration in {script}") - return match.group(1) - - -def validate_ufade_checkout(checkout: Path) -> tuple[Path, Path, str]: - resolved_checkout = required_absolute_path(checkout, "UFADE checkout") - if not resolved_checkout.is_dir(): - raise UFADEValidationError(f"UFADE checkout directory does not exist: {resolved_checkout}") - script = resolved_checkout / "ufade.py" - license_path = resolved_checkout / "LICENSE" - requirements_path = resolved_checkout / "requirements.txt" - for required_file in (script, license_path, requirements_path): - if not required_file.is_file(): - raise UFADEValidationError(f"UFADE checkout is missing required file: {required_file}") - license_text = license_path.read_text(encoding="utf-8", errors="replace") - if "GNU GENERAL PUBLIC LICENSE" not in license_text or "Version 3" not in license_text: - raise UFADEValidationError(f"UFADE checkout does not contain the expected GPL-3.0 license: {license_path}") - version = read_ufade_version(script) - return resolved_checkout, script, version - - -def parse_python_version(value: str) -> tuple[int, int, int]: - fields = value.strip().split(".") - if len(fields) != 3 or any(not field.isdigit() for field in fields): - raise UFADEValidationError(f"Python returned an unexpected version string: {value!r}") - return int(fields[0]), int(fields[1]), int(fields[2]) - - -def validate_ufade_python(python: Path) -> tuple[Path, str]: - resolved_python = required_absolute_path(python, "UFADE Python executable") - if not resolved_python.is_file(): - raise UFADEValidationError(f"UFADE Python executable does not exist: {resolved_python}") - completed = subprocess.run( - [str(resolved_python), "-c", "import sys; print('.'.join(str(value) for value in sys.version_info[:3]))"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - timeout=10, - ) - if completed.returncode != 0: - detail = completed.stderr.strip() or completed.stdout.strip() - raise UFADEValidationError(f"Could not query UFADE Python at {resolved_python}: {detail}") - version = completed.stdout.strip() - major, minor, _ = parse_python_version(version) - if (major, minor) != UFADE_REQUIRED_PYTHON: - raise UFADEValidationError( - f"UFADE requires Python 3.11, but {resolved_python} reports Python {version}" - ) - return resolved_python, version - - -def validate_ufade_dependencies(checkout: Path, python: Path) -> None: - import_statement = ", ".join(UFADE_RUNTIME_IMPORTS) - completed = subprocess.run( - [str(python), "-c", f"import {import_statement}"], - cwd=checkout, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - timeout=30, - ) - if completed.returncode != 0: - detail = completed.stderr.strip() or completed.stdout.strip() - raise UFADEValidationError( - "UFADE's Python 3.11 environment is incomplete. Run " - f"{python} -m pip install -r {checkout / 'requirements.txt'} in that separate environment. " - f"Runtime import check failed: {detail}" - ) - - -def inspect_ufade_installation(checkout: Path, python: Path) -> UFADEInstallation: - resolved_checkout, script, ufade_version = validate_ufade_checkout(checkout) - resolved_python, python_version = validate_ufade_python(python) - validate_ufade_dependencies(resolved_checkout, resolved_python) - return UFADEInstallation( - checkout=resolved_checkout, - script=script, - python=resolved_python, - ufade_version=ufade_version, - python_version=python_version, - developer_images_available=developer_images_are_available(resolved_checkout), - ) diff --git a/ios_developer_toolkit/validation.py b/ios_developer_toolkit/validation.py deleted file mode 100644 index 9bbab3a..0000000 --- a/ios_developer_toolkit/validation.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - - -def output_indicates_failure(payload: bytes | str) -> bool: - text = payload.decode("utf-8", errors="replace") if isinstance(payload, bytes) else payload - normalized = text.lower() - failure_markers = ( - " error device not found:", - "\nerror:", - "no device connected", - "device is not connected", - "failed to access an invalid lockdown service", - "developer mode is not enabled", - "developermodeisnotenablederror", - "traceback (most recent call last)", - ) - return any(marker in normalized for marker in failure_markers) diff --git a/ios_developer_toolkit/workspace_profile.py b/ios_developer_toolkit/workspace_profile.py deleted file mode 100644 index 3ea79e1..0000000 --- a/ios_developer_toolkit/workspace_profile.py +++ /dev/null @@ -1,363 +0,0 @@ -from __future__ import annotations - -import json -import os -from dataclasses import asdict, dataclass, replace -from pathlib import Path -from typing import Mapping - -from ios_developer_toolkit.command_catalog import command_presets, preset_categories -from ios_developer_toolkit.support_bundle import sanitize_support_text - - -MAX_WORKSPACE_PROFILE_BYTES = 1_048_576 -WORKSPACE_NAMES = ( - "Home", - "Device & DDI", - "Capability Matrix", - "Location Lab", - "Live Logs", - "Command Center", - "Installed Apps", - "Backup", - "Sideload IPA", - "Evidence Capture", - "Ecosystem Tools", - "Man Pages", - "Scope & Safety", -) -DDI_SOURCES = ("personalized", "local-xcode") - - -class WorkspaceProfileError(ValueError): - """Raised when a local, shareable workspace profile is invalid.""" - - -@dataclass(frozen=True) -class AppWorkflowPreferences: - calculate_app_sizes: bool - install_as_developer_package: bool - - -@dataclass(frozen=True) -class BackupWorkflowPreferences: - force_full_backup: bool - require_encryption: bool - - -@dataclass(frozen=True) -class EvidenceWorkflowPreferences: - capture_duration_seconds: int - include_syslog: bool - include_oslog: bool - include_pcap: bool - include_screenshot: bool - include_crash_pull: bool - - -@dataclass(frozen=True) -class LocationWorkflowPreferences: - timing_randomness_ms: int - ignore_timing_delays: bool - route_speed_preset_kmh: int - route_speed_kmh: int - route_interval_seconds: int - route_traversals: int - - -@dataclass(frozen=True) -class WorkspaceProfile: - created_with_version: str - name: str - description: str - default_workspace: str - ddi_source: str - command_category: str - command_preset: str - app_workflow: AppWorkflowPreferences - backup_workflow: BackupWorkflowPreferences - evidence_workflow: EvidenceWorkflowPreferences - location_workflow: LocationWorkflowPreferences - - -def _validated_text(value: str, label: str, maximum_length: int, allow_empty: bool) -> str: - if not isinstance(value, str): - raise WorkspaceProfileError(f"Workspace profile {label} must be a string") - normalized = value.strip() - if not normalized and not allow_empty: - raise WorkspaceProfileError(f"Workspace profile {label} is required") - if len(normalized) > maximum_length: - raise WorkspaceProfileError( - f"Workspace profile {label} exceeds {maximum_length} characters: {len(normalized)}" - ) - if any(not character.isprintable() for character in normalized): - raise WorkspaceProfileError(f"Workspace profile {label} contains control characters") - if sanitize_support_text(normalized, ()) != normalized: - raise WorkspaceProfileError( - f"Workspace profile {label} appears to contain a local path, account, device, or network identifier" - ) - return normalized - - -def validate_workspace_profile(profile: WorkspaceProfile) -> WorkspaceProfile: - created_with_version = _validated_text(profile.created_with_version, "toolkit version", 40, False) - name = _validated_text(profile.name, "name", 100, False) - description = _validated_text(profile.description, "description", 500, True) - if profile.default_workspace not in WORKSPACE_NAMES: - raise WorkspaceProfileError(f"Unknown default workspace: {profile.default_workspace!r}") - if profile.ddi_source not in DDI_SOURCES: - raise WorkspaceProfileError(f"Unknown DDI source preference: {profile.ddi_source!r}") - categories = ("All categories", *preset_categories()) - if profile.command_category not in categories: - raise WorkspaceProfileError(f"Unknown command category: {profile.command_category!r}") - presets = {preset.identifier: preset for preset in command_presets()} - preset = presets.get(profile.command_preset) - if preset is None: - raise WorkspaceProfileError(f"Unknown guided command preset: {profile.command_preset!r}") - if profile.command_category != "All categories" and preset.category != profile.command_category: - raise WorkspaceProfileError( - f"Guided preset {profile.command_preset!r} is not in category {profile.command_category!r}" - ) - boolean_values = { - "calculate app sizes": profile.app_workflow.calculate_app_sizes, - "install as developer package": profile.app_workflow.install_as_developer_package, - "force full backup": profile.backup_workflow.force_full_backup, - "require encryption": profile.backup_workflow.require_encryption, - "include syslog": profile.evidence_workflow.include_syslog, - "include oslog": profile.evidence_workflow.include_oslog, - "include pcap": profile.evidence_workflow.include_pcap, - "include screenshot": profile.evidence_workflow.include_screenshot, - "include crash pull": profile.evidence_workflow.include_crash_pull, - "ignore timing delays": profile.location_workflow.ignore_timing_delays, - } - invalid_boolean_fields = tuple( - label for label, value in boolean_values.items() if not isinstance(value, bool) - ) - if invalid_boolean_fields: - raise WorkspaceProfileError( - f"Workspace profile boolean fields are invalid: {invalid_boolean_fields}" - ) - _bounded_integer(profile.evidence_workflow.capture_duration_seconds, "capture duration", 10, 3600) - _bounded_integer(profile.location_workflow.timing_randomness_ms, "timing randomness", 0, 60000) - allowed_speed_presets = (5, 10, 20, 40, 100) - if profile.location_workflow.route_speed_preset_kmh not in allowed_speed_presets: - raise WorkspaceProfileError( - f"Route speed preset must be one of {allowed_speed_presets}: " - f"{profile.location_workflow.route_speed_preset_kmh}" - ) - _bounded_integer(profile.location_workflow.route_speed_kmh, "route speed", 1, 300) - _bounded_integer(profile.location_workflow.route_interval_seconds, "route interval", 1, 60) - _bounded_integer(profile.location_workflow.route_traversals, "route traversals", 1, 20) - return replace( - profile, - created_with_version=created_with_version, - name=name, - description=description, - ) - - -def _bounded_integer(value: int, label: str, minimum: int, maximum: int) -> int: - if not isinstance(value, int) or isinstance(value, bool) or value < minimum or value > maximum: - raise WorkspaceProfileError( - f"Workspace profile {label} must be between {minimum} and {maximum}: {value!r}" - ) - return value - - -def workspace_profile_mapping(profile: WorkspaceProfile) -> dict[str, object]: - validated = validate_workspace_profile(profile) - return { - "schema_version": 1, - "created_with_version": validated.created_with_version, - "name": validated.name, - "description": validated.description, - "default_workspace": validated.default_workspace, - "settings": { - "ddi_source": validated.ddi_source, - "command": { - "category": validated.command_category, - "preset": validated.command_preset, - }, - "app_workflow": asdict(validated.app_workflow), - "backup_workflow": asdict(validated.backup_workflow), - "evidence_workflow": asdict(validated.evidence_workflow), - "location_workflow": asdict(validated.location_workflow), - }, - "privacy": { - "schema_excludes": [ - "device identity and targets", - "credentials and authorization acknowledgements", - "local paths and coordinates", - "command parameters", - "case text and capture output", - ], - "user_supplied_text_fields": ["name", "description"], - "warning": "Review the user-supplied name and description before sharing.", - }, - } - - -def render_workspace_profile_json(profile: WorkspaceProfile) -> str: - return json.dumps(workspace_profile_mapping(profile), indent=2, sort_keys=True) + "\n" - - -def render_workspace_profile_preview(profile: WorkspaceProfile) -> str: - validated = validate_workspace_profile(profile) - evidence = validated.evidence_workflow - location = validated.location_workflow - return ( - f"Profile: {validated.name}\n" - f"Description: {validated.description or '(none)'}\n" - f"Created with toolkit: {validated.created_with_version}\n" - f"Default workspace: {validated.default_workspace}\n" - f"DDI source: {validated.ddi_source}\n" - f"Guided command category: {validated.command_category}\n" - f"Guided command preset: {validated.command_preset}\n" - "\n" - "App workflow\n" - f" Calculate app sizes: {validated.app_workflow.calculate_app_sizes}\n" - f" Install as developer package: {validated.app_workflow.install_as_developer_package}\n" - "\n" - "Backup workflow\n" - f" Force full backup: {validated.backup_workflow.force_full_backup}\n" - f" Require encryption: {validated.backup_workflow.require_encryption}\n" - "\n" - "Evidence workflow\n" - f" Capture duration: {evidence.capture_duration_seconds} seconds\n" - f" Classic syslog: {evidence.include_syslog}\n" - f" DVT OSLog: {evidence.include_oslog}\n" - f" PCAP: {evidence.include_pcap}\n" - f" Screenshot: {evidence.include_screenshot}\n" - f" Crash pull: {evidence.include_crash_pull}\n" - "\n" - "Location workflow\n" - f" Timing randomness: {location.timing_randomness_ms} ms\n" - f" Ignore timing delays: {location.ignore_timing_delays}\n" - f" Route speed preset: {location.route_speed_preset_kmh} km/h\n" - f" Route speed: {location.route_speed_kmh} km/h\n" - f" Point interval: {location.route_interval_seconds} seconds\n" - f" Traversals: {location.route_traversals}\n" - "\n" - "Excluded by schema: device identity, credentials, paths, coordinates, command parameters, case text, and output.\n" - "Importing changes visible controls only. It never runs a command or starts a device operation.\n" - ) - - -def _required_mapping(record: Mapping[str, object], key: str) -> Mapping[str, object]: - value = record.get(key) - if not isinstance(value, dict): - raise WorkspaceProfileError(f"Workspace profile field {key!r} must be a JSON object") - return value - - -def _required_string(record: Mapping[str, object], key: str) -> str: - value = record.get(key) - if not isinstance(value, str): - raise WorkspaceProfileError(f"Workspace profile field {key!r} must be a string") - return value - - -def _required_boolean(record: Mapping[str, object], key: str) -> bool: - value = record.get(key) - if not isinstance(value, bool): - raise WorkspaceProfileError(f"Workspace profile field {key!r} must be a boolean") - return value - - -def _required_integer(record: Mapping[str, object], key: str) -> int: - value = record.get(key) - if not isinstance(value, int) or isinstance(value, bool): - raise WorkspaceProfileError(f"Workspace profile field {key!r} must be an integer") - return value - - -def parse_workspace_profile(record: Mapping[str, object]) -> WorkspaceProfile: - schema_version = record.get("schema_version") - if not isinstance(schema_version, int) or isinstance(schema_version, bool) or schema_version != 1: - raise WorkspaceProfileError("Workspace profile has an unsupported schema version") - settings = _required_mapping(record, "settings") - command = _required_mapping(settings, "command") - app_workflow = _required_mapping(settings, "app_workflow") - backup_workflow = _required_mapping(settings, "backup_workflow") - evidence_workflow = _required_mapping(settings, "evidence_workflow") - location_workflow = _required_mapping(settings, "location_workflow") - profile = WorkspaceProfile( - created_with_version=_required_string(record, "created_with_version"), - name=_required_string(record, "name"), - description=_required_string(record, "description"), - default_workspace=_required_string(record, "default_workspace"), - ddi_source=_required_string(settings, "ddi_source"), - command_category=_required_string(command, "category"), - command_preset=_required_string(command, "preset"), - app_workflow=AppWorkflowPreferences( - calculate_app_sizes=_required_boolean(app_workflow, "calculate_app_sizes"), - install_as_developer_package=_required_boolean(app_workflow, "install_as_developer_package"), - ), - backup_workflow=BackupWorkflowPreferences( - force_full_backup=_required_boolean(backup_workflow, "force_full_backup"), - require_encryption=_required_boolean(backup_workflow, "require_encryption"), - ), - evidence_workflow=EvidenceWorkflowPreferences( - capture_duration_seconds=_required_integer(evidence_workflow, "capture_duration_seconds"), - include_syslog=_required_boolean(evidence_workflow, "include_syslog"), - include_oslog=_required_boolean(evidence_workflow, "include_oslog"), - include_pcap=_required_boolean(evidence_workflow, "include_pcap"), - include_screenshot=_required_boolean(evidence_workflow, "include_screenshot"), - include_crash_pull=_required_boolean(evidence_workflow, "include_crash_pull"), - ), - location_workflow=LocationWorkflowPreferences( - timing_randomness_ms=_required_integer(location_workflow, "timing_randomness_ms"), - ignore_timing_delays=_required_boolean(location_workflow, "ignore_timing_delays"), - route_speed_preset_kmh=_required_integer(location_workflow, "route_speed_preset_kmh"), - route_speed_kmh=_required_integer(location_workflow, "route_speed_kmh"), - route_interval_seconds=_required_integer(location_workflow, "route_interval_seconds"), - route_traversals=_required_integer(location_workflow, "route_traversals"), - ), - ) - return validate_workspace_profile(profile) - - -def load_workspace_profile(source: Path) -> WorkspaceProfile: - path = source.expanduser().resolve() - if path.suffix.casefold() != ".json": - raise WorkspaceProfileError(f"Workspace profile must have a .json extension: {path}") - if not path.is_file(): - raise WorkspaceProfileError(f"Workspace profile is not a readable file: {path}") - try: - size = path.stat().st_size - if size > MAX_WORKSPACE_PROFILE_BYTES: - raise WorkspaceProfileError( - f"Workspace profile exceeds {MAX_WORKSPACE_PROFILE_BYTES} bytes: {size}" - ) - payload = json.loads(path.read_text(encoding="utf-8")) - except OSError as error: - raise WorkspaceProfileError(f"Could not read workspace profile at {path}: {error}") from error - except json.JSONDecodeError as error: - raise WorkspaceProfileError(f"Workspace profile is not valid JSON: {error}") from error - if not isinstance(payload, dict): - raise WorkspaceProfileError("Workspace profile root must be a JSON object") - return parse_workspace_profile(payload) - - -def write_workspace_profile(destination: Path, profile: WorkspaceProfile) -> Path: - path = destination.expanduser().resolve() - if path.suffix.casefold() != ".json": - raise WorkspaceProfileError(f"Workspace profile destination must end in .json: {path}") - if not path.parent.is_dir(): - raise WorkspaceProfileError(f"Workspace profile parent directory does not exist: {path.parent}") - content = render_workspace_profile_json(profile).encode("utf-8") - try: - descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - except FileExistsError as error: - raise WorkspaceProfileError(f"Refusing to overwrite existing workspace profile: {path}") from error - except OSError as error: - raise WorkspaceProfileError(f"Could not create workspace profile at {path}: {error}") from error - try: - with os.fdopen(descriptor, "wb") as output: - output.write(content) - output.flush() - os.fsync(output.fileno()) - except OSError as error: - path.unlink(missing_ok=True) - raise WorkspaceProfileError(f"Could not write workspace profile at {path}: {error}") from error - return path diff --git a/ios_developer_toolkit/xcode_handoff.py b/ios_developer_toolkit/xcode_handoff.py deleted file mode 100644 index ea21b20..0000000 --- a/ios_developer_toolkit/xcode_handoff.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import annotations - -import os -import shutil -from pathlib import Path - -from ios_developer_toolkit.runtime import ExecutableCommand - - -class XcodeHandoffError(ValueError): - """Raised when an Apple developer-tool handoff cannot be built safely.""" - - -def executable_command(name: str, fixed_candidates: tuple[Path, ...]) -> ExecutableCommand: - if not name or Path(name).name != name: - raise XcodeHandoffError(f"Developer tool name must be a basename: {name!r}") - candidates = (*fixed_candidates, *(Path(path) for path in (shutil.which(name),) if path is not None)) - executable = next((path for path in candidates if path.is_file() and os.access(path, os.X_OK)), None) - if executable is None: - checked = ", ".join(str(path) for path in candidates) or "no candidate paths" - raise XcodeHandoffError(f"Could not find executable developer tool {name}; checked {checked}") - return ExecutableCommand(executable.resolve(), ()) - - -def coredevice_details_handoff(udid: str) -> tuple[ExecutableCommand, tuple[str, ...]]: - normalized_udid = udid.strip() - if not normalized_udid: - raise XcodeHandoffError("CoreDevice details require a selected device identifier") - command = executable_command("xcrun", (Path("/usr/bin/xcrun"),)) - return command, ( - "devicectl", - "device", - "info", - "details", - "--device", - normalized_udid, - "--timeout", - "30", - ) - - -def rvi_list_handoff() -> tuple[ExecutableCommand, tuple[str, ...]]: - command = executable_command( - "rvictl", - (Path("/Library/Apple/usr/bin/rvictl"), Path("/usr/bin/rvictl")), - ) - return command, ("-l",) - - -def validated_xcode_target(path: Path, allowed_suffixes: tuple[str, ...], allowed_names: tuple[str, ...]) -> Path: - resolved = path.expanduser().resolve() - normalized_suffixes = tuple(suffix.casefold() for suffix in allowed_suffixes) - normalized_names = tuple(name.casefold() for name in allowed_names) - if resolved.suffix.casefold() not in normalized_suffixes and resolved.name.casefold() not in normalized_names: - expected = ", ".join((*allowed_suffixes, *allowed_names)) - raise XcodeHandoffError(f"Unsupported Xcode handoff target {resolved}; expected one of: {expected}") - if not resolved.exists(): - raise XcodeHandoffError(f"Xcode handoff target does not exist: {resolved}") - return resolved - - -def validated_xcode_project(path: Path) -> Path: - return validated_xcode_target(path, (".xcodeproj", ".xcworkspace"), ("Package.swift",)) - - -def xcode_project_handoff(path: Path) -> tuple[ExecutableCommand, tuple[str, ...]]: - target = validated_xcode_project(path) - command = executable_command("xcrun", (Path("/usr/bin/xcrun"),)) - return command, ("xed", str(target)) - - -def validated_xcode_artifact(path: Path) -> Path: - return validated_xcode_target(path, (".xcresult", ".trace"), ()) diff --git a/macos/Info.plist b/macos/Info.plist deleted file mode 100644 index 2375986..0000000 --- a/macos/Info.plist +++ /dev/null @@ -1,30 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleDisplayName - iOS Developer Toolkit - CFBundleExecutable - iOSDeveloperToolkit - CFBundleIconFile - iOSDeveloperToolkit - CFBundleIdentifier - io.hideouts.ios-developer-toolkit - CFBundleName - iOS Developer Toolkit - CFBundlePackageType - APPL - CFBundleShortVersionString - 0.3.4 - CFBundleVersion - 6 - LSMinimumSystemVersion - 13.0 - NSHighResolutionCapable - - NSPrincipalClass - NSApplication - - diff --git a/macos/iOSDeveloperToolkit b/macos/iOSDeveloperToolkit deleted file mode 100755 index 42c5f80..0000000 --- a/macos/iOSDeveloperToolkit +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -CONTENTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -PROJECT_DIR="$(cd "$CONTENTS_DIR/../../.." && pwd)" - -cd "$PROJECT_DIR" -exec "$PROJECT_DIR/venv/bin/python" -m ios_developer_toolkit diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 89a93cb..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,61 +0,0 @@ -site_name: iOS Developer Toolkit -site_description: Guided macOS workbench for authorized iPhone and iPad development, diagnostics, backup, and evidence preservation -site_url: https://hideouts-io.github.io/iOS-Developer-Toolkit/ -repo_url: https://github.com/hideouts-io/iOS-Developer-Toolkit -repo_name: hideouts-io/iOS-Developer-Toolkit -edit_uri: edit/main/docs/ -docs_dir: docs -site_dir: site -strict: true - -theme: - name: material - language: en - features: - - content.code.copy - - navigation.footer - - navigation.indexes - - navigation.sections - - navigation.top - - search.highlight - - search.suggest - palette: - - media: "(prefers-color-scheme: light)" - scheme: default - primary: black - accent: red - toggle: - icon: material/weather-night - name: Use dark mode - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: black - accent: red - toggle: - icon: material/weather-sunny - name: Use light mode - -plugins: - - search - -markdown_extensions: - - admonition - - attr_list - - md_in_html - - tables - - toc: - permalink: true - -extra_css: - - stylesheets/extra.css - -nav: - - Overview: index.md - - Quick start: quick-start.md - - Architecture: architecture.md - - Safety and privacy: safety.md - - Troubleshooting: troubleshooting.md - - Release verification: release-verification.md - - Contributing: contributing.md - - Physical-device testing: PHYSICAL_DEVICE_TEST_PROTOCOL.md - - Product audit and roadmap: PRODUCT_AUDIT_2026-09-21.md diff --git a/packaging/main.py b/packaging/main.py deleted file mode 100644 index 6b4ce68..0000000 --- a/packaging/main.py +++ /dev/null @@ -1,5 +0,0 @@ -from ios_developer_toolkit.__main__ import main - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/packaging/pysidedeploy.spec b/packaging/pysidedeploy.spec deleted file mode 100644 index 612fa66..0000000 --- a/packaging/pysidedeploy.spec +++ /dev/null @@ -1,37 +0,0 @@ -[app] -title = iOS Developer Toolkit -project_dir = .. -input_file = packaging/main.py -exec_directory = build/release -project_file = -icon = macos/iOSDeveloperToolkit.icns - -[python] -python_path = -packages = Nuitka==4.2.1 -android_packages = - -[qt] -qml_files = -excluded_qml_plugins = -modules = Core,Gui,Widgets -plugins = platforms,imageformats - -[android] -wheel_pyside = -wheel_shiboken = -plugins = - -[nuitka] -macos.permissions = -mode = standalone -extra_args = --quiet --assume-yes-for-downloads --noinclude-qt-translations --include-package=ios_developer_toolkit --include-package=pymobiledevice3 --include-package=developer_disk_image --include-data-dir=ios_developer_toolkit/assets=ios_developer_toolkit/assets --include-package-data=pymobiledevice3 --include-package-data=developer_disk_image --nofollow-import-to=IPython --nofollow-import-to=jedi --macos-app-name="iOS Developer Toolkit" --macos-app-version=0.3.4 --macos-app-macos-min-version=13.0 --macos-app-mode=gui - -[buildozer] -mode = release -recipe_dir = -jars_dir = -ndk_path = -sdk_path = -local_libs = -arch = diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..961cb44 --- /dev/null +++ b/project.yml @@ -0,0 +1,81 @@ +# XcodeGen specification for the macOS app. The generated iOSDeveloperToolkit.xcodeproj is +# committed, so building does not require XcodeGen; regenerate after adding app targets with: +# xcodegen generate +name: iOSDeveloperToolkit +options: + bundleIdPrefix: io.hideouts + deploymentTarget: + macOS: "14.0" + createIntermediateGroups: true + generateEmptyDirectories: true + groupSortPosition: top +settings: + base: + SWIFT_VERSION: "6.0" + MACOSX_DEPLOYMENT_TARGET: "14.0" + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: "1" + DEAD_CODE_STRIPPING: YES + ENABLE_USER_SCRIPT_SANDBOXING: YES +packages: + Toolkit: + path: . +targets: + iOSDeveloperToolkit: + type: application + platform: macOS + sources: + - path: App/iOSDeveloperToolkit + dependencies: + - package: Toolkit + product: ToolkitCore + - package: Toolkit + product: DeviceKit + - package: Toolkit + product: ToolkitFeatures + settings: + base: + PRODUCT_NAME: iOS Developer Toolkit + PRODUCT_MODULE_NAME: iOSDeveloperToolkitApp + PRODUCT_BUNDLE_IDENTIFIER: io.hideouts.iOSDeveloperToolkit + INFOPLIST_FILE: App/iOSDeveloperToolkit/Info.plist + CODE_SIGN_ENTITLEMENTS: App/iOSDeveloperToolkit/iOSDeveloperToolkit.entitlements + ENABLE_HARDENED_RUNTIME: YES + CODE_SIGN_STYLE: Manual + CODE_SIGN_IDENTITY: "-" + DEVELOPMENT_TEAM: "" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES + COMBINE_HIDPI_IMAGES: YES + ARCHS: "$(ARCHS_STANDARD)" + ONLY_ACTIVE_ARCH[config=Debug]: YES + SWIFT_TREAT_WARNINGS_AS_ERRORS: YES + iOSDeveloperToolkitUITests: + type: bundle.ui-testing + platform: macOS + sources: + - path: App/UITests + dependencies: + - target: iOSDeveloperToolkit + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: io.hideouts.iOSDeveloperToolkitUITests + TEST_TARGET_NAME: iOSDeveloperToolkit + CODE_SIGN_STYLE: Manual + CODE_SIGN_IDENTITY: "-" + DEVELOPMENT_TEAM: "" + GENERATE_INFOPLIST_FILE: YES + SWIFT_TREAT_WARNINGS_AS_ERRORS: YES +schemes: + iOSDeveloperToolkit: + build: + targets: + iOSDeveloperToolkit: all + run: + config: Debug + test: + config: Debug + targets: + - iOSDeveloperToolkitUITests + archive: + config: Release diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 1b2c01a..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,32 +0,0 @@ -[build-system] -requires = ["setuptools>=80", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "ios-developer-toolkit" -version = "0.3.4" -description = "Safety-focused pymobiledevice3 GUI, Developer Disk Image mounter, and iOS evidence workbench" -requires-python = ">=3.10,<3.14" -license = "MIT" -dependencies = [ - "PySide6-Essentials==6.9.3", - "pymobiledevice3==11.15.1", -] - -[project.optional-dependencies] -release = [ - "Nuitka==4.2.1", -] - -[project.scripts] -ios-developer-toolkit = "ios_developer_toolkit.app:main" -ios-developer-collect = "ios_developer_toolkit.collector:main" -ios-local-ddi = "ios_developer_toolkit.local_ddi:main" -ios-ipa-inspect = "ios_developer_toolkit.ipa_inspector:main" - -[tool.setuptools.packages.find] -where = ["."] -include = ["ios_developer_toolkit*"] - -[tool.setuptools.package-data] -ios_developer_toolkit = ["assets/*.png"] diff --git a/requirements/docs.txt b/requirements/docs.txt deleted file mode 100644 index d3504c7..0000000 --- a/requirements/docs.txt +++ /dev/null @@ -1 +0,0 @@ -mkdocs-material==9.7.7 diff --git a/requirements/release-sbom.txt b/requirements/release-sbom.txt deleted file mode 100644 index c70a43d..0000000 --- a/requirements/release-sbom.txt +++ /dev/null @@ -1 +0,0 @@ -cyclonedx-bom==7.3.1 diff --git a/script/build_and_run.sh b/script/build_and_run.sh deleted file mode 100755 index 03c7d1e..0000000 --- a/script/build_and_run.sh +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -MODE="${1:-run}" -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -PROJECT_DIR="$ROOT_DIR" -VENV_DIR="$PROJECT_DIR/venv" -DIST_DIR="$PROJECT_DIR/dist" -APP_BUNDLE="$DIST_DIR/iOS Developer Toolkit.app" -APP_CONTENTS="$APP_BUNDLE/Contents" -APP_MACOS="$APP_CONTENTS/MacOS" -APP_RESOURCES="$APP_CONTENTS/Resources" -APP_EXECUTABLE="$APP_MACOS/iOSDeveloperToolkit" -PROCESS_PATTERN="[i]os_developer_toolkit" - -stop_existing() { - while IFS= read -r process_id; do - if [[ -n "$process_id" ]]; then - kill "$process_id" - fi - done < <(pgrep -f "$PROCESS_PATTERN" || true) -} - -project_runtime_digest() { - shasum -a 256 "$PROJECT_DIR/pyproject.toml" | awk '{print $1}' -} - -python_is_compatible() { - local python_command="$1" - "$python_command" -c 'import sys; raise SystemExit(not ((3, 10) <= sys.version_info[:2] < (3, 14)))' -} - -select_compatible_python() { - local candidate - local candidate_path - for candidate in python3.13 python3.12 python3.11 python3.10 python3; do - if ! candidate_path="$(command -v "$candidate")"; then - continue - fi - if python_is_compatible "$candidate_path"; then - printf '%s\n' "$candidate_path" - return 0 - fi - done - echo "Python 3.10 through 3.13 is required; no compatible interpreter was found" >&2 - return 1 -} - -runtime_matches_project() { - local environment_directory="$1" - local runtime_stamp="$environment_directory/.ios-developer-toolkit-runtime" - if [[ ! -f "$runtime_stamp" ]]; then - return 1 - fi - local recorded_digest - read -r recorded_digest < "$runtime_stamp" - if [[ "$recorded_digest" != "$(project_runtime_digest)" ]]; then - return 1 - fi - "$environment_directory/bin/python" -c 'import PySide6; import pymobiledevice3' - "$environment_directory/bin/python" -m pip check >/dev/null - "$environment_directory/bin/pymobiledevice3" version >/dev/null -} - -install_project_runtime() { - local environment_directory="$1" - local runtime_stamp="$environment_directory/.ios-developer-toolkit-runtime" - for legacy_distribution in PySide6 PySide6-Addons; do - if "$environment_directory/bin/python" -m pip show "$legacy_distribution" >/dev/null 2>&1; then - "$environment_directory/bin/python" -m pip uninstall --yes "$legacy_distribution" || return 1 - fi - done - "$environment_directory/bin/python" -m pip install \ - --disable-pip-version-check \ - --force-reinstall \ - --upgrade \ - "$PROJECT_DIR" || return 1 - "$environment_directory/bin/python" -m pip check || return 1 - "$environment_directory/bin/python" -c 'import PySide6; import pymobiledevice3' || return 1 - project_runtime_digest > "$runtime_stamp" -} - -replace_incompatible_environment() { - local bootstrap_python="$1" - local previous_environment="" - if [[ -d "$VENV_DIR" ]]; then - previous_environment="$(mktemp -d "$PROJECT_DIR/.toolkit-venv-previous.XXXXXX")" - rmdir "$previous_environment" - mv "$VENV_DIR" "$previous_environment" - fi - - if ! "$bootstrap_python" -m venv "$VENV_DIR"; then - if [[ -n "$previous_environment" ]]; then - mv "$previous_environment" "$VENV_DIR" - fi - return 1 - fi - if ! install_project_runtime "$VENV_DIR"; then - /usr/bin/find "$VENV_DIR" -depth -delete - if [[ -n "$previous_environment" ]]; then - mv "$previous_environment" "$VENV_DIR" - fi - return 1 - fi - if [[ -n "$previous_environment" ]]; then - /usr/bin/find "$previous_environment" -depth -delete - fi -} - -build_app() { - local bootstrap_python - bootstrap_python="$(select_compatible_python)" - if [[ ! -x "$VENV_DIR/bin/python" ]] || ! python_is_compatible "$VENV_DIR/bin/python"; then - replace_incompatible_environment "$bootstrap_python" - elif ! runtime_matches_project "$VENV_DIR"; then - install_project_runtime "$VENV_DIR" - fi - mkdir -p "$APP_MACOS" "$APP_RESOURCES" - cp "$PROJECT_DIR/macos/Info.plist" "$APP_CONTENTS/Info.plist" - cp "$PROJECT_DIR/macos/iOSDeveloperToolkit" "$APP_EXECUTABLE" - cp "$PROJECT_DIR/macos/iOSDeveloperToolkit.icns" "$APP_RESOURCES/iOSDeveloperToolkit.icns" - chmod +x "$APP_EXECUTABLE" -} - -open_app() { - /usr/bin/open -n "$APP_BUNDLE" -} - -stop_existing -build_app - -case "$MODE" in - run) - open_app - ;; - --debug|debug) - lldb -- "$VENV_DIR/bin/python" -m ios_developer_toolkit - ;; - --logs|logs) - open_app - /usr/bin/log stream --info --style compact --predicate 'process == "Python"' - ;; - --telemetry|telemetry) - open_app - /usr/bin/log stream --info --style compact --predicate 'process == "Python" AND eventMessage CONTAINS[c] "ios_developer_toolkit"' - ;; - --verify|verify) - open_app - sleep 3 - if ! APP_PID="$(pgrep -f "$PROCESS_PATTERN" | head -n 1)"; then - echo "iOS Developer Toolkit exited before launch verification completed" >&2 - exit 1 - fi - sleep 2 - kill -0 "$APP_PID" - ;; - *) - echo "usage: $0 [run|--debug|--logs|--telemetry|--verify]" >&2 - exit 2 - ;; -esac diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..ebf7c36 --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,143 @@ +#!/bin/bash +# Builds a release of iOS Developer Toolkit, locally or in CI. Needs only Xcode. +# +# scripts/build-release.sh [VERSION] [OUTPUT_DIR] +# +# VERSION defaults to ToolkitVersion.current and must match it and MARKETING_VERSION in +# project.yml. OUTPUT_DIR defaults to build-output/release/VERSION (replaced on each run); any +# other OUTPUT_DIR must be empty or absent. +# +# Produces, in OUTPUT_DIR: +# iOS-Developer-Toolkit-VERSION-macOS-universal.zip the app (arm64 + x86_64), ad-hoc signed +# with the hardened runtime; idt is at +# Contents/MacOS/idt and dependency licenses, +# notices, and the SBOM are in +# Contents/Resources/Licenses +# iOS-Developer-Toolkit-VERSION.spdx.json SPDX 2.3 SBOM from Package.resolved +# SHA256SUMS.txt checksums of both files +set -euo pipefail + +fail() { echo "build-release: $*" >&2; exit 1; } +step() { echo "==> $*"; } + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +current="$(sed -n 's/.*static let current = "\(.*\)".*/\1/p' Sources/ToolkitCore/ToolkitVersion.swift)" +marketing="$(sed -n 's/^ *MARKETING_VERSION: "\(.*\)"$/\1/p' project.yml)" +VERSION="${1:-$current}" +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "version must look like 1.2.3 (got '$VERSION')" +[[ "$VERSION" == "$current" ]] || fail "version $VERSION does not match ToolkitVersion.current ($current)" +[[ "$VERSION" == "$marketing" ]] || fail "version $VERSION does not match MARKETING_VERSION in project.yml ($marketing)" + +COMMIT="$(git rev-parse HEAD 2>/dev/null || echo unknown)" +if [[ "$COMMIT" != unknown && -n "$(git status --porcelain --untracked-files=no 2>/dev/null)" ]]; then + echo "build-release: warning: the working tree has uncommitted changes; the SBOM records $COMMIT-dirty" >&2 + COMMIT="$COMMIT-dirty" +fi + +DEFAULT_OUT="$ROOT/build-output/release/$VERSION" +OUT="${2:-$DEFAULT_OUT}" +if [[ "$OUT" == "$DEFAULT_OUT" ]]; then + rm -rf "$OUT" +elif [[ -e "$OUT" && -n "$(ls -A "$OUT")" ]]; then + fail "$OUT is not empty" +fi +mkdir -p "$OUT" +OUT="$(cd "$OUT" && pwd)" + +CACHE="$ROOT/build-output/release-cache" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/idt-release.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT + +NAME="iOS-Developer-Toolkit-$VERSION" +ZIP="$OUT/$NAME-macOS-universal.zip" +SBOM="$OUT/$NAME.spdx.json" +ENTITLEMENTS="App/iOSDeveloperToolkit/iOSDeveloperToolkit.entitlements" + +step "Xcode: $(xcodebuild -version | tr '\n' ' ')" + +step "Archiving the app (arm64 + x86_64, Release)" +xcodebuild -project iOSDeveloperToolkit.xcodeproj -scheme iOSDeveloperToolkit \ + -configuration Release -destination 'generic/platform=macOS' \ + -derivedDataPath "$CACHE/DerivedData" -archivePath "$WORK/app.xcarchive" \ + ARCHS="arm64 x86_64" ONLY_ACTIVE_ARCH=NO \ + -quiet archive +APP="$WORK/app.xcarchive/Products/Applications/iOS Developer Toolkit.app" +[[ -d "$APP" ]] || fail "the archive does not contain the app" + +step "Building idt (arm64 + x86_64, release)" +if ! swift build -c release --product idt --arch arm64 --arch x86_64 --scratch-path "$CACHE/spm" > "$WORK/idt-build.log" 2>&1; then + grep -E "error:|warning:" "$WORK/idt-build.log" | sort -u | head -40 >&2 + tail -40 "$WORK/idt-build.log" >&2 + fail "the universal idt build failed (output above)" +fi +BIN="$(swift build -c release --product idt --arch arm64 --arch x86_64 --scratch-path "$CACHE/spm" --show-bin-path)" +cp "$BIN/idt" "$APP/Contents/MacOS/idt" + +step "Adding licenses, notices, and the SBOM" +LICENSES="$APP/Contents/Resources/Licenses" +mkdir -p "$LICENSES" +cp LICENSE "$LICENSES/LICENSE-iOS-Developer-Toolkit.txt" +cp THIRD_PARTY_NOTICES.md SOURCE_AVAILABILITY.md "$LICENSES/" +for identity in $(sed -n 's/.*"identity" : "\(.*\)".*/\1/p' Package.resolved); do + checkout="$CACHE/spm/checkouts/$identity" + [[ -d "$checkout" ]] || fail "missing checkout for $identity" + mkdir -p "$LICENSES/$identity" + found=0 + for file in "$checkout"/LICENSE* "$checkout"/NOTICE*; do + [[ -f "$file" ]] || continue + cp "$file" "$LICENSES/$identity/" + found=1 + done + [[ "$found" == 1 ]] || fail "no license file found for $identity" +done +xcrun swift scripts/generate-sbom.swift Package.resolved "$CACHE/spm/checkouts" "$VERSION" "$COMMIT" "$SBOM" +cp "$SBOM" "$LICENSES/sbom.spdx.json" + +step "Signing (ad hoc, hardened runtime)" +codesign --force --sign - --options runtime --timestamp=none \ + --identifier io.hideouts.iOSDeveloperToolkit.idt "$APP/Contents/MacOS/idt" +codesign --force --sign - --options runtime --timestamp=none \ + --entitlements "$ENTITLEMENTS" "$APP" + +verify_app() { + local app="$1" + codesign --verify --deep --strict "$app" || fail "codesign verification failed for $app" + local details + for binary in "$app/Contents/MacOS/iOS Developer Toolkit" "$app/Contents/MacOS/idt"; do + details="$(codesign --display --verbose=2 "$binary" 2>&1)" + grep -q 'Signature=adhoc' <<<"$details" || fail "$(basename "$binary") is not ad-hoc signed" + grep -Eq 'flags=0x[0-9a-f]+\(.*runtime' <<<"$details" || fail "$(basename "$binary") lacks the hardened runtime" + local archs + archs="$(lipo -archs "$binary")" + [[ " $archs " == *" arm64 "* && " $archs " == *" x86_64 "* ]] || fail "$(basename "$binary") is not universal ($archs)" + if otool -L "$binary" | grep -qi python; then fail "$(basename "$binary") links Python"; fi + done + local plist_version + plist_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist")" + [[ "$plist_version" == "$VERSION" ]] || fail "Info.plist version is $plist_version, expected $VERSION" + local idt_version + idt_version="$("$app/Contents/MacOS/idt" --version)" + [[ "$idt_version" == "$VERSION" ]] || fail "idt reports $idt_version, expected $VERSION" + if [[ "$(uname -m)" == arm64 ]] && arch -x86_64 /usr/bin/true 2>/dev/null; then + [[ "$(arch -x86_64 "$app/Contents/MacOS/idt" --version)" == "$VERSION" ]] || fail "the x86_64 slice of idt does not run" + fi + [[ -f "$app/Contents/Resources/Licenses/swift-nio-ssl/NOTICE.txt" ]] || fail "license notices are missing" +} + +step "Verifying the signed app" +verify_app "$APP" + +step "Packaging" +ditto -c -k --sequesterRsrc --keepParent "$APP" "$ZIP" +(cd "$OUT" && shasum -a 256 "$(basename "$ZIP")" "$(basename "$SBOM")" > SHA256SUMS.txt) + +step "Verifying the ZIP" +mkdir "$WORK/unzipped" +ditto -x -k "$ZIP" "$WORK/unzipped" +verify_app "$WORK/unzipped/iOS Developer Toolkit.app" +(cd "$OUT" && shasum -a 256 -c SHA256SUMS.txt) + +step "Done: $OUT" +ls -l "$OUT" diff --git a/scripts/build_macos_release.sh b/scripts/build_macos_release.sh deleted file mode 100755 index 7f7d1d0..0000000 --- a/scripts/build_macos_release.sh +++ /dev/null @@ -1,168 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -if [[ "$#" -ne 3 ]]; then - echo "Usage: $0 VERSION OUTPUT_DIRECTORY PYTHON_EXECUTABLE" >&2 - exit 64 -fi - -release_version="$1" -output_directory="$2" -repository_root="$(cd "$(dirname "$0")/.." && pwd)" -python_executable="$3" -machine_architecture="$(uname -m)" -required_macos_version="13.0" -export COPYFILE_DISABLE=1 - -if [[ "${MACOSX_DEPLOYMENT_TARGET:-}" != "$required_macos_version" ]]; then - echo "Release builds require MACOSX_DEPLOYMENT_TARGET=$required_macos_version so the executable can match the advertised macOS floor." >&2 - exit 73 -fi - -if [[ ! -f "$repository_root/pyproject.toml" || ! -f "$repository_root/requirements/release-sbom.txt" || ! -d "$repository_root/ios_developer_toolkit" ]]; then - echo "Release builder could not validate the repository root: $repository_root" >&2 - exit 65 -fi - -if [[ "$machine_architecture" != "arm64" && "$machine_architecture" != "x86_64" ]]; then - echo "Unsupported macOS architecture: $machine_architecture" >&2 - exit 66 -fi - -configured_version="$(cd "$repository_root" && "$python_executable" -c 'from ios_developer_toolkit import APP_VERSION; print(APP_VERSION)')" -if [[ "$configured_version" != "$release_version" ]]; then - echo "Requested version $release_version does not match application version $configured_version" >&2 - exit 67 -fi - -release_root="$(cd "$repository_root" && mkdir -p "$output_directory" && cd "$output_directory" && pwd)" -staging_root="$(mktemp -d /private/tmp/iosdevtoolkit-release.XXXXXX)" -cleanup_staging() { - if [[ ! -d "$staging_root" || "$staging_root" != /private/tmp/iosdevtoolkit-release.* ]]; then - echo "Refusing to remove an unexpected release staging path: $staging_root" >&2 - return 74 - fi - /usr/bin/find "$staging_root" -depth -delete -} -trap cleanup_staging EXIT -if [[ -z "${NUITKA_CACHE_DIR:-}" ]]; then - export NUITKA_CACHE_DIR="$staging_root/nuitka-cache" -fi -/bin/mkdir -p "$NUITKA_CACHE_DIR" -build_environment="$staging_root/release-venv" -metadata_environment="$staging_root/metadata-venv" -deployment_project_directory="$staging_root/deployment-project" -source_wrapper="$deployment_project_directory/main.py" -generated_app_path="$staging_root/build/iOS Developer Toolkit.app" -app_path="$staging_root/iOS Developer Toolkit.app" -archive_name="iOS-Developer-Toolkit-v${release_version}-macOS-${machine_architecture}.zip" -archive_path="$release_root/$archive_name" -sbom_name="iOS-Developer-Toolkit-v${release_version}-macOS-${machine_architecture}.cdx.json" -sbom_path="$release_root/$sbom_name" -runtime_requirements="$staging_root/runtime-requirements.txt" -post_build_requirements="$staging_root/post-build-requirements.txt" -sbom_requirements="$staging_root/sbom-requirements.txt" -license_directory="$staging_root/third-party-licenses" -deployment_config="$staging_root/pysidedeploy.spec" - -/bin/rm -f "$archive_path" "$sbom_path" - -"$python_executable" -m venv "$build_environment" -"$build_environment/bin/python" -m pip install --disable-pip-version-check --upgrade pip -"$build_environment/bin/python" -m pip install --disable-pip-version-check "$repository_root" -"$build_environment/bin/python" -m pip freeze --local --require-virtualenv > "$runtime_requirements" -/usr/bin/sed -i '' "s|^ios-developer-toolkit @ .*|ios-developer-toolkit==$release_version|" "$runtime_requirements" -/bin/cp "$runtime_requirements" "$sbom_requirements" -echo "Nuitka==4.2.1" >> "$sbom_requirements" -"$build_environment/bin/python" -m pip install --disable-pip-version-check "$repository_root[release]" -"$build_environment/bin/python" -m pip freeze --local --require-virtualenv > "$post_build_requirements" -/usr/bin/sed -i '' "s|^ios-developer-toolkit @ .*|ios-developer-toolkit==$release_version|" "$post_build_requirements" -missing_runtime_requirements="$(while IFS= read -r requirement; do - /usr/bin/grep -Fqx -- "$requirement" "$post_build_requirements" || echo "$requirement" -done < "$runtime_requirements")" -if [[ -n "$missing_runtime_requirements" ]]; then - echo "Installing release tooling changed the frozen runtime dependency set:" >&2 - echo "$missing_runtime_requirements" >&2 - exit 71 -fi -"$python_executable" -m venv "$metadata_environment" -"$metadata_environment/bin/python" -m pip install --disable-pip-version-check \ - --requirement "$repository_root/requirements/release-sbom.txt" -"$metadata_environment/bin/cyclonedx-py" requirements "$sbom_requirements" \ - --pyproject "$repository_root/pyproject.toml" \ - --mc-type application \ - --sv 1.6 \ - --of JSON \ - -o "$sbom_path" -"$build_environment/bin/python" scripts/collect_third_party_licenses.py "$sbom_requirements" "$license_directory" - -cd "$repository_root" -"$build_environment/bin/python" -m unittest discover -s tests -v -/bin/mkdir -p "$deployment_project_directory" -/bin/cp packaging/main.py "$source_wrapper" -/bin/cp packaging/pysidedeploy.spec "$deployment_config" -/usr/bin/sed -i '' \ - -e "s|^project_dir =.*|project_dir = $deployment_project_directory|" \ - -e "s|^input_file =.*|input_file = $source_wrapper|" \ - -e "s|^exec_directory =.*|exec_directory = $staging_root/build|" \ - -e "s|^icon =.*|icon = $repository_root/macos/iOSDeveloperToolkit.icns|" \ - -e "s|--macos-app-version=[^[:space:]]*|--macos-app-version=$release_version|" \ - "$deployment_config" -"$build_environment/bin/pyside6-deploy" -c "$deployment_config" --force --keep-deployment-files - -if [[ ! -d "$generated_app_path" ]]; then - echo "pyside6-deploy did not create the expected app bundle: $generated_app_path" >&2 - exit 68 -fi -if [[ -e "$app_path" ]]; then - echo "Release staging path already exists: $app_path" >&2 - exit 69 -fi - -/bin/cp -R "$generated_app_path" "$app_path" -plist_path="$app_path/Contents/Info.plist" -/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier io.hideouts.ios-developer-toolkit" "$plist_path" -/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName iOS Developer Toolkit" "$plist_path" -/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $release_version" "$plist_path" -/usr/libexec/PlistBuddy -c "Add :CFBundleVersion string 6" "$plist_path" 2>/dev/null || /usr/libexec/PlistBuddy -c "Set :CFBundleVersion 6" "$plist_path" -/usr/libexec/PlistBuddy -c "Add :LSMinimumSystemVersion string 13.0" "$plist_path" 2>/dev/null || /usr/libexec/PlistBuddy -c "Set :LSMinimumSystemVersion 13.0" "$plist_path" - -bundle_license_directory="$app_path/Contents/Resources/Licenses" -/bin/mkdir -p "$bundle_license_directory" -/bin/cp "$repository_root/LICENSE" "$bundle_license_directory/IOS_DEVELOPER_TOOLKIT_LICENSE.txt" -/bin/cp "$repository_root/THIRD_PARTY_NOTICES.md" "$bundle_license_directory/THIRD_PARTY_NOTICES.md" -/bin/cp "$repository_root/SOURCE_AVAILABILITY.md" "$bundle_license_directory/SOURCE_AVAILABILITY.md" -/usr/bin/ditto --norsrc "$license_directory" "$bundle_license_directory/ThirdPartyPackages" -/bin/cp "$sbom_path" "$app_path/Contents/Resources/BOM.cdx.json" - -if [[ ! -s "$bundle_license_directory/ThirdPartyPackages/THIRD_PARTY_PACKAGES.md" || ! -s "$app_path/Contents/Resources/BOM.cdx.json" ]]; then - echo "Release bundle is missing its generated license inventory or SBOM" >&2 - exit 70 -fi -"$build_environment/bin/python" scripts/verify_release_metadata.py "$app_path" "$sbom_path" "$release_version" - -/usr/bin/xattr -cr "$app_path" -/usr/bin/codesign --force --deep --sign - --timestamp=none "$app_path" -/usr/bin/codesign --verify --deep --strict --verbose=2 "$app_path" - -bundle_executable="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$plist_path")" -compiled_executable="$app_path/Contents/MacOS/$bundle_executable" -compiled_architecture="$(/usr/bin/lipo -archs "$compiled_executable")" -if [[ "$compiled_architecture" != "$machine_architecture" ]]; then - echo "Compiled executable architecture is $compiled_architecture; expected $machine_architecture" >&2 - exit 69 -fi -"$build_environment/bin/python" scripts/verify_macos_bundle.py \ - "$app_path" \ - "$machine_architecture" \ - "$required_macos_version" - -"$compiled_executable" --toolkit-internal-pymobiledevice3 version -"$compiled_executable" --toolkit-internal-worker capability --help -QT_QPA_PLATFORM=offscreen "$compiled_executable" --toolkit-internal-smoke-test - -/usr/bin/ditto -c -k --sequesterRsrc --keepParent "$app_path" "$archive_path" -/usr/bin/shasum -a 256 "$archive_path" "$sbom_path" -echo "$archive_path" -echo "$sbom_path" diff --git a/scripts/check-layout.sh b/scripts/check-layout.sh new file mode 100755 index 0000000..52fdf24 --- /dev/null +++ b/scripts/check-layout.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Renders every page of the app at the default (1180x700) and minimum (900x560) window sizes with +# the built-in screenshot harness (Demo Mode, no device discovery) and fails if any page is +# squeezed, overflows the window, is missing, or the harness times out. +# +# scripts/check-layout.sh "/Contents/MacOS/iOS Developer Toolkit" +set -euo pipefail +app="$1"; root="$2" +for size in 1180x700 900x560; do + out="$root/$size" + "$app" -ui-testing YES -demo-mode YES -populate-demo YES -window-size "$size" -capture-screenshots "$out" + cat "$out/window-geometry.txt"; echo + if [[ -e "$out/TIMEOUT" ]]; then echo "::error::screenshot harness timed out at $size"; exit 1; fi + pages="$(grep -c ': window (' "$out/window-geometry.txt" || true)" + if (( pages < 14 )); then echo "::error::only $pages pages rendered at $size"; exit 1; fi + if grep -E 'SQUEEZED|OVERFLOW' "$out/window-geometry.txt"; then echo "::error::layout problem at $size"; exit 1; fi +done +echo "layout OK" diff --git a/scripts/ci-select-xcode.sh b/scripts/ci-select-xcode.sh new file mode 100755 index 0000000..410d02f --- /dev/null +++ b/scripts/ci-select-xcode.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Selects Xcode for a GitHub Actions job by exporting DEVELOPER_DIR (no sudo, no xcode-select). +# Uses Xcode_$XCODE_VERSION.app when XCODE_VERSION is set (for example "26.2"), otherwise the +# newest non-beta Xcode installed on the runner. +set -euo pipefail + +if [[ -n "${XCODE_VERSION:-}" ]]; then + xcode="/Applications/Xcode_${XCODE_VERSION}.app" + [[ -d "$xcode" ]] || { echo "Xcode $XCODE_VERSION is not installed on this runner:" >&2; ls -d /Applications/Xcode*.app >&2; exit 1; } +else + xcode="$(ls -d /Applications/Xcode_*.app 2>/dev/null | grep -Eiv 'beta|rc|release_candidate' | sort -V | tail -1 || true)" + [[ -n "$xcode" ]] || xcode="/Applications/Xcode.app" +fi + +developer_dir="$xcode/Contents/Developer" +if [[ -n "${GITHUB_ENV:-}" ]]; then + echo "DEVELOPER_DIR=$developer_dir" >> "$GITHUB_ENV" +fi +DEVELOPER_DIR="$developer_dir" xcodebuild -version +DEVELOPER_DIR="$developer_dir" swift --version diff --git a/scripts/collect_third_party_licenses.py b/scripts/collect_third_party_licenses.py deleted file mode 100644 index f07049d..0000000 --- a/scripts/collect_third_party_licenses.py +++ /dev/null @@ -1,206 +0,0 @@ -from __future__ import annotations - -import re -import shutil -import sys -from dataclasses import dataclass -from importlib.metadata import Distribution, PackageNotFoundError, distribution -from pathlib import Path -from typing import Sequence - -from packaging.requirements import InvalidRequirement, Requirement - - -class LicenseCollectionError(RuntimeError): - """Raised when release dependency metadata cannot be collected safely.""" - - -@dataclass(frozen=True) -class PackageNotice: - name: str - version: str - declared_license: str - source_url: str - copied_files: tuple[str, ...] - - -def normalize_distribution_name(name: str) -> str: - return re.sub(r"[-_.]+", "-", name).lower() - - -def parse_requirement_names(requirements_path: Path) -> tuple[str, ...]: - names: set[str] = set() - for line_number, raw_line in enumerate(requirements_path.read_text(encoding="utf-8").splitlines(), start=1): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - try: - requirement = Requirement(line) - except InvalidRequirement as error: - raise LicenseCollectionError( - f"Invalid frozen requirement at {requirements_path}:{line_number}: {line}" - ) from error - normalized_name = normalize_distribution_name(requirement.name) - if normalized_name != "ios-developer-toolkit": - names.add(requirement.name) - if not names: - raise LicenseCollectionError(f"No third-party distributions were found in {requirements_path}") - return tuple(sorted(names, key=str.casefold)) - - -def declared_license(package_distribution: Distribution) -> tuple[str, str | None]: - license_value = package_distribution.metadata.get("License-Expression") - if license_value is None: - license_value = package_distribution.metadata.get("License") - if license_value is None or not license_value.strip(): - return ("Not declared in installed package metadata", None) - normalized_license = " ".join(license_value.split()) - if len(normalized_license) > 240: - return ("Full license text stored in `METADATA-LICENSE.txt`", license_value.strip() + "\n") - return (normalized_license, None) - - -def source_url(package_distribution: Distribution) -> str: - project_urls = package_distribution.metadata.get_all("Project-URL") or [] - preferred_labels = ("source", "repository", "homepage") - parsed_urls: dict[str, str] = {} - for project_url in project_urls: - label, separator, url = project_url.partition(",") - if separator and url.strip(): - parsed_urls[label.strip().lower()] = url.strip() - for preferred_label in preferred_labels: - for label, url in parsed_urls.items(): - if preferred_label in label: - return url - homepage = package_distribution.metadata.get("Home-page") - if homepage is not None and homepage.strip(): - return homepage.strip() - return "Not declared in installed package metadata" - - -def is_license_file(path: Path) -> bool: - filename = path.name.lower() - return filename.startswith(("license", "copying", "notice")) - - -def copy_license_files(package_distribution: Distribution, destination: Path) -> tuple[str, ...]: - package_files = package_distribution.files - if package_files is None: - raise LicenseCollectionError( - f"Installed distribution {package_distribution.metadata['Name']} has no file inventory" - ) - copied_files: list[str] = [] - for package_file in sorted(package_files, key=lambda path: str(path).casefold()): - relative_path = Path(str(package_file)) - if not is_license_file(relative_path): - continue - source_path = Path(package_distribution.locate_file(package_file)).resolve() - if not source_path.is_file(): - raise LicenseCollectionError( - f"License file declared by {package_distribution.metadata['Name']} is missing: {source_path}" - ) - destination.mkdir(parents=True, exist_ok=True) - destination_path = destination / relative_path.name - if destination_path.exists(): - destination_path = destination / f"{relative_path.parent.name}-{relative_path.name}" - shutil.copy2(source_path, destination_path) - copied_files.append(destination_path.name) - return tuple(copied_files) - - -def collect_package_notice(distribution_name: str, output_directory: Path) -> PackageNotice: - try: - package_distribution = distribution(distribution_name) - except PackageNotFoundError as error: - raise LicenseCollectionError( - f"Frozen release dependency is not installed: {distribution_name}" - ) from error - package_name = package_distribution.metadata.get("Name") - if package_name is None or not package_name.strip(): - raise LicenseCollectionError(f"Installed distribution has no Name metadata: {distribution_name}") - version = package_distribution.version - if not version: - raise LicenseCollectionError(f"Installed distribution has no version: {package_name}") - package_directory = output_directory / normalize_distribution_name(package_name) - copied_files = list(copy_license_files(package_distribution, package_directory)) - license_summary, complete_metadata_license = declared_license(package_distribution) - if complete_metadata_license is not None: - package_directory.mkdir(parents=True, exist_ok=True) - metadata_license_path = package_directory / "METADATA-LICENSE.txt" - metadata_license_path.write_text(complete_metadata_license, encoding="utf-8") - copied_files.append(metadata_license_path.name) - return PackageNotice( - name=package_name, - version=version, - declared_license=license_summary, - source_url=source_url(package_distribution), - copied_files=tuple(copied_files), - ) - - -def markdown_cell(value: str) -> str: - return value.replace("|", "\\|").replace("\n", " ") - - -def render_inventory(notices: Sequence[PackageNotice]) -> str: - lines = [ - "# Installed third-party Python packages", - "", - "This file was generated from the exact frozen dependency set used for this application build. License declarations come from installed package metadata. Copied files are the license or notice files supplied by each installed wheel.", - "", - "| Package | Version | Declared license | Source | Copied license files |", - "|---|---:|---|---|---|", - ] - for notice in notices: - copied_files = ", ".join(f"`{filename}`" for filename in notice.copied_files) - if not copied_files: - copied_files = "None supplied by installed wheel" - source = markdown_cell(notice.source_url) - if source.startswith("https://"): - source = f"[Upstream]({source})" - lines.append( - "| " - + " | ".join( - ( - markdown_cell(notice.name), - markdown_cell(notice.version), - markdown_cell(notice.declared_license), - source, - copied_files, - ) - ) - + " |" - ) - lines.extend(("", "See `THIRD_PARTY_NOTICES.md` for the release-critical dependency summary and redistribution boundary.", "")) - return "\n".join(lines) - - -def collect_licenses(requirements_path: Path, output_directory: Path) -> tuple[PackageNotice, ...]: - if not requirements_path.is_file(): - raise LicenseCollectionError(f"Frozen requirements file does not exist: {requirements_path}") - if output_directory.exists(): - raise LicenseCollectionError(f"License output directory already exists: {output_directory}") - output_directory.mkdir(parents=True) - notices = tuple( - collect_package_notice(distribution_name, output_directory) - for distribution_name in parse_requirement_names(requirements_path) - ) - inventory_path = output_directory / "THIRD_PARTY_PACKAGES.md" - inventory_path.write_text(render_inventory(notices), encoding="utf-8") - return notices - - -def main(arguments: Sequence[str]) -> int: - if len(arguments) != 3: - raise LicenseCollectionError( - "Usage: collect_third_party_licenses.py FROZEN_REQUIREMENTS OUTPUT_DIRECTORY" - ) - requirements_path = Path(arguments[1]).resolve() - output_directory = Path(arguments[2]).resolve() - notices = collect_licenses(requirements_path, output_directory) - print(f"Collected notices for {len(notices)} third-party distributions in {output_directory}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv)) diff --git a/scripts/generate-sbom.swift b/scripts/generate-sbom.swift new file mode 100644 index 0000000..873c27b --- /dev/null +++ b/scripts/generate-sbom.swift @@ -0,0 +1,131 @@ +// Generates an SPDX 2.3 JSON SBOM for a release from Package.resolved. +// +// swift scripts/generate-sbom.swift +// +// Licenses are read from each dependency's checked-out LICENSE file (Apache-2.0 or MIT), with +// the bundled BoringSSL licenses added for swift-nio-ssl. Anything unrecognized is recorded as +// NOASSERTION rather than guessed. + +import Foundation + +let arguments = CommandLine.arguments +guard arguments.count == 6 else { + FileHandle.standardError.write(Data("usage: generate-sbom.swift \n".utf8)) + exit(64) +} +let resolvedURL = URL(fileURLWithPath: arguments[1]) +let checkouts = URL(fileURLWithPath: arguments[2], isDirectory: true) +let version = arguments[3] +let commit = arguments[4] +let outputURL = URL(fileURLWithPath: arguments[5]) + +struct Resolved: Decodable { + struct Pin: Decodable { + struct State: Decodable { var revision: String; var version: String? } + var identity: String + var location: String + var state: State + } + var pins: [Pin] +} + +func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data("generate-sbom: \(message)\n".utf8)) + exit(1) +} + +func declaredLicense(identity: String) -> String { + let directory = checkouts.appendingPathComponent(identity, isDirectory: true) + let names = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? [] + guard let name = names.first(where: { $0.uppercased().hasPrefix("LICENSE") }), + let text = try? String(contentsOf: directory.appendingPathComponent(name), encoding: .utf8) else { + return "NOASSERTION" + } + var license: String + if text.contains("Apache License") && text.contains("Version 2.0") { + license = "Apache-2.0" + } else if text.contains("Permission is hereby granted, free of charge") { + license = "MIT" + } else { + return "NOASSERTION" + } + if identity == "swift-nio-ssl" { + // swift-nio-ssl vendors BoringSSL (see its NOTICE.txt). + license += " AND ISC AND OpenSSL" + } + return license +} + +func purl(location: String, version: String?) -> String? { + guard let url = URL(string: location), let host = url.host else { return nil } + var path = url.path + if path.hasSuffix(".git") { path.removeLast(4) } + return "pkg:swift/\(host)\(path)" + (version.map { "@\($0)" } ?? "") +} + +guard let data = try? Data(contentsOf: resolvedURL), + let resolved = try? JSONDecoder().decode(Resolved.self, from: data) else { + fail("could not read \(resolvedURL.path)") +} + +let appID = "SPDXRef-Package-iOSDeveloperToolkit" +var packages: [[String: Any]] = [[ + "SPDXID": appID, + "name": "iOS Developer Toolkit", + "versionInfo": version, + "downloadLocation": "git+https://github.com/hideouts-io/iOS-Developer-Toolkit.git@\(commit)", + "licenseConcluded": "MIT", + "licenseDeclared": "MIT", + "copyrightText": "Copyright (c) 2026 hideouts-io", + "filesAnalyzed": false, + "primaryPackagePurpose": "APPLICATION", + "supplier": "Organization: hideouts-io", +]] +var relationships: [[String: String]] = [[ + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": appID, +]] + +for pin in resolved.pins.sorted(by: { $0.identity < $1.identity }) { + let id = "SPDXRef-Package-" + pin.identity.map { $0.isLetter || $0.isNumber || $0 == "." ? String($0) : "-" }.joined() + var package: [String: Any] = [ + "SPDXID": id, + "name": pin.identity, + "versionInfo": pin.state.version ?? pin.state.revision, + "downloadLocation": "git+\(pin.location)@\(pin.state.revision)", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": declaredLicense(identity: pin.identity), + "copyrightText": "NOASSERTION", + "filesAnalyzed": false, + "primaryPackagePurpose": "LIBRARY", + ] + if let locator = purl(location: pin.location, version: pin.state.version) { + package["externalRefs"] = [["referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": locator]] + } + packages.append(package) + relationships.append(["spdxElementId": appID, "relationshipType": "DEPENDS_ON", "relatedSpdxElement": id]) +} + +let formatter = ISO8601DateFormatter() +formatter.formatOptions = [.withInternetDateTime] +let document: [String: Any] = [ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "iOS-Developer-Toolkit-\(version)", + "documentNamespace": "https://github.com/hideouts-io/iOS-Developer-Toolkit/spdx/\(version)/\(commit)", + "creationInfo": [ + "created": formatter.string(from: Date()), + "creators": ["Tool: scripts/generate-sbom.swift", "Organization: hideouts-io"], + ], + "packages": packages, + "relationships": relationships, +] + +do { + let json = try JSONSerialization.data(withJSONObject: document, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + try json.write(to: outputURL, options: .withoutOverwriting) +} catch { + fail("could not write \(outputURL.path): \(error.localizedDescription)") +} diff --git a/scripts/verify_command_catalog.py b/scripts/verify_command_catalog.py deleted file mode 100644 index 9648dd8..0000000 --- a/scripts/verify_command_catalog.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -"""Verify that every guided preset still matches installed pymobiledevice3 help.""" - -from __future__ import annotations - -import subprocess - -from ios_developer_toolkit.command_catalog import command_presets -from ios_developer_toolkit.command_drift import ( - HelpRouteProbe, - evaluate_command_drift, - help_routes_for_presets, - render_command_drift_report, -) -from ios_developer_toolkit.runtime import ExecutableCommand, command_argv, pymobiledevice3_command - - -HELP_TIMEOUT_SECONDS = 10 - - -def probe_live_help(command: ExecutableCommand, command_path: tuple[str, ...]) -> HelpRouteProbe: - """Return the result of one device-free pymobiledevice3 help probe.""" - arguments = (*command_path, "--help") - try: - completed = subprocess.run( - command_argv(command, arguments), - check=False, - capture_output=True, - text=True, - timeout=HELP_TIMEOUT_SECONDS, - ) - except subprocess.TimeoutExpired: - return HelpRouteProbe( - command_path, - None, - "", - "", - f"Live help exceeded the {HELP_TIMEOUT_SECONDS}-second per-route limit.", - ) - except OSError as error: - return HelpRouteProbe( - command_path, - None, - "", - "", - f"Could not start live help: {error}", - ) - return HelpRouteProbe( - command_path, - completed.returncode, - completed.stdout, - completed.stderr, - None, - ) - - -def main() -> int: - """Print a command-drift report and return nonzero for incompatible guidance.""" - presets = command_presets() - command = pymobiledevice3_command() - probes = tuple(probe_live_help(command, route) for route in help_routes_for_presets(presets)) - results = evaluate_command_drift(presets, probes) - print(render_command_drift_report(results)) - return 0 if all(result.state == "verified" for result in results) else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/verify_macos_bundle.py b/scripts/verify_macos_bundle.py deleted file mode 100644 index 74b01d5..0000000 --- a/scripts/verify_macos_bundle.py +++ /dev/null @@ -1,166 +0,0 @@ -from __future__ import annotations - -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Sequence - - -MACH_O_MAGICS = frozenset( - { - b"\xce\xfa\xed\xfe", - b"\xfe\xed\xfa\xce", - b"\xcf\xfa\xed\xfe", - b"\xfe\xed\xfa\xcf", - b"\xca\xfe\xba\xbe", - b"\xbe\xba\xfe\xca", - b"\xca\xfe\xba\xbf", - b"\xbf\xba\xfe\xca", - } -) - - -class MacOSBundleValidationError(RuntimeError): - """Raised when a bundled Mach-O cannot satisfy the advertised platform floor.""" - - -@dataclass(frozen=True) -class MachORecord: - path: Path - architectures: tuple[str, ...] - minimum_macos_versions: tuple[str, ...] - - -def version_parts(value: str) -> tuple[int, int, int]: - components = value.split(".") - if not components or len(components) > 3 or any(not component.isdigit() for component in components): - raise MacOSBundleValidationError(f"Invalid macOS version in Mach-O load command: {value!r}") - numbers = tuple(int(component) for component in components) - return (numbers + (0, 0, 0))[:3] - - -def parse_minimum_macos_versions(otool_output: str) -> tuple[str, ...]: - versions: list[str] = [] - active_command = "" - for raw_line in otool_output.splitlines(): - line = raw_line.strip() - if line == "cmd LC_BUILD_VERSION": - active_command = "build" - continue - if line == "cmd LC_VERSION_MIN_MACOSX": - active_command = "legacy" - continue - if active_command == "build" and line.startswith("minos "): - versions.append(line.removeprefix("minos ").split()[0]) - active_command = "" - continue - if active_command == "legacy" and line.startswith("version "): - versions.append(line.removeprefix("version ").split()[0]) - active_command = "" - return tuple(versions) - - -def is_mach_o(path: Path) -> bool: - try: - with path.open("rb") as stream: - return stream.read(4) in MACH_O_MAGICS - except OSError as error: - raise MacOSBundleValidationError(f"Could not read bundle file {path}: {error}") from error - - -def run_tool(arguments: Sequence[str], target: Path) -> str: - try: - completed = subprocess.run( - (*arguments, str(target)), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - ) - except OSError as error: - raise MacOSBundleValidationError( - f"Could not execute {arguments[0]} for {target}: {error}" - ) from error - if completed.returncode != 0: - raise MacOSBundleValidationError( - f"{' '.join((*arguments, str(target)))} exited {completed.returncode}. " - f"stdout={completed.stdout.strip()!r} stderr={completed.stderr.strip()!r}" - ) - return completed.stdout - - -def inspect_mach_o(path: Path) -> MachORecord: - architecture_output = run_tool(("/usr/bin/lipo", "-archs"), path) - architectures = tuple(architecture_output.split()) - if not architectures: - raise MacOSBundleValidationError(f"lipo returned no architectures for bundled Mach-O: {path}") - load_commands = run_tool(("/usr/bin/otool", "-l"), path) - minimum_versions = parse_minimum_macos_versions(load_commands) - if not minimum_versions: - raise MacOSBundleValidationError( - f"Bundled Mach-O has no LC_BUILD_VERSION or LC_VERSION_MIN_MACOSX floor: {path}" - ) - return MachORecord(path, architectures, minimum_versions) - - -def validate_mach_o_records( - records: Sequence[MachORecord], - expected_architecture: str, - maximum_macos_version: str, -) -> None: - maximum_parts = version_parts(maximum_macos_version) - for record in records: - if expected_architecture not in record.architectures: - raise MacOSBundleValidationError( - f"Bundled Mach-O {record.path} does not contain required architecture " - f"{expected_architecture}; found {', '.join(record.architectures)}" - ) - for minimum_version in record.minimum_macos_versions: - if version_parts(minimum_version) > maximum_parts: - raise MacOSBundleValidationError( - f"Bundled Mach-O {record.path} requires macOS {minimum_version}, newer than the " - f"advertised macOS {maximum_macos_version} floor" - ) - - -def inspect_application_bundle(application_path: Path) -> tuple[MachORecord, ...]: - if not application_path.is_dir() or application_path.suffix != ".app": - raise MacOSBundleValidationError(f"Application bundle does not exist: {application_path}") - records = tuple( - inspect_mach_o(path) - for path in sorted(application_path.rglob("*")) - if path.is_file() and is_mach_o(path) - ) - if not records: - raise MacOSBundleValidationError(f"Application bundle contains no Mach-O files: {application_path}") - return records - - -def main(arguments: Sequence[str]) -> int: - if len(arguments) != 4: - raise MacOSBundleValidationError( - "Usage: verify_macos_bundle.py APPLICATION_PATH EXPECTED_ARCHITECTURE MAXIMUM_MACOS_VERSION" - ) - application_path = Path(arguments[1]).resolve() - expected_architecture = arguments[2] - maximum_macos_version = arguments[3] - records = inspect_application_bundle(application_path) - validate_mach_o_records(records, expected_architecture, maximum_macos_version) - observed_versions = sorted( - {version for record in records for version in record.minimum_macos_versions}, - key=version_parts, - ) - print( - f"Validated {len(records)} bundled Mach-O files for {expected_architecture}; " - f"observed macOS floors: {', '.join(observed_versions)}; advertised floor: {maximum_macos_version}" - ) - return 0 - - -if __name__ == "__main__": - try: - raise SystemExit(main(sys.argv)) - except MacOSBundleValidationError as error: - print(f"macOS bundle validation failed: {error}", file=sys.stderr) - raise SystemExit(1) diff --git a/scripts/verify_release_metadata.py b/scripts/verify_release_metadata.py deleted file mode 100644 index 01cb0be..0000000 --- a/scripts/verify_release_metadata.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import annotations - -import json -import sys -from pathlib import Path -from typing import Mapping, Sequence - - -class ReleaseMetadataError(RuntimeError): - """Raised when a release bundle lacks verifiable metadata resources.""" - - -def read_json(path: Path) -> Mapping[str, object]: - try: - loaded = json.loads(path.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: - raise ReleaseMetadataError(f"Could not read CycloneDX SBOM {path}: {error}") from error - if not isinstance(loaded, dict): - raise ReleaseMetadataError(f"CycloneDX SBOM {path} must contain a JSON object") - return loaded - - -def required_text_resource(path: Path) -> str: - if not path.is_file(): - raise ReleaseMetadataError(f"Release bundle is missing required resource: {path}") - try: - content = path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError) as error: - raise ReleaseMetadataError(f"Could not read required resource {path}: {error}") from error - if not content.strip(): - raise ReleaseMetadataError(f"Release bundle resource is empty: {path}") - return content - - -def metadata_component(sbom: Mapping[str, object]) -> Mapping[str, object]: - metadata = sbom.get("metadata") - if not isinstance(metadata, dict): - raise ReleaseMetadataError("CycloneDX SBOM does not define metadata") - component = metadata.get("component") - if not isinstance(component, dict): - raise ReleaseMetadataError("CycloneDX SBOM does not define a metadata component") - return component - - -def verify_release_metadata(application_path: Path, sbom_path: Path, release_version: str) -> None: - resource_directory = application_path / "Contents" / "Resources" - bundle_sbom_path = resource_directory / "BOM.cdx.json" - if not sbom_path.is_file(): - raise ReleaseMetadataError(f"Release SBOM does not exist: {sbom_path}") - if not bundle_sbom_path.is_file(): - raise ReleaseMetadataError(f"Release bundle is missing its SBOM: {bundle_sbom_path}") - if sbom_path.read_bytes() != bundle_sbom_path.read_bytes(): - raise ReleaseMetadataError("Release SBOM differs from Contents/Resources/BOM.cdx.json") - - sbom = read_json(sbom_path) - if sbom.get("bomFormat") != "CycloneDX": - raise ReleaseMetadataError("Release SBOM does not declare CycloneDX format") - if sbom.get("specVersion") != "1.6": - raise ReleaseMetadataError("Release SBOM does not declare CycloneDX 1.6") - serial_number = sbom.get("serialNumber") - if not isinstance(serial_number, str) or not serial_number: - raise ReleaseMetadataError("Release SBOM does not define a CycloneDX serial number for attestation") - component = metadata_component(sbom) - if component.get("name") != "ios-developer-toolkit": - raise ReleaseMetadataError("Release SBOM metadata does not identify ios-developer-toolkit") - if component.get("version") != release_version: - raise ReleaseMetadataError( - f"Release SBOM version {component.get('version')!r} does not match {release_version!r}" - ) - if "file://" in sbom_path.read_text(encoding="utf-8"): - raise ReleaseMetadataError("Release SBOM contains a local file URL") - - licenses_directory = resource_directory / "Licenses" - required_text_resource(licenses_directory / "IOS_DEVELOPER_TOOLKIT_LICENSE.txt") - notices = required_text_resource(licenses_directory / "THIRD_PARTY_NOTICES.md") - source_availability = required_text_resource(licenses_directory / "SOURCE_AVAILABILITY.md") - inventory = required_text_resource(licenses_directory / "ThirdPartyPackages" / "THIRD_PARTY_PACKAGES.md") - if "pymobiledevice3" not in notices: - raise ReleaseMetadataError("Third-party notices do not identify pymobiledevice3") - if "pymobiledevice3" not in inventory or "Nuitka" not in inventory: - raise ReleaseMetadataError("Generated package inventory is missing release-critical dependencies") - if "pymobiledevice3" not in source_availability: - raise ReleaseMetadataError("Source-availability statement does not identify pymobiledevice3") - - -def main(arguments: Sequence[str]) -> int: - if len(arguments) != 4: - raise ReleaseMetadataError( - "Usage: verify_release_metadata.py APPLICATION_PATH SBOM_PATH RELEASE_VERSION" - ) - verify_release_metadata( - Path(arguments[1]).resolve(), - Path(arguments[2]).resolve(), - arguments[3], - ) - print("Release metadata verification passed") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv)) diff --git a/tests/test_action_palette.py b/tests/test_action_palette.py deleted file mode 100644 index 999dc9e..0000000 --- a/tests/test_action_palette.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -import unittest - -from ios_developer_toolkit.action_palette import ( - ActionPaletteError, - action_palette_entry, - filter_action_palette, - validate_action_palette, -) - - -class ActionPaletteTests(unittest.TestCase): - def setUp(self) -> None: - self.entries = ( - action_palette_entry( - "navigate:Live Logs", - "Open Live Logs", - "Workspace", - "Open independent logging streams.", - ("unified", "syslog", "oslog"), - ), - action_palette_entry( - "preset:lockdown", - "Choose Lockdown overview", - "Guided command", - "Prepare the read-only Lockdown preset for review.", - ("device", "pairing"), - ), - action_palette_entry( - "utility:session-activity", - "Open Session Activity", - "Utility", - "Review typed operation results.", - ("history", "manifest"), - ), - ) - - def test_filters_all_terms_across_titles_summaries_and_keywords(self) -> None: - self.assertEqual( - tuple(entry.identifier for entry in filter_action_palette(self.entries, "device pairing")), - ("preset:lockdown",), - ) - self.assertEqual( - tuple(entry.identifier for entry in filter_action_palette(self.entries, "manifest")), - ("utility:session-activity",), - ) - - def test_ranks_title_matches_before_keyword_matches(self) -> None: - matching = filter_action_palette(self.entries, "live") - - self.assertEqual(matching[0].identifier, "navigate:Live Logs") - - def test_rejects_duplicate_or_incomplete_entries(self) -> None: - with self.assertRaises(ActionPaletteError): - validate_action_palette((self.entries[0], self.entries[0])) - with self.assertRaises(ActionPaletteError): - action_palette_entry("", "Missing", "Utility", "Invalid.", ()) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_backup_process.py b/tests/test_backup_process.py deleted file mode 100644 index 297afc3..0000000 --- a/tests/test_backup_process.py +++ /dev/null @@ -1,100 +0,0 @@ -from __future__ import annotations - -import sys -import time -import unittest -from collections.abc import Callable -from pathlib import Path - -from PySide6.QtCore import QCoreApplication - -from ios_developer_toolkit.backup_process import BackupProcessController -from ios_developer_toolkit.backup_protocol import BackupEvent, BackupRequest -from ios_developer_toolkit.qt_process import OperationResult -from ios_developer_toolkit.runtime import ExecutableCommand - - -class BackupProcessControllerTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.application = QCoreApplication.instance() or QCoreApplication(["backup-process-tests"]) - - def test_sends_private_request_over_stdin_and_emits_typed_event(self) -> None: - controller = BackupProcessController(self.application) - events: list[BackupEvent] = [] - results: list[OperationResult] = [] - controller.event_received.connect(events.append) - controller.completed.connect(results.append) - password = "private-smoke-password" - script = ( - "import json,sys; request=json.load(sys.stdin); " - "print(json.dumps({'event':'encryption-state','message':'checked','encrypted':" - "request['require_encryption']}))" - ) - - controller.start( - ExecutableCommand(Path(sys.executable), ("-c", script)), - "status", - BackupRequest("test-device", Path("/tmp"), True, password, False), - {}, - 500, - ) - self._wait_for(lambda: bool(results), 3) - - self.assertEqual(results[0].outcome, "succeeded") - self.assertEqual(events, [BackupEvent("encryption-state", "checked", None, True, None)]) - self.assertNotIn(password, results[0].argv) - self.assertNotIn(password.encode("utf-8"), results[0].stdout) - self.assertFalse(controller.is_running()) - - def test_rejects_malformed_worker_event_and_stops_process(self) -> None: - controller = BackupProcessController(self.application) - results: list[OperationResult] = [] - controller.completed.connect(results.append) - script = "import sys,time; sys.stdin.read(); print('not-json', flush=True); time.sleep(10)" - - controller.start( - ExecutableCommand(Path(sys.executable), ("-c", script)), - "backup", - BackupRequest("test-device", Path("/tmp"), False, "", False), - {}, - 500, - ) - self._wait_for(lambda: bool(results), 3) - - self.assertEqual(results[0].outcome, "failed") - self.assertIsNotNone(results[0].error_message) - self.assertIn("Invalid backup helper event", results[0].error_message or "") - self.assertFalse(controller.is_running()) - - def test_cancels_long_running_worker_once(self) -> None: - controller = BackupProcessController(self.application) - results: list[OperationResult] = [] - controller.completed.connect(results.append) - script = "import sys,time; sys.stdin.read(); time.sleep(10)" - - controller.start( - ExecutableCommand(Path(sys.executable), ("-c", script)), - "backup", - BackupRequest("test-device", Path("/tmp"), False, "", False), - {}, - 500, - ) - self._wait_for(controller.is_running, 1) - controller.cancel() - self._wait_for(lambda: bool(results), 3) - - self.assertEqual(len(results), 1) - self.assertEqual(results[0].outcome, "cancelled") - self.assertFalse(controller.is_running()) - - def _wait_for(self, predicate: Callable[[], bool], timeout_seconds: int) -> None: - deadline = time.monotonic() + timeout_seconds - while not predicate() and time.monotonic() < deadline: - self.application.processEvents() - time.sleep(0.01) - self.application.processEvents() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_capability_matrix.py b/tests/test_capability_matrix.py deleted file mode 100644 index 17d8f23..0000000 --- a/tests/test_capability_matrix.py +++ /dev/null @@ -1,266 +0,0 @@ -from __future__ import annotations - -import json -import os -import shutil -import tempfile -import unittest -from pathlib import Path - -from ios_developer_toolkit.capability_matrix import ( - CapabilityMatrixError, - CapabilityResult, - CapabilityWorkerCompleted, - CapabilityWorkerStarted, - CommandOutcome, - _probe_xcode_tools, - capability_definitions, - capability_state_counts, - command_succeeded, - compact_command_detail, - evaluate_preset_readiness, - lock_state_from_payload, - mounted_image_summary, - parse_capability_worker_event, - preset_capability_identifiers, - result_for, - untested_capability_results, -) -from ios_developer_toolkit.command_catalog import command_presets -from ios_developer_toolkit.device_compatibility import ( - CompatibilityReportEnvironment, - DeviceCompatibilityError, - append_observation, - compatibility_report_mapping, - compatibility_history_path, - create_compatibility_report, - create_observation, - latest_observations, - load_observations, - render_compatibility_markdown, - write_compatibility_json_report, - write_compatibility_markdown_report, -) -from ios_developer_toolkit.models import IOSDevice - - -class CapabilityMatrixTests(unittest.TestCase): - def test_catalog_and_initial_results_are_complete_and_unique(self) -> None: - definitions = capability_definitions() - results = untested_capability_results() - self.assertEqual(len(definitions), len(results)) - self.assertEqual(len({item.identifier for item in definitions}), len(definitions)) - self.assertEqual( - {item.identifier for item in definitions}, - {item.identifier for item in results}, - ) - self.assertTrue(all(item.state == "not-tested" for item in results)) - - def test_counts_every_supported_capability_state_including_attention(self) -> None: - results = ( - result_for("pymobiledevice3", "ready", "ready", "tested"), - result_for("xcode-tools", "attention", "attention", "tested"), - ) - - counts = dict(capability_state_counts(results)) - - self.assertEqual(counts["ready"], 1) - self.assertEqual(counts["attention"], 1) - self.assertEqual(set(counts), {"ready", "attention", "unavailable", "blocked", "not-tested", "not-applicable"}) - - def test_evaluates_command_specific_readiness_without_mutating_matrix_results(self) -> None: - dvt = next(preset for preset in command_presets() if preset.identifier == "dvt-device") - initial = {result.identifier: result for result in untested_capability_results()} - self.assertEqual( - preset_capability_identifiers(dvt), - ("device-connection", "pairing-trust", "developer-mode", "developer-image", "rsd-tunnel", "dvt"), - ) - self.assertEqual(evaluate_preset_readiness(dvt, initial).state, "not-tested") - - ready = dict(initial) - for identifier in preset_capability_identifiers(dvt): - state = "not-applicable" if identifier == "rsd-tunnel" else "ready" - ready[identifier] = result_for(identifier, state, "tested", "evidence") - self.assertEqual(evaluate_preset_readiness(dvt, ready).state, "ready") - - attention = dict(ready) - attention["developer-mode"] = result_for("developer-mode", "attention", "disabled", "tested") - evaluation = evaluate_preset_readiness(dvt, attention) - self.assertEqual(evaluation.state, "needs-attention") - self.assertIn("Enable Settings", evaluation.remediation[0]) - self.assertTrue(all(result.state == "not-tested" for result in initial.values())) - - def test_parses_strict_worker_events(self) -> None: - started = parse_capability_worker_event('{"event":"started","total":11}') - self.assertEqual(started, CapabilityWorkerStarted(total=11)) - result = untested_capability_results()[0] - result_event = parse_capability_worker_event( - json.dumps({"event": "result", "result": result.to_mapping()}) - ) - self.assertIsInstance(result_event, CapabilityResult) - self.assertEqual(result_event, result) - self.assertEqual(parse_capability_worker_event('{"event":"completed"}'), CapabilityWorkerCompleted()) - with self.assertRaises(CapabilityMatrixError): - parse_capability_worker_event('{"event":"started","total":true}') - with self.assertRaises(CapabilityMatrixError): - parse_capability_worker_event('{"event":"unknown"}') - - def test_interprets_mounted_images_and_lock_state_without_mutating_payloads(self) -> None: - mounted_payload = [{"PersonalizedImageType": "DeveloperDiskImage"}] - mounted, evidence = mounted_image_summary(mounted_payload) - self.assertTrue(mounted) - self.assertIn("DeveloperDiskImage", evidence) - self.assertEqual(mounted_payload, [{"PersonalizedImageType": "DeveloperDiskImage"}]) - self.assertEqual(mounted_image_summary([]), (False, "The image mounter returned an empty mounted-image list.")) - unrelated, unrelated_evidence = mounted_image_summary([{"ImageType": "Cryptex1"}]) - self.assertFalse(unrelated) - self.assertIn("No record was identifiable", unrelated_evidence) - self.assertEqual(lock_state_from_payload({"result": {"lockState": "unlocked"}}), "unlocked") - self.assertEqual(lock_state_from_payload({"deviceIsLocked": True}), "locked") - self.assertIsNone(lock_state_from_payload({"state": "unknown"})) - - def test_command_outcome_detects_semantic_failure_and_redacts_identifier(self) -> None: - success = CommandOutcome(("lockdown", "info"), 0, '{"DeviceName":"Test"}', "", False) - semantic_failure = CommandOutcome( - ("developer", "dvt", "device-information"), - 0, - "", - "2026-08-27 worker[123] ERROR Device not found: PRIVATE-UDID", - False, - ) - self.assertTrue(command_succeeded(success)) - self.assertFalse(command_succeeded(semantic_failure)) - detail = compact_command_detail(semantic_failure, "PRIVATE-UDID") - self.assertNotIn("PRIVATE-UDID", detail) - self.assertIn("", detail) - - def test_real_device_observations_are_local_and_keep_only_a_fingerprint(self) -> None: - temporary_directory = Path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, temporary_directory) - history_path = compatibility_history_path(temporary_directory) - device = IOSDevice("PRIVATE-UDID", "Private iPhone", "iPhone14,5", "26.3.1", "23D123", "USB") - result = CapabilityResult( - "device-connection", - "Connection", - "Selected device", - "ready", - "Connected PRIVATE-UDID", - "Observed PRIVATE-UDID over USB", - "No action required", - ) - observation = create_observation("2026-09-14T12:00:00+00:00", device, (result,)) - append_observation(history_path, observation) - persisted = history_path.read_text(encoding="utf-8") - self.assertNotIn("PRIVATE-UDID", persisted) - self.assertNotIn("Private iPhone", persisted) - self.assertEqual(load_observations(history_path), (observation,)) - - def test_latest_real_device_observation_wins_without_mixing_devices(self) -> None: - device_one = IOSDevice("DEVICE-ONE", "One", "iPhone14,5", "26.3.1", "23D123", "USB") - device_two = IOSDevice("DEVICE-TWO", "Two", "iPad14,3", "26.3.1", "23D123", "USB") - first = create_observation("2026-09-14T10:00:00+00:00", device_one, untested_capability_results()) - newer = create_observation("2026-09-14T11:00:00+00:00", device_one, untested_capability_results()) - other = create_observation("2026-09-14T09:00:00+00:00", device_two, untested_capability_results()) - self.assertEqual(latest_observations((first, newer, other)), (other, newer)) - - def test_real_device_observation_rejects_duplicate_capabilities(self) -> None: - device = IOSDevice("DEVICE", "One", "iPhone14,5", "26.3.1", "23D123", "USB") - with self.assertRaises(DeviceCompatibilityError): - create_observation("2026-09-14T10:00:00+00:00", device, (untested_capability_results()[0],) * 2) - - def test_sanitized_compatibility_report_omits_stable_identity_and_private_paths(self) -> None: - device = IOSDevice("PRIVATE-UDID", "Private iPhone", "iPhone14,5", "26.3.1", "23D123", "USB") - result = CapabilityResult( - "developer-image", - "Developer", - "Developer image", - "ready", - "Ready for PRIVATE-UDID", - "Mounted from /Users/julian/Private/DDI for analyst@example.com", - "No action required", - ) - observation = create_observation("2026-09-14T12:00:00+00:00", device, (result,)) - environment = CompatibilityReportEnvironment( - "0.3.4", - "15.6.1", - "arm64", - "3.13.7", - "source-python", - "11.15.1", - "6.9.3", - ) - report = create_compatibility_report( - "2026-09-22T12:00:00+00:00", - environment, - (observation,), - ) - - payload = json.dumps(compatibility_report_mapping(report), sort_keys=True) - markdown = render_compatibility_markdown(report) - - self.assertNotIn("PRIVATE-UDID", payload) - self.assertNotIn("Private iPhone", payload) - self.assertNotIn(observation.device_fingerprint, payload) - self.assertNotIn("/Users/julian", payload) - self.assertNotIn("analyst@example.com", payload) - self.assertIn("", payload) - self.assertIn("", payload) - self.assertIn("iPhone14,5", markdown) - self.assertIn("pymobiledevice3", markdown) - - def test_compatibility_reports_are_owner_only_and_refuse_overwrite(self) -> None: - temporary_directory = Path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, temporary_directory) - device = IOSDevice("DEVICE", "One", "iPad14,3", "26.3.1", "23D123", "USB") - observation = create_observation( - "2026-09-14T12:00:00+00:00", - device, - untested_capability_results(), - ) - environment = CompatibilityReportEnvironment( - "0.3.4", - "15.6.1", - "arm64", - "3.13.7", - "frozen-app", - "11.15.1", - "6.9.3", - ) - report = create_compatibility_report( - "2026-09-22T12:00:00+00:00", - environment, - (observation,), - ) - json_path = write_compatibility_json_report(temporary_directory / "compatibility.json", report) - markdown_path = write_compatibility_markdown_report(temporary_directory / "compatibility.md", report) - - self.assertEqual(os.stat(json_path).st_mode & 0o777, 0o600) - self.assertEqual(os.stat(markdown_path).st_mode & 0o777, 0o600) - self.assertEqual(json.loads(json_path.read_text(encoding="utf-8"))["schema_version"], 1) - self.assertIn("## Observed device 1", markdown_path.read_text(encoding="utf-8")) - with self.assertRaises(DeviceCompatibilityError): - write_compatibility_json_report(json_path, report) - - def test_compatibility_report_requires_completed_observations(self) -> None: - environment = CompatibilityReportEnvironment( - "0.3.4", - "15.6.1", - "arm64", - "3.13.7", - "source-python", - "11.15.1", - "6.9.3", - ) - with self.assertRaises(DeviceCompatibilityError): - create_compatibility_report("2026-09-22T12:00:00+00:00", environment, ()) - - @unittest.skipIf(shutil.which("xcrun") is None, "xcrun is unavailable") - def test_xcode_tool_probe_uses_the_executable_command_wrapper(self) -> None: - result = _probe_xcode_tools() - - self.assertEqual(result.identifier, "xcode-tools") - self.assertIn(result.state, ("ready", "attention")) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_collection_process.py b/tests/test_collection_process.py deleted file mode 100644 index c860328..0000000 --- a/tests/test_collection_process.py +++ /dev/null @@ -1,166 +0,0 @@ -from __future__ import annotations - -import sys -import time -import unittest -from collections.abc import Callable -from pathlib import Path - -from PySide6.QtCore import QCoreApplication - -from ios_developer_toolkit.collection_process import CollectionProcessController -from ios_developer_toolkit.collection_protocol import CollectionEvent -from ios_developer_toolkit.qt_process import OperationResult -from ios_developer_toolkit.runtime import ExecutableCommand - - -class CollectionProcessControllerTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.application = QCoreApplication.instance() or QCoreApplication(["collection-process-tests"]) - - def test_reassembles_fragmented_json_event_and_drains_terminal_output(self) -> None: - controller = CollectionProcessController(self.application) - events: list[CollectionEvent] = [] - results: list[OperationResult] = [] - controller.event_received.connect(events.append) - controller.completed.connect(results.append) - first = '{"event":"case-created","message":"created",' - second = '"timestamp":"2026-09-22T00:00:00+00:00","path":"/tmp/toolkit-case"}' - script = ( - f"import sys,time; sys.stdout.write({first!r}); sys.stdout.flush(); time.sleep(0.05); " - f"sys.stdout.write({second!r})" - ) - - controller.start( - ExecutableCommand(Path(sys.executable), ("-c", script)), - (), - {}, - 3_000, - ) - self._wait_for(lambda: bool(results), 3) - - self.assertEqual(results[0].outcome, "succeeded") - self.assertEqual(len(events), 1) - self.assertEqual(events[0].event, "case-created") - self.assertEqual(events[0].path, Path("/tmp/toolkit-case")) - self.assertEqual(results[0].stdout, (first + second).encode("utf-8")) - - def test_cancel_waits_for_case_finalization_event(self) -> None: - controller = CollectionProcessController(self.application) - events: list[CollectionEvent] = [] - results: list[OperationResult] = [] - controller.event_received.connect(events.append) - controller.completed.connect(results.append) - script = """ -import json -import signal -import time - -stop = False - -def request_stop(signum, frame): - global stop - del signum, frame - stop = True - -signal.signal(signal.SIGTERM, request_stop) -print(json.dumps({"event":"case-created","message":"created","timestamp":"2026-09-22T00:00:00+00:00","path":"/tmp/toolkit-case"}), flush=True) -while not stop: - time.sleep(0.01) -print(json.dumps({"event":"case-finished","message":"finalized","timestamp":"2026-09-22T00:00:01+00:00","path":"/tmp/toolkit-case","status":"cancelled","failures":0}), flush=True) -""" - - controller.start( - ExecutableCommand(Path(sys.executable), ("-c", script)), - (), - {}, - 3_000, - ) - self._wait_for(lambda: len(events) == 1, 3) - controller.cancel() - self._wait_for(lambda: bool(results), 3) - - self.assertEqual(results[0].outcome, "cancelled") - self.assertEqual([event.event for event in events], ["case-created", "case-finished"]) - self.assertEqual(events[-1].status, "cancelled") - self.assertFalse(controller.is_running()) - - def test_protocol_failure_requests_finalization_and_preserves_root_cause(self) -> None: - controller = CollectionProcessController(self.application) - events: list[CollectionEvent] = [] - results: list[OperationResult] = [] - controller.event_received.connect(events.append) - controller.completed.connect(results.append) - script = """ -import json -import signal -import time - -stop = False - -def request_stop(signum, frame): - global stop - del signum, frame - stop = True - -signal.signal(signal.SIGTERM, request_stop) -print("not-json", flush=True) -while not stop: - time.sleep(0.01) -print(json.dumps({"event":"case-finished","message":"finalized","timestamp":"2026-09-22T00:00:01+00:00","path":"/tmp/toolkit-case","status":"cancelled","failures":1}), flush=True) -""" - - controller.start( - ExecutableCommand(Path(sys.executable), ("-c", script)), - (), - {}, - 3_000, - ) - self._wait_for(lambda: bool(results), 3) - - self.assertEqual(results[0].outcome, "failed") - self.assertIn("Invalid collector event", results[0].error_message or "") - self.assertEqual([event.event for event in events], ["case-finished"]) - self.assertFalse(controller.is_running()) - - def test_forces_stop_when_finalization_deadline_expires(self) -> None: - controller = CollectionProcessController(self.application) - events: list[CollectionEvent] = [] - results: list[OperationResult] = [] - controller.event_received.connect(events.append) - controller.completed.connect(results.append) - script = """ -import json -import signal -import time - -signal.signal(signal.SIGTERM, signal.SIG_IGN) -print(json.dumps({"event":"case-created","message":"created","timestamp":"2026-09-22T00:00:00+00:00","path":"/tmp/toolkit-case"}), flush=True) -time.sleep(10) -""" - - controller.start( - ExecutableCommand(Path(sys.executable), ("-c", script)), - (), - {}, - 100, - ) - self._wait_for(lambda: len(events) == 1, 3) - controller.cancel() - self._wait_for(lambda: bool(results), 3) - - self.assertEqual(results[0].outcome, "timed-out") - self.assertIn("finalization", results[0].error_message or "") - self.assertFalse(controller.is_running()) - - def _wait_for(self, predicate: Callable[[], bool], timeout_seconds: int) -> None: - deadline = time.monotonic() + timeout_seconds - while not predicate() and time.monotonic() < deadline: - self.application.processEvents() - time.sleep(0.01) - self.application.processEvents() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_core.py b/tests/test_core.py deleted file mode 100644 index 7282f70..0000000 --- a/tests/test_core.py +++ /dev/null @@ -1,808 +0,0 @@ -from __future__ import annotations - -import json -import plistlib -import shutil -import subprocess -import tempfile -import unittest -import zipfile -from datetime import datetime, timezone -from pathlib import Path - -from ios_developer_toolkit.action_safety import advanced_action_safety, confirmation_phrase, guided_action_safety -from ios_developer_toolkit.backup_protocol import BackupRequestError, parse_backup_event, parse_backup_request -from ios_developer_toolkit.catalog import is_potentially_mutating, snapshot_commands -from ios_developer_toolkit.command_catalog import ( - CommandCatalogError, - command_presets, - manpage_entries, - preset_by_identifier, - render_preset_arguments, -) -from ios_developer_toolkit.command_drift import ( - HelpRouteProbe, - evaluate_command_drift, - expected_option_tokens, - help_routes_for_presets, -) -from ios_developer_toolkit.case_workflow import CaseWorkflowError, create_guided_case, validate_collection_case -from ios_developer_toolkit.connection_diagnostics import ( - devices_connection_diagnostic, - failed_connection_diagnostic, - launch_failed_connection_diagnostic, - malformed_output_connection_diagnostic, - process_error_connection_diagnostic, - timed_out_connection_diagnostic, -) -from ios_developer_toolkit.collector import safe_udid_fragment -from ios_developer_toolkit.demo_mode import DEMO_DEVICE_IDENTIFIER, demo_connection_banner, demo_device -from ios_developer_toolkit.installed_apps import InstalledAppsDataError, format_byte_count, parse_installed_apps_json -from ios_developer_toolkit.ipa_inspector import ( - IPAInspectionError, - inspect_ipa, - read_archive_metadata, - validate_bundle_identifier, -) -from ios_developer_toolkit.local_ddi import parse_attached_image -from ios_developer_toolkit.location_lab import ( - Coordinates, - LocationLabError, - add_saved_location, - build_route, - clear_location_arguments, - coordinates_to_map_fractions, - inspect_gpx, - map_fractions_to_coordinates, - move_coordinates, - parse_location_input, - parse_route_waypoints, - parse_saved_locations, - play_location_arguments, - set_location_arguments, - validate_coordinates, -) -from ios_developer_toolkit.live_logs import ( - LiveLogInvestigationReport, - LiveLogError, - append_finding, - annotation_path_for, - compile_line_filter, - create_finding, - parse_finding_tags, - render_investigation_report, - create_spool_paths, - line_matches, - sha256_file, - stream_spec, -) -from ios_developer_toolkit.models import DeviceDataError, parse_devices_json -from ios_developer_toolkit.support_bundle import ( - SupportBundleContext, - SupportBundleError, - SupportStatus, - create_sanitized_support_bundle, -) -from ios_developer_toolkit.ufade_connector import ( - UFADEValidationError, - checkout_python_path, - developer_images_are_available, - macos_setup_commands, - parse_python_version, - validate_ufade_checkout, -) -from ios_developer_toolkit.validation import output_indicates_failure - - -class DeviceParsingTests(unittest.TestCase): - def test_parses_current_usbmux_shape(self) -> None: - devices = parse_devices_json( - """[ - { - "Identifier": "00008110-001122334455001E", - "DeviceName": "Research iPhone", - "ProductType": "iPhone14,5", - "ProductVersion": "26.3.1", - "BuildVersion": "23D123", - "ConnectionType": "USB", - "IgnoredFutureField": true - } - ]""" - ) - self.assertEqual(len(devices), 1) - self.assertEqual(devices[0].identifier, "00008110-001122334455001E") - self.assertEqual(devices[0].product_version, "26.3.1") - - def test_rejects_missing_identifier(self) -> None: - with self.assertRaises(DeviceDataError): - parse_devices_json('[{"DeviceName": "Unnamed"}]') - - -class DemoModeTests(unittest.TestCase): - def test_simulated_device_is_visibly_labeled_and_never_looks_like_usbmux_data(self) -> None: - device = demo_device() - self.assertEqual(device.identifier, DEMO_DEVICE_IDENTIFIER) - self.assertIn("simulated", device.name) - self.assertEqual(device.connection_type, "Demo") - self.assertTrue(demo_connection_banner().startswith("DEMO MODE")) - - -class CommandPolicyTests(unittest.TestCase): - def test_read_commands_do_not_require_mutation_confirmation(self) -> None: - self.assertFalse(is_potentially_mutating(("version",))) - self.assertFalse(is_potentially_mutating(("bonjour", "rsd"))) - self.assertFalse(is_potentially_mutating(("remote", "browse"))) - self.assertFalse(is_potentially_mutating(("developer", "dvt", "ls", "/"))) - self.assertFalse(is_potentially_mutating(("pcap", "--out", "capture.pcap"))) - - def test_state_changing_commands_require_confirmation(self) -> None: - self.assertTrue(is_potentially_mutating(("profile", "erase-device"))) - self.assertTrue(is_potentially_mutating(("developer", "dvt", "launch", "com.example.app"))) - - def test_collection_catalog_contains_requested_coverage(self) -> None: - identifiers = {spec.identifier for spec in snapshot_commands(True, True)} - self.assertTrue({"dvt-device", "dvt-processes", "dvt-filesystem", "screenshot", "crash-pull"} <= identifiers) - - -class ActionSafetyTests(unittest.TestCase): - def test_classifies_read_only_host_write_device_change_and_high_impact_actions(self) -> None: - self.assertEqual(advanced_action_safety(("apps", "list")).level, "read-only") - self.assertEqual(advanced_action_safety(("pcap", "--out", "/tmp/capture.pcap")).level, "host-write") - self.assertEqual(advanced_action_safety(("apps", "install", "/tmp/application.ipa")).level, "device-change") - self.assertEqual(advanced_action_safety(("restore", "update")).level, "high-impact") - - def test_typed_acknowledgement_binds_to_the_selected_device_suffix(self) -> None: - device_identifier = "00008110-001122334455001E" - self.assertEqual( - confirmation_phrase(guided_action_safety("device-change"), device_identifier), - "RUN 55001E", - ) - self.assertEqual( - confirmation_phrase(advanced_action_safety(("restore", "update")), device_identifier), - "IRREVERSIBLE 55001E", - ) - - -class GuidedCommandCatalogTests(unittest.TestCase): - def test_catalog_has_unique_presets_and_broad_command_families(self) -> None: - presets = command_presets() - identifiers = {preset.identifier for preset in presets} - categories = {preset.category for preset in presets} - self.assertEqual(len(identifiers), len(presets)) - self.assertGreaterEqual(len(presets), 40) - self.assertEqual( - categories, - {"Device Basics", "Apps & Files", "Logging & Capture", "Developer & DVT", "Web & Discovery", "Device Actions"}, - ) - - def test_renders_validated_parameter_without_shell_parsing(self) -> None: - preset = preset_by_identifier("location-set") - arguments = render_preset_arguments(preset, {"latitude": "34.0522", "longitude": "-118.2437"}) - self.assertEqual( - arguments, - ("developer", "dvt", "simulate-location", "set", "--", "34.0522", "-118.2437"), - ) - - def test_rejects_invalid_bundle_and_url_parameters(self) -> None: - with self.assertRaises(CommandCatalogError): - render_preset_arguments(preset_by_identifier("apps-query"), {"bundle_id": "../../unsafe"}) - with self.assertRaises(CommandCatalogError): - render_preset_arguments(preset_by_identifier("open-url"), {"url": "file:///etc/passwd"}) - with self.assertRaises(CommandCatalogError): - render_preset_arguments( - preset_by_identifier("location-set"), - {"latitude": "nan", "longitude": "0"}, - ) - - def test_manpages_cover_every_top_level_group_from_attached_inventory(self) -> None: - paths = {entry.command_path for entry in manpage_entries()} - expected = { - ("activation",), ("afc",), ("amfi",), ("apps",), ("backup2",), ("btlogger",), - ("bonjour",), ("companion",), ("crash",), ("cryptex",), ("developer",), - ("diagnostics",), ("idam",), ("lockdown",), ("mounter",), ("notification",), - ("pcap",), ("power-assertion",), ("processes",), ("profile",), ("provision",), - ("remote",), ("restore",), ("springboard",), ("syslog",), ("usbmux",), - ("webinspector",), ("version",), - } - self.assertTrue(expected <= paths) - - def test_every_preset_has_a_live_help_route_and_no_erase_or_restore_shortcut(self) -> None: - help_paths = {entry.command_path for entry in manpage_entries()} - forbidden_prefixes = (("restore",), ("profile", "erase-device"), ("backup2", "erase-device")) - for preset in command_presets(): - self.assertTrue( - any(path and preset.manpage_path[: len(path)] == path for path in help_paths), - msg=f"missing live help route for {preset.identifier}", - ) - self.assertFalse( - any(preset.argument_template[: len(prefix)] == prefix for prefix in forbidden_prefixes), - msg=f"high-impact shortcut exposed by {preset.identifier}", - ) - - def test_streaming_service_presets_require_explicit_stop_controls(self) -> None: - for identifier in ("dvt-netstat", "core-apps"): - with self.subTest(identifier=identifier): - self.assertTrue(preset_by_identifier(identifier).long_running) - - -class CommandDriftTests(unittest.TestCase): - def test_live_help_evaluation_flags_missing_options_and_routes_without_running_a_preset(self) -> None: - pcap = preset_by_identifier("pcap") - apps = preset_by_identifier("apps-list") - probes = ( - HelpRouteProbe(("pcap",), 0, "Usage: pcap [--capture FILE]", "", None), - HelpRouteProbe(("apps", "list"), 2, "", "No such command", None), - ) - results = evaluate_command_drift((pcap, apps), probes) - self.assertEqual(expected_option_tokens(pcap), ("--out",)) - self.assertEqual(help_routes_for_presets((pcap, apps)), (("pcap",), ("apps", "list"))) - self.assertEqual(results[0].state, "option-mismatch") - self.assertEqual(results[1].state, "route-missing") - - def test_live_help_evaluation_accepts_exact_option_boundaries(self) -> None: - pcap = preset_by_identifier("pcap") - matching = HelpRouteProbe(("pcap",), 0, "Usage: pcap --out=PATH", "", None) - nonmatching = HelpRouteProbe(("pcap",), 0, "Usage: pcap --output=PATH", "", None) - self.assertEqual(evaluate_command_drift((pcap,), (matching,))[0].state, "verified") - self.assertEqual(evaluate_command_drift((pcap,), (nonmatching,))[0].state, "option-mismatch") - - def test_live_help_evaluation_accepts_help_written_to_standard_error(self) -> None: - pcap = next(preset for preset in command_presets() if preset.identifier == "pcap") - probe = HelpRouteProbe(("pcap",), 0, "", "Usage: pcap --out=PATH", None) - - self.assertEqual(evaluate_command_drift((pcap,), (probe,))[0].state, "verified") - - def test_live_help_evaluation_accepts_terminal_styled_options(self) -> None: - pcap = preset_by_identifier("pcap") - styled_help = "Usage: pcap \x1b[36m--\x1b[0m\x1b[36mout\x1b[0m PATH" - probe = HelpRouteProbe(("pcap",), 0, styled_help, "", None) - - self.assertEqual(evaluate_command_drift((pcap,), (probe,))[0].state, "verified") - - -class EvidenceNamingTests(unittest.TestCase): - def test_udid_fragment_is_sanitized_and_bounded(self) -> None: - self.assertEqual(safe_udid_fragment("00008110-001122334455001E"), "22334455001E") - - def test_guided_case_records_authorized_intake_and_validates_target(self) -> None: - temporary_directory = Path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, temporary_directory) - udid = "00008110-001122334455001E" - case_path, intake = create_guided_case( - temporary_directory, - udid, - " Device validation ", - "Authorized release testing.", - True, - ) - self.assertEqual(intake.title, "Device validation") - self.assertEqual(validate_collection_case(case_path, udid), case_path) - self.assertTrue((case_path / "case-intake.json").is_file()) - self.assertTrue((case_path / "snapshots").is_dir()) - - def test_guided_case_requires_authorization_and_matching_target(self) -> None: - temporary_directory = Path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, temporary_directory) - with self.assertRaises(CaseWorkflowError): - create_guided_case(temporary_directory, "TARGET1", "Case", "", False) - case_path, _ = create_guided_case(temporary_directory, "TARGET1", "Case", "", True) - with self.assertRaises(CaseWorkflowError): - validate_collection_case(case_path, "TARGET2") - - def test_guided_case_cannot_be_reused_after_finalization(self) -> None: - temporary_directory = Path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, temporary_directory) - case_path, _ = create_guided_case(temporary_directory, "TARGET1", "Case", "", True) - (case_path / "manifest.json").write_text("{}\n", encoding="utf-8") - with self.assertRaises(CaseWorkflowError): - validate_collection_case(case_path, "TARGET1") - - -class OutputValidationTests(unittest.TestCase): - def test_detects_zero_exit_device_error_text(self) -> None: - output = "2026-08-23 main[123] ERROR Device not found: TEST-DEVICE" - self.assertTrue(output_indicates_failure(output)) - - def test_success_information_is_not_an_error(self) -> None: - output = "INFO DeveloperDiskImage mounted successfully" - self.assertFalse(output_indicates_failure(output)) - - -class ConnectionDiagnosticTests(unittest.TestCase): - def test_reports_each_discovery_outcome_without_raw_device_data(self) -> None: - diagnostics = ( - launch_failed_connection_diagnostic(), - failed_connection_diagnostic(7), - process_error_connection_diagnostic(), - timed_out_connection_diagnostic(), - malformed_output_connection_diagnostic(), - devices_connection_diagnostic(0), - devices_connection_diagnostic(2), - ) - self.assertEqual( - tuple(diagnostic.state for diagnostic in diagnostics), - ( - "launch-failed", - "discovery-failed", - "discovery-failed", - "discovery-timed-out", - "malformed-output", - "no-devices", - "devices-available", - ), - ) - self.assertIn("status 7", diagnostics[1].report()) - self.assertIn("stopped before returning", diagnostics[2].report()) - self.assertIn("Devices available: 2", diagnostics[-1].report()) - self.assertNotIn("Identifier", "\n".join(diagnostic.report() for diagnostic in diagnostics)) - - -class SupportBundleTests(unittest.TestCase): - def test_creates_a_reviewable_zip_without_known_device_or_host_identifiers(self) -> None: - temporary_directory = Path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, temporary_directory) - destination = temporary_directory / "support.zip" - context = SupportBundleContext( - "0.3.1", - "Command Center", - 1, - True, - (("ready", 2), ("not-tested", 8)), - "Verified: 49. Device 00008110-001122334455001E at 192.168.1.8.", - ( - SupportStatus("connection", "Julian iPhone 00008110-001122334455001E"), - SupportStatus("capability_matrix", "Saved in /Users/julian/Private/diagnostics"), - ), - ("00008110-001122334455001E", "Julian iPhone"), - False, - ) - result = create_sanitized_support_bundle(destination, context) - self.assertEqual(result.path, destination) - self.assertEqual( - result.entries, - ("README.txt", "environment.json", "context.json", "command-drift.txt", "SHA256SUMS.json"), - ) - with zipfile.ZipFile(destination) as archive: - combined = "\n".join(archive.read(name).decode("utf-8") for name in archive.namelist()) - self.assertNotIn("00008110-001122334455001E", combined) - self.assertNotIn("Julian iPhone", combined) - self.assertNotIn("192.168.1.8", combined) - self.assertNotIn("/Users/julian", combined) - self.assertIn("", combined) - self.assertIn("", combined) - self.assertIn("", combined) - - def test_refuses_to_overwrite_an_existing_support_zip(self) -> None: - temporary_directory = Path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, temporary_directory) - destination = temporary_directory / "support.zip" - destination.write_bytes(b"existing") - context = SupportBundleContext( - "0.3.1", "Home", 0, False, (), "", (), (), False, - ) - with self.assertRaises(SupportBundleError): - create_sanitized_support_bundle(destination, context) - - -class LocalDDITests(unittest.TestCase): - def test_parses_hdiutil_plist(self) -> None: - payload = plistlib.dumps( - { - "system-entities": [ - {"dev-entry": "/dev/disk99"}, - {"dev-entry": "/dev/disk99s1", "mount-point": "/Volumes/Test DDI"}, - ] - } - ) - attached = parse_attached_image(payload) - self.assertEqual(attached.device_entry, "/dev/disk99s1") - self.assertEqual(attached.mount_point, Path("/Volumes/Test DDI")) - - -class LocationLabTests(unittest.TestCase): - def test_imports_coordinates_and_full_map_links_without_network_resolution(self) -> None: - cases = ( - ("34.0522,-118.2437", Coordinates(34.0522, -118.2437)), - ("geo:37.3349,-122.0090", Coordinates(37.3349, -122.0090)), - ( - "https://maps.apple.com/?ll=51.5007%2C-0.1246", - Coordinates(51.5007, -0.1246), - ), - ( - "https://www.google.com/maps/@35.6586,139.7454,15z", - Coordinates(35.6586, 139.7454), - ), - ( - "https://www.google.com/maps/search/?api=1&query=-33.8568%2C151.2153", - Coordinates(-33.8568, 151.2153), - ), - ) - for payload, expected in cases: - with self.subTest(payload=payload): - self.assertEqual(parse_location_input(payload), expected) - for unsupported in ("https://maps.app.goo.gl/short", "https://maps.apple.com/?q=Coffee"): - with self.subTest(unsupported=unsupported): - with self.assertRaises(LocationLabError): - parse_location_input(unsupported) - - def test_round_trips_coordinates_through_offline_map_fractions(self) -> None: - original = Coordinates(latitude=34.0522, longitude=-118.2437) - horizontal, vertical = coordinates_to_map_fractions(original) - restored = map_fractions_to_coordinates(horizontal, vertical) - self.assertAlmostEqual(restored.latitude, original.latitude, places=12) - self.assertAlmostEqual(restored.longitude, original.longitude, places=12) - with self.assertRaises(LocationLabError): - map_fractions_to_coordinates(1.1, 0.5) - - def test_builds_version_specific_location_commands(self) -> None: - coordinates = Coordinates(latitude=34.0522, longitude=-118.2437) - self.assertEqual( - set_location_arguments("26.3.1", coordinates), - ("developer", "dvt", "simulate-location", "set", "--", "34.0522", "-118.2437"), - ) - self.assertEqual( - clear_location_arguments("16.7.12"), - ("developer", "simulate-location", "clear"), - ) - self.assertEqual( - play_location_arguments("16.7.12", Path("/tmp/route.gpx"), 250, True), - ( - "developer", - "simulate-location", - "play", - str(Path("/tmp/route.gpx").resolve()), - "250", - "--disable-sleep", - ), - ) - - def test_rejects_nonfinite_and_out_of_range_coordinates(self) -> None: - for latitude, longitude in (("nan", "0"), ("91", "0"), ("0", "-181")): - with self.subTest(latitude=latitude, longitude=longitude): - with self.assertRaises(LocationLabError): - validate_coordinates(latitude, longitude) - - def test_inspects_track_points_and_hashes_gpx(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - route = Path(temporary_directory) / "route.gpx" - route.write_text( - """ - - - - - - - """, - encoding="utf-8", - ) - inspection = inspect_gpx(route) - self.assertEqual(inspection.track_point_count, 2) - self.assertEqual(inspection.timed_point_count, 1) - self.assertEqual(inspection.first_point, Coordinates(34.0522, -118.2437)) - self.assertEqual(len(inspection.sha256), 64) - - def test_rejects_gpx_without_track_points(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - route = Path(temporary_directory) / "waypoints.gpx" - route.write_text('', encoding="utf-8") - with self.assertRaises(LocationLabError): - inspect_gpx(route) - - def test_saved_location_schema_is_strict_and_names_are_unique(self) -> None: - locations = parse_saved_locations( - '{"version": 1, "locations": [{"name": "Lab", "latitude": 1.5, "longitude": 2.5}]}' - ) - self.assertEqual(locations[0].name, "Lab") - with self.assertRaises(LocationLabError): - add_saved_location(locations, "lab", Coordinates(3.0, 4.0)) - with self.assertRaises(LocationLabError): - parse_saved_locations('{"version": 1, "locations": [{"name": "Broken", "latitude": "1"}]}') - - def test_builds_bounded_timestamped_ping_pong_route(self) -> None: - waypoints = parse_route_waypoints("34.0522,-118.2437\n34.0523,-118.2436") - route = build_route(waypoints, 5.0, 2, 2, datetime(2026, 8, 24, tzinfo=timezone.utc)) - self.assertGreater(len(route.points), 2) - self.assertAlmostEqual(route.points[0].latitude, route.points[-1].latitude, places=9) - self.assertAlmostEqual(route.points[0].longitude, route.points[-1].longitude, places=9) - self.assertIn("2026-08-24T00:00:00Z", route.gpx_document) - self.assertEqual(route.gpx_document.count(" None: - origin = Coordinates(34.0522, -118.2437) - moved = move_coordinates(origin, 90.0, 100.0) - self.assertEqual(origin, Coordinates(34.0522, -118.2437)) - self.assertAlmostEqual(moved.latitude, origin.latitude, places=4) - self.assertGreater(moved.longitude, origin.longitude) - - def test_rejects_unbounded_generated_route(self) -> None: - with self.assertRaises(LocationLabError): - build_route( - (Coordinates(0.0, 0.0), Coordinates(0.0, 179.0)), - 1.0, - 1, - 20, - datetime(2026, 8, 24, tzinfo=timezone.utc), - ) - - -class LiveLogTests(unittest.TestCase): - def test_stream_catalog_uses_distinct_current_pymobiledevice3_services(self) -> None: - self.assertEqual(stream_spec("unified").arguments, ("syslog", "live", "--format", "json", "--label")) - self.assertEqual(stream_spec("classic").arguments, ("syslog", "live-old")) - self.assertTrue(stream_spec("dvt-oslog").requires_developer_services) - - def test_literal_and_regex_filters_are_explicit(self) -> None: - literal = compile_line_filter("process[1]", False, False) - self.assertTrue(line_matches("PROCESS[1] started", literal)) - self.assertFalse(line_matches("process1 started", literal)) - regex = compile_line_filter(r"error\s+\d+", True, False) - self.assertTrue(line_matches("Error 42", regex)) - with self.assertRaises(LiveLogError): - compile_line_filter("[", True, True) - - def test_spool_paths_are_sanitized_and_keep_structured_extension(self) -> None: - raw, metadata = create_spool_paths(Path("/tmp/logs"), stream_spec("unified"), "device/../../unsafe") - self.assertEqual(raw.parent, Path("/tmp/logs").resolve()) - self.assertEqual(raw.suffix, ".jsonl") - self.assertNotIn("/../", str(raw)) - self.assertEqual(metadata.suffixes, [".meta", ".json"]) - - def test_finding_preserves_selected_text_and_context_separately_from_raw_log(self) -> None: - temporary_directory = Path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, temporary_directory) - raw_path = temporary_directory / "capture.jsonl" - findings_path = annotation_path_for(raw_path) - finding = create_finding( - "Investigate this authentication failure.", - "2026-09-14 authd: failed login", - "unified", - "DEVICE-1", - 384, - "authd", - False, - False, - "lead", - ("authentication", "review"), - ) - append_finding(findings_path, finding) - record = json.loads(findings_path.read_text(encoding="utf-8")) - self.assertEqual(record["note"], "Investigate this authentication failure.") - self.assertEqual(record["raw_bytes_observed"], 384) - self.assertEqual(record["assessment"], "lead") - self.assertEqual(record["tags"], ["authentication", "review"]) - - def test_finding_requires_a_note_and_selection(self) -> None: - with self.assertRaises(LiveLogError): - create_finding("", "line", "unified", "DEVICE-1", 0, "", False, False, "observation", ()) - with self.assertRaises(LiveLogError): - create_finding("note", "", "unified", "DEVICE-1", 0, "", False, False, "observation", ()) - - def test_finding_tags_are_normalized_and_invalid_tags_are_rejected(self) -> None: - self.assertEqual(parse_finding_tags("Auth, network, auth"), ("auth", "network")) - with self.assertRaises(LiveLogError): - parse_finding_tags("contains spaces") - - def test_investigation_report_separates_capture_facts_from_analyst_annotations(self) -> None: - finding = create_finding( - "Correlate with the application crash report.", - "2026-09-14 process[12]: failed request", - "unified", - "DEVICE-1", - 512, - "failed", - False, - False, - "needs-corroboration", - ("network",), - ) - report = LiveLogInvestigationReport( - stream="unified", - stream_title="Unified Logs", - device_name="Research iPhone", - device_identifier="DEVICE-1", - started_at="2026-09-14T00:00:00+00:00", - finished_at="2026-09-14T00:02:00+00:00", - raw_filename="capture.jsonl", - raw_sha256="a" * 64, - raw_bytes=1024, - decoded_lines=7, - investigation_reference="CASE-42", - ) - rendered = render_investigation_report(report, (finding,)) - self.assertIn("## Capture facts", rendered) - self.assertIn("## Analyst findings", rendered) - self.assertIn("not device-generated facts", rendered) - self.assertIn("CASE-42", rendered) - - def test_hashes_raw_log_bytes(self) -> None: - temporary_directory = Path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, temporary_directory) - raw_path = temporary_directory / "capture.log" - raw_path.write_bytes(b"forensic log bytes\n") - self.assertEqual(sha256_file(raw_path), "2f64c1a1b0a217c8dbaca08eeaedee479eedb04f3acafaa8b063c3c77dcf4a86") - - -class IPAInspectionTests(unittest.TestCase): - def test_validates_bundle_identifier_before_uninstall(self) -> None: - self.assertEqual(validate_bundle_identifier("com.example.application"), "com.example.application") - with self.assertRaises(IPAInspectionError): - validate_bundle_identifier("../../unsafe") - - def test_verifies_an_ad_hoc_signed_bundle(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - app_path = root / "Payload" / "Signed.app" - app_path.mkdir(parents=True) - executable_path = app_path / "SignedApp" - shutil.copyfile("/usr/bin/true", executable_path) - executable_path.chmod(0o755) - (app_path / "Info.plist").write_bytes( - plistlib.dumps( - { - "CFBundleName": "Signed App", - "CFBundleIdentifier": "com.example.signed", - "CFBundleShortVersionString": "1.0", - "CFBundleVersion": "1", - "CFBundleExecutable": "SignedApp", - } - ) - ) - subprocess.run( - ["/usr/bin/codesign", "--force", "--sign", "-", str(app_path)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=True, - ) - ipa_path = root / "Signed.ipa" - with zipfile.ZipFile(ipa_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: - for source_path in sorted(app_path.rglob("*")): - if source_path.is_file(): - archive.write(source_path, source_path.relative_to(root)) - inspection = inspect_ipa(ipa_path) - self.assertEqual(inspection.bundle_identifier, "com.example.signed") - self.assertEqual(inspection.signature.status, "valid") - self.assertEqual(inspection.provisioning.status, "absent") - - def test_reads_metadata_and_reports_invalid_synthetic_signature(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - ipa_path = Path(temporary_directory) / "Example.ipa" - info_plist = plistlib.dumps( - { - "CFBundleDisplayName": "Example App", - "CFBundleIdentifier": "com.example.app", - "CFBundleShortVersionString": "1.2.3", - "CFBundleVersion": "45", - "CFBundleExecutable": "ExampleApp", - "MinimumOSVersion": "17.0", - } - ) - with zipfile.ZipFile(ipa_path, "w") as archive: - archive.writestr("Payload/Example.app/Info.plist", info_plist) - archive.writestr("Payload/Example.app/ExampleApp", b"not-a-mach-o") - archive.writestr("Payload/Example.app/_CodeSignature/CodeResources", b"not-a-signature") - metadata = read_archive_metadata(ipa_path) - inspection = inspect_ipa(ipa_path) - self.assertEqual(metadata.bundle_identifier, "com.example.app") - self.assertEqual(inspection.app_name, "Example App") - self.assertEqual(inspection.provisioning.status, "absent") - self.assertEqual(inspection.signature.status, "invalid") - - def test_rejects_unsafe_archive_path(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - ipa_path = Path(temporary_directory) / "Unsafe.ipa" - with zipfile.ZipFile(ipa_path, "w") as archive: - archive.writestr("../outside", b"unsafe") - with self.assertRaises(IPAInspectionError): - read_archive_metadata(ipa_path) - - -class InstalledAppsTests(unittest.TestCase): - def test_parses_and_sorts_app_inventory(self) -> None: - apps = parse_installed_apps_json( - """{ - "com.example.zeta": { - "CFBundleIdentifier": "com.example.zeta", - "CFBundleDisplayName": "Zeta", - "CFBundleShortVersionString": "2.0", - "CFBundleVersion": "20", - "ApplicationType": "User", - "StaticDiskUsage": 1500000, - "DynamicDiskUsage": 500000 - }, - "com.apple.alpha": { - "CFBundleIdentifier": "com.apple.alpha", - "CFBundleName": "Alpha", - "ApplicationType": "System" - } - }""" - ) - self.assertEqual(tuple(app.name for app in apps), ("Alpha", "Zeta")) - self.assertEqual(apps[1].total_bytes, 2000000) - self.assertEqual(format_byte_count(apps[1].total_bytes), "2.0 MB") - - def test_rejects_mismatched_bundle_identifier(self) -> None: - with self.assertRaises(InstalledAppsDataError): - parse_installed_apps_json( - '{"com.example.expected": {"CFBundleIdentifier": "com.example.different"}}' - ) - - -class BackupWorkerParsingTests(unittest.TestCase): - def test_parses_secure_backup_request(self) -> None: - request = parse_backup_request( - """{ - "udid": "00008110-001122334455001E", - "destination": "/tmp/iOS Backups", - "require_encryption": true, - "new_password": "local-backup-secret", - "full": false - }""" - ) - self.assertEqual(request.destination, Path("/tmp/iOS Backups").resolve()) - self.assertTrue(request.require_encryption) - self.assertEqual(request.new_password, "local-backup-secret") - - def test_rejects_relative_backup_destination(self) -> None: - with self.assertRaises(BackupRequestError): - parse_backup_request( - '{"udid":"device","destination":"relative","require_encryption":false,"new_password":"","full":false}' - ) - - def test_parses_backup_progress_event(self) -> None: - event = parse_backup_event( - '{"event":"progress","message":"Backup progress: 42%","percent":42}' - ) - self.assertEqual(event.percent, 42) - self.assertIsNone(event.encrypted) - - -class UFADEConnectorTests(unittest.TestCase): - def test_builds_isolated_macos_setup_and_checkout_python_path(self) -> None: - commands = macos_setup_commands() - self.assertIn("--recurse-submodules", commands[1]) - self.assertIn("python3.11 -m venv .venv", commands) - self.assertEqual(commands[-1], ".venv/bin/python -m pip install -r requirements.txt") - self.assertEqual( - checkout_python_path(Path("/Applications/UFADE")), - Path("/Applications/UFADE/.venv/bin/python"), - ) - - def test_reports_whether_developer_image_submodule_is_populated(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - checkout = Path(temporary_directory) - self.assertFalse(developer_images_are_available(checkout)) - (checkout / "ufade_developer" / "Developer").mkdir(parents=True) - self.assertTrue(developer_images_are_available(checkout)) - - def test_validates_external_gpl_checkout_and_reads_version(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - checkout = Path(temporary_directory) - (checkout / "ufade.py").write_text('u_version = "1.0.4"\n', encoding="utf-8") - (checkout / "LICENSE").write_text( - "GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007\n", - encoding="utf-8", - ) - (checkout / "requirements.txt").write_text("pymobiledevice3==7.8.3\n", encoding="utf-8") - resolved_checkout, script, version = validate_ufade_checkout(checkout) - self.assertEqual(resolved_checkout, checkout.resolve()) - self.assertEqual(script, checkout.resolve() / "ufade.py") - self.assertEqual(version, "1.0.4") - - def test_rejects_checkout_without_expected_license(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - checkout = Path(temporary_directory) - (checkout / "ufade.py").write_text('u_version = "1.0.4"\n', encoding="utf-8") - (checkout / "LICENSE").write_text("MIT License\n", encoding="utf-8") - (checkout / "requirements.txt").write_text("pymobiledevice3==7.8.3\n", encoding="utf-8") - with self.assertRaises(UFADEValidationError): - validate_ufade_checkout(checkout) - - def test_parses_exact_python_version_triplet(self) -> None: - self.assertEqual(parse_python_version("3.11.9"), (3, 11, 9)) - with self.assertRaises(UFADEValidationError): - parse_python_version("Python 3.11.9") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_device_scanner.py b/tests/test_device_scanner.py deleted file mode 100644 index 24bdf73..0000000 --- a/tests/test_device_scanner.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -import json -import sys -import time -import unittest -from collections.abc import Callable -from pathlib import Path - -from PySide6.QtCore import QCoreApplication - -from ios_developer_toolkit.app import DeviceScanner -from ios_developer_toolkit.models import IOSDevice -from ios_developer_toolkit.runtime import ExecutableCommand - - -class DeviceScannerTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.application = QCoreApplication.instance() or QCoreApplication(["device-scanner-tests"]) - - def test_consumes_valid_discovery_json_after_child_exit(self) -> None: - payload = json.dumps( - [ - { - "Identifier": "00008110-001122334455001E", - "DeviceName": "Test iPhone", - "ProductType": "iPhone14,5", - "ProductVersion": "26.3.1", - "BuildVersion": "23D123", - "ConnectionType": "USB", - } - ] - ) - scanner = DeviceScanner( - ExecutableCommand(Path(sys.executable), ("-c", f"import sys; sys.stdout.write({payload!r})")) - ) - observed_devices: list[tuple[IOSDevice, ...]] = [] - observed_errors: list[str] = [] - observed_diagnostics: list[object] = [] - scanner.devices_changed.connect(observed_devices.append) - scanner.scan_error.connect(observed_errors.append) - scanner.diagnostic_changed.connect(observed_diagnostics.append) - scanner.scan() - self._wait_for(lambda: bool(observed_devices), 3) - - self.assertEqual(observed_errors, []) - self.assertEqual(len(observed_devices), 1) - self.assertEqual(observed_devices[0][0].identifier, "00008110-001122334455001E") - self.assertEqual(len(observed_diagnostics), 1) - self.assertEqual(observed_diagnostics[0].state, "devices-available") - self.assertEqual(observed_diagnostics[0].device_count, 1) - - def test_reports_failed_process_launch_without_exposing_qprocess_details(self) -> None: - scanner = DeviceScanner(ExecutableCommand(Path("/missing-ios-toolkit-pymobiledevice3"), ())) - observed_errors: list[str] = [] - observed_diagnostics: list[object] = [] - scanner.scan_error.connect(observed_errors.append) - scanner.diagnostic_changed.connect(observed_diagnostics.append) - scanner.scan() - - deadline = time.monotonic() + 3 - while not observed_diagnostics and time.monotonic() < deadline: - self.application.processEvents() - time.sleep(0.01) - - self.assertEqual(len(observed_diagnostics), 1) - self.assertEqual(observed_diagnostics[0].state, "launch-failed") - self.assertEqual(observed_errors, ["The usbmux discovery process could not start"]) - - def _wait_for(self, predicate: Callable[[], bool], timeout_seconds: int) -> None: - deadline = time.monotonic() + timeout_seconds - while not predicate() and time.monotonic() < deadline: - self.application.processEvents() - time.sleep(0.01) - self.application.processEvents() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_external_tools.py b/tests/test_external_tools.py deleted file mode 100644 index 1b238f2..0000000 --- a/tests/test_external_tools.py +++ /dev/null @@ -1,114 +0,0 @@ -from __future__ import annotations - -import os -import stat -import tempfile -import unittest -from pathlib import Path - -from ios_developer_toolkit.external_tools import ( - ExternalToolInstallation, - ExternalToolValidationError, - discover_external_tool_executables, - external_tool_command, - external_tool_environment, - external_tool_spec, - external_tool_specs, - inspect_external_tool_executable, - parse_external_tool_version, - validate_external_tool_installation, -) - - -class ExternalToolTests(unittest.TestCase): - def test_catalog_has_unique_current_adapters(self) -> None: - specs = external_tool_specs() - self.assertEqual(tuple(spec.identifier for spec in specs), ("go-ios", "idb", "ipsw")) - self.assertEqual(len({spec.executable_name for spec in specs}), len(specs)) - self.assertTrue(all(spec.license_name == "MIT" for spec in specs)) - self.assertTrue(all(spec.version_arguments and spec.probe_arguments for spec in specs)) - - def test_discovers_expected_executable_and_records_provenance(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - spec = external_tool_spec("go-ios") - executable_path = root / spec.executable_name - executable_path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") - executable_path.chmod(executable_path.stat().st_mode | stat.S_IXUSR) - - candidates = discover_external_tool_executables(spec, root, str(root)) - executable = inspect_external_tool_executable(spec, executable_path) - - self.assertEqual(candidates, (executable_path.resolve(),)) - self.assertEqual(executable.spec_identifier, "go-ios") - self.assertEqual(len(executable.sha256), 64) - - def test_rejects_relative_path_and_changed_executable(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - spec = external_tool_spec("ipsw") - with self.assertRaises(ExternalToolValidationError): - inspect_external_tool_executable(spec, Path("ipsw")) - - executable_path = root / spec.executable_name - executable_path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") - executable_path.chmod(0o700) - executable = inspect_external_tool_executable(spec, executable_path) - installation = ExternalToolInstallation(executable, "3.1.723") - executable_path.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") - with self.assertRaises(ExternalToolValidationError): - validate_external_tool_installation(spec, installation) - - def test_parses_each_upstream_version_or_build_shape(self) -> None: - self.assertEqual( - parse_external_tool_version( - external_tool_spec("go-ios"), - 'diagnostic line\n{"version":"1.3.2"}\n', - ), - "1.3.2", - ) - self.assertEqual( - parse_external_tool_version( - external_tool_spec("idb"), - '{"build_date":"2026-09-22","build_time":"11:12:36"}', - ), - "build 2026-09-22 11:12:36", - ) - self.assertEqual( - parse_external_tool_version( - external_tool_spec("ipsw"), - "Version: 3.1.723, BuildCommit: abc123\n", - ), - "3.1.723", - ) - with self.assertRaises(ExternalToolValidationError): - parse_external_tool_version(external_tool_spec("idb"), "idb") - - def test_command_removes_tool_routing_and_secret_environment(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - spec = external_tool_spec("idb") - executable_path = root / spec.executable_name - executable_path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") - executable_path.chmod(0o700) - executable = inspect_external_tool_executable(spec, executable_path) - command = external_tool_command(spec, executable) - environment = external_tool_environment( - { - "PATH": os.environ.get("PATH", ""), - "IDB_COMPANION": "remote.example:1234", - "IDB_UDID": "sensitive-target", - }, - spec, - ) - - self.assertEqual(command.program, Path("/usr/bin/env")) - self.assertIn("IDB_COMPANION", command.prefix_arguments) - self.assertIn("IDB_UDID", command.prefix_arguments) - self.assertEqual(command.prefix_arguments[-1], str(executable.path)) - self.assertNotIn("IDB_COMPANION", environment) - self.assertNotIn("IDB_UDID", environment) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_interactive_process.py b/tests/test_interactive_process.py deleted file mode 100644 index 2d50943..0000000 --- a/tests/test_interactive_process.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -import sys -import time -import unittest -from collections.abc import Callable -from pathlib import Path - -from PySide6.QtCore import QCoreApplication - -from ios_developer_toolkit.interactive_process import InteractiveProcessController -from ios_developer_toolkit.qt_process import OperationResult -from ios_developer_toolkit.runtime import ExecutableCommand - - -class InteractiveProcessControllerTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.application = QCoreApplication.instance() or QCoreApplication(["interactive-process-tests"]) - - def test_returns_terminal_output_and_status(self) -> None: - controller = InteractiveProcessController(self.application) - results: list[OperationResult] = [] - controller.completed.connect(results.append) - - controller.start( - ExecutableCommand(Path(sys.executable), ()), - ("-c", "import sys; sys.stdout.write('ready'); sys.stderr.write('notice')"), - {}, - Path.cwd(), - 500, - ) - self._wait_for(lambda: bool(results), 3) - - self.assertEqual(len(results), 1) - self.assertEqual(results[0].outcome, "succeeded") - self.assertEqual(results[0].stdout, b"ready") - self.assertEqual(results[0].stderr, b"notice") - self.assertFalse(controller.is_running()) - - def test_cancels_streaming_command_once(self) -> None: - controller = InteractiveProcessController(self.application) - results: list[OperationResult] = [] - controller.completed.connect(results.append) - - controller.start( - ExecutableCommand(Path(sys.executable), ()), - ("-c", "import time; time.sleep(10)"), - {}, - Path.cwd(), - 500, - ) - controller.cancel() - controller.cancel() - self._wait_for(lambda: bool(results), 3) - - self.assertEqual(len(results), 1) - self.assertEqual(results[0].outcome, "cancelled") - self.assertFalse(controller.is_running()) - - def test_reports_launch_failure(self) -> None: - controller = InteractiveProcessController(self.application) - results: list[OperationResult] = [] - controller.completed.connect(results.append) - - controller.start(ExecutableCommand(Path("/missing/interactive-tool"), ()), (), {}, Path.cwd(), 500) - self._wait_for(lambda: bool(results), 3) - - self.assertEqual(len(results), 1) - self.assertEqual(results[0].outcome, "launch-failed") - self.assertTrue(results[0].error_message) - self.assertFalse(controller.is_running()) - - def _wait_for(self, predicate: Callable[[], bool], timeout_seconds: int) -> None: - deadline = time.monotonic() + timeout_seconds - while not predicate() and time.monotonic() < deadline: - self.application.processEvents() - time.sleep(0.01) - self.application.processEvents() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_macos_bundle.py b/tests/test_macos_bundle.py deleted file mode 100644 index f1c7b1b..0000000 --- a/tests/test_macos_bundle.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -import tempfile -import unittest -from pathlib import Path - -from scripts.verify_macos_bundle import ( - MACH_O_MAGICS, - MacOSBundleValidationError, - MachORecord, - is_mach_o, - parse_minimum_macos_versions, - validate_mach_o_records, - version_parts, -) - - -class MacOSBundleValidationTests(unittest.TestCase): - def test_parses_modern_and_legacy_floors_for_every_architecture(self) -> None: - output = """ -Load command 9 - cmd LC_BUILD_VERSION - cmdsize 32 - platform 1 - minos 11.0 - sdk 15.0 -Load command 8 - cmd LC_VERSION_MIN_MACOSX - cmdsize 16 - version 10.15 - sdk 14.4 -""" - - self.assertEqual(parse_minimum_macos_versions(output), ("11.0", "10.15")) - - def test_rejects_newer_floor_or_missing_native_architecture(self) -> None: - compatible = MachORecord(Path("compatible.dylib"), ("arm64", "x86_64"), ("12.0", "13.0")) - validate_mach_o_records((compatible,), "arm64", "13.0") - - with self.assertRaisesRegex(MacOSBundleValidationError, "requires macOS 15.0"): - validate_mach_o_records( - (MachORecord(Path("newer.dylib"), ("arm64",), ("15.0",)),), - "arm64", - "13.0", - ) - with self.assertRaisesRegex(MacOSBundleValidationError, "does not contain required architecture x86_64"): - validate_mach_o_records((compatible,), "x86_64h", "13.0") - - def test_recognizes_mach_o_magic_without_treating_text_as_native_code(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - binary = root / "binary" - text = root / "text" - binary.write_bytes(next(iter(MACH_O_MAGICS)) + b"payload") - text.write_text("#!/bin/sh\n", encoding="utf-8") - - self.assertTrue(is_mach_o(binary)) - self.assertFalse(is_mach_o(text)) - - def test_parses_and_compares_three_component_versions(self) -> None: - self.assertEqual(version_parts("13"), (13, 0, 0)) - self.assertEqual(version_parts("13.0.1"), (13, 0, 1)) - with self.assertRaisesRegex(MacOSBundleValidationError, "Invalid macOS version"): - version_parts("13.x") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_mvt_connector.py b/tests/test_mvt_connector.py deleted file mode 100644 index cbc633a..0000000 --- a/tests/test_mvt_connector.py +++ /dev/null @@ -1,128 +0,0 @@ -from __future__ import annotations - -import os -import plistlib -import stat -import tempfile -import unittest -from pathlib import Path - -from ios_developer_toolkit.mvt_connector import ( - MVTAnalysisRequest, - MVTBackup, - MVTInstallation, - MVTValidationError, - create_mvt_analysis_request, - inspect_mvt_backup, - inspect_mvt_executable, - mvt_analysis_arguments, - mvt_command, - mvt_environment, - parse_mvt_version_output, - validate_mvt_output, -) - - -class MVTConnectorTests(unittest.TestCase): - def test_inspects_executable_and_removes_inherited_secret_routes(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - executable_path = root / "mvt-ios" - executable_path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") - executable_path.chmod(executable_path.stat().st_mode | stat.S_IXUSR) - executable = inspect_mvt_executable(executable_path) - command = mvt_command(executable) - - self.assertEqual(len(executable.sha256), 64) - self.assertEqual(command.program, Path("/usr/bin/env")) - self.assertIn("MVT_IOS_BACKUP_PASSWORD", command.prefix_arguments) - self.assertEqual(command.prefix_arguments[-1], str(executable_path.resolve())) - - def test_parses_current_version_output_and_rejects_unrecognized_output(self) -> None: - self.assertEqual( - parse_mvt_version_output("MVT - Mobile Verification Toolkit\nVersion: 2026.9.21\n"), - "2026.9.21", - ) - with self.assertRaises(MVTValidationError): - parse_mvt_version_output("unknown program\n") - - def test_resolves_one_backup_and_rejects_encrypted_input(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - backup = root / "device-backup" - backup.mkdir() - (backup / "Manifest.db").write_bytes(b"database") - (backup / "Info.plist").write_bytes(plistlib.dumps({"Device Name": "Example"})) - - inspection = inspect_mvt_backup(root) - self.assertEqual(inspection.path, backup.resolve()) - self.assertIsNone(inspection.encrypted) - - (backup / "Manifest.plist").write_bytes(plistlib.dumps({"IsEncrypted": True})) - with self.assertRaises(MVTValidationError): - inspect_mvt_backup(backup) - - def test_requires_new_output_outside_the_backup(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - backup_path = root / "backup" - backup_path.mkdir() - backup = MVTBackup(backup_path.resolve(), False) - isolated_output = validate_mvt_output(root / "analysis", backup) - self.assertEqual(isolated_output, (root / "analysis").resolve()) - with self.assertRaises(MVTValidationError): - validate_mvt_output(backup_path / "analysis", backup) - existing = root / "existing" - existing.mkdir() - with self.assertRaises(MVTValidationError): - validate_mvt_output(existing, backup) - - def test_builds_offline_analysis_without_password_or_implicit_iocs(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - executable_path = root / "mvt-ios" - executable_path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") - executable_path.chmod(executable_path.stat().st_mode | stat.S_IXUSR) - executable = inspect_mvt_executable(executable_path) - installation = MVTInstallation(executable, "2026.9.21") - backup_path = root / "backup" - backup_path.mkdir() - (backup_path / "Manifest.db").write_bytes(b"database") - (backup_path / "Info.plist").write_bytes(plistlib.dumps({})) - ioc_path = root / "indicators.stix2" - ioc_path.write_text("{}", encoding="utf-8") - request = create_mvt_analysis_request( - installation, - backup_path, - root / "analysis", - (ioc_path,), - True, - True, - False, - ) - arguments = mvt_analysis_arguments(request) - - self.assertIsInstance(request, MVTAnalysisRequest) - self.assertIn("--fast", arguments) - self.assertIn("--hashes", arguments) - self.assertIn(str(ioc_path.resolve()), arguments) - self.assertNotIn("password", " ".join(arguments).casefold()) - - config_directory = root / "config" - config_directory.mkdir() - environment = mvt_environment( - { - "PATH": os.environ.get("PATH", ""), - "MVT_IOS_BACKUP_PASSWORD": "secret", - "MVT_STIX2": "/unexpected", - }, - config_directory, - False, - ) - self.assertNotIn("MVT_IOS_BACKUP_PASSWORD", environment) - self.assertNotIn("MVT_STIX2", environment) - self.assertEqual(environment["MVT_NETWORK_ACCESS_ALLOWED"], "false") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_operation_history.py b/tests/test_operation_history.py deleted file mode 100644 index 8d3ee7d..0000000 --- a/tests/test_operation_history.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -import json -import shutil -import stat -import tempfile -import unittest -from pathlib import Path - -from ios_developer_toolkit.operation_history import ( - OperationHistoryError, - append_operation_record, - operation_context, - operation_manifest, - operation_record, - write_operation_manifest, -) -from ios_developer_toolkit.qt_process import OperationResult - - -class OperationHistoryTests(unittest.TestCase): - def setUp(self) -> None: - self.context = operation_context( - "Inspect device", - "Command Center", - "Selected iPhone", - "pymobiledevice3", - ("device:selected", "trust:ready"), - ("/tmp/output.txt",), - ) - self.result = OperationResult( - ("/tool/pymobiledevice3", "lockdown", "info", "--udid", "PRIVATE-DEVICE-ID"), - "succeeded", - "2026-09-22T12:00:00+00:00", - "2026-09-22T12:00:01.250000+00:00", - 0, - None, - b"private stdout", - b"diagnostic stderr", - ) - - def test_builds_typed_record_with_timing_and_output_digests(self) -> None: - record = operation_record(self.context, self.result) - - self.assertEqual(record.duration_milliseconds, 1250) - self.assertEqual(record.stdout_bytes, len(b"private stdout")) - self.assertEqual(len(record.stdout_sha256), 64) - self.assertEqual(record.argv[-1], "PRIVATE-DEVICE-ID") - self.assertEqual(record.output_paths, (str(Path("/tmp/output.txt").resolve()),)) - - def test_manifest_omits_raw_output_but_retains_exact_argument_vector(self) -> None: - manifest = operation_manifest(operation_record(self.context, self.result)) - payload = json.dumps(manifest) - - self.assertNotIn("private stdout", payload) - self.assertNotIn("diagnostic stderr", payload) - self.assertIn("PRIVATE-DEVICE-ID", payload) - self.assertFalse(manifest["captured_output"]["raw_output_included"]) - - def test_append_is_bounded_and_rejects_duplicate_records(self) -> None: - first = operation_record(self.context, self.result) - second_result = OperationResult( - self.result.argv, - "failed", - "2026-09-22T12:01:00+00:00", - "2026-09-22T12:01:02+00:00", - 1, - "failed", - b"", - b"failed", - ) - second = operation_record(self.context, second_result) - - self.assertEqual(append_operation_record((first,), second, 1), (second,)) - with self.assertRaises(OperationHistoryError): - append_operation_record((first,), first, 10) - - def test_writes_private_manifest_without_overwriting(self) -> None: - temporary_directory = Path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, temporary_directory) - destination = temporary_directory / "operation.json" - record = operation_record(self.context, self.result) - - self.assertEqual(write_operation_manifest(destination, record), destination.resolve()) - self.assertEqual(stat.S_IMODE(destination.stat().st_mode), 0o600) - with self.assertRaises(OperationHistoryError): - write_operation_manifest(destination, record) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py deleted file mode 100644 index c5c76f0..0000000 --- a/tests/test_project_metadata.py +++ /dev/null @@ -1,90 +0,0 @@ -from __future__ import annotations - -import plistlib -import tomllib -import unittest -from pathlib import Path - -from packaging.specifiers import SpecifierSet -from packaging.version import Version - -from ios_developer_toolkit import APP_VERSION - - -REPOSITORY_ROOT = Path(__file__).resolve().parents[1] - - -class ProjectMetadataTests(unittest.TestCase): - def test_python_range_matches_the_pinned_qt_runtime(self) -> None: - pyproject = tomllib.loads((REPOSITORY_ROOT / "pyproject.toml").read_text(encoding="utf-8")) - project = pyproject["project"] - self.assertIsInstance(project, dict) - supported_python = SpecifierSet(str(project["requires-python"])) - - self.assertNotIn(Version("3.9"), supported_python) - self.assertIn(Version("3.10"), supported_python) - self.assertIn(Version("3.13"), supported_python) - self.assertNotIn(Version("3.14"), supported_python) - - def test_source_and_packaging_metadata_match_application_version(self) -> None: - pyproject = tomllib.loads((REPOSITORY_ROOT / "pyproject.toml").read_text(encoding="utf-8")) - project = pyproject["project"] - self.assertIsInstance(project, dict) - self.assertEqual(project["version"], APP_VERSION) - - with (REPOSITORY_ROOT / "macos" / "Info.plist").open("rb") as stream: - plist = plistlib.load(stream) - self.assertEqual(plist["CFBundleShortVersionString"], APP_VERSION) - self.assertEqual(plist["CFBundleDisplayName"], "iOS Developer Toolkit") - self.assertEqual(plist["CFBundleIdentifier"], "io.hideouts.ios-developer-toolkit") - self.assertEqual(plist["CFBundleVersion"], "6") - self.assertEqual(plist["LSMinimumSystemVersion"], "13.0") - - deployment_specification = (REPOSITORY_ROOT / "packaging" / "pysidedeploy.spec").read_text(encoding="utf-8") - self.assertIn(f"--macos-app-version={APP_VERSION}", deployment_specification) - self.assertIn("--macos-app-macos-min-version=13.0", deployment_specification) - release_builder = (REPOSITORY_ROOT / "scripts" / "build_macos_release.sh").read_text(encoding="utf-8") - self.assertIn("--macos-app-version=$release_version", release_builder) - self.assertIn("CFBundleVersion string 6", release_builder) - self.assertIn('scripts/verify_macos_bundle.py', release_builder) - self.assertIn('MACOSX_DEPLOYMENT_TARGET', release_builder) - - def test_dependency_notices_match_the_pinned_pymobiledevice3_release(self) -> None: - pyproject = tomllib.loads((REPOSITORY_ROOT / "pyproject.toml").read_text(encoding="utf-8")) - project = pyproject["project"] - self.assertIsInstance(project, dict) - dependencies = project["dependencies"] - self.assertIsInstance(dependencies, list) - pinned_dependency = next( - dependency for dependency in dependencies if isinstance(dependency, str) and dependency.startswith("pymobiledevice3==") - ) - pinned_version = pinned_dependency.removeprefix("pymobiledevice3==") - - notices = (REPOSITORY_ROOT / "THIRD_PARTY_NOTICES.md").read_text(encoding="utf-8") - source_availability = (REPOSITORY_ROOT / "SOURCE_AVAILABILITY.md").read_text(encoding="utf-8") - self.assertIn(f"| {pinned_version} |", notices) - self.assertIn(f"tree/v{pinned_version}", notices) - self.assertIn(f"tree/v{pinned_version}", source_availability) - - def test_dependency_notices_match_the_pinned_pyside6_release(self) -> None: - pyproject = tomllib.loads((REPOSITORY_ROOT / "pyproject.toml").read_text(encoding="utf-8")) - project = pyproject["project"] - self.assertIsInstance(project, dict) - dependencies = project["dependencies"] - self.assertIsInstance(dependencies, list) - pinned_dependency = next( - dependency - for dependency in dependencies - if isinstance(dependency, str) and dependency.startswith("PySide6-Essentials==") - ) - pinned_version = pinned_dependency.removeprefix("PySide6-Essentials==") - - notices = (REPOSITORY_ROOT / "THIRD_PARTY_NOTICES.md").read_text(encoding="utf-8") - readme = (REPOSITORY_ROOT / "README.md").read_text(encoding="utf-8") - self.assertIn(f"| {pinned_version} |", notices) - self.assertIn(f"?h=v{pinned_version}", notices) - self.assertIn(f"| PySide6 Essentials | `{pinned_version}` |", readme) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_qt_process.py b/tests/test_qt_process.py deleted file mode 100644 index b9f56d9..0000000 --- a/tests/test_qt_process.py +++ /dev/null @@ -1,140 +0,0 @@ -from __future__ import annotations - -import sys -import time -import unittest -from collections.abc import Callable -from pathlib import Path - -from PySide6.QtCore import QCoreApplication - -from ios_developer_toolkit.qt_process import FiniteProcessController, OperationResult, finite_process_request -from ios_developer_toolkit.runtime import ExecutableCommand - - -class FiniteProcessControllerTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.application = QCoreApplication.instance() or QCoreApplication(["finite-process-tests"]) - - def test_returns_terminal_stdout_stderr_and_exit_status(self) -> None: - controller = FiniteProcessController(self.application) - results: list[OperationResult] = [] - controller.completed.connect(results.append) - request = finite_process_request( - ExecutableCommand(Path(sys.executable), ()), - ("-c", "import sys; sys.stdout.write('ready'); sys.stderr.write('notice')"), - {}, - 3_000, - 500, - ) - - controller.start(request) - self._wait_for(lambda: bool(results), 3) - - self.assertEqual(len(results), 1) - self.assertEqual(results[0].outcome, "succeeded") - self.assertEqual(results[0].exit_code, 0) - self.assertEqual(results[0].stdout, b"ready") - self.assertEqual(results[0].stderr, b"notice") - self.assertEqual(results[0].argv, (sys.executable, "-c", request.arguments[1])) - self.assertFalse(controller.is_running()) - - def test_times_out_a_finite_process_once(self) -> None: - controller = FiniteProcessController(self.application) - results: list[OperationResult] = [] - controller.completed.connect(results.append) - request = finite_process_request( - ExecutableCommand(Path(sys.executable), ()), - ("-c", "import time; time.sleep(10)"), - {}, - 100, - 500, - ) - - controller.start(request) - self._wait_for(lambda: bool(results), 3) - - self.assertEqual(len(results), 1) - self.assertEqual(results[0].outcome, "timed-out") - self.assertFalse(controller.is_running()) - - def test_cancels_a_finite_process_once(self) -> None: - controller = FiniteProcessController(self.application) - results: list[OperationResult] = [] - controller.completed.connect(results.append) - request = finite_process_request( - ExecutableCommand(Path(sys.executable), ()), - ("-c", "import time; time.sleep(10)"), - {}, - 3_000, - 500, - ) - - controller.start(request) - controller.cancel() - self._wait_for(lambda: bool(results), 3) - - self.assertEqual(len(results), 1) - self.assertEqual(results[0].outcome, "cancelled") - self.assertFalse(controller.is_running()) - - def test_relaunches_without_reusing_previous_output(self) -> None: - controller = FiniteProcessController(self.application) - results: list[OperationResult] = [] - controller.completed.connect(results.append) - first = finite_process_request( - ExecutableCommand(Path(sys.executable), ()), - ("-c", "print('first')"), - {}, - 3_000, - 500, - ) - second = finite_process_request( - ExecutableCommand(Path(sys.executable), ()), - ("-c", "print('second')"), - {}, - 3_000, - 500, - ) - - controller.start(first) - self._wait_for(lambda: len(results) == 1, 3) - controller.start(second) - self._wait_for(lambda: len(results) == 2, 3) - - self.assertEqual(results[0].stdout, b"first\n") - self.assertEqual(results[1].stdout, b"second\n") - self.assertFalse(controller.is_running()) - - def test_reports_operating_system_error_when_launch_fails(self) -> None: - controller = FiniteProcessController(self.application) - results: list[OperationResult] = [] - controller.completed.connect(results.append) - request = finite_process_request( - ExecutableCommand(Path("/path/that/does/not/exist"), ()), - (), - {}, - 3_000, - 500, - ) - - controller.start(request) - self._wait_for(lambda: bool(results), 3) - - self.assertEqual(len(results), 1) - self.assertEqual(results[0].outcome, "launch-failed") - self.assertIsNotNone(results[0].error_message) - self.assertTrue(results[0].error_message) - self.assertFalse(controller.is_running()) - - def _wait_for(self, predicate: Callable[[], bool], timeout_seconds: int) -> None: - deadline = time.monotonic() + timeout_seconds - while not predicate() and time.monotonic() < deadline: - self.application.processEvents() - time.sleep(0.01) - self.application.processEvents() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py deleted file mode 100644 index bde443e..0000000 --- a/tests/test_release_metadata.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import annotations - -import json -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path - - -REPOSITORY_ROOT = Path(__file__).resolve().parents[1] -VERIFY_SCRIPT = REPOSITORY_ROOT / "scripts" / "verify_release_metadata.py" - - -def create_release_fixture(root: Path, release_version: str) -> tuple[Path, Path]: - application_path = root / "iOS Developer Toolkit.app" - resource_directory = application_path / "Contents" / "Resources" - licenses_directory = resource_directory / "Licenses" - package_directory = licenses_directory / "ThirdPartyPackages" - package_directory.mkdir(parents=True) - sbom = { - "bomFormat": "CycloneDX", - "specVersion": "1.6", - "serialNumber": "urn:uuid:12345678-1234-1234-1234-123456789012", - "metadata": {"component": {"name": "ios-developer-toolkit", "version": release_version}}, - } - sbom_path = root / "release.cdx.json" - serialized_sbom = json.dumps(sbom, sort_keys=True) - sbom_path.write_text(serialized_sbom, encoding="utf-8") - (resource_directory / "BOM.cdx.json").write_text(serialized_sbom, encoding="utf-8") - (licenses_directory / "IOS_DEVELOPER_TOOLKIT_LICENSE.txt").write_text("MIT\n", encoding="utf-8") - (licenses_directory / "THIRD_PARTY_NOTICES.md").write_text("pymobiledevice3\n", encoding="utf-8") - (licenses_directory / "SOURCE_AVAILABILITY.md").write_text("pymobiledevice3\n", encoding="utf-8") - (package_directory / "THIRD_PARTY_PACKAGES.md").write_text( - "pymobiledevice3\nNuitka\n", encoding="utf-8" - ) - return application_path, sbom_path - - -class ReleaseMetadataTests(unittest.TestCase): - def run_verifier(self, application_path: Path, sbom_path: Path, release_version: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [sys.executable, str(VERIFY_SCRIPT), str(application_path), str(sbom_path), release_version], - check=False, - capture_output=True, - text=True, - ) - - def test_accepts_matching_packaged_metadata(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - application_path, sbom_path = create_release_fixture(Path(temporary_directory), "0.3.1") - result = self.run_verifier(application_path, sbom_path, "0.3.1") - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("Release metadata verification passed", result.stdout) - - def test_rejects_a_bundle_with_a_different_sbom(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - application_path, sbom_path = create_release_fixture(Path(temporary_directory), "0.3.1") - (application_path / "Contents" / "Resources" / "BOM.cdx.json").write_text("{}", encoding="utf-8") - result = self.run_verifier(application_path, sbom_path, "0.3.1") - - self.assertNotEqual(result.returncode, 0) - self.assertIn("differs from Contents/Resources/BOM.cdx.json", result.stderr) - - def test_rejects_an_sbom_without_the_required_attestation_serial_number(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - application_path, sbom_path = create_release_fixture(Path(temporary_directory), "0.3.1") - sbom = json.loads(sbom_path.read_text(encoding="utf-8")) - del sbom["serialNumber"] - serialized_sbom = json.dumps(sbom, sort_keys=True) - sbom_path.write_text(serialized_sbom, encoding="utf-8") - (application_path / "Contents" / "Resources" / "BOM.cdx.json").write_text( - serialized_sbom, - encoding="utf-8", - ) - result = self.run_verifier(application_path, sbom_path, "0.3.1") - - self.assertNotEqual(result.returncode, 0) - self.assertIn("does not define a CycloneDX serial number", result.stderr) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_runtime.py b/tests/test_runtime.py deleted file mode 100644 index 69476d3..0000000 --- a/tests/test_runtime.py +++ /dev/null @@ -1,106 +0,0 @@ -from __future__ import annotations - -import plistlib -import sys -import tempfile -import unittest -from pathlib import Path - -from ios_developer_toolkit.entrypoint import dispatch_internal, parsed_worker -from ios_developer_toolkit.runtime import ( - ExecutableCommand, - FrozenExecutableError, - command_arguments, - command_argv, - command_text, - frozen_executable_path, - macos_bundle_executable, - pymobiledevice3_command, - worker_command, -) - - -class ExecutableCommandTests(unittest.TestCase): - def test_composes_prefix_and_requested_arguments_without_mutation(self) -> None: - command = ExecutableCommand(Path("/Applications/Toolkit"), ("--internal", "pmd3")) - requested = ("usbmux", "list") - - self.assertEqual( - command_arguments(command, requested), - ("--internal", "pmd3", "usbmux", "list"), - ) - self.assertEqual( - command_argv(command, requested), - ("/Applications/Toolkit", "--internal", "pmd3", "usbmux", "list"), - ) - self.assertEqual(requested, ("usbmux", "list")) - - def test_command_text_quotes_paths_for_display(self) -> None: - command = ExecutableCommand(Path("/Applications/iOS Developer Toolkit"), ()) - - self.assertEqual( - command_text(command, ("apps", "install", "/tmp/Test App.ipa")), - "'/Applications/iOS Developer Toolkit' apps install '/tmp/Test App.ipa'", - ) - - def test_source_commands_use_installed_entry_points_and_modules(self) -> None: - self.assertEqual(pymobiledevice3_command().program, Path(sys.executable).with_name("pymobiledevice3")) - self.assertEqual( - worker_command("collector").prefix_arguments, - ("-m", "ios_developer_toolkit.collector"), - ) - - def test_frozen_macos_runtime_uses_bundle_plist_launcher(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - contents = Path(temporary_directory) / "Toolkit.app" / "Contents" - macos_directory = contents / "MacOS" - macos_directory.mkdir(parents=True) - launcher = macos_directory / "ToolkitLauncher" - launcher.write_bytes(b"launcher") - launcher.chmod(0o755) - with (contents / "Info.plist").open("wb") as plist_file: - plistlib.dump({"CFBundleExecutable": launcher.name}, plist_file) - - resolved = frozen_executable_path( - macos_directory / "python", - macos_directory / "ios_developer_toolkit" / "runtime.py", - macos_directory / "python", - "darwin", - ) - - self.assertEqual(resolved, launcher.resolve()) - - def test_frozen_macos_runtime_rejects_missing_bundle_launcher(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - contents = Path(temporary_directory) / "Toolkit.app" / "Contents" - macos_directory = contents / "MacOS" - macos_directory.mkdir(parents=True) - with (contents / "Info.plist").open("wb") as plist_file: - plistlib.dump({"CFBundleExecutable": "MissingLauncher"}, plist_file) - - with self.assertRaisesRegex(FrozenExecutableError, "does not exist"): - macos_bundle_executable( - macos_directory / "python", - macos_directory / "ios_developer_toolkit" / "runtime.py", - ) - - def test_non_macos_frozen_runtime_requires_an_executable_file(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - executable = Path(temporary_directory) / "toolkit" - executable.write_bytes(b"launcher") - executable.chmod(0o644) - - with self.assertRaisesRegex(FrozenExecutableError, "missing or not executable"): - frozen_executable_path(Path("unused"), Path("unused"), executable, "linux") - - -class InternalDispatchTests(unittest.TestCase): - def test_dispatch_requires_a_supported_internal_mode_and_worker(self) -> None: - self.assertIsNone(dispatch_internal(())) - self.assertIsNone(dispatch_internal(("--unrelated",))) - with self.assertRaisesRegex(ValueError, "Unsupported internal worker"): - parsed_worker("unknown") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_workspace_profile.py b/tests/test_workspace_profile.py deleted file mode 100644 index f550f06..0000000 --- a/tests/test_workspace_profile.py +++ /dev/null @@ -1,104 +0,0 @@ -from __future__ import annotations - -import json -import os -import shutil -import tempfile -import unittest -from dataclasses import replace -from pathlib import Path - -from ios_developer_toolkit.command_catalog import command_presets -from ios_developer_toolkit.workspace_profile import ( - AppWorkflowPreferences, - BackupWorkflowPreferences, - EvidenceWorkflowPreferences, - LocationWorkflowPreferences, - WorkspaceProfile, - WorkspaceProfileError, - load_workspace_profile, - parse_workspace_profile, - render_workspace_profile_json, - render_workspace_profile_preview, - workspace_profile_mapping, - write_workspace_profile, -) - - -def example_profile() -> WorkspaceProfile: - preset = command_presets()[0] - return WorkspaceProfile( - created_with_version="0.3.4", - name="Release validation", - description="Shared control defaults without targets or paths", - default_workspace="Capability Matrix", - ddi_source="personalized", - command_category=preset.category, - command_preset=preset.identifier, - app_workflow=AppWorkflowPreferences(True, False), - backup_workflow=BackupWorkflowPreferences(True, True), - evidence_workflow=EvidenceWorkflowPreferences(300, True, True, True, False, True), - location_workflow=LocationWorkflowPreferences(100, False, 5, 7, 2, 3), - ) - - -class WorkspaceProfileTests(unittest.TestCase): - def test_round_trips_only_reviewed_non_sensitive_control_defaults(self) -> None: - profile = example_profile() - - payload = workspace_profile_mapping(profile) - parsed = parse_workspace_profile(json.loads(render_workspace_profile_json(profile))) - preview = render_workspace_profile_preview(profile) - - self.assertEqual(parsed, profile) - privacy = payload["privacy"] - self.assertIsInstance(privacy, dict) - self.assertIn("device identity and targets", privacy["schema_excludes"]) - serialized = json.dumps(payload, sort_keys=True) - self.assertNotIn("/Users/", serialized) - self.assertNotIn("PRIVATE-UDID", serialized) - self.assertNotIn("password", serialized.casefold()) - self.assertIn("Importing changes visible controls only", preview) - - def test_parser_requires_known_workspace_preset_and_bounded_values(self) -> None: - profile = example_profile() - with self.assertRaises(WorkspaceProfileError): - workspace_profile_mapping(replace(profile, default_workspace="Unknown")) - with self.assertRaises(WorkspaceProfileError): - workspace_profile_mapping(replace(profile, command_preset="unknown-preset")) - with self.assertRaises(WorkspaceProfileError): - workspace_profile_mapping(replace(profile, description="Stored at /Users/private/team")) - invalid_location = replace(profile.location_workflow, route_traversals=21) - with self.assertRaises(WorkspaceProfileError): - workspace_profile_mapping(replace(profile, location_workflow=invalid_location)) - - def test_parser_ignores_unrelated_extra_fields(self) -> None: - payload = workspace_profile_mapping(example_profile()) - payload["future_top_level"] = "ignored" - settings = payload["settings"] - self.assertIsInstance(settings, dict) - settings["future_setting"] = {"ignored": True} - - parsed = parse_workspace_profile(payload) - - self.assertEqual(parsed, example_profile()) - - def test_private_file_round_trip_refuses_overwrite_and_oversize_input(self) -> None: - temporary_directory = Path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, temporary_directory) - destination = temporary_directory / "team-profile.json" - - written = write_workspace_profile(destination, example_profile()) - - self.assertEqual(os.stat(written).st_mode & 0o777, 0o600) - self.assertEqual(load_workspace_profile(written), example_profile()) - with self.assertRaises(WorkspaceProfileError): - write_workspace_profile(destination, example_profile()) - oversized = temporary_directory / "oversized.json" - oversized.write_bytes(b" " * 1_048_577) - with self.assertRaises(WorkspaceProfileError): - load_workspace_profile(oversized) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_xcode_handoff.py b/tests/test_xcode_handoff.py deleted file mode 100644 index 0cd2eee..0000000 --- a/tests/test_xcode_handoff.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -import os -import shutil -import tempfile -import unittest -from pathlib import Path - -from ios_developer_toolkit.xcode_handoff import ( - XcodeHandoffError, - coredevice_details_handoff, - rvi_list_handoff, - validated_xcode_artifact, - validated_xcode_project, - xcode_project_handoff, -) - - -class XcodeHandoffTests(unittest.TestCase): - @unittest.skipIf(shutil.which("xcrun") is None, "xcrun is unavailable") - def test_builds_selected_device_coredevice_details_command(self) -> None: - command, arguments = coredevice_details_handoff(" 00008110-001122334455001E ") - - self.assertTrue(command.program.is_file()) - self.assertTrue(os.access(command.program, os.X_OK)) - self.assertEqual( - arguments, - ( - "devicectl", - "device", - "info", - "details", - "--device", - "00008110-001122334455001E", - "--timeout", - "30", - ), - ) - - @unittest.skipUnless( - Path("/Library/Apple/usr/bin/rvictl").is_file() or shutil.which("rvictl") is not None, - "rvictl is unavailable", - ) - def test_resolves_read_only_rvi_inventory_command(self) -> None: - command, arguments = rvi_list_handoff() - - self.assertTrue(command.program.is_file()) - self.assertTrue(os.access(command.program, os.X_OK)) - self.assertEqual(arguments, ("-l",)) - - def test_validates_native_xcode_projects_results_and_traces(self) -> None: - temporary_directory = Path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, temporary_directory) - project = temporary_directory / "Toolkit.xcodeproj" - result = temporary_directory / "Toolkit.xcresult" - trace = temporary_directory / "Toolkit.trace" - package = temporary_directory / "Package.swift" - for directory in (project, result, trace): - directory.mkdir() - package.write_text("// swift-tools-version: 6.0\n", encoding="utf-8") - - self.assertEqual(validated_xcode_project(project), project.resolve()) - self.assertEqual(validated_xcode_project(package), package.resolve()) - self.assertEqual(validated_xcode_artifact(result), result.resolve()) - self.assertEqual(validated_xcode_artifact(trace), trace.resolve()) - command, arguments = xcode_project_handoff(project) - self.assertTrue(command.program.is_file()) - self.assertEqual(arguments, ("xed", str(project.resolve()))) - - def test_rejects_missing_or_unrelated_handoff_target(self) -> None: - temporary_directory = Path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, temporary_directory) - unrelated = temporary_directory / "notes.txt" - unrelated.write_text("not an Xcode artifact\n", encoding="utf-8") - - with self.assertRaises(XcodeHandoffError): - validated_xcode_artifact(unrelated) - with self.assertRaises(XcodeHandoffError): - validated_xcode_project(temporary_directory / "Missing.xcodeproj") - - -if __name__ == "__main__": - unittest.main()