# Integrating ipa-hub updates in an Apple app

This is the integration contract for apps installed through ipa-hub. The manager is hosted at <https://ipa-hub.giladgd.com/app/>; the app-update endpoints described here continue to run on the user's Mac. Do not send update requests to GitHub Pages.

## Instruction for a coding agent

> Read https://ipa-hub.giladgd.com/docs/app-updates.md and implement optional ipa-hub update support in this app. Read the IPAHub dictionary from Info.plist and its updateURL array of strings at runtime; hide the feature when absent or invalid. Preserve every complete URL and try the next destination when DNS resolution or connection establishment fails. The server selects the saved installation's update channel; do not add a release parameter. Check for updates, ask the user before installing, and display streamed progress. Never replay an installation after a timeout or lost response. Follow the network, privacy, HTTP/ATS, error, and retry requirements in that guide. Do not hard-code a server, installation ID, repository, or device ID. Keep normal app installations working unchanged.

## Detect support at runtime

Before signing, ipa-hub adds an `IPAHub` dictionary to the **main application's** `Info.plist`:

```xml
<key>IPAHub</key>
<dict>
    <key>updateURL</key>
    <array>
        <string>http://my-mac.local:19464/api/app/0123456789abcdef0123456789abcdef0123456789abcdef/update</string>
        <string>http://192.168.1.10:19464/api/app/0123456789abcdef0123456789abcdef0123456789abcdef/update</string>
        <string>http://[fd00::10]:19464/api/app/0123456789abcdef0123456789abcdef0123456789abcdef/update</string>
    </array>
    <key>source</key><string>gh:owner/repo</string>
    <key>filter</key><string>tv</string>
    <key>release</key><string>latest</string>
</dict>
```

`IPAHub.updateURL` is a **non-empty array of complete URL strings**, one per destination. Parse each string as a URL without splitting it. The dictionary also includes `source`, and optional `filter`, requested signing `team`, and release channel `release` (`latest` or `pre-release`). URLs carry the source/filter/team query items as well (omitted above for readability), but do not include a `release` parameter. File/ordinary HTTP sources do not have a release channel. Treat unknown fields as optional and ignore them.

The server selects the update channel from the app installation's saved record. An installation from `latest` checks the latest stable release; one from `pre-release` checks the latest prerelease. An installation from a specific tag, including a prerelease tag, checks `latest` by default. Updates and signature refreshes preserve this channel even though they download a specific release. The dictionary's `release` field describes that policy; clients do not need to send it back to the server.

The installer discovers the Mac's `.local` hostname, usable non-loopback interface IPv4/IPv6 addresses, and running Tailscale IPs and DNS name. Link-local/scoped, unspecified and multicast addresses are excluded. Private LAN and Tailscale addresses are included; "available interfaces" does not mean only internet-routable IPs. Numeric IPv6 URLs use brackets. Destinations are deduplicated and respect the saved listener's bind address.

This discovery runs even when no server is running. The installer saves its listener host and port in `server/config.json`: explicit server settings or the library's `publicURL` port take precedence, then `IPA_HUB_PORT`, the saved port, or `19464`. Starting the server later with the same data directory reuses the saved settings. Changing the server port or network addresses after signing can make old URLs unreachable; re-install or re-sign to refresh the list. `.local` can continue working after IP changes on the same LAN.

```swift
let metadata = Bundle.main.object(forInfoDictionaryKey: "IPAHub") as? [String: Any]
let destinations = metadata?["updateURL"] as? [String] ?? []
// Validate every destination's scheme, host and /api/app/<48 hex characters>/update path.
// The complete client below also accepts older single /update/<48 hex characters> URLs.
```

Do not put a placeholder URL into your normal builds. Metadata is an opt-in capability, not proof of a particular installer: another signer can edit it too. Absent or malformed metadata means the feature is unavailable; hide its controls without showing an error. Do not infer availability from bundle identifiers, signing teams, debug/release builds, or a successful connection to an arbitrary server.

Read the list from the current bundle on each app launch. The installation ID (a random 48-character hexadecimal token) is replaced after a successful installation. Every destination in one list refers to the same installation. Do not retain an old ID across app updates or reuse another device's URLs. Re-signing injects metadata; it does not add an update client to an application automatically.

## URL and authorization contract

Use the supplied scheme, host, port, path, and query items. The token identifies a stored app/device record; the server ignores source/filter/team/release query changes for authorization and uses that record to select the source. Never reconstruct the token or use these routes to install a new app.

The caller must be the host Mac or a paired device identified by the connection's IP; an admin device can operate on another device's record. Knowing the URL alone does not authorize a new caller. Native update/check calls do **not** require an installation signing key, a WebRTC handshake, a bearer access token, or a browser CSRF token. They cannot enroll keys or install arbitrary apps. New manager installs and other browser operations use the separate pinned, encrypted WebRTC protocol.

Connect directly to the Mac. The Mac must be awake, ipa-hub running, and the device reachable for installation. If a VPN IP cannot be associated with its paired device, these native HTTP routes return 403; use the local network or the enrolled manager. Do not attempt to copy manager credentials into the app. Tokens and complete update URLs should never be sent to analytics, crash reports, logs, or third-party services.

Generated native endpoints use `http://<destination>/api/app/<installation-id>/update`. They **do not** inherit the manager's application-level encryption or server pin. Use them on a trusted local network or authenticated VPN: HTTP cannot authenticate a machine that later occupies a saved address. The HTTPS Pages URL hosts App Manager, whose connection uses pinned WebRTC; it does not host native update routes. Do not follow redirects with the installation ID or discover arbitrary alternative destinations beyond the embedded list.

Try the URLs in order, moving to the next on DNS resolution failures or connection refusal. Remember the last reachable destination for this app session and try it first next time. A real HTTP error (including 400 or 403) is a server response, not permission to try another host. Do not retry installation after a timeout, lost response or interrupted stream: the Mac may already have received it. The Swift client only falls back on errors known to happen before delivery, including a local ATS rejection. User cancellation must stop attempts.

## Every update endpoint

Let `U` be **one** URL from the injected list, including its query. Append `/check` to its **path**, keeping query items in place. `<id>` below is the 48-character installation ID.

| Method                    | Route                                            | Behavior                                                                                                                   |
| ------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| GET or POST               | `/api/app/<id>/update/check`                     | Check the tracked source. If an update is available, start background prefetch after sending the JSON response.            |
| GET or POST               | `/api/app/<id>/update/check?prefetch=false`      | Check only; do not prefetch.                                                                                               |
| GET or POST               | `/api/app/<id>/update/check?prefetch=foreground` | Stream check/download progress, then the check result as NDJSON. Does not install.                                         |
| GET or POST               | `/api/app/<id>/update?prefetch=foreground`       | The same foreground-prefetch stream, without appending `/check`.                                                           |
| GET or POST               | `/api/app/<id>/update/check?prefetch=true`       | Explicit background prefetch, the default.                                                                                 |
| POST (GET also supported) | `/api/app/<id>/update`                           | Download if needed, sign, and install. Reopen only if the app was running immediately before installation. Streams NDJSON. |
| POST (GET also supported) | `/api/app/<id>/update?open=false`                | Install without opening the app afterward.                                                                                 |
| POST (GET also supported) | `/api/app/<id>/update?open=true`                 | Allow reopening if the app was running immediately before installation (the default).                                      |

Previously installed apps can keep using `/update/<token>` and `/update/<token>/check` with the same behavior. These aliases remain authorized against the stored app/device record. The new `/api/app/.../update` routes are the only native exceptions to the restriction on remote HTTP `/api/*` calls; other manager API routes still require WebRTC.

The shown query items are **added to** any existing source/filter/team items, not substituted for them. The saved installation determines the release channel; a `release` query parameter does not override it. `open` accepts exactly `true` or `false`. `prefetch` accepts exactly `true`, `false`, or `foreground`; invalid values fail with an error. Supplying `prefetch` on the base `/update` route selects check/prefetch mode, just like `/check`, and never installs. `open` controls installation only. No request body is required. Prefer POST for an installation because it changes device state. GET is retained for existing integrations. No separate native `/resign`, `/latest`, `/status`, or `/cancel` endpoint exists; signature refresh and batch operations are manager features.

Immediately before installing an update or re-signing, after download and signing finish, the Mac checks the target app's main process. It saves that result and reopens the app only if it was running and `open` is not `false`. Background processes count as running: this is not a foreground-focus check. It uses `devicectl` for physical devices and `simctl` for simulators, without a UI-test runner, debugger attachment, or app-side confirmation. If the check fails, installation continues with a warning and the app stays closed afterward. `open=true` does not override this check. Fresh app installations retain their normal open-after-install behavior.

Example check response:

```json
{
    "available": true,
    "current": "v1.2.0",
    "latest": "v1.3.0",
    "asset": {"id": "123", "name": "Example.ipa", "url": "https://example.com/Example.ipa"},
    "prefetch": "background"
}
```

Only `available` is always present. Optional fields are `current`, `latest`, `asset`, `reason`, and `prefetched`; `/check` additionally reports `prefetch` as `disabled`, `complete`, or `background`. `background` acknowledges scheduling, not success. `complete` means foreground prefetch finished. `disabled` means no update was available or prefetch was explicitly disabled. A failed background prefetch does not retroactively change the check response; the eventual install retries its download. Concurrent background requests for the same installation are deduplicated.

Release sources compare their tracked channel, tag and asset fingerprint. Direct IPA URLs use validators such as ETag, Last-Modified and length. If the remote file has no usable validators, `available: false` can accompany a `reason` explaining that a reliable comparison is unavailable; do not promise the installed file is current. Local files have no remote release channel. Credentials supplied at installation remain private on the Mac and are reused automatically; the app does not need them and cannot retrieve them.

Foreground prefetch returns `application/x-ndjson` immediately as progress becomes available, with the check object in the final `result` event:

```json
{"type":"progress","progress":{"step":"check","message":"Checking for updates to Example"}}
{"type":"progress","progress":{"step":"download","message":"Downloading Example.ipa","completed":1048576,"total":8388608}}
{"type":"result","result":{"available":true,"current":"v1.2.0","latest":"v1.3.0","prefetched":true,"prefetch":"complete"}}
```

Read this as a stream, not a single JSON object. A no-update check still ends with a `result` event containing `available: false` and `prefetch: "disabled"`. Errors and selections use the same events as installation below. Disconnecting cancels an unfinished foreground prefetch and removes its partial download; it never signs or installs the app. Background and disabled prefetch continue to return ordinary JSON.

## Installation stream

The response has `Content-Type: application/x-ndjson`: **one JSON object per line**, not SSE and not one JSON array. Decode UTF-8 incrementally; lines can span network chunks. Events are:

```json
{"type":"progress","progress":{"step":"download","message":"Downloading Example.ipa","completed":1048576,"total":8388608}}
{"type":"progress","progress":{"step":"sign","message":"Signing application"}}
{"type":"result","result":{"device":{},"app":{},"warnings":[]}}
```

The result objects above are abbreviated. Consumers may ignore `result` if they only need confirmation. Progress always contains `step` and `message`; `completed` and `total` are optional. Show an indeterminate indicator without a positive total, otherwise clamp `completed / total` to 0...1. Progress is per step, not a global percentage. Preserve warning text if presenting result details; do not log the entire app record with its update URL.

Before streaming, failures use a non-2xx status with `{ "error": "human-readable message" }`. Unknown/expired tokens or malformed parameters currently return 400; unpaired or unauthorized callers return 403; a stopping server can return 503. Handle any non-2xx status rather than depending exclusively on these values. After streaming starts the HTTP status remains 200, so also handle:

```json
{"type":"error","message":"No available device matches this installation."}
{"type":"selection","selection":{"key":"asset","message":"Select an IPA","choices":[{"id":"a","label":"Example.ipa"}]}}
```

[Filter Healing](https://ipa-hub.giladgd.com/docs/guide/sources.md#filter-healing) tries saved refinements and the installed release's filenames before requesting an asset selection. As a last resort it can download matching candidates to inspect their compatibility, even during a check with `prefetch=false`. It saves successful refinements for future checks.

`selection` means user input is required before installation can continue. This endpoint has no selection-resume parameters; ask the user to finish configuration in the manager/CLI (for example a precise asset filter or signing profile) and check again. Ignore unknown future event types. Treat completion without a `result` event as **unconfirmed**, not success.

Ask for confirmation **before** requesting installation, save user work, and explain that the app may close while it is replaced. Cancellation or a lost stream does not cancel work already running on the Mac. Never automatically retry an installation after disconnection; the first request may have succeeded. Check the current installed app/manager status first. Update checks can be retried with bounded backoff; avoid aggressive background polling and repeated Local Network permission prompts.

## Global update links

The manager can issue an additional stable URL for a tracked app: `/global-update/<installation-id>`. The random ID identifies exactly one app installation on one device and stays valid across updates and signature refreshes. It is separate from the URLs in `IPAHub.updateURL` embedded in the signed app; the embedded routes described above retain their existing behavior.

| Request                                      | Behavior                                                                                                         |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `GET /global-update/<id>`                    | Check only; return the update-check object with `installing: false`.                                             |
| `GET /global-update/<id>?install`            | Check and schedule an available update; return HTTP 202 with `installing: true` and a `job` ID.                  |
| `GET /global-update/<id>?install=foreground` | Check and install an available update, streaming the same NDJSON events documented above until completion.       |
| `?install=false`                             | Check only.                                                                                                      |
| `?open=false`                                | Never reopen after installation. By default, reopen only if the app was running immediately before installation. |

POST is also accepted. `install=true` means background; the spelling `install=forgroung` is accepted as an alias for `foreground`. If no update is available, all modes return ordinary JSON with `installing: false`, so inspect `Content-Type` before decoding. Invalid parameters return 400, unauthorized callers 403, and concurrent installation requests for the same link 409. Background completion appears in the manager’s live activity; HTTP 202 confirms scheduling, not success.

For this global foreground endpoint, disconnecting the HTTP request aborts a source check or download that is still in progress and removes a partial download. Once extraction/signing/installation has begun, it finishes independently. Do not assume cancelling means the app was not installed; inspect its current status before retrying. Background installs continue after the check response closes.

Newly issued global update URLs are private credentials: a caller on another machine can use one to check for and update only its tracked app on its target device. The caller does not need pairing or App Manager enrollment, and the link grants no access to other API routes. The server uses the saved source and signing choices; source, app, or device overrides in the request are ignored. Private download hosts still require the server's `--allow-host` configuration. Access tokens remain on the Mac and are reused without being returned by the endpoint.

Existing links without this grant still require the host Mac or an authorized paired device. Using **Get global update URL** again issues a new credential without upgrading the old identifier. The native `/api/app/<id>/update` endpoints retain their Mac/paired-device checks.

These routes require network reachability; “global” does not imply an Internet relay. Keep HTTP links on a trusted LAN or private VPN. To revoke one, remove `server/update-links/<id>.json` from the Mac's ipa-hub data directory. Future issuance generates a new secret, and already-running installations continue. See [Remote coding agents](https://ipa-hub.giladgd.com/docs/guide/remote-agents.md) for an end-to-end example and instructions to share with an agent.

## Swift client

A complete Foundation client is available as [IpaHubUpdater.swift](https://ipa-hub.giladgd.com/docs/examples/IpaHubUpdater.swift). It reads and validates the destination array, falls back after DNS/connect failures, remembers the last reachable URL, preserves query items, refuses redirects, checks HTTP status, consumes progress lines, and handles selection/error/unconfirmed completion. It targets iOS/iPadOS/tvOS 15+, macOS 12+, watchOS 8+, and visionOS. It is a source example for your app, not an additional ipa-hub runtime dependency.

```swift
@MainActor
func checkForIpaHubUpdate() async {
    guard let updater = IpaHubUpdater() else { return }
    do {
        let result = try await updater.check(prefetch: .background)
        if result.available {
            // Present a confirmation UI; retain updater until the user chooses.
            print("An update is available") // Do not log its URL or app record.
        }
    } catch {
        // Show a recoverable message; the app continues working normally.
        print(error.localizedDescription)
    }
}
```

After the user confirms:

```swift
// Keep this updater alive in your view model; serialize update actions.
try await updater.install(openAfterInstall: true) { progress in
    statusText = progress.message
    fractionCompleted = progress.fraction // nil means indeterminate
}
```

For explicit control of prefetch:

```swift
let checkOnly = try await updater.check(prefetch: .disabled)
let readyToInstall = try await updater.check(prefetch: .foreground) { progress in
    statusText = progress.message
    fractionCompleted = progress.fraction
}
// Foreground prefetch does not sign or install; still ask before install().
```

A SwiftUI view model can expose `@Published var statusText`, `@Published var fractionCompleted: Double?` and a busy flag. Render `ProgressView(value: fractionCompleted)` when present and `ProgressView()` otherwise. Keep updates disabled during another install; make cancellation say “stop waiting”, rather than claiming to cancel the server's job. Test the full flow on a real device because Simulator builds cannot replace physical-device apps.

## Local Network privacy and ATS

ipa-hub prepares the main application's plist **before signing**, on physical devices and simulators:

- It keeps an existing non-empty `NSLocalNetworkUsageDescription`. If missing or blank, it inserts the description below. The system may still ask the user for Local Network permission; metadata cannot grant that permission.
- It merges `NSExceptionAllowsInsecureHTTPLoads` into `NSAppTransportSecurity.NSExceptionDomains` for **each exact HTTP destination**. IPv6 exception keys have no brackets; keys never include a port. Existing unrelated exceptions and other ATS settings are preserved. HTTPS destinations do not receive HTTP exceptions.

For example, a signed app with `.local`, IPv4 and IPv6 update destinations receives entries like these:

```xml
<key>NSLocalNetworkUsageDescription</key>
<string>Connect to your Mac to check for and install updates through ipa-hub</string>
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSExceptionDomains</key>
    <dict>
        <key>my-mac.local</key>
        <dict><key>NSExceptionAllowsInsecureHTTPLoads</key><true/></dict>
        <key>192.168.1.10</key>
        <dict><key>NSExceptionAllowsInsecureHTTPLoads</key><true/></dict>
        <key>fd00::10</key>
        <dict><key>NSExceptionAllowsInsecureHTTPLoads</key><true/></dict>
    </dict>
</dict>
```

Do not copy the sample addresses into your normal builds: ipa-hub substitutes the destinations discovered at installation. You may provide your own purpose text in your source plist. For a manually configured development build, add exceptions for its actual destinations before building; URLSession cannot dynamically add ATS plist exceptions.

You do **not** need `NSAllowsArbitraryLoads`, and ipa-hub does not add it. ATS still applies to other destinations. Exceptions apply to the named host, across its paths and ports; ATS cannot limit one to only `/api/app/.../update`. Existing app-wide exceptions, if supplied by the app, are left unchanged.

Apple documents that `.local` connections are allowed by default on modern OS versions; `NSAllowsLocalNetworking` can declare that intent. Numeric IPs changed in iOS/iPadOS 17 and macOS 14: use individual IP (or CIDR) entries in `NSExceptionDomains`. A hostname exception does not cover requests using its IP, nor vice versa. This is why ipa-hub adds exact entries for the hostname, each IPv4/IPv6 address, and any Tailscale hostname/IPs. See Apple's [local networking ATS setting](https://developer.apple.com/documentation/bundleresources/information-property-list/nsapptransportsecurity/nsallowslocalnetworking) and [exception domain rules](https://developer.apple.com/documentation/bundleresources/information-property-list/nsapptransportsecurity/nsexceptiondomains).

Use direct URLSession requests; no Bonjour browsing is needed, so do not add unrelated Bonjour service declarations. Test the targeted iOS/iPadOS/tvOS/visionOS versions on devices. ATS exceptions do not bypass a Local Network denial or make plain HTTP encrypted. See Apple's [Local Network usage description](https://developer.apple.com/documentation/bundleresources/information-property-list/nslocalnetworkusagedescription), [local network privacy guidance](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy), and [URLSession streamed bytes](https://ipa-hub.giladgd.com/docs/guide/app-updates/%3Chttps://developer.apple.com/documentation/foundation/urlsession/bytes(for:delegate:)>).

## Integration acceptance checks

- A normal installation without metadata shows no ipa-hub updater.
- A signed installation discovers its own URL list without hard-coded values, even if installed before the server first started.
- DNS failure for `.local` falls back to the supplied IPv4/IPv6/Tailscale destinations; a later request tries the last reachable URL first.
- An array with one or multiple destinations works; empty arrays and non-string entries disable the integration.
- Checks use the saved stable/prerelease channel without a release query parameter, and tagged installs default to stable updates.
- HTTP errors and ambiguous disconnections do not replay an installation.
- Signing preserves a developer's privacy purpose text and other ATS settings, and adds missing text and exact HTTP-host exceptions.
- `/check` preserves existing query items and supports all three prefetch policies.
- Foreground prefetch consumes progress and the final check result from NDJSON; background acknowledgement is not treated as download completion.
- Installation needs user confirmation, shows determinate/indeterminate progress, and handles error and selection events even under HTTP 200.
- Offline, permission-denied, unauthorized, invalid-token and unconfirmed-disconnect states do not crash or break the rest of the app.
- A successful new installation reads its new metadata on next launch.
- No secrets appear in logs, analytics, redirects, or requests to a guessed host.
- No automatic retry can trigger a second installation after an ambiguous disconnect.

