From cde2472a8385e33aa3f403d856ee91f93c4f9067 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 28 Oct 2026 20:07:42 -0700 Subject: [PATCH] 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> --- mobile/src/platform/native-pdf-focus.test.tsx | 219 ++++++++++++++++++ mobile/src/platform/native-pdf-view.ts | 16 +- 2 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 mobile/src/platform/native-pdf-focus.test.tsx diff --git a/mobile/src/platform/native-pdf-focus.test.tsx b/mobile/src/platform/native-pdf-focus.test.tsx new file mode 100644 index 00000000000..db4790d8a71 --- /dev/null +++ b/mobile/src/platform/native-pdf-focus.test.tsx @@ -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 + 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) | 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) => 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) +}) diff --git a/mobile/src/platform/native-pdf-view.ts b/mobile/src/platform/native-pdf-view.ts index 64ea39b76c5..a68c5295d84 100644 --- a/mobile/src/platform/native-pdf-view.ts +++ b/mobile/src/platform/native-pdf-view.ts @@ -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> => { + 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 { 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 }