Files
miniSCP/src/bun/fs/local.ts
T
JokinandClaude Fable 5 c7271fb7cf Windows compatibility pass (beta target)
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>
2026-07-14 00:02:42 +02:00

144 lines
4.0 KiB
TypeScript

import { createReadStream, createWriteStream } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Stats } from "node:fs";
import type { EntryType, FileEntry } from "../../shared/types";
import type { FileSystemProvider } from "./provider";
function typeOf(st: Stats): EntryType {
if (st.isSymbolicLink()) return "symlink";
return st.isDirectory() ? "dir" : "file";
}
/**
* uid/gid -> names, cached per process (a listing usually has 1-3 owners).
*
* PORTABILITY: POSIX-only lookups (`id -nu` works on macOS and Linux; group
* names use dscacheutil on macOS, getent elsewhere). On Windows these return
* undefined and the Owner/Group columns show "—".
*/
const idNameCache = new Map<string, string | undefined>();
function resolveId(kind: "user" | "group", id: number): string | undefined {
if (process.platform === "win32") return undefined;
const cacheKey = `${kind}:${id}`;
if (idNameCache.has(cacheKey)) return idNameCache.get(cacheKey);
let name: string | undefined;
try {
const cmd =
kind === "user"
? ["id", "-nu", String(id)]
: process.platform === "darwin"
? ["dscacheutil", "-q", "group", "-a", "gid", String(id)]
: ["getent", "group", String(id)];
const proc = Bun.spawnSync(cmd, { stdout: "pipe", stderr: "ignore" });
if (proc.success) {
const out = proc.stdout.toString();
if (kind === "user") name = out.trim() || undefined;
else if (process.platform === "darwin") name = /^name:\s*(.+)$/m.exec(out)?.[1]?.trim();
else name = out.split(":")[0]?.trim() || undefined;
}
} catch {
// lookup tool missing — fall through to the numeric id
}
name ??= String(id);
idNameCache.set(cacheKey, name);
return name;
}
/**
* Drive roots for the Windows panel source picker ("C:\", "D:\", …).
* PORTABILITY: win32-only concept; empty elsewhere ("/" is always reachable).
*/
export function listLocalDrives(): string[] {
if (process.platform !== "win32") return [];
try {
const proc = Bun.spawnSync(["fsutil", "fsinfo", "drives"], { stdout: "pipe", stderr: "ignore" });
if (!proc.success) return [];
// output: "Drives: C:\ D:\ ..."
return [...proc.stdout.toString().matchAll(/[A-Za-z]:\\/g)].map((m) => m[0]);
} catch {
return [];
}
}
function toEntry(name: string, full: string, st: Stats): FileEntry {
return {
name,
path: full,
type: typeOf(st),
size: st.size,
mtime: st.mtimeMs,
mode: st.mode & 0o7777,
owner: resolveId("user", st.uid),
group: resolveId("group", st.gid),
};
}
export class LocalProvider implements FileSystemProvider {
readonly kind = "local" as const;
readonly fsKey = "local";
join = (...parts: string[]) => path.join(...parts);
dirname = (p: string) => path.dirname(p);
basename = (p: string) => path.basename(p);
normalize = (p: string) => path.resolve(p);
async homeDir(): Promise<string> {
return os.homedir();
}
async list(dir: string): Promise<FileEntry[]> {
const names = await fs.readdir(dir);
const entries: FileEntry[] = [];
for (const name of names) {
const full = path.join(dir, name);
try {
entries.push(toEntry(name, full, await fs.lstat(full)));
} catch {
// entry vanished or is unreadable — skip it
}
}
return entries;
}
async stat(p: string): Promise<FileEntry> {
return toEntry(path.basename(p), p, await fs.lstat(p));
}
async mkdir(p: string): Promise<void> {
await fs.mkdir(p);
}
async createEmptyFile(p: string): Promise<void> {
await fs.writeFile(p, "", { flag: "wx" });
}
async rename(oldPath: string, newPath: string): Promise<void> {
await fs.rename(oldPath, newPath);
}
async deleteFile(p: string): Promise<void> {
await fs.unlink(p);
}
async deleteEmptyDir(p: string): Promise<void> {
await fs.rmdir(p);
}
async chmod(p: string, mode: number): Promise<void> {
await fs.chmod(p, mode);
}
createReadStream(p: string) {
return createReadStream(p);
}
createWriteStream(p: string) {
return createWriteStream(p);
}
async dispose(): Promise<void> {}
}