craft-native 0.0.436 → 0.0.443
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/LICENSE.md +30 -0
- package/README.md +35 -0
- package/dist/android/src/index.d.ts +44 -0
- package/dist/android/src/index.js +82 -3
- package/dist/android/templates/AndroidManifest.xml.template +2 -1
- package/dist/android/templates/CraftHealthServices.kt.template +2 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +42 -1
- package/dist/android/templates/CraftNativeMotion.kt.template +269 -0
- package/dist/android/templates/CraftNativeServices.kt.template +6 -1
- package/dist/android/templates/CraftPassiveSteps.kt.template +159 -0
- package/dist/android/templates/CraftRecordingPressure.kt.template +156 -0
- package/dist/android/templates/LocationRecordingService.kt.template +77 -9
- package/dist/android/templates/MainActivity.kt.template +3 -0
- package/dist/android/templates/MainActivityNative.kt.template +132 -1
- package/dist/cli.js +289 -108
- package/dist/components/index.d.ts +25 -0
- package/dist/index.cjs +207 -27
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +321 -140
- package/dist/installer-background.d.ts +2 -0
- package/dist/installer-layout.d.ts +1 -0
- package/dist/ios/src/index.d.ts +23 -1
- package/dist/ios/src/index.js +49 -9
- package/dist/ios/templates/CraftApp.swift +14 -0
- package/dist/ios/templates/CraftHealth.swift +2 -1
- package/dist/ios/templates/CraftHealthBackground.swift +308 -0
- package/dist/ios/templates/CraftNativeActions.swift +153 -0
- package/dist/ios/templates/CraftNativePedometer.swift +181 -0
- package/dist/ios/templates/CraftNativeScreen.swift +76 -0
- package/dist/macos/src/index.js +1 -1
- package/dist/macos/src/toolchain.d.ts +1 -1
- package/dist/macos/templates/CraftMacApplication.swift +124 -39
- package/dist/macos/templates/CraftMacRenderer.swift +111 -33
- package/dist/macos/templates/CraftMacRuntime.swift +270 -81
- package/dist/package.d.ts +40 -0
- package/dist/types.d.ts +26 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function writeInstallerLayout(mount: string, volume: string, appName: string): void;
|
package/dist/ios/src/index.d.ts
CHANGED
|
@@ -84,12 +84,20 @@ export interface CraftConfig {
|
|
|
84
84
|
enableAudioRecording?: boolean;
|
|
85
85
|
enableVideoRecording?: boolean;
|
|
86
86
|
enableMotionSensors?: boolean;
|
|
87
|
+
/** Core Motion relative altitude and pressure; includes the motion usage description. */
|
|
88
|
+
enableBarometer?: boolean;
|
|
89
|
+
/** Core Motion interval steps, cadence and seven-day device history. */
|
|
90
|
+
enablePedometer?: boolean;
|
|
87
91
|
/** Shaking the phone raises a `shake` event. Arms the accelerometer while the app is in front. */
|
|
88
92
|
enableShakeGesture?: boolean;
|
|
89
93
|
enableLocalDatabase?: boolean;
|
|
90
94
|
enableBluetooth?: boolean;
|
|
91
95
|
enableNFC?: boolean;
|
|
92
96
|
enableHealthKit?: boolean;
|
|
97
|
+
/** Observe declared HealthKit types on background wakes; requires enableHealthKit. */
|
|
98
|
+
enableHealthKitBackground?: boolean;
|
|
99
|
+
healthKitBackgroundTypes?: Array<'steps' | 'heartRate' | 'restingHeartRate' | 'heartRateVariability' | 'activeEnergy' | 'distance' | 'bodyMass' | 'sleep' | 'workouts'>;
|
|
100
|
+
healthKitBackgroundFrequency?: 'immediate' | 'hourly' | 'daily' | 'weekly';
|
|
93
101
|
/** Native Apple Wallet pass presentation and lookup for native screens. */
|
|
94
102
|
enableWallet?: boolean;
|
|
95
103
|
enableLiveActivities?: boolean;
|
|
@@ -200,7 +208,9 @@ export interface CraftConfig {
|
|
|
200
208
|
* screen until the page says it is ready (`splash.hide()`), so the app goes
|
|
201
209
|
* from launch to content without a blank web view in between. PNG, PDF or
|
|
202
210
|
* SVG, drawn at its own size in points: give an SVG the width and height it
|
|
203
|
-
* should have on screen.
|
|
211
|
+
* should have on screen. A PNG with `@2x` and `@3x` files beside it
|
|
212
|
+
* (`LaunchLogo.png`, `LaunchLogo@2x.png`, `LaunchLogo@3x.png`, as ts-images'
|
|
213
|
+
* `generateSplashScreens` writes them) is sharp on every screen.
|
|
204
214
|
*/
|
|
205
215
|
splashImagePath?: string;
|
|
206
216
|
/** The splash logo in Dark Mode, on `backgroundColorDark`. The light one if unset. */
|
|
@@ -361,6 +371,8 @@ export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer
|
|
|
361
371
|
* well as the one-file bundle, and the app runs each screen from its own
|
|
362
372
|
* route's file. Stacks asks for route bundles only when this is true.
|
|
363
373
|
*/
|
|
374
|
+
/** Build-time proof for Stacks capability mappings, including unreleased source builds. */
|
|
375
|
+
export declare const nativeSensorCapabilities: readonly ['barometer', 'pedometer', 'healthKitBackground'];
|
|
364
376
|
export declare const nativeRouteBundles = true;
|
|
365
377
|
/**
|
|
366
378
|
* The app evaluates stx's shared runtime (`__runtime.js`, stx's
|
|
@@ -522,6 +534,16 @@ export declare function renderPrivacyManifest(config: CraftConfig): string;
|
|
|
522
534
|
export declare function pngHasAlpha(bytes: Uint8Array): boolean;
|
|
523
535
|
/** The asset the launch screen and the splash both draw, so one hands over to the other unseen. */
|
|
524
536
|
export declare const LAUNCH_LOGO = "LaunchLogo";
|
|
537
|
+
/**
|
|
538
|
+
* A bitmap splash's `@2x` and `@3x` files beside it (`LaunchLogo.png`,
|
|
539
|
+
* `LaunchLogo@2x.png`, `LaunchLogo@3x.png`, as ts-images'
|
|
540
|
+
* `generateSplashScreens` writes them), by scale. A bitmap without them is
|
|
541
|
+
* one image, drawn at its pixel size in points.
|
|
542
|
+
*/
|
|
543
|
+
export declare function splashScales(source: string): Array<{
|
|
544
|
+
scale: 1 | 2 | 3;
|
|
545
|
+
path: string;
|
|
546
|
+
}>;
|
|
525
547
|
/** The launch screen: the background colour, and the splash logo on it when there is one. */
|
|
526
548
|
export declare function renderLaunchScreen(config: Pick<CraftConfig, 'splashImagePath'>): string;
|
|
527
549
|
/** Near-black or near-white, whichever reads on `background` (WCAG relative luminance). */
|
package/dist/ios/src/index.js
CHANGED
|
@@ -18,7 +18,7 @@ function iosPermissions(config) {
|
|
|
18
18
|
contacts: config.enableContacts,
|
|
19
19
|
calendar: config.enableCalendar,
|
|
20
20
|
bluetooth: config.enableBluetooth || config.enableHeartRate,
|
|
21
|
-
motion: config.enableMotionSensors || config.enableShakeGesture,
|
|
21
|
+
motion: config.enableMotionSensors || config.enableShakeGesture || config.enableBarometer || config.enablePedometer,
|
|
22
22
|
biometric: config.enableBiometric,
|
|
23
23
|
music: config.enableAppleMusic,
|
|
24
24
|
health: config.enableHealthKit,
|
|
@@ -618,6 +618,7 @@ function hybridConfigProblems(config) {
|
|
|
618
618
|
problems.push(`nativeScriptThread "${String(config.nativeScriptThread)}" must be "main" or "background".`);
|
|
619
619
|
return problems;
|
|
620
620
|
}
|
|
621
|
+
var nativeSensorCapabilities = ["barometer", "pedometer", "healthKitBackground"];
|
|
621
622
|
var nativeRouteBundles = true;
|
|
622
623
|
var nativeRouteRuntime = true;
|
|
623
624
|
var nativeMessages = true;
|
|
@@ -701,11 +702,16 @@ var DEFAULT_CONFIG = {
|
|
|
701
702
|
enableAudioRecording: false,
|
|
702
703
|
enableVideoRecording: false,
|
|
703
704
|
enableMotionSensors: false,
|
|
705
|
+
enableBarometer: false,
|
|
706
|
+
enablePedometer: false,
|
|
704
707
|
enableShakeGesture: false,
|
|
705
708
|
enableLocalDatabase: false,
|
|
706
709
|
enableBluetooth: false,
|
|
707
710
|
enableNFC: false,
|
|
708
711
|
enableHealthKit: false,
|
|
712
|
+
enableHealthKitBackground: false,
|
|
713
|
+
healthKitBackgroundTypes: [],
|
|
714
|
+
healthKitBackgroundFrequency: "hourly",
|
|
709
715
|
enableWallet: false,
|
|
710
716
|
enableLiveActivities: false,
|
|
711
717
|
enableWatchApp: false,
|
|
@@ -762,7 +768,7 @@ function renderUsageDescriptions(config) {
|
|
|
762
768
|
[config.enableContacts, "NSContactsUsageDescription", `${config.appName} accesses contacts only when you choose a contact feature.`],
|
|
763
769
|
[config.enableCalendar, "NSCalendarsUsageDescription", `${config.appName} accesses your calendar only when you choose a calendar feature.`],
|
|
764
770
|
[config.enableBluetooth || config.enableHeartRate, "NSBluetoothAlwaysUsageDescription", config.enableHeartRate ? `${config.appName} connects to your heart-rate monitor over Bluetooth to show and record your heart rate during a workout.` : `${config.appName} uses Bluetooth to connect to nearby devices.`],
|
|
765
|
-
[config.enableMotionSensors, "NSMotionUsageDescription", `${config.appName} uses motion data for activity features.`],
|
|
771
|
+
[config.enableMotionSensors || config.enableBarometer || config.enablePedometer, "NSMotionUsageDescription", `${config.appName} uses motion data for activity features.`],
|
|
766
772
|
[config.enableNFC, "NFCReaderUsageDescription", `${config.appName} reads NFC tags when you start a scan.`],
|
|
767
773
|
[config.enableLocalNetwork, "NSLocalNetworkUsageDescription", `${config.appName} connects to your own devices on this network.`],
|
|
768
774
|
[config.enableHealthKit, "NSHealthShareUsageDescription", `${config.appName} reads health data you choose to share.`],
|
|
@@ -871,8 +877,24 @@ ${plistArray([identifier])}
|
|
|
871
877
|
function associatedDomainEntries(domains) {
|
|
872
878
|
return [...new Set((domains ?? []).map((value) => value.trim()).filter(Boolean).map((value) => /^[a-z]+:/i.test(value) ? value : `applinks:${value}`))];
|
|
873
879
|
}
|
|
880
|
+
function validateHealthKitBackground(config) {
|
|
881
|
+
if (!config.enableHealthKitBackground)
|
|
882
|
+
return;
|
|
883
|
+
if (!config.enableHealthKit)
|
|
884
|
+
throw new Error("enableHealthKitBackground requires enableHealthKit");
|
|
885
|
+
const types = config.healthKitBackgroundTypes;
|
|
886
|
+
const supported = ["steps", "heartRate", "restingHeartRate", "heartRateVariability", "activeEnergy", "distance", "bodyMass", "sleep", "workouts"];
|
|
887
|
+
if (!Array.isArray(types) || !types.length || types.some((type) => !supported.includes(type)))
|
|
888
|
+
throw new Error("healthKitBackgroundTypes must declare supported HealthKit sample types");
|
|
889
|
+
if (!["immediate", "hourly", "daily", "weekly"].includes(config.healthKitBackgroundFrequency ?? "hourly"))
|
|
890
|
+
throw new Error("healthKitBackgroundFrequency must be immediate, hourly, daily or weekly");
|
|
891
|
+
}
|
|
874
892
|
function renderEntitlements(config) {
|
|
893
|
+
validateHealthKitBackground(config);
|
|
875
894
|
const entries = [];
|
|
895
|
+
if (config.enableHealthKitBackground)
|
|
896
|
+
entries.push(` <key>com.apple.developer.healthkit.background-delivery</key>
|
|
897
|
+
<true/>`);
|
|
876
898
|
const associatedDomains = associatedDomainEntries(config.associatedDomains);
|
|
877
899
|
if (associatedDomains.length) {
|
|
878
900
|
entries.push(` <key>com.apple.developer.associated-domains</key>
|
|
@@ -1085,6 +1107,14 @@ function renderAssetCatalog(output, config) {
|
|
|
1085
1107
|
renderLaunchLogo(catalog, config);
|
|
1086
1108
|
}
|
|
1087
1109
|
var LAUNCH_LOGO = "LaunchLogo";
|
|
1110
|
+
function splashScales(source) {
|
|
1111
|
+
const extension = extname(source);
|
|
1112
|
+
if (!/^\.(?:png|jpe?g)$/i.test(extension))
|
|
1113
|
+
return [{ scale: 1, path: source }];
|
|
1114
|
+
const stem = source.slice(0, -extension.length);
|
|
1115
|
+
const scales = [2, 3].map((scale) => ({ scale, path: `${stem}@${scale}x${extension}` })).filter((item) => existsSync2(item.path));
|
|
1116
|
+
return scales.length ? [{ scale: 1, path: source }, ...scales] : [{ scale: 1, path: source }];
|
|
1117
|
+
}
|
|
1088
1118
|
function renderLaunchLogo(catalog, config) {
|
|
1089
1119
|
const imageset = join4(catalog, `${LAUNCH_LOGO}.imageset`);
|
|
1090
1120
|
rmSync3(imageset, { recursive: true, force: true });
|
|
@@ -1101,13 +1131,18 @@ function renderLaunchLogo(catalog, config) {
|
|
|
1101
1131
|
const extension = source.split(".").pop().toLowerCase();
|
|
1102
1132
|
if (extension === "svg" || extension === "pdf")
|
|
1103
1133
|
vector = true;
|
|
1104
|
-
const
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
filename
|
|
1109
|
-
|
|
1110
|
-
|
|
1134
|
+
const files = splashScales(source);
|
|
1135
|
+
const scaled = files.length > 1;
|
|
1136
|
+
for (const { scale, path } of files) {
|
|
1137
|
+
const filename = `${LAUNCH_LOGO}${dark ? "-dark" : ""}${scale === 1 ? "" : `@${scale}x`}.${extension}`;
|
|
1138
|
+
cpSync(path, join4(imageset, filename));
|
|
1139
|
+
images.push({
|
|
1140
|
+
...dark ? { appearances: [{ appearance: "luminosity", value: "dark" }] } : {},
|
|
1141
|
+
filename,
|
|
1142
|
+
idiom: "universal",
|
|
1143
|
+
...scaled ? { scale: `${scale}x` } : {}
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1111
1146
|
}
|
|
1112
1147
|
writeFileSync3(join4(imageset, "Contents.json"), `${JSON.stringify({
|
|
1113
1148
|
images,
|
|
@@ -1358,6 +1393,8 @@ function writeShell(output, config) {
|
|
|
1358
1393
|
writeSource(join4(output, "Sources", "CraftNativeScreen.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeScreen.swift"), "utf8"));
|
|
1359
1394
|
writeSource(join4(output, "Sources", "CraftNativeFlatList.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeFlatList.swift"), "utf8"));
|
|
1360
1395
|
writeSource(join4(output, "Sources", "CraftNativeActions.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeActions.swift"), "utf8"));
|
|
1396
|
+
writeSource(join4(output, "Sources", "CraftNativePedometer.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativePedometer.swift"), "utf8"));
|
|
1397
|
+
writeSource(join4(output, "Sources", "CraftHealthBackground.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftHealthBackground.swift"), "utf8"));
|
|
1361
1398
|
writeSource(join4(output, "Sources", "CraftHealth.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftHealth.swift"), "utf8"));
|
|
1362
1399
|
writeSource(join4(output, "Sources", "CraftMusic.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftMusic.swift"), "utf8"));
|
|
1363
1400
|
writeSource(join4(output, "Sources", "CraftBluetooth.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftBluetooth.swift"), "utf8"));
|
|
@@ -1424,6 +1461,7 @@ async function init(options) {
|
|
|
1424
1461
|
teamId: teamId || "",
|
|
1425
1462
|
...given
|
|
1426
1463
|
};
|
|
1464
|
+
validateHealthKitBackground(config);
|
|
1427
1465
|
if (config.renderer !== "web" && config.renderer !== "native") {
|
|
1428
1466
|
throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
|
|
1429
1467
|
}
|
|
@@ -1784,6 +1822,7 @@ export {
|
|
|
1784
1822
|
nativeMessages,
|
|
1785
1823
|
nativeRouteBundles,
|
|
1786
1824
|
nativeRouteRuntime,
|
|
1825
|
+
nativeSensorCapabilities,
|
|
1787
1826
|
nativeStartPath,
|
|
1788
1827
|
normalizeNativePath,
|
|
1789
1828
|
open,
|
|
@@ -1815,6 +1854,7 @@ export {
|
|
|
1815
1854
|
runPostGenerateHook,
|
|
1816
1855
|
serializeSimulatorSteps,
|
|
1817
1856
|
showSimulator,
|
|
1857
|
+
splashScales,
|
|
1818
1858
|
syncNativeFonts,
|
|
1819
1859
|
syncWebAssets,
|
|
1820
1860
|
usesNativeBundle
|
|
@@ -299,6 +299,15 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
|
|
|
299
299
|
// push, a refresh, a route recording's location) draws nothing, and
|
|
300
300
|
// its events still need someone to hear them.
|
|
301
301
|
CraftHybrid.shared.configure(config)
|
|
302
|
+
CraftHealthBackground.shared.emit = { type in
|
|
303
|
+
let detail: [String: Any] = ["type": type]
|
|
304
|
+
CraftEventManager.shared.sendToWeb("craftHealthBackground", data: detail)
|
|
305
|
+
NotificationCenter.default.post(name: CraftWebServices.event, object: nil,
|
|
306
|
+
userInfo: ["event": "craftHealthBackground", "detail": detail])
|
|
307
|
+
}
|
|
308
|
+
CraftHealthBackground.shared.configure(types: config.enableHealthKit && config.enableHealthKitBackground ? config.healthKitBackgroundTypes : [],
|
|
309
|
+
frequency: config.healthKitBackgroundFrequency)
|
|
310
|
+
|
|
302
311
|
if let shortcuts = config.appShortcuts {
|
|
303
312
|
application.shortcutItems = shortcuts.prefix(4).map { shortcut in
|
|
304
313
|
UIApplicationShortcutItem(type: shortcut.id, localizedTitle: shortcut.title, localizedSubtitle: shortcut.subtitle,
|
|
@@ -1830,6 +1839,8 @@ struct CraftConfig: Codable {
|
|
|
1830
1839
|
var enableAudioRecording: Bool = false
|
|
1831
1840
|
var enableVideoRecording: Bool = false
|
|
1832
1841
|
var enableMotionSensors: Bool = false
|
|
1842
|
+
var enableBarometer: Bool = false
|
|
1843
|
+
var enablePedometer: Bool = false
|
|
1833
1844
|
/// Shaking the phone raises a `shake` event, for a report-a-problem
|
|
1834
1845
|
/// gesture. Its own flag rather than a corner of `enableMotionSensors`,
|
|
1835
1846
|
/// because it arms the accelerometer for as long as the app is in front:
|
|
@@ -1839,6 +1850,9 @@ struct CraftConfig: Codable {
|
|
|
1839
1850
|
var enableBluetooth: Bool = false
|
|
1840
1851
|
var enableNFC: Bool = false
|
|
1841
1852
|
var enableHealthKit: Bool = false
|
|
1853
|
+
var enableHealthKitBackground: Bool = false
|
|
1854
|
+
var healthKitBackgroundTypes: [String] = []
|
|
1855
|
+
var healthKitBackgroundFrequency: String = "hourly"
|
|
1842
1856
|
/// Apple Wallet pass presentation and lookup for native screens.
|
|
1843
1857
|
var enableWallet: Bool = false
|
|
1844
1858
|
var enableLiveActivities: Bool = false
|
|
@@ -227,6 +227,7 @@ final class CraftHealth {
|
|
|
227
227
|
if readOnly { shareTypes.removeAll() }
|
|
228
228
|
healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { [weak self] success, error in
|
|
229
229
|
if success {
|
|
230
|
+
DispatchQueue.main.async { CraftHealthBackground.shared.refresh() }
|
|
230
231
|
completion(.success(true))
|
|
231
232
|
} else {
|
|
232
233
|
completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Authorization failed")))
|
|
@@ -401,7 +402,7 @@ final class CraftHealth {
|
|
|
401
402
|
|
|
402
403
|
/// The quantity behind each health data type, its unit, and the one
|
|
403
404
|
/// statistic that type supports.
|
|
404
|
-
|
|
405
|
+
static func healthQuantity(_ type: String) -> (type: HKQuantityType?, unit: HKUnit, options: HKStatisticsOptions)? {
|
|
405
406
|
let bpm = HKUnit.count().unitDivided(by: .minute())
|
|
406
407
|
switch type {
|
|
407
408
|
case "steps": return (HKQuantityType.quantityType(forIdentifier: .stepCount), .count(), .cumulativeSum)
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
#if CRAFT_PERMISSION_HEALTH
|
|
3
|
+
import HealthKit
|
|
4
|
+
#endif
|
|
5
|
+
|
|
6
|
+
struct CraftHealthBackgroundSample: Codable {
|
|
7
|
+
let id: String
|
|
8
|
+
let startDate: Double
|
|
9
|
+
let endDate: Double
|
|
10
|
+
var value: Double?
|
|
11
|
+
var unit: String?
|
|
12
|
+
var category: Int?
|
|
13
|
+
var workoutActivity: UInt?
|
|
14
|
+
var durationSeconds: Double?
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
struct CraftHealthBackgroundPage {
|
|
18
|
+
let anchor: Data
|
|
19
|
+
let samples: [CraftHealthBackgroundSample]
|
|
20
|
+
let deleted: [String]
|
|
21
|
+
let more: Bool
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
struct CraftHealthBackgroundBatch: Codable {
|
|
25
|
+
let id: String
|
|
26
|
+
let type: String
|
|
27
|
+
let samples: [CraftHealthBackgroundSample]
|
|
28
|
+
let deleted: [String]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
struct CraftHealthBackgroundState: Codable {
|
|
32
|
+
var anchors: [String: Data] = [:]
|
|
33
|
+
var observedTypes: [String]? = nil
|
|
34
|
+
var batches: [CraftHealthBackgroundBatch] = []
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// An atomic snapshot keeps a new anchor inseparable from its unconsumed delta.
|
|
38
|
+
protocol CraftHealthBackgroundPersistence {
|
|
39
|
+
func load() throws -> CraftHealthBackgroundState
|
|
40
|
+
func save(_ state: CraftHealthBackgroundState) throws
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
final class CraftHealthBackgroundFile: CraftHealthBackgroundPersistence {
|
|
44
|
+
private let url: URL
|
|
45
|
+
init(url: URL) { self.url = url }
|
|
46
|
+
func load() throws -> CraftHealthBackgroundState {
|
|
47
|
+
guard FileManager.default.fileExists(atPath: url.path) else { return CraftHealthBackgroundState() }
|
|
48
|
+
return try JSONDecoder().decode(CraftHealthBackgroundState.self, from: Data(contentsOf: url))
|
|
49
|
+
}
|
|
50
|
+
func save(_ state: CraftHealthBackgroundState) throws {
|
|
51
|
+
let data = try JSONEncoder().encode(state)
|
|
52
|
+
guard data.count <= 8 * 1024 * 1024 else { throw CraftHealthBackgroundError.outboxFull }
|
|
53
|
+
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true,
|
|
54
|
+
attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication])
|
|
55
|
+
try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
|
|
56
|
+
var excluded = url
|
|
57
|
+
var values = URLResourceValues()
|
|
58
|
+
values.isExcludedFromBackup = true
|
|
59
|
+
try excluded.setResourceValues(values)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
enum CraftHealthBackgroundError: Error { case unavailable, outboxFull, invalidAnchor, missingAnchor }
|
|
64
|
+
|
|
65
|
+
/// All callbacks and ownership transitions are serialized on the main queue.
|
|
66
|
+
protocol CraftHealthBackgroundSource {
|
|
67
|
+
func observe(type: String, update: @escaping (Error?, @escaping () -> Void) -> Void) -> () -> Void
|
|
68
|
+
func enable(type: String, frequency: String, completion: @escaping (Error?) -> Void)
|
|
69
|
+
func disable(type: String)
|
|
70
|
+
func read(type: String, anchor: Data?, completion: @escaping (Result<CraftHealthBackgroundPage, Error>) -> Void) -> () -> Void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
final class CraftHealthBackground {
|
|
74
|
+
static let shared: CraftHealthBackground = {
|
|
75
|
+
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
|
76
|
+
#if CRAFT_PERMISSION_HEALTH
|
|
77
|
+
let source: CraftHealthBackgroundSource = CraftHealthKitBackgroundSource()
|
|
78
|
+
#else
|
|
79
|
+
let source: CraftHealthBackgroundSource = CraftUnavailableHealthBackgroundSource()
|
|
80
|
+
#endif
|
|
81
|
+
return CraftHealthBackground(source: source, persistence: CraftHealthBackgroundFile(url: base.appendingPathComponent("craft-health/delivery.json")))
|
|
82
|
+
}()
|
|
83
|
+
|
|
84
|
+
private final class Read {
|
|
85
|
+
var completions: [() -> Void] = []
|
|
86
|
+
var cancel: (() -> Void)?
|
|
87
|
+
var deadline: DispatchWorkItem?
|
|
88
|
+
var dirty = false
|
|
89
|
+
}
|
|
90
|
+
private let readTimeout: TimeInterval
|
|
91
|
+
private let source: CraftHealthBackgroundSource
|
|
92
|
+
private let persistence: CraftHealthBackgroundPersistence
|
|
93
|
+
private var state = CraftHealthBackgroundState()
|
|
94
|
+
private var loaded = false
|
|
95
|
+
private var observers: [String: () -> Void] = [:]
|
|
96
|
+
private var reads: [String: Read] = [:]
|
|
97
|
+
private var types: [String] = []
|
|
98
|
+
private var frequency = "hourly"
|
|
99
|
+
private var generation = UUID()
|
|
100
|
+
private(set) var errors: [String: String] = [:]
|
|
101
|
+
var emit: (String) -> Void = { _ in }
|
|
102
|
+
|
|
103
|
+
init(source: CraftHealthBackgroundSource, persistence: CraftHealthBackgroundPersistence, readTimeout: TimeInterval = 20) {
|
|
104
|
+
self.source = source
|
|
105
|
+
self.persistence = persistence
|
|
106
|
+
self.readTimeout = readTimeout
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func configure(types: [String], frequency: String) {
|
|
110
|
+
stop()
|
|
111
|
+
self.types = Array(Set(types)).sorted()
|
|
112
|
+
self.frequency = frequency
|
|
113
|
+
refresh()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func refresh() {
|
|
117
|
+
if !loaded {
|
|
118
|
+
do { state = try persistence.load(); loaded = true }
|
|
119
|
+
catch { errors["storage"] = "Health delivery state could not be read"; return }
|
|
120
|
+
}
|
|
121
|
+
errors.removeValue(forKey: "storage")
|
|
122
|
+
let current = generation
|
|
123
|
+
let observed = Set(state.observedTypes ?? []).union(state.anchors.keys).union(types)
|
|
124
|
+
if Set(state.observedTypes ?? []) != observed {
|
|
125
|
+
do {
|
|
126
|
+
var next = state
|
|
127
|
+
next.observedTypes = observed.sorted()
|
|
128
|
+
try persistence.save(next)
|
|
129
|
+
state = next
|
|
130
|
+
} catch { errors["storage"] = "Health registration intent could not be persisted"; return }
|
|
131
|
+
}
|
|
132
|
+
// Keep retired declarations so a temporarily unavailable store can
|
|
133
|
+
// retry disabling them on a later launch, even before its first delta.
|
|
134
|
+
for old in observed where !types.contains(old) { source.disable(type: old) }
|
|
135
|
+
for type in types {
|
|
136
|
+
if observers[type] == nil {
|
|
137
|
+
observers[type] = source.observe(type: type) { [weak self] error, acknowledge in
|
|
138
|
+
guard let self, self.generation == current else { acknowledge(); return }
|
|
139
|
+
if error != nil { self.errors[type] = "HealthKit observer failed"; acknowledge(); return }
|
|
140
|
+
self.changed(type, acknowledge: acknowledge)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
source.enable(type: type, frequency: frequency) { [weak self] error in
|
|
144
|
+
guard let self, self.generation == current else { return }
|
|
145
|
+
if error != nil { self.errors["\(type).registration"] = "Background delivery registration failed" }
|
|
146
|
+
else { self.errors.removeValue(forKey: "\(type).registration"); self.changed(type, acknowledge: {}) }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private func changed(_ type: String, acknowledge: @escaping () -> Void) {
|
|
152
|
+
if let read = reads[type] { read.completions.append(acknowledge); read.dirty = true; return }
|
|
153
|
+
let read = Read()
|
|
154
|
+
read.completions.append(acknowledge)
|
|
155
|
+
reads[type] = read
|
|
156
|
+
// Never strand HealthKit's completion waiting for an unavailable store,
|
|
157
|
+
// a locked device, an unresponsive query or JavaScript consumption.
|
|
158
|
+
let deadline = DispatchWorkItem { [weak self, weak read] in
|
|
159
|
+
guard let self, let read, self.reads[type] === read else { return }
|
|
160
|
+
self.errors[type] = "HealthKit read timed out; anchor retained for retry"
|
|
161
|
+
self.finish(type, read)
|
|
162
|
+
}
|
|
163
|
+
read.deadline = deadline
|
|
164
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + readTimeout, execute: deadline)
|
|
165
|
+
next(type, read)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private func next(_ type: String, _ read: Read) {
|
|
169
|
+
read.dirty = false
|
|
170
|
+
read.cancel = source.read(type: type, anchor: state.anchors[type]) { [weak self, weak read] answer in
|
|
171
|
+
guard let self, let read, self.reads[type] === read else { return }
|
|
172
|
+
read.cancel = nil
|
|
173
|
+
switch answer {
|
|
174
|
+
case .failure:
|
|
175
|
+
self.errors[type] = "HealthKit read failed; anchor retained for retry"
|
|
176
|
+
self.finish(type, read)
|
|
177
|
+
case .success(let page):
|
|
178
|
+
do {
|
|
179
|
+
var next = self.state
|
|
180
|
+
if !page.samples.isEmpty || !page.deleted.isEmpty {
|
|
181
|
+
next.batches.append(CraftHealthBackgroundBatch(id: UUID().uuidString, type: type, samples: page.samples, deleted: page.deleted))
|
|
182
|
+
}
|
|
183
|
+
next.anchors[type] = page.anchor
|
|
184
|
+
try self.persistence.save(next)
|
|
185
|
+
self.state = next
|
|
186
|
+
self.errors.removeValue(forKey: type)
|
|
187
|
+
if !page.samples.isEmpty || !page.deleted.isEmpty { self.emit(type) }
|
|
188
|
+
if page.more || read.dirty { self.next(type, read) }
|
|
189
|
+
else { self.finish(type, read) }
|
|
190
|
+
} catch {
|
|
191
|
+
self.errors[type] = "Health delivery could not be persisted; anchor retained for retry"
|
|
192
|
+
self.finish(type, read)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private func finish(_ type: String, _ read: Read) {
|
|
199
|
+
guard reads[type] === read else { return }
|
|
200
|
+
reads.removeValue(forKey: type)
|
|
201
|
+
read.deadline?.cancel()
|
|
202
|
+
read.cancel?()
|
|
203
|
+
let completions = read.completions
|
|
204
|
+
read.completions.removeAll()
|
|
205
|
+
completions.forEach { $0() }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
func stop() {
|
|
209
|
+
generation = UUID()
|
|
210
|
+
observers.values.forEach { $0() }
|
|
211
|
+
observers.removeAll()
|
|
212
|
+
for (type, read) in reads { finish(type, read) }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
func perform(method: String, args: [Any], completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
216
|
+
if !loaded { refresh() }
|
|
217
|
+
guard loaded else { completion(.failure(CraftNativeActionError(code: "STORAGE_ERROR", message: "Health delivery state is unavailable"))); return }
|
|
218
|
+
do {
|
|
219
|
+
switch method {
|
|
220
|
+
case "status": completion(.success(["types": types, "frequency": frequency, "pendingBatches": state.batches.count, "errors": errors]))
|
|
221
|
+
case "refresh": refresh(); completion(.success(true))
|
|
222
|
+
case "pending":
|
|
223
|
+
let data = try JSONEncoder().encode(Array(state.batches.prefix(20)))
|
|
224
|
+
completion(.success(try JSONSerialization.jsonObject(with: data)))
|
|
225
|
+
case "acknowledge":
|
|
226
|
+
guard let ids = args.first as? [String], ids.count <= 20 else {
|
|
227
|
+
completion(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "acknowledge needs up to 20 batch ids"))); return
|
|
228
|
+
}
|
|
229
|
+
var next = state
|
|
230
|
+
let acknowledged = Set(ids)
|
|
231
|
+
next.batches.removeAll { acknowledged.contains($0.id) }
|
|
232
|
+
try persistence.save(next)
|
|
233
|
+
state = next
|
|
234
|
+
refresh()
|
|
235
|
+
completion(.success(true))
|
|
236
|
+
default: completion(.failure(CraftNativeActionError(code: "UNKNOWN_ACTION", message: "Unknown HealthBackground action")))
|
|
237
|
+
}
|
|
238
|
+
} catch { completion(.failure(CraftNativeActionError(code: "STORAGE_ERROR", message: "Health delivery state could not be saved"))) }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private final class CraftUnavailableHealthBackgroundSource: CraftHealthBackgroundSource {
|
|
243
|
+
func disable(type: String) {}
|
|
244
|
+
func observe(type: String, update: @escaping (Error?, @escaping () -> Void) -> Void) -> () -> Void { {} }
|
|
245
|
+
func enable(type: String, frequency: String, completion: @escaping (Error?) -> Void) { completion(CraftHealthBackgroundError.unavailable) }
|
|
246
|
+
func read(type: String, anchor: Data?, completion: @escaping (Result<CraftHealthBackgroundPage, Error>) -> Void) -> () -> Void { completion(.failure(CraftHealthBackgroundError.unavailable)); return {} }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#if CRAFT_PERMISSION_HEALTH
|
|
250
|
+
private final class CraftHealthKitBackgroundSource: CraftHealthBackgroundSource {
|
|
251
|
+
private var store: HKHealthStore? { CraftHealth.shared.healthStore }
|
|
252
|
+
private func sampleType(_ name: String) -> HKSampleType? {
|
|
253
|
+
if name == "sleep" { return HKCategoryType.categoryType(forIdentifier: .sleepAnalysis) }
|
|
254
|
+
if name == "workouts" { return HKObjectType.workoutType() }
|
|
255
|
+
return CraftHealth.healthQuantity(name)?.type
|
|
256
|
+
}
|
|
257
|
+
func disable(type: String) {
|
|
258
|
+
guard let store, let sample = sampleType(type) else { return }
|
|
259
|
+
store.disableBackgroundDelivery(for: sample) { _, _ in }
|
|
260
|
+
}
|
|
261
|
+
func observe(type: String, update: @escaping (Error?, @escaping () -> Void) -> Void) -> () -> Void {
|
|
262
|
+
guard let store, let sample = sampleType(type) else { return {} }
|
|
263
|
+
let query = HKObserverQuery(sampleType: sample, predicate: nil) { _, completion, error in
|
|
264
|
+
DispatchQueue.main.async { update(error, completion) }
|
|
265
|
+
}
|
|
266
|
+
store.execute(query)
|
|
267
|
+
return { store.stop(query) }
|
|
268
|
+
}
|
|
269
|
+
func enable(type: String, frequency: String, completion: @escaping (Error?) -> Void) {
|
|
270
|
+
guard let store, let sample = sampleType(type) else { completion(CraftHealthBackgroundError.unavailable); return }
|
|
271
|
+
let value: HKUpdateFrequency = ["immediate": .immediate, "hourly": .hourly, "daily": .daily, "weekly": .weekly][frequency] ?? .hourly
|
|
272
|
+
store.enableBackgroundDelivery(for: sample, frequency: value) { success, error in
|
|
273
|
+
DispatchQueue.main.async { completion(success ? nil : error ?? CraftHealthBackgroundError.unavailable) }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
func read(type: String, anchor: Data?, completion: @escaping (Result<CraftHealthBackgroundPage, Error>) -> Void) -> () -> Void {
|
|
277
|
+
guard let store, let sample = sampleType(type) else { completion(.failure(CraftHealthBackgroundError.unavailable)); return {} }
|
|
278
|
+
let saved: HKQueryAnchor?
|
|
279
|
+
do {
|
|
280
|
+
saved = try anchor.map { data in
|
|
281
|
+
guard let value = try NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: data) else { throw CraftHealthBackgroundError.invalidAnchor }
|
|
282
|
+
return value
|
|
283
|
+
}
|
|
284
|
+
} catch { completion(.failure(error)); return {} }
|
|
285
|
+
let query = HKAnchoredObjectQuery(type: sample, predicate: nil, anchor: saved, limit: 250) { _, samples, deleted, anchor, error in
|
|
286
|
+
let answer: Result<CraftHealthBackgroundPage, Error>
|
|
287
|
+
do {
|
|
288
|
+
if let error { throw error }
|
|
289
|
+
guard let anchor else { throw CraftHealthBackgroundError.missingAnchor }
|
|
290
|
+
let data = try NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true)
|
|
291
|
+
let records = (samples ?? []).map { sample -> CraftHealthBackgroundSample in
|
|
292
|
+
var record = CraftHealthBackgroundSample(id: sample.uuid.uuidString, startDate: sample.startDate.timeIntervalSince1970 * 1000, endDate: sample.endDate.timeIntervalSince1970 * 1000)
|
|
293
|
+
if let quantity = sample as? HKQuantitySample, let spec = CraftHealth.healthQuantity(type) {
|
|
294
|
+
record.value = quantity.quantity.doubleValue(for: spec.unit)
|
|
295
|
+
record.unit = spec.unit.unitString
|
|
296
|
+
} else if let category = sample as? HKCategorySample { record.category = category.value }
|
|
297
|
+
else if let workout = sample as? HKWorkout { record.workoutActivity = workout.workoutActivityType.rawValue; record.durationSeconds = workout.duration }
|
|
298
|
+
return record
|
|
299
|
+
}
|
|
300
|
+
answer = .success(CraftHealthBackgroundPage(anchor: data, samples: records, deleted: (deleted ?? []).map { $0.uuid.uuidString }, more: records.count + (deleted?.count ?? 0) >= 250))
|
|
301
|
+
} catch { answer = .failure(error) }
|
|
302
|
+
DispatchQueue.main.async { completion(answer) }
|
|
303
|
+
}
|
|
304
|
+
store.execute(query)
|
|
305
|
+
return { store.stop(query) }
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
#endif
|