mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
* fix(terminal): let remote desktop viewers own the shared PTY width
A remote (relay/shared-control) desktop viewer resizes the host source PTY to
its own width, but was never registered as a width owner. So the host's own fit
cascade (window resize, split drag, tab reveal, "+"-new-tab re-render) freely
resized the viewed PTY back to the host-local width with no signal to the
viewer. The viewer kept its narrower grid while the host streamed wider
alt-screen frames -> cell-layout garble ("porridge") until a manual resize.
The mobile "presence lock" already solves this shape for phones by suppressing
the host's pty:resize while the phone drives. This does the same for remote
desktop viewers WITHOUT joining the mobile driver state machine (a viewer needs
only resize suppression, not input lock / phone-fit / driver banners), and it
routes every PTY geometry change through the existing enqueueLayout/applyLayout
serialization path rather than resizing the PTY ad hoc.
Design:
- Registry `remoteDesktopViewers: Map<ptyId, Map<subscriptionKey, viewport>>`,
keyed per SUBSCRIPTION so duplicate streams of one client cannot release each
other. isPtyResizeDrivenRemotely() = mobile driver OR any viewer present;
pty:resize (host fit cascade) bails on it. INPUT is never locked (shared
control: host and viewers can both type), unlike mobile.
- New internal layout target { kind: 'remote-desktop' } in applyLayout: clears
terminalFitOverrides like 'desktop', resizes only when dims changed, emits no
fit-override notifications, and does not call onExternalPtyResize (so a
viewer's width never pollutes desktop restore state). enqueueLayout remains
the sole serialized writer of PTY dimensions.
- Smallest client wins: applyRemoteDesktopLayout() sizes the PTY to the SMALLEST
attached viewer, so viewers with different screen sizes never overflow the
narrowest grid.
- No snapshot/replay race: a viewport that arrives while the initial scrollback
snapshot is being serialized is buffered and applied as ONE serialized resize
before serialization, so a mid-repaint alt screen is never baked into the
snapshot.
- Reclaim on detach: when the last viewer leaves, applyRemoteDesktopLayout
resizes the PTY back to the host's own width via enqueueLayout({kind:'desktop'}),
so the host reflows to full width without a manual window jiggle. The host
width is captured at the pty:resize suppression point (the host's own resize
attempts) — a source a remote viewport never pollutes.
- Mobile coexistence: a phone outranks a viewer; when the phone leaves, the
mobile-release paths call applyRemoteDesktopLayout so a surviving viewer keeps
the PTY, else the host reclaims.
Verified live (two local desktop instances): correct render on connect, no
garble on viewer-window resize (synchronous), and host reclaims full width on
disconnect. Unit tests cover the registry lifecycle, smallest-client-wins,
reclaim-via-layout, and mobile coexistence.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
* fix(terminal): address remote-desktop PTY width review findings
Resolves four CodeRabbit findings on the remote-desktop viewer width
ownership change, plus a round-trip race surfaced while fixing the leak:
- Leak: the one-shot terminal.updateViewport RPC has no disconnect hook,
so it must never create a width floor (nothing releases it, pinning the
host at a stale width). Add refreshRemoteDesktopViewer, which only
refreshes floors the client already owns via its stream subscription
(matched by clientId), mirroring the mobile updateMobileViewport
no-op-without-subscription invariant. The one-shot handler now refreshes
instead of registering.
- Round-trip replay: with the one-shot fallback now refresh-only, a resize
landing during the subscribe round-trip (connected, stream not yet
current) was dropped. The transport now replays the latest viewport over
the stream once it becomes current.
- Reclaim: keep the host reclaim target unless the reclaim resize actually
landed (result.ok), so a failed reclaim can retry against true host
geometry instead of a stale remote width.
- Snapshot drain: drain pendingRemoteDesktopViewport in the
sendRequestedSnapshot finally block; a viewer resize parked during a
SnapshotRequest buffering window was otherwise dropped until the next
resize.
- Key scope: scope the multiplex width-floor key by connectionId so two
connections reusing the same client-local streamId can't overwrite or
release each other's floor.
Tests: refresh-never-creates-floor, reclaim-target-retained-on-failed-resize,
SnapshotRequest parked-resize drain, connection-scoped key assertions, and a
round-trip replay race test (verified to fail without the flush).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(terminal): arbitrate active desktop viewport ownership
Co-authored-by: Orca <help@stably.ai>
* test(terminal): expect viewport claim capability
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): preserve input across ownership teardown
Co-authored-by: Orca <help@stably.ai>
* test(terminal): keep subscribe buffer coverage within lint limit
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: shady <shady2k@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Orca <help@stably.ai>
124 lines
4.3 KiB
TypeScript
124 lines
4.3 KiB
TypeScript
import { BrowserWindow, ipcMain } from 'electron'
|
|
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
|
import type {
|
|
RuntimeBrowserDriverState,
|
|
RuntimeStatus,
|
|
RuntimeSyncWindowGraphResult,
|
|
RuntimeSyncWindowGraph,
|
|
RuntimeTerminalDriverState
|
|
} from '../../shared/runtime-types'
|
|
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
|
import { RpcDispatcher } from '../runtime/rpc/dispatcher'
|
|
|
|
export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
|
|
ipcMain.removeHandler('runtime:syncWindowGraph')
|
|
ipcMain.removeHandler('runtime:getStatus')
|
|
ipcMain.removeHandler('runtime:call')
|
|
|
|
ipcMain.handle(
|
|
'runtime:syncWindowGraph',
|
|
(event, graph: RuntimeSyncWindowGraph): RuntimeSyncWindowGraphResult => {
|
|
const window = BrowserWindow.fromWebContents(event.sender)
|
|
if (!window) {
|
|
throw new Error('Runtime graph sync must originate from a BrowserWindow')
|
|
}
|
|
return runtime.syncWindowGraph(window.id, graph)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle('runtime:getStatus', (): RuntimeStatus => {
|
|
return runtime.getStatus()
|
|
})
|
|
|
|
ipcMain.handle(
|
|
'runtime:call',
|
|
async (
|
|
_event,
|
|
args: { method: string; params?: unknown }
|
|
): Promise<RuntimeRpcResponse<unknown>> => {
|
|
return (await new RpcDispatcher({ runtime }).dispatch({
|
|
id: 'desktop-ipc',
|
|
authToken: 'desktop-ipc',
|
|
method: args.method,
|
|
params: args.params
|
|
})) as RuntimeRpcResponse<unknown>
|
|
}
|
|
)
|
|
|
|
ipcMain.removeHandler('runtime:getTerminalFitOverrides')
|
|
ipcMain.handle(
|
|
'runtime:getTerminalFitOverrides',
|
|
(): {
|
|
ptyId: string
|
|
mode: 'mobile-fit' | 'remote-desktop-fit'
|
|
cols: number
|
|
rows: number
|
|
}[] => {
|
|
const overrides = runtime.getAllTerminalFitOverrides()
|
|
return Array.from(overrides.entries()).map(([ptyId, override]) => ({
|
|
ptyId,
|
|
...override
|
|
}))
|
|
}
|
|
)
|
|
|
|
ipcMain.removeHandler('runtime:getTerminalDrivers')
|
|
ipcMain.handle(
|
|
'runtime:getTerminalDrivers',
|
|
(): { ptyId: string; driver: RuntimeTerminalDriverState }[] => {
|
|
const drivers = runtime.getAllTerminalDrivers()
|
|
return Array.from(drivers.entries()).map(([ptyId, driver]) => ({ ptyId, driver }))
|
|
}
|
|
)
|
|
|
|
ipcMain.removeHandler('runtime:getBrowserDrivers')
|
|
ipcMain.handle(
|
|
'runtime:getBrowserDrivers',
|
|
(): { browserPageId: string; driver: RuntimeBrowserDriverState }[] => {
|
|
const drivers = runtime.getAllBrowserDrivers()
|
|
return Array.from(drivers.entries()).map(([browserPageId, driver]) => ({
|
|
browserPageId,
|
|
driver
|
|
}))
|
|
}
|
|
)
|
|
|
|
// Why: the desktop "Restore" button sets the display mode to 'desktop' and
|
|
// applies it, which restores the PTY to its original dimensions and emits
|
|
// a 'resized' event to any active mobile subscriber. This uses the same
|
|
// code path as the mobile toggle button (terminal.setDisplayMode RPC).
|
|
ipcMain.removeHandler('runtime:restoreTerminalFit')
|
|
ipcMain.handle('runtime:restoreTerminalFit', async (_event, args: { ptyId: string }) => {
|
|
// Why: this IPC powers the desktop "Take back" button. Beyond restoring
|
|
// PTY dims (the original semantic), it now also reclaims the input
|
|
// floor for the desktop via the driver state machine. The lock banner
|
|
// unmounts and desktop input/resize are unblocked until the next
|
|
// mobile interaction takes the floor again. See
|
|
// docs/mobile-presence-lock.md.
|
|
//
|
|
// Why async: reclaimTerminalForDesktop awaits applyMobileDisplayMode's
|
|
// PTY-resize chain. Returning the unresolved Promise to ipcMain made
|
|
// Electron try to structured-clone a Promise — "An object could not
|
|
// be cloned" error — and the renderer's restoreTerminalFit() rejected
|
|
// with no useful info.
|
|
try {
|
|
const reclaimed = await runtime.reclaimTerminalForDesktop(args.ptyId)
|
|
return { restored: reclaimed }
|
|
} catch {
|
|
return { restored: false }
|
|
}
|
|
})
|
|
|
|
ipcMain.removeHandler('runtime:reclaimBrowserForDesktop')
|
|
ipcMain.handle(
|
|
'runtime:reclaimBrowserForDesktop',
|
|
(_event, args: { browserPageId: string }): { reclaimed: boolean } => {
|
|
try {
|
|
return { reclaimed: runtime.reclaimBrowserForDesktop(args.browserPageId) }
|
|
} catch {
|
|
return { reclaimed: false }
|
|
}
|
|
}
|
|
)
|
|
}
|