@craft-native/ios 0.0.396 → 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/dist/index.d.ts CHANGED
@@ -46,6 +46,13 @@ export interface CraftConfig {
46
46
  backgroundColor?: string;
47
47
  /** Default tint for native controls and selected tabs, as #RRGGBB. */
48
48
  tintColor?: string;
49
+ /**
50
+ * CSS font stack for the native chrome's labels (tab bar, bar buttons); the
51
+ * first family packaged in `nativeFonts` wins, a generic one keeps the system face.
52
+ */
53
+ fontFamily?: string;
54
+ /** CSS font stack for navigation titles; `fontFamily` when unset. */
55
+ headingFontFamily?: string;
49
56
  /** The launch and webview background while the phone is in Dark Mode. */
50
57
  backgroundColorDark?: string;
51
58
  /** Edge-swipe back and forward through the page's history, pushState entries included. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.396",
3
+ "version": "0.0.408",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- manager.desiredAccuracy = body["enableHighAccuracy"] as? Bool == true
6910
- ? kCLLocationAccuracyBest
6911
- : kCLLocationAccuracyHundredMeters
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 = kCLLocationAccuracyBest
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
- private func readLocationRecording(callbackId: String?) {
7271
- do { resolveCallback(callbackId, result: try loadRecordedLocationsStrict()) }
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", "pauseRecording": "pauseLocationRecording",
498
+ "startRecording": "startLocationRecording", "setRecordingPowerMode": "setLocationRecordingPowerMode",
499
+ "pauseRecording": "pauseLocationRecording",
498
500
  "resumeRecording": "resumeLocationRecording", "stopRecording": "stopLocationRecording",
499
501
  "getRecordingState": "getLocationRecordingState", "readRecording": "readLocationRecording",
500
502
  "acknowledgeRecording": "acknowledgeLocationRecording",
@@ -7629,6 +7629,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7629
7629
  appearance.configureWithOpaqueBackground()
7630
7630
  appearance.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
7631
7631
  appearance.shadowColor = .clear
7632
+ CraftChromeFonts.apply(to: appearance)
7632
7633
  navigationItem.standardAppearance = appearance
7633
7634
  navigationItem.scrollEdgeAppearance = appearance
7634
7635
  navigationItem.compactAppearance = appearance
@@ -517,6 +517,95 @@ enum CraftSymbols {
517
517
  /// controller, which offers no way to say which style it wants, so the first
518
518
  /// call teaches that controller's class to answer from here. Only that exact
519
519
  /// class is touched, and only once.
520
+ /// The app's typefaces on UIKit's own chrome. A screen's text follows the
521
+ /// stx tree's `fontFamily`; the navigation bar, the tab bar and bar button
522
+ /// labels are UIKit's, outside that tree, so a branded app drew them in the
523
+ /// system face beside screens in its own. `fontFamily` / `headingFontFamily`
524
+ /// (CSS stacks, as the screens use) put the same faces there, through the
525
+ /// appearance proxies every bar starts from.
526
+ enum CraftChromeFonts {
527
+ /// The packaged family a body or heading stack resolved to; nil keeps the system face.
528
+ private(set) static var body: String?
529
+ private(set) static var heading: String?
530
+
531
+ /// The first family of a CSS stack the app can draw. A generic family
532
+ /// ahead of any packaged one means the system face: nil.
533
+ static func family(_ stack: String?) -> String? {
534
+ guard let stack else { return nil }
535
+ for part in stack.split(separator: ",") {
536
+ let name = part.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
537
+ switch name.lowercased() {
538
+ case "": continue
539
+ case "sans-serif", "ui-sans-serif", "system-ui", "-apple-system", "serif", "ui-serif", "monospace", "ui-monospace": return nil
540
+ default: if UIFont(name: name, size: 12) != nil { return name }
541
+ }
542
+ }
543
+ return nil
544
+ }
545
+
546
+ /// `family` at a chrome size, bold for a heavy weight and scaled with Dynamic Type.
547
+ static func font(_ family: String?, size: CGFloat, weight: UIFont.Weight, textStyle: UIFont.TextStyle) -> UIFont? {
548
+ guard let family, let custom = UIFont(name: family, size: size) else { return nil }
549
+ var traits = custom.fontDescriptor.symbolicTraits
550
+ if weight >= .semibold { traits.insert(.traitBold) }
551
+ let descriptor = custom.fontDescriptor.withSymbolicTraits(traits) ?? custom.fontDescriptor
552
+ return UIFontMetrics(forTextStyle: textStyle).scaledFont(for: UIFont(descriptor: descriptor, size: size))
553
+ }
554
+
555
+ static var titleFont: UIFont? { font(heading, size: 17, weight: .semibold, textStyle: .headline) }
556
+ static var largeTitleFont: UIFont? { font(heading, size: 34, weight: .bold, textStyle: .largeTitle) }
557
+ static var tabLabelFont: UIFont? { font(body, size: 10, weight: .medium, textStyle: .caption2) }
558
+ static var barButtonFont: UIFont? { font(body, size: 17, weight: .regular, textStyle: .body) }
559
+
560
+ static func configure(_ config: CraftConfig) {
561
+ body = family(config.fontFamily)
562
+ heading = family(config.headingFontFamily) ?? body
563
+ if let title = titleFont, let large = largeTitleFont {
564
+ let bar = UINavigationBar.appearance()
565
+ bar.titleTextAttributes = [.font: title]
566
+ bar.largeTitleTextAttributes = [.font: large]
567
+ }
568
+ if let label = tabLabelFont {
569
+ for state in [UIControl.State.normal, .selected] {
570
+ UITabBarItem.appearance().setTitleTextAttributes([.font: label], for: state)
571
+ }
572
+ }
573
+ if let button = barButtonFont {
574
+ for state in [UIControl.State.normal, .highlighted, .disabled] {
575
+ UIBarButtonItem.appearance().setTitleTextAttributes([.font: button], for: state)
576
+ }
577
+ }
578
+ }
579
+
580
+ /// The label face on a tab bar. The Liquid Glass bar reads its labels from
581
+ /// its own appearance and ignores UITabBarItem's proxy, so the face goes on
582
+ /// a copy of the bar's appearance, which keeps its material untouched.
583
+ static func apply(to tabBar: UITabBar) {
584
+ guard let label = tabLabelFont else { return }
585
+ func labelled(_ appearance: UITabBarAppearance) -> UITabBarAppearance {
586
+ let copy = appearance.copy()
587
+ for layout in [copy.stackedLayoutAppearance, copy.inlineLayoutAppearance, copy.compactInlineLayoutAppearance] {
588
+ for state in [layout.normal, layout.selected, layout.focused, layout.disabled] {
589
+ state.titleTextAttributes[.font] = label
590
+ }
591
+ }
592
+ return copy
593
+ }
594
+ tabBar.standardAppearance = labelled(tabBar.standardAppearance)
595
+ if let edge = tabBar.scrollEdgeAppearance { tabBar.scrollEdgeAppearance = labelled(edge) }
596
+ }
597
+
598
+ /// The title faces on a bar appearance a screen builds itself (an opaque bar).
599
+ static func apply(to appearance: UINavigationBarAppearance) {
600
+ if let title = titleFont { appearance.titleTextAttributes[.font] = title }
601
+ if let large = largeTitleFont { appearance.largeTitleTextAttributes[.font] = large }
602
+ if let button = barButtonFont {
603
+ appearance.buttonAppearance.normal.titleTextAttributes[.font] = button
604
+ appearance.doneButtonAppearance.normal.titleTextAttributes[.font] = button
605
+ }
606
+ }
607
+ }
608
+
520
609
  enum CraftStatusBar {
521
610
  static var style: UIStatusBarStyle = .default
522
611
  private static var taught = Set<ObjectIdentifier>()
@@ -17,7 +17,7 @@ import Darwin
17
17
  /// openurl <url> opens a link through the system, answering "Open in the app?"
18
18
  /// universallink <https-url> waits up to 60s for an OS-verified link; never opens a browser
19
19
  /// shot <name> screenshot → shots/<name>.png
20
- /// tree <name> accessibility tree → shots/<name>.txt
20
+ /// tree <name> accessibility tree + identified snapshot values → shots/<name>.txt / <name>-values.json
21
21
  /// tap <text> first hittable element whose label contains text
22
22
  /// tapif <text> tap it when it is on screen, else carry on (the big timer's "Show the controls")
23
23
  /// tapid <identifier>
@@ -367,6 +367,22 @@ final class DriverTests: XCTestCase {
367
367
  post("/save?name=\(arg).png", shot.pngRepresentation)
368
368
  case "tree":
369
369
  post("/save?name=\(arg).txt", Data(app.debugDescription.utf8))
370
+ // debugDescription abbreviates values further than the snapshot.
371
+ // Retain snapshot values separately; long receipts chunk themselves.
372
+ do {
373
+ var values: [[String: String]] = []
374
+ func collect(_ node: XCUIElementSnapshot) {
375
+ if !node.identifier.isEmpty, let value = node.value as? String {
376
+ values.append(["id": node.identifier, "label": node.label, "value": value])
377
+ }
378
+ for child in node.children { collect(child) }
379
+ }
380
+ collect(try app.snapshot())
381
+ let data = try JSONSerialization.data(withJSONObject: values, options: [.sortedKeys])
382
+ post("/save?name=\(arg)-values.json", data)
383
+ } catch {
384
+ XCTFail("Could not retain accessibility values: \(error)")
385
+ }
370
386
  case "tap":
371
387
  if let target = waitFor(arg, 10) { press(target); note("tap \(arg): ok") }
372
388
  else { XCTFail("Element not found"); note("FAIL tap \(arg): NOT FOUND") }