From 8de1f67031ae5327b9010d1e37dc266cf4cb6402 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:38:55 -0700 Subject: [PATCH 1/7] feat(preview): add SSH port forwarding backend --- .../port-forward-service.test.ts | 78 ++++ .../port-forwards/port-forward-service.ts | 104 ++++++ .../port-forwards/port-forward-tunnel.test.ts | 139 +++++++ .../core/port-forwards/port-forward-tunnel.ts | 121 ++++++ .../main/core/preview-servers/controller.ts | 15 + .../preview-server-service-instance.ts | 16 + .../preview-server-service.test.ts | 300 +++++++++++++++ .../preview-servers/preview-server-service.ts | 348 ++++++++++++++++++ .../terminal-url-detector.test.ts | 77 ++++ .../preview-servers/terminal-url-detector.ts | 159 ++++++++ .../main/core/projects/project-provider.ts | 2 + .../src/main/core/tasks/task-builder.ts | 1 + .../impl/local-terminal-provider.test.ts | 53 ++- .../terminals/impl/local-terminal-provider.ts | 33 +- .../impl/ssh-terminal-provider.test.ts | 56 ++- .../terminals/impl/ssh-terminal-provider.ts | 36 +- .../main/core/workspaces/workspace-factory.ts | 8 + apps/emdash-desktop/src/main/rpc.ts | 2 + .../src/shared/core/preview-servers/events.ts | 4 + .../src/shared/core/preview-servers/types.ts | 61 +++ 20 files changed, 1602 insertions(+), 11 deletions(-) create mode 100644 apps/emdash-desktop/src/main/core/port-forwards/port-forward-service.test.ts create mode 100644 apps/emdash-desktop/src/main/core/port-forwards/port-forward-service.ts create mode 100644 apps/emdash-desktop/src/main/core/port-forwards/port-forward-tunnel.test.ts create mode 100644 apps/emdash-desktop/src/main/core/port-forwards/port-forward-tunnel.ts create mode 100644 apps/emdash-desktop/src/main/core/preview-servers/controller.ts create mode 100644 apps/emdash-desktop/src/main/core/preview-servers/preview-server-service-instance.ts create mode 100644 apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts create mode 100644 apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts create mode 100644 apps/emdash-desktop/src/main/core/preview-servers/terminal-url-detector.test.ts create mode 100644 apps/emdash-desktop/src/main/core/preview-servers/terminal-url-detector.ts create mode 100644 apps/emdash-desktop/src/shared/core/preview-servers/events.ts create mode 100644 apps/emdash-desktop/src/shared/core/preview-servers/types.ts diff --git a/apps/emdash-desktop/src/main/core/port-forwards/port-forward-service.test.ts b/apps/emdash-desktop/src/main/core/port-forwards/port-forward-service.test.ts new file mode 100644 index 0000000000..9af5ff30d2 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/port-forwards/port-forward-service.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; +import { PortForwardService } from './port-forward-service'; + +function fakeProxy(): Pick { + return { + isConnected: true, + get client() { + return {} as SshClientProxy['client']; + }, + }; +} + +describe('PortForwardService', () => { + it('deduplicates opens by id and closes the tunnel once', async () => { + const close = vi.fn(); + const service = new PortForwardService({ + openTunnel: vi.fn(async () => ({ localPort: 6100, close })), + }); + + const first = await service.open({ + id: 'forward-1', + projectId: 'project-1', + workspaceId: 'workspace-1', + connectionId: 'ssh-1', + proxy: fakeProxy(), + remotePort: 5173, + }); + const second = await service.open({ + id: 'forward-1', + projectId: 'project-1', + workspaceId: 'workspace-1', + connectionId: 'ssh-1', + proxy: fakeProxy(), + remotePort: 5173, + }); + + expect(second).toEqual(first); + + await service.stop('forward-1'); + await service.stop('forward-1'); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('stops only tunnels owned by the requested workspace', async () => { + const closeFirst = vi.fn(); + const closeSecond = vi.fn(); + const service = new PortForwardService({ + openTunnel: vi + .fn() + .mockResolvedValueOnce({ localPort: 6100, close: closeFirst }) + .mockResolvedValueOnce({ localPort: 6101, close: closeSecond }), + }); + + await service.open({ + id: 'forward-1', + projectId: 'project-1', + workspaceId: 'workspace-1', + connectionId: 'ssh-1', + proxy: fakeProxy(), + remotePort: 5173, + }); + await service.open({ + id: 'forward-2', + projectId: 'project-1', + workspaceId: 'workspace-2', + connectionId: 'ssh-1', + proxy: fakeProxy(), + remotePort: 5174, + }); + + await service.stopForWorkspace('project-1', 'workspace-1'); + + expect(closeFirst).toHaveBeenCalledTimes(1); + expect(closeSecond).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/emdash-desktop/src/main/core/port-forwards/port-forward-service.ts b/apps/emdash-desktop/src/main/core/port-forwards/port-forward-service.ts new file mode 100644 index 0000000000..12a54f4eb9 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/port-forwards/port-forward-service.ts @@ -0,0 +1,104 @@ +import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; +import { openPortForwardTunnel, type PortForwardTunnel } from './port-forward-tunnel'; + +export type OpenPortForwardRequest = { + id: string; + projectId: string; + workspaceId: string; + connectionId: string; + proxy: Pick; + remotePort: number; + preferredLocalPort?: number; +}; + +export type PortForwardRecord = { + id: string; + projectId: string; + workspaceId: string; + connectionId: string; + remotePort: number; + localPort: number; +}; + +type PortForwardEntry = PortForwardRecord & { + tunnel: PortForwardTunnel; +}; + +export class PortForwardService { + private readonly tunnels = new Map(); + private readonly openTunnel: (request: { + proxy: Pick; + remotePort: number; + preferredLocalPort?: number; + }) => Promise; + private readonly onTunnelClosed?: (id: string) => void; + + constructor( + options: { + openTunnel?: (request: { + proxy: Pick; + remotePort: number; + preferredLocalPort?: number; + }) => Promise; + onTunnelClosed?: (id: string) => void; + } = {} + ) { + this.openTunnel = options.openTunnel ?? openPortForwardTunnel; + this.onTunnelClosed = options.onTunnelClosed; + } + + async open(request: OpenPortForwardRequest): Promise { + const existing = this.tunnels.get(request.id); + if (existing) return toRecord(existing); + + const tunnel = await this.openTunnel({ + proxy: request.proxy, + remotePort: request.remotePort, + preferredLocalPort: request.preferredLocalPort, + }); + const entry: PortForwardEntry = { + id: request.id, + projectId: request.projectId, + workspaceId: request.workspaceId, + connectionId: request.connectionId, + remotePort: request.remotePort, + localPort: tunnel.localPort, + tunnel, + }; + this.tunnels.set(request.id, entry); + return toRecord(entry); + } + + async stop(id: string): Promise { + const entry = this.tunnels.get(id); + if (!entry) return; + this.tunnels.delete(id); + await entry.tunnel.close(); + this.onTunnelClosed?.(id); + } + + async stopForWorkspace(projectId: string, workspaceId: string): Promise { + const ids = Array.from(this.tunnels.values()) + .filter((entry) => entry.projectId === projectId && entry.workspaceId === workspaceId) + .map((entry) => entry.id); + await Promise.all(ids.map((id) => this.stop(id))); + } + + async stopForProject(projectId: string): Promise { + const ids = Array.from(this.tunnels.values()) + .filter((entry) => entry.projectId === projectId) + .map((entry) => entry.id); + await Promise.all(ids.map((id) => this.stop(id))); + } +} + +function toRecord(entry: PortForwardEntry): PortForwardRecord { + return { + id: entry.id, + projectId: entry.projectId, + workspaceId: entry.workspaceId, + connectionId: entry.connectionId, + remotePort: entry.remotePort, + localPort: entry.localPort, + }; +} diff --git a/apps/emdash-desktop/src/main/core/port-forwards/port-forward-tunnel.test.ts b/apps/emdash-desktop/src/main/core/port-forwards/port-forward-tunnel.test.ts new file mode 100644 index 0000000000..d8100e454e --- /dev/null +++ b/apps/emdash-desktop/src/main/core/port-forwards/port-forward-tunnel.test.ts @@ -0,0 +1,139 @@ +import net from 'node:net'; +import { Transform } from 'node:stream'; +import type { ClientChannel } from 'ssh2'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; +import { openPortForwardTunnel } from './port-forward-tunnel'; + +class EchoChannel extends Transform { + override _transform( + chunk: Buffer, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void + ): void { + this.push(Buffer.from(`remote:${chunk.toString('utf8')}`)); + callback(); + } +} + +function makeProxy() { + const calls: Array<{ + sourceHost: string; + sourcePort: number; + remoteHost: string; + remotePort: number; + }> = []; + + return { + calls, + proxy: { + get isConnected() { + return true; + }, + get client() { + return { + forwardOut( + sourceHost: string, + sourcePort: number, + remoteHost: string, + remotePort: number, + callback: (error: Error | undefined, channel: ClientChannel) => void + ) { + calls.push({ sourceHost, sourcePort, remoteHost, remotePort }); + callback(undefined, new EchoChannel() as unknown as ClientChannel); + }, + } as SshClientProxy['client']; + }, + } satisfies Pick, + }; +} + +function listen(server: net.Server): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen({ host: '127.0.0.1', port: 0 }, () => { + const address = server.address(); + if (typeof address === 'object' && address) { + resolve(address.port); + return; + } + reject(new Error('server did not bind to a TCP port')); + }); + }); +} + +function closeServer(server: net.Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +function roundTrip(port: number, payload: string): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ host: '127.0.0.1', port }); + let data = ''; + socket.setTimeout(1000); + socket.on('connect', () => socket.write(payload)); + socket.on('data', (chunk) => { + data += chunk.toString('utf8'); + socket.end(); + }); + socket.on('end', () => resolve(data)); + socket.on('error', reject); + socket.on('timeout', () => { + socket.destroy(); + reject(new Error('socket timed out')); + }); + }); +} + +describe('openPortForwardTunnel', () => { + const blockers: net.Server[] = []; + + afterEach(async () => { + await Promise.all(blockers.splice(0).map(closeServer)); + }); + + it('binds a local listener and forwards sockets through ssh2 forwardOut', async () => { + const { proxy, calls } = makeProxy(); + + const tunnel = await openPortForwardTunnel({ + proxy, + remotePort: 5173, + }); + + try { + await expect(roundTrip(tunnel.localPort, 'ping')).resolves.toBe('remote:ping'); + expect(calls).toEqual([ + { + sourceHost: '127.0.0.1', + sourcePort: 0, + remoteHost: '127.0.0.1', + remotePort: 5173, + }, + ]); + } finally { + await tunnel.close(); + } + }); + + it('falls back to an OS-selected local port when the preferred port is busy', async () => { + const blocker = net.createServer(); + blockers.push(blocker); + const busyPort = await listen(blocker); + const { proxy } = makeProxy(); + + const tunnel = await openPortForwardTunnel({ + proxy, + remotePort: 3000, + preferredLocalPort: busyPort, + }); + + try { + expect(tunnel.localPort).not.toBe(busyPort); + await expect(roundTrip(tunnel.localPort, 'ok')).resolves.toBe('remote:ok'); + } finally { + await tunnel.close(); + } + }); +}); diff --git a/apps/emdash-desktop/src/main/core/port-forwards/port-forward-tunnel.ts b/apps/emdash-desktop/src/main/core/port-forwards/port-forward-tunnel.ts new file mode 100644 index 0000000000..ffcffd5649 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/port-forwards/port-forward-tunnel.ts @@ -0,0 +1,121 @@ +import net from 'node:net'; +import type { ClientChannel } from 'ssh2'; +import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; + +const LOCAL_BIND_HOST = '127.0.0.1'; +const REMOTE_TARGET_HOST = '127.0.0.1'; + +export type PortForwardTunnel = { + localPort: number; + close(): Promise; +}; + +export type OpenPortForwardTunnelOptions = { + proxy: Pick; + remotePort: number; + preferredLocalPort?: number; +}; + +export async function openPortForwardTunnel( + options: OpenPortForwardTunnelOptions +): Promise { + try { + return await bindTunnel(options, options.preferredLocalPort ?? 0); + } catch (error) { + if (options.preferredLocalPort !== undefined && isAddressInUse(error)) { + return await bindTunnel(options, 0); + } + throw error; + } +} + +function bindTunnel( + options: OpenPortForwardTunnelOptions, + localPort: number +): Promise { + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + forwardSocket(socket, options); + }); + + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.removeListener('listening', onListening); + reject(error); + }; + + const onListening = () => { + server.removeListener('error', onError); + const address = server.address(); + if (typeof address !== 'object' || address === null) { + reject(new Error('port forward listener did not bind to a TCP address')); + return; + } + + resolve({ + localPort: address.port, + close: async () => { + for (const socket of sockets) socket.destroy(); + await closeServer(server); + }, + }); + }; + + server.once('error', onError); + server.once('listening', onListening); + server.listen({ host: LOCAL_BIND_HOST, port: localPort }); + }); +} + +function forwardSocket(socket: net.Socket, options: OpenPortForwardTunnelOptions): void { + if (!options.proxy.isConnected) { + socket.destroy(); + return; + } + + let client; + try { + client = options.proxy.client; + } catch { + socket.destroy(); + return; + } + + client.forwardOut( + LOCAL_BIND_HOST, + 0, + REMOTE_TARGET_HOST, + options.remotePort, + (error: Error | undefined, channel: ClientChannel) => { + if (error) { + socket.destroy(error); + return; + } + + socket.on('error', () => channel.destroy()); + channel.on('error', () => socket.destroy()); + socket.pipe(channel).pipe(socket); + } + ); +} + +function closeServer(server: net.Server): Promise { + return new Promise((resolve, reject) => { + if (!server.listening) { + resolve(); + return; + } + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +function isAddressInUse(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: string }).code === 'EADDRINUSE' + ); +} diff --git a/apps/emdash-desktop/src/main/core/preview-servers/controller.ts b/apps/emdash-desktop/src/main/core/preview-servers/controller.ts new file mode 100644 index 0000000000..ddb58198f0 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/preview-servers/controller.ts @@ -0,0 +1,15 @@ +import type { ManualPreviewServerRequest } from '@shared/core/preview-servers/types'; +import { createRPCController } from '@shared/lib/ipc/rpc'; +import { previewServerService } from './preview-server-service-instance'; + +export const previewServersController = createRPCController({ + listForWorkspace: async (args: { projectId: string; workspaceId: string }) => + previewServerService.listForWorkspace(args), + + forwardManual: async (request: ManualPreviewServerRequest) => + previewServerService.forwardManual(request), + + stop: async (id: string) => previewServerService.stop(id), + + restart: async (id: string) => previewServerService.restart(id), +}); diff --git a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service-instance.ts b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service-instance.ts new file mode 100644 index 0000000000..4d43953b01 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service-instance.ts @@ -0,0 +1,16 @@ +import { sshConnectionManager } from '@main/core/ssh/lifecycle/production-ssh-connection-manager'; +import { events } from '@main/lib/events'; +import { previewServerEventChannel } from '@shared/core/preview-servers/events'; +import { PortForwardService } from '../port-forwards/port-forward-service'; +import { PreviewServerService } from './preview-server-service'; + +export const previewServerService = new PreviewServerService({ + portForwards: new PortForwardService(), + emit: (event) => events.emit(previewServerEventChannel, event), + getConnectionState: (connectionId) => sshConnectionManager.getConnectionState(connectionId), + getSshProxy: async (connectionId) => await sshConnectionManager.connect(connectionId), +}); + +sshConnectionManager.on('connection-event', (event) => { + previewServerService.handleSshConnectionEvent(event); +}); diff --git a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts new file mode 100644 index 0000000000..6c43cb8c23 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; +import type { PreviewServerEvent } from '@shared/core/preview-servers/types'; +import { previewServerUrl } from '@shared/core/preview-servers/types'; +import type { ConnectionState } from '@shared/core/ssh/ssh'; +import { PortForwardService } from '../port-forwards/port-forward-service'; +import { PreviewServerService } from './preview-server-service'; + +function createService(options: { connectionState?: ConnectionState } = {}) { + const events: PreviewServerEvent[] = []; + const closedTunnelIds: string[] = []; + let openedTunnels = 0; + let connectionState = options.connectionState ?? 'connected'; + const portForwards = new PortForwardService({ + openTunnel: async () => { + openedTunnels++; + return { + localPort: 6000 + openedTunnels, + close: async () => {}, + }; + }, + onTunnelClosed: (id) => closedTunnelIds.push(id), + }); + + const service = new PreviewServerService({ + portForwards, + emit: (event) => events.push(event), + getConnectionState: () => connectionState, + getSshProxy: async () => fakeProxy(), + closeDelayMs: 250, + }); + + return { + service, + events, + closedTunnelIds, + get openedTunnels() { + return openedTunnels; + }, + setConnectionState(next: ConnectionState) { + connectionState = next; + }, + }; +} + +function fakeProxy() { + return { + isConnected: true, + get client() { + return {} as SshClientProxy['client']; + }, + } satisfies Pick; +} + +describe('PreviewServerService', () => { + it('registers local detected URLs as workspace-owned direct previews', async () => { + const { service, events } = createService(); + + const first = await service.registerDetectedTarget({ + projectId: 'project-1', + workspaceId: 'workspace-1', + transport: 'local', + source: { kind: 'terminal-output', terminalId: 'terminal-1' }, + protocol: 'http:', + host: 'localhost', + port: 5173, + urlPath: '/app', + }); + const duplicate = await service.registerDetectedTarget({ + projectId: 'project-1', + workspaceId: 'workspace-1', + transport: 'local', + source: { kind: 'terminal-output', terminalId: 'terminal-2' }, + protocol: 'http:', + host: 'localhost', + port: 5173, + urlPath: '/ignored', + }); + + expect(duplicate.id).toBe(first.id); + expect( + service.listForWorkspace({ projectId: 'project-1', workspaceId: 'workspace-1' }) + ).toEqual([first]); + expect(previewServerUrl(first)).toBe('http://localhost:5173/app'); + expect(events).toEqual([{ type: 'upsert', server: first }]); + }); + + it('deduplicates SSH detected URLs by workspace, connection, and remote port', async () => { + const context = createService(); + + const first = await context.service.registerDetectedTarget({ + projectId: 'project-1', + workspaceId: 'workspace-1', + connectionId: 'connection-1', + transport: 'ssh', + proxy: fakeProxy(), + source: { kind: 'terminal-output', terminalId: 'terminal-1' }, + protocol: 'http:', + port: 5173, + urlPath: '/', + }); + const duplicate = await context.service.registerDetectedTarget({ + projectId: 'project-1', + workspaceId: 'workspace-1', + connectionId: 'connection-1', + transport: 'ssh', + proxy: fakeProxy(), + source: { kind: 'terminal-output', terminalId: 'terminal-1' }, + protocol: 'http:', + port: 5173, + urlPath: '/ignored', + }); + + expect(duplicate.id).toBe(first.id); + expect(context.openedTunnels).toBe(1); + expect(previewServerUrl(first)).toBe('http://127.0.0.1:6001/'); + expect( + context.service.listForWorkspace({ projectId: 'project-1', workspaceId: 'workspace-1' }) + ).toEqual([first]); + }); + + it('keeps SSH terminal previews through transport-loss PTY exits', async () => { + vi.useFakeTimers(); + try { + const context = createService({ connectionState: 'reconnecting' }); + const server = await context.service.registerDetectedTarget({ + projectId: 'project-1', + workspaceId: 'workspace-1', + connectionId: 'connection-1', + transport: 'ssh', + proxy: fakeProxy(), + source: { kind: 'terminal-output', terminalId: 'terminal-1' }, + protocol: 'http:', + port: 5173, + urlPath: '/', + }); + + await context.service.handleTerminalSourceClosed({ + projectId: 'project-1', + workspaceId: 'workspace-1', + terminalId: 'terminal-1', + transport: 'ssh', + connectionId: 'connection-1', + reason: 'pty-exit', + }); + await vi.advanceTimersByTimeAsync(250); + + expect( + context.service.listForWorkspace({ projectId: 'project-1', workspaceId: 'workspace-1' }) + ).toEqual([server]); + expect(context.closedTunnelIds).toEqual([]); + } finally { + vi.useRealTimers(); + } + }); + + it('stops SSH terminal previews after PTY exit when SSH remains connected', async () => { + vi.useFakeTimers(); + try { + const context = createService({ connectionState: 'connected' }); + const server = await context.service.registerDetectedTarget({ + projectId: 'project-1', + workspaceId: 'workspace-1', + connectionId: 'connection-1', + transport: 'ssh', + proxy: fakeProxy(), + source: { kind: 'terminal-output', terminalId: 'terminal-1' }, + protocol: 'http:', + port: 5173, + urlPath: '/', + }); + + await context.service.handleTerminalSourceClosed({ + projectId: 'project-1', + workspaceId: 'workspace-1', + terminalId: 'terminal-1', + transport: 'ssh', + connectionId: 'connection-1', + reason: 'pty-exit', + }); + await context.service.stop(server.id); + await vi.advanceTimersByTimeAsync(250); + + expect( + context.service.listForWorkspace({ projectId: 'project-1', workspaceId: 'workspace-1' }) + ).toEqual([]); + expect(context.events.filter((event) => event.type === 'remove')).toEqual([ + { type: 'remove', id: server.id }, + ]); + expect(context.closedTunnelIds).toEqual([ + 'preview:ssh:auto:project-1:workspace-1:connection-1:5173', + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('translates SSH connection events into forwarded preview status updates', async () => { + const context = createService(); + const server = await context.service.registerDetectedTarget({ + projectId: 'project-1', + workspaceId: 'workspace-1', + connectionId: 'connection-1', + transport: 'ssh', + proxy: fakeProxy(), + source: { kind: 'terminal-output', terminalId: 'terminal-1' }, + protocol: 'http:', + port: 5173, + urlPath: '/', + }); + + context.service.handleSshConnectionEvent({ + type: 'reconnecting', + connectionId: 'connection-1', + }); + context.service.handleSshConnectionEvent({ type: 'reconnected', connectionId: 'connection-1' }); + context.service.handleSshConnectionEvent({ + type: 'reconnect-failed', + connectionId: 'connection-1', + }); + + const statusEvents = context.events + .filter((event) => event.type === 'upsert' && event.server.id === server.id) + .map((event) => (event.type === 'upsert' ? event.server.status : null)); + + expect(statusEvents).toEqual([ + { kind: 'ready' }, + { kind: 'reconnecting' }, + { kind: 'ready' }, + { kind: 'failed', message: 'SSH connection failed to reconnect' }, + ]); + }); + + it('creates manual forwarded previews with generated identity and root path', async () => { + const context = createService(); + + const first = await context.service.forwardManual({ + projectId: 'project-1', + workspaceId: 'workspace-1', + connectionId: 'connection-1', + protocol: 'https:', + remotePort: 8443, + preferredLocalPort: 9443, + }); + const second = await context.service.forwardManual({ + projectId: 'project-1', + workspaceId: 'workspace-1', + connectionId: 'connection-1', + protocol: 'https:', + remotePort: 8443, + preferredLocalPort: 9444, + }); + + expect(first.id).not.toBe(second.id); + expect(first.source).toEqual({ kind: 'manual' }); + expect(first.urlPath).toBe('/'); + expect(first.kind).toBe('forwarded'); + expect(previewServerUrl(first)).toBe('https://127.0.0.1:6001/'); + expect(context.openedTunnels).toBe(2); + }); + + it('stops only previews owned by a released workspace', async () => { + const context = createService(); + const first = await context.service.registerDetectedTarget({ + projectId: 'project-1', + workspaceId: 'workspace-1', + connectionId: 'connection-1', + transport: 'ssh', + proxy: fakeProxy(), + source: { kind: 'terminal-output', terminalId: 'terminal-1' }, + protocol: 'http:', + port: 5173, + urlPath: '/', + }); + const second = await context.service.registerDetectedTarget({ + projectId: 'project-1', + workspaceId: 'workspace-2', + connectionId: 'connection-1', + transport: 'ssh', + proxy: fakeProxy(), + source: { kind: 'terminal-output', terminalId: 'terminal-2' }, + protocol: 'http:', + port: 5174, + urlPath: '/', + }); + + await context.service.stopForWorkspace('project-1', 'workspace-1'); + + expect( + context.service.listForWorkspace({ projectId: 'project-1', workspaceId: 'workspace-1' }) + ).toEqual([]); + expect( + context.service.listForWorkspace({ projectId: 'project-1', workspaceId: 'workspace-2' }) + ).toEqual([second]); + expect(context.events).toContainEqual({ type: 'remove', id: first.id }); + expect(context.closedTunnelIds).toEqual([ + 'preview:ssh:auto:project-1:workspace-1:connection-1:5173', + ]); + }); +}); diff --git a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts new file mode 100644 index 0000000000..729ded0da1 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts @@ -0,0 +1,348 @@ +import { randomUUID } from 'node:crypto'; +import type { + DirectPreviewServer, + DirectPreviewServerHost, + ManualPreviewServerRequest, + PreviewServer, + PreviewServerEvent, + PreviewServerProtocol, + PreviewServerSource, +} from '@shared/core/preview-servers/types'; +import type { ConnectionState } from '@shared/core/ssh/ssh'; +import { PortForwardService } from '../port-forwards/port-forward-service'; +import type { SshClientProxy } from '../ssh/lifecycle/ssh-client-proxy'; +import type { SshConnectionManagerEvent } from '../ssh/lifecycle/ssh-connection-manager'; +import type { DetectedPreviewUrl, PreviewSourceClosed } from './terminal-url-detector'; + +export type RegisterDetectedPreviewTarget = + | { + projectId: string; + workspaceId: string; + transport: 'local'; + source: PreviewServerSource; + protocol: PreviewServerProtocol; + host: DirectPreviewServerHost; + port: number; + urlPath: string; + } + | { + projectId: string; + workspaceId: string; + transport: 'ssh'; + connectionId: string; + proxy: Pick; + source: PreviewServerSource; + protocol: PreviewServerProtocol; + port: number; + urlPath: string; + }; + +export type TerminalSourceClosedInput = { + projectId: string; + workspaceId: string; + terminalId: string; + transport: 'local' | 'ssh'; + connectionId?: string; + reason: PreviewSourceClosed['reason']; + server?: DetectedPreviewUrl; +}; + +type PreviewMetadata = { + identity: string; + tunnelId?: string; +}; + +export class PreviewServerService { + private readonly servers = new Map(); + private readonly identities = new Map(); + private readonly metadata = new Map(); + private readonly portForwards: PortForwardService; + private readonly emit: (event: PreviewServerEvent) => void; + private readonly getConnectionState: (connectionId: string) => ConnectionState; + private readonly getSshProxy: ( + connectionId: string + ) => Promise>; + private readonly closeDelayMs: number; + + constructor({ + portForwards = new PortForwardService(), + emit, + getConnectionState, + getSshProxy, + closeDelayMs = 250, + }: { + portForwards?: PortForwardService; + emit: (event: PreviewServerEvent) => void; + getConnectionState: (connectionId: string) => ConnectionState; + getSshProxy?: (connectionId: string) => Promise>; + closeDelayMs?: number; + }) { + this.portForwards = portForwards; + this.emit = emit; + this.getConnectionState = getConnectionState; + this.getSshProxy = + getSshProxy ?? + (async () => { + throw new Error('SSH proxy resolver is not configured'); + }); + this.closeDelayMs = closeDelayMs; + } + + async registerDetectedTarget(target: RegisterDetectedPreviewTarget): Promise { + if (target.transport === 'local') { + return this.registerLocalTarget(target); + } + + const identity = sshAutoIdentity(target); + const existing = this.serverForIdentity(identity); + if (existing) return existing; + + const tunnelId = `preview:${identity}`; + const forward = await this.portForwards.open({ + id: tunnelId, + projectId: target.projectId, + workspaceId: target.workspaceId, + connectionId: target.connectionId, + proxy: target.proxy, + remotePort: target.port, + preferredLocalPort: target.port, + }); + const server: PreviewServer = { + id: identity, + kind: 'forwarded', + projectId: target.projectId, + workspaceId: target.workspaceId, + source: target.source, + protocol: target.protocol, + urlPath: target.urlPath, + status: { kind: 'ready' }, + connectionId: target.connectionId, + remotePort: target.port, + localPort: forward.localPort, + }; + this.addServer(identity, server, { identity, tunnelId }); + return server; + } + + listForWorkspace({ + projectId, + workspaceId, + }: { + projectId: string; + workspaceId: string; + }): PreviewServer[] { + return Array.from(this.servers.values()).filter( + (server) => server.projectId === projectId && server.workspaceId === workspaceId + ); + } + + async handleTerminalSourceClosed(input: TerminalSourceClosedInput): Promise { + if (input.transport === 'local') { + await this.stopForTerminal(input); + return; + } + + if (input.reason !== 'pty-exit' || !input.connectionId) return; + setTimeout(() => { + if (this.getConnectionState(input.connectionId!) === 'connected') { + void this.stopForTerminal(input); + } + }, this.closeDelayMs); + } + + async forwardManual(request: ManualPreviewServerRequest): Promise { + const id = `manual:${randomUUID()}`; + const tunnelId = `preview:${id}`; + const proxy = await this.getSshProxy(request.connectionId); + const forward = await this.portForwards.open({ + id: tunnelId, + projectId: request.projectId, + workspaceId: request.workspaceId, + connectionId: request.connectionId, + proxy, + remotePort: request.remotePort, + preferredLocalPort: request.preferredLocalPort ?? request.remotePort, + }); + const server: PreviewServer = { + id, + kind: 'forwarded', + projectId: request.projectId, + workspaceId: request.workspaceId, + source: { kind: 'manual' }, + protocol: request.protocol, + urlPath: '/', + status: { kind: 'ready' }, + connectionId: request.connectionId, + remotePort: request.remotePort, + localPort: forward.localPort, + }; + this.addServer(id, server, { identity: id, tunnelId }); + return server; + } + + handleSshConnectionEvent(event: Pick): void { + if ( + event.type !== 'disconnected' && + event.type !== 'reconnecting' && + event.type !== 'reconnected' && + event.type !== 'reconnect-failed' + ) { + return; + } + + for (const server of this.servers.values()) { + if (server.kind !== 'forwarded' || server.connectionId !== event.connectionId) continue; + + const next = + event.type === 'disconnected' || event.type === 'reconnecting' + ? { ...server, status: { kind: 'reconnecting' as const } } + : event.type === 'reconnected' + ? { ...server, status: { kind: 'ready' as const } } + : { + ...server, + status: { + kind: 'failed' as const, + message: 'SSH connection failed to reconnect', + }, + }; + + this.servers.set(next.id, next); + this.emit({ type: 'upsert', server: next }); + } + } + + async stop(id: string): Promise { + const server = this.servers.get(id); + if (!server) return; + this.servers.delete(id); + const metadata = this.metadata.get(id); + this.metadata.delete(id); + if (metadata) this.identities.delete(metadata.identity); + if (metadata?.tunnelId) await this.portForwards.stop(metadata.tunnelId); + this.emit({ type: 'remove', id }); + } + + async restart(id: string): Promise { + const server = this.servers.get(id); + const metadata = this.metadata.get(id); + if (!server || server.kind !== 'forwarded' || !metadata?.tunnelId) return server; + + await this.portForwards.stop(metadata.tunnelId); + const proxy = await this.getSshProxy(server.connectionId); + const forward = await this.portForwards.open({ + id: metadata.tunnelId, + projectId: server.projectId, + workspaceId: server.workspaceId, + connectionId: server.connectionId, + proxy, + remotePort: server.remotePort, + preferredLocalPort: server.localPort, + }); + const next: PreviewServer = { + ...server, + localPort: forward.localPort, + status: { kind: 'ready' }, + }; + this.servers.set(id, next); + this.emit({ type: 'upsert', server: next }); + return next; + } + + async stopForWorkspace(projectId: string, workspaceId: string): Promise { + const ids = Array.from(this.servers.values()) + .filter((server) => server.projectId === projectId && server.workspaceId === workspaceId) + .map((server) => server.id); + await Promise.all(ids.map((id) => this.stop(id))); + } + + async stopForProject(projectId: string): Promise { + const ids = Array.from(this.servers.values()) + .filter((server) => server.projectId === projectId) + .map((server) => server.id); + await Promise.all(ids.map((id) => this.stop(id))); + } + + private registerLocalTarget( + target: Extract + ): DirectPreviewServer { + const identity = localAutoIdentity(target); + const existing = this.serverForIdentity(identity); + if (existing) return existing as DirectPreviewServer; + + const server: DirectPreviewServer = { + id: identity, + kind: 'direct', + projectId: target.projectId, + workspaceId: target.workspaceId, + source: target.source, + protocol: target.protocol, + urlPath: target.urlPath, + status: { kind: 'ready' }, + host: target.host, + port: target.port, + }; + this.addServer(identity, server, { identity }); + return server; + } + + private async stopForTerminal(input: { + projectId: string; + workspaceId: string; + terminalId: string; + server?: DetectedPreviewUrl; + }): Promise { + const ids = Array.from(this.servers.values()) + .filter( + (server) => + server.projectId === input.projectId && + server.workspaceId === input.workspaceId && + server.source.kind === 'terminal-output' && + server.source.terminalId === input.terminalId && + matchesDetectedServer(server, input.server) + ) + .map((server) => server.id); + await Promise.all(ids.map((id) => this.stop(id))); + } + + private addServer(identity: string, server: PreviewServer, metadata: PreviewMetadata): void { + this.identities.set(identity, server.id); + this.servers.set(server.id, server); + this.metadata.set(server.id, metadata); + this.emit({ type: 'upsert', server }); + } + + private serverForIdentity(identity: string): PreviewServer | undefined { + const id = this.identities.get(identity); + return id ? this.servers.get(id) : undefined; + } +} + +function localAutoIdentity(target: { + projectId: string; + workspaceId: string; + host: DirectPreviewServerHost; + port: number; +}): string { + return `local:auto:${target.projectId}:${target.workspaceId}:${target.host}:${target.port}`; +} + +function sshAutoIdentity(target: { + projectId: string; + workspaceId: string; + connectionId: string; + port: number; +}): string { + return `ssh:auto:${target.projectId}:${target.workspaceId}:${target.connectionId}:${target.port}`; +} + +function matchesDetectedServer( + server: PreviewServer, + detected: DetectedPreviewUrl | undefined +): boolean { + if (!detected) return true; + if (server.protocol !== detected.protocol) return false; + if (server.kind === 'direct') { + return server.host === detected.host && server.port === detected.port; + } + return server.remotePort === detected.port; +} diff --git a/apps/emdash-desktop/src/main/core/preview-servers/terminal-url-detector.test.ts b/apps/emdash-desktop/src/main/core/preview-servers/terminal-url-detector.test.ts new file mode 100644 index 0000000000..5ce6cb9a25 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/preview-servers/terminal-url-detector.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Pty, PtyExitInfo } from '@main/core/pty/pty'; +import { + wireTerminalUrlDetector, + type DetectedPreviewUrl, + type PreviewSourceClosed, +} from './terminal-url-detector'; + +function fakePty(): Pty & { + emitData(data: string): void; + emitExit(info?: PtyExitInfo): void; +} { + const dataHandlers: Array<(data: string) => void> = []; + const exitHandlers: Array<(info: PtyExitInfo) => void> = []; + + return { + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: (handler) => dataHandlers.push(handler), + onExit: (handler) => exitHandlers.push(handler), + emitData(data) { + for (const handler of dataHandlers) handler(data); + }, + emitExit(info = { exitCode: 0 }) { + for (const handler of exitHandlers) handler(info); + }, + }; +} + +describe('wireTerminalUrlDetector', () => { + it('reports each localhost URL once with normalized host and path details', () => { + const pty = fakePty(); + const detected: DetectedPreviewUrl[] = []; + const closed: PreviewSourceClosed[] = []; + + wireTerminalUrlDetector({ + pty, + probeLocalPorts: false, + onDetected: (server) => { + detected.push(server); + }, + onSourceClosed: (event) => { + closed.push(event); + }, + }); + + pty.emitData( + '\x1b[32mready\x1b[0m http://localhost:3000/app?tab=1#top and http://0.0.0.0:5173/' + ); + pty.emitData('duplicate http://localhost:3000/ignored'); + pty.emitData('later https://127.0.0.1:8443/admin'); + pty.emitExit(); + + expect(detected).toEqual([ + { + protocol: 'http:', + host: 'localhost', + port: 3000, + urlPath: '/app?tab=1#top', + }, + { + protocol: 'http:', + host: '127.0.0.1', + port: 5173, + urlPath: '/', + }, + { + protocol: 'https:', + host: '127.0.0.1', + port: 8443, + urlPath: '/admin', + }, + ]); + expect(closed).toEqual([{ reason: 'pty-exit' }]); + }); +}); diff --git a/apps/emdash-desktop/src/main/core/preview-servers/terminal-url-detector.ts b/apps/emdash-desktop/src/main/core/preview-servers/terminal-url-detector.ts new file mode 100644 index 0000000000..087ca5c969 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/preview-servers/terminal-url-detector.ts @@ -0,0 +1,159 @@ +import net from 'node:net'; +import type { Pty } from '@main/core/pty/pty'; +import type { + DirectPreviewServerHost, + PreviewServerProtocol, +} from '@shared/core/preview-servers/types'; + +const PROBE_INTERVAL_MS = 1000; +const PROBE_TIMEOUT_MS = 500; +const PROBE_FAILURES_TO_CLOSE = 2; +const URL_PATTERN = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d{2,5})?(?:\/\S*)?/g; +const MAX_BUFFER = 4096; + +export type DetectedPreviewUrl = { + protocol: PreviewServerProtocol; + host: DirectPreviewServerHost; + port: number; + urlPath: string; +}; + +export type PreviewSourceClosed = + | { reason: 'pty-exit' } + | { reason: 'local-probe-failed'; server: DetectedPreviewUrl }; + +export function wireTerminalUrlDetector({ + pty, + probeLocalPorts = true, + onDetected, + onSourceClosed, +}: { + pty: Pty; + probeLocalPorts?: boolean; + onDetected: (server: DetectedPreviewUrl) => void | Promise; + onSourceClosed?: (event: PreviewSourceClosed) => void | Promise; +}): void { + let buffer = ''; + const detected = new Map(); + const stopProbes = new Map void>(); + + const stopAllProbes = () => { + for (const stop of stopProbes.values()) stop(); + stopProbes.clear(); + }; + + pty.onExit(() => { + buffer = ''; + stopAllProbes(); + void onSourceClosed?.({ reason: 'pty-exit' }); + }); + + pty.onData((chunk) => { + buffer += chunk; + if (buffer.length > MAX_BUFFER) { + buffer = buffer.slice(-MAX_BUFFER); + } + + const clean = stripAnsi(buffer); + for (const match of clean.matchAll(URL_PATTERN)) { + const parsed = parsePreviewUrl(match[0]); + if (!parsed) continue; + + const key = detectedKey(parsed); + if (detected.has(key)) continue; + + detected.set(key, parsed); + void onDetected(parsed); + + if (probeLocalPorts) { + stopProbes.set( + key, + startProbe(parsed, () => { + stopProbes.delete(key); + detected.delete(key); + void onSourceClosed?.({ reason: 'local-probe-failed', server: parsed }); + }) + ); + } + } + }); +} + +function stripAnsi(value: string): string { + return value + .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '') + .replace(/\r/g, '') + .replace(/\x1b\][^\x07]*\x07/g, '') + .replace(/\x1b\][^\x1b]*\x1b\\/g, ''); +} + +function parsePreviewUrl(raw: string): DetectedPreviewUrl | null { + try { + const url = new URL(raw); + const protocol = url.protocol === 'https:' ? 'https:' : 'http:'; + const host = normalizeHost(url.hostname); + const port = Number(url.port) || (protocol === 'https:' ? 443 : 80); + const urlPath = `${url.pathname || '/'}${url.search}${url.hash}`; + return { protocol, host, port, urlPath }; + } catch { + return null; + } +} + +function normalizeHost(host: string): DirectPreviewServerHost { + return host === 'localhost' ? 'localhost' : '127.0.0.1'; +} + +function detectedKey(server: DetectedPreviewUrl): string { + return `${server.protocol}:${server.port}`; +} + +function isPortOpen(host: string, port: number): Promise { + return new Promise((resolve) => { + const socket = net.createConnection({ host, port }); + socket.setTimeout(PROBE_TIMEOUT_MS); + socket.on('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.on('error', () => resolve(false)); + socket.on('timeout', () => { + socket.destroy(); + resolve(false); + }); + }); +} + +function startProbe(server: DetectedPreviewUrl, onClosed: () => void): () => void { + let stopped = false; + let consecutiveFailures = 0; + let timer: ReturnType | undefined; + + const tick = async () => { + if (stopped) return; + const open = await isPortOpen(server.host, server.port); + if (stopped) return; + if (open) { + consecutiveFailures = 0; + } else { + consecutiveFailures++; + if (consecutiveFailures >= PROBE_FAILURES_TO_CLOSE) { + stopped = true; + onClosed(); + return; + } + } + timer = setTimeout(() => { + void tick(); + }, PROBE_INTERVAL_MS); + }; + + timer = setTimeout(() => { + void tick(); + }, 0); + + return () => { + stopped = true; + if (timer) clearTimeout(timer); + }; +} diff --git a/apps/emdash-desktop/src/main/core/projects/project-provider.ts b/apps/emdash-desktop/src/main/core/projects/project-provider.ts index 2900eee2b2..eb4663d396 100644 --- a/apps/emdash-desktop/src/main/core/projects/project-provider.ts +++ b/apps/emdash-desktop/src/main/core/projects/project-provider.ts @@ -2,6 +2,7 @@ import type { IExecutionContext } from '@main/core/execution-context/types'; import type { FileSystemProvider } from '@main/core/fs/types'; import type { GitFetchService } from '@main/core/git/git-fetch-service'; import type { GitRepositoryService } from '@main/core/git/repository-service'; +import { previewServerService } from '@main/core/preview-servers/preview-server-service-instance'; import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; import type { IDisposable } from '@main/lib/lifecycle'; import type { Branch, FetchError } from '@shared/core/git/git'; @@ -117,5 +118,6 @@ export class ProjectProvider implements IDisposable { const mode = projectSettings.tmux ? 'detach' : 'terminate'; await taskSessionManager.teardownAllForProject(this.projectId, mode); await workspaceRegistry.releaseAllForProject(this.projectId, mode); + await previewServerService.stopForProject(this.projectId); } } diff --git a/apps/emdash-desktop/src/main/core/tasks/task-builder.ts b/apps/emdash-desktop/src/main/core/tasks/task-builder.ts index bf8161b491..ca449ee923 100644 --- a/apps/emdash-desktop/src/main/core/tasks/task-builder.ts +++ b/apps/emdash-desktop/src/main/core/tasks/task-builder.ts @@ -62,6 +62,7 @@ export async function buildTaskFromWorkspace( await buildTaskProviders(type, { projectId, taskId: task.id, + workspaceId: workspace.id, taskPath: workspace.path, tmuxEnabled, shellSetup, diff --git a/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.test.ts b/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.test.ts index 11690c82c4..e22ef62819 100644 --- a/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.test.ts +++ b/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.test.ts @@ -10,6 +10,15 @@ const ptyMock = vi.hoisted(() => ({ exitHandlers: [] as Array<(info: PtyExitInfo) => void>, })); +const previewServerServiceMock = vi.hoisted(() => ({ + registerDetectedTarget: vi.fn(), + handleTerminalSourceClosed: vi.fn(), +})); + +const terminalUrlDetectorMock = vi.hoisted(() => ({ + wireTerminalUrlDetector: vi.fn(), +})); + vi.mock('@main/core/pty/local-pty', () => ({ spawnLocalPty: vi.fn(() => ({ write: vi.fn(), @@ -29,8 +38,12 @@ vi.mock('@main/core/pty/pty-session-registry', () => ({ }, })); -vi.mock('../dev-server-watcher', () => ({ - wireTerminalDevServerWatcher: vi.fn(), +vi.mock('@main/core/preview-servers/preview-server-service-instance', () => ({ + previewServerService: previewServerServiceMock, +})); + +vi.mock('@main/core/preview-servers/terminal-url-detector', () => ({ + wireTerminalUrlDetector: terminalUrlDetectorMock.wireTerminalUrlDetector, })); vi.mock('@main/core/pty/terminal-color-scheme', () => ({ @@ -55,6 +68,9 @@ const ctx = { describe('LocalTerminalProvider', () => { beforeEach(() => { ptyMock.exitHandlers.length = 0; + terminalUrlDetectorMock.wireTerminalUrlDetector.mockClear(); + previewServerServiceMock.registerDetectedTarget.mockClear(); + previewServerServiceMock.handleTerminalSourceClosed.mockClear(); vi.mocked(ptySessionRegistry.register).mockClear(); }); @@ -100,4 +116,37 @@ describe('LocalTerminalProvider', () => { (provider as unknown as { shellProfiles: Map }).shellProfiles.has(sessionId) ).toBe(false); }); + + it('registers detected preview URLs against the workspace scope', async () => { + const provider = new LocalTerminalProvider({ + projectId: terminal.projectId, + workspaceId: 'workspace-1', + scopeId: terminal.taskId, + taskPath: '/repo', + ctx, + }); + + await provider.spawnTerminal(terminal); + + const detectorOptions = terminalUrlDetectorMock.wireTerminalUrlDetector.mock.calls[0]?.[0]; + expect(detectorOptions).toMatchObject({ probeLocalPorts: true }); + + detectorOptions.onDetected({ + protocol: 'http:', + host: 'localhost', + port: 5173, + urlPath: '/', + }); + + expect(previewServerServiceMock.registerDetectedTarget).toHaveBeenCalledWith({ + projectId: 'project-1', + workspaceId: 'workspace-1', + transport: 'local', + source: { kind: 'terminal-output', terminalId: 'terminal-1' }, + protocol: 'http:', + host: 'localhost', + port: 5173, + urlPath: '/', + }); + }); }); diff --git a/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.ts b/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.ts index 611e968fac..04d91fb7dc 100644 --- a/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.ts +++ b/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.ts @@ -1,4 +1,6 @@ import type { IExecutionContext } from '@main/core/execution-context/types'; +import { previewServerService } from '@main/core/preview-servers/preview-server-service-instance'; +import { wireTerminalUrlDetector } from '@main/core/preview-servers/terminal-url-detector'; import { isUnexpectedPtyExit } from '@main/core/pty/exit-classification'; import { spawnLocalPty } from '@main/core/pty/local-pty'; import type { Pty } from '@main/core/pty/pty'; @@ -18,7 +20,6 @@ import { log } from '@main/lib/logger'; import { makePtySessionId } from '@shared/core/pty/ptySessionId'; import type { TerminalShellId } from '@shared/core/terminals/terminal-settings'; import type { Terminal } from '@shared/core/terminals/terminals'; -import { wireTerminalDevServerWatcher } from '../dev-server-watcher'; import { type LifecycleScriptSpawnRequest, type TerminalProvider, @@ -43,6 +44,7 @@ export class LocalTerminalProvider implements TerminalProvider { private shellProfiles = new Map(); private respawnCounts = new Map(); private readonly projectId: string; + private readonly workspaceId: string; private readonly scopeId: string; private readonly taskPath: string; private readonly tmux: boolean; @@ -52,6 +54,7 @@ export class LocalTerminalProvider implements TerminalProvider { constructor({ projectId, + workspaceId, scopeId, taskPath, tmux = false, @@ -60,6 +63,7 @@ export class LocalTerminalProvider implements TerminalProvider { taskEnvVars = {}, }: { projectId: string; + workspaceId?: string; scopeId: string; taskPath: string; tmux?: boolean; @@ -68,6 +72,7 @@ export class LocalTerminalProvider implements TerminalProvider { taskEnvVars?: Record; }) { this.projectId = projectId; + this.workspaceId = workspaceId ?? scopeId; this.scopeId = scopeId; this.taskPath = taskPath; this.tmux = tmux; @@ -178,7 +183,31 @@ export class LocalTerminalProvider implements TerminalProvider { }); if (policy.watchDevServer) { - wireTerminalDevServerWatcher({ pty, scopeId: this.scopeId, terminalId: terminal.id }); + wireTerminalUrlDetector({ + pty, + probeLocalPorts: true, + onDetected: (server) => { + void previewServerService.registerDetectedTarget({ + projectId: this.projectId, + workspaceId: this.workspaceId, + transport: 'local', + source: { kind: 'terminal-output', terminalId: terminal.id }, + protocol: server.protocol, + host: server.host, + port: server.port, + urlPath: server.urlPath, + }); + }, + onSourceClosed: (event) => + previewServerService.handleTerminalSourceClosed({ + projectId: this.projectId, + workspaceId: this.workspaceId, + terminalId: terminal.id, + transport: 'local', + reason: event.reason, + server: 'server' in event ? event.server : undefined, + }), + }); } pty.onExit((info) => { diff --git a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts index 0e2ec49f04..c811f26aa5 100644 --- a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts +++ b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts @@ -11,6 +11,15 @@ const ptyMock = vi.hoisted(() => ({ exitHandlers: [] as Array<(info: PtyExitInfo) => void>, })); +const previewServerServiceMock = vi.hoisted(() => ({ + registerDetectedTarget: vi.fn(), + handleTerminalSourceClosed: vi.fn(), +})); + +const terminalUrlDetectorMock = vi.hoisted(() => ({ + wireTerminalUrlDetector: vi.fn(), +})); + vi.mock('@main/core/pty/ssh2-pty', () => ({ openSsh2Pty: vi.fn(async () => ({ success: true, @@ -40,8 +49,12 @@ vi.mock('@main/core/ssh/lifecycle/production-ssh-connection-manager', () => ({ }, })); -vi.mock('../dev-server-watcher', () => ({ - wireTerminalDevServerWatcher: vi.fn(), +vi.mock('@main/core/preview-servers/preview-server-service-instance', () => ({ + previewServerService: previewServerServiceMock, +})); + +vi.mock('@main/core/preview-servers/terminal-url-detector', () => ({ + wireTerminalUrlDetector: terminalUrlDetectorMock.wireTerminalUrlDetector, })); vi.mock('@main/core/pty/terminal-color-scheme', () => ({ @@ -73,6 +86,9 @@ const proxy = { describe('SshTerminalProvider', () => { beforeEach(() => { ptyMock.exitHandlers.length = 0; + terminalUrlDetectorMock.wireTerminalUrlDetector.mockClear(); + previewServerServiceMock.registerDetectedTarget.mockClear(); + previewServerServiceMock.handleTerminalSourceClosed.mockClear(); vi.mocked(ptySessionRegistry.register).mockClear(); proxy.getRemoteShellProfile = vi.fn(async () => ({ shell: '/bin/bash', @@ -126,4 +142,40 @@ describe('SshTerminalProvider', () => { (provider as unknown as { shellProfiles: Map }).shellProfiles.has(sessionId) ).toBe(false); }); + + it('registers detected preview URLs against the SSH workspace and connection', async () => { + const provider = new SshTerminalProvider({ + projectId: terminal.projectId, + workspaceId: 'workspace-1', + scopeId: terminal.taskId, + taskPath: '/repo', + ctx, + proxy, + connectionId: 'ssh-1', + }); + + await provider.spawnTerminal(terminal); + + const detectorOptions = terminalUrlDetectorMock.wireTerminalUrlDetector.mock.calls[0]?.[0]; + expect(detectorOptions).toMatchObject({ probeLocalPorts: false }); + + detectorOptions.onDetected({ + protocol: 'http:', + host: '127.0.0.1', + port: 5173, + urlPath: '/', + }); + + expect(previewServerServiceMock.registerDetectedTarget).toHaveBeenCalledWith({ + projectId: 'project-1', + workspaceId: 'workspace-1', + connectionId: 'ssh-1', + transport: 'ssh', + proxy, + source: { kind: 'terminal-output', terminalId: 'terminal-1' }, + protocol: 'http:', + port: 5173, + urlPath: '/', + }); + }); }); diff --git a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts index bf2c11759c..2937ea8a57 100644 --- a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts +++ b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts @@ -1,4 +1,6 @@ import type { IExecutionContext } from '@main/core/execution-context/types'; +import { previewServerService } from '@main/core/preview-servers/preview-server-service-instance'; +import { wireTerminalUrlDetector } from '@main/core/preview-servers/terminal-url-detector'; import { isUnexpectedPtyExit } from '@main/core/pty/exit-classification'; import type { Pty } from '@main/core/pty/pty'; import { ptySessionRegistry, type PtySessionMetadata } from '@main/core/pty/pty-session-registry'; @@ -21,7 +23,6 @@ import { makePtySessionId } from '@shared/core/pty/ptySessionId'; import type { GeneralSessionConfig } from '@shared/core/terminals/general-session'; import type { TerminalShellId } from '@shared/core/terminals/terminal-settings'; import type { Terminal } from '@shared/core/terminals/terminals'; -import { wireTerminalDevServerWatcher } from '../dev-server-watcher'; const DEFAULT_COLS = 80; const DEFAULT_ROWS = 24; @@ -43,6 +44,7 @@ export class SshTerminalProvider implements TerminalProvider { private respawnCounts = new Map(); private terminals = new Map(); private readonly projectId: string; + private readonly workspaceId: string; private readonly scopeId: string; private readonly taskPath: string; private readonly taskEnvVars: Record; @@ -55,6 +57,7 @@ export class SshTerminalProvider implements TerminalProvider { constructor({ projectId, + workspaceId, scopeId, taskPath, taskEnvVars = {}, @@ -65,6 +68,7 @@ export class SshTerminalProvider implements TerminalProvider { connectionId, }: { projectId: string; + workspaceId?: string; scopeId: string; taskPath: string; taskEnvVars?: Record; @@ -75,6 +79,7 @@ export class SshTerminalProvider implements TerminalProvider { connectionId: string; }) { this.projectId = projectId; + this.workspaceId = workspaceId ?? scopeId; this.scopeId = scopeId; this.taskPath = taskPath; this.taskEnvVars = taskEnvVars; @@ -196,11 +201,32 @@ export class SshTerminalProvider implements TerminalProvider { const pty = result.data; if (policy.watchDevServer) { - wireTerminalDevServerWatcher({ + wireTerminalUrlDetector({ pty, - scopeId: this.scopeId, - terminalId: terminal.id, - probe: false, + probeLocalPorts: false, + onDetected: (server) => { + void previewServerService.registerDetectedTarget({ + projectId: this.projectId, + workspaceId: this.workspaceId, + connectionId: this.connectionId, + transport: 'ssh', + proxy: this.proxy, + source: { kind: 'terminal-output', terminalId: terminal.id }, + protocol: server.protocol, + port: server.port, + urlPath: server.urlPath, + }); + }, + onSourceClosed: (event) => + previewServerService.handleTerminalSourceClosed({ + projectId: this.projectId, + workspaceId: this.workspaceId, + terminalId: terminal.id, + transport: 'ssh', + connectionId: this.connectionId, + reason: event.reason, + server: 'server' in event ? event.server : undefined, + }), }); } diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts index 4fe181ea9f..d2c92592e8 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts @@ -10,6 +10,7 @@ import { GitFetchService } from '@main/core/git/git-fetch-service'; import { GitService } from '@main/core/git/impl/git-service'; import { RemoteStatusFingerprintPoller } from '@main/core/git/remote-status-fingerprint-poller'; import { GitRepositoryService } from '@main/core/git/repository-service'; +import { previewServerService } from '@main/core/preview-servers/preview-server-service-instance'; import { workspaceFileIndexService } from '@main/core/search/workspace-file-index-service'; import { appSettingsService } from '@main/core/settings/settings-service'; import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; @@ -99,6 +100,7 @@ export function createWorkspaceFactory( type.kind === 'ssh' ? new SshTerminalProvider({ projectId: context.projectId, + workspaceId, scopeId: workspaceId, taskPath: workDir, tmux: tmuxEnabled, @@ -110,6 +112,7 @@ export function createWorkspaceFactory( }) : new LocalTerminalProvider({ projectId: context.projectId, + workspaceId, scopeId: workspaceId, taskPath: workDir, tmux: tmuxEnabled, @@ -229,6 +232,7 @@ export function createWorkspaceFactory( onCreate: context.extraHooks?.onCreate, onDestroy: async (ws) => { + await previewServerService.stopForWorkspace(context.projectId, workspaceId); statusPoller?.stop(); if (ownsFetchService) { fetchService.stop(); @@ -265,6 +269,7 @@ export function createWorkspaceFactory( }, onDetach: async (ws) => { + await previewServerService.stopForWorkspace(context.projectId, workspaceId); statusPoller?.stop(); await context.extraHooks?.onDetach?.(ws); }, @@ -275,6 +280,7 @@ export function createWorkspaceFactory( type TaskProviderOpts = { projectId: string; taskId: string; + workspaceId: string; taskPath: string; tmuxEnabled: boolean; shellSetup?: string; @@ -320,6 +326,7 @@ export async function buildTaskProviders( }), terminals: new SshTerminalProvider({ projectId: opts.projectId, + workspaceId: opts.workspaceId, scopeId: opts.taskId, taskPath: opts.taskPath, tmux: opts.tmuxEnabled, @@ -347,6 +354,7 @@ export async function buildTaskProviders( }), terminals: new LocalTerminalProvider({ projectId: opts.projectId, + workspaceId: opts.workspaceId, scopeId: opts.taskId, taskPath: opts.taskPath, tmux: opts.tmuxEnabled, diff --git a/apps/emdash-desktop/src/main/rpc.ts b/apps/emdash-desktop/src/main/rpc.ts index a499e3bc27..04c16bf34a 100644 --- a/apps/emdash-desktop/src/main/rpc.ts +++ b/apps/emdash-desktop/src/main/rpc.ts @@ -20,6 +20,7 @@ import { mcpController } from './core/mcp/controller'; import { mondayController } from './core/monday/controller'; import { plainController } from './core/plain/controller'; import { planeController } from './core/plane/controller'; +import { previewServersController } from './core/preview-servers/controller'; import { projectController } from './core/projects/controller'; import { promptLibraryController } from './core/prompt-library/controller'; import { ptyController } from './core/pty/controller'; @@ -69,6 +70,7 @@ export const rpcRouter = createRPCRouter({ skills: skillsController, ssh: sshController, projects: projectController, + previewServers: previewServersController, tasks: taskController, conversations: conversationController, terminals: terminalsController, diff --git a/apps/emdash-desktop/src/shared/core/preview-servers/events.ts b/apps/emdash-desktop/src/shared/core/preview-servers/events.ts new file mode 100644 index 0000000000..bbe028da31 --- /dev/null +++ b/apps/emdash-desktop/src/shared/core/preview-servers/events.ts @@ -0,0 +1,4 @@ +import { defineEvent } from '@shared/lib/ipc/events'; +import type { PreviewServerEvent } from './types'; + +export const previewServerEventChannel = defineEvent('preview-server:event'); diff --git a/apps/emdash-desktop/src/shared/core/preview-servers/types.ts b/apps/emdash-desktop/src/shared/core/preview-servers/types.ts new file mode 100644 index 0000000000..e8d0c477b7 --- /dev/null +++ b/apps/emdash-desktop/src/shared/core/preview-servers/types.ts @@ -0,0 +1,61 @@ +export type PreviewServerSource = + | { + kind: 'terminal-output'; + terminalId: string; + } + | { kind: 'manual' }; + +export type PreviewServerStatus = + | { kind: 'starting' } + | { kind: 'ready' } + | { kind: 'reconnecting' } + | { kind: 'failed'; message: string }; + +export type PreviewServerProtocol = 'http:' | 'https:'; +export type DirectPreviewServerHost = 'localhost' | '127.0.0.1'; + +export type PreviewServerBase = { + id: string; + projectId: string; + workspaceId: string; + source: PreviewServerSource; + protocol: PreviewServerProtocol; + urlPath: string; + status: PreviewServerStatus; +}; + +export type DirectPreviewServer = PreviewServerBase & { + kind: 'direct'; + host: DirectPreviewServerHost; + port: number; +}; + +export type ForwardedPreviewServer = PreviewServerBase & { + kind: 'forwarded'; + connectionId: string; + remotePort: number; + localPort: number; +}; + +export type PreviewServer = DirectPreviewServer | ForwardedPreviewServer; + +export function previewServerUrl(server: PreviewServer): string { + if (server.kind === 'direct') { + return `${server.protocol}//${server.host}:${server.port}${server.urlPath}`; + } + + return `${server.protocol}//127.0.0.1:${server.localPort}${server.urlPath}`; +} + +export type ManualPreviewServerRequest = { + projectId: string; + workspaceId: string; + connectionId: string; + protocol: PreviewServerProtocol; + remotePort: number; + preferredLocalPort?: number; +}; + +export type PreviewServerEvent = + | { type: 'upsert'; server: PreviewServer } + | { type: 'remove'; id: string }; From e26957a1aedc39c3aff551be518df8a8eefc8c1d Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:39:20 -0700 Subject: [PATCH 2/7] feat(preview): surface preview server controls --- .../features/browser/browser-pane.tsx | 6 +- .../tasks/components/dev-server-pills.tsx | 68 ------- .../tasks/components/preview-server-pills.tsx | 1 + .../preview-servers/manual-forward-button.tsx | 31 ++++ .../preview-servers/manual-forward-dialog.tsx | 121 +++++++++++++ .../preview-servers/preview-server-format.ts | 43 +++++ .../preview-servers/preview-server-pill.tsx | 117 ++++++++++++ .../preview-servers/preview-server-pills.tsx | 22 +++ .../features/tasks/stores/dev-server-store.ts | 50 ------ .../tasks/stores/preview-server-store.test.ts | 170 ++++++++++++++++++ .../tasks/stores/preview-server-store.ts | 129 +++++++++++++ .../tasks/stores/workspace-view-model.tsx | 15 +- .../renderer/features/tasks/task-titlebar.tsx | 4 +- .../features/tasks/task-view-context.tsx | 12 +- 14 files changed, 655 insertions(+), 134 deletions(-) delete mode 100644 apps/emdash-desktop/src/renderer/features/tasks/components/dev-server-pills.tsx create mode 100644 apps/emdash-desktop/src/renderer/features/tasks/components/preview-server-pills.tsx create mode 100644 apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/manual-forward-button.tsx create mode 100644 apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/manual-forward-dialog.tsx create mode 100644 apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-format.ts create mode 100644 apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pill.tsx create mode 100644 apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pills.tsx delete mode 100644 apps/emdash-desktop/src/renderer/features/tasks/stores/dev-server-store.ts create mode 100644 apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.test.ts create mode 100644 apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.ts diff --git a/apps/emdash-desktop/src/renderer/features/browser/browser-pane.tsx b/apps/emdash-desktop/src/renderer/features/browser/browser-pane.tsx index 280eeeeb57..b60741e8e0 100644 --- a/apps/emdash-desktop/src/renderer/features/browser/browser-pane.tsx +++ b/apps/emdash-desktop/src/renderer/features/browser/browser-pane.tsx @@ -1,6 +1,6 @@ import { observer } from 'mobx-react-lite'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useDevServers } from '@renderer/features/tasks/task-view-context'; +import { usePreviewServers } from '@renderer/features/tasks/task-view-context'; import { rpc } from '@renderer/lib/ipc'; import { normalizeBrowserUrl, normalizeBrowserZoomFactor } from '@shared/browser'; import { browserControlsRegistry } from './browser-controls-registry'; @@ -19,7 +19,7 @@ const WEBVIEW_ALLOW_POPUPS_ATTRIBUTE = 'true' as unknown as boolean; export const BrowserPane = observer(function BrowserPane({ browserId }: { browserId: string }) { const session = browserSessionStore.getSession(browserId); - const devServers = useDevServers(); + const previewServers = usePreviewServers(); const webviewRef = useRef(null); const focusUrlRef = useRef<() => void>(() => {}); const pendingUrlRef = useRef(null); @@ -240,7 +240,7 @@ export const BrowserPane = observer(function BrowserPane({ browserId }: { browse />
{showStartPage ? ( - + ) : webviewProps && isRegistered ? ( - {urls.map((url) => ( - - - } - > - - {formatUrl(url)} - - - - void rpc.app.openExternal(url)}> - Open in System Browser - - { - taskView.tabGroupManager.openBrowser(url); - taskView.setFocusedRegion('main'); - }} - > - Open in Emdash Browser - - - - ))} - - ); -}); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-server-pills.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-server-pills.tsx new file mode 100644 index 0000000000..a4a9e2ac24 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-server-pills.tsx @@ -0,0 +1 @@ +export { PreviewServerPills } from './preview-servers/preview-server-pills'; diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/manual-forward-button.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/manual-forward-button.tsx new file mode 100644 index 0000000000..8f38027e9f --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/manual-forward-button.tsx @@ -0,0 +1,31 @@ +import { Globe, Plus } from 'lucide-react'; +import { useState } from 'react'; +import { Dialog } from '@renderer/lib/ui/dialog'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/lib/ui/tooltip'; +import { ManualForwardDialog } from './manual-forward-dialog'; + +export function ManualForwardButton() { + const [open, setOpen] = useState(false); + + return ( + + + setOpen(true)} + /> + } + > + + + + Forward remote port + + setOpen(false)} /> + + ); +} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/manual-forward-dialog.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/manual-forward-dialog.tsx new file mode 100644 index 0000000000..92ea9b4193 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/manual-forward-dialog.tsx @@ -0,0 +1,121 @@ +import { useState, type FormEvent } from 'react'; +import { usePreviewServers } from '@renderer/features/tasks/task-view-context'; +import { Button } from '@renderer/lib/ui/button'; +import { + DialogContent, + DialogContentArea, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@renderer/lib/ui/dialog'; +import { Input } from '@renderer/lib/ui/input'; +import { Label } from '@renderer/lib/ui/label'; +import type { PreviewServerProtocol } from '@shared/core/preview-servers/types'; + +function parsePort(value: string): number | undefined { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const port = Number(trimmed); + if (!Number.isInteger(port) || port < 1 || port > 65535) return undefined; + return port; +} + +export function ManualForwardDialog({ onClose }: { onClose: () => void }) { + const previews = usePreviewServers(); + const [protocol, setProtocol] = useState('http:'); + const [remotePort, setRemotePort] = useState(''); + const [localPort, setLocalPort] = useState(''); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + + const parsedRemotePort = parsePort(remotePort); + const parsedLocalPort = parsePort(localPort); + if (!parsedRemotePort) { + setError('Enter a remote port between 1 and 65535.'); + return; + } + if (localPort.trim() && !parsedLocalPort) { + setError('Enter a local port between 1 and 65535.'); + return; + } + + setIsSubmitting(true); + try { + await previews.forwardManual({ + protocol, + remotePort: parsedRemotePort, + ...(parsedLocalPort ? { preferredLocalPort: parsedLocalPort } : {}), + }); + setRemotePort(''); + setLocalPort(''); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
+ + Forward Port + + + + Create an SSH tunnel from a remote dev server port to a local preview URL. + +
+ + + + setRemotePort(event.target.value)} + placeholder="5173" + /> + + setLocalPort(event.target.value)} + placeholder="Auto" + /> +
+ {error ?

{error}

: null} +
+ + + + +
+
+ ); +} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-format.ts b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-format.ts new file mode 100644 index 0000000000..664087cc48 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-format.ts @@ -0,0 +1,43 @@ +import type { PreviewServer } from '@shared/core/preview-servers/types'; +import { previewServerUrl } from '@shared/core/preview-servers/types'; + +export function formatPreviewUrl(url: string): string { + try { + const u = new URL(url); + return u.port ? `${u.hostname}:${u.port}` : u.hostname; + } catch { + return url; + } +} + +export function formatPreviewServerLabel(server: PreviewServer): string { + if (server.kind === 'forwarded') { + return `${server.remotePort} -> ${server.localPort}`; + } + return formatPreviewUrl(previewServerUrl(server)); +} + +export function previewServerStatusLabel(server: PreviewServer): string { + switch (server.status.kind) { + case 'ready': + return server.kind === 'forwarded' ? 'Forwarded' : 'Ready'; + case 'starting': + return 'Starting'; + case 'reconnecting': + return 'Reconnecting'; + case 'failed': + return 'Failed'; + } +} + +export function previewServerStatusClasses(server: PreviewServer): string { + switch (server.status.kind) { + case 'ready': + return 'bg-background-info text-foreground-info hover:bg-background-info-hover'; + case 'starting': + case 'reconnecting': + return 'bg-background-warning text-foreground-warning hover:bg-background-warning-hover'; + case 'failed': + return 'bg-background-destructive text-foreground-destructive hover:bg-destructive/20'; + } +} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pill.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pill.tsx new file mode 100644 index 0000000000..785ac4ce45 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pill.tsx @@ -0,0 +1,117 @@ +import { + ChevronDown, + Clipboard, + ExternalLink, + Globe, + Loader2, + RefreshCcw, + Square, +} from 'lucide-react'; +import { observer } from 'mobx-react-lite'; +import { + usePreviewServers, + useWorkspaceViewModel, +} from '@renderer/features/tasks/task-view-context'; +import { rpc } from '@renderer/lib/ipc'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@renderer/lib/ui/dropdown-menu'; +import { MicroLabel } from '@renderer/lib/ui/label'; +import { cn } from '@renderer/utils/utils'; +import type { PreviewServer } from '@shared/core/preview-servers/types'; +import { previewServerUrl } from '@shared/core/preview-servers/types'; +import { + formatPreviewServerLabel, + previewServerStatusClasses, + previewServerStatusLabel, +} from './preview-server-format'; + +export const PreviewServerPill = observer(function PreviewServerPill({ + server, +}: { + server: PreviewServer; +}) { + const previews = usePreviewServers(); + const taskView = useWorkspaceViewModel(); + const url = previewServerUrl(server); + const canOpen = server.status.kind === 'ready'; + + return ( + + + } + > + {server.status.kind === 'starting' || server.status.kind === 'reconnecting' ? ( + + ) : ( + + )} + {formatPreviewServerLabel(server)} + + + +
+ Preview +
+ {url} +
+ {server.kind === 'forwarded' ? ( +
+ Remote {server.remotePort} to local {server.localPort} +
+ ) : null} + {server.status.kind === 'failed' ? ( +
{server.status.message}
+ ) : null} +
+ + { + if (canOpen) taskView.tabGroupManager.openBrowser(url); + taskView.setFocusedRegion('main'); + }} + > + + Open in Emdash Browser + + canOpen && void rpc.app.openExternal(url)} + > + + Open in System Browser + + void rpc.app.clipboardWriteText(url)}> + + Copy URL + + + {server.kind === 'forwarded' ? ( + void previews.restart(server.id)}> + + Restart Forward + + ) : null} + void previews.stop(server.id)}> + + Stop + +
+
+ ); +}); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pills.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pills.tsx new file mode 100644 index 0000000000..3e977031bb --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pills.tsx @@ -0,0 +1,22 @@ +import { observer } from 'mobx-react-lite'; +import { usePreviewServers, useWorkspace } from '@renderer/features/tasks/task-view-context'; +import { ManualForwardButton } from './manual-forward-button'; +import { PreviewServerPill } from './preview-server-pill'; + +export const PreviewServerPills = observer(function PreviewServerPills() { + const previews = usePreviewServers(); + const workspace = useWorkspace(); + const isRemoteWorkspace = Boolean(workspace.sshConnectionId); + const servers = previews.servers; + + if (servers.length === 0 && !isRemoteWorkspace) return null; + + return ( + <> + {servers.map((server) => ( + + ))} + {isRemoteWorkspace ? : null} + + ); +}); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/dev-server-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/dev-server-store.ts deleted file mode 100644 index 97cdf02441..0000000000 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/dev-server-store.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { events } from '@renderer/lib/ipc'; -import type { IDisposable } from '@renderer/lib/stores/lifecycle'; -import { Resource } from '@renderer/lib/stores/resource'; -import { hostPreviewEventChannel } from '@shared/events/hostPreviewEvents'; -import type { HostPreviewEvent } from '@shared/hostPreview'; - -export class DevServerStore implements IDisposable { - /** - * Event-driven resource — starts empty, updated by `hostPreviewEventChannel` - * events. Each event atomically replaces the map to trigger MobX reactivity. - */ - readonly servers: Resource, HostPreviewEvent>; - - constructor(taskId: string, workspaceId: string) { - this.servers = new Resource, HostPreviewEvent>( - null, - [ - { - kind: 'event', - subscribe: (handler) => - events.on(hostPreviewEventChannel, (event) => { - if (event.taskId === taskId || event.taskId === workspaceId) { - handler(event); - } - }), - onEvent: (event, ctx) => { - const next = new Map(ctx.data ?? []); - if (event.type === 'url' && event.terminalId && event.url) { - next.set(event.terminalId, event.url); - } else if (event.type === 'exit' && event.terminalId) { - next.delete(event.terminalId); - } - ctx.set(next); - }, - }, - ], - { init: new Map() } - ); - - this.servers.start(); - } - - get urls(): string[] { - return Array.from(this.servers.data?.values() ?? []); - } - - dispose(): void { - this.servers.dispose(); - } -} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.test.ts new file mode 100644 index 0000000000..85a6241cbd --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PreviewServer, PreviewServerEvent } from '@shared/core/preview-servers/types'; +import { previewServerUrl } from '@shared/core/preview-servers/types'; + +const handlers: Array<(event: PreviewServerEvent) => void> = []; + +function emitPreviewServerEvent(event: PreviewServerEvent): void { + for (const handler of handlers) handler(event); +} + +const rpcMocks = vi.hoisted(() => ({ + listForWorkspace: vi.fn(), + forwardManual: vi.fn(), + restart: vi.fn(), + stop: vi.fn(), +})); + +vi.mock('@renderer/lib/ipc', () => ({ + events: { + on: vi.fn((_channel, handler: (event: PreviewServerEvent) => void) => { + handlers.push(handler); + return () => {}; + }), + }, + rpc: { + previewServers: rpcMocks, + }, +})); + +const { PreviewServerStore } = await import('./preview-server-store'); + +function directServer(overrides: Partial = {}): PreviewServer { + return { + kind: 'direct', + id: 'direct-1', + projectId: 'project-1', + workspaceId: 'workspace-1', + source: { kind: 'terminal-output', terminalId: 'terminal-1' }, + protocol: 'http:', + host: 'localhost', + port: 5173, + urlPath: '/', + status: { kind: 'ready' }, + ...overrides, + } as PreviewServer; +} + +function forwardedServer(overrides: Partial = {}): PreviewServer { + return { + kind: 'forwarded', + id: 'forwarded-1', + projectId: 'project-1', + workspaceId: 'workspace-1', + source: { kind: 'terminal-output', terminalId: 'terminal-1' }, + protocol: 'http:', + connectionId: 'ssh-1', + remotePort: 3000, + localPort: 6100, + urlPath: '/', + status: { kind: 'ready' }, + ...overrides, + } as PreviewServer; +} + +describe('PreviewServerStore', () => { + beforeEach(() => { + handlers.length = 0; + rpcMocks.listForWorkspace.mockReset(); + rpcMocks.forwardManual.mockReset(); + rpcMocks.restart.mockReset(); + rpcMocks.stop.mockReset(); + }); + + it('loads preview servers for a workspace and exposes ready URLs', async () => { + const ready = forwardedServer({ id: 'ready' }); + const reconnecting = forwardedServer({ + id: 'reconnecting', + localPort: 6101, + status: { kind: 'reconnecting' }, + }); + rpcMocks.listForWorkspace.mockResolvedValueOnce([ready, reconnecting]); + + const store = new PreviewServerStore({ + projectId: 'project-1', + workspaceId: 'workspace-1', + }); + await store.serversResource.load(); + + expect(rpcMocks.listForWorkspace).toHaveBeenCalledWith({ + projectId: 'project-1', + workspaceId: 'workspace-1', + }); + expect(store.servers.map((server) => server.id)).toEqual(['ready', 'reconnecting']); + expect(store.urls).toEqual([previewServerUrl(ready)]); + + store.dispose(); + }); + + it('applies upsert and remove events for the active workspace', async () => { + rpcMocks.listForWorkspace.mockResolvedValue([]); + const store = new PreviewServerStore({ + projectId: 'project-1', + workspaceId: 'workspace-1', + }); + await store.serversResource.load(); + store.start(); + + const active = directServer(); + emitPreviewServerEvent({ type: 'upsert', server: active }); + emitPreviewServerEvent({ + type: 'upsert', + server: directServer({ + id: 'other', + workspaceId: 'workspace-2', + port: 5174, + }), + }); + + expect(store.servers.map((server) => server.id)).toEqual(['direct-1']); + + emitPreviewServerEvent({ type: 'remove', id: active.id }); + + expect(store.servers).toEqual([]); + + store.dispose(); + }); + + it('forwards a manual remote port through the workspace connection', async () => { + const forwarded = forwardedServer({ + id: 'manual-1', + source: { kind: 'manual' }, + remotePort: 8080, + localPort: 6500, + }); + rpcMocks.forwardManual.mockResolvedValueOnce(forwarded); + rpcMocks.listForWorkspace.mockResolvedValueOnce([]); + + const store = new PreviewServerStore({ + projectId: 'project-1', + workspaceId: 'workspace-1', + connectionId: 'ssh-1', + }); + await store.forwardManual({ protocol: 'http:', remotePort: 8080 }); + + expect(rpcMocks.forwardManual).toHaveBeenCalledWith({ + projectId: 'project-1', + workspaceId: 'workspace-1', + connectionId: 'ssh-1', + protocol: 'http:', + remotePort: 8080, + }); + expect(store.servers.map((server) => server.id)).toEqual(['manual-1']); + + store.dispose(); + }); + + it('requires an SSH connection for manual forwarding', async () => { + const store = new PreviewServerStore({ + projectId: 'project-1', + workspaceId: 'workspace-1', + }); + + await expect(store.forwardManual({ protocol: 'http:', remotePort: 8080 })).rejects.toThrow( + 'Manual port forwarding requires an SSH workspace' + ); + + expect(rpcMocks.forwardManual).not.toHaveBeenCalled(); + store.dispose(); + }); +}); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.ts new file mode 100644 index 0000000000..0e0fd52182 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.ts @@ -0,0 +1,129 @@ +import { events, rpc } from '@renderer/lib/ipc'; +import type { IDisposable } from '@renderer/lib/stores/lifecycle'; +import { Resource } from '@renderer/lib/stores/resource'; +import { previewServerEventChannel } from '@shared/core/preview-servers/events'; +import type { + ManualPreviewServerRequest, + PreviewServer, + PreviewServerEvent, + PreviewServerProtocol, +} from '@shared/core/preview-servers/types'; +import { previewServerUrl } from '@shared/core/preview-servers/types'; + +type PreviewServerStoreOptions = { + projectId: string; + workspaceId: string; + connectionId?: string; +}; + +type ManualForwardInput = { + protocol: PreviewServerProtocol; + remotePort: number; + preferredLocalPort?: number; +}; + +export class PreviewServerStore implements IDisposable { + readonly serversResource: Resource, PreviewServerEvent>; + + private readonly projectId: string; + private readonly workspaceId: string; + private readonly connectionId: string | undefined; + private started = false; + + constructor({ projectId, workspaceId, connectionId }: PreviewServerStoreOptions) { + this.projectId = projectId; + this.workspaceId = workspaceId; + this.connectionId = connectionId; + this.serversResource = new Resource, PreviewServerEvent>( + async () => { + const servers = await rpc.previewServers.listForWorkspace({ projectId, workspaceId }); + return new Map(servers.map((server) => [server.id, server])); + }, + [ + { + kind: 'event', + subscribe: (handler) => events.on(previewServerEventChannel, handler), + onEvent: (event, ctx) => { + const next = new Map(ctx.data ?? []); + if (event.type === 'upsert') { + if ( + event.server.projectId !== this.projectId || + event.server.workspaceId !== this.workspaceId + ) { + return; + } + next.set(event.server.id, event.server); + } else { + next.delete(event.id); + } + ctx.set(next); + }, + }, + ], + { init: new Map(), refData: true } + ); + } + + start(): void { + if (this.started) return; + this.started = true; + this.serversResource.start(); + } + + get servers(): PreviewServer[] { + return Array.from(this.serversResource.data?.values() ?? []).sort(comparePreviewServers); + } + + get urls(): string[] { + return this.servers + .filter((server) => server.status.kind === 'ready') + .map((server) => previewServerUrl(server)); + } + + async forwardManual(input: ManualForwardInput): Promise { + if (!this.connectionId) { + throw new Error('Manual port forwarding requires an SSH workspace'); + } + const request: ManualPreviewServerRequest = { + projectId: this.projectId, + workspaceId: this.workspaceId, + connectionId: this.connectionId, + protocol: input.protocol, + remotePort: input.remotePort, + ...(input.preferredLocalPort ? { preferredLocalPort: input.preferredLocalPort } : {}), + }; + const server = await rpc.previewServers.forwardManual(request); + this.upsert(server); + return server; + } + + async restart(id: string): Promise { + const server = await rpc.previewServers.restart(id); + if (server) this.upsert(server); + } + + async stop(id: string): Promise { + await rpc.previewServers.stop(id); + const next = new Map(this.serversResource.data ?? []); + next.delete(id); + this.serversResource.setValue(next); + } + + dispose(): void { + this.serversResource.dispose(); + } + + private upsert(server: PreviewServer): void { + if (server.projectId !== this.projectId || server.workspaceId !== this.workspaceId) return; + const next = new Map(this.serversResource.data ?? []); + next.set(server.id, server); + this.serversResource.setValue(next); + } +} + +function comparePreviewServers(a: PreviewServer, b: PreviewServer): number { + const aPort = a.kind === 'forwarded' ? a.remotePort : a.port; + const bPort = b.kind === 'forwarded' ? b.remotePort : b.port; + if (aPort !== bPort) return aPort - bPort; + return a.id.localeCompare(b.id); +} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.tsx b/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.tsx index eb634f105a..3d63623679 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.tsx @@ -2,7 +2,7 @@ import { computed, makeAutoObservable, observable, reaction, runInAction } from import { DiffTabLifecycleStore } from '@renderer/features/tasks/diff-view/stores/diff-tab-lifecycle-store'; import { DiffViewStore } from '@renderer/features/tasks/diff-view/stores/diff-view-store'; import { FileModelLifecycleStore } from '@renderer/features/tasks/editor/stores/file-model-lifecycle-store'; -import { DevServerStore } from '@renderer/features/tasks/stores/dev-server-store'; +import { PreviewServerStore } from '@renderer/features/tasks/stores/preview-server-store'; import { TabGroupManagerStore } from '@renderer/features/tasks/tabs/tab-group-manager-store'; import type { TabManagerStore } from '@renderer/features/tasks/tabs/tab-manager-store'; import { TerminalTabViewStore } from '@renderer/features/tasks/terminals/terminal-tab-view-store'; @@ -56,7 +56,7 @@ export class WorkspaceViewModel implements ILifecycle { */ diffView: DiffViewStore | null = null; prStore: PrStore | null = null; - devServers: DevServerStore | null = null; + previewServers: PreviewServerStore | null = null; private _diffTabLifecycle: DiffTabLifecycleStore | null = null; @@ -291,7 +291,12 @@ export class WorkspaceViewModel implements ILifecycle { const taskData = this._taskStore.data as Task; const workspaceId = this._taskStore.workspaceId!; - this.devServers = new DevServerStore(this.taskId, workspaceId); + this.previewServers = new PreviewServerStore({ + projectId: taskData.projectId, + workspaceId, + connectionId: workspace.sshConnectionId, + }); + this.previewServers.start(); this.prStore = new PrStore( taskData.projectId, workspaceId, @@ -373,8 +378,8 @@ export class WorkspaceViewModel implements ILifecycle { this._diffTabLifecycle = null; this.prStore?.dispose(); this.prStore = null; - this.devServers?.dispose(); - this.devServers = null; + this.previewServers?.dispose(); + this.previewServers = null; this._conversationHydration.dispose(); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/task-titlebar.tsx b/apps/emdash-desktop/src/renderer/features/tasks/task-titlebar.tsx index 76f860c65b..598559763c 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/task-titlebar.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/task-titlebar.tsx @@ -45,8 +45,8 @@ import { formatDiffLineCount } from '@renderer/utils/format-diff-line-count'; import { cn } from '@renderer/utils/utils'; import type { LinkedIssue } from '@shared/core/linked-issue'; import { AutomationRunPill } from './components/automation-run-pill'; -import { DevServerPills } from './components/dev-server-pills'; import { IssueSelector, ProviderLogo } from './components/issue-selector/issue-selector'; +import { PreviewServerPills } from './components/preview-servers/preview-server-pills'; import { type SidebarTab } from './types'; import { useGitActions } from './use-git-actions'; @@ -311,7 +311,7 @@ const ActiveTaskTitlebar = observer(function ActiveTaskTitlebar({ } rightSlot={
- + Date: Tue, 16 Jun 2026 12:39:31 -0700 Subject: [PATCH 3/7] refactor(preview): remove legacy host preview events --- .../main/core/terminals/dev-server-watcher.ts | 137 ------------------ .../src/shared/events/hostPreviewEvents.ts | 4 - apps/emdash-desktop/src/shared/hostPreview.ts | 8 - 3 files changed, 149 deletions(-) delete mode 100644 apps/emdash-desktop/src/main/core/terminals/dev-server-watcher.ts delete mode 100644 apps/emdash-desktop/src/shared/events/hostPreviewEvents.ts delete mode 100644 apps/emdash-desktop/src/shared/hostPreview.ts diff --git a/apps/emdash-desktop/src/main/core/terminals/dev-server-watcher.ts b/apps/emdash-desktop/src/main/core/terminals/dev-server-watcher.ts deleted file mode 100644 index 6a2b560010..0000000000 --- a/apps/emdash-desktop/src/main/core/terminals/dev-server-watcher.ts +++ /dev/null @@ -1,137 +0,0 @@ -import net from 'node:net'; -import { events } from '@main/lib/events'; -import { hostPreviewEventChannel } from '@shared/events/hostPreviewEvents'; -import type { Pty } from '../pty/pty'; - -const PROBE_INTERVAL_MS = 1000; -const PROBE_TIMEOUT_MS = 500; -const PROBE_FAILURES_TO_CLOSE = 2; - -const URL_PATTERN = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d{2,5})?(?:\/\S*)?/; -const MAX_BUFFER = 4096; - -function normalizeUrl(raw: string): string { - return raw.replace('0.0.0.0', '127.0.0.1'); -} - -function stripAnsi(s: string): string { - return s - .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '') - .replace(/\r/g, '') - .replace(/\x1b\][^\x07]*\x07/g, '') - .replace(/\x1b\][^\x1b]*\x1b\\/g, ''); -} - -function parseTarget(url: string): { host: string; port: number } | null { - try { - const u = new URL(url); - const port = Number(u.port) || (u.protocol === 'https:' ? 443 : 80); - const host = u.hostname === '0.0.0.0' ? '127.0.0.1' : u.hostname; - return { host, port }; - } catch { - return null; - } -} - -function isPortOpen(host: string, port: number): Promise { - return new Promise((resolve) => { - const socket = net.createConnection({ host, port }); - socket.setTimeout(PROBE_TIMEOUT_MS); - socket.on('connect', () => { - socket.destroy(); - resolve(true); - }); - socket.on('error', () => resolve(false)); - socket.on('timeout', () => { - socket.destroy(); - resolve(false); - }); - }); -} - -function startProbe(url: string, onClosed: () => void): () => void { - const target = parseTarget(url); - if (!target) return () => {}; - - let stopped = false; - let consecutiveFailures = 0; - let timer: ReturnType; - - const tick = async () => { - if (stopped) return; - const open = await isPortOpen(target.host, target.port); - if (stopped) return; - if (open) { - consecutiveFailures = 0; - } else { - consecutiveFailures++; - if (consecutiveFailures >= PROBE_FAILURES_TO_CLOSE) { - stopped = true; - onClosed(); - return; - } - } - timer = setTimeout(() => { - void tick(); - }, PROBE_INTERVAL_MS); - }; - - timer = setTimeout(() => { - void tick(); - }, 0); - - return () => { - stopped = true; - clearTimeout(timer); - }; -} - -export function wireTerminalDevServerWatcher({ - pty, - scopeId, - terminalId, - probe = true, -}: { - pty: Pty; - scopeId: string; - terminalId: string; - /** Set to false for SSH sessions where remote ports are not locally reachable */ - probe?: boolean; -}): void { - let buffer = ''; - let found = false; - let stopProbe: (() => void) | null = null; - - const cleanup = () => { - buffer = ''; - stopProbe?.(); - stopProbe = null; - if (found) { - found = false; - events.emit(hostPreviewEventChannel, { type: 'exit', taskId: scopeId, terminalId }); - } - }; - - pty.onExit(cleanup); - - pty.onData((chunk) => { - if (found) return; - - buffer += chunk; - if (buffer.length > MAX_BUFFER) { - buffer = buffer.slice(-MAX_BUFFER); - } - - const clean = stripAnsi(buffer); - const match = clean.match(URL_PATTERN); - if (!match) return; - - found = true; - const url = normalizeUrl(match[0]); - events.emit(hostPreviewEventChannel, { type: 'url', taskId: scopeId, terminalId, url }); - - if (probe) { - stopProbe = startProbe(url, cleanup); - } - }); -} diff --git a/apps/emdash-desktop/src/shared/events/hostPreviewEvents.ts b/apps/emdash-desktop/src/shared/events/hostPreviewEvents.ts deleted file mode 100644 index 7be95b349a..0000000000 --- a/apps/emdash-desktop/src/shared/events/hostPreviewEvents.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { HostPreviewEvent } from '@shared/hostPreview'; -import { defineEvent } from '@shared/lib/ipc/events'; - -export const hostPreviewEventChannel = defineEvent('preview:host:event'); diff --git a/apps/emdash-desktop/src/shared/hostPreview.ts b/apps/emdash-desktop/src/shared/hostPreview.ts deleted file mode 100644 index f005bdd882..0000000000 --- a/apps/emdash-desktop/src/shared/hostPreview.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type HostPreviewEvent = { - type: 'url' | 'setup' | 'exit'; - taskId: string; - terminalId?: string; - url?: string; - status?: 'starting' | 'line' | 'done' | 'error'; - line?: string; -}; From 4176005e8d26dcc88a12c5fa9df4513e0cca51dc Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:15:22 -0700 Subject: [PATCH 4/7] chore: cleanup unused file --- .../renderer/features/tasks/components/preview-server-pills.tsx | 1 - 1 file changed, 1 deletion(-) delete mode 100644 apps/emdash-desktop/src/renderer/features/tasks/components/preview-server-pills.tsx diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-server-pills.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-server-pills.tsx deleted file mode 100644 index a4a9e2ac24..0000000000 --- a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-server-pills.tsx +++ /dev/null @@ -1 +0,0 @@ -export { PreviewServerPills } from './preview-servers/preview-server-pills'; From ee4ec7722fb4759b0c7aedc2f68fa7a80a844aaa Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:17:20 -0700 Subject: [PATCH 5/7] refactor(preview): require ssh proxy resolver --- .../main/core/preview-servers/preview-server-service.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts index 729ded0da1..414ed94fb5 100644 --- a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts +++ b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts @@ -74,17 +74,13 @@ export class PreviewServerService { portForwards?: PortForwardService; emit: (event: PreviewServerEvent) => void; getConnectionState: (connectionId: string) => ConnectionState; - getSshProxy?: (connectionId: string) => Promise>; + getSshProxy: (connectionId: string) => Promise>; closeDelayMs?: number; }) { this.portForwards = portForwards; this.emit = emit; this.getConnectionState = getConnectionState; - this.getSshProxy = - getSshProxy ?? - (async () => { - throw new Error('SSH proxy resolver is not configured'); - }); + this.getSshProxy = getSshProxy; this.closeDelayMs = closeDelayMs; } From 9ea331c9924614b8ec3278e3aa1947ca0eb89938 Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:42:37 -0700 Subject: [PATCH 6/7] fix: surface failed ssh preview tunnels --- .../preview-server-service.test.ts | 155 +++++++++++++++++- .../preview-servers/preview-server-service.ts | 136 +++++++++++---- .../impl/local-terminal-provider.test.ts | 1 + .../terminals/impl/local-terminal-provider.ts | 27 +-- .../impl/ssh-terminal-provider.test.ts | 1 + .../terminals/impl/ssh-terminal-provider.ts | 30 ++-- .../preview-servers/preview-server-format.ts | 7 +- .../preview-servers/preview-server-pill.tsx | 25 ++- .../tasks/stores/preview-server-store.test.ts | 15 +- .../tasks/stores/preview-server-store.ts | 4 +- .../src/shared/core/preview-servers/types.ts | 5 +- 11 files changed, 329 insertions(+), 77 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts index 6c43cb8c23..d6a5050097 100644 --- a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts +++ b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts @@ -4,21 +4,33 @@ import type { PreviewServerEvent } from '@shared/core/preview-servers/types'; import { previewServerUrl } from '@shared/core/preview-servers/types'; import type { ConnectionState } from '@shared/core/ssh/ssh'; import { PortForwardService } from '../port-forwards/port-forward-service'; +import type { PortForwardTunnel } from '../port-forwards/port-forward-tunnel'; import { PreviewServerService } from './preview-server-service'; -function createService(options: { connectionState?: ConnectionState } = {}) { +function createService( + options: { + connectionState?: ConnectionState; + openTunnel?: (request: { + proxy: Pick; + remotePort: number; + preferredLocalPort?: number; + }) => Promise; + } = {} +) { const events: PreviewServerEvent[] = []; const closedTunnelIds: string[] = []; let openedTunnels = 0; let connectionState = options.connectionState ?? 'connected'; const portForwards = new PortForwardService({ - openTunnel: async () => { - openedTunnels++; - return { - localPort: 6000 + openedTunnels, - close: async () => {}, - }; - }, + openTunnel: + options.openTunnel ?? + (async () => { + openedTunnels++; + return { + localPort: 6000 + openedTunnels, + close: async () => {}, + }; + }), onTunnelClosed: (id) => closedTunnelIds.push(id), }); @@ -52,6 +64,16 @@ function fakeProxy() { } satisfies Pick; } +function deferred() { + let resolve: (value: T | PromiseLike) => void = () => {}; + let reject: (reason?: unknown) => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + describe('PreviewServerService', () => { it('registers local detected URLs as workspace-owned direct previews', async () => { const { service, events } = createService(); @@ -117,6 +139,122 @@ describe('PreviewServerService', () => { expect( context.service.listForWorkspace({ projectId: 'project-1', workspaceId: 'workspace-1' }) ).toEqual([first]); + expect( + context.events + .filter((event) => event.type === 'upsert' && event.server.id === first.id) + .map((event) => (event.type === 'upsert' ? event.server.status : null)) + ).toEqual([{ kind: 'starting' }, { kind: 'ready' }]); + }); + + it('deduplicates SSH detections while the tunnel is still opening', async () => { + const tunnel = deferred(); + let openCount = 0; + const context = createService({ + openTunnel: async () => { + openCount++; + return await tunnel.promise; + }, + }); + + const firstPromise = context.service.registerDetectedTarget({ + projectId: 'project-1', + workspaceId: 'workspace-1', + connectionId: 'connection-1', + transport: 'ssh', + proxy: fakeProxy(), + source: { kind: 'terminal-output', terminalId: 'terminal-1' }, + protocol: 'http:', + port: 5173, + urlPath: '/', + }); + const duplicate = await context.service.registerDetectedTarget({ + projectId: 'project-1', + workspaceId: 'workspace-1', + connectionId: 'connection-1', + transport: 'ssh', + proxy: fakeProxy(), + source: { kind: 'terminal-output', terminalId: 'terminal-2' }, + protocol: 'http:', + port: 5173, + urlPath: '/ignored', + }); + + expect(duplicate.status).toEqual({ kind: 'starting' }); + expect(openCount).toBe(1); + + tunnel.resolve({ localPort: 6100, close: async () => {} }); + const first = await firstPromise; + + expect(previewServerUrl(first)).toBe('http://127.0.0.1:6100/'); + expect(openCount).toBe(1); + }); + + it('keeps a failed SSH preview row when automatic tunnel opening fails', async () => { + const context = createService({ + openTunnel: async () => { + throw new Error('bind failed'); + }, + }); + + const server = await context.service.registerDetectedTarget({ + projectId: 'project-1', + workspaceId: 'workspace-1', + connectionId: 'connection-1', + transport: 'ssh', + proxy: fakeProxy(), + source: { kind: 'terminal-output', terminalId: 'terminal-1' }, + protocol: 'http:', + port: 5173, + urlPath: '/', + }); + + expect(server.kind).toBe('forwarded'); + expect(previewServerUrl(server)).toBeNull(); + expect(server.status).toEqual({ + kind: 'failed', + message: 'Failed to open SSH port forward: bind failed', + }); + expect( + context.service.listForWorkspace({ projectId: 'project-1', workspaceId: 'workspace-1' }) + ).toEqual([server]); + expect( + context.events + .filter((event) => event.type === 'upsert' && event.server.id === server.id) + .map((event) => (event.type === 'upsert' ? event.server.status : null)) + ).toEqual([ + { kind: 'starting' }, + { kind: 'failed', message: 'Failed to open SSH port forward: bind failed' }, + ]); + }); + + it('restarts a failed SSH preview using the remote port as the preferred local port', async () => { + const preferredLocalPorts: Array = []; + let attempt = 0; + const context = createService({ + openTunnel: async (request) => { + attempt++; + preferredLocalPorts.push(request.preferredLocalPort); + if (attempt === 1) throw new Error('bind failed'); + return { localPort: 6200, close: async () => {} }; + }, + }); + const failed = await context.service.registerDetectedTarget({ + projectId: 'project-1', + workspaceId: 'workspace-1', + connectionId: 'connection-1', + transport: 'ssh', + proxy: fakeProxy(), + source: { kind: 'terminal-output', terminalId: 'terminal-1' }, + protocol: 'http:', + port: 5173, + urlPath: '/', + }); + + const restarted = await context.service.restart(failed.id); + + expect(preferredLocalPorts).toEqual([5173, 5173]); + expect(restarted?.status).toEqual({ kind: 'ready' }); + expect(previewServerUrl(restarted!)).toBe('http://127.0.0.1:6200/'); }); it('keeps SSH terminal previews through transport-loss PTY exits', async () => { @@ -224,6 +362,7 @@ describe('PreviewServerService', () => { .map((event) => (event.type === 'upsert' ? event.server.status : null)); expect(statusEvents).toEqual([ + { kind: 'starting' }, { kind: 'ready' }, { kind: 'reconnecting' }, { kind: 'ready' }, diff --git a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts index 414ed94fb5..fbfd1f78c5 100644 --- a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts +++ b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { log } from '@main/lib/logger'; import type { DirectPreviewServer, DirectPreviewServerHost, @@ -89,20 +90,17 @@ export class PreviewServerService { return this.registerLocalTarget(target); } + return await this.registerSshTarget(target); + } + + private async registerSshTarget( + target: Extract + ): Promise { const identity = sshAutoIdentity(target); const existing = this.serverForIdentity(identity); if (existing) return existing; const tunnelId = `preview:${identity}`; - const forward = await this.portForwards.open({ - id: tunnelId, - projectId: target.projectId, - workspaceId: target.workspaceId, - connectionId: target.connectionId, - proxy: target.proxy, - remotePort: target.port, - preferredLocalPort: target.port, - }); const server: PreviewServer = { id: identity, kind: 'forwarded', @@ -111,13 +109,53 @@ export class PreviewServerService { source: target.source, protocol: target.protocol, urlPath: target.urlPath, - status: { kind: 'ready' }, + status: { kind: 'starting' }, connectionId: target.connectionId, remotePort: target.port, - localPort: forward.localPort, }; this.addServer(identity, server, { identity, tunnelId }); - return server; + + try { + const forward = await this.portForwards.open({ + id: tunnelId, + projectId: target.projectId, + workspaceId: target.workspaceId, + connectionId: target.connectionId, + proxy: target.proxy, + remotePort: target.port, + preferredLocalPort: target.port, + }); + const current = this.servers.get(server.id); + if (!current || current.kind !== 'forwarded') { + await this.portForwards.stop(tunnelId); + return server; + } + const next: PreviewServer = { + ...current, + localPort: forward.localPort, + status: { kind: 'ready' }, + }; + this.servers.set(next.id, next); + this.emit({ type: 'upsert', server: next }); + return next; + } catch (error) { + log.warn('PreviewServerService: failed to open SSH preview tunnel', { + projectId: target.projectId, + workspaceId: target.workspaceId, + connectionId: target.connectionId, + remotePort: target.port, + error: String(error), + }); + const current = this.servers.get(server.id); + if (!current || current.kind !== 'forwarded') return server; + const next: PreviewServer = { + ...current, + status: { kind: 'failed', message: previewForwardErrorMessage(error) }, + }; + this.servers.set(next.id, next); + this.emit({ type: 'upsert', server: next }); + return next; + } } listForWorkspace({ @@ -188,6 +226,8 @@ export class PreviewServerService { for (const server of this.servers.values()) { if (server.kind !== 'forwarded' || server.connectionId !== event.connectionId) continue; + if (server.localPort === undefined && server.status.kind === 'failed') continue; + if (event.type === 'reconnected' && server.localPort === undefined) continue; const next = event.type === 'disconnected' || event.type === 'reconnecting' @@ -223,25 +263,56 @@ export class PreviewServerService { const metadata = this.metadata.get(id); if (!server || server.kind !== 'forwarded' || !metadata?.tunnelId) return server; - await this.portForwards.stop(metadata.tunnelId); - const proxy = await this.getSshProxy(server.connectionId); - const forward = await this.portForwards.open({ - id: metadata.tunnelId, - projectId: server.projectId, - workspaceId: server.workspaceId, - connectionId: server.connectionId, - proxy, - remotePort: server.remotePort, - preferredLocalPort: server.localPort, - }); - const next: PreviewServer = { + const starting: PreviewServer = { ...server, - localPort: forward.localPort, - status: { kind: 'ready' }, + status: { kind: 'starting' }, }; - this.servers.set(id, next); - this.emit({ type: 'upsert', server: next }); - return next; + this.servers.set(id, starting); + this.emit({ type: 'upsert', server: starting }); + + try { + await this.portForwards.stop(metadata.tunnelId); + const proxy = await this.getSshProxy(server.connectionId); + const forward = await this.portForwards.open({ + id: metadata.tunnelId, + projectId: server.projectId, + workspaceId: server.workspaceId, + connectionId: server.connectionId, + proxy, + remotePort: server.remotePort, + preferredLocalPort: server.localPort ?? server.remotePort, + }); + const current = this.servers.get(id); + if (!current || current.kind !== 'forwarded') { + await this.portForwards.stop(metadata.tunnelId); + return starting; + } + const next: PreviewServer = { + ...current, + localPort: forward.localPort, + status: { kind: 'ready' }, + }; + this.servers.set(id, next); + this.emit({ type: 'upsert', server: next }); + return next; + } catch (error) { + log.warn('PreviewServerService: failed to restart SSH preview tunnel', { + projectId: server.projectId, + workspaceId: server.workspaceId, + connectionId: server.connectionId, + remotePort: server.remotePort, + error: String(error), + }); + const current = this.servers.get(id); + if (!current || current.kind !== 'forwarded') return starting; + const next: PreviewServer = { + ...current, + status: { kind: 'failed', message: previewForwardErrorMessage(error) }, + }; + this.servers.set(id, next); + this.emit({ type: 'upsert', server: next }); + return next; + } } async stopForWorkspace(projectId: string, workspaceId: string): Promise { @@ -342,3 +413,10 @@ function matchesDetectedServer( } return server.remotePort === detected.port; } + +function previewForwardErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message + ? `Failed to open SSH port forward: ${message}` + : 'Failed to open SSH port forward'; +} diff --git a/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.test.ts b/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.test.ts index e22ef62819..18e1bdf31e 100644 --- a/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.test.ts +++ b/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.test.ts @@ -70,6 +70,7 @@ describe('LocalTerminalProvider', () => { ptyMock.exitHandlers.length = 0; terminalUrlDetectorMock.wireTerminalUrlDetector.mockClear(); previewServerServiceMock.registerDetectedTarget.mockClear(); + previewServerServiceMock.registerDetectedTarget.mockResolvedValue(undefined); previewServerServiceMock.handleTerminalSourceClosed.mockClear(); vi.mocked(ptySessionRegistry.register).mockClear(); }); diff --git a/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.ts b/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.ts index 04d91fb7dc..f0e8e090cf 100644 --- a/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.ts +++ b/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.ts @@ -187,16 +187,23 @@ export class LocalTerminalProvider implements TerminalProvider { pty, probeLocalPorts: true, onDetected: (server) => { - void previewServerService.registerDetectedTarget({ - projectId: this.projectId, - workspaceId: this.workspaceId, - transport: 'local', - source: { kind: 'terminal-output', terminalId: terminal.id }, - protocol: server.protocol, - host: server.host, - port: server.port, - urlPath: server.urlPath, - }); + void previewServerService + .registerDetectedTarget({ + projectId: this.projectId, + workspaceId: this.workspaceId, + transport: 'local', + source: { kind: 'terminal-output', terminalId: terminal.id }, + protocol: server.protocol, + host: server.host, + port: server.port, + urlPath: server.urlPath, + }) + .catch((error) => { + log.warn('LocalTerminalProvider: preview target registration failed', { + terminalId: terminal.id, + error: String(error), + }); + }); }, onSourceClosed: (event) => previewServerService.handleTerminalSourceClosed({ diff --git a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts index c811f26aa5..87fe0b4522 100644 --- a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts +++ b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts @@ -88,6 +88,7 @@ describe('SshTerminalProvider', () => { ptyMock.exitHandlers.length = 0; terminalUrlDetectorMock.wireTerminalUrlDetector.mockClear(); previewServerServiceMock.registerDetectedTarget.mockClear(); + previewServerServiceMock.registerDetectedTarget.mockResolvedValue(undefined); previewServerServiceMock.handleTerminalSourceClosed.mockClear(); vi.mocked(ptySessionRegistry.register).mockClear(); proxy.getRemoteShellProfile = vi.fn(async () => ({ diff --git a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts index 2937ea8a57..2f29d3c4d5 100644 --- a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts +++ b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts @@ -205,17 +205,25 @@ export class SshTerminalProvider implements TerminalProvider { pty, probeLocalPorts: false, onDetected: (server) => { - void previewServerService.registerDetectedTarget({ - projectId: this.projectId, - workspaceId: this.workspaceId, - connectionId: this.connectionId, - transport: 'ssh', - proxy: this.proxy, - source: { kind: 'terminal-output', terminalId: terminal.id }, - protocol: server.protocol, - port: server.port, - urlPath: server.urlPath, - }); + void previewServerService + .registerDetectedTarget({ + projectId: this.projectId, + workspaceId: this.workspaceId, + connectionId: this.connectionId, + transport: 'ssh', + proxy: this.proxy, + source: { kind: 'terminal-output', terminalId: terminal.id }, + protocol: server.protocol, + port: server.port, + urlPath: server.urlPath, + }) + .catch((error) => { + log.warn('SshTerminalProvider: preview target registration failed', { + terminalId: terminal.id, + connectionId: this.connectionId, + error: String(error), + }); + }); }, onSourceClosed: (event) => previewServerService.handleTerminalSourceClosed({ diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-format.ts b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-format.ts index 664087cc48..6857368330 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-format.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-format.ts @@ -1,5 +1,4 @@ import type { PreviewServer } from '@shared/core/preview-servers/types'; -import { previewServerUrl } from '@shared/core/preview-servers/types'; export function formatPreviewUrl(url: string): string { try { @@ -12,9 +11,11 @@ export function formatPreviewUrl(url: string): string { export function formatPreviewServerLabel(server: PreviewServer): string { if (server.kind === 'forwarded') { - return `${server.remotePort} -> ${server.localPort}`; + return server.localPort === undefined + ? `remote ${server.remotePort}` + : `${server.remotePort} -> ${server.localPort}`; } - return formatPreviewUrl(previewServerUrl(server)); + return formatPreviewUrl(`${server.protocol}//${server.host}:${server.port}${server.urlPath}`); } export function previewServerStatusLabel(server: PreviewServer): string { diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pill.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pill.tsx index 785ac4ce45..40bda55b1d 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pill.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pill.tsx @@ -38,7 +38,11 @@ export const PreviewServerPill = observer(function PreviewServerPill({ const previews = usePreviewServers(); const taskView = useWorkspaceViewModel(); const url = previewServerUrl(server); - const canOpen = server.status.kind === 'ready'; + const hasUrl = url !== null; + const canOpen = server.status.kind === 'ready' && hasUrl; + const title = hasUrl + ? `${previewServerStatusLabel(server)} at ${url}` + : `${previewServerStatusLabel(server)} for ${formatPreviewServerLabel(server)}`; return ( @@ -51,7 +55,7 @@ export const PreviewServerPill = observer(function PreviewServerPill({ previewServerStatusClasses(server) )} aria-label={`Open preview ${formatPreviewServerLabel(server)}`} - title={`${previewServerStatusLabel(server)} at ${url}`} + title={title} /> } > @@ -66,12 +70,14 @@ export const PreviewServerPill = observer(function PreviewServerPill({
Preview -
- {url} +
+ {url ?? 'No local URL'}
{server.kind === 'forwarded' ? (
- Remote {server.remotePort} to local {server.localPort} + {server.localPort === undefined + ? `Remote ${server.remotePort}` + : `Remote ${server.remotePort} to local ${server.localPort}`}
) : null} {server.status.kind === 'failed' ? ( @@ -82,7 +88,7 @@ export const PreviewServerPill = observer(function PreviewServerPill({ { - if (canOpen) taskView.tabGroupManager.openBrowser(url); + if (canOpen && url) taskView.tabGroupManager.openBrowser(url); taskView.setFocusedRegion('main'); }} > @@ -91,12 +97,15 @@ export const PreviewServerPill = observer(function PreviewServerPill({ canOpen && void rpc.app.openExternal(url)} + onClick={() => canOpen && url && void rpc.app.openExternal(url)} > Open in System Browser - void rpc.app.clipboardWriteText(url)}> + url && void rpc.app.clipboardWriteText(url)} + > Copy URL diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.test.ts index 85a6241cbd..147bf70ea2 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.test.ts @@ -71,14 +71,21 @@ describe('PreviewServerStore', () => { rpcMocks.stop.mockReset(); }); - it('loads preview servers for a workspace and exposes ready URLs', async () => { + it('loads preview servers for a workspace and exposes addressable URLs', async () => { const ready = forwardedServer({ id: 'ready' }); const reconnecting = forwardedServer({ id: 'reconnecting', + remotePort: 3001, localPort: 6101, status: { kind: 'reconnecting' }, }); - rpcMocks.listForWorkspace.mockResolvedValueOnce([ready, reconnecting]); + const failed = forwardedServer({ + id: 'failed', + remotePort: 3002, + localPort: undefined, + status: { kind: 'failed', message: 'failed' }, + }); + rpcMocks.listForWorkspace.mockResolvedValueOnce([ready, reconnecting, failed]); const store = new PreviewServerStore({ projectId: 'project-1', @@ -90,8 +97,8 @@ describe('PreviewServerStore', () => { projectId: 'project-1', workspaceId: 'workspace-1', }); - expect(store.servers.map((server) => server.id)).toEqual(['ready', 'reconnecting']); - expect(store.urls).toEqual([previewServerUrl(ready)]); + expect(store.servers.map((server) => server.id)).toEqual(['ready', 'reconnecting', 'failed']); + expect(store.urls).toEqual([previewServerUrl(ready), previewServerUrl(reconnecting)]); store.dispose(); }); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.ts index 0e0fd52182..2803bd02b2 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.ts @@ -76,8 +76,8 @@ export class PreviewServerStore implements IDisposable { get urls(): string[] { return this.servers - .filter((server) => server.status.kind === 'ready') - .map((server) => previewServerUrl(server)); + .map((server) => previewServerUrl(server)) + .filter((url): url is string => url !== null); } async forwardManual(input: ManualForwardInput): Promise { diff --git a/apps/emdash-desktop/src/shared/core/preview-servers/types.ts b/apps/emdash-desktop/src/shared/core/preview-servers/types.ts index e8d0c477b7..1aa1d112c2 100644 --- a/apps/emdash-desktop/src/shared/core/preview-servers/types.ts +++ b/apps/emdash-desktop/src/shared/core/preview-servers/types.ts @@ -34,16 +34,17 @@ export type ForwardedPreviewServer = PreviewServerBase & { kind: 'forwarded'; connectionId: string; remotePort: number; - localPort: number; + localPort?: number; }; export type PreviewServer = DirectPreviewServer | ForwardedPreviewServer; -export function previewServerUrl(server: PreviewServer): string { +export function previewServerUrl(server: PreviewServer): string | null { if (server.kind === 'direct') { return `${server.protocol}//${server.host}:${server.port}${server.urlPath}`; } + if (server.localPort === undefined) return null; return `${server.protocol}//127.0.0.1:${server.localPort}${server.urlPath}`; } From f56a5a8d2fa0e9b80caaaae6bf879a61ed5cd5fd Mon Sep 17 00:00:00 2001 From: Jona Schwarz <133047589+jschwxrz@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:21:39 -0700 Subject: [PATCH 7/7] fix: handle stale ssh preview forwards --- .../port-forwards/port-forward-service.ts | 39 +++++++---- .../port-forwards/port-forward-tunnel.test.ts | 65 +++++++++++++++++++ .../core/port-forwards/port-forward-tunnel.ts | 10 ++- .../preview-server-service.test.ts | 47 +++++++++++++- .../preview-servers/preview-server-service.ts | 52 ++++++++++++--- 5 files changed, 189 insertions(+), 24 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/port-forwards/port-forward-service.ts b/apps/emdash-desktop/src/main/core/port-forwards/port-forward-service.ts index 12a54f4eb9..d9a61e5f2a 100644 --- a/apps/emdash-desktop/src/main/core/port-forwards/port-forward-service.ts +++ b/apps/emdash-desktop/src/main/core/port-forwards/port-forward-service.ts @@ -1,5 +1,9 @@ import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; -import { openPortForwardTunnel, type PortForwardTunnel } from './port-forward-tunnel'; +import { + openPortForwardTunnel, + type OpenPortForwardTunnelOptions, + type PortForwardTunnel, +} from './port-forward-tunnel'; export type OpenPortForwardRequest = { id: string; @@ -24,27 +28,33 @@ type PortForwardEntry = PortForwardRecord & { tunnel: PortForwardTunnel; }; +export type PortForwardConnectionErrorHandler = (id: string, error: Error) => void; + export class PortForwardService { private readonly tunnels = new Map(); - private readonly openTunnel: (request: { - proxy: Pick; - remotePort: number; - preferredLocalPort?: number; - }) => Promise; + private readonly openTunnel: ( + request: OpenPortForwardTunnelOptions + ) => Promise; private readonly onTunnelClosed?: (id: string) => void; + private readonly connectionErrorHandlers = new Set(); constructor( options: { - openTunnel?: (request: { - proxy: Pick; - remotePort: number; - preferredLocalPort?: number; - }) => Promise; + openTunnel?: (request: OpenPortForwardTunnelOptions) => Promise; onTunnelClosed?: (id: string) => void; + onConnectionError?: PortForwardConnectionErrorHandler; } = {} ) { this.openTunnel = options.openTunnel ?? openPortForwardTunnel; this.onTunnelClosed = options.onTunnelClosed; + if (options.onConnectionError) { + this.connectionErrorHandlers.add(options.onConnectionError); + } + } + + onConnectionError(handler: PortForwardConnectionErrorHandler): () => void { + this.connectionErrorHandlers.add(handler); + return () => this.connectionErrorHandlers.delete(handler); } async open(request: OpenPortForwardRequest): Promise { @@ -55,6 +65,7 @@ export class PortForwardService { proxy: request.proxy, remotePort: request.remotePort, preferredLocalPort: request.preferredLocalPort, + onConnectionError: (error) => this.emitConnectionError(request.id, error), }); const entry: PortForwardEntry = { id: request.id, @@ -90,6 +101,12 @@ export class PortForwardService { .map((entry) => entry.id); await Promise.all(ids.map((id) => this.stop(id))); } + + private emitConnectionError(id: string, error: Error): void { + for (const handler of this.connectionErrorHandlers) { + handler(id, error); + } + } } function toRecord(entry: PortForwardEntry): PortForwardRecord { diff --git a/apps/emdash-desktop/src/main/core/port-forwards/port-forward-tunnel.test.ts b/apps/emdash-desktop/src/main/core/port-forwards/port-forward-tunnel.test.ts index d8100e454e..5020ae8b56 100644 --- a/apps/emdash-desktop/src/main/core/port-forwards/port-forward-tunnel.test.ts +++ b/apps/emdash-desktop/src/main/core/port-forwards/port-forward-tunnel.test.ts @@ -48,6 +48,29 @@ function makeProxy() { }; } +function makeRejectingProxy(error: Error) { + return { + proxy: { + get isConnected() { + return true; + }, + get client() { + return { + forwardOut( + _sourceHost: string, + _sourcePort: number, + _remoteHost: string, + _remotePort: number, + callback: (error: Error | undefined, channel: ClientChannel) => void + ) { + callback(error, undefined as unknown as ClientChannel); + }, + } as SshClientProxy['client']; + }, + } satisfies Pick, + }; +} + function listen(server: net.Server): Promise { return new Promise((resolve, reject) => { server.once('error', reject); @@ -87,6 +110,20 @@ function roundTrip(port: number, payload: string): Promise { }); } +function connectUntilClosed(port: number): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ host: '127.0.0.1', port }); + socket.setTimeout(1000); + socket.on('connect', () => socket.write('ping')); + socket.on('close', () => resolve()); + socket.on('error', () => resolve()); + socket.on('timeout', () => { + socket.destroy(); + reject(new Error('socket timed out')); + }); + }); +} + describe('openPortForwardTunnel', () => { const blockers: net.Server[] = []; @@ -136,4 +173,32 @@ describe('openPortForwardTunnel', () => { await tunnel.close(); } }); + + it('closes local sockets without an uncaught exception when the remote port refuses connections', async () => { + const error = new Error('(SSH) Channel open failure: Connection refused'); + const { proxy } = makeRejectingProxy(error); + const connectionErrors: string[] = []; + const uncaughtErrors: string[] = []; + const onUncaught = (uncaught: Error) => { + uncaughtErrors.push(uncaught.message); + }; + process.once('uncaughtException', onUncaught); + + const tunnel = await openPortForwardTunnel({ + proxy, + remotePort: 5173, + onConnectionError: (error) => connectionErrors.push(error.message), + }); + + try { + await connectUntilClosed(tunnel.localPort); + await new Promise((resolve) => setImmediate(resolve)); + + expect(connectionErrors).toEqual(['(SSH) Channel open failure: Connection refused']); + expect(uncaughtErrors).toEqual([]); + } finally { + process.removeListener('uncaughtException', onUncaught); + await tunnel.close(); + } + }); }); diff --git a/apps/emdash-desktop/src/main/core/port-forwards/port-forward-tunnel.ts b/apps/emdash-desktop/src/main/core/port-forwards/port-forward-tunnel.ts index ffcffd5649..d37229c5da 100644 --- a/apps/emdash-desktop/src/main/core/port-forwards/port-forward-tunnel.ts +++ b/apps/emdash-desktop/src/main/core/port-forwards/port-forward-tunnel.ts @@ -14,6 +14,7 @@ export type OpenPortForwardTunnelOptions = { proxy: Pick; remotePort: number; preferredLocalPort?: number; + onConnectionError?: (error: Error) => void; }; export async function openPortForwardTunnel( @@ -37,6 +38,7 @@ function bindTunnel( const server = net.createServer((socket) => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); + socket.on('error', () => {}); forwardSocket(socket, options); }); @@ -90,12 +92,16 @@ function forwardSocket(socket: net.Socket, options: OpenPortForwardTunnelOptions options.remotePort, (error: Error | undefined, channel: ClientChannel) => { if (error) { - socket.destroy(error); + options.onConnectionError?.(error); + socket.destroy(); return; } socket.on('error', () => channel.destroy()); - channel.on('error', () => socket.destroy()); + channel.on('error', (error: Error) => { + options.onConnectionError?.(error); + socket.destroy(); + }); socket.pipe(channel).pipe(socket); } ); diff --git a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts index d6a5050097..b5420d93bb 100644 --- a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts +++ b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts @@ -14,6 +14,7 @@ function createService( proxy: Pick; remotePort: number; preferredLocalPort?: number; + onConnectionError?: (error: Error) => void; }) => Promise; } = {} ) { @@ -212,7 +213,7 @@ describe('PreviewServerService', () => { expect(previewServerUrl(server)).toBeNull(); expect(server.status).toEqual({ kind: 'failed', - message: 'Failed to open SSH port forward: bind failed', + message: 'Failed to open SSH port forward', }); expect( context.service.listForWorkspace({ projectId: 'project-1', workspaceId: 'workspace-1' }) @@ -223,7 +224,7 @@ describe('PreviewServerService', () => { .map((event) => (event.type === 'upsert' ? event.server.status : null)) ).toEqual([ { kind: 'starting' }, - { kind: 'failed', message: 'Failed to open SSH port forward: bind failed' }, + { kind: 'failed', message: 'Failed to open SSH port forward' }, ]); }); @@ -257,6 +258,48 @@ describe('PreviewServerService', () => { expect(previewServerUrl(restarted!)).toBe('http://127.0.0.1:6200/'); }); + it('marks a forwarded SSH preview failed when later browser traffic cannot reach the remote port', async () => { + let onConnectionError: ((error: Error) => void) | undefined; + const context = createService({ + openTunnel: async (request) => { + onConnectionError = request.onConnectionError; + return { localPort: 6100, close: async () => {} }; + }, + }); + const server = await context.service.registerDetectedTarget({ + projectId: 'project-1', + workspaceId: 'workspace-1', + connectionId: 'connection-1', + transport: 'ssh', + proxy: fakeProxy(), + source: { kind: 'terminal-output', terminalId: 'terminal-1' }, + protocol: 'http:', + port: 5173, + urlPath: '/', + }); + + onConnectionError?.(new Error('(SSH) Channel open failure: Connection refused')); + await new Promise((resolve) => setImmediate(resolve)); + + const [failed] = context.service.listForWorkspace({ + projectId: 'project-1', + workspaceId: 'workspace-1', + }); + expect(failed).toMatchObject({ + id: server.id, + kind: 'forwarded', + status: { + kind: 'failed', + message: 'Remote preview port is no longer accepting connections', + }, + }); + expect(previewServerUrl(failed!)).toBeNull(); + expect(context.closedTunnelIds).toEqual([ + 'preview:ssh:auto:project-1:workspace-1:connection-1:5173', + ]); + expect(context.events.at(-1)).toEqual({ type: 'upsert', server: failed }); + }); + it('keeps SSH terminal previews through transport-loss PTY exits', async () => { vi.useFakeTimers(); try { diff --git a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts index fbfd1f78c5..cdaf509fce 100644 --- a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts +++ b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts @@ -83,6 +83,14 @@ export class PreviewServerService { this.getConnectionState = getConnectionState; this.getSshProxy = getSshProxy; this.closeDelayMs = closeDelayMs; + this.portForwards.onConnectionError((tunnelId, error) => { + void this.handlePortForwardConnectionError(tunnelId, error).catch((handlerError) => { + log.warn('PreviewServerService: failed to handle SSH preview tunnel connection error', { + tunnelId, + error: String(handlerError), + }); + }); + }); } async registerDetectedTarget(target: RegisterDetectedPreviewTarget): Promise { @@ -150,7 +158,7 @@ export class PreviewServerService { if (!current || current.kind !== 'forwarded') return server; const next: PreviewServer = { ...current, - status: { kind: 'failed', message: previewForwardErrorMessage(error) }, + status: { kind: 'failed', message: 'Failed to open SSH port forward' }, }; this.servers.set(next.id, next); this.emit({ type: 'upsert', server: next }); @@ -307,7 +315,7 @@ export class PreviewServerService { if (!current || current.kind !== 'forwarded') return starting; const next: PreviewServer = { ...current, - status: { kind: 'failed', message: previewForwardErrorMessage(error) }, + status: { kind: 'failed', message: 'Failed to open SSH port forward' }, }; this.servers.set(id, next); this.emit({ type: 'upsert', server: next }); @@ -352,6 +360,32 @@ export class PreviewServerService { return server; } + private async handlePortForwardConnectionError(tunnelId: string, error: Error): Promise { + const server = this.serverForTunnel(tunnelId); + if (!server || server.kind !== 'forwarded') return; + if (server.status.kind === 'failed' && server.localPort === undefined) return; + + log.warn('PreviewServerService: SSH preview tunnel connection failed', { + projectId: server.projectId, + workspaceId: server.workspaceId, + connectionId: server.connectionId, + remotePort: server.remotePort, + error: String(error), + }); + + await this.portForwards.stop(tunnelId); + const current = this.servers.get(server.id); + if (!current || current.kind !== 'forwarded') return; + + const next: PreviewServer = { + ...current, + localPort: undefined, + status: { kind: 'failed', message: 'Remote preview port is no longer accepting connections' }, + }; + this.servers.set(next.id, next); + this.emit({ type: 'upsert', server: next }); + } + private async stopForTerminal(input: { projectId: string; workspaceId: string; @@ -382,6 +416,13 @@ export class PreviewServerService { const id = this.identities.get(identity); return id ? this.servers.get(id) : undefined; } + + private serverForTunnel(tunnelId: string): PreviewServer | undefined { + for (const [serverId, metadata] of this.metadata.entries()) { + if (metadata.tunnelId === tunnelId) return this.servers.get(serverId); + } + return undefined; + } } function localAutoIdentity(target: { @@ -413,10 +454,3 @@ function matchesDetectedServer( } return server.remotePort === detected.port; } - -function previewForwardErrorMessage(error: unknown): string { - const message = error instanceof Error ? error.message : String(error); - return message - ? `Failed to open SSH port forward: ${message}` - : 'Failed to open SSH port forward'; -}