diff --git a/Project.swift b/Project.swift index 9537406f..1f1c1c38 100644 --- a/Project.swift +++ b/Project.swift @@ -105,9 +105,12 @@ let project = Project( // auto-write the `CFBundleAlternateIcons` plist entries, so the asset // catalog itself is the source of truth for which alternate icons exist // (the `./icons` script just adds/removes sets — no names list to keep - // in sync here). The primary stays `AppIcon`. + // in sync here). The primary stays `AppIcon`. Where ships no custom + // global accent color (it tints per-region in SwiftUI), so clear the + // name actool otherwise looks for — an unset `AccentColor` warns. settings: .settings(base: [ "ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS": "YES", + "ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME": "", ]), ), .target( diff --git a/Shared/LifecycleKit/README.md b/Shared/LifecycleKit/README.md index 167f76d8..68244098 100644 --- a/Shared/LifecycleKit/README.md +++ b/Shared/LifecycleKit/README.md @@ -197,7 +197,9 @@ launch works before any window exists) and drive it: ```swift // App delegate / launch site: let runner = LifecycleRunner( - reason: launchOptions?[.location] != nil ? .background(.location) : .userForeground, + // A `.background` launch state means iOS woke us headless (e.g. for a + // queued location event); an active/inactive launch is user-visible. + reason: application.applicationState == .background ? .background(.location) : .userForeground, initializePrerequisites: { deps.installLocationManager() }, // synchronous, must-exist-now wiring sequence: LifecycleSteps { LifecycleStep.work("open-store") { _ in try await deps.openStore() } diff --git a/Shared/LifecycleKit/Tests/LifecycleStepTests.swift b/Shared/LifecycleKit/Tests/LifecycleStepTests.swift index ad6d092c..e68c5e21 100644 --- a/Shared/LifecycleKit/Tests/LifecycleStepTests.swift +++ b/Shared/LifecycleKit/Tests/LifecycleStepTests.swift @@ -63,18 +63,18 @@ struct LifecycleStepConfigurationTests { } @Test func workConditionGatesTheStep() async { - var flag = false - let step = LifecycleStep.work("a", condition: { flag }) { _ in } + let flag = MutableFlag() + let step = LifecycleStep.work("a", condition: { flag.isOn }) { _ in } #expect(await step.condition() == false) - flag = true + flag.isOn = true #expect(await step.condition() == true) } @Test func initConditionGatesTheStep() async { - var flag = false - let step = LifecycleStep(id: "a", condition: { flag }) { _ in } + let flag = MutableFlag() + let step = LifecycleStep(id: "a", condition: { flag.isOn }) { _ in } #expect(await step.condition() == false) - flag = true + flag.isOn = true #expect(await step.condition() == true) } @@ -92,3 +92,13 @@ struct LifecycleStepConfigurationTests { #expect(step.presentation != nil) } } + +/// A mutable reference the `@MainActor` condition closures can flip *after* +/// they've been captured. `LifecycleStep.condition` is a `@MainActor` (and thus +/// `Sendable`) closure, so capturing and later mutating a plain local `var` +/// trips Swift 6's "mutated after capture by sendable closure"; reading through +/// a reference doesn't. +@MainActor +private final class MutableFlag { + var isOn = false +} diff --git a/Where/Where/Sources/AppDelegate.swift b/Where/Where/Sources/AppDelegate.swift index 35fa2657..650c4b99 100644 --- a/Where/Where/Sources/AppDelegate.swift +++ b/Where/Where/Sources/AppDelegate.swift @@ -1,3 +1,4 @@ +import CoreLocation import LifecycleKit import UIKit import WhereCore @@ -23,10 +24,16 @@ final class AppDelegate: NSObject, UIApplicationDelegate { private(set) var launcher: LifecycleRunner! func application( - _: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil, + _ application: UIApplication, + didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil, ) -> Bool { - let reason = WhereLaunch.lifecycleReason(from: launchOptions) + // A `.background` launch state means iOS woke us headless (replacing the + // deprecated `launchOptions[.location]` check); authorization tells us + // whether that could have been the location wake we register for. + let reason = WhereLaunch.lifecycleReason( + from: application.applicationState, + locationAuthorization: CLLocationManager().authorizationStatus, + ) // `initializePrerequisites` installs the CLLocationManager synchronously // (so a queued location event isn't lost) and registers the // foreground-notification presenter; the rest (store open, etc.) runs as diff --git a/Where/Where/Tests/WhereTests.swift b/Where/Where/Tests/WhereTests.swift index 81b6ec51..a247bb69 100644 --- a/Where/Where/Tests/WhereTests.swift +++ b/Where/Where/Tests/WhereTests.swift @@ -1,3 +1,4 @@ +import CoreLocation import LifecycleKit import Testing import UIKit @@ -8,14 +9,39 @@ import WhereUI /// from `WhereApp` through `AppDelegate` into `RootView`. @MainActor struct WhereAppTests { - @Test func lifecycleReasonMapsLocationLaunchOption() { - let options: [UIApplication.LaunchOptionsKey: Any] = [.location: true] - #expect(WhereLaunch.lifecycleReason(from: options) == .background(.location)) + @Test func backgroundLaunchWithAlwaysAuthMapsToLocationRelaunch() { + #expect( + WhereLaunch.lifecycleReason(from: .background, locationAuthorization: .authorizedAlways) + == .background(.location), + ) } - @Test func lifecycleReasonDefaultsToUserForeground() { - #expect(WhereLaunch.lifecycleReason(from: nil) == .userForeground) - #expect(WhereLaunch.lifecycleReason(from: [:]) == .userForeground) + @Test func backgroundLaunchWithoutAlwaysAuthIsNotAttributedToLocation() { + // Where can only be woken headless by an Always-authorized location + // event, so any other authorization is an honest `.other` background. + for status in [ + CLAuthorizationStatus.authorizedWhenInUse, + .denied, + .restricted, + .notDetermined, + ] { + #expect( + WhereLaunch.lifecycleReason(from: .background, locationAuthorization: status) + == .background(.other), + ) + } + } + + @Test func foregroundLaunchStatesMapToUserForeground() { + // A foreground launch is user-visible regardless of authorization. + #expect( + WhereLaunch.lifecycleReason(from: .active, locationAuthorization: .notDetermined) + == .userForeground, + ) + #expect( + WhereLaunch.lifecycleReason(from: .inactive, locationAuthorization: .authorizedAlways) + == .userForeground, + ) } @Test func appDelegateBuildsLauncherForRootView() { @@ -25,15 +51,9 @@ struct WhereAppTests { // Mirrors `WhereApp.body`: `RootView(model: appDelegate.model, launcher: // appDelegate.launcher)`. _ = RootView(model: delegate.model, launcher: delegate.launcher) + // The hosted test app runs in the foreground, so the launch always maps + // to `.userForeground` (the background/authorization branches are + // covered by the pure-mapping tests above). #expect(delegate.launcher.reason == .userForeground) } - - @Test func appDelegateMapsBackgroundLocationRelaunch() { - let delegate = AppDelegate() - let options: [UIApplication.LaunchOptionsKey: Any] = [.location: true] - _ = delegate.application(UIApplication.shared, didFinishLaunchingWithOptions: options) - - #expect(delegate.launcher.reason == .background(.location)) - _ = RootView(model: delegate.model, launcher: delegate.launcher) - } } diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index aa8cb24e..8b149eee 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -1,3 +1,4 @@ +import CoreLocation import LifecycleKit import LogKit import SwiftUI @@ -51,13 +52,32 @@ public enum LaunchStepID: String { public enum WhereLaunch { private static let logger = WhereLog.channel(.launch) - /// Maps UIKit launch options to the lifecycle reason the runner consumes. - /// A CoreLocation key means iOS relaunched the process headless to service - /// a location event; everything else is treated as a user foreground launch. + /// Maps the process's launch-time application state to the lifecycle reason + /// the runner consumes. An `.active`/`.inactive` launch is a user-visible + /// foreground launch; a `.background` launch state means iOS woke the + /// process headless. + /// + /// The only thing that wakes Where headless is a CoreLocation + /// significant-change / visit event, which requires *Always* authorization + /// — so a background launch is attributed to `.location` only when that + /// authorization is present, and to `.other` otherwise rather than claiming + /// a location wake the process couldn't have received. (The cause is + /// informational; both keep the launch on the background step path.) + /// + /// This replaces inspecting the `UIApplication.LaunchOptionsKey.location` + /// launch option, deprecated in iOS 26 in favor of handling the location + /// events through the `CLLocationManagerDelegate` after scene connection: + /// the `CLLocationManager` installed in `initializePrerequisites` still + /// delivers the buffered event, and the launch state alone tells us whether + /// anyone will see UI. public static func lifecycleReason( - from launchOptions: [UIApplication.LaunchOptionsKey: Any]?, + from applicationState: UIApplication.State, + locationAuthorization: CLAuthorizationStatus, ) -> LifecycleReason { - launchOptions?[.location] != nil ? .background(.location) : .userForeground + guard applicationState == .background else { return .userForeground } + return locationAuthorization == .authorizedAlways + ? .background(.location) + : .background(.other) } /// Build the runner for `model`, launching for `reason`. diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index 602a168f..36622f38 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -69,7 +69,11 @@ struct RegionSummaryCard: View { /// glyph watermarked into the corner, the way a passport page is printed /// beneath its stamps. private var stampPaper: some View { - ZStack { + // Read the main-actor `style.tint` once here so the nonisolated + // `Canvas` renderer closure captures the `Sendable` `Color` rather than + // reaching back into main-actor state from a nonisolated context. + let tint = style.tint + return ZStack { Canvas { context, size in let wobble: CGFloat = compact ? 2 : 3 let lineWidth: CGFloat = compact ? 2 : 3 @@ -90,7 +94,7 @@ struct RegionSummaryCard: View { ) context.stroke( Path(ellipseIn: rect), - with: .color(style.tint.opacity(opacity)), + with: .color(tint.opacity(opacity)), lineWidth: lineWidth, ) } diff --git a/Where/WhereUI/Sources/Shared/LocationNamer.swift b/Where/WhereUI/Sources/Shared/LocationNamer.swift index 245742d9..dfbb5320 100644 --- a/Where/WhereUI/Sources/Shared/LocationNamer.swift +++ b/Where/WhereUI/Sources/Shared/LocationNamer.swift @@ -1,33 +1,31 @@ import CoreLocation import Foundation +import MapKit import WhereCore /// The human-readable pieces of a reverse-geocoded coordinate, and the rule /// for turning them into a single "City, Country" label. Split out from the /// geocoder so the formatting is a pure, testable value (constructing real -/// `CLPlacemark`s is impractical in unit tests). +/// MapKit results is impractical in unit tests). struct PlaceComponents: Equatable { - var locality: String? - var area: String? + var city: String? var country: String? - /// A compact label: the most specific available place, qualified by - /// country. Prefers the city (`locality`), falling back to the state / - /// province (`area`); returns `nil` only when nothing is known. + /// A compact label: the city qualified by country. Falls back to whichever + /// single piece is known; returns `nil` only when neither is. var displayName: String? { - guard let primary = locality ?? area else { return country } - guard let country else { return primary } - return "\(primary), \(country)" + guard let city else { return country } + guard let country else { return city } + return "\(city), \(country)" } } extension PlaceComponents { - init(placemark: CLPlacemark) { - self.init( - locality: placemark.locality, - area: placemark.administrativeArea, - country: placemark.country, - ) + /// iOS 26's MapKit geocoder folds the old `CLPlacemark` fields into + /// formatted strings; `cityName` and `regionName` (the country) are the two + /// pieces this compact teaser needs. + init(_ representations: MKAddressRepresentations) { + self.init(city: representations.cityName, country: representations.regionName) } } @@ -75,14 +73,17 @@ actor LocationNamer { return name } - /// Creates a throwaway `CLGeocoder` per call (sidestepping its - /// one-request-at-a-time constraint) and maps the first placemark to a - /// label. Returns `nil` on any failure. + /// Reverse-geocodes `coordinate` with MapKit and maps the first result to a + /// label, returning `nil` on any failure. Runs on the main actor because + /// `MKReverseGeocodingRequest` vends its (non-`Sendable`) `MKMapItem`s + /// there; only the resulting `String` crosses back. + @MainActor private static func reverseGeocode(_ coordinate: Coordinate) async -> String? { let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) + guard let request = MKReverseGeocodingRequest(location: location) else { return nil } guard - let placemark = try? await CLGeocoder().reverseGeocodeLocation(location).first + let representations = try? await request.mapItems.first?.addressRepresentations else { return nil } - return PlaceComponents(placemark: placemark).displayName + return PlaceComponents(representations).displayName } } diff --git a/Where/WhereUI/Tests/PlaceComponentsTests.swift b/Where/WhereUI/Tests/PlaceComponentsTests.swift index d9f77d1b..e0f0fb8b 100644 --- a/Where/WhereUI/Tests/PlaceComponentsTests.swift +++ b/Where/WhereUI/Tests/PlaceComponentsTests.swift @@ -2,34 +2,26 @@ import Testing @testable import WhereUI /// Verifies the pure place-name formatting that `LocationNamer` layers over the -/// system geocoder: city preferred, qualified by country, with sensible -/// fallbacks. +/// system geocoder: city qualified by country, with sensible fallbacks when +/// only one piece is known. struct PlaceComponentsTests { @Test func cityIsQualifiedByCountry() { - let place = PlaceComponents( - locality: "Paris", - area: "Île-de-France", - country: "France", - ) + let place = PlaceComponents(city: "Paris", country: "France") #expect(place.displayName == "Paris, France") } - @Test func fallsBackToAreaWhenCityIsUnknown() { - let place = PlaceComponents( - locality: nil, - area: "California", - country: "United States", - ) - #expect(place.displayName == "California, United States") + @Test func fallsBackToCountryWhenCityIsUnknown() { + let place = PlaceComponents(city: nil, country: "United States") + #expect(place.displayName == "United States") } - @Test func countryOnlyWhenThatIsAllWeKnow() { - let place = PlaceComponents(locality: nil, area: nil, country: "Japan") - #expect(place.displayName == "Japan") + @Test func fallsBackToCityWhenCountryIsUnknown() { + let place = PlaceComponents(city: "Reykjavík", country: nil) + #expect(place.displayName == "Reykjavík") } @Test func nothingKnownResolvesToNil() { - let place = PlaceComponents(locality: nil, area: nil, country: nil) + let place = PlaceComponents(city: nil, country: nil) #expect(place.displayName == nil) } } diff --git a/Where/WhereUI/Tests/WhereResetTests.swift b/Where/WhereUI/Tests/WhereResetTests.swift index 29431ddd..054ed4c2 100644 --- a/Where/WhereUI/Tests/WhereResetTests.swift +++ b/Where/WhereUI/Tests/WhereResetTests.swift @@ -146,7 +146,7 @@ struct WhereResetTests { let preferences = makePreferences() let (model, source) = try makeModelWithSource(status: .always, preferences: preferences) model.completeOnboarding() - weak var weakOriginal = model.session + weak let weakOriginal = model.session let launcher = WhereLaunch.makeLauncher(model: model, reason: .userForeground) await launcher.run()