craft-native 0.0.395 → 0.0.408
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/android/templates/CraftBridge.kt.template +2 -0
- package/dist/android/templates/CraftNative.kt.template +7 -0
- package/dist/android/templates/CraftNativeFlatList.kt.template +7 -0
- package/dist/android/templates/CraftNativeMapGoogle.kt.template +3 -0
- package/dist/android/templates/CraftNativeMapLibre.kt.template +3 -0
- package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +9 -0
- package/dist/android/templates/CraftNativeServices.kt.template +2 -1
- package/dist/android/templates/CraftNativeShell.kt.template +12 -2
- package/dist/android/templates/LocationRecordingService.kt.template +125 -6
- package/dist/android/templates/MainActivityNative.kt.template +338 -21
- package/dist/api/mobile.d.ts +59 -2
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +16 -0
- package/dist/index.cjs +54 -7
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +54 -7
- package/dist/ios/src/index.d.ts +7 -0
- package/dist/ios/templates/CraftApp.swift +268 -12
- package/dist/ios/templates/CraftHybrid.swift +1 -0
- package/dist/ios/templates/CraftNativeActions.swift +3 -1
- package/dist/ios/templates/CraftNativeFlatList.swift +4 -0
- package/dist/ios/templates/CraftNativeMutation.swift +27 -2
- package/dist/ios/templates/CraftNativeScreen.swift +293 -44
- package/dist/ios/templates/CraftNativeUI.swift +89 -0
- package/dist/ios/templates/testing/DriverTests.swift.template +17 -1
- package/dist/macos/src/index.d.ts +14 -3
- package/dist/macos/src/index.js +124 -27
- package/dist/macos/src/toolchain.d.ts +56 -0
- package/dist/macos/templates/CraftMacApplication.swift +54 -9
- package/dist/macos/templates/CraftMacRenderer.swift +376 -18
- package/dist/macos/templates/CraftMacRuntime.swift +114 -1
- package/dist/macos/templates/CraftMacWebView.swift +416 -0
- package/dist/macos/templates/CraftNativeMutation.swift +27 -2
- package/dist/mobile.js +53 -6
- package/package.json +1 -1
|
@@ -288,6 +288,7 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
|
|
|
288
288
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
|
289
289
|
let config = CraftConfig.bundled()
|
|
290
290
|
CraftInterfaceStyleController.configure(config)
|
|
291
|
+
CraftChromeFonts.configure(config)
|
|
291
292
|
// First, so a crash anywhere in the rest of launch is reported too.
|
|
292
293
|
CraftCrashReporter.shared.start(config)
|
|
293
294
|
UNUserNotificationCenter.current().delegate = self
|
|
@@ -1791,6 +1792,12 @@ struct CraftConfig: Codable {
|
|
|
1791
1792
|
var backgroundColor: String = "#1a1a2e"
|
|
1792
1793
|
/// Default native control and selected-tab tint, independent of page chrome.
|
|
1793
1794
|
var tintColor: String? = nil
|
|
1795
|
+
/// CSS font stacks for the app's own chrome (navigation titles, tab and
|
|
1796
|
+
/// bar button labels): the first family packaged with the app wins, and a
|
|
1797
|
+
/// generic one (`system-ui`, `sans-serif`) keeps the system face.
|
|
1798
|
+
var fontFamily: String? = nil
|
|
1799
|
+
/// Navigation titles; `fontFamily` when unset.
|
|
1800
|
+
var headingFontFamily: String? = nil
|
|
1794
1801
|
/// The background while the phone is in Dark Mode; the light one if unset.
|
|
1795
1802
|
var backgroundColorDark: String? = nil
|
|
1796
1803
|
/// Edge-swipe back and forward through the page's history.
|
|
@@ -2331,6 +2338,28 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2331
2338
|
private var isLocationRecordingPaused = false
|
|
2332
2339
|
private var locationRecordingId: String?
|
|
2333
2340
|
private var locationRecordingStartedAt: TimeInterval?
|
|
2341
|
+
/// The power mode the recording was asked for: `default`, `saver` (a
|
|
2342
|
+
/// long recording, an ultra, that trades fixes nobody needs for
|
|
2343
|
+
/// battery) or `auto` (a saver while the device itself is saving
|
|
2344
|
+
/// power). Set by `startRecording`, changed by `setRecordingPowerMode`
|
|
2345
|
+
/// and kept in the state file. See `applyLocationRecordingPower`.
|
|
2346
|
+
private var locationRecordingPowerMode = "default"
|
|
2347
|
+
/// What the recorder is using: the mode, with `auto` resolved from the
|
|
2348
|
+
/// device. Never true outside a recording.
|
|
2349
|
+
private var locationRecordingPowerSaver: Bool {
|
|
2350
|
+
guard isRecordingLocation else { return false }
|
|
2351
|
+
switch locationRecordingPowerMode {
|
|
2352
|
+
case "saver": return true
|
|
2353
|
+
case "auto": return Self.deviceIsSavingPower
|
|
2354
|
+
default: return false
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
/// The saver setting `applyLocationRecordingPower` last gave the manager.
|
|
2358
|
+
private var locationRecordingAppliedSaver = false
|
|
2359
|
+
private var locationRecordingPowerObservers: [NSObjectProtocol] = []
|
|
2360
|
+
/// Recent fix speeds while saving power; steady ones relax the accuracy.
|
|
2361
|
+
private var locationRecordingRecentSpeeds: [Double] = []
|
|
2362
|
+
private var locationRecordingSteady = false
|
|
2334
2363
|
private let recordingDirectory: URL?
|
|
2335
2364
|
private var locationRecordingRestoreError: Error?
|
|
2336
2365
|
|
|
@@ -2580,7 +2609,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2580
2609
|
/// `craft_ios_deliver_result` instead of evaluating JavaScript here —
|
|
2581
2610
|
/// one reply path, owned by whichever side received the page's message.
|
|
2582
2611
|
func dispatch(action: String, body: [String: Any], callbackId: String?) {
|
|
2583
|
-
if ["startLocationRecording", "pauseLocationRecording", "resumeLocationRecording", "stopLocationRecording", "getLocationRecordingState", "readLocationRecording", "acknowledgeLocationRecording"].contains(action),
|
|
2612
|
+
if ["startLocationRecording", "setLocationRecordingPowerMode", "pauseLocationRecording", "resumeLocationRecording", "stopLocationRecording", "getLocationRecordingState", "readLocationRecording", "acknowledgeLocationRecording"].contains(action),
|
|
2584
2613
|
locationRecordingRestoreError != nil {
|
|
2585
2614
|
rejectCallback(callbackId, error: "Unable to restore recording data", code: "RECORDING_STORAGE_ERROR")
|
|
2586
2615
|
return
|
|
@@ -2799,6 +2828,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2799
2828
|
} else {
|
|
2800
2829
|
rejectCallback(callbackId, error: "Geolocation is disabled", code: "CAPABILITY_DISABLED")
|
|
2801
2830
|
}
|
|
2831
|
+
case "setLocationRecordingPowerMode":
|
|
2832
|
+
if !CraftZigRuntime.supportsRecordingPowerMode {
|
|
2833
|
+
rejectCallback(callbackId, error: "The linked runtime's recorder has no power modes", code: "RECORDING_POWER_MODE_UNSUPPORTED")
|
|
2834
|
+
} else if config.enableGeolocation {
|
|
2835
|
+
if requireLocationRecordingOwner(body, callbackId: callbackId) { setLocationRecordingPowerMode(body: body, callbackId: callbackId) }
|
|
2836
|
+
} else {
|
|
2837
|
+
rejectCallback(callbackId, error: "Geolocation is disabled", code: "CAPABILITY_DISABLED")
|
|
2838
|
+
}
|
|
2802
2839
|
case "pauseLocationRecording":
|
|
2803
2840
|
if requireLocationRecordingOwner(body, callbackId: callbackId) { pauseLocationRecording(callbackId: callbackId) }
|
|
2804
2841
|
case "resumeLocationRecording":
|
|
@@ -2808,7 +2845,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2808
2845
|
case "getLocationRecordingState":
|
|
2809
2846
|
getLocationRecordingState(callbackId: callbackId)
|
|
2810
2847
|
case "readLocationRecording":
|
|
2811
|
-
if requireLocationRecordingOwner(body, callbackId: callbackId) { readLocationRecording(callbackId: callbackId) }
|
|
2848
|
+
if requireLocationRecordingOwner(body, callbackId: callbackId) { readLocationRecording(body: body, callbackId: callbackId) }
|
|
2812
2849
|
case "acknowledgeLocationRecording":
|
|
2813
2850
|
_ = archivedLocationAction(action, body: body, callbackId: callbackId)
|
|
2814
2851
|
// App Badge
|
|
@@ -4169,6 +4206,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
4169
4206
|
crypto: true,
|
|
4170
4207
|
locationRecordingOwnership: \(config.enableGeolocation && CraftZigRuntime.supportsRecordingOwnership),
|
|
4171
4208
|
locationRecordingIntentIdentity: \(config.enableGeolocation && CraftZigRuntime.supportsRecordingIntentIdentity),
|
|
4209
|
+
locationRecordingPowerMode: \(config.enableGeolocation && CraftZigRuntime.supportsRecordingPowerMode),
|
|
4172
4210
|
backgroundLocation: \(config.enableBackgroundLocation),
|
|
4173
4211
|
healthKit: \(config.enableHealthKit),
|
|
4174
4212
|
liveActivities: \(config.enableLiveActivities),
|
|
@@ -5772,7 +5810,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5772
5810
|
};
|
|
5773
5811
|
craft._invoke = function(action, payload) {
|
|
5774
5812
|
var recordingOptions = action === 'heartRate' && payload && ['startRecording', 'pauseRecording', 'resumeRecording', 'stopRecording', 'readRecording', 'getRecordingState', 'acknowledgeRecording'].indexOf(payload.method) !== -1
|
|
5775
|
-
? payload.options : ['startLocationRecording', 'pauseLocationRecording', 'resumeLocationRecording', 'stopLocationRecording', 'readLocationRecording', 'getLocationRecordingState', 'acknowledgeLocationRecording'].indexOf(action) !== -1 ? payload : null;
|
|
5813
|
+
? payload.options : ['startLocationRecording', 'setLocationRecordingPowerMode', 'pauseLocationRecording', 'resumeLocationRecording', 'stopLocationRecording', 'readLocationRecording', 'getLocationRecordingState', 'acknowledgeLocationRecording'].indexOf(action) !== -1 ? payload : null;
|
|
5776
5814
|
if (recordingOptions && Object.prototype.hasOwnProperty.call(recordingOptions, 'recordingId') &&
|
|
5777
5815
|
(typeof recordingOptions.recordingId !== 'string' || !recordingOptions.recordingId.trim())) {
|
|
5778
5816
|
var invalidStart = new Error('recordingId must be a nonempty recording ID');
|
|
@@ -5950,6 +5988,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5950
5988
|
void stopLocationWatchIfUnused().catch(function() {});
|
|
5951
5989
|
},
|
|
5952
5990
|
startRecording: function(options) { return craft._invoke('startLocationRecording', options || {}); },
|
|
5991
|
+
setRecordingPowerMode: function(options) { return craft._invoke('setLocationRecordingPowerMode', options || {}); },
|
|
5953
5992
|
pauseRecording: function(options) { return craft._invoke('pauseLocationRecording', options || {}); },
|
|
5954
5993
|
resumeRecording: function(options) { return craft._invoke('resumeLocationRecording', options || {}); },
|
|
5955
5994
|
stopRecording: function(options) { return craft._invoke('stopLocationRecording', options || {}); },
|
|
@@ -6906,9 +6945,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6906
6945
|
return
|
|
6907
6946
|
}
|
|
6908
6947
|
manager.delegate = self
|
|
6909
|
-
|
|
6910
|
-
|
|
6911
|
-
|
|
6948
|
+
// A recording in progress owns the shared manager's accuracy: a
|
|
6949
|
+
// one-off fix must neither coarsen it nor undo its power saving.
|
|
6950
|
+
if !(isRecordingLocation && !isLocationRecordingPaused) {
|
|
6951
|
+
manager.desiredAccuracy = body["enableHighAccuracy"] as? Bool == true
|
|
6952
|
+
? kCLLocationAccuracyBest
|
|
6953
|
+
: kCLLocationAccuracyHundredMeters
|
|
6954
|
+
}
|
|
6912
6955
|
|
|
6913
6956
|
if let pendingCallbackId = singleLocationCallbackId {
|
|
6914
6957
|
finishSingleLocationRequest()
|
|
@@ -6974,7 +7017,113 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6974
7017
|
singleLocationCallbackId = nil
|
|
6975
7018
|
singleLocationAcquisitionStarted = false
|
|
6976
7019
|
singleLocationAuthorizationRequested = false
|
|
6977
|
-
locationManager?.desiredAccuracy =
|
|
7020
|
+
locationManager?.desiredAccuracy = recordingDesiredAccuracy
|
|
7021
|
+
}
|
|
7022
|
+
|
|
7023
|
+
/// Fixes at least this far apart, metres, while saving power. A
|
|
7024
|
+
/// filter of d metres spaces fixes d/v seconds apart at v m/s; the
|
|
7025
|
+
/// route counts fixes more than 30 s apart as a pause, so d must stay
|
|
7026
|
+
/// under 30 s at the slowest speed that still counts as moving (0.2
|
|
7027
|
+
/// m/s on the server): 5 m is 25 s at 0.2 m/s.
|
|
7028
|
+
static let powerSaverDistanceFilterM: CLLocationDistance = 5
|
|
7029
|
+
|
|
7030
|
+
/// Best, except while a power-saving recording holds a steady speed.
|
|
7031
|
+
private var recordingDesiredAccuracy: CLLocationAccuracy {
|
|
7032
|
+
locationRecordingPowerSaver && locationRecordingSteady ? kCLLocationAccuracyNearestTenMeters : kCLLocationAccuracyBest
|
|
7033
|
+
}
|
|
7034
|
+
|
|
7035
|
+
/// The manager's filter and accuracy for the recording's power mode.
|
|
7036
|
+
private func applyLocationRecordingPower() {
|
|
7037
|
+
let saver = locationRecordingPowerSaver
|
|
7038
|
+
locationRecordingAppliedSaver = saver
|
|
7039
|
+
locationManager?.distanceFilter = saver ? Self.powerSaverDistanceFilterM : kCLDistanceFilterNone
|
|
7040
|
+
if singleLocationCallbackId == nil || (isRecordingLocation && !isLocationRecordingPaused) {
|
|
7041
|
+
locationManager?.desiredAccuracy = recordingDesiredAccuracy
|
|
7042
|
+
}
|
|
7043
|
+
}
|
|
7044
|
+
|
|
7045
|
+
/// A new mode, a resume or a change of the device's power state starts
|
|
7046
|
+
/// the steady-pace history over: speeds seen under the old setting say
|
|
7047
|
+
/// nothing about the new one.
|
|
7048
|
+
private func resetLocationRecordingPace() {
|
|
7049
|
+
locationRecordingRecentSpeeds.removeAll()
|
|
7050
|
+
locationRecordingSteady = false
|
|
7051
|
+
}
|
|
7052
|
+
|
|
7053
|
+
/// Under this charge, unplugged, `auto` saves power.
|
|
7054
|
+
static let autoPowerSaverBatteryLevel: Float = 0.2
|
|
7055
|
+
|
|
7056
|
+
/// The device wants power saved: Low Power Mode, or a battery under
|
|
7057
|
+
/// 20% that is not charging (`unplugged`; an unknown state, as in the
|
|
7058
|
+
/// simulator, does not count).
|
|
7059
|
+
static var deviceIsSavingPower: Bool {
|
|
7060
|
+
if ProcessInfo.processInfo.isLowPowerModeEnabled { return true }
|
|
7061
|
+
let device = UIDevice.current
|
|
7062
|
+
return device.isBatteryMonitoringEnabled && device.batteryState == .unplugged
|
|
7063
|
+
&& device.batteryLevel >= 0 && device.batteryLevel < autoPowerSaverBatteryLevel
|
|
7064
|
+
}
|
|
7065
|
+
|
|
7066
|
+
/// `auto` follows the platform's own change notifications, only while a
|
|
7067
|
+
/// recording runs in that mode, so no other recording pays for them.
|
|
7068
|
+
private func updateLocationRecordingPowerObservers() {
|
|
7069
|
+
let wanted = isRecordingLocation && locationRecordingPowerMode == "auto"
|
|
7070
|
+
guard wanted != !locationRecordingPowerObservers.isEmpty else { return }
|
|
7071
|
+
let center = NotificationCenter.default
|
|
7072
|
+
if !wanted {
|
|
7073
|
+
locationRecordingPowerObservers.forEach { center.removeObserver($0) }
|
|
7074
|
+
locationRecordingPowerObservers.removeAll()
|
|
7075
|
+
UIDevice.current.isBatteryMonitoringEnabled = false
|
|
7076
|
+
return
|
|
7077
|
+
}
|
|
7078
|
+
UIDevice.current.isBatteryMonitoringEnabled = true
|
|
7079
|
+
let names: [Notification.Name] = [
|
|
7080
|
+
.NSProcessInfoPowerStateDidChange,
|
|
7081
|
+
UIDevice.batteryLevelDidChangeNotification,
|
|
7082
|
+
UIDevice.batteryStateDidChangeNotification,
|
|
7083
|
+
]
|
|
7084
|
+
locationRecordingPowerObservers = names.map { name in
|
|
7085
|
+
center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
|
7086
|
+
self?.locationRecordingDeviceStateChanged()
|
|
7087
|
+
}
|
|
7088
|
+
}
|
|
7089
|
+
}
|
|
7090
|
+
|
|
7091
|
+
/// The device's power state changed: re-resolve `auto`, and only touch
|
|
7092
|
+
/// the manager when the answer is different.
|
|
7093
|
+
private func locationRecordingDeviceStateChanged() {
|
|
7094
|
+
guard isRecordingLocation, locationRecordingPowerMode == "auto",
|
|
7095
|
+
locationRecordingPowerSaver != locationRecordingAppliedSaver else { return }
|
|
7096
|
+
resetLocationRecordingPace()
|
|
7097
|
+
applyLocationRecordingPower()
|
|
7098
|
+
}
|
|
7099
|
+
|
|
7100
|
+
static let recordingPowerModes = ["default", "saver", "auto"]
|
|
7101
|
+
|
|
7102
|
+
/// A mode the page may ask for; nil for anything else, `nil` included.
|
|
7103
|
+
private static func recordingPowerMode(_ value: Any?) -> String? {
|
|
7104
|
+
guard let mode = value as? String, recordingPowerModes.contains(mode) else { return nil }
|
|
7105
|
+
return mode
|
|
7106
|
+
}
|
|
7107
|
+
|
|
7108
|
+
/// Steady: the last ten fixes within a fifth of their mean speed, at
|
|
7109
|
+
/// least a slow walk, each accurate to 10 m. Anything else (a change
|
|
7110
|
+
/// of pace, a weak signal) goes straight back to Best.
|
|
7111
|
+
private func noteLocationRecordingSpeed(_ location: CLLocation) {
|
|
7112
|
+
guard locationRecordingPowerSaver else { return }
|
|
7113
|
+
let usable = location.speed >= 0 && location.horizontalAccuracy >= 0 && location.horizontalAccuracy <= 10
|
|
7114
|
+
if usable {
|
|
7115
|
+
locationRecordingRecentSpeeds.append(location.speed)
|
|
7116
|
+
if locationRecordingRecentSpeeds.count > 10 { locationRecordingRecentSpeeds.removeFirst(locationRecordingRecentSpeeds.count - 10) }
|
|
7117
|
+
} else if location.horizontalAccuracy > 20 || location.horizontalAccuracy < 0 {
|
|
7118
|
+
locationRecordingRecentSpeeds.removeAll()
|
|
7119
|
+
}
|
|
7120
|
+
let speeds = locationRecordingRecentSpeeds
|
|
7121
|
+
let mean = speeds.isEmpty ? 0 : speeds.reduce(0, +) / Double(speeds.count)
|
|
7122
|
+
let steady = speeds.count >= 10 && mean >= 0.8 && speeds.allSatisfy { abs($0 - mean) <= mean * 0.2 }
|
|
7123
|
+
if steady != locationRecordingSteady {
|
|
7124
|
+
locationRecordingSteady = steady
|
|
7125
|
+
applyLocationRecordingPower()
|
|
7126
|
+
}
|
|
6978
7127
|
}
|
|
6979
7128
|
|
|
6980
7129
|
private func locationData(_ location: CLLocation) -> [String: Any] {
|
|
@@ -7046,7 +7195,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
7046
7195
|
}
|
|
7047
7196
|
|
|
7048
7197
|
private func persistLocationRecordingState() throws {
|
|
7049
|
-
let state: [String: Any] = ["active": isRecordingLocation, "paused": isLocationRecordingPaused, "id": locationRecordingId ?? NSNull(), "startedAt": locationRecordingStartedAt ?? NSNull()]
|
|
7198
|
+
let state: [String: Any] = ["active": isRecordingLocation, "paused": isLocationRecordingPaused, "id": locationRecordingId ?? NSNull(), "startedAt": locationRecordingStartedAt ?? NSNull(), "powerMode": locationRecordingPowerMode]
|
|
7050
7199
|
try CraftRecordingArchive.writeAtomically(JSONSerialization.data(withJSONObject: state), locationRecordingStateURL)
|
|
7051
7200
|
}
|
|
7052
7201
|
|
|
@@ -7067,6 +7216,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
7067
7216
|
isLocationRecordingPaused = state["paused"] as? Bool ?? false
|
|
7068
7217
|
locationRecordingId = state["id"] as? String
|
|
7069
7218
|
locationRecordingStartedAt = state["startedAt"] as? TimeInterval
|
|
7219
|
+
locationRecordingPowerMode = Self.recordingPowerMode(state["powerMode"]) ?? "default"
|
|
7070
7220
|
if let id = locationRecordingId, try CraftRecordingArchive.isAcknowledged(recordingBaseDirectory, "location", id) {
|
|
7071
7221
|
try CraftRecordingArchive.acknowledge(recordingBaseDirectory, "location", id)
|
|
7072
7222
|
if FileManager.default.fileExists(atPath: locationRecordingTrackURL.path) { try FileManager.default.removeItem(at: locationRecordingTrackURL) }
|
|
@@ -7076,9 +7226,11 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
7076
7226
|
isLocationRecordingPaused = false
|
|
7077
7227
|
try persistLocationRecordingState()
|
|
7078
7228
|
}
|
|
7229
|
+
updateLocationRecordingPowerObservers()
|
|
7079
7230
|
if isRecordingLocation && !isLocationRecordingPaused {
|
|
7080
7231
|
locationManager?.delegate = self
|
|
7081
7232
|
configureBackgroundLocationIfNeeded()
|
|
7233
|
+
applyLocationRecordingPower()
|
|
7082
7234
|
locationManager?.startUpdatingLocation()
|
|
7083
7235
|
}
|
|
7084
7236
|
|
|
@@ -7161,6 +7313,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
7161
7313
|
rejectCallback(callbackId, error: "A location recording is already active", code: "RECORDING_ALREADY_ACTIVE")
|
|
7162
7314
|
return
|
|
7163
7315
|
}
|
|
7316
|
+
var powerMode = "default"
|
|
7317
|
+
if let requested = body["powerMode"], !(requested is NSNull) {
|
|
7318
|
+
guard let mode = Self.recordingPowerMode(requested) else {
|
|
7319
|
+
rejectCallback(callbackId, error: "powerMode must be 'default', 'saver' or 'auto'", code: "INVALID_ARGUMENT")
|
|
7320
|
+
return
|
|
7321
|
+
}
|
|
7322
|
+
powerMode = mode
|
|
7323
|
+
}
|
|
7164
7324
|
let directory = locationRecordingTrackURL.deletingLastPathComponent()
|
|
7165
7325
|
let id: String
|
|
7166
7326
|
do {
|
|
@@ -7176,20 +7336,24 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
7176
7336
|
rejectCallback(callbackId, error: "Unable to create the location recording", code: "RECORDING_STORAGE_ERROR")
|
|
7177
7337
|
return
|
|
7178
7338
|
}
|
|
7179
|
-
let previous = (locationRecordingId, locationRecordingStartedAt, isRecordingLocation, isLocationRecordingPaused)
|
|
7339
|
+
let previous = (locationRecordingId, locationRecordingStartedAt, isRecordingLocation, isLocationRecordingPaused, locationRecordingPowerMode)
|
|
7180
7340
|
locationRecordingId = id
|
|
7181
7341
|
locationRecordingStartedAt = Date().timeIntervalSince1970 * 1000
|
|
7182
7342
|
isRecordingLocation = true
|
|
7183
7343
|
isLocationRecordingPaused = false
|
|
7344
|
+
locationRecordingPowerMode = powerMode
|
|
7345
|
+
resetLocationRecordingPace()
|
|
7184
7346
|
do { try persistLocationRecordingState() }
|
|
7185
7347
|
catch {
|
|
7186
|
-
(locationRecordingId, locationRecordingStartedAt, isRecordingLocation, isLocationRecordingPaused) = previous
|
|
7348
|
+
(locationRecordingId, locationRecordingStartedAt, isRecordingLocation, isLocationRecordingPaused, locationRecordingPowerMode) = previous
|
|
7187
7349
|
rejectCallback(callbackId, error: "Unable to persist the location recording", code: "RECORDING_STORAGE_ERROR")
|
|
7188
7350
|
return
|
|
7189
7351
|
}
|
|
7190
7352
|
locationManager?.delegate = self
|
|
7191
7353
|
requestLocationAuthorization()
|
|
7192
7354
|
configureBackgroundLocationIfNeeded()
|
|
7355
|
+
updateLocationRecordingPowerObservers()
|
|
7356
|
+
applyLocationRecordingPower()
|
|
7193
7357
|
locationManager?.startUpdatingLocation()
|
|
7194
7358
|
resolveCallback(callbackId, result: locationRecordingSummary())
|
|
7195
7359
|
|
|
@@ -7198,6 +7362,31 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
7198
7362
|
#endif
|
|
7199
7363
|
}
|
|
7200
7364
|
|
|
7365
|
+
/// Change the running recording's power mode. The state file carries it,
|
|
7366
|
+
/// so a relaunch restores what was last asked for.
|
|
7367
|
+
private func setLocationRecordingPowerMode(body: [String: Any], callbackId: String?) {
|
|
7368
|
+
guard let mode = Self.recordingPowerMode(body["powerMode"]) else {
|
|
7369
|
+
rejectCallback(callbackId, error: "powerMode must be 'default', 'saver' or 'auto'", code: "INVALID_ARGUMENT")
|
|
7370
|
+
return
|
|
7371
|
+
}
|
|
7372
|
+
guard isRecordingLocation else {
|
|
7373
|
+
rejectCallback(callbackId, error: "No active location recording", code: "NO_ACTIVE_RECORDING")
|
|
7374
|
+
return
|
|
7375
|
+
}
|
|
7376
|
+
let previous = locationRecordingPowerMode
|
|
7377
|
+
locationRecordingPowerMode = mode
|
|
7378
|
+
do { try persistLocationRecordingState() }
|
|
7379
|
+
catch {
|
|
7380
|
+
locationRecordingPowerMode = previous
|
|
7381
|
+
rejectCallback(callbackId, error: "Unable to persist the location recording", code: "RECORDING_STORAGE_ERROR")
|
|
7382
|
+
return
|
|
7383
|
+
}
|
|
7384
|
+
resetLocationRecordingPace()
|
|
7385
|
+
updateLocationRecordingPowerObservers()
|
|
7386
|
+
applyLocationRecordingPower()
|
|
7387
|
+
resolveCallback(callbackId, result: locationRecordingSummary())
|
|
7388
|
+
}
|
|
7389
|
+
|
|
7201
7390
|
private func pauseLocationRecording(callbackId: String?) {
|
|
7202
7391
|
guard isRecordingLocation else {
|
|
7203
7392
|
rejectCallback(callbackId, error: "No active location recording", code: "NO_ACTIVE_RECORDING")
|
|
@@ -7235,6 +7424,11 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
7235
7424
|
locationManager?.delegate = self
|
|
7236
7425
|
requestLocationAuthorization()
|
|
7237
7426
|
configureBackgroundLocationIfNeeded()
|
|
7427
|
+
// The manager forgets nothing across a pause, but the pace seen
|
|
7428
|
+
// before it says nothing about the pace after it, and a recording
|
|
7429
|
+
// restored while paused never had its filter set at all.
|
|
7430
|
+
resetLocationRecordingPace()
|
|
7431
|
+
applyLocationRecordingPower()
|
|
7238
7432
|
locationManager?.startUpdatingLocation()
|
|
7239
7433
|
resolveCallback(callbackId, result: locationRecordingSummary())
|
|
7240
7434
|
|
|
@@ -7255,6 +7449,9 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
7255
7449
|
locationManager?.stopUpdatingLocation()
|
|
7256
7450
|
locationManager?.allowsBackgroundLocationUpdates = false
|
|
7257
7451
|
}
|
|
7452
|
+
resetLocationRecordingPace()
|
|
7453
|
+
updateLocationRecordingPowerObservers()
|
|
7454
|
+
applyLocationRecordingPower()
|
|
7258
7455
|
var summary = locationRecordingSummary()
|
|
7259
7456
|
summary["locations"] = locations
|
|
7260
7457
|
resolveCallback(callbackId, result: summary)
|
|
@@ -7267,8 +7464,18 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
7267
7464
|
resolveCallback(callbackId, result: summary)
|
|
7268
7465
|
}
|
|
7269
7466
|
|
|
7270
|
-
|
|
7271
|
-
|
|
7467
|
+
/// The recording's fixes, or with `since` (epoch ms) only those recorded
|
|
7468
|
+
/// after it. A screen showing a long run reads every few seconds, and a
|
|
7469
|
+
/// 20-hour track is ~72,000 lines: `since` reads the file's tail, not all
|
|
7470
|
+
/// of it, so each read costs what the new fixes do.
|
|
7471
|
+
private func readLocationRecording(body: [String: Any], callbackId: String?) {
|
|
7472
|
+
do {
|
|
7473
|
+
if let since = (body["since"] as? NSNumber)?.doubleValue, since.isFinite {
|
|
7474
|
+
resolveCallback(callbackId, result: try loadRecordedLocationsStrict(after: since))
|
|
7475
|
+
} else {
|
|
7476
|
+
resolveCallback(callbackId, result: try loadRecordedLocationsStrict())
|
|
7477
|
+
}
|
|
7478
|
+
}
|
|
7272
7479
|
catch { rejectCallback(callbackId, error: "Unable to read the location recording", code: "RECORDING_STORAGE_ERROR") }
|
|
7273
7480
|
}
|
|
7274
7481
|
|
|
@@ -7278,6 +7485,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
7278
7485
|
"active": isRecordingLocation,
|
|
7279
7486
|
"paused": isLocationRecordingPaused,
|
|
7280
7487
|
"startedAt": locationRecordingStartedAt ?? NSNull(),
|
|
7488
|
+
"powerMode": locationRecordingPowerMode,
|
|
7489
|
+
"effectivePowerMode": locationRecordingPowerSaver ? "saver" : "default",
|
|
7281
7490
|
]
|
|
7282
7491
|
}
|
|
7283
7492
|
|
|
@@ -7306,6 +7515,39 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
7306
7515
|
|
|
7307
7516
|
private func loadRecordedLocations() -> [[String: Any]] { (try? loadRecordedLocationsStrict()) ?? [] }
|
|
7308
7517
|
|
|
7518
|
+
/// The fixes recorded after `since` (epoch ms), oldest first, read from
|
|
7519
|
+
/// the end of the track backwards: a window of the file's last bytes,
|
|
7520
|
+
/// widened until it reaches a fix at or before `since` or the start.
|
|
7521
|
+
/// Fixes are appended in time order, one JSON object per line.
|
|
7522
|
+
private func loadRecordedLocationsStrict(after since: Double) throws -> [[String: Any]] {
|
|
7523
|
+
if locationRecordingId == nil { return [] }
|
|
7524
|
+
let handle = try FileHandle(forReadingFrom: locationRecordingTrackURL)
|
|
7525
|
+
defer { try? handle.close() }
|
|
7526
|
+
let size = try handle.seekToEnd()
|
|
7527
|
+
var window: UInt64 = 64 * 1024
|
|
7528
|
+
while true {
|
|
7529
|
+
let start = size > window ? size - window : 0
|
|
7530
|
+
try handle.seek(toOffset: start)
|
|
7531
|
+
let data = try handle.readToEnd() ?? Data()
|
|
7532
|
+
var lines = data.split(separator: 0x0A, omittingEmptySubsequences: true)
|
|
7533
|
+
// Mid-file, the window's first line may be cut: it is older than
|
|
7534
|
+
// every line after it, so it is never the one needed.
|
|
7535
|
+
if start > 0, !lines.isEmpty { lines.removeFirst() }
|
|
7536
|
+
var newer: [[String: Any]] = []
|
|
7537
|
+
var reachedOlder = start == 0
|
|
7538
|
+
for line in lines.reversed() {
|
|
7539
|
+
guard let fix = try? JSONSerialization.jsonObject(with: Data(line)) as? [String: Any] else { continue }
|
|
7540
|
+
if let time = (fix["timestamp"] as? NSNumber)?.doubleValue, time <= since {
|
|
7541
|
+
reachedOlder = true
|
|
7542
|
+
break
|
|
7543
|
+
}
|
|
7544
|
+
newer.append(fix)
|
|
7545
|
+
}
|
|
7546
|
+
if reachedOlder { return Array(newer.reversed()) }
|
|
7547
|
+
window *= 4
|
|
7548
|
+
}
|
|
7549
|
+
}
|
|
7550
|
+
|
|
7309
7551
|
private func loadRecordedLocationsStrict() throws -> [[String: Any]] {
|
|
7310
7552
|
if locationRecordingId == nil { return [] }
|
|
7311
7553
|
let text = try String(contentsOf: locationRecordingTrackURL, encoding: .utf8)
|
|
@@ -7325,7 +7567,17 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
7325
7567
|
}
|
|
7326
7568
|
|
|
7327
7569
|
if isRecordingLocation, let id = locationRecordingId { data["recordingId"] = id }
|
|
7570
|
+
// In the background Core Location can hand over several fixes at
|
|
7571
|
+
// once: the recording keeps each of them, not just the newest, or
|
|
7572
|
+
// the route would have holes a pause is made of.
|
|
7573
|
+
for earlier in locations.dropLast() {
|
|
7574
|
+
var sample = locationData(earlier)
|
|
7575
|
+
if isRecordingLocation, let id = locationRecordingId { sample["recordingId"] = id }
|
|
7576
|
+
appendRecordedLocation(sample)
|
|
7577
|
+
noteLocationRecordingSpeed(earlier)
|
|
7578
|
+
}
|
|
7328
7579
|
appendRecordedLocation(data)
|
|
7580
|
+
noteLocationRecordingSpeed(location)
|
|
7329
7581
|
if isWatchingLocation || isRecordingLocation {
|
|
7330
7582
|
sendToWeb("craftLocationUpdate", data: data)
|
|
7331
7583
|
}
|
|
@@ -9843,6 +10095,10 @@ final class CraftZigRuntime: NSObject {
|
|
|
9843
10095
|
return unsafeBitCast(symbol, to: RecordingOwnershipVersionFn.self)() >= 1
|
|
9844
10096
|
}
|
|
9845
10097
|
|
|
10098
|
+
/// Power modes belong to Swift's recorder. A linked Zig runtime owns the
|
|
10099
|
+
/// recorder instead and has no power mode, so it must not be advertised.
|
|
10100
|
+
static var supportsRecordingPowerMode: Bool { !isLinked }
|
|
10101
|
+
|
|
9846
10102
|
static var supportsRecordingIntentIdentity: Bool {
|
|
9847
10103
|
if !isLinked { return true }
|
|
9848
10104
|
guard performRecordingFn != nil,
|
|
@@ -1184,6 +1184,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
1184
1184
|
#if compiler(>=6.2)
|
|
1185
1185
|
if #available(iOS 26.0, *) { tabBarMinimizeBehavior = .never }
|
|
1186
1186
|
#endif
|
|
1187
|
+
CraftChromeFonts.apply(to: tabBar)
|
|
1187
1188
|
accessoryButton.accessibilityIdentifier = "craft-tab-accessory"
|
|
1188
1189
|
accessoryButton.addTarget(self, action: #selector(openTabAccessory), for: .touchUpInside)
|
|
1189
1190
|
chromeSubscription = CraftChrome.shared.objectWillChange.sink { [weak self] in
|
|
@@ -79,6 +79,7 @@ enum CraftNativeActions {
|
|
|
79
79
|
values.append("geolocation")
|
|
80
80
|
if CraftZigRuntime.supportsRecordingOwnership { values.append("locationRecordingOwnership") }
|
|
81
81
|
if CraftZigRuntime.supportsRecordingIntentIdentity { values.append("locationRecordingIntentIdentity") }
|
|
82
|
+
if CraftZigRuntime.supportsRecordingPowerMode { values.append("locationRecordingPowerMode") }
|
|
82
83
|
}
|
|
83
84
|
// The Music app's song and transport are the coordinator's too.
|
|
84
85
|
if config.enableAppleMusic { values.append("music") }
|
|
@@ -494,7 +495,8 @@ enum CraftNativeActions {
|
|
|
494
495
|
}
|
|
495
496
|
let actions = [
|
|
496
497
|
"getCurrentPosition": "getCurrentPosition", "watchPosition": "watchPosition", "clearWatch": "clearWatch",
|
|
497
|
-
"startRecording": "startLocationRecording", "
|
|
498
|
+
"startRecording": "startLocationRecording", "setRecordingPowerMode": "setLocationRecordingPowerMode",
|
|
499
|
+
"pauseRecording": "pauseLocationRecording",
|
|
498
500
|
"resumeRecording": "resumeLocationRecording", "stopRecording": "stopLocationRecording",
|
|
499
501
|
"getRecordingState": "getLocationRecordingState", "readRecording": "readLocationRecording",
|
|
500
502
|
"acknowledgeRecording": "acknowledgeLocationRecording",
|
|
@@ -42,6 +42,10 @@ private final class CraftNativeFlatListLayout: UICollectionViewFlowLayout {
|
|
|
42
42
|
/// The screen controller owns rendered row state; this view owns collection
|
|
43
43
|
/// diffs, viewport state, and UICollectionView cell reuse.
|
|
44
44
|
final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowLayout, UIGestureRecognizerDelegate {
|
|
45
|
+
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
46
|
+
craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
|
|
47
|
+
}
|
|
48
|
+
|
|
45
49
|
typealias RenderItem = (_ node: [String: Any], _ identity: String, _ previous: UIView?) -> UIView
|
|
46
50
|
|
|
47
51
|
private final class Cell: UICollectionViewCell {
|
|
@@ -35,6 +35,9 @@ final class CraftNativeMutationDocument {
|
|
|
35
35
|
var style: [String: Any]
|
|
36
36
|
var events: [String: Any]
|
|
37
37
|
var text: [String]
|
|
38
|
+
// Legacy snapshots may interleave bare text and native children.
|
|
39
|
+
// Keep each text fragment's slot among the child nodes on import.
|
|
40
|
+
var textPositions: [Int]? = nil
|
|
38
41
|
var children: [String]
|
|
39
42
|
var parent: String?
|
|
40
43
|
}
|
|
@@ -43,6 +46,11 @@ final class CraftNativeMutationDocument {
|
|
|
43
46
|
private var rootId: String?
|
|
44
47
|
private(set) var revision = 0
|
|
45
48
|
|
|
49
|
+
/// The normalized committed tree, whose root need not be named "root".
|
|
50
|
+
var document: [String: Any]? {
|
|
51
|
+
rootId.flatMap { materialize($0, nodes: nodes) }
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
func replace(with document: [String: Any]) {
|
|
47
55
|
var replacement: [String: Node] = [:]
|
|
48
56
|
let root = importNode(document, preferredId: "root", parent: nil, nodes: &replacement)
|
|
@@ -217,6 +225,7 @@ final class CraftNativeMutationDocument {
|
|
|
217
225
|
if patch.keys.contains("events") { mergePatch(patch["events"], into: &node.events) }
|
|
218
226
|
if patch.keys.contains("children") || patch.keys.contains("text") {
|
|
219
227
|
node.text = try textChildren(patch["children"] ?? patch["text"])
|
|
228
|
+
node.textPositions = nil
|
|
220
229
|
}
|
|
221
230
|
nodes[id] = node
|
|
222
231
|
}
|
|
@@ -422,9 +431,18 @@ final class CraftNativeMutationDocument {
|
|
|
422
431
|
"events": node.events,
|
|
423
432
|
]
|
|
424
433
|
if let key = node.key { value["key"] = key }
|
|
425
|
-
let text: [Any] = node.text
|
|
426
434
|
let children: [Any] = node.children.compactMap { materialize($0, nodes: nodes) }
|
|
427
|
-
|
|
435
|
+
var content: [Any] = []
|
|
436
|
+
var textIndex = 0
|
|
437
|
+
for index in 0...children.count {
|
|
438
|
+
while textIndex < node.text.count,
|
|
439
|
+
min(node.textPositions?[textIndex] ?? 0, children.count) == index {
|
|
440
|
+
content.append(node.text[textIndex])
|
|
441
|
+
textIndex += 1
|
|
442
|
+
}
|
|
443
|
+
if index < children.count { content.append(children[index]) }
|
|
444
|
+
}
|
|
445
|
+
value["children"] = content
|
|
428
446
|
return value
|
|
429
447
|
}
|
|
430
448
|
|
|
@@ -440,6 +458,12 @@ final class CraftNativeMutationDocument {
|
|
|
440
458
|
var id = explicit ?? key.map { "\(parent ?? "root")/key:\($0)" } ?? preferredId
|
|
441
459
|
if nodes[id] != nil { id = "\(preferredId)#\(nodes.count)" }
|
|
442
460
|
let children = value["children"] as? [Any] ?? []
|
|
461
|
+
var textPositions: [Int] = []
|
|
462
|
+
var childCount = 0
|
|
463
|
+
for child in children {
|
|
464
|
+
if child is String { textPositions.append(childCount) }
|
|
465
|
+
else if child is [String: Any] { childCount += 1 }
|
|
466
|
+
}
|
|
443
467
|
nodes[id] = Node(
|
|
444
468
|
id: id,
|
|
445
469
|
key: value["key"] as? String,
|
|
@@ -448,6 +472,7 @@ final class CraftNativeMutationDocument {
|
|
|
448
472
|
style: dictionary(value["style"]),
|
|
449
473
|
events: dictionary(value["events"]),
|
|
450
474
|
text: children.compactMap { $0 as? String },
|
|
475
|
+
textPositions: textPositions,
|
|
451
476
|
children: [],
|
|
452
477
|
parent: parent
|
|
453
478
|
)
|