Custom signal-based runtime (no i18n dependency): shared src/shared/i18n dictionaries, ui I18nService.t() reactive by construction so language switches live, and a backend i18n.ts for native menus + thrown errors. Every user-facing string keyed across templates, toasts, dialogs, file kinds, columns, macOS menu/context menus, and errors. Language picker in Options (auto-detects OS locale, falls back to English); switching relabels the webview and native chrome instantly. English/Spanish/French/ German fully drafted; Chinese/Japanese/Basque drafted, flagged for native review. Docs updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
220 lines
6.3 KiB
TypeScript
220 lines
6.3 KiB
TypeScript
import dns from "node:dns/promises";
|
|
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { Client, type ConnectConfig, type SFTPWrapper } from "ssh2";
|
|
import type { ConnectionConfig, EntryType, FileEntry } from "../../shared/types";
|
|
import { t } from "../i18n";
|
|
import { expandTilde } from "../store/ssh-config";
|
|
import type { FileSystemProvider } from "./provider";
|
|
|
|
const S_IFMT = 0o170000;
|
|
const S_IFDIR = 0o040000;
|
|
const S_IFLNK = 0o120000;
|
|
|
|
/**
|
|
* Owner/group from the ls-style longname openssh sends with each entry
|
|
* ("-rw-r--r-- 1 root wheel 1024 Jan 1 00:00 name"). Servers aren't
|
|
* required to use this format — bail to undefined when it doesn't parse.
|
|
*/
|
|
function ownerFromLongname(longname: string | undefined): { owner?: string; group?: string } {
|
|
if (!longname || !/^[-dlbcps]/.test(longname)) return {};
|
|
const fields = longname.split(/\s+/);
|
|
if (fields.length < 5) return {};
|
|
return { owner: fields[2], group: fields[3] };
|
|
}
|
|
|
|
function typeFromMode(mode: number): EntryType {
|
|
const fmt = mode & S_IFMT;
|
|
if (fmt === S_IFDIR) return "dir";
|
|
if (fmt === S_IFLNK) return "symlink";
|
|
return "file";
|
|
}
|
|
|
|
export class SftpProvider implements FileSystemProvider {
|
|
readonly kind = "sftp" as const;
|
|
readonly fsKey: string;
|
|
private closedByUs = false;
|
|
|
|
private constructor(
|
|
private client: Client,
|
|
private sftp: SFTPWrapper,
|
|
fsKey: string,
|
|
) {
|
|
this.fsKey = fsKey;
|
|
}
|
|
|
|
/** 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,
|
|
};
|
|
const agentSock =
|
|
process.env.SSH_AUTH_SOCK ??
|
|
(process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : undefined);
|
|
switch (config.auth.kind) {
|
|
case "password":
|
|
connectConfig.password = config.auth.password;
|
|
break;
|
|
case "agent":
|
|
if (!agentSock) throw new Error(t("errors.sshAgentUnavailable"));
|
|
connectConfig.agent = agentSock;
|
|
break;
|
|
case "key": {
|
|
// Alongside a passphrase-less key file, offer the agent too — it
|
|
// covers encrypted keys already loaded via ssh-add/UseKeychain.
|
|
try {
|
|
connectConfig.privateKey = readFileSync(expandTilde(config.auth.privateKeyPath));
|
|
} catch {
|
|
if (!agentSock)
|
|
throw new Error(t("errors.identityFileNotFound", { path: config.auth.privateKeyPath }));
|
|
}
|
|
if (config.auth.passphrase) connectConfig.passphrase = config.auth.passphrase;
|
|
else if (agentSock) connectConfig.agent = agentSock;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Filesystem identity: resolve the host so a hostname and its IP compare
|
|
// equal (self-copy guards). Falls back to the raw host string offline.
|
|
let resolvedHost = config.host.toLowerCase();
|
|
try {
|
|
resolvedHost = (await dns.lookup(config.host)).address;
|
|
} catch {
|
|
// unresolvable (e.g. already an IP on some systems) — keep the string
|
|
}
|
|
const fsKey = `${resolvedHost}:${config.port}`;
|
|
|
|
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, fsKey));
|
|
}
|
|
});
|
|
})
|
|
.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,
|
|
...ownerFromLongname(e.longname),
|
|
})),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|