diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index b32044ff5a5..d89acf462e0 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -79,16 +79,22 @@ async function withScratch(run) { * this file under the 600-line cap. */ const EXPECTED_PAGE_ROUTES = [ - { pathname: '/h/[hostId]', grants: ['navigate', 'storage'] }, - { pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] }, + { pathname: '/h/[hostId]', grants: ['navigate', 'storage', 'haptics'] }, + { + pathname: '/h/[hostId]/agent-history/[worktreeId]', + grants: ['navigate', 'storage', 'haptics'] + }, { pathname: '/h/[hostId]/tasks', - grants: ['navigate', 'storage', 'externalLink', 'native.clipboard.write'] + grants: ['navigate', 'storage', 'externalLink', 'haptics', 'native.clipboard.write'] + }, + { + pathname: '/h/[hostId]/files/[worktreeId]', + grants: ['navigate', 'storage', 'externalLink', 'haptics'] }, - { pathname: '/h/[hostId]/files/[worktreeId]', grants: ['navigate', 'storage', 'externalLink'] }, { pathname: '/h/[hostId]/files/preview/[worktreeId]', - grants: ['navigate', 'storage', 'externalLink'] + grants: ['navigate', 'storage', 'externalLink', 'haptics'] } ] diff --git a/config/scripts/mobile-web-app-files-render.test.mjs b/config/scripts/mobile-web-app-files-render.test.mjs index 52ab7a50927..5c8baedd48b 100644 --- a/config/scripts/mobile-web-app-files-render.test.mjs +++ b/config/scripts/mobile-web-app-files-render.test.mjs @@ -48,7 +48,7 @@ const SHELL_HOST = { lastConnected: 1 } /** Exactly what the preview declares in `MOBILE_WEB_PAGE_ROUTES`, plus the protocol's own grant. */ -const PREVIEW_GRANTS = ['navigate', 'storage', 'externalLink'] +const PREVIEW_GRANTS = ['navigate', 'storage', 'externalLink', 'haptics'] /** * A terminal artifact, which is the only preview this screen lets anyone edit. diff --git a/config/scripts/mobile-web-app-haptics-seam.mjs b/config/scripts/mobile-web-app-haptics-seam.mjs new file mode 100644 index 00000000000..d7caa005297 --- /dev/null +++ b/config/scripts/mobile-web-app-haptics-seam.mjs @@ -0,0 +1,213 @@ +/** + * The haptics seam, and how a census tells a page function that asks the shell from one that does + * nothing. + * + * `haptics.web.ts` used to be five empty bodies, which is a shape no scan can distinguish from a + * file it failed to read: an empty result and a green census meant the same thing. Now each of the + * five posts one `native.haptics.trigger` notify carrying its own kind, so the census measures the + * kinds it found — and runs the same walk over the native sibling, where the same five functions + * exist and none of them posts, as the control that says the walk can tell the two apart. + * + * Shared rather than restated in each route's census, for the reason + * `mobile-web-app-external-link-seam.mjs` is: two spellings of one rule drift. + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' + +/** The seam as the web build resolves it: `.web.ts` wins under the builder's `resolveExtensions`. */ +export const HAPTICS_SEAM = 'src/platform/haptics.web.ts' + +/** Its native sibling, which the page must never resolve to — it imports `expo-haptics`. */ +export const HAPTICS_NATIVE = 'src/platform/haptics.ts' + +/** The module that declares the kinds, so a census reads them instead of listing them again. */ +export const HAPTICS_KINDS_MODULE = 'src/mobile-web-shell/bridge/bridge-haptics-notify.ts' + +const KINDS_CONST = 'BRIDGE_HAPTICS_KINDS' +const PUBLISH_FUNCTION = 'publishHapticsNotifier' + +const parse = (source, fileName) => + ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true) + +/** + * The kinds the notify admits, read off the tuple that declares them. + * + * Parsed rather than matched, so a mention of the name in a comment or a docstring is not a + * declaration, and so the quote style is settled for free. + */ +export function bridgeHapticsKinds(source, fileName = 'bridge-haptics-notify.ts') { + const parsed = parse(source, fileName) + for (const statement of parsed.statements) { + if (!ts.isVariableStatement(statement)) { + continue + } + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || declaration.name.text !== KINDS_CONST) { + continue + } + // `as const` wraps the literal in an assertion expression; the tuple is inside it. + const initializer = + declaration.initializer !== undefined && ts.isAsExpression(declaration.initializer) + ? declaration.initializer.expression + : declaration.initializer + if (initializer === undefined || !ts.isArrayLiteralExpression(initializer)) { + continue + } + return initializer.elements + .filter((element) => ts.isStringLiteral(element)) + .map((element) => element.text) + } + } + return [] +} + +/** + * The name of the module-level binding `publishHapticsNotifier` assigns, or null in a module that + * publishes nothing. + * + * Derived rather than assumed: the census must not be keyed on a local called `post`, because + * renaming it would silently turn every posting site into a non-posting one and leave the census + * green on a page with no haptics at all. + */ +function notifierBinding(parsed) { + let binding = null + const visit = (node) => { + if ( + ts.isFunctionDeclaration(node) && + node.name !== undefined && + node.name.text === PUBLISH_FUNCTION + ) { + const assign = (inner) => { + if ( + ts.isBinaryExpression(inner) && + inner.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isIdentifier(inner.left) + ) { + binding = inner.left.text + } + ts.forEachChild(inner, assign) + } + ts.forEachChild(node, assign) + return + } + ts.forEachChild(node, visit) + } + ts.forEachChild(parsed, visit) + return binding +} + +/** Every string literal this call is handed, so a site that posts a computed kind reports none. */ +function literalArguments(call) { + return call.arguments.filter((argument) => ts.isStringLiteral(argument)).map((a) => a.text) +} + +/** + * Every exported `trigger…` function in a haptics module, and the kind it posts. + * + * `kind` is null for a function that posts nothing, which is what the native sibling's five are and + * what the web sibling's five used to be. Reported as sites rather than as a boolean because a + * census whose red names `path:line` is read once and one that names a file is grepped for. + */ +export function hapticsTriggerSites(source, fileName = 'haptics.ts') { + const parsed = parse(source, fileName) + const binding = notifierBinding(parsed) + const lineOf = (node) => parsed.getLineAndCharacterOfPosition(node.getStart(parsed)).line + 1 + const sites = [] + for (const statement of parsed.statements) { + if ( + !ts.isFunctionDeclaration(statement) || + statement.name === undefined || + !statement.name.text.startsWith('trigger') || + statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) !== + true + ) { + continue + } + const posted = [] + if (binding !== null && statement.body !== undefined) { + const visit = (node) => { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + if (node.expression.text === binding) { + posted.push(...literalArguments(node)) + } + } + ts.forEachChild(node, visit) + } + ts.forEachChild(statement.body, visit) + } + sites.push({ + name: statement.name.text, + line: lineOf(statement), + // One kind per call and one call per function: two would be two taps for one gesture. + kind: posted.length === 1 ? posted[0] : null + }) + } + return sites +} + +/** The kinds a module posts, in the order its functions are declared. */ +export function hapticsPostedKinds(source, fileName = 'haptics.ts') { + return hapticsTriggerSites(source, fileName) + .map((site) => site.kind) + .filter((kind) => kind !== null) +} + +/** + * The names a module imports from the app's haptics, which is how the shell's mapping is held to it. + * + * `page-haptics.ts` names each function as a named import rather than reaching a namespace, so a + * row naming something `haptics.ts` does not export is already a compile error. This is the other + * direction, which no type states: a haptic that file grows with no kind of its own would be one + * the page can never ask for, and comparing this list against the file's own exports is the only + * thing that sees it. + */ +export function hapticsImportedNames(source, fileName = 'module.ts') { + const parsed = parse(source, fileName) + const names = [] + for (const statement of parsed.statements) { + if ( + !ts.isImportDeclaration(statement) || + !ts.isStringLiteral(statement.moduleSpecifier) || + !/(?:\.\.?\/)+platform\/haptics$/.test(statement.moduleSpecifier.text) + ) { + continue + } + const bindings = statement.importClause?.namedBindings + if (bindings !== undefined && ts.isNamedImports(bindings)) { + // The imported name, not the local one: a renamed import is the same export. + names.push(...bindings.elements.map((element) => (element.propertyName ?? element.name).text)) + } + } + return [...new Set(names)].sort() +} + +/** + * Every module in a closure that imports the haptics seam, as the path the closure reports. + * + * The specifier is read extensionless, because that is how a consumer writes it and how the builder + * resolves it: a module importing `../platform/haptics` gets the `.web.ts` on the page and the + * native file on a phone, and the census's job is to say which one the closure ended up with. + */ +export function hapticsSeamImporters(mobileDir, closure) { + const specifier = /(?:^|['"])(?:\.\.?\/)+platform\/haptics(?:\.web)?['"]$/ + return closure.local + .filter((file) => file !== HAPTICS_SEAM && file !== HAPTICS_NATIVE) + .filter((file) => { + let source + try { + source = readFileSync(join(mobileDir, file), 'utf8') + } catch { + return false + } + const parsed = parse(source, file) + return parsed.statements.some( + (statement) => + (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) && + statement.moduleSpecifier !== undefined && + ts.isStringLiteral(statement.moduleSpecifier) && + specifier.test(`'${statement.moduleSpecifier.text}'`) + ) + }) + .sort() +} diff --git a/config/scripts/mobile-web-app-haptics-seam.test.mjs b/config/scripts/mobile-web-app-haptics-seam.test.mjs new file mode 100644 index 00000000000..3ddf53a74d1 --- /dev/null +++ b/config/scripts/mobile-web-app-haptics-seam.test.mjs @@ -0,0 +1,301 @@ +/** + * What a page's taps reach for a haptic, and the grant every page route needs to get one. + * + * Inside the shell's WebView `expo-haptics` fakes an iOS haptic by clicking a hidden checkbox it + * appends to `document.head`, which is what killed a long press on the worktree list (C1.9). So the + * page's seam posts `native.haptics.trigger` instead and the app plays the device's own — and a + * route that imports the seam without declaring `haptics` is a page whose taps go quiet, because + * grants are resolved once from the route the shell opened. + * + * The scan has a control rather than an empty list: the same walk over the native sibling finds the + * same five functions and no posting site, which is what says it can tell the two apart. + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs' +import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' +import { + HAPTICS_KINDS_MODULE, + HAPTICS_NATIVE, + HAPTICS_SEAM, + bridgeHapticsKinds, + hapticsImportedNames, + hapticsPostedKinds, + hapticsSeamImporters, + hapticsTriggerSites +} from './mobile-web-app-haptics-seam.mjs' + +const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url)) +const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip + +const read = (file) => readFileSync(join(mobileDir, file), 'utf8') + +/** The route module behind each declared page route, which is what a closure is read from. */ +const ROUTE_MODULES = new Map([ + ['/h/[hostId]', 'app/h/[hostId]/index.tsx'], + ['/h/[hostId]/agent-history/[worktreeId]', 'app/h/[hostId]/agent-history/[worktreeId].tsx'], + ['/h/[hostId]/tasks', 'app/h/[hostId]/tasks.tsx'], + ['/h/[hostId]/files/[worktreeId]', 'app/h/[hostId]/files/[worktreeId].tsx'], + ['/h/[hostId]/files/preview/[worktreeId]', 'app/h/[hostId]/files/preview/[worktreeId].tsx'] +]) + +const HAPTICS_GRANT = 'haptics' + +/** The shell's mapping from a notify kind to one of the app's own functions. */ +const SHELL_MAPPING = 'src/mobile-web-shell/page-haptics.ts' + +describe('the seam reader', () => { + it('names the kind each exported trigger posts', () => { + expect( + hapticsTriggerSites( + [ + 'let post = () => false', + 'export function publishHapticsNotifier(notify) {', + ' post = notify', + '}', + "export function triggerSelection() { post('selection') }" + ].join('\n'), + 'haptics.web.ts' + ) + ).toEqual([{ name: 'triggerSelection', line: 5, kind: 'selection' }]) + }) + + it('reads the binding the publisher assigns rather than a name called post', () => { + // Keyed on `post`, renaming the local would turn every posting site into a non-posting one and + // leave this census green on a page whose taps buzz for nothing. + expect( + hapticsPostedKinds( + [ + 'let ask = () => false', + 'export function publishHapticsNotifier(notify) { ask = notify }', + "export function triggerError() { ask('error') }" + ].join('\n'), + 'haptics.web.ts' + ) + ).toEqual(['error']) + }) + + it('reports a function that posts nothing as a site with no kind', () => { + expect( + hapticsTriggerSites('export function triggerSelection() {}\n', 'haptics.web.ts') + ).toEqual([{ name: 'triggerSelection', line: 1, kind: null }]) + }) + + it('reports no kind for a function that posts one it computed, which nothing can pin', () => { + expect( + hapticsPostedKinds( + [ + 'let post = () => false', + 'export function publishHapticsNotifier(notify) { post = notify }', + 'export function triggerSelection(kind) { post(kind) }' + ].join('\n'), + 'haptics.web.ts' + ) + ).toEqual([]) + }) + + it('reports no kind for a function that posts twice, which is two taps for one gesture', () => { + expect( + hapticsPostedKinds( + [ + 'let post = () => false', + 'export function publishHapticsNotifier(notify) { post = notify }', + "export function triggerSelection() { post('selection'); post('success') }" + ].join('\n'), + 'haptics.web.ts' + ) + ).toEqual([]) + }) + + it('leaves alone a trigger the module does not export', () => { + expect( + hapticsTriggerSites( + [ + 'let post = () => false', + 'export function publishHapticsNotifier(notify) { post = notify }', + "function triggerLocal() { post('selection') }" + ].join('\n'), + 'haptics.web.ts' + ) + ).toEqual([]) + }) + + it('ignores the seam named inside a comment or a string, which text matching cannot', () => { + expect( + hapticsPostedKinds( + [ + 'let post = () => false', + 'export function publishHapticsNotifier(notify) { post = notify }', + "// export function triggerSelection() { post('selection') }", + 'const hint = "post(\'success\')"' + ].join('\n'), + 'haptics.web.ts' + ) + ).toEqual([]) + }) + + it('reads the kinds off the tuple that declares them', () => { + expect(bridgeHapticsKinds("export const BRIDGE_HAPTICS_KINDS = ['a', 'b'] as const\n")).toEqual( + ['a', 'b'] + ) + // A mention is not a declaration, which is why this is parsed rather than matched. + expect(bridgeHapticsKinds("// BRIDGE_HAPTICS_KINDS = ['a']\n")).toEqual([]) + }) +}) + +/** + * The two siblings measured against each other, which is what makes "all five post" a number. + * + * The native file is the control: same five names, same walk, no posting site. Without it an empty + * result and a file the scan could not read would report the same thing. + */ +describe('the two haptics siblings', () => { + it('posts every kind the notify admits from the web sibling, and nothing more', () => { + const kinds = bridgeHapticsKinds(read(HAPTICS_KINDS_MODULE), HAPTICS_KINDS_MODULE) + expect(kinds).toHaveLength(5) + const posted = hapticsPostedKinds(read(HAPTICS_SEAM), HAPTICS_SEAM) + expect([...posted].sort()).toEqual([...kinds].sort()) + }) + + it('finds five functions in the native sibling and no posting site at all', () => { + const sites = hapticsTriggerSites(read(HAPTICS_NATIVE), HAPTICS_NATIVE) + expect(sites).toHaveLength(5) + expect(sites.filter((site) => site.kind !== null)).toEqual([]) + }) + + it('exports the same five names from both, which is what makes one a substitution', () => { + const names = (file) => hapticsTriggerSites(read(file), file).map((site) => site.name) + expect(names(HAPTICS_SEAM)).toEqual(names(HAPTICS_NATIVE)) + }) + + /** + * The third direction, which no type in the app states. + * + * The shell's table refuses a kind with no row and a row naming a function that does not exist, + * both at compile time. It says nothing about a haptic `haptics.ts` grows with no kind of its own, + * which would be one the page can never ask for however many rows the table has. + */ + it('maps every function the app exports from the shell side, so none is unreachable', () => { + const exported = hapticsTriggerSites(read(HAPTICS_NATIVE), HAPTICS_NATIVE).map( + (site) => site.name + ) + expect(exported).toHaveLength(5) + expect(hapticsImportedNames(read(SHELL_MAPPING), SHELL_MAPPING)).toEqual([...exported].sort()) + }) +}) + +describe('the imported-name reader', () => { + it('names what a module takes from the app haptics', () => { + expect( + hapticsImportedNames( + "import { triggerError, triggerSuccess } from '../platform/haptics'\n", + 'page-haptics.ts' + ) + ).toEqual(['triggerError', 'triggerSuccess']) + }) + + it('reads the imported name and not the local one, a renamed import being the same export', () => { + expect( + hapticsImportedNames( + "import { triggerError as boom } from '../platform/haptics'\n", + 'page-haptics.ts' + ) + ).toEqual(['triggerError']) + }) + + it('leaves alone an import of the web sibling or of something else entirely', () => { + expect( + hapticsImportedNames( + [ + "import { triggerError } from '../platform/haptics.web'", + "import { triggerSuccess } from './other-haptics'", + "// import { triggerEdgeBump } from '../platform/haptics'" + ].join('\n'), + 'page-haptics.ts' + ) + ).toEqual([]) + }) +}) + +describeClosure( + 'every page route closure and the haptics seam', + () => { + it.each([...ROUTE_MODULES])('resolves the seam to the web sibling: %s', async (_route, mod) => { + const closure = await mobileWebAppRouteClosure(mod) + expect(closure.local).toContain(HAPTICS_SEAM) + expect(closure.local).not.toContain(HAPTICS_NATIVE) + // The precondition an assertion about a closure needs: the walk read a page, not nothing. + expect(closure.local.length).toBeGreaterThan(250) + }) + + it.each([...ROUTE_MODULES])( + 'imports the seam from at least one module, so the grant is not idle: %s', + async (_route, mod) => { + const closure = await mobileWebAppRouteClosure(mod) + expect(hapticsSeamImporters(mobileDir, closure).length).toBeGreaterThan(0) + } + ) + + /** + * The grant list derived from the closures rather than written by hand. + * + * Grants are resolved once, from the route the shell opened, and carried for the life of the + * session. A route that imports the seam and declares nothing is a page whose taps are silent + * with nothing on screen to say why. + * + * The cost of the answer being every route: `implementedPageRoutes` filters on + * `grants.every(implementsGrant)`, so against a shell that does not carry the token no page + * route is served at all and the phone renders the native screens. The mechanism is pinned in + * `mobile/src/mobile-web-shell/page-route-policy.test.ts`. + */ + it('declares haptics on exactly the routes whose closure reaches the seam', async () => { + const reaching = [] + for (const [route, mod] of ROUTE_MODULES) { + const closure = await mobileWebAppRouteClosure(mod) + if (hapticsSeamImporters(mobileDir, closure).length > 0) { + reaching.push(route) + } + } + expect(reaching.length).toBeGreaterThan(0) + const declared = MOBILE_WEB_PAGE_ROUTES.filter((route) => + route.grants.includes(HAPTICS_GRANT) + ).map((route) => route.pathname) + expect([...declared].sort()).toEqual([...reaching].sort()) + }) + + it('covers every declared page route, so a new one cannot be missed by this file', () => { + // The map above is a hand list of route modules; this is what holds it to the declarations. + expect([...ROUTE_MODULES.keys()].sort()).toEqual( + MOBILE_WEB_PAGE_ROUTES.map((route) => route.pathname).sort() + ) + }) + + /** + * What the notify costs a page to download: one module. + * + * Measured, not assumed: every page closure grew by exactly `bridge-haptics-notify.ts`, and it + * arrives through `page-route-policy.ts` reading the grant token rather than through the seam, + * whose own import of the kind type is erased. Its only dependency is `zod`, which the envelope + * already put in every closure, so the module total moved by the same one. + * + * Pinned structurally rather than as a total: an absolute closure count is main's to move, and a + * number that drifts for unrelated reasons is one nobody reads. + */ + it('adds one module to a page closure, and only the two haptics modules are in it', async () => { + for (const mod of ROUTE_MODULES.values()) { + const closure = await mobileWebAppRouteClosure(mod) + expect(closure.local.filter((file) => file.includes('haptics')).sort(), mod).toEqual([ + HAPTICS_KINDS_MODULE, + HAPTICS_SEAM + ]) + // The engine of the delta: the grant token is a value the route policy reads, and the + // policy is in every page closure. Without this the +1 would have no stated cause. + expect(closure.local, mod).toContain('src/mobile-web-shell/page-route-policy.ts') + } + }) + }, + 240_000 +) diff --git a/config/scripts/mobile-web-app-tasks-render.test.mjs b/config/scripts/mobile-web-app-tasks-render.test.mjs index 9113497b797..b71e049c435 100644 --- a/config/scripts/mobile-web-app-tasks-render.test.mjs +++ b/config/scripts/mobile-web-app-tasks-render.test.mjs @@ -39,7 +39,7 @@ const SHELL_HOST = { const UNMATCHED = 'Unmatched Route' const ROUTE_KEY = './h/[hostId]/tasks.tsx' /** Exactly what the route declares in `MOBILE_WEB_PAGE_ROUTES`, plus the protocol's own grant. */ -const TASKS_GRANTS = ['navigate', 'storage', 'externalLink', 'native.clipboard.write'] +const TASKS_GRANTS = ['navigate', 'storage', 'externalLink', 'haptics', 'native.clipboard.write'] const bundles = mobileWebAppDependenciesPresent() const describeRender = bundles ? describe : describe.skip diff --git a/config/scripts/mobile-web-page-routes.mjs b/config/scripts/mobile-web-page-routes.mjs index 23ec7211481..ffcbd5fc3e2 100644 --- a/config/scripts/mobile-web-page-routes.mjs +++ b/config/scripts/mobile-web-page-routes.mjs @@ -12,22 +12,30 @@ * * Declared here rather than in src/shared because the builder is the only thing that reads it: the * shape it must satisfy is MobileWebBundleRouteSchema, which the manifest write is checked against. + * + * `haptics` is on every entry below, and by measurement rather than by habit: the shared worktree + * row is in all five closures and calls the seam, so a route without the grant is a page whose taps + * stop buzzing. mobile-web-app-haptics-seam.test.mjs derives that list from the closures and fails + * on a route that imports the seam and declares nothing. */ export const MOBILE_WEB_PAGE_ROUTES = [ // The worktree list. `navigate` because every row opens a session screen that is still native. // `storage` because its pins and its last-visited repo are the app's, not the document's. - { pathname: '/h/[hostId]', grants: ['navigate', 'storage'] }, + { pathname: '/h/[hostId]', grants: ['navigate', 'storage', 'haptics'] }, // Agent session history. `navigate` because a resumed session opens the session screen, which is // native, and because the list above now reaches this one without leaving the page. `storage` // because the host layout above every page route reads the app's own sidebar width. - { pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] }, + { + pathname: '/h/[hostId]/agent-history/[worktreeId]', + grants: ['navigate', 'storage', 'haptics'] + }, // Tasks. `navigate` for the session screens its rows open and for the Back that pops the native // stack; `storage` for the shared components it renders; `externalLink` for the provider links // in its items, checks and drawers; `native.clipboard.write` for the two copy actions in its // comment review. Grants are scoped per route, so naming fewer here serves fewer. { pathname: '/h/[hostId]/tasks', - grants: ['navigate', 'storage', 'externalLink', 'native.clipboard.write'] + grants: ['navigate', 'storage', 'externalLink', 'haptics', 'native.clipboard.write'] }, // The file explorer. `navigate` because its Back pops the native stack. `storage` for the shared // components the host layout renders above it. @@ -54,7 +62,7 @@ export const MOBILE_WEB_PAGE_ROUTES = [ // the session's — in its own PR. { pathname: '/h/[hostId]/files/[worktreeId]', - grants: ['navigate', 'storage', 'externalLink'] + grants: ['navigate', 'storage', 'externalLink', 'haptics'] }, // The file preview. Same three. `externalLink` is this route's own rather than inherited: a // Markdown preview renders links and `MobileMarkdown` opens them through the platform seam, which @@ -63,6 +71,6 @@ export const MOBILE_WEB_PAGE_ROUTES = [ // the reasons are not. { pathname: '/h/[hostId]/files/preview/[worktreeId]', - grants: ['navigate', 'storage', 'externalLink'] + grants: ['navigate', 'storage', 'externalLink', 'haptics'] } ] diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx index 92ca4c20a2e..7f6958ec4f4 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx @@ -79,6 +79,16 @@ vi.mock('expo-clipboard', () => ({ setStringAsync: () => Promise.resolve(true), getStringAsync: () => Promise.resolve('') })) +// Same reason, and the screen only hands `playPageHaptic` over: which expo member each kind +// reaches is `page-haptics.test.ts`. `Platform.OS` above is pinned to `ios`, so the Android +// members are never evaluated and are not listed. +vi.mock('expo-haptics', () => ({ + impactAsync: () => Promise.resolve(), + notificationAsync: () => Promise.resolve(), + selectionAsync: () => Promise.resolve(), + ImpactFeedbackStyle: { Light: 'light', Medium: 'medium' }, + NotificationFeedbackType: { Error: 'error', Success: 'success' } +})) vi.mock('expo-document-picker', () => ({ getDocumentAsync: () => Promise.resolve(null) })) vi.mock('expo-image-picker', () => ({ launchImageLibraryAsync: () => Promise.resolve({ canceled: true }), diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx index 86e74aa163b..d81f5d1ec04 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx @@ -18,6 +18,7 @@ import { isDevelopmentBuild, useMobileWebShellDroppedFrames } from './mobile-web-shell-dev-facts' +import { playPageHaptic } from './page-haptics' import { useMobileWebShellBridge } from './use-mobile-web-shell-bridge' import type { MobileWebShellRuntime } from './mobile-web-shell-runtime' import { useNativeDeviceVerbs } from '../platform/use-native-device-verbs' @@ -213,6 +214,10 @@ export function MobileWebShellScreen({ console.warn('[web-shell] could not open a URL for the page', { url, error }) }) }, + // The app's own haptics, reached through one mapping rather than a second copy of the + // `Platform.OS` split. Nothing crosses back and nothing can fail: each function already + // swallows its own rejection on the device. + onHaptic: playPageHaptic, // The page's own Back goes nowhere: it holds the one history entry the entry wrote, so the only // stack to pop is this one. onNavigateBack: popShellStack, diff --git a/mobile/src/mobile-web-shell/bridge-host-contract.ts b/mobile/src/mobile-web-shell/bridge-host-contract.ts index 8fb78fd9328..670dc334001 100644 --- a/mobile/src/mobile-web-shell/bridge-host-contract.ts +++ b/mobile/src/mobile-web-shell/bridge-host-contract.ts @@ -2,6 +2,7 @@ import type { TerminalBacklogEnd, TerminalBacklogTimers } from './bridge-termina import type { RpcClient } from '../transport/rpc-client' import type { BridgeRefusal } from './bridge/bridge-caps' import type { BridgeInitHost, BridgeInitRoute } from './bridge/bridge-envelope' +import type { BridgeHapticsKind } from './bridge/bridge-haptics-notify' import type { BridgeErrorCapture } from './bridge/bridge-error-capture' import type { BridgeNativeVerb } from './bridge/bridge-native-verbs' import type { BridgeNotifyRefusal } from './bridge/bridge-notify-grants' @@ -134,6 +135,16 @@ export type BridgeHostOptions = { * failed is invisible on both sides unless the caller says so. */ onExternalLink: (url: string) => void + /** + * Plays one haptic on this device. Required for the reason `onExternalLink` is: the `haptics` + * grant is issued on the strength of this existing. + * + * Injected rather than called here, as every other device-local notify is: a static import of the + * app's haptics would put `react-native` and `expo-haptics` in this module's graph, and the host + * is the protocol's half of the bridge on either. It must not throw — this runs on the native + * frame handler — and it owes the page nothing, which is why a notify rather than a verb. + */ + onHaptic: (kind: BridgeHapticsKind) => void /** * Pops the native stack this page was pushed onto. Required for the reason `onNavigate` is: the * `navigate` grant carries this verb too, and a page told it may hand its Back button over and diff --git a/mobile/src/mobile-web-shell/bridge-host-init.test.ts b/mobile/src/mobile-web-shell/bridge-host-init.test.ts index c2b0554624b..32c1ee2dfd3 100644 --- a/mobile/src/mobile-web-shell/bridge-host-init.test.ts +++ b/mobile/src/mobile-web-shell/bridge-host-init.test.ts @@ -43,6 +43,7 @@ describe('init and state', () => { 'storage', 'externalLink', 'screencastBinary', + 'haptics', 'native.clipboard.write', 'native.clipboard.read', 'native.media.pick', diff --git a/mobile/src/mobile-web-shell/bridge-host-notifications.test.ts b/mobile/src/mobile-web-shell/bridge-host-notifications.test.ts index 5789f5445db..170720344fe 100644 --- a/mobile/src/mobile-web-shell/bridge-host-notifications.test.ts +++ b/mobile/src/mobile-web-shell/bridge-host-notifications.test.ts @@ -6,7 +6,13 @@ import { BRIDGE_FAULT_GRANT, BRIDGE_NAVIGATE_BACK_NOTIFY } from './bridge/bridge-envelope' +import { + BRIDGE_HAPTICS_GRANT, + BRIDGE_HAPTICS_KINDS, + BRIDGE_HAPTICS_NOTIFY +} from './bridge/bridge-haptics-notify' import { BRIDGE_NATIVE_GRANTS } from './bridge/bridge-init-frame' +import { MOBILE_WEB_SHELL_GRANTS } from './page-route-policy' describe('notifications, refusals and the fence', () => { it('forwards foreground with the arity the page used, and the viewport whole', () => { @@ -335,3 +341,75 @@ describe('externalLink', () => { expect(init.type === 'init' && init.grants.native).toContain(BRIDGE_EXTERNAL_LINK_GRANT) }) }) + +/** + * The notify that reaches hardware. + * + * Nothing crosses back, which is the reason it is a notify: a reply would spend a slot in the same + * 64-deep in-flight window a forwarded request does, and the file explorer plays one per row tap. + * So the oracle is what the shell was asked to play, and the refusals are the only report there is. + */ +describe('haptics', () => { + const play = (kind: string) => clientFrame({ type: 'notify', name: BRIDGE_HAPTICS_NOTIFY, kind }) + + it('plays each kind on the device and asks the client for nothing', () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'ready' })) + for (const kind of BRIDGE_HAPTICS_KINDS) { + bridge.host.receive(play(kind)) + } + expect(bridge.haptics).toEqual([...BRIDGE_HAPTICS_KINDS]) + expect(bridge.client.requests).toHaveLength(0) + expect(bridge.client.foregroundCalls).toEqual([]) + expect(bridge.diagnostics).toEqual([]) + }) + + it('plays one per frame, so a twelve-row scroll is twelve taps and not one', () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'ready' })) + for (let row = 0; row < 12; row += 1) { + bridge.host.receive(play('selection')) + } + expect(bridge.haptics).toHaveLength(12) + }) + + it('plays nothing for a route that was granted no haptics', () => { + // Granted everything else this shell implements, so the refusal is this row and not an empty list. + const bridge = harness({ + routeGrants: MOBILE_WEB_SHELL_GRANTS.filter((grant) => grant !== BRIDGE_HAPTICS_GRANT) + }) + bridge.host.receive(clientFrame({ type: 'ready' })) + bridge.host.receive(play('selection')) + expect(bridge.haptics).toEqual([]) + expect(bridge.diagnostics).toEqual([ + { kind: 'notify-refused', name: BRIDGE_HAPTICS_NOTIFY, why: 'ungranted' } + ]) + }) + + it('plays nothing for a page that has not asked for a session', () => { + const bridge = harness() + bridge.host.receive(play('selection')) + expect(bridge.haptics).toEqual([]) + expect(bridge.diagnostics).toEqual([ + { kind: 'notify-refused', name: BRIDGE_HAPTICS_NOTIFY, why: 'before-ready' } + ]) + }) + + it('plays nothing for a kind this app has no function for', () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'ready' })) + bridge.host.receive(play('heavyImpact')) + expect(bridge.haptics).toEqual([]) + // Dropped by the envelope rather than by the grant check: the kinds are a closed list, so a + // shell older than a kind refuses the whole frame instead of playing something else. + expect(bridge.diagnostics).toEqual([{ kind: 'refused', refusal: 'unrecognised-message' }]) + }) + + it('is advertised under the token a route can declare, not under the notify name', () => { + const bridge = harness() + bridge.host.receive(clientFrame({ type: 'ready' })) + const init = bridge.last() + expect(init.type === 'init' && init.grants.native).toContain(BRIDGE_HAPTICS_GRANT) + expect(init.type === 'init' && init.grants.native).not.toContain(BRIDGE_HAPTICS_NOTIFY) + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge-host-test-harness.ts b/mobile/src/mobile-web-shell/bridge-host-test-harness.ts index bbf5ba596e7..43e35d41cb5 100644 --- a/mobile/src/mobile-web-shell/bridge-host-test-harness.ts +++ b/mobile/src/mobile-web-shell/bridge-host-test-harness.ts @@ -8,6 +8,7 @@ import { } from './bridge-host-test-fakes' import { createBridgeHost, type BridgeHost, type BridgeHostDiagnostic } from './bridge-host' import type { BridgeNavigateBackOutcome } from './bridge-host-contract' +import type { BridgeHapticsKind } from './bridge/bridge-haptics-notify' import { MOBILE_WEB_SHELL_GRANTS } from './page-route-policy' import { BRIDGE_NATIVE_VERBS, @@ -35,6 +36,8 @@ export type Harness = { navigations: string[] /** Every URL the page asked the shell to open outside the app, in order. */ externalLinks: string[] + /** Every haptic the page asked the shell to play, in order. */ + haptics: BridgeHapticsKind[] /** Every text the page wrote to the pasteboard through a native verb, in order. */ clipboardWrites: string[] /** One entry per `navigate-back` the host answered, in order, with what the shell did. */ @@ -85,6 +88,7 @@ export function harness( const diagnostics: BridgeHostDiagnostic[] = [] const navigations: string[] = [] const externalLinks: string[] = [] + const haptics: BridgeHapticsKind[] = [] const clipboardWrites: string[] = [] const backPops: BridgeNavigateBackOutcome[] = [] const storageWrites: { key: string; value: string | null }[] = [] @@ -113,6 +117,7 @@ export function harness( onRouteRefused: (issue) => routeRefusals.push(issue), onNavigate: options.onNavigate ?? ((href) => navigations.push(href)), onExternalLink: (url) => externalLinks.push(url), + onHaptic: (kind) => haptics.push(kind), serveNativeVerb: (verb, params) => { if (options.serveNativeVerb !== undefined) { return options.serveNativeVerb(verb, params) @@ -159,6 +164,7 @@ export function harness( droppedBinaryFrames, navigations, externalLinks, + haptics, clipboardWrites, backPops, storageWrites, diff --git a/mobile/src/mobile-web-shell/bridge-host.ts b/mobile/src/mobile-web-shell/bridge-host.ts index b06255979db..07654822e27 100644 --- a/mobile/src/mobile-web-shell/bridge-host.ts +++ b/mobile/src/mobile-web-shell/bridge-host.ts @@ -18,6 +18,7 @@ import { } from './bridge/bridge-envelope' import { captureBridgeError } from './bridge/bridge-error-capture' import { createBridgeInitFrame } from './bridge/bridge-init-frame' +import { BRIDGE_HAPTICS_NOTIFY } from './bridge/bridge-haptics-notify' import { bridgeNotifyRefusal } from './bridge/bridge-notify-grants' import { splitBridgeReply } from './bridge/bridge-reply-chunking' import { isPageStorageKeyForHost } from './page-storage-keys' @@ -257,6 +258,12 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { options.onStorageWrite(message.key, message.value) return } + if (message.name === BRIDGE_HAPTICS_NOTIFY) { + // Local, and the only notify the shell answers with hardware. Nothing crosses back, which + // is the whole reason this is a notify: a reply would spend an in-flight slot per row tap. + options.onHaptic(message.kind) + return + } client.updateTerminalSubscriptionViewport(message.terminal, { cols: message.cols, rows: message.rows diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.test.ts index d206c562391..c92b2afd4ca 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.test.ts @@ -8,6 +8,12 @@ import { BRIDGE_NAVIGATE_BACK_NOTIFY, BRIDGE_PROTOCOL_VERSION } from './bridge-envelope' +import { BRIDGE_MAX_MESSAGE_BYTES, utf8ByteLength } from './bridge-caps' +import { + BRIDGE_HAPTICS_GRANT, + BRIDGE_HAPTICS_KINDS, + BRIDGE_HAPTICS_NOTIFY +} from './bridge-haptics-notify' import { GRANTS, INIT, createPageClient } from './bridge-page-client-test-harness' beforeEach(() => { @@ -207,3 +213,90 @@ describe('externalLink', () => { expect(page.client.notifyExternalLink('https://example.com')).toBe(false) }) }) + +/** + * The haptic the page asks for and hears nothing back about. + * + * Gated at the call site as well as at the frame, for the reason every gated notify is: `notify` is + * a closed list, so a shell that granted no haptics refuses the whole frame, and a caller told the + * frame left would be told a lie. Unlike `externalLink` nobody reads the answer — a tap that did + * not buzz is every tap on every phone before this page existed — so it is returned and not logged. + */ +describe('bridge client haptics', () => { + const granted = (page: ReturnType): void => { + page.deliver({ ...INIT, grants: { ...GRANTS, native: [BRIDGE_HAPTICS_GRANT] } }) + } + + it('posts one frame per kind, carrying the kind it was asked for', () => { + const page = createPageClient() + granted(page) + for (const kind of BRIDGE_HAPTICS_KINDS) { + expect(page.client.notifyHaptics(kind), kind).toBe(true) + } + expect(page.frames().slice(1)).toEqual( + BRIDGE_HAPTICS_KINDS.map((kind) => ({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'notify', + name: BRIDGE_HAPTICS_NOTIFY, + kind + })) + ) + }) + + it('stays quiet against a shell that granted nothing, because the frame would be refused whole', () => { + const page = createPageClient() + page.start() + expect(page.client.notifyHaptics('selection')).toBe(false) + expect(page.sent).toHaveLength(1) + }) + + it('stays quiet against a shell that granted the notify name instead of the token', () => { + const page = createPageClient() + page.deliver({ ...INIT, grants: { ...GRANTS, native: [BRIDGE_HAPTICS_NOTIFY] } }) + expect(page.client.notifyHaptics('selection')).toBe(false) + expect(page.sent).toHaveLength(1) + }) + + it('answers false before a session and after close rather than throwing inside a tap handler', () => { + const early = createPageClient() + expect(early.client.notifyHaptics('selection')).toBe(false) + const page = createPageClient() + granted(page) + page.client.close() + expect(page.client.notifyHaptics('selection')).toBe(false) + expect(page.frames().at(-1)).toEqual({ v: BRIDGE_PROTOCOL_VERSION, type: 'close' }) + }) +}) + +/** + * What a haptic costs on the wire, measured off the frame the client posted rather than a written + * copy of its shape: the two drift, and the one that drifts is the budget. + * + * The worst kind is the longest name, and a scrolling list is the worst case for the count: the + * file explorer plays `selection` once per row, so twelve rows is the number to think about. + */ +describe('the bytes a haptic spends', () => { + it('costs well under a thousandth of the frame cap, whichever kind it is', () => { + const page = createPageClient() + page.deliver({ ...INIT, grants: { ...GRANTS, native: [BRIDGE_HAPTICS_GRANT] } }) + const bytes = BRIDGE_HAPTICS_KINDS.map((kind) => { + page.client.notifyHaptics(kind) + return utf8ByteLength(page.sent.at(-1) ?? '') + }) + // One per kind, widest first: `mediumImpact` is the longest name and `error` the shortest. + expect(bytes).toEqual([77, 74, 72, 70, 73]) + expect(Math.max(...bytes) / BRIDGE_MAX_MESSAGE_BYTES).toBeLessThan(0.0002) + }) + + it('costs a twelve-row scroll under a kilobyte, one frame per row', () => { + const page = createPageClient() + page.deliver({ ...INIT, grants: { ...GRANTS, native: [BRIDGE_HAPTICS_GRANT] } }) + const before = page.sent.length + for (let row = 0; row < 12; row += 1) { + page.client.notifyHaptics('selection') + } + const scroll = page.sent.slice(before) + expect(scroll).toHaveLength(12) + expect(scroll.reduce((total, json) => total + utf8ByteLength(json), 0)).toBe(888) + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts index 73f0b81adca..0e1cdf18960 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts @@ -7,22 +7,27 @@ import { type BridgeClientMessage } from './bridge-envelope' import { readBridgeExternalLinkUrl } from './bridge-caps' +import { + BRIDGE_HAPTICS_GRANT, + BRIDGE_HAPTICS_NOTIFY, + type BridgeHapticsKind +} from './bridge-haptics-notify' import { captureBridgeError } from './bridge-error-capture' /** * Everything the page posts and hears nothing back about. * - * Five of the six post through one guard, but only two reach its throw, and it is not the guard + * Six of the seven post through one guard, but only two reach its throw, and it is not the guard * `sendRequest` uses. A call before `init` is a mount-order bug and throws; a call after `close` is * an unmounting screen posting one more nudge on its way out, which the native clients answer * inertly rather than by throwing into a teardown path nobody wrote a catch for. Nothing here * returns a promise, so nothing here can be awaited into a rejection either. * * Only the two ungated notifies reach that throw. A grant is read off the session, so before `init` - * there is no grant either and `navigate`, `navigate-back`, `externalLink` and `storage` answer - * false without asking: that is the same false they answer a shell that withheld the grant, and - * every caller already handles it — `useRouteHandoff` pushes or goes back inside the page instead, - * where a throw would take down a tap handler nobody wrapped. + * there is no grant either and `navigate`, `navigate-back`, `externalLink`, `storage` and the + * haptic answer false without asking: that is the same false they answer a shell that withheld the + * grant, and every caller already handles it — `useRouteHandoff` pushes or goes back inside the + * page instead, where a throw would take down a tap handler nobody wrapped. * * `notifyPageFault` reads the session instead of requiring it for a different reason: its one caller * is an error boundary, and a report that threw would replace the page's last word with an error @@ -48,6 +53,7 @@ export type BridgeClientNotifications = { notifyNavigateBack: () => boolean notifyExternalLink: (url: string) => boolean notifyStorageWrite: (key: string, value: string | null) => boolean + notifyHaptics: (kind: BridgeHapticsKind) => boolean notifyPageFault: (error: unknown) => boolean } @@ -112,6 +118,11 @@ export function createBridgeClientNotifications( notifyStorageWrite: (key, value) => deps.hasGrant('storage') && post({ v: BRIDGE_PROTOCOL_VERSION, type: 'notify', name: 'storage', key, value }), + // The answer is returned and never logged: a warning per refused frame would be one per row of + // a scrolling list, and a tap that did not buzz is every tap on every phone before this page. + notifyHaptics: (kind) => + deps.hasGrant(BRIDGE_HAPTICS_GRANT) && + post({ v: BRIDGE_PROTOCOL_VERSION, type: 'notify', name: BRIDGE_HAPTICS_NOTIFY, kind }), notifyPageFault: (error) => { if (deps.isClosed() || !deps.hasGrant(BRIDGE_FAULT_GRANT)) { return false diff --git a/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts index 4f38876e53b..e46c687569a 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts @@ -21,6 +21,7 @@ import { BRIDGE_ROUTE_HREF_PATTERN, BRIDGE_ROUTE_PATHNAME_PATTERN } from './bridge-caps' +import { BRIDGE_HAPTICS_KINDS, BRIDGE_HAPTICS_NOTIFY } from './bridge-haptics-notify' import { BRIDGE_BINARY_FORMATS, BRIDGE_CONNECTION_STATES, @@ -130,6 +131,12 @@ describe('client messages', () => { } ], ['a navigate-back notify', { type: 'notify', name: BRIDGE_NAVIGATE_BACK_NOTIFY }], + // One per kind, spread from the list itself: a kind added to the tuple and left out of the + // schema's enum would otherwise be accepted here by a case nobody wrote. + ...BRIDGE_HAPTICS_KINDS.map( + (kind) => + [`a ${kind} haptics notify`, { type: 'notify', name: BRIDGE_HAPTICS_NOTIFY, kind }] as const + ), ['close', { type: 'close' }] ] as const @@ -199,6 +206,11 @@ describe('client messages', () => { 'a page fault whose error is not a capture', client({ type: 'notify', name: BRIDGE_FAULT_GRANT, error: 'the route threw' }) ], + [ + 'a haptic this app has no function for', + client({ type: 'notify', name: BRIDGE_HAPTICS_NOTIFY, kind: 'heavyImpact' }) + ], + ['a haptics notify naming no kind', client({ type: 'notify', name: BRIDGE_HAPTICS_NOTIFY })], ['a bare array', []], ['a bare string', 'ready'] ] as const diff --git a/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts b/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts index 6cd486312f4..3168a59f833 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { isRpcResponse } from '../../transport/rpc-response-shape' import type { RpcResponse } from '../../transport/types' import { BridgeErrorCaptureSchema } from './bridge-error-capture' +import { BRIDGE_HAPTICS_NOTIFY_FIELDS } from './bridge-haptics-notify' import { isPageStorageKey, PAGE_STORAGE_MAX_ENTRIES, @@ -316,7 +317,9 @@ const BridgeClientMessageSchema = z.discriminatedUnion('type', [ /** The capture an `error` frame already carries, so both directions share one bound and one * reader. Nothing is owed back: the page is telling the shell, not asking it. */ error: BridgeErrorCaptureSchema - }) + }), + // Behind the `haptics` grant, and the fields are its own module's for the reason stated there. + z.object({ v: versionSchema, ...BRIDGE_HAPTICS_NOTIFY_FIELDS }) ]), z.object({ v: versionSchema, type: z.literal('close') }) ]) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-haptics-notify.ts b/mobile/src/mobile-web-shell/bridge/bridge-haptics-notify.ts new file mode 100644 index 00000000000..28a83a40834 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-haptics-notify.ts @@ -0,0 +1,51 @@ +import { z } from 'zod' + +/** + * The one haptic the page asks the shell for, as a `notify` rather than a verb. + * + * Fire-and-forget on the request/reply table would be wrong twice over: a reply costs a slot in the + * same 64-deep in-flight window a forwarded request spends, and there are 90 call sites in this + * app, some of them one per row of a scrolling list. Nothing is owed back — a haptic the shell did + * not play is a tap that felt like every tap on every phone before this page existed. + * + * Split out of `bridge-envelope.ts` rather than added to it, as `bridge-event-envelope-bytes.ts` + * was: that file is the protocol's schemas and it is at its line cap. The arm below is its fields + * without `v`, because the envelope owns the version literal and reading it back from here would be + * an import cycle. + */ + +/** + * The grant, a single token rather than the notify's own name. + * + * The notify table's grants are tokens — `navigate`, `storage` — because a notify is not a verb: + * `MOBILE_WEB_SHELL_GRANTS` spreads the dotted names from the verb table alone. One token is also + * what the capability is: an app either plays haptics or it does not. + */ +export const BRIDGE_HAPTICS_GRANT = 'haptics' + +/** The notify name. Dotted like a verb because it names a device the shell owns, not a screen. */ +export const BRIDGE_HAPTICS_NOTIFY = 'native.haptics.trigger' + +/** + * Exactly the five haptics `src/platform/haptics.ts` has, and nothing the page can invent. + * + * A closed list, so a kind outside it takes the whole frame down as `unrecognised-message` on an + * older shell; adding one is a compatibility change rather than an additive field. The shell's + * handler is keyed on this tuple, so a kind here with no function behind it does not compile. + */ +export const BRIDGE_HAPTICS_KINDS = [ + 'mediumImpact', + 'selection', + 'success', + 'error', + 'edgeBump' +] as const + +export type BridgeHapticsKind = (typeof BRIDGE_HAPTICS_KINDS)[number] + +/** Spread into the envelope's notify union beside `v`, which the envelope adds. */ +export const BRIDGE_HAPTICS_NOTIFY_FIELDS = { + type: z.literal('notify'), + name: z.literal(BRIDGE_HAPTICS_NOTIFY), + kind: z.enum(BRIDGE_HAPTICS_KINDS) +} diff --git a/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.test.ts index 83753af7432..1675cccd3dd 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' -import { BRIDGE_FAULT_GRANT, BRIDGE_NAVIGATE_BACK_NOTIFY } from './bridge-envelope' -import { bridgeNotifyRefusal } from './bridge-notify-grants' +import { + BRIDGE_EXTERNAL_LINK_GRANT, + BRIDGE_FAULT_GRANT, + BRIDGE_NAVIGATE_BACK_NOTIFY +} from './bridge-envelope' +import { BRIDGE_HAPTICS_GRANT, BRIDGE_HAPTICS_NOTIFY } from './bridge-haptics-notify' +import { bridgeNotifyRefusal, type BridgeNotifyName } from './bridge-notify-grants' const GRANTED = [BRIDGE_FAULT_GRANT] @@ -97,3 +102,81 @@ describe('the grant table', () => { ).toBeNull() }) }) + +/** + * Haptics, the first notify added since the protocol's own, and the second whose name is not its + * grant: `native.haptics.trigger` rides the single token `haptics`. + * + * A token because a notify is not a verb: every grant in that table is one, and the dotted names + * `MOBILE_WEB_SHELL_GRANTS` carries are spread from the verb table. A route declaring the notify's + * own name would be declaring something no shell advertises, which the case below pins. + */ +describe('the haptics notify', () => { + it('is refused on a route that was granted no haptics', () => { + expect(bridgeNotifyRefusal({ name: BRIDGE_HAPTICS_NOTIFY, initSent: true, granted: [] })).toBe( + 'ungranted' + ) + // Granted everything else this shell has, so the refusal is the haptics row and not an + // empty list. + expect( + bridgeNotifyRefusal({ + name: BRIDGE_HAPTICS_NOTIFY, + initSent: true, + granted: ['navigate', 'storage', BRIDGE_EXTERNAL_LINK_GRANT, BRIDGE_FAULT_GRANT] + }) + ).toBe('ungranted') + }) + + it('is served on a route that was granted the token', () => { + expect( + bridgeNotifyRefusal({ + name: BRIDGE_HAPTICS_NOTIFY, + initSent: true, + granted: [BRIDGE_HAPTICS_GRANT] + }) + ).toBeNull() + }) + + it('is not served against a grant list that names the notify instead of the token', () => { + expect( + bridgeNotifyRefusal({ + name: BRIDGE_HAPTICS_NOTIFY, + initSent: true, + granted: [BRIDGE_HAPTICS_NOTIFY] + }) + ).toBe('ungranted') + }) + + it('is refused before a grant is read at all from a page with no session', () => { + expect( + bridgeNotifyRefusal({ + name: BRIDGE_HAPTICS_NOTIFY, + initSent: false, + granted: [BRIDGE_HAPTICS_GRANT] + }) + ).toBe('before-ready') + }) +}) + +/** + * The totality shown rather than described. + * + * The docstring above says a name with no row is a compile error; this is the error. Every row the + * table has, less the haptics one, against the same `Record` over the union — checked by + * `tsconfig.test.json`, so the day the omission stops being an error the unused directive is. + */ +describe('a grant table missing a row', () => { + it('does not typecheck', () => { + // @ts-expect-error TS2741: no row for the haptics notify, the hole the Record closes. + const incomplete: Readonly> = { + foreground: null, + terminalViewport: null, + navigate: 'navigate', + [BRIDGE_NAVIGATE_BACK_NOTIFY]: 'navigate', + storage: 'storage', + [BRIDGE_EXTERNAL_LINK_GRANT]: BRIDGE_EXTERNAL_LINK_GRANT, + [BRIDGE_FAULT_GRANT]: BRIDGE_FAULT_GRANT + } + expect(Object.keys(incomplete)).toHaveLength(7) + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts b/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts index e9d8bf61a6b..d91b07c55c1 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts @@ -4,9 +4,10 @@ import { BRIDGE_NAVIGATE_BACK_NOTIFY, type BridgeClientMessage } from './bridge-envelope' +import { BRIDGE_HAPTICS_GRANT, BRIDGE_HAPTICS_NOTIFY } from './bridge-haptics-notify' /** Every `notify` name the envelope accepts, so the table below cannot be asked about another. */ -type BridgeNotifyName = Extract['name'] +export type BridgeNotifyName = Extract['name'] /** * Which grant each `notify` name rides, and `null` for the ones that ride none. @@ -20,8 +21,12 @@ type BridgeNotifyName = Extract['name'] * new enters `MOBILE_WEB_SHELL_GRANTS`. Keyed on the notify name alone it would be refused by every * shell that exists. * - * `foreground` and `terminalViewport` are the protocol's own and ride no grant. The other four are + * `foreground` and `terminalViewport` are the protocol's own and ride no grant. The other five are * inert while every page is offered all of them, and load-bearing the moment a grant is per-route. + * + * Haptics is the second whose name is not its grant, and for a different reason from + * `navigate-back`: every grant in this table is a token because a notify is not a verb, and the + * dotted names in `MOBILE_WEB_SHELL_GRANTS` come from the verb table alone. */ const BRIDGE_NOTIFY_GRANTS: Readonly> = { foreground: null, @@ -30,7 +35,8 @@ const BRIDGE_NOTIFY_GRANTS: Readonly> = [BRIDGE_NAVIGATE_BACK_NOTIFY]: 'navigate', storage: 'storage', [BRIDGE_EXTERNAL_LINK_GRANT]: BRIDGE_EXTERNAL_LINK_GRANT, - [BRIDGE_FAULT_GRANT]: BRIDGE_FAULT_GRANT + [BRIDGE_FAULT_GRANT]: BRIDGE_FAULT_GRANT, + [BRIDGE_HAPTICS_NOTIFY]: BRIDGE_HAPTICS_GRANT } export type BridgeNotifyRefusal = 'before-ready' | 'ungranted' diff --git a/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts b/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts index 1ac079fac0a..919326d9645 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts @@ -1,6 +1,7 @@ import type { RpcClient } from '../../transport/rpc-client' import { createBridgeHost, type BridgeHost, type BridgeHostDiagnostic } from '../bridge-host' import type { BridgeNavigateBackOutcome } from '../bridge-host-contract' +import type { BridgeHapticsKind } from './bridge-haptics-notify' import type { BridgeNativeVerb } from './bridge-native-verbs' import { MOBILE_WEB_SHELL_GRANTS } from '../page-route-policy' import { createFakeRpcClient, type FakeRpcClient } from '../bridge-host-test-fakes' @@ -44,6 +45,8 @@ export type BridgePortPair = { navigations: string[] /** Every URL the page asked the shell to open outside the app, in order. */ externalLinks: string[] + /** Every haptic the page asked the shell to play, in order. */ + haptics: BridgeHapticsKind[] /** One entry per stack pop the page asked for, with what the shell did about it. */ backPops: BridgeNavigateBackOutcome[] /** Every allowlisted key the page wrote through the shell, in order. */ @@ -177,6 +180,7 @@ export function createBridgePortPair( const hostDiagnostics: BridgeHostDiagnostic[] = [] const navigations: string[] = [] const externalLinks: string[] = [] + const haptics: BridgeHapticsKind[] = [] const backPops: BridgeNavigateBackOutcome[] = [] const storageWrites: { key: string; value: string | null }[] = [] const pageFaults: BridgeErrorCapture[] = [] @@ -202,6 +206,7 @@ export function createBridgePortPair( sessionEstablished: options.sessionEstablished ?? false, onNavigate: (href) => navigations.push(href), onExternalLink: (url) => externalLinks.push(url), + onHaptic: (kind) => haptics.push(kind), // The pair has no device: what a test reads here is that the host answered without forwarding. // Each verb gets a shape its own row declares, so a case that calls one it did not configure // reads an answer rather than `native_verb_result`, which is a shell bug's code. @@ -248,6 +253,7 @@ export function createBridgePortPair( hostDiagnostics, navigations, externalLinks, + haptics, backPops, storageWrites, pageFaults, diff --git a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts index 013efe7d4c2..e2ccc51815c 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts @@ -14,6 +14,7 @@ import { BridgeSendFailedError, BridgeShellReplacedError } from './bridge-client-errors' +import type { BridgeHapticsKind } from './bridge-haptics-notify' import { createBridgeInboundFrameReader } from './bridge-client-inbound-frames' import { createBridgeClientNotifications } from './bridge-client-notifications' import { BridgeClientRequests } from './bridge-client-requests' @@ -82,6 +83,11 @@ export type BridgeRpcClient = RpcClient & { callNativeVerb: (verb: BridgeNativeVerb, params: unknown) => Promise /** Writes one allowlisted key into the app's store. False when the shell granted no `storage`. */ notifyStorageWrite: (key: string, value: string | null) => boolean + /** + * Asks the shell to play one haptic. False when the shell granted no `haptics`, which no caller + * has to do anything about: a tap that did not buzz is what the page did before this existed. + */ + notifyHaptics: (kind: BridgeHapticsKind) => boolean /** * Tells the shell this page cannot render what it was opened for. Never throws and never rejects: * the one caller is an error boundary, and a report that threw would be the second failure. @@ -345,6 +351,7 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp }) }, notifyStorageWrite: notifications.notifyStorageWrite, + notifyHaptics: notifications.notifyHaptics, notifyPageFault: notifications.notifyPageFault, close, onReady: (listener) => { diff --git a/mobile/src/mobile-web-shell/page-haptics.test.ts b/mobile/src/mobile-web-shell/page-haptics.test.ts new file mode 100644 index 00000000000..864aeb7f007 --- /dev/null +++ b/mobile/src/mobile-web-shell/page-haptics.test.ts @@ -0,0 +1,117 @@ +/** + * The page's haptic is the app's haptic. + * + * Asserted against `expo-haptics` rather than against `platform/haptics`: a test that mocked the + * app's own module would pin this file's table and prove nothing about the thing a hand feels, and + * the whole reason haptics ride one mapping is that the `Platform.OS` split and the Android + * `HapticFeedbackConstants` must not be written twice. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { BRIDGE_HAPTICS_KINDS, type BridgeHapticsKind } from './bridge/bridge-haptics-notify' + +/** Annotated rather than asserted: the platform is a two-value union and the log starts empty. */ +type MockDevice = { platform: { OS: 'ios' | 'android' }; calls: string[] } + +// Hoisted, because `vi.mock` is: a factory closing over an ordinary `const` reads it before its +// initializer has run. The device call each haptic makes is the only thing recorded. +const device = vi.hoisted((): MockDevice => ({ platform: { OS: 'ios' }, calls: [] })) +const { calls, platform } = device + +vi.mock('react-native', () => ({ Platform: device.platform })) + +vi.mock('expo-haptics', () => ({ + impactAsync: (style: string) => { + device.calls.push(`impact:${style}`) + return Promise.resolve() + }, + selectionAsync: () => { + device.calls.push('selection') + return Promise.resolve() + }, + notificationAsync: (type: string) => { + device.calls.push(`notification:${type}`) + return Promise.resolve() + }, + performAndroidHapticsAsync: (constant: string) => { + device.calls.push(`android:${constant}`) + return Promise.resolve() + }, + ImpactFeedbackStyle: { Light: 'light', Medium: 'medium' }, + NotificationFeedbackType: { Success: 'success', Error: 'error' }, + AndroidHaptics: { + Long_Press: 'long-press', + Gesture_Start: 'gesture-start', + Confirm: 'confirm', + Reject: 'reject', + Clock_Tick: 'clock-tick' + } +})) + +import { playPageHaptic } from './page-haptics' + +beforeEach(() => { + calls.length = 0 + platform.OS = 'ios' +}) + +describe('the haptic a page asked for, on iOS', () => { + it.each([ + ['mediumImpact', 'impact:medium'], + ['selection', 'selection'], + ['success', 'notification:success'], + ['error', 'notification:error'], + ['edgeBump', 'impact:light'] + ] as const)('plays %s as %s', (kind, expected) => { + playPageHaptic(kind) + expect(calls).toEqual([expected]) + }) +}) + +/** + * The other platform, unchanged: `performAndroidHapticsAsync` reaches + * `HapticFeedbackConstants`, which works with no `VIBRATE` permission and is why the split exists. + */ +describe('the same haptic on Android', () => { + it.each([ + ['mediumImpact', 'android:long-press'], + ['selection', 'android:gesture-start'], + ['success', 'android:confirm'], + ['error', 'android:reject'], + ['edgeBump', 'android:clock-tick'] + ] as const)('plays %s as %s', (kind, expected) => { + platform.OS = 'android' + playPageHaptic(kind) + expect(calls).toEqual([expected]) + }) +}) + +describe('the kinds and the functions behind them', () => { + it('spends exactly one call per notify, which is what a per-row tap can afford', () => { + for (const kind of BRIDGE_HAPTICS_KINDS) { + playPageHaptic(kind) + } + expect(calls).toHaveLength(BRIDGE_HAPTICS_KINDS.length) + }) + + /** + * Every kind reaches a different device call, which is what says the table has no duplicate row. + * + * A table mapping two kinds to one function would pass every case above — each still plays + * something — and would mean a Save that felt like a failure. The third direction, a haptic + * `haptics.ts` grows with no kind of its own, is the census's: + * `config/scripts/mobile-web-app-haptics-seam.test.mjs` reads both files' names. + */ + it('plays a different device call for every kind, so no two share a row', () => { + for (const kind of BRIDGE_HAPTICS_KINDS) { + playPageHaptic(kind) + } + expect(new Set(calls).size).toBe(BRIDGE_HAPTICS_KINDS.length) + }) +}) + +describe('the kind union', () => { + it('is the five the app has and nothing else', () => { + const kinds: readonly BridgeHapticsKind[] = BRIDGE_HAPTICS_KINDS + expect([...kinds]).toEqual(['mediumImpact', 'selection', 'success', 'error', 'edgeBump']) + }) +}) diff --git a/mobile/src/mobile-web-shell/page-haptics.ts b/mobile/src/mobile-web-shell/page-haptics.ts new file mode 100644 index 00000000000..6e168be6574 --- /dev/null +++ b/mobile/src/mobile-web-shell/page-haptics.ts @@ -0,0 +1,35 @@ +import { + triggerEdgeBump, + triggerError, + triggerMediumImpact, + triggerSelection, + triggerSuccess +} from '../platform/haptics' +import type { BridgeHapticsKind } from './bridge/bridge-haptics-notify' + +/** + * A haptic the page asked for, played by the same functions a native screen plays. + * + * The native file's own bodies and nothing beside them: the `Platform.OS` split, the Android + * `HapticFeedbackConstants` and the iOS styles all stay where they are, so a phone feels the same + * tap whether the screen came from the bundle or from the app. A second mapping would be the one + * that drifted. + * + * Total in both the directions a type can state. Keyed on the kind union, a kind with no row does + * not compile; named as imports rather than reached through a namespace, a row naming a function + * `haptics.ts` does not export does not compile either. The third direction — a haptic that file + * grows with no kind of its own, which the page could never ask for — is the census's, in + * `config/scripts/mobile-web-app-haptics-seam.test.mjs`, which reads both files' names. + */ +const HAPTIC_BY_KIND: Readonly void>> = { + mediumImpact: triggerMediumImpact, + selection: triggerSelection, + success: triggerSuccess, + error: triggerError, + edgeBump: triggerEdgeBump +} + +/** Nothing is owed back: every function above is already `void …catch(() => {})` on the device. */ +export function playPageHaptic(kind: BridgeHapticsKind): void { + HAPTIC_BY_KIND[kind]() +} diff --git a/mobile/src/mobile-web-shell/page-route-policy.test.ts b/mobile/src/mobile-web-shell/page-route-policy.test.ts index 489708f9030..2d137c8fe1e 100644 --- a/mobile/src/mobile-web-shell/page-route-policy.test.ts +++ b/mobile/src/mobile-web-shell/page-route-policy.test.ts @@ -7,6 +7,7 @@ import { MOBILE_WEB_SHELL_GRANTS, grantsForRoute } from './page-route-policy' +import { BRIDGE_HAPTICS_GRANT } from './bridge/bridge-haptics-notify' import { BRIDGE_NATIVE_METHOD_PREFIX, BRIDGE_NATIVE_VERB_NAMES, @@ -81,6 +82,7 @@ describe('the grants this app implements', () => { 'storage', 'externalLink', 'screencastBinary', + 'haptics', 'native.clipboard.write', 'native.clipboard.read', 'native.media.pick', @@ -222,3 +224,41 @@ describe('a grant name this build has never heard of', () => { ).toEqual(['navigate', 'native.clipboard.write']) }) }) + +/** + * What a token on every page route costs against a shell that does not carry it. + * + * `implementedPageRoutes` filters on `grants.every(implementsGrant)`, so one grant this build lacks + * takes the whole route native rather than degrading the feature that needed it. `haptics` is + * declared by all five page routes, which makes the whole set conditional on a shell carrying the + * token; the route list itself is pinned in `config/scripts/mobile-web-app-haptics-seam.test.mjs`, + * and this is the mechanism behind it. + */ +describe('a page route that needs the haptics token', () => { + const route = { + pathname: '/h/[hostId]', + grants: ['navigate', 'storage', BRIDGE_HAPTICS_GRANT] + } + + it('is served by this shell, which implements the token', () => { + expect(implementedPageRoutes([route])).toEqual(['/h/[hostId]']) + }) + + it('renders natively against a shell whose grant list does not carry it', () => { + // An older shell's view of the same declaration: a grant it does not implement, whatever it is + // spelled. Nothing degrades — the route goes native whole, pins and sidebar and all. + const older = { + ...route, + grants: route.grants.map((grant) => + grant === BRIDGE_HAPTICS_GRANT ? 'hapticsUnderAnotherName' : grant + ) + } + expect(implementedPageRoutes([older])).toEqual([]) + // The control, so the empty list above is the token and not the other two grants. + expect( + implementedPageRoutes([ + { ...route, grants: route.grants.filter((grant) => grant !== BRIDGE_HAPTICS_GRANT) } + ]) + ).toEqual(['/h/[hostId]']) + }) +}) diff --git a/mobile/src/mobile-web-shell/page-route-policy.ts b/mobile/src/mobile-web-shell/page-route-policy.ts index ff09c7efaaa..5e3a2c87dbd 100644 --- a/mobile/src/mobile-web-shell/page-route-policy.ts +++ b/mobile/src/mobile-web-shell/page-route-policy.ts @@ -1,4 +1,5 @@ import type { MobileWebBundleManifestRead } from '../transport/mobile-web-bundle-reply-schemas' +import { BRIDGE_HAPTICS_GRANT } from './bridge/bridge-haptics-notify' import { BRIDGE_NATIVE_VERB_NAMES } from './bridge/bridge-native-verbs' import { BRIDGE_SCREENCAST_BINARY_GRANT } from './bridge/bridge-screencast-grant' @@ -20,6 +21,10 @@ export const MOBILE_WEB_SHELL_GRANTS = [ // The screencast's binary frames, encoded into `event.binary` for a page that subscribed with // `wantsBinary`. Named where the rule that reads it lives, so the two cannot drift. BRIDGE_SCREENCAST_BINARY_GRANT, + // The device's own feedback, played by the app's functions on the page's behalf. A token rather + // than the notify's dotted name, because a notify is not a verb: the dotted names below are the + // verb table's, spread from it. + BRIDGE_HAPTICS_GRANT, // Spread rather than restated: the verb table is keyed on this same tuple, so a verb cannot be // advertised without a row and a row cannot exist without being advertised. ...BRIDGE_NATIVE_VERB_NAMES @@ -53,7 +58,13 @@ export function matchesRoutePattern(pathname: string, pattern: string): boolean }) } -/** The patterns this shell will render from the page: listed, and needing nothing it lacks. */ +/** + * The patterns this shell will render from the page: listed, and needing nothing it lacks. + * + * `every` and not `some`: one grant this build lacks takes the whole route native, so a token every + * page route declares couples the whole set to a shell that carries it — `haptics` is the first, + * and against a shell without it no page route is served at all. + */ export function implementedPageRoutes(routes: readonly MobileWebPageRoute[] | undefined): string[] { return (routes ?? []) .filter((route) => route.grants.every(implementsGrant)) diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts index b82b08b769b..d793bee94fe 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts @@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' import type { OrcaMobileWebShellViewHandle } from '../../modules/orca-mobile-web-shell/src' import { BRIDGE_NATIVE_VERB_NAMES } from './bridge/bridge-native-verbs' +import { BRIDGE_HAPTICS_GRANT, type BridgeHapticsKind } from './bridge/bridge-haptics-notify' import { BRIDGE_SCREENCAST_BINARY_GRANT } from './bridge/bridge-screencast-grant' import { BRIDGE_FAULT_GRANT, @@ -44,6 +45,7 @@ type Probe = { view: MobileWebShellBridgeView | null navigations: string[] externalLinks: string[] + haptics: BridgeHapticsKind[] backPops: number storageWrites: { key: string; value: string | null }[] /** The running total after each dropped screencast frame, as the screen receives it. */ @@ -130,11 +132,13 @@ function Harness(props: { 'navigate', 'storage', 'externalLink', + BRIDGE_HAPTICS_GRANT, BRIDGE_SCREENCAST_BINARY_GRANT, ...BRIDGE_NATIVE_VERB_NAMES ], onNavigate: (href) => props.probe.navigations.push(href), onExternalLink: (url) => props.probe.externalLinks.push(url), + onHaptic: (kind) => props.probe.haptics.push(kind), serveNativeVerb: () => Promise.resolve({ value: 'pasteboard' }), onNavigateBack: () => { props.probe.backPops += 1 @@ -196,6 +200,7 @@ async function mount(session: MobileWebShellSessionState): Promise { view: null, navigations: [], externalLinks: [], + haptics: [], backPops: 0, droppedBinaryFrames: [], storageWrites: [] @@ -539,6 +544,7 @@ describe('the callbacks a render passes', () => { view: null, navigations: [], externalLinks: [], + haptics: [], backPops: 0, droppedBinaryFrames: [], storageWrites: [] @@ -600,6 +606,7 @@ describe('client changes', () => { view: null, navigations: [], externalLinks: [], + haptics: [], backPops: 0, droppedBinaryFrames: [], storageWrites: [] diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts index b44e44c9d72..840c34e8606 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts @@ -6,6 +6,7 @@ import type { import { useHostClient } from '../transport/client-context' import { createBridgeDiagnosticReporter } from './bridge-diagnostic-log' import type { BridgeInitRoute } from './bridge/bridge-envelope' +import type { BridgeHapticsKind } from './bridge/bridge-haptics-notify' import { createBridgeHost, type BridgeHost } from './bridge-host' import type { BridgeNavigateBackOutcome } from './bridge-host-contract' import type { BridgeNativeVerb } from './bridge/bridge-native-verbs' @@ -67,6 +68,8 @@ export function useMobileWebShellBridge(args: { onNavigate: (href: string) => void /** Opens a URL outside the app, on the page's behalf. */ onExternalLink: (url: string) => void + /** Plays one haptic on this device, on the page's behalf. */ + onHaptic: (kind: BridgeHapticsKind) => void /** Serves one `native.` verb on this device, for a page that was granted it. */ serveNativeVerb: (verb: BridgeNativeVerb, params: unknown) => Promise /** Pops the stack this page was pushed onto, and says so when it did not. */ @@ -108,6 +111,7 @@ export function useMobileWebShellBridge(args: { // fresh closure every render must not tear one down and settle its pendings. const navigateRef = useRef(args.onNavigate) const externalLinkRef = useRef(args.onExternalLink) + const hapticRef = useRef(args.onHaptic) const nativeVerbRef = useRef(args.serveNativeVerb) const navigateBackRef = useRef(args.onNavigateBack) const storageWriteRef = useRef(args.onStorageWrite) @@ -124,6 +128,7 @@ export function useMobileWebShellBridge(args: { routeGrantsRef.current = args.routeGrants navigateRef.current = args.onNavigate externalLinkRef.current = args.onExternalLink + hapticRef.current = args.onHaptic nativeVerbRef.current = args.serveNativeVerb navigateBackRef.current = args.onNavigateBack storageWriteRef.current = args.onStorageWrite @@ -135,6 +140,7 @@ export function useMobileWebShellBridge(args: { }, [ args.onBinaryFramesDropped, args.onExternalLink, + args.onHaptic, args.serveNativeVerb, args.onNavigate, args.onNavigateBack, @@ -183,6 +189,9 @@ export function useMobileWebShellBridge(args: { onExternalLink: (url) => { externalLinkRef.current(url) }, + onHaptic: (kind) => { + hapticRef.current(kind) + }, serveNativeVerb: (verb, params) => nativeVerbRef.current(verb, params), host: snapshot.host, readStorage: () => readStorageRef.current(), diff --git a/mobile/src/platform/haptics.web.test.ts b/mobile/src/platform/haptics.web.test.ts new file mode 100644 index 00000000000..e816d7c52e0 --- /dev/null +++ b/mobile/src/platform/haptics.web.test.ts @@ -0,0 +1,86 @@ +/** + * The web form: the page has no haptics of its own, so the shell is asked for one. + * + * Every case asserts the kind as well as the count. A seam that posted something for all five + * names would pass a test that only counted, and the five kinds are the whole content of the frame. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { BRIDGE_HAPTICS_KINDS } from '../mobile-web-shell/bridge/bridge-haptics-notify' +import { + publishHapticsNotifier, + triggerEdgeBump, + triggerError, + triggerMediumImpact, + triggerSelection, + triggerSuccess +} from './haptics.web' + +const asked: string[] = [] + +beforeEach(() => { + asked.length = 0 + publishHapticsNotifier((kind) => { + asked.push(kind) + return true + }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('the haptic each page function asks the shell for', () => { + // Kind first, because the title reads the first argument: `%#` consumes none, so with the + // function in front `%s` printed its whole body as the name of the case. + it.each([ + ['mediumImpact', triggerMediumImpact], + ['selection', triggerSelection], + ['success', triggerSuccess], + ['error', triggerError], + ['edgeBump', triggerEdgeBump] + ] as const)('posts exactly one notify, carrying %s', (kind, trigger) => { + trigger() + expect(asked).toEqual([kind]) + }) + + it('covers every kind the notify accepts, so no name is left on a no-op', () => { + // The two halves measured against each other: the five functions the app's screens call, and + // the five kinds the frame admits. A function missing here is a dead tap on the page. + for (const trigger of [ + triggerMediumImpact, + triggerSelection, + triggerSuccess, + triggerError, + triggerEdgeBump + ]) { + trigger() + } + expect([...asked].sort()).toEqual([...BRIDGE_HAPTICS_KINDS].sort()) + }) + + it('posts one frame per call, because a scrolling list calls once per row', () => { + for (let row = 0; row < 12; row += 1) { + triggerSelection() + } + expect(asked).toHaveLength(12) + }) +}) + +describe('a shell that will not play it', () => { + it('says nothing, because nobody reads the answer and every row tap would say it again', () => { + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + publishHapticsNotifier(() => false) + expect(() => triggerSelection()).not.toThrow() + expect(warned).not.toHaveBeenCalled() + }) + + it('asks nothing at all in a document that published no notifier', async () => { + // A fresh module, because the notifier is module state and every case above has published one. + vi.resetModules() + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const fresh: typeof import('./haptics.web') = await import('./haptics.web') + expect(() => fresh.triggerError()).not.toThrow() + expect(asked).toEqual([]) + expect(warned).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/platform/haptics.web.ts b/mobile/src/platform/haptics.web.ts index 2053f00ede6..33826c7f0df 100644 --- a/mobile/src/platform/haptics.web.ts +++ b/mobile/src/platform/haptics.web.ts @@ -1,24 +1,52 @@ +import type { BridgeHapticsKind } from '../mobile-web-shell/bridge/bridge-haptics-notify' + /** - * Haptics inside the shell's page: nothing at all. + * Haptics inside the shell's page: the device's own, played by the app on the page's behalf. * - * expo-haptics has a web build, and that is the problem rather than the solution. With no + * Not `expo-haptics`. It has a web build, and that is the problem rather than the solution: with no * `navigator.vibrate` — iOS Safari, which is the WebView the page runs in — it fakes a haptic by * appending a hidden `