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.
151 lines
8.3 KiB
Markdown
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.
|