Skip to content

feat(install): alternative installproxy install method via AFC staging - #810

Open
danielpaulus wants to merge 2 commits into
mainfrom
feat/issue-400-installproxy-install
Open

feat(install): alternative installproxy install method via AFC staging#810
danielpaulus wants to merge 2 commits into
mainfrom
feat/issue-400-installproxy-install

Conversation

@danielpaulus

Copy link
Copy Markdown
Owner

Problem

App installs currently go exclusively through zipconduit, which requires unpacking the bundle on the host and streaming it uncompressed to the device. USB transfers are capped at USB2 speeds (~30-35 MB/s), so sending the uncompressed payload is the bottleneck for large apps. Issue #400 asks for an alternative install path based on AFC + installation_proxy, like pymobiledevice3, which transfers the compressed .ipa as-is and lets the device do the unpacking.

Design

The new path mirrors pymobiledevice3's install_from_local:

  1. Connect to AFC, ensure PublicStaging exists (AFC root is /var/mobile/Media), and upload the .ipa unchanged to PublicStaging/<basename>.
  2. Connect to com.apple.mobile.installation_proxy and send {"Command": "Install", "PackagePath": "PublicStaging/<basename>", "ClientOptions": {}}.
  3. Stream the length-prefixed plist progress responses, logging Status/PercentComplete, until Status == "Complete" or an Error/ErrorDescription response, which is surfaced as an error.

zipconduit remains the default; the new method is opt-in via ios install --path=<ipa> --method=installproxy. The installproxy method supports .ipa files only — for .app folders it returns a clear error pointing at the default method.

Implementation

  • ios/installationproxy/install.go:
    • Connection.Install(packagePath, options) — sends the Install command and blocks streaming progress until complete or error, following the existing Uninstall pattern (PlistCodec + ios.ParsePlist).
    • InstallIpa(device, ipaPath) — orchestrates AFC staging (Stat/MkDir PublicStaging, upload) and the Install command. ClientOptions is always sent (empty dict when no options).
    • installCommand / evaluateInstallProgress are separate small funcs so the request construction and response parsing are unit-testable.
  • ios/afc/client.go: File now implements io.ReaderFrom, splitting uploads into fileWrite packets of at most 64KiB payload each (matching common AFC implementations), so staging a large .ipa never produces oversized AFC packets. Client.WriteToFile uses it.
  • CLI: --method=<method> on ios install (cmd_device_apps.go, docopt usage + help text in main.go, internal/clihelp/help.yaml, regenerated testdata/help/global.golden). Unknown values fail fast with the supported values listed.

Options considered

  • --method=installproxy (chosen): a single flag with named values keeps ios install as one command, defaults to the existing behavior, and leaves room for future methods without adding a flag per transport.
  • --installproxy boolean flag: simplest, but a second method later would mean mutually-exclusive booleans and docopt disambiguation pain.
  • Separate command (ios install-ipa / ios installproxy install): splits one user intent ("install this app") across commands and doubles the docs/registry surface.
  • Auto-selecting installproxy for .ipa inputs: changes behavior silently for existing users and makes failures harder to attribute; explicit opt-in is safer until the method has soaked on the device runners.

Test plan

  • go build ./..., go test ./... (all green), gofmt -l clean on changed files.
  • New unit tests, all device-free:
    • ios/installationproxy/install_test.go: Install request plist construction (Command/PackagePath/ClientOptions, including the always-present empty dict) asserted by decoding the bytes written to an in-memory connection; progress → completion streaming; error responses (Error + ErrorDescription) surfaced as errors; progress/complete/error parsing against canned XML plists; unknown updates rejected.
    • ios/afc/upload_unit_test.go: File.ReadFrom chunking against an in-memory acking connection double — 2×64KiB+1234 bytes yields exactly three fileWrite packets with correct handle, sizes, and byte-identical reassembled payload; empty upload sends nothing.
  • CLI smoke-tested locally: help output, --method=installproxy parsing, unknown method rejection.
  • Follow-up validation: a real install e2e (stage + install an .ipa via --method=installproxy on the device runners) is the next step; this PR intentionally ships the device-free implementation and unit coverage first.

Fixes #400

🤖 Generated with Claude Code

https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk

Introduce an alternative app installation path modeled after
pymobiledevice3: upload the .ipa as-is via AFC to PublicStaging/<name>,
then send an installation_proxy Install command with PackagePath and
ClientOptions and stream PercentComplete progress until the install
completes or fails. The compressed .ipa is transferred without unpacking
it on the host, which is faster for large bundles over USB2.

- installationproxy: new Connection.Install (Install command + progress
  streaming) and InstallIpa (AFC staging + install orchestration)
- afc: File now implements io.ReaderFrom, chunking uploads into
  fileWrite packets of at most 64KiB; WriteToFile uses it
- cli: ios install gains --method=<method> with values 'zipconduit'
  (default, unchanged) and 'installproxy' (.ipa only)
- unit tests for the Install request plist, progress/completion/error
  parsing against canned plists, and AFC upload chunking against
  in-memory connection doubles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
@danielpaulus

Copy link
Copy Markdown
Owner Author

/test-devices

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🧪 Running real-device tests on PR #810run.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

❌ Real-device tests failed — see run.

The installproxy method uploaded the .ipa to PublicStaging via AFC but
never deleted it, leaking device storage on every install (an .ipa can be
hundreds of MB). Delete the staged package after installation via a
deferred AFC Remove, whether the install succeeds or fails, matching
pymobiledevice3's finally-block cleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
@danielpaulus

Copy link
Copy Markdown
Owner Author

Adversarial review of PR #810 (installproxy install via AFC staging)

Reviewed the installation_proxy protocol against pymobiledevice3 and libimobiledevice, the AFC chunked-upload change, and the response loop. Pushed one fix; the rest of the flagged items are false positives.

Fix applied (1163639)

  • Device-storage leak — the staged .ipa was uploaded to PublicStaging via AFC but never removed after install. An .ipa can be hundreds of MB, so every install leaked storage on the device. Added a deferred afcClient.Remove(remotePath) that runs whether the install succeeds or fails, matching pymobiledevice3's finally: afc.rm_single(...) cleanup. The defer is registered after defer afcClient.Close(), so LIFO ordering removes the file while the AFC client is still open.

Findings verified as correct / dismissed

  • PackagePath should have a leading slash (/PublicStaging/...) — false positive. libimobiledevice's ideviceinstaller uses the relative PublicStaging/... (no leading slash), and go-ios's own zipconduit uses PublicStaging/%s. The relative path is correct.
  • Empty ClientOptions breaks Developer packages — not a bug. pymobiledevice3/libimobiledevice only set PackageType: Developer when explicitly requested; the default (customer .ipa) sends an empty dict. The exported Install(path, options) already lets callers pass PackageType.
  • Response loop assumes one plist per packet — false positive. PlistCodec.Decode is length-prefixed and reads exactly one message per call; installation_proxy sends one plist per progress update. This is the same pattern as the existing Uninstall and pymobiledevice3's recv_plist().
  • Loop hangs on non-Complete terminal status / no timeout — consistent with existing Uninstall and the reference impls. On device disconnect, plistCodec.Decode returns an error that propagates out, so Install does not hang forever on a dropped connection. Not a regression introduced here.
  • AFC 64 KiB chunk exceeds a whole-packet boundary — false positive. The AFC wire format has no whole-packet size cap (EntireLen is a plain uint64); 64 KiB is the payload chunk (handle + header added on top), matching pymobiledevice3's MAXIMUM_WRITE_SIZE = 0x10000. The prior io.Copy path already sent packets of chunk+overhead.
  • fd.ReadFrom bypasses the source's WriterTo vs the old io.Copy — not a regression; it's an improvement. For bytes.Reader sources, io.Copy previously took the WriterTo path and wrote the entire buffer in a single fd.Write (one oversized packet); ReadFrom now chunks to 64 KiB. For os.File sources both paths chunk. Error propagation is identical (return on first write error).

Verification

go build ./..., go test ./... all green; gofmt -l clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Introduce an alternative app installation approach based on InstallProxy service

1 participant