fix(mobile): close hosted runtime review blockers

This commit is contained in:
Jinwoo-H
2026-08-30 01:19:30 -04:00
parent 66a9d9086e
commit 4eefe642ed
24 changed files with 125 additions and 5305 deletions
+16 -1
View File
@@ -215,6 +215,18 @@ const DESKTOP_IRRELEVANT_PREFIXES = [
'.github/workflows/mobile-android-release.yml'
]
// The hosted mobile UI is packaged and served by the desktop runtime; changes
// here must keep desktop typecheck/package jobs enabled even though most RN
// sources remain mobile-only.
const DESKTOP_RELEVANT_MOBILE_PREFIXES = [
'mobile/host-web-app/',
'mobile/packages/expo-mobile-web-shell/',
'src/mobile-web/',
'src/shared/mobile-web/',
'config/scripts/package-mobile-web-rnw.mjs',
'config/scripts/verify-mobile-web-rnw-build.mjs'
]
export function isDocsOnlyPath(file) {
if (DOCS_ONLY_FILES.has(file)) {
return true
@@ -297,7 +309,10 @@ function isTestFile(file) {
}
function isDesktopIrrelevantPath(file) {
return matchesPrefix(file, DESKTOP_IRRELEVANT_PREFIXES)
return (
matchesPrefix(file, DESKTOP_IRRELEVANT_PREFIXES) &&
!matchesPrefix(file, DESKTOP_RELEVANT_MOBILE_PREFIXES)
)
}
function isNativeCacheInputPath(file) {
+2 -1386
View File
File diff suppressed because it is too large Load Diff
@@ -41,6 +41,7 @@ export class MobileWebRouteErrorBoundary extends Component<
}
componentDidCatch(error: unknown, info: ErrorInfo): void {
window.dispatchEvent(new Event('orca-mobile-web-route-failure'))
console.error('[mobile-web] hosted route stopped', {
code: mobileWebRouteFailureCode(error),
componentDepth: info.componentStack?.split('\n').length ?? 0
@@ -9,7 +9,7 @@ internal fun isAllowedMobileWebBridgeDocumentUrl(value: String, sessionId: Strin
val url = URI(value)
value.length <= MOBILE_WEB_DOCUMENT_URL_LIMIT &&
url.scheme == MOBILE_WEB_ORIGIN_SCHEME &&
url.host == MOBILE_WEB_ORIGIN_HOST &&
isMobileWebOriginForSession(url, sessionId) &&
url.port == -1 &&
url.userInfo == null &&
url.fragment == sessionId
@@ -28,7 +28,7 @@ internal fun installMobileWebDebugIsolationProbe(
WebViewCompat.addDocumentStartJavaScript(
webView,
script,
setOf(MOBILE_WEB_ORIGIN)
setOf("*")
)
} else {
throw IllegalStateException("mobile_web_debug_isolation_probe_unavailable")
@@ -0,0 +1,21 @@
package expo.modules.mobilewebshell
import android.net.Uri
private const val MOBILE_WEB_ORIGIN_SUFFIX = ".orca-mobile-web.invalid"
/** Derives a per-session origin so WebView cookies/storage cannot cross hosts. */
internal fun mobileWebOriginForSession(sessionId: String): String {
require(sessionId.isNotEmpty() && sessionId.length <= 128 && sessionId.all { it.isLetterOrDigit() || it == '-' || it == '_' }) {
"mobile_web_session_id_invalid"
}
return "$MOBILE_WEB_ORIGIN_SCHEME://${sessionId.take(32)}$MOBILE_WEB_ORIGIN_SUFFIX"
}
internal fun mobileWebOriginUriForSession(sessionId: String): Uri = Uri.parse(mobileWebOriginForSession(sessionId))
internal fun isMobileWebOriginForSession(url: Uri, sessionId: String): Boolean =
url.scheme == MOBILE_WEB_ORIGIN_SCHEME &&
url.host == mobileWebOriginUriForSession(sessionId).host &&
url.port == -1 &&
url.userInfo == null
@@ -642,7 +642,7 @@ internal class MobileWebPackageStore internal constructor(
private fun sessionResponse(sessionId: String, buildId: String): Map<String, String> = mapOf(
"sessionId" to sessionId,
"buildId" to buildId,
"url" to "$MOBILE_WEB_ORIGIN/#$sessionId"
"url" to "${mobileWebOriginForSession(sessionId)}/#$sessionId"
)
private fun randomIdentifier(): String {
@@ -119,15 +119,15 @@ internal class MobileWebShellView(
return
}
if (sessionId == activeSessionId) return
removeBridgeMessageListener()
addBridgeMessageListener()
activeSessionId = sessionId
removeBridgeMessageListener()
addBridgeMessageListener(sessionId)
webView.stopLoading()
attachWebView()
visibility = View.VISIBLE
webView.visibility = View.VISIBLE
onLoadState(mapOf("state" to "loading"))
webView.loadUrl("$MOBILE_WEB_ORIGIN/#$sessionId")
webView.loadUrl("${mobileWebOriginForSession(sessionId)}/#$sessionId")
}
fun activateSessionView(sessionId: String) {
@@ -180,12 +180,12 @@ internal class MobileWebShellView(
}
}
private fun addBridgeMessageListener() {
private fun addBridgeMessageListener(sessionId: String) {
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
WebViewCompat.addWebMessageListener(
webView,
MOBILE_WEB_BRIDGE_NAME,
setOf(MOBILE_WEB_ORIGIN),
setOf(mobileWebOriginForSession(sessionId)),
OriginLockedMessageListener()
)
} else {
@@ -206,7 +206,7 @@ internal class MobileWebShellView(
val documentUrl = view.url?.let(Uri::parse) ?: return
if (
!isMainFrame ||
!isMobileWebOrigin(sourceOrigin) ||
!isMobileWebOriginForSession(sourceOrigin, sessionId) ||
!isAllowedMobileWebBridgeDocumentUrl(documentUrl.toString(), sessionId) ||
body.toByteArray(Charsets.UTF_8).size > MOBILE_WEB_MESSAGE_BYTE_LIMIT
) return
@@ -259,7 +259,7 @@ internal class MobileWebShellView(
request.method == "GET" &&
request.requestHeaders.keys.none { it.equals("Range", ignoreCase = true) } &&
sessionId != null &&
isMobileWebOrigin(url) &&
isMobileWebOriginForSession(url, sessionId) &&
(url.fragment == null || (request.isForMainFrame && url.fragment == sessionId)) &&
url.query == null &&
!url.encodedPath.orEmpty().contains('%') &&
@@ -290,7 +290,7 @@ internal class MobileWebShellView(
private fun isAllowedDocumentUrl(url: Uri): Boolean =
activeSessionId != null &&
isMobileWebOrigin(url) &&
isMobileWebOriginForSession(url, activeSessionId ?: return false) &&
url.path == "/" &&
url.encodedPath == "/" &&
url.query == null &&
@@ -299,7 +299,7 @@ internal class MobileWebShellView(
private fun isAllowedEmbeddedDocumentUrl(url: Uri): Boolean =
activeSessionId != null &&
isMobileWebOrigin(url) &&
isMobileWebOriginForSession(url, activeSessionId ?: return false) &&
url.path == "/$MOBILE_WEB_MERMAID_FRAME_PATH" &&
url.encodedPath == "/$MOBILE_WEB_MERMAID_FRAME_PATH" &&
url.query == null &&
@@ -316,12 +316,6 @@ internal class MobileWebShellView(
)
}
private fun isMobileWebOrigin(url: Uri): Boolean =
url.scheme == MOBILE_WEB_ORIGIN_SCHEME &&
url.host == MOBILE_WEB_ORIGIN_HOST &&
url.port == -1 &&
url.userInfo == null
private object JSONObjectQuote {
fun quote(value: String): String = org.json.JSONObject.quote(value)
}
@@ -6,24 +6,25 @@ import org.junit.Test
class MobileWebBridgeDocumentUrlTest {
private val sessionId = "S".repeat(43)
private val origin = mobileWebOriginForSession(sessionId)
@Test
fun `accepts session-bound client routes`() {
assertTrue(
isAllowedMobileWebBridgeDocumentUrl(
"https://orca-mobile-web.invalid/#$sessionId",
"$origin/#$sessionId",
sessionId
)
)
assertTrue(
isAllowedMobileWebBridgeDocumentUrl(
"https://orca-mobile-web.invalid/h/paired-orca-desktop/tasks#$sessionId",
"$origin/h/paired-orca-desktop/tasks#$sessionId",
sessionId
)
)
assertTrue(
isAllowedMobileWebBridgeDocumentUrl(
"https://orca-mobile-web.invalid/h/paired-orca-desktop/session/workspace" +
"$origin/h/paired-orca-desktop/session/workspace" +
"?name=Feature+One#$sessionId",
sessionId
)
@@ -33,13 +34,13 @@ class MobileWebBridgeDocumentUrlTest {
@Test
fun `rejects documents outside the active origin and session`() {
val rejected = listOf(
"http://orca-mobile-web.invalid/#$sessionId",
"https://user@orca-mobile-web.invalid/#$sessionId",
"https://orca-mobile-web.invalid:443/#$sessionId",
"http://${origin.removePrefix("https://")}/#$sessionId",
"https://user@${origin.removePrefix("https://")}/#$sessionId",
"$origin:443/#$sessionId",
"https://orca-mobile-web.invalid.evil.test/#$sessionId",
"https://orca-mobile-web.invalid/",
"https://orca-mobile-web.invalid/#${"T".repeat(43)}",
"https://orca-mobile-web.invalid/${"a".repeat(8 * 1024)}#$sessionId",
"$origin/",
"${mobileWebOriginForSession("T".repeat(43))}/#$sessionId",
"$origin/${"a".repeat(8 * 1024)}#$sessionId",
"not a url"
)
@@ -179,7 +179,10 @@ export function useMobileWebPackageRecovery({
}, [ownedSessionRef, recoverSession, setPackageWarning])
const clearCache = useCallback(async () => {
const hostEpoch = hostEpochRef.current
// Invalidate refresh/open continuations before any await so a clear cannot race a
// download that publishes a session into the cache being removed.
const hostEpoch = hostEpochRef.current + 1
hostEpochRef.current = hostEpoch
const current = ownedSessionRef.current
if (!host || !activeHostIdRef.current) {
return
@@ -170,8 +170,7 @@ export class MobileE2EEV2PhysicalChannel {
if (this.state !== 'ready') {
return false
}
this.outboundQueue.enqueue(item)
return true
return this.outboundQueue.enqueue(item)
}
}
+4 -20
View File
@@ -7017,21 +7017,13 @@ export class OrcaRuntimeService {
}
private collectMobileSessionWorktreeIdsForSshTarget(targetId: string): Set<string> {
const repoIds = new Set(
(this.store?.getRepos() ?? [])
.filter((repo) => repo.connectionId === targetId)
.map((repo) => repo.id)
)
if (repoIds.size === 0) {
return new Set()
}
const worktreeIds = new Set<string>()
const executionHostId = toSshExecutionHostId(targetId)
for (const worktreeId of [
...this.getKnownWorkspaceSessionWorktreeIds(),
...this.mobileSessionTabsByWorktree.keys()
]) {
const parsed = splitWorktreeId(worktreeId)
if (parsed && repoIds.has(parsed.repoId)) {
if (this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) === executionHostId) {
worktreeIds.add(worktreeId)
}
}
@@ -7042,21 +7034,13 @@ export class OrcaRuntimeService {
targetId: string,
generation: number
): Promise<void> {
const repoIds = new Set(
(this.store?.getRepos() ?? [])
.filter((repo) => repo.connectionId === targetId)
.map((repo) => repo.id)
)
if (repoIds.size === 0) {
return
}
const worktreeIds = new Set<string>()
const executionHostId = toSshExecutionHostId(targetId)
for (const worktreeId of [
...this.getKnownWorkspaceSessionWorktreeIds(),
...this.mobileSessionTabsByWorktree.keys()
]) {
const parsed = splitWorktreeId(worktreeId)
if (parsed && repoIds.has(parsed.repoId)) {
if (this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) === executionHostId) {
worktreeIds.add(worktreeId)
}
}
File diff suppressed because it is too large Load Diff
@@ -32,6 +32,7 @@ export const TerminalSubscribe = TerminalHandle.extend({
terminalBinaryStream: z.literal(1).optional(),
desktopViewportClaims: z.literal(1).optional(),
mobileInputLeaseOnly: z.literal(1).optional(),
queryReply: z.literal(1).optional(),
writeUnavailable: z.literal(1).optional()
})
.optional()
@@ -54,6 +55,7 @@ export const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({
ackOutputSourceRanges: z.literal(1).optional(),
desktopViewportClaims: z.literal(1).optional(),
outputPause: z.literal(1).optional(),
queryReply: z.literal(1).optional(),
writeUnavailable: z.literal(1).optional()
})
.optional()
@@ -108,10 +108,12 @@ export async function sendTerminalStreamInput(
text: string
client: TerminalViewportClient | undefined
isMobile: boolean
inputKind?: 'input' | 'query-reply'
}
): Promise<TerminalStreamInputOutcome> {
const action = { text: args.text, enter: false, interrupt: false }
const clientId = args.isMobile ? args.client?.id : undefined
const clientId =
args.inputKind === 'query-reply' ? undefined : args.isMobile ? args.client?.id : undefined
const floorClaim: MobileInputFloorClaimHolder = { current: null }
try {
if (!clientId) {
@@ -31,6 +31,7 @@ export function registerLegacyBinaryControlFrames(
clientId,
isMobile,
supportsDesktopViewportClaims,
supportsQueryReply,
supportsWriteUnavailable
} = args
if (!registerBinaryStreamHandler) {
@@ -40,7 +41,10 @@ export function registerLegacyBinaryControlFrames(
if (controls.isClosed()) {
return
}
if (frame.opcode === TerminalStreamOpcode.Input) {
if (
frame.opcode === TerminalStreamOpcode.Input ||
(frame.opcode === TerminalStreamOpcode.QueryReply && supportsQueryReply)
) {
const text = decodeTerminalStreamText(frame.payload)
if (!text) {
return
@@ -56,7 +60,8 @@ export function registerLegacyBinaryControlFrames(
terminal: params.terminal,
text,
client: params.client,
isMobile
isMobile,
inputKind: frame.opcode === TerminalStreamOpcode.QueryReply ? 'query-reply' : 'input'
})
if (!controls.isClosed() && outcome === 'rejected' && supportsWriteUnavailable) {
controls.sendFrame(TerminalStreamOpcode.WriteUnavailable)
@@ -29,6 +29,7 @@ export async function publishLegacyBinaryInitialSnapshot(
ptyId,
clientId,
isMobile,
supportsQueryReply,
missingHeadlessStateBeforeMobileFit,
rendererMountRequestedBeforePty,
serializerGenerationBeforeMobileFit
@@ -148,7 +149,8 @@ export async function publishLegacyBinaryInitialSnapshot(
cols: serialized?.cols ?? size?.cols,
rows: serialized?.rows ?? size?.rows,
displayMode: state.displayMode,
seq: layoutSeq
seq: layoutSeq,
...(supportsQueryReply ? { capabilities: { queryReply: 1 as const } } : {})
})
const snapshotStats = sendSnapshotFrames(state.sendFrame, {
kind: 'scrollback',
@@ -22,6 +22,7 @@ export type TerminalSubscriptionArgs = {
clientId: string | undefined
isMobile: boolean
supportsDesktopViewportClaims: boolean
supportsQueryReply: boolean
supportsWriteUnavailable: boolean
missingHeadlessStateBeforeMobileFit: boolean
rendererMountRequestedBeforePty: boolean
@@ -58,10 +58,13 @@ export async function publishMultiplexInitialSnapshot(
rows: serialized?.rows ?? size?.rows,
displayMode,
seq: layoutSeq,
...((stream.ackOutputSourceRanges || stream.supportsOutputPause) && {
...((stream.ackOutputSourceRanges ||
stream.supportsOutputPause ||
stream.supportsQueryReply) && {
capabilities: {
...(stream.ackOutputSourceRanges ? { ackOutputSourceRanges: 1 as const } : {}),
...(stream.supportsOutputPause ? { outputPause: 1 as const } : {})
...(stream.supportsOutputPause ? { outputPause: 1 as const } : {}),
...(stream.supportsQueryReply ? { queryReply: 1 as const } : {})
}
}),
...(stream.ackOutputSourceRanges ? { streamGeneration: stream.streamGeneration } : {}),
@@ -60,7 +60,10 @@ export function installMultiplexSlotFrames(
}
return
}
if (frame.opcode === TerminalStreamOpcode.Input) {
if (
frame.opcode === TerminalStreamOpcode.Input ||
(frame.opcode === TerminalStreamOpcode.QueryReply && stream.supportsQueryReply)
) {
const text = decodeTerminalStreamText(frame.payload)
if (!text) {
return
@@ -78,7 +81,8 @@ export function installMultiplexSlotFrames(
terminal: stream.terminal,
text,
client: stream.client,
isMobile: stream.isMobile
isMobile: stream.isMobile,
inputKind: frame.opcode === TerminalStreamOpcode.QueryReply ? 'query-reply' : 'input'
})
state.notifyStreamWriteUnavailable(stream, outcome)
})
@@ -54,6 +54,7 @@ export async function initializeMultiplexStream(
ackInFlightBytes: 0,
ackWindowBytes: TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES,
supportsOutputPause: request.capabilities?.outputPause === 1,
supportsQueryReply: request.capabilities?.queryReply === 1,
supportsWriteUnavailable: request.capabilities?.writeUnavailable === 1,
outputPaused: false,
supportsDesktopViewportClaims: request.capabilities?.desktopViewportClaims === 1,
@@ -68,6 +68,7 @@ export type TerminalMultiplexStream = {
ackInFlightBytes: number
ackWindowBytes: number
supportsOutputPause: boolean
supportsQueryReply: boolean
supportsWriteUnavailable: boolean
outputPaused: boolean
supportsDesktopViewportClaims: boolean
@@ -72,6 +72,7 @@ export const TERMINAL_SUBSCRIBE_METHODS: RpcAnyMethod[] = [
clientId,
isMobile,
supportsDesktopViewportClaims: params.capabilities?.desktopViewportClaims === 1,
supportsQueryReply: params.capabilities?.queryReply === 1,
supportsWriteUnavailable: params.capabilities?.writeUnavailable === 1,
rendererMountRequestedBeforePty,
missingHeadlessStateBeforeMobileFit,
@@ -219,8 +219,14 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState {
})
}
const unsubscribe = subscribeToMobileWebShellMessages(window, receive)
const onRouteFailure = (): void => {
cancelAnimationFrame(healthFrame)
cancelAnimationFrame(interactiveFrame)
}
window.addEventListener('orca-mobile-web-route-failure', onRouteFailure)
return () => {
unsubscribe()
window.removeEventListener('orca-mobile-web-route-failure', onRouteFailure)
client?.dispose()
cancelAnimationFrame(healthFrame)
cancelAnimationFrame(interactiveFrame)