craft-native 0.0.487 → 0.0.491
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android/src/index.d.ts +28 -0
- package/dist/android/src/index.js +15 -0
- package/dist/android/templates/CraftNativeServices.kt.template +10 -1
- package/dist/android/templates/CraftNativeShell.kt.template +282 -8
- package/dist/android/templates/MainActivityNative.kt.template +166 -28
- package/dist/cli.js +6 -3
- package/dist/index.cjs +6 -3
- package/dist/index.js +6 -3
- package/dist/ios/src/index.d.ts +42 -1
- package/dist/ios/src/index.js +15 -0
- package/dist/ios/templates/CraftApp.swift +30 -0
- package/dist/ios/templates/CraftHybrid.swift +49 -0
- package/dist/ios/templates/CraftNativeActions.swift +9 -1
- package/dist/ios/templates/CraftNativeScreen.swift +213 -28
- package/dist/ios/templates/CraftNativeServices.swift +35 -0
- package/dist/ios/templates/CraftVoiceRecording.swift +17 -1
- package/dist/ios/templates/CraftWebAudio.swift +77 -12
- package/dist/ios/templates/testing/DriverTests.swift.template +31 -1
- package/dist/mobile.js +2 -2
- package/dist/package.d.ts +5 -0
- package/package.json +1 -1
|
@@ -12,6 +12,7 @@ import OSLog
|
|
|
12
12
|
import Security
|
|
13
13
|
import SwiftUI
|
|
14
14
|
import UIKit
|
|
15
|
+
import UIKit.UIGestureRecognizerSubclass
|
|
15
16
|
|
|
16
17
|
/// A menu belongs to its native view, not to coordinates in a web page.
|
|
17
18
|
final class CraftNativeContextMenu: NSObject, UIContextMenuInteractionDelegate {
|
|
@@ -5195,6 +5196,23 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
5195
5196
|
/// Native inspection of an SVG plot. Coordinates are fractions of the plot's
|
|
5196
5197
|
/// bounds; labels and values are already localized by the application. No JS
|
|
5197
5198
|
/// callback or bridge round trip is needed while a finger moves.
|
|
5199
|
+
/// Reports where each touch starts and then fails, so it observes a window
|
|
5200
|
+
/// without ever taking a touch from the view underneath.
|
|
5201
|
+
final class CraftTouchBeganRecognizer: UIGestureRecognizer {
|
|
5202
|
+
private let began: (CGPoint) -> Void
|
|
5203
|
+
init(began: @escaping (CGPoint) -> Void) {
|
|
5204
|
+
self.began = began
|
|
5205
|
+
super.init(target: nil, action: nil)
|
|
5206
|
+
cancelsTouchesInView = false
|
|
5207
|
+
delaysTouchesBegan = false
|
|
5208
|
+
delaysTouchesEnded = false
|
|
5209
|
+
}
|
|
5210
|
+
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
|
|
5211
|
+
state = .failed
|
|
5212
|
+
if let touch = touches.first { began(touch.location(in: view)) }
|
|
5213
|
+
}
|
|
5214
|
+
}
|
|
5215
|
+
|
|
5198
5216
|
final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
|
|
5199
5217
|
struct Reading {
|
|
5200
5218
|
let key: String
|
|
@@ -5214,6 +5232,7 @@ final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
|
|
|
5214
5232
|
private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:)))
|
|
5215
5233
|
private lazy var pan = UIPanGestureRecognizer(target: self, action: #selector(panned(_:)))
|
|
5216
5234
|
private lazy var hold = UILongPressGestureRecognizer(target: self, action: #selector(held(_:)))
|
|
5235
|
+
private var outside: CraftTouchBeganRecognizer?
|
|
5217
5236
|
|
|
5218
5237
|
init(view: UIView) {
|
|
5219
5238
|
self.view = view
|
|
@@ -5241,6 +5260,7 @@ final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
|
|
|
5241
5260
|
color: (point["color"] as? String).flatMap { UIColor(css: $0) } ?? .systemBlue, label: label, rows: rows)
|
|
5242
5261
|
}
|
|
5243
5262
|
selectedIndex = -1
|
|
5263
|
+
unwatchOutside()
|
|
5244
5264
|
view?.isUserInteractionEnabled = !readings.isEmpty
|
|
5245
5265
|
if readings.isEmpty { view?.accessibilityTraits.remove(.adjustable); view?.accessibilityValue = nil }
|
|
5246
5266
|
[tap, pan, hold].forEach { $0.isEnabled = !readings.isEmpty }
|
|
@@ -5270,6 +5290,7 @@ final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
|
|
|
5270
5290
|
}
|
|
5271
5291
|
|
|
5272
5292
|
@discardableResult func dismiss() -> Bool {
|
|
5293
|
+
unwatchOutside()
|
|
5273
5294
|
guard selectedIndex >= 0 else { return false }
|
|
5274
5295
|
selectedIndex = -1
|
|
5275
5296
|
configureAccessibility()
|
|
@@ -5277,20 +5298,47 @@ final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
|
|
|
5277
5298
|
return true
|
|
5278
5299
|
}
|
|
5279
5300
|
|
|
5280
|
-
private func
|
|
5281
|
-
guard let view, view.bounds.width > 0 else { return }
|
|
5301
|
+
private func nearest(at point: CGPoint) -> Int? {
|
|
5302
|
+
guard let view, view.bounds.width > 0 else { return nil }
|
|
5282
5303
|
let fraction = max(0, min(1, point.x / view.bounds.width))
|
|
5283
|
-
|
|
5304
|
+
return readings.indices.min(by: { abs(readings[$0].x - fraction) < abs(readings[$1].x - fraction) })
|
|
5305
|
+
}
|
|
5306
|
+
private func select(at point: CGPoint) {
|
|
5307
|
+
if let index = nearest(at: point) { select(index); watchOutside() }
|
|
5308
|
+
}
|
|
5309
|
+
/// A tap pins the reading under it. Tapping the pinned reading again puts
|
|
5310
|
+
/// it away, so a reading can always be closed where it was opened.
|
|
5311
|
+
func tap(at point: CGPoint) {
|
|
5312
|
+
guard let index = nearest(at: point) else { return }
|
|
5313
|
+
if index == selectedIndex { dismiss() } else { select(at: point) }
|
|
5314
|
+
}
|
|
5315
|
+
/// A drag or a hold reads the chart only while the finger is down.
|
|
5316
|
+
func scrub(_ state: UIGestureRecognizer.State, at point: CGPoint) {
|
|
5317
|
+
switch state {
|
|
5318
|
+
case .began, .changed: select(at: point)
|
|
5319
|
+
case .ended, .cancelled, .failed: dismiss()
|
|
5320
|
+
default: break
|
|
5321
|
+
}
|
|
5284
5322
|
}
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
|
|
5323
|
+
/// A touch that starts anywhere off the chart closes a pinned reading. The
|
|
5324
|
+
/// watcher never recognises, so the touch still reaches what it landed on.
|
|
5325
|
+
func touchBegan(inWindow point: CGPoint) {
|
|
5326
|
+
guard let view, let window = view.window else { return }
|
|
5327
|
+
if !view.bounds.contains(view.convert(point, from: window)) { dismiss() }
|
|
5289
5328
|
}
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5329
|
+
private func watchOutside() {
|
|
5330
|
+
guard outside == nil, let window = view?.window else { return }
|
|
5331
|
+
let watcher = CraftTouchBeganRecognizer { [weak self] point in self?.touchBegan(inWindow: point) }
|
|
5332
|
+
window.addGestureRecognizer(watcher)
|
|
5333
|
+
outside = watcher
|
|
5293
5334
|
}
|
|
5335
|
+
private func unwatchOutside() {
|
|
5336
|
+
if let outside { outside.view?.removeGestureRecognizer(outside) }
|
|
5337
|
+
outside = nil
|
|
5338
|
+
}
|
|
5339
|
+
@objc private func tapped(_ gesture: UITapGestureRecognizer) { tap(at: gesture.location(in: view)) }
|
|
5340
|
+
@objc private func panned(_ gesture: UIPanGestureRecognizer) { scrub(gesture.state, at: gesture.location(in: view)) }
|
|
5341
|
+
@objc private func held(_ gesture: UILongPressGestureRecognizer) { scrub(gesture.state, at: gesture.location(in: view)) }
|
|
5294
5342
|
func gestureRecognizerShouldBegin(_ gesture: UIGestureRecognizer) -> Bool {
|
|
5295
5343
|
guard gesture === pan else { return true }
|
|
5296
5344
|
let velocity = pan.velocity(in: view)
|
|
@@ -6002,9 +6050,23 @@ final class CraftNativeSolidSwitch: UIControl {
|
|
|
6002
6050
|
return CGRect(x: min(max(x, track.minX), track.maxX - rect.width - stretch),
|
|
6003
6051
|
y: rect.minY, width: rect.width + stretch, height: rect.height)
|
|
6004
6052
|
}
|
|
6053
|
+
/// How much wider than tall the thumb is drawn.
|
|
6054
|
+
///
|
|
6055
|
+
/// iOS 26 draws a wide pill where every system before it drew a circle,
|
|
6056
|
+
/// so a square thumb reads as a control from an older OS sitting among
|
|
6057
|
+
/// native ones. Measured on iOS 26.5 (iPhone 17 Pro simulator) by walking
|
|
6058
|
+
/// a real `UISwitch`: the control is 63x28 and its thumb 37x24, inset 2 on
|
|
6059
|
+
/// every side, travelling from x=2 to x=24.
|
|
6060
|
+
///
|
|
6061
|
+
/// Expressed against the thumb's own height rather than as a fixed size,
|
|
6062
|
+
/// so it keeps following whatever the real `UISwitch` reports — which is
|
|
6063
|
+
/// where `naturalSize` already comes from. At the system's own 24-point
|
|
6064
|
+
/// thumb height this is exactly 37.
|
|
6065
|
+
private static let thumbAspect: CGFloat = 37.0 / 24.0
|
|
6005
6066
|
private func thumbRect(at position: CGFloat) -> CGRect {
|
|
6006
6067
|
let track = trackRect.insetBy(dx: 2, dy: 2)
|
|
6007
|
-
|
|
6068
|
+
let width = min(track.width, track.height * Self.thumbAspect)
|
|
6069
|
+
return CGRect(x: track.minX + (track.width - width) * position, y: track.minY, width: width, height: track.height)
|
|
6008
6070
|
}
|
|
6009
6071
|
override func draw(_ rect: CGRect) {
|
|
6010
6072
|
let track = trackRect
|
|
@@ -6341,12 +6403,37 @@ private final class CraftNativeTextView: UITextView {
|
|
|
6341
6403
|
if !isScrollEnabled, contentOffset != .zero { contentOffset = .zero }
|
|
6342
6404
|
let inset = textContainerInset
|
|
6343
6405
|
let padding = textContainer.lineFragmentPadding
|
|
6344
|
-
|
|
6345
|
-
|
|
6346
|
-
|
|
6347
|
-
|
|
6348
|
-
|
|
6349
|
-
|
|
6406
|
+
let measured = placeholderLabel
|
|
6407
|
+
.sizeThatFits(CGSize(width: Self.placeholderWidth(bounds: bounds, inset: inset, padding: padding),
|
|
6408
|
+
height: .greatestFiniteMagnitude))
|
|
6409
|
+
placeholderLabel.frame = Self.placeholderFrame(bounds: bounds, inset: inset, padding: padding,
|
|
6410
|
+
fittedHeight: measured.height)
|
|
6411
|
+
}
|
|
6412
|
+
|
|
6413
|
+
static func placeholderWidth(bounds: CGRect, inset: UIEdgeInsets, padding: CGFloat) -> CGFloat {
|
|
6414
|
+
max(0, bounds.width - inset.left - inset.right - padding * 2)
|
|
6415
|
+
}
|
|
6416
|
+
|
|
6417
|
+
/// Where the placeholder sits inside the box.
|
|
6418
|
+
///
|
|
6419
|
+
/// Anchored at the top, and no taller than the text it holds. `UILabel`
|
|
6420
|
+
/// centres its text block vertically within its frame, while
|
|
6421
|
+
/// `UITextView` lays its glyphs out from the top, so a label handed the
|
|
6422
|
+
/// box's whole remaining height drew the placeholder of a four-row notes
|
|
6423
|
+
/// field halfway down it — nowhere near the caret that replaced it the
|
|
6424
|
+
/// moment the field was tapped (#501). The two rules only agreed when
|
|
6425
|
+
/// the box was exactly one line tall, which is why single-line fields
|
|
6426
|
+
/// looked right and every `textarea` did not. Given only the height its
|
|
6427
|
+
/// text needs, the label has no spare height to centre within.
|
|
6428
|
+
///
|
|
6429
|
+
/// Clamped to what is left of the box so that a placeholder longer than
|
|
6430
|
+
/// the field stays inside it rather than drawing past the bottom.
|
|
6431
|
+
static func placeholderFrame(bounds: CGRect, inset: UIEdgeInsets, padding: CGFloat, fittedHeight: CGFloat) -> CGRect {
|
|
6432
|
+
let available = max(0, bounds.height - inset.top - inset.bottom)
|
|
6433
|
+
return CGRect(x: inset.left + padding,
|
|
6434
|
+
y: inset.top,
|
|
6435
|
+
width: placeholderWidth(bounds: bounds, inset: inset, padding: padding),
|
|
6436
|
+
height: min(max(0, fittedHeight), available))
|
|
6350
6437
|
}
|
|
6351
6438
|
|
|
6352
6439
|
func refreshPlaceholder() {
|
|
@@ -7037,6 +7124,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7037
7124
|
|
|
7038
7125
|
override func viewWillDisappear(_ animated: Bool) {
|
|
7039
7126
|
super.viewWillDisappear(animated)
|
|
7127
|
+
// A screen does not take the keyboard with it. Its field is still
|
|
7128
|
+
// first responder at this point, and once the view leaves the window
|
|
7129
|
+
// the next screen's `window?.endEditing` no longer reaches it — the
|
|
7130
|
+
// keyboard then stays up with nothing able to dismiss it until the
|
|
7131
|
+
// app is backgrounded (#497). This is the one moment both facts hold:
|
|
7132
|
+
// the screen is going, and its view still owns the responder.
|
|
7133
|
+
//
|
|
7134
|
+
// `lastFocusedInput` is deliberately left alone. A screen merely
|
|
7135
|
+
// covered by a push comes back, and restoreFocus putting the person
|
|
7136
|
+
// back where they were is the behaviour that was already there.
|
|
7137
|
+
view.endEditing(true)
|
|
7040
7138
|
screenVisibilityChanged(false)
|
|
7041
7139
|
stopCelebrations(in: view)
|
|
7042
7140
|
// Release only this screen's accessory. A retained or prepared screen
|
|
@@ -7045,7 +7143,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7045
7143
|
CraftChrome.shared.releaseTabAccessory(ownedBy: self)
|
|
7046
7144
|
// Popped (or its stack dismissed) while the bar was away: it comes
|
|
7047
7145
|
// back for whatever shows next, a web page included.
|
|
7048
|
-
if
|
|
7146
|
+
if tabBarHiddenApplied, isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true {
|
|
7049
7147
|
CraftChrome.shared.setHiddenByScreen(false)
|
|
7050
7148
|
}
|
|
7051
7149
|
}
|
|
@@ -7087,7 +7185,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7087
7185
|
|
|
7088
7186
|
private func restoreShellChrome() {
|
|
7089
7187
|
guard !isAppScript else { return }
|
|
7090
|
-
|
|
7188
|
+
applyTabBarHidden()
|
|
7091
7189
|
// A screen not yet asked about the accessory (one shown for the first
|
|
7092
7190
|
// time, before its script has run) leaves the bar as it is; one that
|
|
7093
7191
|
// has drawn without asking for it takes off what nobody claims.
|
|
@@ -7183,6 +7281,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7183
7281
|
private func disposeRuntime() {
|
|
7184
7282
|
guard !runtimeDisposed else { return }
|
|
7185
7283
|
runtimeDisposed = true
|
|
7284
|
+
// Again here, and forgetting the input this time: a screen can be torn
|
|
7285
|
+
// down without ever appearing (one prepared and then replaced), so
|
|
7286
|
+
// viewWillDisappear is not guaranteed to have run. Nothing comes back
|
|
7287
|
+
// from here, so there is no focus worth restoring.
|
|
7288
|
+
//
|
|
7289
|
+
// `viewIfLoaded`, never `view`: this also runs from `deinit`, where
|
|
7290
|
+
// `view` is a `UIView!` that UIKit has already cleared — reading it
|
|
7291
|
+
// traps on the implicit unwrap, and a screen that never appeared
|
|
7292
|
+
// would instead have `loadView` resurrect `self` while it is being
|
|
7293
|
+
// deallocated. A screen with no loaded view has nothing focused.
|
|
7294
|
+
forgetFocusedInput()
|
|
7295
|
+
viewIfLoaded?.endEditing(true)
|
|
7186
7296
|
frameDelivery.close()
|
|
7187
7297
|
storageLifetime.close()
|
|
7188
7298
|
pendingCapabilityDeadlines.values.forEach { $0.cancel() }
|
|
@@ -7658,7 +7768,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7658
7768
|
/// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
|
|
7659
7769
|
/// `craft.secureStorage.set` wrote.
|
|
7660
7770
|
/// - `craft.navigation.setOptions({ title, titleColor, largeTitle, hidden,
|
|
7661
|
-
/// backTitle, tabBarHidden, tabItem, swipeBack,
|
|
7771
|
+
/// backTitle, tabBarHidden, tabBarHiddenWhenPushed, tabItem, swipeBack,
|
|
7772
|
+
/// contentSwipeBack,
|
|
7662
7773
|
/// tabSwipeEnabled, backgroundColor, navigationBarSolid, leftButtons,
|
|
7663
7774
|
/// rightButtons, tabAccessory })`; a button's tap is the event
|
|
7664
7775
|
/// `navButton`. `craft.d.ts` types no navigation surface, so this list
|
|
@@ -8125,6 +8236,32 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8125
8236
|
private var statusBarHiddenOverride: Bool?
|
|
8126
8237
|
/// The app's tab bar away while this screen shows (`tabBarHidden`).
|
|
8127
8238
|
private var tabBarHidden = false
|
|
8239
|
+
/// The bar away only once this screen is pushed onto something
|
|
8240
|
+
/// (`tabBarHiddenWhenPushed`): UIKit's `hidesBottomBarWhenPushed` idiom,
|
|
8241
|
+
/// for a screen that keeps the bar as its stack's root and drops it when
|
|
8242
|
+
/// it is a detail. Craft's tab bar is drawn by CraftChrome rather than a
|
|
8243
|
+
/// UITabBarController, so the depth is read here and the bar is asked
|
|
8244
|
+
/// directly; the Android shell has honoured the same key all along.
|
|
8245
|
+
private var tabBarHiddenWhenPushed = false
|
|
8246
|
+
/// What this screen last told the chrome. Teardown has to put back a bar
|
|
8247
|
+
/// this screen took away, and recomputing the answer there is no good:
|
|
8248
|
+
/// UIKit has already taken a popped screen out of `viewControllers` by
|
|
8249
|
+
/// `viewWillDisappear`, so a bar hidden for depth would read as never
|
|
8250
|
+
/// hidden at all and stay off for whatever shows next.
|
|
8251
|
+
private var tabBarHiddenApplied = false
|
|
8252
|
+
|
|
8253
|
+
/// Whether the bar should be away for this screen as it stands now.
|
|
8254
|
+
private var wantsTabBarHidden: Bool {
|
|
8255
|
+
if tabBarHidden { return true }
|
|
8256
|
+
guard tabBarHiddenWhenPushed, let stack = navigationController?.viewControllers else { return false }
|
|
8257
|
+
guard let depth = stack.firstIndex(where: { $0 === self }) else { return false }
|
|
8258
|
+
return depth > 0
|
|
8259
|
+
}
|
|
8260
|
+
|
|
8261
|
+
private func applyTabBarHidden() {
|
|
8262
|
+
tabBarHiddenApplied = wantsTabBarHidden
|
|
8263
|
+
CraftChrome.shared.setHiddenByScreen(tabBarHiddenApplied)
|
|
8264
|
+
}
|
|
8128
8265
|
private var tabAccessory: [String: Any]?
|
|
8129
8266
|
/// Whether the screen has said anything about the accessory yet
|
|
8130
8267
|
/// (`tabAccessory`, null included): until it has, the bar keeps showing
|
|
@@ -8210,8 +8347,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8210
8347
|
}
|
|
8211
8348
|
if let hidden = options["tabBarHidden"] as? Bool {
|
|
8212
8349
|
tabBarHidden = hidden
|
|
8350
|
+
}
|
|
8351
|
+
if let hiddenWhenPushed = options["tabBarHiddenWhenPushed"] as? Bool {
|
|
8352
|
+
tabBarHiddenWhenPushed = hiddenWhenPushed
|
|
8353
|
+
}
|
|
8354
|
+
if options.keys.contains("tabBarHidden") || options.keys.contains("tabBarHiddenWhenPushed") {
|
|
8355
|
+
// Prepared tabs and screens under a push keep the answer and apply
|
|
8356
|
+
// it when they are shown (restoreShellChrome); only the screen on
|
|
8357
|
+
// display may move the shared bar.
|
|
8213
8358
|
if navigationController?.topViewController === self, isShownOnScreen {
|
|
8214
|
-
|
|
8359
|
+
applyTabBarHidden()
|
|
8215
8360
|
}
|
|
8216
8361
|
}
|
|
8217
8362
|
if let swipeBack = options["swipeBack"] as? Bool {
|
|
@@ -8324,6 +8469,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8324
8469
|
|
|
8325
8470
|
/// A bar button from `{ id, symbol | title }`. Its tap is the event
|
|
8326
8471
|
/// `navButton` with `{ id }`.
|
|
8472
|
+
/// A bar button's choices, or nil when it has none worth a menu.
|
|
8473
|
+
static func navigationMenu(_ raw: Any?, chosen: @escaping (String) -> Void) -> UIMenu? {
|
|
8474
|
+
let actions: [UIAction] = ((raw as? [[String: Any]]) ?? []).prefix(12).compactMap { choice in
|
|
8475
|
+
guard let id = choice["id"] as? String, !id.isEmpty,
|
|
8476
|
+
let title = choice["title"] as? String, !title.isEmpty else { return nil }
|
|
8477
|
+
var attributes: UIMenuElement.Attributes = []
|
|
8478
|
+
if choice["disabled"] as? Bool == true { attributes.insert(.disabled) }
|
|
8479
|
+
if choice["destructive"] as? Bool == true { attributes.insert(.destructive) }
|
|
8480
|
+
let action = UIAction(title: title, image: (choice["symbol"] as? String).flatMap { UIImage(systemName: $0) },
|
|
8481
|
+
attributes: attributes) { _ in chosen(id) }
|
|
8482
|
+
action.accessibilityIdentifier = "nav-menu-\(id)"
|
|
8483
|
+
return action
|
|
8484
|
+
}
|
|
8485
|
+
return actions.isEmpty ? nil : UIMenu(children: actions)
|
|
8486
|
+
}
|
|
8487
|
+
|
|
8327
8488
|
private func navigationButton(_ spec: [String: Any]) -> UIBarButtonItem? {
|
|
8328
8489
|
guard let id = spec["id"] as? String, !id.isEmpty else { return nil }
|
|
8329
8490
|
let action = UIAction { [weak self] _ in
|
|
@@ -8332,11 +8493,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8332
8493
|
if spec["variant"] as? String == "accent" || spec["variant"] as? String == "account" {
|
|
8333
8494
|
return accountNavigationButton(spec, action: action)
|
|
8334
8495
|
}
|
|
8496
|
+
// `menu`: the button opens a native menu instead of sending its own
|
|
8497
|
+
// id, and each choice is reported as a press of that choice's id.
|
|
8498
|
+
let menu = Self.navigationMenu(spec["menu"]) { [weak self] choice in
|
|
8499
|
+
self?.send(type: "EVENT", payload: ["handlerName": "navButton", "nativeEvent": ["id": choice]])
|
|
8500
|
+
}
|
|
8335
8501
|
let item: UIBarButtonItem
|
|
8336
8502
|
if let symbol = spec["symbol"] as? String, let image = UIImage(systemName: symbol) {
|
|
8337
|
-
item = UIBarButtonItem(image: image, primaryAction: action)
|
|
8503
|
+
item = menu.map { UIBarButtonItem(image: image, menu: $0) } ?? UIBarButtonItem(image: image, primaryAction: action)
|
|
8338
8504
|
} else if let title = spec["title"] as? String {
|
|
8339
|
-
item = UIBarButtonItem(title: title, primaryAction: action)
|
|
8505
|
+
item = menu.map { UIBarButtonItem(title: title, menu: $0) } ?? UIBarButtonItem(title: title, primaryAction: action)
|
|
8340
8506
|
} else {
|
|
8341
8507
|
return nil
|
|
8342
8508
|
}
|
|
@@ -8927,14 +9093,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8927
9093
|
])
|
|
8928
9094
|
}
|
|
8929
9095
|
case "NAVIGATE", "NAVIGATE_REPLACE":
|
|
8930
|
-
|
|
8931
|
-
|
|
8932
|
-
|
|
8933
|
-
|
|
9096
|
+
guard let screen = payload["screen"] as? String, !screen.isEmpty else { return }
|
|
9097
|
+
// Replacing a path needs no stack of its own: it asks the shell to
|
|
9098
|
+
// show that path, which is why it delegates rather than pushing.
|
|
9099
|
+
// The app script has no stack, so this was refused to it along
|
|
9100
|
+
// with everything else, and its only way to redirect was to push
|
|
9101
|
+
// sign-in on top of the screen it meant to prevent (#499). The
|
|
9102
|
+
// gate in CraftHybrid.configure is the fix for launch; this is
|
|
9103
|
+
// what lets a session that ends mid-run replace rather than stack.
|
|
8934
9104
|
if type == "NAVIGATE_REPLACE", screen.hasPrefix("/") {
|
|
9105
|
+
guard isAppScript || navigationController?.topViewController === self else { return }
|
|
8935
9106
|
hybridEvents?.nativeScreen(self, replace: screen)
|
|
8936
9107
|
return
|
|
8937
9108
|
}
|
|
9109
|
+
// A screen's own stack; the app script has none (it opens paths).
|
|
9110
|
+
guard !isAppScript, navigationController?.topViewController === self,
|
|
9111
|
+
let navigation = navigationController else { return }
|
|
8938
9112
|
let params = payload["params"] as? [String: Any] ?? [:]
|
|
8939
9113
|
let next = CraftNativeScreenController(config: config, routeName: screen, routeParams: params)
|
|
8940
9114
|
next.hybridEvents = hybridEvents
|
|
@@ -9376,7 +9550,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9376
9550
|
previousNodesByProtocolID.removeAll(keepingCapacity: true)
|
|
9377
9551
|
adoptedPreviousProtocolIDs.removeAll(keepingCapacity: true)
|
|
9378
9552
|
indexPreviousNodes(previous)
|
|
9379
|
-
|
|
9553
|
+
// The app's configured body face seeds the inheritance chain, so a
|
|
9554
|
+
// screen's text is branded for the same reason its chrome is. Without
|
|
9555
|
+
// this the chain starts empty and `fontFamily` reached the navigation
|
|
9556
|
+
// bar, the tab labels and the bar buttons — all UIAppearance proxies —
|
|
9557
|
+
// while every label, button and field inside a screen stayed in the
|
|
9558
|
+
// system face (#500).
|
|
9559
|
+
//
|
|
9560
|
+
// A node that sets its own family still wins: the fill below only
|
|
9561
|
+
// applies where a node set none or `inherit`. Nil unless the app
|
|
9562
|
+
// configured one, which the same guard makes free.
|
|
9563
|
+
let next = reconcile(document, identity: "root", path: "root", previous: previous,
|
|
9564
|
+
inheritedFontFamily: CraftChromeFonts.body)
|
|
9380
9565
|
previousNodesByProtocolID.removeAll(keepingCapacity: true)
|
|
9381
9566
|
adoptedPreviousProtocolIDs.removeAll(keepingCapacity: true)
|
|
9382
9567
|
if previous?.view !== next.view {
|
|
@@ -324,11 +324,19 @@ final class CraftNativeSounds {
|
|
|
324
324
|
object: nil,
|
|
325
325
|
queue: .main,
|
|
326
326
|
) { note in CraftNativeSounds.handleInterruption(note) }
|
|
327
|
+
/// There is no interruption for the media server restarting, and it is
|
|
328
|
+
/// not recoverable the way an interruption is: see `handleMediaReset`.
|
|
329
|
+
private static let mediaResetObserver = NotificationCenter.default.addObserver(
|
|
330
|
+
forName: AVAudioSession.mediaServicesWereResetNotification,
|
|
331
|
+
object: nil,
|
|
332
|
+
queue: .main,
|
|
333
|
+
) { _ in CraftNativeSounds.handleMediaReset() }
|
|
327
334
|
/// `{ id, event: "ended" | "error", message? }` to the screen's script.
|
|
328
335
|
var emit: (([String: Any]) -> Void)?
|
|
329
336
|
|
|
330
337
|
init() {
|
|
331
338
|
_ = Self.interruptionObserver
|
|
339
|
+
_ = Self.mediaResetObserver
|
|
332
340
|
Self.services.add(self)
|
|
333
341
|
}
|
|
334
342
|
|
|
@@ -362,6 +370,33 @@ final class CraftNativeSounds {
|
|
|
362
370
|
}
|
|
363
371
|
}
|
|
364
372
|
|
|
373
|
+
/// The media server restarted, which orphans every audio object the
|
|
374
|
+
/// process holds. Apple's requirement is to discard them and make new
|
|
375
|
+
/// ones; an orphaned `AVQueuePlayer` never plays again, never reaches the
|
|
376
|
+
/// end of its item and so never reports anything. Without this, a sound
|
|
377
|
+
/// playing across a reset left the script a handle that was silent
|
|
378
|
+
/// forever, `pause`/`resume` answering `true` about nothing, and the
|
|
379
|
+
/// session claim never released — while Android reports the equivalent
|
|
380
|
+
/// permanent loss of audio focus as an `error` event and drops the sound.
|
|
381
|
+
///
|
|
382
|
+
/// A file player's playhead does not survive the reset, so the handle is
|
|
383
|
+
/// released and the loss reported rather than guessed at. Web Audio is
|
|
384
|
+
/// recovered instead (CraftWebAudio.swift): its graph is pure synthesis
|
|
385
|
+
/// the script is still driving, so a new engine continues it.
|
|
386
|
+
static let mediaResetMessage = "Audio stopped because the media services were reset"
|
|
387
|
+
|
|
388
|
+
/// Sound services and their playback controls are used on main.
|
|
389
|
+
private static func handleMediaReset() {
|
|
390
|
+
// The pre-reset interruption, if any, is gone with the server.
|
|
391
|
+
sessionInterrupted = false
|
|
392
|
+
for service in services.allObjects {
|
|
393
|
+
for id in Array(service.sounds.keys) {
|
|
394
|
+
service.remove(id, result: .failure(CraftNativeActionError(code: "AUDIO_ERROR", message: mediaResetMessage)))
|
|
395
|
+
service.emit?(["id": id, "event": "error", "message": mediaResetMessage])
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
365
400
|
/// Starts `url`. `answer` hears once it plays (or could not be loaded).
|
|
366
401
|
func play(id: String, url: URL, options: [String: Any], answer: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
367
402
|
stop(id: id, fadeOut: 0)
|
|
@@ -15,7 +15,12 @@ extension AVAudioRecorder: CraftVoiceRecorder {}
|
|
|
15
15
|
/// is starting, so a late permission/encoder answer cannot resurrect it.
|
|
16
16
|
final class CraftVoiceRecording {
|
|
17
17
|
static let shared = CraftVoiceRecording()
|
|
18
|
-
|
|
18
|
+
/// Deleting a recording somebody just cancelled is work they are waiting
|
|
19
|
+
/// on: until it is gone, what they decided not to send still exists. At
|
|
20
|
+
/// `.utility` a busy device could leave the removal queued for seconds
|
|
21
|
+
/// behind everything else (it missed a two-second bound on a loaded CI
|
|
22
|
+
/// simulator while every other step of the cancellation was on time).
|
|
23
|
+
private static let fileQueue = DispatchQueue(label: "craft.voice-recording-files", qos: .userInitiated)
|
|
19
24
|
private static let launchCleanup: Void = discardAbandonedRecordings(includeLegacy: false)
|
|
20
25
|
private static let legacyCleanup: Void = discardAbandonedRecordings()
|
|
21
26
|
private static let folderName = "voice-recordings"
|
|
@@ -106,7 +111,16 @@ final class CraftVoiceRecording {
|
|
|
106
111
|
private let startupTimeout: TimeInterval
|
|
107
112
|
private let stopTimeout: TimeInterval
|
|
108
113
|
struct FileRemovalReceipt: Encodable {
|
|
114
|
+
/// From deciding to delete to the block actually running: the trace,
|
|
115
|
+
/// the submission, and the wait for a thread.
|
|
109
116
|
let queueMilliseconds: Double
|
|
117
|
+
/// The first of those three alone. A run where `queueMilliseconds` is
|
|
118
|
+
/// seconds and this is near zero spent them waiting for GCD to admit
|
|
119
|
+
/// the block; one where this carries most of them spent them in
|
|
120
|
+
/// `trace`, which takes a process-wide lock and then logs
|
|
121
|
+
/// synchronously. Without it the two are indistinguishable, which is
|
|
122
|
+
/// where #490 ran out of evidence at 7638ms of queue for 3ms of work.
|
|
123
|
+
let submitMilliseconds: Double
|
|
110
124
|
let removalMilliseconds: Double
|
|
111
125
|
let ownedBefore: Bool
|
|
112
126
|
let removed: Bool
|
|
@@ -178,12 +192,14 @@ final class CraftVoiceRecording {
|
|
|
178
192
|
let queued = ProcessInfo.processInfo.systemUptime
|
|
179
193
|
let observed = fileRemovalObserved
|
|
180
194
|
Self.trace("cancel \(session.key.prefix(8)): file removal queued")
|
|
195
|
+
let submitted = ProcessInfo.processInfo.systemUptime
|
|
181
196
|
Self.fileQueue.async {
|
|
182
197
|
let began = ProcessInfo.processInfo.systemUptime
|
|
183
198
|
let owned = CraftNativeFiles.owned(file.path) != nil
|
|
184
199
|
let removed = CraftNativeFiles.remove(file.path)
|
|
185
200
|
let receipt = FileRemovalReceipt(
|
|
186
201
|
queueMilliseconds: (began - queued) * 1000,
|
|
202
|
+
submitMilliseconds: (submitted - queued) * 1000,
|
|
187
203
|
removalMilliseconds: (ProcessInfo.processInfo.systemUptime - began) * 1000,
|
|
188
204
|
ownedBefore: owned, removed: removed,
|
|
189
205
|
existsAfter: FileManager.default.fileExists(atPath: file.path))
|
|
@@ -1361,7 +1361,12 @@ final class CraftWebAudio {
|
|
|
1361
1361
|
private var engine: AVAudioEngine?
|
|
1362
1362
|
private var claimedSession = false
|
|
1363
1363
|
private var pump: Timer?
|
|
1364
|
+
/// Notifications about the audio session and the app, registered once and
|
|
1365
|
+
/// outliving any one engine.
|
|
1364
1366
|
private var observers: [NSObjectProtocol] = []
|
|
1367
|
+
/// And the one about this engine's configuration, which does not: a
|
|
1368
|
+
/// rebuilt engine is a different object (`rebuildEngine`).
|
|
1369
|
+
private var engineObserver: NSObjectProtocol?
|
|
1365
1370
|
private weak var script: JSContext?
|
|
1366
1371
|
|
|
1367
1372
|
init(live: Bool = true) {
|
|
@@ -1434,15 +1439,7 @@ final class CraftWebAudio {
|
|
|
1434
1439
|
contexts[id] = nil
|
|
1435
1440
|
return nil
|
|
1436
1441
|
}
|
|
1437
|
-
|
|
1438
|
-
let source = AVAudioSourceNode(format: format) { silence, _, frameCount, bufferList in
|
|
1439
|
-
let buffers = UnsafeMutableAudioBufferListPointer(bufferList)
|
|
1440
|
-
silence.pointee = ObjCBool(graph.render(Int(frameCount), into: buffers))
|
|
1441
|
-
return noErr
|
|
1442
|
-
}
|
|
1443
|
-
engine.attach(source)
|
|
1444
|
-
engine.connect(source, to: engine.mainMixerNode, format: format)
|
|
1445
|
-
context.source = source
|
|
1442
|
+
attachSource(context, format: format, to: self.engine ?? makeEngine())
|
|
1446
1443
|
refreshEngine()
|
|
1447
1444
|
}
|
|
1448
1445
|
startPump()
|
|
@@ -1482,25 +1479,84 @@ final class CraftWebAudio {
|
|
|
1482
1479
|
|
|
1483
1480
|
// MARK: Engine and session
|
|
1484
1481
|
|
|
1482
|
+
/// A context's graph reaches the engine through a source node. Attached
|
|
1483
|
+
/// when the context opens, and again on a fresh engine for every live
|
|
1484
|
+
/// context after the media server restarts.
|
|
1485
|
+
private func attachSource(_ context: Context, format: AVAudioFormat, to engine: AVAudioEngine) {
|
|
1486
|
+
let graph = context.graph
|
|
1487
|
+
let source = AVAudioSourceNode(format: format) { silence, _, frameCount, bufferList in
|
|
1488
|
+
let buffers = UnsafeMutableAudioBufferListPointer(bufferList)
|
|
1489
|
+
silence.pointee = ObjCBool(graph.render(Int(frameCount), into: buffers))
|
|
1490
|
+
return noErr
|
|
1491
|
+
}
|
|
1492
|
+
engine.attach(source)
|
|
1493
|
+
engine.connect(source, to: engine.mainMixerNode, format: format)
|
|
1494
|
+
context.source = source
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1485
1497
|
private func makeEngine() -> AVAudioEngine {
|
|
1486
1498
|
let engine = AVAudioEngine()
|
|
1487
1499
|
self.engine = engine
|
|
1488
1500
|
let center = NotificationCenter.default
|
|
1489
1501
|
// A route change (headphones) or an interruption (a call) stops the
|
|
1490
|
-
// engine; it starts again when the context still plays.
|
|
1491
|
-
|
|
1502
|
+
// engine; it starts again when the context still plays. This one is
|
|
1503
|
+
// scoped to the engine, so a rebuilt engine replaces it.
|
|
1504
|
+
if let engineObserver { center.removeObserver(engineObserver) }
|
|
1505
|
+
engineObserver = center.addObserver(forName: .AVAudioEngineConfigurationChange, object: engine, queue: .main) { [weak self] _ in
|
|
1492
1506
|
self?.refreshEngine()
|
|
1493
|
-
}
|
|
1507
|
+
}
|
|
1508
|
+
// The rest are about the session and the app, not this engine.
|
|
1509
|
+
guard observers.isEmpty else { return engine }
|
|
1494
1510
|
observers.append(center.addObserver(forName: AVAudioSession.interruptionNotification, object: nil, queue: .main) { [weak self] note in
|
|
1495
1511
|
let type = (note.userInfo?[AVAudioSessionInterruptionTypeKey] as? NSNumber)?.uintValue
|
|
1496
1512
|
if type == AVAudioSession.InterruptionType.ended.rawValue { self?.refreshEngine() }
|
|
1497
1513
|
})
|
|
1514
|
+
observers.append(center.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main) { [weak self] _ in
|
|
1515
|
+
self?.rebuildEngine()
|
|
1516
|
+
})
|
|
1498
1517
|
observers.append(center.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main) { [weak self] _ in
|
|
1499
1518
|
self?.refreshEngine()
|
|
1500
1519
|
})
|
|
1501
1520
|
return engine
|
|
1502
1521
|
}
|
|
1503
1522
|
|
|
1523
|
+
/// The media server restarted, which orphans every audio object the
|
|
1524
|
+
/// process holds — this engine and the source nodes attached to it. An
|
|
1525
|
+
/// orphaned engine can be told to start and still produce no sound, and
|
|
1526
|
+
/// no configuration-change notification is posted for it, so
|
|
1527
|
+
/// `refreshEngine` cannot recover from this. Apple's requirement is to
|
|
1528
|
+
/// discard the orphans and make new ones.
|
|
1529
|
+
///
|
|
1530
|
+
/// A context does survive, because `CraftAudioGraph` is pure synthesis
|
|
1531
|
+
/// holding no AVFoundation object: it is given a fresh source node on a
|
|
1532
|
+
/// fresh engine and keeps playing from where its own clock is. That is
|
|
1533
|
+
/// why this recovers where a file-backed sound reports the loss instead
|
|
1534
|
+
/// (`CraftNativeSounds.handleMediaReset`) — a player's playhead does not
|
|
1535
|
+
/// survive the reset, a synthesised graph does. Starting the new engine
|
|
1536
|
+
/// reactivates the session, so the claim this screen already holds does
|
|
1537
|
+
/// not need to be taken again.
|
|
1538
|
+
private func rebuildEngine() {
|
|
1539
|
+
guard live, let orphaned = engine else { return }
|
|
1540
|
+
for context in contexts.values {
|
|
1541
|
+
if let source = context.source {
|
|
1542
|
+
orphaned.disconnectNodeOutput(source)
|
|
1543
|
+
orphaned.detach(source)
|
|
1544
|
+
}
|
|
1545
|
+
context.source = nil
|
|
1546
|
+
}
|
|
1547
|
+
orphaned.stop()
|
|
1548
|
+
if let engineObserver { NotificationCenter.default.removeObserver(engineObserver) }
|
|
1549
|
+
engineObserver = nil
|
|
1550
|
+
engine = nil
|
|
1551
|
+
guard !contexts.isEmpty else { return }
|
|
1552
|
+
let engine = makeEngine()
|
|
1553
|
+
for context in contexts.values {
|
|
1554
|
+
guard let format = AVAudioFormat(standardFormatWithSampleRate: context.graph.sampleRate, channels: 2) else { continue }
|
|
1555
|
+
attachSource(context, format: format, to: engine)
|
|
1556
|
+
}
|
|
1557
|
+
refreshEngine()
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1504
1560
|
private func refreshEngine() {
|
|
1505
1561
|
guard live, let engine else { return }
|
|
1506
1562
|
let playing = contexts.values.contains { $0.running }
|
|
@@ -1522,6 +1578,8 @@ final class CraftWebAudio {
|
|
|
1522
1578
|
}
|
|
1523
1579
|
|
|
1524
1580
|
private func stopEngine() {
|
|
1581
|
+
if let engineObserver { NotificationCenter.default.removeObserver(engineObserver) }
|
|
1582
|
+
engineObserver = nil
|
|
1525
1583
|
observers.forEach(NotificationCenter.default.removeObserver)
|
|
1526
1584
|
observers.removeAll()
|
|
1527
1585
|
engine?.stop()
|
|
@@ -1714,6 +1772,13 @@ final class CraftWebAudio {
|
|
|
1714
1772
|
}
|
|
1715
1773
|
|
|
1716
1774
|
var contextIdsForTesting: [Int] { Array(contexts.keys).sorted() }
|
|
1775
|
+
/// The engine and a context's source node, so a test can see that a
|
|
1776
|
+
/// media-services reset replaced them rather than restarting orphans
|
|
1777
|
+
/// (`rebuildEngine`). Posting the notification cannot really orphan them,
|
|
1778
|
+
/// so identity is the only observable; the clock moving afterwards is
|
|
1779
|
+
/// what shows the replacements actually render.
|
|
1780
|
+
var engineForTesting: AVAudioEngine? { engine }
|
|
1781
|
+
func sourceForTesting(_ contextId: Int) -> AVAudioSourceNode? { contexts[contextId]?.source }
|
|
1717
1782
|
|
|
1718
1783
|
// MARK: Helpers
|
|
1719
1784
|
|