Two panels on the same server hold two SSH sessions by design, and every self-copy guard compared sessionId — so same-server same-path copies slipped through all of them, and createWriteStream truncated the destination before the read began: the file (or every file in a folder) became 0 bytes, with the overwrite dialog making it look like a legitimate replace. Fix, defense in depth, keyed on FILESYSTEM identity instead of session: - fsKey on every provider: 'local', or resolvedIP:port for SFTP (host is DNS-resolved at connect so hostname-vs-IP aliases compare equal; username excluded — same host:port is the same filesystem regardless of login) - engine guard blocks equal/nested paths when fsKeys match; per-file belt-and-suspenders check right before streams open - probe layer for hidden aliases (different addresses, same machine): when fsKeys differ but paths collide, a token file written via dst and read via src decides; visible => same machine => blocked. SFTP exposes no dev/inode (what cp/rsync use), so probing is the only deterministic test; fails open and cleans up after itself - UI: same-folder toast now fires across two sessions to one server, and the overwrite dialog auto-skips (never offers Replace) when the colliding item IS the source file Known residual, documented in README 'Same-file protection': one filesystem mounted at different paths on two connections is undetectable client-side. Regression suite against the real box: file-onto-itself and folder-onto-itself blocked with contents byte-intact, folder-into-descendant blocked, legit same-server different-dir copies still work, local same-path still blocked, simulated alias caught by the probe with cleanup verified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BunSCP
A cross-platform, dual-panel SFTP/local file manager — a small WinSCP-style commander built on Bun, Electrobun, and Angular.
Each of the two panels independently shows either the local filesystem or a remote server over SSH/SFTP, so you can move files local↔remote, remote↔remote, or local↔local through one symmetric engine.
Name & icon are placeholders. See Rebranding — it's a one-file change.
Features
- Dual-panel commander UI; either panel can be Local or any saved connection.
- Session tabs: each tab is an independent left/right workspace; remote sessions stay connected and transfers keep running in background tabs.
- Per-panel action toolbar with a direction-explicit copy button, plus a VSCode-style status bar showing live transfer progress (click to expand the transfer queue with per-file speed and ETA).
- Overwrite confirmation on name collisions, with a source-vs-destination comparison (size, modified date, which is newer) and batch replace-all / skip-all.
- Settings (gear, top-right): light / dark / system theme and speed units (MB/s or Mbps), persisted across restarts.
- On macOS the tab bar is fused with the window titlebar; other platforms keep a standard titlebar.
- Edit files (F4 / toolbar / File menu): a built-in editor works on local and remote files directly; or pick an external app (Options → Editor → Custom → Choose…). External edits of remote files download to a temp folder, are watched, and every save auto-uploads (visible in the transfer list) until the session disconnects. Leftover temp files from a crash are offered for cleanup at the next start.
- Browse, sort (name/size/modified, folders first), keyboard navigation.
- Create folder / file, rename, delete (recursive), change permissions (chmod).
- Upload, download, and remote→remote copy — files and whole folders — with a transfer queue, per-job progress, and cancel.
- Connection manager: saved connections organised into folders, password or private-key auth (with passphrase).
Stack
| Layer | Choice |
|---|---|
| Runtime | Bun (main process, all SSH/SFTP + file IO) |
| Desktop | Electrobun 1.18 (native webview, typed RPC bridge) |
| Frontend | Angular 22 (standalone, signals, zoneless) in ui/ |
| Styling | Tailwind CSS v4, shadcn-style tokens (no component library) |
| Icons | @ng-icons/mynaui |
| SSH/SFTP | ssh2 (pure-JS under Bun) |
The repo is a Bun workspace: the Electrobun app at the root, the Angular app in
ui/. Shared types and the RPC contract live in src/shared/ and are imported
by both sides.
src/shared/ types.ts, rpc.ts (wire contract), branding.ts
src/bun/ Electrobun main process
fs/ FileSystemProvider + LocalProvider + SftpProvider + registry
transfers/ engine.ts (queue/stream copy) + walk.ts (recursive helpers)
store/ connections.json persistence
ui/src/app/ Angular components + signal services
Prerequisites
- Bun ≥ 1.3 (
bun --version). - macOS: Xcode Command Line Tools (for the app build).
- No Node install required — a preload shim (
scripts/node-version-shim.ts) satisfies the Angular CLI's Node version check while running under Bun.
Setup
bun install # installs root + ui workspace deps
Develop
bun run dev
This starts the Angular dev server (hot reload for UI code) and launches the
Electrobun app pointed at it. Edits under ui/ reload instantly; edits to the
Bun main process (src/bun/**, RPC schema) trigger an app relaunch.
Build a packaged app
bun run build # ng build (ui/dist) + electrobun build -> build/*/BunSCP*.app
The Angular bundle is copied into the app under views://app. The macOS icon is
compiled from assets/icon.iconset into AppIcon.icns automatically.
For a distributable (optimized, with auto-update channel) build, use
bunx electrobun build --env=stable (see the
Electrobun docs for signing,
notarization, and release hosting).
Rebranding
Everything user-facing reads from src/shared/branding.ts:
export const BRANDING = {
appName: "BunSCP", // window title, bundle name, app-data folder
identifier: "es.teknes.bunscp",
version: "0.1.0",
icon: "assets/icon.iconset",
} as const;
To change the icon: replace assets/icon.png (1024×1024) and run
./scripts/gen-iconset.sh (regenerates the .iconset with sips), or point
icon at your own .iconset. scripts/make-icon.py regenerates the current
placeholder.
Renaming
appNamechanges the app-data directory, so existing saved connections won't be found under the new name — copyconnections.jsonacross if you need them.
Where connections are stored
Saved connections live in a plain JSON file in the OS app-data directory
(macOS: ~/Library/Application Support/BunSCP/connections.json), written with
0600 permissions.
⚠️ Passwords are stored in plaintext in this file (a deliberate v1 simplicity choice). Anyone who can read the file can read the passwords. If that isn't acceptable for your use, use key-based auth or don't save the password (connect ad-hoc). OS-keychain storage is a natural future addition.
Keyboard shortcuts
Tab switch panel · ↑/↓/PgUp/PgDn/Home/End move cursor ·
Space select · Enter open folder · Backspace parent ·
Ctrl/Cmd+A select all · F5 copy to other panel · F2 rename ·
F7 new folder · F8/Delete delete · Cmd/Ctrl+T new tab ·
Cmd/Ctrl+W close tab · Ctrl+Tab next tab.
(On Macs, function keys may need fn — every action also has a toolbar button.)
Same-file protection
Copying a file or folder onto itself would truncate it to zero bytes (the destination is opened for writing before the source is read). BunSCP blocks this at several layers:
- Same session / same host: every connection carries a filesystem identity
(
local, orresolvedIP:port— the hostname is DNS-resolved, somyserver.lanand its IP compare equal even across two panels/tabs holding separate SSH sessions to the same server). Equal identity + equal or nested paths ⇒ the transfer is refused before anything is opened. - Hidden aliases (two different addresses that reach the same machine —
NAT, split DNS): when identities differ but source and destination paths
collide anyway, a probe file is written through one connection and looked up
through the other; if it's visible on both, they're the same machine and the
copy is refused. (SFTP offers no device/inode identity — the mechanism
cp/rsyncuse locally — so probing is the only deterministic test.) - The overwrite dialog never offers to "Replace" a file with itself — such items are auto-skipped with a notice.
Known residual limitation: the same filesystem mounted at different paths on two connections (bind mounts, NFS exported twice, shared container volumes) cannot be detected — the paths differ, so no guard or probe triggers. Be careful when two panels reach the same data through different mount points.
Portability notes
The codebase is platform-neutral except for two seams, both macOS-only today:
- External editor launch (
launchEditorin src/bun/edits.ts): uses macOSopen [-a app] file. A Windows port should usecmd /c start "" [app] file; Linuxxdg-open file(or invoke the configured editor binary directly). The.apppicker in thepickEditorApphandler needs the equivalent executable picker per platform. - Application menu (src/bun/menu.ts): electrobun menus are macOS-only; the in-app UI covers every menu action.
Known issues
- Rare native crash under load. A long-running session (multiple remote
connections, extended uptime) can hit a Bun panic — a segfault inside Bun
1.3.13's FFI thread-safe callbacks executing Electrobun's native→worker
bridge (
FFI_Callback_threadsafe_call→web_worker.spinin the decoded crash report). This is an upstream Bun × Electrobun bug, not app code; a restart recovers cleanly and in-flight work isn't corrupted. Expected to resolve when Electrobun bumps its pinned Bun (currently 1.3.13; 1.3.14+ available). Bun's own crash reporter link is printed to the console on panic.
Not in v1
Drag-and-drop, file preview/edit, transfer resume, symlink following (symlinks are listed but skipped by transfers), OS keychain, tab persistence across restarts, Windows/Linux packaging (the code is portable; only macOS packaging is wired up).