diff --git a/README.md b/README.md index b8155cb..e0930bd 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ remote server over SSH/SFTP, so you can move files **local↔remote**, 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). +(macOS/Windows/Linux matrix, runtime deps + smoke tests), and +[gotchas](docs/gotchas.md) (known landmines). ## Features @@ -75,6 +76,11 @@ ui/src/app/ Angular components + signal services - [Bun](https://bun.sh) ≥ 1.3 (`bun --version`). - macOS: **Xcode Command Line Tools** (for the app build). +- Linux: **AppIndicator** — the app links it for the tray and will not start + without it (`ERR_DLOPEN_FAILED`). `libayatana-appindicator` (Arch), + `libayatana-appindicator3-1` (Debian/Ubuntu), + `libayatana-appindicator-gtk3` (Fedora), plus system `webkit2gtk`. + Details and diagnostics in [platform notes](docs/platform-notes.md). - No Node install required — a preload shim (`scripts/node-version-shim.ts`) satisfies the Angular CLI's Node version check while running under Bun. @@ -107,11 +113,14 @@ cross-compile between OSes: bun run release -- --target mac-arm64 # on the Mac (native) bun run release -- --target mac-x64 # on the same arm64 Mac, via Rosetta 2 bun run release -- --target win-x64 # on a Windows 11 x64 machine +bun run release -- --target linux-x64 # on a Linux x64 machine ``` Artifacts land in `release//miniSCP-Build-.*` — a `.dmg` per mac arch; on Windows a `-Setup.zip` installer **and** a `-portable.zip` -(unzip anywhere and run). The mac-x64 build fetches electrobun's x64 CLI once +(unzip anywhere and run); on Linux a portable `.tar.gz` (unpack and run +`miniSCP.sh`, which sets the NVIDIA workaround then execs `bin/launcher`). +The mac-x64 build fetches electrobun's x64 CLI once into `~/.cache/miniscp/` and runs it under Rosetta so the whole pipeline believes it is on an Intel Mac. CI: `.gitea/workflows/release.yml` runs the same script on a macos + windows runner matrix once act_runners are @@ -210,6 +219,12 @@ shell-integration wording, drive listing, and the connections-file ACL. The application menu ([src/bun/menu.ts](src/bun/menu.ts)) is macOS-only for now; the in-app UI covers every menu action. +Right-click menus are **HTML, on every platform** +([ui/src/app/context-menu.ts](ui/src/app/context-menu.ts)) rather than native — +electrobun's native context menu is a documented no-op on Linux, and one menu +implementation beats two that drift. Only reveal / open-with-default / copy-path +cross to the backend, through the `shellItemAction` RPC. + ## Windows (beta) Windows 11+ x64 is supported as a **beta** target (win-arm64 deferred). All @@ -231,6 +246,29 @@ drive root ('..' disappears); local↔remote transfers; external editor launch renders colored; Permissions absent on local panels; Ctrl+labels in tooltips; portable zip runs from any folder. +## Linux (beta) + +Linux x64 is supported as a **beta** target and builds a portable tarball. +One hard requirement: the AppIndicator library listed under +[Prerequisites](#prerequisites) — electrobun links it for the tray, so without +it the app aborts at startup with `ERR_DLOPEN_FAILED` rather than merely losing +its tray icon. + +- **No native menus at all** upstream (both the app menubar and context menus + are no-ops). The menubar was already macOS-only and the context menus are now + HTML, so nothing is missing in practice. +- **Tray visibility** depends on the desktop: KDE, XFCE, MATE and Cinnamon show + it; GNOME 3.26+ needs the AppIndicator extension. The app works either way. +- **Unsigned and unpackaged**: no AppImage, `.deb`, desktop entry or icon + installation — unpack the tarball and run `miniSCP.sh`. +- **NVIDIA GPUs** need `WEBKIT_DISABLE_DMABUF_RENDERER=1` or the window renders + black. Every launch path (dev scripts and the packaged `miniSCP.sh` wrapper) + sets it automatically when an NVIDIA driver is detected — see + [gotchas](docs/gotchas.md#linux) if you add a new way to launch the app. + +Full dependency table, diagnostics and the first-boot smoke test: +[platform notes](docs/platform-notes.md). + ## Known issues - **Rare native crash under load.** A long-running session (multiple remote @@ -246,5 +284,5 @@ portable zip runs from any folder. Drag-and-drop, file preview/edit, transfer resume, symlink following (symlinks are listed but skipped by transfers), OS keychain, tab persistence -across restarts, Windows/Linux packaging (the code is portable; only macOS -packaging is wired up). +across restarts, Linux desktop integration (AppImage/.deb/.desktop entry — +the release is a portable tarball). diff --git a/docs/architecture.md b/docs/architecture.md index 6537070..016279f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,7 +10,8 @@ Dense reference for development and debugging. Companion files: 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. + 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`. @@ -148,10 +149,6 @@ would float over editor windows). - `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. Labels go through @@ -161,7 +158,8 @@ would float over editor windows). 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). `context-menu.ts` labels resolve through `t()` at show-time. + 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). @@ -220,12 +218,24 @@ would float over editor windows). 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_CHROME`, `IS_WINDOWS`, `MOD_LABEL`/`ALT_LABEL`) - + `blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside - text fields). + `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 @@ -298,7 +308,18 @@ still scales and 7.css's `pt` shorthand stops resetting weight/line-height. (+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. +dialog push buttons), `w7-btn` on the 10 dialog action buttons, `w7-menu` (+ +`w7-menu-sep`) on the HTML context menu. + +**Context menu**: 7.css ships no menu component, so the Aero menu is +hand-authored across both layers — the row ` + } + } + + } + `, + // Closing on scroll/blur matches native menu behavior: the anchor row moves + // out from under a menu that would otherwise float over stale content. + host: { + '(window:blur)': 'close()', + '(window:resize)': 'close()', + '(document:scroll)': 'close()', + }, +}) +export class ContextMenu { + readonly menu = inject(ContextMenuService); + private readonly focus = inject(FocusService); + private readonly menuEl = viewChild>('menuEl'); + + readonly activeIndex = signal(-1); + + /** Click point, flipped/clamped so the menu never overflows the viewport. */ + readonly position = computed(() => { + const req = this.menu.request(); + if (!req) return { x: 0, y: 0 }; + const height = + req.items.reduce((sum, i) => sum + (i.type === 'separator' ? SEPARATOR_H : ITEM_H), 0) + 8; + const x = + req.x + MENU_W > window.innerWidth - EDGE_GAP + ? Math.max(EDGE_GAP, req.x - MENU_W) + : req.x; + const y = + req.y + height > window.innerHeight - EDGE_GAP + ? Math.max(EDGE_GAP, req.y - height) + : req.y; + return { x, y }; + }); + + constructor() { + // Focus the menu once it exists so arrow keys / Escape reach it. Keyed on + // the viewChild signal, NOT on request(): under zoneless CD an effect can + // run before the @if has rendered the element. + effect(() => this.menuEl()?.nativeElement.focus()); + } + + close(): void { + if (!this.menu.request()) return; + this.menu.close(); + this.activeIndex.set(-1); // a fresh menu starts with nothing highlighted + this.focus.bump(); + } + + pick(item: MenuItem): void { + if (!item.action) return; + const action = item.action; + this.close(); + this.menu.onPick(action); + } + + /** Right-clicking the backdrop closes rather than stacking a second menu. */ + onBackdropContext(event: MouseEvent): void { + event.preventDefault(); + this.close(); + } + + onKeydown(event: KeyboardEvent): void { + const items = this.menu.request()?.items ?? []; + switch (event.key) { + case 'Escape': + event.preventDefault(); + event.stopPropagation(); + this.close(); + break; + case 'ArrowDown': + event.preventDefault(); + this.step(items, 1); + break; + case 'ArrowUp': + event.preventDefault(); + this.step(items, -1); + break; + case 'Enter': + case ' ': { + event.preventDefault(); + const item = items[this.activeIndex()]; + if (item) this.pick(item); + break; + } + } + } + + /** Move the highlight by one selectable item, skipping separators, wrapping. */ + private step(items: MenuItem[], delta: number): void { + const count = items.length; + if (!count) return; + let index = this.activeIndex(); + for (let hops = 0; hops < count; hops++) { + index = (index + delta + count) % count; + if (items[index].type !== 'separator') { + this.activeIndex.set(index); + return; + } + } + } +} diff --git a/ui/src/app/file-actions.service.ts b/ui/src/app/file-actions.service.ts index 48d9375..ab40e70 100644 --- a/ui/src/app/file-actions.service.ts +++ b/ui/src/app/file-actions.service.ts @@ -1,5 +1,6 @@ import { Injectable, inject } from '@angular/core'; import type { FileEntry } from '@shared/types'; +import { ContextMenuService } from './context-menu.service'; import { DialogsService } from './dialogs.service'; import { joinPath } from './format'; import { type PanelState, type Side } from './panels.service'; @@ -26,21 +27,25 @@ export class FileActionsService { private readonly rpc = inject(RpcService); private readonly toast = inject(ToastService); private readonly dialogs = inject(DialogsService); + private readonly menu = inject(ContextMenuService); private readonly settings = inject(SettingsService); menuTarget: MenuTarget | null = null; - /** Right-click entry point: remember the target and ask bun for a native menu. */ - async openContextMenu(target: MenuTarget): Promise { + /** Right-click entry point: remember the target, then open the menu on it. */ + openContextMenu(event: MouseEvent, target: MenuTarget): void { this.menuTarget = target; const { panel, entry } = target; - await this.rpc.request.showRowMenu({ - path: entry?.path ?? panel.cwd(), - name: entry?.name ?? '', - type: entry?.type ?? 'dir', - isLocal: panel.sessionId() === 'local', - hasEntry: !!entry, - }); + this.menu.open( + event, + this.menu.rowItems({ + path: entry?.path ?? panel.cwd(), + name: entry?.name ?? '', + type: entry?.type ?? 'dir', + isLocal: panel.sessionId() === 'local', + hasEntry: !!entry, + }), + ); } async trash(panel: PanelState, entry: FileEntry): Promise { diff --git a/ui/src/app/file-panel/file-panel.ts b/ui/src/app/file-panel/file-panel.ts index 4881b7e..943dc29 100644 --- a/ui/src/app/file-panel/file-panel.ts +++ b/ui/src/app/file-panel/file-panel.ts @@ -12,6 +12,7 @@ import { NgIcon } from '@ng-icons/core'; import { I18nService } from '../i18n.service'; import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types'; import { ConnectionsService } from '../connections.service'; +import { ContextMenuService } from '../context-menu.service'; import { DialogsService } from '../dialogs.service'; import { FileActionsService } from '../file-actions.service'; import { fileGlyph, fileIconClass, fileKindKey } from '../file-icons'; @@ -47,6 +48,7 @@ export class FilePanel { readonly transfers = inject(TransfersService); readonly connections = inject(ConnectionsService); readonly dialogs = inject(DialogsService); + private readonly menu = inject(ContextMenuService); private readonly settings = inject(SettingsService); private readonly rpc = inject(RpcService); readonly panel = computed(() => this.panels.get(this.side())); @@ -88,7 +90,7 @@ export class FilePanel { headerContextMenu(event: MouseEvent): void { event.preventDefault(); - void this.rpc.request.showColumnsMenu({ visible: this.settings.columns() }); + this.menu.open(event, this.menu.columnItems(this.settings.columns())); } pickDrive(drive: string): void { @@ -238,14 +240,14 @@ export class FilePanel { // standard behavior: keep the selection when right-clicking inside it if (entry && !panel.selected().has(entry.name)) panel.clearSelection(); panel.cursor.set(index); - void this.actions.openContextMenu({ side: this.side(), panel, entry }); + this.actions.openContextMenu(event, { side: this.side(), panel, entry }); } listContextMenu(event: MouseEvent): void { event.preventDefault(); if (this.blocked()) return; this.activate(); - void this.actions.openContextMenu({ side: this.side(), panel: this.panel(), entry: null }); + this.actions.openContextMenu(event, { side: this.side(), panel: this.panel(), entry: null }); } openAt(index: number): void { diff --git a/ui/src/app/platform.ts b/ui/src/app/platform.ts index ece8bd4..bf85cfa 100644 --- a/ui/src/app/platform.ts +++ b/ui/src/app/platform.ts @@ -18,7 +18,7 @@ declare global { export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : ''; -const IS_MAC = PLATFORM === 'darwin'; +export const IS_MAC = PLATFORM === 'darwin'; /** * The win7 skin makes the window frameless on EVERY platform and draws its own @@ -40,6 +40,8 @@ export const IS_MAC_CHROME = IS_MAC && !IS_WIN7_CHROME; export const IS_WINDOWS = PLATFORM === 'win32'; +export const IS_LINUX = PLATFORM === 'linux'; + /** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. Keyed on the real * platform, NOT IS_MAC_CHROME — the keyboard does not change with the skin. */ export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+'; diff --git a/ui/src/app/rpc.service.ts b/ui/src/app/rpc.service.ts index ab81323..b45c21f 100644 --- a/ui/src/app/rpc.service.ts +++ b/ui/src/app/rpc.service.ts @@ -103,6 +103,15 @@ export class RpcService { this.menuListeners.add(listener); } + /** + * Dispatch an action that originated in the UI (the HTML context menu) as if + * it had arrived from bun, so app.ts keeps ONE handler for menu actions + * regardless of whether the app menubar or a right-click produced them. + */ + emitMenuAction(action: string): void { + for (const listener of this.menuListeners) listener(action); + } + onSettingsChanged(listener: (settings: AppSettings) => void): void { this.settingsListeners.add(listener); } diff --git a/ui/src/styles.css b/ui/src/styles.css index 51dcbcd..8845976 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -579,6 +579,19 @@ body { .win7 .w7-flat::after { content: none; } + + /* Context-menu rows are