@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 +19 -0
- package/dist/index.d.ts +2 -5
- package/dist/index.js +20 -0
- package/package.json +1 -1
- package/templates/CraftActivityAttributes.swift +29 -2
- package/templates/CraftApp.swift +1 -1
- package/templates/CraftHealth.swift +14 -0
- package/templates/CraftLiveActivities.swift +106 -2
- package/templates/CraftLiveActivityWidget.swift.template +1 -1
- package/templates/CraftNativeActions.swift +9 -1
- package/templates/CraftNativeScreen.swift +51 -1
- package/templates/CraftVoiceRecording.swift +75 -0
- package/templates/project.yml.template +6 -0
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
|
-
|
|
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
|
@@ -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
|
-
|
|
11
|
+
var status: String
|
|
7
12
|
let distanceMeters: Double
|
|
8
13
|
let durationSeconds: Double
|
|
9
|
-
|
|
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
|
+
}
|
package/templates/CraftApp.swift
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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
|
|
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
|