craft-native 0.0.304 → 0.0.328
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 -7
- 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/CraftNativeShell.kt.template +26 -8
- package/dist/android/templates/CraftPermissionPolicy.kt.template +96 -0
- package/dist/android/templates/MainActivityNative.kt.template +120 -6
- package/dist/android/templates/build.gradle.kts.app.template +1 -5
- package/dist/api/mobile.d.ts +9 -0
- package/dist/cli.js +63 -20
- package/dist/index.cjs +67 -20
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +67 -20
- package/dist/ios/src/index.d.ts +2 -5
- package/dist/ios/src/index.js +26 -1
- package/dist/ios/src/testing.d.ts +6 -0
- package/dist/ios/templates/CraftActivityAttributes.swift +29 -2
- package/dist/ios/templates/CraftApp.swift +45 -7
- package/dist/ios/templates/CraftHealth.swift +14 -0
- package/dist/ios/templates/CraftHybrid.swift +6 -0
- package/dist/ios/templates/CraftLiveActivities.swift +106 -2
- package/dist/ios/templates/CraftLiveActivityWidget.swift.template +1 -1
- package/dist/ios/templates/CraftNativeActions.swift +10 -1
- package/dist/ios/templates/CraftNativeScreen.swift +170 -9
- package/dist/ios/templates/CraftVoiceRecording.swift +75 -0
- package/dist/ios/templates/project.yml.template +6 -0
- package/dist/ios/templates/testing/DriverTests.swift.template +38 -0
- package/dist/package.d.ts +15 -4
- package/dist/types.d.ts +11 -4
- package/package.json +1 -1
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.328";
|
|
121
121
|
|
|
122
122
|
// src/sdk-version.ts
|
|
123
123
|
var SDK_VERSION = version;
|
|
@@ -384,6 +384,7 @@ async function packageMacOS(config, outDir) {
|
|
|
384
384
|
iconPath: config.iconPath,
|
|
385
385
|
provisioningProfile: opts.provisioningProfile,
|
|
386
386
|
additionalExecutables: opts.additionalExecutables,
|
|
387
|
+
nativeRuntimePath: opts.nativeRuntimePath,
|
|
387
388
|
outputPath: appBundlePath
|
|
388
389
|
});
|
|
389
390
|
if (!appBundle.success) {
|
|
@@ -432,6 +433,12 @@ async function packageMacOS(config, outDir) {
|
|
|
432
433
|
} catch (error) {
|
|
433
434
|
return [{ success: false, platform: "macos", format: "app", error: error.message }];
|
|
434
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
|
+
}
|
|
435
442
|
results.push({ success: true, platform: "macos", format: "app", outputPath: appBundlePath });
|
|
436
443
|
if (opts.dmg !== false) {
|
|
437
444
|
const outputPath = join2(outDir, `${name}-${version}.dmg`);
|
|
@@ -480,24 +487,36 @@ async function notarizeIfRequested(opts, artifactPath, format) {
|
|
|
480
487
|
error: "Notarization requires a keychain profile, API key, or Apple ID/password/team ID"
|
|
481
488
|
}];
|
|
482
489
|
}
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
+
}
|
|
501
520
|
}
|
|
502
521
|
async function packageWindows(config, outDir) {
|
|
503
522
|
const results = [];
|
|
@@ -666,7 +685,7 @@ ${schemes.length ? `MimeType=${schemes.map((scheme) => `x-scheme-handler/${schem
|
|
|
666
685
|
}
|
|
667
686
|
function macOSInfoPlist(metadata) {
|
|
668
687
|
const entries = [
|
|
669
|
-
["CFBundleExecutable", metadata.name],
|
|
688
|
+
["CFBundleExecutable", metadata.executableName || metadata.name],
|
|
670
689
|
["CFBundleIdentifier", metadata.bundleId],
|
|
671
690
|
["CFBundleName", metadata.name],
|
|
672
691
|
["CFBundleShortVersionString", metadata.version],
|
|
@@ -809,6 +828,9 @@ function copyMacOSAppBundle(source, destination) {
|
|
|
809
828
|
return { success: false, error: error.message };
|
|
810
829
|
}
|
|
811
830
|
}
|
|
831
|
+
function macOSRuntimeExecutablePath(appBundlePath) {
|
|
832
|
+
return join2(appBundlePath, "Contents", "Helpers", "CraftRuntime.app", "Contents", "MacOS", "craft");
|
|
833
|
+
}
|
|
812
834
|
function createMacOSAppBundle(opts) {
|
|
813
835
|
try {
|
|
814
836
|
const { name, binaryPath, iconPath, provisioningProfile, additionalExecutables, outputPath } = opts;
|
|
@@ -825,6 +847,27 @@ function createMacOSAppBundle(opts) {
|
|
|
825
847
|
copyFileSync(executable, destination);
|
|
826
848
|
chmodSync(destination, 493);
|
|
827
849
|
}
|
|
850
|
+
if (opts.nativeRuntimePath) {
|
|
851
|
+
const helper = macOSRuntimeExecutablePath(outputPath);
|
|
852
|
+
const helperContents = resolve(helper, "..", "..");
|
|
853
|
+
mkdirSync(join2(helperContents, "MacOS"), { recursive: true });
|
|
854
|
+
copyFileSync(opts.nativeRuntimePath, helper);
|
|
855
|
+
chmodSync(helper, 493);
|
|
856
|
+
if (iconPath) {
|
|
857
|
+
mkdirSync(join2(helperContents, "Resources"), { recursive: true });
|
|
858
|
+
copyFileSync(iconPath, join2(helperContents, "Resources", "icon.icns"));
|
|
859
|
+
}
|
|
860
|
+
writeFileSync2(join2(helperContents, "Info.plist"), macOSInfoPlist({
|
|
861
|
+
name,
|
|
862
|
+
executableName: "craft",
|
|
863
|
+
version: opts.version,
|
|
864
|
+
bundleId: `${opts.bundleId}.runtime`,
|
|
865
|
+
buildNumber: opts.buildNumber,
|
|
866
|
+
minimumSystemVersion: opts.minimumSystemVersion,
|
|
867
|
+
menuBarOnly: true,
|
|
868
|
+
iconName: iconPath ? "icon" : undefined
|
|
869
|
+
}));
|
|
870
|
+
}
|
|
828
871
|
let iconName;
|
|
829
872
|
if (iconPath) {
|
|
830
873
|
if (!existsSync3(iconPath))
|
|
@@ -10641,6 +10684,9 @@ class AutoUpdater extends EventEmitter3 {
|
|
|
10641
10684
|
if (this.isNewerVersion(this.cachedManifest.version, this.config.currentVersion)) {
|
|
10642
10685
|
this.updateInfo = this.cachedManifest;
|
|
10643
10686
|
this.emit("update-available", this.cachedManifest);
|
|
10687
|
+
if (this.config.autoDownload) {
|
|
10688
|
+
await this.downloadUpdate();
|
|
10689
|
+
}
|
|
10644
10690
|
return this.cachedManifest;
|
|
10645
10691
|
}
|
|
10646
10692
|
this.emit("update-not-available");
|
|
@@ -11595,6 +11641,7 @@ export {
|
|
|
11595
11641
|
liveActivities,
|
|
11596
11642
|
loadURL,
|
|
11597
11643
|
location,
|
|
11644
|
+
macOSRuntimeExecutablePath,
|
|
11598
11645
|
materialYou,
|
|
11599
11646
|
media,
|
|
11600
11647
|
menuItem,
|
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;
|
|
@@ -189,7 +190,12 @@ function serializeSimulatorSteps(steps) {
|
|
|
189
190
|
};
|
|
190
191
|
const line = (step) => {
|
|
191
192
|
let arg = "";
|
|
192
|
-
if ("
|
|
193
|
+
if (step.command === "holdtab") {
|
|
194
|
+
const seconds = step.seconds ?? 6;
|
|
195
|
+
if (!step.text.trim() || step.text.includes("|") || number(seconds) < 2 || seconds > 10)
|
|
196
|
+
throw new Error("holdtab needs a nonempty tab name and 2\u201310 seconds");
|
|
197
|
+
arg = `${step.text} | ${seconds}`;
|
|
198
|
+
} else if ("paths" in step) {
|
|
193
199
|
if (!step.paths.length || step.paths.length > 64)
|
|
194
200
|
throw new Error("Expected 1\u201364 touch paths");
|
|
195
201
|
for (const path of step.paths) {
|
|
@@ -301,6 +307,21 @@ function createNativeMutationValidator() {
|
|
|
301
307
|
}
|
|
302
308
|
|
|
303
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
|
+
}
|
|
304
325
|
function syncNativeFonts(fonts, output) {
|
|
305
326
|
const destination = join3(output, "NativeFonts");
|
|
306
327
|
const names = new Set;
|
|
@@ -502,11 +523,13 @@ function installNativeBundle(source, output) {
|
|
|
502
523
|
rmSync2(routes, { recursive: true, force: true });
|
|
503
524
|
cpSync(source, routes, { recursive: true });
|
|
504
525
|
}
|
|
526
|
+
compressNativeSources(routes);
|
|
505
527
|
rmSync2(single, { force: true });
|
|
506
528
|
return;
|
|
507
529
|
}
|
|
508
530
|
if (resolve(source) !== resolve(single))
|
|
509
531
|
cpSync(source, single);
|
|
532
|
+
compressNativeSources(single);
|
|
510
533
|
rmSync2(routes, { recursive: true, force: true });
|
|
511
534
|
}
|
|
512
535
|
function hasInstalledNativeBundle(output) {
|
|
@@ -1210,6 +1233,7 @@ function writeShell(output, config) {
|
|
|
1210
1233
|
cpSync(join3(TEMPLATES_DIR, "CraftNativeUI.swift"), join3(output, "Sources", "CraftNativeUI.swift"));
|
|
1211
1234
|
cpSync(join3(TEMPLATES_DIR, "CraftNativeServices.swift"), join3(output, "Sources", "CraftNativeServices.swift"));
|
|
1212
1235
|
cpSync(join3(TEMPLATES_DIR, "CraftNativeFiles.swift"), join3(output, "Sources", "CraftNativeFiles.swift"));
|
|
1236
|
+
cpSync(join3(TEMPLATES_DIR, "CraftVoiceRecording.swift"), join3(output, "Sources", "CraftVoiceRecording.swift"));
|
|
1213
1237
|
cpSync(join3(TEMPLATES_DIR, "CraftWebAudio.swift"), join3(output, "Sources", "CraftWebAudio.swift"));
|
|
1214
1238
|
cpSync(join3(TEMPLATES_DIR, "CraftHybrid.swift"), join3(output, "Sources", "CraftHybrid.swift"));
|
|
1215
1239
|
writeFileSync2(join3(output, "Sources", "CraftCrashReporter.swift"), readFileSync2(join3(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
|
|
@@ -1580,6 +1604,7 @@ export {
|
|
|
1580
1604
|
backgroundRefreshIdentifier,
|
|
1581
1605
|
bootSimulator,
|
|
1582
1606
|
build,
|
|
1607
|
+
compressNativeSources,
|
|
1583
1608
|
createNativeMutationValidator,
|
|
1584
1609
|
findApplicationSwiftSources,
|
|
1585
1610
|
hasNativeRoutes,
|
|
@@ -61,6 +61,12 @@ export type SimulatorStep = {
|
|
|
61
61
|
| {
|
|
62
62
|
command: 'touches';
|
|
63
63
|
paths: TimedTouchPoint[][];
|
|
64
|
+
}
|
|
65
|
+
/** Standard XCTest tab hold; independent screenshots must be captured before release. */
|
|
66
|
+
| {
|
|
67
|
+
command: 'holdtab';
|
|
68
|
+
text: string;
|
|
69
|
+
seconds?: number;
|
|
64
70
|
} | {
|
|
65
71
|
command: 'field';
|
|
66
72
|
index: number;
|
|
@@ -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
|
+
}
|
|
@@ -766,6 +766,13 @@ final class CraftChrome: ObservableObject {
|
|
|
766
766
|
@Published var tabAccessory: Tab?
|
|
767
767
|
private weak var tabAccessoryOwner: AnyObject?
|
|
768
768
|
@Published var selected: String?
|
|
769
|
+
/// The visible destination may be the accessory, independently of its owning stack.
|
|
770
|
+
@Published var visiblePath: String?
|
|
771
|
+
var accessorySelected: Bool {
|
|
772
|
+
guard let path = visiblePath, let accessory = tabAccessory?.id else { return false }
|
|
773
|
+
return path == accessory || path.hasPrefix(accessory + "/")
|
|
774
|
+
}
|
|
775
|
+
var highlightedTab: String? { accessorySelected ? nil : selected }
|
|
769
776
|
let tabSwipeProgress = CraftTabSwipeProgress()
|
|
770
777
|
private(set) var tabSwipePosition: CGFloat? {
|
|
771
778
|
get { tabSwipeProgress.position }
|
|
@@ -1172,6 +1179,18 @@ final class CraftChromeRelay: NSObject, WKScriptMessageHandler {
|
|
|
1172
1179
|
|
|
1173
1180
|
/// The floating tab bar: Liquid Glass on iOS 26 and later, a material capsule before.
|
|
1174
1181
|
struct CraftTabBarView: View {
|
|
1182
|
+
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
|
1183
|
+
|
|
1184
|
+
var body: some View {
|
|
1185
|
+
// ScaledMetric reads the environment of its owning view. Put the
|
|
1186
|
+
// metrics in a child so this compact-chrome cap reaches them too.
|
|
1187
|
+
CraftTabBarContent(viewerTextSize: dynamicTypeSize)
|
|
1188
|
+
.dynamicTypeSize(...DynamicTypeSize.xxxLarge)
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
private struct CraftTabBarContent: View {
|
|
1193
|
+
let viewerTextSize: DynamicTypeSize
|
|
1175
1194
|
@ObservedObject private var chrome = CraftChrome.shared
|
|
1176
1195
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
1177
1196
|
@Environment(\.scenePhase) private var scenePhase
|
|
@@ -1183,7 +1202,7 @@ struct CraftTabBarView: View {
|
|
|
1183
1202
|
@ScaledMetric(relativeTo: .caption2) private var badgeSize: CGFloat = 10
|
|
1184
1203
|
|
|
1185
1204
|
var body: some View {
|
|
1186
|
-
if
|
|
1205
|
+
if !chrome.tabs.isEmpty {
|
|
1187
1206
|
HStack(spacing: 8) {
|
|
1188
1207
|
bar
|
|
1189
1208
|
if let accessory = chrome.tabAccessory {
|
|
@@ -1191,8 +1210,13 @@ struct CraftTabBarView: View {
|
|
|
1191
1210
|
surface {
|
|
1192
1211
|
Image(systemName: accessory.symbol)
|
|
1193
1212
|
.font(.system(size: iconSize))
|
|
1194
|
-
.foregroundStyle(Color.primary)
|
|
1213
|
+
.foregroundStyle(chrome.accessorySelected ? selectedColor : Color.primary)
|
|
1195
1214
|
.frame(width: 62, height: 62)
|
|
1215
|
+
.background {
|
|
1216
|
+
if chrome.accessorySelected {
|
|
1217
|
+
Circle().fill(selectedColor.opacity(0.14)).padding(4)
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1196
1220
|
}
|
|
1197
1221
|
.contentShape(Rectangle())
|
|
1198
1222
|
.overlay(alignment: .topTrailing) {
|
|
@@ -1207,9 +1231,17 @@ struct CraftTabBarView: View {
|
|
|
1207
1231
|
}
|
|
1208
1232
|
}
|
|
1209
1233
|
.buttonStyle(.plain)
|
|
1234
|
+
.accessibilityShowsLargeContentViewer {
|
|
1235
|
+
Image(systemName: accessory.symbol)
|
|
1236
|
+
Text(accessory.accessibilityLabel ?? accessory.title)
|
|
1237
|
+
}
|
|
1238
|
+
// Keep accessibility intent for the viewer; displayed
|
|
1239
|
+
// sizes come from the bounded metrics owned above.
|
|
1240
|
+
.environment(\.dynamicTypeSize, viewerTextSize)
|
|
1210
1241
|
.accessibilityLabel(accessory.accessibilityLabel ?? accessory.title)
|
|
1211
1242
|
.accessibilityValue(accessory.badge ?? "")
|
|
1212
1243
|
.accessibilityIdentifier("craft-tab-accessory")
|
|
1244
|
+
.accessibilityAddTraits(chrome.accessorySelected ? .isSelected : [])
|
|
1213
1245
|
}
|
|
1214
1246
|
}
|
|
1215
1247
|
.padding(.horizontal, 16)
|
|
@@ -1219,9 +1251,14 @@ struct CraftTabBarView: View {
|
|
|
1219
1251
|
.onAppear { report(proxy) }
|
|
1220
1252
|
.onChange(of: proxy.frame(in: .global)) { _ in report(proxy) }
|
|
1221
1253
|
})
|
|
1222
|
-
|
|
1254
|
+
// Keep the view mounted: a disappearing conditional inside a
|
|
1255
|
+
// child does not reliably animate in the UIKit navigation transaction.
|
|
1256
|
+
.offset(y: chrome.tabBarVisible || reduceMotion ? 0 : 120)
|
|
1257
|
+
.opacity(chrome.tabBarVisible ? 1 : 0)
|
|
1258
|
+
.allowsHitTesting(chrome.tabBarVisible)
|
|
1259
|
+
.accessibilityHidden(!chrome.tabBarVisible)
|
|
1260
|
+
.animation(.easeInOut(duration: reduceMotion ? 0.12 : 0.24), value: chrome.tabBarVisible)
|
|
1223
1261
|
.ignoresSafeArea(.keyboard)
|
|
1224
|
-
.dynamicTypeSize(...DynamicTypeSize.xxxLarge)
|
|
1225
1262
|
}
|
|
1226
1263
|
}
|
|
1227
1264
|
|
|
@@ -1260,7 +1297,7 @@ struct CraftTabBarView: View {
|
|
|
1260
1297
|
private var items: some View {
|
|
1261
1298
|
HStack(spacing: 0) {
|
|
1262
1299
|
ForEach(chrome.tabs) { tab in
|
|
1263
|
-
let isSelected = tab.id == chrome.
|
|
1300
|
+
let isSelected = tab.id == chrome.highlightedTab
|
|
1264
1301
|
Button { chrome.tap(tab) } label: {
|
|
1265
1302
|
VStack(spacing: 3) {
|
|
1266
1303
|
ZStack(alignment: .topTrailing) {
|
|
@@ -1305,6 +1342,7 @@ struct CraftTabBarView: View {
|
|
|
1305
1342
|
Image(systemName: symbol(tab.symbol, selected: isSelected))
|
|
1306
1343
|
Text(tab.displayTitle)
|
|
1307
1344
|
}
|
|
1345
|
+
.environment(\.dynamicTypeSize, viewerTextSize)
|
|
1308
1346
|
.accessibilityIdentifier("craft-tab-\(tab.id)")
|
|
1309
1347
|
.accessibilityLabel(tab.accessibilityLabel ?? tab.badge.map { "\(tab.displayTitle), \($0)" } ?? tab.displayTitle)
|
|
1310
1348
|
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
|
|
@@ -1312,7 +1350,7 @@ struct CraftTabBarView: View {
|
|
|
1312
1350
|
}
|
|
1313
1351
|
.background {
|
|
1314
1352
|
CraftTabSelectionIndicator(
|
|
1315
|
-
selected: chrome.tabs.firstIndex(where: { $0.id == chrome.
|
|
1353
|
+
selected: chrome.tabs.firstIndex(where: { $0.id == chrome.highlightedTab }),
|
|
1316
1354
|
count: chrome.tabs.count,
|
|
1317
1355
|
cornerRadius: chrome.tabSelectionCornerRadius
|
|
1318
1356
|
)
|
|
@@ -8243,7 +8281,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
8243
8281
|
// MARK: - Live Activities
|
|
8244
8282
|
private func liveActivity(method: String, args: [Any], callbackId: String?) {
|
|
8245
8283
|
let token = armDeadline(Coordinator.liveActivityDeadline, callbackId: callbackId, error: "Live Activity request timed out")
|
|
8246
|
-
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
|
|
8247
8285
|
guard let self, self.claimDeadline(token) else { return }
|
|
8248
8286
|
switch result {
|
|
8249
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
|
}
|
|
@@ -1664,6 +1664,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1664
1664
|
(nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
|
|
1665
1665
|
}
|
|
1666
1666
|
currentTab = id
|
|
1667
|
+
if let top = nav.topViewController { CraftChrome.shared.visiblePath = nativePaths[ObjectIdentifier(top)] }
|
|
1667
1668
|
for (key, other) in stacks { other.view.isHidden = key != id }
|
|
1668
1669
|
nav.view.frame = view.bounds
|
|
1669
1670
|
view.bringSubviewToFront(nav.view)
|
|
@@ -2042,6 +2043,9 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
2042
2043
|
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
|
|
2043
2044
|
lastNavigation = CACurrentMediaTime()
|
|
2044
2045
|
if animated { CraftFramePacing.shared.begin("navigation") }
|
|
2046
|
+
if navigationController === visibleStack {
|
|
2047
|
+
CraftChrome.shared.visiblePath = nativePaths[ObjectIdentifier(viewController)]
|
|
2048
|
+
}
|
|
2045
2049
|
// The page draws its own header, and a tab's root screen its own
|
|
2046
2050
|
// title unless it asked for the bar; a pushed native screen gets the
|
|
2047
2051
|
// bar and its Back.
|
|
@@ -2084,6 +2088,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
2084
2088
|
hybrid.pageReset(to: rootPath)
|
|
2085
2089
|
}
|
|
2086
2090
|
guard navigationController === visibleStack else { return }
|
|
2091
|
+
// Also restore selection after a cancelled interactive pop.
|
|
2092
|
+
CraftChrome.shared.visiblePath = nativePaths[ObjectIdentifier(viewController)]
|
|
2087
2093
|
if let slot = viewController as? CraftHybridWebSlot { adoptWeb(into: slot) } else { parkWeb() }
|
|
2088
2094
|
}
|
|
2089
2095
|
|
|
@@ -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
|
}
|