craft-native 0.0.319 → 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.
package/dist/index.cjs CHANGED
@@ -375,7 +375,7 @@ function craftBinaryNotFoundMessage(triedPath) {
375
375
  `);
376
376
  }
377
377
  // package.json
378
- var version = "0.0.319";
378
+ var version = "0.0.328";
379
379
 
380
380
  // src/sdk-version.ts
381
381
  var SDK_VERSION = version;
@@ -677,6 +677,12 @@ async function packageMacOS(config, outDir) {
677
677
  } catch (error) {
678
678
  return [{ success: false, platform: "macos", format: "app", error: error.message }];
679
679
  }
680
+ if (opts.notarize && !opts.appStore) {
681
+ const notarized = await notarizeIfRequested(opts, appBundlePath, "app");
682
+ results.push(...notarized);
683
+ if (notarized.some((result) => !result.success))
684
+ return results;
685
+ }
680
686
  results.push({ success: true, platform: "macos", format: "app", outputPath: appBundlePath });
681
687
  if (opts.dmg !== false) {
682
688
  const outputPath = import_path.join(outDir, `${name}-${version}.dmg`);
@@ -725,24 +731,36 @@ async function notarizeIfRequested(opts, artifactPath, format) {
725
731
  error: "Notarization requires a keychain profile, API key, or Apple ID/password/team ID"
726
732
  }];
727
733
  }
728
- const submitted = await runTool("xcrun", notarytoolArguments({
729
- artifactPath,
730
- appleId: opts.appleId,
731
- applePassword: opts.applePassword,
732
- teamId: opts.teamId,
733
- keychainProfile: opts.notaryKeychainProfile,
734
- apiKey: opts.notaryApiKey
735
- }));
736
- if (!submitted.success)
737
- return [{ success: false, platform: "macos", format: `${format} (notarize)`, error: submitted.error }];
738
- const stapled = await runTool("xcrun", ["stapler", "staple", artifactPath]);
739
- return [{
740
- success: stapled.success,
741
- platform: "macos",
742
- format: `${format} (notarize)`,
743
- outputPath: stapled.success ? artifactPath : undefined,
744
- error: stapled.success ? undefined : stapled.error
745
- }];
734
+ const scratch = format === "app" ? createTempDir("craft-notarize-") : undefined;
735
+ try {
736
+ const submission = scratch ? import_path.join(scratch, "app.zip") : artifactPath;
737
+ if (scratch) {
738
+ const zipped = await runTool("ditto", ["-c", "-k", "--keepParent", artifactPath, submission]);
739
+ if (!zipped.success)
740
+ return [{ success: false, platform: "macos", format: `${format} (notarize)`, error: zipped.error }];
741
+ }
742
+ const submitted = await runTool("xcrun", notarytoolArguments({
743
+ artifactPath: submission,
744
+ appleId: opts.appleId,
745
+ applePassword: opts.applePassword,
746
+ teamId: opts.teamId,
747
+ keychainProfile: opts.notaryKeychainProfile,
748
+ apiKey: opts.notaryApiKey
749
+ }));
750
+ if (!submitted.success)
751
+ return [{ success: false, platform: "macos", format: `${format} (notarize)`, error: submitted.error }];
752
+ const stapled = await runTool("xcrun", ["stapler", "staple", artifactPath]);
753
+ return [{
754
+ success: stapled.success,
755
+ platform: "macos",
756
+ format: `${format} (notarize)`,
757
+ outputPath: stapled.success ? artifactPath : undefined,
758
+ error: stapled.success ? undefined : stapled.error
759
+ }];
760
+ } finally {
761
+ if (scratch)
762
+ removeTempDir(scratch);
763
+ }
746
764
  }
747
765
  async function packageWindows(config, outDir) {
748
766
  const results = [];
@@ -10910,6 +10928,9 @@ class AutoUpdater extends import_events3.EventEmitter {
10910
10928
  if (this.isNewerVersion(this.cachedManifest.version, this.config.currentVersion)) {
10911
10929
  this.updateInfo = this.cachedManifest;
10912
10930
  this.emit("update-available", this.cachedManifest);
10931
+ if (this.config.autoDownload) {
10932
+ await this.downloadUpdate();
10933
+ }
10913
10934
  return this.cachedManifest;
10914
10935
  }
10915
10936
  this.emit("update-not-available");
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.319";
120
+ var version = "0.0.328";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -433,6 +433,12 @@ async function packageMacOS(config, outDir) {
433
433
  } catch (error) {
434
434
  return [{ success: false, platform: "macos", format: "app", error: error.message }];
435
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
+ }
436
442
  results.push({ success: true, platform: "macos", format: "app", outputPath: appBundlePath });
437
443
  if (opts.dmg !== false) {
438
444
  const outputPath = join2(outDir, `${name}-${version}.dmg`);
@@ -481,24 +487,36 @@ async function notarizeIfRequested(opts, artifactPath, format) {
481
487
  error: "Notarization requires a keychain profile, API key, or Apple ID/password/team ID"
482
488
  }];
483
489
  }
484
- const submitted = await runTool("xcrun", notarytoolArguments({
485
- artifactPath,
486
- appleId: opts.appleId,
487
- applePassword: opts.applePassword,
488
- teamId: opts.teamId,
489
- keychainProfile: opts.notaryKeychainProfile,
490
- apiKey: opts.notaryApiKey
491
- }));
492
- if (!submitted.success)
493
- return [{ success: false, platform: "macos", format: `${format} (notarize)`, error: submitted.error }];
494
- const stapled = await runTool("xcrun", ["stapler", "staple", artifactPath]);
495
- return [{
496
- success: stapled.success,
497
- platform: "macos",
498
- format: `${format} (notarize)`,
499
- outputPath: stapled.success ? artifactPath : undefined,
500
- error: stapled.success ? undefined : stapled.error
501
- }];
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
+ }
502
520
  }
503
521
  async function packageWindows(config, outDir) {
504
522
  const results = [];
@@ -10666,6 +10684,9 @@ class AutoUpdater extends EventEmitter3 {
10666
10684
  if (this.isNewerVersion(this.cachedManifest.version, this.config.currentVersion)) {
10667
10685
  this.updateInfo = this.cachedManifest;
10668
10686
  this.emit("update-available", this.cachedManifest);
10687
+ if (this.config.autoDownload) {
10688
+ await this.downloadUpdate();
10689
+ }
10669
10690
  return this.cachedManifest;
10670
10691
  }
10671
10692
  this.emit("update-not-available");
@@ -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;
@@ -306,6 +307,21 @@ function createNativeMutationValidator() {
306
307
  }
307
308
 
308
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
+ }
309
325
  function syncNativeFonts(fonts, output) {
310
326
  const destination = join3(output, "NativeFonts");
311
327
  const names = new Set;
@@ -507,11 +523,13 @@ function installNativeBundle(source, output) {
507
523
  rmSync2(routes, { recursive: true, force: true });
508
524
  cpSync(source, routes, { recursive: true });
509
525
  }
526
+ compressNativeSources(routes);
510
527
  rmSync2(single, { force: true });
511
528
  return;
512
529
  }
513
530
  if (resolve(source) !== resolve(single))
514
531
  cpSync(source, single);
532
+ compressNativeSources(single);
515
533
  rmSync2(routes, { recursive: true, force: true });
516
534
  }
517
535
  function hasInstalledNativeBundle(output) {
@@ -1215,6 +1233,7 @@ function writeShell(output, config) {
1215
1233
  cpSync(join3(TEMPLATES_DIR, "CraftNativeUI.swift"), join3(output, "Sources", "CraftNativeUI.swift"));
1216
1234
  cpSync(join3(TEMPLATES_DIR, "CraftNativeServices.swift"), join3(output, "Sources", "CraftNativeServices.swift"));
1217
1235
  cpSync(join3(TEMPLATES_DIR, "CraftNativeFiles.swift"), join3(output, "Sources", "CraftNativeFiles.swift"));
1236
+ cpSync(join3(TEMPLATES_DIR, "CraftVoiceRecording.swift"), join3(output, "Sources", "CraftVoiceRecording.swift"));
1218
1237
  cpSync(join3(TEMPLATES_DIR, "CraftWebAudio.swift"), join3(output, "Sources", "CraftWebAudio.swift"));
1219
1238
  cpSync(join3(TEMPLATES_DIR, "CraftHybrid.swift"), join3(output, "Sources", "CraftHybrid.swift"));
1220
1239
  writeFileSync2(join3(output, "Sources", "CraftCrashReporter.swift"), readFileSync2(join3(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
@@ -1585,6 +1604,7 @@ export {
1585
1604
  backgroundRefreshIdentifier,
1586
1605
  bootSimulator,
1587
1606
  build,
1607
+ compressNativeSources,
1588
1608
  createNativeMutationValidator,
1589
1609
  findApplicationSwiftSources,
1590
1610
  hasNativeRoutes,
@@ -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
+ }
@@ -8281,7 +8281,7 @@ struct CraftWebView: UIViewRepresentable {
8281
8281
  // MARK: - Live Activities
8282
8282
  private func liveActivity(method: String, args: [Any], callbackId: String?) {
8283
8283
  let token = armDeadline(Coordinator.liveActivityDeadline, callbackId: callbackId, error: "Live Activity request timed out")
8284
- 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
8285
8285
  guard let self, self.claimDeadline(token) else { return }
8286
8286
  switch result {
8287
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
  }
@@ -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
  }
@@ -95,6 +95,7 @@ enum CraftNativeActions {
95
95
  // which the native Video element plays in place of the address.
96
96
  values.append("media")
97
97
  if config.enableCamera { values.append("camera") }
98
+ if config.enableAudioRecording { values.append("audioRecording") }
98
99
  // craft.crashReporter: the app's crash and error reporter (bughq).
99
100
  if config.crashReporting?.key.isEmpty == false { values.append("crashReporter") }
100
101
  return values
@@ -373,7 +374,7 @@ enum CraftNativeActions {
373
374
  }
374
375
  return
375
376
  case ("LiveActivities", _):
376
- CraftLiveActivities.perform(method: method, args: args, enabled: config.enableLiveActivities, appName: config.appName) { result in
377
+ CraftLiveActivities.perform(method: method, args: args, enabled: config.enableLiveActivities, appName: config.appName, healthEnabled: config.enableHealthKit) { result in
377
378
  complete(requestToken, result, completion)
378
379
  }
379
380
  return
@@ -555,6 +556,13 @@ enum CraftNativeActions {
555
556
  CraftNativeShare.share(options) { complete(requestToken, $0, completion) }
556
557
  }
557
558
  return
559
+ case ("AudioRecording", _):
560
+ guard config.enableAudioRecording else {
561
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Audio recording is disabled"), completion)
562
+ return
563
+ }
564
+ CraftVoiceRecording.shared.perform(method: method, args: args) { result in complete(requestToken, result, completion) }
565
+ return
558
566
  case ("Speech", "speak"):
559
567
  // Settles when the words have been said (true) or were cut off
560
568
  // (false), as the page's `craft.speech.speak` does.
@@ -1,4 +1,5 @@
1
1
  import Foundation
2
+ import zlib
2
3
  import CoreFoundation
3
4
  import ImageIO
4
5
  import AVFoundation
@@ -5550,10 +5551,36 @@ enum CraftNativeBundle {
5550
5551
  return read(url)
5551
5552
  }
5552
5553
 
5554
+ /// gzip assets are immutable, decoded lazily and held in the same bounded cache.
5555
+ private static func unpack(_ bytes: Data) -> Data? {
5556
+ guard bytes.starts(with: [0x1f, 0x8b]) else { return bytes }
5557
+ return bytes.withUnsafeBytes { raw -> Data? in
5558
+ var stream = z_stream()
5559
+ guard inflateInit2_(&stream, 15 + 32, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.size)) == Z_OK else { return nil }
5560
+ defer { inflateEnd(&stream) }
5561
+ stream.next_in = UnsafeMutablePointer<Bytef>(mutating: raw.bindMemory(to: Bytef.self).baseAddress)
5562
+ stream.avail_in = uInt(bytes.count)
5563
+ var result = Data()
5564
+ var chunk = [UInt8](repeating: 0, count: 32768)
5565
+ var status = Z_OK
5566
+ repeat {
5567
+ status = chunk.withUnsafeMutableBytes { output in
5568
+ stream.next_out = output.bindMemory(to: Bytef.self).baseAddress
5569
+ stream.avail_out = uInt(output.count)
5570
+ return inflate(&stream, Z_NO_FLUSH)
5571
+ }
5572
+ guard status == Z_OK || status == Z_STREAM_END else { return nil }
5573
+ result.append(contentsOf: chunk.prefix(chunk.count - Int(stream.avail_out)))
5574
+ guard result.count <= 64 << 20 else { return nil }
5575
+ } while status != Z_STREAM_END
5576
+ return result
5577
+ }
5578
+ }
5579
+
5553
5580
  private static func read(_ url: URL) -> (source: String, url: URL)? {
5554
5581
  let key = url.path as NSString
5555
5582
  if let cached = sources.object(forKey: key) { return (cached as String, url) }
5556
- guard let text = try? String(contentsOf: url, encoding: .utf8) else { return nil }
5583
+ guard let bytes = try? Data(contentsOf: url), let decoded = unpack(bytes), let text = String(data: decoded, encoding: .utf8) else { return nil }
5557
5584
  sources.setObject(text as NSString, forKey: key, cost: text.utf8.count)
5558
5585
  return (text, url)
5559
5586
  }
@@ -5700,6 +5727,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5700
5727
  /// itself to be in front and acts on it: a sign-in guard on a prepared
5701
5728
  /// Record tab pushed Login over the Feed at launch.
5702
5729
  private var reportedVisible: Bool?
5730
+ private var voiceRecordingOwner: String?
5703
5731
  private let capabilityScope = UUID().uuidString
5704
5732
  private var pendingCapabilityRequests = Set<String>()
5705
5733
  private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
@@ -5970,6 +5998,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5970
5998
  private func disposeRuntime() {
5971
5999
  guard !runtimeDisposed else { return }
5972
6000
  runtimeDisposed = true
6001
+ if let owner = voiceRecordingOwner {
6002
+ voiceRecordingOwner = nil
6003
+ CraftVoiceRecording.shared.perform(method: "cancel", args: [owner]) { _ in }
6004
+ }
5973
6005
  frameWanted = false
5974
6006
  CraftNativeFrameClock.shared.cancel(self)
5975
6007
  disposeRenderedTree()
@@ -7674,6 +7706,24 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7674
7706
  }
7675
7707
  let id = args.first as? String ?? ""
7676
7708
  switch (module, method) {
7709
+ case ("AudioRecording", _):
7710
+ guard config.enableAudioRecording else {
7711
+ reply(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Audio recording is disabled")))
7712
+ return true
7713
+ }
7714
+ if method == "start" {
7715
+ guard voiceRecordingOwner == nil else {
7716
+ reply(.failure(CraftNativeActionError(code: "RECORDING_BUSY", message: "This screen already owns a recording")))
7717
+ return true
7718
+ }
7719
+ voiceRecordingOwner = id
7720
+ }
7721
+ if method == "cancel", voiceRecordingOwner == id { voiceRecordingOwner = nil }
7722
+ CraftVoiceRecording.shared.perform(method: method, args: args, isActive: { [weak self] in self?.runtimeDisposed == false }) { [weak self] result in
7723
+ if case .failure = result, self?.voiceRecordingOwner == id { self?.voiceRecordingOwner = nil }
7724
+ reply(result)
7725
+ }
7726
+ return true
7677
7727
  case ("View", "focus"):
7678
7728
  let options = args.first as? [String: Any]
7679
7729
  let nodeId = options?["nodeId"] as? String ?? (args.first as? String) ?? ""