From 040113ea96de352acd422fbd56c6ea2fa942e611 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 22 Sep 2026 22:16:10 -0400 Subject: [PATCH] feat(mobile): read the bundle range capability and range replies Adds the range reply reader and operation, and picks range or chunk from the status.get capabilities the connection already proved, so an older desktop keeps being paged in chunks with no probe round trip. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../transport/mobile-web-bundle-operations.ts | 20 +++- .../mobile-web-bundle-range-reply.test.ts | 96 +++++++++++++++++++ .../mobile-web-bundle-read-method.ts | 16 ++++ .../mobile-web-bundle-reply-schemas.ts | 15 +++ 4 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 mobile/src/transport/mobile-web-bundle-range-reply.test.ts create mode 100644 mobile/src/transport/mobile-web-bundle-read-method.ts diff --git a/mobile/src/transport/mobile-web-bundle-operations.ts b/mobile/src/transport/mobile-web-bundle-operations.ts index 7db7b510c65..0e507372d06 100644 --- a/mobile/src/transport/mobile-web-bundle-operations.ts +++ b/mobile/src/transport/mobile-web-bundle-operations.ts @@ -4,20 +4,22 @@ import { MOBILE_WEB_BUNDLE_MANIFEST_METHOD, type MobileWebBundleErrorCode } from '../../../src/shared/mobile-web-bundle/bundle-rpc-contract' +import { MOBILE_WEB_BUNDLE_RANGE_METHOD } from '../../../src/shared/mobile-web-bundle/bundle-range-rpc-contract' import { MobileWebBundleChunkReplySchema, - MobileWebBundleManifestReplySchema + MobileWebBundleManifestReplySchema, + MobileWebBundleRangeReplySchema } from './mobile-web-bundle-reply-schemas' import { isRpcDeliveryUnknown } from './rpc-delivery-ambiguity' import { defineRpcOperation } from './rpc-operation' import { isLogicalClientCutoverError } from './stable-logical-rpc-client' import { rpcResultVariant } from './rpc-operation-result-reader' -// The two reads that hand a paired phone the desktop's mobile web bundle. Both are +// The reads that hand a paired phone the desktop's mobile web bundle. All are // `require-result-or-throw`: there is no partial success here, and a salvage policy would produce a -// half-bundle that fails a hash check much later, far from the cause. Both settle at `on-settle`, +// half-bundle that fails a hash check much later, far from the cause. All settle at `on-settle`, // because each reply is acted on before the next request is built — the manifest decides which -// assets to page, and a chunk decides the next offset. +// assets to page, and a chunk or range decides the next offset. /** The whole manifest plus the chunk size the host will serve it at. */ export const mobileWebBundleManifestRead = defineRpcOperation({ @@ -37,6 +39,16 @@ export const mobileWebBundleChunkRead = defineRpcOperation({ read: rpcResultVariant('mobile-web-bundle-chunk', MobileWebBundleChunkReplySchema) }) +/** Up to 384 KiB of one asset, gzipped when that shrinks it. Only sent to a host that advertised + * `mobileWeb.bundle.range.v1`; see `mobile-web-bundle-read-method.ts`. */ +export const mobileWebBundleRangeRead = defineRpcOperation({ + name: 'mobileWeb.bundle-range', + method: MOBILE_WEB_BUNDLE_RANGE_METHOD, + acceptance: 'require-result-or-throw', + barrier: 'on-settle', + read: rpcResultVariant('mobile-web-bundle-range', MobileWebBundleRangeReplySchema) +}) + /** A code is a bare snake_case token, so only the two positions one can occupy are read. */ const LEADING_CODE_TOKEN = /^[a-z][a-z0-9_]*/ diff --git a/mobile/src/transport/mobile-web-bundle-range-reply.test.ts b/mobile/src/transport/mobile-web-bundle-range-reply.test.ts new file mode 100644 index 00000000000..eb09f22af46 --- /dev/null +++ b/mobile/src/transport/mobile-web-bundle-range-reply.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { + MOBILE_WEB_BUNDLE_RANGE_MAX_DATA_BASE64_LENGTH, + MOBILE_WEB_BUNDLE_RANGE_METHOD +} from '../../../src/shared/mobile-web-bundle/bundle-range-rpc-contract' +import { + MOBILE_WEB_BUNDLE_CAPABILITY, + MOBILE_WEB_BUNDLE_RANGE_CAPABILITY +} from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' +import { MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES } from '../../../src/shared/mobile-web-bundle/manifest-contract' +import { mobileWebBundleRangeRead } from './mobile-web-bundle-operations' +import { mobileWebBundleReadMethodFor } from './mobile-web-bundle-read-method' + +function rangeReply(overrides: Record = {}) { + return { + buildId: 'a'.repeat(64), + path: 'index.html', + offset: 0, + assetByteLength: 12, + sha256: 'b'.repeat(64), + encoding: 'gzip', + dataBase64: 'aGVsbG8=', + eof: true, + ...overrides + } +} + +describe('mobile web bundle range reply reader', () => { + it('reads a range and passes an unknown member through', () => { + const result = mobileWebBundleRangeRead.read({ ...rangeReply(), later: 1 }) + + expect(result.compatible).toBe(true) + if (!result.compatible) { + return + } + expect(result.variant).toBe('mobile-web-bundle-range') + expect(result.value).toMatchObject({ encoding: 'gzip', later: 1 }) + }) + + // Refusing it here would fail with a shape error; the decoder names the encoding instead. + it('reads an encoding this build does not know through to the decoder', () => { + expect(mobileWebBundleRangeRead.read(rangeReply({ encoding: 'br' })).compatible).toBe(true) + }) + + it('bounds dataBase64 at the range size the contract allows', () => { + const at = 'A'.repeat(MOBILE_WEB_BUNDLE_RANGE_MAX_DATA_BASE64_LENGTH) + expect(mobileWebBundleRangeRead.read(rangeReply({ dataBase64: at })).compatible).toBe(true) + expect(mobileWebBundleRangeRead.read(rangeReply({ dataBase64: `${at}A` })).compatible).toBe( + false + ) + }) + + it('requires every member that makes a range self-describing', () => { + for (const overrides of [ + { buildId: 'nope' }, + { path: '../escape.js' }, + { offset: -1 }, + { assetByteLength: MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES + 1 }, + { sha256: 'b'.repeat(63) }, + { encoding: undefined }, + { encoding: '' }, + { dataBase64: undefined }, + { eof: undefined } + ]) { + expect(mobileWebBundleRangeRead.read(rangeReply(overrides)).compatible).toBe(false) + } + }) + + it('is a require-result read of the range method', () => { + expect(mobileWebBundleRangeRead.method).toBe(MOBILE_WEB_BUNDLE_RANGE_METHOD) + expect(mobileWebBundleRangeRead.acceptance).toBe('require-result-or-throw') + expect(mobileWebBundleRangeRead.barrier).toBe('on-settle') + }) +}) + +describe('which read method a host is paged with', () => { + const NEW_DESKTOP = [ + 'files.pathsExist', + MOBILE_WEB_BUNDLE_CAPABILITY, + MOBILE_WEB_BUNDLE_RANGE_CAPABILITY, + 'terminal.quick-commands.v1' + ] + /** Derived, never written down: the one string removed from what the new desktop sends. */ + const OLD_DESKTOP = NEW_DESKTOP.filter( + (capability) => capability !== MOBILE_WEB_BUNDLE_RANGE_CAPABILITY + ) + + it('pages a new client against a new host in ranges', () => { + expect(mobileWebBundleReadMethodFor(NEW_DESKTOP)).toBe('range') + }) + + it('pages a new client against an old host in chunks', () => { + expect(mobileWebBundleReadMethodFor(OLD_DESKTOP)).toBe('chunk') + expect(mobileWebBundleReadMethodFor([])).toBe('chunk') + }) +}) diff --git a/mobile/src/transport/mobile-web-bundle-read-method.ts b/mobile/src/transport/mobile-web-bundle-read-method.ts new file mode 100644 index 00000000000..59f7d40d3db --- /dev/null +++ b/mobile/src/transport/mobile-web-bundle-read-method.ts @@ -0,0 +1,16 @@ +import { MOBILE_WEB_BUNDLE_RANGE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' + +/** `range` pages 384 KiB gzipped windows; `chunk` pages the 48 KiB raw ones every bundle host serves. */ +export type MobileWebBundleReadMethod = 'range' | 'chunk' + +/** + * Read off the `status.get` capabilities this connection already proved, never probed: a phone + * calling a method an older desktop never allowlisted is told `forbidden`, not `method_not_found`, + * so a probe would need to read an authorization code as absence. A host without the capability + * keeps being paged in chunks, which is what it has always served. + */ +export function mobileWebBundleReadMethodFor( + hostCapabilities: readonly string[] +): MobileWebBundleReadMethod { + return hostCapabilities.includes(MOBILE_WEB_BUNDLE_RANGE_CAPABILITY) ? 'range' : 'chunk' +} diff --git a/mobile/src/transport/mobile-web-bundle-reply-schemas.ts b/mobile/src/transport/mobile-web-bundle-reply-schemas.ts index 879e6154221..c8ef1dc5915 100644 --- a/mobile/src/transport/mobile-web-bundle-reply-schemas.ts +++ b/mobile/src/transport/mobile-web-bundle-reply-schemas.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { MOBILE_WEB_BUNDLE_CHUNK_BYTES } from '../../../src/shared/mobile-web-bundle/bundle-rpc-contract' +import { MOBILE_WEB_BUNDLE_RANGE_MAX_DATA_BASE64_LENGTH } from '../../../src/shared/mobile-web-bundle/bundle-range-rpc-contract' import { computeMobileWebBundleId, MobileWebBundleAssetPathSchema, @@ -129,6 +130,20 @@ export const MobileWebBundleChunkReplySchema = z.looseObject({ eof: z.boolean() }) +/** A chunk's self-description plus the encoding of `dataBase64`. `encoding` is read as a string, not + * a closed enum: an encoding this build cannot decode is a typed refusal at the decoder, which + * names it, rather than a reply-shape failure that names nothing. */ +export const MobileWebBundleRangeReplySchema = z.looseObject({ + buildId: z.string().regex(SHA256_PATTERN), + path: MobileWebBundleAssetPathSchema, + offset: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES), + assetByteLength: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES), + sha256: z.string().regex(SHA256_PATTERN), + encoding: z.string().min(1).max(32), + dataBase64: z.string().max(MOBILE_WEB_BUNDLE_RANGE_MAX_DATA_BASE64_LENGTH), + eof: z.boolean() +}) + export type MobileWebBundleManifestReply = z.output export type MobileWebBundleManifestRead = MobileWebBundleManifestReply['manifest'] export type MobileWebBundleAssetRead = MobileWebBundleManifestRead['assets'][number]