craft-native 0.0.467 → 0.0.475

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
@@ -868,7 +871,11 @@ internal class CraftNativeChrome(
868
871
  private var tabBarHiddenByScreen = false
869
872
  private var visiblePath: String? = null
870
873
  private val accessorySelected: Boolean
871
- get() = accessorySpec?.optString("path")?.let { path -> visiblePath == path || visiblePath?.startsWith(path + "/") == true } == true
874
+ get() = accessorySpec?.let { isAccessorySelected(it) } == true || companionViews.any { isAccessorySelected(it.second) }
875
+ private fun isAccessorySelected(item: JSONObject): Boolean {
876
+ val path = item.optString("path")
877
+ return visiblePath == path || visiblePath?.startsWith(path + "/") == true
878
+ }
872
879
  private var swipeTracking = false
873
880
  private var swipeIntercepted = false
874
881
  private var horizontalScrollDeferred = false
@@ -965,6 +972,9 @@ internal class CraftNativeChrome(
965
972
  tabRow.gravity = Gravity.CENTER_VERTICAL
966
973
  tabRow.setPadding(dp(12), dp(4), dp(12), dp(4))
967
974
  tabRow.addView(tabBar, LayoutParams(0, dp(64), 1f))
975
+ companionRow.orientation = HORIZONTAL
976
+ companionRow.gravity = Gravity.CENTER_VERTICAL
977
+ tabRow.addView(companionRow, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(64)))
968
978
  accessory.addView(accessoryIcon, FrameLayout.LayoutParams(dp(26), dp(26), Gravity.CENTER))
969
979
  accessoryBadge.textSize = 10f
970
980
  accessoryBadge.gravity = Gravity.CENTER
@@ -1087,6 +1097,24 @@ internal class CraftNativeChrome(
1087
1097
  path.startsWith("/") && !path.startsWith("//") && it.has("title")
1088
1098
  }
1089
1099
  accessory.visibility = if (accessorySpec == null) View.GONE else View.VISIBLE
1100
+ val companions = accessorySpec?.optJSONArray("companions")
1101
+ val companionSpecs = companions?.toString().orEmpty()
1102
+ if (companionSpecs != renderedCompanionSpecs) {
1103
+ renderedCompanionSpecs = companionSpecs
1104
+ companionRow.removeAllViews()
1105
+ companionViews.clear()
1106
+ for (index in 0 until minOf(companions?.length() ?: 0, 2)) {
1107
+ val item = companions?.optJSONObject(index) ?: continue
1108
+ val path = item.optString("path")
1109
+ if (!path.startsWith("/") || path.startsWith("//") || path == accessorySpec?.optString("path") || !item.has("title")) continue
1110
+ val action = FrameLayout(context)
1111
+ action.contentDescription = item.optString("accessibilityLabel").ifBlank { item.optString("title") }
1112
+ action.addView(ImageView(context), FrameLayout.LayoutParams(dp(26), dp(26), Gravity.CENTER))
1113
+ action.setOnClickListener { onAccessory?.invoke(path) }
1114
+ companionRow.addView(action, LayoutParams(dp(48), dp(48)).apply { marginStart = dp(8) })
1115
+ companionViews.add(action to item)
1116
+ }
1117
+ }
1090
1118
  accessorySpec?.let {
1091
1119
  accessory.contentDescription = it.optString("accessibilityLabel").ifBlank { it.optString("title") } + it.optString("badge").takeIf { badge -> badge.isNotBlank() && badge != "null" }?.let { badge -> ", $badge" }.orEmpty()
1092
1120
  accessoryBadge.text = it.optString("badge").takeUnless { badge -> badge == "null" }.orEmpty().take(8)
@@ -1191,9 +1219,16 @@ internal class CraftNativeChrome(
1191
1219
  backButton.imageTintList = ColorStateList.valueOf(accent)
1192
1220
  val surface = if (dark) Color.parseColor("#1C1C1E") else Color.parseColor("#F9F9F9")
1193
1221
  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)) }
1222
+ val primarySelected = accessorySpec?.let { isAccessorySelected(it) } == true
1223
+ accessory.isSelected = primarySelected
1224
+ companionViews.forEach { (action, item) ->
1225
+ val selected = isAccessorySelected(item)
1226
+ action.isSelected = selected
1227
+ 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() }
1228
+ (action.getChildAt(0) as ImageView).setImageDrawable(CraftNativeIcons.drawable(context, null, item.optString("symbol"), if (selected) accent else text))
1229
+ }
1230
+ 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() }
1231
+ accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), if (primarySelected) accent else text)) }
1197
1232
  tabViews.forEachIndexed { index, (icon, label) ->
1198
1233
  tabBar.getChildAt(index).isSelected = index == selectedTab && !accessorySelected
1199
1234
  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.467";
3246
+ var version = "0.0.475";
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.467";
379
+ var version = "0.0.475";
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.467";
120
+ var version = "0.0.475";
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"]);
@@ -1615,6 +1615,7 @@ function writeShell(output, config) {
1615
1615
  writeSource(join4(output, "Sources", "CraftVoiceRecording.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftVoiceRecording.swift"), "utf8"));
1616
1616
  writeSource(join4(output, "Sources", "CraftWebAudio.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftWebAudio.swift"), "utf8"));
1617
1617
  writeSource(join4(output, "Sources", "CraftHybrid.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftHybrid.swift"), "utf8"));
1618
+ writeSource(join4(output, "Sources", "CraftFramePacingLog.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftFramePacingLog.swift"), "utf8"));
1618
1619
  writeSource(join4(output, "Sources", "CraftCrashReporter.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
1619
1620
  writeSource(join4(output, "Sources", "CraftLiveActivities.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftLiveActivities.swift"), "utf8"));
1620
1621
  writeSource(join4(output, "Shared", "CraftActivityAttributes.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftActivityAttributes.swift"), "utf8"));
@@ -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)
@@ -0,0 +1,18 @@
1
+ import Foundation
2
+
3
+ /// The opt-in frame trace shares one append-only file for this app process.
4
+ enum CraftFramePacingLog {
5
+ static func open(in directory: URL) throws -> FileHandle {
6
+ let manager = FileManager.default
7
+ try manager.createDirectory(at: directory, withIntermediateDirectories: true)
8
+ let url = directory.appendingPathComponent("craft-frame-pacing.log")
9
+ if !manager.fileExists(atPath: url.path),
10
+ !manager.createFile(atPath: url.path, contents: nil) {
11
+ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteUnknownError,
12
+ userInfo: [NSFilePathErrorKey: url.path])
13
+ }
14
+ let handle = try FileHandle(forWritingTo: url)
15
+ _ = try handle.seekToEnd()
16
+ return handle
17
+ }
18
+ }
@@ -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.
@@ -2703,11 +2745,11 @@ final class CraftFramePacing: NSObject {
2703
2745
  private let output = DispatchQueue(label: "craft.frame-pacing", qos: .utility)
2704
2746
  private lazy var file: FileHandle? = {
2705
2747
  guard let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return nil }
2706
- let url = caches.appendingPathComponent("craft-frame-pacing.log")
2707
- if !FileManager.default.fileExists(atPath: url.path) { FileManager.default.createFile(atPath: url.path, contents: nil) }
2708
- let handle = try? FileHandle(forWritingTo: url)
2709
- _ = try? handle?.seekToEnd()
2710
- return handle
2748
+ do { return try CraftFramePacingLog.open(in: caches) }
2749
+ catch {
2750
+ NSLog("[craft frames] trace file unavailable: %@", String(describing: error))
2751
+ return nil
2752
+ }
2711
2753
  }()
2712
2754
 
2713
2755
  private func write(_ line: String) {
@@ -10779,6 +10779,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10779
10779
  // beneath the screen's floating chrome.
10780
10780
  let overflow = style["overflow"] as? String
10781
10781
  view.clipsToBounds = overflow == "hidden"
10782
+ // A single-line editor owns a text viewport. Its private label
10783
+ // can be wider than the control while editing or showing a URL;
10784
+ // inherited visible overflow must not open that viewport.
10785
+ || view is UITextField
10782
10786
  || (view is UIScrollView && overflow != "visible")
10783
10787
  || (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
10784
10788
  || ((view is UIImageView || view is CraftNativePositionedImageView) && (borderRadius > 0 || hasCornerRadii))
@@ -11440,6 +11444,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11440
11444
  // UISwitch also owns its native identity and enabled/on state.
11441
11445
  // Assigning .button changes its legacy AX type into a button while
11442
11446
  // modern XCTest still identifies its UIKit class as a switch.
11447
+ // The solid variant publishes "1"/"0" as a string from
11448
+ // refreshAccessibility(). A UISwitch left to UIKit does not: the value
11449
+ // it hands XCTest arrives as a number, so every `value as? String`
11450
+ // read finds nothing. The driver's flip check is one of those reads,
11451
+ // which is why a switch that did flip was reported as never flipping,
11452
+ // and only sometimes - it depends which representation the accessibility
11453
+ // snapshot was built from. Publishing the string makes both variants
11454
+ // answer the same way. An authored accessibilityValue still wins.
11455
+ if let toggle = view as? UISwitch, !authoredAccessibilityValues.contains(id) {
11456
+ view.accessibilityValue = toggle.isOn ? "1" : "0"
11457
+ }
11443
11458
  if view is UITextView || view is UISwitch { return }
11444
11459
  if let toggle = view as? CraftNativeSolidSwitch { toggle.refreshAccessibility(); return }
11445
11460
  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 {
@@ -182,6 +182,30 @@ final class DriverTests: XCTestCase {
182
182
  return nil
183
183
  }
184
184
 
185
+ /// Safari's web view extends behind its browser bars. Those regions accept
186
+ /// touches for the bars, not for page controls reported inside the web view.
187
+ func browserContentFrame(_ frame: CGRect, topBar: CGRect?, bottomBar: CGRect?) -> CGRect {
188
+ var top = frame.minY
189
+ var bottom = frame.maxY
190
+ if let bar = topBar, bar.contains(CGPoint(x: frame.midX, y: bar.midY)), bar.intersects(frame) {
191
+ top = max(top, bar.maxY)
192
+ }
193
+ if let bar = bottomBar, bar.contains(CGPoint(x: frame.midX, y: bar.midY)), bar.intersects(frame) {
194
+ bottom = min(bottom, bar.minY)
195
+ }
196
+ return CGRect(x: frame.minX, y: top, width: frame.width, height: max(0, bottom - top))
197
+ }
198
+
199
+ func scrollViewport() -> CGRect {
200
+ let webView = app.webViews.firstMatch
201
+ let topBar = app.otherElements["TopBrowserBar"]
202
+ let bottomBar = app.toolbars["BottomBrowserToolbar"]
203
+ guard webView.exists, topBar.exists || bottomBar.exists else { return app.frame }
204
+ return browserContentFrame(webView.frame.intersection(app.frame),
205
+ topBar: topBar.exists ? topBar.frame : nil,
206
+ bottomBar: bottomBar.exists ? bottomBar.frame : nil)
207
+ }
208
+
185
209
  /// A short target fits entirely; an oversized target fills the viewport.
186
210
  func visibleScrollFrame(_ target: CGRect, in viewport: CGRect) -> Bool {
187
211
  let visible = target.intersection(viewport)
@@ -192,7 +216,7 @@ final class DriverTests: XCTestCase {
192
216
 
193
217
  func scrollTargetVisible(_ target: XCUIElement?) -> Bool {
194
218
  guard let target, target.exists, target.isHittable,
195
- visibleScrollFrame(target.frame, in: app.frame) else { return false }
219
+ visibleScrollFrame(target.frame, in: scrollViewport()) else { return false }
196
220
  // Hittable alone accepts controls clipped by the scroll view's edge.
197
221
  // Only an ancestor scroll view clips this target, not its siblings.
198
222
  // One that does not overlap the target is not its ancestor, whatever
@@ -220,7 +244,7 @@ final class DriverTests: XCTestCase {
220
244
  /// below the screen while drawing it over the lower half - and it stops
221
245
  /// short of a keyboard whose frame is known.
222
246
  func scrollPage(towardTop: Bool) {
223
- let frame = app.frame
247
+ let frame = scrollViewport()
224
248
  var lower = frame.minY + frame.height * 0.55
225
249
  let keyboard = app.keyboards.firstMatch
226
250
  if keyboard.exists, !keyboard.frame.isEmpty, keyboard.frame.minY < frame.maxY {
@@ -719,7 +743,7 @@ final class DriverTests: XCTestCase {
719
743
  var found = element(containing: arg)
720
744
  var drags = 0
721
745
  while !scrollTargetVisible(found) && drags < 12 {
722
- let towardTop = found.map { !$0.frame.isEmpty && $0.frame.midY < app.frame.midY } ?? false
746
+ let towardTop = found.map { !$0.frame.isEmpty && $0.frame.midY < scrollViewport().midY } ?? false
723
747
  scrollPage(towardTop: towardTop)
724
748
  drags += 1
725
749
  found = element(containing: arg)
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.467",
3
+ "version": "0.0.475",
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>",