Skip to content

Commit 4fe86f0

Browse files
fix(ios): settle silent location requests
1 parent 092222d commit 4fe86f0

2 files changed

Lines changed: 125 additions & 24 deletions

File tree

packages/ios/src/index.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,15 @@ describe('Craft iOS builder', () => {
111111
expect(swift).not.toContain('loadFileURL')
112112
expect(swift).toContain("craft.contractVersion = '1.0.0'")
113113
expect(swift).toContain('craft.location = {')
114+
expect(swift).toContain("error.name = 'GeolocationPositionError'")
115+
expect(swift).toContain('error.code = 3')
116+
expect(swift).toContain("action: 'getCurrentPosition'")
117+
expect(swift).toContain('enableHighAccuracy: options.enableHighAccuracy === true')
118+
expect(swift).toContain('private var singleLocationTimeoutWorkItem: DispatchWorkItem?')
119+
expect(swift).toContain('manager.delegate = self')
120+
expect(swift).toContain('let alreadyGranted = status == .authorizedAlways')
121+
expect(swift).toContain('Date().timeIntervalSince(cachedLocation.timestamp) * 1000 <= maximumAge')
122+
expect(swift).toContain('code: "LOCATION_TIMEOUT"')
114123
expect(swift.match(/private var pendingCallbackId/g)?.length).toBe(1)
115124
expect(plist).toContain('NSLocationWhenInUseUsageDescription')
116125
expect(plist).toContain('<string>wildloop</string>')

packages/ios/templates/CraftApp.swift

Lines changed: 116 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,9 @@ struct CraftWebView: UIViewRepresentable {
481481
// Location
482482
private var locationManager: CLLocationManager?
483483
private var singleLocationCallbackId: String?
484+
private var singleLocationTimeoutWorkItem: DispatchWorkItem?
484485
private var locationPermissionCallbackId: String?
486+
private var locationPermissionRequiresAlways = false
485487
private var isWatchingLocation = false
486488
private var isRecordingLocation = false
487489
private var isLocationRecordingPaused = false
@@ -764,7 +766,7 @@ struct CraftWebView: UIViewRepresentable {
764766
// Geolocation
765767
case "getCurrentPosition":
766768
if config.enableGeolocation {
767-
getCurrentPosition(callbackId: callbackId)
769+
getCurrentPosition(body: body, callbackId: callbackId)
768770
} else {
769771
rejectCallback(callbackId, error: "Geolocation is disabled", code: "CAPABILITY_DISABLED")
770772
}
@@ -2056,12 +2058,44 @@ struct CraftWebView: UIViewRepresentable {
20562058
20572059
// Geolocation
20582060
geolocation: {
2059-
getCurrentPosition: function() {
2061+
getCurrentPosition: function(options) {
2062+
options = options || {};
20602063
var self = window.craft;
20612064
var id = 'cb_' + (++self._callbackId);
2062-
window.webkit.messageHandlers.craft.postMessage({action: 'getCurrentPosition', callbackId: id});
2065+
var requestedTimeout = Number(options.timeout);
2066+
var timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout >= 0
2067+
? requestedTimeout
2068+
: 30000;
20632069
return new Promise(function(resolve, reject) {
2064-
self._callbacks[id] = {resolve: resolve, reject: reject};
2070+
var timeout;
2071+
self._callbacks[id] = {
2072+
resolve: function(value) { clearTimeout(timeout); resolve(value); },
2073+
reject: function(error) { clearTimeout(timeout); reject(error); }
2074+
};
2075+
timeout = setTimeout(function() {
2076+
if (!self._callbacks[id]) return;
2077+
delete self._callbacks[id];
2078+
var error = new Error('Location request timed out after ' + timeoutMs + 'ms');
2079+
error.name = 'GeolocationPositionError';
2080+
error.code = 3;
2081+
error.bridge = true;
2082+
reject(error);
2083+
}, timeoutMs);
2084+
try {
2085+
window.webkit.messageHandlers.craft.postMessage({
2086+
action: 'getCurrentPosition',
2087+
callbackId: id,
2088+
enableHighAccuracy: options.enableHighAccuracy === true,
2089+
timeout: timeoutMs,
2090+
maximumAge: Number.isFinite(Number(options.maximumAge))
2091+
? Math.max(0, Number(options.maximumAge))
2092+
: 0
2093+
});
2094+
} catch (error) {
2095+
clearTimeout(timeout);
2096+
delete self._callbacks[id];
2097+
reject(error);
2098+
}
20652099
});
20662100
},
20672101
watchPosition: function(callback) {
@@ -3313,11 +3347,27 @@ struct CraftWebView: UIViewRepresentable {
33133347
}
33143348
switch permission {
33153349
case "location", "locationAlways":
3350+
guard let manager = locationManager else {
3351+
rejectCallback(callbackId, error: "Geolocation is disabled", code: "CAPABILITY_DISABLED")
3352+
return
3353+
}
3354+
manager.delegate = self
3355+
let requiresAlways = permission == "locationAlways" || config.enableBackgroundLocation
3356+
let status = manager.authorizationStatus
3357+
let alreadyGranted = status == .authorizedAlways || (!requiresAlways && status == .authorizedWhenInUse)
3358+
if alreadyGranted || status == .denied || status == .restricted {
3359+
resolveCallback(callbackId, result: permissionStatus(alreadyGranted, denied: status == .denied, restricted: status == .restricted))
3360+
return
3361+
}
3362+
if let pendingCallbackId = locationPermissionCallbackId {
3363+
rejectCallback(pendingCallbackId, error: "A newer location permission request replaced this request", code: "REQUEST_REPLACED")
3364+
}
33163365
locationPermissionCallbackId = callbackId
3317-
if permission == "locationAlways" || config.enableBackgroundLocation {
3318-
locationManager?.requestAlwaysAuthorization()
3366+
locationPermissionRequiresAlways = requiresAlways
3367+
if requiresAlways {
3368+
manager.requestAlwaysAuthorization()
33193369
} else {
3320-
locationManager?.requestWhenInUseAuthorization()
3370+
manager.requestWhenInUseAuthorization()
33213371
}
33223372
case "camera":
33233373
AVCaptureDevice.requestAccess(for: .video) { granted in
@@ -3363,11 +3413,59 @@ struct CraftWebView: UIViewRepresentable {
33633413
}
33643414

33653415
// MARK: - Geolocation
3366-
private func getCurrentPosition(callbackId: String?) {
3367-
locationManager?.delegate = self
3416+
private func getCurrentPosition(body: [String: Any], callbackId: String?) {
3417+
guard let manager = locationManager else {
3418+
rejectCallback(callbackId, error: "Geolocation is disabled", code: "CAPABILITY_DISABLED")
3419+
return
3420+
}
3421+
manager.delegate = self
3422+
manager.desiredAccuracy = body["enableHighAccuracy"] as? Bool == true
3423+
? kCLLocationAccuracyBest
3424+
: kCLLocationAccuracyHundredMeters
3425+
3426+
if let pendingCallbackId = singleLocationCallbackId {
3427+
finishSingleLocationRequest()
3428+
rejectCallback(pendingCallbackId, error: "A newer location request replaced this request", code: "POSITION_UNAVAILABLE")
3429+
}
33683430
singleLocationCallbackId = callbackId
3431+
let maximumAge = max(0, (body["maximumAge"] as? NSNumber)?.doubleValue ?? 0)
3432+
if maximumAge > 0,
3433+
let cachedLocation = manager.location,
3434+
Date().timeIntervalSince(cachedLocation.timestamp) * 1000 <= maximumAge {
3435+
finishSingleLocationRequest()
3436+
resolveCallback(callbackId, result: locationData(cachedLocation))
3437+
return
3438+
}
3439+
3440+
let timeoutMs = max(0, (body["timeout"] as? NSNumber)?.doubleValue ?? 30_000)
3441+
let timeoutWorkItem = DispatchWorkItem { [weak self] in
3442+
guard let self, self.singleLocationCallbackId == callbackId else { return }
3443+
self.finishSingleLocationRequest()
3444+
self.rejectCallback(callbackId, error: "Location request timed out", code: "LOCATION_TIMEOUT")
3445+
}
3446+
singleLocationTimeoutWorkItem = timeoutWorkItem
3447+
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(min(timeoutMs + 100, Double(Int.max)))), execute: timeoutWorkItem)
33693448
requestLocationAuthorization()
3370-
locationManager?.requestLocation()
3449+
manager.requestLocation()
3450+
}
3451+
3452+
private func finishSingleLocationRequest() {
3453+
singleLocationTimeoutWorkItem?.cancel()
3454+
singleLocationTimeoutWorkItem = nil
3455+
singleLocationCallbackId = nil
3456+
}
3457+
3458+
private func locationData(_ location: CLLocation) -> [String: Any] {
3459+
[
3460+
"latitude": location.coordinate.latitude,
3461+
"longitude": location.coordinate.longitude,
3462+
"altitude": location.altitude,
3463+
"accuracy": location.horizontalAccuracy,
3464+
"altitudeAccuracy": location.verticalAccuracy,
3465+
"heading": location.course,
3466+
"speed": location.speed,
3467+
"timestamp": location.timestamp.timeIntervalSince1970 * 1000
3468+
]
33713469
}
33723470

33733471
private func watchPosition(callbackId: String?) {
@@ -3555,20 +3653,11 @@ struct CraftWebView: UIViewRepresentable {
35553653

35563654
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
35573655
guard let location = locations.last else { return }
3558-
let data: [String: Any] = [
3559-
"latitude": location.coordinate.latitude,
3560-
"longitude": location.coordinate.longitude,
3561-
"altitude": location.altitude,
3562-
"accuracy": location.horizontalAccuracy,
3563-
"altitudeAccuracy": location.verticalAccuracy,
3564-
"heading": location.course,
3565-
"speed": location.speed,
3566-
"timestamp": location.timestamp.timeIntervalSince1970 * 1000
3567-
]
3656+
let data = locationData(location)
35683657

35693658
if let callbackId = singleLocationCallbackId {
3659+
finishSingleLocationRequest()
35703660
resolveCallback(callbackId, result: data)
3571-
singleLocationCallbackId = nil
35723661
}
35733662

35743663
appendRecordedLocation(data)
@@ -3578,18 +3667,21 @@ struct CraftWebView: UIViewRepresentable {
35783667
}
35793668

35803669
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
3581-
rejectCallback(singleLocationCallbackId, error: error.localizedDescription)
3582-
singleLocationCallbackId = nil
3670+
let callbackId = singleLocationCallbackId
3671+
finishSingleLocationRequest()
3672+
rejectCallback(callbackId, error: error.localizedDescription, code: "POSITION_UNAVAILABLE")
35833673
sendToWeb("craftLocationError", data: ["message": error.localizedDescription])
35843674
}
35853675

35863676
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
35873677
guard let callbackId = locationPermissionCallbackId else { return }
35883678
let status = manager.authorizationStatus
35893679
if status == .notDetermined { return }
3590-
let granted = status == .authorizedAlways || status == .authorizedWhenInUse
3680+
if locationPermissionRequiresAlways && status == .authorizedWhenInUse { return }
3681+
let granted = status == .authorizedAlways || (!locationPermissionRequiresAlways && status == .authorizedWhenInUse)
35913682
resolveCallback(callbackId, result: permissionStatus(granted, denied: status == .denied, restricted: status == .restricted))
35923683
locationPermissionCallbackId = nil
3684+
locationPermissionRequiresAlways = false
35933685
}
35943686

35953687
// MARK: - Memory Usage (for Profiling)

0 commit comments

Comments
 (0)