@craft-native/ios 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/README.md +18 -0
- package/dist/cli.js +19 -0
- package/dist/index.d.ts +2 -5
- package/dist/index.js +26 -1
- package/dist/testing.d.ts +6 -0
- package/package.json +1 -1
- package/templates/CraftActivityAttributes.swift +29 -2
- package/templates/CraftApp.swift +45 -7
- package/templates/CraftHealth.swift +14 -0
- package/templates/CraftHybrid.swift +6 -0
- package/templates/CraftLiveActivities.swift +106 -2
- package/templates/CraftLiveActivityWidget.swift.template +1 -1
- package/templates/CraftNativeActions.swift +10 -1
- package/templates/CraftNativeScreen.swift +170 -9
- package/templates/CraftVoiceRecording.swift +75 -0
- package/templates/project.yml.template +6 -0
- package/templates/testing/DriverTests.swift.template +38 -0
package/README.md
CHANGED
|
@@ -1324,3 +1324,21 @@ and detents; Android sizes its native bottom panel to the selected detent
|
|
|
1324
1324
|
without an extra dependency. Omitting presentationStyle keeps the existing
|
|
1325
1325
|
full-screen overlay. A native sheet's close request still belongs to its
|
|
1326
1326
|
owner, which sets visible to false.
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
### Controlled native paging and font families
|
|
1330
|
+
|
|
1331
|
+
A horizontal `ScrollView` with `pagingEnabled: true` and a numeric `pageIndex`
|
|
1332
|
+
uses viewport-sized direct children. The host advertises `nativePager` and emits
|
|
1333
|
+
`onPageSelected` with `{ index, count }` after initial layout, count changes and
|
|
1334
|
+
settled user selection. Updates to `pageIndex` select a page; removed pages clamp
|
|
1335
|
+
the index and resizing preserves it. Offscreen pages stay mounted but are hidden
|
|
1336
|
+
from native accessibility. `scrollEnabled: false` locks swiping while retaining
|
|
1337
|
+
controls in the selected page. Without `pageIndex`, scroll content keeps its
|
|
1338
|
+
ordinary intrinsic layout; this is an opt-in contract for a shared Pager.
|
|
1339
|
+
|
|
1340
|
+
Text `fontFamily` accepts a CSS fallback list. Bundled/custom fonts take priority
|
|
1341
|
+
when available. `monospace`/`ui-monospace`, `serif`/`ui-serif` and
|
|
1342
|
+
`sans-serif`/`ui-sans-serif`/`system-ui` select platform fonts, retaining requested
|
|
1343
|
+
weight, italic style and system text scaling. A missing custom face falls through
|
|
1344
|
+
to the next family rather than silently replacing a requested mono font with sans.
|
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;
|
|
@@ -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
|
// 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,
|
package/dist/testing.d.ts
CHANGED
|
@@ -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;
|
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
|
@@ -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
|
}
|
|
@@ -43,6 +43,7 @@ enum CraftNativeActions {
|
|
|
43
43
|
// `speech`, `audio` (background sound) and `eventStream` (a fetch read
|
|
44
44
|
// as it arrives) need nothing the app has to declare.
|
|
45
45
|
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation", "nativePlatformControls"]
|
|
46
|
+
values.append("nativePager")
|
|
46
47
|
values.append("storageCompareAndSet")
|
|
47
48
|
values.append("storageSetIfAbsent")
|
|
48
49
|
values.append("viewScroll")
|
|
@@ -94,6 +95,7 @@ enum CraftNativeActions {
|
|
|
94
95
|
// which the native Video element plays in place of the address.
|
|
95
96
|
values.append("media")
|
|
96
97
|
if config.enableCamera { values.append("camera") }
|
|
98
|
+
if config.enableAudioRecording { values.append("audioRecording") }
|
|
97
99
|
// craft.crashReporter: the app's crash and error reporter (bughq).
|
|
98
100
|
if config.crashReporting?.key.isEmpty == false { values.append("crashReporter") }
|
|
99
101
|
return values
|
|
@@ -372,7 +374,7 @@ enum CraftNativeActions {
|
|
|
372
374
|
}
|
|
373
375
|
return
|
|
374
376
|
case ("LiveActivities", _):
|
|
375
|
-
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
|
|
376
378
|
complete(requestToken, result, completion)
|
|
377
379
|
}
|
|
378
380
|
return
|
|
@@ -554,6 +556,13 @@ enum CraftNativeActions {
|
|
|
554
556
|
CraftNativeShare.share(options) { complete(requestToken, $0, completion) }
|
|
555
557
|
}
|
|
556
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
|
|
557
566
|
case ("Speech", "speak"):
|
|
558
567
|
// Settles when the words have been said (true) or were cut off
|
|
559
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
|
|
@@ -2744,6 +2745,7 @@ final class CraftNativeCelebration {
|
|
|
2744
2745
|
}
|
|
2745
2746
|
|
|
2746
2747
|
private class CraftNativeFlowView: UIStackView {
|
|
2748
|
+
var pageViewport: CGSize? { didSet { if pageViewport != oldValue { invalidateMeasurements() } } }
|
|
2747
2749
|
var celebration: CraftNativeCelebration?
|
|
2748
2750
|
func configureCelebration(_ raw: Any?) {
|
|
2749
2751
|
if raw != nil && celebration == nil { celebration = CraftNativeCelebration(view: self) }
|
|
@@ -2966,6 +2968,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
2966
2968
|
}
|
|
2967
2969
|
|
|
2968
2970
|
func measure(width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
|
|
2971
|
+
if let page = pageViewport { return CGSize(width: page.width * CGFloat(arrangedSubviews.filter { !$0.isHidden }.count), height: page.height) }
|
|
2969
2972
|
let key = MeasureKey(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight)
|
|
2970
2973
|
if let cached = measurements[key] { return cached }
|
|
2971
2974
|
let size = layout(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight, place: false).size
|
|
@@ -2977,6 +2980,12 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
2977
2980
|
super.layoutSubviews()
|
|
2978
2981
|
craftApplyCornerRadius()
|
|
2979
2982
|
celebration?.layout()
|
|
2983
|
+
if let page = pageViewport {
|
|
2984
|
+
for (index, child) in arrangedSubviews.filter({ !$0.isHidden }).enumerated() {
|
|
2985
|
+
child.frame = CGRect(x: CGFloat(index) * page.width, y: 0, width: page.width, height: page.height)
|
|
2986
|
+
}
|
|
2987
|
+
return
|
|
2988
|
+
}
|
|
2980
2989
|
let scale = window?.screen.scale ?? traitCollection.displayScale
|
|
2981
2990
|
let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
|
|
2982
2991
|
var moved = false
|
|
@@ -4344,6 +4353,46 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
4344
4353
|
private var laidOutSize: CGSize = .zero
|
|
4345
4354
|
private let pullToRefresh = UIRefreshControl()
|
|
4346
4355
|
private var refreshHandler: (() -> Void)?
|
|
4356
|
+
private var authoredPageIndex: Int?
|
|
4357
|
+
private var pendingPageIndex: Int?
|
|
4358
|
+
private var currentPageIndex = 0
|
|
4359
|
+
private var pageAnimating = false
|
|
4360
|
+
private var reportedPage: (Int, Int)?
|
|
4361
|
+
private var pageHandler: ((Int, Int) -> Void)?
|
|
4362
|
+
|
|
4363
|
+
/// Opt-in viewport-sized pages. Ordinary horizontal scroll strips retain
|
|
4364
|
+
/// their intrinsic widths; only a controlled pageIndex enables this mode.
|
|
4365
|
+
func configurePager(_ raw: Any?, onSelect: ((Int, Int) -> Void)?) {
|
|
4366
|
+
let next = craftNativeNumber(raw).flatMap { number -> Int? in
|
|
4367
|
+
let value = number.doubleValue
|
|
4368
|
+
return value.isFinite ? Int(min(Double(Int.max / 2), max(0, value))) : nil
|
|
4369
|
+
}
|
|
4370
|
+
if next != authoredPageIndex { pendingPageIndex = next; contentNeedsLayout = true }
|
|
4371
|
+
authoredPageIndex = next
|
|
4372
|
+
pageHandler = onSelect
|
|
4373
|
+
if next == nil { contentStack.pageViewport = nil; reportedPage = nil; pageAnimating = false }
|
|
4374
|
+
setNeedsLayout()
|
|
4375
|
+
}
|
|
4376
|
+
|
|
4377
|
+
func beginPageDrag() { pageAnimating = false }
|
|
4378
|
+
|
|
4379
|
+
func finishPageMotion() {
|
|
4380
|
+
pageAnimating = false
|
|
4381
|
+
DispatchQueue.main.async { [weak self] in self?.settlePage() }
|
|
4382
|
+
}
|
|
4383
|
+
|
|
4384
|
+
func settlePage() {
|
|
4385
|
+
guard authoredPageIndex != nil, bounds.width > 0, !pageAnimating,
|
|
4386
|
+
!isTracking, !isDragging, !isDecelerating else { return }
|
|
4387
|
+
let pages = contentStack.arrangedSubviews.filter { !$0.isHidden }
|
|
4388
|
+
let index = min(max(0, Int((contentOffset.x / bounds.width).rounded())), max(0, pages.count - 1))
|
|
4389
|
+
currentPageIndex = index
|
|
4390
|
+
for (i, child) in pages.enumerated() { child.accessibilityElementsHidden = i != index }
|
|
4391
|
+
if reportedPage?.0 != index || reportedPage?.1 != pages.count {
|
|
4392
|
+
reportedPage = (index, pages.count)
|
|
4393
|
+
pageHandler?(index, pages.count)
|
|
4394
|
+
}
|
|
4395
|
+
}
|
|
4347
4396
|
|
|
4348
4397
|
override init(frame: CGRect) {
|
|
4349
4398
|
super.init(frame: frame)
|
|
@@ -4384,6 +4433,11 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
4384
4433
|
/// The size of the content laid out across the visible width (or, sideways,
|
|
4385
4434
|
/// height), which is also the scroll view's own size when nothing fixes it.
|
|
4386
4435
|
func contentFitting(width: CGFloat?, height: CGFloat?) -> CGSize {
|
|
4436
|
+
if authoredPageIndex != nil && !isVertical {
|
|
4437
|
+
let pageWidth = max(0, width ?? bounds.width)
|
|
4438
|
+
let pageHeight = max(0, height ?? bounds.height)
|
|
4439
|
+
return CGSize(width: pageWidth * CGFloat(contentStack.arrangedSubviews.filter { !$0.isHidden }.count), height: pageHeight)
|
|
4440
|
+
}
|
|
4387
4441
|
if isVertical {
|
|
4388
4442
|
let contentWidth = max(0, (width ?? bounds.width) - adjustedContentInset.left - adjustedContentInset.right)
|
|
4389
4443
|
return contentStack.measure(width: contentWidth, height: nil, maxWidth: contentWidth, maxHeight: CraftNativeFlexLayout.unbounded)
|
|
@@ -4406,7 +4460,9 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
4406
4460
|
craftApplyCornerRadius()
|
|
4407
4461
|
if contentNeedsLayout || laidOutSize != bounds.size {
|
|
4408
4462
|
contentNeedsLayout = false
|
|
4463
|
+
let resized = laidOutSize != bounds.size
|
|
4409
4464
|
laidOutSize = bounds.size
|
|
4465
|
+
if authoredPageIndex != nil && !isVertical { contentStack.pageViewport = bounds.size }
|
|
4410
4466
|
var size = contentFitting(width: bounds.width, height: bounds.height)
|
|
4411
4467
|
if isVertical {
|
|
4412
4468
|
size.width = max(0, bounds.width - adjustedContentInset.left - adjustedContentInset.right)
|
|
@@ -4416,12 +4472,31 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
4416
4472
|
let frame = CGRect(origin: .zero, size: size)
|
|
4417
4473
|
if contentStack.frame != frame { contentStack.frame = frame }
|
|
4418
4474
|
if contentSize != size { contentSize = size }
|
|
4475
|
+
if authoredPageIndex != nil && !isVertical {
|
|
4476
|
+
contentStack.layoutIfNeeded()
|
|
4477
|
+
let count = contentStack.arrangedSubviews.filter { !$0.isHidden }.count
|
|
4478
|
+
let index = min(max(0, pendingPageIndex ?? currentPageIndex), max(0, count - 1))
|
|
4479
|
+
let target = CGPoint(x: CGFloat(index) * bounds.width, y: 0)
|
|
4480
|
+
let moves = pendingPageIndex != nil || resized || index != currentPageIndex
|
|
4481
|
+
let animated = moves && target != contentOffset && !resized && !UIAccessibility.isReduceMotionEnabled
|
|
4482
|
+
if moves {
|
|
4483
|
+
pageAnimating = animated
|
|
4484
|
+
setContentOffset(target, animated: animated)
|
|
4485
|
+
}
|
|
4486
|
+
pendingPageIndex = nil
|
|
4487
|
+
currentPageIndex = index
|
|
4488
|
+
if !animated && !isTracking && !isDragging && !isDecelerating { settlePage() }
|
|
4489
|
+
}
|
|
4419
4490
|
}
|
|
4420
4491
|
// A scroll view lays out on every frame of a scroll: the sticky
|
|
4421
4492
|
// headers follow it here, over content placed by this same pass.
|
|
4422
4493
|
if !stickyIndices.isEmpty || pendingScrollTarget != nil { contentStack.layoutIfNeeded() }
|
|
4423
4494
|
updateStickyHeaders()
|
|
4424
4495
|
scrollToPendingTarget()
|
|
4496
|
+
// A nonanimated target changes the offset synchronously inside this
|
|
4497
|
+
// layout pass. Rebase the headers against that actual viewport, not
|
|
4498
|
+
// the viewport from before the target was applied.
|
|
4499
|
+
updateStickyHeaders()
|
|
4425
4500
|
}
|
|
4426
4501
|
|
|
4427
4502
|
// MARK: Sticky headers
|
|
@@ -4444,7 +4519,7 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
4444
4519
|
view.center.y - view.bounds.height / 2
|
|
4445
4520
|
}
|
|
4446
4521
|
|
|
4447
|
-
|
|
4522
|
+
fileprivate func updateStickyHeaders() {
|
|
4448
4523
|
let children = contentStack.arrangedSubviews
|
|
4449
4524
|
let headers = isVertical ? stickyIndices.compactMap { $0 < children.count ? children[$0] : nil }.filter { !$0.isHidden } : []
|
|
4450
4525
|
for view in stickyViews where !headers.contains(where: { $0 === view }) {
|
|
@@ -4617,11 +4692,13 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
4617
4692
|
craftScrollGesture(gestureRecognizer, runsWith: otherGestureRecognizer)
|
|
4618
4693
|
}
|
|
4619
4694
|
|
|
4620
|
-
func setAxis(_ axis: NSLayoutConstraint.Axis) {
|
|
4695
|
+
func setAxis(_ axis: NSLayoutConstraint.Axis, resetBounce: Bool = true) {
|
|
4621
4696
|
if contentStack.axis != axis { setContentNeedsLayout() }
|
|
4622
4697
|
contentStack.axis = axis
|
|
4623
|
-
|
|
4624
|
-
|
|
4698
|
+
if resetBounce {
|
|
4699
|
+
alwaysBounceVertical = axis == .vertical
|
|
4700
|
+
alwaysBounceHorizontal = axis == .horizontal
|
|
4701
|
+
}
|
|
4625
4702
|
}
|
|
4626
4703
|
}
|
|
4627
4704
|
|
|
@@ -5474,10 +5551,36 @@ enum CraftNativeBundle {
|
|
|
5474
5551
|
return read(url)
|
|
5475
5552
|
}
|
|
5476
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
|
+
|
|
5477
5580
|
private static func read(_ url: URL) -> (source: String, url: URL)? {
|
|
5478
5581
|
let key = url.path as NSString
|
|
5479
5582
|
if let cached = sources.object(forKey: key) { return (cached as String, url) }
|
|
5480
|
-
guard let
|
|
5583
|
+
guard let bytes = try? Data(contentsOf: url), let decoded = unpack(bytes), let text = String(data: decoded, encoding: .utf8) else { return nil }
|
|
5481
5584
|
sources.setObject(text as NSString, forKey: key, cost: text.utf8.count)
|
|
5482
5585
|
return (text, url)
|
|
5483
5586
|
}
|
|
@@ -5624,6 +5727,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5624
5727
|
/// itself to be in front and acts on it: a sign-in guard on a prepared
|
|
5625
5728
|
/// Record tab pushed Login over the Feed at launch.
|
|
5626
5729
|
private var reportedVisible: Bool?
|
|
5730
|
+
private var voiceRecordingOwner: String?
|
|
5627
5731
|
private let capabilityScope = UUID().uuidString
|
|
5628
5732
|
private var pendingCapabilityRequests = Set<String>()
|
|
5629
5733
|
private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
|
|
@@ -5894,6 +5998,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5894
5998
|
private func disposeRuntime() {
|
|
5895
5999
|
guard !runtimeDisposed else { return }
|
|
5896
6000
|
runtimeDisposed = true
|
|
6001
|
+
if let owner = voiceRecordingOwner {
|
|
6002
|
+
voiceRecordingOwner = nil
|
|
6003
|
+
CraftVoiceRecording.shared.perform(method: "cancel", args: [owner]) { _ in }
|
|
6004
|
+
}
|
|
5897
6005
|
frameWanted = false
|
|
5898
6006
|
CraftNativeFrameClock.shared.cancel(self)
|
|
5899
6007
|
disposeRenderedTree()
|
|
@@ -7598,6 +7706,24 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7598
7706
|
}
|
|
7599
7707
|
let id = args.first as? String ?? ""
|
|
7600
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
|
|
7601
7727
|
case ("View", "focus"):
|
|
7602
7728
|
let options = args.first as? [String: Any]
|
|
7603
7729
|
let nodeId = options?["nodeId"] as? String ?? (args.first as? String) ?? ""
|
|
@@ -8461,6 +8587,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8461
8587
|
scroll.isScrollEnabled = props["scrollEnabled"] as? Bool != false
|
|
8462
8588
|
scroll.bounces = props["bounces"] as? Bool ?? true
|
|
8463
8589
|
scroll.isPagingEnabled = props["pagingEnabled"] as? Bool ?? false
|
|
8590
|
+
let pageHandler = nonEmptyHandler(events["onPageSelected"])
|
|
8591
|
+
scroll.configurePager(props["pageIndex"], onSelect: pageHandler.map { handler in
|
|
8592
|
+
{ [weak self] index, count in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["index": index, "count": count]]) }
|
|
8593
|
+
})
|
|
8464
8594
|
scroll.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
|
|
8465
8595
|
scroll.setKeyboardShouldPersistTaps(props["keyboardShouldPersistTaps"])
|
|
8466
8596
|
scroll.showsVerticalScrollIndicator = props["showsVerticalScrollIndicator"] as? Bool != false
|
|
@@ -8492,6 +8622,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8492
8622
|
contentStyle.merge(override) { _, next in next }
|
|
8493
8623
|
}
|
|
8494
8624
|
configureStack(scroll.contentStack, style: contentStyle)
|
|
8625
|
+
// Content-style defaults are vertical; the scroll host's explicit
|
|
8626
|
+
// axis must survive that shared container configuration without
|
|
8627
|
+
// resetting the authored bounce options applied above.
|
|
8628
|
+
scroll.setAxis(direction, resetBounce: false)
|
|
8495
8629
|
reconcileChildren(children.filter {
|
|
8496
8630
|
guard let child = $0 as? [String: Any] else { return true }
|
|
8497
8631
|
return canonicalType(child["type"] as? String ?? "") != "RefreshControl"
|
|
@@ -9712,10 +9846,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9712
9846
|
for: size,
|
|
9713
9847
|
compatibleWith: traitCollection
|
|
9714
9848
|
)
|
|
9715
|
-
|
|
9716
|
-
|
|
9849
|
+
let families = (style["fontFamily"] as? String ?? "").split(separator: ",").map {
|
|
9850
|
+
$0.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
|
|
9851
|
+
}
|
|
9852
|
+
let selectedFont = families.lazy.compactMap { family -> UIFont? in
|
|
9853
|
+
switch family.lowercased() {
|
|
9854
|
+
case "monospace", "ui-monospace": return .monospacedSystemFont(ofSize: scaledSize, weight: weight)
|
|
9855
|
+
case "serif", "ui-serif":
|
|
9856
|
+
let system = UIFont.systemFont(ofSize: scaledSize, weight: weight)
|
|
9857
|
+
return system.fontDescriptor.withDesign(.serif).map { UIFont(descriptor: $0, size: scaledSize) }
|
|
9858
|
+
case "sans-serif", "ui-sans-serif", "system-ui", "-apple-system": return .systemFont(ofSize: scaledSize, weight: weight)
|
|
9859
|
+
default:
|
|
9860
|
+
guard let custom = UIFont(name: family, size: scaledSize) else { return nil }
|
|
9861
|
+
let weighted = custom.fontDescriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: weight.rawValue]])
|
|
9862
|
+
var traits = custom.fontDescriptor.symbolicTraits
|
|
9863
|
+
if weight >= .semibold { traits.insert(.traitBold) } else { traits.remove(.traitBold) }
|
|
9864
|
+
let descriptor = weighted.withSymbolicTraits(traits) ?? weighted
|
|
9865
|
+
return UIFont(descriptor: descriptor, size: scaledSize)
|
|
9866
|
+
}
|
|
9867
|
+
}.first
|
|
9868
|
+
var font = selectedFont ?? .systemFont(ofSize: scaledSize, weight: weight)
|
|
9717
9869
|
if style["fontStyle"] as? String == "italic",
|
|
9718
|
-
let descriptor = font.fontDescriptor.withSymbolicTraits(.traitItalic) {
|
|
9870
|
+
let descriptor = font.fontDescriptor.withSymbolicTraits(font.fontDescriptor.symbolicTraits.union(.traitItalic)) {
|
|
9719
9871
|
font = UIFont(descriptor: descriptor, size: scaledSize)
|
|
9720
9872
|
}
|
|
9721
9873
|
return font
|
|
@@ -10502,6 +10654,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10502
10654
|
}
|
|
10503
10655
|
|
|
10504
10656
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
10657
|
+
// Sticky positioning is a native scroll behavior and must also run
|
|
10658
|
+
// without a JavaScript onScroll subscription, including animations.
|
|
10659
|
+
(scrollView as? CraftNativeScrollView)?.updateStickyHeaders()
|
|
10505
10660
|
guard let handler = scrollHandlers[ObjectIdentifier(scrollView)] else { return }
|
|
10506
10661
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
|
|
10507
10662
|
}
|
|
@@ -10510,12 +10665,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10510
10665
|
|
|
10511
10666
|
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
|
10512
10667
|
CraftFramePacing.shared.begin(pacingName)
|
|
10668
|
+
(scrollView as? CraftNativeScrollView)?.beginPageDrag()
|
|
10513
10669
|
guard let handler = scrollBeginHandlers[ObjectIdentifier(scrollView)] else { return }
|
|
10514
10670
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
|
|
10515
10671
|
}
|
|
10516
10672
|
|
|
10517
10673
|
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
|
10518
|
-
if !decelerate { CraftFramePacing.shared.end(pacingName) }
|
|
10674
|
+
if !decelerate { CraftFramePacing.shared.end(pacingName); (scrollView as? CraftNativeScrollView)?.finishPageMotion() }
|
|
10519
10675
|
guard let handler = scrollEndHandlers[ObjectIdentifier(scrollView)] else { return }
|
|
10520
10676
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
|
|
10521
10677
|
}
|
|
@@ -10527,10 +10683,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10527
10683
|
|
|
10528
10684
|
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
|
10529
10685
|
CraftFramePacing.shared.end(pacingName)
|
|
10686
|
+
(scrollView as? CraftNativeScrollView)?.finishPageMotion()
|
|
10530
10687
|
guard let handler = scrollMomentumEndHandlers[ObjectIdentifier(scrollView)] else { return }
|
|
10531
10688
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
|
|
10532
10689
|
}
|
|
10533
10690
|
|
|
10691
|
+
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
|
10692
|
+
(scrollView as? CraftNativeScrollView)?.finishPageMotion()
|
|
10693
|
+
}
|
|
10694
|
+
|
|
10534
10695
|
private func scrollEvent(_ scrollView: UIScrollView) -> [String: Any] {
|
|
10535
10696
|
[
|
|
10536
10697
|
"contentOffset": ["x": scrollView.contentOffset.x, "y": scrollView.contentOffset.y],
|
|
@@ -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
|
|
@@ -22,6 +22,7 @@ import Darwin
|
|
|
22
22
|
/// tapif <text> tap it when it is on screen, else carry on (the big timer's "Show the controls")
|
|
23
23
|
/// tapid <identifier>
|
|
24
24
|
/// longpressid <identifier> holds a hittable native element to open its edit menu
|
|
25
|
+
/// holdtab <name> [| secs] standard XCTest hold (2–10s, default 6), BEGIN/END timing receipt
|
|
25
26
|
/// touches <JSON> timed normalized touch paths in one native dispatch
|
|
26
27
|
/// tapxy <x> <y> normalised 0…1 of the screen
|
|
27
28
|
/// tapexact <label> a button whose label is exactly this
|
|
@@ -381,6 +382,43 @@ final class DriverTests: XCTestCase {
|
|
|
381
382
|
}
|
|
382
383
|
target.press(forDuration: 1.2)
|
|
383
384
|
note("longpressid \(arg): ok")
|
|
385
|
+
case "holdtab":
|
|
386
|
+
let parts = arg.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) }
|
|
387
|
+
guard parts.count <= 2, let name = parts.first, !name.isEmpty,
|
|
388
|
+
let duration = parts.count == 2 ? Double(parts[1]) : 6,
|
|
389
|
+
duration.isFinite, (2...10).contains(duration) else {
|
|
390
|
+
check(false, "holdtab needs a tab name and 2–10 seconds")
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
guard let button = poll(10, { tabButton(name) }) else {
|
|
394
|
+
check(false, "holdtab cannot find tab \(name)")
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
let frame = button.frame
|
|
398
|
+
let coordinate = button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
|
399
|
+
let startedAt = Date().timeIntervalSince1970
|
|
400
|
+
let startedUptime = ProcessInfo.processInfo.systemUptime
|
|
401
|
+
// BEGIN arms the independent screenshot watcher. XCTest still
|
|
402
|
+
// performs its normal idle checks before synthesizing the press;
|
|
403
|
+
// the watcher must wait for that synthesis, not infer touch-down.
|
|
404
|
+
note("BEGIN holdtab \(name) | \(duration) | \(startedAt)")
|
|
405
|
+
fflush(nil)
|
|
406
|
+
coordinate.press(forDuration: duration)
|
|
407
|
+
let returnedAt = Date().timeIntervalSince1970
|
|
408
|
+
let returnedUptime = ProcessInfo.processInfo.systemUptime
|
|
409
|
+
note("END holdtab \(name) | \(returnedAt)")
|
|
410
|
+
let receipt: [String: Any] = [
|
|
411
|
+
"tab": name, "requestedSeconds": duration,
|
|
412
|
+
"input": "XCUICoordinate.press(forDuration:)",
|
|
413
|
+
"startedAt": startedAt, "returnedAt": returnedAt,
|
|
414
|
+
"startedUptime": startedUptime, "returnedUptime": returnedUptime,
|
|
415
|
+
"frame": ["x": frame.minX, "y": frame.minY, "width": frame.width, "height": frame.height],
|
|
416
|
+
"note": "Call times include XCTest idle checks; during-hold screenshots require an independent synthesis-timed capture.",
|
|
417
|
+
]
|
|
418
|
+
if let data = try? JSONSerialization.data(withJSONObject: receipt, options: [.sortedKeys]) {
|
|
419
|
+
post("/save?name=holdtab-\(Int(startedUptime * 1000)).json", data)
|
|
420
|
+
}
|
|
421
|
+
check(true, "holdtab \(name): standard \(duration)s press returned")
|
|
384
422
|
case "touches":
|
|
385
423
|
do {
|
|
386
424
|
guard let data = arg.data(using: .utf8),
|