diff --git a/README.md b/README.md index 97484c1..bc79918 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,13 @@ remote server over SSH/SFTP, so you can move files **local↔remote**, > **Name & icon are placeholders.** See [Rebranding](#rebranding) — it's a > one-file change. +## Developer docs + +Dense reference material lives in [docs/](docs/): [architecture](docs/architecture.md) +(processes, windows, RPC, every module/service), [build & release](docs/build-and-release.md) +(Build-n versioning, per-target release pipeline, CI), [platform notes](docs/platform-notes.md) +(macOS/Windows matrix + smoke tests), and [gotchas](docs/gotchas.md) (known landmines). + ## Features - Dual-panel commander UI; either panel can be Local or any saved connection. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..ee83b5b --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,212 @@ +# miniSCP architecture + +Dense reference for development and debugging. Companion files: +[build-and-release.md](build-and-release.md), [platform-notes.md](platform-notes.md), +[gotchas.md](gotchas.md). + +## Process model + +- **Bun main process** (`src/bun/`): electrobun runs it inside a WebWorker of + its native launcher. Owns every privileged capability: filesystem, SSH, + window management, native menus/dialogs/tray, the connection/settings stores. +- **Webviews** (`ui/` Angular): one per window, system WebKit on macOS / + WebView2 on Windows. Zero privileged access — everything goes through RPC. +- **RPC transport**: one WebSocket server in the bun process, multiplexed by + `webviewId` (each webview auto-connects using natively injected globals). + Requests are typed by the `AppRPC` schema in `src/shared/rpc.ts`. + +### The one-schema rule + +`AppRPC` is a single superset schema shared by all window kinds. This works +because electrobun allows **partial handler sets** (a side only implements +what it serves) and **silently drops messages nobody listens to**. Every +response crosses the bridge as `Result` (`{ok:true,…}|{ok:false,error}`) — +exceptions never cross; `wrap()` in `src/bun/rpc-handlers.ts` enforces it. + +### Per-window RPC instances (critical) + +Each `BrowserWindow` MUST receive its own `defineRPC(...)` instance: binding a +BrowserView calls `rpc.setTransport(...)`, so a shared instance would have its +transport stolen by the most recent window. `createAppRPC` (main), +`createEditorRPC` (per editor window, hooks close over that window's record), +`createManagerRPC` (the single manager window). `rpc.send.*` on an instance +targets exactly its own webview. + +### The `events` late-binding pattern + +`createAppRPC` needs to trigger things that need `rpc.send` (which doesn't +exist until after construction). `AppDeps.events` is a mutable object of +no-op functions, reassigned in `src/bun/index.ts` after everything exists: +`sessionClosed`, `connectionsChanged`, `openEditor`, `settingsChanged`, +`openConnectionManager`, `showAbout`. + +## Window kinds + +`src/bun/windows.ts` — `buildPreload(mode, ctx?)` injects `__APP_PLATFORM`, +`__APP_MODE` (`main|editor|manager`), `__APP_CTX` before any page script. +`ui/src/main.ts` picks the root component from `APP_MODE`; all three roots use +selector `app-root` (index.html hosts only that tag). `openSecondaryWindow` is +the ONLY place that branches platform for secondary chrome: macOS +`hiddenInset` + `trafficLightOffset {x:12,y:13}` (the UI renders an +`h-10 pl-24` drag-region header via `IS_MAC_CHROME`); other platforms keep the +native titlebar and a compact `h-9` non-drag header. + +`WindowRegistry` (same file): keyed window map with dedupe/activate, +cascade placement off an anchor frame, focus tracking. electrobun's emitter +has **no `off()`** — `track()` closures capture only `(this, key)` so closed +records can be garbage collected. A future "window follows window" grouping +belongs here (`anchor.on('move')` + `setPosition`). + +### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/editor-window/`) + +One window per file, key `sessionId+path` (sessionId, not fsKey: saves route +through a concrete session, and `local` is one shared session anyway). +Re-opening focuses the existing window. The webview fetches its own content +(`readFile`) — `openEditor` just creates the window with ctx in the preload. + +**Close safety** (electrobun window `close` is NOT cancellable — verified, no +`allow` response like `before-quit` has): +1. ⌘W (routed via the app menu → `editorCloseRequest`) / Close button → + styled in-window Save/Don't Save/Cancel; the confirm path calls + `editorClose` which sets `suppressPrompt` before `win.close()`. +2. Traffic-light close → the window is already gone, but the editor mirrored + its draft to bun (`editorState`, 400 ms debounce + flush on blur/dirty-flip) + → native Save/Discard box from the held draft. A failed save (dead session) + parks the draft via `EditManager.rescueDraft` into the edits-leftovers dir, + offered back at next boot. Known limit: the last ≤400 ms of keystrokes can + be lost on an instant type-then-close. +3. ⌘Q → the app-level `before-quit` guard (cancellable) counts dirty editors + plus running transfers. + +`editorSave` is a dedicated request (not generic `writeFile`) so one handler +atomically writes + cleans the bun record + retitles + pushes `pathRefresh` +to the main window (panels showing that directory refresh). Editor upgrades: +scroll-synced line gutter (hidden when wrap is on — wrapped line numbers would +need per-edit height measurement), Alt/⌥Z wrap toggle, custom find bar +(matches computed in TS, `setSelectionRange` + scrollTop math — deliberately +not `findInPage`, which is a shim on WebView2). + +### Connection-manager window (`src/bun/manager-window.ts`) + +Single instance, centered over main, **modal to main only** (editors stay +usable). No native modality exists, so three layers approximate it: bun +bounces main's `focus` event back to the manager; the main webview renders a +click-swallowing overlay (`managerState` message); main drops its keyboard +shortcuts and bun drops main-targeted menu actions while open. Connect / +open-stack execute in the MAIN window via the `connect:` / +`open-stack:` menuAction channel (the manager closes right after — a +failed connect surfaces in main). Store edits push `connectionsChanged` so +main's `ConnectionsService` reloads; a reload-retry in `App.findConnection` +covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it +would float over editor windows). + +## Bun modules + +- `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops + + path helpers + streams + **`fsKey`** (see self-copy below). +- `fs/local.ts` — node:fs, `lstat`-based, `fsKey="local"`. Owner/group via + cached `id -nu` / `dscacheutil|getent` spawns (win32 → undefined). + `listLocalDrives()` for the Windows drive picker (`fsutil`). +- `fs/sftp.ts` — ssh2 under Bun (pure JS; native accelerators are build + `external`s). POSIX paths always. `fsKey = resolvedIP:port` via `dns.lookup` + at connect. Owner/group parsed from the ls-style readdir `longname`. + Keepalive 15 s; `closedByUs` flag so `dispose()` doesn't fire the onClose + callback. mtime seconds→ms; mode masked `& 0o7777`. +- `fs/registry.ts` — sessionId → provider map; `"local"` is built-in; + `add()` mints `sftp-`; `drop()` (session died) vs `remove()` (clean). +- `transfers/engine.ts` — FIFO job queue. `Semaphore` with live resize + (`wake()` reserves slots synchronously — an async waiter increment was a + race). Per-job worker pool (`MAX_WORKERS_PER_JOB=8`) draws from the shared + slot budget, so in-folder parallelism and cross-job fairness share one knob + (`setConcurrency` from settings; `single` mode = 1). `activeStreams` set + enables hard cancel. `emit` throttled 100 ms. `failSession` fails that + session's jobs. Probe files: `.{appname}-probe-*`. +- `transfers/walk.ts` — recursive tree walk (segment arrays, skips symlinks) + + `removeRecursive`. +- **Self-copy protection** (data-loss class): copying a file onto itself + truncates it (`createWriteStream` O_TRUNCs before the read). Guards compare + **fsKey, never sessionId** — two sessions to one server are distinct + sessions on one filesystem. When fsKeys differ but paths collide, + `probeSameFilesystem` writes a token via dst and reads it via src to unmask + hidden aliases (SFTP has no dev/inode). Fails open (permissions produce + their own error). Residual risk (documented in README): same filesystem + mounted at different paths. +- `edits.ts` — `readFileText`/`writeFileText` (2 MB cap) for the built-in + editor; `EditManager` for the external-editor flow: temp-download under + `appDataDir()/edits//` + `meta.json`, directory watch (survives atomic + renames), 400 ms debounce → engine upload. Watchers die with their session; + leftover dirs are offered for cleanup at boot. `launchEditor` is the + platform seam (`open` / `cmd /c start` / `xdg-open`). +- `store/paths.ts` — per-OS `appDataDir()` + `migrateLegacyDataDir()` (renames + a `BRANDING.legacyNames` dir into place on boot; list currently empty). +- `store/connections.ts` — plaintext-passwords JSON (deliberate v1 choice), + atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults + spread-merge on load, so NEW SETTINGS KEYS need no backend change. +- `context-menu.ts` — native row/empty-space menus + the column picker. + Local shell actions (reveal/open/copy-path) run bun-side via Utils; others + forward through `menuAction` and the UI applies them to the row it + remembered (`FileActionsService.menuTarget`). Labels are per-platform. +- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the + Connect submenu mirrors the store). ⌘W is intercepted by the menu, so bun + routes `close-tab` to the focused editor window first. +- `tray.ts` — static icon, click = activate main. macOS template image; + Windows colored icon. No `setImage` calls ever (they lose the template + treatment) and no menu (clicks arrive directly). +- `index.ts` — composition root: wiring order matters (events assigned after + rpc; manager/editors constructed after win; quit guard; focus tracking for + notification suppression across all windows). + +## UI services (`ui/src/app/`) + +- `rpc.service.ts` — wraps `Electroview.defineRPC`. **Push messages fan out + through plain callback registries, never signals**: a signal consumed from + an effect that also writes signals re-triggers itself (this froze the whole + app once), and signal coalescing drops progress snapshots. All requests are + gated on the socket-open promise — calls fired during boot would otherwise + be silently lost. `request` and `send` are ready-gated Proxies. +- `panels.service.ts` — `PanelState` (per panel): sessionId, fsKey, cwd, + entries, sort, cursor, selection, type-to-filter. `sorted` computed: + filter → dirs-first sort (name/size/mtime numeric, kind/rights/owner/group + via display-string compare) → synthetic `'..'` row unshifted unless + `isRootPath(cwd)`. `touched` marks user navigation (connect flows only + auto-load `localDir` into an untouched local panel). `connectToken` + supersedes stale connects. `Workspace` = tab = left/right pair + activeSide. + Remote goes RIGHT on connect/open-stack (commander convention); per-panel + dropdown connects target their own panel. +- `transfers.service.ts` — jobMap from `transferUpdate` callbacks; EMA speed + (`SPEED_SMOOTHING=0.7`); `copyToOtherPanel` guards same-fs+same-dir before + enqueue and runs the collision flow (same-path auto-skip, type-mismatch + skip, overwrite dialog with all/none batch modes). +- `settings.service.ts` — one signal per setting; **imperative apply pattern** + (`applyTheme/DarkTone/Accent/TextSize` set `data-*` attributes on + ``); `applyAll` also serves the `settingsChanged` broadcast so editor + and manager windows restyle live (they render no settings UI, so no + persist echo). New setting = five touches here + types.ts (backend merges + automatically). +- `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the + main window's panels listen. Exists so dialogs don't inject PanelsService + (whose constructor spawns a workspace — fatal in secondary windows). +- `connections.service.ts` — store mirror + ssh-config hosts; reloads on the + `connectionsChanged` push. +- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always + against an explicit panel; `menuTarget` remembers the right-clicked row. +- `file-icons.ts` — extension → category map driving glyphs, colors + (fixed `--icon-*` tokens, never accent-tinted) and the Kind column. +- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`, + `EDITOR_CONTEXT`, `IS_MAC_CHROME`, `IS_WINDOWS`, `MOD_LABEL`/`ALT_LABEL`) + + `blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside + text fields). + +## Theming + +`ui/src/styles.css`: shadcn-style OKLCH tokens; dark values are duplicated in +`:root[data-theme='dark']` AND a `prefers-color-scheme` media block (system +mode) — every dark token change goes in BOTH. Accent tones: one +`--tone-h/--tone-c` line per tone + three formula blocks derive `--primary`, +`--ring`, `--cursor-tint`; graphite = attribute removed = pristine zinc. +`@theme inline` maps tokens to Tailwind utilities (`bg-primary`, +`text-icon-folder`, `bg-cursor-tint`). Selected rows are `bg-primary/15` +(follow the accent); the cursor row uses `--cursor-tint` so graphite stays +pixel-identical to the old neutral hover. Text-size levels set `--row-h`, +`--list-font` etc. per `data-text` attribute, consumed via `h-(--row-h)` / +`text-(length:--list-font)` arbitrary utilities. diff --git a/docs/build-and-release.md b/docs/build-and-release.md new file mode 100644 index 0000000..5b02a80 --- /dev/null +++ b/docs/build-and-release.md @@ -0,0 +1,84 @@ +# Build & release + +## Versioning — "Build n" + +Sublime-style: the version IS the commit count. `scripts/gen-version.ts` runs +`git rev-list --count HEAD` + `git rev-parse --short HEAD` and writes the +**gitignored** `src/shared/version.gen.ts` (`BUILD = {number, commit, date, +electrobun}`). It is regenerated by `postinstall` and at the start of every +`dev`/`build`/`start`/`release` script; committing it would bump the count it +records, hence gitignored. Fresh checkouts fail `tsc` until `bun install` has +run once (postinstall creates the file). `BRANDING.version` stays +semver-shaped (`0.1.`) for electrobun's config/updater; `BRANDING.buildLabel` +("Build n") is what humans see (About box, options popover). + +## Dev loop + +- `bun run dev` — gen-version → scripts/dev.ts: starts `ng serve` (HMR) and + `electrobun dev --watch`, with `DEV_SERVER_URL` pointing the webviews at the + dev server (only honored on the dev channel and only if the server answers). +- `bun run build` — gen-version → Angular build → `electrobun build` + (**dev channel** — fine for boot checks, NOT a release). +- `scripts/node-version-shim.ts` (preloaded via `bunfig.toml`, root AND ui/) + fakes a Node version the Angular CLI accepts under Bun. Both preloads are + load-bearing. + +## Release builds + +`bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64]` (default: host). +Steps: gen-version → gen-icons → Angular build → `electrobun build +--env=stable` → copy/rename artifacts into +`release//miniSCP-Build-.*`. + +Facts the script encodes: + +- **`--env=stable` is mandatory for releases** — the CLI defaults to the dev + channel (different app name `miniSCP-dev`, no distributable artifacts). +- **No OS cross-compile.** The CLI builds for its host (its `targets` config + field is unread in 1.18.1); packaging needs host tools (codesign/hdiutil on + macOS, rcedit/PowerShell on Windows). Hence one script run per artifact, on + the matching machine; `win-x64` on a non-Windows host errors out. +- **mac-x64 from the arm64 Mac (Rosetta)**: the script downloads electrobun's + x64 CLI binary once (`electrobun-cli-darwin-x64.tar.gz` from the GitHub + release matching the installed version, cached at + `~/.cache/miniscp/electrobun-cli-x64-/`) and runs it directly — + Rosetta translates it, the CLI sees `process.arch === "x64"`, downloads the + `dist-macos-x64` core into the electrobun package dir on first use, and + packages a genuine x64 app. Note: Bun's `fetch`→`Bun.write` streaming hung + on the GitHub redirect chain; the script uses `curl -fsSL` deliberately. +- **Core binary downloads**: the installed npm package ships mac-arm64 + binaries only; other `dist--` cores are fetched from + `github.com/blackboardsh/electrobun/releases` at build time — network to + GitHub is a build-time requirement on every fresh machine/runner. +- **Artifact shapes** (stable channel, in `artifacts/`): macOS → + `stable-macos--miniSCP.dmg` + `.app.tar.zst` + `update.json` + (updater feed); Windows → `stable-win-x64-miniSCP-Setup.zip` + (self-extracting installer → `%LOCALAPPDATA%`) plus the script's own + **portable zip** made from the assembled app folder under + `build/stable-win-x64/` (layout to confirm on the first real Windows build — + the step warns instead of failing). +- The script filters artifacts by app name, so stale artifacts from older + brandings don't get collected. + +## CI (Gitea Actions) + +`.gitea/workflows/release.yml` — tag-triggered (`build-*`), macos + windows +runner matrix, each running the same release script; inert until act_runners +with those labels are registered. Needs `fetch-depth: 0` (the build number is +the commit count). Runner prereqs are commented in the workflow file. + +## Icons + +`scripts/gen-icons.ts` (resvg, dev-time): renders `assets/logo.svg` into the +macOS iconset + `icon.png`, a multi-size `assets/icon.ico` (Windows installer/ +taskbar, wired via `electrobun.config.ts win.icon`), the macOS tray template +PNG from `assets/tray/tray-glyph.svg`, and a small colored Windows tray PNG. +Rerun after touching either SVG; outputs are committed (build inputs). + +## electrobun config notes + +`electrobun.config.ts`: app identity comes from `BRANDING`; `build.bun.external` +must keep `["cpu-features", "*.node"]` (ssh2's optional native accelerators — +bundling them breaks the build; runtime falls back to pure JS); +`build.copy` ships the Angular dist and tray assets into `views://`; +`bundleCEF: false` everywhere (system WebKit / WebView2). diff --git a/docs/gotchas.md b/docs/gotchas.md new file mode 100644 index 0000000..13e28a4 --- /dev/null +++ b/docs/gotchas.md @@ -0,0 +1,97 @@ +# Gotchas — landmines this codebase has already stepped on + +Every one of these caused a real bug or debugging spiral. Read before +touching the adjacent code. + +## Angular (v22, zoneless, signals, OnPush) + +- **Never consume push streams through signal+effect.** An effect that reads + a signal and (directly or transitively) writes signals re-triggers itself — + this froze the entire app on the first transfer. Push messages + (transferUpdate etc.) go through plain callback registries in RpcService. + Signal coalescing would also silently drop intermediate progress snapshots. +- **No signal writes during template render** (NG0600). Anything that loads + panels from code paths reachable during render defers with + `queueMicrotask` (see `PanelsService.newWorkspace`). +- **`[value]` on `