P4: SFTP provider, connection store, connection manager UI
- SftpProvider (ssh2): password + key auth, keepalive, clean-dispose vs unexpected-close distinction, POSIX path helpers, s->ms attr normalization - ConnectionStore: plain JSON at appDataDir, 0600, atomic writes, folders - connect/disconnect + store CRUD handlers; sessionClosed push -> panels fall back to local and in-flight jobs fail - UI: session dropdown per panel (local/saved connections/manage), disconnect button, connection manager modal (folders, edit form, save/connect) - electrobun bundler: external cpu-features/*.node (ssh2 optional natives) - RpcService: gate requests on RPC socket open; defer initial panel loads past first render - verified against 192.168.10.122: full op matrix, upload/download/remote-remote recursive transfers, roundtrip integrity, recursive remote delete, store CRUD Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Client, type ConnectConfig, type SFTPWrapper } from "ssh2";
|
||||
import type { ConnectionConfig, EntryType, FileEntry } from "../../shared/types";
|
||||
import type { FileSystemProvider } from "./provider";
|
||||
|
||||
const S_IFMT = 0o170000;
|
||||
const S_IFDIR = 0o040000;
|
||||
const S_IFLNK = 0o120000;
|
||||
|
||||
function typeFromMode(mode: number): EntryType {
|
||||
const fmt = mode & S_IFMT;
|
||||
if (fmt === S_IFDIR) return "dir";
|
||||
if (fmt === S_IFLNK) return "symlink";
|
||||
return "file";
|
||||
}
|
||||
|
||||
function expandTilde(p: string): string {
|
||||
if (p === "~") return os.homedir();
|
||||
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
|
||||
return p;
|
||||
}
|
||||
|
||||
export class SftpProvider implements FileSystemProvider {
|
||||
readonly kind = "sftp" as const;
|
||||
private closedByUs = false;
|
||||
|
||||
private constructor(
|
||||
private client: Client,
|
||||
private sftp: SFTPWrapper,
|
||||
) {}
|
||||
|
||||
/** Resolves once the SSH connection is ready and the SFTP subsystem is open. */
|
||||
static async connect(
|
||||
config: ConnectionConfig,
|
||||
onClose: (reason: string) => void,
|
||||
): Promise<SftpProvider> {
|
||||
const connectConfig: ConnectConfig = {
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
readyTimeout: 20_000,
|
||||
keepaliveInterval: 15_000,
|
||||
keepaliveCountMax: 3,
|
||||
};
|
||||
if (config.auth.kind === "password") {
|
||||
connectConfig.password = config.auth.password;
|
||||
} else {
|
||||
connectConfig.privateKey = readFileSync(expandTilde(config.auth.privateKeyPath));
|
||||
if (config.auth.passphrase) connectConfig.passphrase = config.auth.passphrase;
|
||||
}
|
||||
|
||||
const client = new Client();
|
||||
const provider = await new Promise<SftpProvider>((resolve, reject) => {
|
||||
client
|
||||
.on("ready", () => {
|
||||
client.sftp((err, sftp) => {
|
||||
if (err) {
|
||||
client.end();
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(new SftpProvider(client, sftp));
|
||||
}
|
||||
});
|
||||
})
|
||||
.on("error", reject)
|
||||
.connect(connectConfig);
|
||||
});
|
||||
|
||||
let notified = false;
|
||||
const notify = (reason: string) => {
|
||||
if (notified || provider.closedByUs) return;
|
||||
notified = true;
|
||||
onClose(reason);
|
||||
};
|
||||
client.on("close", () => notify("connection closed"));
|
||||
client.on("error", (err) => notify(err.message));
|
||||
return provider;
|
||||
}
|
||||
|
||||
join = (...parts: string[]) => path.posix.join(...parts);
|
||||
dirname = (p: string) => path.posix.dirname(p);
|
||||
basename = (p: string) => path.posix.basename(p);
|
||||
normalize = (p: string) => path.posix.normalize(p);
|
||||
|
||||
homeDir(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.sftp.realpath(".", (err, abs) => (err ? reject(err) : resolve(abs)));
|
||||
});
|
||||
}
|
||||
|
||||
list(dir: string): Promise<FileEntry[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.sftp.readdir(dir, (err, entries) => {
|
||||
if (err) return reject(err);
|
||||
resolve(
|
||||
entries
|
||||
.filter((e) => e.filename !== "." && e.filename !== "..")
|
||||
.map((e) => ({
|
||||
name: e.filename,
|
||||
path: path.posix.join(dir, e.filename),
|
||||
type: typeFromMode(e.attrs.mode),
|
||||
size: e.attrs.size,
|
||||
mtime: e.attrs.mtime * 1000, // sftp attrs are seconds
|
||||
mode: e.attrs.mode & 0o7777,
|
||||
})),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
stat(p: string): Promise<FileEntry> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.sftp.lstat(p, (err, stats) => {
|
||||
if (err) return reject(err);
|
||||
resolve({
|
||||
name: path.posix.basename(p),
|
||||
path: p,
|
||||
type: typeFromMode(stats.mode),
|
||||
size: stats.size,
|
||||
mtime: stats.mtime * 1000,
|
||||
mode: stats.mode & 0o7777,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
mkdir(p: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.sftp.mkdir(p, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
createEmptyFile(p: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.sftp.writeFile(p, "", { flag: "wx" }, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
rename(oldPath: string, newPath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.sftp.rename(oldPath, newPath, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
deleteFile(p: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.sftp.unlink(p, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
deleteEmptyDir(p: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.sftp.rmdir(p, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
chmod(p: string, mode: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.sftp.chmod(p, mode, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
createReadStream(p: string) {
|
||||
return this.sftp.createReadStream(p);
|
||||
}
|
||||
|
||||
createWriteStream(p: string) {
|
||||
return this.sftp.createWriteStream(p);
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.closedByUs = true;
|
||||
this.client.end();
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -2,6 +2,7 @@ import { BrowserWindow, Updater } from "electrobun/bun";
|
||||
import { BRANDING } from "../shared/branding";
|
||||
import { SessionRegistry } from "./fs/registry";
|
||||
import { createAppRPC } from "./rpc-handlers";
|
||||
import { ConnectionStore } from "./store/connections";
|
||||
import { TransferEngine } from "./transfers/engine";
|
||||
|
||||
const PROD_URL = "views://app/index.html";
|
||||
@@ -26,7 +27,9 @@ async function getMainViewUrl(): Promise<string> {
|
||||
|
||||
const registry = new SessionRegistry();
|
||||
const engine = new TransferEngine(registry);
|
||||
const rpc = createAppRPC(registry, engine);
|
||||
const store = new ConnectionStore();
|
||||
const events = { sessionClosed: (_info: { sessionId: string; reason: string }) => {} };
|
||||
const rpc = createAppRPC({ registry, engine, store, events });
|
||||
const url = await getMainViewUrl();
|
||||
|
||||
new BrowserWindow({
|
||||
@@ -42,5 +45,6 @@ new BrowserWindow({
|
||||
});
|
||||
|
||||
engine.onUpdate = (snapshot) => rpc.send.transferUpdate(snapshot);
|
||||
events.sessionClosed = (info) => rpc.send.sessionClosed(info);
|
||||
|
||||
console.log(`${BRANDING.appName} started (${url})`);
|
||||
|
||||
+57
-14
@@ -2,6 +2,8 @@ import { BrowserView } from "electrobun/bun";
|
||||
import type { AppRPC } from "../shared/rpc";
|
||||
import type { Result } from "../shared/types";
|
||||
import type { SessionRegistry } from "./fs/registry";
|
||||
import { SftpProvider } from "./fs/sftp";
|
||||
import type { ConnectionStore } from "./store/connections";
|
||||
import type { TransferEngine } from "./transfers/engine";
|
||||
import { removeRecursive } from "./transfers/walk";
|
||||
|
||||
@@ -9,6 +11,14 @@ import { removeRecursive } from "./transfers/walk";
|
||||
// default would cut them off.
|
||||
const MAX_REQUEST_TIME_MS = 10 * 60_000;
|
||||
|
||||
export interface AppDeps {
|
||||
registry: SessionRegistry;
|
||||
engine: TransferEngine;
|
||||
store: ConnectionStore;
|
||||
/** Assigned after the rpc object exists (it needs rpc.send). */
|
||||
events: { sessionClosed: (info: { sessionId: string; reason: string }) => void };
|
||||
}
|
||||
|
||||
async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<T>> {
|
||||
try {
|
||||
return { ok: true, ...(await fn()) };
|
||||
@@ -17,16 +27,37 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
|
||||
}
|
||||
}
|
||||
|
||||
const NOT_YET = { ok: false as const, error: "Not implemented yet" };
|
||||
|
||||
export function createAppRPC(registry: SessionRegistry, engine: TransferEngine) {
|
||||
export function createAppRPC({ registry, engine, store, events }: AppDeps) {
|
||||
return BrowserView.defineRPC<AppRPC>({
|
||||
maxRequestTime: MAX_REQUEST_TIME_MS,
|
||||
handlers: {
|
||||
requests: {
|
||||
// ---- sessions ----
|
||||
connect: ({ config }) =>
|
||||
wrap(async () => {
|
||||
let sessionId = "";
|
||||
const provider = await SftpProvider.connect(config, (reason) => {
|
||||
if (!sessionId) return;
|
||||
registry.drop(sessionId);
|
||||
engine.failSession(sessionId, reason);
|
||||
events.sessionClosed({ sessionId, reason });
|
||||
});
|
||||
sessionId = registry.add(provider);
|
||||
const homeDir = await provider.homeDir();
|
||||
console.log(`[rpc] connected ${sessionId} -> ${config.username}@${config.host}`);
|
||||
return { sessionId, homeDir };
|
||||
}),
|
||||
|
||||
disconnect: ({ sessionId }) =>
|
||||
wrap(async () => {
|
||||
await registry.remove(sessionId);
|
||||
console.log(`[rpc] disconnected ${sessionId}`);
|
||||
return {};
|
||||
}),
|
||||
|
||||
// ---- filesystem ----
|
||||
listDir: ({ sessionId, path }) =>
|
||||
wrap(async () => {
|
||||
console.log(`[rpc] listDir ${sessionId} ${path ?? "(home)"}`);
|
||||
const fs = registry.get(sessionId);
|
||||
const dir = fs.normalize(path ?? (await fs.homeDir()));
|
||||
return { path: dir, entries: await fs.list(dir) };
|
||||
@@ -68,10 +99,7 @@ export function createAppRPC(registry: SessionRegistry, engine: TransferEngine)
|
||||
return {};
|
||||
}),
|
||||
|
||||
// P4: SFTP sessions
|
||||
connect: async () => NOT_YET,
|
||||
disconnect: async () => NOT_YET,
|
||||
|
||||
// ---- transfers ----
|
||||
enqueueTransfers: (params) =>
|
||||
wrap(async () => ({ jobIds: engine.enqueue(params) })),
|
||||
|
||||
@@ -86,12 +114,27 @@ export function createAppRPC(registry: SessionRegistry, engine: TransferEngine)
|
||||
engine.dismiss(jobId);
|
||||
return {};
|
||||
}),
|
||||
// P4: connection store
|
||||
getConnections: async () => NOT_YET,
|
||||
upsertConnection: async () => NOT_YET,
|
||||
deleteConnection: async () => NOT_YET,
|
||||
upsertFolder: async () => NOT_YET,
|
||||
deleteFolder: async () => NOT_YET,
|
||||
|
||||
// ---- connection store ----
|
||||
getConnections: () => wrap(async () => ({ store: await store.load() })),
|
||||
|
||||
upsertConnection: ({ connection }) =>
|
||||
wrap(async () => ({ id: await store.upsertConnection(connection) })),
|
||||
|
||||
deleteConnection: ({ id }) =>
|
||||
wrap(async () => {
|
||||
await store.deleteConnection(id);
|
||||
return {};
|
||||
}),
|
||||
|
||||
upsertFolder: ({ folder }) =>
|
||||
wrap(async () => ({ id: await store.upsertFolder(folder) })),
|
||||
|
||||
deleteFolder: ({ id }) =>
|
||||
wrap(async () => {
|
||||
await store.deleteFolder(id);
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ConnFolder, ConnectionsFile, SavedConnection } from "../../shared/types";
|
||||
import { appDataDir } from "./paths";
|
||||
|
||||
const EMPTY: ConnectionsFile = { version: 1, folders: [], connections: [] };
|
||||
|
||||
/**
|
||||
* Plain-JSON store for saved connections (passwords included — the user's
|
||||
* explicit v1 choice). Kept at 0600 and written atomically via tmp+rename.
|
||||
*/
|
||||
export class ConnectionStore {
|
||||
private readonly file = path.join(appDataDir(), "connections.json");
|
||||
private data: ConnectionsFile | null = null;
|
||||
|
||||
async load(): Promise<ConnectionsFile> {
|
||||
if (!this.data) {
|
||||
try {
|
||||
this.data = JSON.parse(await fs.readFile(this.file, "utf8")) as ConnectionsFile;
|
||||
} catch {
|
||||
this.data = structuredClone(EMPTY);
|
||||
}
|
||||
}
|
||||
return this.data;
|
||||
}
|
||||
|
||||
async upsertConnection(connection: SavedConnection): Promise<string> {
|
||||
const data = await this.load();
|
||||
const saved = { ...connection, id: connection.id || crypto.randomUUID() };
|
||||
const index = data.connections.findIndex((c) => c.id === saved.id);
|
||||
if (index >= 0) data.connections[index] = saved;
|
||||
else data.connections.push(saved);
|
||||
await this.save();
|
||||
return saved.id;
|
||||
}
|
||||
|
||||
async deleteConnection(id: string): Promise<void> {
|
||||
const data = await this.load();
|
||||
data.connections = data.connections.filter((c) => c.id !== id);
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async upsertFolder(folder: ConnFolder): Promise<string> {
|
||||
const data = await this.load();
|
||||
const saved = { ...folder, id: folder.id || crypto.randomUUID() };
|
||||
const index = data.folders.findIndex((f) => f.id === saved.id);
|
||||
if (index >= 0) data.folders[index] = saved;
|
||||
else data.folders.push(saved);
|
||||
await this.save();
|
||||
return saved.id;
|
||||
}
|
||||
|
||||
/** Deleting a folder moves its connections to the root. */
|
||||
async deleteFolder(id: string): Promise<void> {
|
||||
const data = await this.load();
|
||||
data.folders = data.folders.filter((f) => f.id !== id);
|
||||
for (const connection of data.connections) {
|
||||
if (connection.folderId === id) connection.folderId = null;
|
||||
}
|
||||
await this.save();
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
await fs.mkdir(path.dirname(this.file), { recursive: true });
|
||||
const tmp = `${this.file}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(this.data, null, "\t"), { mode: 0o600 });
|
||||
await fs.rename(tmp, this.file);
|
||||
await fs.chmod(this.file, 0o600);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { BRANDING } from "../../shared/branding";
|
||||
|
||||
export function appDataDir(): string {
|
||||
const home = os.homedir();
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return path.join(home, "Library", "Application Support", BRANDING.appName);
|
||||
case "win32":
|
||||
return path.join(
|
||||
process.env.APPDATA ?? path.join(home, "AppData", "Roaming"),
|
||||
BRANDING.appName,
|
||||
);
|
||||
default:
|
||||
return path.join(process.env.XDG_CONFIG_HOME ?? path.join(home, ".config"), BRANDING.appName);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user