Files
Felitendo 8b06b305f5 feat: give the manager the island the app has
FTStore Manager puts an app on a device through the same three stages FTStore
does, and had nothing to show for any of it once the screen was off. It has a
Live Activity now: the same widget the app carries, embedded as its own
extension under its own identifier, naming the app, filling a bar while the
bytes arrive, adding the rate and the time left, then Preparing and Installing.

It is the app's island with the pause button taken out. This download manager
has no pause and no screen to offer one from, so there are no intents to compile
into the extension and no paused state in the payload. The island is read-only
on every version rather than only on the older ones.

It ends when the installer answers, which is the part the app had to learn the
hard way. Open launches what was just installed and leaves the sheet standing
behind it, so ending on dismissal left the island describing an install that had
finished, until somebody came back and closed a receipt they were done with.
Dismissal still ends it, which is what covers a flow that never gets an answer
at all.

A dropped connection is now one of those answers. It used to remove the download
and tell nobody, leaving the flow spinning with no way to find out; that was
merely invisible before and would now be an island counting bytes that stopped
arriving.

None of this is visible from a build: an IPA whose extension is missing, misnamed
or misfiled installs, runs, and simply shows no island. Both of the Manager's
release workflows therefore run the same check the app's do against the built
IPA, and the ad-hoc signing step seals the nested extension. Two products now
build an extension of the same name out of projects that share their
identifiers, so the separate derived data paths carry more weight than before,
and the notes that explain them say so.
2026-09-01 08:35:02 +02:00

151 lines
8.3 KiB
Markdown

# FTStoreActivity — Live Activity / Dynamic Island widget
This folder holds the **Live Activity** widget extension that shows the
download → sign → install progress in the Dynamic Island and on the lock screen.
The widget-extension **target exists in `FTStore.xcodeproj`** (added by hand to
`project.pbxproj`, synchronized-folder style, ids prefixed `57AC11…`). Key facts:
- This folder is a synchronized root group owned by the `FTStoreActivity`
target; `Info.plist` and `README.md` are membership exceptions.
- `FTStore/Backend/Observable/InstallActivityAttributes.swift` and
`…/InstallActivityIntents.swift` are shared into the extension via an
exception set on the `FTStore` folder: the widget needs the attributes type to
render, and the intent types to build a `Button(intent:)`. **Both must stay in
both targets.** The intents are `LiveActivityIntent`, which runs in the *app's*
process, so the app needs the same types to receive them; drop either
membership and the island either fails to compile or grows a button that
does nothing.
- Bundle id: **`$(FTSTORE_PRODUCT_BUNDLE_IDENTIFIER).activity`** → `app.ftstore.activity`.
- iOS Deployment Target **16.1** (the app is 16.0; ActivityKit needs 16.1, and
`LiveActivityManager` uses the 16.2 `ActivityContent` API).
- The appex is **iOS-only**: the embed build file and the target dependency
carry `platformFilter = ios`, so Mac Catalyst builds skip it entirely.
- The app's `Info.plist` already has **`NSSupportsLiveActivities = YES`**.
- The Makefile ad-hoc codesign uses `--deep` so the nested unsigned appex
doesn't make sealing fail; on-device signing re-signs PlugIns anyway.
## How it's driven
- `LiveActivityManager` (in the app target) starts/updates/ends the activity.
- `AppInstallManager` calls it across the lifecycle: `start` on Store download,
`update(.signing)` while signing, `update(.installing)` at install hand-off,
and `end` when the installer answers, on cancel, and on failure. The install
sheet closing ends it too, which is what covers a flow that never gets an
answer — "Open" leaves the sheet standing, so dismissal alone is too late.
- `DownloadManager` calls `refresh()` when a download is held or released, which
is the one thing the island shows that progress does not move.
- Everything is best-effort and availability-gated: with no widget target (or on
iOS < 16.2), all calls are safe no-ops and the rest of the app is unaffected.
## One activity for every install, not one each
`LiveActivityManager` keeps a registry of installs and pushes **a single
activity** describing all of them. It used to request one per install, which
works for exactly one: iOS collapses the Dynamic Island to its *minimal*
presentation the moment a second activity exists, so two installs bought two
dots and no numbers, and there is a cap past which further requests are simply
refused. Batching is also the only shape that can say "3 apps", add up a
transfer rate, or offer one pause button for the lot.
What follows from that, and must keep holding:
- **`end(id:)` ends the *install*, not the activity.** The activity itself only
ends once the last install is gone, so cancelling one of three leaves the
other two on screen. Every existing caller (`cancelDownload`, a failure, the
install sheet closing, an abandoned certificate setup) keeps its meaning.
- **One icon, and only when there is one install.** `ContentState` has a 4 KB
budget and a downscaled icon is roughly 3 KB of it, so a batch shows its count
where a single install shows its artwork. Never add a second `Data` field.
- **`items` is capped** (`ContentState.maximumItems`) with the remainder in
`hiddenCount`. Names are cheap but not free, and the expanded island has one
line for them.
- **Numbers are hidden once nothing is downloading.** `isDownloading` gates the
percentage, the bar, the byte counts and the countdown, because signing and
installing have no measurable progress and the last download figure would sit
there unmoving next to a label that had changed.
- **The countdown is a `Date`, not a string.** `finishAt` lets the widget use
`Text(timerInterval:countsDown:)`, which keeps ticking between our updates. A
pushed "4m left" is wrong a second after it arrives, and updates are throttled
to whole-percent changes.
## The pause button is iOS 17 and above
`InstallPauseButton` draws nothing below iOS 17: a Live Activity could not carry
a button before then. Everything else on the island is unchanged there, so 16.x
gets exactly the read-only island it always had. The intents post a notification
that `LiveActivityManager` turns into `DownloadManager.pauseAllDownloads()` /
`resumeAllDownloads()` — a notification rather than a direct call because
`InstallActivityIntents.swift` is compiled into the extension too, where there is
no download manager to call.
## Don't let another product share this build's derived data
`manager/` is a fork of `ios/` down to the pbxproj GUIDs, and Xcode keys its
build database on those. When both Makefiles pointed `-derivedDataPath` at
`$(TMPDIR)/FTStore`, the Manager build — which had no `FTStoreActivity` target
then — treated this extension as a stale file and went to delete it, while this
build picked up the Manager's object files. The IPA still built, still
installed, and still ran; it just carried an `.appex` left over from an older
build, and the island showed nothing. Both Makefiles now key the path on the
product directory.
The Manager has an extension of its own now (`manager/FTStoreActivity`, the same
target name, the same GUIDs, its own bundle id). That makes the separation more
important rather than less: the two would now overwrite each other's appex
instead of one deleting the other, and the result looks the same from outside.
The symptom is invisible from the app side, so every release workflow runs
`ios/scripts/verify-live-activity.sh` against the built IPA and fails on it.
## What that script checks, and why each one
Presence was the first check, added after the derived-data incident above. It is
only the first of several ways to ship an app that installs, runs, and has no
island — the rest passed a presence check unchanged:
- **The extension is there, with an executable inside it.** An empty `.appex`
directory still zips.
- **Its bundle id sits under the app's.** iOS will not load an app extension
whose identifier is not prefixed by its host's, and it reports that nowhere.
The two ids come from separate `PRODUCT_BUNDLE_IDENTIFIER` settings in the
pbxproj, so they can drift — a disambiguator applied to one and not the other
does exactly that, and on-device signing rewrites both (see
`SigningHandler._modifyPluginIdentifiers`) precisely because of this rule.
- **It declares `com.apple.widgetkit-extension`.** Any other extension point
makes the appex dead weight.
- **The app sets `NSSupportsLiveActivities`.** Without it `Activity.request`
refuses at runtime, and `LiveActivityManager` treats every call as
best-effort — a silent no-op that looks exactly like everything working.
If the island is dead and this script passes, the build is not the problem.
## Asking the device why
`LiveActivityManager.start` says which of its reasons applied, on the
`LiveActivity` category. With the phone attached:
```sh
log stream --predicate 'subsystem BEGINSWITH "app.ftstore" AND category == "LiveActivity"'
```
Start a download in the Store and read one line:
- *needs iOS 16.2 or newer* — the OS, nothing to fix here.
- *switched off for FTStore in iOS Settings* — Settings → FTStore → Live
Activities. This is the common one and it looks exactly like a broken build.
- *ActivityKit refused the request* — the extension is not loadable on the
device even though it is in the IPA. That is the case the build cannot see.
- *Island started* — it works; the phone may simply not have a Dynamic Island
(iPhone 14 Pro and newer), in which case look at the lock screen instead.
Nothing at all means `AppInstallManager` never reached `start`, so the install
did not come through the Store's own download path.
## Verifying
Run on a device or simulator with iOS 16.2+. Tap **Download** in the Store:
the Dynamic Island (iPhone 14 Pro and newer) and the lock-screen banner should
show the app name, a progress bar while downloading, then "Preparing" and
"Installing", and dismiss shortly after.