craft-native 0.0.98 → 0.0.99
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/api/mobile.d.ts +39 -1
- package/dist/bridge/webkit-pending.d.ts +2 -0
- package/dist/cli.js +53 -21
- package/dist/index.cjs +12 -4
- package/dist/index.js +12 -4
- package/dist/ios/src/index.d.ts +34 -0
- package/dist/ios/src/index.js +52 -20
- package/dist/ios/templates/CraftApp.swift +286 -27
- package/dist/ios/templates/Info.plist.template +1 -1
- package/dist/mobile.js +12 -0
- package/package.json +1 -1
package/dist/api/mobile.d.ts
CHANGED
|
@@ -727,7 +727,9 @@ export declare const pushNotifications: {
|
|
|
727
727
|
*/
|
|
728
728
|
onReceive(callback: (data: Record<string, unknown>) => void): () => void;
|
|
729
729
|
};
|
|
730
|
-
export type HealthDataType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts';
|
|
730
|
+
export type HealthDataType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate' | 'heartRateVariability' | 'bodyMass' | 'sleep';
|
|
731
|
+
/** The types with one value per day. `sleep` is hours asleep, counted on the day you woke. */
|
|
732
|
+
export type HealthDailyType = Exclude<HealthDataType, 'workouts'>;
|
|
731
733
|
export interface HealthDataOptions {
|
|
732
734
|
startDate?: number;
|
|
733
735
|
endDate?: number;
|
|
@@ -756,10 +758,46 @@ export interface HealthWorkout {
|
|
|
756
758
|
export interface HealthWorkoutResult {
|
|
757
759
|
id: string;
|
|
758
760
|
}
|
|
761
|
+
export interface HealthWorkoutQuery {
|
|
762
|
+
/** Epoch milliseconds. Defaults to 30 days ago. */
|
|
763
|
+
startDate?: number;
|
|
764
|
+
/** Epoch milliseconds. Defaults to now. */
|
|
765
|
+
endDate?: number;
|
|
766
|
+
/** At most this many, newest first. Defaults to 200, capped at 1000. */
|
|
767
|
+
limit?: number;
|
|
768
|
+
}
|
|
769
|
+
/** The activity of a workout read from Apple Health, named independently of the SDK. */
|
|
770
|
+
export type HealthWorkoutActivity = 'running' | 'cycling' | 'walking' | 'hiking' | 'swimming' | 'rowing' | 'elliptical' | 'stairClimbing' | 'yoga' | 'pilates' | 'strength' | 'hiit' | 'crossTraining' | 'crossCountrySkiing' | 'skiing' | 'paddling' | 'climbing' | 'dance' | 'mobility' | 'other';
|
|
771
|
+
/** A workout recorded by the watch, or by any app that writes to Apple Health. */
|
|
772
|
+
export interface HealthWorkoutSample {
|
|
773
|
+
/** HealthKit's UUID: stable across reads, so it can key an import. */
|
|
774
|
+
id: string;
|
|
775
|
+
type: HealthWorkoutActivity;
|
|
776
|
+
startDate: number;
|
|
777
|
+
endDate: number;
|
|
778
|
+
durationSeconds: number;
|
|
779
|
+
sourceName: string;
|
|
780
|
+
indoor: boolean;
|
|
781
|
+
distanceMeters?: number;
|
|
782
|
+
elevationGainMeters?: number;
|
|
783
|
+
activeEnergyCalories?: number;
|
|
784
|
+
averageHeartRate?: number;
|
|
785
|
+
maxHeartRate?: number;
|
|
786
|
+
}
|
|
787
|
+
export interface HealthDailyValue {
|
|
788
|
+
/** The local day, YYYY-MM-DD. */
|
|
789
|
+
date: string;
|
|
790
|
+
value: number;
|
|
791
|
+
unit: string;
|
|
792
|
+
}
|
|
759
793
|
export declare const health: {
|
|
760
794
|
requestAuthorization(types: HealthDataType[]): Promise<boolean>;
|
|
761
795
|
getData(type: HealthDataType, options?: HealthDataOptions): Promise<HealthDataResult>;
|
|
762
796
|
saveWorkout(workout: HealthWorkout): Promise<HealthWorkoutResult>;
|
|
797
|
+
/** Workouts in Apple Health, newest first. iOS only for now. */
|
|
798
|
+
getWorkouts(options?: HealthWorkoutQuery): Promise<HealthWorkoutSample[]>;
|
|
799
|
+
/** One value per local day between two dates. iOS only for now. */
|
|
800
|
+
getDailyStatistics(type: HealthDailyType, options?: HealthDataOptions): Promise<HealthDailyValue[]>;
|
|
763
801
|
};
|
|
764
802
|
export interface LiveActivityState {
|
|
765
803
|
status?: string;
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* `craft` message handler.
|
|
23
23
|
*/
|
|
24
24
|
export declare function isWebKitHost(): boolean;
|
|
25
|
+
/** Desktop window handles use the injected Craft envelope on both WebKit and WebView2. */
|
|
26
|
+
export declare function isDesktopWebViewHost(): boolean;
|
|
25
27
|
/**
|
|
26
28
|
* Send a fire-and-forget message into the WKWebView host. Returns true if
|
|
27
29
|
* the message was delivered, false otherwise.
|
package/dist/cli.js
CHANGED
|
@@ -1054,6 +1054,7 @@ __export(exports_src, {
|
|
|
1054
1054
|
showSimulator: () => showSimulator,
|
|
1055
1055
|
run: () => run,
|
|
1056
1056
|
resolveRuntimeDir: () => resolveRuntimeDir,
|
|
1057
|
+
resolveAppearance: () => resolveAppearance,
|
|
1057
1058
|
renderWatchEntitlements: () => renderWatchEntitlements,
|
|
1058
1059
|
renderUsageDescriptions: () => renderUsageDescriptions,
|
|
1059
1060
|
renderUrlTypes: () => renderUrlTypes,
|
|
@@ -1063,6 +1064,8 @@ __export(exports_src, {
|
|
|
1063
1064
|
renderEntitlements: () => renderEntitlements,
|
|
1064
1065
|
renderDeviceFamilies: () => renderDeviceFamilies,
|
|
1065
1066
|
renderBackgroundModes: () => renderBackgroundModes,
|
|
1067
|
+
renderAppearance: () => renderAppearance,
|
|
1068
|
+
productName: () => productName,
|
|
1066
1069
|
pickSimulator: () => pickSimulator,
|
|
1067
1070
|
orderSimulators: () => orderSimulators,
|
|
1068
1071
|
open: () => open,
|
|
@@ -1084,6 +1087,19 @@ function plistString(key, value) {
|
|
|
1084
1087
|
return ` <key>${key}</key>
|
|
1085
1088
|
<string>${xmlEscape(value)}</string>`;
|
|
1086
1089
|
}
|
|
1090
|
+
function resolveAppearance(config) {
|
|
1091
|
+
if (config.appearance)
|
|
1092
|
+
return config.appearance;
|
|
1093
|
+
return config.darkMode === false ? "light" : "dark";
|
|
1094
|
+
}
|
|
1095
|
+
function renderAppearance(config) {
|
|
1096
|
+
const appearance = resolveAppearance(config);
|
|
1097
|
+
if (appearance === "system")
|
|
1098
|
+
return { interfaceStyle: "Automatic", statusBarStyle: "UIStatusBarStyleDefault" };
|
|
1099
|
+
if (appearance === "light")
|
|
1100
|
+
return { interfaceStyle: "Light", statusBarStyle: "UIStatusBarStyleDarkContent" };
|
|
1101
|
+
return { interfaceStyle: "Dark", statusBarStyle: "UIStatusBarStyleLightContent" };
|
|
1102
|
+
}
|
|
1087
1103
|
function renderUsageDescriptions(config) {
|
|
1088
1104
|
const entries = [
|
|
1089
1105
|
[config.enableSpeechRecognition, "NSSpeechRecognitionUsageDescription", `${config.appName} uses speech recognition for voice input.`],
|
|
@@ -1276,17 +1292,25 @@ function renderAssetCatalog(output, config) {
|
|
|
1276
1292
|
info: { author: "xcode", version: 1 }
|
|
1277
1293
|
}, null, 2)}
|
|
1278
1294
|
`);
|
|
1279
|
-
const
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
color:
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1295
|
+
const colorEntry = (hex) => {
|
|
1296
|
+
const color = hex?.replace(/^#/, "") || "000000";
|
|
1297
|
+
const normalized = color.length === 3 ? [...color].map((value) => `${value}${value}`).join("") : color.padEnd(6, "0").slice(0, 6);
|
|
1298
|
+
const components = [0, 2, 4].map((index) => (Number.parseInt(normalized.slice(index, index + 2), 16) / 255).toFixed(3));
|
|
1299
|
+
return {
|
|
1300
|
+
"color-space": "srgb",
|
|
1301
|
+
components: { alpha: "1.000", blue: components[2], green: components[1], red: components[0] }
|
|
1302
|
+
};
|
|
1303
|
+
};
|
|
1304
|
+
const colors2 = [{ color: colorEntry(config.backgroundColor), idiom: "universal" }];
|
|
1305
|
+
if (config.backgroundColorDark) {
|
|
1306
|
+
colors2.push({
|
|
1307
|
+
appearances: [{ appearance: "luminosity", value: "dark" }],
|
|
1308
|
+
color: colorEntry(config.backgroundColorDark),
|
|
1288
1309
|
idiom: "universal"
|
|
1289
|
-
}
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
writeFileSync3(join2(launchBackground, "Contents.json"), `${JSON.stringify({
|
|
1313
|
+
colors: colors2,
|
|
1290
1314
|
info: { author: "xcode", version: 1 }
|
|
1291
1315
|
}, null, 2)}
|
|
1292
1316
|
`);
|
|
@@ -1356,10 +1380,18 @@ async function refreshRuntime(output, override) {
|
|
|
1356
1380
|
await installRuntime(output, dir);
|
|
1357
1381
|
console.log(" Refreshed the Zig runtime from", dir);
|
|
1358
1382
|
}
|
|
1383
|
+
function productName(name) {
|
|
1384
|
+
const words = name.split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
1385
|
+
const joined = words.map((word) => word[0].toUpperCase() + word.slice(1)).join("");
|
|
1386
|
+
if (!joined)
|
|
1387
|
+
return "Craft";
|
|
1388
|
+
return /^\d/.test(joined) ? `App${joined}` : joined;
|
|
1389
|
+
}
|
|
1359
1390
|
async function init(options) {
|
|
1360
|
-
const { name, bundleId, teamId, output } = options;
|
|
1391
|
+
const { name: displayName, bundleId, teamId, output } = options;
|
|
1392
|
+
const name = productName(displayName);
|
|
1361
1393
|
console.log(`
|
|
1362
|
-
\u26A1 Initializing Craft iOS project: ${
|
|
1394
|
+
\u26A1 Initializing Craft iOS project: ${displayName}`);
|
|
1363
1395
|
console.log(` Output: ${output}
|
|
1364
1396
|
`);
|
|
1365
1397
|
const dirs = [output, join2(output, "Sources"), join2(output, "Shared"), join2(output, "dist")];
|
|
@@ -1370,11 +1402,11 @@ async function init(options) {
|
|
|
1370
1402
|
mkdirSync2(dir, { recursive: true });
|
|
1371
1403
|
}
|
|
1372
1404
|
}
|
|
1373
|
-
const finalBundleId = bundleId || `com.craft.${name.toLowerCase()
|
|
1405
|
+
const finalBundleId = bundleId || `com.craft.${name.toLowerCase()}`;
|
|
1374
1406
|
const bundleIdPrefix = finalBundleId.split(".").slice(0, -1).join(".");
|
|
1375
1407
|
const config = {
|
|
1376
1408
|
...DEFAULT_CONFIG,
|
|
1377
|
-
appName:
|
|
1409
|
+
appName: displayName,
|
|
1378
1410
|
bundleId: finalBundleId,
|
|
1379
1411
|
teamId: teamId || "",
|
|
1380
1412
|
...options.config
|
|
@@ -1386,7 +1418,7 @@ async function init(options) {
|
|
|
1386
1418
|
const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
1387
1419
|
writeFileSync3(join2(output, "Sources", `${name}App.swift`), swiftSource);
|
|
1388
1420
|
const infoPlistTemplate = readFileSync3(join2(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
|
|
1389
|
-
const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g,
|
|
1421
|
+
const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
|
|
1390
1422
|
<true/>
|
|
1391
1423
|
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
|
1392
1424
|
<true/>` : "");
|
|
@@ -1469,10 +1501,10 @@ ${nativeDependencies.join(`
|
|
|
1469
1501
|
if (config.enableWatchApp) {
|
|
1470
1502
|
const watchSource = readFileSync3(join2(TEMPLATES_DIR, "CraftWatchApp.swift.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, name);
|
|
1471
1503
|
writeFileSync3(join2(output, "WatchExtension", `${name}WatchApp.swift`), watchSource);
|
|
1472
|
-
const watchInfo = readFileSync3(join2(TEMPLATES_DIR, "WatchApp.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g,
|
|
1504
|
+
const watchInfo = readFileSync3(join2(TEMPLATES_DIR, "WatchApp.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
1473
1505
|
writeFileSync3(join2(output, "WatchApp", "Info.plist"), watchInfo);
|
|
1474
1506
|
writeFileSync3(join2(output, "WatchApp", "Watch.entitlements"), renderWatchEntitlements(config));
|
|
1475
|
-
const watchExtensionInfo = readFileSync3(join2(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g,
|
|
1507
|
+
const watchExtensionInfo = readFileSync3(join2(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
1476
1508
|
writeFileSync3(join2(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
|
|
1477
1509
|
writeFileSync3(join2(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
|
|
1478
1510
|
}
|
|
@@ -1481,7 +1513,7 @@ ${nativeDependencies.join(`
|
|
|
1481
1513
|
<head>
|
|
1482
1514
|
<meta charset="UTF-8">
|
|
1483
1515
|
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
1484
|
-
<title>${
|
|
1516
|
+
<title>${xmlEscape(displayName)}</title>
|
|
1485
1517
|
<style>
|
|
1486
1518
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
1487
1519
|
body {
|
|
@@ -1502,7 +1534,7 @@ ${nativeDependencies.join(`
|
|
|
1502
1534
|
</head>
|
|
1503
1535
|
<body>
|
|
1504
1536
|
<div class="container">
|
|
1505
|
-
<h1>\u26A1 ${
|
|
1537
|
+
<h1>\u26A1 ${xmlEscape(displayName)}</h1>
|
|
1506
1538
|
<p>Built with Craft iOS</p>
|
|
1507
1539
|
<p class="ready" id="status">Waiting for Craft bridge...</p>
|
|
1508
1540
|
</div>
|
|
@@ -1552,7 +1584,7 @@ async function build(options) {
|
|
|
1552
1584
|
if (result.exitCode === 0) {
|
|
1553
1585
|
console.log(" Running xcodegen...");
|
|
1554
1586
|
await $`cd ${output} && xcodegen generate`.quiet();
|
|
1555
|
-
console.log(`\u2705 Xcode project created: ${config.appName}.xcodeproj`);
|
|
1587
|
+
console.log(`\u2705 Xcode project created: ${productName(config.appName)}.xcodeproj`);
|
|
1556
1588
|
} else {
|
|
1557
1589
|
throw new Error("xcodegen not found");
|
|
1558
1590
|
}
|
|
@@ -4587,7 +4619,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
4587
4619
|
`);
|
|
4588
4620
|
}
|
|
4589
4621
|
// package.json
|
|
4590
|
-
var version = "0.0.
|
|
4622
|
+
var version = "0.0.99";
|
|
4591
4623
|
|
|
4592
4624
|
// bin/cli.ts
|
|
4593
4625
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
|
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
|
|
|
310
310
|
`);
|
|
311
311
|
}
|
|
312
312
|
// package.json
|
|
313
|
-
var version = "0.0.
|
|
313
|
+
var version = "0.0.99";
|
|
314
314
|
|
|
315
315
|
// src/sdk-version.ts
|
|
316
316
|
var SDK_VERSION = version;
|
|
@@ -2296,6 +2296,14 @@ function ensureGlobalHooks() {
|
|
|
2296
2296
|
function isWebKitHost() {
|
|
2297
2297
|
return typeof window !== "undefined" && !!window.webkit?.messageHandlers?.craft;
|
|
2298
2298
|
}
|
|
2299
|
+
function isDesktopWebViewHost() {
|
|
2300
|
+
if (isWebKitHost())
|
|
2301
|
+
return true;
|
|
2302
|
+
if (typeof window === "undefined")
|
|
2303
|
+
return false;
|
|
2304
|
+
const host2 = window;
|
|
2305
|
+
return typeof host2.chrome?.webview?.postMessage === "function";
|
|
2306
|
+
}
|
|
2299
2307
|
function postWebKit(message) {
|
|
2300
2308
|
if (!isWebKitHost())
|
|
2301
2309
|
return false;
|
|
@@ -2602,7 +2610,7 @@ class Window {
|
|
|
2602
2610
|
return this._call("executeJavaScript", { code });
|
|
2603
2611
|
}
|
|
2604
2612
|
async _call(action, data) {
|
|
2605
|
-
if (
|
|
2613
|
+
if (isDesktopWebViewHost()) {
|
|
2606
2614
|
const injected = getInjectedWindowBridge();
|
|
2607
2615
|
if (!injected?._call) {
|
|
2608
2616
|
throw new Error("Craft window bridge is unavailable");
|
|
@@ -2643,7 +2651,7 @@ class WindowManager {
|
|
|
2643
2651
|
throw new Error(`Window id "${CURRENT_WINDOW_ID}" is reserved for the current window`);
|
|
2644
2652
|
}
|
|
2645
2653
|
const existing = this._windows.get(id);
|
|
2646
|
-
if (
|
|
2654
|
+
if (isDesktopWebViewHost()) {
|
|
2647
2655
|
const injected = getInjectedWindowBridge();
|
|
2648
2656
|
if (!injected?.open) {
|
|
2649
2657
|
throw new Error("Craft window bridge is unavailable");
|
|
@@ -2663,7 +2671,7 @@ class WindowManager {
|
|
|
2663
2671
|
return win;
|
|
2664
2672
|
}
|
|
2665
2673
|
async getFocused() {
|
|
2666
|
-
if (
|
|
2674
|
+
if (isDesktopWebViewHost()) {
|
|
2667
2675
|
const injected = getInjectedWindowBridge();
|
|
2668
2676
|
if (!injected?._call) {
|
|
2669
2677
|
throw new Error("Craft window bridge is unavailable");
|
package/dist/index.js
CHANGED
|
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
|
|
|
58
58
|
`);
|
|
59
59
|
}
|
|
60
60
|
// package.json
|
|
61
|
-
var version = "0.0.
|
|
61
|
+
var version = "0.0.99";
|
|
62
62
|
|
|
63
63
|
// src/sdk-version.ts
|
|
64
64
|
var SDK_VERSION = version;
|
|
@@ -2056,6 +2056,14 @@ function ensureGlobalHooks() {
|
|
|
2056
2056
|
function isWebKitHost() {
|
|
2057
2057
|
return typeof window !== "undefined" && !!window.webkit?.messageHandlers?.craft;
|
|
2058
2058
|
}
|
|
2059
|
+
function isDesktopWebViewHost() {
|
|
2060
|
+
if (isWebKitHost())
|
|
2061
|
+
return true;
|
|
2062
|
+
if (typeof window === "undefined")
|
|
2063
|
+
return false;
|
|
2064
|
+
const host2 = window;
|
|
2065
|
+
return typeof host2.chrome?.webview?.postMessage === "function";
|
|
2066
|
+
}
|
|
2059
2067
|
function postWebKit(message) {
|
|
2060
2068
|
if (!isWebKitHost())
|
|
2061
2069
|
return false;
|
|
@@ -2362,7 +2370,7 @@ class Window {
|
|
|
2362
2370
|
return this._call("executeJavaScript", { code });
|
|
2363
2371
|
}
|
|
2364
2372
|
async _call(action, data) {
|
|
2365
|
-
if (
|
|
2373
|
+
if (isDesktopWebViewHost()) {
|
|
2366
2374
|
const injected = getInjectedWindowBridge();
|
|
2367
2375
|
if (!injected?._call) {
|
|
2368
2376
|
throw new Error("Craft window bridge is unavailable");
|
|
@@ -2403,7 +2411,7 @@ class WindowManager {
|
|
|
2403
2411
|
throw new Error(`Window id "${CURRENT_WINDOW_ID}" is reserved for the current window`);
|
|
2404
2412
|
}
|
|
2405
2413
|
const existing = this._windows.get(id);
|
|
2406
|
-
if (
|
|
2414
|
+
if (isDesktopWebViewHost()) {
|
|
2407
2415
|
const injected = getInjectedWindowBridge();
|
|
2408
2416
|
if (!injected?.open) {
|
|
2409
2417
|
throw new Error("Craft window bridge is unavailable");
|
|
@@ -2423,7 +2431,7 @@ class WindowManager {
|
|
|
2423
2431
|
return win;
|
|
2424
2432
|
}
|
|
2425
2433
|
async getFocused() {
|
|
2426
|
-
if (
|
|
2434
|
+
if (isDesktopWebViewHost()) {
|
|
2427
2435
|
const injected = getInjectedWindowBridge();
|
|
2428
2436
|
if (!injected?._call) {
|
|
2429
2437
|
throw new Error("Craft window bridge is unavailable");
|
package/dist/ios/src/index.d.ts
CHANGED
|
@@ -3,13 +3,22 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Generates native iOS apps from web content using WKWebView.
|
|
5
5
|
*/
|
|
6
|
+
export type CraftAppearance = 'light' | 'dark' | 'system';
|
|
6
7
|
export interface CraftConfig {
|
|
7
8
|
appName: string;
|
|
8
9
|
bundleId: string;
|
|
9
10
|
version?: string;
|
|
10
11
|
buildNumber?: string;
|
|
11
12
|
darkMode?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Which interface style the app draws in. `system` follows the phone's
|
|
15
|
+
* Light/Dark setting (and the page's `prefers-color-scheme` with it); `light`
|
|
16
|
+
* and `dark` pin one. Unset keeps the older `darkMode` switch.
|
|
17
|
+
*/
|
|
18
|
+
appearance?: CraftAppearance;
|
|
12
19
|
backgroundColor?: string;
|
|
20
|
+
/** The launch and webview background while the phone is in Dark Mode. */
|
|
21
|
+
backgroundColorDark?: string;
|
|
13
22
|
enableSpeechRecognition?: boolean;
|
|
14
23
|
enableHaptics?: boolean;
|
|
15
24
|
enableShare?: boolean;
|
|
@@ -115,6 +124,20 @@ export interface RunOptions {
|
|
|
115
124
|
output: string;
|
|
116
125
|
}
|
|
117
126
|
export declare function renderDeviceFamilies(config: CraftConfig): string;
|
|
127
|
+
/** `appearance` when set, otherwise what the older `darkMode` switch pinned. */
|
|
128
|
+
export declare function resolveAppearance(config: Pick<CraftConfig, 'appearance' | 'darkMode'>): CraftAppearance;
|
|
129
|
+
/**
|
|
130
|
+
* The Info.plist interface style and the status bar that reads on it.
|
|
131
|
+
*
|
|
132
|
+
* The status bar used to be `LightContent` whatever the app looked like:
|
|
133
|
+
* white text that vanished on a light page. It now follows the appearance —
|
|
134
|
+
* dark text on a light app, light on a dark one, and the system's choice when
|
|
135
|
+
* the app follows the system.
|
|
136
|
+
*/
|
|
137
|
+
export declare function renderAppearance(config: Pick<CraftConfig, 'appearance' | 'darkMode'>): {
|
|
138
|
+
interfaceStyle: string;
|
|
139
|
+
statusBarStyle: string;
|
|
140
|
+
};
|
|
118
141
|
export declare function renderUsageDescriptions(config: CraftConfig): string;
|
|
119
142
|
export declare function renderOrientations(config: CraftConfig): string;
|
|
120
143
|
export declare function renderUrlTypes(config: CraftConfig): string;
|
|
@@ -181,6 +204,17 @@ export declare function resolveRuntimeDir(override?: string | null): string | nu
|
|
|
181
204
|
* Returns true when a runtime was installed.
|
|
182
205
|
*/
|
|
183
206
|
export declare function installRuntime(output: string, runtimeDir: string): Promise<boolean>;
|
|
207
|
+
/**
|
|
208
|
+
* The identifier an app's display name becomes wherever Xcode or Swift needs
|
|
209
|
+
* one: the project, the target, the product and the `@main` type.
|
|
210
|
+
*
|
|
211
|
+
* The display name goes in front of people and may say anything; an
|
|
212
|
+
* identifier may not. "HQ.training" used to become `struct HQ.trainingApp`,
|
|
213
|
+
* which does not compile. Words are joined in PascalCase ("HQTraining"), and
|
|
214
|
+
* a name that starts with a digit or has no letters at all still yields a
|
|
215
|
+
* valid Swift type.
|
|
216
|
+
*/
|
|
217
|
+
export declare function productName(name: string): string;
|
|
184
218
|
export declare function init(options: InitOptions): Promise<void>;
|
|
185
219
|
/**
|
|
186
220
|
* Build web assets and generate Xcode project
|
package/dist/ios/src/index.js
CHANGED
|
@@ -64,6 +64,19 @@ function plistString(key, value) {
|
|
|
64
64
|
return ` <key>${key}</key>
|
|
65
65
|
<string>${xmlEscape(value)}</string>`;
|
|
66
66
|
}
|
|
67
|
+
function resolveAppearance(config) {
|
|
68
|
+
if (config.appearance)
|
|
69
|
+
return config.appearance;
|
|
70
|
+
return config.darkMode === false ? "light" : "dark";
|
|
71
|
+
}
|
|
72
|
+
function renderAppearance(config) {
|
|
73
|
+
const appearance = resolveAppearance(config);
|
|
74
|
+
if (appearance === "system")
|
|
75
|
+
return { interfaceStyle: "Automatic", statusBarStyle: "UIStatusBarStyleDefault" };
|
|
76
|
+
if (appearance === "light")
|
|
77
|
+
return { interfaceStyle: "Light", statusBarStyle: "UIStatusBarStyleDarkContent" };
|
|
78
|
+
return { interfaceStyle: "Dark", statusBarStyle: "UIStatusBarStyleLightContent" };
|
|
79
|
+
}
|
|
67
80
|
function renderUsageDescriptions(config) {
|
|
68
81
|
const entries = [
|
|
69
82
|
[config.enableSpeechRecognition, "NSSpeechRecognitionUsageDescription", `${config.appName} uses speech recognition for voice input.`],
|
|
@@ -256,17 +269,25 @@ function renderAssetCatalog(output, config) {
|
|
|
256
269
|
info: { author: "xcode", version: 1 }
|
|
257
270
|
}, null, 2)}
|
|
258
271
|
`);
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
color:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
272
|
+
const colorEntry = (hex) => {
|
|
273
|
+
const color = hex?.replace(/^#/, "") || "000000";
|
|
274
|
+
const normalized = color.length === 3 ? [...color].map((value) => `${value}${value}`).join("") : color.padEnd(6, "0").slice(0, 6);
|
|
275
|
+
const components = [0, 2, 4].map((index) => (Number.parseInt(normalized.slice(index, index + 2), 16) / 255).toFixed(3));
|
|
276
|
+
return {
|
|
277
|
+
"color-space": "srgb",
|
|
278
|
+
components: { alpha: "1.000", blue: components[2], green: components[1], red: components[0] }
|
|
279
|
+
};
|
|
280
|
+
};
|
|
281
|
+
const colors = [{ color: colorEntry(config.backgroundColor), idiom: "universal" }];
|
|
282
|
+
if (config.backgroundColorDark) {
|
|
283
|
+
colors.push({
|
|
284
|
+
appearances: [{ appearance: "luminosity", value: "dark" }],
|
|
285
|
+
color: colorEntry(config.backgroundColorDark),
|
|
268
286
|
idiom: "universal"
|
|
269
|
-
}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
writeFileSync(join(launchBackground, "Contents.json"), `${JSON.stringify({
|
|
290
|
+
colors,
|
|
270
291
|
info: { author: "xcode", version: 1 }
|
|
271
292
|
}, null, 2)}
|
|
272
293
|
`);
|
|
@@ -340,10 +361,18 @@ async function refreshRuntime(output, override) {
|
|
|
340
361
|
await installRuntime(output, dir);
|
|
341
362
|
console.log(" Refreshed the Zig runtime from", dir);
|
|
342
363
|
}
|
|
364
|
+
function productName(name) {
|
|
365
|
+
const words = name.split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
366
|
+
const joined = words.map((word) => word[0].toUpperCase() + word.slice(1)).join("");
|
|
367
|
+
if (!joined)
|
|
368
|
+
return "Craft";
|
|
369
|
+
return /^\d/.test(joined) ? `App${joined}` : joined;
|
|
370
|
+
}
|
|
343
371
|
async function init(options) {
|
|
344
|
-
const { name, bundleId, teamId, output } = options;
|
|
372
|
+
const { name: displayName, bundleId, teamId, output } = options;
|
|
373
|
+
const name = productName(displayName);
|
|
345
374
|
console.log(`
|
|
346
|
-
\u26A1 Initializing Craft iOS project: ${
|
|
375
|
+
\u26A1 Initializing Craft iOS project: ${displayName}`);
|
|
347
376
|
console.log(` Output: ${output}
|
|
348
377
|
`);
|
|
349
378
|
const dirs = [output, join(output, "Sources"), join(output, "Shared"), join(output, "dist")];
|
|
@@ -354,11 +383,11 @@ async function init(options) {
|
|
|
354
383
|
mkdirSync(dir, { recursive: true });
|
|
355
384
|
}
|
|
356
385
|
}
|
|
357
|
-
const finalBundleId = bundleId || `com.craft.${name.toLowerCase()
|
|
386
|
+
const finalBundleId = bundleId || `com.craft.${name.toLowerCase()}`;
|
|
358
387
|
const bundleIdPrefix = finalBundleId.split(".").slice(0, -1).join(".");
|
|
359
388
|
const config = {
|
|
360
389
|
...DEFAULT_CONFIG,
|
|
361
|
-
appName:
|
|
390
|
+
appName: displayName,
|
|
362
391
|
bundleId: finalBundleId,
|
|
363
392
|
teamId: teamId || "",
|
|
364
393
|
...options.config
|
|
@@ -370,7 +399,7 @@ async function init(options) {
|
|
|
370
399
|
const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
371
400
|
writeFileSync(join(output, "Sources", `${name}App.swift`), swiftSource);
|
|
372
401
|
const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
|
|
373
|
-
const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g,
|
|
402
|
+
const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
|
|
374
403
|
<true/>
|
|
375
404
|
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
|
376
405
|
<true/>` : "");
|
|
@@ -453,10 +482,10 @@ ${nativeDependencies.join(`
|
|
|
453
482
|
if (config.enableWatchApp) {
|
|
454
483
|
const watchSource = readFileSync(join(TEMPLATES_DIR, "CraftWatchApp.swift.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, name);
|
|
455
484
|
writeFileSync(join(output, "WatchExtension", `${name}WatchApp.swift`), watchSource);
|
|
456
|
-
const watchInfo = readFileSync(join(TEMPLATES_DIR, "WatchApp.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g,
|
|
485
|
+
const watchInfo = readFileSync(join(TEMPLATES_DIR, "WatchApp.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
457
486
|
writeFileSync(join(output, "WatchApp", "Info.plist"), watchInfo);
|
|
458
487
|
writeFileSync(join(output, "WatchApp", "Watch.entitlements"), renderWatchEntitlements(config));
|
|
459
|
-
const watchExtensionInfo = readFileSync(join(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g,
|
|
488
|
+
const watchExtensionInfo = readFileSync(join(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
460
489
|
writeFileSync(join(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
|
|
461
490
|
writeFileSync(join(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
|
|
462
491
|
}
|
|
@@ -465,7 +494,7 @@ ${nativeDependencies.join(`
|
|
|
465
494
|
<head>
|
|
466
495
|
<meta charset="UTF-8">
|
|
467
496
|
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
468
|
-
<title>${
|
|
497
|
+
<title>${xmlEscape(displayName)}</title>
|
|
469
498
|
<style>
|
|
470
499
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
471
500
|
body {
|
|
@@ -486,7 +515,7 @@ ${nativeDependencies.join(`
|
|
|
486
515
|
</head>
|
|
487
516
|
<body>
|
|
488
517
|
<div class="container">
|
|
489
|
-
<h1>\u26A1 ${
|
|
518
|
+
<h1>\u26A1 ${xmlEscape(displayName)}</h1>
|
|
490
519
|
<p>Built with Craft iOS</p>
|
|
491
520
|
<p class="ready" id="status">Waiting for Craft bridge...</p>
|
|
492
521
|
</div>
|
|
@@ -536,7 +565,7 @@ async function build(options) {
|
|
|
536
565
|
if (result.exitCode === 0) {
|
|
537
566
|
console.log(" Running xcodegen...");
|
|
538
567
|
await $`cd ${output} && xcodegen generate`.quiet();
|
|
539
|
-
console.log(`\u2705 Xcode project created: ${config.appName}.xcodeproj`);
|
|
568
|
+
console.log(`\u2705 Xcode project created: ${productName(config.appName)}.xcodeproj`);
|
|
540
569
|
} else {
|
|
541
570
|
throw new Error("xcodegen not found");
|
|
542
571
|
}
|
|
@@ -655,6 +684,7 @@ export {
|
|
|
655
684
|
showSimulator,
|
|
656
685
|
run,
|
|
657
686
|
resolveRuntimeDir,
|
|
687
|
+
resolveAppearance,
|
|
658
688
|
renderWatchEntitlements,
|
|
659
689
|
renderUsageDescriptions,
|
|
660
690
|
renderUrlTypes,
|
|
@@ -664,6 +694,8 @@ export {
|
|
|
664
694
|
renderEntitlements,
|
|
665
695
|
renderDeviceFamilies,
|
|
666
696
|
renderBackgroundModes,
|
|
697
|
+
renderAppearance,
|
|
698
|
+
productName,
|
|
667
699
|
pickSimulator,
|
|
668
700
|
orderSimulators,
|
|
669
701
|
open,
|
|
@@ -223,7 +223,7 @@ struct CraftApp: App {
|
|
|
223
223
|
WindowGroup {
|
|
224
224
|
CraftWebView(config: appState.config)
|
|
225
225
|
.ignoresSafeArea()
|
|
226
|
-
.preferredColorScheme(appState.config.
|
|
226
|
+
.preferredColorScheme(appState.config.colorScheme)
|
|
227
227
|
.environmentObject(appState)
|
|
228
228
|
.onOpenURL { url in
|
|
229
229
|
// Handle deep links and universal links
|
|
@@ -353,7 +353,12 @@ struct CraftConfig: Codable {
|
|
|
353
353
|
var appName: String = "Craft App"
|
|
354
354
|
var bundleId: String = "com.craft.app"
|
|
355
355
|
var darkMode: Bool = true
|
|
356
|
+
/// "light", "dark" or "system". Absent in configs older than the field,
|
|
357
|
+
/// which keep what `darkMode` pinned.
|
|
358
|
+
var appearance: String? = nil
|
|
356
359
|
var backgroundColor: String = "#1a1a2e"
|
|
360
|
+
/// The background while the phone is in Dark Mode; the light one if unset.
|
|
361
|
+
var backgroundColorDark: String? = nil
|
|
357
362
|
var enableSpeechRecognition: Bool = false
|
|
358
363
|
var enableHaptics: Bool = false
|
|
359
364
|
var enableShare: Bool = false
|
|
@@ -393,6 +398,26 @@ struct CraftConfig: Codable {
|
|
|
393
398
|
var trustedOrigins: [String] = []
|
|
394
399
|
}
|
|
395
400
|
|
|
401
|
+
extension CraftConfig {
|
|
402
|
+
/// The scheme SwiftUI pins, or nil to follow the phone's setting.
|
|
403
|
+
var colorScheme: ColorScheme? {
|
|
404
|
+
switch appearance {
|
|
405
|
+
case "system": return nil
|
|
406
|
+
case "light": return .light
|
|
407
|
+
case "dark": return .dark
|
|
408
|
+
default: return darkMode ? .dark : .light
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/// The webview's background, resolved per trait so a Dark Mode switch
|
|
413
|
+
/// repaints it without a reload.
|
|
414
|
+
var resolvedBackgroundColor: UIColor {
|
|
415
|
+
let light = UIColor(hex: backgroundColor) ?? .black
|
|
416
|
+
guard let darkHex = backgroundColorDark, let dark = UIColor(hex: darkHex) else { return light }
|
|
417
|
+
return UIColor { traits in traits.userInterfaceStyle == .dark ? dark : light }
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
396
421
|
// MARK: - WebView
|
|
397
422
|
final class BundledAssetSchemeHandler: NSObject, WKURLSchemeHandler {
|
|
398
423
|
private let rootURL: URL?
|
|
@@ -517,7 +542,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
517
542
|
CraftEventManager.shared.setWebView(webView)
|
|
518
543
|
|
|
519
544
|
// Parse background color
|
|
520
|
-
let bgColor =
|
|
545
|
+
let bgColor = config.resolvedBackgroundColor
|
|
521
546
|
webView.backgroundColor = bgColor
|
|
522
547
|
webView.scrollView.backgroundColor = bgColor
|
|
523
548
|
|
|
@@ -1298,6 +1323,32 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1298
1323
|
} else {
|
|
1299
1324
|
rejectCallback(callbackId, error: "HealthKit is disabled")
|
|
1300
1325
|
}
|
|
1326
|
+
case "getHealthWorkouts":
|
|
1327
|
+
if config.enableHealthKit {
|
|
1328
|
+
getHealthWorkouts(
|
|
1329
|
+
startDate: body["startDate"] as? Double,
|
|
1330
|
+
endDate: body["endDate"] as? Double,
|
|
1331
|
+
limit: body["limit"] as? Int,
|
|
1332
|
+
callbackId: callbackId
|
|
1333
|
+
)
|
|
1334
|
+
} else {
|
|
1335
|
+
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
1336
|
+
}
|
|
1337
|
+
case "getHealthDailyStatistics":
|
|
1338
|
+
if config.enableHealthKit {
|
|
1339
|
+
if let dataType = body["type"] as? String {
|
|
1340
|
+
getHealthDailyStatistics(
|
|
1341
|
+
type: dataType,
|
|
1342
|
+
startDate: body["startDate"] as? Double,
|
|
1343
|
+
endDate: body["endDate"] as? Double,
|
|
1344
|
+
callbackId: callbackId
|
|
1345
|
+
)
|
|
1346
|
+
} else {
|
|
1347
|
+
rejectCallback(callbackId, error: "getHealthDailyStatistics was called without a type", code: "INVALID_ARGUMENT")
|
|
1348
|
+
}
|
|
1349
|
+
} else {
|
|
1350
|
+
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
1351
|
+
}
|
|
1301
1352
|
|
|
1302
1353
|
// MARK: - Live Activities
|
|
1303
1354
|
case "startLiveActivity":
|
|
@@ -3341,7 +3392,15 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3341
3392
|
options = options || {};
|
|
3342
3393
|
return craft._invoke('getHealthData', {type: type, startDate: options.startDate, endDate: options.endDate});
|
|
3343
3394
|
},
|
|
3344
|
-
saveWorkout: function(workout) { return craft._invoke('saveHealthWorkout', workout || {}); }
|
|
3395
|
+
saveWorkout: function(workout) { return craft._invoke('saveHealthWorkout', workout || {}); },
|
|
3396
|
+
getWorkouts: function(options) {
|
|
3397
|
+
options = options || {};
|
|
3398
|
+
return craft._invoke('getHealthWorkouts', {startDate: options.startDate, endDate: options.endDate, limit: options.limit});
|
|
3399
|
+
},
|
|
3400
|
+
getDailyStatistics: function(type, options) {
|
|
3401
|
+
options = options || {};
|
|
3402
|
+
return craft._invoke('getHealthDailyStatistics', {type: type, startDate: options.startDate, endDate: options.endDate});
|
|
3403
|
+
}
|
|
3345
3404
|
};
|
|
3346
3405
|
craft.liveActivity = {
|
|
3347
3406
|
start: function(options) { return craft._invoke('startLiveActivity', options || {}); },
|
|
@@ -5100,6 +5159,30 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5100
5159
|
}
|
|
5101
5160
|
case "workouts":
|
|
5102
5161
|
readTypes.insert(HKObjectType.workoutType())
|
|
5162
|
+
// A workout's own heart rate and energy are read through
|
|
5163
|
+
// these; without them every workout comes back bare.
|
|
5164
|
+
if let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate) {
|
|
5165
|
+
readTypes.insert(heartType)
|
|
5166
|
+
}
|
|
5167
|
+
if let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) {
|
|
5168
|
+
readTypes.insert(energyType)
|
|
5169
|
+
}
|
|
5170
|
+
case "restingHeartRate":
|
|
5171
|
+
if let restingType = HKQuantityType.quantityType(forIdentifier: .restingHeartRate) {
|
|
5172
|
+
readTypes.insert(restingType)
|
|
5173
|
+
}
|
|
5174
|
+
case "heartRateVariability":
|
|
5175
|
+
if let hrvType = HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN) {
|
|
5176
|
+
readTypes.insert(hrvType)
|
|
5177
|
+
}
|
|
5178
|
+
case "bodyMass":
|
|
5179
|
+
if let massType = HKQuantityType.quantityType(forIdentifier: .bodyMass) {
|
|
5180
|
+
readTypes.insert(massType)
|
|
5181
|
+
}
|
|
5182
|
+
case "sleep":
|
|
5183
|
+
if let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) {
|
|
5184
|
+
readTypes.insert(sleepType)
|
|
5185
|
+
}
|
|
5103
5186
|
default:
|
|
5104
5187
|
break
|
|
5105
5188
|
}
|
|
@@ -5120,44 +5203,32 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5120
5203
|
return
|
|
5121
5204
|
}
|
|
5122
5205
|
|
|
5123
|
-
|
|
5124
|
-
var unit: HKUnit?
|
|
5125
|
-
|
|
5126
|
-
switch type {
|
|
5127
|
-
case "steps":
|
|
5128
|
-
quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)
|
|
5129
|
-
unit = .count()
|
|
5130
|
-
case "heartRate":
|
|
5131
|
-
quantityType = HKQuantityType.quantityType(forIdentifier: .heartRate)
|
|
5132
|
-
unit = HKUnit(from: "count/min")
|
|
5133
|
-
case "activeEnergy":
|
|
5134
|
-
quantityType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)
|
|
5135
|
-
unit = .kilocalorie()
|
|
5136
|
-
case "distance":
|
|
5137
|
-
quantityType = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)
|
|
5138
|
-
unit = .meter()
|
|
5139
|
-
default:
|
|
5206
|
+
guard let spec = Self.healthQuantity(type), let qType = spec.type else {
|
|
5140
5207
|
rejectCallback(callbackId, error: "Unknown health data type")
|
|
5141
5208
|
return
|
|
5142
5209
|
}
|
|
5143
|
-
|
|
5144
|
-
guard let qType = quantityType, let qUnit = unit else {
|
|
5145
|
-
rejectCallback(callbackId, error: "Invalid health data type")
|
|
5146
|
-
return
|
|
5147
|
-
}
|
|
5210
|
+
let qUnit = spec.unit
|
|
5148
5211
|
|
|
5149
5212
|
let start = startDate != nil ? Date(timeIntervalSince1970: startDate! / 1000) : Calendar.current.date(byAdding: .day, value: -7, to: Date())!
|
|
5150
5213
|
let end = endDate != nil ? Date(timeIntervalSince1970: endDate! / 1000) : Date()
|
|
5151
5214
|
|
|
5152
5215
|
let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate)
|
|
5153
5216
|
|
|
5154
|
-
|
|
5217
|
+
// A discrete type (heart rate, weight) has no sum: HealthKit fails
|
|
5218
|
+
// the whole query when asked for one, which is what heart rate
|
|
5219
|
+
// used to do. Each type is asked for the statistic it has.
|
|
5220
|
+
let query = HKStatisticsQuery(quantityType: qType, quantitySamplePredicate: predicate, options: spec.options) { [weak self] _, result, error in
|
|
5155
5221
|
if let error = error {
|
|
5222
|
+
// No samples in the range is an answer, not a failure.
|
|
5223
|
+
if (error as? HKError)?.code == .errorNoData {
|
|
5224
|
+
self?.resolveCallback(callbackId, result: ["value": 0, "unit": qUnit.unitString])
|
|
5225
|
+
return
|
|
5226
|
+
}
|
|
5156
5227
|
self?.rejectCallback(callbackId, error: error.localizedDescription)
|
|
5157
5228
|
return
|
|
5158
5229
|
}
|
|
5159
5230
|
|
|
5160
|
-
let value = result
|
|
5231
|
+
let value = result.flatMap { Self.statisticValue($0, options: spec.options, unit: qUnit) } ?? 0
|
|
5161
5232
|
self?.resolveCallback(callbackId, result: ["value": value, "unit": qUnit.unitString])
|
|
5162
5233
|
}
|
|
5163
5234
|
|
|
@@ -5252,6 +5323,194 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5252
5323
|
}
|
|
5253
5324
|
}
|
|
5254
5325
|
|
|
5326
|
+
/// The quantity behind each health data type, its unit, and the one
|
|
5327
|
+
/// statistic that type supports.
|
|
5328
|
+
private static func healthQuantity(_ type: String) -> (type: HKQuantityType?, unit: HKUnit, options: HKStatisticsOptions)? {
|
|
5329
|
+
let bpm = HKUnit.count().unitDivided(by: .minute())
|
|
5330
|
+
switch type {
|
|
5331
|
+
case "steps": return (HKQuantityType.quantityType(forIdentifier: .stepCount), .count(), .cumulativeSum)
|
|
5332
|
+
case "activeEnergy": return (HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned), .kilocalorie(), .cumulativeSum)
|
|
5333
|
+
case "distance": return (HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning), .meter(), .cumulativeSum)
|
|
5334
|
+
case "heartRate": return (HKQuantityType.quantityType(forIdentifier: .heartRate), bpm, .discreteAverage)
|
|
5335
|
+
case "restingHeartRate": return (HKQuantityType.quantityType(forIdentifier: .restingHeartRate), bpm, .discreteAverage)
|
|
5336
|
+
case "heartRateVariability": return (HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN), .secondUnit(with: .milli), .discreteAverage)
|
|
5337
|
+
case "bodyMass": return (HKQuantityType.quantityType(forIdentifier: .bodyMass), .gramUnit(with: .kilo), .mostRecent)
|
|
5338
|
+
default: return nil
|
|
5339
|
+
}
|
|
5340
|
+
}
|
|
5341
|
+
|
|
5342
|
+
private static func statisticValue(_ statistics: HKStatistics, options: HKStatisticsOptions, unit: HKUnit) -> Double? {
|
|
5343
|
+
if options.contains(.cumulativeSum) { return statistics.sumQuantity()?.doubleValue(for: unit) }
|
|
5344
|
+
if options.contains(.mostRecent) { return statistics.mostRecentQuantity()?.doubleValue(for: unit) }
|
|
5345
|
+
return statistics.averageQuantity()?.doubleValue(for: unit)
|
|
5346
|
+
}
|
|
5347
|
+
|
|
5348
|
+
private static let healthDayFormatter: DateFormatter = {
|
|
5349
|
+
let formatter = DateFormatter()
|
|
5350
|
+
formatter.calendar = Calendar(identifier: .gregorian)
|
|
5351
|
+
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
5352
|
+
formatter.timeZone = .current
|
|
5353
|
+
formatter.dateFormat = "yyyy-MM-dd"
|
|
5354
|
+
return formatter
|
|
5355
|
+
}()
|
|
5356
|
+
|
|
5357
|
+
/// A stable name for a workout's activity, independent of the SDK's
|
|
5358
|
+
/// raw values, so a page can map it to its own sports.
|
|
5359
|
+
private static func workoutTypeName(_ type: HKWorkoutActivityType) -> String {
|
|
5360
|
+
switch type {
|
|
5361
|
+
case .running: return "running"
|
|
5362
|
+
case .cycling: return "cycling"
|
|
5363
|
+
case .walking: return "walking"
|
|
5364
|
+
case .hiking: return "hiking"
|
|
5365
|
+
case .swimming: return "swimming"
|
|
5366
|
+
case .rowing: return "rowing"
|
|
5367
|
+
case .elliptical: return "elliptical"
|
|
5368
|
+
case .stairClimbing, .stairs: return "stairClimbing"
|
|
5369
|
+
case .yoga: return "yoga"
|
|
5370
|
+
case .pilates: return "pilates"
|
|
5371
|
+
case .functionalStrengthTraining, .traditionalStrengthTraining, .coreTraining: return "strength"
|
|
5372
|
+
case .highIntensityIntervalTraining: return "hiit"
|
|
5373
|
+
case .crossTraining, .mixedCardio: return "crossTraining"
|
|
5374
|
+
case .crossCountrySkiing: return "crossCountrySkiing"
|
|
5375
|
+
case .downhillSkiing, .snowboarding: return "skiing"
|
|
5376
|
+
case .paddleSports: return "paddling"
|
|
5377
|
+
case .climbing: return "climbing"
|
|
5378
|
+
case .dance, .socialDance, .cardioDance: return "dance"
|
|
5379
|
+
case .cooldown, .flexibility, .mindAndBody: return "mobility"
|
|
5380
|
+
default: return "other"
|
|
5381
|
+
}
|
|
5382
|
+
}
|
|
5383
|
+
|
|
5384
|
+
/// Workouts in Apple Health — the watch's, and every app's that
|
|
5385
|
+
/// writes there — newest first, with the numbers a training log needs.
|
|
5386
|
+
private func getHealthWorkouts(startDate: Double?, endDate: Double?, limit: Int?, callbackId: String?) {
|
|
5387
|
+
guard let healthStore = healthStore else {
|
|
5388
|
+
rejectCallback(callbackId, error: "HealthKit not available")
|
|
5389
|
+
return
|
|
5390
|
+
}
|
|
5391
|
+
|
|
5392
|
+
let start = startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Calendar.current.date(byAdding: .day, value: -30, to: Date())!
|
|
5393
|
+
let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
|
|
5394
|
+
let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
|
|
5395
|
+
let newestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
|
|
5396
|
+
let cap = max(1, min(limit ?? 200, 1000))
|
|
5397
|
+
|
|
5398
|
+
let query = HKSampleQuery(sampleType: HKObjectType.workoutType(), predicate: predicate, limit: cap, sortDescriptors: [newestFirst]) { [weak self] _, samples, error in
|
|
5399
|
+
if let error = error, (error as? HKError)?.code != .errorNoData {
|
|
5400
|
+
self?.rejectCallback(callbackId, error: error.localizedDescription)
|
|
5401
|
+
return
|
|
5402
|
+
}
|
|
5403
|
+
|
|
5404
|
+
let bpm = HKUnit.count().unitDivided(by: .minute())
|
|
5405
|
+
let workouts: [[String: Any]] = (samples as? [HKWorkout] ?? []).map { workout in
|
|
5406
|
+
var item: [String: Any] = [
|
|
5407
|
+
"id": workout.uuid.uuidString,
|
|
5408
|
+
"type": Self.workoutTypeName(workout.workoutActivityType),
|
|
5409
|
+
"startDate": workout.startDate.timeIntervalSince1970 * 1000,
|
|
5410
|
+
"endDate": workout.endDate.timeIntervalSince1970 * 1000,
|
|
5411
|
+
"durationSeconds": workout.duration,
|
|
5412
|
+
"sourceName": workout.sourceRevision.source.name,
|
|
5413
|
+
"indoor": (workout.metadata?[HKMetadataKeyIndoorWorkout] as? Bool) ?? false,
|
|
5414
|
+
]
|
|
5415
|
+
if let meters = workout.totalDistance?.doubleValue(for: .meter()), meters > 0 {
|
|
5416
|
+
item["distanceMeters"] = meters
|
|
5417
|
+
}
|
|
5418
|
+
if let ascent = workout.metadata?[HKMetadataKeyElevationAscended] as? HKQuantity {
|
|
5419
|
+
item["elevationGainMeters"] = ascent.doubleValue(for: .meter())
|
|
5420
|
+
}
|
|
5421
|
+
if #available(iOS 16.0, *) {
|
|
5422
|
+
if let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate),
|
|
5423
|
+
let heart = workout.statistics(for: heartType) {
|
|
5424
|
+
if let average = heart.averageQuantity()?.doubleValue(for: bpm) { item["averageHeartRate"] = average }
|
|
5425
|
+
if let maximum = heart.maximumQuantity()?.doubleValue(for: bpm) { item["maxHeartRate"] = maximum }
|
|
5426
|
+
}
|
|
5427
|
+
if let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned),
|
|
5428
|
+
let calories = workout.statistics(for: energyType)?.sumQuantity()?.doubleValue(for: .kilocalorie()) {
|
|
5429
|
+
item["activeEnergyCalories"] = calories
|
|
5430
|
+
}
|
|
5431
|
+
} else if let calories = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()) {
|
|
5432
|
+
item["activeEnergyCalories"] = calories
|
|
5433
|
+
}
|
|
5434
|
+
return item
|
|
5435
|
+
}
|
|
5436
|
+
self?.resolveCallback(callbackId, result: workouts)
|
|
5437
|
+
}
|
|
5438
|
+
healthStore.execute(query)
|
|
5439
|
+
}
|
|
5440
|
+
|
|
5441
|
+
/// One value per local day: a sum for steps, energy and distance, an
|
|
5442
|
+
/// average for heart rate, resting heart rate and HRV, the latest
|
|
5443
|
+
/// weight, and hours asleep for sleep (counted on the day you woke).
|
|
5444
|
+
private func getHealthDailyStatistics(type: String, startDate: Double?, endDate: Double?, callbackId: String?) {
|
|
5445
|
+
guard let healthStore = healthStore else {
|
|
5446
|
+
rejectCallback(callbackId, error: "HealthKit not available")
|
|
5447
|
+
return
|
|
5448
|
+
}
|
|
5449
|
+
|
|
5450
|
+
let calendar = Calendar.current
|
|
5451
|
+
let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
|
|
5452
|
+
let start = calendar.startOfDay(for: startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? calendar.date(byAdding: .day, value: -30, to: end)!)
|
|
5453
|
+
|
|
5454
|
+
if type == "sleep" {
|
|
5455
|
+
guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
|
|
5456
|
+
rejectCallback(callbackId, error: "Sleep data is unavailable")
|
|
5457
|
+
return
|
|
5458
|
+
}
|
|
5459
|
+
// From the evening before the first day, so its night counts.
|
|
5460
|
+
let predicate = HKQuery.predicateForSamples(withStart: calendar.date(byAdding: .hour, value: -12, to: start), end: end, options: [])
|
|
5461
|
+
let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { [weak self] _, samples, error in
|
|
5462
|
+
if let error = error, (error as? HKError)?.code != .errorNoData {
|
|
5463
|
+
self?.rejectCallback(callbackId, error: error.localizedDescription)
|
|
5464
|
+
return
|
|
5465
|
+
}
|
|
5466
|
+
// Time in bed and time awake are not sleep; every asleep
|
|
5467
|
+
// stage (and the older single "asleep" value) is.
|
|
5468
|
+
let awake: Set<Int> = [HKCategoryValueSleepAnalysis.inBed.rawValue, HKCategoryValueSleepAnalysis.awake.rawValue]
|
|
5469
|
+
var secondsByDay: [String: Double] = [:]
|
|
5470
|
+
for sample in samples as? [HKCategorySample] ?? [] where !awake.contains(sample.value) {
|
|
5471
|
+
let day = Self.healthDayFormatter.string(from: sample.endDate)
|
|
5472
|
+
secondsByDay[day, default: 0] += sample.endDate.timeIntervalSince(sample.startDate)
|
|
5473
|
+
}
|
|
5474
|
+
let days = secondsByDay.keys.sorted().map { day -> [String: Any] in
|
|
5475
|
+
["date": day, "value": (secondsByDay[day]! / 3600 * 100).rounded() / 100, "unit": "hr"]
|
|
5476
|
+
}
|
|
5477
|
+
self?.resolveCallback(callbackId, result: days)
|
|
5478
|
+
}
|
|
5479
|
+
healthStore.execute(query)
|
|
5480
|
+
return
|
|
5481
|
+
}
|
|
5482
|
+
|
|
5483
|
+
guard let spec = Self.healthQuantity(type), let qType = spec.type else {
|
|
5484
|
+
rejectCallback(callbackId, error: "Unknown health data type")
|
|
5485
|
+
return
|
|
5486
|
+
}
|
|
5487
|
+
let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
|
|
5488
|
+
let query = HKStatisticsCollectionQuery(
|
|
5489
|
+
quantityType: qType,
|
|
5490
|
+
quantitySamplePredicate: predicate,
|
|
5491
|
+
options: spec.options,
|
|
5492
|
+
anchorDate: start,
|
|
5493
|
+
intervalComponents: DateComponents(day: 1)
|
|
5494
|
+
)
|
|
5495
|
+
query.initialResultsHandler = { [weak self] _, collection, error in
|
|
5496
|
+
if let error = error, (error as? HKError)?.code != .errorNoData {
|
|
5497
|
+
self?.rejectCallback(callbackId, error: error.localizedDescription)
|
|
5498
|
+
return
|
|
5499
|
+
}
|
|
5500
|
+
var days: [[String: Any]] = []
|
|
5501
|
+
collection?.enumerateStatistics(from: start, to: end) { statistics, _ in
|
|
5502
|
+
guard let value = Self.statisticValue(statistics, options: spec.options, unit: spec.unit) else { return }
|
|
5503
|
+
days.append([
|
|
5504
|
+
"date": Self.healthDayFormatter.string(from: statistics.startDate),
|
|
5505
|
+
"value": value,
|
|
5506
|
+
"unit": spec.unit.unitString,
|
|
5507
|
+
])
|
|
5508
|
+
}
|
|
5509
|
+
self?.resolveCallback(callbackId, result: days)
|
|
5510
|
+
}
|
|
5511
|
+
healthStore.execute(query)
|
|
5512
|
+
}
|
|
5513
|
+
|
|
5255
5514
|
// MARK: - Live Activities
|
|
5256
5515
|
private func startLiveActivity(body: [String: Any], callbackId: String?) {
|
|
5257
5516
|
guard #available(iOS 16.2, *), ActivityAuthorizationInfo().areActivitiesEnabled else {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
<key>UIUserInterfaceStyle</key>
|
|
39
39
|
<string>{{UI_STYLE}}</string>
|
|
40
40
|
<key>UIStatusBarStyle</key>
|
|
41
|
-
<string>
|
|
41
|
+
<string>{{STATUS_BAR_STYLE}}</string>
|
|
42
42
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
|
43
43
|
<false/>
|
|
44
44
|
{{USAGE_DESCRIPTIONS}}
|
package/dist/mobile.js
CHANGED
|
@@ -674,6 +674,18 @@ var health = {
|
|
|
674
674
|
if (!craft?.health?.saveWorkout)
|
|
675
675
|
throw new Error("Saving health workouts is unavailable");
|
|
676
676
|
return craft.health.saveWorkout(workout);
|
|
677
|
+
},
|
|
678
|
+
async getWorkouts(options = {}) {
|
|
679
|
+
const craft = getCraftRoot();
|
|
680
|
+
if (!craft?.health?.getWorkouts)
|
|
681
|
+
throw new Error("Reading health workouts is unavailable");
|
|
682
|
+
return craft.health.getWorkouts(options);
|
|
683
|
+
},
|
|
684
|
+
async getDailyStatistics(type, options = {}) {
|
|
685
|
+
const craft = getCraftRoot();
|
|
686
|
+
if (!craft?.health?.getDailyStatistics)
|
|
687
|
+
throw new Error("Daily health statistics are unavailable");
|
|
688
|
+
return craft.health.getDailyStatistics(type, options);
|
|
677
689
|
}
|
|
678
690
|
};
|
|
679
691
|
async function updateLiveActivity(idOrState, state) {
|