Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion Shared/LifecycleKit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
22 changes: 16 additions & 6 deletions Shared/LifecycleKit/Tests/LifecycleStepTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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
}
13 changes: 10 additions & 3 deletions Where/Where/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CoreLocation
import LifecycleKit
import UIKit
import WhereCore
Expand All @@ -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
Expand Down
50 changes: 35 additions & 15 deletions Where/Where/Tests/WhereTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CoreLocation
import LifecycleKit
import Testing
import UIKit
Expand All @@ -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() {
Expand All @@ -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)
}
}
30 changes: 25 additions & 5 deletions Where/WhereUI/Sources/Launch/WhereLaunch.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CoreLocation
import LifecycleKit
import LogKit
import SwiftUI
Expand Down Expand Up @@ -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`.
Expand Down
8 changes: 6 additions & 2 deletions Where/WhereUI/Sources/Primary/RegionSummaryCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
}
Expand Down
41 changes: 21 additions & 20 deletions Where/WhereUI/Sources/Shared/LocationNamer.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}

Expand Down Expand Up @@ -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
}
}
28 changes: 10 additions & 18 deletions Where/WhereUI/Tests/PlaceComponentsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
2 changes: 1 addition & 1 deletion Where/WhereUI/Tests/WhereResetTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading