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]