craft-native 0.0.304 → 0.0.319

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.
@@ -286,12 +286,12 @@ async function createAndroidEmulator(options) {
286
286
  if (options.acceptLicenses)
287
287
  await run([options.sdkmanager, `--sdk_root=${sdk}`, "--licenses"], `y
288
288
  `.repeat(100));
289
- await run([options.sdkmanager, `--sdk_root=${sdk}`, "platform-tools", "emulator"], options.acceptLicenses ? `y
289
+ await run([options.sdkmanager, `--sdk_root=${sdk}`, "--verbose", "platform-tools", "emulator"], options.acceptLicenses ? `y
290
290
  `.repeat(100) : undefined);
291
291
  const acceleration = await run([executable, "-accel-check"], undefined, 30000, true);
292
292
  if (options.acceleration === "hardware" && acceleration.code !== 0)
293
293
  throw new Error(`Android hardware acceleration is unavailable; inspect ${artifacts}`);
294
- await run([options.sdkmanager, `--sdk_root=${sdk}`, options.image], options.acceptLicenses ? `y
294
+ await run([options.sdkmanager, `--sdk_root=${sdk}`, "--verbose", options.image], options.acceptLicenses ? `y
295
295
  `.repeat(100) : undefined);
296
296
  const devices = (await run([adb, "devices"], undefined, 1e4)).stdout;
297
297
  if (devices.split(`
@@ -806,6 +806,10 @@ internal class CraftNativeChrome(
806
806
  private val density = resources.displayMetrics.density
807
807
  private var screenBackgroundColor: Int? = null
808
808
  private var selectedTab = 0
809
+ private var tabBarHiddenByScreen = false
810
+ private var visiblePath: String? = null
811
+ private val accessorySelected: Boolean
812
+ get() = accessorySpec?.optString("path")?.let { path -> visiblePath == path || visiblePath?.startsWith(path + "/") == true } == true
809
813
  private var swipeTracking = false
810
814
  private var swipeIntercepted = false
811
815
  private var horizontalScrollDeferred = false
@@ -969,7 +973,8 @@ internal class CraftNativeChrome(
969
973
  }
970
974
 
971
975
  /** Draws `options` (a screen's `setOptions`) for a screen `depth` deep in its stack. */
972
- fun apply(options: JSONObject, depth: Int, fallbackTitle: String?) {
976
+ fun apply(options: JSONObject, depth: Int, fallbackTitle: String?, path: String? = null) {
977
+ visiblePath = path
973
978
  screenBackgroundColor = options.opt("backgroundColor")
974
979
  ?.takeUnless { it == JSONObject.NULL }
975
980
  ?.let { craftNativeOptionalString(it) }
@@ -1004,7 +1009,19 @@ internal class CraftNativeChrome(
1004
1009
  renderedButtonSpecs = buttonSpecs
1005
1010
  }
1006
1011
  val tabBarHidden = options.optBoolean("tabBarHidden", false) || depth > 0 && options.optBoolean("tabBarHiddenWhenPushed", false)
1007
- tabRow.visibility = if (tabs.isEmpty() || tabBarHidden) View.GONE else View.VISIBLE
1012
+ if (tabBarHiddenByScreen != tabBarHidden) {
1013
+ tabBarHiddenByScreen = tabBarHidden
1014
+ tabRow.animate().cancel()
1015
+ val duration = if (android.animation.ValueAnimator.areAnimatorsEnabled()) 240L else 0L
1016
+ if (tabBarHidden) {
1017
+ tabRow.animate().translationY(dp(80).toFloat()).alpha(0f).setDuration(duration).withEndAction {
1018
+ if (tabBarHiddenByScreen) tabRow.visibility = View.GONE
1019
+ }.start()
1020
+ } else if (tabs.isNotEmpty()) {
1021
+ tabRow.visibility = View.VISIBLE
1022
+ tabRow.animate().translationY(0f).alpha(1f).setDuration(duration).start()
1023
+ }
1024
+ } else if (tabs.isEmpty()) tabRow.visibility = View.GONE
1008
1025
  accessorySpec = options.optJSONObject("tabAccessory")?.takeIf {
1009
1026
  val path = it.optString("path")
1010
1027
  path.startsWith("/") && !path.startsWith("//") && it.has("title")
@@ -1091,7 +1108,7 @@ internal class CraftNativeChrome(
1091
1108
  /** Selection belongs to the native tab stack, including retained pushed screens. */
1092
1109
  internal fun tabIconAppearance(index: Int, motionAllowed: Boolean): CraftTabIconAppearance {
1093
1110
  val tab = tabs[index]
1094
- val selected = index == selectedTab
1111
+ val selected = index == selectedTab && !accessorySelected
1095
1112
  return CraftTabIconAppearance(
1096
1113
  if (selected) selectedSymbols[tab.id] ?: tabSymbols[tab.id] ?: tab.symbol else tabSymbols[tab.id] ?: tab.symbol,
1097
1114
  if (selected) selectedIconTints[tab.id] ?: tabIconTints[tab.id] else tabIconTints[tab.id],
@@ -1114,12 +1131,13 @@ internal class CraftNativeChrome(
1114
1131
  backButton.imageTintList = ColorStateList.valueOf(accent)
1115
1132
  val surface = if (dark) Color.parseColor("#1C1C1E") else Color.parseColor("#F9F9F9")
1116
1133
  tabBar.background = GradientDrawable().apply { setColor(surface); cornerRadius = dp(32).toFloat() }
1117
- accessory.background = GradientDrawable().apply { setColor(surface); cornerRadius = dp(32).toFloat() }
1118
- accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), text)) }
1134
+ accessory.isSelected = accessorySelected
1135
+ accessory.background = GradientDrawable().apply { setColor(if (accessorySelected) Color.argb(36, Color.red(accent), Color.green(accent), Color.blue(accent)) else surface); cornerRadius = dp(32).toFloat() }
1136
+ accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), if (accessorySelected) accent else text)) }
1119
1137
  tabViews.forEachIndexed { index, (icon, label) ->
1120
- tabBar.getChildAt(index).isSelected = index == selectedTab
1138
+ tabBar.getChildAt(index).isSelected = index == selectedTab && !accessorySelected
1121
1139
  tabBar.getChildAt(index).background = tabCellBackground(index, dark)
1122
- val color = if (index == selectedTab) accent else muted
1140
+ val color = if (index == selectedTab && !accessorySelected) accent else muted
1123
1141
  val id = tabs[index].id
1124
1142
  val number = tabIconText[id]
1125
1143
  val appearance = tabIconAppearance(index, hasWindowFocus() && tabRow.isShown && android.animation.ValueAnimator.areAnimatorsEnabled())
@@ -1209,7 +1227,7 @@ internal class CraftNativeChrome(
1209
1227
  private fun accentColor(dark: Boolean): Int = if (dark) Color.parseColor("#0A84FF") else Color.parseColor("#007AFF")
1210
1228
 
1211
1229
  private fun tabCellBackground(index: Int, dark: Boolean): android.graphics.drawable.Drawable? {
1212
- if (index != selectedTab || (tabBarAppearance.selectionColor == null && tabBarAppearance.selectionCornerRadius == null)) return rippleBackground()
1230
+ if (accessorySelected || index != selectedTab || (tabBarAppearance.selectionColor == null && tabBarAppearance.selectionCornerRadius == null)) return rippleBackground()
1213
1231
  val color = tabBarAppearance.selectionColor?.let { runCatching { Color.parseColor(it) }.getOrNull() }
1214
1232
  ?: if (dark) Color.argb(42, 255, 255, 255) else Color.argb(23, 0, 0, 0)
1215
1233
  val radius = tabBarAppearance.selectionCornerRadius?.let { (it * density).toInt().toFloat() } ?: dp(32).toFloat()
@@ -864,6 +864,7 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
864
864
  super.onWindowVisibilityChanged(visibility)
865
865
  if (visibility != View.VISIBLE) celebration?.stop()
866
866
  }
867
+ var pageViewport: Pair<Int, Int>? = null
867
868
  var onLayoutChanged: (() -> Unit)? = null
868
869
  private val styles = mutableMapOf<View, CraftNativeLayoutStyle>()
869
870
  private var wrap = false
@@ -1839,6 +1840,12 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1839
1840
  }
1840
1841
 
1841
1842
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
1843
+ pageViewport?.let { (pageWidth, pageHeight) ->
1844
+ val pages = (0 until childCount).map { getChildAt(it) }.filter { it.visibility != View.GONE }
1845
+ pages.forEach { it.measure(MeasureSpec.makeMeasureSpec(pageWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(pageHeight, MeasureSpec.EXACTLY)) }
1846
+ setMeasuredDimension(pageWidth * pages.size, pageHeight)
1847
+ return
1848
+ }
1842
1849
  val unboundedHorizontal = unboundedWidth && orientation == HORIZONTAL
1843
1850
  val widthMode = if (unboundedHorizontal) View.MeasureSpec.UNSPECIFIED else View.MeasureSpec.getMode(widthMeasureSpec)
1844
1851
  widthDefinite = widthMode == View.MeasureSpec.EXACTLY
@@ -1875,6 +1882,12 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1875
1882
 
1876
1883
  override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
1877
1884
  celebration?.layout()
1885
+ pageViewport?.let { (pageWidth, pageHeight) ->
1886
+ val pages = (0 until childCount).map { getChildAt(it) }.filter { it.visibility != View.GONE }
1887
+ pages.forEachIndexed { index, child -> child.layout(index * pageWidth, 0, (index + 1) * pageWidth, pageHeight) }
1888
+ onLayoutChanged?.invoke()
1889
+ return
1890
+ }
1878
1891
  widthDefinite = true
1879
1892
  if (grid) {
1880
1893
  val layout = gridLayout(width, height)
@@ -2237,15 +2250,92 @@ private class CraftNativeKeyboardAvoidingView(context: Context) : FrameLayout(co
2237
2250
  }
2238
2251
  }
2239
2252
 
2240
- private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(context) {
2253
+ /** CSS family fallback order, using bundled app fonts before platform families. */
2254
+ internal fun craftNativeFontFamily(family: String?, bundledFonts: Map<String, Typeface> = emptyMap()): Typeface =
2255
+ family?.split(',')?.asSequence()?.map { it.trim().trim('"', '\'') }?.mapNotNull { name ->
2256
+ bundledFonts[name] ?: when (name.lowercase()) {
2257
+ "monospace", "ui-monospace" -> Typeface.MONOSPACE
2258
+ "serif", "ui-serif" -> Typeface.SERIF
2259
+ "sans-serif", "ui-sans-serif", "system-ui", "-apple-system" -> Typeface.SANS_SERIF
2260
+ else -> Typeface.create(name, Typeface.NORMAL).takeUnless { it == Typeface.DEFAULT }
2261
+ }
2262
+ }?.firstOrNull() ?: Typeface.DEFAULT
2263
+
2264
+ internal class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(context) {
2241
2265
  val content = CraftNativeFlexLayout(context)
2242
2266
  // Keep one refresh target across axis changes. Replacing all children of
2243
2267
  // SwipeRefreshLayout would also detach its own animated indicator.
2244
2268
  private val scrollContainer = FrameLayout(context)
2245
- private val vertical = ScrollView(context)
2246
- private val horizontal = HorizontalScrollView(context)
2269
+ private val vertical = object : ScrollView(context) {
2270
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean = isEnabled && super.onInterceptTouchEvent(event)
2271
+ override fun onTouchEvent(event: MotionEvent): Boolean = isEnabled && super.onTouchEvent(event)
2272
+ }
2273
+ private val horizontal = object : HorizontalScrollView(context) {
2274
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean = isEnabled && super.onInterceptTouchEvent(event)
2275
+ override fun onTouchEvent(event: MotionEvent): Boolean = isEnabled && super.onTouchEvent(event)
2276
+ }
2247
2277
  private var horizontalMode: Boolean? = null
2248
2278
  private var pagingEnabled = false
2279
+ private var authoredPageIndex: Int? = null
2280
+ private var pendingPageIndex: Int? = null
2281
+ private var currentPageIndex = 0
2282
+ private var reportedPage: Pair<Int, Int>? = null
2283
+ private var pageHandler: ((Int, Int) -> Unit)? = null
2284
+ private var pageSettleGeneration = 0
2285
+ private var pageDragging = false
2286
+ private var pageInMotion = false
2287
+
2288
+ private fun schedulePageSettle(scroller: View) {
2289
+ val generation = ++pageSettleGeneration
2290
+ scroller.postDelayed({
2291
+ if (generation == pageSettleGeneration && !pageDragging) {
2292
+ pageInMotion = false
2293
+ settlePage()
2294
+ }
2295
+ }, 80L)
2296
+ }
2297
+
2298
+ fun configurePager(value: Any?, onSelect: ((Int, Int) -> Unit)?) {
2299
+ val next = (value as? Number)?.toDouble()?.takeIf { it.isFinite() }?.coerceIn(0.0, (Int.MAX_VALUE / 2).toDouble())?.toInt()
2300
+ if (next != authoredPageIndex) pendingPageIndex = next
2301
+ authoredPageIndex = next
2302
+ pageHandler = onSelect
2303
+ if (next == null) { content.pageViewport = null; reportedPage = null }
2304
+ requestLayout()
2305
+ }
2306
+
2307
+ private fun settlePage() {
2308
+ if (authoredPageIndex == null || horizontal.width <= 0 || pageDragging || pageInMotion) return
2309
+ val pages = (0 until content.childCount).map { content.getChildAt(it) }.filter { it.visibility != View.GONE }
2310
+ val index = (horizontal.scrollX.toFloat() / horizontal.width).roundToInt().coerceIn(0, max(0, pages.size - 1))
2311
+ val target = index * horizontal.width
2312
+ if (horizontal.scrollX != target) {
2313
+ // ACTION_UP can start native motion after our touch listener's snap.
2314
+ // A rounded index is not settled until that viewport is fully shown.
2315
+ pageInMotion = true
2316
+ horizontal.smoothScrollTo(target, 0)
2317
+ schedulePageSettle(horizontal)
2318
+ return
2319
+ }
2320
+ currentPageIndex = index
2321
+ pages.forEachIndexed { i, page -> page.importantForAccessibility = if (i == index) View.IMPORTANT_FOR_ACCESSIBILITY_AUTO else View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS }
2322
+ val report = index to pages.size
2323
+ if (report != reportedPage) { reportedPage = report; pageHandler?.invoke(index, pages.size) }
2324
+ }
2325
+
2326
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
2327
+ val oldWidth = horizontal.width
2328
+ super.onLayout(changed, left, top, right, bottom)
2329
+ if (authoredPageIndex != null && horizontalMode == true && horizontal.width > 0) {
2330
+ val count = (0 until content.childCount).count { content.getChildAt(it).visibility != View.GONE }
2331
+ val index = (pendingPageIndex ?: currentPageIndex).coerceIn(0, max(0, count - 1))
2332
+ if (pendingPageIndex != null || oldWidth != horizontal.width || index != currentPageIndex) horizontal.scrollTo(index * horizontal.width, 0)
2333
+ pendingPageIndex = null
2334
+ currentPageIndex = index
2335
+ settlePage()
2336
+ }
2337
+ }
2338
+
2249
2339
  private var bounces = true
2250
2340
  private var alwaysBounceVertical = true
2251
2341
  private var alwaysBounceHorizontal = true
@@ -2273,6 +2363,10 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2273
2363
  // configured. Interception is gated below instead.
2274
2364
  isEnabled = true
2275
2365
  setHorizontal(false)
2366
+ // Paging is owned by this native control even before an Activity
2367
+ // supplies optional bridge callbacks. Its normal configuration may
2368
+ // replace these listeners without changing gesture ownership.
2369
+ setScrollListeners(null, null, null, null, null, "none", null)
2276
2370
  }
2277
2371
 
2278
2372
  override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
@@ -2301,6 +2395,7 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2301
2395
  val unspecified = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
2302
2396
  val horizontalPadding = paddingLeft + paddingRight
2303
2397
  val verticalPadding = paddingTop + paddingBottom
2398
+ content.pageViewport = if (authoredPageIndex != null && sideways) max(0, widthSize - horizontalPadding) to max(0, heightSize - verticalPadding) else null
2304
2399
  content.measure(
2305
2400
  if (sideways || widthMode == MeasureSpec.UNSPECIFIED) unspecified
2306
2401
  else MeasureSpec.makeMeasureSpec(max(0, widthSize - horizontalPadding), MeasureSpec.EXACTLY),
@@ -2316,7 +2411,7 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2316
2411
  ViewGroup.LayoutParams.WRAP_CONTENT,
2317
2412
  ViewGroup.LayoutParams.MATCH_PARENT,
2318
2413
  )
2319
- params.width = max(params.width, content.measuredWidth)
2414
+ params.width = if (authoredPageIndex != null) content.measuredWidth else max(params.width, content.measuredWidth)
2320
2415
  content.layoutParams = params
2321
2416
  }
2322
2417
  fun fit(mode: Int, size: Int, wanted: Int): Int = when (mode) {
@@ -2648,11 +2743,18 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2648
2743
 
2649
2744
  scroller.setOnScrollChangeListener { _, x, y, _, _ ->
2650
2745
  if (horizontalMode != true) updateStickyHeaders()
2746
+ if (authoredPageIndex != null) {
2747
+ pageInMotion = true
2748
+ schedulePageSettle(scroller)
2749
+ }
2651
2750
  onScroll?.invoke(x, y)
2652
2751
  }
2653
2752
  scroller.setOnTouchListener { _, event ->
2654
2753
  when (event.actionMasked) {
2655
2754
  android.view.MotionEvent.ACTION_DOWN -> {
2755
+ pageDragging = true
2756
+ pageInMotion = false
2757
+ pageSettleGeneration += 1
2656
2758
  if (momentumActive) {
2657
2759
  momentumActive = false
2658
2760
  onMomentumEnd?.invoke()
@@ -2674,6 +2776,8 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2674
2776
  }
2675
2777
  onEndDrag?.invoke()
2676
2778
  if (pagingEnabled) snapToPage(scroller)
2779
+ pageDragging = false
2780
+ if (authoredPageIndex != null) schedulePageSettle(scroller)
2677
2781
  if (moved && !momentumActive) {
2678
2782
  momentumActive = true
2679
2783
  lastX = scroller.scrollX
@@ -2692,6 +2796,10 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2692
2796
 
2693
2797
  fun clearScrollListeners() {
2694
2798
  scrollListenerGeneration += 1
2799
+ pageSettleGeneration += 1
2800
+ pageDragging = false
2801
+ pageInMotion = false
2802
+ pageHandler = null
2695
2803
  vertical.setOnScrollChangeListener(null)
2696
2804
  vertical.setOnTouchListener(null)
2697
2805
  horizontal.setOnScrollChangeListener(null)
@@ -4868,7 +4976,7 @@ class MainActivity : AppCompatActivity() {
4868
4976
  val bars = chrome ?: return
4869
4977
  val depth = screens.indexOf(screen).coerceAtLeast(0)
4870
4978
  val fallback = if (depth == 0) routes.tabs.getOrNull(currentTab)?.title else null
4871
- bars.apply(screen.options, depth, fallback)
4979
+ bars.apply(screen.options, depth, fallback, screen.path)
4872
4980
  }
4873
4981
 
4874
4982
  /** Applies a screen-specific background, falling back to the dynamic app colour. */
@@ -4932,6 +5040,7 @@ class MainActivity : AppCompatActivity() {
4932
5040
  put("viewReparent")
4933
5041
  put("viewNonModalPresentation")
4934
5042
  put("nativePlatformControls")
5043
+ put("nativePager")
4935
5044
  }
4936
5045
 
4937
5046
  private fun appVersion(): Pair<String, String> = runCatching {
@@ -6095,6 +6204,10 @@ class MainActivity : AppCompatActivity() {
6095
6204
  craftNativeBoolean(props.opt("alwaysBounceHorizontal"), horizontal),
6096
6205
  )
6097
6206
  setPagingEnabled(craftNativeBoolean(props.opt("pagingEnabled")))
6207
+ val pageHandler = craftNativeEventName(events, "onPageSelected").takeIf { it.isNotBlank() }
6208
+ configurePager(props.opt("pageIndex"), pageHandler?.let { handler ->
6209
+ { index, count -> send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject().put("index", index).put("count", count))) }
6210
+ })
6098
6211
  val refreshNode = (0 until children.length()).asSequence()
6099
6212
  .mapNotNull { children.optJSONObject(it) }
6100
6213
  .firstOrNull { canonicalType(it.optString("type")) == "RefreshControl" }
@@ -6916,7 +7029,7 @@ class MainActivity : AppCompatActivity() {
6916
7029
  }
6917
7030
  val italic = craftNativeOptionalString(style.opt("fontStyle")) == "italic"
6918
7031
  val family = craftNativeOptionalString(style.opt("fontFamily"))?.takeIf { it.isNotBlank() }
6919
- val base = family?.let { bundledFonts[it] } ?: Typeface.create(family, Typeface.NORMAL)
7032
+ val base = craftNativeFontFamily(family, bundledFonts)
6920
7033
  view.typeface = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
6921
7034
  Typeface.create(base, weight, italic)
6922
7035
  } else Typeface.create(base, (if (weight >= 600) Typeface.BOLD else Typeface.NORMAL) or
package/dist/cli.js CHANGED
@@ -262,6 +262,7 @@ async function packageMacOS(config, outDir) {
262
262
  iconPath: config.iconPath,
263
263
  provisioningProfile: opts.provisioningProfile,
264
264
  additionalExecutables: opts.additionalExecutables,
265
+ nativeRuntimePath: opts.nativeRuntimePath,
265
266
  outputPath: appBundlePath
266
267
  });
267
268
  if (!appBundle.success) {
@@ -543,7 +544,7 @@ ${schemes.length ? `MimeType=${schemes.map((scheme) => `x-scheme-handler/${schem
543
544
  }
544
545
  function macOSInfoPlist(metadata) {
545
546
  const entries = [
546
- ["CFBundleExecutable", metadata.name],
547
+ ["CFBundleExecutable", metadata.executableName || metadata.name],
547
548
  ["CFBundleIdentifier", metadata.bundleId],
548
549
  ["CFBundleName", metadata.name],
549
550
  ["CFBundleShortVersionString", metadata.version],
@@ -686,6 +687,9 @@ function copyMacOSAppBundle(source, destination) {
686
687
  return { success: false, error: error.message };
687
688
  }
688
689
  }
690
+ function macOSRuntimeExecutablePath(appBundlePath) {
691
+ return join2(appBundlePath, "Contents", "Helpers", "CraftRuntime.app", "Contents", "MacOS", "craft");
692
+ }
689
693
  function createMacOSAppBundle(opts) {
690
694
  try {
691
695
  const { name, binaryPath, iconPath, provisioningProfile, additionalExecutables, outputPath } = opts;
@@ -702,6 +706,27 @@ function createMacOSAppBundle(opts) {
702
706
  copyFileSync(executable, destination);
703
707
  chmodSync(destination, 493);
704
708
  }
709
+ if (opts.nativeRuntimePath) {
710
+ const helper = macOSRuntimeExecutablePath(outputPath);
711
+ const helperContents = resolve(helper, "..", "..");
712
+ mkdirSync(join2(helperContents, "MacOS"), { recursive: true });
713
+ copyFileSync(opts.nativeRuntimePath, helper);
714
+ chmodSync(helper, 493);
715
+ if (iconPath) {
716
+ mkdirSync(join2(helperContents, "Resources"), { recursive: true });
717
+ copyFileSync(iconPath, join2(helperContents, "Resources", "icon.icns"));
718
+ }
719
+ writeFileSync3(join2(helperContents, "Info.plist"), macOSInfoPlist({
720
+ name,
721
+ executableName: "craft",
722
+ version: opts.version,
723
+ bundleId: `${opts.bundleId}.runtime`,
724
+ buildNumber: opts.buildNumber,
725
+ minimumSystemVersion: opts.minimumSystemVersion,
726
+ menuBarOnly: true,
727
+ iconName: iconPath ? "icon" : undefined
728
+ }));
729
+ }
705
730
  let iconName;
706
731
  if (iconPath) {
707
732
  if (!existsSync4(iconPath))
@@ -2945,7 +2970,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
2945
2970
  `);
2946
2971
  }
2947
2972
  // package.json
2948
- var version = "0.0.304";
2973
+ var version = "0.0.319";
2949
2974
 
2950
2975
  // bin/cli.ts
2951
2976
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -158,6 +158,7 @@ __export(exports_src, {
158
158
  liveActivities: () => liveActivities,
159
159
  loadURL: () => loadURL,
160
160
  location: () => location,
161
+ macOSRuntimeExecutablePath: () => macOSRuntimeExecutablePath,
161
162
  materialYou: () => materialYou,
162
163
  media: () => media,
163
164
  menuItem: () => menuItem,
@@ -374,7 +375,7 @@ function craftBinaryNotFoundMessage(triedPath) {
374
375
  `);
375
376
  }
376
377
  // package.json
377
- var version = "0.0.304";
378
+ var version = "0.0.319";
378
379
 
379
380
  // src/sdk-version.ts
380
381
  var SDK_VERSION = version;
@@ -627,6 +628,7 @@ async function packageMacOS(config, outDir) {
627
628
  iconPath: config.iconPath,
628
629
  provisioningProfile: opts.provisioningProfile,
629
630
  additionalExecutables: opts.additionalExecutables,
631
+ nativeRuntimePath: opts.nativeRuntimePath,
630
632
  outputPath: appBundlePath
631
633
  });
632
634
  if (!appBundle.success) {
@@ -909,7 +911,7 @@ ${schemes.length ? `MimeType=${schemes.map((scheme) => `x-scheme-handler/${schem
909
911
  }
910
912
  function macOSInfoPlist(metadata) {
911
913
  const entries = [
912
- ["CFBundleExecutable", metadata.name],
914
+ ["CFBundleExecutable", metadata.executableName || metadata.name],
913
915
  ["CFBundleIdentifier", metadata.bundleId],
914
916
  ["CFBundleName", metadata.name],
915
917
  ["CFBundleShortVersionString", metadata.version],
@@ -1052,6 +1054,9 @@ function copyMacOSAppBundle(source, destination) {
1052
1054
  return { success: false, error: error.message };
1053
1055
  }
1054
1056
  }
1057
+ function macOSRuntimeExecutablePath(appBundlePath) {
1058
+ return import_path.join(appBundlePath, "Contents", "Helpers", "CraftRuntime.app", "Contents", "MacOS", "craft");
1059
+ }
1055
1060
  function createMacOSAppBundle(opts) {
1056
1061
  try {
1057
1062
  const { name, binaryPath, iconPath, provisioningProfile, additionalExecutables, outputPath } = opts;
@@ -1068,6 +1073,27 @@ function createMacOSAppBundle(opts) {
1068
1073
  import_fs.copyFileSync(executable, destination);
1069
1074
  import_fs.chmodSync(destination, 493);
1070
1075
  }
1076
+ if (opts.nativeRuntimePath) {
1077
+ const helper = macOSRuntimeExecutablePath(outputPath);
1078
+ const helperContents = import_path.resolve(helper, "..", "..");
1079
+ import_fs.mkdirSync(import_path.join(helperContents, "MacOS"), { recursive: true });
1080
+ import_fs.copyFileSync(opts.nativeRuntimePath, helper);
1081
+ import_fs.chmodSync(helper, 493);
1082
+ if (iconPath) {
1083
+ import_fs.mkdirSync(import_path.join(helperContents, "Resources"), { recursive: true });
1084
+ import_fs.copyFileSync(iconPath, import_path.join(helperContents, "Resources", "icon.icns"));
1085
+ }
1086
+ import_fs.writeFileSync(import_path.join(helperContents, "Info.plist"), macOSInfoPlist({
1087
+ name,
1088
+ executableName: "craft",
1089
+ version: opts.version,
1090
+ bundleId: `${opts.bundleId}.runtime`,
1091
+ buildNumber: opts.buildNumber,
1092
+ minimumSystemVersion: opts.minimumSystemVersion,
1093
+ menuBarOnly: true,
1094
+ iconName: iconPath ? "icon" : undefined
1095
+ }));
1096
+ }
1071
1097
  let iconName;
1072
1098
  if (iconPath) {
1073
1099
  if (!import_fs.existsSync(iconPath))
package/dist/index.d.cts CHANGED
@@ -24,7 +24,7 @@ export declare function parseCraftVersionOutput(output: string): string;
24
24
  * (#236).
25
25
  */
26
26
  export declare function craftProcessFailedMessage(code: number | null, stderr: string, quiet: boolean): string;
27
- export { packageApp, pack, type PackageConfig, type PackageResult } from './package.js';
27
+ export { packageApp, pack, macOSRuntimeExecutablePath, type PackageConfig, type PackageResult } from './package.js';
28
28
  export { craftBinaryNotFoundMessage, resolveCraftBinary } from './binary-resolver.js';
29
29
  export * from './utils/index.js';
30
30
  export * from './api/index.js';
@@ -85,4 +85,4 @@ export declare function show(html: string, options?: WindowOptions): Promise<voi
85
85
  * Quick helper to load a URL
86
86
  */
87
87
  export declare function loadURL(url: string, options?: WindowOptions): Promise<void>;
88
- export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, MenubarState, CraftTimersAPI, Permission, HapticType, CraftHapticsAPI, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftFsWatchEvent, CraftFsWatchHandle, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
88
+ export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, MenubarState, MenubarInitOptions, CraftTimersAPI, Permission, HapticType, CraftHapticsAPI, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftFsWatchEvent, CraftFsWatchHandle, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
package/dist/index.d.ts CHANGED
@@ -24,7 +24,7 @@ export declare function parseCraftVersionOutput(output: string): string;
24
24
  * (#236).
25
25
  */
26
26
  export declare function craftProcessFailedMessage(code: number | null, stderr: string, quiet: boolean): string;
27
- export { packageApp, pack, type PackageConfig, type PackageResult } from './package.js';
27
+ export { packageApp, pack, macOSRuntimeExecutablePath, type PackageConfig, type PackageResult } from './package.js';
28
28
  export { craftBinaryNotFoundMessage, resolveCraftBinary } from './binary-resolver.js';
29
29
  export * from './utils/index.js';
30
30
  export * from './api/index.js';
@@ -85,4 +85,4 @@ export declare function show(html: string, options?: WindowOptions): Promise<voi
85
85
  * Quick helper to load a URL
86
86
  */
87
87
  export declare function loadURL(url: string, options?: WindowOptions): Promise<void>;
88
- export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, MenubarState, CraftTimersAPI, Permission, HapticType, CraftHapticsAPI, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftFsWatchEvent, CraftFsWatchHandle, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
88
+ export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, MenubarState, MenubarInitOptions, CraftTimersAPI, Permission, HapticType, CraftHapticsAPI, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftFsWatchEvent, CraftFsWatchHandle, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.304";
120
+ var version = "0.0.319";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -384,6 +384,7 @@ async function packageMacOS(config, outDir) {
384
384
  iconPath: config.iconPath,
385
385
  provisioningProfile: opts.provisioningProfile,
386
386
  additionalExecutables: opts.additionalExecutables,
387
+ nativeRuntimePath: opts.nativeRuntimePath,
387
388
  outputPath: appBundlePath
388
389
  });
389
390
  if (!appBundle.success) {
@@ -666,7 +667,7 @@ ${schemes.length ? `MimeType=${schemes.map((scheme) => `x-scheme-handler/${schem
666
667
  }
667
668
  function macOSInfoPlist(metadata) {
668
669
  const entries = [
669
- ["CFBundleExecutable", metadata.name],
670
+ ["CFBundleExecutable", metadata.executableName || metadata.name],
670
671
  ["CFBundleIdentifier", metadata.bundleId],
671
672
  ["CFBundleName", metadata.name],
672
673
  ["CFBundleShortVersionString", metadata.version],
@@ -809,6 +810,9 @@ function copyMacOSAppBundle(source, destination) {
809
810
  return { success: false, error: error.message };
810
811
  }
811
812
  }
813
+ function macOSRuntimeExecutablePath(appBundlePath) {
814
+ return join2(appBundlePath, "Contents", "Helpers", "CraftRuntime.app", "Contents", "MacOS", "craft");
815
+ }
812
816
  function createMacOSAppBundle(opts) {
813
817
  try {
814
818
  const { name, binaryPath, iconPath, provisioningProfile, additionalExecutables, outputPath } = opts;
@@ -825,6 +829,27 @@ function createMacOSAppBundle(opts) {
825
829
  copyFileSync(executable, destination);
826
830
  chmodSync(destination, 493);
827
831
  }
832
+ if (opts.nativeRuntimePath) {
833
+ const helper = macOSRuntimeExecutablePath(outputPath);
834
+ const helperContents = resolve(helper, "..", "..");
835
+ mkdirSync(join2(helperContents, "MacOS"), { recursive: true });
836
+ copyFileSync(opts.nativeRuntimePath, helper);
837
+ chmodSync(helper, 493);
838
+ if (iconPath) {
839
+ mkdirSync(join2(helperContents, "Resources"), { recursive: true });
840
+ copyFileSync(iconPath, join2(helperContents, "Resources", "icon.icns"));
841
+ }
842
+ writeFileSync2(join2(helperContents, "Info.plist"), macOSInfoPlist({
843
+ name,
844
+ executableName: "craft",
845
+ version: opts.version,
846
+ bundleId: `${opts.bundleId}.runtime`,
847
+ buildNumber: opts.buildNumber,
848
+ minimumSystemVersion: opts.minimumSystemVersion,
849
+ menuBarOnly: true,
850
+ iconName: iconPath ? "icon" : undefined
851
+ }));
852
+ }
828
853
  let iconName;
829
854
  if (iconPath) {
830
855
  if (!existsSync3(iconPath))
@@ -11595,6 +11620,7 @@ export {
11595
11620
  liveActivities,
11596
11621
  loadURL,
11597
11622
  location,
11623
+ macOSRuntimeExecutablePath,
11598
11624
  materialYou,
11599
11625
  media,
11600
11626
  menuItem,
@@ -189,7 +189,12 @@ function serializeSimulatorSteps(steps) {
189
189
  };
190
190
  const line = (step) => {
191
191
  let arg = "";
192
- if ("paths" in step) {
192
+ if (step.command === "holdtab") {
193
+ const seconds = step.seconds ?? 6;
194
+ if (!step.text.trim() || step.text.includes("|") || number(seconds) < 2 || seconds > 10)
195
+ throw new Error("holdtab needs a nonempty tab name and 2\u201310 seconds");
196
+ arg = `${step.text} | ${seconds}`;
197
+ } else if ("paths" in step) {
193
198
  if (!step.paths.length || step.paths.length > 64)
194
199
  throw new Error("Expected 1\u201364 touch paths");
195
200
  for (const path of step.paths) {
@@ -61,6 +61,12 @@ export type SimulatorStep = {
61
61
  | {
62
62
  command: 'touches';
63
63
  paths: TimedTouchPoint[][];
64
+ }
65
+ /** Standard XCTest tab hold; independent screenshots must be captured before release. */
66
+ | {
67
+ command: 'holdtab';
68
+ text: string;
69
+ seconds?: number;
64
70
  } | {
65
71
  command: 'field';
66
72
  index: number;
@@ -766,6 +766,13 @@ final class CraftChrome: ObservableObject {
766
766
  @Published var tabAccessory: Tab?
767
767
  private weak var tabAccessoryOwner: AnyObject?
768
768
  @Published var selected: String?
769
+ /// The visible destination may be the accessory, independently of its owning stack.
770
+ @Published var visiblePath: String?
771
+ var accessorySelected: Bool {
772
+ guard let path = visiblePath, let accessory = tabAccessory?.id else { return false }
773
+ return path == accessory || path.hasPrefix(accessory + "/")
774
+ }
775
+ var highlightedTab: String? { accessorySelected ? nil : selected }
769
776
  let tabSwipeProgress = CraftTabSwipeProgress()
770
777
  private(set) var tabSwipePosition: CGFloat? {
771
778
  get { tabSwipeProgress.position }
@@ -1172,6 +1179,18 @@ final class CraftChromeRelay: NSObject, WKScriptMessageHandler {
1172
1179
 
1173
1180
  /// The floating tab bar: Liquid Glass on iOS 26 and later, a material capsule before.
1174
1181
  struct CraftTabBarView: View {
1182
+ @Environment(\.dynamicTypeSize) private var dynamicTypeSize
1183
+
1184
+ var body: some View {
1185
+ // ScaledMetric reads the environment of its owning view. Put the
1186
+ // metrics in a child so this compact-chrome cap reaches them too.
1187
+ CraftTabBarContent(viewerTextSize: dynamicTypeSize)
1188
+ .dynamicTypeSize(...DynamicTypeSize.xxxLarge)
1189
+ }
1190
+ }
1191
+
1192
+ private struct CraftTabBarContent: View {
1193
+ let viewerTextSize: DynamicTypeSize
1175
1194
  @ObservedObject private var chrome = CraftChrome.shared
1176
1195
  @Environment(\.accessibilityReduceMotion) private var reduceMotion
1177
1196
  @Environment(\.scenePhase) private var scenePhase
@@ -1183,7 +1202,7 @@ struct CraftTabBarView: View {
1183
1202
  @ScaledMetric(relativeTo: .caption2) private var badgeSize: CGFloat = 10
1184
1203
 
1185
1204
  var body: some View {
1186
- if chrome.tabBarVisible && !chrome.tabs.isEmpty {
1205
+ if !chrome.tabs.isEmpty {
1187
1206
  HStack(spacing: 8) {
1188
1207
  bar
1189
1208
  if let accessory = chrome.tabAccessory {
@@ -1191,8 +1210,13 @@ struct CraftTabBarView: View {
1191
1210
  surface {
1192
1211
  Image(systemName: accessory.symbol)
1193
1212
  .font(.system(size: iconSize))
1194
- .foregroundStyle(Color.primary)
1213
+ .foregroundStyle(chrome.accessorySelected ? selectedColor : Color.primary)
1195
1214
  .frame(width: 62, height: 62)
1215
+ .background {
1216
+ if chrome.accessorySelected {
1217
+ Circle().fill(selectedColor.opacity(0.14)).padding(4)
1218
+ }
1219
+ }
1196
1220
  }
1197
1221
  .contentShape(Rectangle())
1198
1222
  .overlay(alignment: .topTrailing) {
@@ -1207,9 +1231,17 @@ struct CraftTabBarView: View {
1207
1231
  }
1208
1232
  }
1209
1233
  .buttonStyle(.plain)
1234
+ .accessibilityShowsLargeContentViewer {
1235
+ Image(systemName: accessory.symbol)
1236
+ Text(accessory.accessibilityLabel ?? accessory.title)
1237
+ }
1238
+ // Keep accessibility intent for the viewer; displayed
1239
+ // sizes come from the bounded metrics owned above.
1240
+ .environment(\.dynamicTypeSize, viewerTextSize)
1210
1241
  .accessibilityLabel(accessory.accessibilityLabel ?? accessory.title)
1211
1242
  .accessibilityValue(accessory.badge ?? "")
1212
1243
  .accessibilityIdentifier("craft-tab-accessory")
1244
+ .accessibilityAddTraits(chrome.accessorySelected ? .isSelected : [])
1213
1245
  }
1214
1246
  }
1215
1247
  .padding(.horizontal, 16)
@@ -1219,9 +1251,14 @@ struct CraftTabBarView: View {
1219
1251
  .onAppear { report(proxy) }
1220
1252
  .onChange(of: proxy.frame(in: .global)) { _ in report(proxy) }
1221
1253
  })
1222
- .transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
1254
+ // Keep the view mounted: a disappearing conditional inside a
1255
+ // child does not reliably animate in the UIKit navigation transaction.
1256
+ .offset(y: chrome.tabBarVisible || reduceMotion ? 0 : 120)
1257
+ .opacity(chrome.tabBarVisible ? 1 : 0)
1258
+ .allowsHitTesting(chrome.tabBarVisible)
1259
+ .accessibilityHidden(!chrome.tabBarVisible)
1260
+ .animation(.easeInOut(duration: reduceMotion ? 0.12 : 0.24), value: chrome.tabBarVisible)
1223
1261
  .ignoresSafeArea(.keyboard)
1224
- .dynamicTypeSize(...DynamicTypeSize.xxxLarge)
1225
1262
  }
1226
1263
  }
1227
1264
 
@@ -1260,7 +1297,7 @@ struct CraftTabBarView: View {
1260
1297
  private var items: some View {
1261
1298
  HStack(spacing: 0) {
1262
1299
  ForEach(chrome.tabs) { tab in
1263
- let isSelected = tab.id == chrome.selected
1300
+ let isSelected = tab.id == chrome.highlightedTab
1264
1301
  Button { chrome.tap(tab) } label: {
1265
1302
  VStack(spacing: 3) {
1266
1303
  ZStack(alignment: .topTrailing) {
@@ -1305,6 +1342,7 @@ struct CraftTabBarView: View {
1305
1342
  Image(systemName: symbol(tab.symbol, selected: isSelected))
1306
1343
  Text(tab.displayTitle)
1307
1344
  }
1345
+ .environment(\.dynamicTypeSize, viewerTextSize)
1308
1346
  .accessibilityIdentifier("craft-tab-\(tab.id)")
1309
1347
  .accessibilityLabel(tab.accessibilityLabel ?? tab.badge.map { "\(tab.displayTitle), \($0)" } ?? tab.displayTitle)
1310
1348
  .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
@@ -1312,7 +1350,7 @@ struct CraftTabBarView: View {
1312
1350
  }
1313
1351
  .background {
1314
1352
  CraftTabSelectionIndicator(
1315
- selected: chrome.tabs.firstIndex(where: { $0.id == chrome.selected }),
1353
+ selected: chrome.tabs.firstIndex(where: { $0.id == chrome.highlightedTab }),
1316
1354
  count: chrome.tabs.count,
1317
1355
  cornerRadius: chrome.tabSelectionCornerRadius
1318
1356
  )
@@ -1664,6 +1664,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1664
1664
  (nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
1665
1665
  }
1666
1666
  currentTab = id
1667
+ if let top = nav.topViewController { CraftChrome.shared.visiblePath = nativePaths[ObjectIdentifier(top)] }
1667
1668
  for (key, other) in stacks { other.view.isHidden = key != id }
1668
1669
  nav.view.frame = view.bounds
1669
1670
  view.bringSubviewToFront(nav.view)
@@ -2042,6 +2043,9 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
2042
2043
  func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
2043
2044
  lastNavigation = CACurrentMediaTime()
2044
2045
  if animated { CraftFramePacing.shared.begin("navigation") }
2046
+ if navigationController === visibleStack {
2047
+ CraftChrome.shared.visiblePath = nativePaths[ObjectIdentifier(viewController)]
2048
+ }
2045
2049
  // The page draws its own header, and a tab's root screen its own
2046
2050
  // title unless it asked for the bar; a pushed native screen gets the
2047
2051
  // bar and its Back.
@@ -2084,6 +2088,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
2084
2088
  hybrid.pageReset(to: rootPath)
2085
2089
  }
2086
2090
  guard navigationController === visibleStack else { return }
2091
+ // Also restore selection after a cancelled interactive pop.
2092
+ CraftChrome.shared.visiblePath = nativePaths[ObjectIdentifier(viewController)]
2087
2093
  if let slot = viewController as? CraftHybridWebSlot { adoptWeb(into: slot) } else { parkWeb() }
2088
2094
  }
2089
2095
 
@@ -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")
@@ -2744,6 +2744,7 @@ final class CraftNativeCelebration {
2744
2744
  }
2745
2745
 
2746
2746
  private class CraftNativeFlowView: UIStackView {
2747
+ var pageViewport: CGSize? { didSet { if pageViewport != oldValue { invalidateMeasurements() } } }
2747
2748
  var celebration: CraftNativeCelebration?
2748
2749
  func configureCelebration(_ raw: Any?) {
2749
2750
  if raw != nil && celebration == nil { celebration = CraftNativeCelebration(view: self) }
@@ -2966,6 +2967,7 @@ private class CraftNativeFlowView: UIStackView {
2966
2967
  }
2967
2968
 
2968
2969
  func measure(width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
2970
+ if let page = pageViewport { return CGSize(width: page.width * CGFloat(arrangedSubviews.filter { !$0.isHidden }.count), height: page.height) }
2969
2971
  let key = MeasureKey(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight)
2970
2972
  if let cached = measurements[key] { return cached }
2971
2973
  let size = layout(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight, place: false).size
@@ -2977,6 +2979,12 @@ private class CraftNativeFlowView: UIStackView {
2977
2979
  super.layoutSubviews()
2978
2980
  craftApplyCornerRadius()
2979
2981
  celebration?.layout()
2982
+ if let page = pageViewport {
2983
+ for (index, child) in arrangedSubviews.filter({ !$0.isHidden }).enumerated() {
2984
+ child.frame = CGRect(x: CGFloat(index) * page.width, y: 0, width: page.width, height: page.height)
2985
+ }
2986
+ return
2987
+ }
2980
2988
  let scale = window?.screen.scale ?? traitCollection.displayScale
2981
2989
  let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
2982
2990
  var moved = false
@@ -4344,6 +4352,46 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4344
4352
  private var laidOutSize: CGSize = .zero
4345
4353
  private let pullToRefresh = UIRefreshControl()
4346
4354
  private var refreshHandler: (() -> Void)?
4355
+ private var authoredPageIndex: Int?
4356
+ private var pendingPageIndex: Int?
4357
+ private var currentPageIndex = 0
4358
+ private var pageAnimating = false
4359
+ private var reportedPage: (Int, Int)?
4360
+ private var pageHandler: ((Int, Int) -> Void)?
4361
+
4362
+ /// Opt-in viewport-sized pages. Ordinary horizontal scroll strips retain
4363
+ /// their intrinsic widths; only a controlled pageIndex enables this mode.
4364
+ func configurePager(_ raw: Any?, onSelect: ((Int, Int) -> Void)?) {
4365
+ let next = craftNativeNumber(raw).flatMap { number -> Int? in
4366
+ let value = number.doubleValue
4367
+ return value.isFinite ? Int(min(Double(Int.max / 2), max(0, value))) : nil
4368
+ }
4369
+ if next != authoredPageIndex { pendingPageIndex = next; contentNeedsLayout = true }
4370
+ authoredPageIndex = next
4371
+ pageHandler = onSelect
4372
+ if next == nil { contentStack.pageViewport = nil; reportedPage = nil; pageAnimating = false }
4373
+ setNeedsLayout()
4374
+ }
4375
+
4376
+ func beginPageDrag() { pageAnimating = false }
4377
+
4378
+ func finishPageMotion() {
4379
+ pageAnimating = false
4380
+ DispatchQueue.main.async { [weak self] in self?.settlePage() }
4381
+ }
4382
+
4383
+ func settlePage() {
4384
+ guard authoredPageIndex != nil, bounds.width > 0, !pageAnimating,
4385
+ !isTracking, !isDragging, !isDecelerating else { return }
4386
+ let pages = contentStack.arrangedSubviews.filter { !$0.isHidden }
4387
+ let index = min(max(0, Int((contentOffset.x / bounds.width).rounded())), max(0, pages.count - 1))
4388
+ currentPageIndex = index
4389
+ for (i, child) in pages.enumerated() { child.accessibilityElementsHidden = i != index }
4390
+ if reportedPage?.0 != index || reportedPage?.1 != pages.count {
4391
+ reportedPage = (index, pages.count)
4392
+ pageHandler?(index, pages.count)
4393
+ }
4394
+ }
4347
4395
 
4348
4396
  override init(frame: CGRect) {
4349
4397
  super.init(frame: frame)
@@ -4384,6 +4432,11 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4384
4432
  /// The size of the content laid out across the visible width (or, sideways,
4385
4433
  /// height), which is also the scroll view's own size when nothing fixes it.
4386
4434
  func contentFitting(width: CGFloat?, height: CGFloat?) -> CGSize {
4435
+ if authoredPageIndex != nil && !isVertical {
4436
+ let pageWidth = max(0, width ?? bounds.width)
4437
+ let pageHeight = max(0, height ?? bounds.height)
4438
+ return CGSize(width: pageWidth * CGFloat(contentStack.arrangedSubviews.filter { !$0.isHidden }.count), height: pageHeight)
4439
+ }
4387
4440
  if isVertical {
4388
4441
  let contentWidth = max(0, (width ?? bounds.width) - adjustedContentInset.left - adjustedContentInset.right)
4389
4442
  return contentStack.measure(width: contentWidth, height: nil, maxWidth: contentWidth, maxHeight: CraftNativeFlexLayout.unbounded)
@@ -4406,7 +4459,9 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4406
4459
  craftApplyCornerRadius()
4407
4460
  if contentNeedsLayout || laidOutSize != bounds.size {
4408
4461
  contentNeedsLayout = false
4462
+ let resized = laidOutSize != bounds.size
4409
4463
  laidOutSize = bounds.size
4464
+ if authoredPageIndex != nil && !isVertical { contentStack.pageViewport = bounds.size }
4410
4465
  var size = contentFitting(width: bounds.width, height: bounds.height)
4411
4466
  if isVertical {
4412
4467
  size.width = max(0, bounds.width - adjustedContentInset.left - adjustedContentInset.right)
@@ -4416,12 +4471,31 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4416
4471
  let frame = CGRect(origin: .zero, size: size)
4417
4472
  if contentStack.frame != frame { contentStack.frame = frame }
4418
4473
  if contentSize != size { contentSize = size }
4474
+ if authoredPageIndex != nil && !isVertical {
4475
+ contentStack.layoutIfNeeded()
4476
+ let count = contentStack.arrangedSubviews.filter { !$0.isHidden }.count
4477
+ let index = min(max(0, pendingPageIndex ?? currentPageIndex), max(0, count - 1))
4478
+ let target = CGPoint(x: CGFloat(index) * bounds.width, y: 0)
4479
+ let moves = pendingPageIndex != nil || resized || index != currentPageIndex
4480
+ let animated = moves && target != contentOffset && !resized && !UIAccessibility.isReduceMotionEnabled
4481
+ if moves {
4482
+ pageAnimating = animated
4483
+ setContentOffset(target, animated: animated)
4484
+ }
4485
+ pendingPageIndex = nil
4486
+ currentPageIndex = index
4487
+ if !animated && !isTracking && !isDragging && !isDecelerating { settlePage() }
4488
+ }
4419
4489
  }
4420
4490
  // A scroll view lays out on every frame of a scroll: the sticky
4421
4491
  // headers follow it here, over content placed by this same pass.
4422
4492
  if !stickyIndices.isEmpty || pendingScrollTarget != nil { contentStack.layoutIfNeeded() }
4423
4493
  updateStickyHeaders()
4424
4494
  scrollToPendingTarget()
4495
+ // A nonanimated target changes the offset synchronously inside this
4496
+ // layout pass. Rebase the headers against that actual viewport, not
4497
+ // the viewport from before the target was applied.
4498
+ updateStickyHeaders()
4425
4499
  }
4426
4500
 
4427
4501
  // MARK: Sticky headers
@@ -4444,7 +4518,7 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4444
4518
  view.center.y - view.bounds.height / 2
4445
4519
  }
4446
4520
 
4447
- private func updateStickyHeaders() {
4521
+ fileprivate func updateStickyHeaders() {
4448
4522
  let children = contentStack.arrangedSubviews
4449
4523
  let headers = isVertical ? stickyIndices.compactMap { $0 < children.count ? children[$0] : nil }.filter { !$0.isHidden } : []
4450
4524
  for view in stickyViews where !headers.contains(where: { $0 === view }) {
@@ -4617,11 +4691,13 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4617
4691
  craftScrollGesture(gestureRecognizer, runsWith: otherGestureRecognizer)
4618
4692
  }
4619
4693
 
4620
- func setAxis(_ axis: NSLayoutConstraint.Axis) {
4694
+ func setAxis(_ axis: NSLayoutConstraint.Axis, resetBounce: Bool = true) {
4621
4695
  if contentStack.axis != axis { setContentNeedsLayout() }
4622
4696
  contentStack.axis = axis
4623
- alwaysBounceVertical = axis == .vertical
4624
- alwaysBounceHorizontal = axis == .horizontal
4697
+ if resetBounce {
4698
+ alwaysBounceVertical = axis == .vertical
4699
+ alwaysBounceHorizontal = axis == .horizontal
4700
+ }
4625
4701
  }
4626
4702
  }
4627
4703
 
@@ -8461,6 +8537,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8461
8537
  scroll.isScrollEnabled = props["scrollEnabled"] as? Bool != false
8462
8538
  scroll.bounces = props["bounces"] as? Bool ?? true
8463
8539
  scroll.isPagingEnabled = props["pagingEnabled"] as? Bool ?? false
8540
+ let pageHandler = nonEmptyHandler(events["onPageSelected"])
8541
+ scroll.configurePager(props["pageIndex"], onSelect: pageHandler.map { handler in
8542
+ { [weak self] index, count in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["index": index, "count": count]]) }
8543
+ })
8464
8544
  scroll.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
8465
8545
  scroll.setKeyboardShouldPersistTaps(props["keyboardShouldPersistTaps"])
8466
8546
  scroll.showsVerticalScrollIndicator = props["showsVerticalScrollIndicator"] as? Bool != false
@@ -8492,6 +8572,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8492
8572
  contentStyle.merge(override) { _, next in next }
8493
8573
  }
8494
8574
  configureStack(scroll.contentStack, style: contentStyle)
8575
+ // Content-style defaults are vertical; the scroll host's explicit
8576
+ // axis must survive that shared container configuration without
8577
+ // resetting the authored bounce options applied above.
8578
+ scroll.setAxis(direction, resetBounce: false)
8495
8579
  reconcileChildren(children.filter {
8496
8580
  guard let child = $0 as? [String: Any] else { return true }
8497
8581
  return canonicalType(child["type"] as? String ?? "") != "RefreshControl"
@@ -9712,10 +9796,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9712
9796
  for: size,
9713
9797
  compatibleWith: traitCollection
9714
9798
  )
9715
- var font = (style["fontFamily"] as? String).flatMap { UIFont(name: $0, size: scaledSize) }
9716
- ?? .systemFont(ofSize: scaledSize, weight: weight)
9799
+ let families = (style["fontFamily"] as? String ?? "").split(separator: ",").map {
9800
+ $0.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
9801
+ }
9802
+ let selectedFont = families.lazy.compactMap { family -> UIFont? in
9803
+ switch family.lowercased() {
9804
+ case "monospace", "ui-monospace": return .monospacedSystemFont(ofSize: scaledSize, weight: weight)
9805
+ case "serif", "ui-serif":
9806
+ let system = UIFont.systemFont(ofSize: scaledSize, weight: weight)
9807
+ return system.fontDescriptor.withDesign(.serif).map { UIFont(descriptor: $0, size: scaledSize) }
9808
+ case "sans-serif", "ui-sans-serif", "system-ui", "-apple-system": return .systemFont(ofSize: scaledSize, weight: weight)
9809
+ default:
9810
+ guard let custom = UIFont(name: family, size: scaledSize) else { return nil }
9811
+ let weighted = custom.fontDescriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: weight.rawValue]])
9812
+ var traits = custom.fontDescriptor.symbolicTraits
9813
+ if weight >= .semibold { traits.insert(.traitBold) } else { traits.remove(.traitBold) }
9814
+ let descriptor = weighted.withSymbolicTraits(traits) ?? weighted
9815
+ return UIFont(descriptor: descriptor, size: scaledSize)
9816
+ }
9817
+ }.first
9818
+ var font = selectedFont ?? .systemFont(ofSize: scaledSize, weight: weight)
9717
9819
  if style["fontStyle"] as? String == "italic",
9718
- let descriptor = font.fontDescriptor.withSymbolicTraits(.traitItalic) {
9820
+ let descriptor = font.fontDescriptor.withSymbolicTraits(font.fontDescriptor.symbolicTraits.union(.traitItalic)) {
9719
9821
  font = UIFont(descriptor: descriptor, size: scaledSize)
9720
9822
  }
9721
9823
  return font
@@ -10502,6 +10604,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10502
10604
  }
10503
10605
 
10504
10606
  func scrollViewDidScroll(_ scrollView: UIScrollView) {
10607
+ // Sticky positioning is a native scroll behavior and must also run
10608
+ // without a JavaScript onScroll subscription, including animations.
10609
+ (scrollView as? CraftNativeScrollView)?.updateStickyHeaders()
10505
10610
  guard let handler = scrollHandlers[ObjectIdentifier(scrollView)] else { return }
10506
10611
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
10507
10612
  }
@@ -10510,12 +10615,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10510
10615
 
10511
10616
  func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
10512
10617
  CraftFramePacing.shared.begin(pacingName)
10618
+ (scrollView as? CraftNativeScrollView)?.beginPageDrag()
10513
10619
  guard let handler = scrollBeginHandlers[ObjectIdentifier(scrollView)] else { return }
10514
10620
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
10515
10621
  }
10516
10622
 
10517
10623
  func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
10518
- if !decelerate { CraftFramePacing.shared.end(pacingName) }
10624
+ if !decelerate { CraftFramePacing.shared.end(pacingName); (scrollView as? CraftNativeScrollView)?.finishPageMotion() }
10519
10625
  guard let handler = scrollEndHandlers[ObjectIdentifier(scrollView)] else { return }
10520
10626
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
10521
10627
  }
@@ -10527,10 +10633,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10527
10633
 
10528
10634
  func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
10529
10635
  CraftFramePacing.shared.end(pacingName)
10636
+ (scrollView as? CraftNativeScrollView)?.finishPageMotion()
10530
10637
  guard let handler = scrollMomentumEndHandlers[ObjectIdentifier(scrollView)] else { return }
10531
10638
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
10532
10639
  }
10533
10640
 
10641
+ func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
10642
+ (scrollView as? CraftNativeScrollView)?.finishPageMotion()
10643
+ }
10644
+
10534
10645
  private func scrollEvent(_ scrollView: UIScrollView) -> [String: Any] {
10535
10646
  [
10536
10647
  "contentOffset": ["x": scrollView.contentOffset.x, "y": scrollView.contentOffset.y],
@@ -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 accepts native suppression of Apple modules,
2378
+ * including Focus, Now Playing and camera/microphone 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,7 @@ export interface MenubarState {
2383
2388
  authorized?: boolean;
2384
2389
  busy?: boolean;
2385
2390
  hiddenAppCount?: number;
2391
+ preserveSystemItems?: boolean;
2386
2392
  markerWidth?: number;
2387
- error?: 'unavailable' | 'permissionRequired' | 'layoutUnavailable' | 'activationFailed' | 'activationTimeout' | null;
2393
+ error?: 'unavailable' | 'permissionRequired' | 'layoutUnavailable' | 'emptyGroup' | 'visibilityMismatch' | 'activationFailed' | 'activationTimeout' | null;
2388
2394
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.304",
3
+ "version": "0.0.319",
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>",