From 95f99614b8ecd62a87b352a128681ea890e3d4c4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 22:40:03 -0400 Subject: [PATCH 1/7] Clear Where's unset AccentColor to silence an asset-catalog warning The Where app tints per-region and ships no global accent color, but (unlike RegionViewer and Foreman) never cleared ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME, so actool warned that 'AccentColor' wasn't present in any asset catalog. Co-authored-by: Cursor --- Project.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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( From 1e1092eb403707d34bbc7776954507b5cfec02ce Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 22:40:08 -0400 Subject: [PATCH 2/7] Fix main-actor isolation warning in RegionSummaryCard's Canvas The stamp-paper Canvas renderer runs nonisolated, so reading the main-actor `style.tint` inside it warned. Read the Sendable Color into a local before the closure and capture that instead. Co-authored-by: Cursor --- Where/WhereUI/Sources/Primary/RegionSummaryCard.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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, ) } From 795d42d0b3447023f2015f18f533a4c444b03a54 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 22:40:12 -0400 Subject: [PATCH 3/7] Fix 'mutated after capture' warning in LifecycleStep condition tests LifecycleStep.condition is a @MainActor (hence Sendable) closure, so capturing a local `var flag` and flipping it afterward tripped Swift 6's "mutated after capture by sendable closure". Read the flag through a small MutableFlag reference instead. Co-authored-by: Cursor --- .../Tests/LifecycleStepTests.swift | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) 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 +} From ceaba290194b0fe85a435cce1e9d178b54e81c93 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 22:40:17 -0400 Subject: [PATCH 4/7] Make WhereResetTests' weak binding a let `weakOriginal` is never reassigned, so the WeakMutability warning wants a `let`. Swift 6.2 allows `weak let`, so use it. Co-authored-by: Cursor --- Where/WhereUI/Tests/WhereResetTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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() From 5beda1d3b5ec22ac7c7648fe5e9389cbc0469a0b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 22:40:25 -0400 Subject: [PATCH 5/7] Migrate LocationNamer to MapKit's MKReverseGeocodingRequest CLGeocoder and reverseGeocodeLocation are deprecated in iOS 26. Switch to MKReverseGeocodingRequest. MapKit no longer exposes structured CLPlacemark fields, so PlaceComponents now derives its compact "City, Country" label from MKAddressRepresentations (cityName / regionName). The geocode runs on the main actor because MapKit vends its non-Sendable MKMapItems there; only the resulting String crosses back. Behavior note: a city-less coordinate that previously fell back to "State, Country" now degrades to the country name. Co-authored-by: Cursor --- .../Sources/Shared/LocationNamer.swift | 41 ++++++++++--------- .../WhereUI/Tests/PlaceComponentsTests.swift | 28 +++++-------- 2 files changed, 31 insertions(+), 38 deletions(-) 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) } } From e46148403f30b944ac2be3d528d1ba01af6b0a81 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 22:40:31 -0400 Subject: [PATCH 6/7] Detect background launch via applicationState, not the deprecated location key UIApplication.LaunchOptionsKey.location is deprecated in iOS 26. Derive the LifecycleReason from the launch-time UIApplication.State instead: a .background launch means iOS woke the process headless, which for Where only happens to service a queued CoreLocation event. The CLLocationManager installed in initializePrerequisites still delivers the buffered event. WhereLaunch.lifecycleReason now maps UIApplication.State -> LifecycleReason (covered by pure tests); the AppDelegate test asserts it wired the reason from the live launch state. Also refresh the LifecycleKit README example. Co-authored-by: Cursor --- Shared/LifecycleKit/README.md | 4 ++- Where/Where/Sources/AppDelegate.swift | 8 ++++-- Where/Where/Tests/WhereTests.swift | 28 ++++++++----------- .../WhereUI/Sources/Launch/WhereLaunch.swift | 20 +++++++++---- 4 files changed, 35 insertions(+), 25 deletions(-) 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/Where/Where/Sources/AppDelegate.swift b/Where/Where/Sources/AppDelegate.swift index 35fa2657..acafa951 100644 --- a/Where/Where/Sources/AppDelegate.swift +++ b/Where/Where/Sources/AppDelegate.swift @@ -23,10 +23,12 @@ 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 for a queued + // location event (the deprecated `launchOptions[.location]` check). + let reason = WhereLaunch.lifecycleReason(from: application.applicationState) // `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..bb02d079 100644 --- a/Where/Where/Tests/WhereTests.swift +++ b/Where/Where/Tests/WhereTests.swift @@ -8,14 +8,13 @@ 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 backgroundLaunchStateMapsToLocationRelaunch() { + #expect(WhereLaunch.lifecycleReason(from: .background) == .background(.location)) } - @Test func lifecycleReasonDefaultsToUserForeground() { - #expect(WhereLaunch.lifecycleReason(from: nil) == .userForeground) - #expect(WhereLaunch.lifecycleReason(from: [:]) == .userForeground) + @Test func foregroundLaunchStatesMapToUserForeground() { + #expect(WhereLaunch.lifecycleReason(from: .active) == .userForeground) + #expect(WhereLaunch.lifecycleReason(from: .inactive) == .userForeground) } @Test func appDelegateBuildsLauncherForRootView() { @@ -25,15 +24,12 @@ struct WhereAppTests { // Mirrors `WhereApp.body`: `RootView(model: appDelegate.model, launcher: // appDelegate.launcher)`. _ = RootView(model: delegate.model, launcher: delegate.launcher) - #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) + // The reason now derives from the live launch-time application state + // (the mapping itself is covered above); assert the delegate wired it + // from that state rather than hardcoding a value. + #expect( + delegate.launcher.reason + == WhereLaunch.lifecycleReason(from: UIApplication.shared.applicationState), + ) } } diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index aa8cb24e..1c8ec12a 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -51,13 +51,23 @@ 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. A `.background` launch state means iOS woke the + /// process headless — for Where that only happens to service a queued + /// CoreLocation event (significant-change / visit) — so it maps to + /// `.background(.location)`; an `.active`/`.inactive` launch is a + /// user-visible foreground launch. + /// + /// 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, ) -> LifecycleReason { - launchOptions?[.location] != nil ? .background(.location) : .userForeground + applicationState == .background ? .background(.location) : .userForeground } /// Build the runner for `model`, launching for `reason`. From e95f55a4419683ae2335d9a6d3bd14509d927b4d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 22:58:54 -0400 Subject: [PATCH 7/7] Attribute a background launch to location only when Always-authorized lifecycleReason mapped every headless (.background) launch to .background(.location), which over-claims: iOS only wakes Where for a significant-change/visit event, and that requires Always authorization. Guard the location attribution on CLAuthorizationStatus.authorizedAlways and fall back to .background(.other) otherwise, so a future non-location background cause isn't mislabeled. The cause is informational (both stay on the background step path), so behavior is unchanged today. Tests now cover the authorized/unauthorized background split, and the AppDelegate smoke test asserts the deterministic foreground mapping. Co-authored-by: Cursor --- Where/Where/Sources/AppDelegate.swift | 11 +++-- Where/Where/Tests/WhereTests.swift | 46 ++++++++++++++----- .../WhereUI/Sources/Launch/WhereLaunch.swift | 22 ++++++--- 3 files changed, 59 insertions(+), 20 deletions(-) diff --git a/Where/Where/Sources/AppDelegate.swift b/Where/Where/Sources/AppDelegate.swift index acafa951..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 @@ -26,9 +27,13 @@ final class AppDelegate: NSObject, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil, ) -> Bool { - // A `.background` launch state means iOS woke us headless for a queued - // location event (the deprecated `launchOptions[.location]` check). - let reason = WhereLaunch.lifecycleReason(from: application.applicationState) + // 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 bb02d079..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,13 +9,39 @@ import WhereUI /// from `WhereApp` through `AppDelegate` into `RootView`. @MainActor struct WhereAppTests { - @Test func backgroundLaunchStateMapsToLocationRelaunch() { - #expect(WhereLaunch.lifecycleReason(from: .background) == .background(.location)) + @Test func backgroundLaunchWithAlwaysAuthMapsToLocationRelaunch() { + #expect( + WhereLaunch.lifecycleReason(from: .background, locationAuthorization: .authorizedAlways) + == .background(.location), + ) + } + + @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() { - #expect(WhereLaunch.lifecycleReason(from: .active) == .userForeground) - #expect(WhereLaunch.lifecycleReason(from: .inactive) == .userForeground) + // 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() { @@ -24,12 +51,9 @@ struct WhereAppTests { // Mirrors `WhereApp.body`: `RootView(model: appDelegate.model, launcher: // appDelegate.launcher)`. _ = RootView(model: delegate.model, launcher: delegate.launcher) - // The reason now derives from the live launch-time application state - // (the mapping itself is covered above); assert the delegate wired it - // from that state rather than hardcoding a value. - #expect( - delegate.launcher.reason - == WhereLaunch.lifecycleReason(from: UIApplication.shared.applicationState), - ) + // 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) } } diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index 1c8ec12a..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 @@ -52,11 +53,16 @@ public enum WhereLaunch { private static let logger = WhereLog.channel(.launch) /// Maps the process's launch-time application state to the lifecycle reason - /// the runner consumes. A `.background` launch state means iOS woke the - /// process headless — for Where that only happens to service a queued - /// CoreLocation event (significant-change / visit) — so it maps to - /// `.background(.location)`; an `.active`/`.inactive` launch is a - /// user-visible foreground launch. + /// 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 @@ -66,8 +72,12 @@ public enum WhereLaunch { /// anyone will see UI. public static func lifecycleReason( from applicationState: UIApplication.State, + locationAuthorization: CLAuthorizationStatus, ) -> LifecycleReason { - applicationState == .background ? .background(.location) : .userForeground + guard applicationState == .background else { return .userForeground } + return locationAuthorization == .authorizedAlways + ? .background(.location) + : .background(.other) } /// Build the runner for `model`, launching for `reason`.