Skip to content
Open
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
9 changes: 9 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ let package = Package(
name: "Stuff",
platforms: [
.iOS(.v26),
.macOS(.v26),
],
products: [
.library(name: "StuffCore", targets: ["StuffCore"]),
.library(name: "WhereCore", targets: ["WhereCore"]),
.library(name: "WhereData", targets: ["WhereData"]),
.library(name: "WhereUI", targets: ["WhereUI"]),
.library(name: "WhereTesting", targets: ["WhereTesting"]),
],
Expand All @@ -21,6 +23,13 @@ let package = Package(
name: "WhereCore",
path: "Where/WhereCore/Sources",
),
.target(
name: "WhereData",
dependencies: [
.target(name: "WhereCore"),
],
path: "Where/WhereData/Sources",
),
.target(
name: "WhereUI",
dependencies: [
Expand Down
30 changes: 29 additions & 1 deletion Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import ProjectDescription
let destinations: Destinations = [.iPhone, .iPad]
let deployment: DeploymentTargets = .iOS("26.0")

/// Local Swift package (see root `Package.swift`) for StuffCore, WhereCore, WhereUI, and WhereTesting.
/// Local Swift package (see root `Package.swift`) for StuffCore, WhereCore, WhereData, WhereUI, and WhereTesting.
private let stuffPackage = Package.local(path: .relativeToRoot("."))

func unitTests(
Expand Down Expand Up @@ -44,10 +44,20 @@ let project = Project(
infoPlist: .extendingDefault(with: [
"UILaunchScreen": .dictionary([:]),
"UIApplicationSupportsIndirectInputEvents": .boolean(true),
"NSLocationAlwaysAndWhenInUseUsageDescription": .string("Where uses your location in the background to build day-by-day tax records."),
"NSLocationWhenInUseUsageDescription": .string("Where uses your location to classify the states you visit for tax reporting."),
"BGTaskSchedulerPermittedIdentifiers": .array([
.string("com.stuff.where.app-refresh"),
]),
"UIBackgroundModes": .array([
.string("location"),
.string("processing"),
]),
]),
sources: ["Where/Where/Sources/**"],
resources: ["Where/Where/Resources/**"],
dependencies: [
.package(product: "WhereData"),
.package(product: "WhereUI"),
],
),
Expand Down Expand Up @@ -98,11 +108,29 @@ let project = Project(
productDependency: "WhereCore",
sources: ["Where/WhereCore/Tests/**"],
),
unitTests(
name: "WhereDataTests",
bundleIdSuffix: "wheredata",
productDependency: "WhereData",
sources: ["Where/WhereData/Tests/**"],
),
unitTests(
name: "WhereUITests",
bundleIdSuffix: "whereui",
productDependency: "WhereUI",
sources: ["Where/WhereUI/Tests/**"],
),
],
schemes: [
.scheme(
name: "WhereUITests",
shared: true,
buildAction: .buildAction(targets: ["WhereUITests"]),
testAction: .targets(
["WhereUITests"],
configuration: .debug,
options: .options(coverage: true),
),
),
],
)
35 changes: 35 additions & 0 deletions Where/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Where

There **Where** app is an app designed to track how much time to spend in a given location, usually state by state within the US.

Any time per day spent in a state counts entirely towards a day in that state. So it's possible to be in two states on a given day.



## Build Information

Repo-wide build, formatting, and agent-sync rules live in the root [`AGENTS.md`](../AGENTS.md).

## Layout

| Piece | Role | Path |
|-------|------|------|
| **Where** | iOS app (`com.stuff.where`) | [`Where/`](Where/) — [`Sources/`](Where/Sources/), [`Tests/`](Where/Tests/), [`Resources/`](Where/Resources/) |
| **WhereCore** | SPM library (domain / non-UI) | [`WhereCore/Sources/`](WhereCore/Sources/), [`Tests/`](WhereCore/Tests/) |
| **WhereUI** | SPM library (SwiftUI); depends on **WhereCore** | [`WhereUI/Sources/`](WhereUI/Sources/), [`Tests/`](WhereUI/Tests/) |
| **WhereTesting** | SPM test helpers for Where targets | [`WhereTesting/Sources/`](WhereTesting/Sources/) |

The app target depends on the **WhereUI** package product only; **WhereUI** pulls in **WhereCore** via SPM.

## Xcode / Tuist

Targets are declared in the root [`Project.swift`](../Project.swift) and [`Package.swift`](../Package.swift):

- **WhereTests** — tests the app; depends on the **Where** target and **WhereTesting** (no **StuffTestHost**).
- **WhereCoreTests** and **WhereUITests** — hosted unit tests (see `unitTests` in `Project.swift`): **StuffTestHost** + **WhereTesting** + the corresponding package product.

Regenerate the Xcode project from the repo root with `./ide`.

## Conventions

Match the rest of the repo: **Swift Testing** only, bundle IDs under `com.stuff.*`, and shared cross-feature code in [`Shared/`](../Shared/) (**StuffCore**, etc.) rather than duplicating it under `Where/`.
44 changes: 44 additions & 0 deletions Where/Where/Sources/Tracking/AppRefreshCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import BackgroundTasks
import Foundation
import WhereData

@MainActor
final class AppRefreshCoordinator {
static let taskIdentifier = "com.stuff.where.app-refresh"

private let trackingController: BackgroundTrackingController

init(trackingController: BackgroundTrackingController) {
self.trackingController = trackingController
}

func register() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: Self.taskIdentifier,
using: nil,
) { [weak self] task in
guard let refreshTask = task as? BGAppRefreshTask else {
task.setTaskCompleted(success: false)
return
}

self?.handle(refreshTask)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Background task never completed when self is nil

Medium Severity

In the register closure, if self is nil when the background task fires, self?.handle(refreshTask) silently no-ops without ever calling setTaskCompleted on the task. The system expects every delivered BGTask to be completed; failing to do so can cause the OS to throttle or stop scheduling future background refresh tasks for this app.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 60d1a5e. Configure here.

}

func schedule() {
let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier)
request.earliestBeginDate = Date(timeIntervalSinceNow: 4 * 60 * 60)
try? BGTaskScheduler.shared.submit(request)
}

private func handle(_ task: BGAppRefreshTask) {
schedule()
task.expirationHandler = {}

Task {
await trackingController.refreshMonitoring()
task.setTaskCompleted(success: true)
}
}
}
36 changes: 36 additions & 0 deletions Where/Where/Sources/Tracking/AppleJurisdictionResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import CoreLocation
import Foundation
import WhereCore

actor AppleJurisdictionResolver: JurisdictionResolving {
private let geocoder = CLGeocoder()

Check warning on line 6 in Where/Where/Sources/Tracking/AppleJurisdictionResolver.swift

View workflow job for this annotation

GitHub Actions / Build & Test

'CLGeocoder' was deprecated in iOS 26.0: Use MapKit

Check warning on line 6 in Where/Where/Sources/Tracking/AppleJurisdictionResolver.swift

View workflow job for this annotation

GitHub Actions / Build & Test

'CLGeocoder' was deprecated in iOS 26.0: Use MapKit

func jurisdiction(for event: TrackingWakeEvent) async -> TaxJurisdiction {
let location = CLLocation(
latitude: event.latitude,
longitude: event.longitude,
)

do {
let placemarks = try await geocoder.reverseGeocodeLocation(location)

Check warning on line 15 in Where/Where/Sources/Tracking/AppleJurisdictionResolver.swift

View workflow job for this annotation

GitHub Actions / Build & Test

'reverseGeocodeLocation' was deprecated in iOS 26.0: Use MKReverseGeocodingRequest
guard let placemark = placemarks.first else {
return .unknown
}

guard placemark.isoCountryCode == "US" else {
return .unknown
}

guard
let administrativeArea = placemark.administrativeArea,
let state = USState(rawValue: administrativeArea.uppercased())
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverse geocoder always returns unknown for US states

High Severity

CLPlacemark.administrativeArea returns full state names like "California" or "New York", but USState(rawValue:) expects two-letter abbreviations like "CA" or "NY". Calling .uppercased() on the full name produces "CALIFORNIA", which never matches any USState raw value. Every US location will resolve to .unknown, making automatic jurisdiction tracking completely non-functional.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 60d1a5e. Configure here.

else {
return .unknown
}

return .state(state)
} catch {
return .unknown
}
}
}
179 changes: 179 additions & 0 deletions Where/Where/Sources/Tracking/AppleLocationBridge.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import CoreLocation
import Foundation
import WhereCore
import WhereData

@MainActor
final class AppleLocationBridge: NSObject, CLLocationManagerDelegate, LocationAuthorizationProviding, LocationWakeSource {

Check warning on line 7 in Where/Where/Sources/Tracking/AppleLocationBridge.swift

View workflow job for this annotation

GitHub Actions / Build & Test

conformance of 'AppleLocationBridge' to protocol 'CLLocationManagerDelegate' crosses into main actor-isolated code and can cause data races; this is an error in the Swift 6 language mode

Check warning on line 7 in Where/Where/Sources/Tracking/AppleLocationBridge.swift

View workflow job for this annotation

GitHub Actions / Build & Test

conformance of 'AppleLocationBridge' to protocol 'CLLocationManagerDelegate' crosses into main actor-isolated code and can cause data races; this is an error in the Swift 6 language mode
private let locationManager: CLLocationManager
private weak var trackingController: BackgroundTrackingController?
private let regionRadius: CLLocationDistance = 150_000

init(
locationManager: CLLocationManager = CLLocationManager(),
trackingController: BackgroundTrackingController? = nil,
) {
self.locationManager = locationManager
self.trackingController = trackingController
super.init()
self.locationManager.delegate = self
self.locationManager.desiredAccuracy = kCLLocationAccuracyKilometer
self.locationManager.allowsBackgroundLocationUpdates = true
self.locationManager.pausesLocationUpdatesAutomatically = true
}

func attach(trackingController: BackgroundTrackingController) {
self.trackingController = trackingController
}

func currentAuthorizationStatus() async -> TrackingAuthorizationStatus {
map(locationManager.authorizationStatus)
}

func requestAlwaysAuthorization() async {
locationManager.requestAlwaysAuthorization()
}

func startMonitoring(configuration: TrackingMonitoringConfiguration) async {
if configuration.wantsSignificantLocationChanges {
locationManager.startMonitoringSignificantLocationChanges()
}

if configuration.wantsVisitMonitoring {
locationManager.startMonitoringVisits()
}

refreshRegions(configuration: configuration)
}

func refreshRegionMonitoring(configuration: TrackingMonitoringConfiguration) async {
refreshRegions(configuration: configuration)
}

func stopMonitoring() async {
locationManager.stopMonitoringSignificantLocationChanges()
locationManager.stopMonitoringVisits()

for region in locationManager.monitoredRegions {
locationManager.stopMonitoring(for: region)
}
}

func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
guard let trackingController else { return }
let status = map(manager.authorizationStatus)
Task {
await trackingController.handleAuthorizationStatusChange(status)
}
}

func locationManager(_: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let trackingController, let location = locations.last else { return }

let event = TrackingWakeEvent(
timestamp: location.timestamp,
reason: .significantLocationChange,
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude,
horizontalAccuracy: location.horizontalAccuracy,
)

Task {
await trackingController.handleWakeEvent(event)
}
}

func locationManager(_: CLLocationManager, didVisit visit: CLVisit) {
guard let trackingController else { return }

let event = TrackingWakeEvent(
timestamp: visit.arrivalDate == .distantPast ? Date() : visit.arrivalDate,
reason: .visit,
latitude: visit.coordinate.latitude,
longitude: visit.coordinate.longitude,
horizontalAccuracy: visit.horizontalAccuracy,
)

Task {
await trackingController.handleWakeEvent(event)
}
}

func locationManager(_: CLLocationManager, didEnterRegion region: CLRegion) {
handleRegionWake(region: region)
}

func locationManager(_: CLLocationManager, didExitRegion region: CLRegion) {
handleRegionWake(region: region)
}

private func refreshRegions(configuration: TrackingMonitoringConfiguration) {
for region in locationManager.monitoredRegions {
locationManager.stopMonitoring(for: region)
}

for jurisdiction in configuration.jurisdictionRegions.prefix(20) {
guard let region = circularRegion(for: jurisdiction) else { continue }
locationManager.startMonitoring(for: region)
}
}

private func circularRegion(for jurisdiction: TaxJurisdiction) -> CLCircularRegion? {
let coordinate: CLLocationCoordinate2D

switch jurisdiction {
case .state(.california):
coordinate = CLLocationCoordinate2D(latitude: 36.7783, longitude: -119.4179)
case .state(.newYork):
coordinate = CLLocationCoordinate2D(latitude: 42.9538, longitude: -75.5268)
case .state, .unknown:
return nil
}

let region = CLCircularRegion(
center: coordinate,
radius: regionRadius,
identifier: jurisdiction.abbreviation,
)
region.notifyOnEntry = true
region.notifyOnExit = true
return region
}

private func handleRegionWake(region: CLRegion) {
guard
let trackingController,
let circularRegion = region as? CLCircularRegion
else {
return
}

let event = TrackingWakeEvent(
timestamp: Date(),
reason: .regionBoundary,
latitude: circularRegion.center.latitude,
longitude: circularRegion.center.longitude,
)

Task {
await trackingController.handleWakeEvent(event)
}
}

private func map(_ status: CLAuthorizationStatus) -> TrackingAuthorizationStatus {
switch status {
case .notDetermined:
.notDetermined
case .restricted:
.restricted
case .denied:
.denied
case .authorizedWhenInUse:
.authorizedWhenInUse
case .authorizedAlways:
.authorizedAlways
@unknown default:
.restricted
}
}
}
Loading
Loading