peachsim 0.1.659 → 0.1.661

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 (96) hide show
  1. package/README.md +10 -15
  2. package/cli/cli-update.ts +53 -21
  3. package/cli/commands/compat.ts +6 -2
  4. package/cli/commands/control.ts +12 -3
  5. package/cli/commands/inspect.ts +16 -14
  6. package/cli/commands/upgrade.ts +4 -2
  7. package/cli/current-sim.ts +4 -0
  8. package/cli/drivers/linux-browser-libs.lock.json +1088 -0
  9. package/cli/drivers/linux-browser-libs.ts +182 -0
  10. package/cli/drivers/playwright-provisioning.ts +20 -98
  11. package/cli/drivers/playwright-sim-host.ts +9 -0
  12. package/cli/drivers/playwright.ts +2 -1
  13. package/dist-lib/agent-daemon-client.cjs +1 -1
  14. package/dist-lib/agent-events.cjs +1 -1
  15. package/dist-lib/agent-identity.cjs +1 -1
  16. package/dist-lib/agent-sessions.cjs +1 -1
  17. package/dist-lib/attached-projects.cjs +1 -1
  18. package/dist-lib/auth/shared-session.cjs +1 -1
  19. package/dist-lib/backend-origin.cjs +18 -1
  20. package/dist-lib/beta.cjs +1 -1
  21. package/dist-lib/beta.mjs +1 -1
  22. package/dist-lib/bridge-constants.cjs +1 -1
  23. package/dist-lib/bridge-contract-input.cjs +1 -1
  24. package/dist-lib/bridge-contract-input.mjs +1 -1
  25. package/dist-lib/bridge-contract.cjs +1 -1
  26. package/dist-lib/bridge-contract.mjs +1 -1
  27. package/dist-lib/capture-contract.cjs +1 -1
  28. package/dist-lib/capture-contract.mjs +1 -1
  29. package/dist-lib/cli-constants.cjs +1 -1
  30. package/dist-lib/cloud-contract.cjs +1 -1
  31. package/dist-lib/cloud-contract.mjs +1 -1
  32. package/dist-lib/cloud-test-args.cjs +1 -1
  33. package/dist-lib/cloud-test-args.mjs +1 -1
  34. package/dist-lib/cloud.cjs +1 -1
  35. package/dist-lib/cloud.mjs +1 -1
  36. package/dist-lib/config.cjs +1 -1
  37. package/dist-lib/detox/index.cjs +1 -1
  38. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  39. package/dist-lib/home-paths.cjs +1 -1
  40. package/dist-lib/host/bridge-host.cjs +45 -41
  41. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  42. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  43. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  44. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  45. package/dist-lib/host/websocket-proxy.cjs +1 -1
  46. package/dist-lib/index.cjs +5 -1
  47. package/dist-lib/jump-to-source-babel.cjs +1 -1
  48. package/dist-lib/jump-to-source-native.cjs +1 -1
  49. package/dist-lib/kotlin-host.cjs +8 -3
  50. package/dist-lib/menu.cjs +1 -1
  51. package/dist-lib/menu.mjs +1 -1
  52. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  53. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  54. package/dist-lib/metro-production-bundle.cjs +1 -1
  55. package/dist-lib/metro-production-bundle.mjs +1 -1
  56. package/dist-lib/metro.cjs +1 -1
  57. package/dist-lib/native-app-identity.cjs +1 -1
  58. package/dist-lib/native-app-identity.mjs +1 -1
  59. package/dist-lib/plist.cjs +1 -1
  60. package/dist-lib/plist.mjs +1 -1
  61. package/dist-lib/profiles.cjs +1 -1
  62. package/dist-lib/public-brand.cjs +1 -1
  63. package/dist-lib/react-native-host-modules.cjs +1 -1
  64. package/dist-lib/react-native-host-modules.mjs +1 -1
  65. package/dist-lib/render-mode.cjs +1 -1
  66. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  67. package/dist-lib/sdk.cjs +1 -1
  68. package/dist-lib/sdk.mjs +1 -1
  69. package/dist-lib/skills.cjs +1339 -180
  70. package/dist-lib/swift-compile.cjs +1 -1
  71. package/dist-lib/swift-compile.mjs +1 -1
  72. package/dist-lib/swift-package.cjs +1 -1
  73. package/dist-lib/swift-package.mjs +1 -1
  74. package/dist-lib/swift-submit.cjs +1 -1
  75. package/dist-lib/swift-submit.mjs +1 -1
  76. package/dist-lib/swift.cjs +1 -1
  77. package/dist-lib/vite-swift.cjs +1 -1
  78. package/dist-lib/vite.cjs +1 -1
  79. package/kotlin/AppSources/android/app/Activity.kt +48 -3
  80. package/kotlin/AppSources/android/app/Application.kt +4 -0
  81. package/kotlin/AppSources/android/content/res/Configuration.kt +4 -0
  82. package/kotlin/AppSources/android/content/res/Resources.kt +3 -1
  83. package/kotlin/AppSources/android/view/Window.kt +15 -9
  84. package/kotlin/AppSources/androidx/activity/ComponentActivity.kt +36 -3
  85. package/kotlin/AppSources/dev/peach/app/PeachApp.kt +233 -86
  86. package/kotlin/ComposeSources/PeachComposeHost.kt +15 -3
  87. package/kotlin/fork/README.md +1 -0
  88. package/kotlin/fork/compose-worker-globals.patch +26 -0
  89. package/kotlin/fork/fork.json +1 -1
  90. package/kotlin/toolchain.json +3 -3
  91. package/package.json +2 -1
  92. package/src/android-theme.ts +14 -0
  93. package/src/backend-origin.ts +25 -0
  94. package/src/host/bridge-host.ts +24 -37
  95. package/src/kotlin-app.ts +29 -4
  96. package/src/kotlin-host.ts +7 -2
@@ -24,13 +24,16 @@ import androidx.compose.ui.platform.PlatformInsets
24
24
  import androidx.compose.ui.platform.PlatformWindowInsets
25
25
  import androidx.compose.runtime.getValue
26
26
  import androidx.compose.runtime.key
27
+ import androidx.compose.runtime.mutableIntStateOf
27
28
  import androidx.compose.runtime.mutableStateOf
28
29
  import androidx.compose.runtime.setValue
30
+ import androidx.compose.ui.text.font.FontFamily
29
31
  import androidx.lifecycle.Lifecycle
30
32
  import androidx.navigationevent.NavigationEventDispatcher
31
33
  import androidx.navigationevent.NavigationEventDispatcherOwner
32
34
  import PeachComposeMount
33
35
  import addMount
36
+ import mounts
34
37
  import preparePeachDevice
35
38
  import robotoFamily
36
39
  import org.json.JSONObject
@@ -45,21 +48,37 @@ internal class PeachAppSpec(
45
48
  val window: WindowTheme,
46
49
  val createApplication: () -> Application,
47
50
  val createActivity: () -> Activity,
51
+ // the activity's configChanges includes uiMode: it takes a night mode
52
+ // change in onConfigurationChanged rather than relaunching
53
+ val handlesUiMode: Boolean,
48
54
  )
49
55
 
50
- // the system bars the device delivers to the window's content, in pixels: all
51
- // of them on an edge-to-edge window, none once the decor fits system windows.
52
- private class SystemBarInsets(
53
- private val window: Window,
54
- private val status: Int,
55
- private val navigation: Int,
56
- ) : PlatformWindowInsets {
56
+ // the insets the device delivers to the window's content, in pixels: all of
57
+ // them on an edge-to-edge window, none once the decor fits system windows.
58
+ // insets lists the status bars, navigation bars, display cutout, system
59
+ // gestures, mandatory system gestures and tappable element, each left, top,
60
+ // right and bottom.
61
+ private class DeviceInsets(private val window: Window, insets: String) : PlatformWindowInsets {
62
+ private val pixels = insets.split(',').map { it.toInt() }.also { require(it.size == 24) }
63
+
57
64
  // read when compose lays out, so a later change to the window applies
58
- private fun delivered(pixels: Int) = if (window.fitsSystemWindows) 0 else pixels
59
- override val statusBars = PlatformInsets(getTop = { delivered(status) })
60
- override val navigationBars = PlatformInsets(getBottom = { delivered(navigation) })
61
- override val systemBars =
62
- PlatformInsets(getTop = { delivered(status) }, getBottom = { delivered(navigation) })
65
+ private fun type(index: Int): PlatformInsets {
66
+ fun side(offset: Int) = { if (window.fitsSystemWindows) 0 else pixels[index * 4 + offset] }
67
+ return PlatformInsets(side(0), side(1), side(2), side(3))
68
+ }
69
+
70
+ override val statusBars = type(0)
71
+ override val navigationBars = type(1)
72
+ override val displayCutout = type(2)
73
+ override val systemGestures = type(3)
74
+ override val mandatorySystemGestures = type(4)
75
+ override val tappableElement = type(5)
76
+ override val systemBars = PlatformInsets(
77
+ { maxOf(statusBars.left, navigationBars.left) },
78
+ { maxOf(statusBars.top, navigationBars.top) },
79
+ { maxOf(statusBars.right, navigationBars.right) },
80
+ { maxOf(statusBars.bottom, navigationBars.bottom) },
81
+ )
63
82
  }
64
83
 
65
84
  private class ActivityOwners(activity: ComponentActivity) : PlatformArchitectureComponentsOwner {
@@ -71,6 +90,41 @@ private class ActivityOwners(activity: ComponentActivity) : PlatformArchitecture
71
90
  }
72
91
  }
73
92
 
93
+ // the device and the application one started app process keeps, and what
94
+ // ActivityThread's record holds for its one activity: whether it is paused or
95
+ // stopped, and the state it saved when it stopped, until it resumes
96
+ private class AppProcess(
97
+ val spec: PeachAppSpec,
98
+ val application: Application,
99
+ val density: Float, val fontScale: Float, val locale: String, val gpu: Boolean, val stencilBits: Int,
100
+ val fonts: FontFamily,
101
+ val insets: String,
102
+ val invalidate: (Boolean) -> Unit,
103
+ ) {
104
+ lateinit var activity: Activity
105
+ lateinit var mount: PeachComposeMount
106
+ // the window the device presents, once its scene is in place
107
+ var window: Window? = null
108
+ var mountId = 0
109
+ // the held view's place among its window's views; a relaunched window's
110
+ // first view takes the place the last window's view held, as it takes
111
+ // that view's saved state on the device
112
+ var view = 0
113
+ var paused = false
114
+ var stopped = false
115
+ var state: Bundle? = null
116
+ // the night mode last reported to the activity: ActivityRecord compares
117
+ // the configuration with it before a relaunch or onConfigurationChanged
118
+ var reportedNight = false
119
+ }
120
+
121
+ private var process: AppProcess? = null
122
+
123
+ private fun nightConfiguration(night: Boolean) = Configuration().apply {
124
+ uiMode = Configuration.UI_MODE_TYPE_NORMAL or
125
+ if (night) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
126
+ }
127
+
74
128
  // a cold start in ActivityThread's order: the Application and its onCreate,
75
129
  // then the launcher activity attached to its window and launcher intent,
76
130
  // onCreate, onStart, onPostCreate, onResume and onPostResume, and only then the
@@ -79,71 +133,23 @@ internal fun peachStartApp(
79
133
  spec: PeachAppSpec,
80
134
  density: Float, fontScale: Float, locale: String, backend: String, stencilBits: Int,
81
135
  regular: String, medium: String, bold: String,
82
- statusBar: Int, navigationBar: Int, night: Boolean,
136
+ insets: String, night: Boolean,
83
137
  invalidate: (Boolean) -> Unit,
84
138
  ): String {
85
139
  try {
86
140
  require(backend == "graphite" || backend == "raster")
87
141
  preparePeachDevice(locale)
88
- processResources = Resources(Configuration().apply {
89
- uiMode = Configuration.UI_MODE_TYPE_NORMAL or
90
- if (night) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
91
- })
92
- val fonts = robotoFamily(regular, medium, bold)
142
+ processResources = Resources(nightConfiguration(night))
93
143
  val application = spec.createApplication()
94
144
  application.onCreate()
95
- val activity = spec.createActivity()
96
- val componentActivity = activity as? ComponentActivity
97
- lateinit var window: Window
98
- lateinit var mount: PeachComposeMount
99
- var mountId = 0
100
- // the window's one scene composes the ComposeView it holds; a new
101
- // view starts a new composition, as on the device
102
- var held by mutableStateOf<ComposeView?>(null)
103
- val host = object : PeachWindowHost {
104
- override fun compose(view: ComposeView): AutoCloseable {
105
- held = view
106
- return AutoCloseable { if (held === view) held = null }
107
- }
108
-
109
- override fun windowChanged() {
110
- if (mountId != 0)
111
- mount.emitAtFrame("window") { window.describe().toString() }
112
- }
113
- }
114
- window = Window(activity, spec.window, host)
115
- activity.attach(
116
- application,
117
- spec.component,
118
- Intent(Intent.ACTION_MAIN, setOf(Intent.CATEGORY_LAUNCHER), spec.launcher),
119
- window,
145
+ val app = AppProcess(
146
+ spec, application, density, fontScale, locale, backend == "graphite", stencilBits,
147
+ robotoFamily(regular, medium, bold), insets, invalidate,
120
148
  )
121
- activity.performCreate(null)
122
- activity.performStart()
123
- activity.performPostCreate(null)
124
- activity.performResume()
125
- running = activity
126
- mount = PeachComposeMount(
127
- density, fontScale, locale, backend == "graphite", stencilBits, fonts, invalidate,
128
- owners = componentActivity?.let(::ActivityOwners),
129
- insets = SystemBarInsets(window, statusBar, navigationBar),
130
- clockRunning = { componentActivity == null || componentActivity.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) },
131
- )
132
- mount.setContent {
133
- val content = @Composable { held?.let { view -> key(view) { view.Content() } }; Unit }
134
- // the content view's tree resolves the activity as its back dispatcher owner
135
- if (componentActivity != null)
136
- CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides componentActivity, content = content)
137
- else content()
138
- }
139
- mountId = addMount(mount)
140
- runningMount = mount
141
- window.addToWindowManager()
142
- val view = checkNotNull(held) {
143
- "${spec.component.flattenToShortString()} set no ComposeView as its content; Peach composes only ComposeView content"
144
- }
149
+ process = app
150
+ val view = launchActivity(app, null)
145
151
  val params = view.layoutParams
146
- return window.describe().put("ok", true).put("id", mountId)
152
+ return app.activity.window.describe().put("ok", true).put("id", app.mountId)
147
153
  .put("wrapWidth", params?.width == ViewGroup.LayoutParams.WRAP_CONTENT)
148
154
  .put("wrapHeight", params?.height == ViewGroup.LayoutParams.WRAP_CONTENT)
149
155
  .toString()
@@ -153,9 +159,89 @@ internal fun peachStartApp(
153
159
  }
154
160
  }
155
161
 
156
- // the activity the app runs, once started, and the scene its window composes
157
- private var running: Activity? = null
158
- private var runningMount: PeachComposeMount? = null
162
+ // ActivityThread's handleLaunchActivity: a new instance of the launcher
163
+ // activity created from the state its last instance saved, if any, with what
164
+ // that instance retained, then started, restored, resumed and its window
165
+ // added. the window's scene takes the process's mount id, so the device keeps
166
+ // presenting it.
167
+ private fun launchActivity(app: AppProcess, lastNonConfigurationInstance: Any?): ComposeView {
168
+ val spec = app.spec
169
+ val activity = spec.createActivity()
170
+ val componentActivity = activity as? ComponentActivity
171
+ val state = app.state
172
+ lateinit var window: Window
173
+ lateinit var mount: PeachComposeMount
174
+ // the window's one scene composes the ComposeView it holds; a new
175
+ // view starts a new composition, as on the device. the key is the view's
176
+ // place, not the view, so a relaunched window's view lands on the keys its
177
+ // saved state was written under
178
+ var held by mutableStateOf<ComposeView?>(null)
179
+ var place by mutableIntStateOf((app.view - 1).coerceAtLeast(0))
180
+ val host = object : PeachWindowHost {
181
+ override fun compose(view: ComposeView): AutoCloseable {
182
+ held = view
183
+ place += 1
184
+ app.view = place
185
+ return AutoCloseable { if (held === view) held = null }
186
+ }
187
+
188
+ // a change before the window is presented reaches the device with
189
+ // the window itself
190
+ override fun windowChanged() {
191
+ if (app.window === window)
192
+ mount.emitAtFrame("window") { window.describe().toString() }
193
+ }
194
+ }
195
+ window = Window(activity, spec.window, host)
196
+ activity.attach(
197
+ app.application,
198
+ spec.component,
199
+ Intent(Intent.ACTION_MAIN, setOf(Intent.CATEGORY_LAUNCHER), spec.launcher),
200
+ window,
201
+ lastNonConfigurationInstance,
202
+ )
203
+ activity.performCreate(state)
204
+ activity.performStart()
205
+ if (state != null) activity.performRestoreInstanceState(state)
206
+ activity.performPostCreate(state)
207
+ activity.performResume()
208
+ app.activity = activity
209
+ app.paused = false
210
+ app.stopped = false
211
+ app.state = null
212
+ app.reportedNight = isNight(activity.resources.configuration)
213
+ mount = PeachComposeMount(
214
+ app.density, app.fontScale, app.locale, app.gpu, app.stencilBits, app.fonts, app.invalidate,
215
+ night = isNight(activity.resources.configuration),
216
+ owners = componentActivity?.let(::ActivityOwners),
217
+ insets = DeviceInsets(window, app.insets),
218
+ clockRunning = { componentActivity == null || componentActivity.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) },
219
+ )
220
+ mount.setContent {
221
+ val content = @Composable { held?.let { view -> key(place) { view.Content() } }; Unit }
222
+ // the content view's tree resolves the activity as its back dispatcher owner
223
+ if (componentActivity != null)
224
+ CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides componentActivity, content = content)
225
+ else content()
226
+ }
227
+ if (app.mountId == 0) app.mountId = addMount(mount)
228
+ else {
229
+ // the relaunched window takes the device's bounds and reports its
230
+ // window, which the new instance may have set differently
231
+ mount.constraints = app.mount.constraints
232
+ mounts[app.mountId] = mount
233
+ mount.emitAtFrame("window") { window.describe().toString() }
234
+ app.invalidate(true)
235
+ }
236
+ app.mount = mount
237
+ app.window = window
238
+ window.addToWindowManager()
239
+ return checkNotNull(held) {
240
+ "${spec.component.flattenToShortString()} set no ComposeView as its content; Peach composes only ComposeView content"
241
+ }
242
+ }
243
+
244
+ private fun runningApp() = checkNotNull(process) { "the app is not running" }
159
245
 
160
246
  // the device's back: an activity with predictive back gets it through its
161
247
  // window's back dispatch, where ComponentActivity's dispatcher takes it only
@@ -163,7 +249,7 @@ private var runningMount: PeachComposeMount? = null
163
249
  // rest; without predictive back the key reaches onBackPressed. false when the
164
250
  // task moved to the back, which the device then shows as home.
165
251
  internal fun peachAppBack(predictive: Boolean): Boolean {
166
- val activity = checkNotNull(running) { "the app is not running" }
252
+ val activity = runningApp().activity
167
253
  activity.movedTaskToBack = false
168
254
  val dispatcher = (activity as? ComponentActivity)?.onBackPressedDispatcher
169
255
  if (!predictive) activity.onBackPressed()
@@ -172,20 +258,81 @@ internal fun peachAppBack(predictive: Boolean): Boolean {
172
258
  return !activity.movedTaskToBack
173
259
  }
174
260
 
175
- // the task left the foreground (onPause, onStop, onSaveInstanceState) or came back to it
176
- // (onRestart, onStart, onResume, onPostResume), as ActivityThread drives it
261
+ // ActivityThread's pause and stop; from API 28 an activity that stops saves
262
+ // its state after onStop, kept for a relaunch until it resumes
263
+ private fun pauseAndStop(app: AppProcess) {
264
+ if (!app.paused) {
265
+ app.activity.performPause()
266
+ app.paused = true
267
+ }
268
+ if (!app.stopped) {
269
+ app.activity.performStop()
270
+ app.stopped = true
271
+ app.state = Bundle().also(app.activity::performSaveInstanceState)
272
+ }
273
+ }
274
+
275
+ // the task left the foreground (onPause, onStop, onSaveInstanceState) or came
276
+ // back to it (onRestart, onStart, onResume, onPostResume). a configuration
277
+ // that changed while it was stopped reaches it first: a relaunch in place of
278
+ // the restart, or onConfigurationChanged.
177
279
  internal fun peachAppTask(foreground: Boolean) {
178
- val activity = checkNotNull(running) { "the app is not running" }
179
- if (foreground) {
180
- activity.performRestart()
181
- activity.performResume()
182
- // the frame clock runs again once the activity is started
183
- checkNotNull(runningMount).invalidate(false)
184
- } else {
185
- activity.performPause()
186
- activity.performStop()
187
- // from API 28 an activity that stops saves its state after onStop;
188
- // ActivityThread keeps it for a relaunch, which no path here takes yet
189
- activity.performSaveInstanceState(Bundle())
280
+ val app = runningApp()
281
+ if (!foreground) return pauseAndStop(app)
282
+ if (reportConfiguration(app)) return
283
+ app.activity.performRestart()
284
+ app.activity.performResume()
285
+ app.paused = false
286
+ app.stopped = false
287
+ app.state = null
288
+ // the frame clock runs again once the activity is started
289
+ app.mount.invalidate(false)
290
+ }
291
+
292
+ // gives a visible activity the process configuration when it differs from the
293
+ // one last reported to it: a relaunch, or onConfigurationChanged under
294
+ // configChanges uiMode. true when it relaunched.
295
+ private fun reportConfiguration(app: AppProcess): Boolean {
296
+ val configuration = processResources.configuration
297
+ if (isNight(configuration) == app.reportedNight) return false
298
+ if (!app.spec.handlesUiMode) {
299
+ relaunch(app)
300
+ return true
301
+ }
302
+ app.reportedNight = isNight(configuration)
303
+ app.activity.performConfigurationChanged(Configuration().apply { setTo(configuration) })
304
+ app.mount.night = app.reportedNight
305
+ return false
306
+ }
307
+
308
+ private fun isNight(configuration: Configuration) =
309
+ configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
310
+
311
+ // the device's night mode changed. the process's configuration changes in
312
+ // place and the Application hears it first; a visible activity then takes it.
313
+ // a stopped activity takes it once visible again, if it still differs.
314
+ internal fun peachAppNight(night: Boolean) {
315
+ val app = runningApp()
316
+ val configuration = processResources.configuration
317
+ if (isNight(configuration) == night) return
318
+ configuration.setTo(nightConfiguration(night))
319
+ app.application.onConfigurationChanged(Configuration().apply { setTo(configuration) })
320
+ if (!app.stopped) reportConfiguration(app)
321
+ }
322
+
323
+ // ActivityThread's handleRelaunchActivityInner: pause and stop with the state
324
+ // saved, collect what the next instance keeps, destroy, then launch anew
325
+ private fun relaunch(app: AppProcess) {
326
+ val old = app.activity
327
+ val oldMount = app.mount
328
+ old.isChangingConfigurations = true
329
+ pauseAndStop(app)
330
+ val retained = old.retainNonConfigurationInstances()
331
+ old.performDestroy()
332
+ old.window.removeFromWindowManager()
333
+ try {
334
+ launchActivity(app, retained)
335
+ } finally {
336
+ oldMount.close()
190
337
  }
191
338
  }
@@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableStateOf
7
7
  import androidx.compose.runtime.remember
8
8
  import androidx.compose.runtime.setValue
9
9
  import androidx.compose.runtime.snapshots.Snapshot
10
+ import androidx.compose.ui.LocalSystemTheme
10
11
  import androidx.compose.ui.geometry.Offset
11
12
  import androidx.compose.ui.graphics.asComposeCanvas
12
13
  import androidx.compose.ui.input.InputMode
@@ -44,6 +45,7 @@ import androidx.compose.ui.unit.LayoutDirection
44
45
  import dev.onejs.one.OneNativeSourceViewDispatch
45
46
  import kotlin.io.encoding.Base64
46
47
  import kotlinx.coroutines.Dispatchers
48
+ import org.jetbrains.skiko.SystemTheme
47
49
  import org.jetbrains.skia.BackendRenderTarget
48
50
  import org.jetbrains.skia.Bitmap
49
51
  import org.jetbrains.skia.Color
@@ -123,11 +125,14 @@ internal class PeachComposeMount(
123
125
  private val stencilBits: Int,
124
126
  private val fonts: FontFamily,
125
127
  val invalidate: (Boolean) -> Unit,
128
+ night: Boolean,
126
129
  owners: PlatformArchitectureComponentsOwner? = null,
127
130
  insets: PlatformWindowInsets? = null,
128
131
  private val clockRunning: () -> Boolean = { true },
129
132
  ) : AutoCloseable {
130
133
  var props by mutableStateOf("")
134
+ // the configuration's night mode, which isSystemInDarkTheme reads
135
+ var night by mutableStateOf(night)
131
136
  var constraints = Constraints()
132
137
  set(value) { if (field != value) { field = value; needsMeasure = true } }
133
138
  private val events = mutableListOf<JSONObject>()
@@ -150,7 +155,11 @@ internal class PeachComposeMount(
150
155
 
151
156
  fun setContent(content: @Composable () -> Unit) {
152
157
  try {
153
- scene.setContent { PeachAndroidFonts(fonts, content) }
158
+ scene.setContent {
159
+ CompositionLocalProvider(LocalSystemTheme provides if (night) SystemTheme.DARK else SystemTheme.LIGHT) {
160
+ PeachAndroidFonts(fonts, content)
161
+ }
162
+ }
154
163
  } catch (failure: Throwable) {
155
164
  close()
156
165
  throw failure
@@ -289,7 +298,7 @@ internal fun robotoFamily(regular: String, medium: String, bold: String) = FontF
289
298
 
290
299
  @JsExport
291
300
  fun peachViewMount(source: String, view: String, hash: String, props: String,
292
- density: Float, fontScale: Float, locale: String, backend: String, stencilBits: Int,
301
+ density: Float, fontScale: Float, locale: String, night: Boolean, backend: String, stencilBits: Int,
293
302
  regular: String, medium: String, bold: String, invalidate: (Boolean) -> Unit): String {
294
303
  try {
295
304
  val dispatch = peachViewDispatcher(source)
@@ -297,7 +306,7 @@ fun peachViewMount(source: String, view: String, hash: String, props: String,
297
306
  .put("error", "composable source $source changed; rebuild the app").toString()
298
307
  require(backend == "graphite" || backend == "raster")
299
308
  preparePeachDevice(locale)
300
- val mount = PeachComposeMount(density, fontScale, locale, backend == "graphite", stencilBits, robotoFamily(regular, medium, bold), invalidate)
309
+ val mount = PeachComposeMount(density, fontScale, locale, backend == "graphite", stencilBits, robotoFamily(regular, medium, bold), invalidate, night)
301
310
  mount.props = props
302
311
  mount.setContent { dispatch.Content(view, mount.props, mount::emit) }
303
312
  return JSONObject().put("ok", true).put("id", addMount(mount)).toString()
@@ -307,6 +316,9 @@ fun peachViewMount(source: String, view: String, hash: String, props: String,
307
316
  }
308
317
 
309
318
  @JsExport fun peachViewUpdate(id: Int, props: String) { checkNotNull(mounts[id]).props = props }
319
+ // a host activity that handles uiMode itself, as React Native's does, keeps
320
+ // its views and hands them the new configuration
321
+ @JsExport fun peachViewNight(id: Int, night: Boolean) { checkNotNull(mounts[id]).night = night }
310
322
  @JsExport fun peachViewConstraints(id: Int, minWidth: Int, maxWidth: Int, minHeight: Int, maxHeight: Int) {
311
323
  checkNotNull(mounts[id]).also {
312
324
  it.constraints = Constraints(minWidth, maxWidth, minHeight, maxHeight)
@@ -10,6 +10,7 @@ builds until upstream supplies equivalent worker and native host hooks.
10
10
  | --- | --- | --- |
11
11
  | Compose `ui` | `Actuals.wasm.kt` | clock reads `globalThis.performance.now()` |
12
12
  | Compose `ui` | `PlatformClipboard.web.kt` | worker-safe secure-context and clipboard reads |
13
+ | Compose `ui` | `SystemTheme.kt` | `LocalSystemTheme` visible to the host, which provides the device's night mode |
13
14
  | Compose `ui-text` | `PlatformLocale.web.kt` | explicit `setPlatformLocaleProvider`; its default keeps upstream navigator behavior |
14
15
  | Skiko | `OsArch.web.kt` | explicit `setHostOsProvider`; its default lazily retains upstream OS detection |
15
16
  | Skiko | `Actuals.web.kt` | nanosecond clock reads `globalThis.performance.now()` |
@@ -105,3 +105,29 @@ index 7aa3e26..47e20a8 100644
105
105
 
106
106
  /**
107
107
  * https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API
108
+ diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/SystemTheme.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/SystemTheme.kt
109
+ index a14f182..9c4862d 100644
110
+ --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/SystemTheme.kt
111
+ +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/SystemTheme.kt
112
+ @@ -12,6 +12,11 @@
113
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
114
+ * See the License for the specific language governing permissions and
115
+ * limitations under the License.
116
+ + *
117
+ + * modified for Peach: the system theme composition local is visible to a
118
+ + * native host, which provides the device's night mode to the scenes it owns,
119
+ + * as later upstream releases expose it. see the fork record for the patch and
120
+ + * upstream revision.
121
+ */
122
+
123
+ package androidx.compose.ui
124
+ @@ -27,7 +32,8 @@ enum class SystemTheme {
125
+ Dark, Light, Unknown
126
+ }
127
+
128
+ -internal val LocalSystemTheme = staticCompositionLocalOf { currentSystemTheme }
129
+ +@InternalComposeUiApi
130
+ +val LocalSystemTheme = staticCompositionLocalOf { currentSystemTheme }
131
+
132
+ @InternalComposeUiApi
133
+ @Composable
@@ -18,7 +18,7 @@
18
18
  "tag": "v1.13.0-alpha01",
19
19
  "commit": "a9e47e8aa04c95cfe572ab4297c770f3cbfb234e",
20
20
  "patch": "compose-worker-globals.patch",
21
- "patchSha256": "51fa45bda1e0aae7db2eab1b056def406352958257e1d3b899c5c3d8aa5edddb"
21
+ "patchSha256": "5c45b67b3028637e6eafa351db004f23ad50c6f00ac2b5e7738fd026ac93cef0"
22
22
  },
23
23
  {
24
24
  "name": "skiko",
@@ -18,9 +18,9 @@
18
18
  ],
19
19
  "composeLibraries": [
20
20
  {
21
- "name": "ui-wasm-js-1.13.0-alpha01-peach-0e3c251301e0.klib",
22
- "url": "https://r2.rnxsim.com/peach-kotlin-fork/0e3c251301e0df405e50d0aa7d45b401c1aa26f7ed0a7001c6ce2bc68f5d667b/ui-wasm-js-1.13.0-alpha01.klib",
23
- "sha256": "0e3c251301e0df405e50d0aa7d45b401c1aa26f7ed0a7001c6ce2bc68f5d667b"
21
+ "name": "ui-wasm-js-1.13.0-alpha01-peach-8caddb83ba2f.klib",
22
+ "url": "https://r2.rnxsim.com/peach-kotlin-fork/8caddb83ba2f8dddd4c869eca936f0039d99c39c986f8af162e59eb27dbb92ed/ui-wasm-js-1.13.0-alpha01.klib",
23
+ "sha256": "8caddb83ba2f8dddd4c869eca936f0039d99c39c986f8af162e59eb27dbb92ed"
24
24
  },
25
25
  {
26
26
  "name": "foundation-wasm-js-1.13.0-alpha01.klib",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peachsim",
3
- "version": "0.1.659",
3
+ "version": "0.1.661",
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 (