Files
orca/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.ts
T
Jinwoo Hong ba858ee446 feat(mobile): the keyboard covers the page like a native screen, and the shell says its height (#23110)
The shell no longer shortens the WebView for the keyboard; it publishes the keyboard height like the safe-area insets, so native's keyboard lift, refit hold and dismiss key run on the page unchanged. Keyboard and inset arithmetic read the shell's OS through a host-os seam. One page-version floor (manifest pageVersion, shell floor 1) replaces per-feature accept negotiation; a page below the floor gets the existing update wall, a desktop with no bundle keeps native screens. iOS shell drops the form accessory bar and its own keyboard observers. Native session screens untouched.
2026-09-28 04:07:35 -04:00

156 lines
5.7 KiB
TypeScript

import { fetchMobileWebBundle } from '../transport/mobile-web-bundle-fetch'
import { mobileWebBundleManifestRead } from '../transport/mobile-web-bundle-operations'
import { runRpcOperation } from '../transport/rpc-operation'
import type { RpcClient } from '../transport/rpc-client'
import type { GenerationStore } from './generation-store'
import type { MobileWebShellRuntime } from './mobile-web-shell-runtime'
import { generationDirectoryPath } from './generation-store-file-system'
import type {
CachedGeneration,
MobileWebShellSessionEvent
} from './mobile-web-shell-session-contract'
import {
updateFailureCauseOf,
type MobileWebShellUpdateFailureCause
} from './mobile-web-shell-update-failure'
/**
* The work the session's effects do: read the cache, read the manifest, download a generation.
*
* Split from the hook because these are the only parts that touch the network and the disk, and
* the hook above them is a reducer and a set of callbacks. Each one answers by sending an event
* back; none of them decides anything.
*/
export async function openCache(
store: GenerationStore,
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.
await store.sweepStagedGenerations()
const active = await store.readActiveGeneration(hostKey)
return active === null
? null
: {
buildId: active.buildId,
directory: generationDirectoryPath(active.directory),
totalBytes: active.manifest.totalBytes,
routes: active.manifest.routes,
compat: {
schemaVersion: active.manifest.schemaVersion,
runtimeProtocolVersion: active.manifest.runtimeProtocolVersion,
minCompatibleRuntimeProtocolVersion:
active.manifest.minCompatibleRuntimeProtocolVersion,
pageVersion: active.manifest.pageVersion
}
}
} catch {
// A cache that cannot be read is not a cache that is wrong: nothing is deleted, and the flow
// treats it as absent, which downloads when connected and says so when not.
return null
}
}
/** No client is no link, and the gates are about to say so. */
const NO_CONNECTION: MobileWebShellUpdateFailureCause = { reason: 'no-connection', hostCode: null }
/** Bytes that arrived whole and verified, refused by this phone's own disk. */
const CACHE_WRITE_FAILED: MobileWebShellUpdateFailureCause = {
reason: 'cache-write-failed',
hostCode: null
}
export async function readManifest(
client: RpcClient | null,
flow: number,
send: (event: MobileWebShellSessionEvent) => void
): Promise<void> {
if (client === null) {
send({ type: 'download-failed', flow, cause: NO_CONNECTION })
return
}
try {
const opened = await runRpcOperation(client, mobileWebBundleManifestRead, null)
const manifest = opened.manifest
send({
type: 'manifest-read',
flow,
manifest: {
buildId: manifest.buildId,
schemaVersion: manifest.schemaVersion,
runtimeProtocolVersion: manifest.runtimeProtocolVersion,
minCompatibleRuntimeProtocolVersion: manifest.minCompatibleRuntimeProtocolVersion,
pageVersion: manifest.pageVersion,
totalBytes: manifest.totalBytes,
totalAssets: manifest.assets.length,
routes: manifest.routes,
wire: manifest
}
})
} catch (error) {
send({ type: 'download-failed', flow, cause: updateFailureCauseOf(error) })
}
}
export async function download(args: {
client: RpcClient | null
store: GenerationStore
hostKey: string
flow: number
runtime: MobileWebShellRuntime
startedAt: number
downloads: Set<AbortController>
send: (event: MobileWebShellSessionEvent) => void
}): Promise<void> {
const { client, store, hostKey, flow, runtime, send } = args
if (client === null) {
send({ type: 'download-failed', flow, cause: NO_CONNECTION })
return
}
const controller = new AbortController()
args.downloads.add(controller)
// Past the fetch, a throw is the store's: the bytes had already verified.
let fetchedWhole = false
try {
const fetched = await fetchMobileWebBundle({
client,
signal: controller.signal,
onProgress: (progress) => send({ type: 'fetch-progress', flow, ...progress })
})
// The bytes are in; the session they were for may not be. The fetch throws on an abort it sees,
// but an abort landing between its last read and this line would otherwise still write a
// generation for a host screen nobody is on any more.
if (controller.signal.aborted) {
return
}
fetchedWhole = true
send({ type: 'download-staged', flow })
const staged = await store.stageGeneration(hostKey, fetched)
// Again before the commit, because the commit is the write that is not the staging tree's to
// undo: it renames into the active slot and moves the host index. An abort that landed while
// the bytes were being staged takes the staged tree back out instead.
if (controller.signal.aborted) {
await store.abortStagedGeneration(staged).catch(() => undefined)
return
}
const committed = await store.commitGeneration(staged)
send({
type: 'activated',
flow,
generationDirectory: generationDirectoryPath(committed.directory),
sessionId: runtime.mintSessionId(),
buildId: committed.buildId,
totalBytes: committed.manifest.totalBytes,
elapsedMs: runtime.now() - args.startedAt
})
} catch (error) {
send({
type: 'download-failed',
flow,
cause: fetchedWhole ? CACHE_WRITE_FAILED : updateFailureCauseOf(error)
})
} finally {
args.downloads.delete(controller)
}
}