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:
Jokin
2026-07-11 14:12:00 +02:00
co-authored by Claude Fable 5
parent 88af44acfc
commit 0d0e4cfde4
16 changed files with 1033 additions and 31 deletions
+177
View File
@@ -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();
}
}