Files
miniSCP/docs/architecture.md
T
jgamboaandClaude f7ebed5478 Make Linux a supported platform: HTML context menus + runtime deps + release target
`bun run start` died on Linux with ERR_DLOPEN_FAILED (missing
libayatana-appindicator3.so.1). Fixing the environment exposed the real gap:
electrobun's ContextMenu.showContextMenu is a documented no-op on Linux, and
since the UI suppresses the webview's own right-click menu, EVERY panel context
menu was silently dead there — Send to Other Panel, Open, Rename, Permissions,
Delete, Copy Path, Reveal and the column picker all unreachable. Electrobun
itself logs "Application menus are not supported on Linux. Implement menu UI in
your webview HTML instead." at boot.

- Context menus are now HTML on EVERY platform (ui/src/app/context-menu.ts +
  context-menu.service.ts), not just as a Linux fallback: one implementation
  cannot drift from a second one. The item builders are ported verbatim from
  the old bun menu, so ordering, per-platform labels and the Windows-local
  chmod hide are unchanged, and picks are emitted as the same `ctx-*` / `col:*`
  strings through RpcService.emitMenuAction — App.handleMenuAction is untouched
  and still the single dispatcher for both the macOS menubar and right-clicks.
  Adds what the native menu gave for free: arrow/Enter/Escape keys, viewport
  edge-flip, close on blur/resize/scroll. The Edit item now shows the real F4
  hint instead of the native menu's "e" mnemonic.
- New shellItemAction RPC replaces showRowMenu/showColumnsMenu — the only part
  the webview can't do itself (reveal, open-with-default, copy path). Deletes
  src/bun/context-menu.ts entirely.
- release.ts gains a linux-x64 target. Electrobun emits only the .tar.zst
  updater blob on Linux, so the artifact is a .tar.gz of the assembled app
  folder, mirroring the Windows portable zip.
- Docs: platform-notes gets a Linux runtime-dependency table (AppIndicator is
  linked HARD — a missing lib is a boot failure, not a degraded tray), the
  ldd diagnostic, GNOME tray caveat, known issues and a first-boot smoke test;
  gotchas gets the ERR_DLOPEN_FAILED trail and the benign GLX/GBM startup
  noise; architecture/build-and-release/README updated accordingly.

Verified: backend `tsc --noEmit` clean, `bun run build:ui` clean (399 kB
initial), app boots on Linux with no errors beyond the documented benign
warnings. NOT verified on macOS/Windows — the native menu is retired on those
platforms too, so the menus need eyeballing on a Mac before the next release.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 14:23:58 +02:00

273 lines
17 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 / WebKitGTK on Linux. 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).
- `fs/watcher.ts` — `LocalDirWatcher`: `fs.watch` handles keyed by dir,
debounced ~150 ms (watch fires bursts). Drives local auto-refresh: the main
webview sends `setWatchedDirs` (the active tab's local panel cwds); a change
fires `onChange` → `rpc.send.pathRefresh({sessionId:"local", dir})`.
`onChange` is late-bound in index.ts (the `events` pattern). SFTP has no
watch, so remote panels are polled UI-side instead (see panels.service).
- `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.
- `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. Labels go through
backend `i18n.t` and the menu is rebuilt on a locale change too.
- `i18n.ts` — backend translator (native menus + thrown error messages, which
no Angular service can reach). `setLocale(setting)` caches the resolved
locale (from `settings.locale`, `system` via `Intl…resolvedOptions().locale`);
`t(key, params)` shares the same `src/shared/i18n` dictionaries as the UI.
Wired in index.ts on boot and on `settingsChanged` (relocalizes + rebuilds
the app menu). The context menu no longer needs this — it is Angular and reads
`I18nService` directly.
- `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. **Auto-refresh** (always on):
`refresh({silent})` re-lists without the loading/busy flags and touches no
DOM (scroll/cursor/selection/filter preserved), snapshotting sessionId+cwd
to discard results if the user navigated mid-flight. Only the active tab is
live: its local panels are fs.watched (backend `setWatchedDirs` effect +
`pathRefresh` push → silent refresh), its remote panels polled every 3s
(`REMOTE_POLL_MS`); switching tabs silently refreshes the newly-active pair.
A future on/off setting is stubbed in commented breadcrumbs (types.ts,
settings.service, tab-bar).
- `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.
- `i18n.service.ts` — runtime translator. `locale` signal + `t('ns.key', params)`
(dotted lookup, `{param}` interpolation, `{one,other}` plural maps via
`Intl.PluralRules`); missing keys fall back to English then the raw key.
**Reactive by design**: `t()` reads the `locale` signal, so every template
binding that calls it re-renders live on a language switch (same call-stack
tracking as `iconClass`) — no pipe. Also exposes locale-aware `date()`/`size()`
/`speed()` so the file list reformats on switch. `SettingsService.applyLocale`
resolves the `locale` setting (`system` → `navigator.language`) into it, on
boot and on the `settingsChanged` broadcast (so editor/manager windows follow).
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
against an explicit panel; `menuTarget` remembers the right-clicked row.
- `context-menu.service.ts` + `context-menu.ts` — the panel right-click menus,
in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was
retired on ALL platforms rather than kept as a second code path (see
platform-notes.md). The service builds the item list (row / empty-space menu,
and the header column picker) and holds the open request as a signal; the
component renders it at the pointer with edge-flip clamping, arrow/Enter/Escape
keys, and close-on-blur/resize/scroll. Picked items are emitted verbatim as
`ctx-*` / `col:*` strings through `RpcService.emitMenuAction`, so they land in
the SAME `App.handleMenuAction` dispatcher the macOS app menubar uses — one
handler, two sources. The three shell actions the webview cannot perform
(reveal, open-with-default, copy path) go back to bun via the `shellItemAction`
RPC, which is all that remains of the old native menu.
- `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`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`,
`MOD_LABEL`/`ALT_LABEL`) + `blockNativeContextMenu` (kills WebKit's
Reload/Inspect menu outside text fields — the app draws its own menu instead).
- `updater.service.ts` — auto-updater state for the tab-bar pill. Signals
`state` (`idle→available→downloading→ready`|`error`), `version`, `progress`.
Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on
launch+6h, `onStatusChange` progress); `ready` is derived when the
`downloadUpdate` request resolves ok (not a pushed phase). `download()` /
`apply()` (relaunch) / `check()` (menu). Backend wiring lives in
`src/bun/index.ts`; feed is `release.baseUrl` (`https://miniscp.com/dl`, the
`/dl` host in `site/`). See build-and-release.md "Auto-updater".
## 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. Light is the zinc family; the
dark neutrals are a **warm gray family (hue 107)** anchored on `#1a1a19`
(`--background`) — retoning one dark neutral means retoning them all, or warm
and cool surfaces clash. Surface elevation ramp, both themes: `--chrome`
(frame strips) sits *below* `--background` (content panes) < `--card`
(popovers/dialogs) < `--accent` (hover). Dark ramp `#151514` → `#1a1a19` →
`#20201f` → `#2e2e2d`. The "darker" tone is true-black AMOLED: `--chrome` AND
`--background` are both `#000` (the frame/content step collapses on purpose),
elevation survives only via `--card`/`--accent`, and `--border`/`--input` go to
16%/18% white because those lines then carry the whole layout.
`bg-chrome` is on the tab bar, status bar, transfer bar, the editor/manager
header+footer strips and the manager's connection sidebar; panel-internal
toolbars deliberately stay on `--background`. 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.