Files
miniSCP/docs/architecture.md
T
jgamboaandClaude 0ae2a62229 Stage 4a: context menu is pure 7.css; retire the lookalike and the false claim
The menu now renders 7.css's real contract — ul[role=menu] > li[role=menuitem]
> button for actions, input[type=checkbox] + label for the checkable column
toggles, and dividers as .has-divider on the PRECEDING item (7.css draws them
as an ::after bevel, not as a node; the service's separator entries collapse
onto their predecessor at render time). With the markup right, the framework
supplies everything the hand-written version imitated or lacked: the left
gutter stripe, bevelled separators, the gradient selection band, the checked
box, and disabled/submenu styling we previously had none of.

Deleted: the w7-menu lookalike (both the button-slab stripper in `skin` and
the whole hand-authored Aero set in `skinfix`) and its w7-menu/w7-menu-sep
hooks. The "7.css has no menu component" claims in styles.css and
architecture.md were FALSE — dist/gui/menu.css is a full component; the old
markup (div > button) simply matched none of it. Both corrected to state the
real contract and the trap.

Keyboard now follows the platform instead of a highlight class: arrows move
REAL focus between items — 7.css styles :focus-visible identically to :hover,
and keyboard-initiated programmatic focus counts — and Enter/Space activate
buttons natively; only labels need a synthetic click. This also makes the menu
honest to screen readers.

Dark mode: menu.css hardcodes light literals (color:initial, white bevel
halves, the #b3d3f9 checked-box ring); restated in skinfix's dark arm like the
ListView's.

Also fixed, caught BY the harness: the Stage 3 frame sizing rule was keyed on
bare `.window`, forcing 100vh height onto every 7.css window (the harness's
demo windows blew up to full page). It is now keyed on `w7-frame`, a hook only
the three real window roots carry.

Verified zoomed in light and dark against 7.css's reference markup: gutter,
dividers, checkmark box, grey right-aligned accelerator all match; bundle
490.92 kB.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 03:20:55 +02:00

22 KiB

miniSCP architecture

Dense reference for development and debugging. Companion files: build-and-release.md, platform-notes.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 externals). 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.

Skins (settings.skin: modern | win7)

A fifth theming axis, and the only one that is a class (.win7 on <html>) rather than a data-* attribute — 7.css/dist/7.scoped.css is precompiled against a literal .win7, so the name is not ours to pick. Default modern = class absent, matching the delete-when-default rule.

Cascade (ui/src/styles.css, first lines): an explicit @layer theme, base, components, utilities, skin, skinfix; statement precedes both imports. 7.css is imported with layer(skin), i.e. above utilities, so a bare button rule in 7.css outranks rounded-md on that same button — that inversion is what reskins ~1900 inline utility classes with no template edits. Consequences: (1) any utility later added to a button/input/ select/textarea/table silently loses under the skin; (2) neutralizers that want the Tailwind value back must sit inside @layer skin, not skinfix — revert-layer rolls back one layer, so from skin it lands on utilities, but from skinfix it lands back on 7.css and does nothing. skinfix is for rules that state an explicit value (.w7-btn).

Token bridge (bottom of styles.css): :root.win7 re-points the existing semantic tokens at Win7 values, so utilities Tailwind still controls come out right too. It adds NO new tokens on purpose — a new colour would need another @theme inline line. The Win7 surface ramp inverts the modern one: --background (content panes) is Explorer white and --chrome is the darker #f0f0f0 window surface. --radius: 5px so rounded-md (calc(--radius - 2px), 74 uses) lands exactly on 7.css's 3px --w7-el-bdr. --primary/--ring are deliberately left alone so the accent axis stays live; :root.win7[data-accent] feeds it to --w7-w-bg, which is authentic (Win7 shipped "Window Color"). Dark Win7 is hand-authored (Microsoft never shipped one) as ~20 --w7-* overrides, duplicated forced + system like every other dark block. --w7-font is restated as a px font shorthand so data-text still scales and 7.css's pt shorthand stops resetting weight/line-height.

Markup hooks: role="progressbar" (+aria) on the transfer bars, has-scrollbar on scroll containers (7.css only styles ::-webkit-scrollbar under that class), w7-toolbar on the three header rows (their buttons go flat — Explorer's command bar was flat, unlike dialog push buttons), w7-btn on the 10 dialog action buttons.

Context menu: pure 7.css via its real menu contract — ul[role=menu] > li[role=menuitem] > button for actions, input[type=checkbox] + label for the checkable column toggles, dividers as .has-divider on the PRECEDING item (an ::after bevel, not a node). The contract is exact: an earlier iteration rendered div > button, matched nothing, and wrongly concluded "7.css has no menu component" — it has a full one (dist/gui/menu.css), including the gutter stripe, disabled state and submenu arrows. Keyboard moves REAL focus between items (7.css styles :focus-visible like :hover); Enter/Space activate buttons natively. Only menu.css's hardcoded light literals are restated for dark, in skinfix.

Font: Segoe UI is absent off Windows and not redistributable. Noto Sans — the substitute 7.css itself names — is vendored (OFL-1.1, from @fontsource/noto-sans) into ui/src/fonts/; under src/ and not public/ so esbuild emits it hashed into media/ and rewrites the url().

Window chrome: chromeOptions(skin) in src/bun/windows.ts is the single place either skin's frame is decided, spread by both the main window and openSecondaryWindow. win7 uses titleBarStyle: "hidden" on every platform — that clears Titled from the Cocoa style mask, which is also the only way to remove the macOS traffic lights. Linux honours it too: the GTK wrapper calls gtk_window_set_decorated(false) and logs "GTK: Created window without decorations (custom titlebar)" (verified on GNOME/Wayland), so the frameless Aero chrome is not a mac/win-only feature. The webview then draws the whole frame via ui/src/app/title-bar/title-bar.ts (7.css's .title-bar > .title-bar-text + .title-bar-controls, buttons keyed by aria-label), mounted by all three window roots. Because titleBarStyle is fixed at construction, a skin change only reaches the frame on the next window open; the CSS half flips live, and the options row says so. IS_WIN7_CHROME in platform.ts reads the preload-baked __APP_SKIN (settings resolve too late for layout) and forces IS_MAC_CHROME false, since there are no traffic lights to reserve pl-24 for.

Window controls: new windowMinimize / windowToggleMaximize / windowClose / windowIsMaximized requests, built by windowControlHandlers(getWindow, onClose?) in rpc-handlers.ts and served by all three window kinds (each passing a late-bound getter for its own window — the window takes the rpc in its constructor, same reason events is late-bound). onClose is overridden per window kind so the X reuses the existing close path: editors run their dirty prompt, the manager runs managerClose, main falls through to the cancellable before-quit guard. electrobun emits no maximize event, so trackMaximizeState derives it from resize + the synchronous isMaximized() and pushes windowState — which also covers Aero Snap and Win+Up. Double-click-to-maximize is likewise absent from electrobun's drag regions and is implemented in the title-bar component.