Files
orca/src/main/runtime/runtime-terminal-driver-controller.ts
T
NeilandTauri-EPO 2d9da847cb fix(runtime): keep desktop take-back from stale phone input
Invalidate unfinished phone input claims after desktop takeback or disconnect. Recheck presence after delayed layout and preserve fresh phone input.

Closes #23199.
Adapted from #23200.

Co-authored-by: Tauri-EPO <enrico.pin@gmail.com>
2026-10-21 14:30:57 -07:00

156 lines
4.8 KiB
TypeScript

import type { RuntimeTerminalDriverState } from '../../shared/runtime-types'
import { notifyRuntimeListeners } from './runtime-async-boundaries'
type DriverState = RuntimeTerminalDriverState
type RuntimeTerminalDriverDependencies = {
notifyChanged: (ptyId: string, next: DriverState) => void
canClaimMobileFloor: (ptyId: string, clientId: string) => boolean
commitMobileFloor: (
ptyId: string,
clientId: string,
previousFloor: DriverState,
isCurrent: () => boolean
) => Promise<void>
}
type MobileInputFloorState = {
base: DriverState
generation: number
committedGeneration: number
pending: Map<symbol, { clientId: string; generation: number }>
}
export class RuntimeTerminalDriverController {
private readonly current = new Map<string, DriverState>()
private readonly listeners = new Map<string, Set<(driver: DriverState) => void>>()
private readonly inputFloorClaims = new Map<string, MobileInputFloorState>()
constructor(private readonly deps: RuntimeTerminalDriverDependencies) {}
get(ptyId: string): DriverState {
return this.current.get(ptyId) ?? { kind: 'idle' }
}
getAll(): Map<string, DriverState> {
return new Map(this.current)
}
set(ptyId: string, next: DriverState): void {
// A desktop reclaim or disconnect supersedes unfinished mobile input.
if (next.kind !== 'mobile') {
this.inputFloorClaims.delete(ptyId)
}
const prev = this.get(ptyId)
if (prev.kind === next.kind) {
if (prev.kind === 'mobile' && next.kind === 'mobile' && prev.clientId === next.clientId) {
return
}
if (prev.kind !== 'mobile' && next.kind !== 'mobile') {
return
}
}
if (next.kind === 'idle') {
this.current.delete(ptyId)
} else {
this.current.set(ptyId, next)
}
this.deps.notifyChanged(ptyId, next)
const listeners = this.listeners.get(ptyId)
if (listeners) {
notifyRuntimeListeners(listeners, (listener) => listener(next), 'pty-driver')
}
}
clear(ptyId: string): boolean {
this.inputFloorClaims.delete(ptyId)
if (!this.current.delete(ptyId)) {
return false
}
this.deps.notifyChanged(ptyId, { kind: 'idle' })
return true
}
subscribe(ptyId: string, listener: (driver: DriverState) => void): () => void {
const listeners = this.listeners.get(ptyId) ?? new Set<(driver: DriverState) => void>()
listeners.add(listener)
this.listeners.set(ptyId, listeners)
return () => {
listeners.delete(listener)
if (listeners.size === 0) {
this.listeners.delete(ptyId)
}
}
}
beginMobileInputFloor(
ptyId: string,
clientId: string
): { commit: () => Promise<void>; rollback: () => void } | null {
if (!this.deps.canClaimMobileFloor(ptyId, clientId)) {
return null
}
const state = this.inputFloorClaims.get(ptyId) ?? {
base: this.get(ptyId),
generation: 0,
committedGeneration: 0,
pending: new Map<symbol, { clientId: string; generation: number }>()
}
this.inputFloorClaims.set(ptyId, state)
const token = Symbol('mobile-input-floor')
const generation = ++state.generation
state.pending.set(token, { clientId, generation })
this.set(ptyId, { kind: 'mobile', clientId })
let settled = false
return {
commit: async () => {
if (settled) {
return
}
settled = true
state.pending.delete(token)
if (this.inputFloorClaims.get(ptyId) !== state || generation < state.committedGeneration) {
this.deleteSettledClaim(ptyId, state)
return
}
const previousFloor = state.base
state.committedGeneration = generation
state.base = { kind: 'mobile', clientId }
await this.deps.commitMobileFloor(
ptyId,
clientId,
previousFloor,
() =>
this.inputFloorClaims.get(ptyId) === state && state.committedGeneration === generation
)
this.deleteSettledClaim(ptyId, state)
},
rollback: () => {
if (settled) {
return
}
settled = true
state.pending.delete(token)
if (this.inputFloorClaims.get(ptyId) !== state) {
return
}
const current = this.get(ptyId)
if (current.kind === 'mobile' && current.clientId === clientId) {
const pendingClientId = Array.from(state.pending.values()).at(-1)?.clientId
this.set(
ptyId,
pendingClientId ? { kind: 'mobile', clientId: pendingClientId } : state.base
)
}
this.deleteSettledClaim(ptyId, state)
}
}
}
private deleteSettledClaim(ptyId: string, state: MobileInputFloorState): void {
if (state.pending.size === 0 && this.inputFloorClaims.get(ptyId) === state) {
this.inputFloorClaims.delete(ptyId)
}
}
}