Address emulator PR review feedback

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-07 11:49:40 -07:00
co-authored by Orca
parent 5d7159c987
commit afce1b24f8
7 changed files with 125 additions and 72 deletions
+22 -3
View File
@@ -96,6 +96,21 @@ function readPackage(packageName, fromDir = projectDir) {
}
}
function isKnownOmittedServeSimDependency(packageName, fromDir) {
if (packageName !== 'inspect-webkit') {
return false
}
const serveSimPackageJsonPath = join(projectDir, 'node_modules', 'serve-sim', 'package.json')
if (!existsSync(serveSimPackageJsonPath)) {
return false
}
try {
return realpathSync(fromDir) === realpathSync(dirname(serveSimPackageJsonPath))
} catch {
return false
}
}
function collectPackagedRuntimePackages() {
const packages = new Map()
const visit = (packageName, fromDir = projectDir) => {
@@ -106,9 +121,13 @@ function collectPackagedRuntimePackages() {
let packageInfo
try {
packageInfo = readPackage(packageName, fromDir)
} catch {
// Why: serve-sim lists inspect-webkit but some installs omit it until first CLI run; skip missing closure deps.
return
} catch (error) {
// Why: serve-sim declares inspect-webkit, but current installs omit it.
// Keep that escape hatch narrow so broken packages still fail packaging.
if (isKnownOmittedServeSimDependency(packageName, fromDir)) {
return
}
throw error
}
if (packages.has(packageInfo.name)) {
return
+1 -1
View File
@@ -26,7 +26,7 @@ export const EMULATOR_COMMAND_SPECS: CommandSpec[] = [
path: ['emulator', 'type'],
summary: 'Type text (US ASCII only)',
usage: 'orca emulator type <text> [--device <id>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'text', 'device', 'emulator', 'worktree', 'text'],
allowedFlags: [...GLOBAL_FLAGS, 'text', 'device', 'emulator', 'worktree'],
positionalArgs: ['text']
},
{
+17 -62
View File
@@ -19,13 +19,13 @@ import {
killServeSimHelperProcessesForDevice,
listServeSimHelperProcessesForDevice
} from './serve-sim-helper-processes'
import type { EmulatorBridgeOptions, EmulatorSessionState } from './emulator-bridge-types'
import type { EmulatorBridgeOptions } from './emulator-bridge-types'
import { sendEmulatorGestureSequence, type EmulatorGesturePoint } from './emulator-gesture-sender'
import { parseServeSimDetachedSession } from './serve-sim-detached-session'
import { EmulatorSessionRegistry } from './emulator-session-registry'
export class EmulatorBridge {
private readonly activeByWorktree = new Map<string, string>()
private readonly sessions = new Map<string, EmulatorSessionState>()
private readonly sessionRegistry = new EmulatorSessionRegistry()
private serveSimExecutable: ServeSimExecutable
private readonly waitForEndpointReady: (endpoint: string) => Promise<boolean>
@@ -56,42 +56,15 @@ export class EmulatorBridge {
info: EmulatorSessionInfo,
options: { managed?: boolean } = {}
): void {
const key = info.deviceUdid
this.sessions.set(key, {
deviceUdid: info.deviceUdid,
wsUrl: info.wsUrl,
streamUrl: info.streamUrl,
axUrl: info.axUrl,
pid: info.helperPid,
managed: options.managed === true,
initialized: true
})
this.activeByWorktree.set(worktreeId, key)
this.sessionRegistry.registerActive(worktreeId, info, options)
}
unregisterActiveEmulator(worktreeId: string): void {
this.activeByWorktree.delete(worktreeId)
this.sessionRegistry.unregisterWorktree(worktreeId)
}
getActiveForWorktree(worktreeId?: string): EmulatorSessionInfo | null {
if (!worktreeId) {
return null
}
const key = this.activeByWorktree.get(worktreeId)
if (!key) {
return null
}
const s = this.sessions.get(key)
if (!s) {
return null
}
return {
deviceUdid: s.deviceUdid,
wsUrl: s.wsUrl,
streamUrl: s.streamUrl,
axUrl: s.axUrl,
helperPid: s.pid
}
return this.sessionRegistry.getActiveForWorktree(worktreeId)
}
async getReusableActiveForWorktree(
@@ -126,12 +99,12 @@ export class EmulatorBridge {
worktreeId: string,
options: { shutdownDevice?: boolean; managedOnly?: boolean } = {}
): Promise<string | null> {
const key = this.activeByWorktree.get(worktreeId)
const key = this.sessionRegistry.getActiveSessionKey(worktreeId)
if (!key) {
return null
}
const session = this.sessions.get(key)
this.activeByWorktree.delete(worktreeId)
const session = this.sessionRegistry.getSession(key)
this.sessionRegistry.unregisterWorktree(worktreeId)
if (!session || (options.managedOnly && !session.managed)) {
return null
}
@@ -142,12 +115,7 @@ export class EmulatorBridge {
if (options.shutdownDevice) {
await shutdownSimulatorDevice(session.deviceUdid).catch(() => {})
}
this.sessions.delete(key)
for (const [wt, activeKey] of this.activeByWorktree.entries()) {
if (activeKey === key) {
this.activeByWorktree.delete(wt)
}
}
this.sessionRegistry.clearSessionAndWorktrees(key)
return session.deviceUdid
}
@@ -193,7 +161,7 @@ export class EmulatorBridge {
}
const target = this.getTargetOrThrow(opts)
const udid = await this.ensureUdid(target.udid)
const session = this.sessions.get(udid)
const session = this.sessionRegistry.getSession(udid)
if (!session?.wsUrl) {
throw new EmulatorError('emulator_no_active', 'No active emulator stream for gesture input')
}
@@ -298,16 +266,10 @@ export class EmulatorBridge {
: this.getTargetOrThrow({ worktreeId })
const udid = target.udid
await this.stopServeSimForDevice(udid, {
helperPid: this.sessions.get(udid)?.pid,
helperPid: this.sessionRegistry.getSession(udid)?.pid,
includeOrphaned: true
})
const key = udid
this.sessions.delete(key)
for (const [wt, k] of this.activeByWorktree.entries()) {
if (k === key) {
this.activeByWorktree.delete(wt)
}
}
this.sessionRegistry.clearSessionAndWorktrees(udid)
return udid
}
@@ -317,23 +279,17 @@ export class EmulatorBridge {
: this.getTargetOrThrow({ worktreeId })
const udid = target.udid
await this.stopServeSimForDevice(udid, {
helperPid: this.sessions.get(udid)?.pid,
helperPid: this.sessionRegistry.getSession(udid)?.pid,
includeOrphaned: true
})
await shutdownSimulatorDevice(udid)
const key = udid
this.sessions.delete(key)
for (const [wt, k] of this.activeByWorktree.entries()) {
if (k === key) {
this.activeByWorktree.delete(wt)
}
}
this.sessionRegistry.clearSessionAndWorktrees(udid)
return udid
}
async destroyAllSessions(): Promise<void> {
const promises: Promise<unknown>[] = []
for (const session of this.sessions.values()) {
for (const session of this.sessionRegistry.listSessions()) {
if (session.managed) {
promises.push(
this.stopServeSimForDevice(session.deviceUdid, { helperPid: session.pid })
@@ -343,8 +299,7 @@ export class EmulatorBridge {
}
}
await Promise.allSettled(promises)
this.sessions.clear()
this.activeByWorktree.clear()
this.sessionRegistry.clear()
}
async onAppQuit(): Promise<void> {
@@ -0,0 +1,77 @@
import type { EmulatorSessionInfo } from './emulator-types'
import type { EmulatorSessionState } from './emulator-bridge-types'
export class EmulatorSessionRegistry {
private readonly activeByWorktree = new Map<string, string>()
private readonly sessions = new Map<string, EmulatorSessionState>()
registerActive(
worktreeId: string,
info: EmulatorSessionInfo,
options: { managed?: boolean } = {}
): void {
const key = info.deviceUdid
this.sessions.set(key, {
deviceUdid: info.deviceUdid,
wsUrl: info.wsUrl,
streamUrl: info.streamUrl,
axUrl: info.axUrl,
pid: info.helperPid,
managed: options.managed === true,
initialized: true
})
this.activeByWorktree.set(worktreeId, key)
}
unregisterWorktree(worktreeId: string): void {
this.activeByWorktree.delete(worktreeId)
}
getActiveForWorktree(worktreeId?: string): EmulatorSessionInfo | null {
if (!worktreeId) {
return null
}
const key = this.activeByWorktree.get(worktreeId)
if (!key) {
return null
}
const session = this.sessions.get(key)
return session ? toSessionInfo(session) : null
}
getActiveSessionKey(worktreeId: string): string | null {
return this.activeByWorktree.get(worktreeId) ?? null
}
getSession(key: string): EmulatorSessionState | undefined {
return this.sessions.get(key)
}
listSessions(): EmulatorSessionState[] {
return [...this.sessions.values()]
}
clearSessionAndWorktrees(key: string): void {
this.sessions.delete(key)
for (const [worktreeId, activeKey] of this.activeByWorktree.entries()) {
if (activeKey === key) {
this.activeByWorktree.delete(worktreeId)
}
}
}
clear(): void {
this.sessions.clear()
this.activeByWorktree.clear()
}
}
function toSessionInfo(session: EmulatorSessionState): EmulatorSessionInfo {
return {
deviceUdid: session.deviceUdid,
wsUrl: session.wsUrl,
streamUrl: session.streamUrl,
axUrl: session.axUrl,
helperPid: session.pid
}
}
+2
View File
@@ -12220,6 +12220,8 @@ export class OrcaRuntimeService {
}
private dropDisconnectedPtyRecord(ptyId: string): void {
// Why: pruning can remove a PTY without the normal exit callback.
serveSimStateWatcher.unbindPty(ptyId)
this.ptysById.delete(ptyId)
this.recentPtyOutputById.delete(ptyId)
this.ptyOutputSequenceById.delete(ptyId)
@@ -574,11 +574,7 @@ export function useTabGroupWorkspaceModel({
newSimulatorTab: () => {
// Why: mobile simulators are most useful beside the current tab group.
// Re-open focuses the existing worktree simulator instead of spawning duplicates.
const wtId = useAppStore.getState().activeWorktreeId
if (!wtId) {
return
}
ensureSimulatorTab(wtId, {
ensureSimulatorTab(worktreeId, {
placement: 'rightSplit',
targetGroupId: groupId,
surfacePane: true
+5 -1
View File
@@ -426,7 +426,11 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [
group: 'Tabs',
scope: 'tabs',
searchKeywords: ['shortcut', 'tab', 'simulator', 'emulator', 'mobile', 'ios', 'new'],
defaultBindings: platformBindings(['Mod+Shift+E'])
defaultBindings: {
darwin: ['Mod+Shift+E'],
linux: [],
win32: []
}
},
{
id: 'tab.newMarkdown',