diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 8851a2af1cb..00000000000 --- a/.mcp.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "mcpServers": { - "superset": { - "type": "http", - "url": "https://api.superset.sh/api/agent/mcp" - }, - "expo-mcp": { - "type": "http", - "url": "https://mcp.expo.dev/mcp" - }, - "maestro": { - "command": "maestro", - "args": ["mcp"] - }, - "neon": { - "type": "http", - "url": "https://mcp.neon.tech/mcp" - }, - "linear": { - "type": "http", - "url": "https://mcp.linear.app/mcp" - }, - "sentry": { - "type": "http", - "url": "https://mcp.sentry.dev/mcp" - }, - "desktop-automation": { - "command": "bun", - "args": ["run", "packages/desktop-mcp/src/bin.ts"] - } - } -} diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 9218df3bdd0..2235de9930b 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -127,7 +127,17 @@ const config: Configuration = { ], hardenedRuntime: true, gatekeeperAssess: false, - notarize: true, + entitlements: join(pkg.resources, "build/entitlements.mac.plist"), + entitlementsInherit: join( + pkg.resources, + "build/entitlements.mac.inherit.plist", + ), + notarize: + process.env.APPLE_NOTARIZE === "true" + ? { + teamId: process.env.APPLE_TEAM_ID || "", + } + : false, extendInfo: { CFBundleName: productName, CFBundleDisplayName: productName, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a523fe5dc54..a2c73f0cdd4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -162,6 +162,7 @@ "semver": "^7.7.3", "shell-quote": "^1.8.3", "simple-git": "^3.30.0", + "ssh2": "^1.16.0", "strip-ansi": "^7.1.2", "superjson": "^2.2.5", "tailwind-merge": "^3.4.0", @@ -190,6 +191,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "@types/semver": "^7.7.1", "@types/shell-quote": "^1.7.5", + "@types/ssh2": "^1.15.4", "@vitejs/plugin-react": "^5.0.1", "bun-types": "^1.3.1", "code-inspector-plugin": "^1.2.2", diff --git a/apps/desktop/scripts/setup-signing.sh b/apps/desktop/scripts/setup-signing.sh new file mode 100755 index 00000000000..c683030d041 --- /dev/null +++ b/apps/desktop/scripts/setup-signing.sh @@ -0,0 +1,385 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# macOS Code Signing & Notarization Setup for Superset Desktop +# ============================================================================= +# +# This script helps set up code signing for local builds. +# +# Prerequisites: +# - Apple Developer account ($99/year) at https://developer.apple.com +# - Xcode or Xcode Command Line Tools installed +# +# Usage: +# ./scripts/setup-signing.sh # Interactive setup +# ./scripts/setup-signing.sh --check # Check current signing status +# ./scripts/setup-signing.sh --build # Build signed .dmg +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DESKTOP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +ENV_FILE="$DESKTOP_DIR/.env.signing" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +info() { echo -e "${CYAN}[info]${NC} $*"; } +ok() { echo -e "${GREEN}[ok]${NC} $*"; } +warn() { echo -e "${YELLOW}[warn]${NC} $*"; } +err() { echo -e "${RED}[error]${NC} $*"; } + +# ----------------------------------------------------------------------------- +# Check: list signing identities +# ----------------------------------------------------------------------------- +check_identities() { + echo "" + info "Scanning Keychain for code signing identities..." + echo "" + + local dev_id_app + dev_id_app=$(security find-identity -v -p codesigning | grep "Developer ID Application" || true) + + local apple_dev + apple_dev=$(security find-identity -v -p codesigning | grep "Apple Development" || true) + + local apple_dist + apple_dist=$(security find-identity -v -p codesigning | grep "Apple Distribution" || true) + + if [ -n "$dev_id_app" ]; then + ok "Developer ID Application (for distribution outside App Store):" + echo " $dev_id_app" + else + warn "No 'Developer ID Application' certificate found." + echo " This is needed to distribute signed builds outside the App Store." + echo " Create one at: https://developer.apple.com/account/resources/certificates/add" + echo " Select: 'Developer ID Application'" + fi + + echo "" + + if [ -n "$apple_dev" ]; then + ok "Apple Development (for local testing):" + echo " $apple_dev" + else + warn "No 'Apple Development' certificate found." + fi + + if [ -n "$apple_dist" ]; then + ok "Apple Distribution:" + echo " $apple_dist" + fi + + echo "" + info "All identities:" + security find-identity -v -p codesigning + echo "" +} + +# ----------------------------------------------------------------------------- +# Check: current signing setup status +# ----------------------------------------------------------------------------- +check_status() { + echo "" + echo "==========================================" + echo " Superset Desktop - Signing Status" + echo "==========================================" + + # Certificates + check_identities + + # Entitlements + local ent_parent="$DESKTOP_DIR/src/resources/build/entitlements.mac.plist" + local ent_child="$DESKTOP_DIR/src/resources/build/entitlements.mac.inherit.plist" + + if [ -f "$ent_parent" ]; then + ok "Parent entitlements: $ent_parent" + else + err "Missing parent entitlements: $ent_parent" + fi + + if [ -f "$ent_child" ]; then + ok "Child entitlements: $ent_child" + else + err "Missing child entitlements: $ent_child" + fi + + # Env file + echo "" + if [ -f "$ENV_FILE" ]; then + ok "Signing env file: $ENV_FILE" + echo " Contents (secrets redacted):" + while IFS= read -r line; do + if [[ "$line" =~ ^# ]] || [[ -z "$line" ]]; then + continue + fi + key="${line%%=*}" + val="${line#*=}" + if [[ "$key" == *"PASSWORD"* ]] || [[ "$key" == *"SECRET"* ]] || [[ "$key" == *"KEY"* ]]; then + echo " $key=****" + else + echo " $line" + fi + done < "$ENV_FILE" + else + warn "No .env.signing file found. Run this script without --check to create one." + fi + + echo "" +} + +# ----------------------------------------------------------------------------- +# Interactive setup +# ----------------------------------------------------------------------------- +setup() { + echo "" + echo "==========================================" + echo " Superset Desktop - Signing Setup" + echo "==========================================" + echo "" + + # Step 1: Check certificates + info "Step 1: Checking certificates..." + check_identities + + local dev_id_app + dev_id_app=$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -1 || true) + + local identity="" + + if [ -n "$dev_id_app" ]; then + # Extract the identity name between quotes + identity=$(echo "$dev_id_app" | sed 's/.*"\(.*\)".*/\1/') + ok "Found: $identity" + echo "" + read -rp "Use this identity? [Y/n] " use_found + if [[ "$use_found" =~ ^[Nn] ]]; then + identity="" + fi + fi + + if [ -z "$identity" ]; then + echo "" + warn "No Developer ID Application certificate found." + echo "" + echo "You have two options:" + echo "" + echo " 1) Create a 'Developer ID Application' certificate" + echo " - Go to https://developer.apple.com/account/resources/certificates/add" + echo " - Select 'Developer ID Application'" + echo " - Follow the CSR process (Keychain Access > Certificate Assistant > Request...)" + echo " - Download and double-click the .cer to install" + echo "" + echo " 2) Use your existing Apple Development cert (for local testing only)" + echo "" + + local apple_dev + apple_dev=$(security find-identity -v -p codesigning | grep "Apple Development" | head -1 || true) + + if [ -n "$apple_dev" ]; then + identity=$(echo "$apple_dev" | sed 's/.*"\(.*\)".*/\1/') + read -rp "Use '$identity' for local builds? [Y/n] " use_dev + if [[ "$use_dev" =~ ^[Nn] ]]; then + echo "" + read -rp "Enter signing identity manually (or press Enter to skip): " identity + fi + else + read -rp "Enter signing identity manually (or press Enter to skip): " identity + fi + fi + + # Step 2: Team ID + echo "" + info "Step 2: Apple Team ID" + echo " Find your Team ID at: https://developer.apple.com/account#MembershipDetailsCard" + echo "" + + local team_id="" + read -rp "Enter your Apple Team ID (10-char alphanumeric): " team_id + + # Step 3: Notarization (only needed for distribution) + echo "" + info "Step 3: Notarization setup (optional, for distribution builds)" + echo "" + echo " Notarization requires either:" + echo " a) Apple ID + app-specific password" + echo " b) App Store Connect API key (recommended for CI)" + echo "" + + local notarize="false" + local apple_id="" + local apple_password="" + local api_key="" + local api_key_id="" + local api_issuer="" + + read -rp "Set up notarization? [y/N] " do_notarize + if [[ "$do_notarize" =~ ^[Yy] ]]; then + notarize="true" + echo "" + echo " Choose method:" + echo " 1) Apple ID + app-specific password" + echo " 2) App Store Connect API key" + echo "" + read -rp " Method [1/2]: " method + + if [ "$method" = "2" ]; then + echo "" + echo " Create an API key at: https://appstoreconnect.apple.com/access/integrations/api" + echo " Download the .p8 key file and note the Key ID and Issuer ID." + echo "" + read -rp " Path to .p8 key file: " api_key + read -rp " API Key ID: " api_key_id + read -rp " Issuer ID: " api_issuer + else + echo "" + echo " Create an app-specific password at: https://appleid.apple.com/account/manage" + echo " (Sign in > App-Specific Passwords > Generate)" + echo "" + read -rp " Apple ID (email): " apple_id + read -rsp " App-specific password: " apple_password + echo "" + fi + fi + + # Step 4: Write .env.signing + echo "" + info "Step 4: Writing $ENV_FILE" + + cat > "$ENV_FILE" <> "$ENV_FILE" <> "$ENV_FILE" </dev/null; then + echo ".env.signing" >> "$gitignore" + ok "Added .env.signing to .gitignore" + fi + else + echo ".env.signing" > "$gitignore" + ok "Created .gitignore with .env.signing" + fi + + echo "" + echo "==========================================" + echo " Setup complete!" + echo "==========================================" + echo "" + echo " To build a signed app:" + echo "" + echo " source apps/desktop/.env.signing" + echo " cd apps/desktop" + echo " bun run prebuild && bun run package" + echo "" + echo " Or use the shortcut:" + echo "" + echo " ./scripts/setup-signing.sh --build" + echo "" +} + +# ----------------------------------------------------------------------------- +# Build signed .dmg +# ----------------------------------------------------------------------------- +build_signed() { + echo "" + info "Building signed Superset Desktop..." + echo "" + + if [ -f "$ENV_FILE" ]; then + info "Loading $ENV_FILE" + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a + else + err "No .env.signing found. Run: ./scripts/setup-signing.sh" + exit 1 + fi + + if [ -z "${CSC_NAME:-}" ]; then + err "CSC_NAME not set. Run setup first." + exit 1 + fi + + info "Signing with: $CSC_NAME" + info "Team ID: ${APPLE_TEAM_ID:-not set}" + info "Notarize: ${APPLE_NOTARIZE:-false}" + echo "" + + cd "$DESKTOP_DIR" + + info "Compiling app..." + bun run prebuild + + info "Packaging & signing..." + bun run package + + echo "" + ok "Build complete! Output in: $DESKTOP_DIR/release/" + ls -lh "$DESKTOP_DIR/release/"*.dmg 2>/dev/null || ls -lh "$DESKTOP_DIR/release/" || true + echo "" +} + +# ----------------------------------------------------------------------------- +# Main +# ----------------------------------------------------------------------------- +case "${1:-}" in + --check) + check_status + ;; + --build) + build_signed + ;; + --help|-h) + echo "Usage: $0 [--check|--build|--help]" + echo "" + echo " (no args) Interactive signing setup" + echo " --check Show current signing status" + echo " --build Build a signed .dmg" + echo " --help Show this help" + ;; + *) + setup + ;; +esac diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 59ebc0263b5..0334aa3ae27 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -18,6 +18,7 @@ import { createPortsRouter } from "./ports"; import { createProjectsRouter } from "./projects"; import { createRingtoneRouter } from "./ringtone"; import { createSettingsRouter } from "./settings"; +import { createSshConnectionsRouter } from "./ssh-connections"; import { createTerminalRouter } from "./terminal"; import { createUiStateRouter } from "./ui-state"; import { createWindowRouter } from "./window"; @@ -44,6 +45,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { hotkeys: createHotkeysRouter(getWindow), external: createExternalRouter(), settings: createSettingsRouter(), + sshConnections: createSshConnectionsRouter(), config: createConfigRouter(), uiState: createUiStateRouter(), ringtone: createRingtoneRouter(), diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index d50017c7792..dd727864253 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -6,6 +6,7 @@ import { projects, type SelectProject, settings, + sshConnections, workspaces, } from "@superset/local-db"; import { TRPCError } from "@trpc/server"; @@ -26,6 +27,7 @@ import { publicProcedure, router } from "../.."; import { activateProject, getBranchWorkspace, + getMaxWorkspaceTabOrder, setLastActiveWorkspace, touchWorkspace, } from "../workspaces/utils/db-helpers"; @@ -43,6 +45,10 @@ import { fetchGitHubOwner, getGitHubAvatarUrl } from "./utils/github"; type Project = SelectProject; +function isRemoteProject(project: Project): boolean { + return project.projectType === "remote"; +} + // Return types for openNew procedure (single project) type OpenNewCanceled = { canceled: true }; type OpenNewError = { canceled: false; error: string }; @@ -306,6 +312,10 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { throw new Error(`Project ${input.projectId} not found`); } + if (isRemoteProject(project)) { + return { branches: [], defaultBranch: "main" }; + } + const git = simpleGit(project.mainRepoPath); // Check if origin remote exists @@ -890,6 +900,10 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { throw new Error(`Project ${input.id} not found`); } + if (isRemoteProject(project)) { + return { success: true, defaultBranch: "main", changed: false }; + } + const remoteDefaultBranch = await refreshDefaultBranch( project.mainRepoPath, ); @@ -1024,6 +1038,10 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { return null; } + if (isRemoteProject(project)) { + return null; + } + if (project.githubOwner) { console.log( "[getGitHubAvatar] Using cached owner:", @@ -1073,6 +1091,10 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { return null; } + if (isRemoteProject(project)) { + return null; + } + const authorName = await getGitAuthorName(project.mainRepoPath); if (!authorName) { return null; @@ -1100,6 +1122,10 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { }); } + if (isRemoteProject(project)) { + return { iconUrl: null }; + } + // Skip if the project already has an icon if (project.iconUrl) { return { iconUrl: project.iconUrl }; @@ -1167,6 +1193,109 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { return { iconUrl }; }), + + createRemote: publicProcedure + .input( + z.object({ + sshConnectionId: z.string(), + remotePath: z.string().min(1), + name: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + // Validate SSH connection exists + const sshConn = localDb + .select() + .from(sshConnections) + .where(eq(sshConnections.id, input.sshConnectionId)) + .get(); + if (!sshConn) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `SSH connection ${input.sshConnectionId} not found`, + }); + } + + // Dedup: check for existing remote project with same path and SSH connection + const allRemoteProjects = localDb + .select() + .from(projects) + .where( + and( + eq(projects.mainRepoPath, input.remotePath), + eq(projects.projectType, "remote"), + ), + ) + .all(); + + for (const existingProject of allRemoteProjects) { + const existingWorkspace = localDb + .select() + .from(workspaces) + .where( + and( + eq(workspaces.projectId, existingProject.id), + eq(workspaces.type, "remote"), + eq(workspaces.sshConnectionId, input.sshConnectionId), + isNull(workspaces.deletingAt), + ), + ) + .get(); + + if (existingWorkspace) { + // Reactivate existing project + workspace + touchWorkspace(existingWorkspace.id); + setLastActiveWorkspace(existingWorkspace.id); + activateProject(existingProject); + return { project: existingProject, workspace: existingWorkspace }; + } + } + + // Create new remote project + const projectName = + input.name || input.remotePath.split("/").pop() || "remote-project"; + + const project = localDb + .insert(projects) + .values({ + mainRepoPath: input.remotePath, + name: projectName, + color: getDefaultProjectColor(), + defaultBranch: "main", + projectType: "remote", + }) + .returning() + .get(); + + // Create remote workspace + const maxTabOrder = getMaxWorkspaceTabOrder(project.id); + const workspaceName = `${sshConn.name}:${input.remotePath.split("/").pop() || "remote"}`; + + const workspace = localDb + .insert(workspaces) + .values({ + projectId: project.id, + type: "remote", + branch: "remote", + name: workspaceName, + sshConnectionId: input.sshConnectionId, + remotePath: input.remotePath, + tabOrder: maxTabOrder + 1, + }) + .returning() + .get(); + + setLastActiveWorkspace(workspace.id); + activateProject(project); + + track("project_created", { + project_id: project.id, + type: "remote", + ssh_host: sshConn.host, + }); + + return { project, workspace }; + }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index e3dc1d6e364..e5b04566ee0 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -32,8 +32,8 @@ const SAFE_ID = z ); /** - * Terminal router using daemon-backed terminal runtime - * Sessions are keyed by paneId and linked to workspaces for cwd resolution + * Terminal router using workspace-selected terminal runtimes. + * Sessions are keyed by paneId and linked to workspaces for runtime + cwd resolution. * * Environment variables set for terminal sessions: * - PATH: Prepends ~/.superset/bin so wrapper scripts intercept agent commands @@ -47,11 +47,15 @@ const SAFE_ID = z */ export const createTerminalRouter = () => { const registry = getWorkspaceRuntimeRegistry(); - const terminal = registry.getDefault().terminal; + const defaultTerminal = registry.getDefault().terminal; + const getTerminalForWorkspace = (workspaceId?: string) => { + if (!workspaceId) return defaultTerminal; + return registry.getForWorkspaceId(workspaceId).terminal; + }; if (DEBUG_TERMINAL) { console.log( "[Terminal Router] Using terminal runtime, capabilities:", - terminal.capabilities, + defaultTerminal.capabilities, ); } @@ -98,7 +102,10 @@ export const createTerminalRouter = () => { if (workspace?.type === "worktree") { assertWorkspaceUsable(workspaceId, workspacePath); } - const cwd = resolveCwd(cwdOverride, workspacePath); + const cwd = + workspace?.type === "remote" + ? (cwdOverride ?? workspacePath ?? undefined) + : resolveCwd(cwdOverride, workspacePath); if (DEBUG_TERMINAL) { console.log("[Terminal Router] createOrAttach called:", { @@ -125,6 +132,7 @@ export const createTerminalRouter = () => { }); try { + const terminal = getTerminalForWorkspace(workspaceId); const result = await terminal.createOrAttach({ paneId, tabId, @@ -199,10 +207,12 @@ export const createTerminalRouter = () => { .input( z.object({ paneId: z.string(), + workspaceId: z.string().optional(), data: z.string(), }), ) .mutation(async ({ input }) => { + const terminal = getTerminalForWorkspace(input.workspaceId); try { terminal.write(input); } catch (error) { @@ -225,19 +235,21 @@ export const createTerminalRouter = () => { ackColdRestore: publicProcedure .input(z.object({ paneId: z.string() })) .mutation(({ input }) => { - terminal.ackColdRestore(input.paneId); + defaultTerminal.ackColdRestore(input.paneId); }), resize: publicProcedure .input( z.object({ paneId: z.string(), + workspaceId: z.string().optional(), cols: z.number(), rows: z.number(), seq: z.number().optional(), }), ) .mutation(async ({ input }) => { + const terminal = getTerminalForWorkspace(input.workspaceId); terminal.resize(input); }), @@ -245,10 +257,12 @@ export const createTerminalRouter = () => { .input( z.object({ paneId: z.string(), + workspaceId: z.string().optional(), signal: z.string().optional(), }), ) .mutation(async ({ input }) => { + const terminal = getTerminalForWorkspace(input.workspaceId); terminal.signal(input); }), @@ -256,9 +270,11 @@ export const createTerminalRouter = () => { .input( z.object({ paneId: z.string(), + workspaceId: z.string().optional(), }), ) .mutation(async ({ input }) => { + const terminal = getTerminalForWorkspace(input.workspaceId); await terminal.kill(input); }), @@ -266,9 +282,11 @@ export const createTerminalRouter = () => { .input( z.object({ paneId: z.string(), + workspaceId: z.string().optional(), }), ) .mutation(async ({ input }) => { + const terminal = getTerminalForWorkspace(input.workspaceId); terminal.detach(input); }), @@ -276,20 +294,22 @@ export const createTerminalRouter = () => { .input( z.object({ paneId: z.string(), + workspaceId: z.string().optional(), }), ) .mutation(async ({ input }) => { + const terminal = getTerminalForWorkspace(input.workspaceId); await terminal.clearScrollback(input); }), listDaemonSessions: publicProcedure.query(async () => { - const { sessions } = await terminal.management.listSessions(); + const { sessions } = await defaultTerminal.management.listSessions(); return { sessions }; }), killAllDaemonSessions: publicProcedure.mutation(async () => { const client = getTerminalHostClient(); - const before = await terminal.management.listSessions(); + const before = await defaultTerminal.management.listSessions(); const beforeIds = before.sessions.map((s) => s.sessionId); console.log( "[killAllDaemonSessions] Before kill:", @@ -300,7 +320,7 @@ export const createTerminalRouter = () => { if (beforeIds.length > 0) { const results = await Promise.allSettled( - beforeIds.map((paneId) => terminal.kill({ paneId })), + beforeIds.map((paneId) => defaultTerminal.kill({ paneId })), ); for (const [index, result] of results.entries()) { if (result.status === "rejected") { @@ -354,7 +374,7 @@ export const createTerminalRouter = () => { killDaemonSessionsForWorkspace: publicProcedure .input(z.object({ workspaceId: z.string() })) .mutation(async ({ input }) => { - const { sessions } = await terminal.management.listSessions(); + const { sessions } = await defaultTerminal.management.listSessions(); const toKill = sessions.filter( (session) => session.workspaceId === input.workspaceId, ); @@ -362,7 +382,7 @@ export const createTerminalRouter = () => { if (toKill.length > 0) { const paneIds = toKill.map((session) => session.sessionId); const results = await Promise.allSettled( - paneIds.map((paneId) => terminal.kill({ paneId })), + paneIds.map((paneId) => defaultTerminal.kill({ paneId })), ); for (const [index, result] of results.entries()) { if (result.status === "rejected") { @@ -383,7 +403,7 @@ export const createTerminalRouter = () => { }), clearTerminalHistory: publicProcedure.mutation(async () => { - await terminal.management.resetHistoryPersistence(); + await defaultTerminal.management.resetHistoryPersistence(); return { success: true }; }), @@ -403,12 +423,14 @@ export const createTerminalRouter = () => { ); for (const session of sessions) { - void terminal.kill({ paneId: session.sessionId }).catch((error) => { - console.warn( - "[restartDaemon] Failed to mark session killed:", - error, - ); - }); + void defaultTerminal + .kill({ paneId: session.sessionId }) + .catch((error) => { + console.warn( + "[restartDaemon] Failed to mark session killed:", + error, + ); + }); } await client.shutdownIfRunning({ killSessions: true }); @@ -431,8 +453,14 @@ export const createTerminalRouter = () => { }), getSession: publicProcedure - .input(z.string()) - .query(async ({ input: paneId }) => { + .input( + z.object({ + paneId: z.string(), + workspaceId: z.string().optional(), + }), + ) + .query(async ({ input: { paneId, workspaceId } }) => { + const terminal = getTerminalForWorkspace(workspaceId); return terminal.getSession(paneId); }), @@ -448,6 +476,10 @@ export const createTerminalRouter = () => { return null; } + if (workspace.type === "remote") { + return workspace.remotePath ?? null; + } + if (!workspace.worktreeId) { return null; } @@ -461,8 +493,14 @@ export const createTerminalRouter = () => { }), stream: publicProcedure - .input(z.string()) - .subscription(({ input: paneId }) => { + .input( + z.object({ + paneId: z.string(), + workspaceId: z.string().optional(), + }), + ) + .subscription(({ input: { paneId, workspaceId } }) => { + const terminal = getTerminalForWorkspace(workspaceId); return observable< | { type: "data"; data: string } | { @@ -527,5 +565,73 @@ export const createTerminalRouter = () => { }; }); }), + + /** + * Upload a clipboard image to a remote workspace for TUI app consumption. + * The image is stored on the remote host and made available through + * clipboard proxy scripts (xclip, pngpaste, etc.) in ~/.superset/bin/. + */ + uploadClipboardImage: publicProcedure + .input( + z.object({ + workspaceId: SAFE_ID, + /** Base64-encoded image data */ + imageData: z.string(), + /** MIME type: image/png, image/jpeg, etc. */ + mimeType: z.string(), + }), + ) + .mutation(async ({ input }) => { + const { workspaceId, imageData, mimeType } = input; + + const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB + const estimatedSize = imageData.length * 0.75; + if (estimatedSize > MAX_IMAGE_SIZE) { + throw new TRPCError({ + code: "PAYLOAD_TOO_LARGE", + message: "Image too large (max 10 MB)", + }); + } + + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .get(); + + if (!workspace || workspace.type !== "remote") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Clipboard image upload is only supported for remote workspaces", + }); + } + + const runtime = registry.getForWorkspaceId(workspaceId); + + if (!("clipboard" in runtime)) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Remote runtime does not support clipboard operations", + }); + } + + const remoteRuntime = + runtime as import("main/lib/workspace-runtime/remote-ssh").RemoteSSHWorkspaceRuntime; + await remoteRuntime.ensureConnected(); + const imageBuffer = Buffer.from(imageData, "base64"); + const remotePath = await remoteRuntime.clipboard.uploadImage( + imageBuffer, + mimeType, + ); + const verification = await remoteRuntime.clipboard + .verifyImageReadPath() + .catch(() => ({ + ok: false, + details: ["verification_exception"], + })); + + return { remotePath, verification }; + }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts index 9b8b8d62651..53fd3cb194a 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -1,6 +1,12 @@ import { homedir } from "node:os"; import { join } from "node:path"; -import { projects, settings, workspaces, worktrees } from "@superset/local-db"; +import { + projects, + settings, + sshConnections, + workspaces, + worktrees, +} from "@superset/local-db"; import { and, eq, isNull, not } from "drizzle-orm"; import { track } from "main/lib/analytics"; import { localDb } from "main/lib/local-db"; @@ -926,6 +932,73 @@ export const createCreateProcedures = () => { }); }), + createRemoteWorkspace: publicProcedure + .input( + z.object({ + projectId: z.string(), + sshConnectionId: z.string(), + remotePath: z.string().min(1), + name: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); + if (!project) { + throw new Error(`Project ${input.projectId} not found`); + } + + const sshConn = localDb + .select() + .from(sshConnections) + .where(eq(sshConnections.id, input.sshConnectionId)) + .get(); + if (!sshConn) { + throw new Error(`SSH connection ${input.sshConnectionId} not found`); + } + + const maxTabOrder = getMaxWorkspaceTabOrder(input.projectId); + const workspaceName = + input.name || + `${sshConn.name}:${input.remotePath.split("/").pop() || "remote"}`; + + const workspace = localDb + .insert(workspaces) + .values({ + projectId: input.projectId, + type: "remote", + branch: "remote", + name: workspaceName, + sshConnectionId: input.sshConnectionId, + remotePath: input.remotePath, + tabOrder: maxTabOrder + 1, + }) + .returning() + .get(); + + setLastActiveWorkspace(workspace.id); + activateProject(project); + + track("workspace_created", { + workspace_id: workspace.id, + project_id: project.id, + type: "remote", + ssh_host: sshConn.host, + }); + + return { + workspace, + initialCommands: null, + worktreePath: input.remotePath, + projectId: project.id, + isInitializing: false, + wasExisting: false, + }; + }), + importAllWorktrees: publicProcedure .input(z.object({ projectId: z.string() })) .mutation(async ({ input }) => { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts index 5ebf8b694ab..d9811e88301 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts @@ -1,4 +1,9 @@ -import { projects, workspaces, worktrees } from "@superset/local-db"; +import { + projects, + sshConnections, + workspaces, + worktrees, +} from "@superset/local-db"; import { TRPCError } from "@trpc/server"; import { eq, isNotNull, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; @@ -63,9 +68,17 @@ export const createQueryProcedures = () => { .get() : null; + const sshConnection = workspace.sshConnectionId + ? localDb + .select() + .from(sshConnections) + .where(eq(sshConnections.id, workspace.sshConnectionId)) + .get() + : null; + return { ...workspace, - type: workspace.type as "worktree" | "branch", + type: workspace.type as "worktree" | "branch" | "remote", worktreePath: getWorkspacePath(workspace) ?? "", project: project ? { @@ -81,6 +94,16 @@ export const createQueryProcedures = () => { gitStatus: worktree.gitStatus ?? null, } : null, + sshConnection: sshConnection + ? { + id: sshConnection.id, + name: sshConnection.name, + host: sshConnection.host, + port: sshConnection.port, + username: sshConnection.username, + connectionStatus: sshConnection.connectionStatus, + } + : null, }; }), @@ -105,6 +128,9 @@ export const createQueryProcedures = () => { allWorktrees.map((wt) => [wt.id, wt.path]), ); + const allSshConns = localDb.select().from(sshConnections).all(); + const sshConnectionMap = new Map(allSshConns.map((c) => [c.id, c])); + const groupsMap = new Map< string, { @@ -117,13 +143,14 @@ export const createQueryProcedures = () => { mainRepoPath: string; hideImage: boolean; iconUrl: string | null; + projectType: string; }; workspaces: Array<{ id: string; projectId: string; worktreeId: string | null; worktreePath: string; - type: "worktree" | "branch"; + type: "worktree" | "branch" | "remote"; branch: string; name: string; tabOrder: number; @@ -132,6 +159,10 @@ export const createQueryProcedures = () => { lastOpenedAt: number; isUnread: boolean; isUnnamed: boolean; + sshConnectionId: string | null; + remotePath: string | null; + sshHost: string | null; + sshUsername: string | null; }>; } >(); @@ -148,6 +179,7 @@ export const createQueryProcedures = () => { mainRepoPath: project.mainRepoPath, hideImage: project.hideImage ?? false, iconUrl: project.iconUrl ?? null, + projectType: project.projectType ?? "local", }, workspaces: [], }); @@ -168,14 +200,24 @@ export const createQueryProcedures = () => { worktreePath = worktreePathMap.get(workspace.worktreeId) ?? ""; } else if (workspace.type === "branch") { worktreePath = group.project.mainRepoPath; + } else if (workspace.type === "remote") { + worktreePath = workspace.remotePath ?? ""; } + const sshConn = workspace.sshConnectionId + ? sshConnectionMap.get(workspace.sshConnectionId) + : null; + group.workspaces.push({ ...workspace, - type: workspace.type as "worktree" | "branch", + type: workspace.type as "worktree" | "branch" | "remote", worktreePath, isUnread: workspace.isUnread ?? false, isUnnamed: workspace.isUnnamed ?? false, + sshConnectionId: workspace.sshConnectionId ?? null, + remotePath: workspace.remotePath ?? null, + sshHost: sshConn?.host ?? null, + sshUsername: sshConn?.username ?? null, }); } } diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts index 0ecccdc326b..26f421480f3 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts @@ -18,6 +18,7 @@ export function getWorktreePath(worktreeId: string): string | undefined { * Gets the working directory path for a workspace. * For worktree workspaces: returns the worktree path * For branch workspaces: returns the main repo path + * For remote workspaces: returns the remote path */ export function getWorkspacePath(workspace: SelectWorkspace): string | null { if (workspace.type === "branch") { @@ -29,6 +30,10 @@ export function getWorkspacePath(workspace: SelectWorkspace): string | null { return project?.mainRepoPath ?? null; } + if (workspace.type === "remote") { + return workspace.remotePath ?? null; + } + // For worktree type, use worktree path if (workspace.worktreeId) { const worktree = localDb diff --git a/apps/desktop/src/main/lib/workspace-runtime/index.ts b/apps/desktop/src/main/lib/workspace-runtime/index.ts index 1b7a962fe73..17a5392bc44 100644 --- a/apps/desktop/src/main/lib/workspace-runtime/index.ts +++ b/apps/desktop/src/main/lib/workspace-runtime/index.ts @@ -18,6 +18,7 @@ export { getWorkspaceRuntimeRegistry, resetWorkspaceRuntimeRegistry, } from "./registry"; +export { RemoteSSHWorkspaceRuntime } from "./remote-ssh"; export type { TerminalCapabilities, TerminalEventSource, diff --git a/apps/desktop/src/main/lib/workspace-runtime/registry.ts b/apps/desktop/src/main/lib/workspace-runtime/registry.ts index c39b7850c2c..ca1c0e804db 100644 --- a/apps/desktop/src/main/lib/workspace-runtime/registry.ts +++ b/apps/desktop/src/main/lib/workspace-runtime/registry.ts @@ -4,18 +4,38 @@ * Process-scoped registry for workspace runtime providers. * The registry is cached for the lifetime of the process. * - * Current behavior: - * - All workspaces use the LocalWorkspaceRuntime - * - The runtime is selected once based on settings (requires restart to change) - * - * Future behavior (cloud readiness): - * - Per-workspace selection based on workspace metadata (cloudWorkspaceId, etc.) - * - Local + cloud workspaces can coexist + * Behavior: + * - Local workspaces use LocalWorkspaceRuntime + * - Remote (SSH) workspaces use RemoteSSHWorkspaceRuntime + * - Per-workspace selection based on workspace metadata (type + sshConnectionId) */ +import { sshConnections, workspaces } from "@superset/local-db"; +import { eq } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; import { LocalWorkspaceRuntime } from "./local"; +import { RemoteSSHWorkspaceRuntime } from "./remote-ssh"; +import { getPoolKey } from "./remote-ssh/types"; import type { WorkspaceRuntime, WorkspaceRuntimeRegistry } from "./types"; +const MAX_REMOTE_RUNTIMES = 20; +const REMOTE_RUNTIME_TTL_MS = 30 * 60 * 1000; // 30 minutes +const REMOTE_RUNTIME_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + +type RemoteRuntimeEntry = { + runtime: RemoteSSHWorkspaceRuntime; + lastUsedAt: number; +}; + +type RemoteRuntimeConfig = { + id: string; + host: string; + port: number; + username: string; + identityFile?: string; + useAgent?: boolean; +}; + // ============================================================================= // Registry Implementation // ============================================================================= @@ -23,29 +43,64 @@ import type { WorkspaceRuntime, WorkspaceRuntimeRegistry } from "./types"; /** * Default registry implementation. * - * Currently returns the same LocalWorkspaceRuntime for all workspaces. - * The interface supports per-workspace selection for future cloud work. + * Routes workspaces to the correct runtime: + * - type="remote" + sshConnectionId → RemoteSSHWorkspaceRuntime + * - Otherwise → LocalWorkspaceRuntime + * + * Remote runtimes are cached by connection pool key (user@host:port). */ class DefaultWorkspaceRuntimeRegistry implements WorkspaceRuntimeRegistry { private localRuntime: LocalWorkspaceRuntime | null = null; + private remoteRuntimes = new Map(); + private staleCleanupTimer: ReturnType | null = null; + + constructor() { + this.staleCleanupTimer = setInterval(() => { + this.cleanupStaleRemoteRuntimes(); + }, REMOTE_RUNTIME_CLEANUP_INTERVAL_MS); + this.staleCleanupTimer.unref?.(); + } /** * Get the runtime for a specific workspace. * - * Currently always returns the local runtime. - * Future: will check workspace metadata to select local vs cloud. + * Checks workspace metadata to select local vs remote SSH runtime. */ - getForWorkspaceId(_workspaceId: string): WorkspaceRuntime { - // Currently all workspaces use the local runtime - // Future: check workspace metadata for cloudWorkspaceId to select cloud runtime + getForWorkspaceId(workspaceId: string): WorkspaceRuntime { + try { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .get(); + + if (workspace?.type === "remote" && workspace.sshConnectionId) { + const sshConn = localDb + .select() + .from(sshConnections) + .where(eq(sshConnections.id, workspace.sshConnectionId)) + .get(); + + if (sshConn) { + return this.getOrCreateRemoteRuntime({ + id: sshConn.id, + host: sshConn.host, + port: sshConn.port, + username: sshConn.username, + identityFile: sshConn.privateKeyPath ?? undefined, + useAgent: sshConn.authMethod === "ssh-agent", + }); + } + } + } catch { + // Database not ready or workspace not found — fall through to default + } + return this.getDefault(); } /** * Get the default runtime (for global/legacy endpoints). - * - * Returns the local runtime, lazily initialized. - * The runtime instance is cached for the lifetime of the process. */ getDefault(): WorkspaceRuntime { if (!this.localRuntime) { @@ -53,6 +108,139 @@ class DefaultWorkspaceRuntimeRegistry implements WorkspaceRuntimeRegistry { } return this.localRuntime; } + + /** + * Get or create a cached remote SSH runtime for a given host config. + */ + private getOrCreateRemoteRuntime(config: { + id: string; + host: string; + port: number; + username: string; + identityFile?: string; + useAgent?: boolean; + }): RemoteSSHWorkspaceRuntime { + const key = getPoolKey(config); + const existingEntry = this.remoteRuntimes.get(key); + if (existingEntry) { + this.touchRemoteRuntime(key, existingEntry.runtime); + this.ensureRemoteRuntimeHealthy(key, existingEntry.runtime, config); + return existingEntry.runtime; + } + + const runtime = new RemoteSSHWorkspaceRuntime(config); + this.touchRemoteRuntime(key, runtime); + this.enforceRemoteRuntimeCapacity(); + + // Lazily connect — don't block registry lookup + this.ensureRemoteRuntimeHealthy(key, runtime, config); + + return runtime; + } + + private ensureRemoteRuntimeHealthy( + key: string, + runtime: RemoteSSHWorkspaceRuntime, + config: RemoteRuntimeConfig, + ): void { + void runtime.ensureConnected().catch((err) => { + const current = this.remoteRuntimes.get(key); + // Runtime was already replaced/evicted; ignore stale failure. + if (!current || current.runtime !== runtime) return; + + console.warn( + `[Registry] SSH runtime unhealthy for ${key}; evicting and retrying with fresh runtime:`, + err, + ); + + this.remoteRuntimes.delete(key); + void runtime.dispose().catch((disposeError) => { + console.warn( + `[Registry] Failed to dispose unhealthy SSH runtime ${key}:`, + disposeError, + ); + }); + + const replacement = new RemoteSSHWorkspaceRuntime(config); + this.touchRemoteRuntime(key, replacement); + this.enforceRemoteRuntimeCapacity(); + + void replacement.ensureConnected().catch((retryErr) => { + const latest = this.remoteRuntimes.get(key); + if (latest?.runtime === replacement) { + this.remoteRuntimes.delete(key); + } + void replacement.dispose().catch((disposeError) => { + console.warn( + `[Registry] Failed to dispose replacement SSH runtime ${key}:`, + disposeError, + ); + }); + console.error( + `[Registry] Failed to reconnect replacement SSH runtime ${key}:`, + retryErr, + ); + }); + }); + } + + private touchRemoteRuntime( + key: string, + runtime: RemoteSSHWorkspaceRuntime, + ): void { + // Re-insert to update access order (Map preserves insertion order), + // enabling least-recently-used eviction via first key. + this.remoteRuntimes.delete(key); + this.remoteRuntimes.set(key, { + runtime, + lastUsedAt: Date.now(), + }); + } + + private enforceRemoteRuntimeCapacity(): void { + while (this.remoteRuntimes.size > MAX_REMOTE_RUNTIMES) { + const lruKey = this.remoteRuntimes.keys().next().value as + | string + | undefined; + if (!lruKey) return; + this.evictRemoteRuntime(lruKey, "lru"); + } + } + + private cleanupStaleRemoteRuntimes(): void { + const now = Date.now(); + for (const [key, entry] of this.remoteRuntimes.entries()) { + if (now - entry.lastUsedAt > REMOTE_RUNTIME_TTL_MS) { + this.evictRemoteRuntime(key, "ttl"); + } + } + } + + private evictRemoteRuntime(key: string, reason: "lru" | "ttl"): void { + const entry = this.remoteRuntimes.get(key); + if (!entry) return; + this.remoteRuntimes.delete(key); + void entry.runtime.dispose().catch((error) => { + console.warn( + `[Registry] Failed to dispose SSH runtime ${key} during ${reason} eviction:`, + error, + ); + }); + } + + dispose(): void { + if (this.staleCleanupTimer) { + clearInterval(this.staleCleanupTimer); + this.staleCleanupTimer = null; + } + + for (const [key, entry] of this.remoteRuntimes.entries()) { + this.remoteRuntimes.delete(key); + void entry.runtime.dispose().catch((error) => { + console.warn(`[Registry] Failed to dispose SSH runtime ${key}:`, error); + }); + } + } } // ============================================================================= @@ -66,11 +254,6 @@ let registryInstance: WorkspaceRuntimeRegistry | null = null; * * The registry is process-scoped and cached. Callers should capture it once * (e.g., when creating a tRPC router) and use it for the lifetime of the router. - * - * This design allows: - * 1. Stable runtime instances (no re-creation on each call) - * 2. Consistent event wiring (same backend for all listeners) - * 3. Future per-workspace selection (local vs cloud) */ export function getWorkspaceRuntimeRegistry(): WorkspaceRuntimeRegistry { if (!registryInstance) { @@ -84,5 +267,8 @@ export function getWorkspaceRuntimeRegistry(): WorkspaceRuntimeRegistry { * This should not be called in production code. */ export function resetWorkspaceRuntimeRegistry(): void { + if (registryInstance instanceof DefaultWorkspaceRuntimeRegistry) { + registryInstance.dispose(); + } registryInstance = null; } diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh.ts new file mode 100644 index 00000000000..af2882ed021 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh.ts @@ -0,0 +1,81 @@ +/** + * Remote SSH Workspace Runtime + * + * Provides WorkspaceRuntime for remote workspaces connected over SSH. + * Uses ssh2 for connections, tmux for session persistence, and SFTP for file access. + */ + +import { + getPoolKey, + RemoteClipboardService, + RemoteGitService, + RemoteSSHTerminalRuntime, + SFTPService, + SSHConnection, + SSHConnectionPool, + type SSHHostConfig, +} from "./remote-ssh/index"; +import type { + TerminalRuntime, + WorkspaceRuntime, + WorkspaceRuntimeId, +} from "./types"; + +/** Shared connection pool across all RemoteSSHWorkspaceRuntime instances */ +const _globalPool = new SSHConnectionPool(); + +export class RemoteSSHWorkspaceRuntime implements WorkspaceRuntime { + readonly id: WorkspaceRuntimeId; + readonly terminal: TerminalRuntime; + readonly capabilities: WorkspaceRuntime["capabilities"]; + readonly git: RemoteGitService; + readonly sftp: SFTPService; + readonly clipboard: RemoteClipboardService; + private connection: SSHConnection | null = null; + + constructor(config: SSHHostConfig) { + this.config = config; + this.id = `remote-ssh:${getPoolKey(config)}`; + + // Create a standalone connection for this runtime + const connection = new SSHConnection(config); + this.connection = connection; + + // Create services + const terminalRuntime = new RemoteSSHTerminalRuntime(connection); + this.terminal = terminalRuntime; + this.git = new RemoteGitService(connection); + this.sftp = new SFTPService(connection); + this.clipboard = new RemoteClipboardService(connection, this.sftp); + + this.capabilities = { + terminal: terminalRuntime.capabilities, + }; + } + + /** + * Lazily initialize the SSH connection. + * Called by the registry before returning the runtime. + */ + async ensureConnected(): Promise { + if (this.connection && !this.connection.isConnected) { + await this.connection.connect(); + } + + // Deploy clipboard proxy scripts after connection is established + await this.clipboard.ensureProxyScripts().catch((err) => { + console.warn( + "[RemoteSSHWorkspaceRuntime] Clipboard proxy deploy failed:", + err, + ); + }); + } + + /** + * Disconnect and clean up. + */ + async dispose(): Promise { + await this.terminal.cleanup(); + this.connection?.disconnect(); + } +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/clipboard-service.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/clipboard-service.ts new file mode 100644 index 00000000000..8c010a7ff84 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/clipboard-service.ts @@ -0,0 +1,341 @@ +/** + * Remote Clipboard Service + * + * Bridges the local clipboard to remote SSH hosts for TUI apps. + * - Uploads images via SFTP to ~/.superset/clipboard/ + * - Deploys clipboard proxy scripts to ~/.superset/bin/ + * - Proxy scripts intercept xclip/pngpaste/xsel/wl-paste reads + * and serve the most recently uploaded image. + */ + +import type { SSHConnection } from "./connection"; +import type { SFTPService } from "./sftp-service"; + +const CLIPBOARD_DIR = ".superset/clipboard"; +const BIN_DIR = ".superset/bin"; +const MAX_CLIPBOARD_FILES = 5; +const PROXY_MARKER = "# superset-clipboard-proxy v6"; + +function resolveImageExtension(mimeType: string): string { + const normalized = mimeType.trim().toLowerCase(); + const known: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", + "image/svg+xml": "svg", + }; + + const mapped = known[normalized]; + if (mapped) return mapped; + + const [, subtypeRaw = ""] = normalized.split("/", 2); + const subtype = subtypeRaw.split("+")[0] ?? ""; + const sanitized = subtype.replace(/[^a-z0-9]/g, ""); + return sanitized || "bin"; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} + +export class RemoteClipboardService { + private connection: SSHConnection; + private sftp: SFTPService; + private proxyDeployed = false; + private _homeDir: string | null = null; + + constructor(connection: SSHConnection, sftp: SFTPService) { + this.connection = connection; + this.sftp = sftp; + } + + setConnection(connection: SSHConnection): void { + this.connection = connection; + this.proxyDeployed = false; + this._homeDir = null; + } + + /** + * Upload an image to the remote clipboard directory. + * Returns the absolute path of the uploaded file. + */ + async uploadImage(imageBuffer: Buffer, mimeType: string): Promise { + const homeDir = await this.getHomeDir(); + const clipboardDir = `${homeDir}/${CLIPBOARD_DIR}`; + const ext = resolveImageExtension(mimeType); + const filename = `clipboard-${Date.now()}.${ext}`; + const remotePath = `${clipboardDir}/${filename}`; + const quotedClipboardDir = shellQuote(clipboardDir); + const quotedRemotePath = shellQuote(remotePath); + const quotedLatestPath = shellQuote(`${clipboardDir}/latest`); + + // Ensure directory exists + await this.connection.exec(`mkdir -p ${quotedClipboardDir}`); + + // Write the image file + await this.sftp.writeFile(remotePath, imageBuffer); + + // Update the "latest" symlink so proxy scripts always serve the newest image + await this.connection.exec( + `ln -sf ${quotedRemotePath} ${quotedLatestPath}`, + ); + + // Cleanup old files in the background + void this.cleanupOldFiles(clipboardDir).catch(() => {}); + + return remotePath; + } + + /** + * Verify remote clipboard read path across Linux and macOS style commands. + */ + async verifyImageReadPath(): Promise<{ ok: boolean; details: string[] }> { + const homeDir = await this.getHomeDir(); + const clipboardDir = `${homeDir}/${CLIPBOARD_DIR}`; + const result = await this.connection.exec( + [ + 'export PATH="$HOME/.superset/bin:$PATH"', + `if [ -f ${shellQuote(`${clipboardDir}/latest`)} ]; then echo latest_exists; else echo latest_missing; fi`, + "if xclip -selection clipboard -t image/png -o >/dev/null 2>&1 || wl-paste --type image/png >/dev/null 2>&1 || SUPERSET_CLIPBOARD_IMAGE=1 pbpaste >/dev/null 2>&1; then echo readImage_ok; else echo readImage_fail; fi", + ].join("; "), + ); + + const details = result.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + const ok = + details.includes("latest_exists") && details.includes("readImage_ok"); + return { ok, details }; + } + + /** + * Deploy clipboard proxy scripts to ~/.superset/bin/. + * Idempotent: skips files that already have the proxy marker. + */ + async ensureProxyScripts(): Promise { + if (this.proxyDeployed) return; + + const homeDir = await this.getHomeDir(); + const binDir = `${homeDir}/${BIN_DIR}`; + const clipboardDir = `${homeDir}/${CLIPBOARD_DIR}`; + const quotedBinDir = shellQuote(binDir); + const quotedClipboardDir = shellQuote(clipboardDir); + + await this.connection.exec( + `mkdir -p ${quotedBinDir} ${quotedClipboardDir}`, + ); + + const scripts = buildProxyScripts(clipboardDir); + + for (const [name, content] of Object.entries(scripts)) { + const scriptPath = `${binDir}/${name}`; + const quotedScriptPath = shellQuote(scriptPath); + + // Check if our proxy is already installed + const check = await this.connection + .exec(`head -2 ${quotedScriptPath} 2>/dev/null`) + .catch(() => ({ stdout: "", stderr: "", code: 1 })); + + if (check.stdout.includes(PROXY_MARKER)) continue; + + await this.sftp.writeFile(scriptPath, content); + await this.connection.exec(`chmod +x ${quotedScriptPath}`); + } + + this.proxyDeployed = true; + } + + private async cleanupOldFiles(clipboardDir: string): Promise { + const quotedClipboardDir = shellQuote(clipboardDir); + await this.connection.exec( + `cd ${quotedClipboardDir} && ls -t clipboard-* 2>/dev/null | tail -n +${MAX_CLIPBOARD_FILES + 1} | xargs rm -f 2>/dev/null`, + ); + } + + private async getHomeDir(): Promise { + if (this._homeDir) return this._homeDir; + + let result: { stdout: string }; + try { + result = await this.connection.exec("echo $HOME"); + } catch (error) { + throw new Error( + `Failed to resolve remote HOME directory: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + const homeDir = result.stdout.trim(); + if (!homeDir || !homeDir.startsWith("/")) { + throw new Error( + `Invalid remote HOME directory resolved: "${homeDir || ""}"`, + ); + } + + this._homeDir = homeDir; + return this._homeDir; + } +} + +// --------------------------------------------------------------------------- +// Proxy script generators +// --------------------------------------------------------------------------- + +function buildProxyScripts(clipboardDir: string): Record { + // Helper to generate the "find real binary" logic, skipping our bin dir + const findReal = (bin: string) => ` +_real="" +IFS=: read -ra _path_parts <<< "$PATH" +for _dir in "\${_path_parts[@]}"; do + [[ -z "$_dir" ]] && continue + case "$_dir" in */.superset/bin) continue ;; esac + if [[ -x "$_dir/${bin}" ]]; then + _real="$_dir/${bin}" + break + fi +done`; + + const xclip = `#!/bin/bash +${PROXY_MARKER} +# Proxy xclip: serve Superset clipboard images, forward everything else. + +_output=false _target="" +_args=("$@") + +while [[ $# -gt 0 ]]; do + case "$1" in + -o|-out|--output) _output=true; shift ;; + -t|-target|--target) _target="$2"; shift 2 ;; + *) shift ;; + esac +done + +if [[ "$_output" == true ]] && [[ "$_target" == TARGETS || "$_target" == targets ]]; then + _f="${clipboardDir}/latest" + if [[ -f "$_f" ]]; then + printf '%s\n' "TARGETS" "image/png" "image/jpeg" "image/webp" + exit 0 + fi +fi + +if [[ "$_output" == true ]] && [[ "$_target" == image/* ]]; then + _f="${clipboardDir}/latest" + [[ -f "$_f" ]] && { cat "$_f"; exit 0; } +fi +${findReal("xclip")} +[[ -n "$_real" ]] && exec "$_real" "\${_args[@]}" +exit 1 +`; + + const pngpaste = `#!/bin/bash +${PROXY_MARKER} +# Proxy pngpaste: serve Superset clipboard images. + +_out="\${1:--}" +_f="${clipboardDir}/latest" + +if [[ -f "$_f" ]]; then + if [[ "$_out" == "-" ]]; then cat "$_f"; else cp "$_f" "$_out"; fi + exit 0 +fi +${findReal("pngpaste")} +[[ -n "$_real" ]] && exec "$_real" "$@" +exit 1 +`; + + const xsel = `#!/bin/bash +${PROXY_MARKER} +# Proxy xsel: serve Superset clipboard images. + +_args=("$@") +_clipboard=false _output=false +while [[ $# -gt 0 ]]; do + case "$1" in + --clipboard|-b) _clipboard=true; shift ;; + --output|-o) _output=true; shift ;; + *) shift ;; + esac +done +if [[ "$_clipboard" == true ]] && [[ "$_output" == true ]]; then + _f="${clipboardDir}/latest" + [[ -f "$_f" ]] && { cat "$_f"; exit 0; } +fi +${findReal("xsel")} +[[ -n "$_real" ]] && exec "$_real" "\${_args[@]}" +exit 1 +`; + + const wlPaste = `#!/bin/bash +${PROXY_MARKER} +# Proxy wl-paste: serve Superset clipboard images. + +_args=("$@") +for _arg in "$@"; do + if [[ "$_arg" == "--list-types" || "$_arg" == "-l" ]]; then + _f="${clipboardDir}/latest" + if [[ -f "$_f" ]]; then + printf '%s\n' "image/png" "image/jpeg" "image/webp" + exit 0 + fi + fi +done +_is_image=false +while [[ $# -gt 0 ]]; do + case "$1" in + --type|-t) + [[ "$2" == image/* ]] && _is_image=true + shift 2 + ;; + *) + [[ "$1" == image/* ]] && _is_image=true + shift + ;; + esac +done +if [[ "$_is_image" == true ]]; then + _f="${clipboardDir}/latest" + [[ -f "$_f" ]] && { cat "$_f"; exit 0; } +fi +${findReal("wl-paste")} +[[ -n "$_real" ]] && exec "$_real" "\${_args[@]}" +exit 1 +`; + + const pbpaste = `#!/bin/bash +${PROXY_MARKER} +# Proxy pbpaste: serve Superset clipboard images for agent image reads only. +# Plain text pbpaste calls must fall through to the real binary. + +_f="${clipboardDir}/latest" +if [[ -f "$_f" ]]; then + # Explicit opt-in for image clipboard reads. + if [[ "$SUPERSET_CLIPBOARD_IMAGE" == "1" ]]; then + cat "$_f" + exit 0 + fi + + # Heuristic fallback: allow known agent CLI callers. + # Examples: + # - claude code + # - codex + # - opencode / cursor-agent + _parent_cmd="$(ps -o command= -p "$PPID" 2>/dev/null | tr '[:upper:]' '[:lower:]')" + if [[ "$_parent_cmd" == *"claude"* || "$_parent_cmd" == *"codex"* || "$_parent_cmd" == *"opencode"* || "$_parent_cmd" == *"cursor-agent"* ]]; then + cat "$_f" + exit 0 + fi +fi +${findReal("pbpaste")} +[[ -n "$_real" ]] && exec "$_real" "$@" +exit 1 +`; + + return { + xclip, + pngpaste, + xsel, + "wl-paste": wlPaste, + pbpaste, + }; +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/connection-pool.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/connection-pool.ts new file mode 100644 index 00000000000..6749b0f4ced --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/connection-pool.ts @@ -0,0 +1,113 @@ +/** + * SSH Connection Pool + * + * Manages shared SSH connections keyed by "user@host:port". + * Reuses existing connections for the same host to avoid duplicate sessions. + * Disposes idle connections after 60s of inactivity. + */ + +import { SSHConnection } from "./connection"; +import { getPoolKey, type SSHHostConfig } from "./types"; + +const IDLE_TIMEOUT_MS = 60_000; + +interface PoolEntry { + connection: SSHConnection; + refCount: number; + idleTimer: ReturnType | null; +} + +export class SSHConnectionPool { + private pool = new Map(); + + /** + * Get or create a shared connection for the given host config. + * Increments the reference count. + */ + async getOrCreate(config: SSHHostConfig): Promise { + const key = getPoolKey(config); + + const existing = this.pool.get(key); + if (existing) { + existing.refCount++; + if (existing.idleTimer) { + clearTimeout(existing.idleTimer); + existing.idleTimer = null; + } + + // Reconnect if needed + if (!existing.connection.isConnected) { + await existing.connection.connect(); + } + + return existing.connection; + } + + const connection = new SSHConnection(config); + await connection.connect(); + + this.pool.set(key, { + connection, + refCount: 1, + idleTimer: null, + }); + + return connection; + } + + /** + * Release a connection back to the pool. + * Starts the idle timer if no more references. + */ + release(config: SSHHostConfig): void { + const key = getPoolKey(config); + const entry = this.pool.get(key); + if (!entry) return; + + entry.refCount = Math.max(0, entry.refCount - 1); + + if (entry.refCount === 0) { + entry.idleTimer = setTimeout(() => { + this.dispose(key); + }, IDLE_TIMEOUT_MS); + } + } + + /** + * Get a connection if it exists in the pool (without creating). + */ + get(config: SSHHostConfig): SSHConnection | null { + const key = getPoolKey(config); + return this.pool.get(key)?.connection ?? null; + } + + /** + * Dispose a specific connection. + */ + private dispose(key: string): void { + const entry = this.pool.get(key); + if (!entry) return; + + if (entry.idleTimer) { + clearTimeout(entry.idleTimer); + } + entry.connection.disconnect(); + this.pool.delete(key); + } + + /** + * Dispose all connections in the pool. + */ + disposeAll(): void { + for (const [key] of this.pool) { + this.dispose(key); + } + } + + /** + * Get the number of active connections. + */ + get size(): number { + return this.pool.size; + } +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/connection.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/connection.ts new file mode 100644 index 00000000000..73db136c101 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/connection.ts @@ -0,0 +1,293 @@ +/** + * SSH Connection + * + * Wraps ssh2.Client with connect/disconnect lifecycle, keepalive, + * auto-reconnect, and convenience methods for shell/exec/sftp. + */ + +import { EventEmitter } from "node:events"; +import type { Client, ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2"; +import { + defaultReconnectStrategy, + type ReconnectStrategy, + waitForReconnect, +} from "./reconnect-strategy"; +import { resolveSSHAuth } from "./ssh-key-resolver"; +import type { SSHConnectionState, SSHHostConfig } from "./types"; + +const KEEPALIVE_INTERVAL = 30_000; +const KEEPALIVE_COUNT_MAX = 3; + +export interface SSHConnectionEvents { + connected: []; + disconnected: [error?: Error]; + reconnecting: [attempt: number]; + error: [error: Error]; + stateChange: [state: SSHConnectionState]; +} + +export class SSHConnection extends EventEmitter { + private client: Client | null = null; + private _state: SSHConnectionState = { status: "disconnected" }; + private reconnectAbort: AbortController | null = null; + private intentionalDisconnect = false; + + readonly config: SSHHostConfig; + private readonly reconnectStrategy: ReconnectStrategy; + + constructor( + config: SSHHostConfig, + reconnectStrategy: ReconnectStrategy = defaultReconnectStrategy, + ) { + super(); + this.config = config; + this.reconnectStrategy = reconnectStrategy; + } + + get state(): SSHConnectionState { + return this._state; + } + + get isConnected(): boolean { + return this._state.status === "connected"; + } + + private setState(state: SSHConnectionState): void { + this._state = state; + this.emit("stateChange", state); + } + + /** + * Establish the SSH connection. + */ + async connect(): Promise { + if (this._state.status === "connected") return; + + this.intentionalDisconnect = false; + this.setState({ status: "connecting" }); + + try { + this.client = await this.createClient(); + this.setState({ status: "connected" }); + this.emit("connected"); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + this.setState({ + status: "disconnected", + lastError: error.message, + }); + this.emit("error", error); + throw error; + } + } + + /** + * Disconnect and stop any reconnection attempts. + */ + disconnect(): void { + this.intentionalDisconnect = true; + this.reconnectAbort?.abort(); + this.reconnectAbort = null; + + if (this.client) { + this.client.end(); + this.client = null; + } + + this.setState({ status: "disconnected" }); + this.emit("disconnected"); + } + + /** + * Open a PTY shell channel. + */ + async openShell( + cols: number, + rows: number, + env?: Record, + ): Promise { + const client = this.ensureConnected(); + return new Promise((resolve, reject) => { + client.shell( + { + term: "xterm-256color", + cols, + rows, + }, + { env }, + (err, stream) => { + if (err) return reject(err); + resolve(stream); + }, + ); + }); + } + + /** + * Execute a command on the remote host. + */ + async exec( + command: string, + cwd?: string, + ): Promise<{ stdout: string; stderr: string; code: number }> { + const client = this.ensureConnected(); + const fullCommand = cwd + ? `cd ${escapeShellArg(cwd)} && ${command}` + : command; + + return new Promise((resolve, reject) => { + client.exec(fullCommand, (err, stream) => { + if (err) return reject(err); + + let stdout = ""; + let stderr = ""; + + stream.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + stream.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + stream.on("close", (code: number) => { + resolve({ stdout, stderr, code: code ?? 0 }); + }); + stream.on("error", reject); + }); + }); + } + + /** + * Get an SFTP client. + */ + async getSftp(): Promise { + const client = this.ensureConnected(); + return new Promise((resolve, reject) => { + client.sftp((err, sftp) => { + if (err) return reject(err); + resolve(sftp); + }); + }); + } + + /** + * Get the underlying ssh2.Client (for direct usage when needed). + */ + getClient(): Client { + return this.ensureConnected(); + } + + private ensureConnected(): Client { + if (!this.client || this._state.status !== "connected") { + throw new Error("SSH connection is not established"); + } + return this.client; + } + + private createClient(): Promise { + const { Client: SSH2Client } = require("ssh2") as typeof import("ssh2"); + const client = new SSH2Client(); + + const auth = resolveSSHAuth({ + identityFile: this.config.identityFile, + useAgent: this.config.useAgent, + }); + + const connectConfig: ConnectConfig = { + host: this.config.host, + port: this.config.port, + username: this.config.username, + keepaliveInterval: KEEPALIVE_INTERVAL, + keepaliveCountMax: KEEPALIVE_COUNT_MAX, + readyTimeout: 30_000, + }; + + if (auth.agent) { + connectConfig.agent = auth.agent; + } else if (auth.privateKey) { + connectConfig.privateKey = auth.privateKey; + } + + return new Promise((resolve, reject) => { + const onReady = () => { + cleanup(); + resolve(client); + }; + + const onError = (err: Error) => { + cleanup(); + reject(err); + }; + + const cleanup = () => { + client.removeListener("ready", onReady); + client.removeListener("error", onError); + }; + + client.once("ready", onReady); + client.once("error", onError); + + // Wire up persistent event handlers + client.on("close", () => { + if (this._state.status === "connected" && !this.intentionalDisconnect) { + this.handleUnexpectedDisconnect(); + } + }); + + client.on("error", (err: Error) => { + if (this._state.status === "connected") { + this.emit("error", err); + } + }); + + client.connect(connectConfig); + }); + } + + private handleUnexpectedDisconnect(): void { + this.client = null; + this.emit("disconnected", new Error("Connection lost")); + + // Start reconnection + this.reconnectAbort?.abort(); + this.reconnectAbort = new AbortController(); + void this.reconnectLoop(this.reconnectAbort.signal); + } + + private async reconnectLoop(signal: AbortSignal): Promise { + for ( + let attempt = 0; + attempt < this.reconnectStrategy.maxAttempts; + attempt++ + ) { + if (signal.aborted || this.intentionalDisconnect) return; + + this.setState({ + status: "reconnecting", + reconnectAttempt: attempt + 1, + }); + this.emit("reconnecting", attempt + 1); + + try { + await waitForReconnect(attempt, this.reconnectStrategy, signal); + if (signal.aborted) return; + + this.client = await this.createClient(); + this.setState({ status: "connected" }); + this.emit("connected"); + return; + } catch { + // Continue to next attempt + } + } + + // All attempts exhausted + this.setState({ + status: "disconnected", + lastError: `Failed to reconnect after ${this.reconnectStrategy.maxAttempts} attempts`, + }); + } +} + +function escapeShellArg(arg: string): string { + return `'${arg.replace(/'/g, "'\\''")}'`; +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/git-service.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/git-service.ts new file mode 100644 index 00000000000..7e17d8e59e5 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/git-service.ts @@ -0,0 +1,146 @@ +/** + * Remote Git Service + * + * Provides git operations on remote hosts via SSH exec. + */ + +import type { SSHConnection } from "./connection"; + +export interface RemoteWorktreeInfo { + path: string; + branch: string; + head: string; + isBare: boolean; +} + +export class RemoteGitService { + private connection: SSHConnection; + + constructor(connection: SSHConnection) { + this.connection = connection; + } + + setConnection(connection: SSHConnection): void { + this.connection = connection; + } + + /** + * List git worktrees in a remote repository. + */ + async worktreeList(repoPath: string): Promise { + const result = await this.connection.exec( + "git worktree list --porcelain", + repoPath, + ); + + if (result.code !== 0) { + throw new Error(`git worktree list failed: ${result.stderr}`); + } + + const worktrees: RemoteWorktreeInfo[] = []; + let current: Partial = {}; + + for (const line of result.stdout.split("\n")) { + if (line.startsWith("worktree ")) { + if (current.path) worktrees.push(current as RemoteWorktreeInfo); + current = { path: line.slice(9), isBare: false }; + } else if (line.startsWith("HEAD ")) { + current.head = line.slice(5); + } else if (line.startsWith("branch ")) { + current.branch = line.slice(7).replace("refs/heads/", ""); + } else if (line === "bare") { + current.isBare = true; + } + } + + if (current.path) worktrees.push(current as RemoteWorktreeInfo); + return worktrees; + } + + /** + * Add a git worktree on the remote host. + */ + async worktreeAdd( + repoPath: string, + worktreePath: string, + branch: string, + baseBranch?: string, + ): Promise { + const args = baseBranch + ? `git worktree add -b ${branch} ${escapeArg(worktreePath)} ${baseBranch}` + : `git worktree add ${escapeArg(worktreePath)} ${branch}`; + + const result = await this.connection.exec(args, repoPath); + if (result.code !== 0) { + throw new Error(`git worktree add failed: ${result.stderr}`); + } + } + + /** + * Remove a git worktree on the remote host. + */ + async worktreeRemove( + repoPath: string, + worktreePath: string, + force = false, + ): Promise { + const forceFlag = force ? " --force" : ""; + const result = await this.connection.exec( + `git worktree remove${forceFlag} ${escapeArg(worktreePath)}`, + repoPath, + ); + if (result.code !== 0) { + throw new Error(`git worktree remove failed: ${result.stderr}`); + } + } + + /** + * Get the git status of a remote repository. + */ + async status(repoPath: string): Promise { + const result = await this.connection.exec( + "git status --porcelain", + repoPath, + ); + return result.stdout; + } + + /** + * Get the current branch of a remote repository. + */ + async getCurrentBranch(repoPath: string): Promise { + const result = await this.connection.exec( + "git rev-parse --abbrev-ref HEAD", + repoPath, + ); + if (result.code !== 0) { + throw new Error(`Failed to get branch: ${result.stderr}`); + } + return result.stdout.trim(); + } + + /** + * Check if a path exists on the remote host. + */ + async pathExists(remotePath: string): Promise { + const result = await this.connection.exec( + `test -d ${escapeArg(remotePath)} && echo "exists"`, + ); + return result.stdout.trim() === "exists"; + } + + /** + * Check if a path is a git repository. + */ + async isGitRepo(remotePath: string): Promise { + const result = await this.connection.exec( + "git rev-parse --is-inside-work-tree 2>/dev/null", + remotePath, + ); + return result.stdout.trim() === "true"; + } +} + +function escapeArg(arg: string): string { + return `'${arg.replace(/'/g, "'\\''")}'`; +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/index.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/index.ts new file mode 100644 index 00000000000..3490433693c --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/index.ts @@ -0,0 +1,33 @@ +/** + * Remote SSH Module - Barrel Exports + */ + +export { RemoteClipboardService } from "./clipboard-service"; +export type { SSHConnectionEvents } from "./connection"; +export { SSHConnection } from "./connection"; +export { SSHConnectionPool } from "./connection-pool"; +export type { RemoteWorktreeInfo } from "./git-service"; +export { RemoteGitService } from "./git-service"; +export type { ReconnectStrategy } from "./reconnect-strategy"; +export { + defaultReconnectStrategy, + waitForReconnect, +} from "./reconnect-strategy"; +export type { CreateOrAttachResult, RemoteSessionCallbacks } from "./session"; +export { RemoteSSHSession } from "./session"; +export type { RemoteFileInfo } from "./sftp-service"; +export { SFTPService } from "./sftp-service"; +export type { ResolvedSSHAuth } from "./ssh-key-resolver"; +export { + isSSHAgentAvailable, + listSSHKeys, + resolveSSHAuth, +} from "./ssh-key-resolver"; +export { RemoteSSHTerminalRuntime } from "./terminal-runtime"; +export { TmuxManager } from "./tmux-manager"; +export type { + SSHConnectionPoolKey, + SSHConnectionState, + SSHHostConfig, +} from "./types"; +export { getPoolKey } from "./types"; diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/reconnect-strategy.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/reconnect-strategy.ts new file mode 100644 index 00000000000..1d886a5e406 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/reconnect-strategy.ts @@ -0,0 +1,60 @@ +/** + * Reconnect Strategy + * + * Exponential backoff with jitter for SSH reconnection. + * Delays: 1s, 2s, 4s, 8s, 16s, max 30s + */ + +const BASE_DELAY_MS = 1000; +const MAX_DELAY_MS = 30_000; +const JITTER_FACTOR = 0.3; + +export interface ReconnectStrategy { + /** Get the delay for the next attempt (ms) */ + getDelay(attempt: number): number; + /** Maximum number of reconnect attempts before giving up */ + maxAttempts: number; +} + +function addJitter(delay: number): number { + const jitter = delay * JITTER_FACTOR * (Math.random() * 2 - 1); + return Math.max(0, Math.round(delay + jitter)); +} + +export const defaultReconnectStrategy: ReconnectStrategy = { + maxAttempts: 10, + + getDelay(attempt: number): number { + const exponential = BASE_DELAY_MS * 2 ** attempt; + const capped = Math.min(exponential, MAX_DELAY_MS); + return addJitter(capped); + }, +}; + +/** + * Wait for the reconnect delay, respecting an abort signal. + */ +export function waitForReconnect( + attempt: number, + strategy: ReconnectStrategy = defaultReconnectStrategy, + signal?: AbortSignal, +): Promise { + const delay = strategy.getDelay(attempt); + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Reconnect aborted")); + return; + } + + const timer = setTimeout(resolve, delay); + + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(new Error("Reconnect aborted")); + }, + { once: true }, + ); + }); +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/session.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/session.ts new file mode 100644 index 00000000000..d55bdb2f414 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/session.ts @@ -0,0 +1,274 @@ +/** + * Remote SSH Session + * + * Manages a single terminal pane over SSH + tmux. + * Handles creation, attachment, reattachment after reconnection, + * and data flow through callbacks. + */ + +import type { ClientChannel } from "ssh2"; +import type { SSHConnection } from "./connection"; +import { TmuxManager } from "./tmux-manager"; + +export interface RemoteSessionCallbacks { + onData: (data: string) => void; + onExit: (exitCode: number, signal?: number) => void; +} + +export interface CreateOrAttachResult { + isNew: boolean; + wasRecovered: boolean; + initialContent: string; +} + +export class RemoteSSHSession { + readonly paneId: string; + readonly workspaceId: string; + + private connection: SSHConnection; + private tmuxManager: TmuxManager; + private channel: ClientChannel | null = null; + private callbacks: RemoteSessionCallbacks; + private _isAlive = false; + private _isDetached = false; + private _cwd: string; + private _lastActive: number = Date.now(); + private useTmux: boolean; + + constructor(params: { + paneId: string; + workspaceId: string; + connection: SSHConnection; + tmuxManager: TmuxManager; + callbacks: RemoteSessionCallbacks; + useTmux: boolean; + cwd: string; + }) { + this.paneId = params.paneId; + this.workspaceId = params.workspaceId; + this.connection = params.connection; + this.tmuxManager = params.tmuxManager; + this.callbacks = params.callbacks; + this.useTmux = params.useTmux; + this._cwd = params.cwd; + } + + get isAlive(): boolean { + return this._isAlive; + } + + get isDetached(): boolean { + return this._isDetached; + } + + get cwd(): string { + return this._cwd; + } + + get lastActive(): number { + return this._lastActive; + } + + /** + * Create a new session or attach to an existing tmux session. + */ + async createOrAttach( + cols: number, + rows: number, + initialCommands?: string[], + env?: Record, + ): Promise { + const sessionName = TmuxManager.sessionName(this.paneId); + + if (this.useTmux) { + const exists = await this.tmuxManager.hasSession(sessionName); + + if (exists) { + // Recover existing session + const scrollback = + await this.tmuxManager.captureScrollback(sessionName); + await this.tmuxManager.resizeSession(sessionName, cols, rows); + await this.tmuxManager.ensureProxyPath(sessionName); + + this.channel = await this.connection.openShell(cols, rows, env); + this.wireChannel(); + + // Attach to the existing tmux session + this.channel.write(`tmux attach-session -t ${sessionName}\n`); + + this._isAlive = true; + this._lastActive = Date.now(); + + return { + isNew: false, + wasRecovered: true, + initialContent: scrollback, + }; + } + + // Create new tmux session + await this.tmuxManager.createSession(sessionName, cols, rows, this._cwd); + await this.tmuxManager.ensureProxyPath(sessionName); + + this.channel = await this.connection.openShell(cols, rows, env); + this.wireChannel(); + + // Attach and run initial commands + this.channel.write(`tmux attach-session -t ${sessionName}\n`); + + if (initialCommands?.length) { + for (const cmd of initialCommands) { + // Send commands through tmux + await this.tmuxManager.sendKeys(sessionName, cmd).catch(() => { + // Fallback: write directly + this.channel?.write(`${cmd}\n`); + }); + } + } + } else { + // Raw shell mode (no tmux) + this.channel = await this.connection.openShell(cols, rows, env); + this.wireChannel(); + + // Ensure clipboard proxy scripts are on PATH + this.channel.write('export PATH="$HOME/.superset/bin:$PATH"\n'); + + if (this._cwd) { + this.channel.write(`cd ${escapeShellArg(this._cwd)}\n`); + } + + if (initialCommands?.length) { + for (const cmd of initialCommands) { + this.channel.write(`${cmd}\n`); + } + } + } + + this._isAlive = true; + this._lastActive = Date.now(); + + return { + isNew: true, + wasRecovered: false, + initialContent: "", + }; + } + + /** + * Write data to the terminal. + */ + write(data: string): void { + if (!this.channel || !this._isAlive) return; + this._lastActive = Date.now(); + this.channel.write(data); + } + + /** + * Resize the terminal. + */ + resize(cols: number, rows: number): void { + if (!this.channel) return; + this.channel.setWindow(rows, cols, 0, 0); + + if (this.useTmux) { + const sessionName = TmuxManager.sessionName(this.paneId); + this.tmuxManager.resizeSession(sessionName, cols, rows).catch(() => {}); + } + } + + /** + * Send a signal to the terminal process. + */ + signal(sig: string): void { + if (!this.channel) return; + // Send Ctrl+C for SIGINT + if (sig === "SIGINT") { + this.channel.write("\x03"); + } + } + + /** + * Kill the terminal session. + */ + async kill(): Promise { + this._isAlive = false; + this._isDetached = false; + + if (this.useTmux) { + const sessionName = TmuxManager.sessionName(this.paneId); + await this.tmuxManager.killSession(sessionName).catch(() => {}); + } + + if (this.channel) { + this.channel.close(); + this.channel = null; + } + } + + /** + * Detach from the session (keep SSH channel and shell alive). + * Data events continue to fire but are silently discarded since + * no renderer subscription is listening. + */ + detach(): void { + this._isDetached = true; + } + + /** + * Reattach after a local detach (navigation away and back). + * Returns true if the session is still alive and was reattached. + */ + reattachLocal(): boolean { + if (!this._isAlive || !this.channel) return false; + this._isDetached = false; + return true; + } + + /** + * Reattach to the session after SSH reconnection. + */ + async reattach( + connection: SSHConnection, + cols: number, + rows: number, + ): Promise { + this.connection = connection; + + if (!this.useTmux) return false; + + const sessionName = TmuxManager.sessionName(this.paneId); + const exists = await this.tmuxManager.hasSession(sessionName); + if (!exists) return false; + await this.tmuxManager.ensureProxyPath(sessionName); + + this.channel = await connection.openShell(cols, rows); + this.wireChannel(); + this.channel.write(`tmux attach-session -t ${sessionName}\n`); + this._isAlive = true; + + return true; + } + + private wireChannel(): void { + if (!this.channel) return; + + this.channel.on("data", (data: Buffer) => { + this._lastActive = Date.now(); + this.callbacks.onData(data.toString()); + }); + + this.channel.on("close", () => { + this._isAlive = false; + this._isDetached = false; + this.callbacks.onExit(0); + }); + + this.channel.stderr?.on("data", (data: Buffer) => { + this.callbacks.onData(data.toString()); + }); + } +} + +function escapeShellArg(arg: string): string { + return `'${arg.replace(/'/g, "'\\''")}'`; +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/sftp-service.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/sftp-service.ts new file mode 100644 index 00000000000..06063496bbf --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/sftp-service.ts @@ -0,0 +1,116 @@ +/** + * SFTP Service + * + * Provides remote file access via SFTP subsystem. + */ + +import type { SSHConnection } from "./connection"; + +export interface RemoteFileInfo { + name: string; + path: string; + size: number; + isDirectory: boolean; + modifiedAt: Date; +} + +export class SFTPService { + private connection: SSHConnection; + + constructor(connection: SSHConnection) { + this.connection = connection; + } + + setConnection(connection: SSHConnection): void { + this.connection = connection; + } + + /** + * Read a remote file's contents. + */ + async readFile(remotePath: string): Promise { + const sftp = await this.connection.getSftp(); + return new Promise((resolve, reject) => { + sftp.readFile(remotePath, (err, data) => { + if (err) return reject(err); + resolve(data); + }); + }); + } + + /** + * Read a remote file as a string. + */ + async readFileText( + remotePath: string, + encoding: BufferEncoding = "utf-8", + ): Promise { + const buffer = await this.readFile(remotePath); + return buffer.toString(encoding); + } + + /** + * Write contents to a remote file. + */ + async writeFile(remotePath: string, data: Buffer | string): Promise { + const sftp = await this.connection.getSftp(); + return new Promise((resolve, reject) => { + sftp.writeFile(remotePath, data, (err) => { + if (err) return reject(err); + resolve(); + }); + }); + } + + /** + * List files in a remote directory. + */ + async readdir(remotePath: string): Promise { + const sftp = await this.connection.getSftp(); + return new Promise((resolve, reject) => { + sftp.readdir(remotePath, (err, list) => { + if (err) return reject(err); + resolve( + list.map((entry) => ({ + name: entry.filename, + path: `${remotePath}/${entry.filename}`, + size: entry.attrs.size, + isDirectory: (entry.attrs.mode & 0o40000) !== 0, + modifiedAt: new Date(entry.attrs.mtime * 1000), + })), + ); + }); + }); + } + + /** + * Check if a remote file/directory exists. + */ + async exists(remotePath: string): Promise { + const sftp = await this.connection.getSftp(); + return new Promise((resolve) => { + sftp.stat(remotePath, (err) => { + resolve(!err); + }); + }); + } + + /** + * Get file stats. + */ + async stat( + remotePath: string, + ): Promise<{ size: number; isDirectory: boolean; modifiedAt: Date }> { + const sftp = await this.connection.getSftp(); + return new Promise((resolve, reject) => { + sftp.stat(remotePath, (err, stats) => { + if (err) return reject(err); + resolve({ + size: stats.size, + isDirectory: (stats.mode & 0o40000) !== 0, + modifiedAt: new Date(stats.mtime * 1000), + }); + }); + }); + } +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/ssh-key-resolver.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/ssh-key-resolver.ts new file mode 100644 index 00000000000..73ddac3c027 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/ssh-key-resolver.ts @@ -0,0 +1,115 @@ +/** + * SSH Key Resolver + * + * Discovers and loads SSH keys in priority order: + * 1. SSH agent (SSH_AUTH_SOCK) + * 2. Explicit identityFile from config + * 3. ~/.ssh/id_ed25519 + * 4. ~/.ssh/id_rsa + * 5. ~/.ssh/id_ecdsa + */ + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const DEFAULT_KEY_NAMES = ["id_ed25519", "id_rsa", "id_ecdsa"]; + +export interface ResolvedSSHAuth { + /** Use SSH agent for authentication */ + agent?: string; + /** Private key contents */ + privateKey?: Buffer; + /** Path to the private key file (for diagnostics) */ + keyPath?: string; +} + +/** + * Resolve SSH authentication credentials in priority order. + */ +export function resolveSSHAuth(options?: { + identityFile?: string; + useAgent?: boolean; +}): ResolvedSSHAuth { + const { identityFile, useAgent = true } = options ?? {}; + + // 1. SSH agent + if (useAgent && process.env.SSH_AUTH_SOCK) { + return { agent: process.env.SSH_AUTH_SOCK }; + } + + // 2. Explicit identity file + if (identityFile) { + const resolved = identityFile.startsWith("~") + ? join(homedir(), identityFile.slice(1)) + : identityFile; + if (existsSync(resolved)) { + return { + privateKey: readFileSync(resolved), + keyPath: resolved, + }; + } + } + + // 3. Default key locations + const sshDir = join(homedir(), ".ssh"); + for (const keyName of DEFAULT_KEY_NAMES) { + const keyPath = join(sshDir, keyName); + if (existsSync(keyPath)) { + return { + privateKey: readFileSync(keyPath), + keyPath, + }; + } + } + + return {}; +} + +/** + * List available SSH keys in ~/.ssh (excluding .pub, known_hosts, config, etc.) + */ +export function listSSHKeys(): Array<{ name: string; path: string }> { + const sshDir = join(homedir(), ".ssh"); + const keys: Array<{ name: string; path: string }> = []; + + try { + const { readdirSync } = require("node:fs"); + const entries = readdirSync(sshDir) as string[]; + const exclude = new Set([ + "known_hosts", + "known_hosts.old", + "config", + "authorized_keys", + "environment", + ]); + + for (const entry of entries) { + if (entry.startsWith(".")) continue; + if (entry.endsWith(".pub")) continue; + if (exclude.has(entry)) continue; + + const fullPath = join(sshDir, entry); + try { + const { statSync } = require("node:fs"); + const stat = statSync(fullPath); + if (stat.isFile()) { + keys.push({ name: entry, path: fullPath }); + } + } catch { + // Skip inaccessible files + } + } + } catch { + // ~/.ssh doesn't exist or isn't readable + } + + return keys; +} + +/** + * Check if SSH agent is available. + */ +export function isSSHAgentAvailable(): boolean { + return !!process.env.SSH_AUTH_SOCK; +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/terminal-runtime.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/terminal-runtime.ts new file mode 100644 index 00000000000..01d37fe7bc0 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/terminal-runtime.ts @@ -0,0 +1,337 @@ +/** + * Remote SSH Terminal Runtime + * + * Implements TerminalRuntime for remote SSH workspaces using ssh2 + tmux. + * + * Key invariants: + * 1. Stream subscriptions MUST NOT complete on session exit + * 2. Event signatures match exactly: data:${paneId}, exit:${paneId}, etc. + * 3. Sync operations (write, resize, signal, detach) must not block or throw + * 4. Concurrent createOrAttach calls for the same paneId are deduplicated + */ + +import { EventEmitter } from "node:events"; +import type { CreateSessionParams, SessionResult } from "../../terminal/types"; +import type { ListSessionsResponse } from "../../terminal-host/types"; +import type { + TerminalCapabilities, + TerminalManagement, + TerminalRuntime, +} from "../types"; +import type { SSHConnection } from "./connection"; +import { RemoteSSHSession } from "./session"; +import { TmuxManager } from "./tmux-manager"; + +export class RemoteSSHTerminalRuntime + extends EventEmitter + implements TerminalRuntime +{ + readonly capabilities: TerminalCapabilities; + readonly management: TerminalManagement; + + private sessions = new Map(); + private pendingSessions = new Map>(); + private connection: SSHConnection; + private tmuxManager: TmuxManager; + private tmuxAvailable = false; + + constructor(connection: SSHConnection) { + super(); + this.setMaxListeners(100); + + this.connection = connection; + this.tmuxManager = new TmuxManager(connection); + + // Will be updated after tmux availability check + this.capabilities = { + persistent: false, + coldRestore: false, + }; + + this.management = { + listSessions: () => this.listRemoteSessions(), + killAllSessions: () => this.killAllRemoteSessions(), + resetHistoryPersistence: async () => { + /* no-op for SSH */ + }, + }; + + // Wire connection events + this.connection.on("disconnected", () => { + for (const [paneId] of this.sessions) { + this.emit(`disconnect:${paneId}`); + } + }); + + this.connection.on("connected", () => { + // Reattach sessions after reconnection + void this.reattachSessions(); + }); + + // Check tmux availability lazily + void this.initTmuxCapability(); + } + + private async initTmuxCapability(): Promise { + try { + this.tmuxAvailable = await this.tmuxManager.isTmuxAvailable(); + if (this.tmuxAvailable) { + (this.capabilities as { persistent: boolean }).persistent = true; + (this.capabilities as { coldRestore: boolean }).coldRestore = true; + } + } catch { + // tmux not available, use raw shell mode + } + } + + // =========================================================================== + // Session Operations + // =========================================================================== + + async createOrAttach(params: CreateSessionParams): Promise { + const { paneId } = params; + + // Deduplicate concurrent calls for the same paneId + const pending = this.pendingSessions.get(paneId); + if (pending) return pending; + + const promise = this.doCreateOrAttach(params); + this.pendingSessions.set(paneId, promise); + + try { + return await promise; + } finally { + this.pendingSessions.delete(paneId); + } + } + + private async doCreateOrAttach( + params: CreateSessionParams, + ): Promise { + const { + paneId, + workspaceId, + cwd, + cols = 80, + rows = 24, + initialCommands, + } = params; + + // Try to reattach to an existing alive session (e.g. after navigation away/back) + const existing = this.sessions.get(paneId); + if (existing?.isAlive) { + const reattached = existing.reattachLocal(); + if (reattached) { + existing.resize(cols, rows); + + // Capture tmux scrollback if available for buffer restoration + let scrollback = ""; + if (this.tmuxAvailable) { + const sessionName = TmuxManager.sessionName(paneId); + scrollback = await this.tmuxManager + .captureScrollback(sessionName) + .catch(() => ""); + } + + return { + isNew: false, + wasRecovered: true, + scrollback, + }; + } + // Channel died while detached — fall through to create new session + this.sessions.delete(paneId); + } + + const session = new RemoteSSHSession({ + paneId, + workspaceId, + connection: this.connection, + tmuxManager: this.tmuxManager, + useTmux: this.tmuxAvailable, + cwd: cwd || "", + callbacks: { + onData: (data) => { + this.emit(`data:${paneId}`, data); + }, + onExit: (exitCode, signal) => { + // CRITICAL: emit exit event but do NOT complete any subscription + this.emit(`exit:${paneId}`, exitCode, signal); + }, + }, + }); + + this.sessions.set(paneId, session); + + const result = await session.createOrAttach(cols, rows, initialCommands); + + return { + isNew: result.isNew, + scrollback: result.initialContent, + wasRecovered: result.wasRecovered, + }; + } + + write(params: { paneId: string; data: string }): void { + const session = this.sessions.get(params.paneId); + if (!session) return; + session.write(params.data); + } + + resize(params: { paneId: string; cols: number; rows: number }): void { + const session = this.sessions.get(params.paneId); + if (!session) return; + session.resize(params.cols, params.rows); + } + + signal(params: { paneId: string; signal?: string }): void { + const session = this.sessions.get(params.paneId); + if (!session) return; + session.signal(params.signal || "SIGINT"); + } + + async kill(params: { paneId: string }): Promise { + const session = this.sessions.get(params.paneId); + if (!session) return; + await session.kill(); + this.sessions.delete(params.paneId); + } + + detach(params: { paneId: string }): void { + const session = this.sessions.get(params.paneId); + if (!session) return; + session.detach(); + } + + clearScrollback(_params: { paneId: string }): void { + // No-op for SSH sessions + } + + ackColdRestore(_paneId: string): void { + // No-op for SSH sessions + } + + getSession( + paneId: string, + ): { isAlive: boolean; cwd: string; lastActive: number } | null { + const session = this.sessions.get(paneId); + if (!session) return null; + return { + isAlive: session.isAlive, + cwd: session.cwd, + lastActive: session.lastActive, + }; + } + + // =========================================================================== + // Workspace Operations + // =========================================================================== + + async killByWorkspaceId( + workspaceId: string, + ): Promise<{ killed: number; failed: number }> { + let killed = 0; + let failed = 0; + + for (const [paneId, session] of this.sessions) { + if (session.workspaceId === workspaceId) { + try { + await session.kill(); + this.sessions.delete(paneId); + killed++; + } catch { + failed++; + } + } + } + + return { killed, failed }; + } + + async getSessionCountByWorkspaceId(workspaceId: string): Promise { + let count = 0; + for (const session of this.sessions.values()) { + if (session.workspaceId === workspaceId && session.isAlive) { + count++; + } + } + return count; + } + + refreshPromptsForWorkspace(workspaceId: string): void { + for (const session of this.sessions.values()) { + if (session.workspaceId === workspaceId && session.isAlive) { + session.write("\n"); + } + } + } + + // =========================================================================== + // Event Source + // =========================================================================== + + detachAllListeners(): void { + this.removeAllListeners(); + } + + // =========================================================================== + // Management + // =========================================================================== + + private async listRemoteSessions(): Promise { + const sessions: ListSessionsResponse["sessions"] = []; + + for (const [, session] of this.sessions) { + sessions.push({ + sessionId: session.paneId, + workspaceId: session.workspaceId, + paneId: session.paneId, + isAlive: session.isAlive, + attachedClients: session.isAlive ? 1 : 0, + pid: null, + }); + } + + return { sessions }; + } + + private async killAllRemoteSessions(): Promise { + const promises: Promise[] = []; + for (const [paneId, session] of this.sessions) { + promises.push( + session.kill().then(() => { + this.sessions.delete(paneId); + }), + ); + } + await Promise.allSettled(promises); + } + + // =========================================================================== + // Reconnection + // =========================================================================== + + private async reattachSessions(): Promise { + if (!this.tmuxAvailable) return; + + for (const [paneId, session] of this.sessions) { + try { + const reattached = await session.reattach(this.connection, 80, 24); + if (!reattached) { + this.emit(`exit:${paneId}`, 0); + this.sessions.delete(paneId); + } + } catch { + this.emit(`error:${paneId}`, new Error("Failed to reattach session")); + } + } + } + + // =========================================================================== + // Cleanup + // =========================================================================== + + async cleanup(): Promise { + await this.killAllRemoteSessions(); + } +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/tmux-manager.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/tmux-manager.ts new file mode 100644 index 00000000000..fd9adda5ad0 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/tmux-manager.ts @@ -0,0 +1,173 @@ +/** + * Tmux Manager + * + * Manages remote tmux sessions for terminal persistence. + * Session naming: superset-{paneId} + * Falls back to raw-shell mode if tmux is not installed. + */ + +import type { SSHConnection } from "./connection"; + +export class TmuxManager { + private connection: SSHConnection; + private _tmuxAvailable: boolean | null = null; + + constructor(connection: SSHConnection) { + this.connection = connection; + } + + /** + * Update the underlying connection (for reconnection scenarios). + */ + setConnection(connection: SSHConnection): void { + this.connection = connection; + } + + /** + * Check if tmux is installed on the remote host. + */ + async isTmuxAvailable(): Promise { + if (this._tmuxAvailable !== null) return this._tmuxAvailable; + + try { + const result = await this.connection.exec("command -v tmux"); + this._tmuxAvailable = result.code === 0; + } catch { + this._tmuxAvailable = false; + } + + return this._tmuxAvailable; + } + + /** + * Check if a tmux session with the given name exists. + */ + async hasSession(name: string): Promise { + try { + const result = await this.connection.exec( + `tmux has-session -t ${escapeSessionName(name)} 2>/dev/null`, + ); + return result.code === 0; + } catch { + return false; + } + } + + /** + * Create a new tmux session. + */ + async createSession( + name: string, + cols: number, + rows: number, + cwd?: string, + ): Promise { + const cdCmd = cwd ? `cd ${escapeShellArg(cwd)} && ` : ""; + const escaped = escapeSessionName(name); + await this.connection.exec( + `${cdCmd}tmux new-session -d -s ${escaped} -x ${cols} -y ${rows}`, + ); + await this.ensureProxyPath(name, { applyToCurrentShell: true }); + } + + /** + * Ensure ~/.superset/bin is available in PATH for this tmux session. + * Safe to call repeatedly (existing sessions, reattach paths, etc.). + */ + async ensureProxyPath( + name: string, + options?: { applyToCurrentShell?: boolean }, + ): Promise { + const escaped = escapeSessionName(name); + await this.connection + .exec( + `tmux set-environment -t ${escaped} PATH "$HOME/.superset/bin:$PATH"`, + ) + .catch(() => {}); + if (options?.applyToCurrentShell) { + await this.connection + .exec( + `tmux send-keys -t ${escaped} 'export PATH="$HOME/.superset/bin:$PATH"' Enter`, + ) + .catch(() => {}); + } + } + + /** + * Capture scrollback from a tmux session. + */ + async captureScrollback(name: string, lines = 1000): Promise { + const result = await this.connection.exec( + `tmux capture-pane -t ${escapeSessionName(name)} -p -S -${lines}`, + ); + return result.stdout; + } + + /** + * Kill a tmux session. + */ + async killSession(name: string): Promise { + await this.connection.exec( + `tmux kill-session -t ${escapeSessionName(name)} 2>/dev/null`, + ); + } + + /** + * List all tmux sessions matching the superset prefix. + */ + async listSessions(): Promise< + Array<{ name: string; created: string; size: string }> + > { + try { + const result = await this.connection.exec( + 'tmux list-sessions -F "#{session_name}|#{session_created}|#{session_width}x#{session_height}" 2>/dev/null', + ); + if (result.code !== 0) return []; + + return result.stdout + .trim() + .split("\n") + .filter((line) => line.startsWith("superset-")) + .map((line) => { + const [name, created, size] = line.split("|"); + return { name, created, size }; + }); + } catch { + return []; + } + } + + /** + * Send keys (a command string) to a tmux session. + */ + async sendKeys(name: string, keys: string): Promise { + await this.connection.exec( + `tmux send-keys -t ${escapeSessionName(name)} ${escapeShellArg(keys)} Enter`, + ); + } + + /** + * Resize a tmux session window. + */ + async resizeSession(name: string, cols: number, rows: number): Promise { + await this.connection.exec( + `tmux resize-window -t ${escapeSessionName(name)} -x ${cols} -y ${rows} 2>/dev/null`, + ); + } + + /** + * Get the tmux session name for a pane ID. + */ + static sessionName(paneId: string): string { + return `superset-${paneId}`; + } +} + +function escapeSessionName(name: string): string { + // Tmux session names can contain alphanumeric, dash, underscore + return name.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +function escapeShellArg(arg: string): string { + return `'${arg.replace(/'/g, "'\\''")}'`; +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/types.ts b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/types.ts new file mode 100644 index 00000000000..d14ed304a0d --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/remote-ssh/types.ts @@ -0,0 +1,30 @@ +/** + * SSH Connection Types + * + * Configuration and state types for remote SSH workspace connections. + */ + +export interface SSHHostConfig { + /** sshConnections table ID */ + id: string; + host: string; + port: number; + username: string; + /** Explicit key path; if omitted, try agent + defaults */ + identityFile?: string; + /** Use SSH_AUTH_SOCK (default true) */ + useAgent?: boolean; +} + +export interface SSHConnectionState { + status: "disconnected" | "connecting" | "connected" | "reconnecting"; + lastError?: string; + reconnectAttempt?: number; +} + +/** Connection pool key: "user@host:port" */ +export type SSHConnectionPoolKey = string; + +export function getPoolKey(config: SSHHostConfig): SSHConnectionPoolKey { + return `${config.username}@${config.host}:${config.port}`; +} diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx index 5f7078199b9..273db064950 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -25,7 +25,15 @@ import { DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; import { toast } from "@superset/ui/sonner"; import { useNavigate } from "@tanstack/react-router"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -36,7 +44,7 @@ import { HiChevronUpDown, HiOutlinePencil, } from "react-icons/hi2"; -import { LuFolderOpen } from "react-icons/lu"; +import { LuArrowLeft, LuFolderOpen, LuLoader, LuServer } from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; import { @@ -44,6 +52,7 @@ import { useOpenNew, } from "renderer/react-query/projects"; import { useCreateWorkspace } from "renderer/react-query/workspaces"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useCloseNewWorkspaceModal, useNewWorkspaceModalOpen, @@ -60,7 +69,7 @@ function generateSlugFromTitle(title: string): string { return sanitizeSegment(title); } -type Mode = "existing" | "new" | "cloud"; +type Mode = "existing" | "new" | "cloud" | "remote"; export function NewWorkspaceModal() { const navigate = useNavigate(); @@ -78,6 +87,7 @@ export function NewWorkspaceModal() { const [baseBranchOpen, setBaseBranchOpen] = useState(false); const [branchSearch, setBranchSearch] = useState(""); const [showAdvanced, setShowAdvanced] = useState(false); + const [showSshForm, setShowSshForm] = useState(false); const titleInputRef = useRef(null); const { data: recentProjects = [] } = @@ -140,6 +150,13 @@ export function NewWorkspaceModal() { setBaseBranch(null); }, [selectedProjectId]); + // Auto-select "remote" mode when a remote project is selected + useEffect(() => { + if (project?.projectType === "remote") { + setMode("remote"); + } + }, [project?.projectType]); + const branchSlug = branchNameEdited ? sanitizeBranchName(branchName) : generateSlugFromTitle(title); @@ -160,6 +177,7 @@ export function NewWorkspaceModal() { setBaseBranch(null); setBranchSearch(""); setShowAdvanced(false); + setShowSshForm(false); }; useEffect(() => { @@ -313,6 +331,15 @@ export function NewWorkspaceModal() { Import repo + { + setSelectedProjectId(null); + setShowSshForm(true); + }} + > + + Add SSH Remote + @@ -321,38 +348,53 @@ export function NewWorkspaceModal() { <>
+ {project?.projectType !== "remote" && ( + <> + + + + )} -
@@ -543,18 +585,279 @@ export function NewWorkspaceModal() {

Coming soon

)} + {mode === "remote" && ( + + )} )} {!selectedProjectId && (
-
- Select a project to get started -
+ {showSshForm ? ( + setShowSshForm(false)} + /> + ) : ( +
+ Select a project to get started +
+ )}
)} ); } + +function RemoteWorkspaceForm({ + projectId, + onSuccess, +}: { + projectId: string; + onSuccess: () => void; +}) { + const navigate = useNavigate(); + const [sshConnectionId, setSshConnectionId] = useState(""); + const [remotePath, setRemotePath] = useState(""); + const [workspaceName, setWorkspaceName] = useState(""); + + const { data: connections = [] } = + electronTrpc.sshConnections.list.useQuery(); + + const createRemote = + electronTrpc.workspaces.createRemoteWorkspace.useMutation({ + onSuccess: (data) => { + toast.success("Remote workspace created"); + onSuccess(); + navigateToWorkspace(data.workspace.id, navigate, { replace: true }); + }, + onError: (err) => { + toast.error(err.message); + }, + }); + + const handleCreate = () => { + if (!sshConnectionId || !remotePath.trim()) return; + createRemote.mutate({ + projectId, + sshConnectionId, + remotePath: remotePath.trim(), + name: workspaceName.trim() || undefined, + }); + }; + + if (connections.length === 0) { + return ( +
+ +

No SSH connections

+

+ Configure an SSH connection first. +

+ +
+ ); + } + + return ( +
+
+ + +
+ +
+ + setRemotePath(e.target.value)} + /> +
+ +
+ + setWorkspaceName(e.target.value)} + /> +
+ + +
+ ); +} + +function SshRemoteProjectForm({ + onSuccess, + onBack, +}: { + onSuccess: () => void; + onBack: () => void; +}) { + const navigate = useNavigate(); + const [sshConnectionId, setSshConnectionId] = useState(""); + const [remotePath, setRemotePath] = useState(""); + const [projectName, setProjectName] = useState(""); + + const { data: connections = [] } = + electronTrpc.sshConnections.list.useQuery(); + const utils = electronTrpc.useUtils(); + + const createRemote = electronTrpc.projects.createRemote.useMutation({ + onSuccess: (data) => { + toast.success("Remote project created"); + utils.projects.getRecents.invalidate(); + utils.workspaces.getAllGrouped.invalidate(); + onSuccess(); + navigateToWorkspace(data.workspace.id, navigate, { replace: true }); + }, + onError: (err) => { + toast.error(err.message); + }, + }); + + const handleCreate = () => { + if (!sshConnectionId || !remotePath.trim()) return; + createRemote.mutate({ + sshConnectionId, + remotePath: remotePath.trim(), + name: projectName.trim() || undefined, + }); + }; + + if (connections.length === 0) { + return ( +
+ +

No SSH connections

+

+ Configure an SSH connection first. +

+
+ + +
+
+ ); + } + + return ( +
+ + +
+ + +
+ +
+ + setRemotePath(e.target.value)} + /> +
+ +
+ + setProjectName(e.target.value)} + /> +
+ + +
+ ); +} diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index 1d79221b3bc..774abf9df2d 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -9,13 +9,13 @@ https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP - default-src 'self': Only allow resources from same origin - - script-src 'self' 'wasm-unsafe-eval' https://*.posthog.com: Allow scripts from same origin + WebAssembly (for xterm ImageAddon) + PostHog + - script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval' https://*.posthog.com: Allow scripts from same origin + eval (needed by dependencies) + WebAssembly (for xterm ImageAddon) + PostHog - style-src 'self' 'unsafe-inline': Allow styles from same origin + inline (needed for CSS-in-JS) - connect-src 'self' ws: wss: %NEXT_PUBLIC_API_URL% %NEXT_PUBLIC_ELECTRIC_URL% %NEXT_PUBLIC_STREAMS_URL% https://*.posthog.com https://*.sentry.io sentry-ipc:: Allow WebSocket + API + Electric proxy + Streams server + PostHog + Sentry - img-src 'self' data: https: Allow images from same origin + data URIs + any HTTPS source (needed for favicons from arbitrary sites in browser history) - font-src 'self': Allow fonts from same origin --> - + diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx index f0523525060..09deb2b2cef 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx @@ -12,7 +12,7 @@ import { HiOutlineSparkles, HiOutlineUser, } from "react-icons/hi2"; -import { LuKeyboard } from "react-icons/lu"; +import { LuKeyboard, LuServer } from "react-icons/lu"; import type { SettingsSection } from "renderer/stores/settings-state"; interface GeneralSettingsProps { @@ -30,7 +30,8 @@ type SettingsRoute = | "/settings/integrations" | "/settings/billing" | "/settings/devices" - | "/settings/api-keys"; + | "/settings/api-keys" + | "/settings/ssh-connections"; const GENERAL_SECTIONS: { id: SettingsRoute; @@ -104,6 +105,12 @@ const GENERAL_SECTIONS: { label: "API Keys", icon: , }, + { + id: "/settings/ssh-connections", + section: "ssh-connections", + label: "SSH Connections", + icon: , + }, ]; export function GeneralSettings({ matchCounts }: GeneralSettingsProps) { diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/ssh-connections/components/SshConnectionsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/ssh-connections/components/SshConnectionsSettings.tsx new file mode 100644 index 00000000000..22d1725299e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/ssh-connections/components/SshConnectionsSettings.tsx @@ -0,0 +1,480 @@ +import { Badge } from "@superset/ui/badge"; +import { Button } from "@superset/ui/button"; +import { Card, CardContent } from "@superset/ui/card"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; +import { toast } from "@superset/ui/sonner"; +import { cn } from "@superset/ui/utils"; +import { useState } from "react"; +import { LuLoader, LuPencil, LuPlus, LuServer, LuTrash2 } from "react-icons/lu"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { SETTING_ITEM_ID } from "../../utils/settings-search"; +import type { SettingItemId } from "../../utils/settings-search/settings-search"; +import { isItemVisible } from "../../utils/settings-search/settings-search"; + +interface SshConnectionsSettingsProps { + visibleItems: SettingItemId[] | null; +} + +export function SshConnectionsSettings({ + visibleItems, +}: SshConnectionsSettingsProps) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + + const { data: connections = [], refetch } = + electronTrpc.sshConnections.list.useQuery(); + const { data: sshKeys = [] } = + electronTrpc.sshConnections.listSshKeys.useQuery(); + const { data: agentStatus } = + electronTrpc.sshConnections.checkSshAgent.useQuery(); + + const createMutation = electronTrpc.sshConnections.create.useMutation({ + onSuccess: () => { + refetch(); + toast.success("SSH connection created"); + }, + onError: (err) => toast.error(err.message), + }); + + const updateMutation = electronTrpc.sshConnections.update.useMutation({ + onSuccess: () => { + refetch(); + toast.success("SSH connection updated"); + }, + onError: (err) => toast.error(err.message), + }); + + const deleteMutation = electronTrpc.sshConnections.delete.useMutation({ + onSuccess: () => { + refetch(); + toast.success("SSH connection deleted"); + }, + onError: (err) => toast.error(err.message), + }); + + const testMutation = electronTrpc.sshConnections.testConnection.useMutation({ + onSuccess: (result) => { + refetch(); + if (result.success) { + toast.success("Connection successful"); + } else { + toast.error("Connection failed", { description: result.error }); + } + }, + onError: (err) => toast.error(err.message), + }); + + const browseMutation = + electronTrpc.sshConnections.browseKeyFile.useMutation(); + + const handleOpenCreate = () => { + setEditingId(null); + setIsDialogOpen(true); + }; + + const handleOpenEdit = (id: string) => { + setEditingId(id); + setIsDialogOpen(true); + }; + + const editingConnection = editingId + ? connections.find((c) => c.id === editingId) + : null; + + return ( +
+
+
+

SSH Connections

+

+ Manage SSH host configurations for remote workspaces. +

+
+
+ +
+
+
+

Saved SSH Connections

+

+ Use saved hosts when creating remote workspaces. +

+
+ {isItemVisible(SETTING_ITEM_ID.SSH_CONNECTIONS_ADD, visibleItems) && ( + + )} +
+ + {isItemVisible(SETTING_ITEM_ID.SSH_CONNECTIONS_LIST, visibleItems) && ( +
+ {connections.length === 0 ? ( + + + +

No SSH connections

+

+ Add a connection to create remote workspaces. +

+
+
+ ) : ( + + {connections.map((conn) => { + const status = conn.connectionStatus as { + status: string; + error?: string; + } | null; + const statusValue = status?.status ?? "untested"; + + return ( +
+ +
+
+ + {conn.name} + + + {statusValue.replace("-", " ")} + +
+

+ {conn.username}@{conn.host}:{conn.port} +

+
+
+ + + +
+
+ ); + })} +
+ )} +
+ )} +
+ + { + const result = await browseMutation.mutateAsync(); + return result.canceled ? null : result.path; + }} + onSave={async (values) => { + if (editingId) { + await updateMutation.mutateAsync({ id: editingId, ...values }); + } else { + await createMutation.mutateAsync(values); + } + setIsDialogOpen(false); + }} + isSaving={createMutation.isPending || updateMutation.isPending} + /> +
+ ); +} + +interface SshConnectionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + connection?: { + name: string; + host: string; + port: number; + username: string; + authMethod: string; + privateKeyPath: string | null; + } | null; + sshKeys: Array<{ name: string; path: string }>; + agentAvailable: boolean; + onBrowseKey: () => Promise; + onSave: (values: { + name: string; + host: string; + port: number; + username: string; + authMethod: "key-file" | "ssh-agent"; + privateKeyPath?: string | null; + }) => Promise; + isSaving: boolean; +} + +function SshConnectionDialog({ + open, + onOpenChange, + connection, + sshKeys, + agentAvailable, + onBrowseKey, + onSave, + isSaving, +}: SshConnectionDialogProps) { + const [name, setName] = useState(""); + const [host, setHost] = useState(""); + const [port, setPort] = useState("22"); + const [username, setUsername] = useState(""); + const [authMethod, setAuthMethod] = useState<"key-file" | "ssh-agent">( + "key-file", + ); + const [privateKeyPath, setPrivateKeyPath] = useState(""); + + // Reset form when dialog opens + const handleOpenChange = (isOpen: boolean) => { + if (isOpen) { + if (connection) { + setName(connection.name); + setHost(connection.host); + setPort(String(connection.port)); + setUsername(connection.username); + setAuthMethod(connection.authMethod as "key-file" | "ssh-agent"); + setPrivateKeyPath(connection.privateKeyPath ?? ""); + } else { + setName(""); + setHost(""); + setPort("22"); + setUsername(""); + setAuthMethod("key-file"); + setPrivateKeyPath(""); + } + } + onOpenChange(isOpen); + }; + + const handleSave = async () => { + if (!isValid) return; + + await onSave({ + name, + host, + port: parsedPort, + username, + authMethod, + privateKeyPath: authMethod === "key-file" ? privateKeyPath : null, + }); + }; + + const handleBrowse = async () => { + const path = await onBrowseKey(); + if (path) setPrivateKeyPath(path); + }; + + const parsedPort = Number(port); + const isPortValid = + Number.isInteger(parsedPort) && parsedPort >= 1 && parsedPort <= 65535; + const portError = + port.trim() && !isPortValid + ? "Port must be an integer between 1 and 65535." + : null; + const isValid = + name.trim() && + host.trim() && + isPortValid && + username.trim() && + (authMethod === "ssh-agent" || privateKeyPath.trim()); + + return ( + + + + + {connection ? "Edit SSH Connection" : "Add SSH Connection"} + + + +
+
+ + setName(e.target.value)} + /> +
+ +
+
+ + setHost(e.target.value)} + /> +
+
+ + setPort(e.target.value)} + /> + {portError && ( +

{portError}

+ )} +
+
+ +
+ + setUsername(e.target.value)} + /> +
+ +
+ + +
+ + {authMethod === "key-file" && ( +
+ +
+ setPrivateKeyPath(e.target.value)} + /> + +
+ {sshKeys.length > 0 && !privateKeyPath && ( +
+ {sshKeys.map((key) => ( + + ))} +
+ )} +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/ssh-connections/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/ssh-connections/page.tsx new file mode 100644 index 00000000000..aff680a3801 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/ssh-connections/page.tsx @@ -0,0 +1,24 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { useSettingsSearchQuery } from "renderer/stores/settings-state"; +import { getMatchingItemsForSection } from "../utils/settings-search"; +import { SshConnectionsSettings } from "./components/SshConnectionsSettings"; + +export const Route = createFileRoute( + "/_authenticated/settings/ssh-connections/", +)({ + component: SshConnectionsSettingsPage, +}); + +function SshConnectionsSettingsPage() { + const searchQuery = useSettingsSearchQuery(); + + const visibleItems = useMemo(() => { + if (!searchQuery) return null; + return getMatchingItemsForSection(searchQuery, "ssh-connections").map( + (item) => item.id, + ); + }, [searchQuery]); + + return ; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index e0cdbdc742d..ff308bc8647 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -51,6 +51,10 @@ export const SETTING_ITEM_ID = { // API Keys API_KEYS_LIST: "api-keys-list", API_KEYS_GENERATE: "api-keys-generate", + + // SSH Connections + SSH_CONNECTIONS_LIST: "ssh-connections-list", + SSH_CONNECTIONS_ADD: "ssh-connections-add", } as const; export type SettingItemId = @@ -728,6 +732,42 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "claude code", ], }, + // SSH Connections + { + id: SETTING_ITEM_ID.SSH_CONNECTIONS_LIST, + section: "ssh-connections", + title: "SSH Connections", + description: "Manage SSH host configurations for remote workspaces", + keywords: [ + "ssh", + "remote", + "server", + "host", + "connection", + "key", + "identity", + "agent", + "port", + "username", + ], + }, + { + id: SETTING_ITEM_ID.SSH_CONNECTIONS_ADD, + section: "ssh-connections", + title: "Add SSH Connection", + description: "Configure a new SSH host for remote workspaces", + keywords: [ + "ssh", + "add", + "new", + "create", + "remote", + "server", + "host", + "connect", + "configure", + ], + }, ]; export function searchSettings(query: string): SettingsItem[] { diff --git a/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx index cc2af19ae9a..d936026f88c 100644 --- a/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx @@ -1,14 +1,17 @@ import { Button } from "@superset/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect, useState } from "react"; -import { LuFolderGit, LuFolderOpen, LuX } from "react-icons/lu"; +import { CiSettings } from "react-icons/ci"; +import { LuFolderGit, LuFolderOpen, LuServer, LuX } from "react-icons/lu"; import { processOpenNewResults, useOpenFromPath, useOpenNew, } from "renderer/react-query/projects"; import { SupersetLogo } from "renderer/routes/sign-in/components/SupersetLogo"; +import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; import { CloneRepoDialog } from "./CloneRepoDialog"; import { InitGitDialog } from "./InitGitDialog"; @@ -27,6 +30,7 @@ export function StartView() { >(null); const [isCloneDialogOpen, setIsCloneDialogOpen] = useState(false); const [isDragOver, setIsDragOver] = useState(false); + const openWorkspaceModal = useOpenNewWorkspaceModal(); const isLoading = openNew.isPending || openFromPath.isPending; @@ -196,6 +200,26 @@ export function StartView() { return (
+ {/* Top bar with settings button */} +
+ + + + + + Settings + + +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: Drop zone for external files */}
Clone Repository +
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx index 6e754563a36..828fb61e9fd 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx @@ -15,11 +15,15 @@ interface Workspace { id: string; projectId: string; worktreePath: string; - type: "worktree" | "branch"; + type: "worktree" | "branch" | "remote"; branch: string; name: string; tabOrder: number; isUnread: boolean; + sshConnectionId?: string | null; + remotePath?: string | null; + sshHost?: string | null; + sshUsername?: string | null; } interface ProjectSectionProps { @@ -175,6 +179,8 @@ export function ProjectSection({ index={wsIndex} shortcutIndex={shortcutBaseIndex + wsIndex} isCollapsed={isSidebarCollapsed} + sshHost={workspace.sshHost} + sshUsername={workspace.sshUsername} /> ))} @@ -233,6 +239,8 @@ export function ProjectSection({ isUnread={workspace.isUnread} index={wsIndex} shortcutIndex={shortcutBaseIndex + wsIndex} + sshHost={workspace.sshHost} + sshUsername={workspace.sshUsername} /> ))} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx index e9a2693cc50..fdb150d3c65 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx @@ -24,7 +24,7 @@ interface CollapsedWorkspaceItemProps { id: string; name: string; branch: string; - type: "worktree" | "branch"; + type: "worktree" | "branch" | "remote"; isActive: boolean; isUnread: boolean; workspaceStatus: ActivePaneStatus | null; @@ -35,6 +35,8 @@ interface CollapsedWorkspaceItemProps { onClick: () => void; onDeleteClick: () => void; onCopyPath: () => void; + sshHost?: string | null; + sshUsername?: string | null; } export function CollapsedWorkspaceItem({ @@ -52,8 +54,11 @@ export function CollapsedWorkspaceItem({ onClick, onDeleteClick, onCopyPath, + sshHost, + sshUsername, }: CollapsedWorkspaceItemProps) { const isBranchWorkspace = type === "branch"; + const isRemoteWorkspace = type === "remote"; const collapsedButton = (