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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user