craft-native 0.0.304 → 0.0.328
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android/src/index.d.ts +3 -5
- package/dist/android/src/index.js +50 -7
- package/dist/android/templates/CraftBridge.kt.template +0 -90
- package/dist/android/templates/CraftNative.kt.template +3 -0
- package/dist/android/templates/CraftNativeBackground.kt.template +8 -2
- package/dist/android/templates/CraftNativeServices.kt.template +100 -1
- package/dist/android/templates/CraftNativeShell.kt.template +26 -8
- package/dist/android/templates/CraftPermissionPolicy.kt.template +96 -0
- package/dist/android/templates/MainActivityNative.kt.template +120 -6
- package/dist/android/templates/build.gradle.kts.app.template +1 -5
- package/dist/api/mobile.d.ts +9 -0
- package/dist/cli.js +63 -20
- package/dist/index.cjs +67 -20
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +67 -20
- package/dist/ios/src/index.d.ts +2 -5
- package/dist/ios/src/index.js +26 -1
- package/dist/ios/src/testing.d.ts +6 -0
- package/dist/ios/templates/CraftActivityAttributes.swift +29 -2
- package/dist/ios/templates/CraftApp.swift +45 -7
- package/dist/ios/templates/CraftHealth.swift +14 -0
- package/dist/ios/templates/CraftHybrid.swift +6 -0
- package/dist/ios/templates/CraftLiveActivities.swift +106 -2
- package/dist/ios/templates/CraftLiveActivityWidget.swift.template +1 -1
- package/dist/ios/templates/CraftNativeActions.swift +10 -1
- package/dist/ios/templates/CraftNativeScreen.swift +170 -9
- package/dist/ios/templates/CraftVoiceRecording.swift +75 -0
- package/dist/ios/templates/project.yml.template +6 -0
- package/dist/ios/templates/testing/DriverTests.swift.template +38 -0
- package/dist/package.d.ts +15 -4
- package/dist/types.d.ts +11 -4
- package/package.json +1 -1
|
@@ -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),
|
package/dist/package.d.ts
CHANGED
|
@@ -83,13 +83,17 @@ export interface PackageConfig {
|
|
|
83
83
|
applePassword?: string;
|
|
84
84
|
/** Team ID for notarization. Required by `notarytool` alongside `appleId`. */
|
|
85
85
|
teamId?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Native Craft provider for a server-backed app. Embeds a separately
|
|
88
|
+
* identified agent app so AppKit, Accessibility and MenuBarAgent resolve
|
|
89
|
+
* the GUI process to its own CFBundleExecutable.
|
|
90
|
+
*/
|
|
91
|
+
nativeRuntimePath?: string;
|
|
86
92
|
/**
|
|
87
93
|
* Extra executables copied into `Contents/MacOS/` beside the app binary.
|
|
88
94
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* self-contained. Resolve it relative to the running executable — on macOS
|
|
92
|
-
* the helper lands next to `process.execPath`.
|
|
95
|
+
* Use nativeRuntimePath for the GUI provider. This option is for non-GUI
|
|
96
|
+
* command line helpers, resolved beside process.execPath.
|
|
93
97
|
*/
|
|
94
98
|
additionalExecutables?: string[];
|
|
95
99
|
/**
|
|
@@ -173,6 +177,8 @@ export declare function formatPackagingCommandError(tool: string, code: number |
|
|
|
173
177
|
export declare function packageApp(config: PackageConfig): Promise<PackageResult[]>;
|
|
174
178
|
export interface MacOSBundleMetadata {
|
|
175
179
|
name: string;
|
|
180
|
+
/** Actual executable basename when it differs from the app's display name. */
|
|
181
|
+
executableName?: string;
|
|
176
182
|
version: string;
|
|
177
183
|
bundleId: string;
|
|
178
184
|
/** `CFBundleVersion`. Defaults to `version`. */
|
|
@@ -238,6 +244,11 @@ export declare function notarytoolArguments(opts: {
|
|
|
238
244
|
issuer: string;
|
|
239
245
|
};
|
|
240
246
|
}): string[];
|
|
247
|
+
/**
|
|
248
|
+
* Helper: Create macOS app bundle
|
|
249
|
+
*/
|
|
250
|
+
/** The native GUI agent's location, shared with desktop launchers. */
|
|
251
|
+
export declare function macOSRuntimeExecutablePath(appBundlePath: string): string;
|
|
241
252
|
export declare function dmgCapacityMegabytes(contentBytes: number): number;
|
|
242
253
|
export declare function dmgCreateArguments(opts: {
|
|
243
254
|
appBundlePath: string;
|
package/dist/types.d.ts
CHANGED
|
@@ -1189,9 +1189,7 @@ export interface CraftBridgeAPI {
|
|
|
1189
1189
|
menubar?: {
|
|
1190
1190
|
/** Native visibility requires macOS 27, a direct build and Accessibility.
|
|
1191
1191
|
* No automatic fallback to width-based hiding is performed. */
|
|
1192
|
-
init(options?:
|
|
1193
|
-
backend?: 'legacy' | 'nativeVisibility';
|
|
1194
|
-
}): Promise<void>;
|
|
1192
|
+
init(options?: MenubarInitOptions): Promise<void>;
|
|
1195
1193
|
destroy(): Promise<void>;
|
|
1196
1194
|
requestPermission(): Promise<void>;
|
|
1197
1195
|
collapse(): Promise<void>;
|
|
@@ -2373,6 +2371,13 @@ export interface CraftTimersAPI {
|
|
|
2373
2371
|
signal?: AbortSignal;
|
|
2374
2372
|
}): Promise<void>;
|
|
2375
2373
|
}
|
|
2374
|
+
/** Native grouping policy. Unselected application icons are always protected. */
|
|
2375
|
+
export interface MenubarInitOptions {
|
|
2376
|
+
backend?: 'legacy' | 'nativeVisibility';
|
|
2377
|
+
/** Defaults to true. False groups identifiable Apple controls by position
|
|
2378
|
+
* and accepts native suppression of Focus, Now Playing and privacy indicators. */
|
|
2379
|
+
preserveSystemItems?: boolean;
|
|
2380
|
+
}
|
|
2376
2381
|
/** Settled native menu-bar state; private visibility capability is explicit. */
|
|
2377
2382
|
export interface MenubarState {
|
|
2378
2383
|
collapsed: boolean;
|
|
@@ -2383,6 +2388,8 @@ export interface MenubarState {
|
|
|
2383
2388
|
authorized?: boolean;
|
|
2384
2389
|
busy?: boolean;
|
|
2385
2390
|
hiddenAppCount?: number;
|
|
2391
|
+
hiddenSystemItemCount?: number;
|
|
2392
|
+
preserveSystemItems?: boolean;
|
|
2386
2393
|
markerWidth?: number;
|
|
2387
|
-
error?: 'unavailable' | 'permissionRequired' | 'layoutUnavailable' | 'activationFailed' | 'activationTimeout' | null;
|
|
2394
|
+
error?: 'unavailable' | 'permissionRequired' | 'layoutUnavailable' | 'emptyGroup' | 'visibilityMismatch' | 'activationFailed' | 'activationTimeout' | null;
|
|
2388
2395
|
}
|