fix(mobile): refuse PDF opens from hidden pages (#34869)

* fix(mobile): refuse PDF opens from hidden pages

* test(mobile): assert one refused hidden PDF open instead of scaled loops

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-28 20:07:42 -07:00
committed by GitHub
co-authored by OrcaWin
parent d27c795b76
commit cde2472a83
2 changed files with 232 additions and 3 deletions
@@ -0,0 +1,219 @@
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
const device = vi.hoisted(
(): {
focused: boolean
platform: 'ios' | 'android'
blur: () => void
focus: () => void
client: RpcClient | null
files: Map<string, Uint8Array>
presentations: string[]
creates: number
} => ({
focused: true,
platform: 'ios',
blur: () => {},
focus: () => {},
client: null,
files: new Map(),
presentations: [],
creates: 0
})
)
vi.mock('react-native', () => ({
Platform: {
get OS() {
return device.platform
}
}
}))
vi.mock('expo-router', async () => {
const { useEffect } = await import('react')
const navigation = { isFocused: () => device.focused }
return {
useNavigation: () => navigation,
useFocusEffect: (effect: () => void | (() => void)) =>
useEffect(() => {
let cleanup = device.focused ? effect() : undefined
device.blur = () => {
device.focused = false
cleanup?.()
cleanup = undefined
}
device.focus = () => {
device.focused = true
cleanup = effect()
}
return () => cleanup?.()
}, [effect])
}
})
vi.mock('expo-file-system', () => {
class Directory {
readonly uri: string
constructor(parent: string | Directory, name?: string) {
this.uri = `${typeof parent === 'string' ? parent : parent.uri}${name ? `/${name}` : ''}`
}
get exists() {
return [...device.files.keys()].some((uri) => uri.startsWith(`${this.uri}/`))
}
create(): void {}
delete(): void {
for (const uri of device.files.keys()) {
if (uri.startsWith(`${this.uri}/`)) {
device.files.delete(uri)
}
}
}
}
class File {
readonly uri: string
constructor(parent: string | Directory, name?: string) {
this.uri = `${typeof parent === 'string' ? parent : parent.uri}${name ? `/${name}` : ''}`
}
get contentUri() {
return `content://pdf/${encodeURIComponent(this.uri)}`
}
create(): void {
device.creates += 1
device.files.set(this.uri, new Uint8Array())
}
delete(): void {
device.files.delete(this.uri)
}
open() {
return {
writeBytes: (bytes: Uint8Array) => device.files.set(this.uri, Uint8Array.from(bytes)),
close: () => {}
}
}
}
return { Directory, File, Paths: { cache: 'file:///cache' } }
})
vi.mock('expo-clipboard', () => ({}))
vi.mock('expo-document-picker', () => ({}))
vi.mock('expo-image-picker', () => ({}))
vi.mock('../../modules/orca-mobile-web-shell/src', () => ({
presentPdf: async (uri: string) => {
device.presentations.push(uri)
}
}))
vi.mock('../transport/host-client-hooks', () => ({
useHostClient: () => ({ client: device.client })
}))
import { createElement } from 'react'
import { createFakeRpcClient, rpcSuccess } from '../mobile-web-shell/bridge-host-test-fakes'
import { useNativePdfViewer } from './native-pdf-view'
let tree: ReactTestRenderer | null = null
let serve: ((params: unknown) => Promise<unknown>) | null = null
const open = { action: 'open', worktreeId: 'folder-workspace', relativePath: 'docs/guide.pdf' }
beforeEach(() => {
device.focused = true
device.platform = 'ios'
device.files.clear()
device.presentations = []
device.creates = 0
serve = null
})
afterEach(() => {
act(() => tree?.unmount())
tree = null
})
async function mount() {
const client = createFakeRpcClient()
const send = vi
.spyOn(client, 'sendRequest')
.mockImplementation(async (method) =>
rpcSuccess(
'pdf',
method === 'files.stat'
? { size: 4, isDirectory: false, mtime: 1 }
: { contentBase64: 'JVBERg==', bytesRead: 4, eof: true }
)
)
device.client = client
function Probe() {
serve = useNativePdfViewer('host-one', 'page-one')
return null
}
await act(async () => {
tree = create(createElement(Probe))
})
if (serve === null) {
throw new Error('PDF handler did not mount')
}
return { serve, send }
}
it('refuses a PDF open from a retained hidden page before downloading', async () => {
const handler = await mount()
act(() => device.blur())
await expect(handler.serve(open)).rejects.toThrow('not focused')
expect.soft(handler.send).not.toHaveBeenCalled()
expect.soft(device.creates).toBe(0)
expect(device.presentations).toEqual([])
})
it.each(['ios', 'android'] as const)(
'still downloads and presents after refocus on %s',
async (platform) => {
device.platform = platform
const handler = await mount()
act(() => device.blur())
act(() => device.focus())
await handler.serve(open)
expect(handler.send.mock.calls.map(([method]) => method)).toEqual([
'files.stat',
'files.readChunk',
'files.stat'
])
expect(device.presentations).toHaveLength(1)
expect(device.presentations[0]).toMatch(platform === 'android' ? /^content:/ : /^file:/)
expect([...device.files.values()]).toEqual([new Uint8Array([37, 80, 68, 70])])
}
)
it('cancels an admitted range on blur without presenting its late bytes', async () => {
const handler = await mount()
let finishRange: (reply: ReturnType<typeof rpcSuccess>) => void = () => {}
handler.send.mockImplementationOnce(async () =>
rpcSuccess('stat', { size: 4, isDirectory: false, mtime: 1 })
)
handler.send.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishRange = resolve
})
)
const pending = handler.serve(open).then(
() => 'presented',
() => 'cancelled'
)
await vi.waitFor(() => expect(handler.send).toHaveBeenCalledTimes(2))
act(() => device.blur())
finishRange(rpcSuccess('range', { contentBase64: 'JVBERg==', bytesRead: 4, eof: true }))
expect(await pending).toBe('cancelled')
expect(handler.send).toHaveBeenCalledTimes(2)
expect(device.presentations).toEqual([])
expect(device.files.size).toBe(0)
})
it('keeps the last presented PDF intact when its retained page attempts another open while hidden', async () => {
const handler = await mount()
await handler.serve(open)
const presented = [...device.presentations]
const files = [...device.files.keys()]
act(() => device.blur())
await handler.serve({ ...open, relativePath: 'docs/another.pdf' }).catch(() => undefined)
expect(device.presentations).toEqual(presented)
expect([...device.files.keys()]).toEqual(files)
expect(handler.send).toHaveBeenCalledTimes(3)
})
+13 -3
View File
@@ -1,7 +1,7 @@
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
import { Platform } from 'react-native'
import { Directory, File as FsFile, Paths } from 'expo-file-system'
import { useFocusEffect } from 'expo-router'
import { useFocusEffect, useNavigation } from 'expo-router'
import { presentPdf } from '../../modules/orca-mobile-web-shell/src'
import { getRuntimePathBasename } from '../../../src/shared/cross-platform-path'
import { PDF_MIME_TYPE } from '../files/mobile-file-media'
@@ -38,13 +38,19 @@ function pdfFileSink(file: FsFile): MobileMediaSink {
// An external viewer may still read the last PDF, so earlier ones are cleared only when the next starts.
// Each open gets its own folder so an aborted download's cleanup cannot delete its replacement.
export function createNativePdfViewer(readClient: () => RpcClient | null) {
export function createNativePdfViewer(
readClient: () => RpcClient | null,
isFocused: () => boolean
) {
let current: AbortController | null = null
const open = async ({
worktreeId,
relativePath,
executionHost
}: BridgePdfOpenParams): Promise<Record<string, never>> => {
if (!isFocused()) {
throw new Error('the PDF page is not focused')
}
const client = readClient()
if (!client) {
throw new Error('no desktop connection to download the PDF from')
@@ -94,11 +100,15 @@ export function useNativePdfViewer(
sessionId: string | null
): (params: unknown) => Promise<unknown> {
const { client } = useHostClient(hostId)
const navigation = useNavigation()
const clientRef = useRef(client)
useLayoutEffect(() => {
clientRef.current = client
}, [client])
const viewer = useMemo(() => createNativePdfViewer(() => clientRef.current), [sessionId])
const viewer = useMemo(
() => createNativePdfViewer(() => clientRef.current, navigation.isFocused),
[navigation, sessionId]
)
useFocusEffect(useCallback(() => () => viewer.dispose(), [viewer]))
return viewer.serve
}