From 17a81dc3dc74a280ab6755350f5575d3a9c518c7 Mon Sep 17 00:00:00 2001 From: Ymir <36711026+Ymirke@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:30:16 +0200 Subject: [PATCH 1/7] feat(host-service): add /tcp/:port WebSocket bridge for port forwarding Bridges one client WebSocket to one TCP connection on 127.0.0.1:. Only ports the port scanner attributes to the requested workspace are reachable, so a workspace can forward what it started and nothing else on the host. Frames stay binary and under the relay's 1 MiB ceiling; a slow peer pauses the socket instead of growing memory. Runs behind the existing wsAuth middleware, so a stream that arrives through the relay dial-back carries the host secret like terminals do. Claude-Session: https://claude.ai/code/session_01NxTU235jowe5cpVsG47JqW --- packages/host-service/package.json | 1 + packages/host-service/src/app.ts | 9 + .../src/ports/tcp-forward-route.node-test.ts | 183 ++++++++++++++++++ .../src/ports/tcp-forward-route.ts | 151 +++++++++++++++ 4 files changed, 344 insertions(+) create mode 100644 packages/host-service/src/ports/tcp-forward-route.node-test.ts create mode 100644 packages/host-service/src/ports/tcp-forward-route.ts diff --git a/packages/host-service/package.json b/packages/host-service/package.json index a2090e5b346..fa96f6f2109 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -59,6 +59,7 @@ "test:integration": "bun test --pass-with-no-tests test/integration", "test:integration:daemon": "node --experimental-strip-types --test src/terminal/DaemonClient/DaemonClient.node-test.ts src/daemon/DaemonSupervisor.node-test.ts", "test:integration:terminal": "node --experimental-strip-types --test src/terminal/terminal.initial-command.node-test.ts src/terminal/terminal.ungated-launch.node-test.ts src/terminal/terminal.shell-ready-learning.node-test.ts src/terminal/terminal.fish-prompt-transport.node-test.ts", + "test:integration:ports": "node --experimental-strip-types --test src/ports/tcp-forward-route.node-test.ts", "test:integration:workers": "node --experimental-strip-types --test src/workers/worker-termination.node-test.ts", "test:e2e": "bun run scripts/test-e2e.ts", "bench": "bun test test/integration/pull-requests-scaling.bench.ts", diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index dca7c54c944..5f4dc7413b9 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -10,6 +10,8 @@ import { createApiClient } from "./api"; import { createChatV3Mount, registerChatV3Routes } from "./chat-v3"; import { createDb, type HostDb } from "./db"; import { EventBus, GitWatcher, registerEventBusRoute } from "./events"; +import { portManager } from "./ports/port-manager"; +import { registerTcpForwardRoute } from "./ports/tcp-forward-route"; import type { ApiAuthProvider } from "./providers/auth"; import type { HostAuthProvider } from "./providers/host-auth"; import { runArchivedWorkspaceReconcile } from "./runtime/archived-workspace-reconcile"; @@ -260,6 +262,7 @@ export function createApp(options: CreateAppOptions): CreateAppResult { app.use("/events", wsAuth); app.use("/chat-v3/*", wsAuth); app.use("/browser/*", wsAuth); + app.use("/tcp/*", wsAuth); registerEventBusRoute({ app, eventBus, upgradeWebSocket }); registerBrowserCdpRoute({ @@ -267,6 +270,12 @@ export function createApp(options: CreateAppOptions): CreateAppResult { upgradeWebSocket, getBridge: () => config.browserBridge, }); + registerTcpForwardRoute({ + app, + upgradeWebSocket, + getPortsByWorkspace: (workspaceId) => + portManager.getPortsByWorkspace(workspaceId), + }); registerWorkspaceTerminalRoute({ app, db, diff --git a/packages/host-service/src/ports/tcp-forward-route.node-test.ts b/packages/host-service/src/ports/tcp-forward-route.node-test.ts new file mode 100644 index 00000000000..ca535a722b5 --- /dev/null +++ b/packages/host-service/src/ports/tcp-forward-route.node-test.ts @@ -0,0 +1,183 @@ +import assert from "node:assert/strict"; +import net from "node:net"; +import { after, before, test } from "node:test"; +import { type ServerType, serve } from "@hono/node-server"; +import { createNodeWebSocket } from "@hono/node-ws"; +import type { DetectedPort } from "@superset/port-scanner"; +import { Hono } from "hono"; +import { registerTcpForwardRoute } from "./tcp-forward-route.ts"; + +const WORKSPACE_ID = "ws-owner"; +let httpServer: ServerType; +let httpPort = 0; +let echoServer: net.Server; +let echoPort = 0; +let closedEchoPort = 0; +const echoSockets = new Set(); + +function ownedPort(port: number): DetectedPort { + return { + port, + pid: 1, + processName: "echo", + terminalId: "t1", + workspaceId: WORKSPACE_ID, + detectedAt: 0, + address: "127.0.0.1", + }; +} + +before(async () => { + echoServer = net.createServer((socket) => { + echoSockets.add(socket); + socket.on("close", () => echoSockets.delete(socket)); + socket.pipe(socket); + }); + echoPort = await new Promise((resolve) => { + echoServer.listen(0, "127.0.0.1", () => { + resolve((echoServer.address() as net.AddressInfo).port); + }); + }); + // A port the scanner attributes to the workspace but nothing listens on. + const probe = net.createServer(); + closedEchoPort = await new Promise((resolve) => { + probe.listen(0, "127.0.0.1", () => { + const { port } = probe.address() as net.AddressInfo; + probe.close(() => resolve(port)); + }); + }); + + const app = new Hono(); + const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); + registerTcpForwardRoute({ + app, + upgradeWebSocket, + getPortsByWorkspace: (workspaceId) => + workspaceId === WORKSPACE_ID + ? [ownedPort(echoPort), ownedPort(closedEchoPort)] + : [], + }); + httpPort = await new Promise((resolve) => { + httpServer = serve( + { fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, + (info) => resolve(info.port), + ); + }); + injectWebSocket(httpServer); +}); + +after(async () => { + for (const s of echoSockets) s.destroy(); + await new Promise((r) => echoServer.close(() => r())); + await new Promise((r) => httpServer.close(() => r())); +}); + +function dial(port: number, query = `workspaceId=${WORKSPACE_ID}`): WebSocket { + const ws = new WebSocket(`ws://127.0.0.1:${httpPort}/tcp/${port}?${query}`); + ws.binaryType = "arraybuffer"; + return ws; +} + +function waitClose(ws: WebSocket): Promise<{ code: number; reason: string }> { + return new Promise((resolve) => { + ws.addEventListener("close", (event) => + resolve({ code: event.code, reason: event.reason }), + ); + }); +} + +function waitOpen(ws: WebSocket): Promise { + return new Promise((resolve, reject) => { + ws.addEventListener("open", () => resolve(), { once: true }); + ws.addEventListener("error", () => reject(new Error("ws error")), { + once: true, + }); + }); +} + +function collect(ws: WebSocket, minBytes: number): Promise { + const chunks: Buffer[] = []; + return new Promise((resolve) => { + ws.addEventListener("message", (event) => { + chunks.push(Buffer.from(event.data as ArrayBuffer)); + const all = Buffer.concat(chunks); + if (all.byteLength >= minBytes) resolve(all.toString()); + }); + }); +} + +test("bytes echo both ways through the bridge", async () => { + const ws = dial(echoPort); + await waitOpen(ws); + const got = collect(ws, 6); + ws.send(Buffer.from("hello ")); + assert.equal(await got, "hello "); + ws.close(1000); + await waitClose(ws); +}); + +test("frames sent before the upstream connects are delivered", async () => { + const ws = dial(echoPort); + // Send immediately on open, before the host's net.connect has completed. + const opened = waitOpen(ws); + await opened; + const got = collect(ws, 4); + ws.send(Buffer.from("ab")); + ws.send(Buffer.from("cd")); + assert.equal(await got, "abcd"); + ws.close(1000); + await waitClose(ws); +}); + +test("a port the workspace does not own closes with 1008", async () => { + const ws = dial(echoPort, "workspaceId=someone-else"); + const closed = await waitClose(ws); + assert.equal(closed.code, 1008); + assert.equal(closed.reason, "port not owned by workspace"); +}); + +test("a missing workspaceId closes with 1008", async () => { + const ws = dial(echoPort, ""); + const closed = await waitClose(ws); + assert.equal(closed.code, 1008); + assert.equal(closed.reason, "workspaceId is required"); +}); + +test("an owned port with no listener closes with 1011", async () => { + const ws = dial(closedEchoPort); + const closed = await waitClose(ws); + assert.equal(closed.code, 1011); + assert.match(closed.reason, /upstream connect failed: ECONNREFUSED/); +}); + +test("text frames close with 1003", async () => { + const ws = dial(echoPort); + await waitOpen(ws); + ws.send("nope"); + const closed = await waitClose(ws); + assert.equal(closed.code, 1003); +}); + +test("closing the WebSocket destroys the upstream TCP socket", async () => { + const ws = dial(echoPort); + await waitOpen(ws); + const got = collect(ws, 1); + ws.send(Buffer.from("x")); + await got; + assert.equal(echoSockets.size, 1); + ws.close(1000); + await waitClose(ws); + await new Promise((r) => setTimeout(r, 50)); + assert.equal(echoSockets.size, 0); +}); + +test("closing the upstream TCP socket closes the WebSocket", async () => { + const ws = dial(echoPort); + await waitOpen(ws); + const got = collect(ws, 1); + ws.send(Buffer.from("x")); + await got; + for (const s of echoSockets) s.destroy(); + const closed = await waitClose(ws); + assert.equal(closed.code, 1000); +}); diff --git a/packages/host-service/src/ports/tcp-forward-route.ts b/packages/host-service/src/ports/tcp-forward-route.ts new file mode 100644 index 00000000000..5960310aac0 --- /dev/null +++ b/packages/host-service/src/ports/tcp-forward-route.ts @@ -0,0 +1,151 @@ +import net from "node:net"; +import type { NodeWebSocket } from "@hono/node-ws"; +import type { DetectedPort } from "@superset/port-scanner"; +import type { Hono } from "hono"; + +export interface RegisterTcpForwardRouteOptions { + app: Hono; + upgradeWebSocket: NodeWebSocket["upgradeWebSocket"]; + getPortsByWorkspace: (workspaceId: string) => DetectedPort[]; +} + +// Frames the client sends before the TCP socket connects. Bounded so a +// client can't grow host memory while the upstream stalls. +const MAX_PENDING_FRAMES = 64; +const MAX_PENDING_BYTES = 4 * 1024 * 1024; +// Pause the TCP socket when the WebSocket has this much unsent data queued; +// resume when it drains. Keeps a slow relay leg from buffering unbounded. +const HIGH_WATER_BYTES = 8 * 1024 * 1024; +const DRAIN_POLL_MS = 50; + +/** + * Bridges one client WebSocket to one TCP connection on 127.0.0.1:. + * Only ports the port scanner attributes to the requested workspace are + * reachable: a workspace can forward what it started, nothing else on the host. + * + * Every frame is binary and stays under 1 MiB (Node `net` chunks are at most + * 64 KiB), which is the relay's per-message ceiling. + */ +export function registerTcpForwardRoute({ + app, + upgradeWebSocket, + getPortsByWorkspace, +}: RegisterTcpForwardRouteOptions) { + app.get( + "/tcp/:port", + upgradeWebSocket((c) => { + const port = Number.parseInt(c.req.param("port") ?? "", 10); + const workspaceId = c.req.query("workspaceId") ?? ""; + let socket: net.Socket | null = null; + let connected = false; + const pending: Buffer[] = []; + let pendingBytes = 0; + let drainTimer: ReturnType | null = null; + + const teardown = () => { + if (drainTimer) clearInterval(drainTimer); + drainTimer = null; + socket?.destroy(); + socket = null; + pending.length = 0; + }; + + return { + onOpen: (_event, ws) => { + if (!workspaceId) { + ws.close(1008, "workspaceId is required"); + return; + } + if (!Number.isInteger(port) || port <= 0 || port > 65_535) { + ws.close(1008, "invalid port"); + return; + } + const owned = getPortsByWorkspace(workspaceId).some( + (p) => p.port === port, + ); + if (!owned) { + ws.close(1008, "port not owned by workspace"); + return; + } + + const upstream = net.connect({ host: "127.0.0.1", port }); + socket = upstream; + upstream.on("connect", () => { + connected = true; + for (const chunk of pending) upstream.write(chunk); + pending.length = 0; + pendingBytes = 0; + }); + upstream.on("data", (chunk: Buffer) => { + ws.send(asFrame(chunk)); + const queued = ws.raw?.bufferedAmount ?? 0; + if (queued > HIGH_WATER_BYTES && !drainTimer) { + upstream.pause(); + drainTimer = setInterval(() => { + if ((ws.raw?.bufferedAmount ?? 0) <= HIGH_WATER_BYTES / 2) { + if (drainTimer) clearInterval(drainTimer); + drainTimer = null; + upstream.resume(); + } + }, DRAIN_POLL_MS); + } + }); + upstream.on("close", () => { + teardown(); + ws.close(1000, "upstream closed"); + }); + upstream.on("error", (err: NodeJS.ErrnoException) => { + const reason = connected + ? "upstream error" + : `upstream connect failed: ${err.code ?? err.message}`; + teardown(); + ws.close(1011, reason); + }); + }, + onMessage: (event, ws) => { + if (typeof event.data === "string") { + ws.close(1003, "text frames not supported"); + teardown(); + return; + } + const chunk = toBuffer(event.data); + if (socket && connected) { + socket.write(chunk); + return; + } + pendingBytes += chunk.byteLength; + if ( + pending.length >= MAX_PENDING_FRAMES || + pendingBytes > MAX_PENDING_BYTES + ) { + ws.close(1009, "frame backlog exceeded"); + teardown(); + return; + } + pending.push(chunk); + }, + onClose: teardown, + onError: teardown, + }; + }), + ); +} + +// Node's Buffer is typed over ArrayBufferLike; the ws send signature wants a +// plain ArrayBuffer view. Socket chunks never sit on a SharedArrayBuffer, so +// the view is the same bytes with a narrower type — no copy. +function asFrame(chunk: Buffer): Uint8Array { + return chunk as Uint8Array; +} + +function toBuffer(data: ArrayBufferLike | Blob | Uint8Array): Buffer { + if (Buffer.isBuffer(data)) return data; + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } + if (data instanceof ArrayBuffer || data instanceof SharedArrayBuffer) { + return Buffer.from(data); + } + // @hono/node-ws never hands out a Blob; refuse rather than read it async. + throw new Error("Unsupported WebSocket frame type"); +} From c89686eedabc815aba2f82978394bb3e9d46ee64 Mon Sep 17 00:00:00 2001 From: Ymir <36711026+Ymirke@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:30:16 +0200 Subject: [PATCH 2/7] feat(desktop): forward remote workspace ports over the relay Adds a ForwardTransport interface in the main process with one implementation, RelayForwardTransport: each accepted local TCP connection becomes one relay2 dial-back stream to host-service's /tcp/. The interface leaves room for Direct (Tailscale, VPN, LAN) and SSH transports without touching the manager or the UI. PortForwardManager owns the local listeners. sync() is the single entry point: it stops forwards outside the requested set and starts the missing ones on the same port number. A taken local port reports busy with the owning process when Superset started it; the desktop never remaps on its own. Listeners are dropped on before-quit. Exposed to the renderer as the portForwards tRPC router. Claude-Session: https://claude.ai/code/session_01NxTU235jowe5cpVsG47JqW --- apps/desktop/src/lib/trpc/routers/index.ts | 2 + .../lib/trpc/routers/port-forwards/index.ts | 1 + .../routers/port-forwards/port-forwards.ts | 48 +++ apps/desktop/src/main/index.ts | 4 + .../src/main/lib/host-service-utils.ts | 2 +- .../src/main/lib/port-forward/index.ts | 22 ++ .../port-forward/port-forward-manager.test.ts | 233 ++++++++++++++ .../lib/port-forward/port-forward-manager.ts | 293 ++++++++++++++++++ .../port-forward/relay-forward-transport.ts | 96 ++++++ .../src/main/lib/port-forward/types.ts | 15 + apps/desktop/src/shared/types/index.ts | 1 + .../desktop/src/shared/types/port-forwards.ts | 39 +++ 12 files changed, 755 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/lib/trpc/routers/port-forwards/index.ts create mode 100644 apps/desktop/src/lib/trpc/routers/port-forwards/port-forwards.ts create mode 100644 apps/desktop/src/main/lib/port-forward/index.ts create mode 100644 apps/desktop/src/main/lib/port-forward/port-forward-manager.test.ts create mode 100644 apps/desktop/src/main/lib/port-forward/port-forward-manager.ts create mode 100644 apps/desktop/src/main/lib/port-forward/relay-forward-transport.ts create mode 100644 apps/desktop/src/main/lib/port-forward/types.ts create mode 100644 apps/desktop/src/shared/types/port-forwards.ts diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 7de523755a0..b810eba3677 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -20,6 +20,7 @@ import { createNotificationsRouter } from "./notifications"; import { createPageContentRouter } from "./page-content"; import { createPermissionsRouter } from "./permissions"; import { createPluginsRouter } from "./plugins"; +import { createPortForwardsRouter } from "./port-forwards"; import { createPortsRouter } from "./ports"; import { createProjectsRouter } from "./projects"; import { createResourceMetricsRouter } from "./resource-metrics"; @@ -50,6 +51,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { permissions: createPermissionsRouter(), plugins: createPluginsRouter(), ports: createPortsRouter(), + portForwards: createPortForwardsRouter(), resourceMetrics: createResourceMetricsRouter(), menu: createMenuRouter(), external: createExternalRouter(), diff --git a/apps/desktop/src/lib/trpc/routers/port-forwards/index.ts b/apps/desktop/src/lib/trpc/routers/port-forwards/index.ts new file mode 100644 index 00000000000..953aa53557c --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/port-forwards/index.ts @@ -0,0 +1 @@ +export { createPortForwardsRouter } from "./port-forwards"; diff --git a/apps/desktop/src/lib/trpc/routers/port-forwards/port-forwards.ts b/apps/desktop/src/lib/trpc/routers/port-forwards/port-forwards.ts new file mode 100644 index 00000000000..072bb8c2bf3 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/port-forwards/port-forwards.ts @@ -0,0 +1,48 @@ +import { observable } from "@trpc/server/observable"; +import { portForwardManager, setRelayToken } from "main/lib/port-forward"; +import type { PortForward } from "shared/types"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +export const createPortForwardsRouter = () => { + return router({ + setRelayToken: publicProcedure + .input(z.object({ token: z.string().nullable() })) + .mutation(({ input }) => { + setRelayToken(input.token); + }), + + list: publicProcedure.query((): PortForward[] => portForwardManager.list()), + + subscribe: publicProcedure.subscription(() => { + return observable((emit) => { + const onChange = (forwards: PortForward[]) => emit.next(forwards); + portForwardManager.on("change", onChange); + emit.next(portForwardManager.list()); + return () => { + portForwardManager.off("change", onChange); + }; + }); + }), + + sync: publicProcedure + .input( + z.object({ + hostUrl: z.string(), + workspaceId: z.string(), + ports: z.array(z.number().int().positive()), + }), + ) + .mutation( + ({ input }): Promise => portForwardManager.sync(input), + ), + + retryEphemeral: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(({ input }) => portForwardManager.retryEphemeral(input.id)), + + killLocalOwner: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(({ input }) => portForwardManager.killLocalOwner(input.id)), + }); +}; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 66cb206bfbe..6e8c1087a20 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -38,6 +38,7 @@ import { shutdownTanstackDbPersistence, } from "./lib/persistence/persistence"; import { syncInstalledPluginMcpServers } from "./lib/plugin-installs"; +import { portForwardManager } from "./lib/port-forward"; import { ensureProjectIconsDir, getProjectIconPath } from "./lib/project-icons"; import { runQuitCleanup } from "./lib/quit-sequence"; import { initSentry } from "./lib/sentry"; @@ -242,6 +243,9 @@ app.on("before-quit", async (event) => { } isQuitting = true; + // Local port-forward listeners hold no state worth draining; drop them so + // nothing keeps 127.0.0.1: bound after the app is gone. + portForwardManager.stopAll(); // Snapshot all open windows (bounds + org) before they close, so relaunch // restores them. markAppQuitting() stops per-window close handlers from // shrinking the set as windows close one-by-one. diff --git a/apps/desktop/src/main/lib/host-service-utils.ts b/apps/desktop/src/main/lib/host-service-utils.ts index d8d73d8a5cd..b3150736683 100644 --- a/apps/desktop/src/main/lib/host-service-utils.ts +++ b/apps/desktop/src/main/lib/host-service-utils.ts @@ -47,7 +47,7 @@ function normalizePort(port: number): number | null { return port; } -function canBindPort(port: number): Promise { +export function canBindPort(port: number): Promise { return new Promise((resolve) => { const server = createServer(); const finish = (available: boolean) => { diff --git a/apps/desktop/src/main/lib/port-forward/index.ts b/apps/desktop/src/main/lib/port-forward/index.ts new file mode 100644 index 00000000000..69d890a679d --- /dev/null +++ b/apps/desktop/src/main/lib/port-forward/index.ts @@ -0,0 +1,22 @@ +import { canBindPort } from "../host-service-utils"; +import { portManager } from "../terminal/port-manager"; +import { PortForwardManager } from "./port-forward-manager"; +import { RelayForwardTransport } from "./relay-forward-transport"; + +export { PortForwardManager } from "./port-forward-manager"; +export { RelayForwardTransport } from "./relay-forward-transport"; +export type { ForwardTransport } from "./types"; + +let relayToken: string | null = null; + +export function setRelayToken(token: string | null): void { + relayToken = token; +} + +export const portForwardManager = new PortForwardManager({ + transport: new RelayForwardTransport({ getToken: () => relayToken }), + getLocalPorts: () => portManager.getAllPorts(), + killLocalPort: ({ terminalId, workspaceId, port }) => + portManager.killPort({ terminalId, workspaceId, port }), + canBindPort, +}); diff --git a/apps/desktop/src/main/lib/port-forward/port-forward-manager.test.ts b/apps/desktop/src/main/lib/port-forward/port-forward-manager.test.ts new file mode 100644 index 00000000000..d8034e7f151 --- /dev/null +++ b/apps/desktop/src/main/lib/port-forward/port-forward-manager.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from "bun:test"; +import net from "node:net"; +import { type Duplex, PassThrough } from "node:stream"; +import type { DetectedPort } from "@superset/port-scanner"; +import type { ForwardTarget } from "shared/types"; +import { PortForwardManager } from "./port-forward-manager"; +import type { ForwardTransport } from "./types"; + +const HOST = "https://relay.test/hosts/org:machine"; + +async function startEcho(): Promise<{ port: number; close: () => void }> { + const server = net.createServer((s) => s.pipe(s)); + const port = await new Promise((resolve) => + server.listen(0, "127.0.0.1", () => + resolve((server.address() as net.AddressInfo).port), + ), + ); + return { port, close: () => server.close() }; +} + +async function freePort(): Promise { + const server = net.createServer(); + return new Promise((resolve) => + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as net.AddressInfo; + server.close(() => resolve(port)); + }), + ); +} + +/** A transport that opens a TCP connection to an echo server on this machine. */ +function echoTransport(echoPort: number): ForwardTransport & { + opened: ForwardTarget[]; +} { + const opened: ForwardTarget[] = []; + return { + kind: "relay", + opened, + probe: async () => {}, + openStream: async (target) => { + opened.push(target); + const socket = net.connect({ host: "127.0.0.1", port: echoPort }); + await new Promise((r) => socket.once("connect", () => r())); + return socket as Duplex; + }, + }; +} + +function manager( + transport: ForwardTransport, + overrides: Partial[0]> = {}, +) { + return new PortForwardManager({ + transport, + getLocalPorts: () => [], + killLocalPort: async () => ({ success: true }), + canBindPort: async () => true, + ...overrides, + }); +} + +async function roundTrip(port: number, payload: string): Promise { + const socket = net.connect({ host: "127.0.0.1", port }); + await new Promise((r) => socket.once("connect", () => r())); + socket.write(payload); + const data = await new Promise((r) => socket.once("data", r)); + socket.destroy(); + return data.toString(); +} + +describe("PortForwardManager", () => { + test("sync listens on the requested port and bridges bytes", async () => { + const echo = await startEcho(); + const transport = echoTransport(echo.port); + const m = manager(transport); + const local = await freePort(); + const [fwd] = await m.sync({ + hostUrl: HOST, + workspaceId: "ws1", + ports: [local], + }); + expect(fwd?.status).toEqual({ state: "active", localPort: local }); + expect(await roundTrip(local, "ping")).toBe("ping"); + expect(transport.opened[0]).toEqual({ + hostUrl: HOST, + workspaceId: "ws1", + remotePort: local, + }); + m.stopAll(); + echo.close(); + }); + + test("sync for another workspace stops the previous forwards", async () => { + const echo = await startEcho(); + const m = manager(echoTransport(echo.port)); + const a = await freePort(); + await m.sync({ hostUrl: HOST, workspaceId: "ws1", ports: [a] }); + const b = await freePort(); + const list = await m.sync({ + hostUrl: HOST, + workspaceId: "ws2", + ports: [b], + }); + expect(list.map((f) => f.target.workspaceId)).toEqual(["ws2"]); + expect(await freeToBind(a)).toBe(true); + m.stopAll(); + echo.close(); + }); + + test("a busy local port reports busy with the owner when known", async () => { + const echo = await startEcho(); + const owner: DetectedPort = { + port: echo.port, + pid: 42, + processName: "node", + terminalId: "t1", + workspaceId: "local-ws", + detectedAt: 0, + address: "127.0.0.1", + }; + const m = manager(echoTransport(echo.port), { + getLocalPorts: () => [owner], + }); + const [fwd] = await m.sync({ + hostUrl: HOST, + workspaceId: "ws1", + ports: [echo.port], + }); + expect(fwd?.status).toEqual({ + state: "busy", + localPort: echo.port, + localOwner: { + pid: 42, + processName: "node", + terminalId: "t1", + workspaceId: "local-ws", + }, + }); + m.stopAll(); + echo.close(); + }); + + test("retryEphemeral moves a busy forward to another local port", async () => { + const echo = await startEcho(); + const m = manager(echoTransport(echo.port)); + const [fwd] = await m.sync({ + hostUrl: HOST, + workspaceId: "ws1", + ports: [echo.port], + }); + expect(fwd?.status.state).toBe("busy"); + const retried = await m.retryEphemeral(fwd?.id ?? ""); + expect(retried?.status.state).toBe("active"); + if (retried?.status.state !== "active") throw new Error("not active"); + expect(retried.status.localPort).not.toBe(echo.port); + expect(await roundTrip(retried.status.localPort, "x")).toBe("x"); + m.stopAll(); + echo.close(); + }); + + test("killLocalOwner restarts the forward once the port frees", async () => { + const echo = await startEcho(); + let killed = false; + const m = manager(echoTransport(echo.port), { + getLocalPorts: () => [ + { + port: echo.port, + pid: 1, + processName: "node", + terminalId: "t1", + workspaceId: "local-ws", + detectedAt: 0, + address: "127.0.0.1", + }, + ], + killLocalPort: async () => { + killed = true; + echo.close(); + return { success: true }; + }, + canBindPort: freeToBind, + }); + const [fwd] = await m.sync({ + hostUrl: HOST, + workspaceId: "ws1", + ports: [echo.port], + }); + const result = await m.killLocalOwner(fwd?.id ?? ""); + expect(killed).toBe(true); + expect(result).toEqual({ success: true }); + expect(m.list()[0]?.status).toEqual({ + state: "active", + localPort: echo.port, + }); + m.stopAll(); + }); + + test("a failed probe yields an error status", async () => { + const m = manager({ + kind: "relay", + probe: async () => { + throw new Error("protocol v1"); + }, + openStream: async () => new PassThrough(), + }); + const [fwd] = await m.sync({ + hostUrl: HOST, + workspaceId: "ws1", + ports: [await freePort()], + }); + expect(fwd?.status).toEqual({ state: "error", message: "protocol v1" }); + m.stopAll(); + }); + + test("stopAll releases every listener", async () => { + const echo = await startEcho(); + const m = manager(echoTransport(echo.port)); + const local = await freePort(); + await m.sync({ hostUrl: HOST, workspaceId: "ws1", ports: [local] }); + m.stopAll(); + expect(m.list()).toEqual([]); + expect(await freeToBind(local)).toBe(true); + echo.close(); + }); +}); + +function freeToBind(port: number): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => server.close(() => resolve(true))); + }); +} diff --git a/apps/desktop/src/main/lib/port-forward/port-forward-manager.ts b/apps/desktop/src/main/lib/port-forward/port-forward-manager.ts new file mode 100644 index 00000000000..31429dec67e --- /dev/null +++ b/apps/desktop/src/main/lib/port-forward/port-forward-manager.ts @@ -0,0 +1,293 @@ +import { EventEmitter } from "node:events"; +import net from "node:net"; +import type { DetectedPort } from "@superset/port-scanner"; +import type { + ForwardTarget, + LocalPortOwner, + PortForward, + PortForwardStatus, +} from "shared/types"; +import { portForwardId } from "shared/types"; +import type { ForwardTransport } from "./types"; + +interface ForwardEntry { + forward: PortForward; + server: net.Server | null; + sockets: Set; +} + +export interface PortForwardManagerOptions { + transport: ForwardTransport; + /** Local ports the desktop's own port scanner attributes to a workspace. */ + getLocalPorts: () => DetectedPort[]; + killLocalPort: (owner: LocalPortOwner & { port: number }) => Promise<{ + success: boolean; + error?: string; + }>; + canBindPort: (port: number) => Promise; +} + +const MAX_PENDING_FRAMES = 64; +const MAX_PENDING_BYTES = 4 * 1024 * 1024; +const KILL_WAIT_MS = 3_000; +const KILL_POLL_MS = 250; + +/** + * Owns every local listener that forwards to a remote workspace port. Only the + * selected workspace forwards, so `sync` is the single entry point: it stops + * everything outside the requested set and starts what is missing. + */ +export class PortForwardManager extends EventEmitter<{ + change: [PortForward[]]; +}> { + private readonly entries = new Map(); + + constructor(private readonly options: PortForwardManagerOptions) { + super(); + } + + list(): PortForward[] { + return Array.from(this.entries.values(), (e) => ({ ...e.forward })); + } + + async sync({ + hostUrl, + workspaceId, + ports, + }: { + hostUrl: string; + workspaceId: string; + ports: number[]; + }): Promise { + const wanted = new Map(); + for (const remotePort of new Set(ports)) { + const target = { hostUrl, workspaceId, remotePort }; + wanted.set(portForwardId(target), target); + } + for (const id of Array.from(this.entries.keys())) { + if (!wanted.has(id)) this.stop(id); + } + await Promise.all( + Array.from(wanted.entries()) + .filter(([id]) => !this.entries.has(id)) + .map(([, target]) => this.start(target)), + ); + return this.list(); + } + + async retryEphemeral(id: string): Promise { + const entry = this.entries.get(id); + if (!entry) return null; + if (entry.forward.status.state !== "busy") return { ...entry.forward }; + await this.listen({ entry, localPort: 0 }); + return { ...entry.forward }; + } + + async killLocalOwner( + id: string, + ): Promise<{ success: boolean; error?: string }> { + const entry = this.entries.get(id); + if (!entry) return { success: false, error: "Forward not found" }; + const { status } = entry.forward; + if (status.state !== "busy" || !status.localOwner) { + return { success: false, error: "Local process is not known" }; + } + const port = entry.forward.target.remotePort; + const result = await this.options.killLocalPort({ + ...status.localOwner, + port, + }); + if (!result.success) return result; + const deadline = Date.now() + KILL_WAIT_MS; + while (Date.now() < deadline) { + if (await this.options.canBindPort(port)) { + await this.listen({ entry, localPort: port }); + return { success: true }; + } + await new Promise((r) => setTimeout(r, KILL_POLL_MS)); + } + return { success: false, error: "Local port is still busy" }; + } + + stopAll(): void { + for (const id of Array.from(this.entries.keys())) this.stop(id); + } + + private async start(target: ForwardTarget): Promise { + const id = portForwardId(target); + const entry: ForwardEntry = { + forward: { + id, + target, + status: { state: "error", message: "Starting" }, + transport: this.options.transport.kind, + connections: 0, + }, + server: null, + sockets: new Set(), + }; + this.entries.set(id, entry); + try { + await this.options.transport.probe(target); + } catch (err) { + this.setStatus({ + entry, + status: { state: "error", message: messageOf(err) }, + }); + return; + } + await this.listen({ entry, localPort: target.remotePort }); + } + + private listen({ + entry, + localPort, + }: { + entry: ForwardEntry; + localPort: number; + }): Promise { + this.closeServer(entry); + return new Promise((resolve) => { + const server = net.createServer((socket) => + this.accept({ entry, socket }), + ); + entry.server = server; + server.once("error", (err: NodeJS.ErrnoException) => { + entry.server = null; + if (err.code === "EADDRINUSE") { + this.setStatus({ + entry, + status: { + state: "busy", + localPort, + localOwner: this.findLocalOwner(localPort), + }, + }); + } else { + this.setStatus({ + entry, + status: { state: "error", message: messageOf(err) }, + }); + } + resolve(); + }); + server.listen(localPort, "127.0.0.1", () => { + const address = server.address(); + const bound = + address && typeof address === "object" ? address.port : localPort; + this.setStatus({ + entry, + status: { state: "active", localPort: bound }, + }); + resolve(); + }); + }); + } + + private accept({ + entry, + socket, + }: { + entry: ForwardEntry; + socket: net.Socket; + }): void { + entry.sockets.add(socket); + entry.forward.connections = entry.sockets.size; + this.emitChange(); + socket.on("close", () => { + entry.sockets.delete(socket); + entry.forward.connections = entry.sockets.size; + this.emitChange(); + }); + socket.on("error", () => socket.destroy()); + + // Bytes the client sends while the relay stream is still opening. A + // consumer must be attached from the first tick: an unread socket drops + // data under Bun, and a browser sends its request right after connect. + const pending: Buffer[] = []; + let pendingBytes = 0; + const buffer = (chunk: Buffer) => { + pending.push(chunk); + pendingBytes += chunk.byteLength; + if ( + pending.length > MAX_PENDING_FRAMES || + pendingBytes > MAX_PENDING_BYTES + ) { + socket.destroy(); + } + }; + socket.on("data", buffer); + + this.options.transport + .openStream(entry.forward.target) + .then((stream) => { + socket.off("data", buffer); + if (socket.destroyed) { + stream.destroy(); + return; + } + stream.on("error", () => socket.destroy()); + stream.on("close", () => socket.destroy()); + socket.on("close", () => stream.destroy()); + for (const chunk of pending) stream.write(chunk); + pending.length = 0; + socket.pipe(stream); + stream.pipe(socket); + }) + .catch((err) => { + socket.destroy(); + if (this.entries.get(entry.forward.id) === entry) { + this.setStatus({ + entry, + status: { state: "error", message: messageOf(err) }, + }); + } + }); + } + + private findLocalOwner(port: number): LocalPortOwner | null { + const match = this.options.getLocalPorts().find((p) => p.port === port); + if (!match) return null; + return { + pid: match.pid, + processName: match.processName, + terminalId: match.terminalId, + workspaceId: match.workspaceId, + }; + } + + private stop(id: string): void { + const entry = this.entries.get(id); + if (!entry) return; + this.entries.delete(id); + this.closeServer(entry); + this.emitChange(); + } + + private closeServer(entry: ForwardEntry): void { + for (const socket of entry.sockets) socket.destroy(); + entry.sockets.clear(); + entry.forward.connections = 0; + entry.server?.close(); + entry.server = null; + } + + private setStatus({ + entry, + status, + }: { + entry: ForwardEntry; + status: PortForwardStatus; + }): void { + entry.forward.status = status; + this.emitChange(); + } + + private emitChange(): void { + this.emit("change", this.list()); + } +} + +function messageOf(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/apps/desktop/src/main/lib/port-forward/relay-forward-transport.ts b/apps/desktop/src/main/lib/port-forward/relay-forward-transport.ts new file mode 100644 index 00000000000..9fd5ac574c4 --- /dev/null +++ b/apps/desktop/src/main/lib/port-forward/relay-forward-transport.ts @@ -0,0 +1,96 @@ +import type { Duplex } from "node:stream"; +import type { ForwardTarget } from "shared/types"; +import { createWebSocketStream, WebSocket } from "ws"; +import type { ForwardTransport } from "./types"; + +const OPEN_TIMEOUT_MS = 15_000; + +export interface RelayForwardTransportOptions { + getToken: () => string | null; + fetchFn?: typeof fetch; +} + +/** + * Forwards over relay2's per-stream dial-back: the desktop upgrades + * `/hosts//tcp/` on the relay, the host dials back, and the relay + * splices frames verbatim. Only protocol v2 relays can carry binary client + * frames, so the probe refuses v1 (`/health` without `proto: 2`). + */ +export class RelayForwardTransport implements ForwardTransport { + readonly kind = "relay" as const; + private readonly probes = new Map>(); + + constructor(private readonly options: RelayForwardTransportOptions) {} + + probe({ hostUrl }: Pick): Promise { + const origin = new URL(hostUrl).origin; + let pending = this.probes.get(origin); + if (!pending) { + pending = this.checkProtocol(origin).catch((err) => { + // Let a transient failure retry on the next sync. + this.probes.delete(origin); + throw err; + }); + this.probes.set(origin, pending); + } + return pending; + } + + private async checkProtocol(origin: string): Promise { + const fetchFn = this.options.fetchFn ?? fetch; + const res = await fetchFn(`${origin}/health`); + if (!res.ok) throw new Error(`Relay health check failed (${res.status})`); + const body = (await res.json()) as { proto?: number }; + if (body.proto !== 2) { + throw new Error( + "Host relay does not support port forwarding (protocol v1)", + ); + } + } + + async openStream(target: ForwardTarget): Promise { + const token = this.options.getToken(); + if (!token) throw new Error("Not signed in"); + const url = new URL(`${target.hostUrl}/tcp/${target.remotePort}`); + if (url.protocol === "http:") url.protocol = "ws:"; + if (url.protocol === "https:") url.protocol = "wss:"; + url.searchParams.set("workspaceId", target.workspaceId); + url.searchParams.set("token", token); + + const ws = new WebSocket(url.toString()); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ws.terminate(); + reject(new Error("Relay did not answer")); + }, OPEN_TIMEOUT_MS); + ws.once("open", () => { + clearTimeout(timer); + resolve(); + }); + ws.once("unexpected-response", (_req, res) => { + clearTimeout(timer); + reject(new Error(describeUpgradeFailure(res.statusCode))); + }); + ws.once("error", (err) => { + clearTimeout(timer); + reject(err); + }); + }); + return createWebSocketStream(ws); + } +} + +function describeUpgradeFailure(status: number | undefined): string { + switch (status) { + case 401: + return "Session expired, sign in again"; + case 403: + return "No access to this host"; + case 503: + return "Host is offline"; + case 504: + return "Host did not answer"; + default: + return `Relay refused the stream (${status ?? "unknown"})`; + } +} diff --git a/apps/desktop/src/main/lib/port-forward/types.ts b/apps/desktop/src/main/lib/port-forward/types.ts new file mode 100644 index 00000000000..6623950e04c --- /dev/null +++ b/apps/desktop/src/main/lib/port-forward/types.ts @@ -0,0 +1,15 @@ +import type { Duplex } from "node:stream"; +import type { ForwardTarget, ForwardTransportKind } from "shared/types"; + +/** + * How bytes get from the local machine to 127.0.0.1: on the host. + * The relay implementation ships first; a direct (Tailscale, VPN, LAN) or SSH + * implementation plugs in here without touching the manager or the UI. + */ +export interface ForwardTransport { + readonly kind: ForwardTransportKind; + /** Resolves when the transport can serve this host; rejects with a reason. */ + probe(target: Pick): Promise; + /** One bidirectional byte stream to 127.0.0.1: on the host. */ + openStream(target: ForwardTarget): Promise; +} diff --git a/apps/desktop/src/shared/types/index.ts b/apps/desktop/src/shared/types/index.ts index 72711f77a1d..7b6704c6dc1 100644 --- a/apps/desktop/src/shared/types/index.ts +++ b/apps/desktop/src/shared/types/index.ts @@ -2,6 +2,7 @@ export * from "./config"; export * from "./database"; export * from "./electron"; export * from "./mosaic"; +export * from "./port-forwards"; export * from "./ports"; export * from "./tab"; export * from "./workspace"; diff --git a/apps/desktop/src/shared/types/port-forwards.ts b/apps/desktop/src/shared/types/port-forwards.ts new file mode 100644 index 00000000000..adc51d7208f --- /dev/null +++ b/apps/desktop/src/shared/types/port-forwards.ts @@ -0,0 +1,39 @@ +export interface ForwardTarget { + /** Host-service URL for the host, e.g. https://relay2.superset.sh/hosts/:. */ + hostUrl: string; + workspaceId: string; + remotePort: number; +} + +export interface LocalPortOwner { + pid: number; + processName: string; + terminalId: string; + workspaceId: string; +} + +export type PortForwardStatus = + | { state: "active"; localPort: number } + // localOwner is null when the local port scanner does not know the process; + // only a known owner can be stopped from the UI. + | { state: "busy"; localPort: number; localOwner: LocalPortOwner | null } + | { state: "error"; message: string }; + +export type ForwardTransportKind = "relay"; + +export interface PortForward { + /** `${hostUrl}|${workspaceId}|${remotePort}` */ + id: string; + target: ForwardTarget; + status: PortForwardStatus; + transport: ForwardTransportKind; + connections: number; +} + +export function portForwardId({ + hostUrl, + workspaceId, + remotePort, +}: ForwardTarget): string { + return `${hostUrl}|${workspaceId}|${remotePort}`; +} From 188839a70bfe82bba63832c0470d01ee0703f739 Mon Sep 17 00:00:00 2001 From: Ymir <36711026+Ymirke@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:30:16 +0200 Subject: [PATCH 3/7] feat(desktop): auto-forward the selected remote workspace's ports Selecting a v2 workspace on a remote host forwards each of its detected ports to the same local port; leaving it stops them. Port rows show the state (forwarded, mapped to another port, busy, error). A busy row offers "Stop local " when Superset owns the local process, and "Use another port" otherwise. The renderer pushes the relay JWT to the main process from setJwt, since main has no auth client of its own. RemotePortForwarder reads the live pathname itself; the layout's fuzzy matchRoute keeps its previous value after leaving the workspace route. Docs updated; ExecPlan moved to plans/done with outcomes. Claude-Session: https://claude.ai/code/session_01NxTU235jowe5cpVsG47JqW --- apps/desktop/src/renderer/lib/auth-client.ts | 8 + .../DashboardSidebarPortHoverRow.tsx | 13 +- .../PortForwardBusyActions.tsx | 59 +++ .../PortForwardBusyActions/index.ts | 1 + .../usePortOpenActions/usePortOpenActions.ts | 16 +- .../deriveForwardSyncInput.test.ts | 83 ++++ .../deriveForwardSyncInput.ts | 33 ++ .../hooks/useRemotePortForwarding/index.ts | 2 + .../useRemotePortForwarding.ts | 40 ++ .../PortForwardsProvider.tsx | 47 +++ .../providers/PortForwardsProvider/index.ts | 1 + .../formatPortRowLabel.test.ts | 76 ++++ .../formatPortRowLabel/formatPortRowLabel.ts | 32 ++ .../utils/formatPortRowLabel/index.ts | 1 + .../RemotePortForwarder.tsx | 19 + .../components/RemotePortForwarder/index.ts | 1 + .../TopBarPortRow/TopBarPortRow.tsx | 21 +- .../_authenticated/_dashboard/layout.tsx | 106 ++--- apps/docs/content/docs/ports.mdx | 8 +- apps/docs/content/docs/remote-workspaces.mdx | 8 +- .../20260824-1849-remote-port-forwarding.md | 397 ++++++++++++++++++ 21 files changed, 910 insertions(+), 62 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/PortForwardBusyActions/PortForwardBusyActions.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/PortForwardBusyActions/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useRemotePortForwarding/deriveForwardSyncInput.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useRemotePortForwarding/deriveForwardSyncInput.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useRemotePortForwarding/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useRemotePortForwarding/useRemotePortForwarding.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/PortForwardsProvider/PortForwardsProvider.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/PortForwardsProvider/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/formatPortRowLabel/formatPortRowLabel.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/formatPortRowLabel/formatPortRowLabel.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/formatPortRowLabel/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/RemotePortForwarder/RemotePortForwarder.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/RemotePortForwarder/index.ts create mode 100644 plans/done/20260824-1849-remote-port-forwarding.md diff --git a/apps/desktop/src/renderer/lib/auth-client.ts b/apps/desktop/src/renderer/lib/auth-client.ts index 24845787bb5..81badbedd44 100644 --- a/apps/desktop/src/renderer/lib/auth-client.ts +++ b/apps/desktop/src/renderer/lib/auth-client.ts @@ -10,6 +10,7 @@ import { createAuthClient } from "better-auth/react"; import { useSyncExternalStore } from "react"; import { env } from "renderer/env.renderer"; import { decodeJwtExpiresAtMs } from "renderer/lib/jwt-expiry"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; let authToken: string | null = null; const authTokenListeners = new Set<() => void>(); @@ -54,6 +55,13 @@ export function setJwt(token: string | null) { jwt = token; jwtGeneration++; jwtExpiresAtMs = token ? decodeJwtExpiresAtMs(token) : null; + // The main process dials the relay for port forwards with this same token + // and has no auth client of its own; keep its copy current. + electronTrpcClient.portForwards.setRelayToken + .mutate({ token }) + .catch((err) => { + console.warn("[auth] failed to hand relay token to main:", err); + }); } function jwtIsFresh(): boolean { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarPortsChip/components/DashboardSidebarPortHoverRow/DashboardSidebarPortHoverRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarPortsChip/components/DashboardSidebarPortHoverRow/DashboardSidebarPortHoverRow.tsx index 62d8f77f22b..1d972b72749 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarPortsChip/components/DashboardSidebarPortHoverRow/DashboardSidebarPortHoverRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarPortsChip/components/DashboardSidebarPortHoverRow/DashboardSidebarPortHoverRow.tsx @@ -1,7 +1,10 @@ import { LuX } from "react-icons/lu"; +import { PortForwardBusyActions } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/PortForwardBusyActions"; import { useDashboardSidebarPortKill } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarPortKill"; import type { DashboardSidebarPort } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarPortsData"; import { usePortOpenActions } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/usePortOpenActions"; +import { usePortForward } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/PortForwardsProvider"; +import { formatPortRowLabel } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/formatPortRowLabel"; import { STROKE_WIDTH } from "renderer/screens/main/components/WorkspaceSidebar/constants"; interface DashboardSidebarPortHoverRowProps { @@ -13,6 +16,8 @@ export function DashboardSidebarPortHoverRow({ }: DashboardSidebarPortHoverRowProps) { const { isPending, killPort } = useDashboardSidebarPortKill(); const { openPrimary } = usePortOpenActions(port); + const forward = usePortForward(port); + const address = formatPortRowLabel({ port, forward }); return (
@@ -26,10 +31,14 @@ export function DashboardSidebarPortHoverRow({ {port.label && ( {port.label} )} - - localhost:{port.port} + + {address.text} + {forward && } {/* Always in layout, shown via visibility so the row never changes size on hover. */} + )} + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/PortForwardBusyActions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/PortForwardBusyActions/index.ts new file mode 100644 index 00000000000..938de52bd12 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/PortForwardBusyActions/index.ts @@ -0,0 +1 @@ +export { PortForwardBusyActions } from "./PortForwardBusyActions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/usePortOpenActions/usePortOpenActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/usePortOpenActions/usePortOpenActions.ts index 742f8447db3..92a1a9ac83c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/usePortOpenActions/usePortOpenActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/usePortOpenActions/usePortOpenActions.ts @@ -3,6 +3,7 @@ import { useV2UserPreferences } from "renderer/hooks/useV2UserPreferences"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import type { LinkAction } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import { usePortForward } from "../../providers/PortForwardsProvider"; import type { DashboardSidebarPort } from "../useDashboardSidebarPortsData"; interface UsePortOpenActionsResult { @@ -23,8 +24,14 @@ export function usePortOpenActions( const navigate = useNavigate(); const openUrl = electronTrpc.external.openUrl.useMutation(); const { preferences } = useV2UserPreferences(); - const canOpenInBrowser = port.hostType === "local-device"; - const portUrl = `http://localhost:${port.port}`; + // A remote port opens like a local one once the main process forwards it + // to this machine; the local port number may differ from the remote one. + const forward = usePortForward(port); + const activeForward = + forward?.status.state === "active" ? forward.status : null; + const canOpenInBrowser = + port.hostType === "local-device" || activeForward !== null; + const portUrl = `http://localhost:${activeForward?.localPort ?? port.port}`; const openExternal = () => { if (!canOpenInBrowser || openUrl.isPending) return; @@ -63,8 +70,9 @@ export function usePortOpenActions( }); }; - // Opening the port is the primary action; remote ports can't open a local - // browser tab, so clicking those jumps to the workspace instead. + // Opening the port is the primary action; a remote port that is not + // forwarded can't open a local browser tab, so clicking it jumps to the + // workspace instead. const openPrimary = canOpenInBrowser ? openInBrowser : openWorkspace; return { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useRemotePortForwarding/deriveForwardSyncInput.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useRemotePortForwarding/deriveForwardSyncInput.test.ts new file mode 100644 index 00000000000..f28100d397c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useRemotePortForwarding/deriveForwardSyncInput.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "bun:test"; +import type { + DashboardSidebarPort, + DashboardSidebarPortGroup, +} from "../useDashboardSidebarPortsData"; +import { deriveForwardSyncInput } from "./deriveForwardSyncInput"; + +const REMOTE_HOST = "https://relay2.superset.sh/hosts/org:machine"; + +function port( + overrides: Partial & { port: number }, +): DashboardSidebarPort { + return { + pid: 1, + processName: "node", + terminalId: "t1", + workspaceId: "ws-remote", + detectedAt: 0, + address: "127.0.0.1", + label: null, + hostId: "host-remote", + hostType: "remote-device", + hostUrl: REMOTE_HOST, + ...overrides, + }; +} + +const groups: DashboardSidebarPortGroup[] = [ + { + workspaceId: "ws-remote", + workspaceName: "remote", + hostType: "remote-device", + ports: [ + port({ port: 5173 }), + port({ port: 3000 }), + port({ port: 3000, terminalId: "t2" }), + ], + }, + { + workspaceId: "ws-local", + workspaceName: "local", + hostType: "local-device", + ports: [ + port({ + port: 3000, + workspaceId: "ws-local", + hostId: "host-local", + hostType: "local-device", + hostUrl: "http://localhost:4567", + }), + ], + }, +]; + +describe("deriveForwardSyncInput", () => { + it("forwards the remote ports of the selected workspace, sorted and deduped", () => { + expect( + deriveForwardSyncInput({ activeWorkspaceId: "ws-remote", groups }), + ).toEqual({ + hostUrl: REMOTE_HOST, + workspaceId: "ws-remote", + ports: [3000, 5173], + }); + }); + + it("forwards nothing for a local workspace", () => { + expect( + deriveForwardSyncInput({ activeWorkspaceId: "ws-local", groups }), + ).toEqual({ hostUrl: "", workspaceId: "", ports: [] }); + }); + + it("forwards nothing when no workspace is selected", () => { + expect(deriveForwardSyncInput({ activeWorkspaceId: null, groups })).toEqual( + { hostUrl: "", workspaceId: "", ports: [] }, + ); + }); + + it("forwards nothing for a remote workspace without ports", () => { + expect( + deriveForwardSyncInput({ activeWorkspaceId: "ws-other", groups }), + ).toEqual({ hostUrl: "", workspaceId: "", ports: [] }); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useRemotePortForwarding/deriveForwardSyncInput.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useRemotePortForwarding/deriveForwardSyncInput.ts new file mode 100644 index 00000000000..13665f8e36a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useRemotePortForwarding/deriveForwardSyncInput.ts @@ -0,0 +1,33 @@ +import type { DashboardSidebarPortGroup } from "../useDashboardSidebarPortsData"; + +export interface ForwardSyncInput { + hostUrl: string; + workspaceId: string; + ports: number[]; +} + +/** + * What the main process should forward right now: the remote ports of the + * selected workspace, or nothing when the selection is local or empty. Ports + * are sorted so equal inputs serialize equal and skip a redundant sync. + */ +export function deriveForwardSyncInput({ + activeWorkspaceId, + groups, +}: { + activeWorkspaceId: string | null; + groups: DashboardSidebarPortGroup[]; +}): ForwardSyncInput { + const empty = { hostUrl: "", workspaceId: "", ports: [] }; + if (!activeWorkspaceId) return empty; + const group = groups.find((g) => g.workspaceId === activeWorkspaceId); + if (!group || group.hostType !== "remote-device") return empty; + const remote = group.ports.filter((p) => p.hostType === "remote-device"); + const hostUrl = remote[0]?.hostUrl; + if (!hostUrl) return empty; + return { + hostUrl, + workspaceId: activeWorkspaceId, + ports: Array.from(new Set(remote.map((p) => p.port))).sort((a, b) => a - b), + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useRemotePortForwarding/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useRemotePortForwarding/index.ts new file mode 100644 index 00000000000..645e65c830e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useRemotePortForwarding/index.ts @@ -0,0 +1,2 @@ +export { deriveForwardSyncInput } from "./deriveForwardSyncInput"; +export { useRemotePortForwarding } from "./useRemotePortForwarding"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useRemotePortForwarding/useRemotePortForwarding.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useRemotePortForwarding/useRemotePortForwarding.ts new file mode 100644 index 00000000000..6d25676e905 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useRemotePortForwarding/useRemotePortForwarding.ts @@ -0,0 +1,40 @@ +import { useEffect, useMemo, useRef } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useDashboardSidebarAllPorts } from "../../providers/DashboardSidebarPortsProvider"; +import { deriveForwardSyncInput } from "./deriveForwardSyncInput"; + +// A dev server restart emits remove+add within milliseconds; collapse the +// burst into one sync so the local listener is not torn down and rebuilt. +const SYNC_DEBOUNCE_MS = 200; + +/** + * Keeps the main process's port forwards equal to the remote ports of the + * selected workspace. Selecting a local workspace, or none, stops every + * forward. + */ +export function useRemotePortForwarding(activeWorkspaceId: string | null) { + const { workspacePortGroups } = useDashboardSidebarAllPorts(); + const sync = electronTrpc.portForwards.sync.useMutation(); + const input = useMemo( + () => + deriveForwardSyncInput({ + activeWorkspaceId, + groups: workspacePortGroups, + }), + [activeWorkspaceId, workspacePortGroups], + ); + const key = JSON.stringify(input); + const mutate = sync.mutate; + // The mutation handle is not referentially stable across renders; without + // this guard a re-render with an unchanged key would issue a second, + // identical sync. + const lastSyncedKey = useRef(null); + useEffect(() => { + if (lastSyncedKey.current === key) return; + const timer = setTimeout(() => { + lastSyncedKey.current = key; + mutate(JSON.parse(key)); + }, SYNC_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [key, mutate]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/PortForwardsProvider/PortForwardsProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/PortForwardsProvider/PortForwardsProvider.tsx new file mode 100644 index 00000000000..d8435833cc7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/PortForwardsProvider/PortForwardsProvider.tsx @@ -0,0 +1,47 @@ +import { + createContext, + type ReactNode, + useContext, + useMemo, + useState, +} from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import type { PortForward } from "shared/types"; +import { portForwardId } from "shared/types"; +import type { DashboardSidebarPort } from "../../hooks/useDashboardSidebarPortsData"; + +const PortForwardsContext = createContext | null>( + null, +); + +/** Mirrors the main process's port-forward list; one subscription per window. */ +export function PortForwardsProvider({ children }: { children: ReactNode }) { + const [forwards, setForwards] = useState([]); + electronTrpc.portForwards.subscribe.useSubscription(undefined, { + onData: setForwards, + }); + const byId = useMemo( + () => new Map(forwards.map((f) => [f.id, f])), + [forwards], + ); + return ( + + {children} + + ); +} + +/** The forward for a sidebar port row, or null when none runs for it. */ +export function usePortForward(port: DashboardSidebarPort): PortForward | null { + const byId = useContext(PortForwardsContext); + if (!byId || port.hostType !== "remote-device") return null; + return ( + byId.get( + portForwardId({ + hostUrl: port.hostUrl, + workspaceId: port.workspaceId, + remotePort: port.port, + }), + ) ?? null + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/PortForwardsProvider/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/PortForwardsProvider/index.ts new file mode 100644 index 00000000000..b4ab7d54530 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/PortForwardsProvider/index.ts @@ -0,0 +1 @@ +export { PortForwardsProvider, usePortForward } from "./PortForwardsProvider"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/formatPortRowLabel/formatPortRowLabel.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/formatPortRowLabel/formatPortRowLabel.test.ts new file mode 100644 index 00000000000..ce6bb02a2fd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/formatPortRowLabel/formatPortRowLabel.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "bun:test"; +import type { PortForward } from "shared/types"; +import type { DashboardSidebarPort } from "../../hooks/useDashboardSidebarPortsData"; +import { formatPortRowLabel } from "./formatPortRowLabel"; + +const remote: DashboardSidebarPort = { + port: 3000, + pid: 1, + processName: "node", + terminalId: "t1", + workspaceId: "ws", + detectedAt: 0, + address: "127.0.0.1", + label: null, + hostId: "h", + hostType: "remote-device", + hostUrl: "https://relay2.superset.sh/hosts/org:m", +}; + +function forward(status: PortForward["status"]): PortForward { + return { + id: "x", + target: { hostUrl: remote.hostUrl, workspaceId: "ws", remotePort: 3000 }, + status, + transport: "relay", + connections: 0, + }; +} + +describe("formatPortRowLabel", () => { + it("local port", () => { + expect( + formatPortRowLabel({ + port: { ...remote, hostType: "local-device" }, + forward: null, + }), + ).toEqual({ text: "localhost:3000" }); + }); + it("remote, not forwarded", () => { + expect(formatPortRowLabel({ port: remote, forward: null })).toEqual({ + text: "3000 · remote", + }); + }); + it("remote, forwarded to the same port", () => { + expect( + formatPortRowLabel({ + port: remote, + forward: forward({ state: "active", localPort: 3000 }), + }), + ).toEqual({ text: "3000 · forwarded" }); + }); + it("remote, forwarded to another port", () => { + expect( + formatPortRowLabel({ + port: remote, + forward: forward({ state: "active", localPort: 54321 }), + }), + ).toEqual({ text: "3000 → localhost:54321" }); + }); + it("remote, busy", () => { + expect( + formatPortRowLabel({ + port: remote, + forward: forward({ state: "busy", localPort: 3000, localOwner: null }), + }), + ).toEqual({ text: "3000 · local port busy" }); + }); + it("remote, error", () => { + expect( + formatPortRowLabel({ + port: remote, + forward: forward({ state: "error", message: "Host is offline" }), + }), + ).toEqual({ text: "3000 · Host is offline", title: "Host is offline" }); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/formatPortRowLabel/formatPortRowLabel.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/formatPortRowLabel/formatPortRowLabel.ts new file mode 100644 index 00000000000..8cfe2719429 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/formatPortRowLabel/formatPortRowLabel.ts @@ -0,0 +1,32 @@ +import type { PortForward } from "shared/types"; +import type { DashboardSidebarPort } from "../../hooks/useDashboardSidebarPortsData"; + +/** + * The address column of a port row. Local ports read as before; remote ports + * say whether, and where, they reach the local machine. + */ +export function formatPortRowLabel({ + port, + forward, +}: { + port: DashboardSidebarPort; + forward: PortForward | null; +}): { text: string; title?: string } { + if (port.hostType === "local-device") { + return { text: `localhost:${port.port}` }; + } + if (!forward) return { text: `${port.port} · remote` }; + switch (forward.status.state) { + case "active": + return forward.status.localPort === port.port + ? { text: `${port.port} · forwarded` } + : { text: `${port.port} → localhost:${forward.status.localPort}` }; + case "busy": + return { text: `${port.port} · local port busy` }; + case "error": + return { + text: `${port.port} · ${forward.status.message}`, + title: forward.status.message, + }; + } +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/formatPortRowLabel/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/formatPortRowLabel/index.ts new file mode 100644 index 00000000000..0b7914a320a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/formatPortRowLabel/index.ts @@ -0,0 +1 @@ +export { formatPortRowLabel } from "./formatPortRowLabel"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/RemotePortForwarder/RemotePortForwarder.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/RemotePortForwarder/RemotePortForwarder.tsx new file mode 100644 index 00000000000..f79dc82a880 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/RemotePortForwarder/RemotePortForwarder.tsx @@ -0,0 +1,19 @@ +import { useRouterState } from "@tanstack/react-router"; +import { useRemotePortForwarding } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useRemotePortForwarding"; + +const V2_WORKSPACE_PATH = /^\/v2-workspace\/([^/]+)/; + +/** + * Renders nothing; keeps the main process's port forwards in step with the + * workspace on screen. Reads the live pathname itself, so only this leaf + * re-renders on navigation, and forwards stop the moment the user leaves a + * v2 workspace route. + */ +export function RemotePortForwarder() { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); + const activeWorkspaceId = V2_WORKSPACE_PATH.exec(pathname)?.[1] ?? null; + useRemotePortForwarding(activeWorkspaceId); + return null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/RemotePortForwarder/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/RemotePortForwarder/index.ts new file mode 100644 index 00000000000..0ca98e445c6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/RemotePortForwarder/index.ts @@ -0,0 +1 @@ +export { RemotePortForwarder } from "./RemotePortForwarder"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/TopBarPortsDropdown/components/TopBarPortRow/TopBarPortRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/TopBarPortsDropdown/components/TopBarPortRow/TopBarPortRow.tsx index d0c6067e4b5..a4b814ca145 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/TopBarPortsDropdown/components/TopBarPortRow/TopBarPortRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/TopBarPortsDropdown/components/TopBarPortRow/TopBarPortRow.tsx @@ -2,9 +2,12 @@ import { toast } from "@superset/ui/sonner"; import { cn } from "@superset/ui/utils"; import { LuCopy, LuExternalLink, LuLoaderCircle, LuX } from "react-icons/lu"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; +import { PortForwardBusyActions } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/PortForwardBusyActions"; import { useDashboardSidebarPortKill } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarPortKill"; import type { DashboardSidebarPort } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarPortsData"; import { usePortOpenActions } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/usePortOpenActions"; +import { usePortForward } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/PortForwardsProvider"; +import { formatPortRowLabel } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/formatPortRowLabel"; import { STROKE_WIDTH } from "renderer/screens/main/components/WorkspaceSidebar/constants"; interface TopBarPortRowProps { @@ -23,6 +26,8 @@ export function TopBarPortRow({ port, onNavigate }: TopBarPortRowProps) { const { canOpenInBrowser, portUrl, openExternal, openPrimary } = usePortOpenActions(port); const { copyToClipboard } = useCopyToClipboard(); + const forward = usePortForward(port); + const address = formatPortRowLabel({ port, forward }); const description = [port.label, port.processName] .filter(Boolean) @@ -38,8 +43,8 @@ export function TopBarPortRow({ port, onNavigate }: TopBarPortRowProps) { onNavigate(); }; - // Only offered for local-device ports: `portUrl` is always a localhost - // address, which isn't reachable for a remote host's port. + // Only offered when `portUrl` reaches this machine: a local port, or a + // remote one the main process forwards. const handleCopy = async () => { try { await copyToClipboard(portUrl); @@ -70,10 +75,18 @@ export function TopBarPortRow({ port, onNavigate }: TopBarPortRowProps) { :{port.port} - - {description || `localhost:${port.port}`} + + {description ? `${description} · ${address.text}` : address.text} + {forward?.status.state === "busy" && ( +
+ +
+ )}
{canOpenInBrowser && (