Skip to content

Commit 31ddea5

Browse files
authored
fix(ios): reconcile significant location monitoring
1 parent 8fffea7 commit 31ddea5

8 files changed

Lines changed: 188 additions & 55 deletions

File tree

apps/.i18n/native-source.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10843,63 +10843,63 @@
1084310843
},
1084410844
{
1084510845
"kind": "conditional-branch",
10846-
"line": 2275,
10846+
"line": 2296,
1084710847
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
1084810848
"source": "Action required",
1084910849
"surface": "apple",
1085010850
"id": "native.apple.0d030e96f7738497"
1085110851
},
1085210852
{
1085310853
"kind": "conditional-branch",
10854-
"line": 2275,
10854+
"line": 2296,
1085510855
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
1085610856
"source": "Approval needed",
1085710857
"surface": "apple",
1085810858
"id": "native.apple.ce6a022159f2fb41"
1085910859
},
1086010860
{
1086110861
"kind": "conditional-branch",
10862-
"line": 2608,
10862+
"line": 2629,
1086310863
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
1086410864
"source": "Connecting…",
1086510865
"surface": "apple",
1086610866
"id": "native.apple.b0d9c1ba8ef7ec19"
1086710867
},
1086810868
{
1086910869
"kind": "conditional-branch",
10870-
"line": 2608,
10870+
"line": 2629,
1087110871
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
1087210872
"source": "Reconnecting…",
1087310873
"surface": "apple",
1087410874
"id": "native.apple.bd98e19b49309284"
1087510875
},
1087610876
{
1087710877
"kind": "conditional-branch",
10878-
"line": 2612,
10878+
"line": 2633,
1087910879
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
1088010880
"source": "Connecting...",
1088110881
"surface": "apple",
1088210882
"id": "native.apple.6504753f3621269e"
1088310883
},
1088410884
{
1088510885
"kind": "conditional-branch",
10886-
"line": 2612,
10886+
"line": 2633,
1088710887
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
1088810888
"source": "Reconnecting...",
1088910889
"surface": "apple",
1089010890
"id": "native.apple.c7fbe2a713d78714"
1089110891
},
1089210892
{
1089310893
"kind": "conditional-branch",
10894-
"line": 2920,
10894+
"line": 2941,
1089510895
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
1089610896
"source": "Connected",
1089710897
"surface": "apple",
1089810898
"id": "native.apple.c0a005f81d588867"
1089910899
},
1090010900
{
1090110901
"kind": "conditional-branch",
10902-
"line": 2920,
10902+
"line": 2941,
1090310903
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
1090410904
"source": "Offline",
1090510905
"surface": "apple",

apps/ios/Sources/Location/LocationService.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
1414
private var authWaitID: UUID?
1515
private var authContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
1616
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
17+
private var authorizationChangeHandler: (@MainActor @Sendable (CLAuthorizationStatus) -> Void)?
1718
private var significantLocationCallback: (@Sendable (CLLocation) -> Void)?
1819
private var isMonitoringSignificantChanges = false
1920

@@ -121,6 +122,12 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
121122
self.manager.allowsBackgroundLocationUpdates = enabled
122123
}
123124

125+
func setAuthorizationChangeHandler(
126+
_ handler: @escaping @MainActor @Sendable (CLAuthorizationStatus) -> Void)
127+
{
128+
self.authorizationChangeHandler = handler
129+
}
130+
124131
func stopMonitoringSignificantLocationChanges() {
125132
self.significantLocationCallback = nil
126133
self.isMonitoringSignificantChanges = false
@@ -130,7 +137,7 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
130137
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
131138
let status = manager.authorizationStatus
132139
Task { @MainActor in
133-
self.reconcileBackgroundMonitoringAuthorization(status)
140+
self.authorizationChangeHandler?(status)
134141
guard let waitID = self.authWaitID else { return }
135142
self.finishAuthorizationWait(waitID: waitID, status: status)
136143
}

apps/ios/Sources/Model/NodeAppModel.swift

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import CoreLocation
12
import Observation
23
import OpenClawChatUI
34
import OpenClawKit
@@ -373,6 +374,12 @@ final class NodeAppModel {
373374
self.refreshLastShareEventFromRelay()
374375
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
375376
self.setTalkEnabled(talkEnabled)
377+
self.locationService.setAuthorizationChangeHandler { [weak self] status in
378+
guard let self else { return }
379+
self.reconcileSignificantLocationMonitoring(
380+
mode: self.locationMode(),
381+
authorizationStatus: status)
382+
}
376383

377384
// Wire up deep links from canvas taps
378385
self.screen.onDeepLink = { [weak self] url in
@@ -781,27 +788,41 @@ final class NodeAppModel {
781788

782789
func requestLocationPermissions(mode: OpenClawLocationMode) async -> Bool {
783790
guard mode != .off else {
784-
self.locationService.setBackgroundLocationUpdatesEnabled(false)
785-
self.locationService.stopMonitoringSignificantLocationChanges()
791+
self.reconcileSignificantLocationMonitoring(
792+
mode: mode,
793+
authorizationStatus: self.locationService.authorizationStatus())
786794
return true
787795
}
788796
let status = await self.locationService.ensureAuthorization(mode: mode)
789797
switch status {
790798
case .authorizedAlways:
791-
self.locationService.setBackgroundLocationUpdatesEnabled(mode == .always)
792-
if mode != .always {
793-
self.locationService.stopMonitoringSignificantLocationChanges()
794-
}
799+
self.reconcileSignificantLocationMonitoring(mode: mode, authorizationStatus: status)
795800
return true
796801
case .authorizedWhenInUse:
797-
self.locationService.setBackgroundLocationUpdatesEnabled(false)
798-
self.locationService.stopMonitoringSignificantLocationChanges()
802+
self.reconcileSignificantLocationMonitoring(mode: mode, authorizationStatus: status)
799803
return true
800804
default:
805+
self.reconcileSignificantLocationMonitoring(mode: mode, authorizationStatus: status)
806+
return false
807+
}
808+
}
809+
810+
private func reconcileSignificantLocationMonitoring(
811+
mode: OpenClawLocationMode,
812+
authorizationStatus: CLAuthorizationStatus)
813+
{
814+
guard mode == .always, authorizationStatus == .authorizedAlways else {
801815
self.locationService.setBackgroundLocationUpdatesEnabled(false)
802816
self.locationService.stopMonitoringSignificantLocationChanges()
803-
return false
817+
return
804818
}
819+
SignificantLocationMonitor.startIfNeeded(
820+
locationService: self.locationService,
821+
locationMode: mode,
822+
gateway: self.nodeGateway,
823+
beforeSend: { [weak self] in
824+
await self?.handleSignificantLocationWakeIfNeeded()
825+
})
805826
}
806827

807828
private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex"

apps/ios/Sources/Services/NodeServiceProtocols.swift

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,12 @@ protocol LocationServicing: Sendable {
3232
maxAgeMs: Int?,
3333
timeoutMs: Int?) async throws -> CLLocation
3434
func setBackgroundLocationUpdatesEnabled(_ enabled: Bool)
35+
func setAuthorizationChangeHandler(
36+
_ handler: @escaping @MainActor @Sendable (CLAuthorizationStatus) -> Void)
3537
func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void)
3638
func stopMonitoringSignificantLocationChanges()
3739
}
3840

39-
extension LocationServicing {
40-
func reconcileBackgroundMonitoringAuthorization(_ status: CLAuthorizationStatus) {
41-
guard status != .authorizedAlways else { return }
42-
self.setBackgroundLocationUpdatesEnabled(false)
43-
self.stopMonitoringSignificantLocationChanges()
44-
}
45-
}
46-
4741
@MainActor
4842
protocol DeviceStatusServicing: Sendable {
4943
func status() async throws -> OpenClawDeviceStatusPayload

apps/ios/Tests/LocationPermissionSummaryTests.swift

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ import Testing
110110
#expect(locationService.stopMonitoringCallCount == 1)
111111
}
112112

113-
@MainActor @Test func `always mode keeps significant location monitoring eligible when always is granted`() async {
113+
@MainActor @Test func `always mode starts significant location monitoring when always is granted`() async {
114114
let locationService = MockLocationService(authorizationStatus: .authorizedAlways)
115115
let appModel = NodeAppModel(locationService: locationService)
116116

@@ -119,6 +119,7 @@ import Testing
119119
#expect(granted)
120120
#expect(locationService.backgroundUpdatesEnabled == true)
121121
#expect(locationService.stopMonitoringCallCount == 0)
122+
#expect(locationService.startMonitoringCallCount == 1)
122123
}
123124

124125
@MainActor @Test func `always mode remains selected when ios only grants while using`() async {
@@ -132,29 +133,38 @@ import Testing
132133
#expect(locationService.stopMonitoringCallCount == 1)
133134
}
134135

135-
@MainActor @Test func `authorization downgrade stops significant location monitoring`() {
136+
@MainActor @Test func `external downgrade and always restoration reconcile significant monitoring`() {
137+
let defaultsKey = "location.enabledMode"
138+
let previous = UserDefaults.standard.object(forKey: defaultsKey)
139+
defer {
140+
if let previous {
141+
UserDefaults.standard.set(previous, forKey: defaultsKey)
142+
} else {
143+
UserDefaults.standard.removeObject(forKey: defaultsKey)
144+
}
145+
}
146+
UserDefaults.standard.set(OpenClawLocationMode.always.rawValue, forKey: defaultsKey)
136147
let locationService = MockLocationService(authorizationStatus: .authorizedAlways)
148+
let appModel = NodeAppModel(locationService: locationService)
137149

138-
locationService.reconcileBackgroundMonitoringAuthorization(.authorizedWhenInUse)
150+
withExtendedLifetime(appModel) {
151+
locationService.simulateAuthorizationChange(.authorizedAlways)
152+
locationService.simulateAuthorizationChange(.authorizedWhenInUse)
153+
locationService.simulateAuthorizationChange(.authorizedAlways)
154+
}
139155

140-
#expect(locationService.backgroundUpdatesEnabled == false)
156+
#expect(locationService.backgroundUpdatesEnabled == true)
157+
#expect(locationService.startMonitoringCallCount == 2)
141158
#expect(locationService.stopMonitoringCallCount == 1)
142159
}
143-
144-
@MainActor @Test func `authorized always keeps significant location monitoring active`() {
145-
let locationService = MockLocationService(authorizationStatus: .authorizedAlways)
146-
147-
locationService.reconcileBackgroundMonitoringAuthorization(.authorizedAlways)
148-
149-
#expect(locationService.backgroundUpdatesEnabled == nil)
150-
#expect(locationService.stopMonitoringCallCount == 0)
151-
}
152160
}
153161

154162
@MainActor
155163
private final class MockLocationService: LocationServicing, @unchecked Sendable {
156-
private let status: CLAuthorizationStatus
164+
private var status: CLAuthorizationStatus
165+
private var authorizationChangeHandler: (@MainActor @Sendable (CLAuthorizationStatus) -> Void)?
157166
var backgroundUpdatesEnabled: Bool?
167+
var startMonitoringCallCount = 0
158168
var stopMonitoringCallCount = 0
159169

160170
init(authorizationStatus: CLAuthorizationStatus) {
@@ -191,11 +201,23 @@ private final class MockLocationService: LocationServicing, @unchecked Sendable
191201
self.backgroundUpdatesEnabled = enabled
192202
}
193203

204+
func setAuthorizationChangeHandler(
205+
_ handler: @escaping @MainActor @Sendable (CLAuthorizationStatus) -> Void)
206+
{
207+
self.authorizationChangeHandler = handler
208+
}
209+
194210
func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void) {
195211
_ = onUpdate
212+
self.startMonitoringCallCount += 1
196213
}
197214

198215
func stopMonitoringSignificantLocationChanges() {
199216
self.stopMonitoringCallCount += 1
200217
}
218+
219+
func simulateAuthorizationChange(_ status: CLAuthorizationStatus) {
220+
self.status = status
221+
self.authorizationChangeHandler?(status)
222+
}
201223
}

src/daemon/launchd-plist.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS = 20;
1111
export const LAUNCH_AGENT_UMASK_DECIMAL = 0o077;
1212
export const LAUNCH_AGENT_PROCESS_TYPE = "Interactive";
1313
export const LAUNCH_AGENT_STDIN_PATH = "/dev/null";
14+
export const LAUNCH_AGENT_ENV_WRAPPER_SHELL = "/bin/sh";
1415

1516
const plistEscape = (value: string): string =>
1617
value
@@ -68,12 +69,11 @@ function resolveSiblingGeneratedEnvFilePath(
6869
return `${envFilePath.slice(0, serviceEnvDirEnd)}/${label}.env`;
6970
}
7071

71-
function isGeneratedEnvWrapperArgs(
72-
programArguments: string[],
72+
function isExpectedGeneratedEnvWrapperPair(
73+
wrapperPath: string | undefined,
74+
envFilePath: string | undefined,
7375
options?: ReadLaunchAgentProgramArgumentsOptions,
7476
): boolean {
75-
const wrapperPath = programArguments[0];
76-
const envFilePath = programArguments[1];
7777
if (!wrapperPath || !envFilePath) {
7878
return false;
7979
}
@@ -102,14 +102,34 @@ function isGeneratedEnvWrapperArgs(
102102
);
103103
}
104104

105+
function resolveGeneratedEnvWrapperLayout(
106+
programArguments: string[],
107+
options?: ReadLaunchAgentProgramArgumentsOptions,
108+
): { envFilePath: string; commandStartIndex: number } | null {
109+
if (programArguments[0] === LAUNCH_AGENT_ENV_WRAPPER_SHELL) {
110+
const wrapperPath = programArguments[1];
111+
const envFilePath = programArguments[2];
112+
if (isExpectedGeneratedEnvWrapperPair(wrapperPath, envFilePath, options) && envFilePath) {
113+
return { envFilePath, commandStartIndex: 3 };
114+
}
115+
}
116+
const wrapperPath = programArguments[0];
117+
const envFilePath = programArguments[1];
118+
if (isExpectedGeneratedEnvWrapperPair(wrapperPath, envFilePath, options) && envFilePath) {
119+
return { envFilePath, commandStartIndex: 2 };
120+
}
121+
return null;
122+
}
123+
105124
async function readLaunchAgentEnvironmentFile(
106125
programArguments: string[],
107126
options?: ReadLaunchAgentProgramArgumentsOptions,
108127
): Promise<Record<string, string>> {
109-
const envFilePath = programArguments[1];
110-
if (!isGeneratedEnvWrapperArgs(programArguments, options) || !envFilePath) {
128+
const layout = resolveGeneratedEnvWrapperLayout(programArguments, options);
129+
if (!layout) {
111130
return {};
112131
}
132+
const envFilePath = layout.envFilePath;
113133
let content = "";
114134
const candidateEnvFilePaths = Array.from(
115135
new Set(
@@ -157,10 +177,11 @@ function unwrapGeneratedEnvWrapperArgs(
157177
programArguments: string[],
158178
options?: ReadLaunchAgentProgramArgumentsOptions,
159179
): string[] {
160-
if (!isGeneratedEnvWrapperArgs(programArguments, options)) {
180+
const layout = resolveGeneratedEnvWrapperLayout(programArguments, options);
181+
if (!layout) {
161182
return programArguments;
162183
}
163-
return programArguments.slice(2);
184+
return programArguments.slice(layout.commandStartIndex);
164185
}
165186

166187
const renderEnvDict = (env: Record<string, string | undefined> | undefined): string => {

0 commit comments

Comments
 (0)