diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..1896069 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,75 @@ +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/.gitignore b/.gitignore index 7b9526e..b3a7ed4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ venv/ build/ dist/ +site/ +output/ +.playwright-cli/ ios_developer_toolkit/deployment/ packaging/deployment/ *.egg-info/ @@ -18,6 +21,9 @@ reports/ ios-case-*/ ufade-acquisitions/ UFADE Acquisitions/ +mvt-analyses/ +MVT Analyses/ +mvt-analysis-*/ location-logs/ Location Logs/ iOS Developer Toolkit Location Logs/ @@ -33,6 +39,8 @@ nuitka-crash-report.xml *.pcapng *.ufd *.ufdr +*.stix +*.stix2 # Packages, profiles, and developer-image payloads *.cer diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 033d5ff..bec2d05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,6 +54,13 @@ 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. diff --git a/README.md b/README.md index 8002948..b6c13a3 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,11 @@ ### iOS Developer Toolkit: a guided pymobiledevice3 GUI, Developer Disk Image mounter, and evidence workbench for macOS [![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%2B-3776ab?logo=python&logoColor=white) +![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) [![License](https://img.shields.io/badge/license-MIT-2da44e)](LICENSE) @@ -19,10 +20,12 @@ ![iOS Developer Toolkit Home workspace](docs/screenshots/home.png) -The current interface organizes one trusted device connection into 12 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, and builds hashed evidence cases. +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. + ## Contents - [Start here](#start-here) @@ -43,8 +46,11 @@ The screenshots use an illustrative device name, model, version, build, and UDID - [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) @@ -76,7 +82,7 @@ The application executes the project-pinned binary directly. Guided values becom ## Current release additions and visual tour -Release `v0.3.4` combines the complete 12-workspace interface with the latest connection, streaming, packaging, and repository-readiness work: +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; @@ -84,17 +90,22 @@ Release `v0.3.4` combines the complete 12-workspace interface with the latest co - **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 without retaining raw UDIDs; +- 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, PCAP, screenshots, crashes, and hashed evidence cases are integrated into one selected-device workflow; -- native Apple Silicon and Intel release ZIPs are built separately and verified with 94 tests, embedded CLI checks, a 90-button GUI smoke test, full-bundle architecture and deployment-floor inspection, strict code-signature validation, and one SHA-256 manifest; +- 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 17 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. +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 | |---|---|---| @@ -111,27 +122,32 @@ The README contains 17 sanitized screenshots. The six views below provide a quic | 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. | Stores a one-way device fingerprint, not raw UDIDs or names. | +| 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; mount, list, or remove a developer image | For mounting | Explicit device target and image source | +| **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 or launch a separate UFADE environment | No | Full/incremental encrypted backup or external acquisition | +| **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 | @@ -139,7 +155,31 @@ The README contains 17 sanitized screenshots. The six views below provide a quic 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 `⌘ L` to focus workspace navigation, `⌘ F` to focus contextual search, `⌘ R` to retry discovery, `⌘ 1` through `⌘ 0` to open the first ten workspaces, `⌘ ⇧ 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. +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 @@ -161,6 +201,7 @@ Highlights of the current build: - 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. @@ -200,7 +241,7 @@ These layers are related but not interchangeable: ## Requirements - macOS 13 or later; -- Python 3.10 or later for this project; +- 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; @@ -211,7 +252,7 @@ Current pinned runtime: | Component | Version or path | |---|---| -| Python | `>=3.10` | +| Python | `>=3.10,<3.14` | | PySide6 Essentials | `6.9.3` | | pymobiledevice3 | `11.15.1` | | Local Xcode candidate | `/Library/Developer/CoreDevice/CandidateDDIs/iOS_DDI.dmg` | @@ -241,7 +282,7 @@ cd iOS-Developer-Toolkit The launcher: -1. creates `venv/` when needed; +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. @@ -338,7 +379,7 @@ python3 --version xcode-select -p ``` -If `python3` is missing or older than 3.10, install a supported Python locally before launching. Dependencies belong in the project-created `venv/`; do not install this project's pinned packages globally. +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. @@ -439,6 +480,21 @@ The toolkit attaches this outer host image read-only, validates its `Restore` pa 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) @@ -462,6 +518,8 @@ Every row uses one of six explicit states: **Ready**, **Needs attention**, **Una 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 @@ -532,7 +590,7 @@ For retained system log archives, use the applicable `syslog collect` command th ![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. **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 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. +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: @@ -550,6 +608,8 @@ Long-running commands remain attached to a visible Stop control. Stopping a proc 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. @@ -558,7 +618,7 @@ An empty inventory is not proof that no apps exist. It may instead indicate devi ![Backup providers workspace](docs/screenshots/backup.png) -The Backup workspace keeps two providers isolated. +The Backup workspace keeps three providers isolated. #### MobileBackup2 @@ -574,7 +634,7 @@ If encryption is currently off and **Require encrypted backup** is selected: 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. -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. +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 @@ -627,6 +687,38 @@ The selected toolkit device is shown only as a cross-check; UFADE performs its o 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) @@ -641,7 +733,7 @@ Selecting an IPA starts host-side inspection before the install control can be e - 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. 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. +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. @@ -681,7 +773,33 @@ Optional scope: - 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. Stop/Finalize ends streams and still finalizes the case where possible. +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 @@ -884,9 +1002,11 @@ Treat these outputs as potentially sensitive: - 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, 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. +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 @@ -991,8 +1111,10 @@ Install or update Xcode if the candidate is absent. The toolkit requires the exp . ├── 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_protocol.py # dependency-free backup request/event schema +│ ├── 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 @@ -1000,18 +1122,26 @@ Install or update Xcode if the candidate is absent. The toolkit requires the exp │ ├── 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 @@ -1037,11 +1167,20 @@ venv/bin/python -m ios_developer_toolkit.ipa_inspector --help 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: + +```bash +venv/bin/python -m pip install --requirement requirements/docs.txt +venv/bin/python -m mkdocs build --strict --clean +``` + +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 94 tests, verifies the embedded pymobiledevice3 command, checks the internal worker route, runs the 90-button offscreen GUI smoke test, verifies live help 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 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. @@ -1055,6 +1194,8 @@ Windows and Linux would require a separate host implementation or deliberately i - [`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. diff --git a/SOURCE_AVAILABILITY.md b/SOURCE_AVAILABILITY.md index a8f021d..50c8fc9 100644 --- a/SOURCE_AVAILABILITY.md +++ b/SOURCE_AVAILABILITY.md @@ -13,3 +13,5 @@ The prebuilt application is accompanied by an architecture-specific CycloneDX SB 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. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c04f5a7..9b2903d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -20,7 +20,9 @@ Each architecture-specific release also contains: 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. -UFADE is an optional, separately installed external provider. The toolkit does not bundle UFADE. Other projects named in the README as design references are not copied, imported, or linked unless the README explicitly says otherwise. +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 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. See [SOURCE_AVAILABILITY.md](SOURCE_AVAILABILITY.md) for the project source location, matching tagged source, and upstream source locations for bundled third-party components. diff --git a/docs/PRODUCT_AUDIT_2026-09-21.md b/docs/PRODUCT_AUDIT_2026-09-21.md index 7b267ef..d4f7c9f 100644 --- a/docs/PRODUCT_AUDIT_2026-09-21.md +++ b/docs/PRODUCT_AUDIT_2026-09-21.md @@ -10,7 +10,7 @@ The correct next investment is therefore a **reliable startup and device-discove ## What exists today -The product has twelve workspaces: Home, Device & DDI, Capability Matrix, Location Lab, Live Logs, Command Center, Installed Apps, Backup, Sideload IPA, Evidence Capture, 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, UFADE setup guidance, IPA inspection and installation, RVI/PCAP and artifact collection, guided case intake, support bundles, compatibility history, and keyboard-first navigation. +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. @@ -113,25 +113,25 @@ Upstream contribution candidates are concrete: report the fast-exit scanner pack ### P1 — turn diagnostics into a coherent workbench -* Continue migrating finite subprocess workflows to the reusable operation controller and typed `OperationResult`. Device discovery, Man Pages, and sequential command drift now share final-drain, timeout, cancellation, launch-failure, clean-relaunch, and structured completion semantics; DDI, backup, apps, and capture remain incremental migrations. +* 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. -* Add Xcode project/device handoffs: selected `devicectl` discovery, RVI status, and `.xcresult`/`xctrace` opening without reimplementing those formats. +* 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 -* Add per-operation history, structured output manifests, and a universal command/action palette that only exposes eligible operations. -* Implement a guided MVT backup-analysis handoff with explicit consent, no password persistence, output isolation, and no “clean device” conclusion. -* Add optional user-configured adapters for `go-ios`, `idb`, and `ipsw`, each with executable provenance and version display. -* Publish a small documentation site split into quick start, architecture, safety, troubleshooting, release verification, and contributor paths. +* 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 -* Opt-in anonymized compatibility contribution workflow with a local preview and explicit export confirmation. -* Team/workspace import-export that remains local by default. -* Notarized Developer ID distribution when an eligible signing identity exists. -* Optional device-lab integration through external services, never a mandatory cloud account. +* 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 @@ -142,17 +142,11 @@ Upstream contribution candidates are concrete: report the fast-exit scanner pack * 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. -## Single best next thing to build +## Audit implementation status -**Reliable startup and lossless device discovery.** This is the right first build because the device picker is a dependency for nearly every existing workspace, there is direct evidence of a released UI/CLI disagreement, and the current eager import makes a non-backup dependency capable of blocking the app before the user can receive diagnostics. It improves both personas: beginners see a usable application and accurate connection state; experts get predictable process results that can later underpin every operation. +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. -## Implementation plan - -1. Extract backup request/event schema validation into a dependency-free `backup_protocol` module. The desktop UI and tests import that module; only the backup worker imports the MobileBackup2 transport implementation. -2. Make `DeviceScanner` consume any remaining stdout/stderr synchronously in its completion handler before evaluating exit status or parsing JSON. -3. Add tests for the backup protocol and a real, short-lived QProcess whose valid JSON is available only after it has exited. -4. Update the README’s troubleshooting and architecture material to explain the connection behavior and the no-sudo boundary. -5. Validate `pymobiledevice3` 11.15.1 in the project environment, then run the full 94-test suite, 90-action headless GUI smoke, CLI discovery, and every command-catalog live-help route. Review the diff before handoff. +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 @@ -169,6 +163,20 @@ Upstream contribution candidates are concrete: report the fast-exit scanner pack | 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 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..db03e3e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,33 @@ +# Architecture + +## Service layers + +The toolkit keeps Apple service boundaries visible instead of reducing every failure to “device not connected.” + +| Layer | Typical role | What readiness does not prove | +|---|---|---| +| 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 | + +The [README service-layer diagram](https://github.com/hideouts-io/iOS-Developer-Toolkit#how-the-service-layers-fit-together) is the canonical operational explanation. + +## Process model + +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. + +The key implementation surfaces are: + +- [`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. + +## External-provider boundary + +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. + +See the current [product audit and roadmap](PRODUCT_AUDIT_2026-09-21.md) for the evidence behind these boundaries. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..4fa8329 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,16 @@ +# 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 new file mode 100644 index 0000000..993a5cb --- /dev/null +++ b/docs/index.md @@ -0,0 +1,70 @@ +# 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 new file mode 100644 index 0000000..2c240bb --- /dev/null +++ b/docs/quick-start.md @@ -0,0 +1,25 @@ +# 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 new file mode 100644 index 0000000..9b3ab60 --- /dev/null +++ b/docs/release-verification.md @@ -0,0 +1,19 @@ +# Release verification + +## What a published 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. + +## Verification order + +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. + +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). + +!!! danger "Do not infer notarization" + + 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. diff --git a/docs/safety.md b/docs/safety.md new file mode 100644 index 0000000..691136c --- /dev/null +++ b/docs/safety.md @@ -0,0 +1,26 @@ +# Safety and privacy + +## 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. + +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. + +## Action classes + +| Class | Examples | Review boundary | +|---|---|---| +| 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 | + +The Action Palette exposes only operations currently eligible in the visible state and rechecks eligibility at activation. + +## 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. + +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). diff --git a/docs/screenshots/action-palette.png b/docs/screenshots/action-palette.png new file mode 100644 index 0000000..4527429 Binary files /dev/null and b/docs/screenshots/action-palette.png differ diff --git a/docs/screenshots/ecosystem-tools.png b/docs/screenshots/ecosystem-tools.png new file mode 100644 index 0000000..c66e0e5 Binary files /dev/null and b/docs/screenshots/ecosystem-tools.png differ diff --git a/docs/screenshots/mvt-analysis.png b/docs/screenshots/mvt-analysis.png new file mode 100644 index 0000000..5513164 Binary files /dev/null and b/docs/screenshots/mvt-analysis.png differ diff --git a/docs/screenshots/session-activity.png b/docs/screenshots/session-activity.png new file mode 100644 index 0000000..70658d1 Binary files /dev/null and b/docs/screenshots/session-activity.png differ diff --git a/docs/screenshots/xcode-handoff.png b/docs/screenshots/xcode-handoff.png new file mode 100644 index 0000000..2942e86 Binary files /dev/null and b/docs/screenshots/xcode-handoff.png differ diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..b4886be --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,36 @@ +: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 new file mode 100644 index 0000000..0d78cab --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,24 @@ +# Troubleshooting + +## Start with the failing layer + +| Visible symptom | First check | Next reference | +|---|---|---| +| 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) | + +## Use the built-in diagnostics + +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. + +The toolkit does not attempt to restart SIP-protected Apple services, delete pairing records, use `sudo`, or hide a failed prerequisite behind automatic recovery. + +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. diff --git a/ios_developer_toolkit/action_palette.py b/ios_developer_toolkit/action_palette.py new file mode 100644 index 0000000..f1d7027 --- /dev/null +++ b/ios_developer_toolkit/action_palette.py @@ -0,0 +1,165 @@ +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/app.py b/ios_developer_toolkit/app.py index 7395af1..abfb732 100644 --- a/ios_developer_toolkit/app.py +++ b/ios_developer_toolkit/app.py @@ -5,10 +5,11 @@ 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 Mapping +from typing import Literal, Mapping from PySide6.QtCore import QObject, QProcess, QProcessEnvironment, QRect, QTimer, QUrl, Qt, Signal from PySide6.QtGui import ( @@ -70,7 +71,14 @@ confirmation_phrase, guided_action_safety, ) -from ios_developer_toolkit.backup_protocol import BackupEvent, BackupRequestError, parse_backup_event +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, @@ -95,16 +103,40 @@ 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, @@ -134,6 +166,7 @@ format_byte_count, parse_installed_apps_json, ) +from ios_developer_toolkit.interactive_process import InteractiveProcessController from ios_developer_toolkit.ipa_inspector import ( IPAInspection, IPAInspectionError, @@ -169,6 +202,33 @@ ) 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, @@ -201,6 +261,26 @@ 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") @@ -210,7 +290,20 @@ 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: @@ -484,6 +577,60 @@ def __init__(self) -> None: 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__() @@ -505,9 +652,11 @@ def __init__(self) -> None: self._reconnect_timeout_timer = QTimer(self) self._reconnect_timeout_timer.setSingleShot(True) self._reconnect_timeout_timer.timeout.connect(self._reconnect_timed_out) - self._action_process: QProcess | None = None + 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._action_buffer = bytearray() self._capability_process: QProcess | None = None self._capability_stdout_buffer = bytearray() self._capability_stderr = bytearray() @@ -524,27 +673,58 @@ def __init__(self) -> None: self._compatibility_observations = load_observations(self._compatibility_history_path) except DeviceCompatibilityError as error: self._compatibility_history_error = str(error) - self._collection_process: QProcess | None = None - self._ipa_inspection_process: QProcess | None = None - self._ipa_inspection_stdout = bytearray() - self._ipa_inspection_stderr = bytearray() + 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_process: QProcess | 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._sideload_buffer = bytearray() - self._apps_process: QProcess | None = None + 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._apps_stdout = bytearray() - self._apps_stderr = bytearray() self._installed_apps: tuple[InstalledApp, ...] = () - self._backup_process: QProcess | None = None - self._backup_action = "" - self._backup_stdout = bytearray() - self._backup_stderr = bytearray() + 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, ...] = () @@ -567,7 +747,10 @@ def __init__(self) -> None: self._saved_locations = load_saved_locations(self._saved_locations_path) except LocationLabError as error: self._saved_locations_error = str(error) - self._console_process: QProcess | None = None + 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] = {} @@ -586,6 +769,8 @@ def __init__(self) -> None: 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() @@ -623,7 +808,7 @@ def _build_ui(self) -> None: title = QLabel("iOS Developer Toolkit") title.setObjectName("appTitle") title.setFont(QFont(title.font().family(), 24, QFont.Weight.Bold)) - subtitle = QLabel("pymobiledevice3 Swiss-army GUI • Developer images • diagnostics • evidence") + subtitle = QLabel("iOS developer workbench • pymobiledevice3 • diagnostics • evidence") subtitle.setObjectName("appSubtitle") title_block.addWidget(title) title_block.addWidget(subtitle) @@ -682,6 +867,32 @@ def _build_ui(self) -> None: 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) @@ -701,6 +912,7 @@ def _build_ui(self) -> None: ("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()), ) @@ -738,6 +950,22 @@ def _configure_accessibility(self) -> None: 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." @@ -780,15 +1008,27 @@ def _configure_accessibility(self) -> None: "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") @@ -805,6 +1045,7 @@ def _configure_keyboard_shortcuts(self) -> None: ("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"), ) @@ -842,6 +1083,7 @@ def navigate_to_page_and_focus(self, name: str) -> None: "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, } @@ -903,10 +1145,12 @@ def show_keyboard_shortcuts(self) -> None: "" "" "" + "" "" "" "" "" + "" "" "" "" @@ -925,6 +1169,591 @@ def show_keyboard_shortcuts(self) -> None: 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" @@ -971,6 +1800,13 @@ def _support_bundle_context(self) -> SupportBundleContext: 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 @@ -1121,11 +1957,41 @@ def _build_overview_tab(self) -> QWidget: 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 and Developer Mode command output appears here.") + 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 @@ -1229,6 +2095,14 @@ def _build_capability_matrix_page(self) -> QWidget: 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() @@ -1810,8 +2684,9 @@ def _build_backup_tab(self) -> QWidget: heading.setFont(QFont(heading.font().family(), 20, QFont.Weight.Bold)) layout.addWidget(heading) explanation = QLabel( - "Choose the built-in MobileBackup2 workflow or launch a separately installed UFADE forensic acquisition. " - "The providers use isolated runtimes and do not share passwords or dependencies." + "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) @@ -1820,8 +2695,10 @@ def _build_backup_tab(self) -> QWidget: 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 @@ -2059,6 +2936,192 @@ def _build_ufade_backup_page(self) -> QWidget: 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) @@ -2235,83 +3298,463 @@ def _build_command_center_page(self) -> QWidget: self._update_command_drift_controls() return page - def _build_manpages_page(self) -> QWidget: + def _build_external_tools_page(self) -> QWidget: page = QWidget() layout = QVBoxLayout(page) layout.setSpacing(12) - heading = QLabel("Man Pages & Possibilities") + heading = QLabel("Ecosystem Tools") 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." + "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 - 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) + 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 - 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 _external_tool_button_handler( + self, + identifier: ExternalToolIdentifier, + action: Callable[[ExternalToolIdentifier], None], + ) -> Callable[[bool], None]: + def handle(checked: bool) -> None: + del checked + action(identifier) - def _build_safety_tab(self) -> QWidget: - return build_safety_page(DEVELOPER_DISK_IMAGE_REPOSITORY) + 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()) @@ -2509,7 +3952,7 @@ def _displayed_device(self) -> IOSDevice | None: 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: + 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( @@ -2529,10 +3972,18 @@ def _update_device_fields(self, device: IOSDevice | None) -> None: else "Connect a trusted device, then refresh the inventory." ) enabled = device is not None and not self._demo_mode - self.mount_button.setEnabled(enabled) - self.remove_button.setEnabled(enabled) - self.start_collection_button.setEnabled(enabled and self._collection_process is None) - self.create_case_button.setEnabled(enabled and self._collection_process is None and self._active_case_path is None) + 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() @@ -2721,6 +4172,119 @@ def copy_compatibility_matrix(self) -> None: 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: @@ -3026,6 +4590,14 @@ def mount_selected_ddi(self) -> None: ("--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: @@ -3057,12 +4629,115 @@ 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 - self._start_action(self._pmd3, arguments, device_environment(device.identifier), context) + 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, @@ -3070,56 +4745,64 @@ def _start_action( arguments: tuple[str, ...], environment: Mapping[str, str], context: str, + timeout_milliseconds: int, + history_context: OperationContext, ) -> None: - if self._action_process is not None and self._action_process.state() != QProcess.ProcessState.NotRunning: - QMessageBox.warning(self, "Action Running", "Wait for the current DDI action to finish.") + 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_buffer.clear() - process = QProcess(self) - process.setProgram(str(program.program)) - process.setArguments(list(command_arguments(program, arguments))) - process.setProcessEnvironment(qprocess_environment(environment)) - process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels) - process.readyReadStandardOutput.connect(self._read_action_output) - process.finished.connect(self._action_finished) - process.errorOccurred.connect(self._action_error) - self._action_process = process self._action_context = context + self._begin_operation("device-and-ddi", history_context) self.mount_button.setEnabled(False) self.remove_button.setEnabled(False) - process.start() + 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 _read_action_output(self) -> None: - if self._action_process is not None: - text = bytes(self._action_process.readAllStandardOutput()).decode("utf-8", errors="replace") - self._action_buffer.extend(text.encode("utf-8")) - self.action_output.moveCursor(QTextCursor.MoveOperation.End) - self.action_output.insertPlainText(text) + 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_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: - del exit_status + 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 - self.action_output.appendPlainText(f"\n[finished: exit {exit_code}]\n") - semantic_failure = output_indicates_failure(bytes(self._action_buffer)) + 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": - recent_text = self.action_output.toPlainText().lower() - if exit_code == 0 and not semantic_failure and "true" in recent_text.split("$ ")[-1]: + if succeeded and b"true" in combined_output.lower(): self.developer_mode_status.setText("Developer Mode is enabled") - elif exit_code == 0 and not semantic_failure: + 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 exit_code == 0 and not semantic_failure and context.startswith("mount"): + elif succeeded and context.startswith("mount"): self.developer_mode_status.setText("Developer image operation completed successfully") - self._action_process = None + self._action_context = "" self._update_device_fields(self.selected_device()) - def _action_error(self, process_error: QProcess.ProcessError) -> None: - del process_error - if self._action_process is not None: - self.action_output.appendPlainText(f"\nProcess error: {self._action_process.errorString()}") - 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")) @@ -3795,7 +5478,7 @@ def create_guided_case(self) -> None: if device is None: self._show_no_device() return - if self._collection_process is not None: + 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: @@ -3838,7 +5521,7 @@ def start_collection(self) -> None: if device is None: self._show_no_device() return - if self._collection_process is not None: + 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() @@ -3849,7 +5532,12 @@ def start_collection(self) -> None: "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("Start Evidence Collection", warning): + 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: @@ -3867,57 +5555,88 @@ def start_collection(self) -> None: if enabled: arguments.append(flag) worker = worker_command("collector") - process = QProcess(self) - process.setProgram(str(worker.program)) - process.setArguments(list(command_arguments(worker, arguments))) - process.setProcessEnvironment(qprocess_environment(base_environment())) - process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels) - process.readyReadStandardOutput.connect(self._read_collection_output) - process.finished.connect(self._collection_finished) - process.errorOccurred.connect(self._collection_error) - self._collection_process = process 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) - process.start() + 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 _read_collection_output(self) -> None: - if self._collection_process is None: - return - text = bytes(self._collection_process.readAllStandardOutput()).decode("utf-8", errors="replace") + def _append_collection_output(self, output: bytes) -> None: self.collection_output.moveCursor(QTextCursor.MoveOperation.End) - self.collection_output.insertPlainText(text) - for line in text.splitlines(): - try: - record: object = json.loads(line) - except json.JSONDecodeError: - continue - if isinstance(record, dict) and record.get("event") == "case-created" and isinstance(record.get("path"), str): - self._last_case_path = Path(record["path"]) + 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_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: - del exit_status - self.collection_output.appendPlainText(f"\nCollection process finished with exit code {exit_code}.") - self._collection_process = None + 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: - 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 + 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) + self.create_case_button.setEnabled( + self.selected_device() is not None and self._active_case_path is None + ) self.stop_collection_button.setEnabled(False) - - def _collection_error(self, process_error: QProcess.ProcessError) -> None: - del process_error - if self._collection_process is not None: - self.collection_output.appendPlainText(f"\nProcess error: {self._collection_process.errorString()}") + if self._close_after_collection: + QTimer.singleShot(0, self.close) def stop_collection(self) -> None: - if self._collection_process is not None: + if self._collection_controller.is_running(): self.collection_output.appendPlainText("\nRequesting a clean stop and evidence finalization…") - self._collection_process.terminate() + 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(): @@ -3942,48 +5661,50 @@ 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_process is not None: + 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_stdout.clear() - self._ipa_inspection_stderr.clear() 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") - process = QProcess(self) - process.setProgram(str(worker.program)) - process.setArguments(list(command_arguments(worker, (str(selected_ipa),)))) - process.setProcessEnvironment(qprocess_environment(base_environment())) - process.readyReadStandardOutput.connect(self._read_ipa_inspection_stdout) - process.readyReadStandardError.connect(self._read_ipa_inspection_stderr) - process.finished.connect(self._ipa_inspection_finished) - process.errorOccurred.connect(self._ipa_inspection_error) - self._ipa_inspection_process = process + 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() - process.start() - - def _read_ipa_inspection_stdout(self) -> None: - if self._ipa_inspection_process is not None: - self._ipa_inspection_stdout.extend(bytes(self._ipa_inspection_process.readAllStandardOutput())) - - def _read_ipa_inspection_stderr(self) -> None: - if self._ipa_inspection_process is not None: - self._ipa_inspection_stderr.extend(bytes(self._ipa_inspection_process.readAllStandardError())) - def _ipa_inspection_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: - del exit_status - self._read_ipa_inspection_stdout() - self._read_ipa_inspection_stderr() - stderr_text = self._ipa_inspection_stderr.decode("utf-8", errors="replace").strip() - if exit_code != 0: - message = stderr_text or f"IPA inspector exited with status {exit_code}" + 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) - self.sideload_status.setText("IPA inspection failed. Correct the package error before installation.") + 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(self._ipa_inspection_stdout.decode("utf-8")) + 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.") @@ -3998,21 +5719,13 @@ def _ipa_inspection_finished(self, exit_code: int, exit_status: QProcess.ExitSta self.sideload_status.setText( f"Installation is disabled because the extracted bundle signature is {inspection.signature.status}." ) - self._ipa_inspection_process = None self.ipa_inspection_progress.setVisible(False) self._update_sideload_controls() - def _ipa_inspection_error(self, process_error: QProcess.ProcessError) -> None: - del process_error - if self._ipa_inspection_process is not None: - self.ipa_inspection_summary.setPlainText( - f"Could not start IPA inspection: {self._ipa_inspection_process.errorString()}" - ) - def _update_sideload_controls(self) -> None: device_available = self.selected_device() is not None - action_running = self._sideload_process is not None - inspection_running = self._ipa_inspection_process 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) @@ -4064,63 +5777,69 @@ def _start_sideload_action(self, arguments: tuple[str, ...], context: str) -> No if device is None: self._show_no_device() return - if self._sideload_process is not None: + if self._sideload_controller.is_running(): QMessageBox.warning(self, "App Operation Running", "Stop or wait for the active app operation first.") return - self._sideload_buffer.clear() self._sideload_context = context self.sideload_output.appendPlainText(f"\n$ pymobiledevice3 {shlex.join(arguments)}\n") - 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.readyReadStandardOutput.connect(self._read_sideload_output) - process.finished.connect(self._sideload_finished) - process.errorOccurred.connect(self._sideload_error) - self._sideload_process = process 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() - process.start() - def _read_sideload_output(self) -> None: - if self._sideload_process is None: - return - output = bytes(self._sideload_process.readAllStandardOutput()) - self._sideload_buffer.extend(output) + 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_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: - del exit_status - self._read_sideload_output() + 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(bytes(self._sideload_buffer)) - succeeded = exit_code == 0 and not semantic_failure - self.sideload_output.appendPlainText(f"\n[finished: exit {exit_code}]\n") - self.sideload_status.setText( - f"{context.capitalize()} completed successfully." - if succeeded - else f"{context.capitalize()} failed; review the complete command output above." - ) - self._sideload_process = None + 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 self._apps_process is None: + if succeeded and context == "install" and not self._apps_controller.is_running(): QTimer.singleShot(0, self.refresh_app_inventory) - def _sideload_error(self, process_error: QProcess.ProcessError) -> None: - del process_error - if self._sideload_process is not None: - self.sideload_output.appendPlainText(f"\nProcess error: {self._sideload_process.errorString()}") - def stop_sideload_action(self) -> None: - if self._sideload_process is not None: + if self._sideload_controller.is_running(): self.sideload_output.appendPlainText("\nRequesting app operation stop…") - self._sideload_process.terminate() + self._sideload_controller.cancel() def refresh_app_inventory(self) -> None: device = self.selected_device() @@ -4137,49 +5856,51 @@ def _start_apps_action(self, arguments: tuple[str, ...], context: str) -> None: if device is None: self._show_no_device() return - if self._apps_process is not None: + 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_stdout.clear() - self._apps_stderr.clear() self.apps_output.appendPlainText(f"\n$ pymobiledevice3 {shlex.join(arguments)}\n") - 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.readyReadStandardOutput.connect(self._read_apps_stdout) - process.readyReadStandardError.connect(self._read_apps_stderr) - process.finished.connect(self._apps_finished) - process.errorOccurred.connect(self._apps_error) - self._apps_process = process 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() - process.start() - def _read_apps_stdout(self) -> None: - if self._apps_process is not None: - self._apps_stdout.extend(bytes(self._apps_process.readAllStandardOutput())) - - def _read_apps_stderr(self) -> None: - if self._apps_process is None: - return - output = bytes(self._apps_process.readAllStandardError()) - self._apps_stderr.extend(output) + 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_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: - del exit_status - self._read_apps_stdout() - self._read_apps_stderr() + 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 = bytes(self._apps_stdout + self._apps_stderr) - succeeded = exit_code == 0 and not output_indicates_failure(combined_output) + 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(self._apps_stdout.decode("utf-8")) + 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}") @@ -4190,22 +5911,24 @@ def _apps_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> No elif succeeded and context == "uninstall": self.apps_status.setText("Application uninstalled successfully. Refreshing inventory…") if not succeeded: - stdout_text = self._apps_stdout.decode("utf-8", errors="replace").strip() + stdout_text = result_object.stdout.decode("utf-8", errors="replace").strip() if stdout_text: self.apps_output.appendPlainText(stdout_text) - self.apps_status.setText(f"{context.capitalize()} failed; review the output below.") - self.apps_output.appendPlainText(f"[finished: exit {exit_code}]\n") - self._apps_process = None + 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 _apps_error(self, process_error: QProcess.ProcessError) -> None: - del process_error - if self._apps_process is not None: - self.apps_output.appendPlainText(f"Process error: {self._apps_process.errorString()}") - def _populate_installed_apps(self, apps: tuple[InstalledApp, ...]) -> None: self.installed_apps_table.setSortingEnabled(False) self.installed_apps_table.setRowCount(len(apps)) @@ -4248,7 +5971,7 @@ def _installed_app_selection_changed(self) -> None: self._update_apps_controls() def _update_apps_controls(self) -> None: - running = self._apps_process is not 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) @@ -4293,9 +6016,9 @@ def uninstall_selected_application(self) -> None: self._start_apps_action(("apps", "uninstall", bundle_identifier), "uninstall") def stop_apps_action(self) -> None: - if self._apps_process is not None: + if self._apps_controller.is_running(): self.apps_output.appendPlainText("Requesting app operation stop…") - self._apps_process.terminate() + self._apps_controller.cancel() def choose_backup_destination(self) -> None: selected = QFileDialog.getExistingDirectory( @@ -4322,19 +6045,15 @@ def check_backup_encryption(self) -> None: except BackupRequestError as error: QMessageBox.critical(self, "Invalid Backup Destination", str(error)) return - request: dict[str, str | bool] = { - "udid": device.identifier, - "destination": str(destination), - "require_encryption": False, - "new_password": "", - "full": False, - } + 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 self._backup_process is None and self.selected_device() is not None + 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) @@ -4390,84 +6109,55 @@ def start_backup(self) -> None: 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: dict[str, str | bool] = { - "udid": device.identifier, - "destination": str(destination), - "require_encryption": require_encryption, - "new_password": password, - "full": self.full_backup_checkbox.isChecked(), - } + 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: str, request: dict[str, str | bool]) -> None: - if self._backup_process is not None: + 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 - self._backup_stdout.clear() - self._backup_stderr.clear() worker = worker_command("backup") - process = QProcess(self) - process.setProgram(str(worker.program)) - process.setArguments(list(command_arguments(worker, (action,)))) - process.setProcessEnvironment(qprocess_environment(base_environment())) - process.readyReadStandardOutput.connect(self._read_backup_stdout) - process.readyReadStandardError.connect(self._read_backup_stderr) - process.finished.connect(self._backup_finished) - process.errorOccurred.connect(self._backup_error) - self._backup_process = process 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() - process.start() - if not process.waitForStarted(3000): - self.backup_output.appendPlainText(f"Could not start backup helper: {process.errorString()}") - self._backup_process = None - self._backup_action = "" - self._update_backup_controls() - return - request_payload = json.dumps(request).encode("utf-8") - accepted_bytes = process.write(request_payload) - if accepted_bytes != len(request_payload): - process.kill() - process.waitForFinished(3000) - self._backup_process = None - self._backup_action = "" - self._update_backup_controls() - message = f"Backup helper accepted {accepted_bytes} of {len(request_payload)} request bytes." - self.backup_output.appendPlainText(message) - QMessageBox.critical( - self, - "Backup Request Failed", - f"{message}\nNo backup operation was started.", - ) - return - process.closeWriteChannel() - - def _read_backup_stdout(self) -> None: - if self._backup_process is None: - return - self._backup_stdout.extend(bytes(self._backup_process.readAllStandardOutput())) - while b"\n" in self._backup_stdout: - line, _, remainder = self._backup_stdout.partition(b"\n") - self._backup_stdout = bytearray(remainder) - if line.strip(): - self._handle_backup_event_line(line) - - def _handle_backup_event_line(self, line: bytes) -> None: - try: - event = parse_backup_event(line.decode("utf-8")) - except (BackupRequestError, json.JSONDecodeError, UnicodeDecodeError) as error: - self.backup_output.appendPlainText(f"Invalid backup helper event: {error}") - return - self._handle_backup_event(event) - def _handle_backup_event(self, event: BackupEvent) -> None: + 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))) @@ -4478,41 +6168,41 @@ def _handle_backup_event(self, event: BackupEvent) -> None: 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 _read_backup_stderr(self) -> None: - if self._backup_process is None: - return - output = bytes(self._backup_process.readAllStandardError()) - self._backup_stderr.extend(output) + 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_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: - del exit_status - self._read_backup_stdout() - self._read_backup_stderr() - if self._backup_stdout.strip(): - self._handle_backup_event_line(bytes(self._backup_stdout)) - self._backup_stdout.clear() + 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 exit_code == 0: + 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: - self.backup_output.appendPlainText(f"{action.capitalize()} failed with exit code {exit_code}.") - self._backup_process = None - self._backup_action = "" + 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 _backup_error(self, process_error: QProcess.ProcessError) -> None: - del process_error - if self._backup_process is not None: - self.backup_output.appendPlainText(f"Process error: {self._backup_process.errorString()}") - def _update_backup_controls(self) -> None: - running = self._backup_process is not 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) @@ -4523,14 +6213,18 @@ def _update_backup_controls(self) -> None: 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_process is not None: - self.backup_output.appendPlainText( - "Stopping the backup. The partial destination may be incomplete and will not be treated as valid incremental state." - ) - self._backup_process.terminate() + 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: @@ -4764,6 +6458,393 @@ def open_ufade_output_directory(self) -> None: 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() @@ -4899,7 +6980,7 @@ def _update_command_preview(self) -> None: self._update_command_controls() def _update_command_controls(self) -> None: - running = self._console_process is not None + running = self._console_controller.is_running() preset = self._current_preset preset_valid = False if preset is not None: @@ -5018,7 +7099,7 @@ def open_selected_preset_help(self) -> None: self.select_manpage_path(preset.manpage_path) def run_console_command(self) -> None: - if self._console_process is not None: + if self._console_controller.is_running(): QMessageBox.warning(self, "Command Running", "Stop the active console command first.") return try: @@ -5054,7 +7135,7 @@ def _run_console_arguments( requires_device: bool, profile: ActionSafetyProfile, ) -> None: - if self._console_process is not None: + if self._console_controller.is_running(): QMessageBox.warning(self, "Command Running", "Stop the active console command first.") return device = self.selected_device() @@ -5063,19 +7144,27 @@ def _run_console_arguments( 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") - process = QProcess(self) - process.setProgram(str(self._pmd3.program)) - process.setArguments(list(command_arguments(self._pmd3, arguments))) - process.setWorkingDirectory(str(Path.home())) environment = base_environment() if device is None else device_environment(device.identifier) - process.setProcessEnvironment(qprocess_environment(environment)) - process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels) - process.readyReadStandardOutput.connect(self._read_console_output) - process.finished.connect(self._console_finished) - process.errorOccurred.connect(self._console_error) - self._console_process = process + 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() - process.start() def _advanced_command_can_run_without_device(self, arguments: tuple[str, ...]) -> bool: prefixes = ( @@ -5086,30 +7175,25 @@ def _advanced_command_can_run_without_device(self, arguments: tuple[str, ...]) - ) return any(arguments[: len(prefix)] == prefix for prefix in prefixes) - def _read_console_output(self) -> None: - if self._console_process is not None: - text = bytes(self._console_process.readAllStandardOutput()).decode("utf-8", errors="replace") - self.console_output.moveCursor(QTextCursor.MoveOperation.End) - self.console_output.insertPlainText(text) + 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_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: - del exit_status - self.console_output.appendPlainText(f"\n[finished: exit {exit_code}]") - self._console_process = None + 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 _console_error(self, process_error: QProcess.ProcessError) -> None: - if self._console_process is not None: - self.console_output.appendPlainText(f"\nProcess error: {self._console_process.errorString()}") - if process_error == QProcess.ProcessError.FailedToStart: - self._console_process = None - self._update_command_controls() - def stop_console_command(self) -> None: - if self._console_process is not None: + if self._console_controller.is_running(): self.console_output.appendPlainText("\nRequesting command stop…") - self._console_process.terminate() + self._console_controller.cancel() def start_command_drift_check(self) -> None: if self._command_drift_session_active: @@ -5314,6 +7398,15 @@ def refresh_selected_manpage(self) -> None: "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, @@ -5328,6 +7421,7 @@ def refresh_selected_manpage(self) -> None: 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 @@ -5609,32 +7703,63 @@ def closeEvent(self, event: QCloseEvent) -> None: 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._action_process, - self._collection_process, - self._sideload_process, - self._apps_process, - self._backup_process, - self._console_process, - self._location_process, - ) + for process in (self._location_process,) if process is not None and process.state() != QProcess.ProcessState.NotRunning ) - if critical_processes: + 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, Location Lab, or Command Center operation is still running. " + "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) @@ -5648,9 +7773,6 @@ def closeEvent(self, event: QCloseEvent) -> None: if not process.waitForFinished(10000): process.kill() process.waitForFinished(3000) - for process in (self._ipa_inspection_process,): - if process is not None and process.state() != QProcess.ProcessState.NotRunning: - process.terminate() event.accept() diff --git a/ios_developer_toolkit/backup_process.py b/ios_developer_toolkit/backup_process.py new file mode 100644 index 0000000..650c51f --- /dev/null +++ b/ios_developer_toolkit/backup_process.py @@ -0,0 +1,234 @@ +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 index 3767978..510354f 100644 --- a/ios_developer_toolkit/backup_protocol.py +++ b/ios_developer_toolkit/backup_protocol.py @@ -3,6 +3,10 @@ import json from dataclasses import dataclass from pathlib import Path +from typing import Literal + + +BackupAction = Literal["status", "backup"] class BackupRequestError(ValueError): @@ -27,6 +31,32 @@ class BackupEvent: 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") diff --git a/ios_developer_toolkit/backup_worker.py b/ios_developer_toolkit/backup_worker.py index aae53ec..ca0fce1 100644 --- a/ios_developer_toolkit/backup_worker.py +++ b/ios_developer_toolkit/backup_worker.py @@ -4,17 +4,14 @@ import asyncio import json import sys -from typing import TYPE_CHECKING, Literal, TextIO +from typing import TYPE_CHECKING, TextIO -from ios_developer_toolkit.backup_protocol import BackupRequest, BackupRequestError, parse_backup_request +from ios_developer_toolkit.backup_protocol import BackupAction, BackupRequest, BackupRequestError, parse_backup_request if TYPE_CHECKING: from pymobiledevice3.lockdown import LockdownClient -BackupAction = Literal["status", "backup"] - - def emit_event( event: str, message: str, diff --git a/ios_developer_toolkit/catalog.py b/ios_developer_toolkit/catalog.py index 40ded2f..075913b 100644 --- a/ios_developer_toolkit/catalog.py +++ b/ios_developer_toolkit/catalog.py @@ -54,7 +54,10 @@ 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"), diff --git a/ios_developer_toolkit/collection_process.py b/ios_developer_toolkit/collection_process.py new file mode 100644 index 0000000..4842f12 --- /dev/null +++ b/ios_developer_toolkit/collection_process.py @@ -0,0 +1,221 @@ +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 new file mode 100644 index 0000000..6bb89e7 --- /dev/null +++ b/ios_developer_toolkit/collection_protocol.py @@ -0,0 +1,64 @@ +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/device_compatibility.py b/ios_developer_toolkit/device_compatibility.py index 3ff2c65..71977a0 100644 --- a/ios_developer_toolkit/device_compatibility.py +++ b/ios_developer_toolkit/device_compatibility.py @@ -1,14 +1,18 @@ 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): @@ -28,6 +32,28 @@ class DeviceCompatibilityObservation: 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() @@ -174,3 +200,174 @@ def latest_observations( 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 index 7320e45..997c2e3 100644 --- a/ios_developer_toolkit/entrypoint.py +++ b/ios_developer_toolkit/entrypoint.py @@ -2,14 +2,20 @@ 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, ) @@ -74,10 +80,15 @@ 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 - from PySide6.QtWidgets import QApplication, QLabel, QPushButton + 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() @@ -93,6 +104,21 @@ def run_smoke_test(arguments: Sequence[str]) -> int: 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", @@ -161,14 +187,154 @@ def run_smoke_test(arguments: Sequence[str]) -> int: 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", } @@ -184,6 +350,16 @@ def run_smoke_test(arguments: Sequence[str]) -> int: 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") @@ -200,6 +376,208 @@ def run_smoke_test(arguments: Sequence[str]) -> int: 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) diff --git a/ios_developer_toolkit/external_tools.py b/ios_developer_toolkit/external_tools.py new file mode 100644 index 0000000..ef1d17c --- /dev/null +++ b/ios_developer_toolkit/external_tools.py @@ -0,0 +1,247 @@ +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 new file mode 100644 index 0000000..e2acddb --- /dev/null +++ b/ios_developer_toolkit/file_integrity.py @@ -0,0 +1,12 @@ +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 index df8b3b0..7bce10d 100644 --- a/ios_developer_toolkit/gui_pages.py +++ b/ios_developer_toolkit/gui_pages.py @@ -250,7 +250,7 @@ def toolkit_stylesheet() -> str: #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 { 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/interactive_process.py b/ios_developer_toolkit/interactive_process.py new file mode 100644 index 0000000..60a6c73 --- /dev/null +++ b/ios_developer_toolkit/interactive_process.py @@ -0,0 +1,171 @@ +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/mvt_connector.py b/ios_developer_toolkit/mvt_connector.py new file mode 100644 index 0000000..dab8e3a --- /dev/null +++ b/ios_developer_toolkit/mvt_connector.py @@ -0,0 +1,269 @@ +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 new file mode 100644 index 0000000..9963e64 --- /dev/null +++ b/ios_developer_toolkit/operation_history.py @@ -0,0 +1,347 @@ +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/support_bundle.py b/ios_developer_toolkit/support_bundle.py index cfe0638..087eb54 100644 --- a/ios_developer_toolkit/support_bundle.py +++ b/ios_developer_toolkit/support_bundle.py @@ -88,8 +88,8 @@ def _support_entries(context: SupportBundleContext) -> tuple[tuple[str, str], .. "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"), + "pymobiledevice3_version": installed_package_version("pymobiledevice3"), + "pyside6_version": installed_package_version("PySide6"), } context_document: dict[str, JsonDocumentValue] = { "workspace": context.workspace, @@ -145,7 +145,9 @@ 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: +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: diff --git a/ios_developer_toolkit/workspace_profile.py b/ios_developer_toolkit/workspace_profile.py new file mode 100644 index 0000000..3ea79e1 --- /dev/null +++ b/ios_developer_toolkit/workspace_profile.py @@ -0,0 +1,363 @@ +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 new file mode 100644 index 0000000..ea21b20 --- /dev/null +++ b/ios_developer_toolkit/xcode_handoff.py @@ -0,0 +1,73 @@ +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/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..89a93cb --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,61 @@ +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/pyproject.toml b/pyproject.toml index a3b5cc4..1b2c01a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" 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" +requires-python = ">=3.10,<3.14" license = "MIT" dependencies = [ "PySide6-Essentials==6.9.3", diff --git a/requirements/docs.txt b/requirements/docs.txt new file mode 100644 index 0000000..d3504c7 --- /dev/null +++ b/requirements/docs.txt @@ -0,0 +1 @@ +mkdocs-material==9.7.7 diff --git a/script/build_and_run.sh b/script/build_and_run.sh index 6afe042..03c7d1e 100755 --- a/script/build_and_run.sh +++ b/script/build_and_run.sh @@ -21,12 +21,99 @@ stop_existing() { done < <(pgrep -f "$PROCESS_PATTERN" || true) } -build_app() { - if [[ ! -x "$VENV_DIR/bin/python" ]]; then - python3 -m venv "$VENV_DIR" +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 - if ! "$VENV_DIR/bin/python" -c 'import PySide6; import pymobiledevice3' >/dev/null 2>&1; then - "$VENV_DIR/bin/python" -m pip install --disable-pip-version-check --quiet "$PROJECT_DIR" + "$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" diff --git a/tests/test_action_palette.py b/tests/test_action_palette.py new file mode 100644 index 0000000..999dc9e --- /dev/null +++ b/tests/test_action_palette.py @@ -0,0 +1,62 @@ +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 new file mode 100644 index 0000000..297afc3 --- /dev/null +++ b/tests/test_backup_process.py @@ -0,0 +1,100 @@ +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 index 652d47d..17d8f23 100644 --- a/tests/test_capability_matrix.py +++ b/tests/test_capability_matrix.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import shutil import tempfile import unittest @@ -27,12 +28,18 @@ ) 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 @@ -161,6 +168,92 @@ def test_real_device_observation_rejects_duplicate_capabilities(self) -> None: 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() diff --git a/tests/test_collection_process.py b/tests/test_collection_process.py new file mode 100644 index 0000000..c860328 --- /dev/null +++ b/tests/test_collection_process.py @@ -0,0 +1,166 @@ +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 index c169e58..7282f70 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -129,6 +129,9 @@ def test_simulated_device_is_visibly_labeled_and_never_looks_like_usbmux_data(se 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"))) diff --git a/tests/test_external_tools.py b/tests/test_external_tools.py new file mode 100644 index 0000000..1b238f2 --- /dev/null +++ b/tests/test_external_tools.py @@ -0,0 +1,114 @@ +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 new file mode 100644 index 0000000..2d50943 --- /dev/null +++ b/tests/test_interactive_process.py @@ -0,0 +1,83 @@ +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_mvt_connector.py b/tests/test_mvt_connector.py new file mode 100644 index 0000000..cbc633a --- /dev/null +++ b/tests/test_mvt_connector.py @@ -0,0 +1,128 @@ +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 new file mode 100644 index 0000000..8d3ee7d --- /dev/null +++ b/tests/test_operation_history.py @@ -0,0 +1,91 @@ +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 index a447792..c5c76f0 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -5,6 +5,9 @@ import unittest from pathlib import Path +from packaging.specifiers import SpecifierSet +from packaging.version import Version + from ios_developer_toolkit import APP_VERSION @@ -12,6 +15,17 @@ 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"] diff --git a/tests/test_workspace_profile.py b/tests/test_workspace_profile.py new file mode 100644 index 0000000..f550f06 --- /dev/null +++ b/tests/test_workspace_profile.py @@ -0,0 +1,104 @@ +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 new file mode 100644 index 0000000..0cd2eee --- /dev/null +++ b/tests/test_xcode_handoff.py @@ -0,0 +1,83 @@ +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()
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