Redo connection manager: Stacks, More options, drag & drop, WinSCP import
Folders become Stacks (connections.json v2, no v1 migration): collapsible stack-first list, open-stack-in-tabs (first reuses a pristine tab), drag connections between stacks. More... reveals a per-connection Local directory applied to the other panel when local and untouched. Import reads a WinSCP.ini export incl. deobfuscated passwords. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+8
-8
@@ -11,21 +11,21 @@ import type { ConnectionStore } from "./store/connections";
|
||||
export async function applyApplicationMenu(store: ConnectionStore): Promise<void> {
|
||||
if (process.platform !== "darwin") return;
|
||||
|
||||
const { folders, connections } = await store.load();
|
||||
const { stacks, connections } = await store.load();
|
||||
const item = (c: SavedConnection): ApplicationMenuItemConfig => ({
|
||||
label: c.name,
|
||||
action: `connect:${c.id}`,
|
||||
});
|
||||
const rootItems = connections.filter((c) => !c.folderId).map(item);
|
||||
const folderMenus: ApplicationMenuItemConfig[] = folders.map((folder) => ({
|
||||
label: folder.name,
|
||||
submenu: connections.filter((c) => c.folderId === folder.id).map(item),
|
||||
const looseItems = connections.filter((c) => !c.stackId).map(item);
|
||||
const stackMenus: ApplicationMenuItemConfig[] = stacks.map((stack) => ({
|
||||
label: stack.name,
|
||||
submenu: connections.filter((c) => c.stackId === stack.id).map(item),
|
||||
}));
|
||||
|
||||
const connectSubmenu: ApplicationMenuItemConfig[] = [
|
||||
...rootItems,
|
||||
...folderMenus,
|
||||
...(rootItems.length || folderMenus.length ? [{ type: "separator" } as const] : []),
|
||||
...looseItems,
|
||||
...stackMenus,
|
||||
...(looseItems.length || stackMenus.length ? [{ type: "separator" } as const] : []),
|
||||
{ label: "Connection Manager…", action: "manage-connections" },
|
||||
];
|
||||
|
||||
|
||||
+24
-4
@@ -8,6 +8,7 @@ import { SftpProvider } from "./fs/sftp";
|
||||
import type { ConnectionStore } from "./store/connections";
|
||||
import type { SettingsStore } from "./store/settings";
|
||||
import { loadSshConfigHosts } from "./store/ssh-config";
|
||||
import { importWinScpIni } from "./store/winscp-import";
|
||||
import type { TransferEngine } from "./transfers/engine";
|
||||
import { removeRecursive } from "./transfers/walk";
|
||||
|
||||
@@ -152,20 +153,39 @@ export function createAppRPC({
|
||||
return {};
|
||||
}),
|
||||
|
||||
upsertFolder: ({ folder }) =>
|
||||
upsertStack: ({ stack }) =>
|
||||
wrap(async () => {
|
||||
const id = await store.upsertFolder(folder);
|
||||
const id = await store.upsertStack(stack);
|
||||
events.connectionsChanged();
|
||||
return { id };
|
||||
}),
|
||||
|
||||
deleteFolder: ({ id }) =>
|
||||
deleteStack: ({ id }) =>
|
||||
wrap(async () => {
|
||||
await store.deleteFolder(id);
|
||||
await store.deleteStack(id);
|
||||
events.connectionsChanged();
|
||||
return {};
|
||||
}),
|
||||
|
||||
importWinScp: () =>
|
||||
wrap(async () => {
|
||||
const { homedir } = await import("node:os");
|
||||
const picked = await Utils.openFileDialog({
|
||||
startingFolder: homedir(),
|
||||
allowedFileTypes: "*",
|
||||
canChooseFiles: true,
|
||||
canChooseDirectory: false,
|
||||
allowsMultipleSelection: false,
|
||||
});
|
||||
const file = picked.find((p) => p.trim().length > 0);
|
||||
// dialog cancelled — all-zero result, UI shows nothing
|
||||
if (!file) return { connections: 0, stacks: 0, skipped: 0 };
|
||||
const text = await (await import("node:fs/promises")).readFile(file, "utf8");
|
||||
const result = await importWinScpIni(text, store);
|
||||
events.connectionsChanged();
|
||||
return result;
|
||||
}),
|
||||
|
||||
getSshConfigHosts: () => wrap(async () => ({ hosts: loadSshConfigHosts() })),
|
||||
|
||||
// ---- settings ----
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ConnFolder, ConnectionsFile, SavedConnection } from "../../shared/types";
|
||||
import type { ConnectionsFile, SavedConnection, Stack } from "../../shared/types";
|
||||
import { appDataDir } from "./paths";
|
||||
|
||||
const EMPTY: ConnectionsFile = { version: 1, folders: [], connections: [] };
|
||||
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");
|
||||
@@ -16,7 +17,8 @@ export class ConnectionStore {
|
||||
async load(): Promise<ConnectionsFile> {
|
||||
if (!this.data) {
|
||||
try {
|
||||
this.data = JSON.parse(await fs.readFile(this.file, "utf8")) as ConnectionsFile;
|
||||
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);
|
||||
}
|
||||
@@ -40,22 +42,22 @@ export class ConnectionStore {
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async upsertFolder(folder: ConnFolder): Promise<string> {
|
||||
async upsertStack(stack: Stack): 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);
|
||||
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 folder moves its connections to the root. */
|
||||
async deleteFolder(id: string): Promise<void> {
|
||||
/** Deleting a stack moves its connections to the loose list. */
|
||||
async deleteStack(id: string): Promise<void> {
|
||||
const data = await this.load();
|
||||
data.folders = data.folders.filter((f) => f.id !== id);
|
||||
data.stacks = data.stacks.filter((s) => s.id !== id);
|
||||
for (const connection of data.connections) {
|
||||
if (connection.folderId === id) connection.folderId = null;
|
||||
if (connection.stackId === id) connection.stackId = null;
|
||||
}
|
||||
await this.save();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { AuthConfig, SavedConnection } from "../../shared/types";
|
||||
import type { ConnectionStore } from "./connections";
|
||||
|
||||
const PW_MAGIC = 0xa3;
|
||||
const PW_FLAG = 0xff;
|
||||
|
||||
/**
|
||||
* Reverse WinSCP's documented password obfuscation: the stored value is a hex
|
||||
* string; each nibble pair decodes via ~(byte ^ 0xA3), with a small header
|
||||
* (flag, length, offset) and — when flagged — a `username+hostname` key prefix
|
||||
* that must match. Returns null when the value doesn't decode cleanly.
|
||||
*/
|
||||
export function decodeWinScpPassword(
|
||||
hostname: string,
|
||||
username: string,
|
||||
encoded: string,
|
||||
): string | null {
|
||||
const nibbles = [...encoded].map((c) => Number.parseInt(c, 16));
|
||||
if (nibbles.some(Number.isNaN) || nibbles.length % 2 !== 0) return null;
|
||||
let pos = 0;
|
||||
const next = (): number => {
|
||||
const a = nibbles[pos] ?? 0;
|
||||
const b = nibbles[pos + 1] ?? 0;
|
||||
pos += 2;
|
||||
return ~(((a << 4) + b) ^ PW_MAGIC) & 0xff;
|
||||
};
|
||||
|
||||
const flag = next();
|
||||
let length: number;
|
||||
if (flag === PW_FLAG) {
|
||||
next(); // dummy byte
|
||||
length = next();
|
||||
} else {
|
||||
length = flag;
|
||||
}
|
||||
// `pos += next() * 2` would snapshot pos before next() advances it
|
||||
const offset = next();
|
||||
pos += offset * 2; // offset counts bytes; positions are nibbles
|
||||
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (pos >= nibbles.length) return null;
|
||||
result += String.fromCharCode(next());
|
||||
}
|
||||
if (flag === PW_FLAG) {
|
||||
const key = username + hostname;
|
||||
if (!result.startsWith(key)) return null;
|
||||
result = result.slice(key.length);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** WinSCP percent-encodes special chars in section names and path values. */
|
||||
function decodePercent(value: string): string {
|
||||
return value.replace(/%([0-9A-Fa-f]{2})/g, (_, hex: string) =>
|
||||
String.fromCharCode(Number.parseInt(hex, 16)),
|
||||
);
|
||||
}
|
||||
|
||||
interface IniSection {
|
||||
name: string;
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
function parseIni(text: string): IniSection[] {
|
||||
const sections: IniSection[] = [];
|
||||
let current: IniSection | null = null;
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith(";")) continue;
|
||||
const sectionMatch = line.match(/^\[(.+)\]$/);
|
||||
if (sectionMatch) {
|
||||
current = { name: sectionMatch[1], values: {} };
|
||||
sections.push(current);
|
||||
continue;
|
||||
}
|
||||
const eq = line.indexOf("=");
|
||||
if (eq > 0 && current) {
|
||||
current.values[line.slice(0, eq)] = line.slice(eq + 1);
|
||||
}
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
export interface WinScpImportResult {
|
||||
connections: number;
|
||||
stacks: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import sessions from a WinSCP.ini export. Session folder paths become
|
||||
* stacks (first level joined by " / " for nested folders), RemoteDirectory →
|
||||
* initialPath, LocalDirectory → localDir. [Workspaces\…] sections are ignored.
|
||||
*/
|
||||
export async function importWinScpIni(
|
||||
text: string,
|
||||
store: ConnectionStore,
|
||||
): Promise<WinScpImportResult> {
|
||||
const sessions = parseIni(text).filter((s) => s.name.startsWith("Sessions\\"));
|
||||
const stackIds = new Map<string, string>();
|
||||
// snapshot the count — the store mutates the same array on upsert
|
||||
const stacksBefore = (await store.load()).stacks.length;
|
||||
for (const stack of (await store.load()).stacks) stackIds.set(stack.name, stack.id);
|
||||
|
||||
let connections = 0;
|
||||
let skipped = 0;
|
||||
for (const session of sessions) {
|
||||
const fullName = decodePercent(session.name.slice("Sessions\\".length));
|
||||
if (fullName === "Default Settings") continue;
|
||||
const host = session.values["HostName"];
|
||||
if (!host) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const segments = fullName.split("/");
|
||||
const name = segments.pop() || fullName;
|
||||
const stackName = segments.join(" / ");
|
||||
let stackId: string | null = null;
|
||||
if (stackName) {
|
||||
stackId = stackIds.get(stackName) ?? null;
|
||||
if (!stackId) {
|
||||
stackId = await store.upsertStack({ id: "", name: stackName });
|
||||
stackIds.set(stackName, stackId);
|
||||
}
|
||||
}
|
||||
|
||||
const username = session.values["UserName"] ?? "root";
|
||||
let auth: AuthConfig;
|
||||
const keyFile = session.values["PublicKeyFile"];
|
||||
if (keyFile) {
|
||||
auth = { kind: "key", privateKeyPath: decodePercent(keyFile) };
|
||||
} else {
|
||||
const encoded = session.values["Password"];
|
||||
const password = encoded ? decodeWinScpPassword(host, username, encoded) : null;
|
||||
auth = { kind: "password", password: password ?? "" };
|
||||
}
|
||||
|
||||
const connection: SavedConnection = {
|
||||
id: "",
|
||||
stackId,
|
||||
name,
|
||||
host,
|
||||
port: Number(session.values["PortNumber"]) || 22,
|
||||
username,
|
||||
auth,
|
||||
initialPath: session.values["RemoteDirectory"] || null,
|
||||
localDir: session.values["LocalDirectory"] || null,
|
||||
};
|
||||
await store.upsertConnection(connection);
|
||||
connections++;
|
||||
}
|
||||
|
||||
const stacksAfter = (await store.load()).stacks.length;
|
||||
return { connections, stacks: stacksAfter - stacksBefore, skipped };
|
||||
}
|
||||
Reference in New Issue
Block a user