From 235487ab175f57afa228d377bf5ec8cb2af3b062 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 30 Aug 2026 06:48:53 +0000 Subject: [PATCH 1/2] desktop: let the pane connect to a host that only lives in ssh_config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server picker offers saved connections *and* hosts read from ~/.ssh/config, which are deliberately never persisted. But every remote operation resolved its id with db.findConnection(), which only ever sees saved rows — so selecting an ssh_config host threw "That connection no longer exists." and the pane rendered "Could not read this directory". On a machine with no saved connections that was every server in the list, which is exactly what it looked like from the outside: the remote pane simply did not work. Both lookups now go through resolveConnection(), which falls back to the ssh_config list for an `ssh-config:` id. Saved rows still win, so importing a host and then editing it is not undone by the file it came from. Fixes browsing, mkdir, rename, delete, test and transfers alike. Verified end to end against a real ssh_config host: `ssh-config:dev` resolves and lists its home directory over the same SFTP path the pane uses. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GTQ3RzTAey9nT6r1kbGBCd --- apps/desktop/electron/main/ipc.ts | 20 +++---- .../main/services/connections.test.ts | 54 +++++++++++++++++++ .../electron/main/services/connections.ts | 29 ++++++++++ .../electron/main/services/transfers.ts | 11 ++-- 4 files changed, 93 insertions(+), 21 deletions(-) create mode 100644 apps/desktop/electron/main/services/connections.test.ts create mode 100644 apps/desktop/electron/main/services/connections.ts diff --git a/apps/desktop/electron/main/ipc.ts b/apps/desktop/electron/main/ipc.ts index 183ea60..b3cc42a 100644 --- a/apps/desktop/electron/main/ipc.ts +++ b/apps/desktop/electron/main/ipc.ts @@ -16,6 +16,7 @@ import { TransferRequestSchema, type IpcResult, } from '../shared/contract.js' +import { requireConnection } from './services/connections.js' import { browserFor, dropSession, sessionFor } from './services/sessions.js' import { store } from './services/store.js' import { cancelTransfer, previewTransfer, startTransfer } from './services/transfers.js' @@ -71,8 +72,7 @@ export function registerIpc(): void { handle(IPC.connectionsTest, z.object({ id: z.string().min(1) }), async ({ id }) => { const db = await store() - const connection = await db.findConnection(id) - if (!connection) throw new Error('That connection no longer exists.') + const connection = await requireConnection(id) const session = await sessionFor(connection) const report = await probeConnection(session, connection.rsyncPath) @@ -154,9 +154,7 @@ export function registerIpc(): void { }) handle(IPC.fsListRemote, RemotePathRequestSchema, async ({ connectionId, path }) => { - const db = await store() - const connection = await db.findConnection(connectionId) - if (!connection) throw new Error('That connection no longer exists.') + const connection = await requireConnection(connectionId) const browser = await browserFor(connection) try { @@ -167,9 +165,7 @@ export function registerIpc(): void { }) handle(IPC.fsMkdirRemote, RemotePathRequestSchema, async ({ connectionId, path }) => { - const db = await store() - const connection = await db.findConnection(connectionId) - if (!connection) throw new Error('That connection no longer exists.') + const connection = await requireConnection(connectionId) const browser = await browserFor(connection) try { await browser.mkdir(path) @@ -180,9 +176,7 @@ export function registerIpc(): void { }) handle(IPC.fsRenameRemote, RenameRequestSchema, async ({ connectionId, from, to }) => { - const db = await store() - const connection = await db.findConnection(connectionId) - if (!connection) throw new Error('That connection no longer exists.') + const connection = await requireConnection(connectionId) const browser = await browserFor(connection) try { await browser.rename(from, to) @@ -196,9 +190,7 @@ export function registerIpc(): void { IPC.fsDeleteRemote, z.object({ connectionId: z.string().min(1), path: PathSchema, isDirectory: z.boolean() }), async ({ connectionId, path, isDirectory }) => { - const db = await store() - const connection = await db.findConnection(connectionId) - if (!connection) throw new Error('That connection no longer exists.') + const connection = await requireConnection(connectionId) const browser = await browserFor(connection) try { if (isDirectory) await browser.rmdir(path) diff --git a/apps/desktop/electron/main/services/connections.test.ts b/apps/desktop/electron/main/services/connections.test.ts new file mode 100644 index 0000000..dcd03f3 --- /dev/null +++ b/apps/desktop/electron/main/services/connections.test.ts @@ -0,0 +1,54 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Connection } from '@diskpush/schemas' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +const saved = new Map() + +vi.mock('./store.js', () => ({ + store: async () => ({ findConnection: async (id: string) => saved.get(id) ?? null }), +})) + +const { requireConnection, resolveConnection } = await import('./connections.js') + +beforeAll(() => { + const directory = mkdtempSync(join(tmpdir(), 'diskpush-ssh-config-')) + const path = join(directory, 'config') + writeFileSync(path, 'Host seed1\n HostName seed1.example.com\n User deploy\n Port 2222\n') + process.env.DISKPUSH_SSH_CONFIG = path +}) + +afterEach(() => saved.clear()) + +describe('resolveConnection', () => { + // The bug this exists to prevent: the picker offers ~/.ssh/config hosts but + // never saves them, so a database-only lookup made every one of them fail + // with "That connection no longer exists" the moment it was selected — and + // on a machine with no saved connections, that was every server in the list. + it('resolves a host that only exists in ~/.ssh/config', async () => { + const connection = await resolveConnection('ssh-config:seed1') + expect(connection?.host).toBe('seed1.example.com') + expect(connection?.username).toBe('deploy') + expect(connection?.port).toBe(2222) + }) + + it('prefers the saved row when an imported host was edited afterwards', async () => { + saved.set('ssh-config:seed1', { id: 'ssh-config:seed1', host: 'edited.example.com' } as Connection) + expect((await resolveConnection('ssh-config:seed1'))?.host).toBe('edited.example.com') + }) + + it('resolves a saved connection by its own id', async () => { + saved.set('abc-123', { id: 'abc-123', host: 'saved.example.com' } as Connection) + expect((await resolveConnection('abc-123'))?.host).toBe('saved.example.com') + }) + + it('does not read ssh_config for an id that never came from it', async () => { + expect(await resolveConnection('deleted-uuid')).toBeNull() + }) + + it('still reports a host that has since left ssh_config', async () => { + expect(await resolveConnection('ssh-config:gone')).toBeNull() + await expect(requireConnection('ssh-config:gone')).rejects.toThrow('That connection no longer exists.') + }) +}) diff --git a/apps/desktop/electron/main/services/connections.ts b/apps/desktop/electron/main/services/connections.ts new file mode 100644 index 0000000..1a0312f --- /dev/null +++ b/apps/desktop/electron/main/services/connections.ts @@ -0,0 +1,29 @@ +import { sshConfigConnections } from '@diskpush/ssh-core' +import type { Connection } from '@diskpush/schemas' +import { store } from './store.js' + +/** The id prefix `sshConfigConnections()` gives hosts it reads from ~/.ssh/config. */ +const SSH_CONFIG_PREFIX = 'ssh-config:' + +/** + * Resolves a connection id the renderer sent back to us. + * + * The picker offers saved connections *and* hosts from ~/.ssh/config, and the + * latter are deliberately never persisted — so looking only in the database + * makes every ssh_config host unusable the moment it is selected. Saved rows + * still win: importing a host and then editing it must not be undone by the + * file it came from. + */ +export async function resolveConnection(id: string): Promise { + const saved = await (await store()).findConnection(id) + if (saved) return saved + if (!id.startsWith(SSH_CONFIG_PREFIX)) return null + return sshConfigConnections().find((connection) => connection.id === id) ?? null +} + +/** As `resolveConnection`, for the callers that cannot proceed without one. */ +export async function requireConnection(id: string): Promise { + const connection = await resolveConnection(id) + if (!connection) throw new Error('That connection no longer exists.') + return connection +} diff --git a/apps/desktop/electron/main/services/transfers.ts b/apps/desktop/electron/main/services/transfers.ts index 5f661c8..df86f66 100644 --- a/apps/desktop/electron/main/services/transfers.ts +++ b/apps/desktop/electron/main/services/transfers.ts @@ -14,6 +14,7 @@ import { defaultRsyncOptions, summarizeChanges, type Change, type Endpoint, type import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { IPC, type EndpointRef, type TransferOptions, type TransferRequest } from '../../shared/contract.js' +import { requireConnection, resolveConnection } from './connections.js' import { store } from './store.js' const execFileAsync = promisify(execFile) @@ -30,9 +31,7 @@ const running = new Map() async function resolveEndpoint(ref: EndpointRef): Promise<{ endpoint: Endpoint; connectionId: string | null }> { if (ref.type === 'local') return { endpoint: { type: 'local', path: ref.path }, connectionId: null } - const db = await store() - const connection = await db.findConnection(ref.connectionId) - if (!connection) throw new Error('That connection no longer exists.') + const connection = await requireConnection(ref.connectionId) return { endpoint: { @@ -49,8 +48,7 @@ async function resolveEndpoint(ref: EndpointRef): Promise<{ endpoint: Endpoint; async function shellOptionsFor(connectionId: string | null) { if (!connectionId) return {} - const db = await store() - const connection = await db.findConnection(connectionId) + const connection = await resolveConnection(connectionId) if (!connection) return {} return { keyPath: connection.authType === 'key' || connection.authType === 'key-passphrase' ? connection.keyPath : null, @@ -111,8 +109,7 @@ async function buildPlan(request: TransferRequest, overrides: Partial Date: Sun, 30 Aug 2026 06:52:36 +0000 Subject: [PATCH 2/2] brand: 150px logo in both the desktop header and the site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop lockup was 22px tall, about 66px wide — small enough to read as a favicon rather than a brand. It is now 150px wide, and the header grows from 52px to 72px so the 3:1 lockup sits in it with room above and below rather than touching the border. The site's was 240px (w-60); it comes down to the same 150px so the two surfaces match. Verified in a headless render of the real export, under the app's real CSP, in both themes: header 72px, logo 150x50, 11px clear top and bottom, no CSP refusals. The site's build emits width:150px. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GTQ3RzTAey9nT6r1kbGBCd --- apps/desktop/src/app/page.tsx | 6 +++--- apps/web/components/chrome.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/page.tsx b/apps/desktop/src/app/page.tsx index cef1ef4..f553af1 100644 --- a/apps/desktop/src/app/page.tsx +++ b/apps/desktop/src/app/page.tsx @@ -236,7 +236,7 @@ export default function Workspace() { return (
-
+
{/* Two files, not one. logo.dark.png is the lockup FOR a dark background: its "Disk" is white, so on the light theme it vanished and the header @@ -248,7 +248,7 @@ export default function Workspace() { alt="DiskPush" width={2172} height={724} - className="hidden h-[22px] w-auto dark:block" + className="hidden h-auto w-[150px] dark:block" priority /> DiskPush
diff --git a/apps/web/components/chrome.tsx b/apps/web/components/chrome.tsx index 4b1f391..0ced516 100644 --- a/apps/web/components/chrome.tsx +++ b/apps/web/components/chrome.tsx @@ -67,9 +67,9 @@ function Logo() { alt="DiskPush" width={2172} height={724} - className="logo-dark w-60 h-auto" + className="logo-dark w-[150px] h-auto" /> - DiskPush + DiskPush ) }