mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 08:02:12 +00:00
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.
156 lines
5.7 KiB
TypeScript
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)
|
|
}
|
|
}
|