External editor via cmd/start + exe picker; Permissions hidden for Windows-local (chmod only flips read-only there); Explorer/Recycle Bin wording; Ctrl vs Cmd shortcut labels; separator-aware path joins + drive-root detection; drives list in the panel source picker; icon.ico + colored tray icon; icacls ACL on connections.json; README Windows (beta) section with upstream known issues and a smoke-test checklist. Runtime paths pending first boot on a real Windows machine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
92 lines
3.0 KiB
TypeScript
92 lines
3.0 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { ConnectionsFile, SavedConnection, Stack } from "../../shared/types";
|
|
import { appDataDir } from "./paths";
|
|
|
|
const EMPTY: ConnectionsFile = { version: 2, stacks: [], connections: [] };
|
|
|
|
/**
|
|
* Plain-JSON store for saved connections (passwords included — the user's
|
|
* explicit v1 choice). Kept at 0600 and written atomically via tmp+rename.
|
|
* Only the current version 2 format is read; anything else starts empty.
|
|
*/
|
|
export class ConnectionStore {
|
|
private readonly file = path.join(appDataDir(), "connections.json");
|
|
private data: ConnectionsFile | null = null;
|
|
|
|
async load(): Promise<ConnectionsFile> {
|
|
if (!this.data) {
|
|
try {
|
|
const parsed = JSON.parse(await fs.readFile(this.file, "utf8")) as ConnectionsFile;
|
|
this.data = parsed.version === 2 ? parsed : structuredClone(EMPTY);
|
|
} 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 upsertStack(stack: Stack): Promise<string> {
|
|
const data = await this.load();
|
|
const saved = { ...stack, id: stack.id || crypto.randomUUID() };
|
|
const index = data.stacks.findIndex((s) => s.id === saved.id);
|
|
if (index >= 0) data.stacks[index] = saved;
|
|
else data.stacks.push(saved);
|
|
await this.save();
|
|
return saved.id;
|
|
}
|
|
|
|
/** Deleting a stack moves its connections to the loose list. */
|
|
async deleteStack(id: string): Promise<void> {
|
|
const data = await this.load();
|
|
data.stacks = data.stacks.filter((s) => s.id !== id);
|
|
for (const connection of data.connections) {
|
|
if (connection.stackId === id) connection.stackId = 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);
|
|
this.restrictOnWindows();
|
|
}
|
|
|
|
/**
|
|
* PORTABILITY: 0o600 only toggles the read-only bit on Windows, so the
|
|
* plaintext passwords in this file would be world-readable there. Replace
|
|
* inheritance with a current-user-only ACL, best effort.
|
|
*/
|
|
private restrictOnWindows(): void {
|
|
if (process.platform !== "win32") return;
|
|
try {
|
|
Bun.spawn(
|
|
["icacls", this.file, "/inheritance:r", "/grant:r", `${os.userInfo().username}:F`],
|
|
{ stdout: "ignore", stderr: "ignore" },
|
|
);
|
|
} catch {
|
|
// ACL tooling unavailable — file still works, just unprotected
|
|
}
|
|
}
|
|
}
|