@craft-native/ios 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.
package/dist/cli.js CHANGED
@@ -881,6 +881,7 @@ function writeShell(output, config) {
881
881
  cpSync(join3(TEMPLATES_DIR, "CraftWebAudio.swift"), join3(output, "Sources", "CraftWebAudio.swift"));
882
882
  cpSync(join3(TEMPLATES_DIR, "CraftHybrid.swift"), join3(output, "Sources", "CraftHybrid.swift"));
883
883
  writeFileSync(join3(output, "Sources", "CraftCrashReporter.swift"), readFileSync(join3(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
884
+ cpSync(join3(TEMPLATES_DIR, "CraftLiveActivities.swift"), join3(output, "Sources", "CraftLiveActivities.swift"));
884
885
  cpSync(join3(TEMPLATES_DIR, "CraftActivityAttributes.swift"), join3(output, "Shared", "CraftActivityAttributes.swift"));
885
886
  for (const [enabled, folder, template, filename] of [
886
887
  [config.enableLiveActivities, "WidgetExtension", "CraftLiveActivityWidget.swift.template", `${name}LiveActivity.swift`],
package/dist/index.js CHANGED
@@ -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`],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.194",
3
+ "version": "0.0.196",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 {