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.
388 lines
13 KiB
Swift
388 lines
13 KiB
Swift
//
|
|
// InstallLiveActivity.swift
|
|
// FTStoreActivity
|
|
//
|
|
// Lock-screen banner + Dynamic Island for the download → sign → install
|
|
// lifecycle. Renders `InstallActivityAttributes` (shared with the app target),
|
|
// which describes every install in flight rather than one of them.
|
|
//
|
|
// This is the app's widget with its pause button taken out: the Manager's
|
|
// downloads cannot be held, so the island here is read-only on every iOS
|
|
// version rather than only below 17.
|
|
//
|
|
|
|
import WidgetKit
|
|
import SwiftUI
|
|
import UIKit
|
|
import ActivityKit
|
|
|
|
/// FTStore brand purple (#848ef9). Defined locally so this target needs no
|
|
/// dependency on the app's colour helpers.
|
|
private let ftBrandPurple = Color(red: 132 / 255, green: 142 / 255, blue: 249 / 255)
|
|
|
|
@available(iOS 16.1, *)
|
|
private func installFraction(_ state: InstallActivityAttributes.ContentState) -> Double {
|
|
min(max(state.progress, 0), 1)
|
|
}
|
|
|
|
@available(iOS 16.1, *)
|
|
private func installPercent(_ state: InstallActivityAttributes.ContentState) -> Int {
|
|
Int((installFraction(state) * 100).rounded())
|
|
}
|
|
|
|
/// A number only means something while bytes are arriving.
|
|
///
|
|
/// Signing and installing have no measurable progress, and the manager leaves
|
|
/// the download's last value in place while they run — so printing it there
|
|
/// showed a figure that had stopped moving next to a label that had changed.
|
|
@available(iOS 16.1, *)
|
|
private func showsPercent(_ state: InstallActivityAttributes.ContentState) -> Bool {
|
|
state.isDownloading
|
|
}
|
|
|
|
/// The phase glyph: whatever the first install is doing, since it is the one
|
|
/// whose icon and name the compact presentations show.
|
|
@available(iOS 16.1, *)
|
|
private func batchSystemImage(_ state: InstallActivityAttributes.ContentState) -> String {
|
|
state.items.first?.phase.systemImage ?? InstallActivityAttributes.Phase.downloading.systemImage
|
|
}
|
|
|
|
/// The headline: one app by name, or how many there are.
|
|
///
|
|
/// Names rather than "Downloads" while there is only one, because that is the
|
|
/// question the island is being glanced at to answer. Past one, the count is the
|
|
/// answer and the names are on the line below.
|
|
@available(iOS 16.1, *)
|
|
private func batchTitle(_ state: InstallActivityAttributes.ContentState) -> String {
|
|
let total = state.items.count + state.hiddenCount
|
|
if total <= 1 {
|
|
return state.items.first?.name ?? NSLocalizedString(
|
|
"Downloading",
|
|
comment: "Install phase, shown on the Live Activity"
|
|
)
|
|
}
|
|
return String(
|
|
format: NSLocalizedString("%lld apps", comment: "How many apps are being installed at once"),
|
|
total
|
|
)
|
|
}
|
|
|
|
/// The line under the headline: what is being done, in as few words as it takes.
|
|
/// One install names its phase; several name themselves.
|
|
@available(iOS 16.1, *)
|
|
private func batchSubtitle(_ state: InstallActivityAttributes.ContentState) -> String {
|
|
let total = state.items.count + state.hiddenCount
|
|
if total <= 1 {
|
|
guard let phase = state.items.first?.phase else { return "" }
|
|
return NSLocalizedString(phase.label, comment: "Install phase, shown on the Live Activity")
|
|
}
|
|
|
|
let names = state.items.map(\.name).joined(separator: ", ")
|
|
guard state.hiddenCount > 0 else { return names }
|
|
return names + "…"
|
|
}
|
|
|
|
/// Transfer rate and bytes, in one line, separated the way the rest of the app
|
|
/// separates two facts side by side.
|
|
@available(iOS 16.1, *)
|
|
private func batchDetail(_ state: InstallActivityAttributes.ContentState) -> String? {
|
|
guard state.isDownloading else { return nil }
|
|
|
|
var parts: [String] = []
|
|
if state.bytesPerSecond > 0 {
|
|
parts.append(formatBytes(state.bytesPerSecond) + "/s")
|
|
}
|
|
if state.bytesExpected > 0 {
|
|
parts.append("\(formatBytes(state.bytesDownloaded)) / \(formatBytes(state.bytesExpected))")
|
|
}
|
|
|
|
guard !parts.isEmpty else { return nil }
|
|
return parts.joined(separator: " · ")
|
|
}
|
|
|
|
private func formatBytes(_ bytes: Int64) -> String {
|
|
let formatter = ByteCountFormatter()
|
|
formatter.countStyle = .binary
|
|
formatter.allowedUnits = [.useKB, .useMB, .useGB]
|
|
formatter.includesUnit = true
|
|
formatter.isAdaptive = true
|
|
return formatter.string(fromByteCount: max(bytes, 0))
|
|
}
|
|
|
|
/// The download bar: a thin capsule that fills as the bytes arrive.
|
|
///
|
|
/// A percentage on its own is a number to read; a bar is a thing to glance at,
|
|
/// which is the whole job of a Live Activity. It also gives the expanded island
|
|
/// something to do with the width between the icon and the number, which was
|
|
/// empty.
|
|
///
|
|
/// Absent outside the downloading phase rather than shown full or shown empty:
|
|
/// either would claim to measure something that is not being measured.
|
|
@available(iOS 16.1, *)
|
|
private struct InstallProgressBar: View {
|
|
let state: InstallActivityAttributes.ContentState
|
|
var height: CGFloat = 5
|
|
|
|
var body: some View {
|
|
if showsPercent(state) {
|
|
GeometryReader { geometry in
|
|
ZStack(alignment: .leading) {
|
|
Capsule()
|
|
.fill(ftBrandPurple.opacity(0.22))
|
|
Capsule()
|
|
.fill(ftBrandPurple)
|
|
.frame(width: geometry.size.width * installFraction(state))
|
|
}
|
|
}
|
|
.frame(height: height)
|
|
// The bar is the one thing here that moves on its own, so it is also
|
|
// the one thing worth animating between updates.
|
|
.animation(.easeOut(duration: 0.3), value: state.progress)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The same reading as the bar, in the space a compact island has.
|
|
///
|
|
/// A ring rather than three characters: at this size a shape that is a quarter
|
|
/// full is read instantly, and "29%" has to be focused on to be read at all.
|
|
@available(iOS 16.1, *)
|
|
private struct InstallProgressRing: View {
|
|
let state: InstallActivityAttributes.ContentState
|
|
var size: CGFloat = 18
|
|
var lineWidth: CGFloat = 2.5
|
|
/// Drawn inside the ring. The count when there is one to report, so a glance
|
|
/// at the notch says how many apps are coming rather than only how far along
|
|
/// they are.
|
|
var showsCount: Bool = false
|
|
|
|
private var total: Int { state.items.count + state.hiddenCount }
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// Both rings are drawn *inside* the frame, which is why the track
|
|
// uses `strokeBorder` and the progress arc insets before it strokes.
|
|
//
|
|
// `stroke` centres the line on the circle's path, so half the line
|
|
// width sits outside the frame: an 18 point ring with a 2.5 point
|
|
// line actually covers 20.5. Nothing in the app noticed, because
|
|
// everywhere else there is room for the overhang. The compact region
|
|
// of the Dynamic Island is the one place there is not. It masks its
|
|
// content to the island's own rounded shape, hard against the
|
|
// camera, and the overhang was being cut off there.
|
|
Circle()
|
|
.strokeBorder(ftBrandPurple.opacity(0.25), lineWidth: lineWidth)
|
|
Circle()
|
|
.inset(by: lineWidth / 2)
|
|
.trim(from: 0, to: installFraction(state))
|
|
.stroke(ftBrandPurple, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
|
// From the top, clockwise, which is the direction everything
|
|
// else that fills up in iOS goes.
|
|
.rotationEffect(.degrees(-90))
|
|
.animation(.easeOut(duration: 0.3), value: state.progress)
|
|
|
|
if showsCount, total > 1 {
|
|
Text(verbatim: "\(total)")
|
|
.font(.system(size: size * 0.5, weight: .bold).monospacedDigit())
|
|
.foregroundStyle(ftBrandPurple)
|
|
}
|
|
}
|
|
.frame(width: size, height: size)
|
|
}
|
|
}
|
|
|
|
/// Leading visual: the installed app's icon when we have it, otherwise the phase
|
|
/// glyph. Shared by every presentation (compact / minimal / expanded / lock
|
|
/// screen) so the fallback behaves identically everywhere.
|
|
@available(iOS 16.1, *)
|
|
private struct InstallLeadingIcon: View {
|
|
let state: InstallActivityAttributes.ContentState
|
|
var iconSize: CGFloat
|
|
var symbolFont: Font? = nil
|
|
|
|
var body: some View {
|
|
if
|
|
let data = state.iconData,
|
|
let image = UIImage(data: data)
|
|
{
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
.frame(width: iconSize, height: iconSize)
|
|
.clipShape(RoundedRectangle(cornerRadius: iconSize * 0.22, style: .continuous))
|
|
} else {
|
|
Image(systemName: batchSystemImage(state))
|
|
.font(symbolFont)
|
|
.foregroundStyle(ftBrandPurple)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Time left, counted down by the system rather than by our updates.
|
|
///
|
|
/// `Text(timerInterval:)` keeps ticking on its own between pushes, which is what
|
|
/// makes a figure this volatile worth showing at all: a pushed "4m left" is
|
|
/// wrong a second after it arrives, and we push at most once a second.
|
|
@available(iOS 16.1, *)
|
|
private struct InstallCountdown: View {
|
|
let state: InstallActivityAttributes.ContentState
|
|
|
|
var body: some View {
|
|
if let range = _range {
|
|
HStack(spacing: 3) {
|
|
Image(systemName: "clock")
|
|
Text(timerInterval: range, countsDown: true)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One `Date()` for both ends of the test and the range. Reading the clock
|
|
/// twice can put `now` past `finishAt` between them, and a `ClosedRange`
|
|
/// whose lower bound is above its upper one traps.
|
|
private var _range: ClosedRange<Date>? {
|
|
guard
|
|
state.isDownloading,
|
|
let finishAt = state.finishAt
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
let now = Date()
|
|
guard finishAt > now else { return nil }
|
|
return now...finishAt
|
|
}
|
|
}
|
|
|
|
@available(iOS 16.1, *)
|
|
struct InstallLiveActivity: Widget {
|
|
var body: some WidgetConfiguration {
|
|
ActivityConfiguration(for: InstallActivityAttributes.self) { context in
|
|
InstallLockScreenView(context: context)
|
|
.activitySystemActionForegroundColor(ftBrandPurple)
|
|
} dynamicIsland: { context in
|
|
DynamicIsland {
|
|
DynamicIslandExpandedRegion(.leading) {
|
|
// Inset from the island's rounded corner so the icon isn't clipped.
|
|
InstallLeadingIcon(state: context.state, iconSize: 28, symbolFont: .title2)
|
|
.padding(.leading, 6)
|
|
}
|
|
DynamicIslandExpandedRegion(.trailing) {
|
|
// The number and nothing else. This region is only as wide as
|
|
// the camera leaves it.
|
|
if showsPercent(context.state) {
|
|
Text(verbatim: "\(installPercent(context.state))%")
|
|
.font(.title3.monospacedDigit().weight(.semibold))
|
|
.foregroundStyle(ftBrandPurple)
|
|
.padding(.trailing, 6)
|
|
}
|
|
}
|
|
DynamicIslandExpandedRegion(.bottom) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(batchTitle(context.state))
|
|
.font(.headline)
|
|
.lineLimit(1)
|
|
Text(batchSubtitle(context.state))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
// Across the full width, under both the icon and the
|
|
// number: it is the one element that relates them, and
|
|
// the space it fills was empty.
|
|
InstallProgressBar(state: context.state)
|
|
|
|
if let detail = batchDetail(context.state) {
|
|
HStack(spacing: 6) {
|
|
Text(detail)
|
|
.monospacedDigit()
|
|
Spacer(minLength: 4)
|
|
InstallCountdown(state: context.state)
|
|
}
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 6)
|
|
.padding(.bottom, 4)
|
|
}
|
|
} compactLeading: {
|
|
// App icon on the left (falls back to the phase glyph).
|
|
InstallLeadingIcon(state: context.state, iconSize: 22)
|
|
} compactTrailing: {
|
|
if showsPercent(context.state) {
|
|
InstallProgressRing(state: context.state, showsCount: true)
|
|
} else {
|
|
// Not left empty: a compact island with only a leading item
|
|
// sits lopsided against the camera, and the phase glyph is
|
|
// the honest thing to put there once there is no progress to
|
|
// report.
|
|
Image(systemName: batchSystemImage(context.state))
|
|
.font(.caption)
|
|
.foregroundStyle(ftBrandPurple)
|
|
}
|
|
} minimal: {
|
|
// The ring wins over the icon here: minimal is what iOS falls back
|
|
// to when something else wants the island too, and a bare icon
|
|
// there says an install exists without saying anything about it.
|
|
InstallProgressRing(state: context.state, size: 22, lineWidth: 3, showsCount: true)
|
|
}
|
|
// The hairline around the island picks up the same tint, so the whole
|
|
// thing reads as one object before anything inside it is legible.
|
|
.keylineTint(ftBrandPurple)
|
|
}
|
|
}
|
|
}
|
|
|
|
@available(iOS 16.1, *)
|
|
private struct InstallLockScreenView: View {
|
|
let context: ActivityViewContext<InstallActivityAttributes>
|
|
|
|
var body: some View {
|
|
VStack(spacing: 10) {
|
|
HStack(spacing: 12) {
|
|
InstallLeadingIcon(state: context.state, iconSize: 40, symbolFont: .title)
|
|
.frame(width: 40)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(batchTitle(context.state))
|
|
.font(.headline)
|
|
.lineLimit(1)
|
|
Text(batchSubtitle(context.state))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if showsPercent(context.state) {
|
|
Text(verbatim: "\(installPercent(context.state))%")
|
|
.font(.title2.monospacedDigit().weight(.semibold))
|
|
.foregroundStyle(ftBrandPurple)
|
|
}
|
|
}
|
|
|
|
// Full width and under everything, so the lock screen reads the same
|
|
// way the expanded island does rather than being its own design.
|
|
InstallProgressBar(state: context.state, height: 6)
|
|
|
|
if let detail = batchDetail(context.state) {
|
|
HStack(spacing: 6) {
|
|
Text(detail)
|
|
.monospacedDigit()
|
|
Spacer(minLength: 4)
|
|
InstallCountdown(state: context.state)
|
|
}
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|