peachsim 0.1.660 → 0.1.662

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +10 -15
  2. package/cli/commands/inspect/wait-ready.ts +10 -1
  3. package/cli/drivers/linux-browser-libs.lock.json +1088 -0
  4. package/cli/drivers/linux-browser-libs.ts +182 -0
  5. package/cli/drivers/playwright-provisioning.ts +20 -98
  6. package/cli/drivers/playwright-sim-host.ts +9 -0
  7. package/cli/drivers/playwright.ts +2 -1
  8. package/dist-lib/agent-daemon-client.cjs +1 -1
  9. package/dist-lib/agent-events.cjs +1 -1
  10. package/dist-lib/agent-identity.cjs +1 -1
  11. package/dist-lib/agent-sessions.cjs +1 -1
  12. package/dist-lib/attached-projects.cjs +1 -1
  13. package/dist-lib/auth/shared-session.cjs +1 -1
  14. package/dist-lib/backend-origin.cjs +18 -1
  15. package/dist-lib/beta.cjs +1 -1
  16. package/dist-lib/beta.mjs +1 -1
  17. package/dist-lib/bridge-constants.cjs +1 -1
  18. package/dist-lib/bridge-contract-input.cjs +1 -1
  19. package/dist-lib/bridge-contract-input.mjs +1 -1
  20. package/dist-lib/bridge-contract.cjs +1 -1
  21. package/dist-lib/bridge-contract.mjs +1 -1
  22. package/dist-lib/capture-contract.cjs +1 -1
  23. package/dist-lib/capture-contract.mjs +1 -1
  24. package/dist-lib/cli-constants.cjs +1 -1
  25. package/dist-lib/cloud-contract.cjs +1 -1
  26. package/dist-lib/cloud-contract.mjs +1 -1
  27. package/dist-lib/cloud-test-args.cjs +1 -1
  28. package/dist-lib/cloud-test-args.mjs +1 -1
  29. package/dist-lib/cloud.cjs +1 -1
  30. package/dist-lib/cloud.mjs +1 -1
  31. package/dist-lib/config.cjs +1 -1
  32. package/dist-lib/detox/index.cjs +1 -1
  33. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  34. package/dist-lib/home-paths.cjs +1 -1
  35. package/dist-lib/host/bridge-host.cjs +45 -41
  36. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  37. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  38. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  39. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  40. package/dist-lib/host/websocket-proxy.cjs +1 -1
  41. package/dist-lib/index.cjs +1 -1
  42. package/dist-lib/jump-to-source-babel.cjs +1 -1
  43. package/dist-lib/jump-to-source-native.cjs +1 -1
  44. package/dist-lib/kotlin-host.cjs +191 -59
  45. package/dist-lib/menu.cjs +1 -1
  46. package/dist-lib/menu.mjs +1 -1
  47. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  48. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  49. package/dist-lib/metro-production-bundle.cjs +1 -1
  50. package/dist-lib/metro-production-bundle.mjs +1 -1
  51. package/dist-lib/metro.cjs +1 -1
  52. package/dist-lib/native-app-identity.cjs +1 -1
  53. package/dist-lib/native-app-identity.mjs +1 -1
  54. package/dist-lib/plist.cjs +1 -1
  55. package/dist-lib/plist.mjs +1 -1
  56. package/dist-lib/profiles.cjs +1 -1
  57. package/dist-lib/public-brand.cjs +1 -1
  58. package/dist-lib/react-native-host-modules.cjs +1 -1
  59. package/dist-lib/react-native-host-modules.mjs +1 -1
  60. package/dist-lib/render-mode.cjs +1 -1
  61. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  62. package/dist-lib/sdk.cjs +1 -1
  63. package/dist-lib/sdk.mjs +1 -1
  64. package/dist-lib/skills.cjs +1332 -177
  65. package/dist-lib/swift-compile.cjs +1 -1
  66. package/dist-lib/swift-compile.mjs +1 -1
  67. package/dist-lib/swift-package.cjs +1 -1
  68. package/dist-lib/swift-package.mjs +1 -1
  69. package/dist-lib/swift-submit.cjs +1 -1
  70. package/dist-lib/swift-submit.mjs +1 -1
  71. package/dist-lib/swift.cjs +1 -1
  72. package/dist-lib/vite-swift.cjs +1 -1
  73. package/dist-lib/vite.cjs +1 -1
  74. package/kotlin/AppSources/android/view/Window.kt +15 -9
  75. package/kotlin/AppSources/androidx/activity/ActivityViewModelLazy.kt +21 -0
  76. package/kotlin/AppSources/androidx/activity/ComponentActivity.kt +5 -3
  77. package/kotlin/AppSources/androidx/lifecycle/AndroidViewModel.kt +10 -0
  78. package/kotlin/AppSources/dev/peach/app/PeachApp.kt +37 -16
  79. package/kotlin/tools/abi-classes.main.kts +41 -0
  80. package/package.json +2 -1
  81. package/src/android-theme.ts +14 -0
  82. package/src/backend-origin.ts +25 -0
  83. package/src/host/bridge-host.ts +24 -37
  84. package/src/kotlin-app.ts +22 -4
  85. package/src/kotlin-host.ts +94 -12
  86. package/src/kotlin-view-models.ts +133 -0
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.660 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.662 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.660 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.662 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/swift-compile.ts
4
4
  import { execFile as execFile2 } from "node:child_process";
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.660 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.662 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.660 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.662 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/swift-package.ts
4
4
  function isSwiftManifestFile(file) {
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.660 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.662 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.660 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.662 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/swift-submit.ts
4
4
  var SWIFT_COMPILE_PATH = "/api/swift-compile";
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.660 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.662 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.660 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.662 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
package/dist-lib/vite.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.660 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.662 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -13,6 +13,7 @@ import org.json.JSONObject
13
13
  internal class WindowTheme(
14
14
  val edgeToEdgeEnforced: Boolean,
15
15
  val statusBarColor: Int,
16
+ val navigationBarColor: Int,
16
17
  val statusBarContrastEnforced: Boolean,
17
18
  val navigationBarContrastEnforced: Boolean,
18
19
  val lightStatusBar: Boolean,
@@ -62,15 +63,17 @@ class Window internal constructor(
62
63
  fun getStatusBarColor(): Int = statusBarColor
63
64
  fun setStatusBarColor(color: Int) { statusBarColor = color }
64
65
 
65
- // the theme's navigation bar color resolves through device configuration
66
- // peach kotlin does not model, so it reads only once the app sets it; an
67
- // enforced edge to edge window reports transparent
68
- private var navigationBar: Int? = null
66
+ // the theme's navigation bar color as PhoneWindow resolved it; an enforced
67
+ // edge to edge window reports and draws transparent
68
+ private var navigationBar = theme.navigationBarColor
69
69
 
70
70
  var navigationBarColor: Int
71
- get() = if (edgeToEdgeEnforced) Color.TRANSPARENT
72
- else checkNotNull(navigationBar) { "the theme's navigation bar color is not modeled; read it after setting it" }
73
- set(color) { navigationBar = color }
71
+ get() = if (edgeToEdgeEnforced) Color.TRANSPARENT else navigationBar
72
+ set(color) {
73
+ if (color == navigationBar) return
74
+ navigationBar = color
75
+ host.windowChanged()
76
+ }
74
77
 
75
78
  fun getNavigationBarColor(): Int = navigationBarColor
76
79
  fun setNavigationBarColor(color: Int) { navigationBarColor = color }
@@ -113,11 +116,14 @@ class Window internal constructor(
113
116
  return SCRIM_LIGHT
114
117
  }
115
118
 
116
- // what the decor draws for this window: its inset policy and its status
117
- // bar color as unsigned ARGB
119
+ // what the decor draws for this window: its inset policy and its bar
120
+ // colors as unsigned ARGB. gesture navigation draws no contrast scrim
121
+ // behind a transparent navigation bar, as the oracle's edge to edge
122
+ // captures show
118
123
  internal fun describe(): JSONObject = JSONObject()
119
124
  .put("decorFitsSystemWindows", fits)
120
125
  .put("statusBarColor", drawnStatusBarColor().toLong() and 0xffffffffL)
126
+ .put("navigationBarColor", navigationBarColor.toLong() and 0xffffffffL)
121
127
 
122
128
  // Activity.setContentView(View) fills the content parent
123
129
  internal fun setContentView(view: View, params: ViewGroup.LayoutParams) {
@@ -0,0 +1,21 @@
1
+ package androidx.activity
2
+
3
+ import androidx.lifecycle.ViewModel
4
+ import androidx.lifecycle.ViewModelLazy
5
+ import androidx.lifecycle.ViewModelProvider
6
+ import androidx.lifecycle.viewmodel.CreationExtras
7
+
8
+ // activity-ktx's viewModels(): the activity's store, its default factory and
9
+ // creation extras unless the caller passes its own
10
+ inline fun <reified VM : ViewModel> ComponentActivity.viewModels(
11
+ noinline extrasProducer: (() -> CreationExtras)? = null,
12
+ noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null,
13
+ ): Lazy<VM> {
14
+ val factoryPromise = factoryProducer ?: { defaultViewModelProviderFactory }
15
+ return ViewModelLazy(
16
+ VM::class,
17
+ { viewModelStore },
18
+ factoryPromise,
19
+ { extrasProducer?.invoke() ?: this.defaultViewModelCreationExtras },
20
+ )
21
+ }
@@ -8,7 +8,6 @@ import androidx.lifecycle.LifecycleEventObserver
8
8
  import androidx.lifecycle.LifecycleOwner
9
9
  import androidx.lifecycle.LifecycleRegistry
10
10
  import androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY
11
- import androidx.lifecycle.SavedStateViewModelFactory
12
11
  import androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY
13
12
  import androidx.lifecycle.ViewModelStore
14
13
  import androidx.lifecycle.ViewModelStoreOwner
@@ -23,7 +22,8 @@ import androidx.savedstate.SavedStateRegistryOwner
23
22
  // lifecycle, viewmodel and savedstate libraries: the registry attaches in the
24
23
  // constructor and restores in onCreate before super, the lifecycle moves up on
25
24
  // the post callbacks and down on the pre callbacks, as ReportFragment does from
26
- // API 29, and destroy clears the view model store.
25
+ // API 29, and destroy clears the view model store unless the activity is
26
+ // changing configurations.
27
27
  open class ComponentActivity : Activity(), LifecycleOwner, ViewModelStoreOwner,
28
28
  HasDefaultViewModelProviderFactory, SavedStateRegistryOwner, OnBackPressedDispatcherOwner {
29
29
  private val lifecycleRegistry = LifecycleRegistry(this)
@@ -48,7 +48,9 @@ open class ComponentActivity : Activity(), LifecycleOwner, ViewModelStoreOwner,
48
48
  }
49
49
  }
50
50
  override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
51
- override val defaultViewModelProviderFactory = SavedStateViewModelFactory()
51
+ // SavedStateViewModelFactory's constructor choice, made at compile time
52
+ // for the app's view models since Kotlin/Wasm has no reflection
53
+ override val defaultViewModelProviderFactory get() = dev.peach.app.viewModelFactory()
52
54
  override val defaultViewModelCreationExtras: CreationExtras
53
55
  get() = MutableCreationExtras().also {
54
56
  it[SAVED_STATE_REGISTRY_OWNER_KEY] = this
@@ -0,0 +1,10 @@
1
+ package androidx.lifecycle
2
+
3
+ import android.app.Application
4
+
5
+ // androidx.lifecycle's AndroidViewModel: a view model that holds its
6
+ // application, which the default factory passes to its constructor
7
+ open class AndroidViewModel(private val application: Application) : ViewModel() {
8
+ @Suppress("UNCHECKED_CAST")
9
+ open fun <T : Application> getApplication(): T = application as T
10
+ }
@@ -15,6 +15,7 @@ import android.view.ViewGroup
15
15
  import android.view.Window
16
16
  import android.view.WindowTheme
17
17
  import androidx.activity.ComponentActivity
18
+ import androidx.lifecycle.ViewModelProvider
18
19
  import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
19
20
  import androidx.compose.runtime.Composable
20
21
  import androidx.compose.runtime.CompositionLocalProvider
@@ -51,21 +52,41 @@ internal class PeachAppSpec(
51
52
  // the activity's configChanges includes uiMode: it takes a night mode
52
53
  // change in onConfigurationChanged rather than relaunching
53
54
  val handlesUiMode: Boolean,
55
+ // the app's view models, each built with the constructor the device's
56
+ // default factory finds for it
57
+ val viewModels: ViewModelProvider.Factory,
54
58
  )
55
59
 
56
- // the system bars the device delivers to the window's content, in pixels: all
57
- // of them on an edge-to-edge window, none once the decor fits system windows.
58
- private class SystemBarInsets(
59
- private val window: Window,
60
- private val status: Int,
61
- private val navigation: Int,
62
- ) : PlatformWindowInsets {
60
+ internal fun viewModelFactory(): ViewModelProvider.Factory = runningApp().spec.viewModels
61
+
62
+ internal fun peachApplication(): Application = runningApp().application
63
+
64
+ // the insets the device delivers to the window's content, in pixels: all of
65
+ // them on an edge-to-edge window, none once the decor fits system windows.
66
+ // insets lists the status bars, navigation bars, display cutout, system
67
+ // gestures, mandatory system gestures and tappable element, each left, top,
68
+ // right and bottom.
69
+ private class DeviceInsets(private val window: Window, insets: String) : PlatformWindowInsets {
70
+ private val pixels = insets.split(',').map { it.toInt() }.also { require(it.size == 24) }
71
+
63
72
  // read when compose lays out, so a later change to the window applies
64
- private fun delivered(pixels: Int) = if (window.fitsSystemWindows) 0 else pixels
65
- override val statusBars = PlatformInsets(getTop = { delivered(status) })
66
- override val navigationBars = PlatformInsets(getBottom = { delivered(navigation) })
67
- override val systemBars =
68
- PlatformInsets(getTop = { delivered(status) }, getBottom = { delivered(navigation) })
73
+ private fun type(index: Int): PlatformInsets {
74
+ fun side(offset: Int) = { if (window.fitsSystemWindows) 0 else pixels[index * 4 + offset] }
75
+ return PlatformInsets(side(0), side(1), side(2), side(3))
76
+ }
77
+
78
+ override val statusBars = type(0)
79
+ override val navigationBars = type(1)
80
+ override val displayCutout = type(2)
81
+ override val systemGestures = type(3)
82
+ override val mandatorySystemGestures = type(4)
83
+ override val tappableElement = type(5)
84
+ override val systemBars = PlatformInsets(
85
+ { maxOf(statusBars.left, navigationBars.left) },
86
+ { maxOf(statusBars.top, navigationBars.top) },
87
+ { maxOf(statusBars.right, navigationBars.right) },
88
+ { maxOf(statusBars.bottom, navigationBars.bottom) },
89
+ )
69
90
  }
70
91
 
71
92
  private class ActivityOwners(activity: ComponentActivity) : PlatformArchitectureComponentsOwner {
@@ -85,7 +106,7 @@ private class AppProcess(
85
106
  val application: Application,
86
107
  val density: Float, val fontScale: Float, val locale: String, val gpu: Boolean, val stencilBits: Int,
87
108
  val fonts: FontFamily,
88
- val statusBar: Int, val navigationBar: Int,
109
+ val insets: String,
89
110
  val invalidate: (Boolean) -> Unit,
90
111
  ) {
91
112
  lateinit var activity: Activity
@@ -120,7 +141,7 @@ internal fun peachStartApp(
120
141
  spec: PeachAppSpec,
121
142
  density: Float, fontScale: Float, locale: String, backend: String, stencilBits: Int,
122
143
  regular: String, medium: String, bold: String,
123
- statusBar: Int, navigationBar: Int, night: Boolean,
144
+ insets: String, night: Boolean,
124
145
  invalidate: (Boolean) -> Unit,
125
146
  ): String {
126
147
  try {
@@ -131,7 +152,7 @@ internal fun peachStartApp(
131
152
  application.onCreate()
132
153
  val app = AppProcess(
133
154
  spec, application, density, fontScale, locale, backend == "graphite", stencilBits,
134
- robotoFamily(regular, medium, bold), statusBar, navigationBar, invalidate,
155
+ robotoFamily(regular, medium, bold), insets, invalidate,
135
156
  )
136
157
  process = app
137
158
  val view = launchActivity(app, null)
@@ -201,7 +222,7 @@ private fun launchActivity(app: AppProcess, lastNonConfigurationInstance: Any?):
201
222
  app.density, app.fontScale, app.locale, app.gpu, app.stencilBits, app.fonts, app.invalidate,
202
223
  night = isNight(activity.resources.configuration),
203
224
  owners = componentActivity?.let(::ActivityOwners),
204
- insets = SystemBarInsets(window, app.statusBar, app.navigationBar),
225
+ insets = DeviceInsets(window, app.insets),
205
226
  clockRunning = { componentActivity == null || componentActivity.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) },
206
227
  )
207
228
  mount.setContent {
@@ -0,0 +1,41 @@
1
+ // prints an app klib's classes as the compiler resolved them, one JSON object
2
+ // per line: qualified name, kind, modality, whether it is inner or generic,
3
+ // its supertypes and its public constructors' parameter types, each marked
4
+ // when every parameter has a default
5
+ @file:OptIn(org.jetbrains.kotlin.library.abi.ExperimentalLibraryAbiReader::class)
6
+
7
+ import org.jetbrains.kotlin.library.abi.*
8
+
9
+ fun name(qualified: AbiQualifiedName): String {
10
+ val pkg = qualified.packageName.value.replace('/', '.')
11
+ val relative = qualified.relativeName.value
12
+ return if (pkg.isEmpty()) relative else "$pkg.$relative"
13
+ }
14
+
15
+ fun type(type: AbiType): String =
16
+ ((type as? AbiType.Simple)?.classifierReference as? AbiClassifierReference.ClassReference)
17
+ ?.let { name(it.className) } ?: "?"
18
+
19
+ fun quote(value: String) = "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
20
+
21
+ fun walk(declaration: AbiDeclaration) {
22
+ if (declaration !is AbiClass) return
23
+ val constructors = declaration.declarations.filterIsInstance<AbiFunction>()
24
+ .filter { it.isConstructor }
25
+ .map { constructor ->
26
+ val parameters = constructor.valueParameters.joinToString(",", "[", "]") { quote(type(it.type)) }
27
+ "{\"parameters\":$parameters,\"defaulted\":${constructor.valueParameters.all { it.hasDefaultArg }}}"
28
+ }
29
+ println(
30
+ "{\"name\":${quote(name(declaration.qualifiedName))}" +
31
+ ",\"kind\":${quote(declaration.kind.name)}" +
32
+ ",\"modality\":${quote(declaration.modality.name)}" +
33
+ ",\"inner\":${declaration.isInner}" +
34
+ ",\"generic\":${declaration.typeParameters.isNotEmpty()}" +
35
+ ",\"supers\":${declaration.superTypes.joinToString(",", "[", "]") { quote(type(it)) }}" +
36
+ ",\"constructors\":${constructors.joinToString(",", "[", "]")}}",
37
+ )
38
+ declaration.declarations.forEach(::walk)
39
+ }
40
+
41
+ LibraryAbiReader.readAbiInfo(java.io.File(args[0])).topLevelDeclarations.declarations.forEach(::walk)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peachsim",
3
- "version": "0.1.660",
3
+ "version": "0.1.662",
4
4
  "description": "Vite and Metro plugins, testing drivers, and SDK exports for peach.",
5
5
  "author": "Tamagui LLC",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -303,6 +303,7 @@
303
303
  "build:cli-binary": "bun scripts/build-cli-binary.ts",
304
304
  "smoke:cli-binary": "bun scripts/smoke-cli-binary.ts",
305
305
  "publish:cli-binary": "bun scripts/publish-cli-binaries.ts",
306
+ "publish:linux-browser-libs": "bun scripts/linux-browser-libs.ts publish",
306
307
  "pack:smoke": "bun scripts/pack-smoke.ts"
307
308
  },
308
309
  "dependencies": {
@@ -165,3 +165,17 @@ export function resolveThemeAttribute(
165
165
  // unset: the reader's own default applies, as PhoneWindow's getBoolean/getColor do
166
166
  return undefined
167
167
  }
168
+
169
+ // a framework color resource as context.getColor reads it, following
170
+ // references to other framework colors
171
+ export function frameworkColor(name: string, sources: ThemeSources): number {
172
+ let value = sources.android.colors[name]
173
+ while (value !== undefined && !value.startsWith('#')) {
174
+ const ref = /^@(?:android:)?color\/(.+)$/.exec(value)
175
+ if (!ref?.[1])
176
+ throw new Error(`@android:color/${name} has the unsupported value ${value}`)
177
+ value = sources.android.colors[ref[1]]
178
+ }
179
+ if (value === undefined) throw new Error(`@android:color/${name} is not defined`)
180
+ return color(value)
181
+ }
@@ -46,6 +46,31 @@ export function buildBrowserAssetProxyUrl(targetUrl: string): string {
46
46
  return `${proxyPath}?url=${encodeURIComponent(targetUrl)}`
47
47
  }
48
48
 
49
+ export function resolveBrowserAssetProxyUrl(
50
+ targetUrl: string,
51
+ currentOrigin: string,
52
+ ): string {
53
+ const proxyUrl = isBrowserAssetProxyUrl(targetUrl)
54
+ ? targetUrl
55
+ : buildBrowserAssetProxyUrl(targetUrl)
56
+ // loopback assets belong to the local bridge; external bytes to the backend.
57
+ if (proxyUrl.startsWith('/__bundle-proxy?')) return proxyUrl
58
+ return new URL(proxyUrl, resolveContrastBackendOrigin(currentOrigin)).toString()
59
+ }
60
+
61
+ export function isBrowserAssetProxyUrl(value: string): boolean {
62
+ try {
63
+ const url = new URL(value, DEFAULT_CONTRAST_BACKEND_ORIGIN)
64
+ return (
65
+ isContrastBackendOrigin(url.origin) &&
66
+ url.pathname === '/__fetch-proxy' &&
67
+ url.searchParams.has('url')
68
+ )
69
+ } catch {
70
+ return false
71
+ }
72
+ }
73
+
49
74
  export function isContrastBackendHost(hostname: string): boolean {
50
75
  const normalized = normalizeHostname(hostname)
51
76
  return (
@@ -592,24 +592,11 @@ export class SootSimBridgeHost {
592
592
  // subscriptions get cleaned up automatically on close.
593
593
  this.agentHost.registerSocket(ws)
594
594
 
595
- if (role === 'sim') {
596
- sim = {
597
- id: this.allocateSimId(),
598
- ws,
599
- origin,
600
- connectedAt: Date.now(),
601
- lastSeenAt: Date.now(),
602
- lastActiveAt: 0,
603
- recentActions: [],
604
- }
605
- this.sims.set(sim.id, sim)
606
- this.writeConnectedRuntimeSnapshot()
607
- if (this.shouldPromoteSim(sim)) {
608
- this.primarySimId = sim.id
609
- }
610
- this.broadcastSimAssignments()
611
- this.broadcastSimClientStates()
612
- } else {
595
+ // a browser socket becomes a sim when its page registers. the shell's
596
+ // agent bridge and other page helpers share this endpoint without ever
597
+ // registering, so giving them a sim id at connect listed ghost sims
598
+ // that took an id and could be neither driven nor closed.
599
+ if (role === 'cli') {
613
600
  const fallbackKey = `ws-${this.nextCliFallbackId++}`
614
601
  this.cliIdentityKeyBySocket.set(ws, fallbackKey)
615
602
  }
@@ -709,26 +696,30 @@ export class SootSimBridgeHost {
709
696
  // Origin-based role before this socket has registered a page. This reuses the existing
710
697
  // daemon transport and call envelope; it does not add a second routing
711
698
  // surface or let a registered sim switch roles mid-connection.
712
- if (role === 'sim' && msg.type === 'bridge:hello' && sim && !sim.url) {
713
- const provisionalId = sim.id
714
- this.sims.delete(provisionalId)
715
- if (this.primarySimId === provisionalId) {
716
- this.primarySimId = this.getOpenSim()?.id ?? null
717
- }
718
- sim = null
699
+ if (role === 'sim' && msg.type === 'bridge:hello' && !sim) {
719
700
  role = 'cli'
720
701
  this.cliIdentityKeyBySocket.set(ws, `ws-${this.nextCliFallbackId++}`)
721
- this.writeConnectedRuntimeSnapshot()
722
- this.broadcastSimAssignments()
723
- this.broadcastSimClientStates()
724
702
  }
725
703
 
726
704
  if (role === 'sim') {
727
705
  if (sim) {
728
706
  sim.lastSeenAt = Date.now()
729
707
  }
730
- if (msg.type === 'bridge:register' && sim) {
708
+ if (msg.type === 'bridge:register') {
731
709
  const registration = msg as BridgeSimRegistrationMessage
710
+ const firstRegistration = !sim
711
+ if (!sim) {
712
+ sim = {
713
+ id: this.allocateSimId(),
714
+ ws,
715
+ origin,
716
+ connectedAt: Date.now(),
717
+ lastSeenAt: Date.now(),
718
+ lastActiveAt: 0,
719
+ recentActions: [],
720
+ }
721
+ this.sims.set(sim.id, sim)
722
+ }
732
723
  const restored = this.tryRestoreSimId(sim, registration.simId)
733
724
  sim.url = registration.url
734
725
  sim.title = registration.title
@@ -743,16 +734,12 @@ export class SootSimBridgeHost {
743
734
  if (registration.meta && typeof registration.meta === 'object') {
744
735
  sim.meta = registration.meta as Record<string, unknown>
745
736
  }
746
- // re-evaluate primary now that the sim has a real page. promotion
747
- // is otherwise decided once at bare-connect, before `url` is
748
- // known — so a page-less sim that grabbed primary as the
749
- // last-resort fallback (or a stale zombie still holding it)
750
- // would never be superseded by this freshly-registered, actually
751
- // driveable sim (QA F19-2). only adopt a *different* sim so a
752
- // routine heartbeat re-register doesn't churn the assignment.
737
+ // elect primary once the sim has a page; only adopt a *different*
738
+ // sim so a routine heartbeat re-register doesn't churn the
739
+ // assignment.
753
740
  const reElected = this.primarySimId !== sim.id && this.shouldPromoteSim(sim)
754
741
  if (reElected) this.primarySimId = sim.id
755
- if (restored || reElected) {
742
+ if (firstRegistration || restored || reElected) {
756
743
  this.broadcastSimAssignments()
757
744
  this.broadcastSimClientStates()
758
745
  }
package/src/kotlin-app.ts CHANGED
@@ -5,7 +5,11 @@
5
5
 
6
6
  import fs from 'node:fs'
7
7
  import path from 'node:path'
8
- import { readResourceValues, resolveThemeAttribute } from './android-theme'
8
+ import {
9
+ frameworkColor,
10
+ readResourceValues,
11
+ resolveThemeAttribute,
12
+ } from './android-theme'
9
13
  import { parseXml, type XmlElement } from './android-xml'
10
14
  import frameworkThemes from './generated/android-framework-themes.json' with { type: 'json' }
11
15
 
@@ -18,10 +22,11 @@ export interface KotlinAppProject {
18
22
  namespace: string
19
23
  targetSdk: number
20
24
  // the launched activity's window as its theme and target SDK configure it;
21
- // statusBarColor is ARGB
25
+ // statusBarColor and navigationBarColor are ARGB
22
26
  window: {
23
27
  edgeToEdgeEnforced: boolean
24
28
  statusBarColor: number
29
+ navigationBarColor: number
25
30
  statusBarContrastEnforced: boolean
26
31
  navigationBarContrastEnforced: boolean
27
32
  lightStatusBar: boolean
@@ -221,9 +226,22 @@ function readWindowTheme(
221
226
  lightStatusBar: flag('windowLightStatusBar'),
222
227
  lightNavigationBar: flag('windowLightNavigationBar'),
223
228
  }
229
+ // the theme's navigation bar color; left at the framework's transparent
230
+ // default, a window that is not edge to edge takes the compatible color
231
+ // unless the device's config_navBarDefaultTransparent is set, which it is
232
+ // not on the pixel 8 android 17 device the oracle measures
233
+ const navigationColor = resolveThemeAttribute(name, 'navigationBarColor', sources)
234
+ if (typeof navigationColor === 'boolean')
235
+ throw new Error(`${name}: navigationBarColor is not a color`)
236
+ const navigationDefault = frameworkColor('navigation_bar_default', sources)
237
+ const navigationTheme = navigationColor ?? navigationDefault
238
+ const navigationBarColor =
239
+ navigationTheme === navigationDefault && !edgeToEdgeEnforced
240
+ ? frameworkColor('navigation_bar_compatible', sources)
241
+ : navigationTheme
224
242
  // PhoneWindow enforces edge to edge apart from whether the theme draws bar
225
243
  // backgrounds: either way the bar is transparent
226
- if (edgeToEdgeEnforced) return { ...window, statusBarColor: 0 }
244
+ if (edgeToEdgeEnforced) return { ...window, statusBarColor: 0, navigationBarColor }
227
245
  if (!flag('windowDrawsSystemBarBackgrounds'))
228
246
  throw new Error(
229
247
  `${name} does not draw system bar backgrounds; peach kotlin draws only theme status bar colors`,
@@ -231,7 +249,7 @@ function readWindowTheme(
231
249
  const color = resolveThemeAttribute(name, 'statusBarColor', sources)
232
250
  if (typeof color === 'boolean')
233
251
  throw new Error(`${name}: statusBarColor is not a color`)
234
- return { ...window, statusBarColor: color ?? 0xff000000 }
252
+ return { ...window, statusBarColor: color ?? 0xff000000, navigationBarColor }
235
253
  }
236
254
 
237
255
  function enabled(element: XmlElement): boolean {