craft-native 0.0.97 → 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.
@@ -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 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.97";
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.97";
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 (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");
@@ -11109,6 +11117,8 @@ class CraftApp {
11109
11117
  args.push("--hide-dock-icon");
11110
11118
  if (window3?.menubarOnly)
11111
11119
  args.push("--menubar-only");
11120
+ if (window3?.trayPopover)
11121
+ args.push("--tray-popover");
11112
11122
  if (window3?.titlebarHidden)
11113
11123
  args.push("--titlebar-hidden");
11114
11124
  if (window3?.headless)
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.97";
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 (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");
@@ -10869,6 +10877,8 @@ class CraftApp {
10869
10877
  args.push("--hide-dock-icon");
10870
10878
  if (window3?.menubarOnly)
10871
10879
  args.push("--menubar-only");
10880
+ if (window3?.trayPopover)
10881
+ args.push("--tray-popover");
10872
10882
  if (window3?.titlebarHidden)
10873
10883
  args.push("--titlebar-hidden");
10874
10884
  if (window3?.headless)
@@ -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
@@ -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,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 = UIColor(hex: config.backgroundColor) ?? .black
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
- 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:
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
- let query = HKStatisticsQuery(quantityType: qType, quantitySamplePredicate: predicate, options: .cumulativeSum) { [weak self] _, result, error in
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?.sumQuantity()?.doubleValue(for: qUnit) ?? 0
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>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
@@ -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/dist/types.d.ts CHANGED
@@ -176,6 +176,18 @@ export interface WindowOptions {
176
176
  * @default false
177
177
  */
178
178
  menubarOnly?: boolean;
179
+ /**
180
+ * The window is the tray icon's popover (macOS): clicking the icon opens it
181
+ * underneath, on the popover material with rounded corners, and clicking
182
+ * anywhere else closes it. Implies `systemTray`, `frameless`, `headless`,
183
+ * `resizable: false`, `webWindowMaterial` and `hideDockIcon`.
184
+ *
185
+ * The page gets no `tray.onClick` in this mode, since the click already
186
+ * opened or closed it; `window.show()` and `window.hide()` open and close it
187
+ * in place, and `window.setSize()` keeps it hung from the icon.
188
+ * @default false
189
+ */
190
+ trayPopover?: boolean;
179
191
  /**
180
192
  * Hide the titlebar (content extends to window edge)
181
193
  * @default false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.97",
3
+ "version": "0.0.99",
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>",