From 56d6da7a393a8bf6af6ac977c83924efdda858a7 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:17:57 -0700 Subject: [PATCH 1/8] Harden startup discovery and macOS release validation --- .github/workflows/ci.yml | 2 + .github/workflows/frozen-macos-smoke.yml | 60 +++++++ .github/workflows/release-macos.yml | 2 + CITATION.cff | 4 +- README.md | 27 +-- SOURCE_AVAILABILITY.md | 2 +- THIRD_PARTY_NOTICES.md | 4 +- docs/PRODUCT_AUDIT_2026-09-21.md | 169 ++++++++++++++++++ ios_developer_toolkit/__init__.py | 4 +- ios_developer_toolkit/app.py | 77 +++++++- ios_developer_toolkit/backup_protocol.py | 92 ++++++++++ ios_developer_toolkit/backup_worker.py | 98 +--------- .../connection_diagnostics.py | 61 +++++++ ios_developer_toolkit/entrypoint.py | 7 +- macos/Info.plist | 10 +- packaging/pysidedeploy.spec | 4 +- pyproject.toml | 6 +- scripts/build_macos_release.sh | 25 ++- scripts/verify_command_catalog.py | 68 +++++++ tests/test_core.py | 36 +++- tests/test_device_scanner.py | 78 ++++++++ tests/test_project_metadata.py | 57 ++++++ 22 files changed, 760 insertions(+), 133 deletions(-) create mode 100644 .github/workflows/frozen-macos-smoke.yml create mode 100644 docs/PRODUCT_AUDIT_2026-09-21.md create mode 100644 ios_developer_toolkit/backup_protocol.py create mode 100644 ios_developer_toolkit/connection_diagnostics.py create mode 100644 scripts/verify_command_catalog.py create mode 100644 tests/test_device_scanner.py create mode 100644 tests/test_project_metadata.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71ebe6b..48ab2d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,8 @@ jobs: python -m ios_developer_toolkit.local_ddi --help python -m ios_developer_toolkit.ipa_inspector --help python -m ios_developer_toolkit --toolkit-internal-pymobiledevice3 version + - name: Verify guided command catalog + run: python scripts/verify_command_catalog.py - name: Validate GUI actions env: QT_QPA_PLATFORM: offscreen diff --git a/.github/workflows/frozen-macos-smoke.yml b/.github/workflows/frozen-macos-smoke.yml new file mode 100644 index 0000000..3bfac4d --- /dev/null +++ b/.github/workflows/frozen-macos-smoke.yml @@ -0,0 +1,60 @@ +name: Frozen macOS smoke + +on: + pull_request: + paths: + - ".github/workflows/frozen-macos-smoke.yml" + - "ios_developer_toolkit/**" + - "macos/**" + - "packaging/**" + - "requirements/**" + - "scripts/build_macos_release.sh" + - "scripts/collect_third_party_licenses.py" + - "scripts/verify_release_metadata.py" + - "scripts/verify_command_catalog.py" + - "pyproject.toml" + - "tests/**" + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: frozen-macos-smoke-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-verify: + name: Build and verify ${{ matrix.architecture }} bundle + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + architecture: arm64 + python_architecture: arm64 + - runner: macos-15-intel + architecture: x86_64 + python_architecture: x64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.13" + architecture: ${{ matrix.python_architecture }} + cache: pip + - name: Build the frozen application and run embedded checks + env: + MACOSX_DEPLOYMENT_TARGET: "13.0" + run: | + release_version="$(python3 -c 'from ios_developer_toolkit import APP_VERSION; print(APP_VERSION)')" + ./scripts/build_macos_release.sh "$release_version" "$RUNNER_TEMP/release-smoke" python3 + - name: Confirm smoke artifacts exist + run: | + find "$RUNNER_TEMP/release-smoke" -maxdepth 1 -type f -name '*.zip' -size +0c -print -quit | grep -q . + find "$RUNNER_TEMP/release-smoke" -maxdepth 1 -type f -name '*.cdx.json' -size +0c -print -quit | grep -q . diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 9d4e39f..b529a93 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -30,6 +30,8 @@ jobs: architecture: ${{ matrix.python_architecture }} cache: pip - name: Build and verify native application + env: + MACOSX_DEPLOYMENT_TARGET: "13.0" run: ./scripts/build_macos_release.sh "${GITHUB_REF_NAME#v}" release-assets python3 - uses: actions/upload-artifact@v7 with: diff --git a/CITATION.cff b/CITATION.cff index 52f2b1a..26c818d 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -7,8 +7,8 @@ authors: repository-code: "https://github.com/hideouts-io/iOS-Developer-Toolkit" url: "https://github.com/hideouts-io/iOS-Developer-Toolkit/releases/latest" license: MIT -version: 0.3.1 -date-released: "2026-08-30" +version: 0.3.4 +date-released: "2026-09-21" abstract: "A safety-focused macOS workbench for Developer Disk Images, pymobiledevice3 and DVT diagnostics, iOS logs, packet capture, location simulation, app inspection, backups, and evidence preservation." keywords: - iOS development diff --git a/README.md b/README.md index bcebfa2..e612596 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ![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) ![GUI](https://img.shields.io/badge/GUI-PySide6-41cd52) -![pymobiledevice3](https://img.shields.io/badge/pymobiledevice3-10.11.0-8250df) +![pymobiledevice3](https://img.shields.io/badge/pymobiledevice3-11.15.1-8250df) [![License](https://img.shields.io/badge/license-MIT-2da44e)](LICENSE) > **Scope:** iOS Developer Toolkit is a macOS front end for authorized Apple-device development, diagnostics, testing, backup, and evidence-preservation workflows. It does not jailbreak iOS, bypass a passcode, disable the sandbox, defeat code signing, decrypt protected traffic, or provide unrestricted filesystem access. @@ -76,19 +76,21 @@ The application executes the project-pinned binary directly. Guided values becom ## Current release additions and visual tour -Release `v0.3.3` combines the complete 12-workspace interface with the latest connection, streaming, packaging, and repository-readiness work: +Release `v0.3.4` combines the complete 12-workspace interface with the latest connection, streaming, packaging, and repository-readiness work: - **Guided Command Drift** checks the live `pymobiledevice3 --help` surface for all 49 presets before a device command is run, highlighting missing routes, changed options, failed checks, and cancellations without contacting a device; - **Action Safety** makes state boundaries explicit: local-output actions require review, device changes require a typed device-bound `RUN` phrase, and high-impact actions additionally require a current-backup acknowledgement and an `IRREVERSIBLE` phrase; - **Create Support Bundle…** produces an opt-in local ZIP with sanitized environment, readiness, status, and command-drift metadata plus a SHA-256 manifest; it excludes device identity, captures, backups, logs, command output, credentials, and common host/network identifiers; - **Retry Scan** performs an immediate usbmux device check, while **Reconnect & Retry…** opens a guided detection window without attempting to restart SIP-protected Apple services; +- **Connection diagnostic** records whether usbmux did not launch, failed, returned malformed output, found no devices, or returned selectable devices; its privacy-safe summary is visible in Device & DDI and included in a sanitized support bundle; +- 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; - **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 72 tests, embedded CLI checks, an 89-button GUI smoke test, architecture inspection, strict code-signature validation, and one SHA-256 manifest; +- native Apple Silicon and Intel release ZIPs are built separately and verified with 77 tests, embedded CLI checks, an 89-button GUI smoke test, architecture 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. @@ -101,7 +103,7 @@ The README contains 17 sanitized screenshots. The six views below provide a quic |---|---|---| | [![Location Lab workspace](docs/screenshots/location-lab.png)](docs/screenshots/location-lab.png) | [![Backup workspace](docs/screenshots/backup.png)](docs/screenshots/backup.png) | [![Evidence Capture workspace](docs/screenshots/evidence-collection.png)](docs/screenshots/evidence-collection.png) | -### New in v0.3.3 +### New in v0.3.4 | Function | What it does | Safety boundary | |---|---|---| @@ -210,9 +212,9 @@ Current pinned runtime: |---|---| | Python | `>=3.10` | | PySide6 | `6.11.2` | -| pymobiledevice3 | `10.11.0` | +| pymobiledevice3 | `11.15.1` | | Local Xcode candidate | `/Library/Developer/CoreDevice/CandidateDDIs/iOS_DDI.dmg` | -| Toolkit release | `0.3.3` | +| Toolkit release | `0.3.4` | The current GUI and launcher are macOS-specific. Although upstream `pymobiledevice3` supports other host platforms, this application currently depends on macOS tools and conventions such as Xcode/CoreDevice, `hdiutil`, `security`, `codesign`, `.app` bundles, and macOS user-library paths. @@ -228,10 +230,10 @@ cd iOS-Developer-Toolkit ./script/build_and_run.sh ``` -For the published `v0.3.3` source state: +For the published `v0.3.4` source state: ```bash -git clone --branch v0.3.3 --depth 1 https://github.com/hideouts-io/iOS-Developer-Toolkit.git +git clone --branch v0.3.4 --depth 1 https://github.com/hideouts-io/iOS-Developer-Toolkit.git cd iOS-Developer-Toolkit ./script/build_and_run.sh ``` @@ -286,7 +288,7 @@ Extract the verified ZIP in Finder, or use: ```bash toolkit_arch="$(uname -m)" -toolkit_version="v0.3.3" +toolkit_version="v0.3.4" ditto -x -k "iOS-Developer-Toolkit-${toolkit_version}-macOS-${toolkit_arch}.zip" "iOS Developer Toolkit ${toolkit_version}" ``` @@ -919,6 +921,8 @@ Mounting a DDI, enabling Developer Mode, installing or uninstalling an app, chan The toolkit does not use `sudo`, delete pairing records, or restart SIP-protected Apple discovery agents or the root-owned `usbmuxd` service. If the iPhone is absent from both the macOS USB device tree and `usbmux list`, resolve the physical data connection before changing DDIs, tunnels, or developer services. +If `usbmux list` returns a device but the picker remains empty, use **Retry Scan** once more, then create a sanitized support bundle. The scanner retains stdout and stderr that become available only when its child process exits; the support bundle records the redacted connection state without including your UDID, pairing record, or command output. + ### Developer Mode is missing - pair the device in Xcode through **Window → Devices and Simulators**; @@ -987,6 +991,7 @@ 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_safety.py # typed confirmation policy for state-changing actions +│ ├── 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 @@ -1032,7 +1037,9 @@ The final launcher check opens the application and briefly verifies the process. ### 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 72 tests, verifies the embedded pymobiledevice3 command, checks the internal worker route, runs the 89-button offscreen GUI smoke test, verifies the Mach-O architecture, 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 77 tests, verifies the embedded pymobiledevice3 command, checks the internal worker route, runs the 89-button offscreen GUI smoke test, verifies the Mach-O architecture and its macOS 13.0 load-command floor, 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 a bundle whose executable targets a newer macOS version, so local release builds should use a Python toolchain that can produce macOS 13 binaries; GitHub release CI supplies this target explicitly. The artifacts are not universal binaries: choose the ZIP matching `uname -m`. They are also not Developer ID signed or Apple-notarized because this repository has no release signing identity. A future signing upgrade should use a narrowly scoped Developer ID Application certificate, hardened runtime, Apple notarization, and stapling without changing the two-architecture verification gates. diff --git a/SOURCE_AVAILABILITY.md b/SOURCE_AVAILABILITY.md index 5b8feca..a8f021d 100644 --- a/SOURCE_AVAILABILITY.md +++ b/SOURCE_AVAILABILITY.md @@ -10,6 +10,6 @@ The prebuilt application is accompanied by an architecture-specific CycloneDX SB ## Bundled third-party source -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/v10.11.0), 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. +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. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 868a3a6..68adf5c 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -6,9 +6,9 @@ The prebuilt macOS application contains or is built from the following release-c | Component | Pinned release | Role | Declared license | Source and license information | |---|---:|---|---|---| -| [pymobiledevice3](https://github.com/doronz88/pymobiledevice3) | 10.11.0 | Bundled Apple-device protocol implementation and command surface | GPL-3.0-or-later | [Source for 10.11.0](https://github.com/doronz88/pymobiledevice3/tree/v10.11.0) and [license](https://github.com/doronz88/pymobiledevice3/blob/v10.11.0/LICENSE) | +| [pymobiledevice3](https://github.com/doronz88/pymobiledevice3) | 11.15.1 | Bundled Apple-device protocol implementation and command surface | GPL-3.0-or-later | [Source for 11.15.1](https://github.com/doronz88/pymobiledevice3/tree/v11.15.1) and [license](https://github.com/doronz88/pymobiledevice3/blob/v11.15.1/LICENSE) | | [PySide6](https://doc.qt.io/qtforpython-6/) and Shiboken6 | 6.11.2 | Bundled Qt for Python GUI and bindings | LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only, as declared by the installed wheels | [Qt for Python source](https://code.qt.io/cgit/pyside/pyside-setup.git/tag/?h=v6.11.2) and [Qt licensing](https://www.qt.io/licensing/open-source-lgpl-obligations) | -| [Nuitka](https://github.com/Nuitka/Nuitka) | 4.1.1 | Release compiler; generated applications contain separately licensed Nuitka runtime material | Compiler: GNU AGPL v3; runtime terms are supplied by Nuitka in `LICENSE-RUNTIME.txt` | [Source for 4.1.1](https://github.com/Nuitka/Nuitka/tree/4.1.1) | +| [Nuitka](https://github.com/Nuitka/Nuitka) | 4.2.1 | Release compiler; generated applications contain separately licensed Nuitka runtime material | Compiler: GNU AGPL v3; runtime terms are supplied by Nuitka in `LICENSE-RUNTIME.txt` | [Source for 4.2.1](https://github.com/Nuitka/Nuitka/tree/4.2.1) | | [CPython](https://github.com/python/cpython) | GitHub runner's Python 3.13 patch release | Bundled Python runtime | Python Software Foundation License Version 2 | [Source and license](https://github.com/python/cpython/blob/3.13/LICENSE) | Each architecture-specific release also contains: diff --git a/docs/PRODUCT_AUDIT_2026-09-21.md b/docs/PRODUCT_AUDIT_2026-09-21.md new file mode 100644 index 0000000..1aef6da --- /dev/null +++ b/docs/PRODUCT_AUDIT_2026-09-21.md @@ -0,0 +1,169 @@ +# Product audit — 2026-09-21 + +## Executive assessment + +iOS Developer Toolkit has a stronger foundation than its small version number suggests. It is a macOS PySide6 desktop application that turns a deliberately curated subset of `pymobiledevice3`, Xcode/CoreDevice, Developer Disk Image, RVI, backup, and evidence-preservation workflows into guided operations. Its differentiators are its explicit authorization boundaries, local-first evidence handling, typed acknowledgement for device-changing work, capability matrix, device compatibility observations, investigation-oriented live-log windows, and release artifacts with SBOMs and provenance. + +Its primary product risk was reliability at the first screen. At audit start, the application imported the MobileBackup2 transport implementation while constructing the desktop UI, so a slow or damaged third-party transport import could prevent the interface from becoming available even though backup was not being used. Separately, `DeviceScanner` only consumed `QProcess` output from readiness signals and did not consume bytes still available when the child exited. That created a confirmed race: a packaged build could successfully run `pymobiledevice3 usbmux list` but parse an empty discovery buffer. The P0 implementation delivered with this audit moves transport imports into the backup worker and drains completion output before parsing; it also adds a deterministic fast-exit regression test. + +The correct next investment is therefore a **reliable startup and device-discovery foundation**, not another device command. It makes the existing workbench usable for beginners, gives experts dependable process semantics, and establishes the abstraction needed before further QProcess-heavy workflows are added. + +## What exists today + +The product has 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 repository is a Python 3.10+ PySide6 project with a bundled `pymobiledevice3` runtime model. `ios_developer_toolkit/app.py` is a 5,600+ line `MainWindow`, while domain modules cover capability probing, collectors, live logs, location testing, IPA inspection, support bundles, and device compatibility. CI runs unit tests, compile checks, CLI help checks, and a headless GUI smoke test on macOS. Tagged release CI produces Apple Silicon and Intel bundles, CycloneDX SBOMs, checksums, and GitHub attestations. The app is ad-hoc signed, not Developer ID signed or notarized. + +## Strengths worth protecting + +* The command catalog is declarative, reviewed, parameter-validated, and avoids feeding guided fields into a shell. +* The capability matrix makes the iOS developer stack legible: trust, Developer Mode, DDI, RSD, CoreDevice, DVT, lock state, and Web Inspector are distinguished rather than collapsed into “device failed.” +* The safety model appropriately classifies host writes, device changes, and high-impact operations, and binds acknowledgement phrases to the selected target. +* Live Logs is notably better than a terminal wrapper: it separates raw capture from rendered filtering, supports annotations as analyst claims rather than facts, preserves hashes, and explains capture boundaries. +* Evidence cases use restrictive local permissions, store a local authorization acknowledgement, and state their chain-of-custody limits plainly. +* The sanitized support bundle intentionally excludes identifiers, pairing material, raw captures, and user-entered values. +* The product already has a real-device compatibility observation format that fingerprints a device rather than retaining its raw UDID. +* Release engineering is unusually good for a young desktop project: dual architecture builds, SBOMs, third-party notices, checksums, and build provenance are present. + +## Weaknesses and user impact + +| Finding | User impact | Priority | +| --- | --- | --- | +| GUI startup imported `pymobiledevice3.lockdown` through `backup_worker` before Backup was opened. | A failure in one optional subsystem could block all workflows. Fixed in this audit by moving transport imports to the worker execution path. | Resolved P0 | +| `DeviceScanner` did not drain final `QProcess` stdout/stderr in its completion handler. | A connected device could be invisible in the packaged UI despite the bundled CLI returning valid JSON. Fixed with completion-time draining and a real fast-exit QProcess regression test. | Resolved P0 | +| The repository pinned `pymobiledevice3==10.11.0` while a clean Dependabot PR existed for 11.12.4 and upstream had newer releases. | The app missed modern iOS tunnel fixes and could present stale command assumptions. Fixed with a validated upgrade to 11.15.1: the full test suite, GUI smoke test, CLI discovery, and all 49 live-help routes passed. | Resolved P0 | +| `MainWindow` owns dozens of process/buffer/timer lifecycles. | Completion, cancellation, timeout, and output handling can diverge across workspaces; the scanner defect is evidence of that risk. | P1 | +| Release-only packaging is validated only after a tag is pushed. | A frozen-app regression can escape pull-request CI. | P1 | +| Source `macos/Info.plist` exposes an older version than `pyproject.toml`; release CI corrects it later. | Local app testing can be confusing and screenshots can show stale metadata. | P1 | +| The test suite is mainly pure-function/unit coverage and a structural GUI smoke test. | It now exercises a fast-exit discovery result and launch failure, but still needs shared operation cancellation/relaunch coverage beyond those paths. | P1 | +| The first-run experience assumes familiarity with DDI, RSD, and CoreDevice. | Beginners receive good instructions, but not a single coherent “make my device ready” decision flow. | P1 | +| The README is extensive but is the dominant documentation surface. | It is difficult to keep operational recipes, scope boundaries, architecture, release verification, and contributor guidance discoverable. | P2 | + +## Beginner UX audit + +The first screen has strong visual hierarchy and a useful six-step map, but it asks a new user to understand multiple Apple service layers before confirming the one prerequisite that matters: “Can this Mac see and trust my device?” A first-run assistant should remain optional, but should reduce the path to: connect → unlock/trust → verify connection → enable Developer Mode if needed → choose whether a task needs a DDI → run a safe first action. + +The toolkit should keep its advanced vocabulary, but display it progressively. “RSD tunnel” is useful evidence for an expert; for a beginner it should be introduced as the iOS 17+ developer connection path, with the exact observed status and a one-click non-destructive recheck. The current reconnect guidance is careful not to restart SIP-protected/root-owned services, which is correct and should remain a hard boundary. + +## Expert UX audit + +Experts need less prose and better state correlation. The next UI layer should expose a compact operation record for every command: target, transport, exact argv, start/end time, exit status, timeout/cancel reason, output paths, hashes, and prerequisite states. Existing Live Logs and Evidence Capture show the right pattern, but it is not shared by Command Center, DDI, app, and backup operations. Experts also need a clear distinction between an upstream command being available in live help, a device service being advertised, and a particular operation having completed successfully. + +## Missing product categories + +The toolkit intentionally does not need to become an IDE, jailbreak suite, MDM, spyware scanner, signing service, or remote device farm. It can, however, become more useful in five bounded areas: + +1. A shared diagnostic/remediation engine that maps an operation to explicit prerequisites and reruns only the checks relevant to that operation. +2. A centralized operation lifecycle service for QProcess/subprocess work, with start, final-drain, cancellation, timeout, structured result, and copyable support record semantics. +3. A project-oriented developer workflow that can hand off to Xcode tools for test destinations, `.xcresult` inspection, and selected `devicectl` operations without pretending to replace Xcode. +4. A scoped ecosystem handoff layer: MVT for consented backup analysis, `ipsw` for firmware research, and configurable external tool adapters rather than bundled forks. +5. A device-lab/compatibility contribution path that can export redacted, opt-in capability observations and reproduce upstream `pymobiledevice3` bugs with a standard report. + +## Architecture and maintainability audit + +The project has good domain modules, immutable data classes, clear validation errors, and a runtime wrapper that makes frozen builds invoke internal workers safely. The central weakness is orchestration concentration. `MainWindow` manages process ownership, byte buffers, timers, error mapping, UI enablement, and output rendering for many unrelated workflows. That makes process behavior difficult to test and encourages near-duplicate cleanup logic. + +The target architecture is not a wholesale framework rewrite. Keep PySide6 and the current declarative catalog. Introduce small domain-level operation records and a reusable Qt process controller, then migrate one workflow at a time. UI builders should consume typed readiness and operation results rather than parse child-process bytes. The first change in this direction is to ensure GUI import paths do not import transport-specific worker dependencies and that discovery always consumes terminal output. + +## Reliability, testing, and release audit + +The existing CI/release pipeline is a substantial strength. Its gap is placement: tagged releases build the frozen app, but ordinary pull requests only test source. Add a scheduled or opt-in release-smoke workflow that builds one native frozen artifact and verifies the internal CLI, worker, GUI smoke path, bundle metadata, license inventory, and SBOM. Keep both full architecture builds for releases. + +The highest-value test additions are deterministic process-lifecycle tests: a process that writes valid discovery JSON and exits before readiness delivery; non-zero process errors with stderr only available at exit; cancellation while an operation is active; and clean relaunch without inheriting stale state. A physical-device matrix should remain opt-in, explicitly labeled, and never required to merge a change. + +## Security, privacy, and distribution audit + +The app’s local-first posture is credible: no analytics, no cloud account, and support bundles are reviewed for data minimization. Improve it by surfacing a privacy inventory in the UI, documenting retention paths by workflow, and requiring review before any future export/upload integration. Do not collect telemetry by default. + +Distribution remains the largest trust hurdle. A Developer ID certificate and notarization are unavailable without an Apple Developer Program membership, so the correct present posture is transparent ad-hoc signing, dual architecture artifacts, checksums, SBOMs, provenance, source reproducibility, and precise Gatekeeper instructions. Do not imply that ad-hoc signing makes the app generally trusted. When a signing identity becomes available, add notarized Developer ID releases and an automated post-notarization assessment step. + +## Ecosystem map and integration strategy + +| Project/tool | What it offers | Recommendation | +| --- | --- | --- | +| `pymobiledevice3` | Core cross-platform protocol library/CLI: discovery, tunnels, DDI/DVT, logs, PCAP, backups, apps, Web Inspector. | Primary dependency. Upgrade deliberately, keep live-help drift checks, and contribute minimal reproducible protocol or CLI fixes upstream. | +| Xcode `devicectl`, `simctl`, `xctrace`, `rvictl` | Apple-supported macOS device, simulator, trace, and RVI tooling. | Prefer for macOS-native actions; show exact preconditions and hand off rather than reimplementing Xcode. | +| `libimobiledevice` | Mature cross-platform device library/CLIs for backup, syslog, crash reports, screenshot, pairing, and image mounting. | Optional external adapter only. It overlaps with the current core and adds LGPL/GPL packaging complexity. | +| `go-ios` | Cross-platform static CLI/library, JSON output, app/UI test and accessibility tooling, optional REST API. | Learn from its JSON and device-lab design. Evaluate a user-configured adapter after a stable operation framework; do not bundle a second protocol stack now. | +| Facebook `idb` | Simulator/device automation via a macOS companion and remote client. | Do not embed. Offer documented interoperability for teams already using it; its private-framework and companion model is a separate product surface. | +| MVT | Consented mobile-forensics analysis of iOS backups and IOC checking with its own forensic scope/license. | Add a guided handoff/export later, not an embedded scanner. Do not make “clean” claims or weaken its warning model. | +| `blacktop/ipsw` | Firmware/OTA research, device database, kernel/dyld analysis. | Document as an external firmware-research companion. Do not turn this GUI into an IPSW reverse-engineering suite. | + +Upstream contribution candidates are concrete: report the fast-exit scanner packaging behavior as a Qt application lifecycle pattern if it reproduces outside this project; test the current `pymobiledevice3` upgrade against the toolkit command catalog; and offer redacted iOS/macOS compatibility findings to its issue tracker when a command/service regression is isolated. + +## Competitive positioning + +| Need | Toolkit position | Better companion | Product response | +| --- | --- | --- | --- | +| Developer readiness | Strong guided DDI/RSD/DVT visibility | Xcode Device Hub | Make connection and prerequisites dependable first. | +| Raw protocol coverage | Strong through `pymobiledevice3` | `pymobiledevice3`, `go-ios`, `libimobiledevice` | Do not duplicate every CLI command; curate and expose evidence. | +| Simulator/device automation at scale | Limited | `idb`, Xcode, Appium/WDA ecosystems | Add safe handoffs, not a competing farm. | +| Backup forensics | Bounded acquisition/evidence support | MVT | Build consented MVT handoff with limitations, not a compromise verdict. | +| Firmware research | Minimal | `ipsw` | Offer links/recipes and artifact provenance only. | +| Network capture | Strong macOS RVI workflow | `rvictl` + tcpdump/Wireshark | Continue to clarify encrypted-payload and whole-stack limits. | + +## Prioritized roadmap + +### P0 — make the existing product dependable + +* Remove eager transport imports from desktop startup; load backup transport only in the backup worker. +* Drain final QProcess output for device discovery and add a deterministic fast-exit test. +* Keep the pinned `pymobiledevice3` runtime current through isolated upgrade checks, full tests, GUI smoke testing, and command-catalog live-help validation. The audit implementation validates and pins 11.15.1. +* Add a connection diagnostic record that reports whether discovery failed to launch, returned malformed data, returned zero devices, or returned a selectable device. The audit implementation now provides this record in Device & DDI and the sanitized support bundle without raw discovery output or device identity. + +### P1 — turn diagnostics into a coherent workbench + +* Introduce a reusable operation controller and typed `OperationResult`; migrate scanner, Man Pages, command drift, DDI, backup, apps, and capture incrementally. +* Make a contextual readiness pane for the selected action, with one-click scoped rechecks and copyable remediation. +* Add a physical-device compatibility test protocol. A pre-release dual-architecture frozen-artifact smoke workflow is now present; it remains unexecuted until GitHub Actions runs it. The release builder now also rejects a bundle whose Mach-O minimum macOS version differs from the advertised 13.0 floor. +* 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. + +### 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. + +### 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. + +### Do not build + +* Jailbreak, passcode bypass, root filesystem acquisition, code-signing circumvention, or credential/profile theft features. +* A permanent or stealth location-changing service. Location testing must remain explicit, visibly tracked, and clearable. +* A general “run any destructive command” button or an automated recovery/restore/erase path. +* An embedded MVT-like compromise verdict or claims that lack of findings proves a device is safe. +* A cloud telemetry/sync system for device identifiers, logs, captures, backups, or case records. +* A second bundled iOS protocol stack merely for feature-count parity. + +## Single best next thing to build + +**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. + +## 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 77-test suite, 89-action headless GUI smoke, CLI discovery, and every command-catalog live-help route. Review the diff before handoff. + +## Continuous improvement log + +| Date | Improvement | Verification | Follow-up boundary | +| --- | --- | --- | --- | +| 2026-09-21 | Moved backup transport imports out of desktop startup; fixed terminal output draining for usbmux discovery; added privacy-safe connection diagnostics. | 75 tests, headless GUI smoke, source launcher verification, and deterministic QProcess tests passed. | Real-device discovery remains separately opt-in and time-specific. | +| 2026-09-21 | Upgraded the pinned `pymobiledevice3` runtime to 11.15.1 and reconciled source, bundle, citation, packaging, and third-party source metadata. | CLI version reports 11.15.1; 77 tests and all 49 catalog live-help routes passed locally. | The next packaged artifact must be built by CI before distribution. | +| 2026-09-21 | Added a dual-architecture frozen-artifact smoke workflow, CI command-catalog verification, and a native Mach-O minimum-version gate. | A clean local build passed its full 77-test suite and produced a signed arm64 app; the host's Homebrew Python targets macOS 26, so the new 13.0 gate correctly stopped that incompatible local artifact before ZIP creation. | GitHub Actions runs with `MACOSX_DEPLOYMENT_TARGET=13.0`; its first Apple Silicon and Intel runs remain required before distribution. | + +## Research sources + +* Apple: [Developer Mode guidance](https://developer.apple.com/documentation/xcode/enabling-developer-mode-on-a-device), [Xcode command-line tools](https://developer.apple.com/documentation/xcode/xcode-command-line-tool-reference), and [RVI packet capture](https://developer.apple.com/documentation/network/recording-a-packet-trace). +* `pymobiledevice3`: [repository and documentation](https://github.com/doronz88/pymobiledevice3), [iOS 17+ tunnel guide](https://github.com/doronz88/pymobiledevice3/blob/master/docs/guides/ios17-tunnels.md), and [protocol-layer overview](https://github.com/doronz88/pymobiledevice3/blob/master/misc/understanding_idevice_protocol_layers.md). +* Complementary tools: [libimobiledevice](https://github.com/libimobiledevice/libimobiledevice), [go-ios](https://github.com/danielpaulus/go-ios), [Facebook idb](https://github.com/facebook/idb), [MVT](https://github.com/mvt-project/mvt), and [ipsw](https://github.com/blacktop/ipsw). diff --git a/ios_developer_toolkit/__init__.py b/ios_developer_toolkit/__init__.py index 4adc678..d5afa17 100644 --- a/ios_developer_toolkit/__init__.py +++ b/ios_developer_toolkit/__init__.py @@ -1,3 +1,3 @@ -"""iOS Device Workbench package.""" +"""iOS Developer Toolkit package.""" -APP_VERSION = "0.3.3" +APP_VERSION = "0.3.4" diff --git a/ios_developer_toolkit/app.py b/ios_developer_toolkit/app.py index 50391b0..c302e19 100644 --- a/ios_developer_toolkit/app.py +++ b/ios_developer_toolkit/app.py @@ -70,7 +70,7 @@ confirmation_phrase, guided_action_safety, ) -from ios_developer_toolkit.backup_worker import BackupEvent, BackupRequestError, parse_backup_event +from ios_developer_toolkit.backup_protocol import BackupEvent, BackupRequestError, parse_backup_event from ios_developer_toolkit.case_workflow import CaseWorkflowError, create_guided_case from ios_developer_toolkit.capability_matrix import ( CapabilityMatrixError, @@ -83,6 +83,15 @@ parse_capability_worker_event, untested_capability_results, ) +from ios_developer_toolkit.connection_diagnostics import ( + ConnectionDiagnostic, + devices_connection_diagnostic, + failed_connection_diagnostic, + initial_connection_diagnostic, + launch_failed_connection_diagnostic, + malformed_output_connection_diagnostic, + process_error_connection_diagnostic, +) from ios_developer_toolkit.device_compatibility import ( DeviceCompatibilityError, DeviceCompatibilityObservation, @@ -222,9 +231,16 @@ def base_environment() -> Mapping[str, str]: return environment +def drain_process_output(process: QProcess, stdout: bytearray, stderr: bytearray) -> None: + """Append every byte currently buffered by a completed or running QProcess.""" + stdout.extend(bytes(process.readAllStandardOutput())) + stderr.extend(bytes(process.readAllStandardError())) + + class DeviceScanner(QObject): devices_changed = Signal(object) scan_error = Signal(str) + diagnostic_changed = Signal(object) def __init__(self, executable: ExecutableCommand) -> None: super().__init__() @@ -236,6 +252,7 @@ def __init__(self, executable: ExecutableCommand) -> None: self._stdout = bytearray() self._stderr = bytearray() self._stopping = False + self._error_reported = False def start(self) -> None: self._stopping = False @@ -258,37 +275,57 @@ def scan(self) -> None: return self._stdout.clear() self._stderr.clear() + self._error_reported = False process = QProcess(self) process.setProgram(str(self._executable.program)) process.setArguments(list(command_arguments(self._executable, ("usbmux", "list")))) process.setProcessEnvironment(qprocess_environment(base_environment())) process.readyReadStandardOutput.connect(self._read_stdout) process.readyReadStandardError.connect(self._read_stderr) + process.errorOccurred.connect(self._process_error) process.finished.connect(self._finished) self._process = process process.start() def _read_stdout(self) -> None: if self._process is not None: - self._stdout.extend(bytes(self._process.readAllStandardOutput())) + drain_process_output(self._process, self._stdout, self._stderr) def _read_stderr(self) -> None: if self._process is not None: - self._stderr.extend(bytes(self._process.readAllStandardError())) + drain_process_output(self._process, self._stdout, self._stderr) + + def _process_error(self, process_error: QProcess.ProcessError) -> None: + if self._stopping or self._error_reported: + return + diagnostic = launch_failed_connection_diagnostic() + if process_error != QProcess.ProcessError.FailedToStart: + diagnostic = process_error_connection_diagnostic() + self._error_reported = True + self.diagnostic_changed.emit(diagnostic) + self.scan_error.emit(diagnostic.detail) def _finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: del exit_status + if self._process is not None: + drain_process_output(self._process, self._stdout, self._stderr) if self._stopping: return + if self._error_reported: + return if exit_code != 0: - message = self._stderr.decode("utf-8", errors="replace").strip() - self.scan_error.emit(message or f"Device scan failed with exit code {exit_code}") + diagnostic = failed_connection_diagnostic(exit_code) + self.diagnostic_changed.emit(diagnostic) + self.scan_error.emit(diagnostic.detail) return try: devices = parse_devices_json(self._stdout.decode("utf-8")) - except (DeviceDataError, json.JSONDecodeError, UnicodeDecodeError) as error: - self.scan_error.emit(f"Could not parse device discovery output: {error}") + except (DeviceDataError, json.JSONDecodeError, UnicodeDecodeError): + diagnostic = malformed_output_connection_diagnostic() + self.diagnostic_changed.emit(diagnostic) + self.scan_error.emit(diagnostic.detail) return + self.diagnostic_changed.emit(devices_connection_diagnostic(len(devices))) self.devices_changed.emit(devices) @@ -459,7 +496,7 @@ def __init__(self) -> None: class MainWindow(QMainWindow): def __init__(self) -> None: super().__init__() - self.setWindowTitle(f"iOS Device Workbench {APP_VERSION}") + self.setWindowTitle(f"iOS Developer Toolkit {APP_VERSION}") self.setAccessibleName("iOS Developer Toolkit main window") self.setAccessibleDescription( "Keyboard-first workspace for authorized iPhone and iPad development, diagnostics, backup, and evidence collection." @@ -468,6 +505,7 @@ def __init__(self) -> None: self.resize(1280, 840) self._pmd3 = pymobiledevice3_command() self._devices: tuple[IOSDevice, ...] = () + self._connection_diagnostic = initial_connection_diagnostic() self._demo_mode = False self._demo_device = demo_device() self._active_device_identifier: str | None = None @@ -573,6 +611,7 @@ def __init__(self) -> None: self._scanner = DeviceScanner(self._pmd3) self._scanner.devices_changed.connect(self._devices_changed) self._scanner.scan_error.connect(self._scan_error) + self._scanner.diagnostic_changed.connect(self._connection_diagnostic_changed) self._scanner.start() def _build_ui(self) -> None: @@ -661,7 +700,7 @@ def _build_ui(self) -> None: self.navigation_list.setObjectName("workspaceNavigation") self.navigation_list.setSpacing(2) sidebar_layout.addWidget(self.navigation_list, 1) - version_note = QLabel(f"Toolkit {APP_VERSION}\npymobiledevice3 10.11.0") + version_note = QLabel(f"Toolkit {APP_VERSION}\npymobiledevice3 11.15.1") version_note.setObjectName("sidebarVersion") version_note.setWordWrap(True) sidebar_layout.addWidget(version_note) @@ -949,6 +988,7 @@ def _support_bundle_context(self) -> SupportBundleContext: ) statuses = ( SupportStatus("connection", self.connection_banner.text()), + SupportStatus("connection_diagnostic", self._connection_diagnostic.report()), SupportStatus("developer_mode", self.developer_mode_status.text()), SupportStatus("capability_matrix", self.capability_status.text()), SupportStatus("command_drift", self.command_drift_status.text()), @@ -1026,6 +1066,18 @@ def _build_overview_tab(self) -> QWidget: layout = QVBoxLayout(tab) layout.setSpacing(14) + diagnostic_group = QGroupBox("Connection diagnostic") + diagnostic_layout = QVBoxLayout(diagnostic_group) + self.connection_diagnostic_value = QLabel(self._connection_diagnostic.report()) + self.connection_diagnostic_value.setObjectName("connectionDiagnosticValue") + self.connection_diagnostic_value.setWordWrap(True) + self.connection_diagnostic_value.setAccessibleName("Connection discovery diagnostic") + self.connection_diagnostic_value.setAccessibleDescription( + "Reports the most recent usbmux discovery result without including device identity or raw command output." + ) + diagnostic_layout.addWidget(self.connection_diagnostic_value) + layout.addWidget(diagnostic_group) + device_group = QGroupBox("Connected device") device_layout = QGridLayout(device_group) self.device_name_value = QLabel("No device") @@ -2383,6 +2435,13 @@ def _scan_error(self, message: str) -> None: "complete the cable, unlock, and Finder Trust checks." ) + def _connection_diagnostic_changed(self, diagnostic_object: object) -> None: + if not isinstance(diagnostic_object, ConnectionDiagnostic): + self._connection_diagnostic = malformed_output_connection_diagnostic() + else: + self._connection_diagnostic = diagnostic_object + self.connection_diagnostic_value.setText(self._connection_diagnostic.report()) + def _device_selected(self, index: int) -> None: del index self._update_device_fields(self._displayed_device()) diff --git a/ios_developer_toolkit/backup_protocol.py b/ios_developer_toolkit/backup_protocol.py new file mode 100644 index 0000000..3767978 --- /dev/null +++ b/ios_developer_toolkit/backup_protocol.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + + +class BackupRequestError(ValueError): + """Raised when a backup-worker request or event has an invalid schema.""" + + +@dataclass(frozen=True) +class BackupRequest: + udid: str + destination: Path + require_encryption: bool + new_password: str + full: bool + + +@dataclass(frozen=True) +class BackupEvent: + event: str + message: str + percent: int | None + encrypted: bool | None + path: Path | None + + +def required_string(value: object, field_name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise BackupRequestError(f"{field_name} must be a non-empty string") + return value.strip() + + +def required_boolean(value: object, field_name: str) -> bool: + if not isinstance(value, bool): + raise BackupRequestError(f"{field_name} must be a boolean") + return value + + +def optional_integer(value: object, field_name: str) -> int | None: + if value is None: + return None + if not isinstance(value, int) or isinstance(value, bool): + raise BackupRequestError(f"{field_name} must be an integer when present") + return value + + +def optional_boolean(value: object, field_name: str) -> bool | None: + if value is None: + return None + if not isinstance(value, bool): + raise BackupRequestError(f"{field_name} must be a boolean when present") + return value + + +def parse_backup_request(payload: str) -> BackupRequest: + raw: object = json.loads(payload) + if not isinstance(raw, dict): + raise BackupRequestError("backup request must be a JSON object") + request: dict[object, object] = raw + destination = Path(required_string(request.get("destination"), "destination")).expanduser() + if not destination.is_absolute(): + raise BackupRequestError("destination must be an absolute path") + password_value = request.get("new_password") + if not isinstance(password_value, str): + raise BackupRequestError("new_password must be a string") + return BackupRequest( + udid=required_string(request.get("udid"), "udid"), + destination=destination.resolve(), + require_encryption=required_boolean(request.get("require_encryption"), "require_encryption"), + new_password=password_value, + full=required_boolean(request.get("full"), "full"), + ) + + +def parse_backup_event(payload: str) -> BackupEvent: + raw: object = json.loads(payload) + if not isinstance(raw, dict): + raise BackupRequestError("backup event must be a JSON object") + event: dict[object, object] = raw + path_value = event.get("path") + if path_value is not None and not isinstance(path_value, str): + raise BackupRequestError("path must be a string when present") + return BackupEvent( + event=required_string(event.get("event"), "event"), + message=required_string(event.get("message"), "message"), + percent=optional_integer(event.get("percent"), "percent"), + encrypted=optional_boolean(event.get("encrypted"), "encrypted"), + path=Path(path_value) if isinstance(path_value, str) else None, + ) diff --git a/ios_developer_toolkit/backup_worker.py b/ios_developer_toolkit/backup_worker.py index e354550..aae53ec 100644 --- a/ios_developer_toolkit/backup_worker.py +++ b/ios_developer_toolkit/backup_worker.py @@ -4,104 +4,17 @@ import asyncio import json import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Literal, TextIO +from typing import TYPE_CHECKING, Literal, TextIO -from pymobiledevice3.lockdown import LockdownClient, create_using_usbmux -from pymobiledevice3.services.mobilebackup2 import Mobilebackup2Service +from ios_developer_toolkit.backup_protocol import BackupRequest, BackupRequestError, parse_backup_request - -class BackupRequestError(ValueError): - pass +if TYPE_CHECKING: + from pymobiledevice3.lockdown import LockdownClient BackupAction = Literal["status", "backup"] -@dataclass(frozen=True) -class BackupRequest: - udid: str - destination: Path - require_encryption: bool - new_password: str - full: bool - - -@dataclass(frozen=True) -class BackupEvent: - event: str - message: str - percent: int | None - encrypted: bool | None - path: Path | None - - -def required_string(value: object, field_name: str) -> str: - if not isinstance(value, str) or not value.strip(): - raise BackupRequestError(f"{field_name} must be a non-empty string") - return value.strip() - - -def required_boolean(value: object, field_name: str) -> bool: - if not isinstance(value, bool): - raise BackupRequestError(f"{field_name} must be a boolean") - return value - - -def parse_backup_request(payload: str) -> BackupRequest: - raw: object = json.loads(payload) - if not isinstance(raw, dict): - raise BackupRequestError("backup request must be a JSON object") - request: dict[object, object] = raw - destination = Path(required_string(request.get("destination"), "destination")).expanduser() - if not destination.is_absolute(): - raise BackupRequestError("destination must be an absolute path") - password_value = request.get("new_password") - if not isinstance(password_value, str): - raise BackupRequestError("new_password must be a string") - return BackupRequest( - udid=required_string(request.get("udid"), "udid"), - destination=destination.resolve(), - require_encryption=required_boolean(request.get("require_encryption"), "require_encryption"), - new_password=password_value, - full=required_boolean(request.get("full"), "full"), - ) - - -def optional_integer(value: object, field_name: str) -> int | None: - if value is None: - return None - if not isinstance(value, int) or isinstance(value, bool): - raise BackupRequestError(f"{field_name} must be an integer when present") - return value - - -def optional_boolean(value: object, field_name: str) -> bool | None: - if value is None: - return None - if not isinstance(value, bool): - raise BackupRequestError(f"{field_name} must be a boolean when present") - return value - - -def parse_backup_event(payload: str) -> BackupEvent: - raw: object = json.loads(payload) - if not isinstance(raw, dict): - raise BackupRequestError("backup event must be a JSON object") - event: dict[object, object] = raw - path_value = event.get("path") - if path_value is not None and not isinstance(path_value, str): - raise BackupRequestError("path must be a string when present") - return BackupEvent( - event=required_string(event.get("event"), "event"), - message=required_string(event.get("message"), "message"), - percent=optional_integer(event.get("percent"), "percent"), - encrypted=optional_boolean(event.get("encrypted"), "encrypted"), - path=Path(path_value) if isinstance(path_value, str) else None, - ) - - def emit_event( event: str, message: str, @@ -129,6 +42,9 @@ async def read_encryption_state(lockdown: LockdownClient) -> bool: async def execute_request(action: BackupAction, request: BackupRequest) -> None: + from pymobiledevice3.lockdown import create_using_usbmux + from pymobiledevice3.services.mobilebackup2 import Mobilebackup2Service + lockdown = await create_using_usbmux(serial=request.udid) try: async with Mobilebackup2Service(lockdown) as backup_client: diff --git a/ios_developer_toolkit/connection_diagnostics.py b/ios_developer_toolkit/connection_diagnostics.py new file mode 100644 index 0000000..7430599 --- /dev/null +++ b/ios_developer_toolkit/connection_diagnostics.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Literal + + +ConnectionDiagnosticState = Literal[ + "not-scanned", + "launch-failed", + "discovery-failed", + "malformed-output", + "no-devices", + "devices-available", +] + + +@dataclass(frozen=True) +class ConnectionDiagnostic: + """A privacy-safe result for one usbmux discovery attempt.""" + + state: ConnectionDiagnosticState + checked_at: str + device_count: int + detail: str + + def report(self) -> str: + device_count = f" Devices available: {self.device_count}." if self.state == "devices-available" else "" + return f"{self.detail}{device_count} Checked: {self.checked_at}." + + +def initial_connection_diagnostic() -> ConnectionDiagnostic: + return _diagnostic("not-scanned", 0, "Discovery has not run yet") + + +def launch_failed_connection_diagnostic() -> ConnectionDiagnostic: + return _diagnostic("launch-failed", 0, "The usbmux discovery process could not start") + + +def failed_connection_diagnostic(exit_code: int) -> ConnectionDiagnostic: + return _diagnostic("discovery-failed", 0, f"usbmux discovery exited with status {exit_code}") + + +def process_error_connection_diagnostic() -> ConnectionDiagnostic: + return _diagnostic("discovery-failed", 0, "The usbmux discovery process stopped before returning a device list") + + +def malformed_output_connection_diagnostic() -> ConnectionDiagnostic: + return _diagnostic("malformed-output", 0, "usbmux returned output that was not a valid device list") + + +def devices_connection_diagnostic(device_count: int) -> ConnectionDiagnostic: + if device_count < 0: + raise ValueError(f"Device count cannot be negative: {device_count}") + if device_count == 0: + return _diagnostic("no-devices", 0, "usbmux completed successfully but found no devices") + return _diagnostic("devices-available", device_count, "usbmux completed successfully") + + +def _diagnostic(state: ConnectionDiagnosticState, device_count: int, detail: str) -> ConnectionDiagnostic: + return ConnectionDiagnostic(state, datetime.now(timezone.utc).isoformat(), device_count, detail) diff --git a/ios_developer_toolkit/entrypoint.py b/ios_developer_toolkit/entrypoint.py index fd54d7e..23dac53 100644 --- a/ios_developer_toolkit/entrypoint.py +++ b/ios_developer_toolkit/entrypoint.py @@ -74,7 +74,7 @@ def run_smoke_test(arguments: Sequence[str]) -> int: 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, QPushButton + from PySide6.QtWidgets import QApplication, QLabel, QPushButton from ios_developer_toolkit.app import MainWindow @@ -135,6 +135,11 @@ def run_smoke_test(arguments: Sequence[str]) -> int: missing_shortcuts = expected_shortcuts - actual_shortcuts if missing_shortcuts: raise RuntimeError(f"GUI keyboard shortcuts are missing: {sorted(missing_shortcuts)}") + connection_diagnostic = window.findChild(QLabel, "connectionDiagnosticValue") + if connection_diagnostic is None: + raise RuntimeError("GUI connection diagnostic is missing") + if not connection_diagnostic.text().strip(): + raise RuntimeError("GUI connection diagnostic has no visible state") support_bundle_button = window.findChild(QPushButton, "createSupportBundleButton") if support_bundle_button is None: raise RuntimeError("GUI support-bundle action is missing") diff --git a/macos/Info.plist b/macos/Info.plist index 338f5a5..2375986 100644 --- a/macos/Info.plist +++ b/macos/Info.plist @@ -5,21 +5,21 @@ CFBundleDevelopmentRegion en CFBundleDisplayName - iOS Device Workbench + iOS Developer Toolkit CFBundleExecutable iOSDeveloperToolkit CFBundleIconFile iOSDeveloperToolkit CFBundleIdentifier - local.security.ios-developer-toolkit + io.hideouts.ios-developer-toolkit CFBundleName - iOS Device Workbench + iOS Developer Toolkit CFBundlePackageType APPL CFBundleShortVersionString - 0.3.1 + 0.3.4 CFBundleVersion - 5 + 6 LSMinimumSystemVersion 13.0 NSHighResolutionCapable diff --git a/packaging/pysidedeploy.spec b/packaging/pysidedeploy.spec index 674a674..612fa66 100644 --- a/packaging/pysidedeploy.spec +++ b/packaging/pysidedeploy.spec @@ -8,7 +8,7 @@ icon = macos/iOSDeveloperToolkit.icns [python] python_path = -packages = Nuitka==4.1.1 +packages = Nuitka==4.2.1 android_packages = [qt] @@ -25,7 +25,7 @@ plugins = [nuitka] macos.permissions = mode = standalone -extra_args = --quiet --assume-yes-for-downloads --noinclude-qt-translations --include-package=ios_developer_toolkit --include-package=pymobiledevice3 --include-package=developer_disk_image --include-data-dir=ios_developer_toolkit/assets=ios_developer_toolkit/assets --include-package-data=pymobiledevice3 --include-package-data=developer_disk_image --nofollow-import-to=IPython --nofollow-import-to=jedi --macos-app-name="iOS Developer Toolkit" --macos-app-version=0.3.1 --macos-app-mode=gui +extra_args = --quiet --assume-yes-for-downloads --noinclude-qt-translations --include-package=ios_developer_toolkit --include-package=pymobiledevice3 --include-package=developer_disk_image --include-data-dir=ios_developer_toolkit/assets=ios_developer_toolkit/assets --include-package-data=pymobiledevice3 --include-package-data=developer_disk_image --nofollow-import-to=IPython --nofollow-import-to=jedi --macos-app-name="iOS Developer Toolkit" --macos-app-version=0.3.4 --macos-app-macos-min-version=13.0 --macos-app-mode=gui [buildozer] mode = release diff --git a/pyproject.toml b/pyproject.toml index bb7f371..0fa2ab5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,18 +4,18 @@ build-backend = "setuptools.build_meta" [project] name = "ios-developer-toolkit" -version = "0.3.3" +version = "0.3.4" description = "Safety-focused pymobiledevice3 GUI, Developer Disk Image mounter, and iOS evidence workbench" requires-python = ">=3.10" license = "MIT" dependencies = [ "PySide6==6.11.2", - "pymobiledevice3==10.11.0", + "pymobiledevice3==11.15.1", ] [project.optional-dependencies] release = [ - "Nuitka==4.1.1", + "Nuitka==4.2.1", ] [project.scripts] diff --git a/scripts/build_macos_release.sh b/scripts/build_macos_release.sh index 2a081a2..81091be 100755 --- a/scripts/build_macos_release.sh +++ b/scripts/build_macos_release.sh @@ -12,8 +12,14 @@ output_directory="$2" repository_root="$(cd "$(dirname "$0")/.." && pwd)" python_executable="$3" machine_architecture="$(uname -m)" +required_macos_version="13.0" export COPYFILE_DISABLE=1 +if [[ "${MACOSX_DEPLOYMENT_TARGET:-}" != "$required_macos_version" ]]; then + echo "Release builds require MACOSX_DEPLOYMENT_TARGET=$required_macos_version so the executable can match the advertised macOS floor." >&2 + exit 73 +fi + if [[ ! -f "$repository_root/pyproject.toml" || ! -f "$repository_root/requirements/release-sbom.txt" || ! -d "$repository_root/ios_developer_toolkit" ]]; then echo "Release builder could not validate the repository root: $repository_root" >&2 exit 65 @@ -35,7 +41,8 @@ staging_root="$(mktemp -d /private/tmp/iosdevtoolkit-release.XXXXXX)" export NUITKA_CACHE_DIR="$staging_root/nuitka-cache" build_environment="$staging_root/release-venv" metadata_environment="$staging_root/metadata-venv" -source_wrapper="$staging_root/main.py" +deployment_project_directory="$staging_root/deployment-project" +source_wrapper="$deployment_project_directory/main.py" generated_app_path="$staging_root/build/iOS Developer Toolkit.app" app_path="$staging_root/iOS Developer Toolkit.app" archive_name="iOS-Developer-Toolkit-v${release_version}-macOS-${machine_architecture}.zip" @@ -56,7 +63,7 @@ deployment_config="$staging_root/pysidedeploy.spec" "$build_environment/bin/python" -m pip freeze --local --require-virtualenv > "$runtime_requirements" /usr/bin/sed -i '' "s|^ios-developer-toolkit @ .*|ios-developer-toolkit==$release_version|" "$runtime_requirements" /bin/cp "$runtime_requirements" "$sbom_requirements" -echo "Nuitka==4.1.1" >> "$sbom_requirements" +echo "Nuitka==4.2.1" >> "$sbom_requirements" "$build_environment/bin/python" -m pip install --disable-pip-version-check "$repository_root[release]" "$build_environment/bin/python" -m pip freeze --local --require-virtualenv > "$post_build_requirements" /usr/bin/sed -i '' "s|^ios-developer-toolkit @ .*|ios-developer-toolkit==$release_version|" "$post_build_requirements" @@ -81,13 +88,15 @@ fi cd "$repository_root" "$build_environment/bin/python" -m unittest discover -s tests -v +/bin/mkdir -p "$deployment_project_directory" /bin/cp packaging/main.py "$source_wrapper" /bin/cp packaging/pysidedeploy.spec "$deployment_config" /usr/bin/sed -i '' \ - -e "s|^project_dir =.*|project_dir = $repository_root|" \ + -e "s|^project_dir =.*|project_dir = $deployment_project_directory|" \ -e "s|^input_file =.*|input_file = $source_wrapper|" \ -e "s|^exec_directory =.*|exec_directory = $staging_root/build|" \ -e "s|^icon =.*|icon = $repository_root/macos/iOSDeveloperToolkit.icns|" \ + -e "s|--macos-app-version=[^[:space:]]*|--macos-app-version=$release_version|" \ "$deployment_config" "$build_environment/bin/pyside6-deploy" -c "$deployment_config" --force --keep-deployment-files @@ -105,7 +114,7 @@ plist_path="$app_path/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier io.hideouts.ios-developer-toolkit" "$plist_path" /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName iOS Developer Toolkit" "$plist_path" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $release_version" "$plist_path" -/usr/libexec/PlistBuddy -c "Add :CFBundleVersion string 5" "$plist_path" 2>/dev/null || /usr/libexec/PlistBuddy -c "Set :CFBundleVersion 5" "$plist_path" +/usr/libexec/PlistBuddy -c "Add :CFBundleVersion string 6" "$plist_path" 2>/dev/null || /usr/libexec/PlistBuddy -c "Set :CFBundleVersion 6" "$plist_path" /usr/libexec/PlistBuddy -c "Add :LSMinimumSystemVersion string 13.0" "$plist_path" 2>/dev/null || /usr/libexec/PlistBuddy -c "Set :LSMinimumSystemVersion 13.0" "$plist_path" bundle_license_directory="$app_path/Contents/Resources/Licenses" @@ -133,6 +142,14 @@ if [[ "$compiled_architecture" != "$machine_architecture" ]]; then echo "Compiled executable architecture is $compiled_architecture; expected $machine_architecture" >&2 exit 69 fi +compiled_minimum_macos_version="$(/usr/bin/otool -l "$compiled_executable" | /usr/bin/awk ' + /LC_BUILD_VERSION/ { in_build_version = 1; next } + in_build_version && /minos/ { print $2; exit } +')" +if [[ "$compiled_minimum_macos_version" != "$required_macos_version" ]]; then + echo "Compiled executable requires macOS ${compiled_minimum_macos_version:-an unknown version}; expected $required_macos_version" >&2 + exit 72 +fi "$compiled_executable" --toolkit-internal-pymobiledevice3 version "$compiled_executable" --toolkit-internal-worker capability --help diff --git a/scripts/verify_command_catalog.py b/scripts/verify_command_catalog.py new file mode 100644 index 0000000..9648dd8 --- /dev/null +++ b/scripts/verify_command_catalog.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Verify that every guided preset still matches installed pymobiledevice3 help.""" + +from __future__ import annotations + +import subprocess + +from ios_developer_toolkit.command_catalog import command_presets +from ios_developer_toolkit.command_drift import ( + HelpRouteProbe, + evaluate_command_drift, + help_routes_for_presets, + render_command_drift_report, +) +from ios_developer_toolkit.runtime import ExecutableCommand, command_argv, pymobiledevice3_command + + +HELP_TIMEOUT_SECONDS = 10 + + +def probe_live_help(command: ExecutableCommand, command_path: tuple[str, ...]) -> HelpRouteProbe: + """Return the result of one device-free pymobiledevice3 help probe.""" + arguments = (*command_path, "--help") + try: + completed = subprocess.run( + command_argv(command, arguments), + check=False, + capture_output=True, + text=True, + timeout=HELP_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired: + return HelpRouteProbe( + command_path, + None, + "", + "", + f"Live help exceeded the {HELP_TIMEOUT_SECONDS}-second per-route limit.", + ) + except OSError as error: + return HelpRouteProbe( + command_path, + None, + "", + "", + f"Could not start live help: {error}", + ) + return HelpRouteProbe( + command_path, + completed.returncode, + completed.stdout, + completed.stderr, + None, + ) + + +def main() -> int: + """Print a command-drift report and return nonzero for incompatible guidance.""" + presets = command_presets() + command = pymobiledevice3_command() + probes = tuple(probe_live_help(command, route) for route in help_routes_for_presets(presets)) + results = evaluate_command_drift(presets, probes) + print(render_command_drift_report(results)) + return 0 if all(result.state == "verified" for result in results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_core.py b/tests/test_core.py index 84cf49a..3e69fc9 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -11,7 +11,7 @@ from pathlib import Path from ios_developer_toolkit.action_safety import advanced_action_safety, confirmation_phrase, guided_action_safety -from ios_developer_toolkit.backup_worker import BackupRequestError, parse_backup_event, parse_backup_request +from ios_developer_toolkit.backup_protocol import BackupRequestError, parse_backup_event, parse_backup_request from ios_developer_toolkit.catalog import is_potentially_mutating, snapshot_commands from ios_developer_toolkit.command_catalog import ( CommandCatalogError, @@ -27,6 +27,13 @@ help_routes_for_presets, ) from ios_developer_toolkit.case_workflow import CaseWorkflowError, create_guided_case, validate_collection_case +from ios_developer_toolkit.connection_diagnostics import ( + devices_connection_diagnostic, + failed_connection_diagnostic, + launch_failed_connection_diagnostic, + malformed_output_connection_diagnostic, + process_error_connection_diagnostic, +) from ios_developer_toolkit.collector import safe_udid_fragment from ios_developer_toolkit.demo_mode import DEMO_DEVICE_IDENTIFIER, demo_connection_banner, demo_device from ios_developer_toolkit.installed_apps import InstalledAppsDataError, format_byte_count, parse_installed_apps_json @@ -284,6 +291,33 @@ def test_success_information_is_not_an_error(self) -> None: self.assertFalse(output_indicates_failure(output)) +class ConnectionDiagnosticTests(unittest.TestCase): + def test_reports_each_discovery_outcome_without_raw_device_data(self) -> None: + diagnostics = ( + launch_failed_connection_diagnostic(), + failed_connection_diagnostic(7), + process_error_connection_diagnostic(), + malformed_output_connection_diagnostic(), + devices_connection_diagnostic(0), + devices_connection_diagnostic(2), + ) + self.assertEqual( + tuple(diagnostic.state for diagnostic in diagnostics), + ( + "launch-failed", + "discovery-failed", + "discovery-failed", + "malformed-output", + "no-devices", + "devices-available", + ), + ) + self.assertIn("status 7", diagnostics[1].report()) + self.assertIn("stopped before returning", diagnostics[2].report()) + self.assertIn("Devices available: 2", diagnostics[-1].report()) + self.assertNotIn("Identifier", "\n".join(diagnostic.report() for diagnostic in diagnostics)) + + class SupportBundleTests(unittest.TestCase): def test_creates_a_reviewable_zip_without_known_device_or_host_identifiers(self) -> None: temporary_directory = Path(tempfile.mkdtemp()) diff --git a/tests/test_device_scanner.py b/tests/test_device_scanner.py new file mode 100644 index 0000000..d741f3f --- /dev/null +++ b/tests/test_device_scanner.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import json +import sys +import time +import unittest +from pathlib import Path + +from PySide6.QtCore import QCoreApplication, QProcess + +from ios_developer_toolkit.app import DeviceScanner +from ios_developer_toolkit.models import IOSDevice +from ios_developer_toolkit.runtime import ExecutableCommand + + +class DeviceScannerTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.application = QCoreApplication.instance() or QCoreApplication(["device-scanner-tests"]) + + def test_consumes_valid_discovery_json_after_child_exit(self) -> None: + payload = json.dumps( + [ + { + "Identifier": "00008110-001122334455001E", + "DeviceName": "Test iPhone", + "ProductType": "iPhone14,5", + "ProductVersion": "26.3.1", + "BuildVersion": "23D123", + "ConnectionType": "USB", + } + ] + ) + process = QProcess() + process.setProgram(sys.executable) + process.setArguments(("-c", f"import sys; sys.stdout.write({payload!r})")) + process.start() + self.assertTrue(process.waitForStarted(3_000)) + self.assertTrue(process.waitForFinished(3_000)) + + scanner = DeviceScanner(ExecutableCommand(Path(sys.executable), ())) + observed_devices: list[tuple[IOSDevice, ...]] = [] + observed_errors: list[str] = [] + observed_diagnostics: list[object] = [] + scanner.devices_changed.connect(observed_devices.append) + scanner.scan_error.connect(observed_errors.append) + scanner.diagnostic_changed.connect(observed_diagnostics.append) + scanner._process = process + + scanner._finished(0, QProcess.ExitStatus.NormalExit) + + self.assertEqual(observed_errors, []) + self.assertEqual(len(observed_devices), 1) + self.assertEqual(observed_devices[0][0].identifier, "00008110-001122334455001E") + self.assertEqual(len(observed_diagnostics), 1) + self.assertEqual(observed_diagnostics[0].state, "devices-available") + self.assertEqual(observed_diagnostics[0].device_count, 1) + + def test_reports_failed_process_launch_without_exposing_qprocess_details(self) -> None: + scanner = DeviceScanner(ExecutableCommand(Path("/missing-ios-toolkit-pymobiledevice3"), ())) + observed_errors: list[str] = [] + observed_diagnostics: list[object] = [] + scanner.scan_error.connect(observed_errors.append) + scanner.diagnostic_changed.connect(observed_diagnostics.append) + scanner.scan() + + deadline = time.monotonic() + 3 + while not observed_diagnostics and time.monotonic() < deadline: + self.application.processEvents() + time.sleep(0.01) + + self.assertEqual(len(observed_diagnostics), 1) + self.assertEqual(observed_diagnostics[0].state, "launch-failed") + self.assertEqual(observed_errors, ["The usbmux discovery process could not start"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py new file mode 100644 index 0000000..6e2da91 --- /dev/null +++ b/tests/test_project_metadata.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import plistlib +import tomllib +import unittest +from pathlib import Path + +from ios_developer_toolkit import APP_VERSION + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + +class ProjectMetadataTests(unittest.TestCase): + def test_source_and_packaging_metadata_match_application_version(self) -> None: + pyproject = tomllib.loads((REPOSITORY_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + project = pyproject["project"] + self.assertIsInstance(project, dict) + self.assertEqual(project["version"], APP_VERSION) + + with (REPOSITORY_ROOT / "macos" / "Info.plist").open("rb") as stream: + plist = plistlib.load(stream) + self.assertEqual(plist["CFBundleShortVersionString"], APP_VERSION) + self.assertEqual(plist["CFBundleDisplayName"], "iOS Developer Toolkit") + self.assertEqual(plist["CFBundleIdentifier"], "io.hideouts.ios-developer-toolkit") + self.assertEqual(plist["CFBundleVersion"], "6") + self.assertEqual(plist["LSMinimumSystemVersion"], "13.0") + + deployment_specification = (REPOSITORY_ROOT / "packaging" / "pysidedeploy.spec").read_text(encoding="utf-8") + self.assertIn(f"--macos-app-version={APP_VERSION}", deployment_specification) + self.assertIn("--macos-app-macos-min-version=13.0", deployment_specification) + release_builder = (REPOSITORY_ROOT / "scripts" / "build_macos_release.sh").read_text(encoding="utf-8") + self.assertIn("--macos-app-version=$release_version", release_builder) + self.assertIn("CFBundleVersion string 6", release_builder) + self.assertIn('compiled_minimum_macos_version', release_builder) + self.assertIn('MACOSX_DEPLOYMENT_TARGET', release_builder) + + def test_dependency_notices_match_the_pinned_pymobiledevice3_release(self) -> None: + pyproject = tomllib.loads((REPOSITORY_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + project = pyproject["project"] + self.assertIsInstance(project, dict) + dependencies = project["dependencies"] + self.assertIsInstance(dependencies, list) + pinned_dependency = next( + dependency for dependency in dependencies if isinstance(dependency, str) and dependency.startswith("pymobiledevice3==") + ) + pinned_version = pinned_dependency.removeprefix("pymobiledevice3==") + + notices = (REPOSITORY_ROOT / "THIRD_PARTY_NOTICES.md").read_text(encoding="utf-8") + source_availability = (REPOSITORY_ROOT / "SOURCE_AVAILABILITY.md").read_text(encoding="utf-8") + self.assertIn(f"| {pinned_version} |", notices) + self.assertIn(f"tree/v{pinned_version}", notices) + self.assertIn(f"tree/v{pinned_version}", source_availability) + + +if __name__ == "__main__": + unittest.main() From 3fb654aa31f8165dc8bef507c256c0b3b765e7c1 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:35:32 -0700 Subject: [PATCH 2/8] Centralize device scan process lifecycle --- README.md | 13 +- docs/PHYSICAL_DEVICE_TEST_PROTOCOL.md | 70 ++++++ docs/PRODUCT_AUDIT_2026-09-21.md | 7 +- ios_developer_toolkit/app.py | 105 ++++----- ios_developer_toolkit/command_drift.py | 7 +- .../connection_diagnostics.py | 5 + ios_developer_toolkit/qt_process.py | 200 ++++++++++++++++++ tests/test_core.py | 9 + tests/test_device_scanner.py | 26 +-- tests/test_qt_process.py | 91 ++++++++ 10 files changed, 453 insertions(+), 80 deletions(-) create mode 100644 docs/PHYSICAL_DEVICE_TEST_PROTOCOL.md create mode 100644 ios_developer_toolkit/qt_process.py create mode 100644 tests/test_qt_process.py diff --git a/README.md b/README.md index e612596..438c2cb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ iOS Developer Toolkit logo

-### iOS Device Workbench: a guided pymobiledevice3 GUI, Developer Disk Image mounter, and evidence workbench for macOS +### 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) [![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) @@ -17,7 +17,7 @@ > **Scope:** iOS Developer Toolkit is a macOS front end for authorized Apple-device development, diagnostics, testing, backup, and evidence-preservation workflows. It does not jailbreak iOS, bypass a passcode, disable the sandbox, defeat code signing, decrypt protected traffic, or provide unrestricted filesystem access. -![iOS Device Workbench Home workspace](docs/screenshots/home.png) +![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. @@ -90,7 +90,7 @@ Release `v0.3.4` combines the complete 12-workspace interface with the latest co - 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 77 tests, embedded CLI checks, an 89-button GUI smoke test, architecture inspection, strict code-signature validation, and one SHA-256 manifest; +- native Apple Silicon and Intel release ZIPs are built separately and verified with 81 tests, embedded CLI checks, an 89-button GUI smoke test, architecture 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. @@ -176,7 +176,7 @@ flowchart LR DDI[Personalized DDI at /System/Developer] RSD[RemoteXPC / RSD tunnel] Dev[DVT and CoreDevice services] - Toolkit[iOS Device Workbench] + Toolkit[iOS Developer Toolkit] Case[Local evidence or working output] Device <--> Trust <--> Usbmux <--> Toolkit @@ -1008,6 +1008,7 @@ Install or update Xcode if the candidate is absent. The toolkit requires the exp │ ├── location_lab.py # coordinates, GPX, routes, saved places, evidence │ ├── models.py # typed device and collection models │ ├── 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 │ └── assets/ @@ -1035,9 +1036,11 @@ 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. +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 77 tests, verifies the embedded pymobiledevice3 command, checks the internal worker route, runs the 89-button offscreen GUI smoke test, verifies the Mach-O architecture and its macOS 13.0 load-command floor, 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 81 tests, verifies the embedded pymobiledevice3 command, checks the internal worker route, runs the 89-button offscreen GUI smoke test, verifies the Mach-O architecture and its macOS 13.0 load-command floor, 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 a bundle whose executable targets a newer macOS version, so local release builds should use a Python toolchain that can produce macOS 13 binaries; GitHub release CI supplies this target explicitly. diff --git a/docs/PHYSICAL_DEVICE_TEST_PROTOCOL.md b/docs/PHYSICAL_DEVICE_TEST_PROTOCOL.md new file mode 100644 index 0000000..f59706a --- /dev/null +++ b/docs/PHYSICAL_DEVICE_TEST_PROTOCOL.md @@ -0,0 +1,70 @@ +# Physical-device test protocol + +Use this protocol only with an iPhone or iPad that you own or are authorized to test. Keep raw UDIDs, device names, logs, captures, backups, screenshots, coordinates, and case evidence out of issues, pull requests, and compatibility reports. + +## Required test context + +Record these non-secret facts locally before testing: + +* toolkit commit and application version; +* source or packaged build and native architecture; +* macOS, Xcode, Python, and `pymobiledevice3` versions; +* iPhone or iPad model, iOS version, build, and USB or network connection; +* whether Developer Mode, a DDI, and an RSD tunnel are expected for the tested workflow. + +Do not record the raw UDID in a shared report. The application's Real-Device Compatibility view stores a one-way device fingerprint for local comparisons. + +## Stage 1 — connection readiness + +1. Connect the unlocked device directly with a known data-capable cable. Avoid hubs for the first test. +2. Accept **Allow accessory to connect** on macOS when shown. +3. Tap **Trust** on the device and enter its passcode when shown. +4. Confirm that Finder or Xcode lists the device. +5. Run `pymobiledevice3 usbmux list` in the project environment. Expect one JSON device record. +6. Run `xcrun devicectl list devices`. Expect the same device to be `available (paired)`. +7. Open the toolkit. Expect the physical device in the picker, an **Authorized device connected** banner, and a **devices-available** Connection diagnostic. +8. Disconnect and reconnect once. Expect the picker and banner to recover without restarting `usbmuxd`, deleting pairing records, or requiring `sudo`. + +Failure boundaries: + +* absent from the macOS USB tree: cable, port, lock state, or accessory-authorization problem; +* present in USB but absent from usbmux: pairing or Apple Mobile Device service problem; +* present in usbmux but absent from CoreDevice: Xcode/CoreDevice state problem; +* present in both CLIs but absent from the toolkit: application discovery regression; create a sanitized support bundle. + +## Stage 2 — read-only application checks + +1. Run the full **Capability Matrix** and save a local compatibility observation. +2. Verify that each row distinguishes ready, needs attention, unavailable, blocked, not tested, and not applicable. +3. Run finite read-only Command Center presets for device information, battery, date, mounted images, and installed applications. +4. Open Unified Log, syslog, and DVT OSLog separately. Confirm that each stream starts, receives data, pauses, filters, stops, and offers an explicit raw-save decision. +5. Run app inventory and local IPA inspection without installing or uninstalling an app. +6. Create a sanitized support bundle. Inspect the ZIP and confirm that it contains no raw device identity, command output, capture, log, credential, or user-entered value. + +Expected result: every command either completes with bounded output or remains visibly identified as a stream with an enabled Stop control. No read-only check changes device state. + +## Stage 3 — developer-service checks + +Perform this stage only when Developer Mode is intentionally enabled. + +1. Enable Developer Mode through iOS Settings and complete the required restart. +2. Mount the appropriate personalized DDI or use the Xcode candidate DDI when the selected workflow explicitly calls for it. +3. Re-run only the Developer Mode, DDI, RSD, DVT, and CoreDevice capability rows. +4. Verify DVT directory listing, application listing, and one bounded developer-service snapshot. +5. Start and stop DVT network activity. Confirm that the UI treats it as a stream rather than a finite snapshot. + +Expected result: readiness changes are attributed to the correct layer. A DDI success does not imply that an RSD tunnel or every DVT service is available. + +## Stage 4 — opt-in state-changing checks + +These checks are not required for merge readiness. Run only when their device effect is acceptable and the displayed target is correct. + +* **Location Lab:** set a harmless test coordinate, verify the visible simulated state, then use Clear and confirm that no simulation process remains. +* **IPA sideload:** inspect an eligible development-signed IPA first, install it with the device-bound acknowledgement, verify inventory, then uninstall only if planned. +* **Encrypted backup:** use protected local storage, verify the existing encryption state, understand that enabling backup encryption persists on the device, and confirm the resulting backup independently. +* **PCAP/RVI:** capture a short authorized trace, stop cleanly, open the file in an independent packet analyzer, and document encrypted-payload limitations. +* **Evidence case:** create a disposable case, collect one bounded artifact, finalize it, and independently verify its SHA-256 manifest. + +## Completion record + +Mark each stage as **passed**, **failed**, **not applicable**, or **not tested**. For failures, record the exact layer, command or button, exit status, sanitized error, and whether the failure reproduces in both the source and packaged app. Never convert **not tested** into a compatibility claim. diff --git a/docs/PRODUCT_AUDIT_2026-09-21.md b/docs/PRODUCT_AUDIT_2026-09-21.md index 1aef6da..825d645 100644 --- a/docs/PRODUCT_AUDIT_2026-09-21.md +++ b/docs/PRODUCT_AUDIT_2026-09-21.md @@ -113,9 +113,9 @@ Upstream contribution candidates are concrete: report the fast-exit scanner pack ### P1 — turn diagnostics into a coherent workbench -* Introduce a reusable operation controller and typed `OperationResult`; migrate scanner, Man Pages, command drift, DDI, backup, apps, and capture incrementally. +* Continue migrating finite subprocess workflows to the reusable operation controller and typed `OperationResult`. Device discovery is the first migrated client and now has shared final-drain, timeout, cancellation, launch-failure, and structured completion semantics; Man Pages, command drift, DDI, backup, apps, and capture remain incremental migrations. * Make a contextual readiness pane for the selected action, with one-click scoped rechecks and copyable remediation. -* Add a physical-device compatibility test protocol. A pre-release dual-architecture frozen-artifact smoke workflow is now present; it remains unexecuted until GitHub Actions runs it. The release builder now also rejects a bundle whose Mach-O minimum macOS version differs from the advertised 13.0 floor. +* 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; it remains unexecuted until GitHub Actions runs it. The release builder also rejects a bundle whose Mach-O minimum macOS version differs from the advertised 13.0 floor. * 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. @@ -161,6 +161,9 @@ Upstream contribution candidates are concrete: report the fast-exit scanner pack | 2026-09-21 | Moved backup transport imports out of desktop startup; fixed terminal output draining for usbmux discovery; added privacy-safe connection diagnostics. | 75 tests, headless GUI smoke, source launcher verification, and deterministic QProcess tests passed. | Real-device discovery remains separately opt-in and time-specific. | | 2026-09-21 | Upgraded the pinned `pymobiledevice3` runtime to 11.15.1 and reconciled source, bundle, citation, packaging, and third-party source metadata. | CLI version reports 11.15.1; 77 tests and all 49 catalog live-help routes passed locally. | The next packaged artifact must be built by CI before distribution. | | 2026-09-21 | Added a dual-architecture frozen-artifact smoke workflow, CI command-catalog verification, and a native Mach-O minimum-version gate. | A clean local build passed its full 77-test suite and produced a signed arm64 app; the host's Homebrew Python targets macOS 26, so the new 13.0 gate correctly stopped that incompatible local artifact before ZIP creation. | GitHub Actions runs with `MACOSX_DEPLOYMENT_TARGET=13.0`; its first Apple Silicon and Intel runs remain required before distribution. | +| 2026-09-21 | Added a reusable typed finite-process controller and migrated device discovery to it. | Real child-process tests cover terminal stdout/stderr, fast completion, launch failure, cancellation, timeout, and one-result semantics; the full suite now contains 81 tests. | Migrate other finite QProcess workflows incrementally; long-running streams retain their separate lifecycle. | +| 2026-09-21 | Made live-help drift checks accept successful help emitted on either standard output or standard error. | A clean GitHub runner exposed two false option mismatches while the same pinned CLI passed locally; the channel-specific regression test now preserves strict option matching without assuming a help stream. | Re-run CI on a clean runner and retain failure for genuinely absent routes or options. | +| 2026-09-21 | Added an opt-in physical-device protocol with staged read-only, developer-service, and state-changing checks. | The current host check found no Apple mobile USB device, no usbmux device, and no CoreDevice result, so no physical compatibility claim was made. | Run the protocol with an authorized connected device and retain identifiers and raw evidence locally. | ## Research sources diff --git a/ios_developer_toolkit/app.py b/ios_developer_toolkit/app.py index c302e19..0c2ca5f 100644 --- a/ios_developer_toolkit/app.py +++ b/ios_developer_toolkit/app.py @@ -91,6 +91,7 @@ launch_failed_connection_diagnostic, malformed_output_connection_diagnostic, process_error_connection_diagnostic, + timed_out_connection_diagnostic, ) from ios_developer_toolkit.device_compatibility import ( DeviceCompatibilityError, @@ -166,6 +167,11 @@ ) 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.qt_process import ( + FiniteProcessController, + OperationResult, + finite_process_request, +) from ios_developer_toolkit.runtime import ( ExecutableCommand, command_arguments, @@ -201,6 +207,8 @@ MANPAGE_HELP_KILL_DELAY_MS = 1_500 COMMAND_DRIFT_HELP_TIMEOUT_MS = 5_000 RECONNECT_TIMEOUT_MS = 30_000 +DEVICE_SCAN_TIMEOUT_MS = 10_000 +PROCESS_TERMINATE_GRACE_MS = 1_500 def application_icon_path() -> Path: @@ -231,12 +239,6 @@ def base_environment() -> Mapping[str, str]: return environment -def drain_process_output(process: QProcess, stdout: bytearray, stderr: bytearray) -> None: - """Append every byte currently buffered by a completed or running QProcess.""" - stdout.extend(bytes(process.readAllStandardOutput())) - stderr.extend(bytes(process.readAllStandardError())) - - class DeviceScanner(QObject): devices_changed = Signal(object) scan_error = Signal(str) @@ -248,11 +250,9 @@ def __init__(self, executable: ExecutableCommand) -> None: self._timer = QTimer(self) self._timer.setInterval(3000) self._timer.timeout.connect(self.scan) - self._process: QProcess | None = None - self._stdout = bytearray() - self._stderr = bytearray() + self._controller = FiniteProcessController(self) + self._controller.completed.connect(self._completed) self._stopping = False - self._error_reported = False def start(self) -> None: self._stopping = False @@ -262,64 +262,53 @@ def start(self) -> None: def stop(self) -> None: self._stopping = True self._timer.stop() - if self._process is not None and self._process.state() != QProcess.ProcessState.NotRunning: - self._process.terminate() - if not self._process.waitForFinished(3000): - self._process.kill() - self._process.waitForFinished(1000) + self._controller.shutdown(3000, 1000) def scan(self) -> None: if self._stopping: return - if self._process is not None and self._process.state() != QProcess.ProcessState.NotRunning: + if self._controller.is_running(): return - self._stdout.clear() - self._stderr.clear() - self._error_reported = False - process = QProcess(self) - process.setProgram(str(self._executable.program)) - process.setArguments(list(command_arguments(self._executable, ("usbmux", "list")))) - process.setProcessEnvironment(qprocess_environment(base_environment())) - process.readyReadStandardOutput.connect(self._read_stdout) - process.readyReadStandardError.connect(self._read_stderr) - process.errorOccurred.connect(self._process_error) - process.finished.connect(self._finished) - self._process = process - process.start() - - def _read_stdout(self) -> None: - if self._process is not None: - drain_process_output(self._process, self._stdout, self._stderr) - - def _read_stderr(self) -> None: - if self._process is not None: - drain_process_output(self._process, self._stdout, self._stderr) - - def _process_error(self, process_error: QProcess.ProcessError) -> None: - if self._stopping or self._error_reported: - return - diagnostic = launch_failed_connection_diagnostic() - if process_error != QProcess.ProcessError.FailedToStart: - diagnostic = process_error_connection_diagnostic() - self._error_reported = True - self.diagnostic_changed.emit(diagnostic) - self.scan_error.emit(diagnostic.detail) + request = finite_process_request( + self._executable, + ("usbmux", "list"), + base_environment(), + DEVICE_SCAN_TIMEOUT_MS, + PROCESS_TERMINATE_GRACE_MS, + ) + self._controller.start(request) - def _finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: - del exit_status - if self._process is not None: - drain_process_output(self._process, self._stdout, self._stderr) + def _completed(self, result_object: object) -> None: if self._stopping: return - if self._error_reported: + if not isinstance(result_object, OperationResult): + raise TypeError(f"Expected OperationResult, received {type(result_object).__name__}") + if result_object.outcome == "launch-failed": + diagnostic = launch_failed_connection_diagnostic() + self.diagnostic_changed.emit(diagnostic) + self.scan_error.emit(diagnostic.detail) return - if exit_code != 0: - diagnostic = failed_connection_diagnostic(exit_code) + if result_object.outcome == "timed-out": + diagnostic = timed_out_connection_diagnostic() + self.diagnostic_changed.emit(diagnostic) + self.scan_error.emit(diagnostic.detail) + return + if result_object.outcome in ("crashed", "cancelled"): + diagnostic = process_error_connection_diagnostic() + self.diagnostic_changed.emit(diagnostic) + self.scan_error.emit(diagnostic.detail) + return + if result_object.outcome == "failed": + if result_object.exit_code is None: + raise RuntimeError("Failed device discovery did not provide an exit code") + diagnostic = failed_connection_diagnostic(result_object.exit_code) self.diagnostic_changed.emit(diagnostic) self.scan_error.emit(diagnostic.detail) return + if result_object.outcome != "succeeded": + raise RuntimeError(f"Unsupported device discovery outcome: {result_object.outcome}") try: - devices = parse_devices_json(self._stdout.decode("utf-8")) + devices = parse_devices_json(result_object.stdout.decode("utf-8")) except (DeviceDataError, json.JSONDecodeError, UnicodeDecodeError): diagnostic = malformed_output_connection_diagnostic() self.diagnostic_changed.emit(diagnostic) @@ -638,7 +627,7 @@ def _build_ui(self) -> None: logo.setAccessibleName("iOS Developer Toolkit logo") header_layout.addWidget(logo) title_block = QVBoxLayout() - title = QLabel("iOS Device Workbench") + 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") @@ -5680,8 +5669,8 @@ def closeEvent(self, event: QCloseEvent) -> None: def main() -> int: application = QApplication(sys.argv) - application.setApplicationName("iOS Device Workbench") - application.setOrganizationName("Local Security Tools") + application.setApplicationName("iOS Developer Toolkit") + application.setOrganizationName("hideouts.io") application_icon = QIcon(str(application_icon_path())) if application_icon.isNull(): raise RuntimeError(f"Could not load application icon: {application_icon_path()}") diff --git a/ios_developer_toolkit/command_drift.py b/ios_developer_toolkit/command_drift.py index 81e89c7..6027a5d 100644 --- a/ios_developer_toolkit/command_drift.py +++ b/ios_developer_toolkit/command_drift.py @@ -81,16 +81,17 @@ def _evaluate_preset(preset: CommandPreset, probe: HelpRouteProbe | None) -> Com "route-missing", f"Live help exited with status {probe.exit_code}.{suffix}", ) - if not probe.stdout.strip(): + help_text = "\n".join(output for output in (probe.stdout, probe.stderr) if output.strip()) + if not help_text: return CommandDriftResult( preset.identifier, preset.title, preset.manpage_path, expected_options, "check-failed", - "Live help exited successfully but returned no standard output.", + "Live help exited successfully but returned no output.", ) - missing_options = tuple(option for option in expected_options if not help_includes_option(probe.stdout, option)) + missing_options = tuple(option for option in expected_options if not help_includes_option(help_text, option)) if missing_options: return CommandDriftResult( preset.identifier, diff --git a/ios_developer_toolkit/connection_diagnostics.py b/ios_developer_toolkit/connection_diagnostics.py index 7430599..73788e3 100644 --- a/ios_developer_toolkit/connection_diagnostics.py +++ b/ios_developer_toolkit/connection_diagnostics.py @@ -9,6 +9,7 @@ "not-scanned", "launch-failed", "discovery-failed", + "discovery-timed-out", "malformed-output", "no-devices", "devices-available", @@ -45,6 +46,10 @@ def process_error_connection_diagnostic() -> ConnectionDiagnostic: return _diagnostic("discovery-failed", 0, "The usbmux discovery process stopped before returning a device list") +def timed_out_connection_diagnostic() -> ConnectionDiagnostic: + return _diagnostic("discovery-timed-out", 0, "usbmux discovery exceeded its 10-second limit") + + def malformed_output_connection_diagnostic() -> ConnectionDiagnostic: return _diagnostic("malformed-output", 0, "usbmux returned output that was not a valid device list") diff --git a/ios_developer_toolkit/qt_process.py b/ios_developer_toolkit/qt_process.py new file mode 100644 index 0000000..40a3361 --- /dev/null +++ b/ios_developer_toolkit/qt_process.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Literal, Mapping, Sequence + +from PySide6.QtCore import QObject, QProcess, QProcessEnvironment, QTimer, Signal + +from ios_developer_toolkit.runtime import ExecutableCommand, command_arguments, command_argv + + +ProcessOutcome = Literal["succeeded", "failed", "crashed", "launch-failed", "timed-out", "cancelled"] + + +@dataclass(frozen=True) +class FiniteProcessRequest: + command: ExecutableCommand + arguments: tuple[str, ...] + environment: tuple[tuple[str, str], ...] + timeout_milliseconds: int + terminate_grace_milliseconds: int + + +@dataclass(frozen=True) +class OperationResult: + argv: tuple[str, ...] + outcome: ProcessOutcome + started_at: str + finished_at: str + exit_code: int | None + stdout: bytes + stderr: bytes + + +def finite_process_request( + command: ExecutableCommand, + arguments: Sequence[str], + environment: Mapping[str, str], + timeout_milliseconds: int, + terminate_grace_milliseconds: int, +) -> FiniteProcessRequest: + if timeout_milliseconds <= 0: + raise ValueError(f"Process timeout must be positive: {timeout_milliseconds}") + if terminate_grace_milliseconds <= 0: + raise ValueError(f"Process termination grace period must be positive: {terminate_grace_milliseconds}") + return FiniteProcessRequest( + command, + tuple(arguments), + tuple(sorted(environment.items())), + timeout_milliseconds, + terminate_grace_milliseconds, + ) + + +class FiniteProcessController(QObject): + """Own one bounded QProcess and emit one terminal typed result.""" + + stdout_received = Signal(bytes) + stderr_received = Signal(bytes) + completed = Signal(object) + + def __init__(self, parent: QObject) -> None: + super().__init__(parent) + self._process: QProcess | None = None + self._request: FiniteProcessRequest | None = None + self._stdout = bytearray() + self._stderr = bytearray() + self._started_at = "" + self._stop_outcome: Literal["timed-out", "cancelled"] | None = None + self._completed = False + self._timeout_timer = QTimer(self) + self._timeout_timer.setSingleShot(True) + self._timeout_timer.timeout.connect(self._timeout) + self._kill_timer = QTimer(self) + self._kill_timer.setSingleShot(True) + self._kill_timer.timeout.connect(self._kill) + + def is_running(self) -> bool: + process = self._process + return process is not None and process.state() != QProcess.ProcessState.NotRunning + + def start(self, request: FiniteProcessRequest) -> None: + if self.is_running(): + raise RuntimeError("Cannot start a finite process while another process is running") + self._request = request + self._stdout.clear() + self._stderr.clear() + self._started_at = datetime.now(timezone.utc).isoformat() + self._stop_outcome = None + self._completed = False + + process = QProcess(self) + process.setProgram(str(request.command.program)) + process.setArguments(list(command_arguments(request.command, request.arguments))) + process_environment = QProcessEnvironment.systemEnvironment() + for key, value in request.environment: + process_environment.insert(key, value) + process.setProcessEnvironment(process_environment) + process.readyReadStandardOutput.connect(self._drain_output) + process.readyReadStandardError.connect(self._drain_output) + process.errorOccurred.connect(self._process_error) + process.finished.connect(self._finished) + self._process = process + self._timeout_timer.start(request.timeout_milliseconds) + process.start() + + def cancel(self) -> None: + if not self.is_running(): + return + self._stop_process("cancelled") + + def shutdown(self, terminate_timeout_milliseconds: int, kill_timeout_milliseconds: int) -> None: + if terminate_timeout_milliseconds <= 0: + raise ValueError(f"Shutdown termination timeout must be positive: {terminate_timeout_milliseconds}") + if kill_timeout_milliseconds <= 0: + raise ValueError(f"Shutdown kill timeout must be positive: {kill_timeout_milliseconds}") + process = self._process + if process is None or process.state() == QProcess.ProcessState.NotRunning: + return + self._stop_outcome = "cancelled" + self._timeout_timer.stop() + self._kill_timer.stop() + process.terminate() + if not process.waitForFinished(terminate_timeout_milliseconds): + process.kill() + if not process.waitForFinished(kill_timeout_milliseconds): + raise RuntimeError(f"Process did not stop after terminate and kill: {process.program()}") + + def _drain_output(self) -> None: + process = self._process + if process is None: + return + stdout = bytes(process.readAllStandardOutput()) + stderr = bytes(process.readAllStandardError()) + if stdout: + self._stdout.extend(stdout) + self.stdout_received.emit(stdout) + if stderr: + self._stderr.extend(stderr) + self.stderr_received.emit(stderr) + + def _process_error(self, process_error: QProcess.ProcessError) -> None: + if process_error == QProcess.ProcessError.FailedToStart: + self._finish_once("launch-failed", None) + + def _finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: + self._drain_output() + if self._stop_outcome is not None: + outcome: ProcessOutcome = self._stop_outcome + elif exit_status == QProcess.ExitStatus.CrashExit: + outcome = "crashed" + elif exit_code == 0: + outcome = "succeeded" + else: + outcome = "failed" + self._finish_once(outcome, exit_code) + + def _timeout(self) -> None: + if self.is_running(): + self._stop_process("timed-out") + + def _stop_process(self, outcome: Literal["timed-out", "cancelled"]) -> None: + process = self._process + request = self._request + if process is None or request is None: + raise RuntimeError("Cannot stop a finite process without an active request") + self._stop_outcome = outcome + self._timeout_timer.stop() + process.terminate() + self._kill_timer.start(request.terminate_grace_milliseconds) + + def _kill(self) -> None: + process = self._process + if process is not None and process.state() != QProcess.ProcessState.NotRunning: + process.kill() + + def _finish_once(self, outcome: ProcessOutcome, exit_code: int | None) -> None: + if self._completed: + return + request = self._request + if request is None: + raise RuntimeError("Finite process completed without a request") + self._drain_output() + self._completed = True + self._timeout_timer.stop() + self._kill_timer.stop() + result = OperationResult( + command_argv(request.command, request.arguments), + outcome, + self._started_at, + datetime.now(timezone.utc).isoformat(), + exit_code, + 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/tests/test_core.py b/tests/test_core.py index 3e69fc9..d59267c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -33,6 +33,7 @@ launch_failed_connection_diagnostic, malformed_output_connection_diagnostic, process_error_connection_diagnostic, + timed_out_connection_diagnostic, ) from ios_developer_toolkit.collector import safe_udid_fragment from ios_developer_toolkit.demo_mode import DEMO_DEVICE_IDENTIFIER, demo_connection_banner, demo_device @@ -242,6 +243,12 @@ def test_live_help_evaluation_accepts_exact_option_boundaries(self) -> None: self.assertEqual(evaluate_command_drift((pcap,), (matching,))[0].state, "verified") self.assertEqual(evaluate_command_drift((pcap,), (nonmatching,))[0].state, "option-mismatch") + def test_live_help_evaluation_accepts_help_written_to_standard_error(self) -> None: + pcap = next(preset for preset in command_presets() if preset.identifier == "pcap") + probe = HelpRouteProbe(("pcap",), 0, "", "Usage: pcap --out=PATH", None) + + self.assertEqual(evaluate_command_drift((pcap,), (probe,))[0].state, "verified") + class EvidenceNamingTests(unittest.TestCase): def test_udid_fragment_is_sanitized_and_bounded(self) -> None: @@ -297,6 +304,7 @@ def test_reports_each_discovery_outcome_without_raw_device_data(self) -> None: launch_failed_connection_diagnostic(), failed_connection_diagnostic(7), process_error_connection_diagnostic(), + timed_out_connection_diagnostic(), malformed_output_connection_diagnostic(), devices_connection_diagnostic(0), devices_connection_diagnostic(2), @@ -307,6 +315,7 @@ def test_reports_each_discovery_outcome_without_raw_device_data(self) -> None: "launch-failed", "discovery-failed", "discovery-failed", + "discovery-timed-out", "malformed-output", "no-devices", "devices-available", diff --git a/tests/test_device_scanner.py b/tests/test_device_scanner.py index d741f3f..24bdf73 100644 --- a/tests/test_device_scanner.py +++ b/tests/test_device_scanner.py @@ -4,9 +4,10 @@ import sys import time import unittest +from collections.abc import Callable from pathlib import Path -from PySide6.QtCore import QCoreApplication, QProcess +from PySide6.QtCore import QCoreApplication from ios_developer_toolkit.app import DeviceScanner from ios_developer_toolkit.models import IOSDevice @@ -31,23 +32,17 @@ def test_consumes_valid_discovery_json_after_child_exit(self) -> None: } ] ) - process = QProcess() - process.setProgram(sys.executable) - process.setArguments(("-c", f"import sys; sys.stdout.write({payload!r})")) - process.start() - self.assertTrue(process.waitForStarted(3_000)) - self.assertTrue(process.waitForFinished(3_000)) - - scanner = DeviceScanner(ExecutableCommand(Path(sys.executable), ())) + scanner = DeviceScanner( + ExecutableCommand(Path(sys.executable), ("-c", f"import sys; sys.stdout.write({payload!r})")) + ) observed_devices: list[tuple[IOSDevice, ...]] = [] observed_errors: list[str] = [] observed_diagnostics: list[object] = [] scanner.devices_changed.connect(observed_devices.append) scanner.scan_error.connect(observed_errors.append) scanner.diagnostic_changed.connect(observed_diagnostics.append) - scanner._process = process - - scanner._finished(0, QProcess.ExitStatus.NormalExit) + scanner.scan() + self._wait_for(lambda: bool(observed_devices), 3) self.assertEqual(observed_errors, []) self.assertEqual(len(observed_devices), 1) @@ -73,6 +68,13 @@ def test_reports_failed_process_launch_without_exposing_qprocess_details(self) - self.assertEqual(observed_diagnostics[0].state, "launch-failed") self.assertEqual(observed_errors, ["The usbmux discovery process could not start"]) + def _wait_for(self, predicate: Callable[[], bool], timeout_seconds: int) -> None: + deadline = time.monotonic() + timeout_seconds + while not predicate() and time.monotonic() < deadline: + self.application.processEvents() + time.sleep(0.01) + self.application.processEvents() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_qt_process.py b/tests/test_qt_process.py new file mode 100644 index 0000000..a94d9ec --- /dev/null +++ b/tests/test_qt_process.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import sys +import time +import unittest +from collections.abc import Callable +from pathlib import Path + +from PySide6.QtCore import QCoreApplication + +from ios_developer_toolkit.qt_process import FiniteProcessController, OperationResult, finite_process_request +from ios_developer_toolkit.runtime import ExecutableCommand + + +class FiniteProcessControllerTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.application = QCoreApplication.instance() or QCoreApplication(["finite-process-tests"]) + + def test_returns_terminal_stdout_stderr_and_exit_status(self) -> None: + controller = FiniteProcessController(self.application) + results: list[OperationResult] = [] + controller.completed.connect(results.append) + request = finite_process_request( + ExecutableCommand(Path(sys.executable), ()), + ("-c", "import sys; sys.stdout.write('ready'); sys.stderr.write('notice')"), + {}, + 3_000, + 500, + ) + + controller.start(request) + self._wait_for(lambda: bool(results), 3) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0].outcome, "succeeded") + self.assertEqual(results[0].exit_code, 0) + self.assertEqual(results[0].stdout, b"ready") + self.assertEqual(results[0].stderr, b"notice") + self.assertEqual(results[0].argv, (sys.executable, "-c", request.arguments[1])) + self.assertFalse(controller.is_running()) + + def test_times_out_a_finite_process_once(self) -> None: + controller = FiniteProcessController(self.application) + results: list[OperationResult] = [] + controller.completed.connect(results.append) + request = finite_process_request( + ExecutableCommand(Path(sys.executable), ()), + ("-c", "import time; time.sleep(10)"), + {}, + 100, + 500, + ) + + controller.start(request) + self._wait_for(lambda: bool(results), 3) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0].outcome, "timed-out") + self.assertFalse(controller.is_running()) + + def test_cancels_a_finite_process_once(self) -> None: + controller = FiniteProcessController(self.application) + results: list[OperationResult] = [] + controller.completed.connect(results.append) + request = finite_process_request( + ExecutableCommand(Path(sys.executable), ()), + ("-c", "import time; time.sleep(10)"), + {}, + 3_000, + 500, + ) + + controller.start(request) + controller.cancel() + self._wait_for(lambda: bool(results), 3) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0].outcome, "cancelled") + self.assertFalse(controller.is_running()) + + def _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() From ce452f72e2c89c5143319319494cfbbdb3d401d7 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:48:30 -0700 Subject: [PATCH 3/8] Add contextual command readiness --- README.md | 7 ++- docs/PRODUCT_AUDIT_2026-09-21.md | 1 + ios_developer_toolkit/app.py | 67 ++++++++++++++++++++-- ios_developer_toolkit/capability_matrix.py | 67 ++++++++++++++++++++++ ios_developer_toolkit/entrypoint.py | 8 +++ tests/test_capability_matrix.py | 39 +++++++++++++ 6 files changed, 181 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 438c2cb..eb1ae3d 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ Release `v0.3.4` combines the complete 12-workspace interface with the latest co - **Create Support Bundle…** produces an opt-in local ZIP with sanitized environment, readiness, status, and command-drift metadata plus a SHA-256 manifest; it excludes device identity, captures, backups, logs, command output, credentials, and common host/network identifiers; - **Retry Scan** performs an immediate usbmux device check, while **Reconnect & Retry…** opens a guided detection window without attempting to restart SIP-protected Apple services; - **Connection diagnostic** records whether usbmux did not launch, failed, returned malformed output, found no devices, or returned selectable devices; its privacy-safe summary is visible in Device & DDI and included in a sanitized support bundle; +- **Selected command readiness** maps each guided Command Center action to the exact connection, trust, Developer Mode, DDI, tunnel, CoreDevice, DVT, or Web Inspector checks it needs, with a one-click route to the bounded read-only matrix; - 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; @@ -90,7 +91,7 @@ Release `v0.3.4` combines the complete 12-workspace interface with the latest co - 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 81 tests, embedded CLI checks, an 89-button GUI smoke test, architecture inspection, strict code-signature validation, and one SHA-256 manifest; +- native Apple Silicon and Intel release ZIPs are built separately and verified with 83 tests, embedded CLI checks, a 90-button GUI smoke test, architecture 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. @@ -531,7 +532,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. **Check Guided Command Drift** is a read-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. It reports unavailable routes, changed option syntax, timeouts, and any 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. **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. It reports unavailable routes, changed option syntax, timeouts, and any routes not completed before cancellation. Every preset has a visible risk class: @@ -1040,7 +1041,7 @@ Physical-device validation is opt-in and is not required for pull requests. Use ### 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 81 tests, verifies the embedded pymobiledevice3 command, checks the internal worker route, runs the 89-button offscreen GUI smoke test, verifies the Mach-O architecture and its macOS 13.0 load-command floor, 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 83 tests, verifies the embedded pymobiledevice3 command, checks the internal worker route, runs the 90-button offscreen GUI smoke test, verifies the Mach-O architecture and its macOS 13.0 load-command floor, 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 a bundle whose executable targets a newer macOS version, so local release builds should use a Python toolchain that can produce macOS 13 binaries; GitHub release CI supplies this target explicitly. diff --git a/docs/PRODUCT_AUDIT_2026-09-21.md b/docs/PRODUCT_AUDIT_2026-09-21.md index 825d645..cab8367 100644 --- a/docs/PRODUCT_AUDIT_2026-09-21.md +++ b/docs/PRODUCT_AUDIT_2026-09-21.md @@ -164,6 +164,7 @@ Upstream contribution candidates are concrete: report the fast-exit scanner pack | 2026-09-21 | Added a reusable typed finite-process controller and migrated device discovery to it. | Real child-process tests cover terminal stdout/stderr, fast completion, launch failure, cancellation, timeout, and one-result semantics; the full suite now contains 81 tests. | Migrate other finite QProcess workflows incrementally; long-running streams retain their separate lifecycle. | | 2026-09-21 | Made live-help drift checks accept successful help emitted on either standard output or standard error. | A clean GitHub runner exposed two false option mismatches while the same pinned CLI passed locally; the channel-specific regression test now preserves strict option matching without assuming a help stream. | Re-run CI on a clean runner and retain failure for genuinely absent routes or options. | | 2026-09-21 | Added an opt-in physical-device protocol with staged read-only, developer-service, and state-changing checks. | The current host check found no Apple mobile USB device, no usbmux device, and no CoreDevice result, so no physical compatibility claim was made. | Run the protocol with an authorized connected device and retain identifiers and raw evidence locally. | +| 2026-09-21 | Added contextual readiness for every guided command and corrected support-bundle capability aggregation. | Command-specific tests cover untested, ready, not-applicable, and attention states; the GUI smoke verifies the new control by stable object ID. | Readiness remains a point-in-time local probe and never substitutes for an actual command result. | ## Research sources diff --git a/ios_developer_toolkit/app.py b/ios_developer_toolkit/app.py index 0c2ca5f..7871f46 100644 --- a/ios_developer_toolkit/app.py +++ b/ios_developer_toolkit/app.py @@ -79,7 +79,9 @@ CapabilityWorkerCompleted, CapabilityWorkerStarted, capability_definitions, + capability_state_counts, capability_state_label, + evaluate_preset_readiness, parse_capability_worker_event, untested_capability_results, ) @@ -971,10 +973,7 @@ def _support_bundle_context(self) -> SupportBundleContext: current_item = self.navigation_list.currentItem() if current_item is None: raise RuntimeError("Cannot create a support bundle without a selected workspace") - capability_counts = tuple( - (state, sum(result.state == state for result in self._capability_results.values())) - for state in ("ready", "needs-attention", "unavailable", "blocked", "not-tested", "not-applicable") - ) + capability_counts = capability_state_counts(self._capability_results.values()) statuses = ( SupportStatus("connection", self.connection_banner.text()), SupportStatus("connection_diagnostic", self._connection_diagnostic.report()), @@ -2087,7 +2086,7 @@ def _build_command_center_page(self) -> QWidget: browser_splitter = QSplitter(Qt.Orientation.Horizontal) browser_splitter.setObjectName("commandBrowserSplitter") - browser_splitter.setMaximumHeight(390) + browser_splitter.setMaximumHeight(470) preset_browser = QFrame() preset_browser.setObjectName("commandPresetBrowser") @@ -2134,6 +2133,22 @@ def _build_command_center_page(self) -> QWidget: self.command_prerequisites.setWordWrap(True) detail_layout.addWidget(self.command_prerequisites) + readiness_group = QGroupBox("Selected command readiness") + readiness_layout = QHBoxLayout(readiness_group) + self.command_readiness_status = QLabel("Choose a preset to evaluate its device requirements.") + self.command_readiness_status.setObjectName("commandReadinessStatus") + self.command_readiness_status.setWordWrap(True) + self.command_readiness_status.setAccessibleName("Selected command readiness") + readiness_layout.addWidget(self.command_readiness_status, 1) + self.command_readiness_button = QPushButton("Run Device Readiness Check") + self.command_readiness_button.setObjectName("runCommandReadinessButton") + self.command_readiness_button.setAccessibleDescription( + "Runs the bounded read-only Capability Matrix for the selected physical device." + ) + self.command_readiness_button.clicked.connect(self.run_selected_command_readiness_check) + readiness_layout.addWidget(self.command_readiness_button) + detail_layout.addWidget(readiness_group) + self.preset_parameters_group = QGroupBox("Required values") self.preset_parameters_layout = QFormLayout(self.preset_parameters_group) detail_layout.addWidget(self.preset_parameters_group) @@ -2567,6 +2582,7 @@ def _reset_capability_matrix(self, device: IOSDevice | None) -> None: ) self._populate_capability_matrix() self._update_capability_controls() + self._update_selected_command_readiness() def _capability_state_brush(self, state: CapabilityState) -> QBrush: colors: Mapping[CapabilityState, str] = { @@ -2812,6 +2828,7 @@ def _handle_capability_event(self, payload: str) -> None: f"{capability_state_label(event.state)}" ) self._populate_capability_matrix() + self._update_selected_command_readiness() return if isinstance(event, CapabilityWorkerCompleted): self._capability_worker_completed = True @@ -2858,6 +2875,7 @@ def _capability_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) ) self._populate_capability_matrix() self._update_capability_controls() + self._update_selected_command_readiness() def _capability_error(self, process_error: QProcess.ProcessError) -> None: if self._capability_process is None: @@ -2867,6 +2885,7 @@ def _capability_error(self, process_error: QProcess.ProcessError) -> None: self._capability_process = None self.case_readiness_button.setEnabled(self.selected_device() is not None) self._update_capability_controls() + self._update_selected_command_readiness() def cancel_capability_matrix(self) -> None: self._stop_capability_process("Capability refresh was cancelled.") @@ -4912,8 +4931,46 @@ def _update_command_controls(self) -> None: self.preset_run_button.setText( "Run Guided Command" if preset.risk == "read-only" else "Review && Run Guided Command" ) + self._update_selected_command_readiness() self._update_advanced_safety_note() + def _update_selected_command_readiness(self) -> None: + preset = self._current_preset + if preset is None: + self.command_readiness_status.setText("Choose a preset to evaluate its device requirements.") + self.command_readiness_button.setEnabled(False) + return + if preset.requires_device and self.selected_device() is None: + self.command_readiness_status.setText( + "Blocked — connect, unlock, trust, and select the intended physical device first." + ) + self.command_readiness_button.setEnabled(False) + return + readiness = evaluate_preset_readiness(preset, self._capability_results) + next_steps = " ".join(readiness.remediation) + suffix = f" Next step: {next_steps}" if next_steps else "" + labels = { + "ready": "Ready", + "not-tested": "Not checked", + "needs-attention": "Needs attention", + } + self.command_readiness_status.setText(f"{labels[readiness.state]} — {readiness.summary}{suffix}") + self.command_readiness_button.setEnabled( + preset.requires_device and self.selected_device() is not None and self._capability_process is None + ) + + def run_selected_command_readiness_check(self) -> None: + preset = self._current_preset + if preset is None: + raise CommandCatalogError("Cannot run command readiness without a selected preset") + if not preset.requires_device: + raise CommandCatalogError("The selected preset does not require a device readiness check") + if self.selected_device() is None: + self._show_no_device() + return + self.navigate_to_page("Capability Matrix") + self.refresh_capability_matrix() + def _update_advanced_safety_note(self) -> None: raw_arguments = self.console_input.text().strip() if not raw_arguments: diff --git a/ios_developer_toolkit/capability_matrix.py b/ios_developer_toolkit/capability_matrix.py index 86f07f2..eb37f0a 100644 --- a/ios_developer_toolkit/capability_matrix.py +++ b/ios_developer_toolkit/capability_matrix.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Iterable, Literal, Mapping +from ios_developer_toolkit.command_catalog import CommandPreset from ios_developer_toolkit.models import IOSDevice from ios_developer_toolkit.runtime import ExecutableCommand, command_argv, command_text, device_environment from ios_developer_toolkit.validation import output_indicates_failure @@ -72,6 +73,16 @@ class CapabilityWorkerCompleted: CapabilityWorkerEvent = CapabilityWorkerStarted | CapabilityResult | CapabilityWorkerCompleted +PresetReadinessState = Literal["ready", "not-tested", "needs-attention"] + + +@dataclass(frozen=True) +class PresetReadiness: + state: PresetReadinessState + summary: str + remediation: tuple[str, ...] + + @dataclass(frozen=True) class CommandOutcome: arguments: tuple[str, ...] @@ -186,6 +197,62 @@ def untested_capability_results() -> tuple[CapabilityResult, ...]: ) +def capability_state_counts(results: Iterable[CapabilityResult]) -> tuple[tuple[CapabilityState, int], ...]: + materialized = tuple(results) + return tuple((state, sum(result.state == state for result in materialized)) for state in CAPABILITY_STATES) + + +def preset_capability_identifiers(preset: CommandPreset) -> tuple[str, ...]: + identifiers: list[str] = [] + if preset.requires_device: + identifiers.extend(("device-connection", "pairing-trust")) + if preset.requires_developer_services: + identifiers.extend(("developer-mode", "developer-image", "rsd-tunnel")) + if preset.argument_template[:2] == ("developer", "core-device"): + identifiers.append("coredevice") + else: + identifiers.append("dvt") + if preset.argument_template and preset.argument_template[0] == "webinspector": + identifiers.append("webinspector") + return tuple(dict.fromkeys(identifiers)) + + +def evaluate_preset_readiness( + preset: CommandPreset, + results: Mapping[str, CapabilityResult], +) -> PresetReadiness: + identifiers = preset_capability_identifiers(preset) + if not identifiers: + return PresetReadiness("ready", "No device capability check is required for this preset.", ()) + missing = tuple(identifier for identifier in identifiers if identifier not in results) + if missing: + raise CapabilityMatrixError(f"Capability results are missing required identifiers: {', '.join(missing)}") + required = tuple(results[identifier] for identifier in identifiers) + not_tested = tuple(result for result in required if result.state == "not-tested") + if not_tested: + titles = ", ".join(result.title for result in not_tested) + return PresetReadiness( + "not-tested", + f"Readiness not checked for: {titles}.", + ("Run the one-click Device Readiness Check before this command.",), + ) + attention = tuple(result for result in required if result.state in ("attention", "unavailable", "blocked")) + if attention: + summary = "; ".join( + f"{result.title}: {capability_state_label(result.state)}" for result in attention + ) + return PresetReadiness( + "needs-attention", + summary, + tuple(dict.fromkeys(result.remediation for result in attention)), + ) + return PresetReadiness( + "ready", + "Every tested requirement is ready or not applicable for the selected device.", + (), + ) + + def result_for( identifier: str, state: CapabilityState, diff --git a/ios_developer_toolkit/entrypoint.py b/ios_developer_toolkit/entrypoint.py index 23dac53..f8655f5 100644 --- a/ios_developer_toolkit/entrypoint.py +++ b/ios_developer_toolkit/entrypoint.py @@ -120,6 +120,14 @@ def run_smoke_test(arguments: Sequence[str]) -> int: raise RuntimeError(f"GUI command-drift action is missing: {button_name}") if button.isEnabled(): raise RuntimeError(f"GUI command-drift action should be disabled before a drift check: {button_name}") + command_readiness = window.findChild(QLabel, "commandReadinessStatus") + if command_readiness is None or not command_readiness.text().strip(): + raise RuntimeError("GUI selected-command readiness has no visible state") + command_readiness_button = window.findChild(QPushButton, "runCommandReadinessButton") + if command_readiness_button is None: + raise RuntimeError("GUI selected-command readiness action is missing") + if command_readiness_button.isEnabled(): + raise RuntimeError("GUI selected-command readiness must remain disabled without a selected device") expected_shortcuts = { "shortcutRetryDeviceScan", "shortcutFocusWorkspaceNavigation", diff --git a/tests/test_capability_matrix.py b/tests/test_capability_matrix.py index 7e6f2cd..652d47d 100644 --- a/tests/test_capability_matrix.py +++ b/tests/test_capability_matrix.py @@ -14,13 +14,18 @@ CommandOutcome, _probe_xcode_tools, capability_definitions, + capability_state_counts, command_succeeded, compact_command_detail, + evaluate_preset_readiness, lock_state_from_payload, mounted_image_summary, parse_capability_worker_event, + preset_capability_identifiers, + result_for, untested_capability_results, ) +from ios_developer_toolkit.command_catalog import command_presets from ios_developer_toolkit.device_compatibility import ( DeviceCompatibilityError, append_observation, @@ -44,6 +49,40 @@ def test_catalog_and_initial_results_are_complete_and_unique(self) -> None: ) self.assertTrue(all(item.state == "not-tested" for item in results)) + def test_counts_every_supported_capability_state_including_attention(self) -> None: + results = ( + result_for("pymobiledevice3", "ready", "ready", "tested"), + result_for("xcode-tools", "attention", "attention", "tested"), + ) + + counts = dict(capability_state_counts(results)) + + self.assertEqual(counts["ready"], 1) + self.assertEqual(counts["attention"], 1) + self.assertEqual(set(counts), {"ready", "attention", "unavailable", "blocked", "not-tested", "not-applicable"}) + + def test_evaluates_command_specific_readiness_without_mutating_matrix_results(self) -> None: + dvt = next(preset for preset in command_presets() if preset.identifier == "dvt-device") + initial = {result.identifier: result for result in untested_capability_results()} + self.assertEqual( + preset_capability_identifiers(dvt), + ("device-connection", "pairing-trust", "developer-mode", "developer-image", "rsd-tunnel", "dvt"), + ) + self.assertEqual(evaluate_preset_readiness(dvt, initial).state, "not-tested") + + ready = dict(initial) + for identifier in preset_capability_identifiers(dvt): + state = "not-applicable" if identifier == "rsd-tunnel" else "ready" + ready[identifier] = result_for(identifier, state, "tested", "evidence") + self.assertEqual(evaluate_preset_readiness(dvt, ready).state, "ready") + + attention = dict(ready) + attention["developer-mode"] = result_for("developer-mode", "attention", "disabled", "tested") + evaluation = evaluate_preset_readiness(dvt, attention) + self.assertEqual(evaluation.state, "needs-attention") + self.assertIn("Enable Settings", evaluation.remediation[0]) + self.assertTrue(all(result.state == "not-tested" for result in initial.values())) + def test_parses_strict_worker_events(self) -> None: started = parse_capability_worker_event('{"event":"started","total":11}') self.assertEqual(started, CapabilityWorkerStarted(total=11)) From 7fd444713c847f57e86d5ba19f4f40993f7d45a9 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:55:34 -0700 Subject: [PATCH 4/8] Harden live help process handling --- README.md | 6 +- docs/PRODUCT_AUDIT_2026-09-21.md | 3 +- ios_developer_toolkit/app.py | 125 +++++++++---------------- ios_developer_toolkit/command_drift.py | 4 +- ios_developer_toolkit/entrypoint.py | 17 ++++ tests/test_core.py | 7 ++ tests/test_qt_process.py | 28 ++++++ 7 files changed, 103 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index eb1ae3d..22eacd3 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Release `v0.3.4` combines the complete 12-workspace interface with the latest co - 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 83 tests, embedded CLI checks, a 90-button GUI smoke test, architecture inspection, strict code-signature validation, and one SHA-256 manifest; +- native Apple Silicon and Intel release ZIPs are built separately and verified with 85 tests, embedded CLI checks, a 90-button GUI smoke test, architecture 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. @@ -687,7 +687,7 @@ The collector retries failed snapshots once, keeps the final artifact and a comp ![Man Pages and Possibilities workspace](docs/screenshots/man-pages.png) -The Man Pages browser indexes 59 top-level and nested command routes. Selecting a route is immediate and does not start a process or contact the device. Click **Refresh Live Help** when you want the project-local executable's verbatim `--help` output. You can cancel a slow request, and the toolkit stops it automatically after 15 seconds so the page cannot remain stuck on “Loading live help.” Successful results are cached for the current app session. You can also copy the command prefix or send it to Command Center's Advanced Mode. +The Man Pages browser indexes 59 top-level and nested command routes. Selecting a route is immediate and does not start a process or contact the device. Click **Refresh Live Help** when you want the project-local executable's verbatim `--help` output. The shared finite-operation controller drains output at completion, accepts help written to either output channel, supports cancellation and clean relaunch, and stops a request automatically after 15 seconds so the page cannot remain stuck on “Loading live help.” Successful results are cached for the current app session. You can also copy the command prefix or send it to Command Center's Advanced Mode. This is the safest source for exact syntax in the installed environment. A command listed by the client is still not proof that the selected device build advertises the corresponding Apple service. @@ -1041,7 +1041,7 @@ Physical-device validation is opt-in and is not required for pull requests. Use ### 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 83 tests, verifies the embedded pymobiledevice3 command, checks the internal worker route, runs the 90-button offscreen GUI smoke test, verifies the Mach-O architecture and its macOS 13.0 load-command floor, 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 85 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 Mach-O architecture and its macOS 13.0 load-command floor, 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 a bundle whose executable targets a newer macOS version, so local release builds should use a Python toolchain that can produce macOS 13 binaries; GitHub release CI supplies this target explicitly. diff --git a/docs/PRODUCT_AUDIT_2026-09-21.md b/docs/PRODUCT_AUDIT_2026-09-21.md index cab8367..879dfee 100644 --- a/docs/PRODUCT_AUDIT_2026-09-21.md +++ b/docs/PRODUCT_AUDIT_2026-09-21.md @@ -113,7 +113,7 @@ 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 is the first migrated client and now has shared final-drain, timeout, cancellation, launch-failure, and structured completion semantics; Man Pages, command drift, DDI, backup, apps, and capture remain incremental migrations. +* Continue migrating finite subprocess workflows to the reusable operation controller and typed `OperationResult`. Device discovery and Man Pages now share final-drain, timeout, cancellation, launch-failure, clean-relaunch, and structured completion semantics; command drift, DDI, backup, apps, and capture remain incremental migrations. * 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; it remains unexecuted until GitHub Actions runs it. The release builder also rejects a bundle whose Mach-O minimum macOS version differs from the advertised 13.0 floor. * Generate concise changelog/release notes from tested behavior. Source, bundle, citation, packaging, and third-party-source metadata drift is now covered by automated tests. @@ -165,6 +165,7 @@ Upstream contribution candidates are concrete: report the fast-exit scanner pack | 2026-09-21 | Made live-help drift checks accept successful help emitted on either standard output or standard error. | A clean GitHub runner exposed two false option mismatches while the same pinned CLI passed locally; the channel-specific regression test now preserves strict option matching without assuming a help stream. | Re-run CI on a clean runner and retain failure for genuinely absent routes or options. | | 2026-09-21 | Added an opt-in physical-device protocol with staged read-only, developer-service, and state-changing checks. | The current host check found no Apple mobile USB device, no usbmux device, and no CoreDevice result, so no physical compatibility claim was made. | Run the protocol with an authorized connected device and retain identifiers and raw evidence locally. | | 2026-09-21 | Added contextual readiness for every guided command and corrected support-bundle capability aggregation. | Command-specific tests cover untested, ready, not-applicable, and attention states; the GUI smoke verifies the new control by stable object ID. | Readiness remains a point-in-time local probe and never substitutes for an actual command result. | +| 2026-09-21 | Migrated Man Pages live help to the shared finite-operation controller and normalized styled CLI help for command-drift checks. | The GUI smoke now completes a real live-help request; controller relaunch tests reject stale output, and ANSI-split option tokens remain strictly verifiable. | Sequential command drift and other finite workflows remain incremental migrations. | ## Research sources diff --git a/ios_developer_toolkit/app.py b/ios_developer_toolkit/app.py index 7871f46..11a919e 100644 --- a/ios_developer_toolkit/app.py +++ b/ios_developer_toolkit/app.py @@ -572,15 +572,10 @@ def __init__(self) -> None: self._current_preset: CommandPreset | None = None self._preset_parameter_fields: dict[str, QLineEdit] = {} self._manpages = manpage_entries() - self._manpage_process: QProcess | None = None - self._manpage_stdout = bytearray() - self._manpage_stderr = bytearray() + self._manpage_controller = FiniteProcessController(self) + self._manpage_controller.completed.connect(self._manpage_completed) self._manpage_cache: dict[tuple[str, ...], str] = {} self._manpage_active_path: tuple[str, ...] | None = None - self._manpage_cancel_reason: str | None = None - self._manpage_timeout_timer = QTimer(self) - self._manpage_timeout_timer.setSingleShot(True) - self._manpage_timeout_timer.timeout.connect(self._manpage_timed_out) self._command_drift_process: QProcess | None = None self._command_drift_stdout = bytearray() self._command_drift_stderr = bytearray() @@ -5307,7 +5302,7 @@ def _show_manpage_entry(self, entry: ManPageEntry) -> None: cached_help = self._manpage_cache.get(entry.command_path) if cached_help is not None: self.manpage_output.setPlainText(cached_help) - elif self._manpage_process is None: + elif not self._manpage_controller.is_running(): self.manpage_output.setPlainText( f"{entry.title}\n\nCommand prefix: {prefix}\nCategory: {entry.category}\n\n" "Click Refresh Live Help to query the installed pymobiledevice3 executable. Selection alone never " @@ -5344,99 +5339,65 @@ def refresh_selected_manpage(self) -> None: if entry is None: QMessageBox.information(self, "No Help Topic", "Select a command family first.") return - if self._manpage_process is not None: + if self._manpage_controller.is_running(): QMessageBox.warning(self, "Help Loading", "Wait for the current help page to finish loading.") return - self._manpage_stdout.clear() - self._manpage_stderr.clear() self._manpage_active_path = entry.command_path - self._manpage_cancel_reason = None self.manpage_output.setPlainText( "Loading live help from the installed pymobiledevice3…\n\n" "This can take several seconds on the first Python import. Use Cancel Loading to stop immediately." ) - process = QProcess(self) - process.setProgram(str(self._pmd3.program)) - process.setArguments(list(command_arguments(self._pmd3, (*entry.command_path, "--help")))) - process.setProcessEnvironment(qprocess_environment(base_environment())) - process.readyReadStandardOutput.connect(self._read_manpage_stdout) - process.readyReadStandardError.connect(self._read_manpage_stderr) - process.finished.connect(self._manpage_finished) - process.errorOccurred.connect(self._manpage_error) - self._manpage_process = process + self._manpage_controller.start( + finite_process_request( + self._pmd3, + (*entry.command_path, "--help"), + base_environment(), + MANPAGE_HELP_TIMEOUT_MS, + MANPAGE_HELP_KILL_DELAY_MS, + ) + ) self._update_manpage_controls() - process.start() - self._manpage_timeout_timer.start(MANPAGE_HELP_TIMEOUT_MS) - def _read_manpage_stdout(self) -> None: - if self._manpage_process is not None: - self._manpage_stdout.extend(bytes(self._manpage_process.readAllStandardOutput())) - - def _read_manpage_stderr(self) -> None: - if self._manpage_process is not None: - self._manpage_stderr.extend(bytes(self._manpage_process.readAllStandardError())) - - def _manpage_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: - del exit_status - self._manpage_timeout_timer.stop() - self._read_manpage_stdout() - self._read_manpage_stderr() - stdout = self._manpage_stdout.decode("utf-8", errors="replace") - stderr = self._manpage_stderr.decode("utf-8", errors="replace") - if self._manpage_cancel_reason is not None: - partial_output = stdout or stderr - suffix = f"\n\nPartial output:\n{partial_output}" if partial_output else "" - self.manpage_output.setPlainText(f"{self._manpage_cancel_reason}{suffix}") - elif exit_code == 0 and stdout: + def _manpage_completed(self, result_object: object) -> None: + if not isinstance(result_object, OperationResult): + raise TypeError(f"Expected OperationResult, received {type(result_object).__name__}") + stdout = result_object.stdout.decode("utf-8", errors="replace") + stderr = result_object.stderr.decode("utf-8", errors="replace") + output = stdout or stderr + if result_object.outcome == "cancelled": + suffix = f"\n\nPartial output:\n{output}" if output else "" + self.manpage_output.setPlainText(f"Live help loading was cancelled by the user.{suffix}") + elif result_object.outcome == "timed-out": + suffix = f"\n\nPartial output:\n{output}" if output else "" + self.manpage_output.setPlainText( + "Live help exceeded the 15-second limit and was stopped. The installed CLI did not return " + f"promptly; the Man Pages browser remains available.{suffix}" + ) + elif result_object.outcome == "succeeded" and output: if self._manpage_active_path is None: raise CommandCatalogError("Live help completed without an active command path") - self._manpage_cache[self._manpage_active_path] = stdout - self.manpage_output.setPlainText(stdout) + self._manpage_cache[self._manpage_active_path] = output + self.manpage_output.setPlainText(output) + elif result_object.outcome == "launch-failed": + self.manpage_output.setPlainText( + f"Could not start live help. Verify the project runtime exists and is executable: {result_object.argv[0]}" + ) else: + exit_detail = "unavailable" if result_object.exit_code is None else str(result_object.exit_code) self.manpage_output.setPlainText( - f"Live help failed with exit code {exit_code}.\n\n{stderr or stdout}" + f"Live help {result_object.outcome.replace('-', ' ')} with exit code {exit_detail}.\n\n{output}" ) - self._manpage_process = None self._manpage_active_path = None - self._manpage_cancel_reason = None self._update_manpage_controls() - def _manpage_error(self, process_error: QProcess.ProcessError) -> None: - if self._manpage_process is not None: - self.manpage_output.setPlainText(f"Could not load help: {self._manpage_process.errorString()}") - if process_error == QProcess.ProcessError.FailedToStart: - self._manpage_timeout_timer.stop() - self._manpage_process = None - self._manpage_active_path = None - self._manpage_cancel_reason = None - self._update_manpage_controls() - def cancel_manpage_load(self) -> None: - self._cancel_manpage_load("Live help loading was cancelled by the user.") - - def _manpage_timed_out(self) -> None: - self._cancel_manpage_load( - "Live help exceeded the 15-second limit and was stopped. The installed CLI did not return promptly; " - "the Man Pages browser remains available." - ) - - def _cancel_manpage_load(self, reason: str) -> None: - process = self._manpage_process - if process is None: + if not self._manpage_controller.is_running(): return - self._manpage_timeout_timer.stop() - self._manpage_cancel_reason = reason - self.manpage_output.setPlainText(f"{reason}\n\nStopping the help process…") - process.terminate() - QTimer.singleShot(MANPAGE_HELP_KILL_DELAY_MS, self._kill_manpage_after_cancel) - - def _kill_manpage_after_cancel(self) -> None: - process = self._manpage_process - if process is not None and process.state() != QProcess.ProcessState.NotRunning: - process.kill() + self.manpage_output.setPlainText("Live help cancellation requested.\n\nStopping the help process…") + self._manpage_controller.cancel() def _update_manpage_controls(self) -> None: - running = self._manpage_process is not None + running = self._manpage_controller.is_running() selected = self.selected_manpage_entry() is not None self.manpage_list.setEnabled(not running) self.manpage_search_field.setEnabled(not running) @@ -5703,8 +5664,8 @@ def closeEvent(self, event: QCloseEvent) -> None: return self._scanner.stop() self._reconnect_timeout_timer.stop() - self._manpage_timeout_timer.stop() self._command_drift_timeout_timer.stop() + self._manpage_controller.shutdown(3000, 1000) capability_process = self._capability_process if capability_process is not None and capability_process.state() != QProcess.ProcessState.NotRunning: self._terminate_capability_children(capability_process) @@ -5718,7 +5679,7 @@ def closeEvent(self, event: QCloseEvent) -> None: if not process.waitForFinished(10000): process.kill() process.waitForFinished(3000) - for process in (self._ipa_inspection_process, self._manpage_process, self._command_drift_process): + for process in (self._ipa_inspection_process, self._command_drift_process): if process is not None and process.state() != QProcess.ProcessState.NotRunning: process.terminate() event.accept() diff --git a/ios_developer_toolkit/command_drift.py b/ios_developer_toolkit/command_drift.py index 6027a5d..6d022dd 100644 --- a/ios_developer_toolkit/command_drift.py +++ b/ios_developer_toolkit/command_drift.py @@ -8,6 +8,7 @@ CommandDriftState = Literal["verified", "route-missing", "option-mismatch", "check-failed", "not-checked"] +ANSI_CONTROL_SEQUENCE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") @dataclass(frozen=True) @@ -39,7 +40,8 @@ def expected_option_tokens(preset: CommandPreset) -> tuple[str, ...]: def help_includes_option(help_text: str, option: str) -> bool: pattern = rf"(? int: raise RuntimeError("GUI selected-command readiness action is missing") if command_readiness_button.isEnabled(): raise RuntimeError("GUI selected-command readiness must remain disabled without a selected device") + window.navigate_to_page("Man Pages") + refresh_manpage_button = window.findChild(QPushButton, "refreshManpageButton") + if refresh_manpage_button is None or not refresh_manpage_button.isEnabled(): + raise RuntimeError("GUI live-help action is unavailable") + selected_manpage = window.selected_manpage_entry() + if selected_manpage is None: + raise RuntimeError("GUI live-help index has no selected route") + refresh_manpage_button.click() + live_help_deadline = time.monotonic() + 20 + while window._manpage_controller.is_running() and time.monotonic() < live_help_deadline: + application.processEvents() + application.processEvents() + if window._manpage_controller.is_running(): + raise RuntimeError("GUI live-help action did not complete within its bounded smoke-test window") + if selected_manpage.command_path not in window._manpage_cache: + raise RuntimeError(f"GUI live-help action did not cache successful output: {window.manpage_output.toPlainText()}") expected_shortcuts = { "shortcutRetryDeviceScan", "shortcutFocusWorkspaceNavigation", diff --git a/tests/test_core.py b/tests/test_core.py index d59267c..c169e58 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -249,6 +249,13 @@ def test_live_help_evaluation_accepts_help_written_to_standard_error(self) -> No self.assertEqual(evaluate_command_drift((pcap,), (probe,))[0].state, "verified") + def test_live_help_evaluation_accepts_terminal_styled_options(self) -> None: + pcap = preset_by_identifier("pcap") + styled_help = "Usage: pcap \x1b[36m--\x1b[0m\x1b[36mout\x1b[0m PATH" + probe = HelpRouteProbe(("pcap",), 0, styled_help, "", None) + + self.assertEqual(evaluate_command_drift((pcap,), (probe,))[0].state, "verified") + class EvidenceNamingTests(unittest.TestCase): def test_udid_fragment_is_sanitized_and_bounded(self) -> None: diff --git a/tests/test_qt_process.py b/tests/test_qt_process.py index a94d9ec..5c6cfd8 100644 --- a/tests/test_qt_process.py +++ b/tests/test_qt_process.py @@ -79,6 +79,34 @@ def test_cancels_a_finite_process_once(self) -> None: self.assertEqual(results[0].outcome, "cancelled") self.assertFalse(controller.is_running()) + def test_relaunches_without_reusing_previous_output(self) -> None: + controller = FiniteProcessController(self.application) + results: list[OperationResult] = [] + controller.completed.connect(results.append) + first = finite_process_request( + ExecutableCommand(Path(sys.executable), ()), + ("-c", "print('first')"), + {}, + 3_000, + 500, + ) + second = finite_process_request( + ExecutableCommand(Path(sys.executable), ()), + ("-c", "print('second')"), + {}, + 3_000, + 500, + ) + + controller.start(first) + self._wait_for(lambda: len(results) == 1, 3) + controller.start(second) + self._wait_for(lambda: len(results) == 2, 3) + + self.assertEqual(results[0].stdout, b"first\n") + self.assertEqual(results[1].stdout, b"second\n") + self.assertFalse(controller.is_running()) + def _wait_for(self, predicate: Callable[[], bool], timeout_seconds: int) -> None: deadline = time.monotonic() + timeout_seconds while not predicate() and time.monotonic() < deadline: From 2b1a3eca1e36c60fd3b1517859e7e752f5943532 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:48:44 -0700 Subject: [PATCH 5/8] Fix native macOS packaging gates --- .github/workflows/frozen-macos-smoke.yml | 9 ++++++++- .github/workflows/release-macos.yml | 8 ++++++++ README.md | 2 +- docs/PRODUCT_AUDIT_2026-09-21.md | 3 ++- scripts/build_macos_release.sh | 21 ++++++++++++++++++--- 5 files changed, 37 insertions(+), 6 deletions(-) diff --git a/.github/workflows/frozen-macos-smoke.yml b/.github/workflows/frozen-macos-smoke.yml index 3bfac4d..3771bad 100644 --- a/.github/workflows/frozen-macos-smoke.yml +++ b/.github/workflows/frozen-macos-smoke.yml @@ -40,7 +40,7 @@ jobs: architecture: x86_64 python_architecture: x64 runs-on: ${{ matrix.runner }} - timeout-minutes: 45 + timeout-minutes: 90 steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 @@ -48,9 +48,16 @@ jobs: python-version: "3.13" architecture: ${{ matrix.python_architecture }} cache: pip + - uses: actions/cache@v6 + with: + path: ${{ runner.temp }}/nuitka-cache + key: nuitka-${{ runner.os }}-${{ matrix.architecture }}-${{ hashFiles('pyproject.toml', 'requirements/**', 'packaging/**') }} + restore-keys: | + nuitka-${{ runner.os }}-${{ matrix.architecture }}- - name: Build the frozen application and run embedded checks env: MACOSX_DEPLOYMENT_TARGET: "13.0" + NUITKA_CACHE_DIR: ${{ runner.temp }}/nuitka-cache run: | release_version="$(python3 -c 'from ios_developer_toolkit import APP_VERSION; print(APP_VERSION)')" ./scripts/build_macos_release.sh "$release_version" "$RUNNER_TEMP/release-smoke" python3 diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index b529a93..ee703c4 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -22,6 +22,7 @@ jobs: architecture: x86_64 python_architecture: x64 runs-on: ${{ matrix.runner }} + timeout-minutes: 90 steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 @@ -29,9 +30,16 @@ jobs: python-version: "3.13" architecture: ${{ matrix.python_architecture }} cache: pip + - uses: actions/cache@v6 + with: + path: ${{ runner.temp }}/nuitka-cache + key: nuitka-${{ runner.os }}-${{ matrix.architecture }}-${{ hashFiles('pyproject.toml', 'requirements/**', 'packaging/**') }} + restore-keys: | + nuitka-${{ runner.os }}-${{ matrix.architecture }}- - name: Build and verify native application env: MACOSX_DEPLOYMENT_TARGET: "13.0" + NUITKA_CACHE_DIR: ${{ runner.temp }}/nuitka-cache run: ./scripts/build_macos_release.sh "${GITHUB_REF_NAME#v}" release-assets python3 - uses: actions/upload-artifact@v7 with: diff --git a/README.md b/README.md index 22eacd3..5cc2583 100644 --- a/README.md +++ b/README.md @@ -1043,7 +1043,7 @@ Physical-device validation is opt-in and is not required for pull requests. Use 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 85 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 Mach-O architecture and its macOS 13.0 load-command floor, 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 a bundle whose executable targets a newer macOS version, so local release builds should use a Python toolchain that can produce macOS 13 binaries; GitHub release CI supplies this target explicitly. +The builder requires `MACOSX_DEPLOYMENT_TARGET=13.0`. It rejects a bundle whose executable requires a newer macOS version, while accepting a binary that supports an older minimum because the application still advertises macOS 13 as its supported floor. Local release builds should therefore use a Python toolchain capable of producing macOS 13-compatible binaries; GitHub release CI supplies the target explicitly. The artifacts are not universal binaries: choose the ZIP matching `uname -m`. They are also not Developer ID signed or Apple-notarized because this repository has no release signing identity. A future signing upgrade should use a narrowly scoped Developer ID Application certificate, hardened runtime, Apple notarization, and stapling without changing the two-architecture verification gates. diff --git a/docs/PRODUCT_AUDIT_2026-09-21.md b/docs/PRODUCT_AUDIT_2026-09-21.md index 879dfee..2fbde4f 100644 --- a/docs/PRODUCT_AUDIT_2026-09-21.md +++ b/docs/PRODUCT_AUDIT_2026-09-21.md @@ -115,7 +115,7 @@ Upstream contribution candidates are concrete: report the fast-exit scanner pack * Continue migrating finite subprocess workflows to the reusable operation controller and typed `OperationResult`. Device discovery and Man Pages now share final-drain, timeout, cancellation, launch-failure, clean-relaunch, and structured completion semantics; command drift, DDI, backup, apps, and capture remain incremental migrations. * 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; it remains unexecuted until GitHub Actions runs it. The release builder also rejects a bundle whose Mach-O minimum macOS version differs from the advertised 13.0 floor. +* 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 a bundle whose Mach-O minimum macOS version is newer than the advertised 13.0 floor. * 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. @@ -166,6 +166,7 @@ Upstream contribution candidates are concrete: report the fast-exit scanner pack | 2026-09-21 | Added an opt-in physical-device protocol with staged read-only, developer-service, and state-changing checks. | The current host check found no Apple mobile USB device, no usbmux device, and no CoreDevice result, so no physical compatibility claim was made. | Run the protocol with an authorized connected device and retain identifiers and raw evidence locally. | | 2026-09-21 | Added contextual readiness for every guided command and corrected support-bundle capability aggregation. | Command-specific tests cover untested, ready, not-applicable, and attention states; the GUI smoke verifies the new control by stable object ID. | Readiness remains a point-in-time local probe and never substitutes for an actual command result. | | 2026-09-21 | Migrated Man Pages live help to the shared finite-operation controller and normalized styled CLI help for command-drift checks. | The GUI smoke now completes a real live-help request; controller relaunch tests reject stale output, and ANSI-split option tokens remain strictly verifiable. | Sequential command drift and other finite workflows remain incremental migrations. | +| 2026-09-21 | Corrected the macOS compatibility gate and bounded native-build timing. | The first clean dual-architecture run proved arm64 produced a macOS 11-compatible executable, which is compatible with the advertised macOS 13 floor; Intel exceeded the original 45-minute job limit. | Re-run both native builders with reusable Nuitka caches and a 90-minute cap before merging. | ## Research sources diff --git a/scripts/build_macos_release.sh b/scripts/build_macos_release.sh index 81091be..19fce7a 100755 --- a/scripts/build_macos_release.sh +++ b/scripts/build_macos_release.sh @@ -38,7 +38,10 @@ fi release_root="$(cd "$repository_root" && mkdir -p "$output_directory" && cd "$output_directory" && pwd)" staging_root="$(mktemp -d /private/tmp/iosdevtoolkit-release.XXXXXX)" -export NUITKA_CACHE_DIR="$staging_root/nuitka-cache" +if [[ -z "${NUITKA_CACHE_DIR:-}" ]]; then + export NUITKA_CACHE_DIR="$staging_root/nuitka-cache" +fi +/bin/mkdir -p "$NUITKA_CACHE_DIR" build_environment="$staging_root/release-venv" metadata_environment="$staging_root/metadata-venv" deployment_project_directory="$staging_root/deployment-project" @@ -146,10 +149,22 @@ compiled_minimum_macos_version="$(/usr/bin/otool -l "$compiled_executable" | /us /LC_BUILD_VERSION/ { in_build_version = 1; next } in_build_version && /minos/ { print $2; exit } ')" -if [[ "$compiled_minimum_macos_version" != "$required_macos_version" ]]; then - echo "Compiled executable requires macOS ${compiled_minimum_macos_version:-an unknown version}; expected $required_macos_version" >&2 +compiled_macos_major="${compiled_minimum_macos_version%%.*}" +compiled_macos_minor="${compiled_minimum_macos_version#*.}" +compiled_macos_minor="${compiled_macos_minor%%.*}" +required_macos_major="${required_macos_version%%.*}" +required_macos_minor="${required_macos_version#*.}" +required_macos_minor="${required_macos_minor%%.*}" +if [[ -z "$compiled_minimum_macos_version" || ! "$compiled_macos_major" =~ ^[0-9]+$ || ! "$compiled_macos_minor" =~ ^[0-9]+$ ]]; then + echo "Could not parse the compiled executable's minimum macOS version: ${compiled_minimum_macos_version:-missing}" >&2 + exit 72 +fi +if (( compiled_macos_major > required_macos_major )) || \ + (( compiled_macos_major == required_macos_major && compiled_macos_minor > required_macos_minor )); then + echo "Compiled executable requires macOS $compiled_minimum_macos_version, newer than the advertised $required_macos_version floor" >&2 exit 72 fi +echo "Compiled executable supports macOS $compiled_minimum_macos_version; advertised application floor is $required_macos_version" "$compiled_executable" --toolkit-internal-pymobiledevice3 version "$compiled_executable" --toolkit-internal-worker capability --help From ebac66a19b88ba837d21cfc9f955d51eadbb84a6 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:19:14 -0700 Subject: [PATCH 6/8] Verify full macOS bundle compatibility --- .github/workflows/frozen-macos-smoke.yml | 1 + README.md | 8 +- THIRD_PARTY_NOTICES.md | 2 +- docs/PRODUCT_AUDIT_2026-09-21.md | 3 +- pyproject.toml | 2 +- scripts/build_macos_release.sh | 32 ++--- scripts/verify_macos_bundle.py | 166 +++++++++++++++++++++++ tests/test_macos_bundle.py | 68 ++++++++++ tests/test_project_metadata.py | 21 ++- 9 files changed, 275 insertions(+), 28 deletions(-) create mode 100644 scripts/verify_macos_bundle.py create mode 100644 tests/test_macos_bundle.py diff --git a/.github/workflows/frozen-macos-smoke.yml b/.github/workflows/frozen-macos-smoke.yml index 3771bad..bbdf52c 100644 --- a/.github/workflows/frozen-macos-smoke.yml +++ b/.github/workflows/frozen-macos-smoke.yml @@ -11,6 +11,7 @@ on: - "scripts/build_macos_release.sh" - "scripts/collect_third_party_licenses.py" - "scripts/verify_release_metadata.py" + - "scripts/verify_macos_bundle.py" - "scripts/verify_command_catalog.py" - "pyproject.toml" - "tests/**" diff --git a/README.md b/README.md index 5cc2583..2989fa0 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Release `v0.3.4` combines the complete 12-workspace interface with the latest co - 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 85 tests, embedded CLI checks, a 90-button GUI smoke test, architecture inspection, strict code-signature validation, and one SHA-256 manifest; +- native Apple Silicon and Intel release ZIPs are built separately and verified with 90 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; - 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. @@ -212,7 +212,7 @@ Current pinned runtime: | Component | Version or path | |---|---| | Python | `>=3.10` | -| PySide6 | `6.11.2` | +| PySide6 Essentials | `6.9.3` | | pymobiledevice3 | `11.15.1` | | Local Xcode candidate | `/Library/Developer/CoreDevice/CandidateDDIs/iOS_DDI.dmg` | | Toolkit release | `0.3.4` | @@ -1041,9 +1041,9 @@ Physical-device validation is opt-in and is not required for pull requests. Use ### 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 85 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 Mach-O architecture and its macOS 13.0 load-command floor, 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 90 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 builder requires `MACOSX_DEPLOYMENT_TARGET=13.0`. It rejects a bundle whose executable requires a newer macOS version, while accepting a binary that supports an older minimum because the application still advertises macOS 13 as its supported floor. Local release builds should therefore use a Python toolchain capable of producing macOS 13-compatible binaries; GitHub release CI supplies the target explicitly. +The builder requires `MACOSX_DEPLOYMENT_TARGET=13.0`. It rejects any bundled executable, library, extension, or framework slice that requires a newer macOS version or omits the native release architecture. A component may support an older minimum because the application still advertises macOS 13 as its supported floor. PySide6 is pinned to the newest validated line whose actual Shiboken load commands satisfy that floor; wheel filenames alone are not treated as compatibility evidence. Local release builds should use a Python toolchain capable of producing macOS 13-compatible binaries; GitHub release CI supplies the target explicitly. The artifacts are not universal binaries: choose the ZIP matching `uname -m`. They are also not Developer ID signed or Apple-notarized because this repository has no release signing identity. A future signing upgrade should use a narrowly scoped Developer ID Application certificate, hardened runtime, Apple notarization, and stapling without changing the two-architecture verification gates. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 68adf5c..c04f5a7 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -7,7 +7,7 @@ The prebuilt macOS application contains or is built from the following release-c | Component | Pinned release | Role | Declared license | Source and license information | |---|---:|---|---|---| | [pymobiledevice3](https://github.com/doronz88/pymobiledevice3) | 11.15.1 | Bundled Apple-device protocol implementation and command surface | GPL-3.0-or-later | [Source for 11.15.1](https://github.com/doronz88/pymobiledevice3/tree/v11.15.1) and [license](https://github.com/doronz88/pymobiledevice3/blob/v11.15.1/LICENSE) | -| [PySide6](https://doc.qt.io/qtforpython-6/) and Shiboken6 | 6.11.2 | Bundled Qt for Python GUI and bindings | LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only, as declared by the installed wheels | [Qt for Python source](https://code.qt.io/cgit/pyside/pyside-setup.git/tag/?h=v6.11.2) and [Qt licensing](https://www.qt.io/licensing/open-source-lgpl-obligations) | +| [PySide6 Essentials](https://doc.qt.io/qtforpython-6/) and Shiboken6 | 6.9.3 | Bundled Qt Core, GUI, Widgets, deployment tooling, and Python bindings | LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only, as declared by the installed wheels | [Qt for Python source](https://code.qt.io/cgit/pyside/pyside-setup.git/tag/?h=v6.9.3) and [Qt licensing](https://www.qt.io/licensing/open-source-lgpl-obligations) | | [Nuitka](https://github.com/Nuitka/Nuitka) | 4.2.1 | Release compiler; generated applications contain separately licensed Nuitka runtime material | Compiler: GNU AGPL v3; runtime terms are supplied by Nuitka in `LICENSE-RUNTIME.txt` | [Source for 4.2.1](https://github.com/Nuitka/Nuitka/tree/4.2.1) | | [CPython](https://github.com/python/cpython) | GitHub runner's Python 3.13 patch release | Bundled Python runtime | Python Software Foundation License Version 2 | [Source and license](https://github.com/python/cpython/blob/3.13/LICENSE) | diff --git a/docs/PRODUCT_AUDIT_2026-09-21.md b/docs/PRODUCT_AUDIT_2026-09-21.md index 2fbde4f..155910e 100644 --- a/docs/PRODUCT_AUDIT_2026-09-21.md +++ b/docs/PRODUCT_AUDIT_2026-09-21.md @@ -115,7 +115,7 @@ Upstream contribution candidates are concrete: report the fast-exit scanner pack * Continue migrating finite subprocess workflows to the reusable operation controller and typed `OperationResult`. Device discovery and Man Pages now share final-drain, timeout, cancellation, launch-failure, clean-relaunch, and structured completion semantics; command drift, DDI, backup, apps, and capture remain incremental migrations. * 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 a bundle whose Mach-O minimum macOS version is newer than the advertised 13.0 floor. +* 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. @@ -167,6 +167,7 @@ Upstream contribution candidates are concrete: report the fast-exit scanner pack | 2026-09-21 | Added contextual readiness for every guided command and corrected support-bundle capability aggregation. | Command-specific tests cover untested, ready, not-applicable, and attention states; the GUI smoke verifies the new control by stable object ID. | Readiness remains a point-in-time local probe and never substitutes for an actual command result. | | 2026-09-21 | Migrated Man Pages live help to the shared finite-operation controller and normalized styled CLI help for command-drift checks. | The GUI smoke now completes a real live-help request; controller relaunch tests reject stale output, and ANSI-split option tokens remain strictly verifiable. | Sequential command drift and other finite workflows remain incremental migrations. | | 2026-09-21 | Corrected the macOS compatibility gate and bounded native-build timing. | The first clean dual-architecture run proved arm64 produced a macOS 11-compatible executable, which is compatible with the advertised macOS 13 floor; Intel exceeded the original 45-minute job limit. | Re-run both native builders with reusable Nuitka caches and a 90-minute cap before merging. | +| 2026-09-21 | Expanded compatibility validation from the launcher to every bundled Mach-O and pinned a genuinely compatible Qt line. | PySide6 6.11.2 wheel filenames advertise macOS 13, but direct `otool` inspection found Shiboken load commands requiring macOS 15; PySide6 6.9.3 Shiboken binaries declare macOS 12. | The dual-native CI build must pass the full-bundle architecture and deployment-floor scan before release. | ## Research sources diff --git a/pyproject.toml b/pyproject.toml index 0fa2ab5..a3b5cc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "Safety-focused pymobiledevice3 GUI, Developer Disk Image mounter, requires-python = ">=3.10" license = "MIT" dependencies = [ - "PySide6==6.11.2", + "PySide6-Essentials==6.9.3", "pymobiledevice3==11.15.1", ] diff --git a/scripts/build_macos_release.sh b/scripts/build_macos_release.sh index 19fce7a..7f7d1d0 100755 --- a/scripts/build_macos_release.sh +++ b/scripts/build_macos_release.sh @@ -38,6 +38,14 @@ fi release_root="$(cd "$repository_root" && mkdir -p "$output_directory" && cd "$output_directory" && pwd)" staging_root="$(mktemp -d /private/tmp/iosdevtoolkit-release.XXXXXX)" +cleanup_staging() { + if [[ ! -d "$staging_root" || "$staging_root" != /private/tmp/iosdevtoolkit-release.* ]]; then + echo "Refusing to remove an unexpected release staging path: $staging_root" >&2 + return 74 + fi + /usr/bin/find "$staging_root" -depth -delete +} +trap cleanup_staging EXIT if [[ -z "${NUITKA_CACHE_DIR:-}" ]]; then export NUITKA_CACHE_DIR="$staging_root/nuitka-cache" fi @@ -145,26 +153,10 @@ if [[ "$compiled_architecture" != "$machine_architecture" ]]; then echo "Compiled executable architecture is $compiled_architecture; expected $machine_architecture" >&2 exit 69 fi -compiled_minimum_macos_version="$(/usr/bin/otool -l "$compiled_executable" | /usr/bin/awk ' - /LC_BUILD_VERSION/ { in_build_version = 1; next } - in_build_version && /minos/ { print $2; exit } -')" -compiled_macos_major="${compiled_minimum_macos_version%%.*}" -compiled_macos_minor="${compiled_minimum_macos_version#*.}" -compiled_macos_minor="${compiled_macos_minor%%.*}" -required_macos_major="${required_macos_version%%.*}" -required_macos_minor="${required_macos_version#*.}" -required_macos_minor="${required_macos_minor%%.*}" -if [[ -z "$compiled_minimum_macos_version" || ! "$compiled_macos_major" =~ ^[0-9]+$ || ! "$compiled_macos_minor" =~ ^[0-9]+$ ]]; then - echo "Could not parse the compiled executable's minimum macOS version: ${compiled_minimum_macos_version:-missing}" >&2 - exit 72 -fi -if (( compiled_macos_major > required_macos_major )) || \ - (( compiled_macos_major == required_macos_major && compiled_macos_minor > required_macos_minor )); then - echo "Compiled executable requires macOS $compiled_minimum_macos_version, newer than the advertised $required_macos_version floor" >&2 - exit 72 -fi -echo "Compiled executable supports macOS $compiled_minimum_macos_version; advertised application floor is $required_macos_version" +"$build_environment/bin/python" scripts/verify_macos_bundle.py \ + "$app_path" \ + "$machine_architecture" \ + "$required_macos_version" "$compiled_executable" --toolkit-internal-pymobiledevice3 version "$compiled_executable" --toolkit-internal-worker capability --help diff --git a/scripts/verify_macos_bundle.py b/scripts/verify_macos_bundle.py new file mode 100644 index 0000000..74b01d5 --- /dev/null +++ b/scripts/verify_macos_bundle.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Sequence + + +MACH_O_MAGICS = frozenset( + { + b"\xce\xfa\xed\xfe", + b"\xfe\xed\xfa\xce", + b"\xcf\xfa\xed\xfe", + b"\xfe\xed\xfa\xcf", + b"\xca\xfe\xba\xbe", + b"\xbe\xba\xfe\xca", + b"\xca\xfe\xba\xbf", + b"\xbf\xba\xfe\xca", + } +) + + +class MacOSBundleValidationError(RuntimeError): + """Raised when a bundled Mach-O cannot satisfy the advertised platform floor.""" + + +@dataclass(frozen=True) +class MachORecord: + path: Path + architectures: tuple[str, ...] + minimum_macos_versions: tuple[str, ...] + + +def version_parts(value: str) -> tuple[int, int, int]: + components = value.split(".") + if not components or len(components) > 3 or any(not component.isdigit() for component in components): + raise MacOSBundleValidationError(f"Invalid macOS version in Mach-O load command: {value!r}") + numbers = tuple(int(component) for component in components) + return (numbers + (0, 0, 0))[:3] + + +def parse_minimum_macos_versions(otool_output: str) -> tuple[str, ...]: + versions: list[str] = [] + active_command = "" + for raw_line in otool_output.splitlines(): + line = raw_line.strip() + if line == "cmd LC_BUILD_VERSION": + active_command = "build" + continue + if line == "cmd LC_VERSION_MIN_MACOSX": + active_command = "legacy" + continue + if active_command == "build" and line.startswith("minos "): + versions.append(line.removeprefix("minos ").split()[0]) + active_command = "" + continue + if active_command == "legacy" and line.startswith("version "): + versions.append(line.removeprefix("version ").split()[0]) + active_command = "" + return tuple(versions) + + +def is_mach_o(path: Path) -> bool: + try: + with path.open("rb") as stream: + return stream.read(4) in MACH_O_MAGICS + except OSError as error: + raise MacOSBundleValidationError(f"Could not read bundle file {path}: {error}") from error + + +def run_tool(arguments: Sequence[str], target: Path) -> str: + try: + completed = subprocess.run( + (*arguments, str(target)), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + except OSError as error: + raise MacOSBundleValidationError( + f"Could not execute {arguments[0]} for {target}: {error}" + ) from error + if completed.returncode != 0: + raise MacOSBundleValidationError( + f"{' '.join((*arguments, str(target)))} exited {completed.returncode}. " + f"stdout={completed.stdout.strip()!r} stderr={completed.stderr.strip()!r}" + ) + return completed.stdout + + +def inspect_mach_o(path: Path) -> MachORecord: + architecture_output = run_tool(("/usr/bin/lipo", "-archs"), path) + architectures = tuple(architecture_output.split()) + if not architectures: + raise MacOSBundleValidationError(f"lipo returned no architectures for bundled Mach-O: {path}") + load_commands = run_tool(("/usr/bin/otool", "-l"), path) + minimum_versions = parse_minimum_macos_versions(load_commands) + if not minimum_versions: + raise MacOSBundleValidationError( + f"Bundled Mach-O has no LC_BUILD_VERSION or LC_VERSION_MIN_MACOSX floor: {path}" + ) + return MachORecord(path, architectures, minimum_versions) + + +def validate_mach_o_records( + records: Sequence[MachORecord], + expected_architecture: str, + maximum_macos_version: str, +) -> None: + maximum_parts = version_parts(maximum_macos_version) + for record in records: + if expected_architecture not in record.architectures: + raise MacOSBundleValidationError( + f"Bundled Mach-O {record.path} does not contain required architecture " + f"{expected_architecture}; found {', '.join(record.architectures)}" + ) + for minimum_version in record.minimum_macos_versions: + if version_parts(minimum_version) > maximum_parts: + raise MacOSBundleValidationError( + f"Bundled Mach-O {record.path} requires macOS {minimum_version}, newer than the " + f"advertised macOS {maximum_macos_version} floor" + ) + + +def inspect_application_bundle(application_path: Path) -> tuple[MachORecord, ...]: + if not application_path.is_dir() or application_path.suffix != ".app": + raise MacOSBundleValidationError(f"Application bundle does not exist: {application_path}") + records = tuple( + inspect_mach_o(path) + for path in sorted(application_path.rglob("*")) + if path.is_file() and is_mach_o(path) + ) + if not records: + raise MacOSBundleValidationError(f"Application bundle contains no Mach-O files: {application_path}") + return records + + +def main(arguments: Sequence[str]) -> int: + if len(arguments) != 4: + raise MacOSBundleValidationError( + "Usage: verify_macos_bundle.py APPLICATION_PATH EXPECTED_ARCHITECTURE MAXIMUM_MACOS_VERSION" + ) + application_path = Path(arguments[1]).resolve() + expected_architecture = arguments[2] + maximum_macos_version = arguments[3] + records = inspect_application_bundle(application_path) + validate_mach_o_records(records, expected_architecture, maximum_macos_version) + observed_versions = sorted( + {version for record in records for version in record.minimum_macos_versions}, + key=version_parts, + ) + print( + f"Validated {len(records)} bundled Mach-O files for {expected_architecture}; " + f"observed macOS floors: {', '.join(observed_versions)}; advertised floor: {maximum_macos_version}" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main(sys.argv)) + except MacOSBundleValidationError as error: + print(f"macOS bundle validation failed: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/tests/test_macos_bundle.py b/tests/test_macos_bundle.py new file mode 100644 index 0000000..f1c7b1b --- /dev/null +++ b/tests/test_macos_bundle.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from scripts.verify_macos_bundle import ( + MACH_O_MAGICS, + MacOSBundleValidationError, + MachORecord, + is_mach_o, + parse_minimum_macos_versions, + validate_mach_o_records, + version_parts, +) + + +class MacOSBundleValidationTests(unittest.TestCase): + def test_parses_modern_and_legacy_floors_for_every_architecture(self) -> None: + output = """ +Load command 9 + cmd LC_BUILD_VERSION + cmdsize 32 + platform 1 + minos 11.0 + sdk 15.0 +Load command 8 + cmd LC_VERSION_MIN_MACOSX + cmdsize 16 + version 10.15 + sdk 14.4 +""" + + self.assertEqual(parse_minimum_macos_versions(output), ("11.0", "10.15")) + + def test_rejects_newer_floor_or_missing_native_architecture(self) -> None: + compatible = MachORecord(Path("compatible.dylib"), ("arm64", "x86_64"), ("12.0", "13.0")) + validate_mach_o_records((compatible,), "arm64", "13.0") + + with self.assertRaisesRegex(MacOSBundleValidationError, "requires macOS 15.0"): + validate_mach_o_records( + (MachORecord(Path("newer.dylib"), ("arm64",), ("15.0",)),), + "arm64", + "13.0", + ) + with self.assertRaisesRegex(MacOSBundleValidationError, "does not contain required architecture x86_64"): + validate_mach_o_records((compatible,), "x86_64h", "13.0") + + def test_recognizes_mach_o_magic_without_treating_text_as_native_code(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + binary = root / "binary" + text = root / "text" + binary.write_bytes(next(iter(MACH_O_MAGICS)) + b"payload") + text.write_text("#!/bin/sh\n", encoding="utf-8") + + self.assertTrue(is_mach_o(binary)) + self.assertFalse(is_mach_o(text)) + + def test_parses_and_compares_three_component_versions(self) -> None: + self.assertEqual(version_parts("13"), (13, 0, 0)) + self.assertEqual(version_parts("13.0.1"), (13, 0, 1)) + with self.assertRaisesRegex(MacOSBundleValidationError, "Invalid macOS version"): + version_parts("13.x") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index 6e2da91..a447792 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -32,7 +32,7 @@ def test_source_and_packaging_metadata_match_application_version(self) -> None: release_builder = (REPOSITORY_ROOT / "scripts" / "build_macos_release.sh").read_text(encoding="utf-8") self.assertIn("--macos-app-version=$release_version", release_builder) self.assertIn("CFBundleVersion string 6", release_builder) - self.assertIn('compiled_minimum_macos_version', release_builder) + self.assertIn('scripts/verify_macos_bundle.py', release_builder) self.assertIn('MACOSX_DEPLOYMENT_TARGET', release_builder) def test_dependency_notices_match_the_pinned_pymobiledevice3_release(self) -> None: @@ -52,6 +52,25 @@ def test_dependency_notices_match_the_pinned_pymobiledevice3_release(self) -> No self.assertIn(f"tree/v{pinned_version}", notices) self.assertIn(f"tree/v{pinned_version}", source_availability) + def test_dependency_notices_match_the_pinned_pyside6_release(self) -> None: + pyproject = tomllib.loads((REPOSITORY_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + project = pyproject["project"] + self.assertIsInstance(project, dict) + dependencies = project["dependencies"] + self.assertIsInstance(dependencies, list) + pinned_dependency = next( + dependency + for dependency in dependencies + if isinstance(dependency, str) and dependency.startswith("PySide6-Essentials==") + ) + pinned_version = pinned_dependency.removeprefix("PySide6-Essentials==") + + notices = (REPOSITORY_ROOT / "THIRD_PARTY_NOTICES.md").read_text(encoding="utf-8") + readme = (REPOSITORY_ROOT / "README.md").read_text(encoding="utf-8") + self.assertIn(f"| {pinned_version} |", notices) + self.assertIn(f"?h=v{pinned_version}", notices) + self.assertIn(f"| PySide6 Essentials | `{pinned_version}` |", readme) + if __name__ == "__main__": unittest.main() From 6ffce0f22025da7f684dcb5dd14586f62e1932dc Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:04:26 -0700 Subject: [PATCH 7/8] Fix frozen bundle self execution --- README.md | 4 +- docs/PRODUCT_AUDIT_2026-09-21.md | 4 +- ios_developer_toolkit/app.py | 4 +- ios_developer_toolkit/qt_process.py | 8 ++++ ios_developer_toolkit/runtime.py | 73 ++++++++++++++++++++++++++++- tests/test_qt_process.py | 21 +++++++++ tests/test_runtime.py | 48 +++++++++++++++++++ 7 files changed, 155 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2989fa0..2ae652c 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Release `v0.3.4` combines the complete 12-workspace interface with the latest co - 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 90 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; +- 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; - 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. @@ -1041,7 +1041,7 @@ Physical-device validation is opt-in and is not required for pull requests. Use ### 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 90 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 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 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. diff --git a/docs/PRODUCT_AUDIT_2026-09-21.md b/docs/PRODUCT_AUDIT_2026-09-21.md index 155910e..e825549 100644 --- a/docs/PRODUCT_AUDIT_2026-09-21.md +++ b/docs/PRODUCT_AUDIT_2026-09-21.md @@ -152,14 +152,14 @@ Upstream contribution candidates are concrete: report the fast-exit scanner pack 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 77-test suite, 89-action headless GUI smoke, CLI discovery, and every command-catalog live-help route. Review the diff before handoff. +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. ## Continuous improvement log | Date | Improvement | Verification | Follow-up boundary | | --- | --- | --- | --- | | 2026-09-21 | Moved backup transport imports out of desktop startup; fixed terminal output draining for usbmux discovery; added privacy-safe connection diagnostics. | 75 tests, headless GUI smoke, source launcher verification, and deterministic QProcess tests passed. | Real-device discovery remains separately opt-in and time-specific. | -| 2026-09-21 | Upgraded the pinned `pymobiledevice3` runtime to 11.15.1 and reconciled source, bundle, citation, packaging, and third-party source metadata. | CLI version reports 11.15.1; 77 tests and all 49 catalog live-help routes passed locally. | The next packaged artifact must be built by CI before distribution. | +| 2026-09-21 | Upgraded the pinned `pymobiledevice3` runtime to 11.15.1 and reconciled source, bundle, citation, packaging, and third-party source metadata. | CLI version reports 11.15.1; 94 tests and all 49 catalog live-help routes passed locally. | The next packaged artifact must be built by CI before distribution. | | 2026-09-21 | Added a dual-architecture frozen-artifact smoke workflow, CI command-catalog verification, and a native Mach-O minimum-version gate. | A clean local build passed its full 77-test suite and produced a signed arm64 app; the host's Homebrew Python targets macOS 26, so the new 13.0 gate correctly stopped that incompatible local artifact before ZIP creation. | GitHub Actions runs with `MACOSX_DEPLOYMENT_TARGET=13.0`; its first Apple Silicon and Intel runs remain required before distribution. | | 2026-09-21 | Added a reusable typed finite-process controller and migrated device discovery to it. | Real child-process tests cover terminal stdout/stderr, fast completion, launch failure, cancellation, timeout, and one-result semantics; the full suite now contains 81 tests. | Migrate other finite QProcess workflows incrementally; long-running streams retain their separate lifecycle. | | 2026-09-21 | Made live-help drift checks accept successful help emitted on either standard output or standard error. | A clean GitHub runner exposed two false option mismatches while the same pinned CLI passed locally; the channel-specific regression test now preserves strict option matching without assuming a help stream. | Re-run CI on a clean runner and retain failure for genuinely absent routes or options. | diff --git a/ios_developer_toolkit/app.py b/ios_developer_toolkit/app.py index 11a919e..3f94e51 100644 --- a/ios_developer_toolkit/app.py +++ b/ios_developer_toolkit/app.py @@ -5379,8 +5379,10 @@ def _manpage_completed(self, result_object: object) -> None: self._manpage_cache[self._manpage_active_path] = output self.manpage_output.setPlainText(output) elif result_object.outcome == "launch-failed": + error_detail = result_object.error_message or "QProcess did not provide an operating-system error" self.manpage_output.setPlainText( - f"Could not start live help. Verify the project runtime exists and is executable: {result_object.argv[0]}" + "Could not start live help. Verify the project runtime exists and is executable:\n" + f"{result_object.argv[0]}\n\nSystem error: {error_detail}" ) else: exit_detail = "unavailable" if result_object.exit_code is None else str(result_object.exit_code) diff --git a/ios_developer_toolkit/qt_process.py b/ios_developer_toolkit/qt_process.py index 40a3361..0243850 100644 --- a/ios_developer_toolkit/qt_process.py +++ b/ios_developer_toolkit/qt_process.py @@ -28,6 +28,7 @@ class OperationResult: started_at: str finished_at: str exit_code: int | None + error_message: str | None stdout: bytes stderr: bytes @@ -66,6 +67,7 @@ def __init__(self, parent: QObject) -> None: self._stdout = bytearray() self._stderr = bytearray() self._started_at = "" + self._error_message: str | None = None self._stop_outcome: Literal["timed-out", "cancelled"] | None = None self._completed = False self._timeout_timer = QTimer(self) @@ -86,6 +88,7 @@ def start(self, request: FiniteProcessRequest) -> None: 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 @@ -140,6 +143,10 @@ def _drain_output(self) -> None: self.stderr_received.emit(stderr) def _process_error(self, process_error: QProcess.ProcessError) -> None: + process = self._process + if process is None: + raise RuntimeError("Finite process reported an error without an active process") + self._error_message = process.errorString() if process_error == QProcess.ProcessError.FailedToStart: self._finish_once("launch-failed", None) @@ -190,6 +197,7 @@ def _finish_once(self, outcome: ProcessOutcome, exit_code: int | None) -> None: self._started_at, datetime.now(timezone.utc).isoformat(), exit_code, + self._error_message, bytes(self._stdout), bytes(self._stderr), ) diff --git a/ios_developer_toolkit/runtime.py b/ios_developer_toolkit/runtime.py index 164d4b3..cf4f9ca 100644 --- a/ios_developer_toolkit/runtime.py +++ b/ios_developer_toolkit/runtime.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import plistlib import shlex import sys from dataclasses import dataclass @@ -21,6 +22,10 @@ class ExecutableCommand: prefix_arguments: tuple[str, ...] +class FrozenExecutableError(RuntimeError): + """Raised when a packaged runtime cannot locate its bundle launcher.""" + + def is_frozen_runtime() -> bool: if "__compiled__" in globals(): return True @@ -28,6 +33,70 @@ def is_frozen_runtime() -> bool: return frozen_marker is True +def _bundle_contents_directories(anchors: Sequence[Path]) -> tuple[Path, ...]: + directories: list[Path] = [] + for anchor in anchors: + resolved_anchor = anchor.expanduser().resolve(strict=False) + for candidate in (resolved_anchor, *resolved_anchor.parents): + if candidate.name == "Contents" and candidate not in directories: + directories.append(candidate) + return tuple(directories) + + +def macos_bundle_executable(argument_zero: Path, module_path: Path) -> Path: + contents_directories = _bundle_contents_directories((argument_zero, module_path)) + plist_paths = tuple(directory / "Info.plist" for directory in contents_directories) + existing_plists = tuple(path for path in plist_paths if path.is_file()) + if not existing_plists: + checked = ", ".join(str(path) for path in plist_paths) or "no enclosing .app Contents directory" + raise FrozenExecutableError(f"Packaged macOS runtime could not find Info.plist; checked: {checked}") + + plist_path = existing_plists[0] + try: + with plist_path.open("rb") as plist_file: + plist = plistlib.load(plist_file) + except (OSError, plistlib.InvalidFileException) as error: + raise FrozenExecutableError(f"Packaged macOS runtime could not read {plist_path}: {error}") from error + if not isinstance(plist, Mapping): + raise FrozenExecutableError( + f"Packaged macOS runtime expected a dictionary at the root of {plist_path}, " + f"received {type(plist).__name__}" + ) + + executable_name = plist.get("CFBundleExecutable") + if not isinstance(executable_name, str) or not executable_name or Path(executable_name).name != executable_name: + raise FrozenExecutableError( + f"Packaged macOS runtime has an invalid CFBundleExecutable in {plist_path}: {executable_name!r}" + ) + executable_path = plist_path.parent / "MacOS" / executable_name + if not executable_path.is_file(): + raise FrozenExecutableError( + f"Packaged macOS runtime launcher named by {plist_path} does not exist: {executable_path}" + ) + if not os.access(executable_path, os.X_OK): + raise FrozenExecutableError( + f"Packaged macOS runtime launcher is not executable: {executable_path}" + ) + return executable_path + + +def frozen_executable_path( + argument_zero: Path, + module_path: Path, + interpreter_path: Path, + platform_name: str, +) -> Path: + if platform_name == "darwin": + return macos_bundle_executable(argument_zero, module_path) + if not interpreter_path.is_file() or not os.access(interpreter_path, os.X_OK): + raise FrozenExecutableError(f"Packaged runtime executable is missing or not executable: {interpreter_path}") + return interpreter_path + + +def active_frozen_executable() -> Path: + return frozen_executable_path(Path(sys.argv[0]), Path(__file__), Path(sys.executable), sys.platform) + + def command_arguments(command: ExecutableCommand, arguments: Sequence[str]) -> tuple[str, ...]: return (*command.prefix_arguments, *arguments) @@ -42,7 +111,7 @@ def command_text(command: ExecutableCommand, arguments: Sequence[str]) -> str: def pymobiledevice3_command() -> ExecutableCommand: if is_frozen_runtime(): - return ExecutableCommand(Path(sys.executable), (INTERNAL_PYMOBILEDEVICE3_FLAG,)) + return ExecutableCommand(active_frozen_executable(), (INTERNAL_PYMOBILEDEVICE3_FLAG,)) candidate = Path(sys.executable).with_name("pymobiledevice3") if not candidate.is_file(): raise FileNotFoundError( @@ -67,7 +136,7 @@ def worker_module(worker: ToolkitWorker) -> str: def worker_command(worker: ToolkitWorker) -> ExecutableCommand: if is_frozen_runtime(): - return ExecutableCommand(Path(sys.executable), (INTERNAL_WORKER_FLAG, worker)) + return ExecutableCommand(active_frozen_executable(), (INTERNAL_WORKER_FLAG, worker)) return ExecutableCommand(Path(sys.executable), ("-m", worker_module(worker))) diff --git a/tests/test_qt_process.py b/tests/test_qt_process.py index 5c6cfd8..b9f56d9 100644 --- a/tests/test_qt_process.py +++ b/tests/test_qt_process.py @@ -107,6 +107,27 @@ def test_relaunches_without_reusing_previous_output(self) -> None: self.assertEqual(results[1].stdout, b"second\n") self.assertFalse(controller.is_running()) + def test_reports_operating_system_error_when_launch_fails(self) -> None: + controller = FiniteProcessController(self.application) + results: list[OperationResult] = [] + controller.completed.connect(results.append) + request = finite_process_request( + ExecutableCommand(Path("/path/that/does/not/exist"), ()), + (), + {}, + 3_000, + 500, + ) + + controller.start(request) + self._wait_for(lambda: bool(results), 3) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0].outcome, "launch-failed") + self.assertIsNotNone(results[0].error_message) + self.assertTrue(results[0].error_message) + self.assertFalse(controller.is_running()) + def _wait_for(self, predicate: Callable[[], bool], timeout_seconds: int) -> None: deadline = time.monotonic() + timeout_seconds while not predicate() and time.monotonic() < deadline: diff --git a/tests/test_runtime.py b/tests/test_runtime.py index d48f34b..69476d3 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -1,15 +1,20 @@ from __future__ import annotations +import plistlib import sys +import tempfile import unittest from pathlib import Path from ios_developer_toolkit.entrypoint import dispatch_internal, parsed_worker from ios_developer_toolkit.runtime import ( ExecutableCommand, + FrozenExecutableError, command_arguments, command_argv, command_text, + frozen_executable_path, + macos_bundle_executable, pymobiledevice3_command, worker_command, ) @@ -45,6 +50,49 @@ def test_source_commands_use_installed_entry_points_and_modules(self) -> None: ("-m", "ios_developer_toolkit.collector"), ) + def test_frozen_macos_runtime_uses_bundle_plist_launcher(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + contents = Path(temporary_directory) / "Toolkit.app" / "Contents" + macos_directory = contents / "MacOS" + macos_directory.mkdir(parents=True) + launcher = macos_directory / "ToolkitLauncher" + launcher.write_bytes(b"launcher") + launcher.chmod(0o755) + with (contents / "Info.plist").open("wb") as plist_file: + plistlib.dump({"CFBundleExecutable": launcher.name}, plist_file) + + resolved = frozen_executable_path( + macos_directory / "python", + macos_directory / "ios_developer_toolkit" / "runtime.py", + macos_directory / "python", + "darwin", + ) + + self.assertEqual(resolved, launcher.resolve()) + + def test_frozen_macos_runtime_rejects_missing_bundle_launcher(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + contents = Path(temporary_directory) / "Toolkit.app" / "Contents" + macos_directory = contents / "MacOS" + macos_directory.mkdir(parents=True) + with (contents / "Info.plist").open("wb") as plist_file: + plistlib.dump({"CFBundleExecutable": "MissingLauncher"}, plist_file) + + with self.assertRaisesRegex(FrozenExecutableError, "does not exist"): + macos_bundle_executable( + macos_directory / "python", + macos_directory / "ios_developer_toolkit" / "runtime.py", + ) + + def test_non_macos_frozen_runtime_requires_an_executable_file(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + executable = Path(temporary_directory) / "toolkit" + executable.write_bytes(b"launcher") + executable.chmod(0o644) + + with self.assertRaisesRegex(FrozenExecutableError, "missing or not executable"): + frozen_executable_path(Path("unused"), Path("unused"), executable, "linux") + class InternalDispatchTests(unittest.TestCase): def test_dispatch_requires_a_supported_internal_mode_and_worker(self) -> None: From aa3e666945ec399582427bb518479649ec8c2c31 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:14:28 -0700 Subject: [PATCH 8/8] Centralize command drift process handling --- README.md | 2 +- docs/PRODUCT_AUDIT_2026-09-21.md | 9 ++- ios_developer_toolkit/app.py | 111 ++++++++++------------------ ios_developer_toolkit/entrypoint.py | 16 ++++ 4 files changed, 61 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 2ae652c..8002948 100644 --- a/README.md +++ b/README.md @@ -532,7 +532,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. It reports unavailable routes, changed option syntax, timeouts, and any 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. **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. Every preset has a visible risk class: diff --git a/docs/PRODUCT_AUDIT_2026-09-21.md b/docs/PRODUCT_AUDIT_2026-09-21.md index e825549..7b267ef 100644 --- a/docs/PRODUCT_AUDIT_2026-09-21.md +++ b/docs/PRODUCT_AUDIT_2026-09-21.md @@ -33,9 +33,9 @@ The repository is a Python 3.10+ PySide6 project with a bundled `pymobiledevice3 | `DeviceScanner` did not drain final `QProcess` stdout/stderr in its completion handler. | A connected device could be invisible in the packaged UI despite the bundled CLI returning valid JSON. Fixed with completion-time draining and a real fast-exit QProcess regression test. | Resolved P0 | | The repository pinned `pymobiledevice3==10.11.0` while a clean Dependabot PR existed for 11.12.4 and upstream had newer releases. | The app missed modern iOS tunnel fixes and could present stale command assumptions. Fixed with a validated upgrade to 11.15.1: the full test suite, GUI smoke test, CLI discovery, and all 49 live-help routes passed. | Resolved P0 | | `MainWindow` owns dozens of process/buffer/timer lifecycles. | Completion, cancellation, timeout, and output handling can diverge across workspaces; the scanner defect is evidence of that risk. | P1 | -| Release-only packaging is validated only after a tag is pushed. | A frozen-app regression can escape pull-request CI. | P1 | -| Source `macos/Info.plist` exposes an older version than `pyproject.toml`; release CI corrects it later. | Local app testing can be confusing and screenshots can show stale metadata. | P1 | -| The test suite is mainly pure-function/unit coverage and a structural GUI smoke test. | It now exercises a fast-exit discovery result and launch failure, but still needs shared operation cancellation/relaunch coverage beyond those paths. | P1 | +| Release-only packaging was previously validated only after a tag was pushed. | Dual-native pull-request smoke now builds and inspects the entire frozen app before release. | Resolved P1 | +| Source `macos/Info.plist` previously exposed an older version than `pyproject.toml`. | Source, packaging, citation, and bundle metadata are synchronized and covered by tests. | Resolved P1 | +| Coverage remains weighted toward pure functions and host-only integration. | Fast-exit discovery, process lifecycle, real live help, the complete 49-route drift UI, and both frozen architectures are covered; device-service behavior remains deliberately opt-in through the physical protocol. | P1 | | The first-run experience assumes familiarity with DDI, RSD, and CoreDevice. | Beginners receive good instructions, but not a single coherent “make my device ready” decision flow. | P1 | | The README is extensive but is the dominant documentation surface. | It is difficult to keep operational recipes, scope boundaries, architecture, release verification, and contributor guidance discoverable. | P2 | @@ -113,7 +113,7 @@ 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 and Man Pages now share final-drain, timeout, cancellation, launch-failure, clean-relaunch, and structured completion semantics; command drift, DDI, backup, apps, and capture remain incremental migrations. +* 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. * 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. @@ -168,6 +168,7 @@ Upstream contribution candidates are concrete: report the fast-exit scanner pack | 2026-09-21 | Migrated Man Pages live help to the shared finite-operation controller and normalized styled CLI help for command-drift checks. | The GUI smoke now completes a real live-help request; controller relaunch tests reject stale output, and ANSI-split option tokens remain strictly verifiable. | Sequential command drift and other finite workflows remain incremental migrations. | | 2026-09-21 | Corrected the macOS compatibility gate and bounded native-build timing. | The first clean dual-architecture run proved arm64 produced a macOS 11-compatible executable, which is compatible with the advertised macOS 13 floor; Intel exceeded the original 45-minute job limit. | Re-run both native builders with reusable Nuitka caches and a 90-minute cap before merging. | | 2026-09-21 | Expanded compatibility validation from the launcher to every bundled Mach-O and pinned a genuinely compatible Qt line. | PySide6 6.11.2 wheel filenames advertise macOS 13, but direct `otool` inspection found Shiboken load commands requiring macOS 15; PySide6 6.9.3 Shiboken binaries declare macOS 12. | The dual-native CI build must pass the full-bundle architecture and deployment-floor scan before release. | +| 2026-09-21 | Migrated sequential command-drift probes to the shared finite-operation controller. | A clean Python 3.13 environment passed the 94-test suite and the GUI smoke now runs the entire 49-route drift check through the real asynchronous UI path. | DDI, backup, app, and capture operations remain incremental controller migrations. | ## Research sources diff --git a/ios_developer_toolkit/app.py b/ios_developer_toolkit/app.py index 3f94e51..7395af1 100644 --- a/ios_developer_toolkit/app.py +++ b/ios_developer_toolkit/app.py @@ -576,18 +576,14 @@ def __init__(self) -> None: self._manpage_controller.completed.connect(self._manpage_completed) self._manpage_cache: dict[tuple[str, ...], str] = {} self._manpage_active_path: tuple[str, ...] | None = None - self._command_drift_process: QProcess | None = None - self._command_drift_stdout = bytearray() - self._command_drift_stderr = bytearray() + self._command_drift_controller = FiniteProcessController(self) + self._command_drift_controller.completed.connect(self._command_drift_probe_completed) self._command_drift_paths: tuple[tuple[str, ...], ...] = () self._command_drift_index = 0 self._command_drift_active_path: tuple[str, ...] | None = None - self._command_drift_active_error: str | None = None + self._command_drift_session_active = False self._command_drift_cancelled = False self._command_drift_probes: dict[tuple[str, ...], HelpRouteProbe] = {} - self._command_drift_timeout_timer = QTimer(self) - self._command_drift_timeout_timer.setSingleShot(True) - self._command_drift_timeout_timer.timeout.connect(self._command_drift_timed_out) self._last_case_path: Path | None = None self._active_case_path: Path | None = None self._keyboard_shortcuts: list[QShortcut] = [] @@ -5116,13 +5112,13 @@ def stop_console_command(self) -> None: self._console_process.terminate() def start_command_drift_check(self) -> None: - if self._command_drift_process is not None: + if self._command_drift_session_active: QMessageBox.information(self, "Command Drift Check", "The live-help drift check is already running.") return self._command_drift_paths = help_routes_for_presets(self._presets) self._command_drift_index = 0 self._command_drift_active_path = None - self._command_drift_active_error = None + self._command_drift_session_active = True self._command_drift_cancelled = False self._command_drift_probes = {} self.command_drift_output.clear() @@ -5141,88 +5137,59 @@ def _start_next_command_drift_probe(self) -> None: return command_path = self._command_drift_paths[self._command_drift_index] self._command_drift_active_path = command_path - self._command_drift_active_error = None - self._command_drift_stdout.clear() - self._command_drift_stderr.clear() self.command_drift_status.setText( f"Checking {self._command_drift_index + 1}/{len(self._command_drift_paths)}: " f"pymobiledevice3 {shlex.join(command_path)} --help" ) - process = QProcess(self) - process.setProgram(str(self._pmd3.program)) - process.setArguments(list(command_arguments(self._pmd3, (*command_path, "--help")))) - process.setProcessEnvironment(qprocess_environment(base_environment())) - process.readyReadStandardOutput.connect(self._read_command_drift_stdout) - process.readyReadStandardError.connect(self._read_command_drift_stderr) - process.finished.connect(self._command_drift_probe_finished) - process.errorOccurred.connect(self._command_drift_probe_error) - self._command_drift_process = process - self._update_command_drift_controls() - process.start() - self._command_drift_timeout_timer.start(COMMAND_DRIFT_HELP_TIMEOUT_MS) - - def _read_command_drift_stdout(self) -> None: - if self._command_drift_process is not None: - self._command_drift_stdout.extend(bytes(self._command_drift_process.readAllStandardOutput())) - - def _read_command_drift_stderr(self) -> None: - if self._command_drift_process is not None: - self._command_drift_stderr.extend(bytes(self._command_drift_process.readAllStandardError())) - - def _command_drift_probe_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: - del exit_status - if self.sender() is not self._command_drift_process: - return - self._complete_command_drift_probe(exit_code, self._command_drift_active_error) - - def _command_drift_probe_error(self, process_error: QProcess.ProcessError) -> None: - if self.sender() is not self._command_drift_process: - return - if process_error == QProcess.ProcessError.FailedToStart and self._command_drift_process is not None: - self._complete_command_drift_probe(None, self._command_drift_process.errorString()) - - def _command_drift_timed_out(self) -> None: - process = self._command_drift_process - if process is None: - return - self._command_drift_active_error = ( - f"Live help exceeded the {COMMAND_DRIFT_HELP_TIMEOUT_MS // 1000}-second per-route limit." + self._command_drift_controller.start( + finite_process_request( + self._pmd3, + (*command_path, "--help"), + base_environment(), + COMMAND_DRIFT_HELP_TIMEOUT_MS, + PROCESS_TERMINATE_GRACE_MS, + ) ) - process.kill() + self._update_command_drift_controls() - def _complete_command_drift_probe(self, exit_code: int | None, error: str | None) -> None: + def _command_drift_probe_completed(self, result_object: object) -> None: + if not isinstance(result_object, OperationResult): + raise TypeError(f"Expected OperationResult, received {type(result_object).__name__}") command_path = self._command_drift_active_path - process = self._command_drift_process - if command_path is None or process is None: - return - self._command_drift_timeout_timer.stop() - self._read_command_drift_stdout() - self._read_command_drift_stderr() + if command_path is None: + raise CommandCatalogError("Live-help drift probe completed without an active command path") + if result_object.outcome == "timed-out": + error = f"Live help exceeded the {COMMAND_DRIFT_HELP_TIMEOUT_MS // 1000}-second per-route limit." + elif result_object.outcome == "cancelled": + error = "Live-help drift check was cancelled by the user." + elif result_object.outcome == "launch-failed": + detail = result_object.error_message or "the operating system did not provide an error" + error = f"Could not start live help: {detail}" + elif result_object.outcome == "crashed": + error = "Live help terminated unexpectedly before returning a complete result." + else: + error = None self._command_drift_probes[command_path] = HelpRouteProbe( command_path, - exit_code, - self._command_drift_stdout.decode("utf-8", errors="replace"), - self._command_drift_stderr.decode("utf-8", errors="replace"), + result_object.exit_code, + result_object.stdout.decode("utf-8", errors="replace"), + result_object.stderr.decode("utf-8", errors="replace"), error, ) - self._command_drift_process = None self._command_drift_active_path = None - self._command_drift_active_error = None self._command_drift_index += 1 self._update_command_drift_controls() QTimer.singleShot(0, self._start_next_command_drift_probe) def cancel_command_drift_check(self) -> None: - process = self._command_drift_process - if process is None: + if not self._command_drift_session_active: return self._command_drift_cancelled = True - self._command_drift_active_error = "Live-help drift check was cancelled by the user." self.command_drift_status.setText("Cancelling the current live-help check…") - process.kill() + self._command_drift_controller.cancel() def _finish_command_drift_check(self) -> None: - self._command_drift_timeout_timer.stop() + self._command_drift_session_active = False results = evaluate_command_drift(self._presets, tuple(self._command_drift_probes.values())) report = render_command_drift_report(results) self.command_drift_output.setPlainText(report) @@ -5239,7 +5206,7 @@ def _finish_command_drift_check(self) -> None: self._update_command_drift_controls() def _update_command_drift_controls(self) -> None: - running = self._command_drift_process is not None + running = self._command_drift_session_active self.command_drift_check_button.setEnabled(not running) self.command_drift_cancel_button.setEnabled(running) self.command_drift_copy_button.setEnabled(bool(self.command_drift_output.toPlainText().strip()) and not running) @@ -5666,8 +5633,8 @@ def closeEvent(self, event: QCloseEvent) -> None: return self._scanner.stop() self._reconnect_timeout_timer.stop() - self._command_drift_timeout_timer.stop() self._manpage_controller.shutdown(3000, 1000) + self._command_drift_controller.shutdown(3000, 1000) capability_process = self._capability_process if capability_process is not None and capability_process.state() != QProcess.ProcessState.NotRunning: self._terminate_capability_children(capability_process) @@ -5681,7 +5648,7 @@ def closeEvent(self, event: QCloseEvent) -> None: if not process.waitForFinished(10000): process.kill() process.waitForFinished(3000) - for process in (self._ipa_inspection_process, self._command_drift_process): + 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/entrypoint.py b/ios_developer_toolkit/entrypoint.py index df221f8..7320e45 100644 --- a/ios_developer_toolkit/entrypoint.py +++ b/ios_developer_toolkit/entrypoint.py @@ -121,6 +121,22 @@ def run_smoke_test(arguments: Sequence[str]) -> int: raise RuntimeError(f"GUI command-drift action is missing: {button_name}") if button.isEnabled(): raise RuntimeError(f"GUI command-drift action should be disabled before a drift check: {button_name}") + command_drift_button = window.findChild(QPushButton, "checkCommandDriftButton") + command_drift_copy_button = window.findChild(QPushButton, "copyCommandDriftReportButton") + if command_drift_button is None or command_drift_copy_button is None: + raise RuntimeError("GUI command-drift controls are incomplete") + command_drift_button.click() + command_drift_deadline = time.monotonic() + 180 + while not command_drift_copy_button.isEnabled() and time.monotonic() < command_drift_deadline: + application.processEvents() + time.sleep(0.001) + application.processEvents() + if not command_drift_copy_button.isEnabled(): + window.cancel_command_drift_check() + raise RuntimeError("GUI command-drift check did not complete within its bounded smoke-test window") + command_drift_report = window.command_drift_output.toPlainText() + if "Verified: 49" not in command_drift_report or "All guided preset routes" not in command_drift_report: + raise RuntimeError(f"GUI command-drift check reported incompatible guidance: {command_drift_report}") command_readiness = window.findChild(QLabel, "commandReadinessStatus") if command_readiness is None or not command_readiness.text().strip(): raise RuntimeError("GUI selected-command readiness has no visible state")