craft-native 0.0.304 → 0.0.328

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 (33) hide show
  1. package/dist/android/src/index.d.ts +3 -5
  2. package/dist/android/src/index.js +50 -7
  3. package/dist/android/templates/CraftBridge.kt.template +0 -90
  4. package/dist/android/templates/CraftNative.kt.template +3 -0
  5. package/dist/android/templates/CraftNativeBackground.kt.template +8 -2
  6. package/dist/android/templates/CraftNativeServices.kt.template +100 -1
  7. package/dist/android/templates/CraftNativeShell.kt.template +26 -8
  8. package/dist/android/templates/CraftPermissionPolicy.kt.template +96 -0
  9. package/dist/android/templates/MainActivityNative.kt.template +120 -6
  10. package/dist/android/templates/build.gradle.kts.app.template +1 -5
  11. package/dist/api/mobile.d.ts +9 -0
  12. package/dist/cli.js +63 -20
  13. package/dist/index.cjs +67 -20
  14. package/dist/index.d.cts +2 -2
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +67 -20
  17. package/dist/ios/src/index.d.ts +2 -5
  18. package/dist/ios/src/index.js +26 -1
  19. package/dist/ios/src/testing.d.ts +6 -0
  20. package/dist/ios/templates/CraftActivityAttributes.swift +29 -2
  21. package/dist/ios/templates/CraftApp.swift +45 -7
  22. package/dist/ios/templates/CraftHealth.swift +14 -0
  23. package/dist/ios/templates/CraftHybrid.swift +6 -0
  24. package/dist/ios/templates/CraftLiveActivities.swift +106 -2
  25. package/dist/ios/templates/CraftLiveActivityWidget.swift.template +1 -1
  26. package/dist/ios/templates/CraftNativeActions.swift +10 -1
  27. package/dist/ios/templates/CraftNativeScreen.swift +170 -9
  28. package/dist/ios/templates/CraftVoiceRecording.swift +75 -0
  29. package/dist/ios/templates/project.yml.template +6 -0
  30. package/dist/ios/templates/testing/DriverTests.swift.template +38 -0
  31. package/dist/package.d.ts +15 -4
  32. package/dist/types.d.ts +11 -4
  33. package/package.json +1 -1
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.304";
120
+ var version = "0.0.328";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -384,6 +384,7 @@ async function packageMacOS(config, outDir) {
384
384
  iconPath: config.iconPath,
385
385
  provisioningProfile: opts.provisioningProfile,
386
386
  additionalExecutables: opts.additionalExecutables,
387
+ nativeRuntimePath: opts.nativeRuntimePath,
387
388
  outputPath: appBundlePath
388
389
  });
389
390
  if (!appBundle.success) {
@@ -432,6 +433,12 @@ async function packageMacOS(config, outDir) {
432
433
  } catch (error) {
433
434
  return [{ success: false, platform: "macos", format: "app", error: error.message }];
434
435
  }
436
+ if (opts.notarize && !opts.appStore) {
437
+ const notarized = await notarizeIfRequested(opts, appBundlePath, "app");
438
+ results.push(...notarized);
439
+ if (notarized.some((result) => !result.success))
440
+ return results;
441
+ }
435
442
  results.push({ success: true, platform: "macos", format: "app", outputPath: appBundlePath });
436
443
  if (opts.dmg !== false) {
437
444
  const outputPath = join2(outDir, `${name}-${version}.dmg`);
@@ -480,24 +487,36 @@ async function notarizeIfRequested(opts, artifactPath, format) {
480
487
  error: "Notarization requires a keychain profile, API key, or Apple ID/password/team ID"
481
488
  }];
482
489
  }
483
- const submitted = await runTool("xcrun", notarytoolArguments({
484
- artifactPath,
485
- appleId: opts.appleId,
486
- applePassword: opts.applePassword,
487
- teamId: opts.teamId,
488
- keychainProfile: opts.notaryKeychainProfile,
489
- apiKey: opts.notaryApiKey
490
- }));
491
- if (!submitted.success)
492
- return [{ success: false, platform: "macos", format: `${format} (notarize)`, error: submitted.error }];
493
- const stapled = await runTool("xcrun", ["stapler", "staple", artifactPath]);
494
- return [{
495
- success: stapled.success,
496
- platform: "macos",
497
- format: `${format} (notarize)`,
498
- outputPath: stapled.success ? artifactPath : undefined,
499
- error: stapled.success ? undefined : stapled.error
500
- }];
490
+ const scratch = format === "app" ? createTempDir("craft-notarize-") : undefined;
491
+ try {
492
+ const submission = scratch ? join2(scratch, "app.zip") : artifactPath;
493
+ if (scratch) {
494
+ const zipped = await runTool("ditto", ["-c", "-k", "--keepParent", artifactPath, submission]);
495
+ if (!zipped.success)
496
+ return [{ success: false, platform: "macos", format: `${format} (notarize)`, error: zipped.error }];
497
+ }
498
+ const submitted = await runTool("xcrun", notarytoolArguments({
499
+ artifactPath: submission,
500
+ appleId: opts.appleId,
501
+ applePassword: opts.applePassword,
502
+ teamId: opts.teamId,
503
+ keychainProfile: opts.notaryKeychainProfile,
504
+ apiKey: opts.notaryApiKey
505
+ }));
506
+ if (!submitted.success)
507
+ return [{ success: false, platform: "macos", format: `${format} (notarize)`, error: submitted.error }];
508
+ const stapled = await runTool("xcrun", ["stapler", "staple", artifactPath]);
509
+ return [{
510
+ success: stapled.success,
511
+ platform: "macos",
512
+ format: `${format} (notarize)`,
513
+ outputPath: stapled.success ? artifactPath : undefined,
514
+ error: stapled.success ? undefined : stapled.error
515
+ }];
516
+ } finally {
517
+ if (scratch)
518
+ removeTempDir(scratch);
519
+ }
501
520
  }
502
521
  async function packageWindows(config, outDir) {
503
522
  const results = [];
@@ -666,7 +685,7 @@ ${schemes.length ? `MimeType=${schemes.map((scheme) => `x-scheme-handler/${schem
666
685
  }
667
686
  function macOSInfoPlist(metadata) {
668
687
  const entries = [
669
- ["CFBundleExecutable", metadata.name],
688
+ ["CFBundleExecutable", metadata.executableName || metadata.name],
670
689
  ["CFBundleIdentifier", metadata.bundleId],
671
690
  ["CFBundleName", metadata.name],
672
691
  ["CFBundleShortVersionString", metadata.version],
@@ -809,6 +828,9 @@ function copyMacOSAppBundle(source, destination) {
809
828
  return { success: false, error: error.message };
810
829
  }
811
830
  }
831
+ function macOSRuntimeExecutablePath(appBundlePath) {
832
+ return join2(appBundlePath, "Contents", "Helpers", "CraftRuntime.app", "Contents", "MacOS", "craft");
833
+ }
812
834
  function createMacOSAppBundle(opts) {
813
835
  try {
814
836
  const { name, binaryPath, iconPath, provisioningProfile, additionalExecutables, outputPath } = opts;
@@ -825,6 +847,27 @@ function createMacOSAppBundle(opts) {
825
847
  copyFileSync(executable, destination);
826
848
  chmodSync(destination, 493);
827
849
  }
850
+ if (opts.nativeRuntimePath) {
851
+ const helper = macOSRuntimeExecutablePath(outputPath);
852
+ const helperContents = resolve(helper, "..", "..");
853
+ mkdirSync(join2(helperContents, "MacOS"), { recursive: true });
854
+ copyFileSync(opts.nativeRuntimePath, helper);
855
+ chmodSync(helper, 493);
856
+ if (iconPath) {
857
+ mkdirSync(join2(helperContents, "Resources"), { recursive: true });
858
+ copyFileSync(iconPath, join2(helperContents, "Resources", "icon.icns"));
859
+ }
860
+ writeFileSync2(join2(helperContents, "Info.plist"), macOSInfoPlist({
861
+ name,
862
+ executableName: "craft",
863
+ version: opts.version,
864
+ bundleId: `${opts.bundleId}.runtime`,
865
+ buildNumber: opts.buildNumber,
866
+ minimumSystemVersion: opts.minimumSystemVersion,
867
+ menuBarOnly: true,
868
+ iconName: iconPath ? "icon" : undefined
869
+ }));
870
+ }
828
871
  let iconName;
829
872
  if (iconPath) {
830
873
  if (!existsSync3(iconPath))
@@ -10641,6 +10684,9 @@ class AutoUpdater extends EventEmitter3 {
10641
10684
  if (this.isNewerVersion(this.cachedManifest.version, this.config.currentVersion)) {
10642
10685
  this.updateInfo = this.cachedManifest;
10643
10686
  this.emit("update-available", this.cachedManifest);
10687
+ if (this.config.autoDownload) {
10688
+ await this.downloadUpdate();
10689
+ }
10644
10690
  return this.cachedManifest;
10645
10691
  }
10646
10692
  this.emit("update-not-available");
@@ -11595,6 +11641,7 @@ export {
11595
11641
  liveActivities,
11596
11642
  loadURL,
11597
11643
  location,
11644
+ macOSRuntimeExecutablePath,
11598
11645
  materialYou,
11599
11646
  media,
11600
11647
  menuItem,
@@ -1,8 +1,5 @@
1
- /**
2
- * Craft iOS Builder
3
- *
4
- * Generates native iOS apps from web content using WKWebView.
5
- */
1
+ /** Store large native source assets compressed; the host decodes each once on demand. */
2
+ export declare function compressNativeSources(target: string): void;
6
3
  import { type CraftAppShortcut } from './app-shortcuts.js';
7
4
  /** Package custom fonts and their licenses; removed fonts cannot linger in the app. */
8
5
  export declare function syncNativeFonts(fonts: CraftConfig['nativeFonts'], output: string): string[];
@@ -1,5 +1,6 @@
1
1
  // @bun
2
2
  // ../ios/src/index.ts
3
+ import { gzipSync } from "zlib";
3
4
  import { cpSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync as readdirSync2, rmSync as rmSync2, statSync, writeFileSync as writeFileSync2 } from "fs";
4
5
  import { dirname as dirname2, extname, join as join3, resolve, sep as sep2 } from "path";
5
6
  var {$ } = globalThis.Bun;
@@ -189,7 +190,12 @@ function serializeSimulatorSteps(steps) {
189
190
  };
190
191
  const line = (step) => {
191
192
  let arg = "";
192
- if ("paths" in step) {
193
+ if (step.command === "holdtab") {
194
+ const seconds = step.seconds ?? 6;
195
+ if (!step.text.trim() || step.text.includes("|") || number(seconds) < 2 || seconds > 10)
196
+ throw new Error("holdtab needs a nonempty tab name and 2\u201310 seconds");
197
+ arg = `${step.text} | ${seconds}`;
198
+ } else if ("paths" in step) {
193
199
  if (!step.paths.length || step.paths.length > 64)
194
200
  throw new Error("Expected 1\u201364 touch paths");
195
201
  for (const path of step.paths) {
@@ -301,6 +307,21 @@ function createNativeMutationValidator() {
301
307
  }
302
308
 
303
309
  // ../ios/src/index.ts
310
+ function compressNativeSources(target) {
311
+ if (statSync(target).isDirectory()) {
312
+ for (const entry of readdirSync2(target))
313
+ compressNativeSources(join3(target, entry));
314
+ return;
315
+ }
316
+ if (!/\.(?:js|json)$/.test(target))
317
+ return;
318
+ const bytes = readFileSync2(target);
319
+ if (bytes.length < 1024 || bytes[0] === 31 && bytes[1] === 139)
320
+ return;
321
+ const packed = gzipSync(bytes, { level: 9 });
322
+ if (packed.length < bytes.length)
323
+ writeFileSync2(target, packed);
324
+ }
304
325
  function syncNativeFonts(fonts, output) {
305
326
  const destination = join3(output, "NativeFonts");
306
327
  const names = new Set;
@@ -502,11 +523,13 @@ function installNativeBundle(source, output) {
502
523
  rmSync2(routes, { recursive: true, force: true });
503
524
  cpSync(source, routes, { recursive: true });
504
525
  }
526
+ compressNativeSources(routes);
505
527
  rmSync2(single, { force: true });
506
528
  return;
507
529
  }
508
530
  if (resolve(source) !== resolve(single))
509
531
  cpSync(source, single);
532
+ compressNativeSources(single);
510
533
  rmSync2(routes, { recursive: true, force: true });
511
534
  }
512
535
  function hasInstalledNativeBundle(output) {
@@ -1210,6 +1233,7 @@ function writeShell(output, config) {
1210
1233
  cpSync(join3(TEMPLATES_DIR, "CraftNativeUI.swift"), join3(output, "Sources", "CraftNativeUI.swift"));
1211
1234
  cpSync(join3(TEMPLATES_DIR, "CraftNativeServices.swift"), join3(output, "Sources", "CraftNativeServices.swift"));
1212
1235
  cpSync(join3(TEMPLATES_DIR, "CraftNativeFiles.swift"), join3(output, "Sources", "CraftNativeFiles.swift"));
1236
+ cpSync(join3(TEMPLATES_DIR, "CraftVoiceRecording.swift"), join3(output, "Sources", "CraftVoiceRecording.swift"));
1213
1237
  cpSync(join3(TEMPLATES_DIR, "CraftWebAudio.swift"), join3(output, "Sources", "CraftWebAudio.swift"));
1214
1238
  cpSync(join3(TEMPLATES_DIR, "CraftHybrid.swift"), join3(output, "Sources", "CraftHybrid.swift"));
1215
1239
  writeFileSync2(join3(output, "Sources", "CraftCrashReporter.swift"), readFileSync2(join3(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
@@ -1580,6 +1604,7 @@ export {
1580
1604
  backgroundRefreshIdentifier,
1581
1605
  bootSimulator,
1582
1606
  build,
1607
+ compressNativeSources,
1583
1608
  createNativeMutationValidator,
1584
1609
  findApplicationSwiftSources,
1585
1610
  hasNativeRoutes,
@@ -61,6 +61,12 @@ export type SimulatorStep = {
61
61
  | {
62
62
  command: 'touches';
63
63
  paths: TimedTouchPoint[][];
64
+ }
65
+ /** Standard XCTest tab hold; independent screenshots must be captured before release. */
66
+ | {
67
+ command: 'holdtab';
68
+ text: string;
69
+ seconds?: number;
64
70
  } | {
65
71
  command: 'field';
66
72
  index: number;
@@ -2,20 +2,27 @@ import ActivityKit
2
2
 
3
3
  @available(iOS 16.1, *)
4
4
  struct CraftActivityAttributes: ActivityAttributes {
5
+ struct Transition: Codable, Hashable {
6
+ let at: Double
7
+ let detail: String
8
+ let duration: Double?
9
+ }
5
10
  struct ContentState: Codable, Hashable {
6
- let status: String
11
+ var status: String
7
12
  let distanceMeters: Double
8
13
  let durationSeconds: Double
9
- let progress: Double
14
+ var progress: Double
10
15
  var staleStatus: String?
11
16
  var detail: String?
12
17
  /// Epoch milliseconds. SwiftUI advances these timers without app updates.
18
+ var sessionStartedAt: Double?
13
19
  var timerStartedAt: Double?
14
20
  var timerEndsAt: Double?
15
21
  var paused: Bool?
16
22
  var remainingSeconds: Double?
17
23
  var heartRate: Double?
18
24
  var showsDistance: Bool?
25
+
19
26
  }
20
27
 
21
28
  let activityId: String
@@ -24,3 +31,23 @@ struct CraftActivityAttributes: ActivityAttributes {
24
31
  var deepLink: String?
25
32
  var tint: String?
26
33
  }
34
+
35
+
36
+ /// The whole plan stays in the app process. Only ContentState crosses
37
+ /// ActivityKit's 4 KB payload boundary, regardless of the workout's length.
38
+ @available(iOS 16.1, *)
39
+ struct CraftActivityTimeline {
40
+ var pending: [CraftActivityAttributes.Transition]
41
+
42
+ mutating func advance(_ state: inout CraftActivityAttributes.ContentState, to milliseconds: Double) {
43
+ let due = pending.prefix { $0.at <= milliseconds }
44
+ guard let next = due.last else { return }
45
+ pending.removeFirst(due.count)
46
+ state.detail = next.detail
47
+ state.timerStartedAt = next.duration == nil ? state.sessionStartedAt : next.at
48
+ state.timerEndsAt = next.duration.map { next.at + $0 * 1000 }
49
+ state.remainingSeconds = next.duration.map { max(0, $0 - (milliseconds - next.at) / 1000) }
50
+ state.progress = 0
51
+ if next.duration == 0 { state.status = next.detail; state.paused = true; state.progress = 1 }
52
+ }
53
+ }
@@ -766,6 +766,13 @@ final class CraftChrome: ObservableObject {
766
766
  @Published var tabAccessory: Tab?
767
767
  private weak var tabAccessoryOwner: AnyObject?
768
768
  @Published var selected: String?
769
+ /// The visible destination may be the accessory, independently of its owning stack.
770
+ @Published var visiblePath: String?
771
+ var accessorySelected: Bool {
772
+ guard let path = visiblePath, let accessory = tabAccessory?.id else { return false }
773
+ return path == accessory || path.hasPrefix(accessory + "/")
774
+ }
775
+ var highlightedTab: String? { accessorySelected ? nil : selected }
769
776
  let tabSwipeProgress = CraftTabSwipeProgress()
770
777
  private(set) var tabSwipePosition: CGFloat? {
771
778
  get { tabSwipeProgress.position }
@@ -1172,6 +1179,18 @@ final class CraftChromeRelay: NSObject, WKScriptMessageHandler {
1172
1179
 
1173
1180
  /// The floating tab bar: Liquid Glass on iOS 26 and later, a material capsule before.
1174
1181
  struct CraftTabBarView: View {
1182
+ @Environment(\.dynamicTypeSize) private var dynamicTypeSize
1183
+
1184
+ var body: some View {
1185
+ // ScaledMetric reads the environment of its owning view. Put the
1186
+ // metrics in a child so this compact-chrome cap reaches them too.
1187
+ CraftTabBarContent(viewerTextSize: dynamicTypeSize)
1188
+ .dynamicTypeSize(...DynamicTypeSize.xxxLarge)
1189
+ }
1190
+ }
1191
+
1192
+ private struct CraftTabBarContent: View {
1193
+ let viewerTextSize: DynamicTypeSize
1175
1194
  @ObservedObject private var chrome = CraftChrome.shared
1176
1195
  @Environment(\.accessibilityReduceMotion) private var reduceMotion
1177
1196
  @Environment(\.scenePhase) private var scenePhase
@@ -1183,7 +1202,7 @@ struct CraftTabBarView: View {
1183
1202
  @ScaledMetric(relativeTo: .caption2) private var badgeSize: CGFloat = 10
1184
1203
 
1185
1204
  var body: some View {
1186
- if chrome.tabBarVisible && !chrome.tabs.isEmpty {
1205
+ if !chrome.tabs.isEmpty {
1187
1206
  HStack(spacing: 8) {
1188
1207
  bar
1189
1208
  if let accessory = chrome.tabAccessory {
@@ -1191,8 +1210,13 @@ struct CraftTabBarView: View {
1191
1210
  surface {
1192
1211
  Image(systemName: accessory.symbol)
1193
1212
  .font(.system(size: iconSize))
1194
- .foregroundStyle(Color.primary)
1213
+ .foregroundStyle(chrome.accessorySelected ? selectedColor : Color.primary)
1195
1214
  .frame(width: 62, height: 62)
1215
+ .background {
1216
+ if chrome.accessorySelected {
1217
+ Circle().fill(selectedColor.opacity(0.14)).padding(4)
1218
+ }
1219
+ }
1196
1220
  }
1197
1221
  .contentShape(Rectangle())
1198
1222
  .overlay(alignment: .topTrailing) {
@@ -1207,9 +1231,17 @@ struct CraftTabBarView: View {
1207
1231
  }
1208
1232
  }
1209
1233
  .buttonStyle(.plain)
1234
+ .accessibilityShowsLargeContentViewer {
1235
+ Image(systemName: accessory.symbol)
1236
+ Text(accessory.accessibilityLabel ?? accessory.title)
1237
+ }
1238
+ // Keep accessibility intent for the viewer; displayed
1239
+ // sizes come from the bounded metrics owned above.
1240
+ .environment(\.dynamicTypeSize, viewerTextSize)
1210
1241
  .accessibilityLabel(accessory.accessibilityLabel ?? accessory.title)
1211
1242
  .accessibilityValue(accessory.badge ?? "")
1212
1243
  .accessibilityIdentifier("craft-tab-accessory")
1244
+ .accessibilityAddTraits(chrome.accessorySelected ? .isSelected : [])
1213
1245
  }
1214
1246
  }
1215
1247
  .padding(.horizontal, 16)
@@ -1219,9 +1251,14 @@ struct CraftTabBarView: View {
1219
1251
  .onAppear { report(proxy) }
1220
1252
  .onChange(of: proxy.frame(in: .global)) { _ in report(proxy) }
1221
1253
  })
1222
- .transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
1254
+ // Keep the view mounted: a disappearing conditional inside a
1255
+ // child does not reliably animate in the UIKit navigation transaction.
1256
+ .offset(y: chrome.tabBarVisible || reduceMotion ? 0 : 120)
1257
+ .opacity(chrome.tabBarVisible ? 1 : 0)
1258
+ .allowsHitTesting(chrome.tabBarVisible)
1259
+ .accessibilityHidden(!chrome.tabBarVisible)
1260
+ .animation(.easeInOut(duration: reduceMotion ? 0.12 : 0.24), value: chrome.tabBarVisible)
1223
1261
  .ignoresSafeArea(.keyboard)
1224
- .dynamicTypeSize(...DynamicTypeSize.xxxLarge)
1225
1262
  }
1226
1263
  }
1227
1264
 
@@ -1260,7 +1297,7 @@ struct CraftTabBarView: View {
1260
1297
  private var items: some View {
1261
1298
  HStack(spacing: 0) {
1262
1299
  ForEach(chrome.tabs) { tab in
1263
- let isSelected = tab.id == chrome.selected
1300
+ let isSelected = tab.id == chrome.highlightedTab
1264
1301
  Button { chrome.tap(tab) } label: {
1265
1302
  VStack(spacing: 3) {
1266
1303
  ZStack(alignment: .topTrailing) {
@@ -1305,6 +1342,7 @@ struct CraftTabBarView: View {
1305
1342
  Image(systemName: symbol(tab.symbol, selected: isSelected))
1306
1343
  Text(tab.displayTitle)
1307
1344
  }
1345
+ .environment(\.dynamicTypeSize, viewerTextSize)
1308
1346
  .accessibilityIdentifier("craft-tab-\(tab.id)")
1309
1347
  .accessibilityLabel(tab.accessibilityLabel ?? tab.badge.map { "\(tab.displayTitle), \($0)" } ?? tab.displayTitle)
1310
1348
  .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
@@ -1312,7 +1350,7 @@ struct CraftTabBarView: View {
1312
1350
  }
1313
1351
  .background {
1314
1352
  CraftTabSelectionIndicator(
1315
- selected: chrome.tabs.firstIndex(where: { $0.id == chrome.selected }),
1353
+ selected: chrome.tabs.firstIndex(where: { $0.id == chrome.highlightedTab }),
1316
1354
  count: chrome.tabs.count,
1317
1355
  cornerRadius: chrome.tabSelectionCornerRadius
1318
1356
  )
@@ -8243,7 +8281,7 @@ struct CraftWebView: UIViewRepresentable {
8243
8281
  // MARK: - Live Activities
8244
8282
  private func liveActivity(method: String, args: [Any], callbackId: String?) {
8245
8283
  let token = armDeadline(Coordinator.liveActivityDeadline, callbackId: callbackId, error: "Live Activity request timed out")
8246
- CraftLiveActivities.perform(method: method, args: args, enabled: config.enableLiveActivities, appName: config.appName) { [weak self] result in
8284
+ CraftLiveActivities.perform(method: method, args: args, enabled: config.enableLiveActivities, appName: config.appName, healthEnabled: config.enableHealthKit) { [weak self] result in
8247
8285
  guard let self, self.claimDeadline(token) else { return }
8248
8286
  switch result {
8249
8287
  case .success(let value): self.resolveCallback(callbackId, result: value)
@@ -1011,6 +1011,17 @@ final class CraftHealth {
1011
1011
  }
1012
1012
  }
1013
1013
 
1014
+ /// Only the Live Activity that started this runtime can release it. HQ
1015
+ /// persists its own final result, so collecting here must not duplicate it.
1016
+ func discardActivityWorkout(expectedID: String) {
1017
+ guard #available(iOS 26.0, *), liveWorkoutID == expectedID,
1018
+ let session = liveWorkoutSession as? HKWorkoutSession,
1019
+ let builder = liveWorkoutBuilder as? HKLiveWorkoutBuilder else { return }
1020
+ session.end()
1021
+ builder.discardWorkout()
1022
+ clearLiveWorkout()
1023
+ }
1024
+
1014
1025
  private func clearLiveWorkout() {
1015
1026
  liveWorkoutSession = nil
1016
1027
  liveWorkoutBuilder = nil
@@ -1052,6 +1063,9 @@ final class CraftHealth {
1052
1063
  case "elliptical": return .elliptical
1053
1064
  case "strength": return .traditionalStrengthTraining
1054
1065
  case "hiit": return .highIntensityIntervalTraining
1066
+ case "flexibility": return .flexibility
1067
+ case "yoga": return .yoga
1068
+ case "other": return .other
1055
1069
  default: return nil
1056
1070
  }
1057
1071
  }
@@ -1664,6 +1664,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1664
1664
  (nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
1665
1665
  }
1666
1666
  currentTab = id
1667
+ if let top = nav.topViewController { CraftChrome.shared.visiblePath = nativePaths[ObjectIdentifier(top)] }
1667
1668
  for (key, other) in stacks { other.view.isHidden = key != id }
1668
1669
  nav.view.frame = view.bounds
1669
1670
  view.bringSubviewToFront(nav.view)
@@ -2042,6 +2043,9 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
2042
2043
  func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
2043
2044
  lastNavigation = CACurrentMediaTime()
2044
2045
  if animated { CraftFramePacing.shared.begin("navigation") }
2046
+ if navigationController === visibleStack {
2047
+ CraftChrome.shared.visiblePath = nativePaths[ObjectIdentifier(viewController)]
2048
+ }
2045
2049
  // The page draws its own header, and a tab's root screen its own
2046
2050
  // title unless it asked for the bar; a pushed native screen gets the
2047
2051
  // bar and its Back.
@@ -2084,6 +2088,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
2084
2088
  hybrid.pageReset(to: rootPath)
2085
2089
  }
2086
2090
  guard navigationController === visibleStack else { return }
2091
+ // Also restore selection after a cancelled interactive pop.
2092
+ CraftChrome.shared.visiblePath = nativePaths[ObjectIdentifier(viewController)]
2087
2093
  if let slot = viewController as? CraftHybridWebSlot { adoptWeb(into: slot) } else { parkWeb() }
2088
2094
  }
2089
2095
 
@@ -1,10 +1,16 @@
1
1
  import ActivityKit
2
2
  import Foundation
3
+ import HealthKit
3
4
 
4
5
  /// ActivityKit belongs to the process, not a web view or a retained STX screen.
5
6
  /// A stable application activityId reattaches after a screen or app restart.
6
7
  enum CraftLiveActivities {
7
- static func perform(method: String, args: [Any], enabled: Bool, appName: String, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
8
+ private static var scheduled: [String: Task<Void, Never>] = [:]
9
+ private static var watching: [String: Task<Void, Never>] = [:]
10
+ private static var workoutOwner: (key: String, id: String)?
11
+ private static var startingWorkout: String?
12
+
13
+ static func perform(method: String, args: [Any], enabled: Bool, appName: String, healthEnabled: Bool = false, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
8
14
  guard enabled else {
9
15
  if method == "isAvailable" { completion(.success(false)); return }
10
16
  completion(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Live Activities are disabled")))
@@ -33,11 +39,17 @@ enum CraftLiveActivities {
33
39
  let state = content(body)
34
40
  if let existing = activities.first(where: { $0.attributes.activityId == key }) {
35
41
  await existing.update(ActivityContent(state: state, staleDate: staleDate(body)))
42
+ watch(existing)
43
+ schedule(existing, plan: plan(body))
44
+ startWorkout(existing, body: body, enabled: healthEnabled)
36
45
  completion(.success(["id": existing.id]))
37
46
  return
38
47
  }
39
48
  let attributes = CraftActivityAttributes(activityId: key, title: String(title.prefix(160)), symbol: body["symbol"] as? String, deepLink: safeURL(body["deepLink"] as? String), tint: body["tint"] as? String)
40
49
  let activity = try Activity.request(attributes: attributes, content: ActivityContent(state: state, staleDate: staleDate(body)), pushType: nil)
50
+ watch(activity)
51
+ schedule(activity, plan: plan(body))
52
+ startWorkout(activity, body: body, enabled: healthEnabled)
41
53
  completion(.success(["id": activity.id]))
42
54
  case "update", "end":
43
55
  let id = args.first as? String
@@ -47,12 +59,21 @@ enum CraftLiveActivities {
47
59
  throw CraftNativeActionError(code: "NOT_FOUND", message: "No matching Live Activity is running")
48
60
  }
49
61
  let body = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
50
- let state = content(body, previous: activity.content.state)
62
+ let previous = activity.content.state
63
+ let state = content(body, previous: previous)
51
64
  if method == "end" {
65
+ watching.removeValue(forKey: activity.id)?.cancel()
66
+ scheduled.removeValue(forKey: activity.id)?.cancel()
67
+ stopWorkout(activity.attributes.activityId)
52
68
  await activity.end(ActivityContent(state: state, staleDate: nil), dismissalPolicy: .immediate)
53
69
  completion(.success(["ended": true]))
54
70
  } else {
55
71
  await activity.update(ActivityContent(state: state, staleDate: staleDate(body)))
72
+ schedule(activity, plan: plan(body))
73
+ if let owner = workoutOwner, owner.key == activity.attributes.activityId, previous.paused != state.paused {
74
+ if state.paused == true { CraftHealth.shared.pauseWorkout { _ in } }
75
+ else { CraftHealth.shared.resumeWorkout { _ in } }
76
+ }
56
77
  completion(.success(["updated": true]))
57
78
  }
58
79
  default: throw CraftNativeActionError(code: "UNKNOWN_ACTION", message: "Unsupported Live Activities method")
@@ -62,6 +83,88 @@ enum CraftLiveActivities {
62
83
  }
63
84
  }
64
85
 
86
+ /// Dismissing a Live Activity must release its own background workout
87
+ /// runtime as well, even though it no longer appears in the active list.
88
+ @available(iOS 16.2, *)
89
+ @MainActor private static func watch(_ activity: Activity<CraftActivityAttributes>) {
90
+ guard watching[activity.id] == nil else { return }
91
+ watching[activity.id] = Task { @MainActor in
92
+ for await state in activity.activityStateUpdates {
93
+ if Task.isCancelled { return }
94
+ if state == .ended || state == .dismissed {
95
+ scheduled.removeValue(forKey: activity.id)?.cancel()
96
+ stopWorkout(activity.attributes.activityId)
97
+ watching.removeValue(forKey: activity.id)
98
+ return
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ /// The real HealthKit workout owns background execution on iOS 26. It
105
+ /// collects while training; the caller's existing save path writes the
106
+ /// final workout once. Never stop an unrelated HealthKit session.
107
+ @available(iOS 16.2, *)
108
+ @MainActor private static func startWorkout(_ activity: Activity<CraftActivityAttributes>, body: [String: Any], enabled: Bool) {
109
+ guard enabled, #available(iOS 26.0, *), let type = body["workoutType"] as? String,
110
+ workoutOwner == nil, startingWorkout == nil,
111
+ HKHealthStore().authorizationStatus(for: .workoutType()) == .sharingAuthorized else { return }
112
+ let key = activity.attributes.activityId
113
+ startingWorkout = key
114
+ CraftHealth.shared.startWorkout(body: ["type": type]) { result in
115
+ Task { @MainActor in
116
+ startingWorkout = nil
117
+ guard case .success(let value) = result, let value = value as? [String: Any], let id = value["id"] as? String else { return }
118
+ guard activity.activityState == .active || activity.activityState == .stale else {
119
+ CraftHealth.shared.discardActivityWorkout(expectedID: id)
120
+ return
121
+ }
122
+ workoutOwner = (key, id)
123
+ if activity.content.state.paused == true { CraftHealth.shared.pauseWorkout { _ in } }
124
+ }
125
+ }
126
+ }
127
+
128
+ @MainActor private static func stopWorkout(_ key: String) {
129
+ guard let owner = workoutOwner, owner.key == key else { return }
130
+ workoutOwner = nil
131
+ CraftHealth.shared.discardActivityWorkout(expectedID: owner.id)
132
+ }
133
+
134
+ /// Step boundaries, independently of a retained screen or JS timer.
135
+ /// HealthKit, GPS and BLE use their documented background execution;
136
+ /// without permission iOS may suspend updates until the next app wake.
137
+ @available(iOS 16.2, *)
138
+ @MainActor private static func schedule(_ activity: Activity<CraftActivityAttributes>, plan: [CraftActivityAttributes.Transition]) {
139
+ scheduled.removeValue(forKey: activity.id)?.cancel()
140
+ guard activity.content.state.paused != true, !plan.isEmpty else { return }
141
+ scheduled[activity.id] = Task { @MainActor in
142
+ var timeline = CraftActivityTimeline(pending: plan)
143
+ while !Task.isCancelled, let next = timeline.pending.first {
144
+ let delay = max(0, next.at / 1000 - Date().timeIntervalSince1970)
145
+ do { try await Task.sleep(nanoseconds: UInt64(min(delay, 86400) * 1_000_000_000)) }
146
+ catch { return }
147
+ guard !Task.isCancelled, activity.activityState == .active || activity.activityState == .stale else { return }
148
+ var state = activity.content.state
149
+ timeline.advance(&state, to: Date().timeIntervalSince1970 * 1000)
150
+ if state.paused == true { stopWorkout(activity.attributes.activityId) }
151
+ await activity.update(ActivityContent(state: state, staleDate: timeline.pending.last.map { Date(timeIntervalSince1970: $0.at / 1000 + ($0.duration ?? 8 * 3600) + 1) }))
152
+ }
153
+ scheduled.removeValue(forKey: activity.id)
154
+ }
155
+ }
156
+
157
+ @available(iOS 16.1, *)
158
+ private static func plan(_ body: [String: Any]) -> [CraftActivityAttributes.Transition] {
159
+ let steps = (body["transitions"] as? [[String: Any]] ?? []).prefix(1000).compactMap { item -> CraftActivityAttributes.Transition? in
160
+ guard let at = finite(item, "at"), let detail = item["detail"] as? String else { return nil }
161
+ return .init(at: at, detail: String(detail.prefix(96)), duration: finite(item, "duration"))
162
+ }.sorted { $0.at < $1.at }
163
+ // An untimed exercise waits for the athlete. Never infer its completion.
164
+ if let stop = steps.firstIndex(where: { $0.duration == nil || $0.duration == 0 }) { return Array(steps.prefix(stop + 1)) }
165
+ return steps
166
+ }
167
+
65
168
  private static func safeURL(_ value: String?) -> String? {
66
169
  guard let value, let url = URL(string: value), let scheme = url.scheme?.lowercased(),
67
170
  !["javascript", "data", "file"].contains(scheme) else { return nil }
@@ -87,6 +190,7 @@ enum CraftLiveActivities {
87
190
  progress: min(finite(body, "progress", previous?.progress) ?? 0, 1),
88
191
  staleStatus: body["staleStatus"] as? String ?? previous?.staleStatus,
89
192
  detail: body["detail"] as? String ?? previous?.detail,
193
+ sessionStartedAt: finite(body, "sessionStartedAt", previous?.sessionStartedAt),
90
194
  timerStartedAt: finite(body, "timerStartedAt", previous?.timerStartedAt),
91
195
  timerEndsAt: finite(body, "timerEndsAt", previous?.timerEndsAt),
92
196
  paused: body["paused"] as? Bool ?? previous?.paused,
@@ -61,7 +61,7 @@ struct CraftLiveActivityWidget: Widget {
61
61
  } compactLeading: {
62
62
  activityIcon(context)
63
63
  } compactTrailing: {
64
- timer(context).monospacedDigit().frame(maxWidth: 64, alignment: .trailing)
64
+ timer(context).monospacedDigit().frame(width: 64, alignment: .trailing)
65
65
  } minimal: {
66
66
  activityIcon(context)
67
67
  }