@craft-native/ios 0.0.319 → 0.0.329

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
@@ -6,6 +6,7 @@ import { parseArgs } from "util";
6
6
  import { resolve as resolve2 } from "path";
7
7
 
8
8
  // src/index.ts
9
+ import { gzipSync } from "zlib";
9
10
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync as readdirSync2, rmSync, statSync, writeFileSync } from "fs";
10
11
  import { dirname as dirname2, extname, join as join3, resolve, sep as sep2 } from "path";
11
12
  var {$ } = globalThis.Bun;
@@ -117,6 +118,21 @@ import { dirname, join as join2 } from "path";
117
118
  var templates = join2(dirname(import.meta.dir), "templates/testing");
118
119
 
119
120
  // src/index.ts
121
+ function compressNativeSources(target) {
122
+ if (statSync(target).isDirectory()) {
123
+ for (const entry of readdirSync2(target))
124
+ compressNativeSources(join3(target, entry));
125
+ return;
126
+ }
127
+ if (!/\.(?:js|json)$/.test(target))
128
+ return;
129
+ const bytes = readFileSync(target);
130
+ if (bytes.length < 1024 || bytes[0] === 31 && bytes[1] === 139)
131
+ return;
132
+ const packed = gzipSync(bytes, { level: 9 });
133
+ if (packed.length < bytes.length)
134
+ writeFileSync(target, packed);
135
+ }
120
136
  function syncNativeFonts(fonts, output) {
121
137
  const destination = join3(output, "NativeFonts");
122
138
  const names = new Set;
@@ -315,11 +331,13 @@ function installNativeBundle(source, output) {
315
331
  rmSync(routes, { recursive: true, force: true });
316
332
  cpSync(source, routes, { recursive: true });
317
333
  }
334
+ compressNativeSources(routes);
318
335
  rmSync(single, { force: true });
319
336
  return;
320
337
  }
321
338
  if (resolve(source) !== resolve(single))
322
339
  cpSync(source, single);
340
+ compressNativeSources(single);
323
341
  rmSync(routes, { recursive: true, force: true });
324
342
  }
325
343
  function hasInstalledNativeBundle(output) {
@@ -1010,6 +1028,7 @@ function writeShell(output, config) {
1010
1028
  cpSync(join3(TEMPLATES_DIR, "CraftNativeUI.swift"), join3(output, "Sources", "CraftNativeUI.swift"));
1011
1029
  cpSync(join3(TEMPLATES_DIR, "CraftNativeServices.swift"), join3(output, "Sources", "CraftNativeServices.swift"));
1012
1030
  cpSync(join3(TEMPLATES_DIR, "CraftNativeFiles.swift"), join3(output, "Sources", "CraftNativeFiles.swift"));
1031
+ cpSync(join3(TEMPLATES_DIR, "CraftVoiceRecording.swift"), join3(output, "Sources", "CraftVoiceRecording.swift"));
1013
1032
  cpSync(join3(TEMPLATES_DIR, "CraftWebAudio.swift"), join3(output, "Sources", "CraftWebAudio.swift"));
1014
1033
  cpSync(join3(TEMPLATES_DIR, "CraftHybrid.swift"), join3(output, "Sources", "CraftHybrid.swift"));
1015
1034
  writeFileSync(join3(output, "Sources", "CraftCrashReporter.swift"), readFileSync(join3(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
package/dist/index.d.ts CHANGED
@@ -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[];
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // @bun
2
2
  // 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
  // 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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.319",
3
+ "version": "0.0.329",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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) ?? ""
@@ -0,0 +1,75 @@
1
+ import AVFoundation
2
+ import Foundation
3
+ import UIKit
4
+
5
+ /// One owner at a time, including the asynchronous permission prompt. A late
6
+ /// permission answer cannot start recording after its screen/account left.
7
+ final class CraftVoiceRecording {
8
+ static let shared = CraftVoiceRecording()
9
+ private var owner: String?
10
+ private var recorder: AVAudioRecorder?
11
+ private var file: URL?
12
+ private var backgroundObserver: NSObjectProtocol?
13
+
14
+ private init() {
15
+ backgroundObserver = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in self?.clear() }
16
+ }
17
+
18
+ private func clear() {
19
+ owner = nil
20
+ recorder?.stop()
21
+ recorder = nil
22
+ if let file { CraftNativeFiles.remove(file.path) }
23
+ file = nil
24
+ try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
25
+ }
26
+
27
+ func perform(method: String, args: [Any], isActive: @escaping () -> Bool = { true }, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
28
+ DispatchQueue.main.async { self.performOnMain(method: method, args: args, isActive: isActive, completion: completion) }
29
+ }
30
+
31
+ private func performOnMain(method: String, args: [Any], isActive: @escaping () -> Bool, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
32
+ func fail(_ code: String, _ message: String) { completion(.failure(CraftNativeActionError(code: code, message: message))) }
33
+ guard let key = args.first as? String, UUID(uuidString: key) != nil else { fail("INVALID_ARGUMENT", "A recording owner UUID is required"); return }
34
+ if method == "cancel" {
35
+ if owner == key { clear() }
36
+ completion(.success(true))
37
+ return
38
+ }
39
+ if method == "stop" {
40
+ guard owner == key, let file else { fail("RECORDING_NOT_FOUND", "This recording is no longer available"); return }
41
+ recorder?.stop()
42
+ recorder = nil
43
+ try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
44
+ guard let audio = try? AVAudioFile(forReading: file), audio.length > 0 else { clear(); fail("EMPTY_RECORDING", "The recording was empty"); return }
45
+ let duration = Double(audio.length) / audio.processingFormat.sampleRate
46
+ completion(.success(CraftNativeFiles.describe(file, type: "audio/mp4", extra: ["durationSeconds": duration])))
47
+ return
48
+ }
49
+ guard method == "start" else { fail("UNKNOWN_ACTION", "Unknown voice recording action"); return }
50
+ guard isActive() else { fail("RECORDING_CANCELLED", "The requesting screen has closed"); return }
51
+ guard owner == nil else { fail("RECORDING_BUSY", "Another microphone session is still active"); return }
52
+ let options = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
53
+ let seconds = (options["maxSeconds"] as? NSNumber)?.doubleValue ?? 300
54
+ guard seconds.isFinite, seconds >= 1, seconds <= 300 else { fail("INVALID_ARGUMENT", "Recordings are limited to five minutes"); return }
55
+ owner = key
56
+ AVAudioSession.sharedInstance().requestRecordPermission { allowed in
57
+ DispatchQueue.main.async {
58
+ guard self.owner == key, isActive() else { if self.owner == key { self.clear() }; fail("RECORDING_CANCELLED", "Recording was cancelled"); return }
59
+ guard allowed else { self.clear(); fail("PERMISSION_DENIED", "Microphone permission was refused"); return }
60
+ do {
61
+ let session = AVAudioSession.sharedInstance()
62
+ try session.setCategory(.record, mode: .measurement)
63
+ try session.setActive(true)
64
+ let file = try CraftNativeFiles.newFile(named: "voice-feedback.m4a")
65
+ self.file = file
66
+ let recorder = try AVAudioRecorder(url: file, settings: [AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 16000, AVNumberOfChannelsKey: 1, AVEncoderBitRateKey: 32000])
67
+ self.recorder = recorder
68
+ guard recorder.record(forDuration: max(0.5, seconds - 0.25)) else { throw CraftNativeActionError(code: "RECORDING_FAILED", message: "The microphone could not start") }
69
+ completion(.success(true))
70
+ }
71
+ catch { self.clear(); fail("RECORDING_FAILED", "The microphone could not start") }
72
+ }
73
+ }
74
+ }
75
+ }
@@ -14,6 +14,12 @@ settings:
14
14
  CRAFT_APNS_ENVIRONMENT: development
15
15
  release:
16
16
  CRAFT_APNS_ENVIRONMENT: production
17
+ SWIFT_OPTIMIZATION_LEVEL: "-O"
18
+ SWIFT_COMPILATION_MODE: wholemodule
19
+ GCC_OPTIMIZATION_LEVEL: s
20
+ DEAD_CODE_STRIPPING: YES
21
+ STRIP_INSTALLED_PRODUCT: YES
22
+ COPY_PHASE_STRIP: YES
17
23
  targets:
18
24
  {{APP_NAME}}:
19
25
  type: application