mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/terminal-prompt-delivery
This commit is contained in:
@@ -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']
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -43,6 +43,7 @@ describe('init and state', () => {
|
||||
'storage',
|
||||
'externalLink',
|
||||
'screencastBinary',
|
||||
'haptics',
|
||||
'native.clipboard.write',
|
||||
'native.clipboard.read',
|
||||
'native.media.pick',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof createPageClient>): 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') })
|
||||
])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<Record<BridgeNotifyName, string | null>> = {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<BridgeClientMessage, { type: 'notify' }>['name']
|
||||
export type BridgeNotifyName = Extract<BridgeClientMessage, { type: 'notify' }>['name']
|
||||
|
||||
/**
|
||||
* Which grant each `notify` name rides, and `null` for the ones that ride none.
|
||||
@@ -20,8 +21,12 @@ type BridgeNotifyName = Extract<BridgeClientMessage, { type: 'notify' }>['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<Record<BridgeNotifyName, string | null>> = {
|
||||
foreground: null,
|
||||
@@ -30,7 +35,8 @@ const BRIDGE_NOTIFY_GRANTS: Readonly<Record<BridgeNotifyName, string | null>> =
|
||||
[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'
|
||||
|
||||
@@ -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<TRpc extends RpcClient = FakeRpcClient> = {
|
||||
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<TRpc extends RpcClient>(
|
||||
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<TRpc extends RpcClient>(
|
||||
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<TRpc extends RpcClient>(
|
||||
hostDiagnostics,
|
||||
navigations,
|
||||
externalLinks,
|
||||
haptics,
|
||||
backPops,
|
||||
storageWrites,
|
||||
pageFaults,
|
||||
|
||||
@@ -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<RpcSuccess>
|
||||
/** 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) => {
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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<Record<BridgeHapticsKind, () => 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]()
|
||||
}
|
||||
@@ -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]'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<Mounted> {
|
||||
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: []
|
||||
|
||||
@@ -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<unknown>
|
||||
/** 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(),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 `<label><input type="checkbox" switch>` to `document.head`, clicking it, and
|
||||
* removing it again, once per call. C1.9 traced a long press that never fired on the worktree list
|
||||
* to exactly that stray click, and the file explorer calls `triggerSelection` on every row tap.
|
||||
*
|
||||
* So the page has no haptics. A phone holding the page is a phone whose native app is right there
|
||||
* with the real ones, and a missing tap feedback is worth less than a tap that does not register.
|
||||
* So the page asks the shell instead, over the `native.haptics.trigger` notify: one frame of 70 to
|
||||
* 77 bytes, no reply, and the app's own `Platform.OS` split on the other side. A notify rather than
|
||||
* a verb because nothing is owed back — a reply would spend a slot in the same in-flight window a
|
||||
* forwarded request does, and there are 90 call sites in this app (rulings-ota-c7.md ruling 30).
|
||||
*
|
||||
* Published by the entry rather than read from context, because the callers are plain functions in
|
||||
* render trees the provider does not wrap — the same reason `publishExternalLinkOpener` exists. A
|
||||
* document that published none, or a shell that granted no `haptics`, plays nothing and says
|
||||
* nothing: a warning here would be one per row of a scrolling list, and nobody reads the answer.
|
||||
*
|
||||
* Same five names as the native file, because that is what makes this a substitution: an export
|
||||
* added there and missing here is a build error in the bundle, not a silent no-op.
|
||||
*/
|
||||
export function triggerMediumImpact(): void {}
|
||||
type HapticsNotifier = (kind: BridgeHapticsKind) => boolean
|
||||
|
||||
export function triggerSelection(): void {}
|
||||
let post: HapticsNotifier = () => false
|
||||
|
||||
export function triggerSuccess(): void {}
|
||||
/** Called once by the entry, with the page client's own notify. */
|
||||
export function publishHapticsNotifier(notify: HapticsNotifier): void {
|
||||
post = notify
|
||||
}
|
||||
|
||||
export function triggerError(): void {}
|
||||
export function triggerMediumImpact(): void {
|
||||
post('mediumImpact')
|
||||
}
|
||||
|
||||
export function triggerEdgeBump(): void {}
|
||||
export function triggerSelection(): void {
|
||||
post('selection')
|
||||
}
|
||||
|
||||
export function triggerSuccess(): void {
|
||||
post('success')
|
||||
}
|
||||
|
||||
export function triggerError(): void {
|
||||
post('error')
|
||||
}
|
||||
|
||||
export function triggerEdgeBump(): void {
|
||||
post('edgeBump')
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { publishPageStorage } from '../src/mobile-web-shell/bridge/page-async-st
|
||||
import { PageFaultBoundary } from '../src/mobile-web-shell/bridge/page-fault-boundary'
|
||||
import { publishPageHostProfile } from '../src/mobile-web-shell/bridge/page-host-profile'
|
||||
import { publishExternalLinkOpener } from '../src/platform/external-link.web'
|
||||
import { publishHapticsNotifier } from '../src/platform/haptics.web'
|
||||
// Named with its extension: this entry is the web build's and the provider it needs is the web
|
||||
// sibling's, which takes the page's client. The screens below still import `./client-context`
|
||||
// and reach the same module, because the builder resolves both specifiers to the same file.
|
||||
@@ -82,6 +83,9 @@ bootstrapShellPage({
|
||||
// Same reason, and the same shape: the seam is a plain function in render trees the provider
|
||||
// does not wrap, so the client's notify is published rather than read from context.
|
||||
publishExternalLinkOpener((url) => client.notifyExternalLink(url))
|
||||
// The same shape again, and for the same reason: every haptic on this page is played from a
|
||||
// plain function inside a row's press handler, which no provider wraps.
|
||||
publishHapticsNotifier((kind) => client.notifyHaptics(kind))
|
||||
// Scoped to the host `init` named: with none, no key is writable, which is the right answer
|
||||
// for a shell too old to say whose list this is.
|
||||
publishPageStorage(
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/platform/haptics.web.ts",
|
||||
"reason": "expo-haptics has a web build that fakes an iOS haptic by appending a hidden <label><input type=\"checkbox\" switch> to document.head, clicking it and removing it, once per call. C1.9 traced a long press that never fired on the worktree list to that stray click, and the file explorer calls triggerSelection on every row tap. The page has no haptics instead; the bundle test reads the shipped bytes for the shim."
|
||||
"reason": "expo-haptics has a web build that fakes an iOS haptic by appending a hidden <label><input type=\"checkbox\" switch> to document.head, clicking it and removing it, once per call. C1.9 traced a long press that never fired on the worktree list to that stray click, and the file explorer calls triggerSelection on every row tap. So the page asks the shell for the device's own haptic instead, over the native.haptics.trigger notify behind the haptics grant: 70 to 77 bytes per frame, no reply, and haptics.ts's own Platform.OS split on the other side. A notify rather than a verb because nothing is owed back and a reply would spend a slot in the same in-flight window a forwarded request does, at 90 call sites (rulings-ota-c7.md ruling 30). The five exports are the five kinds the frame admits; mobile-web-app-haptics-seam.test.mjs is the fence."
|
||||
},
|
||||
{
|
||||
"file": "app/h/[hostId]/files/[worktreeId].web.tsx",
|
||||
|
||||
@@ -91,6 +91,7 @@ export function buildPtyHostEnv(
|
||||
// Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md.
|
||||
const openCodeStatusService =
|
||||
openCodeAgent === 'opencode2' ? openCode2HookService : openCodeHookService
|
||||
baseEnv.ORCA_OPENCODE_AGENT = openCodeAgent
|
||||
Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir))
|
||||
if (baseEnv.OPENCODE_CONFIG_DIR) {
|
||||
// Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value.
|
||||
|
||||
@@ -91,11 +91,11 @@ describe('OpenCode hook plugin source', () => {
|
||||
const digest = (source: string): string => createHash('sha256').update(source).digest('hex')
|
||||
|
||||
expect(digest(getOpenCodePluginSource())).toBe(
|
||||
'd14859a36c88aefe3a45cd232789503296e0a23438b151c773414bad64ab8eaa'
|
||||
'938867eae97b7ae4a7193a07755ee74a77c7a7352edcc28b59543b1b00713612'
|
||||
)
|
||||
expect(
|
||||
digest(getOpenCodeFamilyPluginSource('/hook/mimo-code', { emitSessionStart: false }))
|
||||
).toBe('4de14bee0c27ce55f29f70b19aa6ce9967e09b098bba139fb88f0511af7d4fca')
|
||||
).toBe('4c9c27af603a9e85e3e33a30c439d9dfb6785936dea0be76fdd64cf7dc2f7174')
|
||||
})
|
||||
|
||||
it('filters child sessions via parentID lookup before forwarding events', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ export function getStatusPluginFactorySource(options: {
|
||||
emitSessionStart: boolean
|
||||
emitNextEvents?: boolean
|
||||
}): string[] {
|
||||
const expectedAgent = options.emitNextEvents ? 'opencode2' : 'opencode'
|
||||
return [
|
||||
...(options.emitNextEvents ? getOpenCode2EventNormalizationSource() : []),
|
||||
'// Why: accept the factory argument as an optional opaque parameter instead',
|
||||
@@ -15,6 +16,7 @@ export function getStatusPluginFactorySource(options: {
|
||||
'// destructuring form throw synchronously and crash OpenCode with an opaque',
|
||||
'// UnknownError before any event is ever dispatched.',
|
||||
'export const OrcaOpenCodeStatusPlugin = async (_ctx) => {',
|
||||
` if (process.env.ORCA_OPENCODE_AGENT && process.env.ORCA_OPENCODE_AGENT !== '${expectedAgent}') return {};`,
|
||||
' const client = _ctx?.client;',
|
||||
' const factoryID = ++nextFactoryID;',
|
||||
' activeFactoryIDs.add(factoryID);',
|
||||
|
||||
@@ -89,6 +89,7 @@ export class RelayAgentHookRuntime {
|
||||
context.launchAgent === 'opencode2' || isOpenCode2LaunchCommand(launchCommandHint)
|
||||
? 'opencode2'
|
||||
: 'opencode'
|
||||
env.ORCA_OPENCODE_AGENT = opencodeAgent
|
||||
if (this.pluginOverlay.hasOpenCodeSource(opencodeAgent)) {
|
||||
const sourceDir = resolveOpenCodeSourceConfigDir(context.env, context.shell)
|
||||
const inheritedRelayOverlay = sourceDir
|
||||
|
||||
@@ -187,11 +187,10 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues
|
||||
})
|
||||
})
|
||||
|
||||
it('does not mirror the XDG default config root', () => {
|
||||
it('mirrors the XDG default config root when using an overlay', () => {
|
||||
withHome((home) => {
|
||||
// Why: OpenCode APPENDS OPENCODE_CONFIG_DIR to its config-dir list rather than
|
||||
// replacing it, so ~/.config/opencode is read anyway — mirroring it here would
|
||||
// load the user's config and plugins twice.
|
||||
// Why: OPENCODE_CONFIG_DIR replaces the default root, so the overlay must
|
||||
// carry the user's default config and Orca's plugin together.
|
||||
const defaultConfig = join(home, '.config', 'opencode')
|
||||
mkdirSync(defaultConfig, { recursive: true })
|
||||
writeFileSync(join(defaultConfig, 'opencode.json'), '{"model":"default"}')
|
||||
@@ -202,7 +201,7 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape.
|
||||
const dir = install({ opencodePluginSource: '// v1\n' }).overlayDirs.opencode as string
|
||||
|
||||
expect(existsSync(join(dir, 'opencode.json'))).toBe(false)
|
||||
expect(existsSync(join(dir, 'opencode.json'))).toBe(true)
|
||||
expect(existsSync(join(dir, 'plugins', 'orca-opencode-status.js'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { existsSync } from 'node:fs'
|
||||
|
||||
import { getRelayOpenCodePluginPath, type PluginOverlayManager } from './plugin-overlay'
|
||||
import { resolveOpenCodeSourceConfigDir } from './plugin-overlay-env'
|
||||
import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory'
|
||||
import { assertPluginSourceUnderByteCap } from './plugin-source-limit'
|
||||
import {
|
||||
sanitizeWslHookInstanceKey,
|
||||
@@ -26,11 +27,8 @@ export type InstallPluginsResult = {
|
||||
|
||||
export type InstallPluginsHandler = (params: Record<string, unknown>) => InstallPluginsResult
|
||||
|
||||
// Why NOT to fall back to ~/.config/opencode here: OpenCode APPENDS
|
||||
// OPENCODE_CONFIG_DIR to its config-dir list, it does not replace it — the
|
||||
// XDG default is always read too. Mirroring the default into the overlay would
|
||||
// load the user's config (and plugins) twice. Only an explicitly-set dir is
|
||||
// mirrored, because that one leaves the list when we override the var.
|
||||
// OpenCode replaces its default config root when OPENCODE_CONFIG_DIR is set,
|
||||
// so mirror the default root into the guest overlay as well as explicit paths.
|
||||
export function createInstallPluginsHandler(
|
||||
pluginOverlay: PluginOverlayManager,
|
||||
env: NodeJS.ProcessEnv
|
||||
@@ -73,12 +71,15 @@ export function createInstallPluginsHandler(
|
||||
const incoming = typeof opencode === 'string' ? opencode : null
|
||||
// Explicit-only (see header). Constant in practice for a relay's lifetime, so
|
||||
// keying the cache on it is defensive; the rc scan behind it is memoized.
|
||||
const sourceDir = resolveOpenCodeSourceConfigDir(env as Record<string, string>, env.SHELL)
|
||||
const sourceDir =
|
||||
resolveOpenCodeSourceConfigDir(env as Record<string, string>, env.SHELL) ??
|
||||
resolveOpenCodeConfigDirectory(env as Record<string, string>, env.HOME)
|
||||
const existingSourceDir = sourceDir && existsSync(sourceDir) ? sourceDir : undefined
|
||||
const cached = materialized
|
||||
if (
|
||||
cached &&
|
||||
(incoming === null || incoming === cached.source) &&
|
||||
sourceDir === cached.sourceDir &&
|
||||
existingSourceDir === cached.sourceDir &&
|
||||
// Why: the dir surviving a failed rebuild proves nothing — the plugin does.
|
||||
existsSync(getRelayOpenCodePluginPath(cached.dir))
|
||||
) {
|
||||
@@ -87,32 +88,35 @@ export function createInstallPluginsHandler(
|
||||
const overlayId =
|
||||
sanitizeWslHookInstanceKey(env[WSL_HOOK_RELAY_INSTANCE_ENV]) ?? 'wsl-opencode'
|
||||
// Why: null on write failure — caller falls back to the guest's own config (no status), never crossing a Windows overlay into WSL.
|
||||
opencodeDir = pluginOverlay.materializeOpenCode(overlayId, sourceDir) ?? undefined
|
||||
opencodeDir = pluginOverlay.materializeOpenCode(overlayId, existingSourceDir) ?? undefined
|
||||
materialized =
|
||||
opencodeDir && incoming !== null
|
||||
? { source: incoming, sourceDir, dir: opencodeDir }
|
||||
? { source: incoming, sourceDir: existingSourceDir, dir: opencodeDir }
|
||||
: null
|
||||
}
|
||||
}
|
||||
let opencode2Dir: string | undefined
|
||||
if (pluginOverlay.hasOpenCode2Source()) {
|
||||
const incoming = typeof opencode2 === 'string' ? opencode2 : null
|
||||
const sourceDir = resolveOpenCodeSourceConfigDir(env as Record<string, string>, env.SHELL)
|
||||
const sourceDir =
|
||||
resolveOpenCodeSourceConfigDir(env as Record<string, string>, env.SHELL) ??
|
||||
resolveOpenCodeConfigDirectory(env as Record<string, string>, env.HOME)
|
||||
const existingSourceDir = sourceDir && existsSync(sourceDir) ? sourceDir : undefined
|
||||
const cached = materialized2
|
||||
if (
|
||||
cached &&
|
||||
(incoming === null || incoming === cached.source) &&
|
||||
sourceDir === cached.sourceDir &&
|
||||
existingSourceDir === cached.sourceDir &&
|
||||
existsSync(getRelayOpenCodePluginPath(cached.dir, 'opencode2'))
|
||||
) {
|
||||
opencode2Dir = cached.dir
|
||||
} else {
|
||||
const overlayId =
|
||||
sanitizeWslHookInstanceKey(env[WSL_HOOK_RELAY_INSTANCE_ENV]) ?? 'wsl-opencode2'
|
||||
opencode2Dir = pluginOverlay.materializeOpenCode2(overlayId, sourceDir) ?? undefined
|
||||
opencode2Dir = pluginOverlay.materializeOpenCode2(overlayId, existingSourceDir) ?? undefined
|
||||
materialized2 =
|
||||
opencode2Dir && incoming !== null
|
||||
? { source: incoming, sourceDir, dir: opencode2Dir }
|
||||
? { source: incoming, sourceDir: existingSourceDir, dir: opencode2Dir }
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { EditorDiffFileSurface } from './EditorDiffFileSurface'
|
||||
import { EditorEditFileSurface } from './EditorEditFileSurface'
|
||||
import { EditorFileLoadErrorView } from './EditorFileLoadErrorView'
|
||||
import type { FileContent } from './editor-panel-content-types'
|
||||
import { buildPdfScalePreferenceKey } from './pdf-scale-preference-storage'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useEditorConflictNavigation } from './useEditorConflictNavigation'
|
||||
import { useMarkdownDocuments } from './useMarkdownDocuments'
|
||||
@@ -106,6 +107,9 @@ export function EditorContent({
|
||||
viewStateScopeId === activeFile.id
|
||||
? `${activeFile.filePath}:pdf`
|
||||
: `${activeFile.filePath}::${viewStateScopeId}:pdf`
|
||||
// Why: the same absolute path can exist in different worktrees, paired
|
||||
// runtimes, or SSH targets; durable PDF zoom must not cross those owners.
|
||||
const pdfPreferenceKey = buildPdfScalePreferenceKey(activeFile)
|
||||
const monacoLanguage = resolvedLanguage === 'notebook' ? 'json' : resolvedLanguage
|
||||
const reloadOpenCheckRunDetailsTab = useAppStore((state) => state.reloadOpenCheckRunDetailsTab)
|
||||
const markdownDocuments = useMarkdownDocuments(activeFile, isMarkdown, mdViewMode, handleSave)
|
||||
@@ -232,6 +236,7 @@ export function EditorContent({
|
||||
editorViewStateKey={editorViewStateKey}
|
||||
diffViewStateKey={diffViewStateKey}
|
||||
pdfViewStateKey={pdfViewStateKey}
|
||||
pdfPreferenceKey={pdfPreferenceKey}
|
||||
fileContent={fileContents[activeFile.id]}
|
||||
diffContent={diffContents[activeFile.id]}
|
||||
editBuffer={editBuffers[activeFile.id]}
|
||||
|
||||
@@ -30,6 +30,7 @@ export function EditorEditFileSurface({
|
||||
editorViewStateKey,
|
||||
diffViewStateKey,
|
||||
pdfViewStateKey,
|
||||
pdfPreferenceKey,
|
||||
fileContent,
|
||||
diffContent,
|
||||
editBuffer,
|
||||
@@ -61,6 +62,7 @@ export function EditorEditFileSurface({
|
||||
editorViewStateKey: string
|
||||
diffViewStateKey: string
|
||||
pdfViewStateKey: string
|
||||
pdfPreferenceKey: string
|
||||
fileContent: FileContent | undefined
|
||||
diffContent: GitDiffResult | undefined
|
||||
editBuffer: string | undefined
|
||||
@@ -113,6 +115,7 @@ export function EditorEditFileSurface({
|
||||
content={fileContent.content}
|
||||
filePath={activeFile.filePath}
|
||||
mimeType={fileContent.mimeType}
|
||||
preferenceKey={pdfPreferenceKey}
|
||||
scrollCacheKey={pdfViewStateKey}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -29,6 +29,9 @@ type ImageViewerProps = {
|
||||
filePath: string
|
||||
mimeType?: string
|
||||
layout?: 'fill' | 'intrinsic'
|
||||
// Why: callers without an owner identity (for example diff and conflict
|
||||
// panes) must not persist a preference under a path-only key.
|
||||
preferenceKey?: string | null
|
||||
// Why: absent means "no PDF scroll memory" — diff and conflict-review callers
|
||||
// mount several viewers on one path, so they deliberately pass nothing.
|
||||
scrollCacheKey?: string | null
|
||||
@@ -39,6 +42,7 @@ export default function ImageViewer({
|
||||
filePath,
|
||||
mimeType = FALLBACK_IMAGE_MIME_TYPE,
|
||||
layout = 'fill',
|
||||
preferenceKey,
|
||||
scrollCacheKey = null
|
||||
}: ImageViewerProps): JSX.Element {
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false)
|
||||
@@ -215,7 +219,12 @@ export default function ImageViewer({
|
||||
|
||||
if (isPdf) {
|
||||
return (
|
||||
<PdfViewer content={cleanedContent} filePath={filePath} scrollCacheKey={scrollCacheKey} />
|
||||
<PdfViewer
|
||||
content={cleanedContent}
|
||||
filePath={filePath}
|
||||
preferenceKey={preferenceKey}
|
||||
scrollCacheKey={scrollCacheKey}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
stepPdfScalePreference,
|
||||
type PdfScalePreference
|
||||
} from './pdf-scale-preference'
|
||||
import { readPdfScalePreference, writePdfScalePreference } from './pdf-scale-preference-storage'
|
||||
import { pdfViewPositionCache, setWithLRU } from '@/lib/scroll-cache'
|
||||
import {
|
||||
buildPdfScrollDestination,
|
||||
@@ -44,6 +45,9 @@ const USER_SCROLL_INPUT_EVENTS = ['wheel', 'touchstart', 'keydown', 'pointerdown
|
||||
type PdfViewerProps = {
|
||||
content: string
|
||||
filePath: string
|
||||
// Why: callers that do not have an owner identity (for example diff and
|
||||
// conflict panes) must not persist a preference under a path-only key.
|
||||
preferenceKey?: string | null
|
||||
// Why: absent means "no scroll memory" — the diff and conflict-review callers
|
||||
// mount several viewers on one path, so a shared key would cross-write.
|
||||
scrollCacheKey?: string | null
|
||||
@@ -52,6 +56,7 @@ type PdfViewerProps = {
|
||||
export default function PdfViewer({
|
||||
content,
|
||||
filePath,
|
||||
preferenceKey = null,
|
||||
scrollCacheKey = null
|
||||
}: PdfViewerProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -65,22 +70,23 @@ export default function PdfViewer({
|
||||
const findControllerRef = useRef<InstanceType<typeof PDFFindController> | null>(null)
|
||||
const pdfViewerRef = useRef<InstanceType<typeof PdfJsViewer> | null>(null)
|
||||
// Why: content reloads rebuild the pdf.js viewer; keep zoom across updates of
|
||||
// the same file, and only reset when the open path changes.
|
||||
// the same file and restore the durable preference after a remount or restart.
|
||||
const scalePreferenceRef = useRef<PdfScalePreference>('page-width')
|
||||
|
||||
const filename = useMemo(() => filePath.split(/[/\\]/).pop() || filePath, [filePath])
|
||||
const cleanedContent = useMemo(() => content.replace(/\s/g, ''), [content])
|
||||
|
||||
// Why: reset zoom to fit-width when the open path changes. An effect keeps the
|
||||
// reset out of render (refs mutated in render can leak from discarded renders)
|
||||
// and covers same-content/different-path opens the load effect skips.
|
||||
// Why: restore the owner's preference outside render (refs mutated in render
|
||||
// can leak from discarded renders) and cover same-content/different-path opens.
|
||||
useEffect(() => {
|
||||
scalePreferenceRef.current = 'page-width'
|
||||
scalePreferenceRef.current = preferenceKey
|
||||
? (readPdfScalePreference(preferenceKey) ?? 'page-width')
|
||||
: 'page-width'
|
||||
const viewer = pdfViewerRef.current
|
||||
if (viewer) {
|
||||
applyPdfScalePreference(viewer, 'page-width', SCALE_BOUNDS)
|
||||
applyPdfScalePreference(viewer, scalePreferenceRef.current, SCALE_BOUNDS)
|
||||
}
|
||||
}, [filePath])
|
||||
}, [filePath, preferenceKey])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
@@ -306,15 +312,21 @@ export default function PdfViewer({
|
||||
|
||||
// Why: every zoom entry point (toolbar + keyboard) must record the scale
|
||||
// preference so the next content reload restores it (see scalePreferenceRef).
|
||||
const stepZoom = useCallback((direction: 'in' | 'out') => {
|
||||
const viewer = pdfViewerRef.current
|
||||
if (!viewer) {
|
||||
return
|
||||
}
|
||||
const next = stepPdfScalePreference(viewer.currentScale, direction, SCALE_BOUNDS)
|
||||
viewer.currentScale = next.scale
|
||||
scalePreferenceRef.current = next.preference
|
||||
}, [])
|
||||
const stepZoom = useCallback(
|
||||
(direction: 'in' | 'out') => {
|
||||
const viewer = pdfViewerRef.current
|
||||
if (!viewer) {
|
||||
return
|
||||
}
|
||||
const next = stepPdfScalePreference(viewer.currentScale, direction, SCALE_BOUNDS)
|
||||
viewer.currentScale = next.scale
|
||||
scalePreferenceRef.current = next.preference
|
||||
if (preferenceKey) {
|
||||
writePdfScalePreference(preferenceKey, next.preference)
|
||||
}
|
||||
},
|
||||
[preferenceKey]
|
||||
)
|
||||
|
||||
const zoomIn = useCallback(() => stepZoom('in'), [stepZoom])
|
||||
const zoomOut = useCallback(() => stepZoom('out'), [stepZoom])
|
||||
@@ -326,7 +338,10 @@ export default function PdfViewer({
|
||||
}
|
||||
scalePreferenceRef.current = 'page-width'
|
||||
applyPdfScalePreference(viewer, 'page-width', SCALE_BOUNDS)
|
||||
}, [])
|
||||
if (preferenceKey) {
|
||||
writePdfScalePreference(preferenceKey, 'page-width')
|
||||
}
|
||||
}, [preferenceKey])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildPdfScalePreferenceKey,
|
||||
PDF_SCALE_PREFERENCES_STORAGE_KEY,
|
||||
readPdfScalePreference,
|
||||
writePdfScalePreference
|
||||
} from './pdf-scale-preference-storage'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('PDF scale preference storage', () => {
|
||||
it('keeps identical paths isolated by worktree and remote owner', () => {
|
||||
const localKey = buildPdfScalePreferenceKey({ worktreeId: 'worktree-a', filePath: '/doc.pdf' })
|
||||
const runtimeKey = buildPdfScalePreferenceKey({
|
||||
worktreeId: 'worktree-a',
|
||||
runtimeEnvironmentId: 'runtime-b',
|
||||
filePath: '/doc.pdf'
|
||||
})
|
||||
const sshKey = buildPdfScalePreferenceKey({
|
||||
worktreeId: 'worktree-a',
|
||||
externalSshTargetId: 'ssh-c',
|
||||
filePath: '/doc.pdf'
|
||||
})
|
||||
|
||||
expect(new Set([localKey, runtimeKey, sshKey]).size).toBe(3)
|
||||
})
|
||||
|
||||
it('round-trips a preference by file path', () => {
|
||||
const storage = createMemoryStorage()
|
||||
vi.stubGlobal('localStorage', storage)
|
||||
|
||||
writePdfScalePreference('/repo/report.pdf', 1.75)
|
||||
|
||||
expect(readPdfScalePreference('/repo/report.pdf')).toBe(1.75)
|
||||
expect(readPdfScalePreference('/repo/other.pdf')).toBeNull()
|
||||
})
|
||||
|
||||
it('persists fit-to-width resets and keeps files isolated', () => {
|
||||
const storage = createMemoryStorage()
|
||||
vi.stubGlobal('localStorage', storage)
|
||||
|
||||
writePdfScalePreference('/repo/report.pdf', 2)
|
||||
writePdfScalePreference('/repo/other.pdf', 'page-width')
|
||||
|
||||
expect(readPdfScalePreference('/repo/report.pdf')).toBe(2)
|
||||
expect(readPdfScalePreference('/repo/other.pdf')).toBe('page-width')
|
||||
})
|
||||
|
||||
it('ignores malformed stored values', () => {
|
||||
const storage = createMemoryStorage()
|
||||
vi.stubGlobal('localStorage', storage)
|
||||
storage.setItem(
|
||||
PDF_SCALE_PREFERENCES_STORAGE_KEY,
|
||||
JSON.stringify({ '/repo/report.pdf': { scale: 2 } })
|
||||
)
|
||||
|
||||
expect(readPdfScalePreference('/repo/report.pdf')).toBeNull()
|
||||
})
|
||||
|
||||
it('evicts the oldest entries after reaching the storage bound', () => {
|
||||
const storage = createMemoryStorage()
|
||||
vi.stubGlobal('localStorage', storage)
|
||||
|
||||
for (let index = 0; index < 101; index += 1) {
|
||||
writePdfScalePreference(`/repo/report-${index}.pdf`, index)
|
||||
}
|
||||
|
||||
expect(readPdfScalePreference('/repo/report-0.pdf')).toBeNull()
|
||||
expect(readPdfScalePreference('/repo/report-100.pdf')).toBe(100)
|
||||
})
|
||||
|
||||
it('ignores storage write failures', () => {
|
||||
const storage = createMemoryStorage()
|
||||
storage.setItem = () => {
|
||||
throw new Error('storage unavailable')
|
||||
}
|
||||
vi.stubGlobal('localStorage', storage)
|
||||
|
||||
expect(() => writePdfScalePreference('/repo/report.pdf', 1.5)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() {
|
||||
return values.size
|
||||
},
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => {
|
||||
values.set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { PdfScalePreference } from './pdf-scale-preference'
|
||||
|
||||
export const PDF_SCALE_PREFERENCES_STORAGE_KEY = 'orca.pdf.scale-preferences.v1'
|
||||
|
||||
const MAX_STORED_PREFERENCES = 100
|
||||
|
||||
export function buildPdfScalePreferenceKey(input: {
|
||||
worktreeId: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
externalSshTargetId?: string | null
|
||||
filePath: string
|
||||
}): string {
|
||||
return JSON.stringify([
|
||||
input.worktreeId,
|
||||
input.runtimeEnvironmentId?.trim() || 'local',
|
||||
input.externalSshTargetId?.trim() || null,
|
||||
input.filePath
|
||||
])
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isPdfScalePreference(value: unknown): value is PdfScalePreference {
|
||||
return value === 'page-width' || (typeof value === 'number' && Number.isFinite(value))
|
||||
}
|
||||
|
||||
function readStoredPreferences(storage: Storage): Record<string, unknown> {
|
||||
try {
|
||||
const raw = storage.getItem(PDF_SCALE_PREFERENCES_STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
return isRecord(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function getStorage(): Storage | null {
|
||||
try {
|
||||
return globalThis.localStorage === undefined ? null : globalThis.localStorage
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the last zoom choice for a PDF, if one was persisted. */
|
||||
export function readPdfScalePreference(preferenceKey: string): PdfScalePreference | null {
|
||||
const storage = getStorage()
|
||||
if (!storage) {
|
||||
return null
|
||||
}
|
||||
const preference = readStoredPreferences(storage)[preferenceKey]
|
||||
return isPdfScalePreference(preference) ? preference : null
|
||||
}
|
||||
|
||||
/** Persist a PDF zoom choice across viewer remounts and app restarts. */
|
||||
export function writePdfScalePreference(
|
||||
preferenceKey: string,
|
||||
preference: PdfScalePreference
|
||||
): void {
|
||||
const storage = getStorage()
|
||||
if (!storage) {
|
||||
return
|
||||
}
|
||||
|
||||
const stored = readStoredPreferences(storage)
|
||||
// Reinsert to keep recently used files at the end of the bounded map.
|
||||
delete stored[preferenceKey]
|
||||
stored[preferenceKey] = preference
|
||||
const keys = Object.keys(stored)
|
||||
while (keys.length > MAX_STORED_PREFERENCES) {
|
||||
const oldestKey = keys.shift()
|
||||
if (oldestKey !== undefined) {
|
||||
delete stored[oldestKey]
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(PDF_SCALE_PREFERENCES_STORAGE_KEY, JSON.stringify(stored))
|
||||
} catch {
|
||||
// The viewer remains usable when browser storage is unavailable or full.
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,12 @@ export function renderOpenCodeAccountsSection(model: AccountsPaneSectionModel):
|
||||
'auto.components.settings.AccountsPane.62ab430f94',
|
||||
"Paste the full Cookie header from your browser's DevTools → Network → any opencode.ai request, including __Host-console_session (e.g."
|
||||
)}{' '}
|
||||
<code className="text-xs">auth=…; __Host-console_session=…</code>
|
||||
<code className="text-xs">
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.37b4b4a3f7',
|
||||
'auth=…; __Host-console_session=…'
|
||||
)}
|
||||
</code>
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.d5267cce63',
|
||||
'). The auth cookie still covers workspace discovery; auth alone is not enough for usage. OpenCode Go auth is web-based and shared across Windows and WSL terminals.'
|
||||
|
||||
Reference in New Issue
Block a user