craft-native 0.0.98 → 0.0.100

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.
@@ -727,7 +727,13 @@ 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'>;
733
+ export interface HealthAuthorizationOptions {
734
+ /** `false` asks to read only. Default: also allowed to save workouts. */
735
+ write?: boolean;
736
+ }
731
737
  export interface HealthDataOptions {
732
738
  startDate?: number;
733
739
  endDate?: number;
@@ -756,10 +762,51 @@ export interface HealthWorkout {
756
762
  export interface HealthWorkoutResult {
757
763
  id: string;
758
764
  }
765
+ export interface HealthWorkoutQuery {
766
+ /** Epoch milliseconds. Defaults to 30 days ago. */
767
+ startDate?: number;
768
+ /** Epoch milliseconds. Defaults to now. */
769
+ endDate?: number;
770
+ /** At most this many, newest first. Defaults to 200, capped at 1000. */
771
+ limit?: number;
772
+ }
773
+ /** The activity of a workout read from Apple Health, named independently of the SDK. */
774
+ export type HealthWorkoutActivity = 'running' | 'cycling' | 'walking' | 'hiking' | 'swimming' | 'rowing' | 'elliptical' | 'stairClimbing' | 'yoga' | 'pilates' | 'strength' | 'hiit' | 'crossTraining' | 'crossCountrySkiing' | 'skiing' | 'paddling' | 'climbing' | 'dance' | 'mobility' | 'other';
775
+ /** A workout recorded by the watch, or by any app that writes to Apple Health. */
776
+ export interface HealthWorkoutSample {
777
+ /** HealthKit's UUID: stable across reads, so it can key an import. */
778
+ id: string;
779
+ type: HealthWorkoutActivity;
780
+ startDate: number;
781
+ endDate: number;
782
+ durationSeconds: number;
783
+ sourceName: string;
784
+ indoor: boolean;
785
+ distanceMeters?: number;
786
+ elevationGainMeters?: number;
787
+ activeEnergyCalories?: number;
788
+ averageHeartRate?: number;
789
+ maxHeartRate?: number;
790
+ }
791
+ export interface HealthDailyValue {
792
+ /** The local day, YYYY-MM-DD. */
793
+ date: string;
794
+ value: number;
795
+ unit: string;
796
+ }
759
797
  export declare const health: {
760
- requestAuthorization(types: HealthDataType[]): Promise<boolean>;
798
+ /**
799
+ * Ask for the health data a page needs. `{ write: false }` asks to read
800
+ * only, for an app that never writes back; by default it may also save
801
+ * workouts, energy and distance.
802
+ */
803
+ requestAuthorization(types: HealthDataType[], options?: HealthAuthorizationOptions): Promise<boolean>;
761
804
  getData(type: HealthDataType, options?: HealthDataOptions): Promise<HealthDataResult>;
762
805
  saveWorkout(workout: HealthWorkout): Promise<HealthWorkoutResult>;
806
+ /** Workouts in Apple Health, newest first. iOS only for now. */
807
+ getWorkouts(options?: HealthWorkoutQuery): Promise<HealthWorkoutSample[]>;
808
+ /** One value per local day between two dates. iOS only for now. */
809
+ getDailyStatistics(type: HealthDailyType, options?: HealthDataOptions): Promise<HealthDailyValue[]>;
763
810
  };
764
811
  export interface LiveActivityState {
765
812
  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 color = config.backgroundColor?.replace(/^#/, "") || "000000";
1280
- const normalized = color.length === 3 ? [...color].map((value) => `${value}${value}`).join("") : color.padEnd(6, "0").slice(0, 6);
1281
- const components = [0, 2, 4].map((index) => (Number.parseInt(normalized.slice(index, index + 2), 16) / 255).toFixed(3));
1282
- writeFileSync3(join2(launchBackground, "Contents.json"), `${JSON.stringify({
1283
- colors: [{
1284
- color: {
1285
- "color-space": "srgb",
1286
- components: { alpha: "1.000", blue: components[2], green: components[1], red: components[0] }
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: ${name}`);
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().replace(/[^a-z0-9]/g, "")}`;
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: name,
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, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, config.darkMode ? "Dark" : "Light").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>
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, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
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, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
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>${name}</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 ${name}</h1>
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.98";
4622
+ var version = "0.0.100";
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.98";
313
+ var version = "0.0.100";
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 (isWebKitHost()) {
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 (isWebKitHost()) {
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 (isWebKitHost()) {
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.98";
61
+ var version = "0.0.100";
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 (isWebKitHost()) {
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 (isWebKitHost()) {
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 (isWebKitHost()) {
2434
+ if (isDesktopWebViewHost()) {
2427
2435
  const injected = getInjectedWindowBridge();
2428
2436
  if (!injected?._call) {
2429
2437
  throw new Error("Craft window bridge is unavailable");
@@ -3,13 +3,24 @@
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;
22
+ /** Edge-swipe back and forward through the page's history, pushState entries included. */
23
+ swipeNavigation?: boolean;
13
24
  enableSpeechRecognition?: boolean;
14
25
  enableHaptics?: boolean;
15
26
  enableShare?: boolean;
@@ -115,6 +126,20 @@ export interface RunOptions {
115
126
  output: string;
116
127
  }
117
128
  export declare function renderDeviceFamilies(config: CraftConfig): string;
129
+ /** `appearance` when set, otherwise what the older `darkMode` switch pinned. */
130
+ export declare function resolveAppearance(config: Pick<CraftConfig, 'appearance' | 'darkMode'>): CraftAppearance;
131
+ /**
132
+ * The Info.plist interface style and the status bar that reads on it.
133
+ *
134
+ * The status bar used to be `LightContent` whatever the app looked like:
135
+ * white text that vanished on a light page. It now follows the appearance —
136
+ * dark text on a light app, light on a dark one, and the system's choice when
137
+ * the app follows the system.
138
+ */
139
+ export declare function renderAppearance(config: Pick<CraftConfig, 'appearance' | 'darkMode'>): {
140
+ interfaceStyle: string;
141
+ statusBarStyle: string;
142
+ };
118
143
  export declare function renderUsageDescriptions(config: CraftConfig): string;
119
144
  export declare function renderOrientations(config: CraftConfig): string;
120
145
  export declare function renderUrlTypes(config: CraftConfig): string;
@@ -181,6 +206,17 @@ export declare function resolveRuntimeDir(override?: string | null): string | nu
181
206
  * Returns true when a runtime was installed.
182
207
  */
183
208
  export declare function installRuntime(output: string, runtimeDir: string): Promise<boolean>;
209
+ /**
210
+ * The identifier an app's display name becomes wherever Xcode or Swift needs
211
+ * one: the project, the target, the product and the `@main` type.
212
+ *
213
+ * The display name goes in front of people and may say anything; an
214
+ * identifier may not. "HQ.training" used to become `struct HQ.trainingApp`,
215
+ * which does not compile. Words are joined in PascalCase ("HQTraining"), and
216
+ * a name that starts with a digit or has no letters at all still yields a
217
+ * valid Swift type.
218
+ */
219
+ export declare function productName(name: string): string;
184
220
  export declare function init(options: InitOptions): Promise<void>;
185
221
  /**
186
222
  * Build web assets and generate Xcode project
@@ -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 color = config.backgroundColor?.replace(/^#/, "") || "000000";
260
- const normalized = color.length === 3 ? [...color].map((value) => `${value}${value}`).join("") : color.padEnd(6, "0").slice(0, 6);
261
- const components = [0, 2, 4].map((index) => (Number.parseInt(normalized.slice(index, index + 2), 16) / 255).toFixed(3));
262
- writeFileSync(join(launchBackground, "Contents.json"), `${JSON.stringify({
263
- colors: [{
264
- color: {
265
- "color-space": "srgb",
266
- components: { alpha: "1.000", blue: components[2], green: components[1], red: components[0] }
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: ${name}`);
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().replace(/[^a-z0-9]/g, "")}`;
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: name,
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, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, config.darkMode ? "Dark" : "Light").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>
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, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
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, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
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>${name}</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 ${name}</h1>
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.darkMode ? .dark : .light)
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,14 @@ 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
362
+ /// Edge-swipe back and forward through the page's history.
363
+ var swipeNavigation: Bool? = nil
357
364
  var enableSpeechRecognition: Bool = false
358
365
  var enableHaptics: Bool = false
359
366
  var enableShare: Bool = false
@@ -393,6 +400,26 @@ struct CraftConfig: Codable {
393
400
  var trustedOrigins: [String] = []
394
401
  }
395
402
 
403
+ extension CraftConfig {
404
+ /// The scheme SwiftUI pins, or nil to follow the phone's setting.
405
+ var colorScheme: ColorScheme? {
406
+ switch appearance {
407
+ case "system": return nil
408
+ case "light": return .light
409
+ case "dark": return .dark
410
+ default: return darkMode ? .dark : .light
411
+ }
412
+ }
413
+
414
+ /// The webview's background, resolved per trait so a Dark Mode switch
415
+ /// repaints it without a reload.
416
+ var resolvedBackgroundColor: UIColor {
417
+ let light = UIColor(hex: backgroundColor) ?? .black
418
+ guard let darkHex = backgroundColorDark, let dark = UIColor(hex: darkHex) else { return light }
419
+ return UIColor { traits in traits.userInterfaceStyle == .dark ? dark : light }
420
+ }
421
+ }
422
+
396
423
  // MARK: - WebView
397
424
  final class BundledAssetSchemeHandler: NSObject, WKURLSchemeHandler {
398
425
  private let rootURL: URL?
@@ -505,6 +532,13 @@ struct CraftWebView: UIViewRepresentable {
505
532
  let webView = WKWebView(frame: .zero, configuration: webConfig)
506
533
  webView.navigationDelegate = context.coordinator
507
534
  webView.isOpaque = false
535
+ // An edge swipe goes back (and forward) through the page's history,
536
+ // pushState entries included, the way every iOS app's stack does.
537
+ webView.allowsBackForwardNavigationGestures = config.swipeNavigation ?? false
538
+ #if DEBUG
539
+ // Safari's Develop menu can attach to a debug build; never a release.
540
+ if #available(iOS 16.4, *) { webView.isInspectable = true }
541
+ #endif
508
542
 
509
543
  // Before any message can be offered. Zig evaluates every reply against
510
544
  // this webview, and without it an action would run, succeed, and reach
@@ -517,7 +551,7 @@ struct CraftWebView: UIViewRepresentable {
517
551
  CraftEventManager.shared.setWebView(webView)
518
552
 
519
553
  // Parse background color
520
- let bgColor = UIColor(hex: config.backgroundColor) ?? .black
554
+ let bgColor = config.resolvedBackgroundColor
521
555
  webView.backgroundColor = bgColor
522
556
  webView.scrollView.backgroundColor = bgColor
523
557
 
@@ -1276,7 +1310,7 @@ struct CraftWebView: UIViewRepresentable {
1276
1310
  case "requestHealthAuthorization":
1277
1311
  if config.enableHealthKit {
1278
1312
  let types = body["types"] as? [String] ?? []
1279
- requestHealthAuthorization(types: types, callbackId: callbackId)
1313
+ requestHealthAuthorization(types: types, readOnly: body["readOnly"] as? Bool ?? false, callbackId: callbackId)
1280
1314
  } else {
1281
1315
  rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
1282
1316
  }
@@ -1298,6 +1332,32 @@ struct CraftWebView: UIViewRepresentable {
1298
1332
  } else {
1299
1333
  rejectCallback(callbackId, error: "HealthKit is disabled")
1300
1334
  }
1335
+ case "getHealthWorkouts":
1336
+ if config.enableHealthKit {
1337
+ getHealthWorkouts(
1338
+ startDate: body["startDate"] as? Double,
1339
+ endDate: body["endDate"] as? Double,
1340
+ limit: body["limit"] as? Int,
1341
+ callbackId: callbackId
1342
+ )
1343
+ } else {
1344
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
1345
+ }
1346
+ case "getHealthDailyStatistics":
1347
+ if config.enableHealthKit {
1348
+ if let dataType = body["type"] as? String {
1349
+ getHealthDailyStatistics(
1350
+ type: dataType,
1351
+ startDate: body["startDate"] as? Double,
1352
+ endDate: body["endDate"] as? Double,
1353
+ callbackId: callbackId
1354
+ )
1355
+ } else {
1356
+ rejectCallback(callbackId, error: "getHealthDailyStatistics was called without a type", code: "INVALID_ARGUMENT")
1357
+ }
1358
+ } else {
1359
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
1360
+ }
1301
1361
 
1302
1362
  // MARK: - Live Activities
1303
1363
  case "startLiveActivity":
@@ -3188,19 +3248,28 @@ struct CraftWebView: UIViewRepresentable {
3188
3248
  var legacyNotifications = craft.notifications;
3189
3249
 
3190
3250
  craft.contractVersion = '1.0.0';
3251
+ // Actions answered by a person, through a system sheet they
3252
+ // may take a minute to read. Thirty seconds of reading the
3253
+ // HealthKit sheet used to fail the call while the sheet was
3254
+ // still open; these wait for the answer instead.
3255
+ var personFacing = {
3256
+ requestHealthAuthorization: true,
3257
+ requestPermission: true,
3258
+ signInWithApple: true
3259
+ };
3191
3260
  craft._invoke = function(action, payload) {
3192
3261
  var self = craft;
3193
3262
  var id = 'cb_' + (++self._callbackId);
3194
3263
  var message = Object.assign({}, payload || {}, {action: action, callbackId: id});
3195
3264
  window.webkit.messageHandlers.craft.postMessage(message);
3196
3265
  return new Promise(function(resolve, reject) {
3197
- var timeout = setTimeout(function() {
3266
+ var timeout = personFacing[action] ? null : setTimeout(function() {
3198
3267
  delete self._callbacks[id];
3199
3268
  reject(new Error('Craft bridge timed out: ' + action));
3200
3269
  }, 30000);
3201
3270
  self._callbacks[id] = {
3202
- resolve: function(value) { clearTimeout(timeout); resolve(value); },
3203
- reject: function(error) { clearTimeout(timeout); reject(error); }
3271
+ resolve: function(value) { if (timeout) clearTimeout(timeout); resolve(value); },
3272
+ reject: function(error) { if (timeout) clearTimeout(timeout); reject(error); }
3204
3273
  };
3205
3274
  });
3206
3275
  };
@@ -3336,12 +3405,22 @@ struct CraftWebView: UIViewRepresentable {
3336
3405
  readRecording: function() { return craft._invoke('readLocationRecording'); }
3337
3406
  };
3338
3407
  craft.health = {
3339
- requestAuthorization: function(types) { return craft._invoke('requestHealthAuthorization', {types: types || []}); },
3408
+ requestAuthorization: function(types, options) {
3409
+ return craft._invoke('requestHealthAuthorization', {types: types || [], readOnly: Boolean(options && options.write === false)});
3410
+ },
3340
3411
  getData: function(type, options) {
3341
3412
  options = options || {};
3342
3413
  return craft._invoke('getHealthData', {type: type, startDate: options.startDate, endDate: options.endDate});
3343
3414
  },
3344
- saveWorkout: function(workout) { return craft._invoke('saveHealthWorkout', workout || {}); }
3415
+ saveWorkout: function(workout) { return craft._invoke('saveHealthWorkout', workout || {}); },
3416
+ getWorkouts: function(options) {
3417
+ options = options || {};
3418
+ return craft._invoke('getHealthWorkouts', {startDate: options.startDate, endDate: options.endDate, limit: options.limit});
3419
+ },
3420
+ getDailyStatistics: function(type, options) {
3421
+ options = options || {};
3422
+ return craft._invoke('getHealthDailyStatistics', {type: type, startDate: options.startDate, endDate: options.endDate});
3423
+ }
3345
3424
  };
3346
3425
  craft.liveActivity = {
3347
3426
  start: function(options) { return craft._invoke('startLiveActivity', options || {}); },
@@ -5066,7 +5145,7 @@ struct CraftWebView: UIViewRepresentable {
5066
5145
  }
5067
5146
 
5068
5147
  // MARK: - Health
5069
- private func requestHealthAuthorization(types: [String], callbackId: String?) {
5148
+ private func requestHealthAuthorization(types: [String], readOnly: Bool = false, callbackId: String?) {
5070
5149
  guard let healthStore = healthStore else {
5071
5150
  rejectCallback(callbackId, error: "HealthKit not available")
5072
5151
  return
@@ -5100,11 +5179,38 @@ struct CraftWebView: UIViewRepresentable {
5100
5179
  }
5101
5180
  case "workouts":
5102
5181
  readTypes.insert(HKObjectType.workoutType())
5182
+ // A workout's own heart rate and energy are read through
5183
+ // these; without them every workout comes back bare.
5184
+ if let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate) {
5185
+ readTypes.insert(heartType)
5186
+ }
5187
+ if let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) {
5188
+ readTypes.insert(energyType)
5189
+ }
5190
+ case "restingHeartRate":
5191
+ if let restingType = HKQuantityType.quantityType(forIdentifier: .restingHeartRate) {
5192
+ readTypes.insert(restingType)
5193
+ }
5194
+ case "heartRateVariability":
5195
+ if let hrvType = HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN) {
5196
+ readTypes.insert(hrvType)
5197
+ }
5198
+ case "bodyMass":
5199
+ if let massType = HKQuantityType.quantityType(forIdentifier: .bodyMass) {
5200
+ readTypes.insert(massType)
5201
+ }
5202
+ case "sleep":
5203
+ if let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) {
5204
+ readTypes.insert(sleepType)
5205
+ }
5103
5206
  default:
5104
5207
  break
5105
5208
  }
5106
5209
  }
5107
5210
 
5211
+ // A page that only reads asks only to read: the sheet then says
5212
+ // "access", not "access and update".
5213
+ if readOnly { shareTypes.removeAll() }
5108
5214
  healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { [weak self] success, error in
5109
5215
  if success {
5110
5216
  self?.resolveCallback(callbackId, result: true)
@@ -5120,44 +5226,32 @@ struct CraftWebView: UIViewRepresentable {
5120
5226
  return
5121
5227
  }
5122
5228
 
5123
- var quantityType: HKQuantityType?
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:
5229
+ guard let spec = Self.healthQuantity(type), let qType = spec.type else {
5140
5230
  rejectCallback(callbackId, error: "Unknown health data type")
5141
5231
  return
5142
5232
  }
5143
-
5144
- guard let qType = quantityType, let qUnit = unit else {
5145
- rejectCallback(callbackId, error: "Invalid health data type")
5146
- return
5147
- }
5233
+ let qUnit = spec.unit
5148
5234
 
5149
5235
  let start = startDate != nil ? Date(timeIntervalSince1970: startDate! / 1000) : Calendar.current.date(byAdding: .day, value: -7, to: Date())!
5150
5236
  let end = endDate != nil ? Date(timeIntervalSince1970: endDate! / 1000) : Date()
5151
5237
 
5152
5238
  let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate)
5153
5239
 
5154
- let query = HKStatisticsQuery(quantityType: qType, quantitySamplePredicate: predicate, options: .cumulativeSum) { [weak self] _, result, error in
5240
+ // A discrete type (heart rate, weight) has no sum: HealthKit fails
5241
+ // the whole query when asked for one, which is what heart rate
5242
+ // used to do. Each type is asked for the statistic it has.
5243
+ let query = HKStatisticsQuery(quantityType: qType, quantitySamplePredicate: predicate, options: spec.options) { [weak self] _, result, error in
5155
5244
  if let error = error {
5245
+ // No samples in the range is an answer, not a failure.
5246
+ if (error as? HKError)?.code == .errorNoData {
5247
+ self?.resolveCallback(callbackId, result: ["value": 0, "unit": qUnit.unitString])
5248
+ return
5249
+ }
5156
5250
  self?.rejectCallback(callbackId, error: error.localizedDescription)
5157
5251
  return
5158
5252
  }
5159
5253
 
5160
- let value = result?.sumQuantity()?.doubleValue(for: qUnit) ?? 0
5254
+ let value = result.flatMap { Self.statisticValue($0, options: spec.options, unit: qUnit) } ?? 0
5161
5255
  self?.resolveCallback(callbackId, result: ["value": value, "unit": qUnit.unitString])
5162
5256
  }
5163
5257
 
@@ -5252,6 +5346,194 @@ struct CraftWebView: UIViewRepresentable {
5252
5346
  }
5253
5347
  }
5254
5348
 
5349
+ /// The quantity behind each health data type, its unit, and the one
5350
+ /// statistic that type supports.
5351
+ private static func healthQuantity(_ type: String) -> (type: HKQuantityType?, unit: HKUnit, options: HKStatisticsOptions)? {
5352
+ let bpm = HKUnit.count().unitDivided(by: .minute())
5353
+ switch type {
5354
+ case "steps": return (HKQuantityType.quantityType(forIdentifier: .stepCount), .count(), .cumulativeSum)
5355
+ case "activeEnergy": return (HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned), .kilocalorie(), .cumulativeSum)
5356
+ case "distance": return (HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning), .meter(), .cumulativeSum)
5357
+ case "heartRate": return (HKQuantityType.quantityType(forIdentifier: .heartRate), bpm, .discreteAverage)
5358
+ case "restingHeartRate": return (HKQuantityType.quantityType(forIdentifier: .restingHeartRate), bpm, .discreteAverage)
5359
+ case "heartRateVariability": return (HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN), .secondUnit(with: .milli), .discreteAverage)
5360
+ case "bodyMass": return (HKQuantityType.quantityType(forIdentifier: .bodyMass), .gramUnit(with: .kilo), .mostRecent)
5361
+ default: return nil
5362
+ }
5363
+ }
5364
+
5365
+ private static func statisticValue(_ statistics: HKStatistics, options: HKStatisticsOptions, unit: HKUnit) -> Double? {
5366
+ if options.contains(.cumulativeSum) { return statistics.sumQuantity()?.doubleValue(for: unit) }
5367
+ if options.contains(.mostRecent) { return statistics.mostRecentQuantity()?.doubleValue(for: unit) }
5368
+ return statistics.averageQuantity()?.doubleValue(for: unit)
5369
+ }
5370
+
5371
+ private static let healthDayFormatter: DateFormatter = {
5372
+ let formatter = DateFormatter()
5373
+ formatter.calendar = Calendar(identifier: .gregorian)
5374
+ formatter.locale = Locale(identifier: "en_US_POSIX")
5375
+ formatter.timeZone = .current
5376
+ formatter.dateFormat = "yyyy-MM-dd"
5377
+ return formatter
5378
+ }()
5379
+
5380
+ /// A stable name for a workout's activity, independent of the SDK's
5381
+ /// raw values, so a page can map it to its own sports.
5382
+ private static func workoutTypeName(_ type: HKWorkoutActivityType) -> String {
5383
+ switch type {
5384
+ case .running: return "running"
5385
+ case .cycling: return "cycling"
5386
+ case .walking: return "walking"
5387
+ case .hiking: return "hiking"
5388
+ case .swimming: return "swimming"
5389
+ case .rowing: return "rowing"
5390
+ case .elliptical: return "elliptical"
5391
+ case .stairClimbing, .stairs: return "stairClimbing"
5392
+ case .yoga: return "yoga"
5393
+ case .pilates: return "pilates"
5394
+ case .functionalStrengthTraining, .traditionalStrengthTraining, .coreTraining: return "strength"
5395
+ case .highIntensityIntervalTraining: return "hiit"
5396
+ case .crossTraining, .mixedCardio: return "crossTraining"
5397
+ case .crossCountrySkiing: return "crossCountrySkiing"
5398
+ case .downhillSkiing, .snowboarding: return "skiing"
5399
+ case .paddleSports: return "paddling"
5400
+ case .climbing: return "climbing"
5401
+ case .dance, .socialDance, .cardioDance: return "dance"
5402
+ case .cooldown, .flexibility, .mindAndBody: return "mobility"
5403
+ default: return "other"
5404
+ }
5405
+ }
5406
+
5407
+ /// Workouts in Apple Health — the watch's, and every app's that
5408
+ /// writes there — newest first, with the numbers a training log needs.
5409
+ private func getHealthWorkouts(startDate: Double?, endDate: Double?, limit: Int?, callbackId: String?) {
5410
+ guard let healthStore = healthStore else {
5411
+ rejectCallback(callbackId, error: "HealthKit not available")
5412
+ return
5413
+ }
5414
+
5415
+ let start = startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Calendar.current.date(byAdding: .day, value: -30, to: Date())!
5416
+ let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
5417
+ let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
5418
+ let newestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
5419
+ let cap = max(1, min(limit ?? 200, 1000))
5420
+
5421
+ let query = HKSampleQuery(sampleType: HKObjectType.workoutType(), predicate: predicate, limit: cap, sortDescriptors: [newestFirst]) { [weak self] _, samples, error in
5422
+ if let error = error, (error as? HKError)?.code != .errorNoData {
5423
+ self?.rejectCallback(callbackId, error: error.localizedDescription)
5424
+ return
5425
+ }
5426
+
5427
+ let bpm = HKUnit.count().unitDivided(by: .minute())
5428
+ let workouts: [[String: Any]] = (samples as? [HKWorkout] ?? []).map { workout in
5429
+ var item: [String: Any] = [
5430
+ "id": workout.uuid.uuidString,
5431
+ "type": Self.workoutTypeName(workout.workoutActivityType),
5432
+ "startDate": workout.startDate.timeIntervalSince1970 * 1000,
5433
+ "endDate": workout.endDate.timeIntervalSince1970 * 1000,
5434
+ "durationSeconds": workout.duration,
5435
+ "sourceName": workout.sourceRevision.source.name,
5436
+ "indoor": (workout.metadata?[HKMetadataKeyIndoorWorkout] as? Bool) ?? false,
5437
+ ]
5438
+ if let meters = workout.totalDistance?.doubleValue(for: .meter()), meters > 0 {
5439
+ item["distanceMeters"] = meters
5440
+ }
5441
+ if let ascent = workout.metadata?[HKMetadataKeyElevationAscended] as? HKQuantity {
5442
+ item["elevationGainMeters"] = ascent.doubleValue(for: .meter())
5443
+ }
5444
+ if #available(iOS 16.0, *) {
5445
+ if let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate),
5446
+ let heart = workout.statistics(for: heartType) {
5447
+ if let average = heart.averageQuantity()?.doubleValue(for: bpm) { item["averageHeartRate"] = average }
5448
+ if let maximum = heart.maximumQuantity()?.doubleValue(for: bpm) { item["maxHeartRate"] = maximum }
5449
+ }
5450
+ if let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned),
5451
+ let calories = workout.statistics(for: energyType)?.sumQuantity()?.doubleValue(for: .kilocalorie()) {
5452
+ item["activeEnergyCalories"] = calories
5453
+ }
5454
+ } else if let calories = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()) {
5455
+ item["activeEnergyCalories"] = calories
5456
+ }
5457
+ return item
5458
+ }
5459
+ self?.resolveCallback(callbackId, result: workouts)
5460
+ }
5461
+ healthStore.execute(query)
5462
+ }
5463
+
5464
+ /// One value per local day: a sum for steps, energy and distance, an
5465
+ /// average for heart rate, resting heart rate and HRV, the latest
5466
+ /// weight, and hours asleep for sleep (counted on the day you woke).
5467
+ private func getHealthDailyStatistics(type: String, startDate: Double?, endDate: Double?, callbackId: String?) {
5468
+ guard let healthStore = healthStore else {
5469
+ rejectCallback(callbackId, error: "HealthKit not available")
5470
+ return
5471
+ }
5472
+
5473
+ let calendar = Calendar.current
5474
+ let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
5475
+ let start = calendar.startOfDay(for: startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? calendar.date(byAdding: .day, value: -30, to: end)!)
5476
+
5477
+ if type == "sleep" {
5478
+ guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
5479
+ rejectCallback(callbackId, error: "Sleep data is unavailable")
5480
+ return
5481
+ }
5482
+ // From the evening before the first day, so its night counts.
5483
+ let predicate = HKQuery.predicateForSamples(withStart: calendar.date(byAdding: .hour, value: -12, to: start), end: end, options: [])
5484
+ let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { [weak self] _, samples, error in
5485
+ if let error = error, (error as? HKError)?.code != .errorNoData {
5486
+ self?.rejectCallback(callbackId, error: error.localizedDescription)
5487
+ return
5488
+ }
5489
+ // Time in bed and time awake are not sleep; every asleep
5490
+ // stage (and the older single "asleep" value) is.
5491
+ let awake: Set<Int> = [HKCategoryValueSleepAnalysis.inBed.rawValue, HKCategoryValueSleepAnalysis.awake.rawValue]
5492
+ var secondsByDay: [String: Double] = [:]
5493
+ for sample in samples as? [HKCategorySample] ?? [] where !awake.contains(sample.value) {
5494
+ let day = Self.healthDayFormatter.string(from: sample.endDate)
5495
+ secondsByDay[day, default: 0] += sample.endDate.timeIntervalSince(sample.startDate)
5496
+ }
5497
+ let days = secondsByDay.keys.sorted().map { day -> [String: Any] in
5498
+ ["date": day, "value": (secondsByDay[day]! / 3600 * 100).rounded() / 100, "unit": "hr"]
5499
+ }
5500
+ self?.resolveCallback(callbackId, result: days)
5501
+ }
5502
+ healthStore.execute(query)
5503
+ return
5504
+ }
5505
+
5506
+ guard let spec = Self.healthQuantity(type), let qType = spec.type else {
5507
+ rejectCallback(callbackId, error: "Unknown health data type")
5508
+ return
5509
+ }
5510
+ let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
5511
+ let query = HKStatisticsCollectionQuery(
5512
+ quantityType: qType,
5513
+ quantitySamplePredicate: predicate,
5514
+ options: spec.options,
5515
+ anchorDate: start,
5516
+ intervalComponents: DateComponents(day: 1)
5517
+ )
5518
+ query.initialResultsHandler = { [weak self] _, collection, error in
5519
+ if let error = error, (error as? HKError)?.code != .errorNoData {
5520
+ self?.rejectCallback(callbackId, error: error.localizedDescription)
5521
+ return
5522
+ }
5523
+ var days: [[String: Any]] = []
5524
+ collection?.enumerateStatistics(from: start, to: end) { statistics, _ in
5525
+ guard let value = Self.statisticValue(statistics, options: spec.options, unit: spec.unit) else { return }
5526
+ days.append([
5527
+ "date": Self.healthDayFormatter.string(from: statistics.startDate),
5528
+ "value": value,
5529
+ "unit": spec.unit.unitString,
5530
+ ])
5531
+ }
5532
+ self?.resolveCallback(callbackId, result: days)
5533
+ }
5534
+ healthStore.execute(query)
5535
+ }
5536
+
5255
5537
  // MARK: - Live Activities
5256
5538
  private func startLiveActivity(body: [String: Any], callbackId: String?) {
5257
5539
  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>UIStatusBarStyleLightContent</string>
41
+ <string>{{STATUS_BAR_STYLE}}</string>
42
42
  <key>UIViewControllerBasedStatusBarAppearance</key>
43
43
  <false/>
44
44
  {{USAGE_DESCRIPTIONS}}
package/dist/mobile.js CHANGED
@@ -657,11 +657,11 @@ var pushNotifications = {
657
657
  }
658
658
  };
659
659
  var health = {
660
- async requestAuthorization(types) {
660
+ async requestAuthorization(types, options = {}) {
661
661
  const craft = getCraftRoot();
662
662
  if (!craft?.health?.requestAuthorization)
663
663
  throw new Error("Health data is unavailable");
664
- return Boolean(await craft.health.requestAuthorization(types));
664
+ return Boolean(await craft.health.requestAuthorization(types, options));
665
665
  },
666
666
  async getData(type, options = {}) {
667
667
  const craft = getCraftRoot();
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.98",
3
+ "version": "0.0.100",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",