mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
refactor(mobile): every build is the OTA shell; drop the native/OTA build switch (#29969)
* fix(mobile): wall hosts that cannot serve the page instead of opening native screens * refactor(mobile): drop the native-route state and the shell fallback prop; unknown routes say unavailable * test(mobile): read the nullable page route refusal in its policy test * refactor(mobile): every build is the OTA shell; drop the native/OTA build switch The owner retired the native-screen fallback with no emergency native build, so EXPO_PUBLIC_MOBILE_SHELL, mobileShellBuildKind, shellSwitchDecision, the Metro cache-key suffix and both release workflows' shell input go. Every host route mounts the shell; a route naming no screen the shell could open gets the existing refusal screen instead of a native screen. Claude-Session: https://claude.ai/code/session_6cea92f6 * refactor(mobile): drop the dead web route, flag-era comments and the one-value shell kind The host route files no longer switch, so their comments say what they do now: a route naming no screen shows the unavailable screen. app/h/[hostId]/web.tsx duplicated index.tsx and nothing linked to it, so it and its stack entry go (the bundle sweep is re-measured: 77 scripts at 15 routes). Diagnostics print the bridge protocol on its own instead of a shell kind that has one value. Claude-Session: https://claude.ai/code/session_6cea92f6
This commit is contained in:
@@ -18,14 +18,6 @@ on:
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
shell:
|
||||
description: 'Which shell the binary mounts: native screens, or the web page delivered over the air. Default native; `ota` is the only value that changes it.'
|
||||
required: false
|
||||
default: native
|
||||
type: choice
|
||||
options:
|
||||
- native
|
||||
- ota
|
||||
|
||||
jobs:
|
||||
android-build:
|
||||
@@ -80,14 +72,7 @@ jobs:
|
||||
run: npx expo prebuild --platform android --no-install
|
||||
|
||||
- name: Build Android release APK
|
||||
env:
|
||||
# The one build-time constant that decides whether this binary mounts the web page or
|
||||
# the native screens. A tag push and a schedule carry no inputs, so both read native.
|
||||
EXPO_PUBLIC_MOBILE_SHELL: ${{ inputs.shell || 'native' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Mobile shell: $EXPO_PUBLIC_MOBILE_SHELL"
|
||||
cd android && ./gradlew assembleRelease
|
||||
run: cd android && ./gradlew assembleRelease
|
||||
|
||||
# Why: an install that fails with a missing certificate or a package-parse error is
|
||||
# usually a download that died near the end, and the signature block sits in the last
|
||||
|
||||
@@ -22,14 +22,6 @@ on:
|
||||
description: 'Optional TestFlight external tester changelog'
|
||||
required: false
|
||||
type: string
|
||||
shell:
|
||||
description: 'Which shell the binary mounts: native screens, or the web page delivered over the air. Default native; `ota` is the only value that changes it.'
|
||||
required: false
|
||||
default: native
|
||||
type: choice
|
||||
options:
|
||||
- native
|
||||
- ota
|
||||
|
||||
jobs:
|
||||
ios-build:
|
||||
@@ -149,13 +141,7 @@ jobs:
|
||||
# Keep fastlane non-interactive and quiet about analytics in CI.
|
||||
FASTLANE_SKIP_UPDATE_CHECK: '1'
|
||||
FASTLANE_HIDE_CHANGELOG: '1'
|
||||
# The one build-time constant that decides whether this binary mounts the web page or
|
||||
# the native screens. A tag push and a schedule carry no inputs, so both read native.
|
||||
EXPO_PUBLIC_MOBILE_SHELL: ${{ inputs.shell || 'native' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Mobile shell: $EXPO_PUBLIC_MOBILE_SHELL"
|
||||
bundle exec fastlane ios build_and_upload
|
||||
run: bundle exec fastlane ios build_and_upload
|
||||
|
||||
- name: Upload .ipa artifact
|
||||
if: always()
|
||||
|
||||
@@ -505,7 +505,7 @@ export async function mobileWebAppModuleClosure(entryModules, { absWorkingDir }
|
||||
...(absWorkingDir ? { absWorkingDir } : {}),
|
||||
// Extensionless, so `resolveExtensions` picks the same file the bundle ships: a route with a
|
||||
// `.web.tsx` sibling resolves to that one, and naming the `.tsx` path explicitly would measure
|
||||
// the native switch no browser ever loads.
|
||||
// the native route file no browser ever loads.
|
||||
entryPoints: entryModules.map((entry) =>
|
||||
resolve(absWorkingDir ?? base.absWorkingDir, entry.replace(/\.tsx?$/, ''))
|
||||
),
|
||||
|
||||
@@ -423,7 +423,7 @@ describe('the Phase C budget', () => {
|
||||
|
||||
it('bounds every prefix the sweep measured, and the spread it reports', () => {
|
||||
// An upper envelope of the measurement, not a fit: the count rises with the prefix, so the
|
||||
// number above its top row is above all fifteen.
|
||||
// number above its top row is above every row.
|
||||
for (const [key, measured] of MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP) {
|
||||
expect(
|
||||
mobileWebAppBundleMaxChunks(MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.length),
|
||||
@@ -509,9 +509,9 @@ describe('the Phase C budget', () => {
|
||||
// in from 50 with the envelope: it grants the worst swept route to each one past the sweep,
|
||||
// where `4r + 16` granted four, so re-measuring a tree whose routes share more moves it out.
|
||||
expect(await readMobileWebBundleMaxAssets()).toBe(MOBILE_WEB_BUNDLE_MAX_ASSETS)
|
||||
expect(assertAssetCeilingFitsShell(27, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(246)
|
||||
expect(assertAssetCeilingFitsShell(27, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(256)
|
||||
expect(() => assertAssetCeilingFitsShell(28, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow(
|
||||
/257 .*256/
|
||||
/267 .*256/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ export function repoRelativeClosurePath(closurePath) {
|
||||
* Every family whose scenario names a call site inside the closure, less the shell's own.
|
||||
*
|
||||
* `mobileWeb.*` is dropped for the reason each pin file's docstring gives: measured from a route's
|
||||
* native switch rather than its `.web.tsx` sibling the closure reaches the shell, and the shell's
|
||||
* native route file rather than its `.web.tsx` sibling the closure reaches the shell, and the shell's
|
||||
* families belong to the shell.
|
||||
*/
|
||||
export function pageClosureFamilies(closureLocalPaths, scenarios) {
|
||||
|
||||
@@ -86,7 +86,7 @@ export const MOBILE_WEB_PAGE_ROUTES = [
|
||||
//
|
||||
// `pr` and `history` are not listed and never will be. Both are `Redirect`s into this route, so
|
||||
// listing one would put a redirect inside a page whose session stays bound to the pathname it
|
||||
// left; left native they replace into this route and its switch opens the page once. The hop
|
||||
// left; left native they replace into this route and it opens the page once. The hop
|
||||
// census sees them as call sites naming `source-control`, never as targets of their own.
|
||||
{
|
||||
pathname: '/h/[hostId]/source-control/[worktreeId]',
|
||||
|
||||
@@ -50,9 +50,9 @@ export const MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES = 9 * 1024 * 1024
|
||||
* A chunk is emitted per distinct set of importers, not per route, so a route's marginal cost is
|
||||
* what it fails to share rather than what it weighs. Re-measured on this head by building
|
||||
* `routes.slice(0, n)` for every n, which is what the fence below is derived from rather than
|
||||
* fitted to. The spread it shows is 1 to 11: `pr` and `web` add one script each, `tasks` adds eleven.
|
||||
* fitted to. The spread it shows is 1 to 11: `pr` adds one script, `tasks` adds eleven.
|
||||
* The root `./_layout.tsx` (the page's web sibling of the native root) sorts first; with it the
|
||||
* swept tree reads 78 scripts at 16 routes.
|
||||
* swept tree reads 77 scripts at 15 routes.
|
||||
*
|
||||
* This table is the fence's only input, so a route added to the tree stales it and the pins beside
|
||||
* the fence fail until it is re-measured. That is the point: the bound is re-derived, never bumped.
|
||||
@@ -72,8 +72,7 @@ export const MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP = [
|
||||
['./h/[hostId]/session/[worktreeId].tsx', 58],
|
||||
['./h/[hostId]/source-control/[worktreeId].tsx', 63],
|
||||
['./h/[hostId]/tasks.tsx', 74],
|
||||
['./h/[hostId]/web.tsx', 75],
|
||||
['./h/_layout.tsx', 78]
|
||||
['./h/_layout.tsx', 77]
|
||||
]
|
||||
|
||||
const sweptScripts = MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.map(([, scripts]) => scripts)
|
||||
|
||||
@@ -4,33 +4,21 @@ import {
|
||||
workspaceRouteParams
|
||||
} from '../../../../src/navigation/workspace-execution-host'
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { MobileAgentSessionHistoryPanel } from '../../../../src/agent-history/MobileAgentSessionHistoryPanel'
|
||||
import { MobileWebShellScreen } from '../../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { shellScreenRoute } from '../../../../src/mobile-web-shell/shell-screen-route'
|
||||
import { shellSwitchDecision } from '../../../../src/mobile-web-shell/shell-switch-decision'
|
||||
import { PageRouteUnavailableScreen } from '../../../../src/mobile-web-shell/PageRouteUnavailableScreen'
|
||||
import { firstParam } from '../../../../src/navigation/route-param-reader'
|
||||
|
||||
/**
|
||||
* Agent session history, from the desktop's bundle or from this app.
|
||||
*
|
||||
* The switch is `index.tsx`'s, for its reasons: the shell renders the page only for a route the
|
||||
* bundle lists with grants this app implements, and walls the host otherwise.
|
||||
* Agent session history, from the desktop's bundle.
|
||||
*
|
||||
* Two dynamic segments rather than one, so both are encoded: `useLocalSearchParams` answers the
|
||||
* decoded value, and a worktree id or a deep-linked host id carrying `/`, `?`, `#` or whitespace
|
||||
* would otherwise stop being the single segment `matchesRoutePattern` reads it as. A route with no
|
||||
* worktree names no screen the shell could open, so it stays native.
|
||||
* would otherwise stop being the single segment `matchesRoutePattern` reads it as.
|
||||
*
|
||||
* Encoding does not save a `.` or `..` id, which it leaves unchanged, and that pathname fails the
|
||||
* bridge's own segment rule. Handing it over anyway reaches the phone as an `init` naming no
|
||||
* screen, which the page answers with "Update Orca to open this workspace" — a failure screen in
|
||||
* place of the native panel sitting right behind this switch. So the route asks the schema first
|
||||
* and stays native when the answer is no, which is where every route starts.
|
||||
*
|
||||
* The schema is the predicate rather than a copy of its bounds: two spellings of one rule drift,
|
||||
* and the half that matters is the half the page reads. C3.1 made the same call for the files
|
||||
* routes first, and every switch now asks the one module beside the schema
|
||||
* rather than carrying its own copy of the call.
|
||||
* Encoding does not save a `.` or `..` id, and that pathname fails the bridge's own segment rule,
|
||||
* which the page would answer with "Update Orca to open this workspace". So the route asks the
|
||||
* schema first (through `shellScreenRoute`) and refuses here when the answer is no.
|
||||
*/
|
||||
export default withWorkspaceRoute(function MobileAgentSessionHistoryScreen() {
|
||||
const params = useLocalSearchParams<{
|
||||
@@ -46,9 +34,6 @@ export default withWorkspaceRoute(function MobileAgentSessionHistoryScreen() {
|
||||
...(name === '' ? {} : { name }),
|
||||
...workspaceRouteParams(executionHost)
|
||||
}
|
||||
const panel = (
|
||||
<MobileAgentSessionHistoryPanel hostId={hostId} worktreeId={worktreeId} name={name} />
|
||||
)
|
||||
|
||||
const route =
|
||||
hostId && worktreeId
|
||||
@@ -59,16 +44,12 @@ export default withWorkspaceRoute(function MobileAgentSessionHistoryScreen() {
|
||||
...(Object.keys(routeParams).length === 0 ? {} : { params: routeParams })
|
||||
})
|
||||
: null
|
||||
const decision = shellSwitchDecision(route)
|
||||
|
||||
if (decision.kind === 'native') {
|
||||
return panel
|
||||
if (route === null) {
|
||||
return <PageRouteUnavailableScreen hostId={hostId} />
|
||||
}
|
||||
// Keyed on the route: a host captures the grants its session was opened with, so a screen
|
||||
// reused across a route change would keep authorising frames under the grants of the route the
|
||||
// page has left. The key is what makes the change a remount, which disposes that bridge in the
|
||||
// commit, and the new session starts with no grants until its own `init`.
|
||||
return (
|
||||
<MobileWebShellScreen key={decision.route.pathname} hostId={hostId} route={decision.route} />
|
||||
)
|
||||
return <MobileWebShellScreen key={route.pathname} hostId={hostId} route={route} />
|
||||
})
|
||||
|
||||
@@ -4,13 +4,8 @@ import { MobileAgentSessionHistoryPanel } from '../../../../src/agent-history/Mo
|
||||
import { firstParam } from '../../../../src/navigation/route-param-reader'
|
||||
|
||||
/**
|
||||
* Web sibling for agent session history.
|
||||
*
|
||||
* This page is what the shell renders for this route, so there is no shell to mount here and no
|
||||
* flag to read: the switch already happened natively. Its native file also reaches
|
||||
* OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and throws in a
|
||||
* browser, and one throwing route module takes the whole bundle down because the manifest imports
|
||||
* them all.
|
||||
* The page's route for agent session history: the screen itself. The native route file mounts the shell, whose
|
||||
* native view module throws at import in a browser, and the manifest imports every route.
|
||||
*/
|
||||
export default withWorkspaceRoute(function MobileAgentSessionHistoryScreen() {
|
||||
const params = useLocalSearchParams<{
|
||||
|
||||
@@ -4,26 +4,22 @@ import {
|
||||
workspaceRouteParams
|
||||
} from '../../../../src/navigation/workspace-execution-host'
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { MobileFileExplorerPanel } from '../../../../src/files/MobileFileExplorerPanel'
|
||||
import { firstParam } from '../../../../src/navigation/route-param-reader'
|
||||
import {
|
||||
shellScreenRoute,
|
||||
shellScreenRouteKey
|
||||
} from '../../../../src/mobile-web-shell/shell-screen-route'
|
||||
import { MobileWebShellScreen } from '../../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { shellSwitchDecision } from '../../../../src/mobile-web-shell/shell-switch-decision'
|
||||
import { PageRouteUnavailableScreen } from '../../../../src/mobile-web-shell/PageRouteUnavailableScreen'
|
||||
|
||||
/**
|
||||
* The file explorer, from the desktop's bundle or from this app.
|
||||
* The file explorer, from the desktop's bundle. The shell walls a host whose bundle cannot serve it.
|
||||
*
|
||||
* The shell decides, not this switch: it renders the page only for a route the bundle lists with
|
||||
* grants this app implements, and walls the host otherwise.
|
||||
*
|
||||
* Encoded, not interpolated raw, for the reason `web.tsx` states: an id carrying `?`, `#` or
|
||||
* whitespace would build a pathname the page refuses and mount nothing.
|
||||
* Encoded, not interpolated raw: ids arrive decoded, and one carrying `?`, `#` or whitespace would
|
||||
* build a pathname the page refuses and mount nothing.
|
||||
*/
|
||||
export default withWorkspaceRoute(function MobileFileExplorerScreen() {
|
||||
// Through `firstParam`, as the tasks and agent-history switches do: expo-router answers a
|
||||
// Through `firstParam`, as the tasks and agent-history routes do: expo-router answers a
|
||||
// repeated query key with an array, and a bare read puts it straight into the template, where
|
||||
// `String(['a','b'])` is `a,b` and `encodeURIComponent` makes it the single segment `a%2Cb` —
|
||||
// which the bridge's segment rule accepts, so the shell opens a page for a host nobody has.
|
||||
@@ -40,9 +36,6 @@ export default withWorkspaceRoute(function MobileFileExplorerScreen() {
|
||||
...(name === '' ? {} : { name }),
|
||||
...workspaceRouteParams(executionHost)
|
||||
}
|
||||
const native = (
|
||||
<MobileFileExplorerPanel hostId={hostId} worktreeId={worktreeId} name={name} embedded={false} />
|
||||
)
|
||||
|
||||
const route =
|
||||
hostId && worktreeId
|
||||
@@ -54,19 +47,11 @@ export default withWorkspaceRoute(function MobileFileExplorerScreen() {
|
||||
})
|
||||
: null
|
||||
|
||||
const decision = shellSwitchDecision(route)
|
||||
|
||||
if (decision.kind === 'native') {
|
||||
return native
|
||||
if (route === null) {
|
||||
return <PageRouteUnavailableScreen hostId={hostId} />
|
||||
}
|
||||
// Keyed on the route: a host captures the grants its session was opened with, so a screen reused
|
||||
// across a route change would keep authorising frames under the grants of the route the page has
|
||||
// left. The key is what makes the change a remount, which disposes that bridge in the commit.
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
key={shellScreenRouteKey(decision.route)}
|
||||
hostId={hostId}
|
||||
route={decision.route}
|
||||
/>
|
||||
)
|
||||
return <MobileWebShellScreen key={shellScreenRouteKey(route)} hostId={hostId} route={route} />
|
||||
})
|
||||
|
||||
@@ -4,12 +4,8 @@ import { MobileFileExplorerPanel } from '../../../../src/files/MobileFileExplore
|
||||
import { firstParam } from '../../../../src/navigation/route-param-reader'
|
||||
|
||||
/**
|
||||
* Web sibling for the file explorer.
|
||||
*
|
||||
* This page is what the shell renders for this route, so there is no shell to mount here and no
|
||||
* flag to read: the switch already happened natively. Its native file also reaches
|
||||
* OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and throws in a
|
||||
* browser, which is what the page opening this route would hit.
|
||||
* The page's route for the file explorer: the screen itself. The native route file mounts the shell, whose
|
||||
* native view module throws at import in a browser, and the manifest imports every route.
|
||||
*/
|
||||
export default withWorkspaceRoute(function MobileFileExplorerScreen() {
|
||||
// Through `firstParam`, as the native sibling does. The page reaches this route only through
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
workspaceRouteParams
|
||||
} from '../../../../../src/navigation/workspace-execution-host'
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { MobileFilePreviewScreen } from '../../../../../src/files/MobileFilePreviewScreen'
|
||||
import {
|
||||
mobileFilePreviewShellParams,
|
||||
normalizeMobileFilePreviewRouteParams
|
||||
@@ -14,14 +13,14 @@ import {
|
||||
shellScreenRouteKey
|
||||
} from '../../../../../src/mobile-web-shell/shell-screen-route'
|
||||
import { MobileWebShellScreen } from '../../../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { shellSwitchDecision } from '../../../../../src/mobile-web-shell/shell-switch-decision'
|
||||
import { PageRouteUnavailableScreen } from '../../../../../src/mobile-web-shell/PageRouteUnavailableScreen'
|
||||
import { firstParam } from '../../../../../src/navigation/route-param-reader'
|
||||
|
||||
/**
|
||||
* The file preview, from the desktop's bundle or from this app.
|
||||
* The file preview, from the desktop's bundle.
|
||||
*
|
||||
* Normalized before the switch, not after: a route the native screen would refuse is one the shell
|
||||
* has no pathname to build from, and its own refusal message is a better screen than a page opened
|
||||
* on params it will refuse again.
|
||||
* Normalized before the shell route is built, not after: a route the preview would refuse is one the shell has no
|
||||
* pathname to build from, and a refusal here beats a page opened on params it will refuse again.
|
||||
*
|
||||
* Only the two path segments are spelled into the pathname; everything else — the file path among
|
||||
* them — is a param, which is what keeps a `/`, a space or a `..` out of the segment vocabulary the
|
||||
@@ -46,7 +45,6 @@ export default withWorkspaceRoute(function MobileFilePreviewRoute() {
|
||||
worktreeName?: string | string[]
|
||||
}>()
|
||||
const route = normalizeMobileFilePreviewRouteParams(params)
|
||||
const native = <MobileFilePreviewScreen route={route} />
|
||||
const executionHost = useWorkspaceExecutionHost()
|
||||
|
||||
const shellRoute = route.ok
|
||||
@@ -61,12 +59,9 @@ export default withWorkspaceRoute(function MobileFilePreviewRoute() {
|
||||
})
|
||||
: null
|
||||
|
||||
const decision = shellSwitchDecision(shellRoute)
|
||||
|
||||
// `route.ok` again for the compiler: `shellRoute` is built only on the ok branch, so a `shell`
|
||||
// decision already implies it.
|
||||
if (decision.kind === 'native' || !route.ok) {
|
||||
return native
|
||||
// `route.ok` again for the compiler: `shellRoute` is built only on the ok branch.
|
||||
if (shellRoute === null || !route.ok) {
|
||||
return <PageRouteUnavailableScreen hostId={firstParam(params.hostId)} />
|
||||
}
|
||||
// Keyed on the whole route, params included, for two reasons. A host captures the grants its
|
||||
// session was opened with, so only a remount drops the bridge the previous route opened. And the
|
||||
@@ -75,9 +70,9 @@ export default withWorkspaceRoute(function MobileFilePreviewRoute() {
|
||||
// was opened on, with nothing to tell it otherwise.
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
key={shellScreenRouteKey(decision.route)}
|
||||
key={shellScreenRouteKey(shellRoute)}
|
||||
hostId={route.params.hostId}
|
||||
route={decision.route}
|
||||
route={shellRoute}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4,12 +4,8 @@ import { MobileFilePreviewScreen } from '../../../../../src/files/MobileFilePrev
|
||||
import { normalizeMobileFilePreviewRouteParams } from '../../../../../src/files/mobile-file-preview-route'
|
||||
|
||||
/**
|
||||
* Web sibling for the file preview.
|
||||
*
|
||||
* This page is what the shell renders for this route, so there is no shell to mount here and no
|
||||
* flag to read: the switch already happened natively. Its native file also reaches
|
||||
* OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and throws in a
|
||||
* browser, which is what the page opening this route would hit.
|
||||
* The page's route for the file preview: the screen itself. The native route file mounts the shell, whose
|
||||
* native view module throws at import in a browser, and the manifest imports every route.
|
||||
*/
|
||||
export default withWorkspaceRoute(function MobileFilePreviewRoute() {
|
||||
const params = useLocalSearchParams<{
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
|
||||
import { firstParam } from '../../../src/navigation/route-param-reader'
|
||||
import { HostScreen } from '../../../src/host-screen/HostScreen'
|
||||
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
|
||||
import { MobileWebShellScreen } from '../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { shellScreenRoute } from '../../../src/mobile-web-shell/shell-screen-route'
|
||||
import { shellSwitchDecision } from '../../../src/mobile-web-shell/shell-switch-decision'
|
||||
import { PageRouteUnavailableScreen } from '../../../src/mobile-web-shell/PageRouteUnavailableScreen'
|
||||
|
||||
/**
|
||||
* The worktree list, from the desktop's bundle or from this app.
|
||||
* The worktree list, from the desktop's bundle. The shell walls a host whose bundle cannot serve it.
|
||||
*
|
||||
* The shell decides, not this switch: it renders the page only for a route the bundle lists with
|
||||
* grants this app implements, and walls the host otherwise. So the one way to stay native is a build
|
||||
* without the shell.
|
||||
*
|
||||
* Encoded, not interpolated raw, for the reason `web.tsx` states: a deep-linked host id carrying
|
||||
* `?`, `#` or whitespace would build a pathname the page refuses, and a refusal here is a failure
|
||||
* screen rather than the native list this route already has.
|
||||
* Encoded, not interpolated raw: `hostId` arrives decoded, and a deep-linked one carrying `?`, `#`
|
||||
* or whitespace would build a pathname the page refuses.
|
||||
*/
|
||||
function HostListScreen() {
|
||||
// Through `firstParam`, as the other four switches do: expo-router answers a repeated key with
|
||||
// Through `firstParam`, as the other route files do: expo-router answers a repeated key with
|
||||
// an array, and a bare read puts it straight into the template, where `String(['a','b'])` is
|
||||
// `a,b` and `encodeURIComponent` makes it the single segment `a%2Cb` — which the bridge's
|
||||
// segment rule accepts, so the shell would open a page for a host nobody has. An empty array is
|
||||
@@ -27,20 +19,12 @@ function HostListScreen() {
|
||||
const params = useLocalSearchParams<{ hostId?: string | string[] }>()
|
||||
const hostId = firstParam(params.hostId)
|
||||
|
||||
// Asked here as every switch asks it: encoding does not save a `.` or `..` host id, which fails
|
||||
// the bridge's segment rule, and handing that over paints the page's failure screen over the
|
||||
// native list this route already has.
|
||||
const route = shellScreenRoute({ pathname: `/h/${encodeURIComponent(hostId)}` })
|
||||
const decision = shellSwitchDecision(hostId === '' ? null : route)
|
||||
// Wide: the native sidebar hosts the list, so this route's own screen is the empty detail pane.
|
||||
const native = useResponsiveLayout().isWideLayout ? (
|
||||
<WorkspaceDetailPlaceholder />
|
||||
) : (
|
||||
<HostScreen />
|
||||
)
|
||||
|
||||
if (decision.kind === 'native') {
|
||||
return native
|
||||
// Asked here as every route file asks it: encoding does not save a `.` or `..` host id, which fails
|
||||
// the bridge's segment rule.
|
||||
const route =
|
||||
hostId === '' ? null : shellScreenRoute({ pathname: `/h/${encodeURIComponent(hostId)}` })
|
||||
if (route === null) {
|
||||
return <PageRouteUnavailableScreen hostId={hostId} />
|
||||
}
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
@@ -48,7 +32,7 @@ function HostListScreen() {
|
||||
// so a host id change must be a remount rather than a prop update.
|
||||
key={hostId}
|
||||
hostId={hostId}
|
||||
route={decision.route}
|
||||
route={route}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,8 @@ import { HostScreen } from '../../../src/host-screen/HostScreen'
|
||||
import { usePageOwnsHostArea } from '../../../src/mobile-web-shell/page-owns-host-area'
|
||||
|
||||
/**
|
||||
* Web sibling for the worktree list.
|
||||
*
|
||||
* This page is what the shell renders for this route, so there is no shell to mount here and no
|
||||
* flag to read: the switch already happened natively. Its native file also reaches
|
||||
* OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and throws in a
|
||||
* browser, and one throwing route module takes the whole bundle down because the manifest imports
|
||||
* them all.
|
||||
* The page's route for the worktree list: the screen itself. The native route file mounts the shell, whose
|
||||
* native view module throws at import in a browser, and the manifest imports every route.
|
||||
*/
|
||||
export default function HostWorktreeRoute() {
|
||||
// The placeholder only beside the page's own sidebar: without the host area this is the list.
|
||||
|
||||
@@ -4,28 +4,22 @@ import {
|
||||
workspaceRouteParams
|
||||
} from '../../../../src/navigation/workspace-execution-host'
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { MobileDiffReviewRouteScreen } from '../../../../src/session/MobileDiffReviewRouteScreen'
|
||||
import { firstReviewParam } from '../../../../src/session/mobile-diff-review-screen-model'
|
||||
import {
|
||||
shellScreenRoute,
|
||||
shellScreenRouteKey
|
||||
} from '../../../../src/mobile-web-shell/shell-screen-route'
|
||||
import { MobileWebShellScreen } from '../../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { shellSwitchDecision } from '../../../../src/mobile-web-shell/shell-switch-decision'
|
||||
import { PageRouteUnavailableScreen } from '../../../../src/mobile-web-shell/PageRouteUnavailableScreen'
|
||||
|
||||
/**
|
||||
* Diff review, from the desktop's bundle or from this app.
|
||||
*
|
||||
* The files switch's shape. What is different here is that the native screen is a component rather
|
||||
* than the body of this file: `MobileDiffReviewRouteScreen` calls the review controller, and a
|
||||
* controller at this file's top level would subscribe behind the page as well as in front of it.
|
||||
* As an element it is built and not mounted, and only a native-shell build mounts it.
|
||||
* Diff review, from the desktop's bundle. The files route's shape.
|
||||
*
|
||||
* `matchesRoutePattern` is segment-count exact with literal equality on the static segments, so
|
||||
* these five segments collide with nothing: the explorer's five carry `files`.
|
||||
*/
|
||||
export default withWorkspaceRoute(function MobileDiffReviewScreen() {
|
||||
// Through `firstReviewParam`, which is this domain's spelling of the rule every switch follows:
|
||||
// Through `firstReviewParam`, which is this domain's spelling of the rule every route file follows:
|
||||
// expo-router answers a repeated query key with an array, and a bare read would build a single
|
||||
// segment out of `String(['a','b'])` that the bridge's segment rule accepts.
|
||||
const params = useLocalSearchParams<{
|
||||
@@ -39,7 +33,6 @@ export default withWorkspaceRoute(function MobileDiffReviewScreen() {
|
||||
const hostId = firstReviewParam(params.hostId)
|
||||
const worktreeId = firstReviewParam(params.worktreeId)
|
||||
const executionHost = useWorkspaceExecutionHost()
|
||||
const native = <MobileDiffReviewRouteScreen />
|
||||
|
||||
// The four query params are read by the screen itself, so they are carried across whole rather
|
||||
// than re-derived here; each is omitted when empty, because the screen's own normalizers treat an
|
||||
@@ -60,19 +53,11 @@ export default withWorkspaceRoute(function MobileDiffReviewScreen() {
|
||||
})
|
||||
: null
|
||||
|
||||
const decision = shellSwitchDecision(route)
|
||||
|
||||
if (decision.kind === 'native') {
|
||||
return native
|
||||
if (route === null) {
|
||||
return <PageRouteUnavailableScreen hostId={hostId} />
|
||||
}
|
||||
// Keyed on the route: a host captures the grants its session was opened with, so a screen reused
|
||||
// across a route change would keep authorising frames under the grants of the route the page has
|
||||
// left. A `file` change is the common case here and is a param change, not a path change.
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
key={shellScreenRouteKey(decision.route)}
|
||||
hostId={hostId}
|
||||
route={decision.route}
|
||||
/>
|
||||
)
|
||||
return <MobileWebShellScreen key={shellScreenRouteKey(route)} hostId={hostId} route={route} />
|
||||
})
|
||||
|
||||
@@ -2,11 +2,8 @@ import { withWorkspaceRoute } from '../../../../src/navigation/workspace-route'
|
||||
import { MobileDiffReviewRouteScreen } from '../../../../src/session/MobileDiffReviewRouteScreen'
|
||||
|
||||
/**
|
||||
* Web sibling for diff review.
|
||||
*
|
||||
* The shell renders this page for this route, so there is no shell to mount here and no flag to
|
||||
* read. The screen reads its own params, so this file is the whole of the difference: its native
|
||||
* sibling reaches OrcaMobileWebShellView, whose module throws at import in a browser.
|
||||
* The page's route for diff review: the screen itself. The native route file mounts the shell, whose
|
||||
* native view module throws at import in a browser, and the manifest imports every route.
|
||||
*/
|
||||
export default withWorkspaceRoute(function MobileDiffReviewScreen() {
|
||||
return <MobileDiffReviewRouteScreen />
|
||||
|
||||
@@ -5,22 +5,16 @@ import {
|
||||
} from '../../../../src/navigation/workspace-execution-host'
|
||||
import { useCallback } from 'react'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { MobileSessionRouteScreen } from '../../../../src/session/MobileSessionRouteScreen'
|
||||
import { firstParam } from '../../../../src/navigation/route-param-reader'
|
||||
import {
|
||||
shellScreenRoute,
|
||||
shellScreenRouteKey
|
||||
} from '../../../../src/mobile-web-shell/shell-screen-route'
|
||||
import { MobileWebShellScreen } from '../../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { shellSwitchDecision } from '../../../../src/mobile-web-shell/shell-switch-decision'
|
||||
import { PageRouteUnavailableScreen } from '../../../../src/mobile-web-shell/PageRouteUnavailableScreen'
|
||||
|
||||
/**
|
||||
* The session screen — terminal and chat — from the desktop's bundle or from this app.
|
||||
*
|
||||
* The review switch's shape, for its reasons: the native screen is `MobileSessionRouteScreen`
|
||||
* rather than the body of this file, because `useMobileSessionController` at this file's top level
|
||||
* would open the terminal, chat and tab subscriptions behind the page as well as in front of it.
|
||||
* As an element it is built and not mounted, and only a native-shell build mounts it.
|
||||
* The session screen — terminal and chat — from the desktop's bundle.
|
||||
*
|
||||
* Four query params rather than the review's four, and one of them is not part of this screen's
|
||||
* identity: `paneKey`. A notification tap for a pane of the session already on screen is a tab
|
||||
@@ -31,7 +25,7 @@ import { shellSwitchDecision } from '../../../../src/mobile-web-shell/shell-swit
|
||||
* later `init` can replay a spent tap.
|
||||
*/
|
||||
export default withWorkspaceRoute(function MobileSessionScreen() {
|
||||
// Through `firstParam` on every param, as every switch does: expo-router answers a repeated query
|
||||
// Through `firstParam` on every param, as every route file does: expo-router answers a repeated query
|
||||
// key with an array, and a bare read puts `String(['a','b'])` into the template, where
|
||||
// `encodeURIComponent` makes it the single segment `a%2Cb` — which the bridge's segment rule
|
||||
// accepts, so the shell opens a page for a workspace nobody has.
|
||||
@@ -47,7 +41,6 @@ export default withWorkspaceRoute(function MobileSessionScreen() {
|
||||
const worktreeId = firstParam(params.worktreeId)
|
||||
const executionHost = useWorkspaceExecutionHost()
|
||||
const router = useRouter()
|
||||
const native = <MobileSessionRouteScreen />
|
||||
const paneKey = firstParam(params.paneKey) ?? ''
|
||||
// The reader erasing its own request (ruling 34): the page applied a pane and names it back, and
|
||||
// this is where the param it came on lives. Compared rather than obeyed — a tap that moved on
|
||||
@@ -85,24 +78,22 @@ export default withWorkspaceRoute(function MobileSessionScreen() {
|
||||
})
|
||||
: null
|
||||
|
||||
const decision = shellSwitchDecision(route)
|
||||
|
||||
if (decision.kind === 'native') {
|
||||
return native
|
||||
if (route === null) {
|
||||
return <PageRouteUnavailableScreen hostId={hostId} />
|
||||
}
|
||||
// Keyed on the route minus `paneKey`: a host captures the grants its session was opened with, so
|
||||
// a screen reused across a route change would keep authorising frames under the grants of the
|
||||
// route the page has left, and the key is what makes that change a remount. A pane is not such a
|
||||
// change — it is a tab of the session this key already names — so it is left out here and
|
||||
// delivered to the mounted page instead. Derived from the same builder every other switch uses;
|
||||
// delivered to the mounted page instead. Derived from the same builder every other route file uses;
|
||||
// `shellScreenRouteKey` is untouched, because for the other four a param change *is* an identity
|
||||
// change.
|
||||
const { paneKey: _paneKey, ...identity } = routeParams
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
key={shellScreenRouteKey({ pathname: decision.route.pathname, params: identity })}
|
||||
key={shellScreenRouteKey({ pathname: route.pathname, params: identity })}
|
||||
hostId={hostId}
|
||||
route={decision.route}
|
||||
route={route}
|
||||
onRouteParamClear={erasePaneKey}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -2,12 +2,8 @@ import { withWorkspaceRoute } from '../../../../src/navigation/workspace-route'
|
||||
import { MobileSessionRouteScreen } from '../../../../src/session/MobileSessionRouteScreen'
|
||||
|
||||
/**
|
||||
* Web sibling for the session screen.
|
||||
*
|
||||
* The shell renders this page for this route, so there is no shell to mount here and no flag to
|
||||
* read. The screen reads its own params, so this file is the whole of the difference: its native
|
||||
* sibling reaches OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and
|
||||
* throws in a browser, and the route manifest imports every route.
|
||||
* The page's route for the session screen: the screen itself. The native route file mounts the shell, whose
|
||||
* native view module throws at import in a browser, and the manifest imports every route.
|
||||
*/
|
||||
export default withWorkspaceRoute(function MobileSessionScreen() {
|
||||
return <MobileSessionRouteScreen />
|
||||
|
||||
@@ -4,28 +4,26 @@ import {
|
||||
workspaceRouteParams
|
||||
} from '../../../../src/navigation/workspace-execution-host'
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { MobileSourceControlPanel } from '../../../../src/source-control/MobileSourceControlPanel'
|
||||
import { firstParam } from '../../../../src/navigation/route-param-reader'
|
||||
import { parseSourceControlHubTab } from '../../../../src/source-control/mobile-source-control-hub-tab'
|
||||
import {
|
||||
shellScreenRoute,
|
||||
shellScreenRouteKey
|
||||
} from '../../../../src/mobile-web-shell/shell-screen-route'
|
||||
import { MobileWebShellScreen } from '../../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { shellSwitchDecision } from '../../../../src/mobile-web-shell/shell-switch-decision'
|
||||
import { PageRouteUnavailableScreen } from '../../../../src/mobile-web-shell/PageRouteUnavailableScreen'
|
||||
|
||||
/**
|
||||
* The source-control hub, from the desktop's bundle or from this app.
|
||||
* The source-control hub, from the desktop's bundle.
|
||||
*
|
||||
* The files switch's shape, for its reasons: the shell walls a route the bundle does not list or
|
||||
* The files route's shape, for its reasons: the shell walls a route the bundle does not list or
|
||||
* this app's grants do not cover.
|
||||
*
|
||||
* `pr` and `history` are not switched and never will be. Both are `Redirect`s into this route, and
|
||||
* `pr` and `history` never mount the shell. Both are `Redirect`s into this route, and
|
||||
* a redirect inside the page would leave the session bound to a pathname the page has left; left
|
||||
* native they replace into this route, whose switch then mounts the shell. One extra native frame.
|
||||
* native they replace into this route, which then mounts the shell. One extra native frame.
|
||||
*/
|
||||
export default withWorkspaceRoute(function MobileSourceControlScreen() {
|
||||
// Through `firstParam` on every param, as the files switch does: expo-router answers a repeated
|
||||
// Through `firstParam` on every param, as the files route does: expo-router answers a repeated
|
||||
// query key with an array, and a bare read puts `String(['a','b'])` into the template, where
|
||||
// `encodeURIComponent` makes it the single segment `a%2Cb` — which the bridge's segment rule
|
||||
// accepts, so the shell opens a page for a workspace nobody has.
|
||||
@@ -41,16 +39,6 @@ export default withWorkspaceRoute(function MobileSourceControlScreen() {
|
||||
const name = firstParam(params.name)
|
||||
const origin = firstParam(params.origin)
|
||||
const tab = firstParam(params.tab)
|
||||
const native = (
|
||||
<MobileSourceControlPanel
|
||||
hostId={hostId}
|
||||
worktreeId={worktreeId}
|
||||
name={name}
|
||||
origin={origin}
|
||||
initialTab={parseSourceControlHubTab(tab)}
|
||||
embedded={false}
|
||||
/>
|
||||
)
|
||||
|
||||
// Each omitted rather than empty, and the whole record omitted when none of the three was named:
|
||||
// the panel derives its own label, origin and lens from the workspace when the caller named none,
|
||||
@@ -70,19 +58,11 @@ export default withWorkspaceRoute(function MobileSourceControlScreen() {
|
||||
})
|
||||
: null
|
||||
|
||||
const decision = shellSwitchDecision(route)
|
||||
|
||||
if (decision.kind === 'native') {
|
||||
return native
|
||||
if (route === null) {
|
||||
return <PageRouteUnavailableScreen hostId={hostId} />
|
||||
}
|
||||
// Keyed on the route: a host captures the grants its session was opened with, so a screen reused
|
||||
// across a route change would keep authorising frames under the grants of the route the page has
|
||||
// left. The key is what makes the change a remount, which disposes that bridge in the commit.
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
key={shellScreenRouteKey(decision.route)}
|
||||
hostId={hostId}
|
||||
route={decision.route}
|
||||
/>
|
||||
)
|
||||
return <MobileWebShellScreen key={shellScreenRouteKey(route)} hostId={hostId} route={route} />
|
||||
})
|
||||
|
||||
@@ -5,12 +5,8 @@ import { firstParam } from '../../../../src/navigation/route-param-reader'
|
||||
import { parseSourceControlHubTab } from '../../../../src/source-control/mobile-source-control-hub-tab'
|
||||
|
||||
/**
|
||||
* Web sibling for the source-control hub.
|
||||
*
|
||||
* This page is what the shell renders for this route, so there is no shell to mount here and no
|
||||
* flag to read: the switch already happened natively. Its native file also reaches
|
||||
* OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and throws in a
|
||||
* browser, and the route manifest imports every route — one throwing module takes the bundle down.
|
||||
* The page's route for the source-control hub: the screen itself. The native route file mounts the shell, whose
|
||||
* native view module throws at import in a browser, and the manifest imports every route.
|
||||
*/
|
||||
export default withWorkspaceRoute(function MobileSourceControlScreen() {
|
||||
// Through `firstParam`, as the native sibling does. The page reaches this route only through
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { MobileWebShellScreen } from '../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { shellScreenRoute } from '../../../src/mobile-web-shell/shell-screen-route'
|
||||
import { shellSwitchDecision } from '../../../src/mobile-web-shell/shell-switch-decision'
|
||||
import { PageRouteUnavailableScreen } from '../../../src/mobile-web-shell/PageRouteUnavailableScreen'
|
||||
import { firstParam } from '../../../src/navigation/route-param-reader'
|
||||
import { MobileTasksScreen } from '../../../src/tasks/MobileTasksScreen'
|
||||
|
||||
/**
|
||||
* The shell's switch for this route, in `index.tsx`'s shape.
|
||||
* The shell route for tasks, in `index.tsx`'s shape.
|
||||
*
|
||||
* The page is opened from the native home screen, so the pathname and the provider param are what
|
||||
* the shell tells it; `taskSource` rides in `init.route.params`, which the page folds back into
|
||||
* its own URL before the first render.
|
||||
*/
|
||||
export default function MobileTasksRoute() {
|
||||
// Through `firstParam`, as the agent-history switch does: expo-router hands back an array for a
|
||||
// Through `firstParam`, as the agent-history route does: expo-router hands back an array for a
|
||||
// repeated query key, and a bare read builds `/h/host-a%2Chost-b/tasks` out of one.
|
||||
const params = useLocalSearchParams<{
|
||||
hostId?: string | string[]
|
||||
@@ -21,7 +20,6 @@ export default function MobileTasksRoute() {
|
||||
}>()
|
||||
const hostId = firstParam(params.hostId)
|
||||
const taskSource = firstParam(params.taskSource)
|
||||
const native = <MobileTasksScreen />
|
||||
|
||||
const route = hostId
|
||||
? shellScreenRoute({
|
||||
@@ -31,10 +29,8 @@ export default function MobileTasksRoute() {
|
||||
...(taskSource === '' ? {} : { params: { taskSource } })
|
||||
})
|
||||
: null
|
||||
const decision = shellSwitchDecision(route)
|
||||
|
||||
if (decision.kind === 'native') {
|
||||
return native
|
||||
if (route === null) {
|
||||
return <PageRouteUnavailableScreen hostId={hostId} />
|
||||
}
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
@@ -42,7 +38,7 @@ export default function MobileTasksRoute() {
|
||||
// with, so a host id change must be a remount rather than a prop update.
|
||||
key={hostId}
|
||||
hostId={hostId}
|
||||
route={decision.route}
|
||||
route={route}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { MobileTasksScreen } from '../../../src/tasks/MobileTasksScreen'
|
||||
|
||||
/**
|
||||
* Web sibling for the tasks screen, in `index.web.tsx`'s shape.
|
||||
*
|
||||
* This page is what the shell renders for this route, so there is no shell to mount here and no
|
||||
* flag to read: the switch already happened natively. Its native file reaches
|
||||
* `OrcaMobileWebShellView`, whose module calls `requireNativeViewManager` at import and throws in
|
||||
* a browser, and one throwing route module takes the whole bundle down because the manifest
|
||||
* imports them all.
|
||||
* The page's route for the tasks screen: the screen itself. The native route file mounts the shell, whose
|
||||
* native view module throws at import in a browser, and the manifest imports every route.
|
||||
*/
|
||||
export default function MobileTasksRoute() {
|
||||
return <MobileTasksScreen />
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Redirect, useLocalSearchParams } from 'expo-router'
|
||||
import { MobileWebShellScreen } from '../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { shellSwitchDecision } from '../../../src/mobile-web-shell/shell-switch-decision'
|
||||
|
||||
/**
|
||||
* The hybrid shell route, dark in every build but an OTA one.
|
||||
*
|
||||
* In a build without `EXPO_PUBLIC_MOBILE_SHELL=ota` this redirects and the screen is never
|
||||
* constructed, so nothing is fetched, written or swept. It sits under `app/h/[hostId]` so
|
||||
* `HostProtocolGate` in that group's layout still owns the `desktop-too-old` wall above it.
|
||||
*
|
||||
* Reachable by deep link only; no screen links here.
|
||||
*/
|
||||
export default function MobileWebShellRoute() {
|
||||
const { hostId } = useLocalSearchParams<{ hostId: string }>()
|
||||
// Encoded here rather than in the JSX below so the decision holds the route the shell is handed:
|
||||
// `hostId` arrives decoded from the URL, so one carrying `?`, `#` or whitespace would build a
|
||||
// pathname the page refuses and never mount anything.
|
||||
const decision = shellSwitchDecision(
|
||||
hostId ? { pathname: `/h/${encodeURIComponent(hostId)}` } : null
|
||||
)
|
||||
|
||||
if (decision.kind === 'native') {
|
||||
return <Redirect href={`/h/${hostId ?? ''}`} />
|
||||
}
|
||||
// The screen the page stands in for. The document is served at `/`, which matches no route in
|
||||
// the tree the page carries, so this is the only thing that tells it which one to open.
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
// Same reason as the agent-history route: a host holds the grants its session opened with,
|
||||
// so a host id change must be a remount rather than a prop update.
|
||||
key={hostId}
|
||||
hostId={hostId}
|
||||
route={decision.route}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Redirect, useLocalSearchParams } from 'expo-router'
|
||||
|
||||
/**
|
||||
* Web sibling for the hybrid shell route. This page is what that route's WebView displays, so the
|
||||
* shell has nowhere to nest here; the native file also reaches OrcaMobileWebShellView, whose
|
||||
* module calls requireNativeViewManager at import and throws in a browser, and one throwing route
|
||||
* module takes the whole bundle down because the manifest imports them all.
|
||||
*/
|
||||
export default function MobileWebShellRoute() {
|
||||
const { hostId } = useLocalSearchParams<{ hostId: string }>()
|
||||
return <Redirect href={`/h/${hostId ?? ''}`} />
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { MobileWebBundleProbeRow } from '../src/diagnostics/mobile-web-bundle-pr
|
||||
import { MobileWebShellUpdateFailureRow } from '../src/diagnostics/mobile-web-shell-update-failure-row'
|
||||
import { TroubleshootView } from '../src/diagnostics/troubleshoot-view'
|
||||
import { useTroubleshootDiagnostics } from '../src/diagnostics/use-troubleshoot-diagnostics'
|
||||
import { mobileShellBuildKind } from '../src/storage/preferences'
|
||||
|
||||
// Same guard as push-token.ts: `__DEV__` is undefined outside the React Native runtime. The import
|
||||
// above is static, so a release bundle still carries the row's graph and evaluates its hoisted
|
||||
@@ -24,11 +23,10 @@ export default function NativeTroubleshootRoute() {
|
||||
onBack={() => router.back()}
|
||||
onConnectionLog={() => router.push('/connection-log')}
|
||||
developerRow={
|
||||
// The bundle probe stays development-only: it fetches. Only an OTA build runs the shell, so
|
||||
// only it can have update failures to show.
|
||||
// The bundle probe stays development-only: it fetches.
|
||||
<>
|
||||
{isDevelopmentBuild ? <MobileWebBundleProbeRow /> : null}
|
||||
{mobileShellBuildKind() === 'ota' ? <MobileWebShellUpdateFailureRow /> : null}
|
||||
<MobileWebShellUpdateFailureRow />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -10,16 +10,4 @@ const config = getDefaultConfig(projectRoot)
|
||||
// Metro only watches mobile/ by default, so make repo-root shared modules visible.
|
||||
config.watchFolders = Array.from(new Set([...(config.watchFolders ?? []), sharedRoot]))
|
||||
|
||||
/**
|
||||
* The shell kind the bundle is being built for, by the same rule `mobileShellBuildKind` applies in
|
||||
* `src/storage/preferences.ts`. This read runs in Node at config time, so it is not the second
|
||||
* inlined read that module's census forbids.
|
||||
*/
|
||||
const shellBuildKind = process.env.EXPO_PUBLIC_MOBILE_SHELL === 'ota' ? 'ota' : 'native'
|
||||
|
||||
// Why: babel-preset-expo inlines EXPO_PUBLIC_MOBILE_SHELL at transform time, but nothing Metro
|
||||
// hashes into the transform cache key carries that value, so a warm cache from the opposite kind
|
||||
// silently bakes the wrong shell into a release.
|
||||
config.cacheVersion = `${config.cacheVersion}-shell-${shellBuildKind}`
|
||||
|
||||
module.exports = config
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Metro's transform cache is what decides which shell a release bakes in.
|
||||
*
|
||||
* `babel-preset-expo` inlines `EXPO_PUBLIC_MOBILE_SHELL` into `mobileShellBuildKind` at transform
|
||||
* time, but nothing Metro hashes into the transform cache key carries that value, so a warm cache
|
||||
* seeded by the opposite kind returns the opposite shell byte for byte — and the absence of the
|
||||
* variable's name in the bundle cannot tell the two apart. `cacheVersion` is the one input to that
|
||||
* key a project owns.
|
||||
*/
|
||||
const MOBILE_ROOT = join(import.meta.dirname, '..')
|
||||
const CONFIG_PATH = join(MOBILE_ROOT, 'metro.config.js')
|
||||
const SWITCH = 'EXPO_PUBLIC_MOBILE_SHELL'
|
||||
/** The one expression both the config and the app answer the build kind with. */
|
||||
const BUILD_KIND_RULE = `process.env.${SWITCH} === 'ota' ? 'ota' : 'native'`
|
||||
const APP_DEFINITION = join(MOBILE_ROOT, 'src', 'storage', 'preferences.ts')
|
||||
const requireConfig = createRequire(import.meta.url)
|
||||
const originalSwitch = process.env[SWITCH]
|
||||
|
||||
function cacheVersionOf(loaded: unknown): string {
|
||||
if (typeof loaded !== 'object' || loaded === null || !('cacheVersion' in loaded)) {
|
||||
throw new Error('metro.config.js exported no cacheVersion')
|
||||
}
|
||||
const { cacheVersion } = loaded
|
||||
if (typeof cacheVersion !== 'string') {
|
||||
throw new Error(`metro.config.js exported a non-string cacheVersion: ${typeof cacheVersion}`)
|
||||
}
|
||||
return cacheVersion
|
||||
}
|
||||
|
||||
/** Re-evaluates the config, which is the only way the env read at its top level runs again. */
|
||||
function cacheVersionFor(shell: string | undefined): string {
|
||||
if (shell === undefined) {
|
||||
Reflect.deleteProperty(process.env, SWITCH)
|
||||
} else {
|
||||
process.env[SWITCH] = shell
|
||||
}
|
||||
Reflect.deleteProperty(requireConfig.cache, requireConfig.resolve(CONFIG_PATH))
|
||||
return cacheVersionOf(requireConfig(CONFIG_PATH))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (originalSwitch === undefined) {
|
||||
Reflect.deleteProperty(process.env, SWITCH)
|
||||
} else {
|
||||
process.env[SWITCH] = originalSwitch
|
||||
}
|
||||
})
|
||||
|
||||
describe("metro's transform cache key", () => {
|
||||
it('separates the two shell kinds, so neither can be served a warm cache of the other', () => {
|
||||
expect(cacheVersionFor('ota')).not.toEqual(cacheVersionFor('native'))
|
||||
})
|
||||
|
||||
it('reads the kind by the same rule the app applies, so the key names the shell it bakes', () => {
|
||||
const base = cacheVersionFor(undefined).replace(/-shell-native$/, '')
|
||||
|
||||
expect(cacheVersionFor('ota')).toBe(`${base}-shell-ota`)
|
||||
expect(cacheVersionFor('native')).toBe(`${base}-shell-native`)
|
||||
// Every other value is a native build to the app, and so has to be one to the cache as well.
|
||||
expect(cacheVersionFor(undefined)).toBe(`${base}-shell-native`)
|
||||
expect(cacheVersionFor('')).toBe(`${base}-shell-native`)
|
||||
expect(cacheVersionFor('OTA')).toBe(`${base}-shell-native`)
|
||||
expect(cacheVersionFor('page')).toBe(`${base}-shell-native`)
|
||||
})
|
||||
|
||||
it('spells that rule the same way the app does, so the two cannot drift apart silently', () => {
|
||||
// A copy is unavoidable: the config runs in Node at bundle time and the app module is a React
|
||||
// Native one, so neither can import the other. Pinning the spelling is what keeps them equal.
|
||||
for (const path of [CONFIG_PATH, APP_DEFINITION]) {
|
||||
const text = readFileSync(path, 'utf8')
|
||||
const line = text.split('\n').find((candidate) => candidate.includes(BUILD_KIND_RULE))
|
||||
|
||||
expect(line, `${path} does not spell: ${BUILD_KIND_RULE}`).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps metro's own cache version, which invalidates on a bundler upgrade", () => {
|
||||
// Replacing it rather than extending it would trade this bug for that one.
|
||||
const defaultConfig: unknown = requireConfig('expo/metro-config').getDefaultConfig(MOBILE_ROOT)
|
||||
|
||||
expect(cacheVersionFor('ota').startsWith(`${cacheVersionOf(defaultConfig)}-`)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
lastConnectedAt: NOW - 5 * 60_000,
|
||||
platform: 'ios 26.5.1',
|
||||
appVersion: '0.0.29',
|
||||
bridgeProtocolVersion: 1,
|
||||
desktopAppVersion: '1.4.191',
|
||||
entries: [
|
||||
{
|
||||
@@ -45,6 +46,7 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
lastConnectedAt: null,
|
||||
platform: 'android 15',
|
||||
appVersion: '0.0.29',
|
||||
bridgeProtocolVersion: 1,
|
||||
entries: [],
|
||||
nowMs: NOW
|
||||
})
|
||||
@@ -56,27 +58,20 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
expect(report).toContain('No connection events recorded.')
|
||||
})
|
||||
|
||||
it('names the shell build and bridge version under the app line when given', () => {
|
||||
const args = {
|
||||
it('names the bridge version under the app line', () => {
|
||||
const lines = buildConnectionDiagnosticsReport({
|
||||
hostName: 'Host 3',
|
||||
endpoint: 'ws://192.168.1.50:6768',
|
||||
state: 'connected' as const,
|
||||
state: 'connected',
|
||||
reconnectAttempts: 0,
|
||||
lastConnectedAt: null,
|
||||
platform: 'ios 26',
|
||||
appVersion: '0.0.60',
|
||||
bridgeProtocolVersion: 1,
|
||||
entries: [],
|
||||
nowMs: NOW
|
||||
}
|
||||
const lines = buildConnectionDiagnosticsReport({
|
||||
...args,
|
||||
shell: { kind: 'ota', bridgeProtocolVersion: 1 }
|
||||
}).split('\n')
|
||||
expect(lines.slice(2, 4)).toEqual([
|
||||
'App: Orca Mobile 0.0.60 · ios 26',
|
||||
'Shell: ota · bridge protocol 1'
|
||||
])
|
||||
expect(buildConnectionDiagnosticsReport(args)).not.toContain('Shell:')
|
||||
expect(lines.slice(2, 4)).toEqual(['App: Orca Mobile 0.0.60 · ios 26', 'Bridge protocol: 1'])
|
||||
})
|
||||
|
||||
it('explains the most likely cause and redacts credentials before copying', () => {
|
||||
@@ -88,6 +83,7 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
lastConnectedAt: null,
|
||||
platform: 'android 36',
|
||||
appVersion: '0.0.46',
|
||||
bridgeProtocolVersion: 1,
|
||||
activePath: 'tailscale',
|
||||
pendingPath: 'relay',
|
||||
entries: [
|
||||
@@ -121,6 +117,7 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
lastConnectedAt: null,
|
||||
platform: 'android 36',
|
||||
appVersion: '0.0.47',
|
||||
bridgeProtocolVersion: 1,
|
||||
entries: [
|
||||
{
|
||||
id: 'json-secret',
|
||||
@@ -148,6 +145,7 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
lastConnectedAt: null,
|
||||
platform: 'android 36',
|
||||
appVersion: '0.0.47',
|
||||
bridgeProtocolVersion: 1,
|
||||
entries: [
|
||||
{
|
||||
id: 'oversized',
|
||||
@@ -178,6 +176,7 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
lastConnectedAt: null,
|
||||
platform: 'ios',
|
||||
appVersion: 'fixture',
|
||||
bridgeProtocolVersion: 1,
|
||||
entries: [{ id: 'boundary', ts: NOW, level: 'error', message }],
|
||||
nowMs: NOW
|
||||
})
|
||||
@@ -208,6 +207,7 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
lastConnectedAt: null,
|
||||
platform: 'android',
|
||||
appVersion: 'fixture',
|
||||
bridgeProtocolVersion: 1,
|
||||
entries: Object.freeze([entry]),
|
||||
nowMs: NOW
|
||||
})
|
||||
|
||||
@@ -28,8 +28,8 @@ export function buildConnectionDiagnosticsReport(args: {
|
||||
entries: readonly ConnectionLogEntry[]
|
||||
activePath?: MobileConnectionDiagnosticPath
|
||||
pendingPath?: MobileConnectionDiagnosticPath | null
|
||||
/** Which shell this binary is and the page bridge version it speaks; omitted by older callers. */
|
||||
shell?: { kind: 'ota' | 'native'; bridgeProtocolVersion: number }
|
||||
/** The page bridge version this shell speaks. */
|
||||
bridgeProtocolVersion: number
|
||||
nowMs?: number
|
||||
}): string {
|
||||
const now = args.nowMs ?? Date.now()
|
||||
@@ -45,9 +45,7 @@ export function buildConnectionDiagnosticsReport(args: {
|
||||
lines.push('Orca Mobile connection diagnostics')
|
||||
lines.push(`Generated: ${new Date(now).toISOString()}`)
|
||||
lines.push(`App: Orca Mobile ${args.appVersion} · ${args.platform}`)
|
||||
if (args.shell) {
|
||||
lines.push(`Shell: ${args.shell.kind} · bridge protocol ${args.shell.bridgeProtocolVersion}`)
|
||||
}
|
||||
lines.push(`Bridge protocol: ${args.bridgeProtocolVersion}`)
|
||||
const desktopAppVersion = normalizeHostAppVersion(args.desktopAppVersion)
|
||||
lines.push(`Host Orca version: ${desktopAppVersion ?? 'unknown'}`)
|
||||
lines.push(`Host: ${redactConnectionLogText(args.hostName)}`)
|
||||
|
||||
@@ -9,7 +9,6 @@ import { getReportableConnectionIncidentId } from './connection-diagnostics-anal
|
||||
import { buildConnectionDiagnosticsReport } from './connection-diagnostics-report'
|
||||
import { submitConnectionDiagnostics } from './connection-diagnostics-submission'
|
||||
import type { DiagnosticsDeviceOperations } from './diagnostics-device-operations'
|
||||
import { mobileShellBuildKind } from '../storage/preferences'
|
||||
import { BRIDGE_PROTOCOL_VERSION } from '../mobile-web-shell/bridge/bridge-frame-fields'
|
||||
|
||||
export function createNativeDiagnosticsOperations(
|
||||
@@ -36,7 +35,7 @@ export function createNativeDiagnosticsOperations(
|
||||
entries: snapshot.entries,
|
||||
activePath: snapshot.activePath,
|
||||
pendingPath: snapshot.pendingPath,
|
||||
shell: { kind: mobileShellBuildKind(), bridgeProtocolVersion: BRIDGE_PROTOCOL_VERSION }
|
||||
bridgeProtocolVersion: BRIDGE_PROTOCOL_VERSION
|
||||
}),
|
||||
appVersion,
|
||||
platform,
|
||||
|
||||
@@ -48,7 +48,7 @@ export function TroubleshootView({
|
||||
runDiagnostics: () => void
|
||||
onBack: () => void
|
||||
onConnectionLog: () => void
|
||||
/** Slot the route fills in a development build and in an OTA build; null in a native build. */
|
||||
/** Developer and shell rows the route fills in. */
|
||||
developerRow?: ReactNode
|
||||
}) {
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
@@ -4,10 +4,10 @@ import { type HostScreenProps, useHostScreenController } from './use-host-screen
|
||||
/**
|
||||
* The worktree list, and the one screen that renders both natively and inside the shell's page.
|
||||
*
|
||||
* In its own module rather than in the route file because three places mount it: the phone route,
|
||||
* the wide-layout sidebar, and the route's web sibling, which cannot import the route file at all
|
||||
* — that one reaches the native shell view, whose module calls `requireNativeViewManager` at import
|
||||
* and throws in a browser.
|
||||
* In its own module rather than in the route file because two places mount it: the wide-layout
|
||||
* sidebar and the page's route file, which cannot import the native route file — that one reaches
|
||||
* the native shell view, whose module calls `requireNativeViewManager` at import and throws in a
|
||||
* browser.
|
||||
*/
|
||||
export function HostScreen(props: HostScreenProps = {}) {
|
||||
const controller = useHostScreenController(props)
|
||||
|
||||
@@ -306,7 +306,7 @@ export function MobileWebShellScreen({
|
||||
onBinaryFramesDropped: reportDroppedBinaryFrames
|
||||
})
|
||||
|
||||
// A route that moved under a screen that stayed mounted: the session switch keeps `paneKey` out
|
||||
// A route that moved under a screen that stayed mounted: the session route keeps `paneKey` out
|
||||
// of its key so a notification tap for another pane is a tab switch rather than a page reload,
|
||||
// and this is how the page hears about it. Nothing is tracked here — which route the page has,
|
||||
// and which one is still owed it, belong to the host, which outlives any one run of this effect.
|
||||
|
||||
@@ -6,7 +6,7 @@ import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
/**
|
||||
* What a host-scoped pathname nothing can paint lands on.
|
||||
*
|
||||
* The catch-all route is the one switch with no native screen behind it, so its refusal cannot be
|
||||
* The catch-all route has no screen of its own, so its refusal cannot be
|
||||
* a panel: a pathname the bundle does not serve has no renderer on either side. The shell shows it
|
||||
* too when it cannot say which app is behind (`route-unavailable`). This replaces expo-router's Unmatched there, which says "Unmatched
|
||||
* Route" and offers a sitemap link that means nothing to someone holding a stale app.
|
||||
|
||||
@@ -125,7 +125,7 @@ export function createBridgeNotifyForwarder(args: {
|
||||
}
|
||||
if (message.name === BRIDGE_ROUTE_PARAM_CLEAR) {
|
||||
// Local, and the one frame that writes to the shell's own route (ruling 34). Carried up
|
||||
// rather than acted on here: the param lives on the native route the switch holds, and
|
||||
// rather than acted on here: the param lives on the native route that mounted the shell, and
|
||||
// whether this still names it is that holder's comparison to make.
|
||||
options.onRouteParamClear(message.param, message.value)
|
||||
return
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
* builds one, once, and writes it into its history before the first render.
|
||||
*
|
||||
* Its own module rather than the envelope's, because three other modules want the route without
|
||||
* the rest of the protocol: the native switches build one, the host holds one and republishes it
|
||||
* the rest of the protocol: the native route files build one, the host holds one and republishes it
|
||||
* (ruling 33.1), and the page reads one back out of `init`.
|
||||
*/
|
||||
export const BridgeInitRouteSchema = z.object({
|
||||
|
||||
@@ -35,7 +35,7 @@ export type BridgeClearableRouteParam = (typeof BRIDGE_CLEARABLE_ROUTE_PARAMS)[n
|
||||
* Whether two routes are different screens as the page experiences them.
|
||||
*
|
||||
* `shellScreenRouteKey` is the page's own identity for a route, so "moved" means here exactly what
|
||||
* a remount means at the switch, and both ends of the seam read the same definition: the host will
|
||||
* a remount means at the route file, and both ends of the seam read the same definition: the host will
|
||||
* not send an `init` for a route that did not move, and the page will not publish one it was sent
|
||||
* anyway. The second half is not redundant — the shell answers every `ready`, including the ones
|
||||
* the page's own backoff and its stale-`state` restart ask for, and each of those carries the held
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import type { OrcaMobileWebShellViewProps } from '../../modules/orca-mobile-web-shell/src'
|
||||
import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract'
|
||||
|
||||
@@ -184,11 +184,6 @@ beforeEach(() => {
|
||||
dependencies.params = { hostId: 'host-1', page: ['settings'] }
|
||||
dependencies.replace.mockClear()
|
||||
dependencies.push.mockClear()
|
||||
vi.stubEnv('EXPO_PUBLIC_MOBILE_SHELL', 'ota')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('the screen the catch-all paints for each shell state', () => {
|
||||
@@ -199,8 +194,8 @@ describe('the screen the catch-all paints for each shell state', () => {
|
||||
expect(backControl(tree)).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows the same refusal on a native-shell build, with no shell mounted', async () => {
|
||||
vi.stubEnv('EXPO_PUBLIC_MOBILE_SHELL', '')
|
||||
it('shows the same refusal, with no shell mounted, for a pathname naming no screen', async () => {
|
||||
dependencies.params = { hostId: 'host-1', page: [] }
|
||||
const tree = await renderRoute({ kind: 'checking' })
|
||||
expect(textOf(tree)).toContain('This workspace screen is not available on this host.')
|
||||
expect(backControl(tree)).toBeDefined()
|
||||
@@ -274,9 +269,9 @@ describe('the screen the catch-all paints for each shell state', () => {
|
||||
/**
|
||||
* The presence precondition for every case above: the shell is what rendered them.
|
||||
*
|
||||
* Outside an OTA build the switch returns the refusal on its own, with the same text and the same
|
||||
* A route naming no screen gets the refusal without a shell, with the same text and the same
|
||||
* control, so the two cases that assert the refusal would pass against a screen no shell ever
|
||||
* saw. `Checking host` is a string only the shell paints, and the switch cannot reach this state
|
||||
* saw. `Checking host` is a string only the shell paints, and the route cannot reach this state
|
||||
* without having mounted one.
|
||||
*/
|
||||
it('waits on the shell while the answer is still coming', async () => {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type RouteDependencies = {
|
||||
routes: { pathname: string; params?: Record<string, string> }[]
|
||||
@@ -71,11 +71,6 @@ beforeEach(() => {
|
||||
dependencies.routes.length = 0
|
||||
dependencies.refusals.length = 0
|
||||
dependencies.params = { hostId: 'host-1', page: ['settings'] }
|
||||
vi.stubEnv('EXPO_PUBLIC_MOBILE_SHELL', 'ota')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('the catch-all switch', () => {
|
||||
@@ -106,20 +101,13 @@ describe('the catch-all switch', () => {
|
||||
expect(dependencies.routes[0]?.pathname).toBe('/h/host-a/settings')
|
||||
})
|
||||
|
||||
it('refuses rather than falling back to a native screen it does not have', async () => {
|
||||
it('refuses a pathname with no segment past the host', async () => {
|
||||
dependencies.params = { hostId: 'host-1', page: [] }
|
||||
await render()
|
||||
expect(dependencies.routes).toEqual([])
|
||||
expect([...new Set(dependencies.refusals)]).toEqual(['host-1'])
|
||||
})
|
||||
|
||||
it('refuses in a build without the shell', async () => {
|
||||
vi.stubEnv('EXPO_PUBLIC_MOBILE_SHELL', undefined)
|
||||
await render()
|
||||
expect(dependencies.routes).toEqual([])
|
||||
expect(dependencies.refusals.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("carries the deep link's query through to the route it mounts the shell on", async () => {
|
||||
dependencies.params = { hostId: 'host-1', page: ['session', 'wt-1'], tab: 'files' }
|
||||
await render()
|
||||
|
||||
@@ -3,16 +3,14 @@ import { firstParam } from '../navigation/route-param-reader'
|
||||
import { shellScreenRoute, shellScreenRouteKey } from './shell-screen-route'
|
||||
import { MobileWebShellScreen } from './MobileWebShellScreen'
|
||||
import { PageRouteUnavailableScreen } from './PageRouteUnavailableScreen'
|
||||
import { shellSwitchDecision } from './shell-switch-decision'
|
||||
|
||||
/**
|
||||
* Any host-scoped pathname this app has no route file for, handed to the shell.
|
||||
*
|
||||
* The only switch with no native screen behind it: a screen registered after this app shipped is in
|
||||
* the desktop's bundle and nowhere else, so a native-shell build gets a refusal rather than a panel.
|
||||
* `appNamesRoute={false}` gives a pathname the bundle does not serve that same refusal from the
|
||||
* shell: with no screen for it here, nothing says the desktop is the one behind (a stale deep link
|
||||
* looks the same).
|
||||
* A screen registered after this app shipped is in the desktop's bundle and nowhere else.
|
||||
* `appNamesRoute={false}` gives a pathname the bundle does not serve a refusal from the shell: with
|
||||
* no screen for it here, nothing says the desktop is the one behind (a stale deep link looks the
|
||||
* same).
|
||||
*
|
||||
* Least specific of everything in its directory, so it takes only what expo-router would otherwise
|
||||
* send to Unmatched; every route that has a file keeps it, on both platforms.
|
||||
@@ -30,19 +28,18 @@ export default function MobileWebPageCatchAllScreen() {
|
||||
const params = useLocalSearchParams<Record<string, string | string[]>>()
|
||||
const hostId = firstParam(params.hostId)
|
||||
const segments = Array.isArray(params.page) ? params.page : params.page ? [params.page] : []
|
||||
const refusal = <PageRouteUnavailableScreen hostId={hostId} />
|
||||
|
||||
/**
|
||||
* The query, carried to the page the way every named switch carries its own.
|
||||
* The query, carried to the page the way every named route carries its own.
|
||||
*
|
||||
* `useLocalSearchParams` merges this route's params into the search params, so everything that
|
||||
* is not one of the two segments the pathname above is built from is the deep link's query —
|
||||
* which is the only way this switch can find it, having no screen to name the keys of. Dropped,
|
||||
* which is the only way this route can find it, having no screen to name the keys of. Dropped,
|
||||
* `/h/x/session/y?tab=files` opens the page on the session with no tab.
|
||||
*
|
||||
* `firstParam` per key, because `init.route.params` is one value per name: a repeated key would
|
||||
* otherwise cross as `a,b`. An empty value is kept rather than dropped — a named switch drops
|
||||
* its own because it knows what its screen makes of one, and this switch knows no screen.
|
||||
* otherwise cross as `a,b`. An empty value is kept rather than dropped — a named route drops
|
||||
* its own because it knows what its screen makes of one, and this route knows no screen.
|
||||
*
|
||||
* `#` is dropped with them: expo-router parks the URL fragment under that key before it reads
|
||||
* the search string, and a fragment is not a query — the route the page is given carries none.
|
||||
@@ -61,12 +58,10 @@ export default function MobileWebPageCatchAllScreen() {
|
||||
})
|
||||
: null
|
||||
|
||||
const decision = shellSwitchDecision(route)
|
||||
|
||||
if (decision.kind === 'native') {
|
||||
return refusal
|
||||
if (route === null) {
|
||||
return <PageRouteUnavailableScreen hostId={hostId} />
|
||||
}
|
||||
// Keyed on the route, as the other switches are: a host captures the grants its session opened
|
||||
// Keyed on the route, as the other routes are: a host captures the grants its session opened
|
||||
// with, so a screen reused across a route change would authorise frames under grants the page has
|
||||
// left behind.
|
||||
//
|
||||
@@ -76,9 +71,9 @@ export default function MobileWebPageCatchAllScreen() {
|
||||
// `catch-all-page-route-states.test.tsx` drives them.
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
key={shellScreenRouteKey(decision.route)}
|
||||
key={shellScreenRouteKey(route)}
|
||||
hostId={hostId}
|
||||
route={decision.route}
|
||||
route={route}
|
||||
appNamesRoute={false}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type RouteDependencies = {
|
||||
routes: { pathname: string; params?: Record<string, string> }[]
|
||||
panels: { hostId: string; worktreeId: string; name?: string }[]
|
||||
refusals: string[]
|
||||
/** `mount:<pathname>` / `unmount:<pathname>`, which is the only thing that tells a remount from
|
||||
* a prop update — and a remount is what drops the old session's bridge and its grants. */
|
||||
lifecycle: string[]
|
||||
@@ -13,7 +13,7 @@ type RouteDependencies = {
|
||||
|
||||
const dependencies = vi.hoisted((): RouteDependencies => ({
|
||||
routes: [],
|
||||
panels: [],
|
||||
refusals: [],
|
||||
lifecycle: [],
|
||||
params: {}
|
||||
}))
|
||||
@@ -36,13 +36,9 @@ vi.mock('../transport/workspace-server', () => ({
|
||||
}))
|
||||
vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params }))
|
||||
|
||||
vi.mock('../agent-history/MobileAgentSessionHistoryPanel', () => ({
|
||||
MobileAgentSessionHistoryPanel: (props: {
|
||||
hostId: string
|
||||
worktreeId: string
|
||||
name?: string
|
||||
}) => {
|
||||
dependencies.panels.push(props)
|
||||
vi.mock('./PageRouteUnavailableScreen', () => ({
|
||||
PageRouteUnavailableScreen: (props: { hostId: string }) => {
|
||||
dependencies.refusals.push(props.hostId)
|
||||
return null
|
||||
}
|
||||
}))
|
||||
@@ -79,17 +75,12 @@ async function renderRoute(): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
describe('the native agent-history route that hands off to the shell', () => {
|
||||
describe('the agent-history route that hands off to the shell', () => {
|
||||
beforeEach(() => {
|
||||
dependencies.routes.length = 0
|
||||
dependencies.panels.length = 0
|
||||
dependencies.refusals.length = 0
|
||||
dependencies.lifecycle.length = 0
|
||||
dependencies.params = { hostId: 'host-1', worktreeId: 'wt-1', name: 'my worktree' }
|
||||
vi.stubEnv('EXPO_PUBLIC_MOBILE_SHELL', 'ota')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('opens the shell on this screen, with the name as the search half', async () => {
|
||||
@@ -105,46 +96,26 @@ describe('the native agent-history route that hands off to the shell', () => {
|
||||
expect(dependencies.routes).toEqual([{ pathname: '/h/host-1/agent-history/wt-1' }])
|
||||
})
|
||||
|
||||
it('renders the native panel in a build without the shell, which is every store build', async () => {
|
||||
vi.stubEnv('EXPO_PUBLIC_MOBILE_SHELL', undefined)
|
||||
await renderRoute()
|
||||
expect(dependencies.routes).toEqual([])
|
||||
expect(dependencies.panels.at(-1)).toEqual({
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
name: 'my worktree'
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The ids encoding cannot save, which now keep the route native instead of failing it.
|
||||
*
|
||||
* `encodeURIComponent('..')` is `'..'`, so a dot-segment id reaches the bridge's own segment rule
|
||||
* intact and `BridgeInitRouteSchema` refuses it. Before this the route handed it over anyway,
|
||||
* `bridge-host.ts` dropped the route to null, and the page answered with "Update Orca to open
|
||||
* this workspace" — a failure screen in place of the native panel sitting right behind the
|
||||
* switch. The route decides first now, the way C3.1's files routes do.
|
||||
* The ids encoding cannot save: `encodeURIComponent('..')` is `'..'`, which the bridge's segment
|
||||
* rule refuses, so the route refuses here rather than handing the page a route it would refuse.
|
||||
*/
|
||||
it('stays native for a dot-segment id the bridge would refuse', async () => {
|
||||
it('refuses a dot-segment id the bridge would refuse', async () => {
|
||||
for (const hostId of ['.', '..']) {
|
||||
dependencies.params = { hostId, worktreeId: 'wt-1', name: 'n' }
|
||||
dependencies.routes.length = 0
|
||||
dependencies.panels.length = 0
|
||||
dependencies.refusals.length = 0
|
||||
await renderRoute()
|
||||
expect(dependencies.routes, hostId).toEqual([])
|
||||
expect(dependencies.panels.at(-1), hostId).toEqual({
|
||||
hostId,
|
||||
worktreeId: 'wt-1',
|
||||
name: 'n'
|
||||
})
|
||||
expect(dependencies.refusals.at(-1), hostId).toBe(hostId)
|
||||
}
|
||||
})
|
||||
|
||||
it('stays native for a dot-segment worktree id too, which is the other segment', async () => {
|
||||
it('refuses a dot-segment worktree id too, which is the other segment', async () => {
|
||||
dependencies.params = { hostId: 'host-1', worktreeId: '..', name: 'n' }
|
||||
await renderRoute()
|
||||
expect(dependencies.routes).toEqual([])
|
||||
expect(dependencies.panels.at(-1)).toEqual({ hostId: 'host-1', worktreeId: '..', name: 'n' })
|
||||
expect(dependencies.refusals.at(-1)).toBe('host-1')
|
||||
})
|
||||
|
||||
it('encodes both dynamic segments, so a deep-linked id stays one segment each', async () => {
|
||||
@@ -164,11 +135,11 @@ describe('the native agent-history route that hands off to the shell', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('stays native when the route names no worktree, which the shell could not open', async () => {
|
||||
it('refuses when the route names no worktree, which the shell could not open', async () => {
|
||||
dependencies.params = { hostId: 'host-1' }
|
||||
await renderRoute()
|
||||
expect(dependencies.routes).toEqual([])
|
||||
expect(dependencies.panels.at(-1)).toEqual({ hostId: 'host-1', worktreeId: '', name: '' })
|
||||
expect(dependencies.refusals.at(-1)).toBe('host-1')
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type RouteDependencies = {
|
||||
routes: { pathname: string; params?: Record<string, string> }[]
|
||||
panels: { hostId: string; worktreeId: string; name?: string }[]
|
||||
previews: unknown[]
|
||||
refusals: string[]
|
||||
/** `mount:<pathname>` / `unmount:<pathname>`, which is the only thing that tells a remount from
|
||||
* a prop update — and a remount is what drops the old session's bridge and its grants. */
|
||||
lifecycle: string[]
|
||||
@@ -14,8 +13,7 @@ type RouteDependencies = {
|
||||
|
||||
const dependencies = vi.hoisted((): RouteDependencies => ({
|
||||
routes: [],
|
||||
panels: [],
|
||||
previews: [],
|
||||
refusals: [],
|
||||
lifecycle: [],
|
||||
params: {}
|
||||
}))
|
||||
@@ -38,16 +36,9 @@ vi.mock('../transport/workspace-server', () => ({
|
||||
}))
|
||||
vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params }))
|
||||
|
||||
vi.mock('../files/MobileFileExplorerPanel', () => ({
|
||||
MobileFileExplorerPanel: (props: { hostId: string; worktreeId: string; name?: string }) => {
|
||||
dependencies.panels.push(props)
|
||||
return null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../files/MobileFilePreviewScreen', () => ({
|
||||
MobileFilePreviewScreen: (props: unknown) => {
|
||||
dependencies.previews.push(props)
|
||||
vi.mock('./PageRouteUnavailableScreen', () => ({
|
||||
PageRouteUnavailableScreen: (props: { hostId: string }) => {
|
||||
dependencies.refusals.push(props.hostId)
|
||||
return null
|
||||
}
|
||||
}))
|
||||
@@ -93,18 +84,12 @@ async function renderPreview(): Promise<void> {
|
||||
|
||||
beforeEach(() => {
|
||||
dependencies.routes.length = 0
|
||||
dependencies.panels.length = 0
|
||||
dependencies.previews.length = 0
|
||||
dependencies.refusals.length = 0
|
||||
dependencies.lifecycle.length = 0
|
||||
dependencies.params = { hostId: 'host-1', worktreeId: 'wt-1', name: 'my worktree' }
|
||||
vi.stubEnv('EXPO_PUBLIC_MOBILE_SHELL', 'ota')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('the native file explorer route that hands off to the shell', () => {
|
||||
describe('the file explorer route that hands off to the shell', () => {
|
||||
it('opens the shell on this screen, with the name as the search half', async () => {
|
||||
await renderExplorer()
|
||||
expect(dependencies.routes).toEqual([
|
||||
@@ -118,7 +103,7 @@ describe('the native file explorer route that hands off to the shell', () => {
|
||||
* A bare read puts that array straight into a template, where `String(['a','b'])` is `a,b` and
|
||||
* `encodeURIComponent` makes it `a%2Cb` — one segment, so the bridge's segment rule accepts it
|
||||
* and the shell opens a page for a host that does not exist. `firstParam` is what the other
|
||||
* switches read through, and it takes the first value the way the native screen below does.
|
||||
* switches read through, and it takes the first value.
|
||||
*/
|
||||
it('takes the first value of a repeated param, not the joined array', async () => {
|
||||
dependencies.params = {
|
||||
@@ -135,12 +120,13 @@ describe('the native file explorer route that hands off to the shell', () => {
|
||||
expect(BRIDGE_ROUTE_PATHNAME_PATTERN.test(pathname)).toBe(true)
|
||||
})
|
||||
|
||||
it('stays native for a dot-segment id the bridge would refuse', async () => {
|
||||
it('refuses a dot-segment id the bridge would refuse', async () => {
|
||||
for (const hostId of ['.', '..']) {
|
||||
dependencies.params = { hostId, worktreeId: 'wt-1' }
|
||||
dependencies.routes.length = 0
|
||||
await renderExplorer()
|
||||
expect(dependencies.routes, hostId).toEqual([])
|
||||
expect(dependencies.refusals.at(-1), hostId).toBe(hostId)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -157,12 +143,6 @@ describe('the native file explorer route that hands off to the shell', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the native panel in a build without the shell, which is every store build', async () => {
|
||||
vi.stubEnv('EXPO_PUBLIC_MOBILE_SHELL', undefined)
|
||||
await renderExplorer()
|
||||
expect(dependencies.routes).toEqual([])
|
||||
})
|
||||
|
||||
/**
|
||||
* A route change is a new session, and the old one's bridge must not outlive it.
|
||||
*
|
||||
@@ -205,7 +185,7 @@ describe('the native file explorer route that hands off to the shell', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('the native file preview route that hands off to the shell', () => {
|
||||
describe('the file preview route that hands off to the shell', () => {
|
||||
beforeEach(() => {
|
||||
dependencies.params = {
|
||||
hostId: 'host-1',
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type RouteDependencies = {
|
||||
pathnames: string[]
|
||||
hostId: string | string[]
|
||||
nativeRenders: number
|
||||
refusals: number
|
||||
}
|
||||
|
||||
const dependencies = vi.hoisted((): RouteDependencies => ({
|
||||
pathnames: [],
|
||||
hostId: 'host-1',
|
||||
nativeRenders: 0
|
||||
refusals: 0
|
||||
}))
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
@@ -27,21 +27,13 @@ vi.mock('expo-router', () => ({
|
||||
useLocalSearchParams: () => ({ hostId: dependencies.hostId })
|
||||
}))
|
||||
|
||||
vi.mock('../components/WorkspaceDetailPlaceholder', () => ({
|
||||
WorkspaceDetailPlaceholder: () => null
|
||||
}))
|
||||
|
||||
vi.mock('../host-screen/HostScreen', () => ({
|
||||
HostScreen: () => {
|
||||
dependencies.nativeRenders += 1
|
||||
vi.mock('./PageRouteUnavailableScreen', () => ({
|
||||
PageRouteUnavailableScreen: () => {
|
||||
dependencies.refusals += 1
|
||||
return null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../layout/responsive-layout', () => ({
|
||||
useResponsiveLayout: () => ({ isWideLayout: false })
|
||||
}))
|
||||
|
||||
vi.mock('./MobileWebShellScreen', () => ({
|
||||
MobileWebShellScreen: (props: { hostId: string; route: { pathname: string } }) => {
|
||||
dependencies.pathnames.push(props.route.pathname)
|
||||
@@ -58,16 +50,11 @@ async function renderRoute(): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
describe('the native worktree-list route that hands off to the shell', () => {
|
||||
describe('the worktree-list route that hands off to the shell', () => {
|
||||
beforeEach(() => {
|
||||
dependencies.pathnames.length = 0
|
||||
dependencies.nativeRenders = 0
|
||||
dependencies.refusals = 0
|
||||
dependencies.hostId = 'host-1'
|
||||
vi.stubEnv('EXPO_PUBLIC_MOBILE_SHELL', 'ota')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('encodes the host id into the pathname, like the shell route already does', async () => {
|
||||
@@ -82,19 +69,17 @@ describe('the native worktree-list route that hands off to the shell', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a dot-segment host id native instead of handing over a route the page refuses', async () => {
|
||||
it('refuses a dot-segment host id instead of handing over a route the page refuses', async () => {
|
||||
// `encodeURIComponent` leaves a dot alone and `%2e%2e` is a dot segment to the URL parser too,
|
||||
// so this one cannot be encoded into a pathname the bridge accepts. Handed over it reaches the
|
||||
// phone as an `init` naming no screen and the page paints "Update Orca to open this
|
||||
// workspace" over the native list that is sitting right behind this switch.
|
||||
// so this one cannot be encoded into a pathname the bridge accepts.
|
||||
for (const hostId of ['..', '.']) {
|
||||
dependencies.hostId = hostId
|
||||
dependencies.pathnames.length = 0
|
||||
dependencies.nativeRenders = 0
|
||||
dependencies.refusals = 0
|
||||
await renderRoute()
|
||||
expect(BRIDGE_ROUTE_PATHNAME_PATTERN.test(`/h/${hostId}`), hostId).toBe(false)
|
||||
expect(dependencies.pathnames, hostId).toEqual([])
|
||||
expect(dependencies.nativeRenders, hostId).toBeGreaterThan(0)
|
||||
expect(dependencies.refusals, hostId).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -107,10 +92,10 @@ describe('the native worktree-list route that hands off to the shell', () => {
|
||||
expect(dependencies.pathnames).toEqual(['/h/host-1'])
|
||||
})
|
||||
|
||||
it('stays native for an empty repeated host id, which names no host at all', async () => {
|
||||
it('refuses an empty repeated host id, which names no host at all', async () => {
|
||||
dependencies.hostId = []
|
||||
await renderRoute()
|
||||
expect(dependencies.pathnames).toEqual([])
|
||||
expect(dependencies.nativeRenders).toBeGreaterThan(0)
|
||||
expect(dependencies.refusals).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type RouteDependencies = {
|
||||
mounted: string[]
|
||||
/** The pathname each mount was told to open, which is the only thing the page can route on. */
|
||||
pathnames: string[]
|
||||
hostId: string
|
||||
}
|
||||
|
||||
const dependencies = vi.hoisted((): RouteDependencies => ({
|
||||
mounted: [],
|
||||
pathnames: [],
|
||||
hostId: 'host-1'
|
||||
}))
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: { getItem: async () => null, setItem: async () => {} }
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
StyleSheet: { create: (styles: unknown) => styles },
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
Redirect: 'Redirect',
|
||||
useLocalSearchParams: () => ({ hostId: dependencies.hostId })
|
||||
}))
|
||||
|
||||
vi.mock('./MobileWebShellScreen', () => ({
|
||||
MobileWebShellScreen: (props: { hostId: string; route: { pathname: string } }) => {
|
||||
dependencies.mounted.push(props.hostId)
|
||||
dependencies.pathnames.push(props.route.pathname)
|
||||
return null
|
||||
}
|
||||
}))
|
||||
|
||||
import { BRIDGE_ROUTE_PATHNAME_PATTERN } from './bridge/bridge-caps'
|
||||
import MobileWebShellRoute from '../../app/h/[hostId]/web'
|
||||
|
||||
/** Host elements are matched by name, not by `findAllByType`: React's `ElementType` does not admit
|
||||
* an arbitrary React Native host name, so the typed form is a predicate. */
|
||||
function byName(tree: ReactTestRenderer, name: string): ReactTestInstance[] {
|
||||
return tree.root.findAll((node) => String(node.type) === name)
|
||||
}
|
||||
|
||||
async function renderRoute(): Promise<ReactTestRenderer> {
|
||||
const rendered: { tree: ReactTestRenderer | null } = { tree: null }
|
||||
await act(async () => {
|
||||
rendered.tree = create(createElement(MobileWebShellRoute))
|
||||
})
|
||||
if (rendered.tree === null) {
|
||||
throw new Error('route did not render')
|
||||
}
|
||||
return rendered.tree
|
||||
}
|
||||
|
||||
describe('the hybrid shell route', () => {
|
||||
beforeEach(() => {
|
||||
dependencies.mounted.length = 0
|
||||
dependencies.pathnames.length = 0
|
||||
dependencies.hostId = 'host-1'
|
||||
vi.stubEnv('EXPO_PUBLIC_MOBILE_SHELL', 'ota')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('redirects to the host screen in a native build, and mounts nothing', async () => {
|
||||
vi.stubEnv('EXPO_PUBLIC_MOBILE_SHELL', undefined)
|
||||
const tree = await renderRoute()
|
||||
expect(byName(tree, 'Redirect').map((node) => node.props.href)).toEqual(['/h/host-1'])
|
||||
expect(dependencies.mounted).toEqual([])
|
||||
})
|
||||
|
||||
it('mounts the shell screen for this host in an OTA build', async () => {
|
||||
const tree = await renderRoute()
|
||||
expect(byName(tree, 'Redirect')).toEqual([])
|
||||
expect(dependencies.mounted).toEqual(['host-1'])
|
||||
})
|
||||
|
||||
it('encodes the host id into the pathname, so no host id can bend the route', async () => {
|
||||
// Every shape the bridge's pathname rule refuses, reached through a host id the app will
|
||||
// happily route to: a query, a fragment, whitespace, a separator and a backslash.
|
||||
for (const hostId of ['a?b', 'a#b', 'a b', 'a/b', 'a\\b']) {
|
||||
dependencies.hostId = hostId
|
||||
dependencies.pathnames.length = 0
|
||||
await renderRoute()
|
||||
const pathname = dependencies.pathnames[0]
|
||||
expect(pathname, hostId).toBe(`/h/${encodeURIComponent(hostId)}`)
|
||||
expect(BRIDGE_ROUTE_PATHNAME_PATTERN.test(pathname ?? ''), hostId).toBe(true)
|
||||
// And it still names the host it was opened for.
|
||||
expect(decodeURIComponent((pathname ?? '').slice('/h/'.length)), hostId).toBe(hostId)
|
||||
}
|
||||
})
|
||||
|
||||
it('cannot encode a dot-segment host id away, and does not pretend to', async () => {
|
||||
dependencies.hostId = '..'
|
||||
await renderRoute()
|
||||
// `encodeURIComponent` leaves a dot alone, and percent-escaping one would not help either: the
|
||||
// URL parser treats `%2e%2e` as a dot segment too. So this one reaches the bridge as a route
|
||||
// the pattern refuses, and the host is what turns it into a failure screen rather than a blank
|
||||
// WebView. Deep links are the way in, which is why it is worth having a verdict for.
|
||||
expect(dependencies.pathnames).toEqual(['/h/..'])
|
||||
expect(BRIDGE_ROUTE_PATHNAME_PATTERN.test('/h/..')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -26,8 +26,7 @@ export async function openCache(
|
||||
hostKey: string
|
||||
): Promise<CachedGeneration | null> {
|
||||
try {
|
||||
// Here and nowhere earlier: with the flag off no code path reaches this hook, so a store build
|
||||
// never sweeps a cache it never wrote.
|
||||
// Here and nowhere earlier: only a mounted shell sweeps, so nothing sweeps a cache it never wrote.
|
||||
await store.sweepStagedGenerations()
|
||||
const active = await store.readActiveGeneration(hostKey)
|
||||
return active === null
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type RouteDependencies = {
|
||||
routes: { pathname: string; params?: Record<string, string> }[]
|
||||
natives: number
|
||||
refusals: number
|
||||
/** `mount:<pathname>` / `unmount:<pathname>`, which is the only thing that tells a remount from
|
||||
* a prop update — and a remount is what drops the old session's bridge and its grants. */
|
||||
lifecycle: string[]
|
||||
@@ -15,7 +15,7 @@ type RouteDependencies = {
|
||||
|
||||
const dependencies = vi.hoisted((): RouteDependencies => ({
|
||||
routes: [],
|
||||
natives: 0,
|
||||
refusals: 0,
|
||||
lifecycle: [],
|
||||
clears: [],
|
||||
params: {}
|
||||
@@ -46,9 +46,9 @@ vi.mock('expo-router', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('../session/MobileSessionRouteScreen', () => ({
|
||||
MobileSessionRouteScreen: () => {
|
||||
dependencies.natives += 1
|
||||
vi.mock('./PageRouteUnavailableScreen', () => ({
|
||||
PageRouteUnavailableScreen: () => {
|
||||
dependencies.refusals += 1
|
||||
return null
|
||||
}
|
||||
}))
|
||||
@@ -109,16 +109,11 @@ beforeEach(() => {
|
||||
dependencies.routes.length = 0
|
||||
dependencies.lifecycle.length = 0
|
||||
dependencies.clears.length = 0
|
||||
dependencies.natives = 0
|
||||
dependencies.refusals = 0
|
||||
dependencies.params = { hostId: 'host-1', worktreeId: 'wt-1', name: 'my worktree' }
|
||||
vi.stubEnv('EXPO_PUBLIC_MOBILE_SHELL', 'ota')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('the native session route that hands off to the shell', () => {
|
||||
describe('the session route that hands off to the shell', () => {
|
||||
it('opens the shell on this screen, with the name as the search half', async () => {
|
||||
await renderSession()
|
||||
expect(dependencies.routes).toEqual([
|
||||
@@ -149,12 +144,14 @@ describe('the native session route that hands off to the shell', () => {
|
||||
expect(BRIDGE_ROUTE_PATHNAME_PATTERN.test(pathname)).toBe(true)
|
||||
})
|
||||
|
||||
it('stays native for a dot-segment id the bridge would refuse', async () => {
|
||||
it('refuses a dot-segment id the bridge would refuse', async () => {
|
||||
for (const hostId of ['.', '..']) {
|
||||
dependencies.params = { hostId, worktreeId: 'wt-1' }
|
||||
dependencies.routes.length = 0
|
||||
dependencies.refusals = 0
|
||||
await renderSession()
|
||||
expect(dependencies.routes, hostId).toEqual([])
|
||||
expect(dependencies.refusals, hostId).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -171,13 +168,6 @@ describe('the native session route that hands off to the shell', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the native screen in a build without the shell, which is every store build', async () => {
|
||||
vi.stubEnv('EXPO_PUBLIC_MOBILE_SHELL', undefined)
|
||||
await renderSession()
|
||||
expect(dependencies.routes).toEqual([])
|
||||
expect(dependencies.natives).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
/**
|
||||
* A route change is a new session, and the old one's bridge must not outlive it.
|
||||
*
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { deriveHostCacheKey } from './host-cache-key'
|
||||
import { processGenerationStore } from './process-generation-store'
|
||||
|
||||
/** A removed host's recorded update failures go with it. Not gated on the build: a native build
|
||||
* installed over an OTA one inherits its cache, and with no log this only reads a missing file. */
|
||||
/** A removed host's recorded update failures go with it; with no log this only reads a missing file. */
|
||||
export function forgetHostUpdateFailures(hostId: string): Promise<void> {
|
||||
return processGenerationStore().forgetHostUpdateFailures(hostId)
|
||||
}
|
||||
|
||||
@@ -331,11 +331,11 @@ describe('shell lifecycle lines in the exported report', () => {
|
||||
lastConnectedAt: 500,
|
||||
platform: 'ios',
|
||||
appVersion: '0.0.56',
|
||||
bridgeProtocolVersion: 1,
|
||||
entries: entries([...opened.lines, ...page.lines, ...present(stall(page))]),
|
||||
shell: { kind: 'ota', bridgeProtocolVersion: 1 },
|
||||
nowMs: 60_000
|
||||
})
|
||||
expect(report).toContain('Shell: ota · bridge protocol 1')
|
||||
expect(report).toContain('Bridge protocol: 1')
|
||||
expect(report).toContain('Likely cause: Connection is healthy.')
|
||||
expect(report).toContain('[web-shell-flow] Web shell: activating app')
|
||||
expect(report).toContain(
|
||||
@@ -357,6 +357,7 @@ describe('shell lifecycle lines in the exported report', () => {
|
||||
lastConnectedAt: null,
|
||||
platform: 'ios',
|
||||
appVersion: '0.0.56',
|
||||
bridgeProtocolVersion: 1,
|
||||
entries: entries([
|
||||
...failed.lines,
|
||||
...present(stall(replay(DOWNLOAD_OPEN.slice(0, 6)))),
|
||||
@@ -387,6 +388,7 @@ describe('shell lifecycle lines in the exported report', () => {
|
||||
lastConnectedAt: null,
|
||||
platform: 'ios',
|
||||
appVersion: '0.0.56',
|
||||
bridgeProtocolVersion: 1,
|
||||
entries: [timeout, ...entries(replay(DOWNLOAD_OPEN).lines)],
|
||||
nowMs: 60_000
|
||||
})
|
||||
|
||||
+33
-71
@@ -1,31 +1,30 @@
|
||||
import { createElement, type ComponentType } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type SwitchDependencies = {
|
||||
/** Committed mounts, not renders: what this file is about is what the user was shown. */
|
||||
natives: string[]
|
||||
refusals: number
|
||||
shells: string[]
|
||||
params: Record<string, string | string[] | undefined>
|
||||
}
|
||||
|
||||
const dependencies = vi.hoisted((): SwitchDependencies => ({
|
||||
natives: [],
|
||||
refusals: 0,
|
||||
shells: [],
|
||||
params: {}
|
||||
}))
|
||||
|
||||
const nativeScreen = vi.hoisted(
|
||||
() =>
|
||||
async (name: string): Promise<ComponentType<Record<string, unknown>>> => {
|
||||
const React = await import('react')
|
||||
return function NativeScreen() {
|
||||
React.useEffect(() => {
|
||||
dependencies.natives.push(name)
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
const refusalScreen = vi.hoisted(
|
||||
() => async (): Promise<ComponentType<Record<string, unknown>>> => {
|
||||
const React = await import('react')
|
||||
return function RefusalScreen() {
|
||||
React.useEffect(() => {
|
||||
dependencies.refusals += 1
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
@@ -45,7 +44,6 @@ vi.mock('../transport/workspace-server', () => ({
|
||||
useWorkspaceServer: () => ({ routeHost: undefined, server: null })
|
||||
}))
|
||||
vi.mock('expo-router', () => ({
|
||||
Redirect: 'Redirect',
|
||||
useLocalSearchParams: () => dependencies.params,
|
||||
useRouter: () => ({ setParams: () => {} })
|
||||
}))
|
||||
@@ -63,36 +61,8 @@ vi.mock('./MobileWebShellScreen', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../host-screen/HostScreen', async () => ({ HostScreen: await nativeScreen('host-list') }))
|
||||
vi.mock('../components/WorkspaceDetailPlaceholder', async () => ({
|
||||
WorkspaceDetailPlaceholder: await nativeScreen('workspace-detail-placeholder')
|
||||
}))
|
||||
vi.mock('../layout/responsive-layout', () => ({
|
||||
useResponsiveLayout: () => ({ isWideLayout: false })
|
||||
}))
|
||||
vi.mock('../tasks/MobileTasksScreen', async () => ({
|
||||
MobileTasksScreen: await nativeScreen('tasks')
|
||||
}))
|
||||
vi.mock('../agent-history/MobileAgentSessionHistoryPanel', async () => ({
|
||||
MobileAgentSessionHistoryPanel: await nativeScreen('agent-history')
|
||||
}))
|
||||
vi.mock('../files/MobileFileExplorerPanel', async () => ({
|
||||
MobileFileExplorerPanel: await nativeScreen('files')
|
||||
}))
|
||||
vi.mock('../files/MobileFilePreviewScreen', async () => ({
|
||||
MobileFilePreviewScreen: await nativeScreen('files-preview')
|
||||
}))
|
||||
vi.mock('../source-control/MobileSourceControlPanel', async () => ({
|
||||
MobileSourceControlPanel: await nativeScreen('source-control')
|
||||
}))
|
||||
vi.mock('../session/MobileDiffReviewRouteScreen', async () => ({
|
||||
MobileDiffReviewRouteScreen: await nativeScreen('review')
|
||||
}))
|
||||
vi.mock('../session/MobileSessionRouteScreen', async () => ({
|
||||
MobileSessionRouteScreen: await nativeScreen('session')
|
||||
}))
|
||||
vi.mock('./PageRouteUnavailableScreen', async () => ({
|
||||
PageRouteUnavailableScreen: await nativeScreen('catch-all')
|
||||
PageRouteUnavailableScreen: await refusalScreen()
|
||||
}))
|
||||
|
||||
import HostListRoute from '../../app/h/[hostId]/index'
|
||||
@@ -106,16 +76,14 @@ import SessionRoute from '../../app/h/[hostId]/session/[worktreeId]'
|
||||
import CatchAllRoute from './catch-all-page-route'
|
||||
|
||||
/**
|
||||
* Every switch the build kind decides, with the params each needs to name a route the shell could
|
||||
* open. `native` is what that switch renders outside an OTA build — a panel for most of them and the
|
||||
* refusal screen for the catch-all, which has no native screen behind it.
|
||||
* Every route switch, with params naming a route the shell could open and params naming none (which
|
||||
* get the refusal screen).
|
||||
*/
|
||||
type SwitchCase = {
|
||||
readonly name: string
|
||||
readonly Route: ComponentType
|
||||
readonly params: Record<string, string | string[]>
|
||||
/** What the mocked native screen pushes when it mounts. */
|
||||
readonly native: string
|
||||
readonly unroutableParams: Record<string, string | string[]>
|
||||
readonly pathname: string
|
||||
}
|
||||
|
||||
@@ -124,63 +92,63 @@ const SWITCHES: readonly SwitchCase[] = [
|
||||
name: 'host list',
|
||||
Route: HostListRoute,
|
||||
params: { hostId: 'host-1' },
|
||||
native: 'host-list',
|
||||
unroutableParams: { hostId: '' },
|
||||
pathname: '/h/host-1'
|
||||
},
|
||||
{
|
||||
name: 'tasks',
|
||||
Route: TasksRoute,
|
||||
params: { hostId: 'host-1' },
|
||||
native: 'tasks',
|
||||
unroutableParams: { hostId: '' },
|
||||
pathname: '/h/host-1/tasks'
|
||||
},
|
||||
{
|
||||
name: 'agent history',
|
||||
Route: AgentHistoryRoute,
|
||||
params: { hostId: 'host-1', worktreeId: 'wt-1' },
|
||||
native: 'agent-history',
|
||||
unroutableParams: { hostId: 'host-1', worktreeId: '' },
|
||||
pathname: '/h/host-1/agent-history/wt-1'
|
||||
},
|
||||
{
|
||||
name: 'files',
|
||||
Route: FilesRoute,
|
||||
params: { hostId: 'host-1', worktreeId: 'wt-1' },
|
||||
native: 'files',
|
||||
unroutableParams: { hostId: 'host-1', worktreeId: '..' },
|
||||
pathname: '/h/host-1/files/wt-1'
|
||||
},
|
||||
{
|
||||
name: 'file preview',
|
||||
Route: FilesPreviewRoute,
|
||||
params: { hostId: 'host-1', worktreeId: 'wt-1', relativePath: 'src/index.ts' },
|
||||
native: 'files-preview',
|
||||
unroutableParams: { hostId: 'host-1', worktreeId: 'wt-1' },
|
||||
pathname: '/h/host-1/files/preview/wt-1'
|
||||
},
|
||||
{
|
||||
name: 'source control',
|
||||
Route: SourceControlRoute,
|
||||
params: { hostId: 'host-1', worktreeId: 'wt-1' },
|
||||
native: 'source-control',
|
||||
unroutableParams: { hostId: 'host-1', worktreeId: '' },
|
||||
pathname: '/h/host-1/source-control/wt-1'
|
||||
},
|
||||
{
|
||||
name: 'review',
|
||||
Route: ReviewRoute,
|
||||
params: { hostId: 'host-1', worktreeId: 'wt-1' },
|
||||
native: 'review',
|
||||
unroutableParams: { hostId: 'host-1', worktreeId: '' },
|
||||
pathname: '/h/host-1/review/wt-1'
|
||||
},
|
||||
{
|
||||
name: 'session',
|
||||
Route: SessionRoute,
|
||||
params: { hostId: 'host-1', worktreeId: 'wt-1' },
|
||||
native: 'session',
|
||||
unroutableParams: { hostId: 'host-1', worktreeId: '' },
|
||||
pathname: '/h/host-1/session/wt-1'
|
||||
},
|
||||
{
|
||||
name: 'catch-all',
|
||||
Route: CatchAllRoute,
|
||||
params: { hostId: 'host-1', page: ['settings'] },
|
||||
native: 'catch-all',
|
||||
unroutableParams: { hostId: 'host-1' },
|
||||
pathname: '/h/host-1/settings'
|
||||
}
|
||||
]
|
||||
@@ -199,30 +167,24 @@ function renderFirstFrame(Route: ComponentType): ReactTestRenderer {
|
||||
|
||||
describe.each(SWITCHES)('the $name switch', (entry) => {
|
||||
beforeEach(() => {
|
||||
dependencies.natives.length = 0
|
||||
dependencies.refusals = 0
|
||||
dependencies.shells.length = 0
|
||||
})
|
||||
|
||||
it('commits the shell on its first frame', async () => {
|
||||
dependencies.params = { ...entry.params }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('commits the shell on its first frame in an OTA build, and never native', async () => {
|
||||
vi.stubEnv('EXPO_PUBLIC_MOBILE_SHELL', 'ota')
|
||||
renderFirstFrame(entry.Route)
|
||||
expect(dependencies.shells).toEqual([entry.pathname])
|
||||
await act(async () => {})
|
||||
expect(dependencies.natives).toEqual([])
|
||||
expect(dependencies.refusals).toBe(0)
|
||||
expect(dependencies.shells).toEqual([entry.pathname])
|
||||
})
|
||||
|
||||
it('commits native on its first frame in any other build, and never the shell', async () => {
|
||||
vi.stubEnv('EXPO_PUBLIC_MOBILE_SHELL', undefined)
|
||||
it('commits the refusal for params that name no screen the shell could open', async () => {
|
||||
dependencies.params = { ...entry.unroutableParams }
|
||||
renderFirstFrame(entry.Route)
|
||||
expect(dependencies.natives).toEqual([entry.native])
|
||||
await act(async () => {})
|
||||
expect(dependencies.natives).toEqual([entry.native])
|
||||
expect(dependencies.refusals).toBe(1)
|
||||
expect(dependencies.shells).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -8,15 +8,14 @@ import { BridgeInitRouteSchema, type BridgeInitRoute } from './bridge/bridge-ini
|
||||
*
|
||||
* `bridge-host.ts` parses the route against this same schema and drops it to `null` when it fails,
|
||||
* so a route that does not fit reaches the phone as an `init` naming no screen — and the page
|
||||
* answers that with "Update Orca to open this workspace", which is both wrong and worse than the
|
||||
* native screen sitting right behind the switch. Deciding here instead means the route stays
|
||||
* native, which is where every route starts.
|
||||
* answers that with "Update Orca to open this workspace", which is wrong. Deciding here instead
|
||||
* lets the route file show the unavailable screen.
|
||||
*
|
||||
* A file path is the reason the files routes needed it first. Paths are params, not segments, so
|
||||
* `/`, spaces and `..` are all fine; length is not bounded by anything the user cannot exceed,
|
||||
* and `BRIDGE_MAX_ROUTE_PARAM_CHARS` is 1024 while a Windows long path is not. The same call
|
||||
* also catches a `worktreeId` the segment rule refuses, which is the C1.8 class, and a host id
|
||||
* that encoding does not save — a `.` or `..` — which is why every switch asks it now.
|
||||
* that encoding does not save — a `.` or `..` — which is why every route file asks it now.
|
||||
*
|
||||
* The schema itself is the predicate rather than a copy of its bounds: two spellings of one rule
|
||||
* drift, and the half that matters is the half the page reads. Here rather than in one domain
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { shellSwitchDecision } from './shell-switch-decision'
|
||||
|
||||
const ROUTE = { pathname: '/h/host-1/files/wt-1' }
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('the hybrid shell switch decision', () => {
|
||||
it('answers the shell, carrying the route, in an OTA build', () => {
|
||||
vi.stubEnv('EXPO_PUBLIC_MOBILE_SHELL', 'ota')
|
||||
expect(shellSwitchDecision(ROUTE)).toEqual({ kind: 'shell', route: ROUTE })
|
||||
})
|
||||
|
||||
it.each([[undefined], ['native'], ['']])(
|
||||
'answers native in a build whose switch is %p',
|
||||
(value) => {
|
||||
vi.stubEnv('EXPO_PUBLIC_MOBILE_SHELL', value)
|
||||
expect(shellSwitchDecision(ROUTE)).toEqual({ kind: 'native' })
|
||||
}
|
||||
)
|
||||
|
||||
it('answers native for a route the shell could never open, even in an OTA build', () => {
|
||||
vi.stubEnv('EXPO_PUBLIC_MOBILE_SHELL', 'ota')
|
||||
expect(shellSwitchDecision(null)).toEqual({ kind: 'native' })
|
||||
})
|
||||
})
|
||||
@@ -1,19 +0,0 @@
|
||||
import { mobileShellBuildKind } from '../storage/preferences'
|
||||
import type { BridgeInitRoute } from './bridge/bridge-init-route'
|
||||
|
||||
/**
|
||||
* Which renderer a hybrid-shell route switch mounts.
|
||||
*
|
||||
* The build decides, synchronously, so every switch commits its renderer on the first frame: the
|
||||
* shell in an OTA build, for a route it could open, and the native screen everywhere else.
|
||||
*/
|
||||
export type ShellSwitchDecision =
|
||||
| { readonly kind: 'native' }
|
||||
| { readonly kind: 'shell'; readonly route: BridgeInitRoute }
|
||||
|
||||
/** The route is `null` when this switch's params name no screen the shell could open. */
|
||||
export function shellSwitchDecision(route: BridgeInitRoute | null): ShellSwitchDecision {
|
||||
return route !== null && mobileShellBuildKind() === 'ota'
|
||||
? { kind: 'shell', route }
|
||||
: { kind: 'native' }
|
||||
}
|
||||
@@ -162,8 +162,8 @@ export function useMobileWebShellBridge(args: MobileWebShellBridgeArgs): MobileW
|
||||
*
|
||||
* The route in it is not the whole story any more (ruling 33.1). A same-path param change used
|
||||
* to be unreachable — the page routes once, before its first render, so a route that changed
|
||||
* afterwards had nothing left to change, and every switch keyed on the whole route to make one
|
||||
* a remount. The session switch does not: a notification tap for another pane of the session on
|
||||
* afterwards had nothing left to change, and every route file keyed on the whole route to make one
|
||||
* a remount. The session route does not: a notification tap for another pane of the session on
|
||||
* screen is a tab switch, so it keeps `paneKey` out of its key and hands the change to
|
||||
* `publishRoute` below, which re-sends `init` to a page that said it takes one.
|
||||
*/
|
||||
|
||||
@@ -83,7 +83,7 @@ import { useShellStackPop } from './use-shell-stack-pop'
|
||||
|
||||
type Harness = {
|
||||
pop: () => BridgeNavigateBackOutcome
|
||||
/** A second `MobileWebShellScreen` over the same native stack, which `/h/a/web` makes reachable. */
|
||||
/** A second `MobileWebShellScreen` over the same native stack, as a pushed route mounts one. */
|
||||
mountSecondShell: () => void
|
||||
/** The second shell's own pop, through whichever instance is mounted when it is called. */
|
||||
secondPop: () => BridgeNavigateBackOutcome
|
||||
@@ -233,8 +233,8 @@ describe('a page that asks to go back twice in one batch', () => {
|
||||
/**
|
||||
* One native stack, so one pending pop.
|
||||
*
|
||||
* `MobileWebShellScreen` mounts at both `app/h/[hostId]/index.tsx` and `app/h/[hostId]/web.tsx`,
|
||||
* and `/h/a/web` is deep-linkable over `/h/a`, so two shells can be mounted over one stack. A latch
|
||||
* Every host route file mounts `MobileWebShellScreen`, and a pushed route (`/h/a/files/w` over
|
||||
* `/h/a`) leaves both mounted, so two shells can be mounted over one stack. A latch
|
||||
* per screen leaves each of them holding its own and two frames still unwind two screens.
|
||||
*/
|
||||
describe('two shells over one stack', () => {
|
||||
|
||||
@@ -9,8 +9,6 @@ export const HOST_STACK_SCREENS = [
|
||||
{ name: '[hostId]/agent-history/[worktreeId]', title: 'Agent Session History' },
|
||||
{ name: '[hostId]/review/[worktreeId]', title: 'Changes' },
|
||||
{ name: '[hostId]/pr/[worktreeId]', title: 'Pull Request' },
|
||||
// OTA builds only: redirects to the host screen everywhere else.
|
||||
{ name: '[hostId]/web', title: 'Workspace' },
|
||||
// Last, and matched last: every pathname above has a file of its own, so this takes only what
|
||||
// expo-router would otherwise send to Unmatched. Declared for the title alone — an undeclared
|
||||
// child still renders, appended after these with this group's screenOptions.
|
||||
|
||||
@@ -45,11 +45,6 @@ vi.mock('../transport/host-descriptor-recorder', () => ({
|
||||
recordHostDescriptorFromStatus: () => {}
|
||||
}))
|
||||
vi.mock('./route-handoff', () => ({ useRouteHandoff: () => ({ replace: () => {} }) }))
|
||||
// The native switch always hands the page its route, so the census reads what the page is told.
|
||||
vi.mock('../mobile-web-shell/shell-switch-decision', () => ({
|
||||
shellSwitchDecision: (shellRoute: unknown) =>
|
||||
shellRoute ? { kind: 'shell', route: shellRoute } : { kind: 'native' }
|
||||
}))
|
||||
vi.mock('../mobile-web-shell/ShellSwitchPendingScreen', () => ({
|
||||
ShellSwitchPendingScreen: () => null
|
||||
}))
|
||||
|
||||
@@ -10,16 +10,9 @@ import { useHostProtocolGates } from '../components/host-protocol-gates-context'
|
||||
import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff'
|
||||
|
||||
/**
|
||||
* The review screen as a component rather than a route module, which is what lets a switch hold it.
|
||||
*
|
||||
* A route file that both reads the flag and calls `useMobileDiffReviewController` runs the whole
|
||||
* controller — its client subscriptions included — behind the page, because hooks cannot be
|
||||
* conditional. As an element it is created and not mounted, so the controller runs only when a
|
||||
* native-shell build renders it. Same reason the explorer switch
|
||||
* builds its panel as an element.
|
||||
*
|
||||
* The params are read here rather than handed down, so this is the route body and the two route
|
||||
* files above it are the switch and its web sibling.
|
||||
* The review screen as a component rather than a route module: the page's route file mounts it, and
|
||||
* the native route file, which mounts the shell, cannot be imported by the page. It reads its own
|
||||
* params.
|
||||
*/
|
||||
export function MobileDiffReviewRouteScreen() {
|
||||
const params = useLocalSearchParams<{
|
||||
|
||||
@@ -2,17 +2,9 @@ import { MobileSessionSurface } from './MobileSessionSurface'
|
||||
import { useMobileSessionController } from './use-mobile-session-controller'
|
||||
|
||||
/**
|
||||
* The session screen as a component rather than a route module, which is what lets a switch hold it.
|
||||
*
|
||||
* `useMobileSessionController` is 32 hooks deep and opens the terminal, chat and tab subscriptions;
|
||||
* hooks cannot be conditional, so a route file that both read the flag and called it would run all
|
||||
* of that behind the page as well as in front of it. As an element passed for `fallback` it is
|
||||
* created and not mounted, which is `MobileDiffReviewRouteScreen`'s reason and the explorer
|
||||
* switch's before it.
|
||||
*
|
||||
* The params are read below this file — the controller's own foundation hook reads them, as does
|
||||
* the notification pane hook — so nothing is handed down and the two route files above are the
|
||||
* switch and its web sibling.
|
||||
* The session screen as a component rather than a route module: the page's route file mounts it, and
|
||||
* the native route file, which mounts the shell, cannot be imported by the page. Its params are read
|
||||
* below this file, by the controller's foundation hook and the notification pane hook.
|
||||
*/
|
||||
export function MobileSessionRouteScreen() {
|
||||
const controller = useMobileSessionController()
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
loadPushNotificationsEnabled,
|
||||
loadTerminalAutocompleteEnabled,
|
||||
loadTerminalLinkOpenMode,
|
||||
mobileShellBuildKind,
|
||||
readPushNotificationsPreference,
|
||||
readDisabledTerminalLiveInputHandlesPreference,
|
||||
saveDisabledTerminalLiveInputHandles,
|
||||
@@ -507,38 +506,3 @@ describe('terminal link open mode preference', () => {
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:terminalLinkOpenMode', 'phone-browser')
|
||||
})
|
||||
})
|
||||
|
||||
/** The build-time constant the release workflow sets. Under Metro this name is inlined before the
|
||||
* bundle is written, so these cases measure the answer the inlined value produces, not the read. */
|
||||
function setShellBuildSwitch(value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'EXPO_PUBLIC_MOBILE_SHELL')
|
||||
return
|
||||
}
|
||||
process.env.EXPO_PUBLIC_MOBILE_SHELL = value
|
||||
}
|
||||
|
||||
describe('the mobile shell build kind', () => {
|
||||
beforeEach(() => {
|
||||
setShellBuildSwitch(undefined)
|
||||
})
|
||||
|
||||
it('is native when the build set no switch at all, which is every default build', () => {
|
||||
expect(mobileShellBuildKind()).toBe('native')
|
||||
})
|
||||
|
||||
it.each([[''], ['native'], ['OTA'], [' ota'], ['ota-preview'], ['true']])(
|
||||
'is native for %p, so only the exact word opts in',
|
||||
(value) => {
|
||||
setShellBuildSwitch(value)
|
||||
|
||||
expect(mobileShellBuildKind()).toBe('native')
|
||||
}
|
||||
)
|
||||
|
||||
it('is ota when the build set exactly that', () => {
|
||||
setShellBuildSwitch('ota')
|
||||
|
||||
expect(mobileShellBuildKind()).toBe('ota')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -118,25 +118,6 @@ export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise
|
||||
await persistMirrored(AUTOCOMPLETE_KEY, value)
|
||||
}
|
||||
|
||||
export type MobileShellBuildKind = 'native' | 'ota'
|
||||
|
||||
/**
|
||||
* Which shell this binary was built for, and the only place the build-time constant is spelled.
|
||||
*
|
||||
* `babel-preset-expo`'s inline-env-vars plugin replaces a literal `process.env.EXPO_PUBLIC_*`
|
||||
* member expression with the build machine's value, so in a release bundle this function has no
|
||||
* variable left in it. That rewrite only fires on a literal member expression: a destructure, a
|
||||
* computed key or a copy through another binding is not inlined and would read `undefined` on a
|
||||
* device, which is why every caller goes through this one and never through `process.env`.
|
||||
*
|
||||
* Anything but the exact string `ota` — unset, empty, a typo, a value from a stale shell — is
|
||||
* native. A build without the variable, development or release, is the native app; the hybrid shell
|
||||
* mounts only in a build made with it, and nothing stored can change that.
|
||||
*/
|
||||
export function mobileShellBuildKind(): MobileShellBuildKind {
|
||||
return process.env.EXPO_PUBLIC_MOBILE_SHELL === 'ota' ? 'ota' : 'native'
|
||||
}
|
||||
|
||||
const TERMINAL_LIVE_INPUT_DISABLED_PREFIX = 'orca:terminalLiveInputDisabled:'
|
||||
|
||||
export type DisabledTerminalLiveInputHandlesPreference = {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Route A web entry: mounts the phone's h/[hostId] route tree on react-native-web.
|
||||
// Built by `build:mobile-web` into the packaged bundle dir; mounted only by a build with
|
||||
// `EXPO_PUBLIC_MOBILE_SHELL=ota`.
|
||||
// Built by `build:mobile-web` into the packaged bundle dir; mounted by the app's shell.
|
||||
import { useEffect, type PropsWithChildren } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ExpoRoot } from 'expo-router'
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
},
|
||||
{
|
||||
"file": "app/h/[hostId]/index.web.tsx",
|
||||
"reason": "The shell renders this page for this route, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser."
|
||||
"reason": "The shell renders this page for this route, so the page has no shell to mount inside itself. Its native file reaches OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser."
|
||||
},
|
||||
{
|
||||
"file": "src/platform/text-input-font-size.web.ts",
|
||||
@@ -51,7 +51,7 @@
|
||||
},
|
||||
{
|
||||
"file": "app/h/[hostId]/files/[worktreeId].web.tsx",
|
||||
"reason": "The shell renders this page for the file explorer, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser — reproduced in the render check, which painted that console error instead of the route."
|
||||
"reason": "The shell renders this page for the file explorer, so the page has no shell to mount inside itself. Its native file reaches OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser — reproduced in the render check, which painted that console error instead of the route."
|
||||
},
|
||||
{
|
||||
"file": "app/h/[hostId]/files/preview/[worktreeId].web.tsx",
|
||||
@@ -59,11 +59,7 @@
|
||||
},
|
||||
{
|
||||
"file": "app/h/[hostId]/agent-history/[worktreeId].web.tsx",
|
||||
"reason": "The shell renders this page for this route, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser."
|
||||
},
|
||||
{
|
||||
"file": "app/h/[hostId]/web.web.tsx",
|
||||
"reason": "The hybrid shell route opens a WebView on this very page, so on web it redirects to the host instead of nesting the shell inside itself. Its native file pulls in OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser."
|
||||
"reason": "The shell renders this page for this route, so the page has no shell to mount inside itself. Its native file reaches OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser."
|
||||
},
|
||||
{
|
||||
"file": "src/platform/external-link.web.ts",
|
||||
@@ -71,7 +67,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/platform/keyboard-occlusion.web.ts",
|
||||
"reason": "react-native-web's Keyboard is a stub: addListener returns a subscription that never fires and isVisible() is always false, so a screen waiting for keyboardDidShow inside the page waits forever and the software keyboard covers whatever sits at the bottom of the document \u2014 the source-control commit bar, and the review note composer whose KeyboardAvoidingView is driven by those same events. The browser publishes the geometry a different way: the layout viewport keeps its size and visualViewport shrinks, so the occluded strip is innerHeight minus the visual viewport's height and offsetTop, tracked on its resize and scroll. A document with no visualViewport answers 0 rather than guessing."
|
||||
"reason": "react-native-web's Keyboard is a stub: addListener returns a subscription that never fires and isVisible() is always false, so a screen waiting for keyboardDidShow inside the page waits forever and the software keyboard covers whatever sits at the bottom of the document — the source-control commit bar, and the review note composer whose KeyboardAvoidingView is driven by those same events. The browser publishes the geometry a different way: the layout viewport keeps its size and visualViewport shrinks, so the occluded strip is innerHeight minus the visual viewport's height and offsetTop, tracked on its resize and scroll. A document with no visualViewport answers 0 rather than guessing."
|
||||
},
|
||||
{
|
||||
"file": "src/platform/keyboard-persisting-taps.web.ts",
|
||||
@@ -91,11 +87,11 @@
|
||||
},
|
||||
{
|
||||
"file": "app/h/[hostId]/tasks.web.tsx",
|
||||
"reason": "The shell renders this page for this route, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and throws in a browser, and one throwing route module takes the whole bundle down because the manifest imports them all."
|
||||
"reason": "The shell renders this page for this route, so the page has no shell to mount inside itself. Its native file reaches OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and throws in a browser, and one throwing route module takes the whole bundle down because the manifest imports them all."
|
||||
},
|
||||
{
|
||||
"file": "app/h/[hostId]/source-control/[worktreeId].web.tsx",
|
||||
"reason": "The shell renders this page for the source-control hub, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and throws in a browser, and the route manifest imports every route, so one throwing module takes the whole bundle down. The tab, name and origin params are parsed here exactly as the native sibling parses them, because the page reads them back out of its own URL the way the native screen reads them out of the router."
|
||||
"reason": "The shell renders this page for the source-control hub, so the page has no shell to mount inside itself. Its native file reaches OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and throws in a browser, and the route manifest imports every route, so one throwing module takes the whole bundle down. The tab, name and origin params are parsed here exactly as the native sibling parses them, because the page reads them back out of its own URL the way the native screen reads them out of the router."
|
||||
},
|
||||
{
|
||||
"file": "app/h/[hostId]/review/[worktreeId].web.tsx",
|
||||
@@ -103,7 +99,7 @@
|
||||
},
|
||||
{
|
||||
"file": "app/h/[hostId]/session/[worktreeId].web.tsx",
|
||||
"reason": "The shell renders this page for the session screen, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and throws in a browser, and the route manifest imports every route, so one throwing module takes the whole bundle down. Shorter than its siblings because the route body is MobileSessionRouteScreen, which the switch also needs as an element: useMobileSessionController opens the terminal, chat and tab subscriptions, and at the switch's top level it would open them behind the page too."
|
||||
"reason": "The shell renders this page for the session screen, so the page has no shell to mount inside itself. Its native file reaches OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and throws in a browser, and the route manifest imports every route, so one throwing module takes the whole bundle down. Shorter than its siblings because the route body is MobileSessionRouteScreen, which the switch also needs as an element: useMobileSessionController opens the terminal, chat and tab subscriptions, and at the switch's top level it would open them behind the page too."
|
||||
},
|
||||
{
|
||||
"file": "src/browser/browser-frame-layer-paint.web.ts",
|
||||
@@ -195,7 +191,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/terminal/terminal-text-field-submit-binding.web.ts",
|
||||
"reason": "react-native-web's keydown handler withholds onSubmitEditing whenever the Enter keydown reports a composition \u2014 nativeEvent.isComposing, or the Android keyCode 229 that stands for it \u2014 which is a soft keyboard's normal state mid-word, so Enter on the page neither sent the line nor cleared the field while native Android's editor action fired as it always does. Both terminal fields reach their send through onSubmitEditing alone and both are bound here: the live input's carriage return, and the buffered command field's draft. The native file binds nothing, because that editor action needs no help; this one listens for beforeinput and acts on insertLineBreak and insertParagraph, the browser's own end-of-line signals. react-native-web calls preventDefault on every keydown it does submit on, so those events exist only in the cases it dropped and never produce a second carriage return; an IME still choosing a candidate reports insertCompositionText and is left alone."
|
||||
"reason": "react-native-web's keydown handler withholds onSubmitEditing whenever the Enter keydown reports a composition — nativeEvent.isComposing, or the Android keyCode 229 that stands for it — which is a soft keyboard's normal state mid-word, so Enter on the page neither sent the line nor cleared the field while native Android's editor action fired as it always does. Both terminal fields reach their send through onSubmitEditing alone and both are bound here: the live input's carriage return, and the buffered command field's draft. The native file binds nothing, because that editor action needs no help; this one listens for beforeinput and acts on insertLineBreak and insertParagraph, the browser's own end-of-line signals. react-native-web calls preventDefault on every keydown it does submit on, so those events exist only in the cases it dropped and never produce a second carriage return; an IME still choosing a candidate reports insertCompositionText and is left alone."
|
||||
},
|
||||
{
|
||||
"file": "src/transport/host-app-version-store.web.ts",
|
||||
|
||||
Reference in New Issue
Block a user