craft-native 0.0.377 → 0.0.380
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 +9 -1
- package/dist/browser.js +15 -5
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +2 -0
- package/dist/ios/src/index.js +2 -0
- package/dist/ios/templates/CraftApp.swift +3 -0
- package/dist/ios/templates/CraftHybrid.swift +10 -5
- package/dist/ios/templates/CraftNativeActions.swift +13 -3
- package/dist/ios/templates/CraftNativeFiles.swift +121 -60
- package/dist/ios/templates/CraftNativeScreen.swift +104 -31
- package/dist/ios/templates/CraftNativeServices.swift +32 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -658,7 +658,15 @@ This lets browser simulations checkpoint on backgrounding with the same contract
|
|
|
658
658
|
as a native shell. Storage failures must still be handled by the caller.
|
|
659
659
|
|
|
660
660
|
Await `platform.call('Storage', 'set', [key, value], signal)` before reporting
|
|
661
|
-
a saved checkpoint.
|
|
661
|
+
a saved checkpoint. The browser captures the key and JSON value when the call
|
|
662
|
+
starts, before waiting for a Web Lock. Later mutations of your simulation state
|
|
663
|
+
or argument array cannot retarget or change that pending save. `setIfAbsent`
|
|
664
|
+
captures its value too; `compareAndSet` captures both the expected and next JSON
|
|
665
|
+
values (including their `toJSON` representation). Invalid JSON input rejects
|
|
666
|
+
without changing the previous checkpoint. Cancellation and account-scope checks
|
|
667
|
+
still run before committing the captured value.
|
|
668
|
+
|
|
669
|
+
`craft.storage.setSync` updates the immediate UI cache and
|
|
662
670
|
queues its write when Web Locks are available; it cannot acknowledge durability
|
|
663
671
|
or propagate a later quota failure. Background events are an opportunity to
|
|
664
672
|
checkpoint, but browsers can terminate a page before queued work finishes, so
|
package/dist/browser.js
CHANGED
|
@@ -242,8 +242,18 @@ async function createBrowserStorage(options) {
|
|
|
242
242
|
signal?.throwIfAborted();
|
|
243
243
|
const captured = capture();
|
|
244
244
|
const wasAvailable = available();
|
|
245
|
-
await waitForAccount(accountChanged(), signal);
|
|
246
245
|
const name = String(args[0] ?? "");
|
|
246
|
+
let json;
|
|
247
|
+
let expected;
|
|
248
|
+
if (wasAvailable && module === "Storage") {
|
|
249
|
+
if (method === "set" || method === "setIfAbsent" && lock)
|
|
250
|
+
json = encode(args[1]);
|
|
251
|
+
if (method === "compareAndSet" && lock) {
|
|
252
|
+
expected = parse(encode(args[1]));
|
|
253
|
+
json = encode(args[2]);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
await waitForAccount(accountChanged(), signal);
|
|
247
257
|
if (module === "Storage") {
|
|
248
258
|
if (!wasAvailable)
|
|
249
259
|
throw scopeChanged();
|
|
@@ -253,7 +263,7 @@ async function createBrowserStorage(options) {
|
|
|
253
263
|
if (method === "get")
|
|
254
264
|
return parse(storage.getItem(address));
|
|
255
265
|
if (method === "set") {
|
|
256
|
-
storage.setItem(address,
|
|
266
|
+
storage.setItem(address, json);
|
|
257
267
|
return true;
|
|
258
268
|
}
|
|
259
269
|
if (method === "remove") {
|
|
@@ -274,12 +284,12 @@ async function createBrowserStorage(options) {
|
|
|
274
284
|
if (method === "setIfAbsent") {
|
|
275
285
|
if (raw !== null)
|
|
276
286
|
return false;
|
|
277
|
-
storage.setItem(address,
|
|
287
|
+
storage.setItem(address, json);
|
|
278
288
|
return true;
|
|
279
289
|
}
|
|
280
|
-
if (raw === null || !jsonEqual(parse(raw),
|
|
290
|
+
if (raw === null || !jsonEqual(parse(raw), expected))
|
|
281
291
|
return false;
|
|
282
|
-
storage.setItem(address,
|
|
292
|
+
storage.setItem(address, json);
|
|
283
293
|
return true;
|
|
284
294
|
}
|
|
285
295
|
throw Object.assign(new Error(`Unsupported browser storage operation ${method}`), { code: "UNSUPPORTED" });
|
package/dist/cli.js
CHANGED
|
@@ -3047,7 +3047,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3047
3047
|
`);
|
|
3048
3048
|
}
|
|
3049
3049
|
// package.json
|
|
3050
|
-
var version = "0.0.
|
|
3050
|
+
var version = "0.0.380";
|
|
3051
3051
|
|
|
3052
3052
|
// bin/cli.ts
|
|
3053
3053
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
package/dist/ios/src/index.d.ts
CHANGED
|
@@ -44,6 +44,8 @@ export interface CraftConfig {
|
|
|
44
44
|
/** Runs from the generated project root after `craft ios build` writes it. */
|
|
45
45
|
postGenerate?: CraftPostGenerateHook;
|
|
46
46
|
backgroundColor?: string;
|
|
47
|
+
/** Default tint for native controls and selected tabs, as #RRGGBB. */
|
|
48
|
+
tintColor?: string;
|
|
47
49
|
/** The launch and webview background while the phone is in Dark Mode. */
|
|
48
50
|
backgroundColorDark?: string;
|
|
49
51
|
/** Edge-swipe back and forward through the page's history, pushState entries included. */
|
package/dist/ios/src/index.js
CHANGED
|
@@ -1421,6 +1421,8 @@ async function init(options) {
|
|
|
1421
1421
|
if (config.renderer !== "web" && config.renderer !== "native") {
|
|
1422
1422
|
throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
|
|
1423
1423
|
}
|
|
1424
|
+
if (config.tintColor !== undefined && !/^#[0-9a-f]{6}$/i.test(config.tintColor))
|
|
1425
|
+
throw new Error("iOS tintColor must be a #RRGGBB color");
|
|
1424
1426
|
const selection = config.tabBar?.selection;
|
|
1425
1427
|
if (selection?.cornerRadius !== undefined && (!Number.isFinite(selection.cornerRadius) || selection.cornerRadius < 0))
|
|
1426
1428
|
throw new Error("iOS tabBar.selection.cornerRadius must be a finite non-negative number");
|
|
@@ -700,6 +700,7 @@ struct CraftApp: App {
|
|
|
700
700
|
.ignoresSafeArea(.keyboard, edges: .bottom)
|
|
701
701
|
}
|
|
702
702
|
}
|
|
703
|
+
.tint(appState.config.tintColor.flatMap { UIColor(hex: $0) }.map { Color($0) })
|
|
703
704
|
.preferredColorScheme(appState.appearanceScheme)
|
|
704
705
|
.environmentObject(appState)
|
|
705
706
|
.onOpenURL { url in
|
|
@@ -1788,6 +1789,8 @@ struct CraftConfig: Codable {
|
|
|
1788
1789
|
/// which keep what `darkMode` pinned.
|
|
1789
1790
|
var appearance: String? = nil
|
|
1790
1791
|
var backgroundColor: String = "#1a1a2e"
|
|
1792
|
+
/// Default native control and selected-tab tint, independent of page chrome.
|
|
1793
|
+
var tintColor: String? = nil
|
|
1791
1794
|
/// The background while the phone is in Dark Mode; the light one if unset.
|
|
1792
1795
|
var backgroundColorDark: String? = nil
|
|
1793
1796
|
/// Edge-swipe back and forward through the page's history.
|
|
@@ -1192,6 +1192,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
1192
1192
|
}
|
|
1193
1193
|
}
|
|
1194
1194
|
view.backgroundColor = config.resolvedBackgroundColor
|
|
1195
|
+
if let tint = config.tintColor.flatMap({ UIColor(hex: $0) }) { view.tintColor = tint }
|
|
1195
1196
|
parking.frame = view.bounds
|
|
1196
1197
|
parking.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
1197
1198
|
view.insertSubview(parking, at: 0)
|
|
@@ -1265,7 +1266,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
1265
1266
|
view.layoutIfNeeded()
|
|
1266
1267
|
}
|
|
1267
1268
|
}
|
|
1268
|
-
tabBar.tintColor = chrome.tint ?? view.tintColor
|
|
1269
|
+
tabBar.tintColor = chrome.tint ?? config.tintColor.flatMap { UIColor(hex: $0) } ?? view.tintColor
|
|
1269
1270
|
let hidden = !chrome.tabBarVisible || chrome.tabs.isEmpty
|
|
1270
1271
|
if #available(iOS 18.0, *) { setTabBarHidden(hidden, animated: false) }
|
|
1271
1272
|
else { tabBar.isHidden = hidden }
|
|
@@ -1411,7 +1412,10 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
1411
1412
|
accessoryGlass.frame = accessoryButton.frame
|
|
1412
1413
|
let badgeWidth = max(18, accessoryBadge.intrinsicContentSize.width + 8)
|
|
1413
1414
|
accessoryBadge.frame = CGRect(x: accessoryButton.frame.maxX - badgeWidth + 4, y: accessoryButton.frame.minY - 3, width: badgeWidth, height: 18)
|
|
1414
|
-
|
|
1415
|
+
// A compact action floats over the page, not across its whole width.
|
|
1416
|
+
// Only the older-system full-width shelf reserves space in web layout.
|
|
1417
|
+
let accessoryInset: CGFloat = accessoryModel != nil && !iconOnly ? 44 : 0
|
|
1418
|
+
CraftChrome.shared.layoutChanged(tabBar.isHidden ? 0 : max(0, view.bounds.maxY - tabBar.frame.minY) + accessoryInset)
|
|
1415
1419
|
}
|
|
1416
1420
|
|
|
1417
1421
|
private var webAttached = false
|
|
@@ -2273,10 +2277,11 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
2273
2277
|
}
|
|
2274
2278
|
|
|
2275
2279
|
private func safeAreaForTabBar() -> CGFloat {
|
|
2276
|
-
// UIKit supplies the bar (and on iOS 26, its accessory) inset.
|
|
2277
|
-
//
|
|
2280
|
+
// UIKit supplies the bar (and on iOS 26, its standard accessory) inset.
|
|
2281
|
+
// Compact actions overlay content; only the older full-width shelf
|
|
2282
|
+
// needs an extra inset.
|
|
2278
2283
|
let chrome = CraftChrome.shared
|
|
2279
|
-
if chrome.
|
|
2284
|
+
if chrome.tabAccessory?.iconOnly == true { return 0 }
|
|
2280
2285
|
#if compiler(>=6.2)
|
|
2281
2286
|
if #available(iOS 26.0, *) { return 0 }
|
|
2282
2287
|
#endif
|
|
@@ -278,13 +278,13 @@ enum CraftNativeActions {
|
|
|
278
278
|
case ("Files", "pickVideo"):
|
|
279
279
|
let options = args.first as? [String: Any] ?? [:]
|
|
280
280
|
DispatchQueue.main.async {
|
|
281
|
-
CraftNativeFiles.pickVideo(options) { completeFile(requestToken, $0, completion) }
|
|
281
|
+
CraftNativeFiles.pickVideo(options, isCancelled: { isCancelled(requestToken) }) { completeFile(requestToken, $0, completion) }
|
|
282
282
|
}
|
|
283
283
|
return
|
|
284
284
|
case ("Files", "pickFile"):
|
|
285
285
|
let options = args.first as? [String: Any] ?? [:]
|
|
286
286
|
DispatchQueue.main.async {
|
|
287
|
-
CraftNativeFiles.pickFile(options) { completeFile(requestToken, $0, completion) }
|
|
287
|
+
CraftNativeFiles.pickFile(options, isCancelled: { isCancelled(requestToken) }) { completeFile(requestToken, $0, completion) }
|
|
288
288
|
}
|
|
289
289
|
return
|
|
290
290
|
case ("Files", "download"):
|
|
@@ -700,6 +700,13 @@ enum CraftNativeActions {
|
|
|
700
700
|
}
|
|
701
701
|
}
|
|
702
702
|
|
|
703
|
+
/// Read without consuming the token: final delivery owns its removal.
|
|
704
|
+
static func isCancelled(_ requestToken: String) -> Bool {
|
|
705
|
+
cancellationLock.lock()
|
|
706
|
+
defer { cancellationLock.unlock() }
|
|
707
|
+
return cancelledRequests.contains(requestToken)
|
|
708
|
+
}
|
|
709
|
+
|
|
703
710
|
static func cancel(requestToken: String) {
|
|
704
711
|
cancellationLock.lock()
|
|
705
712
|
cancelledRequests.insert(requestToken)
|
|
@@ -1836,7 +1843,9 @@ enum CraftSnapshots {
|
|
|
1836
1843
|
revisions[name] = mine
|
|
1837
1844
|
pending[name] = .some(json)
|
|
1838
1845
|
cache.removeObject(forKey: name as NSString)
|
|
1839
|
-
|
|
1846
|
+
// Admission and queue submission are one ordering boundary. Unlocking
|
|
1847
|
+
// before enqueue lets an older caller overtake a newer revision on disk
|
|
1848
|
+
// while the cache still retains the newer value.
|
|
1840
1849
|
queue.async {
|
|
1841
1850
|
var ok = true
|
|
1842
1851
|
do {
|
|
@@ -1863,6 +1872,7 @@ enum CraftSnapshots {
|
|
|
1863
1872
|
completion?(ok)
|
|
1864
1873
|
}
|
|
1865
1874
|
}
|
|
1875
|
+
lock.unlock()
|
|
1866
1876
|
}
|
|
1867
1877
|
|
|
1868
1878
|
/// Every snapshot there is: on disk, and written but not on disk yet.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import CryptoKit
|
|
2
|
+
import Darwin
|
|
2
3
|
import Foundation
|
|
3
4
|
import ImageIO
|
|
4
5
|
import Network
|
|
@@ -74,11 +75,21 @@ enum CraftNativeFiles {
|
|
|
74
75
|
return info
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
/// Forget
|
|
78
|
+
/// Forget only the requested owned file. True only when it was removed;
|
|
79
|
+
/// pruning its now-empty directory is best effort, not recursive deletion.
|
|
78
80
|
@discardableResult
|
|
79
81
|
static func remove(_ path: String) -> Bool {
|
|
80
82
|
guard let url = owned(path) else { return false }
|
|
81
|
-
|
|
83
|
+
// unlink cannot recursively remove a directory if the entry changes
|
|
84
|
+
// after ownership validation. A denied or already-gone file is false.
|
|
85
|
+
guard url.path.withCString({ unlink($0) }) == 0 else { return false }
|
|
86
|
+
let parent = url.deletingLastPathComponent()
|
|
87
|
+
let base = root.standardizedFileURL.resolvingSymlinksInPath()
|
|
88
|
+
if parent.path != base.path {
|
|
89
|
+
// rmdir refuses nonempty directories, even if a sibling appears
|
|
90
|
+
// concurrently. Never recursively delete an imported file's peers.
|
|
91
|
+
_ = parent.path.withCString { rmdir($0) }
|
|
92
|
+
}
|
|
82
93
|
return true
|
|
83
94
|
}
|
|
84
95
|
|
|
@@ -298,7 +309,8 @@ enum CraftNativeFiles {
|
|
|
298
309
|
|
|
299
310
|
/// System pickers grant access to just the selected video. Copy it while
|
|
300
311
|
/// the provider's URL is valid; no movie bytes cross JavaScriptCore.
|
|
301
|
-
static func pickVideo(_ options: [String: Any], done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
312
|
+
static func pickVideo(_ options: [String: Any], isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
313
|
+
guard !isCancelled() else { done(.failure(importCancelled())); return }
|
|
302
314
|
guard activePicker == nil else {
|
|
303
315
|
done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
|
|
304
316
|
return
|
|
@@ -313,7 +325,7 @@ enum CraftNativeFiles {
|
|
|
313
325
|
DispatchQueue.main.async { activePicker = nil; done(result) }
|
|
314
326
|
}
|
|
315
327
|
if options["source"] as? String == "files" {
|
|
316
|
-
let delegate = VideoDocumentDelegate(limit: limit, done: finish)
|
|
328
|
+
let delegate = VideoDocumentDelegate(limit: limit, isCancelled: isCancelled, done: finish)
|
|
317
329
|
var types: [UTType] = [.movie]
|
|
318
330
|
if let webm = UTType(filenameExtension: "webm") { types.append(webm) }
|
|
319
331
|
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
|
|
@@ -329,7 +341,7 @@ enum CraftNativeFiles {
|
|
|
329
341
|
configuration.filter = .videos
|
|
330
342
|
configuration.selectionLimit = 1
|
|
331
343
|
configuration.preferredAssetRepresentationMode = .current
|
|
332
|
-
let delegate = VideoLibraryDelegate(limit: limit, done: finish)
|
|
344
|
+
let delegate = VideoLibraryDelegate(limit: limit, isCancelled: isCancelled, done: finish)
|
|
333
345
|
let picker = PHPickerViewController(configuration: configuration)
|
|
334
346
|
picker.delegate = delegate
|
|
335
347
|
activePicker = delegate
|
|
@@ -345,7 +357,8 @@ enum CraftNativeFiles {
|
|
|
345
357
|
/// Pick one or more documents and copy them into app-owned storage while
|
|
346
358
|
/// the security-scoped URLs are valid. `contentTypes` accepts MIME types,
|
|
347
359
|
/// UTIs, or filename extensions; an empty list means any document.
|
|
348
|
-
static func pickFile(_ options: [String: Any], done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
360
|
+
static func pickFile(_ options: [String: Any], isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
361
|
+
guard !isCancelled() else { done(.failure(importCancelled())); return }
|
|
349
362
|
guard activePicker == nil else {
|
|
350
363
|
done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
|
|
351
364
|
return
|
|
@@ -372,7 +385,7 @@ enum CraftNativeFiles {
|
|
|
372
385
|
let finish: (Result<Any, CraftNativeActionError>) -> Void = { result in
|
|
373
386
|
DispatchQueue.main.async { activePicker = nil; done(result) }
|
|
374
387
|
}
|
|
375
|
-
let delegate = FileDocumentDelegate(limit: limit, done: finish)
|
|
388
|
+
let delegate = FileDocumentDelegate(limit: limit, isCancelled: isCancelled, done: finish)
|
|
376
389
|
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
|
|
377
390
|
picker.allowsMultipleSelection = selectionLimit > 1
|
|
378
391
|
picker.delegate = delegate
|
|
@@ -409,28 +422,98 @@ enum CraftNativeFiles {
|
|
|
409
422
|
|
|
410
423
|
/// Copy an arbitrary selected document into Craft's private file area and
|
|
411
424
|
/// return the same descriptor used by downloads, writes, and uploads.
|
|
412
|
-
static func copyFile(_ source: URL, limit: Int) -> Result<Any, CraftNativeActionError> {
|
|
425
|
+
static func copyFile(_ source: URL, limit: Int, isCancelled: () -> Bool = { false }) -> Result<Any, CraftNativeActionError> {
|
|
426
|
+
let contentType = (try? source.resourceValues(forKeys: [.contentTypeKey]))?.contentType
|
|
427
|
+
return copyImport(source, limit: limit, type: mimeType(for: source, contentType: contentType), isCancelled: isCancelled)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private static func importCancelled() -> CraftNativeActionError {
|
|
431
|
+
CraftNativeActionError(code: "CANCELLED", message: "The file import was cancelled")
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/// Validate the opened file, not just its path metadata. Nonblocking open
|
|
435
|
+
/// avoids hanging on a FIFO; no-follow refuses symlinks. Metadata is only
|
|
436
|
+
/// an early rejection: enforce the limit on bytes actually read as well.
|
|
437
|
+
private static func copyImport(_ source: URL, limit: Int, type: String, isCancelled: () -> Bool) -> Result<Any, CraftNativeActionError> {
|
|
438
|
+
var destination: URL?
|
|
439
|
+
var succeeded = false
|
|
440
|
+
defer {
|
|
441
|
+
if !succeeded, let destination {
|
|
442
|
+
_ = destination.path.withCString { unlink($0) }
|
|
443
|
+
_ = destination.deletingLastPathComponent().path.withCString { rmdir($0) }
|
|
444
|
+
}
|
|
445
|
+
}
|
|
413
446
|
do {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
let
|
|
428
|
-
|
|
447
|
+
guard !isCancelled() else { throw importCancelled() }
|
|
448
|
+
guard source.isFileURL else {
|
|
449
|
+
throw CraftNativeActionError(code: "INVALID_FILE", message: "Choose a regular local file")
|
|
450
|
+
}
|
|
451
|
+
let descriptor = source.path.withCString { open($0, O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC) }
|
|
452
|
+
guard descriptor >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) }
|
|
453
|
+
let input = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
|
|
454
|
+
defer { try? input.close() }
|
|
455
|
+
var metadata = stat()
|
|
456
|
+
guard fstat(descriptor, &metadata) == 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) }
|
|
457
|
+
guard metadata.st_mode & S_IFMT == S_IFREG else {
|
|
458
|
+
throw CraftNativeActionError(code: "INVALID_FILE", message: "Choose a regular file, not a directory or special file")
|
|
459
|
+
}
|
|
460
|
+
let oversized = CraftNativeActionError(code: "FILE_TOO_LARGE", message: "Choose a nonempty file no larger than \(limit) bytes.")
|
|
461
|
+
guard limit > 0, metadata.st_size > 0, metadata.st_size <= limit else { throw oversized }
|
|
462
|
+
let target = try newFile(named: source.lastPathComponent)
|
|
463
|
+
destination = target
|
|
464
|
+
guard FileManager.default.createFile(atPath: target.path, contents: nil) else {
|
|
465
|
+
throw CraftNativeActionError(code: "WRITE_ERROR", message: "The imported file could not be created")
|
|
466
|
+
}
|
|
467
|
+
let output = try FileHandle(forWritingTo: target)
|
|
468
|
+
defer { try? output.close() }
|
|
469
|
+
var total = 0
|
|
470
|
+
while true {
|
|
471
|
+
guard !isCancelled() else { throw importCancelled() }
|
|
472
|
+
// At the limit, probe one more byte without writing it. This
|
|
473
|
+
// also avoids overflowing limit + 1 for an Int.max caller.
|
|
474
|
+
let chunk = try input.read(upToCount: max(1, min(64 * 1024, limit - total))) ?? Data()
|
|
475
|
+
guard !isCancelled() else { throw importCancelled() }
|
|
476
|
+
if chunk.isEmpty { break }
|
|
477
|
+
guard chunk.count <= limit - total else { throw oversized }
|
|
478
|
+
try output.write(contentsOf: chunk)
|
|
479
|
+
total += chunk.count
|
|
480
|
+
}
|
|
481
|
+
guard total > 0 else { throw oversized }
|
|
482
|
+
try output.close()
|
|
483
|
+
guard !isCancelled() else { throw importCancelled() }
|
|
484
|
+
succeeded = true
|
|
485
|
+
return .success(describe(target, type: type))
|
|
429
486
|
} catch {
|
|
430
|
-
return .failure(CraftNativeActionError(code: "READ_ERROR", message: error.localizedDescription))
|
|
487
|
+
return .failure((error as? CraftNativeActionError) ?? CraftNativeActionError(code: "READ_ERROR", message: error.localizedDescription))
|
|
431
488
|
}
|
|
432
489
|
}
|
|
433
490
|
|
|
491
|
+
/// All-or-nothing ownership for a multi-document selection. Security scope
|
|
492
|
+
/// lasts through each copy; cancellation also rolls back earlier results.
|
|
493
|
+
static func copyFiles(_ sources: [URL], limit: Int, isCancelled: () -> Bool = { false }) -> Result<Any, CraftNativeActionError> {
|
|
494
|
+
var results: [[String: Any]] = []
|
|
495
|
+
var succeeded = false
|
|
496
|
+
defer {
|
|
497
|
+
if !succeeded { results.compactMap { $0["path"] as? String }.forEach { remove($0) } }
|
|
498
|
+
}
|
|
499
|
+
for source in sources {
|
|
500
|
+
guard !isCancelled() else { return .failure(importCancelled()) }
|
|
501
|
+
let granted = source.startAccessingSecurityScopedResource()
|
|
502
|
+
defer { if granted { source.stopAccessingSecurityScopedResource() } }
|
|
503
|
+
switch copyFile(source, limit: limit, isCancelled: isCancelled) {
|
|
504
|
+
case .success(let value):
|
|
505
|
+
guard let info = value as? [String: Any] else {
|
|
506
|
+
return .failure(CraftNativeActionError(code: "READ_ERROR", message: "The selected file could not be described"))
|
|
507
|
+
}
|
|
508
|
+
results.append(info)
|
|
509
|
+
case .failure(let error): return .failure(error)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
guard !isCancelled() else { return .failure(importCancelled()) }
|
|
513
|
+
succeeded = true
|
|
514
|
+
return .success(results)
|
|
515
|
+
}
|
|
516
|
+
|
|
434
517
|
private static func mimeType(for source: URL, contentType: UTType?) -> String {
|
|
435
518
|
let generic = "application/octet-stream"
|
|
436
519
|
if let preferred = contentType?.preferredMIMEType, preferred != generic { return preferred }
|
|
@@ -450,82 +533,60 @@ enum CraftNativeFiles {
|
|
|
450
533
|
|
|
451
534
|
private final class FileDocumentDelegate: NSObject, UIDocumentPickerDelegate {
|
|
452
535
|
let limit: Int
|
|
536
|
+
let isCancelled: () -> Bool
|
|
453
537
|
let done: (Result<Any, CraftNativeActionError>) -> Void
|
|
454
|
-
init(limit: Int, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.done = done }
|
|
538
|
+
init(limit: Int, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.isCancelled = isCancelled; self.done = done }
|
|
455
539
|
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { done(.success([])) }
|
|
456
540
|
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
|
457
541
|
guard !urls.isEmpty else { done(.success([])); return }
|
|
458
|
-
let (limit, done) = (self.limit, self.done)
|
|
542
|
+
let (limit, isCancelled, done) = (self.limit, self.isCancelled, self.done)
|
|
459
543
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
460
|
-
|
|
461
|
-
for url in urls {
|
|
462
|
-
let granted = url.startAccessingSecurityScopedResource()
|
|
463
|
-
defer { if granted { url.stopAccessingSecurityScopedResource() } }
|
|
464
|
-
switch CraftNativeFiles.copyFile(url, limit: limit) {
|
|
465
|
-
case .success(let value):
|
|
466
|
-
guard let info = value as? [String: Any] else { done(.failure(CraftNativeActionError(code: "READ_ERROR", message: "The selected file could not be described"))); return }
|
|
467
|
-
results.append(info)
|
|
468
|
-
case .failure(let error):
|
|
469
|
-
results.compactMap { $0["path"] as? String }.forEach { CraftNativeFiles.remove($0) }
|
|
470
|
-
done(.failure(error))
|
|
471
|
-
return
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
done(.success(results))
|
|
544
|
+
done(CraftNativeFiles.copyFiles(urls, limit: limit, isCancelled: isCancelled))
|
|
475
545
|
}
|
|
476
546
|
}
|
|
477
547
|
}
|
|
478
548
|
|
|
479
|
-
static func copyVideo(_ source: URL, limit: Int) -> Result<Any, CraftNativeActionError> {
|
|
549
|
+
static func copyVideo(_ source: URL, limit: Int, isCancelled: () -> Bool = { false }) -> Result<Any, CraftNativeActionError> {
|
|
480
550
|
let ext = source.pathExtension.lowercased()
|
|
481
551
|
let types = ["mp4": "video/mp4", "m4v": "video/x-m4v", "mov": "video/quicktime", "webm": "video/webm"]
|
|
482
552
|
guard let type = types[ext] else {
|
|
483
553
|
return .failure(CraftNativeActionError(code: "INVALID_VIDEO", message: "Choose an MP4, MOV or WebM video."))
|
|
484
554
|
}
|
|
485
|
-
|
|
486
|
-
let size = ((try FileManager.default.attributesOfItem(atPath: source.path))[.size] as? NSNumber)?.intValue ?? 0
|
|
487
|
-
guard size > 0, size <= limit else {
|
|
488
|
-
return .failure(CraftNativeActionError(code: "FILE_TOO_LARGE", message: "Choose a nonempty video under \(limit / 1024 / 1024)MB."))
|
|
489
|
-
}
|
|
490
|
-
let destination = try newFile(named: source.lastPathComponent)
|
|
491
|
-
do { try FileManager.default.copyItem(at: source, to: destination) }
|
|
492
|
-
catch { try? FileManager.default.removeItem(at: destination.deletingLastPathComponent()); throw error }
|
|
493
|
-
return .success(describe(destination, type: type))
|
|
494
|
-
} catch {
|
|
495
|
-
return .failure(CraftNativeActionError(code: "READ_ERROR", message: error.localizedDescription))
|
|
496
|
-
}
|
|
555
|
+
return copyImport(source, limit: limit, type: type, isCancelled: isCancelled)
|
|
497
556
|
}
|
|
498
557
|
|
|
499
558
|
private final class VideoLibraryDelegate: NSObject, PHPickerViewControllerDelegate {
|
|
500
559
|
let limit: Int
|
|
560
|
+
let isCancelled: () -> Bool
|
|
501
561
|
let done: (Result<Any, CraftNativeActionError>) -> Void
|
|
502
|
-
init(limit: Int, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.done = done }
|
|
562
|
+
init(limit: Int, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.isCancelled = isCancelled; self.done = done }
|
|
503
563
|
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
|
504
564
|
picker.dismiss(animated: true)
|
|
505
565
|
guard let provider = results.first?.itemProvider else { done(.success(NSNull())); return }
|
|
506
|
-
let (limit, done) = (self.limit, self.done)
|
|
566
|
+
let (limit, isCancelled, done) = (self.limit, self.isCancelled, self.done)
|
|
507
567
|
provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
|
|
508
568
|
guard let url else {
|
|
509
569
|
done(.failure(CraftNativeActionError(code: "READ_ERROR", message: error?.localizedDescription ?? "The video could not be read")))
|
|
510
570
|
return
|
|
511
571
|
}
|
|
512
|
-
done(CraftNativeFiles.copyVideo(url, limit: limit))
|
|
572
|
+
done(CraftNativeFiles.copyVideo(url, limit: limit, isCancelled: isCancelled))
|
|
513
573
|
}
|
|
514
574
|
}
|
|
515
575
|
}
|
|
516
576
|
|
|
517
577
|
private final class VideoDocumentDelegate: NSObject, UIDocumentPickerDelegate {
|
|
518
578
|
let limit: Int
|
|
579
|
+
let isCancelled: () -> Bool
|
|
519
580
|
let done: (Result<Any, CraftNativeActionError>) -> Void
|
|
520
|
-
init(limit: Int, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.done = done }
|
|
581
|
+
init(limit: Int, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.isCancelled = isCancelled; self.done = done }
|
|
521
582
|
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { done(.success(NSNull())) }
|
|
522
583
|
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
|
523
584
|
guard let url = urls.first else { done(.success(NSNull())); return }
|
|
524
|
-
let (limit, done) = (self.limit, self.done)
|
|
585
|
+
let (limit, isCancelled, done) = (self.limit, self.isCancelled, self.done)
|
|
525
586
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
526
587
|
let granted = url.startAccessingSecurityScopedResource()
|
|
527
588
|
defer { if granted { url.stopAccessingSecurityScopedResource() } }
|
|
528
|
-
done(CraftNativeFiles.copyVideo(url, limit: limit))
|
|
589
|
+
done(CraftNativeFiles.copyVideo(url, limit: limit, isCancelled: isCancelled))
|
|
529
590
|
}
|
|
530
591
|
}
|
|
531
592
|
}
|
|
@@ -15,7 +15,7 @@ import UIKit
|
|
|
15
15
|
|
|
16
16
|
private let craftNativeCapabilityTimeoutMilliseconds = 30_000
|
|
17
17
|
|
|
18
|
-
/// A retained JavaScript context must lose
|
|
18
|
+
/// A retained JavaScript context must lose storage and snapshot access when
|
|
19
19
|
/// its screen closes. Only the actual synchronous unmount callback may finish
|
|
20
20
|
/// its cleanup; queued script work runs after that allowance ends.
|
|
21
21
|
private final class CraftNativeStorageLifetime {
|
|
@@ -4929,7 +4929,7 @@ final class CraftNativeSvgView: UIView {
|
|
|
4929
4929
|
private func updateFrames() {
|
|
4930
4930
|
if isAnimating {
|
|
4931
4931
|
if animationStart == nil { animationStart = CACurrentMediaTime() }
|
|
4932
|
-
CraftNativeFrameClock.shared.request(self) { [weak self] in self?.nextFrame() }
|
|
4932
|
+
CraftNativeFrameClock.shared.request(self) { [weak self] _ in self?.nextFrame() }
|
|
4933
4933
|
} else {
|
|
4934
4934
|
animationStart = nil
|
|
4935
4935
|
CraftNativeFrameClock.shared.cancel(self)
|
|
@@ -4941,7 +4941,7 @@ final class CraftNativeSvgView: UIView {
|
|
|
4941
4941
|
// Drawn only while some of it is on screen; a row scrolled away keeps
|
|
4942
4942
|
// its time and is right when it comes back.
|
|
4943
4943
|
if let window, window.bounds.intersects(convert(bounds, to: window)) { setNeedsDisplay() }
|
|
4944
|
-
CraftNativeFrameClock.shared.request(self) { [weak self] in self?.nextFrame() }
|
|
4944
|
+
CraftNativeFrameClock.shared.request(self) { [weak self] _ in self?.nextFrame() }
|
|
4945
4945
|
}
|
|
4946
4946
|
|
|
4947
4947
|
private static func hasAnimation(_ children: [Any]) -> Bool {
|
|
@@ -5713,8 +5713,10 @@ extension CAAnimation {
|
|
|
5713
5713
|
final class CraftNativeFrameClock: NSObject {
|
|
5714
5714
|
static let shared = CraftNativeFrameClock()
|
|
5715
5715
|
|
|
5716
|
-
private var waiting: [ObjectIdentifier: () -> Void] = [:]
|
|
5716
|
+
private var waiting: [ObjectIdentifier: (CFTimeInterval) -> Void] = [:]
|
|
5717
5717
|
private var link: CADisplayLink?
|
|
5718
|
+
/// The display's monotonic timestamp, captured before handing off scripts.
|
|
5719
|
+
private var lastTimestamp: CFTimeInterval = 0
|
|
5718
5720
|
|
|
5719
5721
|
/// How many screens wait for the next frame.
|
|
5720
5722
|
var waitingCount: Int { waiting.count }
|
|
@@ -5722,7 +5724,7 @@ final class CraftNativeFrameClock: NSObject {
|
|
|
5722
5724
|
var isRunning: Bool { link.map { !$0.isPaused } ?? false }
|
|
5723
5725
|
|
|
5724
5726
|
/// `deliver` on the next frame, once. Asking again before it comes keeps one.
|
|
5725
|
-
func request(_ owner: AnyObject, _ deliver: @escaping () -> Void) {
|
|
5727
|
+
func request(_ owner: AnyObject, _ deliver: @escaping (CFTimeInterval) -> Void) {
|
|
5726
5728
|
waiting[ObjectIdentifier(owner)] = deliver
|
|
5727
5729
|
if link == nil {
|
|
5728
5730
|
let link = CADisplayLink(target: self, selector: #selector(frame(_:)))
|
|
@@ -5742,10 +5744,15 @@ final class CraftNativeFrameClock: NSObject {
|
|
|
5742
5744
|
}
|
|
5743
5745
|
|
|
5744
5746
|
@objc private func frame(_ link: CADisplayLink) {
|
|
5747
|
+
let timestamp = link.timestamp
|
|
5748
|
+
// Keep waiting for a new vsync rather than delivering a duplicate or
|
|
5749
|
+
// stale display timestamp after the link is paused and requested again.
|
|
5750
|
+
guard timestamp.isFinite, timestamp > lastTimestamp else { return }
|
|
5751
|
+
lastTimestamp = timestamp
|
|
5745
5752
|
let due = waiting
|
|
5746
5753
|
waiting.removeAll()
|
|
5747
5754
|
if waiting.isEmpty { link.isPaused = true }
|
|
5748
|
-
due.values.forEach { $0() }
|
|
5755
|
+
due.values.forEach { $0(timestamp) }
|
|
5749
5756
|
}
|
|
5750
5757
|
}
|
|
5751
5758
|
|
|
@@ -6015,6 +6022,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6015
6022
|
/// The script asked for an animation frame it has not had yet
|
|
6016
6023
|
/// (`requestAnimationFrame`, CraftNativeFrameClock). Main thread only.
|
|
6017
6024
|
private var frameWanted = false
|
|
6025
|
+
private let frameTimeOrigin = CACurrentMediaTime()
|
|
6026
|
+
private var lastFrameTimestamp = -Double.infinity
|
|
6027
|
+
/// Vsync is delivered on main but may wait on the script queue. Closing
|
|
6028
|
+
/// on main invalidates queued delivery without retaining the controller
|
|
6029
|
+
/// or holding a lock while JavaScript calls synchronous UIKit APIs.
|
|
6030
|
+
private final class FrameDelivery {
|
|
6031
|
+
private let lock = NSLock()
|
|
6032
|
+
private var open = true
|
|
6033
|
+
var isOpen: Bool { lock.withLock { open } }
|
|
6034
|
+
func close() { lock.withLock { open = false } }
|
|
6035
|
+
}
|
|
6036
|
+
private let frameDelivery = FrameDelivery()
|
|
6018
6037
|
private var lifecycleObservers: [NSObjectProtocol] = []
|
|
6019
6038
|
private var deepLinkListener: UUID?
|
|
6020
6039
|
private var keyboardSafeAreaInset: CGFloat = 0
|
|
@@ -6070,6 +6089,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6070
6089
|
CraftInterfaceStyleController.apply(to: self, config: config)
|
|
6071
6090
|
navigationItem.title = routeName ?? config.appName
|
|
6072
6091
|
view.backgroundColor = config.resolvedBackgroundColor
|
|
6092
|
+
if let tint = config.tintColor.flatMap({ UIColor(hex: $0) }) { view.tintColor = tint }
|
|
6073
6093
|
rootStack.axis = .vertical
|
|
6074
6094
|
rootStack.alignItems = "stretch"
|
|
6075
6095
|
rootStack.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -6214,15 +6234,34 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6214
6234
|
guard !runtimeDisposed, !frameWanted else { return }
|
|
6215
6235
|
frameWanted = true
|
|
6216
6236
|
guard isAppScript || reportedVisible != false else { return }
|
|
6217
|
-
CraftNativeFrameClock.shared.request(self) { [weak self] in self?.fireFrame() }
|
|
6237
|
+
CraftNativeFrameClock.shared.request(self) { [weak self] timestamp in self?.fireFrame(timestamp: timestamp) }
|
|
6218
6238
|
}
|
|
6219
6239
|
|
|
6220
|
-
|
|
6240
|
+
// Native display-delivery boundary; internal so the native fixture can
|
|
6241
|
+
// exercise stale startup ticks without depending on simulator timing.
|
|
6242
|
+
func fireFrame(timestamp displayTimestamp: CFTimeInterval) {
|
|
6221
6243
|
guard frameWanted, !runtimeDisposed else { return }
|
|
6244
|
+
// Another screen's callback can hide this screen after the clock has
|
|
6245
|
+
// captured its batch. Keep the request pending until it is shown.
|
|
6246
|
+
guard isAppScript || reportedVisible != false else { return }
|
|
6247
|
+
let timestamp = (displayTimestamp - frameTimeOrigin) * 1000
|
|
6248
|
+
// A display tick may predate a newly created controller. Clamping
|
|
6249
|
+
// those ticks to zero delivers multiple frames at the same time.
|
|
6250
|
+
// Wait for a valid tick instead, keeping the JS batch cancellable.
|
|
6251
|
+
guard displayTimestamp.isFinite, timestamp.isFinite, timestamp >= 0,
|
|
6252
|
+
timestamp > lastFrameTimestamp else {
|
|
6253
|
+
CraftNativeFrameClock.shared.request(self) { [weak self] timestamp in self?.fireFrame(timestamp: timestamp) }
|
|
6254
|
+
return
|
|
6255
|
+
}
|
|
6256
|
+
lastFrameTimestamp = timestamp
|
|
6222
6257
|
frameWanted = false
|
|
6223
6258
|
let context = jsContext
|
|
6224
6259
|
let route = routeName ?? "screen"
|
|
6225
|
-
|
|
6260
|
+
let delivery = frameDelivery
|
|
6261
|
+
CraftNativeScriptThread.run(label: "frame \(route)") {
|
|
6262
|
+
guard delivery.isOpen else { return }
|
|
6263
|
+
context.objectForKeyedSubscript("__craftNativeFireFrame")?.call(withArguments: [timestamp])
|
|
6264
|
+
}
|
|
6226
6265
|
}
|
|
6227
6266
|
|
|
6228
6267
|
/// The screen came on screen or went off it: a screen pushed over it was
|
|
@@ -6247,7 +6286,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6247
6286
|
self.hasAppeared = true
|
|
6248
6287
|
}
|
|
6249
6288
|
}
|
|
6250
|
-
if visible, frameWanted { CraftNativeFrameClock.shared.request(self) { [weak self] in self?.fireFrame() } }
|
|
6289
|
+
if visible, frameWanted { CraftNativeFrameClock.shared.request(self) { [weak self] timestamp in self?.fireFrame(timestamp: timestamp) } }
|
|
6251
6290
|
if !visible { CraftNativeFrameClock.shared.cancel(self) }
|
|
6252
6291
|
guard visible != reportedVisible else { return }
|
|
6253
6292
|
let first = reportedVisible == nil
|
|
@@ -6276,6 +6315,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6276
6315
|
private func disposeRuntime() {
|
|
6277
6316
|
guard !runtimeDisposed else { return }
|
|
6278
6317
|
runtimeDisposed = true
|
|
6318
|
+
frameDelivery.close()
|
|
6279
6319
|
storageLifetime.close()
|
|
6280
6320
|
pendingCapabilityDeadlines.values.forEach { $0.cancel() }
|
|
6281
6321
|
pendingCapabilityDeadlines.removeAll()
|
|
@@ -6343,6 +6383,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6343
6383
|
|
|
6344
6384
|
override func viewDidLayoutSubviews() {
|
|
6345
6385
|
super.viewDidLayoutSubviews()
|
|
6386
|
+
// Containment, bar visibility and accessory insets can change after
|
|
6387
|
+
// rendering. Recheck once the scroll viewport has its actual frame.
|
|
6388
|
+
trackContentScrollView()
|
|
6346
6389
|
positionModals()
|
|
6347
6390
|
emitLayoutEvents()
|
|
6348
6391
|
sendViewportIfChanged()
|
|
@@ -6588,28 +6631,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6588
6631
|
if (typeof globalThis.requestAnimationFrame !== 'function') {
|
|
6589
6632
|
(function() {
|
|
6590
6633
|
var frames = [];
|
|
6591
|
-
var
|
|
6634
|
+
var callbacks = new Map();
|
|
6592
6635
|
var nextFrame = 0;
|
|
6593
6636
|
globalThis.requestAnimationFrame = function(callback) {
|
|
6594
6637
|
if (typeof callback !== 'function') return 0;
|
|
6595
6638
|
var id = ++nextFrame;
|
|
6596
|
-
|
|
6639
|
+
callbacks.set(id, callback);
|
|
6640
|
+
frames.push(id);
|
|
6597
6641
|
if (frames.length === 1) craftNativeRequestFrame();
|
|
6598
6642
|
return id;
|
|
6599
6643
|
};
|
|
6600
6644
|
globalThis.cancelAnimationFrame = function(id) {
|
|
6601
|
-
|
|
6645
|
+
callbacks.delete(id);
|
|
6602
6646
|
};
|
|
6603
|
-
globalThis.__craftNativeFireFrame = function() {
|
|
6647
|
+
globalThis.__craftNativeFireFrame = function(now) {
|
|
6604
6648
|
var due = frames;
|
|
6605
|
-
var skip = cancelled;
|
|
6606
6649
|
frames = [];
|
|
6607
|
-
cancelled = new Set();
|
|
6608
|
-
var now = globalThis.performance && typeof globalThis.performance.now === 'function' ? globalThis.performance.now() : Date.now();
|
|
6609
6650
|
var failures = [];
|
|
6610
6651
|
for (var i = 0; i < due.length; i++) {
|
|
6611
|
-
|
|
6612
|
-
|
|
6652
|
+
var callback = callbacks.get(due[i]);
|
|
6653
|
+
callbacks.delete(due[i]);
|
|
6654
|
+
if (!callback) continue;
|
|
6655
|
+
try { callback(now); } catch (error) { failures.push(error); }
|
|
6613
6656
|
}
|
|
6614
6657
|
failures.forEach(function(error) { globalThis.setTimeout(function() { throw error; }, 0); });
|
|
6615
6658
|
};
|
|
@@ -6773,26 +6816,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6773
6816
|
}
|
|
6774
6817
|
}
|
|
6775
6818
|
let snapshotGet: @convention(block) (String) -> String? = { name in
|
|
6776
|
-
CraftSnapshots.read(name)
|
|
6819
|
+
storageLifetime.access(closedValue: nil) { CraftSnapshots.read(name) }
|
|
6777
6820
|
}
|
|
6778
6821
|
// JavaScriptCore retains these outer host blocks. A weak capture only
|
|
6779
6822
|
// on the nested completion still makes the host block retain self,
|
|
6780
6823
|
// forming controller -> JSContext -> host block -> controller.
|
|
6781
6824
|
let snapshotSet: @convention(block) (String, JSValue, JSValue) -> Void = { [weak self] name, value, done in
|
|
6782
|
-
|
|
6783
|
-
|
|
6784
|
-
|
|
6785
|
-
|
|
6786
|
-
|
|
6787
|
-
|
|
6825
|
+
let json = value.isNull || value.isUndefined ? nil : value.toString()
|
|
6826
|
+
// Refuse before queueing disk work. Rejecting only its completion
|
|
6827
|
+
// still lets a closed screen overwrite another screen's checkpoint.
|
|
6828
|
+
storageLifetime.access(closedValue: ()) {
|
|
6829
|
+
CraftSnapshots.write(name, json: json) { [weak self] ok in
|
|
6830
|
+
CraftNativeScriptThread.run {
|
|
6831
|
+
// A writer can finish after UIKit has disposed this
|
|
6832
|
+
// screen. Resolve the pending promise as a cancellation,
|
|
6833
|
+
// never as a successful write into a dead runtime.
|
|
6834
|
+
done.call(withArguments: [self?.runtimeDisposed != false ? false : ok])
|
|
6835
|
+
}
|
|
6788
6836
|
}
|
|
6789
6837
|
}
|
|
6790
6838
|
}
|
|
6791
6839
|
let snapshotClear: @convention(block) (JSValue, JSValue) -> Void = { [weak self] keep, done in
|
|
6792
6840
|
let names = (keep.isUndefined || keep.isNull ? [] : (keep.toArray() ?? [])).compactMap { $0 as? String }
|
|
6793
|
-
|
|
6794
|
-
|
|
6795
|
-
|
|
6841
|
+
storageLifetime.access(closedValue: ()) {
|
|
6842
|
+
CraftSnapshots.clear(keeping: Set(names)) { [weak self] ok in
|
|
6843
|
+
CraftNativeScriptThread.run {
|
|
6844
|
+
done.call(withArguments: [self?.runtimeDisposed != false ? false : ok])
|
|
6845
|
+
}
|
|
6796
6846
|
}
|
|
6797
6847
|
}
|
|
6798
6848
|
}
|
|
@@ -7576,10 +7626,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7576
7626
|
let edgeToEdge = scroll != nil && (prefersLargeTitle == true || scroll === leadingScroll?.view)
|
|
7577
7627
|
let fullEdgeToEdge = rootIsSafeAreaView
|
|
7578
7628
|
let topEdgeToEdge = fullEdgeToEdge || edgeToEdge
|
|
7629
|
+
// Only a leading vertical scroller that reaches the root's bottom
|
|
7630
|
+
// can paint beneath a visible tab bar. A bounded list, fixed footer,
|
|
7631
|
+
// plain screen or presented sheet must retain its safe-area bottom.
|
|
7632
|
+
// Use UIKit's automatic inset for tab/accessory clearance, rather
|
|
7633
|
+
// than adding a second explicit inset (also owned by keyboard code).
|
|
7634
|
+
var tabbedScroll = false
|
|
7635
|
+
if let scroll, scroll === leadingScroll?.view, scroll.bounds.height > 0,
|
|
7636
|
+
let tabBar = tabBarController?.tabBar, !tabBar.isHidden, tabBar.alpha > 0,
|
|
7637
|
+
let window = view.window, tabBar.window === window,
|
|
7638
|
+
tabBar.convert(tabBar.bounds, to: view).intersects(view.bounds) {
|
|
7639
|
+
let viewport = scroll.convert(scroll.bounds, to: rootStack)
|
|
7640
|
+
tabbedScroll = abs(viewport.maxY - rootStack.bounds.maxY) < 0.5
|
|
7641
|
+
}
|
|
7642
|
+
let bottomEdgeToEdge = fullEdgeToEdge || tabbedScroll
|
|
7579
7643
|
rootTopToSafeArea?.isActive = !topEdgeToEdge
|
|
7580
7644
|
rootTopToView?.isActive = topEdgeToEdge
|
|
7581
|
-
|
|
7582
|
-
|
|
7645
|
+
if bottomEdgeToEdge {
|
|
7646
|
+
rootBottomToSafeArea?.isActive = false
|
|
7647
|
+
rootBottomToView?.isActive = true
|
|
7648
|
+
} else {
|
|
7649
|
+
rootBottomToView?.isActive = false
|
|
7650
|
+
rootBottomToSafeArea?.isActive = true
|
|
7651
|
+
}
|
|
7583
7652
|
rootLeadingToSafeArea?.isActive = !fullEdgeToEdge
|
|
7584
7653
|
rootLeadingToView?.isActive = fullEdgeToEdge
|
|
7585
7654
|
rootTrailingToSafeArea?.isActive = !fullEdgeToEdge
|
|
@@ -7769,6 +7838,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7769
7838
|
if let keyboard = keyboardScreenFrame, let window = scroll.window {
|
|
7770
7839
|
let frame = scroll.convert(scroll.bounds, to: window)
|
|
7771
7840
|
overlap = max(0, frame.maxY - window.convert(keyboard, from: nil).minY)
|
|
7841
|
+
// An edge-to-edge scroller already receives the keyboard's
|
|
7842
|
+
// safe-area share through UIKit. Add only uncovered overlap.
|
|
7843
|
+
let automaticBottom = max(0, scroll.adjustedContentInset.bottom - scroll.contentInset.bottom)
|
|
7844
|
+
overlap = max(0, overlap - automaticBottom)
|
|
7772
7845
|
}
|
|
7773
7846
|
if abs(scroll.contentInset.bottom - overlap) > 0.5 { scroll.contentInset.bottom = overlap }
|
|
7774
7847
|
if abs(scroll.verticalScrollIndicatorInsets.bottom - overlap) > 0.5 { scroll.verticalScrollIndicatorInsets.bottom = overlap }
|
|
@@ -314,30 +314,44 @@ final class CraftNativeSounds {
|
|
|
314
314
|
private static var category: AVAudioSession.Category = .ambient
|
|
315
315
|
|
|
316
316
|
private var sounds: [String: Sound] = [:]
|
|
317
|
-
|
|
317
|
+
/// AVAudioSession belongs to the app, not to a screen. Observe for the
|
|
318
|
+
/// process lifetime once sounds are used, including gaps between screens.
|
|
319
|
+
/// Weak membership must not keep a disposed screen's service alive.
|
|
320
|
+
private static let services = NSHashTable<CraftNativeSounds>.weakObjects()
|
|
321
|
+
private static var sessionInterrupted = false
|
|
322
|
+
private static let interruptionObserver = NotificationCenter.default.addObserver(
|
|
323
|
+
forName: AVAudioSession.interruptionNotification,
|
|
324
|
+
object: nil,
|
|
325
|
+
queue: .main,
|
|
326
|
+
) { note in CraftNativeSounds.handleInterruption(note) }
|
|
318
327
|
/// `{ id, event: "ended" | "error", message? }` to the screen's script.
|
|
319
328
|
var emit: (([String: Any]) -> Void)?
|
|
320
329
|
|
|
321
330
|
init() {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
+
_ = Self.interruptionObserver
|
|
332
|
+
Self.services.add(self)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/// Sound services and their playback controls are used on main.
|
|
336
|
+
private static func handleInterruption(_ note: Notification) {
|
|
337
|
+
let type = (note.userInfo?[AVAudioSessionInterruptionTypeKey] as? NSNumber)?.uintValue
|
|
338
|
+
if type == AVAudioSession.InterruptionType.began.rawValue {
|
|
339
|
+
sessionInterrupted = true
|
|
340
|
+
services.allObjects.forEach { service in
|
|
341
|
+
service.sounds.values.forEach { sound in
|
|
331
342
|
guard !sound.paused else { return }
|
|
332
343
|
sound.interrupted = true
|
|
333
344
|
sound.player.pause()
|
|
334
345
|
}
|
|
335
|
-
return
|
|
336
346
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
guard type == AVAudioSession.InterruptionType.ended.rawValue else { return }
|
|
350
|
+
sessionInterrupted = false
|
|
351
|
+
let options = (note.userInfo?[AVAudioSessionInterruptionOptionKey] as? NSNumber)?.uintValue ?? 0
|
|
352
|
+
let shouldResume = options & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0
|
|
353
|
+
services.allObjects.forEach { service in
|
|
354
|
+
service.sounds.values.forEach { sound in
|
|
341
355
|
guard sound.interrupted else { return }
|
|
342
356
|
sound.interrupted = false
|
|
343
357
|
// Without permission to auto-resume, keep a resumable paused
|
|
@@ -348,10 +362,6 @@ final class CraftNativeSounds {
|
|
|
348
362
|
}
|
|
349
363
|
}
|
|
350
364
|
|
|
351
|
-
deinit {
|
|
352
|
-
if let interruptionObserver { NotificationCenter.default.removeObserver(interruptionObserver) }
|
|
353
|
-
}
|
|
354
|
-
|
|
355
365
|
/// Starts `url`. `answer` hears once it plays (or could not be loaded).
|
|
356
366
|
func play(id: String, url: URL, options: [String: Any], answer: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
357
367
|
stop(id: id, fadeOut: 0)
|
|
@@ -364,6 +374,7 @@ final class CraftNativeSounds {
|
|
|
364
374
|
let item = AVPlayerItem(url: url)
|
|
365
375
|
let player = AVQueuePlayer()
|
|
366
376
|
let sound = Sound(player: player, volume: volume)
|
|
377
|
+
sound.interrupted = Self.sessionInterrupted
|
|
367
378
|
sound.pending = answer
|
|
368
379
|
sounds[id] = sound
|
|
369
380
|
let failed: (String) -> Void = { [weak self, weak sound] message in
|
|
@@ -397,7 +408,7 @@ final class CraftNativeSounds {
|
|
|
397
408
|
}
|
|
398
409
|
}
|
|
399
410
|
player.volume = fadeIn > 0 ? 0 : volume
|
|
400
|
-
player.play()
|
|
411
|
+
if !Self.sessionInterrupted { player.play() }
|
|
401
412
|
if fadeIn > 0 { ramp(sound, to: volume, over: fadeIn) }
|
|
402
413
|
}
|
|
403
414
|
|
|
@@ -413,7 +424,7 @@ final class CraftNativeSounds {
|
|
|
413
424
|
|
|
414
425
|
@discardableResult
|
|
415
426
|
func resume(id: String) -> Bool {
|
|
416
|
-
guard let sound = sounds[id], sound.paused else { return false }
|
|
427
|
+
guard !Self.sessionInterrupted, let sound = sounds[id], sound.paused else { return false }
|
|
417
428
|
sound.paused = false
|
|
418
429
|
sound.interrupted = false
|
|
419
430
|
sound.player.volume = sound.volume
|