craft-native 0.0.471 → 0.0.476

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.
@@ -844,6 +844,9 @@ internal class CraftNativeChrome(
844
844
  private val tabBar = LinearLayout(context)
845
845
  private val tabRow = LinearLayout(context)
846
846
  private val accessory = FrameLayout(context)
847
+ private val companionRow = LinearLayout(context)
848
+ private val companionViews = mutableListOf<Pair<FrameLayout, JSONObject>>()
849
+ private var renderedCompanionSpecs = ""
847
850
  private val accessoryIcon = ImageView(context)
848
851
  private val accessoryBadge = TextView(context)
849
852
  private var accessorySpec: JSONObject? = null
@@ -864,11 +867,16 @@ internal class CraftNativeChrome(
864
867
  private var renderedButtonSpecs: Pair<String, String>? = null
865
868
  private val density = resources.displayMetrics.density
866
869
  private var screenBackgroundColor: Int? = null
870
+ private var navigationTitleColor: Int? = null
867
871
  private var selectedTab = 0
868
872
  private var tabBarHiddenByScreen = false
869
873
  private var visiblePath: String? = null
870
874
  private val accessorySelected: Boolean
871
- get() = accessorySpec?.optString("path")?.let { path -> visiblePath == path || visiblePath?.startsWith(path + "/") == true } == true
875
+ get() = accessorySpec?.let { isAccessorySelected(it) } == true || companionViews.any { isAccessorySelected(it.second) }
876
+ private fun isAccessorySelected(item: JSONObject): Boolean {
877
+ val path = item.optString("path")
878
+ return visiblePath == path || visiblePath?.startsWith(path + "/") == true
879
+ }
872
880
  private var swipeTracking = false
873
881
  private var swipeIntercepted = false
874
882
  private var horizontalScrollDeferred = false
@@ -965,6 +973,9 @@ internal class CraftNativeChrome(
965
973
  tabRow.gravity = Gravity.CENTER_VERTICAL
966
974
  tabRow.setPadding(dp(12), dp(4), dp(12), dp(4))
967
975
  tabRow.addView(tabBar, LayoutParams(0, dp(64), 1f))
976
+ companionRow.orientation = HORIZONTAL
977
+ companionRow.gravity = Gravity.CENTER_VERTICAL
978
+ tabRow.addView(companionRow, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(64)))
968
979
  accessory.addView(accessoryIcon, FrameLayout.LayoutParams(dp(26), dp(26), Gravity.CENTER))
969
980
  accessoryBadge.textSize = 10f
970
981
  accessoryBadge.gravity = Gravity.CENTER
@@ -1039,6 +1050,10 @@ internal class CraftNativeChrome(
1039
1050
  ?.takeUnless { it == JSONObject.NULL }
1040
1051
  ?.let { craftNativeOptionalString(it) }
1041
1052
  ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
1053
+ navigationTitleColor = options.opt("titleColor")
1054
+ ?.takeUnless { it == JSONObject.NULL }
1055
+ ?.let { craftNativeOptionalString(it) }
1056
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
1042
1057
  options.optJSONObject("tabItem")?.let(::updateTab)
1043
1058
  val hidden = options.optBoolean("hidden", false)
1044
1059
  val large = options.optBoolean("largeTitle", false) && depth == 0
@@ -1087,6 +1102,24 @@ internal class CraftNativeChrome(
1087
1102
  path.startsWith("/") && !path.startsWith("//") && it.has("title")
1088
1103
  }
1089
1104
  accessory.visibility = if (accessorySpec == null) View.GONE else View.VISIBLE
1105
+ val companions = accessorySpec?.optJSONArray("companions")
1106
+ val companionSpecs = companions?.toString().orEmpty()
1107
+ if (companionSpecs != renderedCompanionSpecs) {
1108
+ renderedCompanionSpecs = companionSpecs
1109
+ companionRow.removeAllViews()
1110
+ companionViews.clear()
1111
+ for (index in 0 until minOf(companions?.length() ?: 0, 2)) {
1112
+ val item = companions?.optJSONObject(index) ?: continue
1113
+ val path = item.optString("path")
1114
+ if (!path.startsWith("/") || path.startsWith("//") || path == accessorySpec?.optString("path") || !item.has("title")) continue
1115
+ val action = FrameLayout(context)
1116
+ action.contentDescription = item.optString("accessibilityLabel").ifBlank { item.optString("title") }
1117
+ action.addView(ImageView(context), FrameLayout.LayoutParams(dp(26), dp(26), Gravity.CENTER))
1118
+ action.setOnClickListener { onAccessory?.invoke(path) }
1119
+ companionRow.addView(action, LayoutParams(dp(48), dp(48)).apply { marginStart = dp(8) })
1120
+ companionViews.add(action to item)
1121
+ }
1122
+ }
1090
1123
  accessorySpec?.let {
1091
1124
  accessory.contentDescription = it.optString("accessibilityLabel").ifBlank { it.optString("title") } + it.optString("badge").takeIf { badge -> badge.isNotBlank() && badge != "null" }?.let { badge -> ", $badge" }.orEmpty()
1092
1125
  accessoryBadge.text = it.optString("badge").takeUnless { badge -> badge == "null" }.orEmpty().take(8)
@@ -1186,14 +1219,21 @@ internal class CraftNativeChrome(
1186
1219
  setBackgroundColor(background)
1187
1220
  bar.setBackgroundColor(background)
1188
1221
  largeTitleView.setBackgroundColor(background)
1189
- titleView.setTextColor(text)
1190
- largeTitleView.setTextColor(text)
1222
+ titleView.setTextColor(navigationTitleColor ?: text)
1223
+ largeTitleView.setTextColor(navigationTitleColor ?: text)
1191
1224
  backButton.imageTintList = ColorStateList.valueOf(accent)
1192
1225
  val surface = if (dark) Color.parseColor("#1C1C1E") else Color.parseColor("#F9F9F9")
1193
1226
  tabBar.background = GradientDrawable().apply { setColor(surface); cornerRadius = dp(32).toFloat() }
1194
- accessory.isSelected = accessorySelected
1195
- 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() }
1196
- accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), if (accessorySelected) accent else text)) }
1227
+ val primarySelected = accessorySpec?.let { isAccessorySelected(it) } == true
1228
+ accessory.isSelected = primarySelected
1229
+ companionViews.forEach { (action, item) ->
1230
+ val selected = isAccessorySelected(item)
1231
+ action.isSelected = selected
1232
+ action.background = GradientDrawable().apply { setColor(if (selected) Color.argb(36, Color.red(accent), Color.green(accent), Color.blue(accent)) else surface); cornerRadius = dp(32).toFloat() }
1233
+ (action.getChildAt(0) as ImageView).setImageDrawable(CraftNativeIcons.drawable(context, null, item.optString("symbol"), if (selected) accent else text))
1234
+ }
1235
+ accessory.background = GradientDrawable().apply { setColor(if (primarySelected) Color.argb(36, Color.red(accent), Color.green(accent), Color.blue(accent)) else surface); cornerRadius = dp(32).toFloat() }
1236
+ accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), if (primarySelected) accent else text)) }
1197
1237
  tabViews.forEachIndexed { index, (icon, label) ->
1198
1238
  tabBar.getChildAt(index).isSelected = index == selectedTab && !accessorySelected
1199
1239
  tabBar.getChildAt(index).background = tabCellBackground(index, dark)
package/dist/cli.js CHANGED
@@ -1012,13 +1012,16 @@ async function detachDiskImage(mount, options = {}) {
1012
1012
  const wait = options.wait ?? ((ms) => new Promise((resolve) => {
1013
1013
  setTimeout(resolve, ms);
1014
1014
  }));
1015
+ const attempts = [];
1015
1016
  for (let attempt = 1;; attempt++) {
1016
1017
  const forced = attempt >= HDIUTIL_MAX_ATTEMPTS;
1017
- const result = await run(forced ? ["detach", mount, "-force"] : ["detach", mount]);
1018
+ const args = forced ? ["detach", mount, "-force"] : ["detach", mount];
1019
+ const result = await run(args);
1018
1020
  if (result.success)
1019
1021
  return { success: true };
1022
+ attempts.push({ args, error: result.error });
1020
1023
  if (!shouldRetryHdiutil(result.error, attempt))
1021
- return { success: false, error: result.error };
1024
+ return { success: false, error: result.error, attempts };
1022
1025
  await wait(attempt * 2000);
1023
1026
  }
1024
1027
  }
@@ -1030,6 +1033,9 @@ async function createDiskImage(opts) {
1030
1033
  const mount = join3(scratch, "mount");
1031
1034
  const writable = join3(scratch, "installer.dmg");
1032
1035
  let mounted = false;
1036
+ let phase = "stage";
1037
+ let attachment = "";
1038
+ let detachAttempts = [];
1033
1039
  const run = async (tool, args) => {
1034
1040
  const result = await runTool(tool, args);
1035
1041
  if (!result.success)
@@ -1046,6 +1052,7 @@ async function createDiskImage(opts) {
1046
1052
  writeFileSync4(join3(payload, ".background", "installer.png"), installerBackground);
1047
1053
  const args = dmgCreateArguments({ ...opts, appBundlePath: payload, outputPath: writable }, pathContentBytes(payload));
1048
1054
  args[args.indexOf("ULMO")] = "UDRW";
1055
+ phase = "create";
1049
1056
  for (let attempt = 1;; attempt++) {
1050
1057
  const result = await runHdiutil(args);
1051
1058
  if (result.success)
@@ -1055,17 +1062,25 @@ async function createDiskImage(opts) {
1055
1062
  rmSync2(writable, { force: true });
1056
1063
  await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
1057
1064
  }
1058
- await run("hdiutil", ["attach", writable, "-nobrowse", "-mountpoint", mount]);
1065
+ phase = "attach";
1066
+ attachment = (await run("hdiutil", ["attach", writable, "-nobrowse", "-mountpoint", mount])).stdout;
1059
1067
  mounted = true;
1068
+ phase = "layout";
1060
1069
  writeInstallerLayout(mount, opts.volumeName, bundleName);
1070
+ phase = "detach";
1061
1071
  const detached = await detachDiskImage(mount);
1062
- if (!detached.success)
1072
+ if (!detached.success) {
1073
+ detachAttempts = detached.attempts;
1063
1074
  throw new Error(detached.error);
1075
+ }
1064
1076
  mounted = false;
1077
+ phase = "convert";
1065
1078
  await run("hdiutil", ["convert", writable, "-format", "ULMO", "-ov", "-o", opts.outputPath]);
1066
1079
  return { success: true, outputPath: opts.outputPath };
1067
1080
  } catch (error) {
1068
- return { success: false, error: error instanceof Error ? error.message : String(error) };
1081
+ const message = error instanceof Error ? error.message : String(error);
1082
+ return { success: false, error: `${message}
1083
+ Disk image lifecycle: ${JSON.stringify({ phase, image: writable, mount, attachment, detachAttempts })}` };
1069
1084
  } finally {
1070
1085
  if (mounted)
1071
1086
  await runTool("hdiutil", ["detach", mount, "-force"]);
@@ -3228,7 +3243,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3228
3243
  `);
3229
3244
  }
3230
3245
  // package.json
3231
- var version = "0.0.471";
3246
+ var version = "0.0.476";
3232
3247
 
3233
3248
  // bin/cli.ts
3234
3249
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -376,7 +376,7 @@ function craftBinaryNotFoundMessage(triedPath) {
376
376
  `);
377
377
  }
378
378
  // package.json
379
- var version = "0.0.471";
379
+ var version = "0.0.476";
380
380
 
381
381
  // src/sdk-version.ts
382
382
  var SDK_VERSION = version;
@@ -1380,13 +1380,16 @@ async function detachDiskImage(mount, options = {}) {
1380
1380
  const wait = options.wait ?? ((ms) => new Promise((resolve) => {
1381
1381
  setTimeout(resolve, ms);
1382
1382
  }));
1383
+ const attempts = [];
1383
1384
  for (let attempt = 1;; attempt++) {
1384
1385
  const forced = attempt >= HDIUTIL_MAX_ATTEMPTS;
1385
- const result = await run(forced ? ["detach", mount, "-force"] : ["detach", mount]);
1386
+ const args = forced ? ["detach", mount, "-force"] : ["detach", mount];
1387
+ const result = await run(args);
1386
1388
  if (result.success)
1387
1389
  return { success: true };
1390
+ attempts.push({ args, error: result.error });
1388
1391
  if (!shouldRetryHdiutil(result.error, attempt))
1389
- return { success: false, error: result.error };
1392
+ return { success: false, error: result.error, attempts };
1390
1393
  await wait(attempt * 2000);
1391
1394
  }
1392
1395
  }
@@ -1398,6 +1401,9 @@ async function createDiskImage(opts) {
1398
1401
  const mount = import_path.join(scratch, "mount");
1399
1402
  const writable = import_path.join(scratch, "installer.dmg");
1400
1403
  let mounted = false;
1404
+ let phase = "stage";
1405
+ let attachment = "";
1406
+ let detachAttempts = [];
1401
1407
  const run = async (tool, args) => {
1402
1408
  const result = await runTool(tool, args);
1403
1409
  if (!result.success)
@@ -1414,6 +1420,7 @@ async function createDiskImage(opts) {
1414
1420
  import_fs.writeFileSync(import_path.join(payload, ".background", "installer.png"), installerBackground);
1415
1421
  const args = dmgCreateArguments({ ...opts, appBundlePath: payload, outputPath: writable }, pathContentBytes(payload));
1416
1422
  args[args.indexOf("ULMO")] = "UDRW";
1423
+ phase = "create";
1417
1424
  for (let attempt = 1;; attempt++) {
1418
1425
  const result = await runHdiutil(args);
1419
1426
  if (result.success)
@@ -1423,17 +1430,25 @@ async function createDiskImage(opts) {
1423
1430
  import_fs.rmSync(writable, { force: true });
1424
1431
  await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
1425
1432
  }
1426
- await run("hdiutil", ["attach", writable, "-nobrowse", "-mountpoint", mount]);
1433
+ phase = "attach";
1434
+ attachment = (await run("hdiutil", ["attach", writable, "-nobrowse", "-mountpoint", mount])).stdout;
1427
1435
  mounted = true;
1436
+ phase = "layout";
1428
1437
  writeInstallerLayout(mount, opts.volumeName, bundleName);
1438
+ phase = "detach";
1429
1439
  const detached = await detachDiskImage(mount);
1430
- if (!detached.success)
1440
+ if (!detached.success) {
1441
+ detachAttempts = detached.attempts;
1431
1442
  throw new Error(detached.error);
1443
+ }
1432
1444
  mounted = false;
1445
+ phase = "convert";
1433
1446
  await run("hdiutil", ["convert", writable, "-format", "ULMO", "-ov", "-o", opts.outputPath]);
1434
1447
  return { success: true, outputPath: opts.outputPath };
1435
1448
  } catch (error) {
1436
- return { success: false, error: error instanceof Error ? error.message : String(error) };
1449
+ const message = error instanceof Error ? error.message : String(error);
1450
+ return { success: false, error: `${message}
1451
+ Disk image lifecycle: ${JSON.stringify({ phase, image: writable, mount, attachment, detachAttempts })}` };
1437
1452
  } finally {
1438
1453
  if (mounted)
1439
1454
  await runTool("hdiutil", ["detach", mount, "-force"]);
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.471";
120
+ var version = "0.0.476";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -1136,13 +1136,16 @@ async function detachDiskImage(mount, options = {}) {
1136
1136
  const wait = options.wait ?? ((ms) => new Promise((resolve) => {
1137
1137
  setTimeout(resolve, ms);
1138
1138
  }));
1139
+ const attempts = [];
1139
1140
  for (let attempt = 1;; attempt++) {
1140
1141
  const forced = attempt >= HDIUTIL_MAX_ATTEMPTS;
1141
- const result = await run(forced ? ["detach", mount, "-force"] : ["detach", mount]);
1142
+ const args = forced ? ["detach", mount, "-force"] : ["detach", mount];
1143
+ const result = await run(args);
1142
1144
  if (result.success)
1143
1145
  return { success: true };
1146
+ attempts.push({ args, error: result.error });
1144
1147
  if (!shouldRetryHdiutil(result.error, attempt))
1145
- return { success: false, error: result.error };
1148
+ return { success: false, error: result.error, attempts };
1146
1149
  await wait(attempt * 2000);
1147
1150
  }
1148
1151
  }
@@ -1154,6 +1157,9 @@ async function createDiskImage(opts) {
1154
1157
  const mount = join3(scratch, "mount");
1155
1158
  const writable = join3(scratch, "installer.dmg");
1156
1159
  let mounted = false;
1160
+ let phase = "stage";
1161
+ let attachment = "";
1162
+ let detachAttempts = [];
1157
1163
  const run = async (tool, args) => {
1158
1164
  const result = await runTool(tool, args);
1159
1165
  if (!result.success)
@@ -1170,6 +1176,7 @@ async function createDiskImage(opts) {
1170
1176
  writeFileSync3(join3(payload, ".background", "installer.png"), installerBackground);
1171
1177
  const args = dmgCreateArguments({ ...opts, appBundlePath: payload, outputPath: writable }, pathContentBytes(payload));
1172
1178
  args[args.indexOf("ULMO")] = "UDRW";
1179
+ phase = "create";
1173
1180
  for (let attempt = 1;; attempt++) {
1174
1181
  const result = await runHdiutil(args);
1175
1182
  if (result.success)
@@ -1179,17 +1186,25 @@ async function createDiskImage(opts) {
1179
1186
  rmSync2(writable, { force: true });
1180
1187
  await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
1181
1188
  }
1182
- await run("hdiutil", ["attach", writable, "-nobrowse", "-mountpoint", mount]);
1189
+ phase = "attach";
1190
+ attachment = (await run("hdiutil", ["attach", writable, "-nobrowse", "-mountpoint", mount])).stdout;
1183
1191
  mounted = true;
1192
+ phase = "layout";
1184
1193
  writeInstallerLayout(mount, opts.volumeName, bundleName);
1194
+ phase = "detach";
1185
1195
  const detached = await detachDiskImage(mount);
1186
- if (!detached.success)
1196
+ if (!detached.success) {
1197
+ detachAttempts = detached.attempts;
1187
1198
  throw new Error(detached.error);
1199
+ }
1188
1200
  mounted = false;
1201
+ phase = "convert";
1189
1202
  await run("hdiutil", ["convert", writable, "-format", "ULMO", "-ov", "-o", opts.outputPath]);
1190
1203
  return { success: true, outputPath: opts.outputPath };
1191
1204
  } catch (error) {
1192
- return { success: false, error: error instanceof Error ? error.message : String(error) };
1205
+ const message = error instanceof Error ? error.message : String(error);
1206
+ return { success: false, error: `${message}
1207
+ Disk image lifecycle: ${JSON.stringify({ phase, image: writable, mount, attachment, detachAttempts })}` };
1193
1208
  } finally {
1194
1209
  if (mounted)
1195
1210
  await runTool("hdiutil", ["detach", mount, "-force"]);
@@ -819,6 +819,7 @@ final class CraftChrome: ObservableObject {
819
819
  var image: String? = nil
820
820
  /// A compact floating icon, instead of the system full-width accessory shelf.
821
821
  var iconOnly = false
822
+ var companions: [Tab] = []
822
823
  }
823
824
 
824
825
  @Published var tabs: [Tab] = []
@@ -828,8 +829,12 @@ final class CraftChrome: ObservableObject {
828
829
  /// The visible destination may be the accessory, independently of its owning stack.
829
830
  @Published var visiblePath: String?
830
831
  var accessorySelected: Bool {
831
- guard let path = visiblePath, let accessory = tabAccessory?.id else { return false }
832
- return path == accessory || path.hasPrefix(accessory + "/")
832
+ guard let accessory = tabAccessory else { return false }
833
+ return ([accessory] + accessory.companions).contains { accessorySelected($0) }
834
+ }
835
+ func accessorySelected(_ accessory: Tab) -> Bool {
836
+ guard let path = visiblePath else { return false }
837
+ return path == accessory.id || path.hasPrefix(accessory.id + "/")
833
838
  }
834
839
  var highlightedTab: String? { accessorySelected ? nil : selected }
835
840
  let tabSwipeProgress = CraftTabSwipeProgress()
@@ -954,7 +959,14 @@ final class CraftChrome: ObservableObject {
954
959
  next = Tab(id: path, title: title, symbol: item["symbol"] as? String ?? "message",
955
960
  badge: (item["badge"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) },
956
961
  accessibilityLabel: item["accessibilityLabel"] as? String,
957
- iconOnly: item["iconOnly"] as? Bool ?? false)
962
+ iconOnly: item["iconOnly"] as? Bool ?? false,
963
+ companions: ((item["companions"] as? [[String: Any]]) ?? []).prefix(2).compactMap { action in
964
+ guard let companionPath = action["path"] as? String,
965
+ companionPath.hasPrefix("/"), !companionPath.hasPrefix("//"), companionPath != path,
966
+ let companionTitle = action["title"] as? String else { return nil }
967
+ return Tab(id: companionPath, title: companionTitle, symbol: action["symbol"] as? String ?? "circle",
968
+ accessibilityLabel: action["accessibilityLabel"] as? String, iconOnly: true)
969
+ })
958
970
  } else {
959
971
  next = nil
960
972
  }
@@ -1265,43 +1277,45 @@ private struct CraftTabBarContent: View {
1265
1277
  if !chrome.tabs.isEmpty {
1266
1278
  HStack(spacing: 8) {
1267
1279
  bar
1268
- if let accessory = chrome.tabAccessory {
1269
- Button { CraftHybrid.shared.open(accessory.id) } label: {
1270
- surface {
1271
- Image(systemName: accessory.symbol)
1272
- .font(.system(size: iconSize))
1273
- .foregroundStyle(chrome.accessorySelected ? selectedColor : Color.primary)
1274
- .frame(width: 62, height: 62)
1275
- .background {
1276
- if chrome.accessorySelected {
1277
- Circle().fill(selectedColor.opacity(0.14)).padding(4)
1280
+ if let primary = chrome.tabAccessory {
1281
+ ForEach(primary.companions + [primary]) { accessory in
1282
+ Button { CraftHybrid.shared.open(accessory.id) } label: {
1283
+ surface {
1284
+ Image(systemName: accessory.symbol)
1285
+ .font(.system(size: iconSize))
1286
+ .foregroundStyle(chrome.accessorySelected(accessory) ? selectedColor : Color.primary)
1287
+ .frame(width: 62, height: 62)
1288
+ .background {
1289
+ if chrome.accessorySelected(accessory) {
1290
+ Circle().fill(selectedColor.opacity(0.14)).padding(4)
1291
+ }
1292
+ }
1293
+ }
1294
+ .contentShape(Rectangle())
1295
+ .overlay(alignment: .topTrailing) {
1296
+ if let badge = accessory.badge {
1297
+ Text(badge)
1298
+ .font(.system(size: badgeSize, weight: .bold))
1299
+ .foregroundStyle(.white)
1300
+ .padding(.horizontal, 4)
1301
+ .frame(minWidth: 18, minHeight: 18)
1302
+ .background(Capsule().fill(Color.red))
1278
1303
  }
1279
1304
  }
1280
1305
  }
1281
- .contentShape(Rectangle())
1282
- .overlay(alignment: .topTrailing) {
1283
- if let badge = accessory.badge {
1284
- Text(badge)
1285
- .font(.system(size: badgeSize, weight: .bold))
1286
- .foregroundStyle(.white)
1287
- .padding(.horizontal, 4)
1288
- .frame(minWidth: 18, minHeight: 18)
1289
- .background(Capsule().fill(Color.red))
1290
- }
1291
- }
1292
- }
1293
- .buttonStyle(.plain)
1294
- .accessibilityShowsLargeContentViewer {
1295
- Image(systemName: accessory.symbol)
1296
- Text(accessory.accessibilityLabel ?? accessory.title)
1306
+ .buttonStyle(.plain)
1307
+ .accessibilityShowsLargeContentViewer {
1308
+ Image(systemName: accessory.symbol)
1309
+ Text(accessory.accessibilityLabel ?? accessory.title)
1310
+ }
1311
+ // Keep accessibility intent for the viewer; displayed
1312
+ // sizes come from the bounded metrics owned above.
1313
+ .environment(\.dynamicTypeSize, viewerTextSize)
1314
+ .accessibilityLabel(accessory.accessibilityLabel ?? accessory.title)
1315
+ .accessibilityValue(accessory.badge ?? "")
1316
+ .accessibilityIdentifier("craft-tab-accessory")
1317
+ .accessibilityAddTraits(chrome.accessorySelected(accessory) ? .isSelected : [])
1297
1318
  }
1298
- // Keep accessibility intent for the viewer; displayed
1299
- // sizes come from the bounded metrics owned above.
1300
- .environment(\.dynamicTypeSize, viewerTextSize)
1301
- .accessibilityLabel(accessory.accessibilityLabel ?? accessory.title)
1302
- .accessibilityValue(accessory.badge ?? "")
1303
- .accessibilityIdentifier("craft-tab-accessory")
1304
- .accessibilityAddTraits(chrome.accessorySelected ? .isSelected : [])
1305
1319
  }
1306
1320
  }
1307
1321
  .padding(.horizontal, 16)
@@ -1192,6 +1192,9 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1192
1192
  private var synchronizingTabs = false
1193
1193
  private var accessoryModel: CraftChrome.Tab?
1194
1194
  private let accessoryButton = UIButton(type: .system)
1195
+ private var companionButtons: [UIButton] = []
1196
+ private var companionModels: [CraftChrome.Tab] = []
1197
+ private var companionGlasses: [UIVisualEffectView] = []
1195
1198
  private let accessoryBadge = UILabel()
1196
1199
  private let accessoryGlass = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
1197
1200
  private static let tabPictures = NSCache<NSURL, UIImage>()
@@ -1410,6 +1413,40 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1410
1413
  private func updateSystemAccessory(hidden: Bool) {
1411
1414
  let chrome = CraftChrome.shared
1412
1415
  let model = hidden ? nil : chrome.tabAccessory
1416
+ // Keep controls when only the selected destination or unread badge changes.
1417
+ let companions = model?.iconOnly == true ? model?.companions ?? [] : []
1418
+ if companionModels != companions {
1419
+ companionModels = companions
1420
+ for button in companionButtons { button.removeFromSuperview() }
1421
+ for glass in companionGlasses { glass.removeFromSuperview() }
1422
+ companionButtons.removeAll()
1423
+ companionGlasses.removeAll()
1424
+ for action in companions {
1425
+ let button = UIButton(type: .system)
1426
+ var configuration = UIButton.Configuration.plain()
1427
+ configuration.image = UIImage(systemName: action.symbol)
1428
+ button.configuration = configuration
1429
+ button.accessibilityIdentifier = "craft-tab-companion:" + action.id
1430
+ button.accessibilityLabel = action.accessibilityLabel ?? action.title
1431
+ button.accessibilityValue = action.badge
1432
+ button.accessibilityTraits = chrome.accessorySelected(action) ? [.button, .selected] : [.button]
1433
+ button.addAction(UIAction { [weak self] _ in self?.hybrid.open(action.id) }, for: .touchUpInside)
1434
+ let glass = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
1435
+ #if compiler(>=6.2)
1436
+ if #available(iOS 26.0, *) { glass.effect = UIGlassEffect() }
1437
+ #endif
1438
+ glass.layer.cornerRadius = 24
1439
+ glass.clipsToBounds = true
1440
+ glass.isUserInteractionEnabled = false
1441
+ view.addSubview(glass)
1442
+ view.addSubview(button)
1443
+ companionGlasses.append(glass)
1444
+ companionButtons.append(button)
1445
+ }
1446
+ }
1447
+ for (index, action) in companionModels.enumerated() {
1448
+ companionButtons[index].accessibilityTraits = chrome.accessorySelected(action) ? [.button, .selected] : [.button]
1449
+ }
1413
1450
  if let model {
1414
1451
  var configuration = UIButton.Configuration.plain()
1415
1452
  configuration.image = UIImage(systemName: model.symbol)
@@ -1419,7 +1456,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1419
1456
  accessoryButton.configuration = configuration
1420
1457
  accessoryButton.accessibilityLabel = model.accessibilityLabel ?? model.displayTitle
1421
1458
  accessoryButton.accessibilityValue = model.badge
1422
- accessoryButton.accessibilityTraits = chrome.accessorySelected ? [.button, .selected] : [.button]
1459
+ accessoryButton.accessibilityTraits = chrome.accessorySelected(model) ? [.button, .selected] : [.button]
1423
1460
  }
1424
1461
  #if compiler(>=6.2)
1425
1462
  if #available(iOS 26.0, *), model?.iconOnly != true {
@@ -1493,6 +1530,11 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1493
1530
  let x = iconOnly ? view.bounds.maxX - view.safeAreaInsets.right - width - 20 : 16
1494
1531
  accessoryButton.frame = CGRect(x: x, y: tabBar.frame.minY - height - (iconOnly ? 8 : 0), width: width, height: height)
1495
1532
  accessoryGlass.frame = accessoryButton.frame
1533
+ for (index, button) in companionButtons.enumerated() {
1534
+ // Array order reads left to right, ending at the primary action.
1535
+ button.frame = accessoryButton.frame.offsetBy(dx: -CGFloat(companionButtons.count - index) * (width + 12), dy: 0)
1536
+ companionGlasses[index].frame = button.frame
1537
+ }
1496
1538
  let badgeWidth = max(18, accessoryBadge.intrinsicContentSize.width + 8)
1497
1539
  accessoryBadge.frame = CGRect(x: accessoryButton.frame.maxX - badgeWidth + 4, y: accessoryButton.frame.minY - 3, width: badgeWidth, height: 18)
1498
1540
  // A compact action floats over the page, not across its whole width.
@@ -7856,6 +7856,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7856
7856
  /// the home indicator too, where its content never reaches: a black
7857
7857
  /// full-screen timer is black edge to edge. nil is the app's.
7858
7858
  private var screenBackgroundColor: UIColor?
7859
+ /// Title contrast belongs to this screen, independently of the bar material.
7860
+ private var navigationTitleColor: UIColor?
7859
7861
 
7860
7862
  override var preferredStatusBarStyle: UIStatusBarStyle {
7861
7863
  statusBarStyleOverride ?? .default
@@ -7926,6 +7928,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7926
7928
  if let solid = options["navigationBarSolid"] as? Bool {
7927
7929
  navigationBarSolid = solid
7928
7930
  }
7931
+ if options.keys.contains("titleColor") {
7932
+ navigationTitleColor = color(options["titleColor"])
7933
+ }
7929
7934
  if let hidden = options["hidden"] as? Bool {
7930
7935
  navigationBarHidden = hidden
7931
7936
  if navigationController?.topViewController === self {
@@ -7941,7 +7946,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7941
7946
  let buttons = (options["rightButtons"] as? [[String: Any]]) ?? []
7942
7947
  navigationItem.rightBarButtonItems = buttons.compactMap(navigationButton).reversed()
7943
7948
  }
7944
- if options.keys.contains("backgroundColor") || options.keys.contains("navigationBarSolid") {
7949
+ if options.keys.contains("backgroundColor") || options.keys.contains("navigationBarSolid") || options.keys.contains("titleColor") {
7945
7950
  applyNavigationBarAppearance()
7946
7951
  trackContentScrollView()
7947
7952
  }
@@ -7952,25 +7957,44 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7952
7957
  }
7953
7958
  }
7954
7959
 
7955
- /// Applies the screen's optional opaque bar without changing the default
7956
- /// UIKit material when the screen did not opt in.
7960
+ /// Per-item appearances preserve each UIKit bar state and never recolour a
7961
+ /// sibling controller's title or opt a material bar into an opaque fill.
7957
7962
  private func applyNavigationBarAppearance() {
7958
- guard navigationBarSolid else {
7963
+ guard navigationBarSolid || navigationTitleColor != nil else {
7959
7964
  navigationItem.standardAppearance = nil
7960
7965
  navigationItem.scrollEdgeAppearance = nil
7961
7966
  navigationItem.compactAppearance = nil
7962
7967
  navigationItem.compactScrollEdgeAppearance = nil
7963
7968
  return
7964
7969
  }
7965
- let appearance = UINavigationBarAppearance()
7966
- appearance.configureWithOpaqueBackground()
7967
- appearance.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
7968
- appearance.shadowColor = .clear
7969
- CraftChromeFonts.apply(to: appearance)
7970
- navigationItem.standardAppearance = appearance
7971
- navigationItem.scrollEdgeAppearance = appearance
7972
- navigationItem.compactAppearance = appearance
7973
- navigationItem.compactScrollEdgeAppearance = appearance
7970
+ let bar = navigationController?.navigationBar
7971
+ let standard = bar?.standardAppearance ?? UINavigationBarAppearance()
7972
+ // UIKit's unspecified scroll-edge appearance uses a transparent
7973
+ // background. Copy its standard text/button attributes before applying
7974
+ // that background so adding title contrast preserves the same states.
7975
+ let scrollEdge = bar?.scrollEdgeAppearance ?? {
7976
+ let appearance = UINavigationBarAppearance(barAppearance: standard)
7977
+ appearance.configureWithTransparentBackground()
7978
+ return appearance
7979
+ }()
7980
+ func styled(_ base: UINavigationBarAppearance) -> UINavigationBarAppearance {
7981
+ let appearance = UINavigationBarAppearance(barAppearance: base)
7982
+ if navigationBarSolid {
7983
+ appearance.configureWithOpaqueBackground()
7984
+ appearance.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
7985
+ appearance.shadowColor = .clear
7986
+ }
7987
+ CraftChromeFonts.apply(to: appearance)
7988
+ if let navigationTitleColor {
7989
+ appearance.titleTextAttributes[.foregroundColor] = navigationTitleColor
7990
+ appearance.largeTitleTextAttributes[.foregroundColor] = navigationTitleColor
7991
+ }
7992
+ return appearance
7993
+ }
7994
+ navigationItem.standardAppearance = styled(standard)
7995
+ navigationItem.scrollEdgeAppearance = styled(scrollEdge)
7996
+ navigationItem.compactAppearance = styled(bar?.compactAppearance ?? standard)
7997
+ navigationItem.compactScrollEdgeAppearance = styled(bar?.compactScrollEdgeAppearance ?? scrollEdge)
7974
7998
  }
7975
7999
 
7976
8000
  private func applyStatusBar(_ props: [String: Any]) {
@@ -11444,6 +11468,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11444
11468
  // UISwitch also owns its native identity and enabled/on state.
11445
11469
  // Assigning .button changes its legacy AX type into a button while
11446
11470
  // modern XCTest still identifies its UIKit class as a switch.
11471
+ // The solid variant publishes "1"/"0" as a string from
11472
+ // refreshAccessibility(). A UISwitch left to UIKit does not: the value
11473
+ // it hands XCTest arrives as a number, so every `value as? String`
11474
+ // read finds nothing. The driver's flip check is one of those reads,
11475
+ // which is why a switch that did flip was reported as never flipping,
11476
+ // and only sometimes - it depends which representation the accessibility
11477
+ // snapshot was built from. Publishing the string makes both variants
11478
+ // answer the same way. An authored accessibilityValue still wins.
11479
+ if let toggle = view as? UISwitch, !authoredAccessibilityValues.contains(id) {
11480
+ view.accessibilityValue = toggle.isOn ? "1" : "0"
11481
+ }
11447
11482
  if view is UITextView || view is UISwitch { return }
11448
11483
  if let toggle = view as? CraftNativeSolidSwitch { toggle.refreshAccessibility(); return }
11449
11484
  view.accessibilityTraits = traits
@@ -30,14 +30,22 @@ enum CraftPresenter {
30
30
  }
31
31
 
32
32
  /// Present over whatever is on screen. A controller that shows as a
33
- /// popover on iPad (an action sheet, a share sheet) points at `anchor` in
34
- /// `view`, or sits in the middle of it with no arrow; iPadOS raises an
35
- /// exception for a popover with neither. False when nothing can present,
36
- /// so the caller answers rather than waiting.
33
+ /// popover (an action sheet, a share sheet, on a regular-width screen)
34
+ /// points at `anchor` in `view`, or sits in the middle of it with no
35
+ /// arrow; iPadOS raises an exception for a popover with neither. False
36
+ /// when nothing can present, so the caller answers rather than waiting.
37
+ ///
38
+ /// Only where the presentation really is a popover. A `.actionSheet`
39
+ /// always carries a `popoverPresentationController`, phone included, and
40
+ /// from iOS 26 configuring it is enough to make the phone present the
41
+ /// sheet as a centred popover -- which silently drops its cancel action,
42
+ /// as UIKit never draws one in a popover. A compact screen is left alone
43
+ /// and gets the standard sheet, with its Cancel.
37
44
  @discardableResult
38
45
  static func present(_ controller: UIViewController, from view: UIView?, anchor: CGRect? = nil) -> Bool {
39
46
  guard let top = topViewController() else { return false }
40
- if let popover = controller.popoverPresentationController {
47
+ let asPopover = (view ?? top.view)?.traitCollection.horizontalSizeClass == .regular
48
+ if asPopover, let popover = controller.popoverPresentationController {
41
49
  let source = view ?? top.view!
42
50
  popover.sourceView = source
43
51
  if let anchor {
package/dist/package.d.ts CHANGED
@@ -273,6 +273,10 @@ export type PackagingToolResult = {
273
273
  success: false;
274
274
  error: string;
275
275
  };
276
+ interface DiskImageDetachAttempt {
277
+ args: string[];
278
+ error: string;
279
+ }
276
280
  /**
277
281
  * Eject a mounted image, retrying the contention hdiutil reports as
278
282
  * "couldn't eject ... - Resource busy".
@@ -292,6 +296,7 @@ export declare function detachDiskImage(mount: string, options?: {
292
296
  } | {
293
297
  success: false;
294
298
  error: string;
299
+ attempts: DiskImageDetachAttempt[];
295
300
  }>;
296
301
  /**
297
302
  * Helper: Create DMG from app bundle
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.471",
3
+ "version": "0.0.476",
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>",