diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e9cf30 --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# 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](#rebranding) — it's a +> one-file change. + +## Features + +- Dual-panel commander UI; either panel can be Local or any saved connection. +- 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](https://bun.sh) ≥ 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 + +```bash +bun install # installs root + ui workspace deps +``` + +## Develop + +```bash +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 + +```bash +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](https://framework.blackboard.sh/electrobun/) for signing, +notarization, and release hosting). + +## Rebranding + +Everything user-facing reads from **[`src/shared/branding.ts`](src/shared/branding.ts)**: + +```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 `appName` changes the app-data directory, so existing saved +> connections won't be found under the new name — copy `connections.json` across +> 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. + +(On Macs, function keys may need `fn` — every action also has a toolbar button.) + +## Not in v1 + +Drag-and-drop, file preview/edit, transfer resume, symlink following +(symlinks are listed but skipped by transfers), OS keychain, Windows/Linux +packaging (the code is portable; only macOS packaging is wired up). diff --git a/assets/icon.iconset/icon_128x128.png b/assets/icon.iconset/icon_128x128.png new file mode 100644 index 0000000..ef0f8d4 Binary files /dev/null and b/assets/icon.iconset/icon_128x128.png differ diff --git a/assets/icon.iconset/icon_128x128@2x.png b/assets/icon.iconset/icon_128x128@2x.png new file mode 100644 index 0000000..9adf35d Binary files /dev/null and b/assets/icon.iconset/icon_128x128@2x.png differ diff --git a/assets/icon.iconset/icon_16x16.png b/assets/icon.iconset/icon_16x16.png new file mode 100644 index 0000000..ea82638 Binary files /dev/null and b/assets/icon.iconset/icon_16x16.png differ diff --git a/assets/icon.iconset/icon_16x16@2x.png b/assets/icon.iconset/icon_16x16@2x.png new file mode 100644 index 0000000..624d692 Binary files /dev/null and b/assets/icon.iconset/icon_16x16@2x.png differ diff --git a/assets/icon.iconset/icon_256x256.png b/assets/icon.iconset/icon_256x256.png new file mode 100644 index 0000000..9adf35d Binary files /dev/null and b/assets/icon.iconset/icon_256x256.png differ diff --git a/assets/icon.iconset/icon_256x256@2x.png b/assets/icon.iconset/icon_256x256@2x.png new file mode 100644 index 0000000..8ccdeff Binary files /dev/null and b/assets/icon.iconset/icon_256x256@2x.png differ diff --git a/assets/icon.iconset/icon_32x32.png b/assets/icon.iconset/icon_32x32.png new file mode 100644 index 0000000..624d692 Binary files /dev/null and b/assets/icon.iconset/icon_32x32.png differ diff --git a/assets/icon.iconset/icon_32x32@2x.png b/assets/icon.iconset/icon_32x32@2x.png new file mode 100644 index 0000000..024d4c6 Binary files /dev/null and b/assets/icon.iconset/icon_32x32@2x.png differ diff --git a/assets/icon.iconset/icon_512x512.png b/assets/icon.iconset/icon_512x512.png new file mode 100644 index 0000000..8ccdeff Binary files /dev/null and b/assets/icon.iconset/icon_512x512.png differ diff --git a/assets/icon.iconset/icon_512x512@2x.png b/assets/icon.iconset/icon_512x512@2x.png new file mode 100644 index 0000000..3bb2789 Binary files /dev/null and b/assets/icon.iconset/icon_512x512@2x.png differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..3bb2789 Binary files /dev/null and b/assets/icon.png differ diff --git a/scripts/gen-iconset.sh b/scripts/gen-iconset.sh new file mode 100755 index 0000000..44b67a9 --- /dev/null +++ b/scripts/gen-iconset.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Regenerate assets/icon.iconset from assets/icon.png (macOS: sips + iconutil). +# Electrobun compiles the .iconset folder into AppIcon.icns at build time. +set -euo pipefail +cd "$(dirname "$0")/.." + +SRC="assets/icon.png" +SET="assets/icon.iconset" +rm -rf "$SET" +mkdir -p "$SET" + +for size in 16 32 128 256 512; do + sips -z "$size" "$size" "$SRC" --out "$SET/icon_${size}x${size}.png" >/dev/null + sips -z $((size * 2)) $((size * 2)) "$SRC" --out "$SET/icon_${size}x${size}@2x.png" >/dev/null +done + +echo "generated $SET" diff --git a/scripts/make-icon.py b/scripts/make-icon.py new file mode 100644 index 0000000..ebbf0a3 --- /dev/null +++ b/scripts/make-icon.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Generate a placeholder app icon (1024x1024 PNG) with a dual-panel + transfer +motif. No third-party deps — raw RGBA encoded via zlib. Replace assets/icon.png +(and re-run scripts/gen-iconset.sh) to rebrand.""" +import struct +import zlib +import sys + +N = 1024 + + +def lerp(a, b, t): + return tuple(round(a[i] + (b[i] - a[i]) * t) for i in range(3)) + + +def rounded_rect_alpha(x, y, x0, y0, x1, y1, r): + """Coverage (0..1) for a rounded rect with ~1px antialiasing.""" + # distance outside the rect's rounded boundary + dx = max(x0 + r - x, 0, x - (x1 - r)) + dy = max(y0 + r - y, 0, y - (y1 - r)) + if x < x0 or x > x1 or y < y0 or y > y1: + return 0.0 + # corner regions + cx = None + if x < x0 + r and y < y0 + r: + cx, cy = x0 + r, y0 + r + elif x > x1 - r and y < y0 + r: + cx, cy = x1 - r, y0 + r + elif x < x0 + r and y > y1 - r: + cx, cy = x0 + r, y1 - r + elif x > x1 - r and y > y1 - r: + cx, cy = x1 - r, y1 - r + if cx is not None: + d = ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5 + return max(0.0, min(1.0, r - d + 0.5)) + return 1.0 + + +BG_TOP = (0x4F, 0x46, 0xE5) # indigo-600 +BG_BOT = (0x18, 0x18, 0x1B) # zinc-900 +PANEL = (0xFA, 0xFA, 0xFA) # near white +ROW = (0xC7, 0xCC, 0xD6) +ARROW = (0xFB, 0xBF, 0x24) # amber-400 + +buf = bytearray() +margin = 96 +radius = 205 +# panels +pgap = 70 +ptop = 340 +pbot = 760 +pw = (N - 2 * 300 - pgap) / 2 +lx0 = 300 +lx1 = lx0 + pw +rx0 = lx1 + pgap +rx1 = rx0 + pw + +for y in range(N): + buf.append(0) # PNG filter byte per scanline + for x in range(N): + outer = rounded_rect_alpha(x + 0.5, y + 0.5, margin, margin, N - margin, N - margin, radius) + t = y / N + col = lerp(BG_TOP, BG_BOT, t) + r, g, b = col + a = outer + # panels + for (x0, x1) in ((lx0, lx1), (rx0, rx1)): + pa = rounded_rect_alpha(x + 0.5, y + 0.5, x0, ptop, x1, pbot, 34) + if pa > 0: + r = round(r * (1 - pa) + PANEL[0] * pa) + g = round(g * (1 - pa) + PANEL[1] * pa) + b = round(b * (1 - pa) + PANEL[2] * pa) + # file rows + for row in range(4): + ry = ptop + 70 + row * 74 + if ry <= y <= ry + 26 and x0 + 34 <= x <= x1 - 34: + r, g, b = ROW + # transfer arrows in the gap + cx0, cx1 = lx1 + 6, rx0 - 6 + midy = (ptop + pbot) / 2 + if cx0 <= x <= cx1 and abs(y - midy) < 90: + # two chevrons pointing right and left, forming <-> feel + rel = (x - cx0) / (cx1 - cx0) + band = abs(y - midy) + if band < 46 - abs(rel - 0.5) * 40: + r, g, b = ARROW + a = max(a, outer) + buf.extend((r, g, b, round(255 * a))) + + +def chunk(tag, data): + return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + + +png = b"\x89PNG\r\n\x1a\n" +png += chunk(b"IHDR", struct.pack(">IIBBBBB", N, N, 8, 6, 0, 0, 0)) +png += chunk(b"IDAT", zlib.compress(bytes(buf), 9)) +png += chunk(b"IEND", b"") + +out = sys.argv[1] if len(sys.argv) > 1 else "assets/icon.png" +with open(out, "wb") as f: + f.write(png) +print(f"wrote {out} ({N}x{N})") diff --git a/src/shared/branding.ts b/src/shared/branding.ts index d703a59..6777f10 100644 --- a/src/shared/branding.ts +++ b/src/shared/branding.ts @@ -4,5 +4,7 @@ export const BRANDING = { appName: "BunSCP", identifier: "es.teknes.bunscp", version: "0.1.0", - icon: "assets/icon.png", + // macOS build converts this .iconset folder into AppIcon.icns. + // Regenerate from assets/icon.png with scripts/gen-iconset.sh. + icon: "assets/icon.iconset", } as const;