craft-native 0.0.254 → 0.0.272

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.
Files changed (31) hide show
  1. package/README.md +22 -0
  2. package/dist/android/src/index.d.ts +4 -2
  3. package/dist/android/src/index.js +65 -4
  4. package/dist/android/templates/AndroidManifest.xml.template +1 -0
  5. package/dist/android/templates/CraftHealthServices.kt.template +132 -0
  6. package/dist/android/templates/CraftHealthServicesService.kt.template +217 -0
  7. package/dist/android/templates/CraftHealthServicesStub.kt.template +16 -0
  8. package/dist/android/templates/CraftNativeHealth.kt.template +115 -0
  9. package/dist/android/templates/CraftNativeHealthStub.kt.template +1 -0
  10. package/dist/android/templates/CraftNativeMutation.kt.template +2 -0
  11. package/dist/android/templates/CraftNativeServices.kt.template +31 -8
  12. package/dist/android/templates/CraftNativeShell.kt.template +5 -0
  13. package/dist/android/templates/MainActivityNative.kt.template +343 -33
  14. package/dist/android/templates/build.gradle.kts.app.template +5 -0
  15. package/dist/api/mobile.d.ts +9 -1
  16. package/dist/browser/lifecycle.d.ts +7 -0
  17. package/dist/browser.js +126 -24
  18. package/dist/cli.js +1 -1
  19. package/dist/components/index.d.ts +19 -10
  20. package/dist/index.cjs +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/ios/templates/CraftApp.swift +55 -7
  23. package/dist/ios/templates/CraftHealth.swift +38 -0
  24. package/dist/ios/templates/CraftHybrid.swift +50 -7
  25. package/dist/ios/templates/CraftNativeActions.swift +109 -22
  26. package/dist/ios/templates/CraftNativeFlatList.swift +187 -18
  27. package/dist/ios/templates/CraftNativeMutation.swift +3 -1
  28. package/dist/ios/templates/CraftNativeScreen.swift +466 -24
  29. package/dist/macos/templates/CraftNativeMutation.swift +3 -1
  30. package/dist/mobile.js +1 -1
  31. package/package.json +1 -1
@@ -1227,6 +1227,11 @@ export interface NativeTabBarOptions {
1227
1227
  tintDark?: string;
1228
1228
  /** Whether Craft's horizontal tab-switch gesture may begin over the page. Defaults to true. */
1229
1229
  swipeEnabled?: boolean;
1230
+ /** The selected tab highlight. Omit to keep the default capsule. */
1231
+ selection?: {
1232
+ /** Continuous corner radius in points. The highlight height is the upper bound. */
1233
+ cornerRadius?: number;
1234
+ };
1230
1235
  }
1231
1236
  /**
1232
1237
  * The phone's own tab bar, floating over the page: Liquid Glass on iOS 26 and
@@ -1698,10 +1703,13 @@ export interface HealthLiveWorkoutResult {
1698
1703
  }
1699
1704
  export interface HealthLiveWorkoutEvent {
1700
1705
  id: string;
1701
- event: 'state' | 'error';
1706
+ event: 'state' | 'metrics' | 'error';
1702
1707
  state?: 'notStarted' | 'prepared' | 'running' | 'paused' | 'stopped' | 'ended' | 'unknown';
1703
1708
  /** Epoch milliseconds for a state transition. */
1704
1709
  date?: number;
1710
+ heartRateBpm?: number;
1711
+ activeEnergyCalories?: number;
1712
+ distanceMeters?: number;
1705
1713
  message?: string;
1706
1714
  }
1707
1715
  /** The health reads and writes a host may or may not have, depending on the app build it runs in. */
@@ -0,0 +1,7 @@
1
+ export type BrowserAppState = 'active' | 'background';
2
+ /** Browser lifecycle shares the native APP_STATE contract, including bfcache navigation. */
3
+ export declare function createBrowserLifecycle(page: Pick<Document, 'hidden' | 'addEventListener' | 'removeEventListener'>, host: Pick<Window, 'addEventListener' | 'removeEventListener'>, changed: (state: BrowserAppState) => void): {
4
+ getState: () => BrowserAppState;
5
+ onStateChange(callback: (state: BrowserAppState) => void): () => void;
6
+ dispose(): void;
7
+ };
package/dist/browser.js CHANGED
@@ -1,3 +1,50 @@
1
+ // src/browser/lifecycle.ts
2
+ function createBrowserLifecycle(page, host, changed) {
3
+ let state = page.hidden ? "background" : "active";
4
+ const listeners = new Set;
5
+ let disposed = false;
6
+ const set = (next) => {
7
+ if (disposed || state === next)
8
+ return;
9
+ state = next;
10
+ changed(state);
11
+ for (const listener of [...listeners]) {
12
+ try {
13
+ listener(state);
14
+ } catch (error) {
15
+ console.error("craft.lifecycle listener failed", error);
16
+ }
17
+ }
18
+ };
19
+ const visibility = () => set(page.hidden ? "background" : "active");
20
+ const hide = () => set("background");
21
+ page.addEventListener("visibilitychange", visibility);
22
+ host.addEventListener("pagehide", hide);
23
+ host.addEventListener("pageshow", visibility);
24
+ return {
25
+ getState: () => state,
26
+ onStateChange(callback) {
27
+ if (typeof callback !== "function")
28
+ throw new TypeError("lifecycle.onStateChange needs a function");
29
+ if (disposed)
30
+ return () => {};
31
+ listeners.add(callback);
32
+ return () => {
33
+ listeners.delete(callback);
34
+ };
35
+ },
36
+ dispose() {
37
+ if (disposed)
38
+ return;
39
+ disposed = true;
40
+ page.removeEventListener("visibilitychange", visibility);
41
+ host.removeEventListener("pagehide", hide);
42
+ host.removeEventListener("pageshow", visibility);
43
+ listeners.clear();
44
+ }
45
+ };
46
+ }
47
+
1
48
  // src/bridge/ids.ts
2
49
  function secureUUID() {
3
50
  const c = globalThis.crypto;
@@ -377,6 +424,8 @@ async function createBrowserPlatform(options = {}) {
377
424
  const files = new Map;
378
425
  const mediaURLs = new Map;
379
426
  const sounds = new Map;
427
+ const soundRequests = new Map;
428
+ const soundCleanup = new Map;
380
429
  const fades = new Map;
381
430
  const cancelFade = (sound) => {
382
431
  const fade = fades.get(sound);
@@ -414,11 +463,12 @@ async function createBrowserPlatform(options = {}) {
414
463
  if (!sound)
415
464
  return;
416
465
  await fade(sound, 0, sound.paused ? 0 : milliseconds);
417
- if (sounds.get(id) !== sound)
418
- return;
466
+ soundCleanup.get(sound)?.();
467
+ soundCleanup.delete(sound);
419
468
  sound.pause();
420
469
  sound.removeAttribute("src");
421
- sounds.delete(id);
470
+ if (sounds.get(id) === sound)
471
+ sounds.delete(id);
422
472
  };
423
473
  const lifetime = new AbortController;
424
474
  let account = new AbortController;
@@ -454,6 +504,7 @@ async function createBrowserPlatform(options = {}) {
454
504
  const resetAccount = async () => {
455
505
  const previousCache = `${namespace}:media:${mediaScope}`;
456
506
  account.abort();
507
+ soundRequests.clear();
457
508
  account = new AbortController;
458
509
  for (const path of files.keys())
459
510
  URL.revokeObjectURL(path);
@@ -462,10 +513,12 @@ async function createBrowserPlatform(options = {}) {
462
513
  files.clear();
463
514
  mediaURLs.clear();
464
515
  for (const audio of sounds.values()) {
516
+ soundCleanup.get(audio)?.();
465
517
  cancelFade(audio);
466
518
  audio.pause();
467
519
  audio.src = "";
468
520
  }
521
+ soundCleanup.clear();
469
522
  sounds.clear();
470
523
  await persistence.accountChanged();
471
524
  mediaScope = persistence.scope();
@@ -480,7 +533,10 @@ async function createBrowserPlatform(options = {}) {
480
533
  });
481
534
  };
482
535
  window.addEventListener("storage", storageEvent);
483
- const capabilityNames = ["storage", "secureStorage", "lifecycle", "dialog", "browser", "linking", "files", "share", "device"];
536
+ const lifecycle = typeof document !== "undefined" ? createBrowserLifecycle(document, window, (state) => emit("APP_STATE", { state })) : null;
537
+ const capabilityNames = ["storage", "secureStorage", "dialog", "browser", "linking", "files", "share", "device"];
538
+ if (lifecycle)
539
+ capabilityNames.push("lifecycle");
484
540
  if (persistence.atomicStorage)
485
541
  capabilityNames.push("storageCompareAndSet", "storageSetIfAbsent");
486
542
  if (typeof globalThis.crypto?.randomUUID === "function" || typeof globalThis.crypto?.getRandomValues === "function")
@@ -500,6 +556,7 @@ async function createBrowserPlatform(options = {}) {
500
556
  const platform = {
501
557
  craft: {
502
558
  ...persistence.craft,
559
+ ...lifecycle ? { lifecycle: { getState: lifecycle.getState, onStateChange: lifecycle.onStateChange, onChange: lifecycle.onStateChange } } : {},
503
560
  session: {
504
561
  key: sessionTokenKey,
505
562
  token: () => persistence.craft.secureStorage.getSync(sessionTokenKey),
@@ -755,29 +812,74 @@ async function createBrowserPlatform(options = {}) {
755
812
  const id = String(args[0]);
756
813
  const sound = sounds.get(id);
757
814
  if (method === "play") {
758
- await stopSound(id);
759
- const audio = new Audio(await platform.resolveMedia(String(args[1])));
760
- const settings = object(args[2]);
761
- audio.loop = Boolean(settings.loop);
762
- const volume = Math.max(0, Math.min(1, Number(settings.volume ?? 1)));
763
- audio.volume = Number(settings.fadeIn) > 0 ? 0 : volume;
764
- audio.onended = () => {
765
- sounds.delete(id);
766
- emit("SOUND_EVENT", { id, event: "ended" });
815
+ const request = {};
816
+ soundRequests.set(id, request);
817
+ const current = () => {
818
+ signal.throwIfAborted();
819
+ if (soundRequests.get(id) !== request)
820
+ throw new DOMException("Playback replaced", "AbortError");
767
821
  };
768
- audio.onerror = () => {
769
- sounds.delete(id);
770
- emit("SOUND_EVENT", { id, event: "error", message: audio.error?.message });
822
+ let audio;
823
+ const release = () => {
824
+ if (!audio)
825
+ return;
826
+ signal.removeEventListener("abort", release);
827
+ soundCleanup.delete(audio);
828
+ cancelFade(audio);
829
+ audio.pause();
830
+ audio.removeAttribute("src");
831
+ if (sounds.get(id) === audio)
832
+ sounds.delete(id);
771
833
  };
772
- sounds.set(id, audio);
773
- await audio.play();
774
- fade(audio, volume, Number(settings.fadeIn ?? 0));
775
- return true;
834
+ try {
835
+ await stopSound(id);
836
+ current();
837
+ const url = await platform.resolveMedia(String(args[1]));
838
+ current();
839
+ audio = new Audio(url);
840
+ const settings = object(args[2]);
841
+ audio.loop = Boolean(settings.loop);
842
+ const volume = Math.max(0, Math.min(1, Number(settings.volume ?? 1)));
843
+ audio.volume = Number(settings.fadeIn) > 0 ? 0 : volume;
844
+ audio.onended = () => {
845
+ release();
846
+ if (soundRequests.get(id) === request) {
847
+ soundRequests.delete(id);
848
+ emit("SOUND_EVENT", { id, event: "ended" });
849
+ }
850
+ };
851
+ audio.onerror = () => {
852
+ const message = audio?.error?.message;
853
+ release();
854
+ if (soundRequests.get(id) === request) {
855
+ soundRequests.delete(id);
856
+ emit("SOUND_EVENT", { id, event: "error", message });
857
+ }
858
+ };
859
+ sounds.set(id, audio);
860
+ soundCleanup.set(audio, release);
861
+ signal.addEventListener("abort", release, { once: true });
862
+ await audio.play();
863
+ current();
864
+ fade(audio, volume, Number(settings.fadeIn ?? 0));
865
+ return true;
866
+ } catch (error) {
867
+ release();
868
+ if (soundRequests.get(id) === request)
869
+ soundRequests.delete(id);
870
+ throw error;
871
+ }
776
872
  }
777
873
  if (method === "stopAll") {
874
+ soundRequests.clear();
778
875
  await Promise.all([...sounds.keys()].map((key) => stopSound(key, Number(first.fadeOut ?? 0))));
779
876
  return true;
780
877
  }
878
+ if (method === "stop") {
879
+ soundRequests.delete(id);
880
+ await stopSound(id, Number(object(args[1]).fadeOut ?? 0));
881
+ return Boolean(sound);
882
+ }
781
883
  if (!sound)
782
884
  return false;
783
885
  if (method === "pause") {
@@ -788,10 +890,6 @@ async function createBrowserPlatform(options = {}) {
788
890
  await sound.play();
789
891
  return true;
790
892
  }
791
- if (method === "stop") {
792
- await stopSound(id, Number(object(args[1]).fadeOut ?? 0));
793
- return true;
794
- }
795
893
  if (method === "setVolume") {
796
894
  await fade(sound, Number(args[1]), Number(object(args[2]).fade ?? 0));
797
895
  return true;
@@ -873,6 +971,8 @@ async function createBrowserPlatform(options = {}) {
873
971
  if (disposed)
874
972
  return;
875
973
  disposed = true;
974
+ soundRequests.clear();
975
+ lifecycle?.dispose();
876
976
  window.removeEventListener("storage", storageEvent);
877
977
  lifetime.abort();
878
978
  account.abort();
@@ -883,10 +983,12 @@ async function createBrowserPlatform(options = {}) {
883
983
  files.clear();
884
984
  mediaURLs.clear();
885
985
  for (const audio of sounds.values()) {
986
+ soundCleanup.get(audio)?.();
886
987
  cancelFade(audio);
887
988
  audio.pause();
888
989
  audio.src = "";
889
990
  }
991
+ soundCleanup.clear();
890
992
  sounds.clear();
891
993
  wakeLock?.release();
892
994
  listeners.clear();
package/dist/cli.js CHANGED
@@ -2842,7 +2842,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
2842
2842
  `);
2843
2843
  }
2844
2844
  // package.json
2845
- var version = "0.0.254";
2845
+ var version = "0.0.272";
2846
2846
 
2847
2847
  // bin/cli.ts
2848
2848
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
@@ -38,6 +38,8 @@ export interface FlexStyle {
38
38
  flexDirection?: 'row' | 'column' | 'row-reverse' | 'column-reverse';
39
39
  /** Flex wrap */
40
40
  flexWrap?: 'nowrap' | 'wrap' | 'wrap-reverse';
41
+ /** Flex/grid sibling order; equal values retain source order */
42
+ order?: number;
41
43
  /** Justify content (main axis) */
42
44
  justifyContent?: 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly';
43
45
  /** Align items (cross axis); baseline aligns text-bearing children by their first baseline */
@@ -309,6 +311,8 @@ export interface BaseProps {
309
311
  accessibilityState?: AccessibilityState;
310
312
  /** Accessibility role */
311
313
  accessibilityRole?: 'none' | 'button' | 'link' | 'image' | 'text' | 'header' | 'search' | 'adjustable' | 'menu' | 'summary' | 'alert' | 'checkbox' | 'combobox' | 'menuitem' | 'progressbar' | 'radio' | 'tablist' | 'radiogroup' | 'list' | 'menubar' | 'spinbutton' | 'switch' | 'tab' | 'timer' | 'toolbar' | 'scrollbar';
314
+ /** Native list chrome slot for keyed FlatList/SectionList children. */
315
+ listRole?: 'header' | 'item' | 'separator' | 'empty' | 'footer';
312
316
  /** Matches a view across an animated state change. */
313
317
  transitionID?: string;
314
318
  /** Children elements */
@@ -454,8 +458,14 @@ export interface VideoProps extends BaseProps {
454
458
  autoPlay?: boolean;
455
459
  loop?: boolean;
456
460
  muted?: boolean;
461
+ /** Controlled playback position in seconds. */
462
+ currentTime?: number;
457
463
  paused?: boolean;
458
464
  resizeMode?: 'contain' | 'cover' | 'stretch';
465
+ /** The source began loading. */
466
+ onLoadStart?: () => void;
467
+ /** The source became ready for playback. */
468
+ onLoadEnd?: () => void;
459
469
  /**
460
470
  * Carries on in a floating window (Picture in Picture) when the app goes to
461
471
  * the background or another app opens while it plays, and comes back into
@@ -848,16 +858,7 @@ export interface ActivityIndicatorProps extends BaseProps {
848
858
  * FlatList component props.
849
859
  */
850
860
  export interface FlatListProps<T> extends ScrollViewBaseProps {
851
- /** Data array */
852
- data: T[];
853
- /** Render item function */
854
- renderItem: (info: {
855
- item: T;
856
- index: number;
857
- }) => any;
858
- /** Key extractor function */
859
- keyExtractor?: (item: T, index: number) => string;
860
- /** Number of columns */
861
+ /** Number of columns in the keyed child tree. */
861
862
  numColumns?: number;
862
863
  /** Render rows in reverse order along the scroll axis. */
863
864
  inverted?: boolean;
@@ -870,6 +871,10 @@ export interface FlatListProps<T> extends ScrollViewBaseProps {
870
871
  /** Refreshing state */
871
872
  refreshing?: boolean;
872
873
  }
874
+ /**
875
+ * @deprecated Grouped SectionList data is deferred until the versioned native
876
+ * list-data protocol exists. Use keyed child rows with `listRole` instead.
877
+ */
873
878
  export interface SectionListSection<T> {
874
879
  data: T[];
875
880
  key?: string;
@@ -881,6 +886,10 @@ export interface ModalProps extends BaseProps {
881
886
  style?: StyleValue<ViewStyle>;
882
887
  visible?: boolean;
883
888
  transparent?: boolean;
889
+ /** Keep the popup interactive without blocking the screen behind it. */
890
+ nonModal?: boolean;
891
+ /** When non-modal, consume an outside tap and call onRequestClose. */
892
+ dismissOnOutsideTap?: boolean;
884
893
  animationType?: 'none' | 'fade' | 'slide';
885
894
  onShow?: () => void;
886
895
  onDismiss?: () => void;
package/dist/index.cjs CHANGED
@@ -373,7 +373,7 @@ function craftBinaryNotFoundMessage(triedPath) {
373
373
  `);
374
374
  }
375
375
  // package.json
376
- var version = "0.0.254";
376
+ var version = "0.0.272";
377
377
 
378
378
  // src/sdk-version.ts
379
379
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.254";
120
+ var version = "0.0.272";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -686,6 +686,9 @@ final class CraftChrome: ObservableObject {
686
686
  /// Whether a hybrid page lets the shell claim horizontal swipes for tabs.
687
687
  /// Native screens use their own per-screen `tabSwipeEnabled` option.
688
688
  private(set) var tabSwipeEnabled = true
689
+ /// The selected tab highlight's corner radius. Nil preserves the default
690
+ /// capsule, while a finite non-negative value opts into a rounded rect.
691
+ private(set) var tabSelectionCornerRadius: CGFloat?
689
692
 
690
693
  weak var webView: WKWebView?
691
694
  /// What the tab bar covers at the bottom of the screen, in points.
@@ -794,10 +797,21 @@ final class CraftChrome: ObservableObject {
794
797
  tabAccessory = next
795
798
  }
796
799
 
797
- /// A retained or prepared screen cannot clear another screen's chrome.
798
- func clearTabAccessory(ownedBy owner: AnyObject) {
800
+ /// A screen going away gives its accessory up without taking it off the
801
+ /// bar (a retained or prepared screen cannot clear another's): the screen coming in shows it until it says what it wants. Taken
802
+ /// off at once, the pill stretched across the bar and back while the
803
+ /// next screen (Messages, opened from the accessory; a tab opened for the
804
+ /// first time) started up and set the same accessory again.
805
+ func releaseTabAccessory(ownedBy owner: AnyObject) {
799
806
  guard tabAccessoryOwner === owner else { return }
800
- updateTabAccessory(nil)
807
+ tabAccessoryOwner = nil
808
+ }
809
+
810
+ /// Takes off an accessory nobody claims any more: the screen now showing
811
+ /// drew without asking for one, or is a page that never does.
812
+ func clearUnclaimedTabAccessory() {
813
+ guard tabAccessoryOwner == nil, tabAccessory != nil else { return }
814
+ tabAccessory = nil
801
815
  }
802
816
 
803
817
  func setHiddenByScreen(_ hidden: Bool) {
@@ -823,6 +837,7 @@ final class CraftChrome: ObservableObject {
823
837
  switch body["type"] as? String {
824
838
  case "tabBar":
825
839
  pendingHide?.cancel()
840
+ tabSelectionCornerRadius = Self.selectionCornerRadius(body["selection"])
826
841
  tabs = (body["tabs"] as? [[String: Any]] ?? []).compactMap { item in
827
842
  guard let id = item["id"] as? String, let title = item["title"] as? String else { return nil }
828
843
  let badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : $0 }
@@ -872,6 +887,15 @@ final class CraftChrome: ObservableObject {
872
887
  }
873
888
  }
874
889
 
890
+ private static func selectionCornerRadius(_ value: Any?) -> CGFloat? {
891
+ guard let selection = value as? [String: Any],
892
+ let raw = selection["cornerRadius"] as? NSNumber,
893
+ raw.doubleValue.isFinite,
894
+ raw.doubleValue >= 0,
895
+ raw.doubleValue <= Double(CGFloat.greatestFiniteMagnitude) else { return nil }
896
+ return CGFloat(raw.doubleValue)
897
+ }
898
+
875
899
  /// A tap on a tab: the page navigates, and says which tab is current.
876
900
  ///
877
901
  /// No haptic. UITabBar plays none, and a tab bar that ticks on every tap is
@@ -1175,7 +1199,7 @@ struct CraftTabBarView: View {
1175
1199
  }
1176
1200
  .foregroundStyle(isSelected ? selectedColor : Color.primary)
1177
1201
  .frame(maxWidth: .infinity, minHeight: 54)
1178
- .contentShape(Capsule())
1202
+ .contentShape(CraftTabSelectionShape(cornerRadius: chrome.tabSelectionCornerRadius))
1179
1203
  }
1180
1204
  .buttonStyle(.plain)
1181
1205
  .accessibilityShowsLargeContentViewer {
@@ -1188,7 +1212,11 @@ struct CraftTabBarView: View {
1188
1212
  }
1189
1213
  }
1190
1214
  .background {
1191
- CraftTabSelectionIndicator(selected: chrome.tabs.firstIndex(where: { $0.id == chrome.selected }), count: chrome.tabs.count)
1215
+ CraftTabSelectionIndicator(
1216
+ selected: chrome.tabs.firstIndex(where: { $0.id == chrome.selected }),
1217
+ count: chrome.tabs.count,
1218
+ cornerRadius: chrome.tabSelectionCornerRadius
1219
+ )
1192
1220
  }
1193
1221
  }
1194
1222
 
@@ -1230,18 +1258,33 @@ struct CraftTabBarView: View {
1230
1258
  }
1231
1259
  }
1232
1260
 
1261
+ private struct CraftTabSelectionShape: Shape {
1262
+ let cornerRadius: CGFloat?
1263
+
1264
+ func path(in rect: CGRect) -> Path {
1265
+ if let cornerRadius {
1266
+ return RoundedRectangle(
1267
+ cornerRadius: min(cornerRadius, min(rect.width, rect.height) / 2),
1268
+ style: .continuous
1269
+ ).path(in: rect)
1270
+ }
1271
+ return Capsule().path(in: rect)
1272
+ }
1273
+ }
1274
+
1233
1275
  private struct CraftTabSelectionIndicator: View {
1234
1276
  @ObservedObject private var progress = CraftChrome.shared.tabSwipeProgress
1235
1277
  let selected: Int?
1236
1278
  let count: Int
1279
+ let cornerRadius: CGFloat?
1237
1280
 
1238
1281
  var body: some View {
1239
1282
  GeometryReader { geometry in
1240
1283
  if let position = progress.position ?? selected.map({ CGFloat($0) }) {
1241
1284
  let width = geometry.size.width / CGFloat(max(1, count))
1242
- Capsule()
1243
- .fill(Color.primary.opacity(0.09))
1285
+ CraftTabSelectionShape(cornerRadius: cornerRadius)
1244
1286
  .frame(width: width, height: geometry.size.height)
1287
+ .foregroundStyle(Color.primary.opacity(0.09))
1245
1288
  .offset(x: position * width)
1246
1289
  }
1247
1290
  }
@@ -6315,6 +6358,7 @@ struct CraftWebView: UIViewRepresentable {
6315
6358
 
6316
6359
  SecItemDelete(query as CFDictionary)
6317
6360
  let status = SecItemAdd(query as CFDictionary, nil)
6361
+ CraftKeychain.forget(account: key)
6318
6362
  return status == errSecSuccess
6319
6363
  }
6320
6364
 
@@ -6341,12 +6385,14 @@ struct CraftWebView: UIViewRepresentable {
6341
6385
  kSecAttrAccount as String: key
6342
6386
  ]
6343
6387
  let status = SecItemDelete(query as CFDictionary)
6388
+ CraftKeychain.forget(account: key)
6344
6389
  return status == errSecSuccess || status == errSecItemNotFound
6345
6390
  }
6346
6391
 
6347
6392
  private func secureClear() -> Bool {
6348
6393
  let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword]
6349
6394
  let status = SecItemDelete(query as CFDictionary)
6395
+ CraftKeychain.forget(account: nil)
6350
6396
  return status == errSecSuccess || status == errSecItemNotFound
6351
6397
  }
6352
6398
 
@@ -8227,6 +8273,7 @@ struct CraftWebView: UIViewRepresentable {
8227
8273
  SecItemDelete(query as CFDictionary)
8228
8274
 
8229
8275
  let status = SecItemAdd(query as CFDictionary, nil)
8276
+ CraftKeychain.forget(account: key)
8230
8277
  if status == errSecSuccess {
8231
8278
  resolveCallback(callbackId, result: ["success": true, "key": key])
8232
8279
  } else {
@@ -8269,6 +8316,7 @@ struct CraftWebView: UIViewRepresentable {
8269
8316
  }
8270
8317
 
8271
8318
  let status = SecItemDelete(query as CFDictionary)
8319
+ CraftKeychain.forget(account: key)
8272
8320
  if status == errSecSuccess || status == errSecItemNotFound {
8273
8321
  resolveCallback(callbackId, result: ["success": true, "key": key])
8274
8322
  } else {
@@ -43,6 +43,19 @@ private final class CraftHealthWorkoutDelegate: NSObject, HKWorkoutSessionDelega
43
43
  }
44
44
  }
45
45
 
46
+ @available(iOS 26.0, *)
47
+ private final class CraftHealthWorkoutBuilderDelegate: NSObject, HKLiveWorkoutBuilderDelegate {
48
+ weak var owner: CraftHealth?
49
+
50
+ init(owner: CraftHealth) { self.owner = owner }
51
+
52
+ func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
53
+ owner?.emitLiveWorkoutMetrics(workoutBuilder)
54
+ }
55
+
56
+ func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
57
+ }
58
+
46
59
  /// Apple Health for the whole app: the web page's bridge (`craft.health` in
47
60
  /// the hybrid's web view, CraftApp.swift) and native screens alike
48
61
  /// (`Health` through CraftNativeActions), so a screen drawn natively reads
@@ -837,12 +850,15 @@ final class CraftHealth {
837
850
  let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
838
851
  let builder = session.associatedWorkoutBuilder()
839
852
  let delegate = CraftHealthWorkoutDelegate(id: { [weak self] in self?.liveWorkoutID ?? "" }) { [weak self] detail in self?.sendWorkout(detail) }
853
+ let metricsDelegate = CraftHealthWorkoutBuilderDelegate(owner: self)
840
854
  session.delegate = delegate
855
+ builder.delegate = metricsDelegate
841
856
  builder.shouldCollectWorkoutEvents = true
842
857
  builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
843
858
  liveWorkoutSession = session
844
859
  liveWorkoutBuilder = builder
845
860
  liveWorkoutDelegate = delegate
861
+ liveWorkoutBuilderDelegate = metricsDelegate
846
862
  liveWorkoutID = UUID().uuidString.lowercased()
847
863
  liveWorkoutType = type
848
864
  liveWorkoutStart = Date()
@@ -923,11 +939,32 @@ final class CraftHealth {
923
939
  liveWorkoutSession = nil
924
940
  liveWorkoutBuilder = nil
925
941
  liveWorkoutDelegate = nil
942
+ liveWorkoutBuilderDelegate = nil
926
943
  liveWorkoutID = nil
927
944
  liveWorkoutType = nil
928
945
  liveWorkoutStart = nil
929
946
  }
930
947
 
948
+ @available(iOS 26.0, *)
949
+ fileprivate func emitLiveWorkoutMetrics(_ builder: HKLiveWorkoutBuilder) {
950
+ guard let id = liveWorkoutID else { return }
951
+ var detail: [String: Any] = ["id": id, "event": "metrics", "date": Date().timeIntervalSince1970 * 1000]
952
+ let bpm = HKUnit.count().unitDivided(by: .minute())
953
+ if let type = HKQuantityType.quantityType(forIdentifier: .heartRate),
954
+ let value = builder.statistics(for: type)?.mostRecentQuantity()?.doubleValue(for: bpm), value.isFinite {
955
+ detail["heartRateBpm"] = value
956
+ }
957
+ if let type = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned),
958
+ let value = builder.statistics(for: type)?.sumQuantity()?.doubleValue(for: .kilocalorie()), value.isFinite {
959
+ detail["activeEnergyCalories"] = value
960
+ }
961
+ if let type = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning),
962
+ let value = builder.statistics(for: type)?.sumQuantity()?.doubleValue(for: .meter()), value.isFinite {
963
+ detail["distanceMeters"] = value
964
+ }
965
+ onMain { [weak self] in self?.sendWorkout(detail) }
966
+ }
967
+
931
968
  private static func workoutActivity(_ type: String) -> HKWorkoutActivityType? {
932
969
  switch type {
933
970
  case "running": return .running
@@ -965,6 +1002,7 @@ final class CraftHealth {
965
1002
  private var liveWorkoutSession: AnyObject?
966
1003
  private var liveWorkoutBuilder: AnyObject?
967
1004
  private var liveWorkoutDelegate: AnyObject?
1005
+ private var liveWorkoutBuilderDelegate: AnyObject?
968
1006
  private var liveWorkoutID: String?
969
1007
  private var liveWorkoutType: String?
970
1008
  private var liveWorkoutStart: Date?