# 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.