mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
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:
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user