P6: placeholder icon, README, packaged build verified
- assets/icon.png (generated by scripts/make-icon.py) + icon.iconset (scripts/gen-iconset.sh); electrobun compiles it to AppIcon.icns - branding.icon points at the iconset - README: features, stack, dev/build, rebranding, plaintext-password warning, shortcuts, v1 scope - verified: full production build (ng build + electrobun build) produces a launchable BunSCP.app that boots standalone from views://app Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@@ -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).
|
||||||
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 736 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -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"
|
||||||
@@ -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})")
|
||||||
@@ -4,5 +4,7 @@ export const BRANDING = {
|
|||||||
appName: "BunSCP",
|
appName: "BunSCP",
|
||||||
identifier: "es.teknes.bunscp",
|
identifier: "es.teknes.bunscp",
|
||||||
version: "0.1.0",
|
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;
|
} as const;
|
||||||
|
|||||||