craft-native 0.0.194 → 0.0.196

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.
@@ -1632,12 +1632,27 @@ export declare const health: {
1632
1632
  onHeartRate(callback: (samples: HealthHeartRateSample[]) => void): () => void;
1633
1633
  };
1634
1634
  export interface LiveActivityState {
1635
+ /** What to show once staleAt passes without fresh app state. */
1636
+ staleStatus?: string;
1637
+ /** Current step, interval or session detail. */
1638
+ detail?: string;
1639
+ /** Epoch milliseconds; system-rendered timers keep advancing while the app sleeps. */
1640
+ timerStartedAt?: number | null;
1641
+ timerEndsAt?: number | null;
1642
+ paused?: boolean;
1643
+ remainingSeconds?: number;
1644
+ heartRate?: number | null;
1645
+ showsDistance?: boolean;
1646
+ staleAt?: number | null;
1635
1647
  status?: string;
1636
1648
  distanceMeters?: number;
1637
1649
  durationSeconds?: number;
1638
1650
  progress?: number;
1639
1651
  }
1640
1652
  export interface LiveActivityOptions extends LiveActivityState {
1653
+ symbol?: string;
1654
+ deepLink?: string;
1655
+ tint?: string;
1641
1656
  activityId: string;
1642
1657
  title: string;
1643
1658
  }
@@ -1647,6 +1662,12 @@ export interface LiveActivityHandle {
1647
1662
  end(finalState?: LiveActivityState): Promise<void>;
1648
1663
  }
1649
1664
  export interface LiveActivitiesApi {
1665
+ isAvailable(): Promise<boolean>;
1666
+ list(): Promise<Array<{
1667
+ id: string;
1668
+ activityId: string;
1669
+ title: string;
1670
+ }>>;
1650
1671
  start(options: LiveActivityOptions): Promise<LiveActivityHandle>;
1651
1672
  update(id: string, state: LiveActivityState): Promise<void>;
1652
1673
  /** @deprecated Pass the activity id returned by start, or use handle.update(state). */
package/dist/cli.js CHANGED
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3424
3424
  `);
3425
3425
  }
3426
3426
  // package.json
3427
- var version = "0.0.194";
3427
+ var version = "0.0.196";
3428
3428
 
3429
3429
  // bin/cli.ts
3430
3430
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -309,7 +309,7 @@ function craftBinaryNotFoundMessage(triedPath) {
309
309
  `);
310
310
  }
311
311
  // package.json
312
- var version = "0.0.194";
312
+ var version = "0.0.196";
313
313
 
314
314
  // src/sdk-version.ts
315
315
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ function craftBinaryNotFoundMessage(triedPath) {
57
57
  `);
58
58
  }
59
59
  // package.json
60
- var version = "0.0.194";
60
+ var version = "0.0.196";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -1018,6 +1018,7 @@ function writeShell(output, config) {
1018
1018
  cpSync(join3(TEMPLATES_DIR, "CraftWebAudio.swift"), join3(output, "Sources", "CraftWebAudio.swift"));
1019
1019
  cpSync(join3(TEMPLATES_DIR, "CraftHybrid.swift"), join3(output, "Sources", "CraftHybrid.swift"));
1020
1020
  writeFileSync2(join3(output, "Sources", "CraftCrashReporter.swift"), readFileSync2(join3(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
1021
+ cpSync(join3(TEMPLATES_DIR, "CraftLiveActivities.swift"), join3(output, "Sources", "CraftLiveActivities.swift"));
1021
1022
  cpSync(join3(TEMPLATES_DIR, "CraftActivityAttributes.swift"), join3(output, "Shared", "CraftActivityAttributes.swift"));
1022
1023
  for (const [enabled, folder, template, filename] of [
1023
1024
  [config.enableLiveActivities, "WidgetExtension", "CraftLiveActivityWidget.swift.template", `${name}LiveActivity.swift`],
@@ -7,8 +7,20 @@ struct CraftActivityAttributes: ActivityAttributes {
7
7
  let distanceMeters: Double
8
8
  let durationSeconds: Double
9
9
  let progress: Double
10
+ var staleStatus: String?
11
+ var detail: String?
12
+ /// Epoch milliseconds. SwiftUI advances these timers without app updates.
13
+ var timerStartedAt: Double?
14
+ var timerEndsAt: Double?
15
+ var paused: Bool?
16
+ var remainingSeconds: Double?
17
+ var heartRate: Double?
18
+ var showsDistance: Bool?
10
19
  }
11
20
 
12
21
  let activityId: String
13
22
  let title: String
23
+ var symbol: String?
24
+ var deepLink: String?
25
+ var tint: String?
14
26
  }
@@ -2752,6 +2752,10 @@ struct CraftWebView: UIViewRepresentable {
2752
2752
  }
2753
2753
 
2754
2754
  // MARK: - Live Activities
2755
+ case "isLiveActivityAvailable":
2756
+ liveActivity(method: "isAvailable", args: [], callbackId: callbackId)
2757
+ case "listLiveActivities":
2758
+ liveActivity(method: "list", args: [], callbackId: callbackId)
2755
2759
  case "startLiveActivity":
2756
2760
  if config.enableLiveActivities {
2757
2761
  startLiveActivity(body: body, callbackId: callbackId)
@@ -5380,6 +5384,8 @@ struct CraftWebView: UIViewRepresentable {
5380
5384
  };
5381
5385
  })();
5382
5386
  craft.liveActivity = {
5387
+ isAvailable: function() { return craft._invoke('isLiveActivityAvailable'); },
5388
+ list: function() { return craft._invoke('listLiveActivities'); },
5383
5389
  start: function(options) { return craft._invoke('startLiveActivity', options || {}); },
5384
5390
  update: function(idOrState, state) {
5385
5391
  if (typeof idOrState !== 'string') return craft._invoke('updateLiveActivity', idOrState || {});
@@ -7463,123 +7469,25 @@ struct CraftWebView: UIViewRepresentable {
7463
7469
  }
7464
7470
 
7465
7471
  // MARK: - Live Activities
7466
- private func startLiveActivity(body: [String: Any], callbackId: String?) {
7467
- guard #available(iOS 16.2, *), ActivityAuthorizationInfo().areActivitiesEnabled else {
7468
- rejectCallback(callbackId, error: "Live Activities are unavailable")
7469
- return
7470
- }
7471
- let attributes = CraftActivityAttributes(
7472
- activityId: body["activityId"] as? String ?? UUID().uuidString,
7473
- title: body["title"] as? String ?? config.appName
7474
- )
7475
- let state = CraftActivityAttributes.ContentState(
7476
- status: body["status"] as? String ?? "Recording",
7477
- distanceMeters: body["distanceMeters"] as? Double ?? 0,
7478
- durationSeconds: body["durationSeconds"] as? Double ?? 0,
7479
- progress: min(max(body["progress"] as? Double ?? 0, 0), 1)
7480
- )
7481
- do {
7482
- let activity = try Activity.request(
7483
- attributes: attributes,
7484
- content: ActivityContent(state: state, staleDate: nil),
7485
- pushType: nil
7486
- )
7487
- resolveCallback(callbackId, result: ["id": activity.id])
7488
- } catch {
7489
- rejectCallback(callbackId, error: error.localizedDescription)
7472
+ private func liveActivity(method: String, args: [Any], callbackId: String?) {
7473
+ let token = armDeadline(Coordinator.liveActivityDeadline, callbackId: callbackId, error: "Live Activity request timed out")
7474
+ CraftLiveActivities.perform(method: method, args: args, enabled: config.enableLiveActivities, appName: config.appName) { [weak self] result in
7475
+ guard let self, self.claimDeadline(token) else { return }
7476
+ switch result {
7477
+ case .success(let value): self.resolveCallback(callbackId, result: value)
7478
+ case .failure(let error): self.rejectCallback(callbackId, error: error.message)
7479
+ }
7490
7480
  }
7491
7481
  }
7492
7482
 
7483
+ private func startLiveActivity(body: [String: Any], callbackId: String?) {
7484
+ liveActivity(method: "start", args: [body], callbackId: callbackId)
7485
+ }
7493
7486
  private func updateLiveActivity(body: [String: Any], callbackId: String?) {
7494
- guard #available(iOS 16.2, *) else {
7495
- rejectCallback(callbackId, error: "No Live Activity is running")
7496
- return
7497
- }
7498
- let activities = Activity<CraftActivityAttributes>.activities
7499
- let activity: Activity<CraftActivityAttributes>
7500
- if let activityId = body["id"] as? String {
7501
- guard let matchingActivity = activities.first(where: { $0.id == activityId }) else {
7502
- rejectCallback(callbackId, error: "No Live Activity with id \(activityId) is running")
7503
- return
7504
- }
7505
- activity = matchingActivity
7506
- } else {
7507
- guard let currentActivity = activities.first else {
7508
- rejectCallback(callbackId, error: "No Live Activity is running")
7509
- return
7510
- }
7511
- activity = currentActivity
7512
- }
7513
- let current = activity.content.state
7514
- let state = CraftActivityAttributes.ContentState(
7515
- status: body["status"] as? String ?? current.status,
7516
- distanceMeters: body["distanceMeters"] as? Double ?? current.distanceMeters,
7517
- durationSeconds: body["durationSeconds"] as? Double ?? current.durationSeconds,
7518
- progress: min(max(body["progress"] as? Double ?? current.progress, 0), 1)
7519
- )
7520
- let token = armDeadline(
7521
- Coordinator.liveActivityDeadline,
7522
- callbackId: callbackId,
7523
- error: "Activity.update did not return within \(Int(Coordinator.liveActivityDeadline))s"
7524
- )
7525
- Task {
7526
- await activity.update(ActivityContent(state: state, staleDate: nil))
7527
- await MainActor.run { [weak self] in
7528
- guard let self, self.claimDeadline(token) else { return }
7529
- self.resolveCallback(callbackId, result: ["updated": true])
7530
- }
7531
- }
7487
+ liveActivity(method: "update", args: [body["id"] ?? NSNull(), body], callbackId: callbackId)
7532
7488
  }
7533
-
7534
7489
  private func endLiveActivity(body: [String: Any], callbackId: String?) {
7535
- guard #available(iOS 16.2, *) else {
7536
- resolveCallback(callbackId, result: ["ended": false])
7537
- return
7538
- }
7539
- let activities = Activity<CraftActivityAttributes>.activities
7540
- let activity: Activity<CraftActivityAttributes>
7541
- if let activityId = body["id"] as? String {
7542
- guard let matchingActivity = activities.first(where: { $0.id == activityId }) else {
7543
- rejectCallback(callbackId, error: "No Live Activity with id \(activityId) is running")
7544
- return
7545
- }
7546
- activity = matchingActivity
7547
- } else {
7548
- guard let currentActivity = activities.first else {
7549
- resolveCallback(callbackId, result: ["ended": false])
7550
- return
7551
- }
7552
- activity = currentActivity
7553
- }
7554
- let hasFinalState = body["status"] != nil
7555
- || body["distanceMeters"] != nil
7556
- || body["durationSeconds"] != nil
7557
- || body["progress"] != nil
7558
- let finalContent: ActivityContent<CraftActivityAttributes.ContentState>?
7559
- if hasFinalState {
7560
- let current = activity.content.state
7561
- let state = CraftActivityAttributes.ContentState(
7562
- status: body["status"] as? String ?? current.status,
7563
- distanceMeters: body["distanceMeters"] as? Double ?? current.distanceMeters,
7564
- durationSeconds: body["durationSeconds"] as? Double ?? current.durationSeconds,
7565
- progress: min(max(body["progress"] as? Double ?? current.progress, 0), 1)
7566
- )
7567
- finalContent = ActivityContent(state: state, staleDate: nil)
7568
- } else {
7569
- finalContent = nil
7570
- }
7571
- let token = armDeadline(
7572
- Coordinator.liveActivityDeadline,
7573
- callbackId: callbackId,
7574
- error: "Activity.end did not return within \(Int(Coordinator.liveActivityDeadline))s"
7575
- )
7576
- Task {
7577
- await activity.end(finalContent, dismissalPolicy: .default)
7578
- await MainActor.run { [weak self] in
7579
- guard let self, self.claimDeadline(token) else { return }
7580
- self.resolveCallback(callbackId, result: ["ended": true])
7581
- }
7582
- }
7490
+ liveActivity(method: "end", args: [body["id"] ?? NSNull(), body], callbackId: callbackId)
7583
7491
  }
7584
7492
 
7585
7493
  // MARK: - Screen Capture
@@ -0,0 +1,98 @@
1
+ import ActivityKit
2
+ import Foundation
3
+
4
+ /// ActivityKit belongs to the process, not a web view or a retained STX screen.
5
+ /// A stable application activityId reattaches after a screen or app restart.
6
+ enum CraftLiveActivities {
7
+ static func perform(method: String, args: [Any], enabled: Bool, appName: String, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
8
+ guard enabled else {
9
+ if method == "isAvailable" { completion(.success(false)); return }
10
+ completion(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Live Activities are disabled")))
11
+ return
12
+ }
13
+ guard #available(iOS 16.2, *) else {
14
+ if method == "isAvailable" { completion(.success(false)) }
15
+ else { completion(.failure(CraftNativeActionError(code: "UNSUPPORTED", message: "Live Activities require iOS 16.2"))) }
16
+ return
17
+ }
18
+ Task { @MainActor in
19
+ do {
20
+ let activities = Activity<CraftActivityAttributes>.activities.filter { $0.activityState == .active || $0.activityState == .stale }
21
+ switch method {
22
+ case "isAvailable": completion(.success(ActivityAuthorizationInfo().areActivitiesEnabled))
23
+ case "list": completion(.success(activities.map { ["id": $0.id, "activityId": $0.attributes.activityId, "title": $0.attributes.title] }))
24
+ case "start":
25
+ guard ActivityAuthorizationInfo().areActivitiesEnabled else {
26
+ throw CraftNativeActionError(code: "NOT_ALLOWED", message: "Live Activities are turned off in Settings")
27
+ }
28
+ let body = args.first as? [String: Any] ?? [:]
29
+ guard let key = body["activityId"] as? String, !key.isEmpty,
30
+ let title = body["title"] as? String, !title.isEmpty else {
31
+ throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A Live Activity needs activityId and title")
32
+ }
33
+ let state = content(body)
34
+ if let existing = activities.first(where: { $0.attributes.activityId == key }) {
35
+ await existing.update(ActivityContent(state: state, staleDate: staleDate(body)))
36
+ completion(.success(["id": existing.id]))
37
+ return
38
+ }
39
+ 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
+ let activity = try Activity.request(attributes: attributes, content: ActivityContent(state: state, staleDate: staleDate(body)), pushType: nil)
41
+ completion(.success(["id": activity.id]))
42
+ case "update", "end":
43
+ let id = args.first as? String
44
+ let activity = id == nil ? activities.first : activities.first(where: { $0.id == id })
45
+ guard let activity else {
46
+ if method == "end" { completion(.success(["ended": false])); return }
47
+ throw CraftNativeActionError(code: "NOT_FOUND", message: "No matching Live Activity is running")
48
+ }
49
+ let body = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
50
+ let state = content(body, previous: activity.content.state)
51
+ if method == "end" {
52
+ await activity.end(ActivityContent(state: state, staleDate: nil), dismissalPolicy: .immediate)
53
+ completion(.success(["ended": true]))
54
+ } else {
55
+ await activity.update(ActivityContent(state: state, staleDate: staleDate(body)))
56
+ completion(.success(["updated": true]))
57
+ }
58
+ default: throw CraftNativeActionError(code: "UNKNOWN_ACTION", message: "Unsupported Live Activities method")
59
+ }
60
+ } catch let error as CraftNativeActionError { completion(.failure(error)) }
61
+ catch { completion(.failure(CraftNativeActionError(code: "ACTIVITY_ERROR", message: error.localizedDescription))) }
62
+ }
63
+ }
64
+
65
+ private static func safeURL(_ value: String?) -> String? {
66
+ guard let value, let url = URL(string: value), let scheme = url.scheme?.lowercased(),
67
+ !["javascript", "data", "file"].contains(scheme) else { return nil }
68
+ return value
69
+ }
70
+
71
+ private static func finite(_ body: [String: Any], _ key: String, _ fallback: Double? = nil) -> Double? {
72
+ guard body.keys.contains(key) else { return fallback }
73
+ guard let number = body[key] as? NSNumber, number.doubleValue.isFinite else { return nil }
74
+ return max(0, number.doubleValue)
75
+ }
76
+
77
+ private static func staleDate(_ body: [String: Any]) -> Date? {
78
+ finite(body, "staleAt").map { Date(timeIntervalSince1970: $0 / 1000) }
79
+ }
80
+
81
+ @available(iOS 16.2, *)
82
+ private static func content(_ body: [String: Any], previous: CraftActivityAttributes.ContentState? = nil) -> CraftActivityAttributes.ContentState {
83
+ CraftActivityAttributes.ContentState(
84
+ status: String((body["status"] as? String ?? previous?.status ?? "").prefix(120)),
85
+ distanceMeters: finite(body, "distanceMeters", previous?.distanceMeters) ?? 0,
86
+ durationSeconds: finite(body, "durationSeconds", previous?.durationSeconds) ?? 0,
87
+ progress: min(finite(body, "progress", previous?.progress) ?? 0, 1),
88
+ staleStatus: body["staleStatus"] as? String ?? previous?.staleStatus,
89
+ detail: body["detail"] as? String ?? previous?.detail,
90
+ timerStartedAt: finite(body, "timerStartedAt", previous?.timerStartedAt),
91
+ timerEndsAt: finite(body, "timerEndsAt", previous?.timerEndsAt),
92
+ paused: body["paused"] as? Bool ?? previous?.paused,
93
+ remainingSeconds: finite(body, "remainingSeconds", previous?.remainingSeconds),
94
+ heartRate: finite(body, "heartRate", previous?.heartRate),
95
+ showsDistance: body["showsDistance"] as? Bool ?? previous?.showsDistance
96
+ )
97
+ }
98
+ }
@@ -4,59 +4,112 @@ import WidgetKit
4
4
 
5
5
  @main
6
6
  struct CraftLiveActivityBundle: WidgetBundle {
7
- var body: some Widget {
8
- CraftLiveActivityWidget()
9
- }
7
+ var body: some Widget { CraftLiveActivityWidget() }
10
8
  }
11
9
 
12
10
  struct CraftLiveActivityWidget: Widget {
13
11
  var body: some WidgetConfiguration {
14
12
  ActivityConfiguration(for: CraftActivityAttributes.self) { context in
15
13
  HStack(spacing: 12) {
16
- Image(systemName: "figure.run")
17
- .foregroundStyle(.green)
18
- VStack(alignment: .leading, spacing: 3) {
19
- Text(context.attributes.title).font(.headline)
20
- Text(context.state.status).font(.caption).foregroundStyle(.secondary)
14
+ Image(systemName: symbol(context)).font(.title2).foregroundStyle(tint(context))
15
+ VStack(alignment: .leading, spacing: 4) {
16
+ Text(context.attributes.title).font(.headline).lineLimit(1)
17
+ if let detail = context.state.detail, !detail.isEmpty {
18
+ Text(detail).font(.subheadline).lineLimit(1)
19
+ }
20
+ Text(context.isStale ? context.state.staleStatus ?? context.state.status : context.state.status).font(.caption).foregroundStyle(.secondary).lineLimit(1)
21
21
  }
22
- Spacer()
23
- VStack(alignment: .trailing, spacing: 3) {
24
- Text(distance(context.state.distanceMeters)).font(.headline.monospacedDigit())
25
- Text(duration(context.state.durationSeconds)).font(.caption.monospacedDigit())
22
+ Spacer(minLength: 8)
23
+ VStack(alignment: .trailing, spacing: 4) {
24
+ timer(context).font(.title2.monospacedDigit().weight(.semibold))
25
+ metrics(context).font(.caption.monospacedDigit())
26
26
  }
27
27
  }
28
28
  .padding()
29
- .activityBackgroundTint(Color(red: 0, green: 0.24, blue: 0.18))
29
+ .activityBackgroundTint(Color(red: 0.07, green: 0.10, blue: 0.16))
30
30
  .activitySystemActionForegroundColor(.white)
31
+ .widgetURL(context.attributes.deepLink.flatMap(URL.init(string:)))
31
32
  } dynamicIsland: { context in
32
33
  DynamicIsland {
33
34
  DynamicIslandExpandedRegion(.leading) {
34
- Label(context.state.status, systemImage: "figure.run")
35
+ Image(systemName: symbol(context)).foregroundStyle(tint(context)).font(.title2)
35
36
  }
36
37
  DynamicIslandExpandedRegion(.trailing) {
37
- Text(distance(context.state.distanceMeters)).monospacedDigit()
38
+ timer(context).font(.title2.monospacedDigit().weight(.semibold))
39
+ }
40
+ DynamicIslandExpandedRegion(.center) {
41
+ VStack(spacing: 3) {
42
+ Text(context.attributes.title).font(.headline).lineLimit(1)
43
+ Text(context.isStale ? context.state.staleStatus ?? context.state.status : context.state.status).font(.caption).foregroundStyle(.secondary).lineLimit(1)
44
+ }
38
45
  }
39
46
  DynamicIslandExpandedRegion(.bottom) {
40
- ProgressView(value: context.state.progress)
41
- .tint(.green)
47
+ VStack(spacing: 8) {
48
+ HStack {
49
+ Text(context.state.detail ?? "").font(.subheadline).lineLimit(1)
50
+ Spacer()
51
+ metrics(context).font(.caption.monospacedDigit())
52
+ }
53
+ if let end = context.state.timerEndsAt, context.state.paused != true, let start = context.state.timerStartedAt, end > start {
54
+ ProgressView(timerInterval: date(start)...date(end), countsDown: false).labelsHidden().tint(tint(context))
55
+ } else {
56
+ ProgressView(value: context.state.progress).tint(tint(context))
57
+ }
58
+ }
42
59
  }
43
60
  } compactLeading: {
44
- Image(systemName: "figure.run")
61
+ Image(systemName: symbol(context)).foregroundStyle(tint(context))
45
62
  } compactTrailing: {
46
- Text(distance(context.state.distanceMeters)).monospacedDigit()
63
+ timer(context).monospacedDigit().frame(maxWidth: 64)
47
64
  } minimal: {
48
- Image(systemName: "figure.run")
65
+ Image(systemName: symbol(context)).foregroundStyle(tint(context))
49
66
  }
50
- .keylineTint(.green)
67
+ .widgetURL(context.attributes.deepLink.flatMap(URL.init(string:)))
68
+ .keylineTint(tint(context))
51
69
  }
52
70
  }
53
71
 
54
- private func distance(_ meters: Double) -> String {
55
- String(format: "%.2f km", meters / 1000)
72
+ private func symbol(_ context: ActivityViewContext<CraftActivityAttributes>) -> String {
73
+ context.state.paused == true ? "pause.fill" : context.attributes.symbol ?? "timer"
74
+ }
75
+
76
+ private func tint(_ context: ActivityViewContext<CraftActivityAttributes>) -> Color {
77
+ let raw = context.attributes.tint?.trimmingCharacters(in: CharacterSet(charactersIn: "#")) ?? "007aff"
78
+ guard raw.count == 6, let value = UInt32(raw, radix: 16) else { return .blue }
79
+ return Color(red: Double((value >> 16) & 255) / 255, green: Double((value >> 8) & 255) / 255, blue: Double(value & 255) / 255)
80
+ }
81
+
82
+ private func date(_ milliseconds: Double) -> Date { Date(timeIntervalSince1970: milliseconds / 1000) }
83
+
84
+ @ViewBuilder
85
+ private func timer(_ context: ActivityViewContext<CraftActivityAttributes>) -> some View {
86
+ if context.state.paused == true {
87
+ Text(duration(context.state.remainingSeconds ?? context.state.durationSeconds))
88
+ } else if let end = context.state.timerEndsAt {
89
+ // Clamped at zero, with no JavaScript timer or per-second Activity.update.
90
+ let start = min(date(context.state.timerStartedAt ?? end - 1), date(end))
91
+ Text(timerInterval: start...date(end), countsDown: true)
92
+ } else if let start = context.state.timerStartedAt {
93
+ Text(date(start), style: .timer)
94
+ } else {
95
+ Text(duration(context.state.durationSeconds))
96
+ }
97
+ }
98
+
99
+ @ViewBuilder
100
+ private func metrics(_ context: ActivityViewContext<CraftActivityAttributes>) -> some View {
101
+ HStack(spacing: 8) {
102
+ if let bpm = context.state.heartRate, bpm > 0 {
103
+ Label("\(Int(bpm))", systemImage: "heart.fill").foregroundStyle(.pink).privacySensitive()
104
+ }
105
+ if context.state.showsDistance != false {
106
+ Text(Measurement(value: context.state.distanceMeters, unit: UnitLength.meters), format: .measurement(width: .abbreviated, usage: .road)).privacySensitive()
107
+ }
108
+ }
56
109
  }
57
110
 
58
111
  private func duration(_ seconds: Double) -> String {
59
112
  let value = max(Int(seconds), 0)
60
- return String(format: "%02d:%02d", value / 60, value % 60)
113
+ return value >= 3600 ? String(format: "%d:%02d:%02d", value / 3600, value / 60 % 60, value % 60) : String(format: "%d:%02d", value / 60, value % 60)
61
114
  }
62
115
  }
@@ -48,6 +48,7 @@ enum CraftNativeActions {
48
48
  if config.enableDeepLinks { values.append("deepLinks") }
49
49
  if config.enableLocalNotifications { values.append("notifications") }
50
50
  if config.enableHealthKit { values.append("health") }
51
+ if config.enableLiveActivities { values.append("liveActivities") }
51
52
  // The in-app browser and the system's web sign-in sheet (CraftBrowser).
52
53
  values.append("browser")
53
54
  if config.enableSocialAuth { values.append("appleSignIn") }
@@ -328,6 +329,11 @@ enum CraftNativeActions {
328
329
  }
329
330
  }
330
331
  return
332
+ case ("LiveActivities", _):
333
+ CraftLiveActivities.perform(method: method, args: args, enabled: config.enableLiveActivities, appName: config.appName) { result in
334
+ complete(requestToken, result, completion)
335
+ }
336
+ return
331
337
  case ("Health", _):
332
338
  // Apple Health, the same as a page's `craft.health` (CraftHealth.swift).
333
339
  guard config.enableHealthKit else {
@@ -214,6 +214,19 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
214
214
  if flowLayout.estimatedItemSize != estimate, bounds.width > 0 {
215
215
  flowLayout.estimatedItemSize = estimate
216
216
  }
217
+ // Cells can outlive a change to the list's width (including its first
218
+ // constrained parent layout). Self-sizing must use the current extent,
219
+ // or UIKit repeatedly receives a preferred width wider than the list.
220
+ var changedExtent = false
221
+ for case let cell as Cell in visibleCells {
222
+ guard let identity = cell.representedIdentity, let item = itemsByIdentity[identity] else { continue }
223
+ let extent = itemExtent(isChrome: item.isChrome)
224
+ if cell.fittingExtent != extent {
225
+ cell.fittingExtent = extent
226
+ changedExtent = true
227
+ }
228
+ }
229
+ if changedExtent { flowLayout.invalidateLayout() }
217
230
  super.layoutSubviews()
218
231
  craftApplyCornerRadius()
219
232
  }
@@ -5534,6 +5534,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5534
5534
  scale = 1
5535
5535
  }
5536
5536
  indicator.indicatorScale = scale
5537
+ applyTransform(style["transform"], to: indicator)
5537
5538
  indicator.color = color(props["color"]) ?? .tintColor
5538
5539
  indicator.hidesWhenStopped = props["hidesWhenStopped"] as? Bool ?? true
5539
5540
  if props["animating"] as? Bool == false {
package/dist/mobile.js CHANGED
@@ -1550,6 +1550,12 @@ async function endLiveActivity(id, finalState) {
1550
1550
  await craft.liveActivity.end();
1551
1551
  }
1552
1552
  var liveActivities = {
1553
+ async isAvailable() {
1554
+ return Boolean(await getCraftRoot()?.liveActivity?.isAvailable?.());
1555
+ },
1556
+ async list() {
1557
+ return await getCraftRoot()?.liveActivity?.list?.() ?? [];
1558
+ },
1553
1559
  async start(options) {
1554
1560
  const craft = getCraftRoot();
1555
1561
  if (!craft?.liveActivity?.start)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.194",
3
+ "version": "0.0.196",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",