mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
fix(mobile): close hosted runtime review blockers
This commit is contained in:
@@ -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
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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
+21
@@ -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
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+9
-15
@@ -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)
|
||||
}
|
||||
|
||||
+10
-9
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user