craft-native 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/android/src/index.d.ts +3 -5
- package/dist/android/src/index.js +50 -5
- package/dist/android/templates/CraftBridge.kt.template +0 -90
- package/dist/android/templates/CraftNative.kt.template +3 -0
- package/dist/android/templates/CraftNativeBackground.kt.template +8 -2
- package/dist/android/templates/CraftNativeServices.kt.template +100 -1
- package/dist/android/templates/CraftPermissionPolicy.kt.template +96 -0
- package/dist/android/templates/MainActivityNative.kt.template +1 -0
- package/dist/android/templates/build.gradle.kts.app.template +1 -5
- package/dist/api/mobile.d.ts +9 -0
- package/dist/cli.js +37 -19
- package/dist/index.cjs +40 -19
- package/dist/index.js +40 -19
- package/dist/ios/src/index.d.ts +2 -5
- package/dist/ios/src/index.js +20 -0
- package/dist/ios/templates/CraftActivityAttributes.swift +29 -2
- package/dist/ios/templates/CraftApp.swift +1 -1
- package/dist/ios/templates/CraftHealth.swift +14 -0
- package/dist/ios/templates/CraftLiveActivities.swift +106 -2
- package/dist/ios/templates/CraftLiveActivityWidget.swift.template +1 -1
- package/dist/ios/templates/CraftNativeActions.swift +9 -1
- package/dist/ios/templates/CraftNativeScreen.swift +51 -1
- package/dist/ios/templates/CraftVoiceRecording.swift +75 -0
- package/dist/ios/templates/project.yml.template +6 -0
- package/dist/types.d.ts +3 -2
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -375,7 +375,7 @@ function craftBinaryNotFoundMessage(triedPath) {
|
|
|
375
375
|
`);
|
|
376
376
|
}
|
|
377
377
|
// package.json
|
|
378
|
-
var version = "0.0.
|
|
378
|
+
var version = "0.0.329";
|
|
379
379
|
|
|
380
380
|
// src/sdk-version.ts
|
|
381
381
|
var SDK_VERSION = version;
|
|
@@ -677,6 +677,12 @@ async function packageMacOS(config, outDir) {
|
|
|
677
677
|
} catch (error) {
|
|
678
678
|
return [{ success: false, platform: "macos", format: "app", error: error.message }];
|
|
679
679
|
}
|
|
680
|
+
if (opts.notarize && !opts.appStore) {
|
|
681
|
+
const notarized = await notarizeIfRequested(opts, appBundlePath, "app");
|
|
682
|
+
results.push(...notarized);
|
|
683
|
+
if (notarized.some((result) => !result.success))
|
|
684
|
+
return results;
|
|
685
|
+
}
|
|
680
686
|
results.push({ success: true, platform: "macos", format: "app", outputPath: appBundlePath });
|
|
681
687
|
if (opts.dmg !== false) {
|
|
682
688
|
const outputPath = import_path.join(outDir, `${name}-${version}.dmg`);
|
|
@@ -725,24 +731,36 @@ async function notarizeIfRequested(opts, artifactPath, format) {
|
|
|
725
731
|
error: "Notarization requires a keychain profile, API key, or Apple ID/password/team ID"
|
|
726
732
|
}];
|
|
727
733
|
}
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
734
|
+
const scratch = format === "app" ? createTempDir("craft-notarize-") : undefined;
|
|
735
|
+
try {
|
|
736
|
+
const submission = scratch ? import_path.join(scratch, "app.zip") : artifactPath;
|
|
737
|
+
if (scratch) {
|
|
738
|
+
const zipped = await runTool("ditto", ["-c", "-k", "--keepParent", artifactPath, submission]);
|
|
739
|
+
if (!zipped.success)
|
|
740
|
+
return [{ success: false, platform: "macos", format: `${format} (notarize)`, error: zipped.error }];
|
|
741
|
+
}
|
|
742
|
+
const submitted = await runTool("xcrun", notarytoolArguments({
|
|
743
|
+
artifactPath: submission,
|
|
744
|
+
appleId: opts.appleId,
|
|
745
|
+
applePassword: opts.applePassword,
|
|
746
|
+
teamId: opts.teamId,
|
|
747
|
+
keychainProfile: opts.notaryKeychainProfile,
|
|
748
|
+
apiKey: opts.notaryApiKey
|
|
749
|
+
}));
|
|
750
|
+
if (!submitted.success)
|
|
751
|
+
return [{ success: false, platform: "macos", format: `${format} (notarize)`, error: submitted.error }];
|
|
752
|
+
const stapled = await runTool("xcrun", ["stapler", "staple", artifactPath]);
|
|
753
|
+
return [{
|
|
754
|
+
success: stapled.success,
|
|
755
|
+
platform: "macos",
|
|
756
|
+
format: `${format} (notarize)`,
|
|
757
|
+
outputPath: stapled.success ? artifactPath : undefined,
|
|
758
|
+
error: stapled.success ? undefined : stapled.error
|
|
759
|
+
}];
|
|
760
|
+
} finally {
|
|
761
|
+
if (scratch)
|
|
762
|
+
removeTempDir(scratch);
|
|
763
|
+
}
|
|
746
764
|
}
|
|
747
765
|
async function packageWindows(config, outDir) {
|
|
748
766
|
const results = [];
|
|
@@ -10910,6 +10928,9 @@ class AutoUpdater extends import_events3.EventEmitter {
|
|
|
10910
10928
|
if (this.isNewerVersion(this.cachedManifest.version, this.config.currentVersion)) {
|
|
10911
10929
|
this.updateInfo = this.cachedManifest;
|
|
10912
10930
|
this.emit("update-available", this.cachedManifest);
|
|
10931
|
+
if (this.config.autoDownload) {
|
|
10932
|
+
await this.downloadUpdate();
|
|
10933
|
+
}
|
|
10913
10934
|
return this.cachedManifest;
|
|
10914
10935
|
}
|
|
10915
10936
|
this.emit("update-not-available");
|
package/dist/index.js
CHANGED
|
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
|
|
|
117
117
|
`);
|
|
118
118
|
}
|
|
119
119
|
// package.json
|
|
120
|
-
var version = "0.0.
|
|
120
|
+
var version = "0.0.329";
|
|
121
121
|
|
|
122
122
|
// src/sdk-version.ts
|
|
123
123
|
var SDK_VERSION = version;
|
|
@@ -433,6 +433,12 @@ async function packageMacOS(config, outDir) {
|
|
|
433
433
|
} catch (error) {
|
|
434
434
|
return [{ success: false, platform: "macos", format: "app", error: error.message }];
|
|
435
435
|
}
|
|
436
|
+
if (opts.notarize && !opts.appStore) {
|
|
437
|
+
const notarized = await notarizeIfRequested(opts, appBundlePath, "app");
|
|
438
|
+
results.push(...notarized);
|
|
439
|
+
if (notarized.some((result) => !result.success))
|
|
440
|
+
return results;
|
|
441
|
+
}
|
|
436
442
|
results.push({ success: true, platform: "macos", format: "app", outputPath: appBundlePath });
|
|
437
443
|
if (opts.dmg !== false) {
|
|
438
444
|
const outputPath = join2(outDir, `${name}-${version}.dmg`);
|
|
@@ -481,24 +487,36 @@ async function notarizeIfRequested(opts, artifactPath, format) {
|
|
|
481
487
|
error: "Notarization requires a keychain profile, API key, or Apple ID/password/team ID"
|
|
482
488
|
}];
|
|
483
489
|
}
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
490
|
+
const scratch = format === "app" ? createTempDir("craft-notarize-") : undefined;
|
|
491
|
+
try {
|
|
492
|
+
const submission = scratch ? join2(scratch, "app.zip") : artifactPath;
|
|
493
|
+
if (scratch) {
|
|
494
|
+
const zipped = await runTool("ditto", ["-c", "-k", "--keepParent", artifactPath, submission]);
|
|
495
|
+
if (!zipped.success)
|
|
496
|
+
return [{ success: false, platform: "macos", format: `${format} (notarize)`, error: zipped.error }];
|
|
497
|
+
}
|
|
498
|
+
const submitted = await runTool("xcrun", notarytoolArguments({
|
|
499
|
+
artifactPath: submission,
|
|
500
|
+
appleId: opts.appleId,
|
|
501
|
+
applePassword: opts.applePassword,
|
|
502
|
+
teamId: opts.teamId,
|
|
503
|
+
keychainProfile: opts.notaryKeychainProfile,
|
|
504
|
+
apiKey: opts.notaryApiKey
|
|
505
|
+
}));
|
|
506
|
+
if (!submitted.success)
|
|
507
|
+
return [{ success: false, platform: "macos", format: `${format} (notarize)`, error: submitted.error }];
|
|
508
|
+
const stapled = await runTool("xcrun", ["stapler", "staple", artifactPath]);
|
|
509
|
+
return [{
|
|
510
|
+
success: stapled.success,
|
|
511
|
+
platform: "macos",
|
|
512
|
+
format: `${format} (notarize)`,
|
|
513
|
+
outputPath: stapled.success ? artifactPath : undefined,
|
|
514
|
+
error: stapled.success ? undefined : stapled.error
|
|
515
|
+
}];
|
|
516
|
+
} finally {
|
|
517
|
+
if (scratch)
|
|
518
|
+
removeTempDir(scratch);
|
|
519
|
+
}
|
|
502
520
|
}
|
|
503
521
|
async function packageWindows(config, outDir) {
|
|
504
522
|
const results = [];
|
|
@@ -10666,6 +10684,9 @@ class AutoUpdater extends EventEmitter3 {
|
|
|
10666
10684
|
if (this.isNewerVersion(this.cachedManifest.version, this.config.currentVersion)) {
|
|
10667
10685
|
this.updateInfo = this.cachedManifest;
|
|
10668
10686
|
this.emit("update-available", this.cachedManifest);
|
|
10687
|
+
if (this.config.autoDownload) {
|
|
10688
|
+
await this.downloadUpdate();
|
|
10689
|
+
}
|
|
10669
10690
|
return this.cachedManifest;
|
|
10670
10691
|
}
|
|
10671
10692
|
this.emit("update-not-available");
|
package/dist/ios/src/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/ios/src/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// ../ios/src/index.ts
|
|
3
|
+
import { gzipSync } from "zlib";
|
|
3
4
|
import { cpSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync as readdirSync2, rmSync as rmSync2, statSync, writeFileSync as writeFileSync2 } from "fs";
|
|
4
5
|
import { dirname as dirname2, extname, join as join3, resolve, sep as sep2 } from "path";
|
|
5
6
|
var {$ } = globalThis.Bun;
|
|
@@ -306,6 +307,21 @@ function createNativeMutationValidator() {
|
|
|
306
307
|
}
|
|
307
308
|
|
|
308
309
|
// ../ios/src/index.ts
|
|
310
|
+
function compressNativeSources(target) {
|
|
311
|
+
if (statSync(target).isDirectory()) {
|
|
312
|
+
for (const entry of readdirSync2(target))
|
|
313
|
+
compressNativeSources(join3(target, entry));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (!/\.(?:js|json)$/.test(target))
|
|
317
|
+
return;
|
|
318
|
+
const bytes = readFileSync2(target);
|
|
319
|
+
if (bytes.length < 1024 || bytes[0] === 31 && bytes[1] === 139)
|
|
320
|
+
return;
|
|
321
|
+
const packed = gzipSync(bytes, { level: 9 });
|
|
322
|
+
if (packed.length < bytes.length)
|
|
323
|
+
writeFileSync2(target, packed);
|
|
324
|
+
}
|
|
309
325
|
function syncNativeFonts(fonts, output) {
|
|
310
326
|
const destination = join3(output, "NativeFonts");
|
|
311
327
|
const names = new Set;
|
|
@@ -507,11 +523,13 @@ function installNativeBundle(source, output) {
|
|
|
507
523
|
rmSync2(routes, { recursive: true, force: true });
|
|
508
524
|
cpSync(source, routes, { recursive: true });
|
|
509
525
|
}
|
|
526
|
+
compressNativeSources(routes);
|
|
510
527
|
rmSync2(single, { force: true });
|
|
511
528
|
return;
|
|
512
529
|
}
|
|
513
530
|
if (resolve(source) !== resolve(single))
|
|
514
531
|
cpSync(source, single);
|
|
532
|
+
compressNativeSources(single);
|
|
515
533
|
rmSync2(routes, { recursive: true, force: true });
|
|
516
534
|
}
|
|
517
535
|
function hasInstalledNativeBundle(output) {
|
|
@@ -1215,6 +1233,7 @@ function writeShell(output, config) {
|
|
|
1215
1233
|
cpSync(join3(TEMPLATES_DIR, "CraftNativeUI.swift"), join3(output, "Sources", "CraftNativeUI.swift"));
|
|
1216
1234
|
cpSync(join3(TEMPLATES_DIR, "CraftNativeServices.swift"), join3(output, "Sources", "CraftNativeServices.swift"));
|
|
1217
1235
|
cpSync(join3(TEMPLATES_DIR, "CraftNativeFiles.swift"), join3(output, "Sources", "CraftNativeFiles.swift"));
|
|
1236
|
+
cpSync(join3(TEMPLATES_DIR, "CraftVoiceRecording.swift"), join3(output, "Sources", "CraftVoiceRecording.swift"));
|
|
1218
1237
|
cpSync(join3(TEMPLATES_DIR, "CraftWebAudio.swift"), join3(output, "Sources", "CraftWebAudio.swift"));
|
|
1219
1238
|
cpSync(join3(TEMPLATES_DIR, "CraftHybrid.swift"), join3(output, "Sources", "CraftHybrid.swift"));
|
|
1220
1239
|
writeFileSync2(join3(output, "Sources", "CraftCrashReporter.swift"), readFileSync2(join3(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
|
|
@@ -1585,6 +1604,7 @@ export {
|
|
|
1585
1604
|
backgroundRefreshIdentifier,
|
|
1586
1605
|
bootSimulator,
|
|
1587
1606
|
build,
|
|
1607
|
+
compressNativeSources,
|
|
1588
1608
|
createNativeMutationValidator,
|
|
1589
1609
|
findApplicationSwiftSources,
|
|
1590
1610
|
hasNativeRoutes,
|
|
@@ -2,20 +2,27 @@ import ActivityKit
|
|
|
2
2
|
|
|
3
3
|
@available(iOS 16.1, *)
|
|
4
4
|
struct CraftActivityAttributes: ActivityAttributes {
|
|
5
|
+
struct Transition: Codable, Hashable {
|
|
6
|
+
let at: Double
|
|
7
|
+
let detail: String
|
|
8
|
+
let duration: Double?
|
|
9
|
+
}
|
|
5
10
|
struct ContentState: Codable, Hashable {
|
|
6
|
-
|
|
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
|
+
}
|
|
@@ -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) ?? ""
|