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.
Files changed (33) hide show
  1. package/dist/android/src/index.d.ts +3 -5
  2. package/dist/android/src/index.js +50 -7
  3. package/dist/android/templates/CraftBridge.kt.template +0 -90
  4. package/dist/android/templates/CraftNative.kt.template +3 -0
  5. package/dist/android/templates/CraftNativeBackground.kt.template +8 -2
  6. package/dist/android/templates/CraftNativeServices.kt.template +100 -1
  7. package/dist/android/templates/CraftNativeShell.kt.template +26 -8
  8. package/dist/android/templates/CraftPermissionPolicy.kt.template +96 -0
  9. package/dist/android/templates/MainActivityNative.kt.template +120 -6
  10. package/dist/android/templates/build.gradle.kts.app.template +1 -5
  11. package/dist/api/mobile.d.ts +9 -0
  12. package/dist/cli.js +63 -20
  13. package/dist/index.cjs +67 -20
  14. package/dist/index.d.cts +2 -2
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +67 -20
  17. package/dist/ios/src/index.d.ts +2 -5
  18. package/dist/ios/src/index.js +26 -1
  19. package/dist/ios/src/testing.d.ts +6 -0
  20. package/dist/ios/templates/CraftActivityAttributes.swift +29 -2
  21. package/dist/ios/templates/CraftApp.swift +45 -7
  22. package/dist/ios/templates/CraftHealth.swift +14 -0
  23. package/dist/ios/templates/CraftHybrid.swift +6 -0
  24. package/dist/ios/templates/CraftLiveActivities.swift +106 -2
  25. package/dist/ios/templates/CraftLiveActivityWidget.swift.template +1 -1
  26. package/dist/ios/templates/CraftNativeActions.swift +10 -1
  27. package/dist/ios/templates/CraftNativeScreen.swift +170 -9
  28. package/dist/ios/templates/CraftVoiceRecording.swift +75 -0
  29. package/dist/ios/templates/project.yml.template +6 -0
  30. package/dist/ios/templates/testing/DriverTests.swift.template +38 -0
  31. package/dist/package.d.ts +15 -4
  32. package/dist/types.d.ts +11 -4
  33. 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
- private func updateStickyHeaders() {
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
- alwaysBounceVertical = axis == .vertical
4624
- alwaysBounceHorizontal = axis == .horizontal
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 text = try? String(contentsOf: url, encoding: .utf8) else { return nil }
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
- var font = (style["fontFamily"] as? String).flatMap { UIFont(name: $0, size: scaledSize) }
9716
- ?? .systemFont(ofSize: scaledSize, weight: weight)
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
- * A server-backed app (a Bun program driving a Craft webview, say) spawns a
90
- * helper at runtime; bundle it here so the installed `.app` is
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.304",
3
+ "version": "0.0.328",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",