diff --git a/mobile/app/h/[hostId]/files/[worktreeId].tsx b/mobile/app/h/[hostId]/files/[worktreeId].tsx index 6f4caf65747..f758c569188 100644 --- a/mobile/app/h/[hostId]/files/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/files/[worktreeId].tsx @@ -174,10 +174,7 @@ export default function MobileFileExplorerScreen() { throw new Error(response.error?.message || 'Unable to open file') } triggerSelection() - router.replace({ - pathname: '/h/[hostId]/session/[worktreeId]', - params: { hostId, worktreeId, name: name ?? '' } - }) + router.back() } catch (err) { triggerError() setError(err instanceof Error ? err.message : 'Unable to open file') diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index af40a566492..34a1fa333da 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1581,7 +1581,7 @@ export default function SessionScreen() { useEffect(() => { if (activeSessionTab?.type !== 'markdown') return const doc = markdownDocs.get(activeSessionTab.id) - if (!doc || doc.status === 'error') { + if (!doc) { void readMarkdownTab(activeSessionTab) } }, [activeSessionTab, markdownDocs, readMarkdownTab]) @@ -1589,7 +1589,7 @@ export default function SessionScreen() { useEffect(() => { if (activeSessionTab?.type !== 'file') return const doc = fileDocs.get(activeSessionTab.id) - if (!doc || doc.status === 'error') { + if (!doc) { void readFileTab(activeSessionTab) } }, [activeSessionTab, fileDocs, readFileTab]) @@ -2456,8 +2456,15 @@ export default function SessionScreen() { const combined = drafts .map((draft) => `# ${draft.title}\n\n${draft.content}`) .join('\n\n---\n\n') - setLeaveDrafts(null) - void Clipboard.setStringAsync(combined).finally(() => router.back()) + void Clipboard.setStringAsync(combined) + .then(() => { + setLeaveDrafts(null) + router.back() + }) + .catch(() => { + triggerError() + showToast("Couldn't copy drafts", 1500) + }) } }, { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3fe6595c2bf..965fb64e43b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -122,6 +122,7 @@ import { } from '../git/repo' import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree' import { listQuickOpenFiles } from '../ipc/filesystem-list-files' +import { resolveAuthorizedPath } from '../ipc/filesystem-auth' import { createSetupRunnerScript, getEffectiveHooks, @@ -1001,7 +1002,7 @@ export class OrcaRuntimeService { const filePath = joinWorktreeRelativePath(worktree.path, relativePath) const content = repo?.connectionId ? await this.readRemoteMobileFile(filePath, repo.connectionId) - : await readLocalMobileFile(filePath) + : await readLocalMobileFile(filePath, this.store as unknown as Store) const truncated = truncateMobileFilePreview(content) return { @@ -7147,12 +7148,13 @@ function joinWorktreeRelativePath(rootPath: string, relativePath: string): strin return posix.join(rootPath, ...normalizedRelativePath.split('/')) } -async function readLocalMobileFile(filePath: string): Promise { - const fileStat = await stat(filePath) +async function readLocalMobileFile(filePath: string, store: Store): Promise { + const authorizedPath = await resolveAuthorizedPath(filePath, store) + const fileStat = await stat(authorizedPath) // Why: mobile file previews are read-only convenience views; cap the read so // opening a generated log or bundle cannot block the WebSocket like oversized scrollback. const readLimit = Math.min(fileStat.size, MOBILE_FILE_READ_MAX_BYTES + 1) - const handle = await open(filePath, 'r') + const handle = await open(authorizedPath, 'r') try { const buffer = Buffer.alloc(readLimit) const { bytesRead } = await handle.read(buffer, 0, readLimit, 0) diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 547ff5933f9..9c2b4102dca 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -39,23 +39,31 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ name: 'session.tabs.subscribe', params: WorktreeTabSelector, handler: async (params, { runtime, connectionId }, emit) => { - const initial = await runtime.listMobileSessionTabs(params.worktree) - emit({ type: 'snapshot', ...initial }) - - const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { - if (snapshot.worktree === initial.worktree) { - emit({ type: 'updated', ...snapshot }) - } - }) - const subscriptionId = `session.tabs:${connectionId ?? 'local'}:${initial.worktree}` + let subscribedWorktree: string | null = null + let unsubscribe = (): void => {} + let closed = false + const subscriptionId = `session.tabs:${connectionId ?? 'local'}:${params.worktree}` runtime.registerSubscriptionCleanup( subscriptionId, () => { + closed = true unsubscribe() emit({ type: 'end' }) }, connectionId ) + const initial = await runtime.listMobileSessionTabs(params.worktree) + if (closed) { + return + } + subscribedWorktree = initial.worktree + emit({ type: 'snapshot', ...initial }) + + unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + if (snapshot.worktree === subscribedWorktree) { + emit({ type: 'updated', ...snapshot }) + } + }) } }), defineMethod({ @@ -63,6 +71,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ params: WorktreeTabSelector, handler: async (params, { runtime, connectionId }) => { const snapshot = await runtime.listMobileSessionTabs(params.worktree) + runtime.cleanupSubscription(`session.tabs:${connectionId ?? 'local'}:${params.worktree}`) runtime.cleanupSubscription(`session.tabs:${connectionId ?? 'local'}:${snapshot.worktree}`) return { unsubscribed: true } } diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 4975821d6c3..ae1224704be 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -561,6 +561,31 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let closed = false let buffering = true const pendingOutput: string[] = [] + let unsubscribeData = (): void => {} + let unsubscribeResize = (): void => {} + let unsubscribeFit = (): void => {} + let resolveStream = (): void => {} + const streamClosed = new Promise((resolve) => { + resolveStream = resolve + }) + // Why: register cleanup before any mobile-fit or snapshot await. A phone + // can disconnect mid-subscribe; cleanup must still remove mobile presence. + const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + closed = true + unsubscribeData() + unsubscribeResize() + unsubscribeFit() + if (isMobile && clientId) { + runtime.handleMobileUnsubscribe(ptyId, clientId) + } + emit({ type: 'end' }) + resolveStream() + }, + connectionId + ) const sendFrame = ( opcode: TerminalStreamOpcode, payload: Uint8Array = new Uint8Array() @@ -571,80 +596,86 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: cursor++, payload })) } // Server-side auto-fit: resize PTY to phone dims before serializing scrollback - if (isMobile && clientId) { - await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) - } - - const unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => { + try { + if (isMobile && clientId) { + await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) + } if (closed) { return } - if (buffering) { - pendingOutput.push(data) + + unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => { + if (closed) { + return + } + if (buffering) { + pendingOutput.push(data) + return + } + sendBinary!( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId, + seq: cursor++, + payload: encodeTerminalStreamText(data) + }) + ) + }) + + const read = await runtime.readTerminal(params.terminal) + const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true) + if (closed) { return } - sendBinary!( - encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Output, - streamId, - seq: cursor++, - payload: encodeTerminalStreamText(data) - }) - ) - }) + const size = runtime.getTerminalSize(ptyId) + const displayMode = runtime.getMobileDisplayMode(ptyId) + // Why: emit the current layout seq with the initial scrollback so + // the mobile client's stale-event filter knows the high-water mark. + // Undefined when the PTY has never transitioned (filter is fail-open). + // See docs/mobile-terminal-layout-state-machine.md. + const seq = runtime.getLayout(ptyId)?.seq + emit({ + type: 'subscribed', + streamId, + lines: read.tail, + truncated: read.truncated, + cols: serialized?.cols ?? size?.cols, + rows: serialized?.rows ?? size?.rows, + displayMode, + seq + }) + const snapshotStats = sendSnapshotFrames(sendFrame, { + kind: 'scrollback', + cols: serialized?.cols ?? size?.cols ?? 80, + rows: serialized?.rows ?? size?.rows ?? 24, + displayMode, + seq, + truncated: read.truncated, + truncatedByByteBudget: serialized?.truncatedByByteBudget, + data: serialized?.data ?? '' + }) + console.log('[mobile-terminal-stream] snapshot', { + terminal: params.terminal, + streamId, + kind: 'scrollback', + bytes: snapshotStats.bytes, + chunks: snapshotStats.chunks, + scrollbackRows: serialized?.scrollbackRows, + truncatedByByteBudget: serialized?.truncatedByByteBudget === true + }) + buffering = false + for (const item of pendingOutput.splice(0)) { + sendBinary!( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId, + seq: cursor++, + payload: encodeTerminalStreamText(item) + }) + ) + } - const read = await runtime.readTerminal(params.terminal) - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true) - const size = runtime.getTerminalSize(ptyId) - const displayMode = runtime.getMobileDisplayMode(ptyId) - // Why: emit the current layout seq with the initial scrollback so - // the mobile client's stale-event filter knows the high-water mark. - // Undefined when the PTY has never transitioned (filter is fail-open). - // See docs/mobile-terminal-layout-state-machine.md. - const seq = runtime.getLayout(ptyId)?.seq - emit({ - type: 'subscribed', - streamId, - lines: read.tail, - truncated: read.truncated, - cols: serialized?.cols ?? size?.cols, - rows: serialized?.rows ?? size?.rows, - displayMode, - seq - }) - const snapshotStats = sendSnapshotFrames(sendFrame, { - kind: 'scrollback', - cols: serialized?.cols ?? size?.cols ?? 80, - rows: serialized?.rows ?? size?.rows ?? 24, - displayMode, - seq, - truncated: read.truncated, - truncatedByByteBudget: serialized?.truncatedByByteBudget, - data: serialized?.data ?? '' - }) - console.log('[mobile-terminal-stream] snapshot', { - terminal: params.terminal, - streamId, - kind: 'scrollback', - bytes: snapshotStats.bytes, - chunks: snapshotStats.chunks, - scrollbackRows: serialized?.scrollbackRows, - truncatedByByteBudget: serialized?.truncatedByByteBudget === true - }) - buffering = false - for (const item of pendingOutput.splice(0)) { - sendBinary!( - encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Output, - streamId, - seq: cursor++, - payload: encodeTerminalStreamText(item) - }) - ) - } - - await new Promise((resolve) => { - const unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { + unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { // Why: true PTY geometry changes should be followed by the TUI's // redraw output, not a full scrollback replay. The client resizes // xterm geometry and consumes subsequent live output on this stream. @@ -661,7 +692,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }) // Legacy fit-override-changed for non-mobile (desktop) subscribers - const unsubscribeFit = !isMobile + unsubscribeFit = !isMobile ? runtime.subscribeToFitOverrideChanges(ptyId, (event) => { emit({ type: 'fit-override-changed', @@ -671,28 +702,12 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }) }) : () => {} + } catch (error) { + runtime.cleanupSubscription(subscriptionId) + throw error + } - // Why: composite subscriptionId per (terminal, clientId) so two - // mobile clients subscribing to the same terminal handle do not - // evict each other via registerSubscriptionCleanup's - // duplicate-key cleanup. See docs/mobile-presence-lock.md. - const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal - runtime.registerSubscriptionCleanup( - subscriptionId, - () => { - closed = true - unsubscribeData() - unsubscribeResize() - unsubscribeFit() - if (isMobile && clientId) { - runtime.handleMobileUnsubscribe(ptyId, clientId) - } - emit({ type: 'end' }) - resolve() - }, - connectionId - ) - }) + await streamClosed } }), defineMethod({