@craft-native/ios 0.0.396 → 0.0.409
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 +7 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +268 -12
- package/templates/CraftHybrid.swift +1 -0
- package/templates/CraftNativeActions.swift +3 -1
- package/templates/CraftNativeScreen.swift +1 -0
- package/templates/CraftNativeUI.swift +89 -0
- package/templates/testing/DriverTests.swift.template +17 -1
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
package/templates/CraftApp.swift
CHANGED
|
@@ -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",
|
|
@@ -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") }
|