213 lines
12 KiB
Markdown
213 lines
12 KiB
Markdown
# 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<T>` (`{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<Rec>` (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:<id>` /
|
|
`open-stack:<id>` 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-<n>`; `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/<id>/` + `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
|
|
`<html>`); `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.
|