diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0a8e9d7..e6bfdb7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,109 +1,86 @@ -# Copilot Instructions — Alchementary Link +# Copilot Instructions — Nexus Terminal + +Nexus Terminal is a native desktop client (SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub, local shell) written in Rust with an eframe/egui UI. All the code lives in the Cargo workspace under `rust/`. ## Build & Test ```bash -make deps # go mod download + npm install -make build # production build → build/bin/alchementary-link.app -make dev # wails dev with hot reload -make test # go test ./... -v -count=1 -make lint # golangci-lint run ./... +cd rust +cargo run --release # the app (binary `nexus-terminal`) +cargo test --workspace # unit and integration tests +cargo clippy --workspace --all-targets -- -D warnings # must be clean +cargo fmt --all --check # must be clean -# Single package test -go test ./internal/storage/ -v -count=1 -run TestProfileCRUD +# Single test +cargo test -p nexus-core --lib storage:: +cargo test -p nexus-core --test ssh_integration ``` -The frontend has no separate build step — `npm run build` (called by Wails) copies xterm vendor libs from `node_modules` into `frontend/src/js/lib/` and `frontend/src/css/lib/`. All frontend assets are embedded at compile time via `frontend/embed.go`. +CI (`.github/workflows/ci.yml`) runs `cargo test --locked`, `cargo fmt --check` and `cargo clippy --all-targets -D warnings` on Linux, macOS and Windows. The toolchain is pinned in `rust/rust-toolchain.toml`. Platform prerequisites and release packaging are in `docs/build-and-release.md`. ## Architecture -Wails v2 desktop app: Go backend + HTML/CSS/JS frontend rendered in a native webview (WKWebView on macOS). +The project is engine-first: a UI-free core crate, and the egui app is one client of it. -**Go backend** (`internal/`): -- `app/` — lifecycle bootstrap: opens SQLite, inits secret stores, wires everything together -- `api/` — single `Service` struct bound to Wails; every public method is callable from JS as `window.go.api.Service.MethodName()` -- `sessions/` — manages concurrent protocol sessions; owns SSH/Telnet/RAW/GitHub client lifecycles; handles auto-reconnect, login scripts, broadcast -- `storage/` — SQLite repository (WAL mode) with embedded sequential migrations in `storage/migrations/` -- `secrets/` — two-tier credential storage: OS keychain (preferred) → AES-256-GCM encrypted file vault (fallback) -- `models/` — shared domain types and defaults; protocol configs are JSON-encoded in the DB -- `sshclient/` — SSH client with agent, TOTP/2FA, and SOCKS5/HTTP proxy support -- `telnetclient/`, `rawclient/`, `githubclient/` — protocol implementations -- `sftpclient/` — SFTP file operations (browse, upload, download, rename, delete) -- `sshconfig/` — OpenSSH `~/.ssh/config` parser and importer -- `recording/` — asciicast v2 session recording and playback -- `exportimport/` — encrypted profile bundles (.link format) -- `updater/` — GitHub Releases update checker -- `knownhosts/` — SSH host key management -- `validation/` — profile and protocol validation -- `audit/` — timestamped audit logging +``` +rust/ +├── core/ nexus-core: storage, secrets, models, protocol clients, session Manager, Service. No egui. +│ ├── migrations/ numbered SQL migrations, embedded with include_str! +│ └── tests/ sessions.rs, ssh_integration.rs, fixtures/ +└── app/ nexus: the egui shell (binary `nexus-terminal`) +``` -**Frontend** (`frontend/`): -- Plain HTML/CSS/JS — no framework, no bundler, no ES modules -- `index.html` is the app shell; loads CSS then JS in strict order -- `src/js/` — 18 modules, each IIFE-wrapped, exporting to `window.AlchementaryLink` -- `src/css/` — modular stylesheets; `tokens.css` defines all design tokens -- Vendor libs (xterm.js, addons) live in `src/js/lib/` and `src/css/lib/` — auto-copied, gitignored +**Core (`rust/core/src/`):** +- `service.rs` — `Service`, the app-facing API: profiles, connect/reconnect, credentials, known hosts, preferences, snippets, import/export, SSH keys, SFTP, updates. File dialogs live in the UI; methods here take paths. +- `sessions.rs` — `Manager`: concurrent sessions, auto-reconnect with backoff, login scripts, broadcast writes, recording hooks, secret resolution (saved / session-only / ask every time), host-key checks. It reports through one `Event` enum (`Data`, `State`, `Closed`, `RawData`, `Irc`, `Github*`, `Forward*`, `Recording*`, `SftpProgress`, `SecretPrompt`, `HostKeyVerify`, `Vnc*`). +- `clients/` — one module per protocol (`ssh`, `sftp`, `telnet`, `raw`, `serial`, `local`, `irc`, `github`, `vnc`). Read the conventions at the top of `clients/mod.rs` before touching one: `connect()` blocks its (worker) thread and returns once usable; the client then owns its reader thread(s) and reports through `Callbacks` (data, fatal error, close exactly once); `write`/`resize`/`close` are callable from any thread and `close` is idempotent. russh-based clients use the shared tokio runtime from `clients::rt()`; the rest use plain threads. +- `storage.rs` (SQLite via rusqlite, WAL), `secrets.rs` (keychain + vault), `config.rs` (data directories and Alchementary Link migration), `models.rs`, `validation.rs`, `exportimport.rs`, `recording.rs` (asciicast v2), `sshconfig.rs`, `updater.rs`, `ircformat.rs`, `terminalenv.rs`, `keyfiles.rs`. -**Go ↔ JS communication:** -- **Synchronous:** Frontend calls `window.go.api.Service.*()` (returns Promise) -- **Asynchronous:** Backend pushes events via `wailsRuntime.EventsEmit()`; frontend listens with `window.runtime.EventsOn()` -- **Bidirectional events** for secret prompts and host key verification: backend emits a request event with a `requestID`, frontend shows a modal, then emits a response event with the same ID. The Go side blocks on a channel keyed by that ID. +**App (`rust/app/src/`):** +- `main.rs` parses flags, opens the `Service` and starts eframe. `app.rs` owns `App`: it installs an event sink that forwards `Event`s over a channel and calls `request_repaint`, drains them each frame (`on_event`), handles `Request`s, the keyboard shortcuts (`shortcuts()`), and lays out the frame. +- `state.rs` — `Shared` (service handle, cached profiles and preferences, tabs, panes, toasts) and the `Request` queue features use to ask the main window to act. +- `views.rs` — `View::{Terminal, Raw, Irc, Github, Vnc}`, one per pane. +- `terminal.rs` (`TermView`: alacritty_terminal drawn with egui's painter), `tabs.rs` (tab strip, workspace, find bar, overlays), `splits.rs` (split tree), `sidebar.rs`, `chrome.rs` (title bar, status bar, command palette `Cmd` enum, prompts, dialogs, toasts). +- Feature windows: `profile_editor.rs`, `quick_connect.rs`, `settings.rs` (including the Help topics), `ssh_keys.rs`, `transfer.rs` (import/export), `sftp.rs`, `forwards.rs`, `snippets.rs`, `triggers.rs`, `recordings.rs`. +- Protocol views: `raw_view.rs`, `irc_view.rs`, `github_view.rs`, `vnc_view.rs`. -Key events: `session:data`, `session:state`, `session:closed`, `session:raw:data`, `session:github:repos`, `session:secret:prompt`/`session:secret:response`, `session:hostkey:verify`/`session:hostkey:response`. - -## WKWebView Constraints (Critical) - -The macOS webview silently blocks or breaks several standard web APIs. These constraints affect every frontend change: - -1. **No `window.confirm()`, `window.prompt()`, `window.alert()`** — they either return false/null silently or do nothing. All user prompts must use the custom modal system (DOM-based backdrop + modal with `is-visible` class toggling). - -2. **No ES modules** — ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/package-lock.json b/frontend/package-lock.json deleted file mode 100644 index e40b582..0000000 --- a/frontend/package-lock.json +++ /dev/null @@ -1,547 +0,0 @@ -{ - "name": "alchementary-link-frontend", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "alchementary-link-frontend", - "version": "0.1.0", - "hasInstallScript": true, - "dependencies": { - "@novnc/novnc": "^1.5.0", - "@xterm/addon-fit": "0.11.0", - "@xterm/addon-search": "0.16.0", - "@xterm/addon-unicode11": "0.9.0", - "@xterm/addon-web-links": "0.12.0", - "@xterm/xterm": "6.0.0" - }, - "devDependencies": { - "esbuild": "^0.28.1" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@novnc/novnc": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz", - "integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==", - "license": "MPL-2.0" - }, - "node_modules/@xterm/addon-fit": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", - "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", - "license": "MIT" - }, - "node_modules/@xterm/addon-search": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", - "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", - "license": "MIT" - }, - "node_modules/@xterm/addon-unicode11": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz", - "integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==", - "license": "MIT" - }, - "node_modules/@xterm/addon-web-links": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", - "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", - "license": "MIT" - }, - "node_modules/@xterm/xterm": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", - "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", - "license": "MIT", - "workspaces": [ - "addons/*" - ] - }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - } - } -} diff --git a/frontend/package.json b/frontend/package.json deleted file mode 100644 index b1a76c3..0000000 --- a/frontend/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "alchementary-link-frontend", - "version": "0.1.0", - "private": true, - "description": "Frontend for Alchementary Link desktop SSH client", - "scripts": { - "install:assets": "node scripts/copy-assets.js", - "bundle:novnc": "node scripts/bundle-novnc.js", - "build": "node scripts/build.js", - "dev": "node scripts/build.js --watch", - "test": "node scripts/test-session-lifecycle.js && node scripts/test-irc.js && node scripts/test-regressions.js", - "postinstall": "npm run install:assets && npm run bundle:novnc" - }, - "dependencies": { - "@novnc/novnc": "^1.5.0", - "@xterm/addon-fit": "0.11.0", - "@xterm/addon-search": "0.16.0", - "@xterm/addon-unicode11": "0.9.0", - "@xterm/addon-web-links": "0.12.0", - "@xterm/xterm": "6.0.0" - }, - "devDependencies": { - "esbuild": "^0.28.1" - } -} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 deleted file mode 100755 index e26e0e5..0000000 --- a/frontend/package.json.md5 +++ /dev/null @@ -1 +0,0 @@ -24ecf1ed6af3b41cf2f6d8fa0bc732ec \ No newline at end of file diff --git a/frontend/scripts/build.js b/frontend/scripts/build.js deleted file mode 100644 index 8372299..0000000 --- a/frontend/scripts/build.js +++ /dev/null @@ -1,233 +0,0 @@ -/** - * build.js — Build script for the Alchementary Link frontend. - * - * In production mode (no flags): copies vendor assets, assembles the - * dist/ directory with all files needed by the Wails embed, and validates - * that every required file is present. - * - * In watch mode (--watch): performs the same build, then watches for - * source changes and prints a notification when files change. Wails dev - * mode handles the actual live-reload; this script simply keeps the - * process alive so the frontend:dev:watcher contract is satisfied. - */ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); - -const frontendDir = path.resolve(__dirname, '..'); -const distDir = path.join(frontendDir, 'dist'); -const watchMode = process.argv.includes('--watch'); - -// Files that must exist after a successful build (relative to frontend/) -const requiredFiles = [ - 'dist/index.html', - 'dist/src/css/lib/xterm.css', - 'dist/src/js/lib/xterm.min.js', - 'dist/src/js/lib/xterm-addon-fit.min.js', - 'dist/src/js/lib/xterm-addon-search.min.js', - 'dist/src/js/lib/xterm-addon-web-links.min.js', - 'dist/src/js/wails-service.js', -]; - -/** - * Recursively copies a directory tree, creating parents as needed. - */ -function copyDirSync(src, dest) { - fs.mkdirSync(dest, { recursive: true }); - for (const entry of fs.readdirSync(src, { withFileTypes: true })) { - const srcPath = path.join(src, entry.name); - const destPath = path.join(dest, entry.name); - if (entry.isDirectory()) { - copyDirSync(srcPath, destPath); - } else { - fs.copyFileSync(srcPath, destPath); - } - } -} - -/** - * Copies one file and creates the parent directory if needed. - */ -function copyFileSync(src, dest) { - fs.mkdirSync(path.dirname(dest), { recursive: true }); - fs.copyFileSync(src, dest); -} - -/** - * Removes a directory tree if it exists. - */ -function cleanDir(dir) { - if (fs.existsSync(dir)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -} - -function copyWailsRuntimeBindings(srcRoot, destRoot) { - const runtimeFiles = [ - 'go/api/Service.js', - 'runtime/runtime.js', - ]; - - for (const relPath of runtimeFiles) { - const src = path.join(srcRoot, relPath); - if (fs.existsSync(src)) { - copyFileSync(src, path.join(destRoot, relPath)); - } - } -} - -function walkFiles(dir, predicate, files = []) { - if (!fs.existsSync(dir)) return files; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - walkFiles(full, predicate, files); - } else if (entry.isFile() && predicate(full)) { - files.push(full); - } - } - return files; -} - -function cleanupDistAssets() { - console.log('[build] Cleaning production frontend assets...'); - const sourceMapFiles = walkFiles(distDir, (file) => file.endsWith('.js') || file.endsWith('.css')); - let mapCommentCount = 0; - for (const file of sourceMapFiles) { - const input = fs.readFileSync(file, 'utf8'); - const output = input - .replace(/(?:\r?\n)?\/\/# sourceMappingURL=.*?(?=\r?\n|$)/g, '') - .replace(/(?:\r?\n)?\/\*# sourceMappingURL=.*?\*\/(?=\r?\n|$)/g, ''); - if (output !== input) { - fs.writeFileSync(file, output); - mapCommentCount += 1; - } - } - - if (mapCommentCount > 0) { - console.log(`[build] Removed ${mapCommentCount} source-map reference(s).`); - } -} - -/** - * Runs the asset copy script to pull xterm files into src/js/lib/ and - * src/css/lib/. - */ -function copyAssets() { - console.log('[build] Copying vendor library assets…'); - execSync('node scripts/copy-assets.js', { - cwd: frontendDir, - stdio: 'inherit', - }); - execSync('node scripts/bundle-novnc.js', { - cwd: frontendDir, - stdio: 'inherit', - }); -} - -/** - * Assembles the dist/ directory with everything the Wails embed needs: - * dist/index.html - * dist/src/css/ (application + lib stylesheets) - * dist/src/js/ (application + lib scripts) - * dist/wailsjs/ (generated backend bindings, if present) - * dist/src/components/ (if present) - * dist/src/assets/ (if present) - */ -function buildDist() { - console.log('[build] Assembling dist/ directory…'); - - // Start fresh so stale files are removed - cleanDir(distDir); - fs.mkdirSync(distDir, { recursive: true }); - - // Copy the root index.html - fs.copyFileSync( - path.join(frontendDir, 'index.html'), - path.join(distDir, 'index.html'), - ); - - // Copy the entire src/ tree (CSS, JS, components, assets, lib files) - const srcDir = path.join(frontendDir, 'src'); - if (fs.existsSync(srcDir)) { - copyDirSync(srcDir, path.join(distDir, 'src')); - } - - // Copy only runtime Wails bindings so embedded production assets do not - // carry TypeScript declarations and model files. - const wailsjsDir = path.join(frontendDir, 'wailsjs'); - if (fs.existsSync(wailsjsDir)) { - copyWailsRuntimeBindings(wailsjsDir, path.join(distDir, 'wailsjs')); - } - - console.log('[build] dist/ assembled.'); -} - -/** - * Validates that every required file exists. - * Returns true if all files are present. - */ -function validate() { - console.log('[build] Validating required files…'); - let valid = true; - for (const relPath of requiredFiles) { - const full = path.join(frontendDir, relPath); - if (!fs.existsSync(full)) { - console.error(` ✗ Missing: ${relPath}`); - valid = false; - } else { - console.log(` ✓ ${relPath}`); - } - } - return valid; -} - -/** - * Watches the src/ directory for changes (development mode). - * Wails dev mode picks up the changes; we just keep the process alive. - */ -function watch() { - const srcDir = path.join(frontendDir, 'src'); - console.log(`[build] Watching ${srcDir} for changes…`); - - // Use fs.watch recursively where supported (macOS, Windows). - // On Linux this falls back to non-recursive — acceptable for dev use. - try { - fs.watch(srcDir, { recursive: true }, (_event, filename) => { - if (filename) { - const stamp = new Date().toLocaleTimeString('en-GB'); - console.log(`[build] ${stamp} — changed: ${filename}`); - } - }); - } catch (err) { - console.warn('[build] Recursive watch not supported; polling fallback.'); - } - - // Prevent the process from exiting - setInterval(() => {}, 60_000); -} - -// --- Main --- - -try { - copyAssets(); -} catch (err) { - console.error('[build] Asset copy failed:', err.message); - process.exit(1); -} - -buildDist(); -cleanupDistAssets(); - -if (!validate()) { - console.error('[build] Validation failed — some required files are missing.'); - process.exit(1); -} - -console.log('[build] Frontend build complete.'); - -if (watchMode) { - watch(); -} diff --git a/frontend/scripts/bundle-novnc.js b/frontend/scripts/bundle-novnc.js deleted file mode 100644 index 6b21f10..0000000 --- a/frontend/scripts/bundle-novnc.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * bundle-novnc.js — Uses esbuild to bundle noVNC's RFB class into a single - * script that exposes window.noVNC.RFB for use in the frontend. - * - * noVNC 1.5+ uses top-level await which is incompatible with IIFE format, - * so we bundle as ESM then wrap in an async IIFE via banner/footer. - */ -"use strict"; - -const esbuild = require("esbuild"); -const path = require("path"); - -const entryPoint = path.resolve(__dirname, "novnc-entry.js"); -const outFile = path.resolve( - __dirname, - "..", - "src", - "js", - "lib", - "novnc.min.js", -); - -esbuild - .build({ - entryPoints: [entryPoint], - bundle: true, - minify: true, - format: "esm", - outfile: outFile, - target: ["safari15"], - logLevel: "info", - banner: { js: ";(async function() {" }, - footer: { js: "})();" }, - }) - .then(() => { - console.log( - "[bundle-novnc] noVNC bundled successfully → src/js/lib/novnc.min.js", - ); - }) - .catch((err) => { - console.error("[bundle-novnc] Failed:", err.message); - process.exit(1); - }); diff --git a/frontend/scripts/copy-assets.js b/frontend/scripts/copy-assets.js deleted file mode 100644 index 5a6ac97..0000000 --- a/frontend/scripts/copy-assets.js +++ /dev/null @@ -1,87 +0,0 @@ -/** - * copy-assets.js — Copies xterm library assets from node_modules into - * the frontend source tree so they can be served directly by Wails. - * - * Run automatically via the postinstall hook in package.json. - */ -"use strict"; - -const fs = require("fs"); -const path = require("path"); - -const frontendDir = path.resolve(__dirname, ".."); - -// Mapping: source (relative to node_modules) → destination (relative to frontend/) -const assets = [ - { - src: "@xterm/xterm/css/xterm.css", - dest: "src/css/lib/xterm.css", - }, - { - src: "@xterm/xterm/lib/xterm.js", - dest: "src/js/lib/xterm.min.js", - }, - { - src: "@xterm/addon-fit/lib/addon-fit.js", - dest: "src/js/lib/xterm-addon-fit.min.js", - }, - { - src: "@xterm/addon-search/lib/addon-search.js", - dest: "src/js/lib/xterm-addon-search.min.js", - }, - { - src: "@xterm/addon-unicode11/lib/addon-unicode11.js", - dest: "src/js/lib/xterm-addon-unicode11.min.js", - }, - { - src: "@xterm/addon-web-links/lib/addon-web-links.js", - dest: "src/js/lib/xterm-addon-web-links.min.js", - }, -]; - -/** - * Ensures the parent directory of the given file path exists. - */ -function ensureDir(filePath) { - const dir = path.dirname(filePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } -} - -/** - * Copies a single asset, logging the result. - */ -function copyAsset(entry) { - const srcPath = path.join(frontendDir, "node_modules", entry.src); - const destPath = path.join(frontendDir, entry.dest); - - if (!fs.existsSync(srcPath)) { - console.error(` ✗ Source not found: ${entry.src}`); - return false; - } - - ensureDir(destPath); - fs.copyFileSync(srcPath, destPath); - console.log(` ✓ ${entry.src} → ${entry.dest}`); - return true; -} - -// --- Main --- - -console.log("[copy-assets] Copying xterm library files…"); - -let failures = 0; -for (const entry of assets) { - if (!copyAsset(entry)) { - failures++; - } -} - -if (failures > 0) { - console.error(`\n[copy-assets] ${failures} file(s) could not be copied.`); - console.error('[copy-assets] Have you run "npm install" first?'); - process.exit(1); -} - -console.log("[copy-assets] All assets copied successfully."); diff --git a/frontend/scripts/novnc-entry.js b/frontend/scripts/novnc-entry.js deleted file mode 100644 index 1958a13..0000000 --- a/frontend/scripts/novnc-entry.js +++ /dev/null @@ -1,7 +0,0 @@ -// Entry point for esbuild bundling of noVNC. -// Assigns RFB to window.noVNC so it's available as a global in the frontend. -import RFB from "@novnc/novnc"; -import { initLogging } from "../node_modules/@novnc/novnc/core/util/logging.js"; - -initLogging("error"); -window.noVNC = { RFB: RFB }; diff --git a/frontend/scripts/test-irc.js b/frontend/scripts/test-irc.js deleted file mode 100644 index e0abace..0000000 --- a/frontend/scripts/test-irc.js +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env node -const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); -const vm = require("vm"); - -const frontendRoot = path.resolve(__dirname, ".."); -const source = fs.readFileSync(path.join(frontendRoot, "src/js/irc.js"), "utf8"); -const context = { - window: { - AlchementaryLink: { - createElement: () => { - throw new Error("formatting tests must not create DOM elements"); - }, - }, - }, -}; -vm.createContext(context); -vm.runInContext(source, context, { filename: "src/js/irc.js" }); - -const formatting = context.window.AlchementaryLink.IRCFormatting; -assert.ok(formatting, "IRC formatting helpers should be exported"); - -assert.strictEqual( - formatting.formatText("plain \x02bold\x02 \x1ditalic\x1d \x1funderlined\x1f"), - "plain \x1b[1mbold\x1b[22m \x1b[3mitalic\x1b[23m \x1b[4munderlined\x1b[24m", -); -assert.strictEqual( - formatting.formatText("\x0304red\x03 default"), - "\x1b[38;5;9mred\x1b[39;49m default", -); -assert.strictEqual( - formatting.formatText("\x0352extended \x0303,01matrix"), - "\x1b[38;5;196mextended \x1b[38;5;2m\x1b[48;5;0mmatrix", -); -assert.strictEqual( - formatting.formatText("\x04ff8800orange\x04 default"), - "\x1b[38;2;255;136;0morange\x1b[39;49m default", -); -assert.strictEqual( - formatting.formatText("\x0304,comma"), - "\x1b[38;5;9m,comma", - "a colour code without a background must leave its comma visible", -); -assert.strictEqual( - formatting.plainText("\x02bold\x02 \x0304red\x03"), - "bold red", -); -assert.strictEqual( - formatting.formatText("before\x1b[31munsafe\x1b]0;title\x07after"), - "beforeunsafeafter", - "untrusted ANSI and OSC sequences must not reach xterm", -); - -const message = formatting.formatEvent( - { - type: "message", - from: "alice", - text: "hello \x02world\x02", - }, - "12:34:56", -); -assert.ok(message.includes("[12:34:56]")); -assert.ok(message.includes("\x1b[1mworld\x1b[22m")); -assert.ok(message.includes("\x1b[1m\x1b[38;5;"), "nicknames should be bold and coloured"); -assert.ok(message.endsWith("\x1b[0m"), "every line must reset terminal styling"); - -const action = formatting.formatEvent( - { type: "action", from: "bob", text: "waves" }, - "01:02:03", -); -assert.ok(action.includes("\x1b[3m"), "actions should be italicised"); - -console.log("IRC formatting regression tests passed."); diff --git a/frontend/scripts/test-regressions.js b/frontend/scripts/test-regressions.js deleted file mode 100644 index ecedd1c..0000000 --- a/frontend/scripts/test-regressions.js +++ /dev/null @@ -1,575 +0,0 @@ -#!/usr/bin/env node -const fs = require("fs"); -const path = require("path"); - -const root = path.resolve(__dirname, ".."); -const repo = path.resolve(root, ".."); - -function read(rel) { - return fs.readFileSync(path.join(repo, rel), "utf8"); -} - -function assertContains(file, needle, message) { - const text = read(file); - if (!text.includes(needle)) { - throw new Error(`${message}\nMissing ${JSON.stringify(needle)} in ${file}`); - } -} - -function assertNotContains(file, needle, message) { - const text = read(file); - if (text.includes(needle)) { - throw new Error(`${message}\nUnexpected ${JSON.stringify(needle)} in ${file}`); - } -} - -function assertBefore(file, first, second, message) { - const text = read(file); - const firstIndex = text.indexOf(first); - const secondIndex = text.indexOf(second); - if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) { - throw new Error( - `${message}\nExpected ${JSON.stringify(first)} before ${JSON.stringify(second)} in ${file}`, - ); - } -} - -function assertTreeNotContains(relDir, needle, message) { - const dir = path.join(repo, relDir); - const offenders = []; - const walk = (current) => { - for (const entry of fs.readdirSync(current, { withFileTypes: true })) { - const full = path.join(current, entry.name); - if (entry.isDirectory()) { - walk(full); - continue; - } - if (!entry.isFile() || !entry.name.endsWith(".js")) continue; - const rel = path.relative(repo, full); - if (read(rel).includes(needle)) offenders.push(rel); - } - }; - walk(dir); - if (offenders.length) { - throw new Error(`${message}\nUnexpected ${JSON.stringify(needle)} in ${offenders.join(", ")}`); - } -} - -assertContains( - "frontend/src/js/terminal.js", - "escapeHtml(title)", - "file-drop prompt titles must escape filenames containing HTML characters", -); -assertContains( - "frontend/src/js/terminal.js", - "SFTPUploadStart", - "drag-drop upload must use chunked SFTP streaming", -); -assertContains( - "frontend/src/js/terminal.js", - "SFTPUploadChunk", - "drag-drop upload must send bounded chunks", -); -assertContains( - "frontend/src/js/terminal.js", - "scheduleFit", - "terminal resize handling must be throttled through scheduled fits", -); -assertContains( - "frontend/src/js/terminal.js", - "dimensionsChanged", - "terminal resize handling must skip unchanged container dimensions", -); -assertContains( - "frontend/src/js/terminal.js", - "cancelScheduledFit", - "terminal teardown must cancel pending fit work", -); -assertContains( - "frontend/src/js/terminal.js", - "this._sendTerminalSize(sessionId, term);", - "every completed terminal fit must publish its geometry to the backend PTY", -); -assertContains( - "frontend/src/js/terminal.js", - "[Exited - press enter to restart]", - "exited local shells must show the inline restart prompt", -); -assertContains( - "frontend/src/js/terminal.js", - "RestartLocalSession", - "pressing enter after a local shell exit must restart the existing session", -); -assertContains( - "frontend/src/js/app.js", - "!localExited &&", - "local shell exits must not show the disconnected overlay", -); -assertNotContains( - "frontend/src/js/terminal.js", - "new ResizeObserver(() => {\n try {\n fitAddon.fit();", - "ResizeObserver must not call fitAddon.fit() directly", -); -assertNotContains( - "frontend/src/js/terminal.js", - "Use SFTP Upload for files over 25 MB", - "drag-drop upload must not reject files only because they exceed the old inline limit", -); -assertContains( - "frontend/src/js/app.js", - "confirmDirectVNCProfile", - "direct VNC connections must show an explicit warning", -); -assertContains( - "frontend/src/js/profiles.js", - "skipDirectVNCWarning", - "save-and-connect should not show duplicate direct VNC prompts", -); -assertContains( - "frontend/src/js/recordings.js", - "import-status", - "profile import failures must be visible inside the import modal", -); -assertContains( - "frontend/src/js/recordings.js", - "_formatImportError", - "profile import failures must be formatted without using HTML injection", -); -assertContains( - "internal/api/api_test.go", - "TestCreateProfileRejectsUnsafeGitHubBaseURL", - "GitHub HTTP/HTTPS URL validation must stay covered", -); -assertContains( - "frontend/index.html", - "mode-leviathan", - "Leviathan mode toggle must be present beside Alchementary Link", -); -assertContains( - "frontend/index.html", - 'id="mode-leviathan"\n type="button"\n hidden', - "Leviathan mode toggle must be hidden before preferences load", -); -assertContains( - "frontend/src/js/app.js", - "prefs.leviathanEnabled === true", - "Leviathan mode must only be exposed by an explicit persisted preference", -); -assertContains( - "frontend/src/js/settings.js", - "KONAMI_SEQUENCE", - "the About window must recognise the Konami sequence", -); -assertContains( - "frontend/src/js/settings.js", - "_toggleLeviathanFromKonami", - "the About-window Konami sequence must toggle Leviathan", -); -assertContains( - "frontend/src/js/settings.js", - "SavePreferences(prefs)", - "the Konami toggle must persist the Leviathan preference immediately", -); -assertContains( - "frontend/src/css/sidebar.css", - "user-select: none", - "sidebar title and mode switcher text must not be selectable", -); -assertContains( - "frontend/src/css/sidebar.css", - "#mode-leviathan", - "Leviathan mode label must have its own right-alignment rule", -); -assertContains( - "frontend/src/css/sidebar.css", - "margin-left: auto;", - "Leviathan mode label must sit on the right side of the sidebar header", -); -assertContains( - "frontend/src/css/tokens.css", - "--tab-bar-height: 44px;", - "tab bar row must match the sidebar header height", -); -assertContains( - "frontend/src/css/sidebar.css", - "height: var(--tab-bar-height);", - "sidebar header must use the shared tab bar height", -); -assertContains( - "frontend/src/css/layout.css", - "height: var(--tab-bar-height);", - "tab bar must use the shared sidebar header height", -); -assertContains( - "frontend/src/js/tabs.js", - "this._setupScrolling();", - "tab bar must enable horizontal scroll handling for overflowed tabs", -); -assertContains( - "frontend/src/js/tabs.js", - "this._setupTooltip();", - "tab bar must show rich hover hints for truncated tabs", -); -assertContains( - "frontend/src/js/tabs.js", - "removeTab(sessionId) {\n this._hideTabTooltip();", - "removing a tab must clear any visible tab tooltip", -); -assertContains( - "frontend/src/js/tabs.js", - "async closeTab(sessionId) {\n this._hideTabTooltip();", - "closing a tab must clear any visible or pending tab tooltip", -); -assertContains( - "frontend/src/js/tabs.js", - "await this.app.closeSessionImmediate(sessionId);", - "all normal tab closes must use the complete session teardown path", -); -assertContains( - "frontend/src/js/app.js", - "this.splitPanes.cleanup(sessionId)", - "closing an owner tab must release all split-pane state and child sessions", -); -assertContains( - "frontend/src/js/app.js", - "window.AlchementaryLink.VNCViewer?.destroyViewer(sessionId)", - "session teardown must disconnect and remove VNC viewers", -); -assertContains( - "frontend/src/js/app.js", - "window.AlchementaryLink.IRCViewer?.destroyViewer(sessionId)", - "session teardown must dispose IRC terminals and resize observers", -); -assertContains( - "frontend/src/js/irc.js", - "wailsService.IRCRooms(this.sessionID)", - "IRC viewers created after connection must hydrate the already joined rooms", -); -assertContains( - "internal/sessions/manager.go", - "GetIRCLastRooms(profile.ID)", - "saved IRC profiles must restore the channels open in their last session", -); -assertContains( - "frontend/src/js/app.js", - "this.recordings.cleanup(sessionId)", - "session teardown must clear frontend recording state", -); -assertContains( - "frontend/src/js/splitpane.js", - "this.app.closeSessionImmediate(leaf.sessionId", - "closing a split child must release its backend session as well as its terminal", -); -assertContains( - "frontend/src/js/splitpane.js", - "await window.AlchementaryLink.wailsService.DisconnectSession(sessionInfo.id)", - "late split-pane connection results must be disconnected after their pane closes", -); -assertContains( - "internal/sessions/manager.go", - "delete(m.sessions, sessionID)", - "explicit backend disconnect must remove the session from the manager map", -); -assertContains( - "frontend/src/js/tabs.js", - "!tab?.isConnected", - "tab tooltip timers must not show hints for removed tab elements", -); -assertContains( - "frontend/src/js/tabs.js", - "_clearTabTooltipTimer", - "tab tooltip cleanup must cancel pending hover timers", -); -assertContains( - "frontend/src/js/tabs.js", - "session.protocol !== \"leviathan\"", - "Leviathan tabs must close without connected-session confirmation", -); -assertContains( - "frontend/src/js/tabs.js", - "session.protocol !== \"github\"", - "GitHub tabs must close without connected-session confirmation", -); -assertContains( - "frontend/src/js/tabs.js", - "Connected since:", - "connection tab hover hints must include the connected-since timestamp", -); -assertContains( - "frontend/src/js/tabs.js", - "tab.appendChild(closeBtn);\n\n // Protocol badge", - "tab close button must render on the left side of each tab", -); -assertContains( - "frontend/src/css/layout.css", - "scrollbar-gutter: stable;", - "overflowing tab lists must expose a stable horizontal scrollbar", -); -assertContains( - "frontend/src/css/tabs.css", - ".tab-tooltip", - "tab hover hints must have dedicated tooltip styling", -); -assertContains( - "frontend/src/js/leviathan.js", - "Hex Editor", - "Leviathan mode must put Hex Editor in the tool list", -); -assertContains( - "frontend/src/js/settings.js", - "Leviathan Binary Tools", - "in-app help must document the integrated Leviathan workbench", -); -assertContains( - "frontend/src/js/settings.js", - "VNC Connections", - "in-app help must document VNC connection profiles", -); -assertContains( - "docs/user-manual.md", - "## 11. Leviathan binary workbench", - "user manual must include a full Leviathan section", -); -assertContains( - "docs/user-manual.md", - "### VNC", - "user manual must include VNC connection guidance", -); -assertContains( - "docs/overview.md", - "### Leviathan binary workbench", - "product overview must include Leviathan as a first-class feature group", -); -assertContains( - "frontend/src/js/leviathan.js", - "LeviathanSetByte", - "Leviathan hex view must support byte editing", -); -assertContains( - "frontend/src/js/leviathan.js", - "_handleHexByteInput", - "Leviathan hex byte edits must update while typing", -); -assertContains( - "frontend/src/js/leviathan.js", - "_updateHexAsciiCell", - "Leviathan hex byte edits must update the ASCII preview immediately", -); -assertContains( - "frontend/src/js/leviathan.js", - "_focusHexByte(cell, 1)", - "Leviathan hex byte edits must advance to the next byte after entry", -); -assertContains( - "frontend/src/js/leviathan.js", - "spectrogram-encode", - "Leviathan must expose an image-to-audio spectrogram encoder", -); -assertContains( - "frontend/src/js/leviathan.js", - "spectrogram-view", - "Leviathan must expose an audio spectrogram visualiser", -); -assertContains( - "frontend/src/js/leviathan.js", - "preserveAspectRatio", - "Leviathan spectrogram encoding must expose aspect-ratio preservation", -); -assertContains( - "frontend/src/js/leviathan.js", - "aspectRatio", - "Leviathan spectrogram visualiser must expose display aspect-ratio control", -); -assertContains( - "frontend/src/js/leviathan.js", - "spectrogram-insert", - "Leviathan spectrogram encoder must support inserting images into carrier audio", -); -assertContains( - "frontend/src/js/app.js", - "setMode(\"leviathan\")", - "App mode switch must route to Leviathan", -); -assertBefore( - "frontend/src/js/splitpane.js", - "localLabel.textContent = 'Local Machine';", - "profiles.forEach(function(profile)", - "split-pane connection choices must put Local Machine before saved profiles", -); -assertContains( - "frontend/src/js/splitpane.js", - "wailsService.ConnectLocal()", - "the Local Machine split-pane choice must open a real local shell", -); -assertContains( - "internal/api/leviathan.go", - "LeviathanRunTool", - "Link API must expose the imported Leviathan engine", -); -assertNotContains( - "internal/api/leviathan.go", - "Pattern: \"*\"", - "Leviathan open dialogs must not use wildcard filters that grey out files on macOS", -); -assertContains( - "internal/leviathan/spectrogram/spectrogram.go", - "github.com/hajimehoshi/go-mp3", - "Leviathan spectrogram visualiser must support MP3 decoding", -); -assertNotContains( - "wails.json", - "\"obfuscated\": true", - "Wails obfuscation must stay disabled because this app embeds frontend/src and uses normal Wails globals", -); -assertNotContains( - "wails.json", - "\"garbleargs\"", - "garble arguments must not be configured when Wails obfuscation is disabled", -); -assertContains( - "frontend/index.html", - "wails-service.js", - "frontend must load the Wails service bridge before app code", -); -assertNotContains( - "frontend/index.html", - 'type="module" src="/src/js/wails-service.js"', - "Wails bridge must load as a synchronous classic script before app code", -); -assertContains( - "frontend/src/js/wails-service.js", - 'prop === "then"', - "Wails service proxy must not masquerade as a Promise thenable", -); -assertContains( - "frontend/src/js/wails-service.js", - "window.go?.api?.Service", - "Wails service bridge must use the normal named Wails API", -); -assertContains( - "frontend/src/js/sshkeys.js", - "textContent: \"Failed to load keys: \" + String(err)", - "SSH key manager errors must be rendered as text rather than injected HTML", -); -assertContains( - "frontend/src/js/sshkeys.js", - "textContent: message", - "SSH key delete confirmation messages must be rendered as text", -); -assertNotContains( - "frontend/src/js/sshkeys.js", - "Failed to load keys: ${err}", - "SSH key manager must not interpolate backend errors into innerHTML", -); -assertContains( - "frontend/package.json", - '"@xterm/xterm":', - "frontend dependencies must use the maintained scoped xterm package", -); -assertContains( - "frontend/package.json", - '"@xterm/addon-fit":', - "frontend dependencies must use the maintained scoped xterm addons", -); -assertContains( - "frontend/scripts/copy-assets.js", - "@xterm/xterm/lib/xterm.js", - "xterm browser assets must be copied from the maintained scoped package", -); -assertContains( - "frontend/scripts/copy-assets.js", - "@xterm/addon-fit/lib/addon-fit.js", - "xterm addon browser assets must be copied from the maintained scoped packages", -); -assertNotContains( - "frontend/package.json", - '"xterm":', - "deprecated unscoped xterm dependency must not be restored", -); -assertNotContains( - "frontend/package.json", - '"xterm-addon-', - "deprecated unscoped xterm addon dependencies must not be restored", -); -assertNotContains( - "frontend/package-lock.json", - '"node_modules/xterm"', - "package lock must not contain the deprecated unscoped xterm package", -); -assertNotContains( - "frontend/package-lock.json", - '"node_modules/xterm-addon-', - "package lock must not contain deprecated unscoped xterm addon packages", -); -assertContains( - "frontend/scripts/build.js", - "sourceMappingURL", - "frontend production build must remove source-map references", -); -assertContains( - "frontend/scripts/build.js", - "bundle-novnc.js", - "frontend production build must regenerate the noVNC bundle", -); -assertContains( - "frontend/scripts/novnc-entry.js", - "initLogging(\"error\")", - "bundled noVNC logging must default to errors only", -); -assertNotContains( - "frontend/src/js/app.js", - "console.debug(\"[session:debug]\"", - "release UI must not print telnet negotiation debug events", -); -assertNotContains( - "frontend/src/js/app.js", - "console.log(\"Backend is ready\")", - "release UI must not print backend-ready lifecycle events", -); -assertContains( - "build.sh", - "LINK_COMPRESS", - "local builds must expose an explicit compression switch", -); -assertContains( - "scripts/build-common.sh", - "link_use_upx_for_target", - "build scripts must share one UPX target-selection helper", -); -assertContains( - "build.sh", - "Skipping UPX compression on macOS", - "local compressed builds must avoid unsupported macOS UPX by default", -); -assertContains( - "build.sh", - "-trimpath", - "local production builds should trim file paths from binaries", -); -assertContains( - "scripts/build-common.sh", - "-buildid=", - "production builds must strip the Go build ID", -); -assertContains( - "scripts/build-common.sh", - "CGO_ENABLED=0", - "production builds must keep the pure-Go SQLite toolchain", -); -assertContains( - "Makefile", - "build-compressed", - "Makefile must expose an explicit compressed build target", -); -assertContains( - "Makefile", - "LINK_FORCE_MACOS_UPX", - "Makefile compressed builds must make macOS UPX opt-in", -); -assertTreeNotContains( - "frontend/src/js", - "window.go.api.Service", - "app code must not call named Wails backend methods directly", -); - -console.log("frontend regression checks passed"); diff --git a/frontend/scripts/test-session-lifecycle.js b/frontend/scripts/test-session-lifecycle.js deleted file mode 100644 index adc3c9f..0000000 --- a/frontend/scripts/test-session-lifecycle.js +++ /dev/null @@ -1,305 +0,0 @@ -#!/usr/bin/env node -const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); -const vm = require("vm"); - -const frontendRoot = path.resolve(__dirname, ".."); - -function loadScript(relativePath, additions = {}) { - const context = { - console, - Promise, - Map, - Set, - setTimeout, - clearTimeout, - window: { AlchementaryLink: {} }, - document: { - querySelector: () => null, - addEventListener: () => {}, - }, - ...additions, - }; - vm.createContext(context); - vm.runInContext( - fs.readFileSync(path.join(frontendRoot, relativePath), "utf8"), - context, - { filename: relativePath }, - ); - return context; -} - -async function testRepeatedSplitCleanup() { - const context = loadScript("src/js/splitpane.js"); - const SplitPaneManager = context.window.AlchementaryLink.SplitPaneManager; - const closed = []; - const app = { - closeSessionImmediate: async (sessionId, options) => { - closed.push({ sessionId, options }); - }, - }; - const manager = new SplitPaneManager(app); - let cancelledDrags = 0; - - for (let i = 0; i < 500; i++) { - const ownerId = `owner-${i}`; - const childId = `child-${i}`; - const ownerEl = { _splitNode: {} }; - const childEl = { _splitNode: {} }; - const ownerLeaf = { - type: "leaf", - paneId: `owner-pane-${i}`, - el: ownerEl, - sessionId: ownerId, - parent: null, - }; - const childLeaf = { - type: "leaf", - paneId: `child-pane-${i}`, - el: childEl, - sessionId: childId, - parent: null, - }; - const branchEl = { _splitNode: {} }; - const branch = { - type: "branch", - first: ownerLeaf, - second: childLeaf, - el: branchEl, - parent: null, - cancelDrag: () => { - cancelledDrags++; - }, - }; - ownerLeaf.parent = branch; - childLeaf.parent = branch; - manager.trees.set(ownerId, branch); - manager.pendingConnections.set(`pending-${i}`, { - ownerSessionId: ownerId, - cancelled: false, - completed: false, - }); - - await manager.cleanup(ownerId); - - assert.strictEqual(ownerEl._splitNode, null); - assert.strictEqual(childEl._splitNode, null); - assert.strictEqual(branchEl._splitNode, null); - } - - assert.strictEqual(manager.trees.size, 0); - assert.strictEqual(manager.pendingConnections.size, 0); - assert.strictEqual(closed.length, 500); - assert.strictEqual(cancelledDrags, 500); - for (const call of closed) { - assert.strictEqual(call.options.removeTab, false); - assert.strictEqual(call.options.cleanupSplits, false); - } -} - -function testClosedPaneRejectsLateConnection() { - const context = loadScript("src/js/splitpane.js"); - const SplitPaneManager = context.window.AlchementaryLink.SplitPaneManager; - const manager = new SplitPaneManager({ closeSessionImmediate: async () => {} }); - const pane = { - dataset: { paneId: "pane-1" }, - isConnected: true, - }; - const leaf = { - type: "leaf", - paneId: "pane-1", - el: pane, - sessionId: null, - parent: null, - }; - pane._splitNode = leaf; - manager.trees.set("owner-1", leaf); - - const token = manager._beginPendingConnection(pane, "owner-1"); - manager._cancelPendingConnection("pane-1"); - - assert.strictEqual( - manager._completePendingConnection(pane, "owner-1", token), - false, - ); - assert.strictEqual(manager.pendingConnections.size, 0); -} - -function testPerSessionStoresReleaseIDs() { - const stateContext = loadScript("src/js/state.js"); - const State = stateContext.window.AlchementaryLink.State; - const state = new State(); - state.addSession({ id: "session-1" }); - state.broadcastTargets = new Set(["session-1"]); - state.removeSession("session-1"); - assert.strictEqual(state.sessions.size, 0); - assert.strictEqual(state.broadcastTargets.size, 0); - - const recordingContext = loadScript("src/js/recordings.js"); - const RecordingManager = - recordingContext.window.AlchementaryLink.RecordingManager; - const recordings = new RecordingManager({}); - recordings.activeSessions.add("session-1"); - recordings.cleanup("session-1"); - assert.strictEqual(recordings.activeSessions.size, 0); -} - -async function testCompleteAppTeardownIsImmediateAndIdempotent() { - let finishBackendClose; - let backendCloses = 0; - const context = loadScript("src/js/app.js"); - const App = context.window.AlchementaryLink.App; - const app = Object.create(App.prototype); - const resources = { - sftp: new Set(["session-1"]), - triggers: new Set(["session-1"]), - recordings: new Set(["session-1"]), - terminals: new Set(["session-1"]), - raw: new Set(["session-1"]), - github: new Set(["session-1"]), - vnc: new Set(["session-1"]), - irc: new Set(["session-1"]), - leviathan: new Set(["session-1"]), - }; - const drop = (name) => ({ - cleanup: (id) => resources[name].delete(id), - close: (id) => resources[name].delete(id), - destroyTerminal: (id) => resources[name].delete(id), - destroyViewer: (id) => resources[name].delete(id), - destroyPanel: (id) => resources[name].delete(id), - }); - - app._closingSessions = new Map(); - app.splitPanes = { cleanup: async () => {} }; - app.sftp = drop("sftp"); - app.triggers = drop("triggers"); - app.recordings = drop("recordings"); - app.terminals = drop("terminals"); - app.rawViewer = drop("raw"); - app.leviathan = drop("leviathan"); - app.tabs = { - order: ["session-1"], - removeTab: (id) => { - app.tabs.order = app.tabs.order.filter((value) => value !== id); - }, - }; - app.state = { - sessions: new Map([["session-1", { id: "session-1" }]]), - getSession(id) { - return this.sessions.get(id); - }, - removeSession(id) { - this.sessions.delete(id); - }, - }; - app.updateStatusBar = () => {}; - - context.window.AlchementaryLink.GitHubViewer = drop("github"); - context.window.AlchementaryLink.VNCViewer = drop("vnc"); - context.window.AlchementaryLink.IRCViewer = drop("irc"); - context.window.AlchementaryLink.wailsService = { - DisconnectSession: () => { - backendCloses++; - return new Promise((resolve) => { - finishBackendClose = resolve; - }); - }, - }; - - const first = app.closeSessionImmediate("session-1"); - const second = app.closeSessionImmediate("session-1"); - assert.strictEqual(first, second); - assert.strictEqual(backendCloses, 1); - assert.strictEqual(app.tabs.order.length, 0); - assert.strictEqual(app.state.sessions.size, 0); - for (const retained of Object.values(resources)) { - assert.strictEqual(retained.size, 0); - } - - finishBackendClose(); - await first; - assert.strictEqual(app._closingSessions.size, 0); -} - -async function testLatencyPingIsSingleFlightAndIgnoresStaleResults() { - const flushAsync = () => new Promise((resolve) => setImmediate(resolve)); - let nextTimerID = 1; - const timers = new Map(); - const latencyEl = { textContent: "", className: "" }; - const context = loadScript("src/js/app.js", { - setTimeout: (callback) => { - const id = nextTimerID++; - timers.set(id, callback); - return id; - }, - clearTimeout: (id) => timers.delete(id), - document: { - querySelector: () => null, - getElementById: (id) => (id === "status-latency" ? latencyEl : null), - addEventListener: () => {}, - }, - }); - const App = context.window.AlchementaryLink.App; - const app = Object.create(App.prototype); - app._latencySessionID = null; - app._latencyTimer = null; - app._latencyGeneration = 0; - app._latencyPingInFlight = false; - - const pending = []; - context.window.AlchementaryLink.wailsService = { - PingSession: (sessionID) => - new Promise((resolve, reject) => { - pending.push({ sessionID, resolve, reject }); - }), - }; - - app._startLatencyPing("session-a"); - app._startLatencyPing("session-a"); - assert.strictEqual(pending.length, 1, "an in-flight ping must not overlap"); - assert.strictEqual(timers.size, 0, "the next ping waits for completion"); - - app._startLatencyPing("session-b"); - assert.strictEqual(pending.length, 2); - pending[0].reject(new Error("stale session closed")); - await flushAsync(); - assert.strictEqual(app._latencySessionID, "session-b"); - assert.strictEqual( - app._latencyPingInFlight, - true, - "a stale rejection must not stop the current session ping", - ); - - pending[1].resolve(42); - await flushAsync(); - assert.strictEqual(latencyEl.textContent, "42 ms"); - assert.strictEqual(latencyEl.className, "latency-good"); - assert.strictEqual(timers.size, 1, "one follow-up ping should be scheduled"); - - const followUp = timers.values().next().value; - timers.clear(); - followUp(); - assert.strictEqual(pending.length, 3); - assert.strictEqual(pending[2].sessionID, "session-b"); - assert.strictEqual(timers.size, 0, "follow-up pings must also be single-flight"); - - pending[2].resolve(250); - await flushAsync(); - assert.strictEqual(latencyEl.className, "latency-bad"); - assert.strictEqual(timers.size, 1); - app._stopLatencyPing(); - assert.strictEqual(timers.size, 0); -} - -(async () => { - await testRepeatedSplitCleanup(); - testClosedPaneRejectsLateConnection(); - testPerSessionStoresReleaseIDs(); - await testCompleteAppTeardownIsImmediateAndIdempotent(); - await testLatencyPingIsSingleFlightAndIgnoresStaleResults(); - console.log("Session lifecycle regression tests passed."); -})().catch((err) => { - console.error(err); - process.exitCode = 1; -}); diff --git a/frontend/src/css/base.css b/frontend/src/css/base.css deleted file mode 100644 index ac13eae..0000000 --- a/frontend/src/css/base.css +++ /dev/null @@ -1,187 +0,0 @@ -/* ========================================================================== - Alchementary Link — Base / Reset - Modern CSS reset and foundational element styles. - ========================================================================== */ - -/* ----------------------------------------------------------------------- - Box-model reset - ----------------------------------------------------------------------- */ -*, -*::before, -*::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -/* ----------------------------------------------------------------------- - Root element - ----------------------------------------------------------------------- */ -html, -body { - height: 100%; - overflow: hidden; - font-family: var(--font-sans); - font-size: var(--text-base); - line-height: var(--leading-normal); - color: var(--text-primary); - background: var(--bg-base); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - user-select: none; -} - -/* ----------------------------------------------------------------------- - Scrollbar styling (Webkit / Blink) - ----------------------------------------------------------------------- */ -::-webkit-scrollbar { - width: 6px; - height: 6px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: var(--border-strong); - border-radius: var(--radius-full); -} - -::-webkit-scrollbar-thumb:hover { - background: var(--text-tertiary); -} - -/* Firefox scrollbar */ -* { - scrollbar-width: thin; - scrollbar-color: var(--border-strong) transparent; -} - -/* ----------------------------------------------------------------------- - Text selection - ----------------------------------------------------------------------- */ -::selection { - background: var(--accent-strong); - color: var(--text-primary); -} - -/* ----------------------------------------------------------------------- - Focus management - ----------------------------------------------------------------------- */ -:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -:focus:not(:focus-visible) { - outline: none; -} - -/* ----------------------------------------------------------------------- - Links - ----------------------------------------------------------------------- */ -a { - color: var(--accent); - text-decoration: none; - transition: color var(--transition-fast); -} - -a:hover { - color: var(--accent-hover); -} - -/* ----------------------------------------------------------------------- - Images - ----------------------------------------------------------------------- */ -img, -svg { - display: block; - max-width: 100%; -} - -/* ----------------------------------------------------------------------- - Lists - ----------------------------------------------------------------------- */ -ul, -ol { - list-style: none; -} - -/* ----------------------------------------------------------------------- - Tables - ----------------------------------------------------------------------- */ -table { - border-collapse: collapse; - border-spacing: 0; -} - -/* ----------------------------------------------------------------------- - Buttons & inputs — inherit font - ----------------------------------------------------------------------- */ -button, -input, -select, -textarea { - font: inherit; - color: inherit; - background: none; - border: none; -} - -button { - cursor: pointer; -} - -/* ----------------------------------------------------------------------- - Monospace elements - ----------------------------------------------------------------------- */ -code, -pre, -kbd, -.mono { - font-family: var(--font-mono); -} - -/* ----------------------------------------------------------------------- - Hidden utility - ----------------------------------------------------------------------- */ -[hidden], -.hidden { - display: none !important; -} - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -/* ----------------------------------------------------------------------- - Text utilities - ----------------------------------------------------------------------- */ -.truncate { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.text-secondary { color: var(--text-secondary); } -.text-tertiary { color: var(--text-tertiary); } -.text-accent { color: var(--accent); } -.text-success { color: var(--success); } -.text-warning { color: var(--warning); } -.text-error { color: var(--error); } - -/* ----------------------------------------------------------------------- - Wails window drag region - ----------------------------------------------------------------------- */ -.wails-drag { - --wails-draggable: drag; -} diff --git a/frontend/src/css/components.css b/frontend/src/css/components.css deleted file mode 100644 index 8615ac2..0000000 --- a/frontend/src/css/components.css +++ /dev/null @@ -1,1230 +0,0 @@ -/* ========================================================================== - Alchementary Link — Reusable Components - Buttons, form controls, badges, dropdowns, modals, notifications, etc. - ========================================================================== */ - -/* ======================================================================= - BUTTONS - ======================================================================= */ - -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--space-2); - height: 30px; - padding: 0 var(--space-3); - font-size: var(--text-sm); - font-weight: var(--weight-medium); - line-height: 1; - border-radius: var(--radius-md); - border: 1px solid transparent; - cursor: pointer; - white-space: nowrap; - transition: - background var(--transition-fast), - border-color var(--transition-fast), - color var(--transition-fast), - box-shadow var(--transition-fast); - user-select: none; -} - -.btn:disabled { - opacity: 0.45; - cursor: not-allowed; - pointer-events: none; -} - -/* Primary — accent filled */ -.btn-primary { - background: var(--accent); - color: var(--text-inverse); - border-color: var(--accent); -} -.btn-primary:hover { - background: var(--accent-hover); - border-color: var(--accent-hover); -} -.btn-primary:active { - background: #3da69f; - border-color: #3da69f; -} - -/* Accent — success-tinted action, e.g. "Save & Connect" */ -.btn-accent { - background: var(--success); - color: var(--text-inverse); - border-color: var(--success); -} -.btn-accent:hover { - background: #57a85c; - border-color: #57a85c; -} -.btn-accent:active { - background: #4a9a4e; - border-color: #4a9a4e; -} - -/* Secondary — subtle border */ -.btn-secondary { - background: transparent; - color: var(--text-secondary); - border-color: var(--border-default); -} -.btn-secondary:hover { - background: var(--bg-hover); - color: var(--text-primary); - border-color: var(--border-strong); -} -.btn-secondary:active { - background: var(--bg-active); -} - -/* Ghost — transparent, hover only */ -.btn-ghost { - background: transparent; - color: var(--text-secondary); -} -.btn-ghost:hover { - background: var(--bg-hover); - color: var(--text-primary); -} -.btn-ghost:active { - background: var(--bg-active); -} - -/* Danger */ -.btn-danger { - background: transparent; - color: var(--error); - border-color: var(--error); -} -.btn-danger:hover { - background: rgba(239, 83, 80, 0.12); -} -.btn-danger:active { - background: rgba(239, 83, 80, 0.2); -} - -.btn-danger-filled { - background: var(--error); - color: #fff; - border-color: var(--error); -} -.btn-danger-filled:hover { - background: #e53935; - border-color: #e53935; -} - -/* Warning */ -.btn-warning { - background: var(--warning); - color: #1a1a1e; - border-color: var(--warning); -} -.btn-warning:hover { - filter: brightness(1.05); -} -.btn-warning:active { - filter: brightness(0.95); -} - -/* Size variants */ -.btn-sm { - height: 26px; - padding: 0 var(--space-2); - font-size: var(--text-xs); -} - -.btn-xs { - height: 22px; - padding: 0 var(--space-2); - font-size: var(--text-xs); - border-radius: var(--radius-sm); -} - -/* Icon-only button (square) */ -.btn-icon { - width: 30px; - padding: 0; - flex-shrink: 0; -} -.btn-icon.btn-sm { - width: 26px; -} -.btn-icon.btn-xs { - width: 22px; -} - -/* Button group */ -.btn-group { - display: inline-flex; -} - -/* Outline button — transparent background, subtle border */ -.btn-outline { - background: transparent; - border: 1px solid var(--border); - color: var(--text-secondary); -} -.btn-outline:hover { - background: var(--surface-2); - color: var(--text-primary); -} - -.btn-group .btn { - border-radius: 0; -} -.btn-group .btn:first-child { - border-radius: var(--radius-md) 0 0 var(--radius-md); -} -.btn-group .btn:last-child { - border-radius: 0 var(--radius-md) var(--radius-md) 0; -} -.btn-group .btn + .btn { - margin-left: -1px; -} - -/* ======================================================================= - FORM CONTROLS - ======================================================================= */ - -.form-group { - display: flex; - flex-direction: column; - gap: var(--space-1); -} - -.form-label { - font-size: var(--text-sm); - font-weight: var(--weight-medium); - color: var(--text-secondary); - user-select: none; -} - -.form-label-required::after { - content: " *"; - color: var(--error); -} - -/* Text input, select, textarea share a common treatment */ -.form-input, -.form-select, -.form-textarea { - height: 30px; - padding: 0 var(--space-3); - font-size: var(--text-sm); - color: var(--text-primary); - background: var(--bg-input); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); - transition: - border-color var(--transition-fast), - box-shadow var(--transition-fast); -} - -.form-input::placeholder, -.form-textarea::placeholder { - color: var(--text-disabled); -} - -.form-input:hover, -.form-select:hover, -.form-textarea:hover { - border-color: var(--border-strong); -} - -.form-input:focus, -.form-select:focus, -.form-textarea:focus { - border-color: var(--accent); - box-shadow: 0 0 0 2px var(--accent-muted); - outline: none; -} - -.form-input:disabled, -.form-select:disabled, -.form-textarea:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* Select arrow */ -.form-select { - appearance: none; - padding-right: var(--space-8); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236e6e88'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 10px center; - cursor: pointer; -} - -/* Textarea */ -.form-textarea { - height: auto; - min-height: 64px; - padding: var(--space-2) var(--space-3); - resize: vertical; - line-height: var(--leading-normal); -} - -/* Help text */ -.form-help { - font-size: var(--text-xs); - color: var(--text-tertiary); - line-height: var(--leading-normal); -} - -/* Error message */ -.form-error { - font-size: var(--text-xs); - color: var(--error); - line-height: var(--leading-normal); -} - -/* Error state on inputs */ -.form-input.is-error, -.form-select.is-error, -.form-textarea.is-error { - border-color: var(--error); -} -.form-input.is-error:focus, -.form-select.is-error:focus, -.form-textarea.is-error:focus { - box-shadow: 0 0 0 2px rgba(239, 83, 80, 0.2); -} - -/* Inline layout (label beside control) */ -.form-inline { - flex-direction: row; - align-items: center; - gap: var(--space-3); -} -.form-inline .form-label { - min-width: 100px; - flex-shrink: 0; -} - -/* Checkbox */ -.form-checkbox { - display: inline-flex; - align-items: center; - gap: var(--space-2); - cursor: pointer; - font-size: var(--text-sm); - color: var(--text-secondary); - user-select: none; -} - -.form-checkbox input[type="checkbox"] { - appearance: none; - width: 16px; - height: 16px; - border: 1px solid var(--border-strong); - border-radius: var(--radius-sm); - background: var(--bg-input); - flex-shrink: 0; - cursor: pointer; - transition: - background var(--transition-fast), - border-color var(--transition-fast); - position: relative; -} - -.form-checkbox input[type="checkbox"]:checked { - background: var(--accent); - border-color: var(--accent); -} - -.form-checkbox input[type="checkbox"]:checked::after { - content: ""; - position: absolute; - top: 2px; - left: 5px; - width: 4px; - height: 8px; - border: solid var(--text-inverse); - border-width: 0 2px 2px 0; - transform: rotate(45deg); -} - -.form-checkbox input[type="checkbox"]:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -/* Toggle switch */ -.form-toggle { - display: inline-flex; - align-items: center; - gap: var(--space-2); - cursor: pointer; - font-size: var(--text-sm); - color: var(--text-secondary); - user-select: none; -} - -.form-toggle input[type="checkbox"] { - appearance: none; - width: 32px; - height: 18px; - border-radius: var(--radius-full); - background: var(--border-strong); - position: relative; - cursor: pointer; - flex-shrink: 0; - transition: background var(--transition-normal); -} - -.form-toggle input[type="checkbox"]::after { - content: ""; - position: absolute; - top: 2px; - left: 2px; - width: 14px; - height: 14px; - border-radius: 50%; - background: var(--text-secondary); - transition: - transform var(--transition-normal), - background var(--transition-normal); -} - -.form-toggle input[type="checkbox"]:checked { - background: var(--accent); -} - -.form-toggle input[type="checkbox"]:checked::after { - transform: translateX(14px); - background: var(--text-inverse); -} - -/* ======================================================================= - BADGES - ======================================================================= */ - -.badge { - display: inline-flex; - align-items: center; - height: 18px; - padding: 0 var(--space-2); - font-size: 10px; - font-weight: var(--weight-semibold); - line-height: 1; - border-radius: var(--radius-sm); - white-space: nowrap; - text-transform: uppercase; - letter-spacing: 0.04em; - background: var(--bg-overlay); - color: var(--text-secondary); -} - -/* Protocol badges */ -.badge-ssh { - background: rgba(78, 205, 196, 0.15); - color: var(--protocol-ssh); -} -.badge-telnet { - background: rgba(255, 167, 38, 0.15); - color: var(--protocol-telnet); -} -.badge-raw { - background: rgba(171, 71, 188, 0.15); - color: var(--protocol-raw); -} -.badge-github { - background: rgba(124, 138, 255, 0.15); - color: var(--protocol-github); -} - -/* Status badges */ -.badge-status { - gap: var(--space-1); -} -.badge-status::before { - content: ""; - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--text-tertiary); - flex-shrink: 0; -} -.badge-status.is-connected::before { - background: var(--success); -} -.badge-status.is-connecting::before { - background: var(--warning); - animation: pulse-dot 1.2s ease infinite; -} -.badge-status.is-error::before { - background: var(--error); -} -.badge-status.is-disconnected::before { - background: var(--text-tertiary); -} - -@keyframes pulse-dot { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.4; - } -} - -/* ======================================================================= - CARDS & SURFACES - ======================================================================= */ - -.surface { - background: var(--bg-elevated); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); -} - -.card { - background: var(--bg-elevated); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-lg); - padding: var(--space-4); - cursor: pointer; - transition: - background var(--transition-fast), - border-color var(--transition-fast), - box-shadow var(--transition-fast); -} -.card:hover { - background: var(--bg-hover); - border-color: var(--border-default); - box-shadow: var(--shadow-md); -} -.card:active { - background: var(--bg-active); -} - -/* ======================================================================= - DROPDOWN / CONTEXT MENU - ======================================================================= */ - -.dropdown { - position: relative; - display: inline-flex; -} - -.dropdown-menu { - position: absolute; - top: 100%; - left: 0; - z-index: var(--z-dropdown); - min-width: 160px; - padding: var(--space-1) 0; - background: var(--bg-overlay); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); - box-shadow: var(--shadow-lg); - opacity: 0; - visibility: hidden; - transform: translateY(-4px); - transition: - opacity var(--transition-fast), - visibility var(--transition-fast), - transform var(--transition-fast); -} - -.dropdown.is-open .dropdown-menu, -.dropdown-menu.is-visible { - opacity: 1; - visibility: visible; - transform: translateY(2px); -} - -/* Right-aligned variant */ -.dropdown-menu.is-right { - left: auto; - right: 0; -} - -.dropdown-item { - display: flex; - align-items: center; - gap: var(--space-2); - width: 100%; - padding: var(--space-1) var(--space-3); - font-size: var(--text-sm); - color: var(--text-secondary); - cursor: pointer; - transition: - background var(--transition-fast), - color var(--transition-fast); - border: none; - background: none; - text-align: left; -} - -.dropdown-item:hover { - background: var(--bg-hover); - color: var(--text-primary); -} - -.dropdown-item.is-danger { - color: var(--error); -} -.dropdown-item.is-danger:hover { - background: rgba(239, 83, 80, 0.1); -} - -.dropdown-item.is-disabled { - opacity: 0.4; - pointer-events: none; -} - -.dropdown-item-icon { - width: 16px; - text-align: center; - flex-shrink: 0; - font-size: var(--text-md); -} - -.dropdown-item-shortcut { - margin-left: auto; - font-size: var(--text-xs); - color: var(--text-tertiary); -} - -.dropdown-separator { - height: 1px; - margin: var(--space-1) 0; - background: var(--border-subtle); -} - -.dropdown-header { - padding: var(--space-1) var(--space-3); - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - color: var(--text-tertiary); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -/* ======================================================================= - NOTIFICATIONS (toast) - ======================================================================= */ - -.notifications-container { - position: fixed; - top: var(--space-4); - right: var(--space-4); - z-index: var(--z-notification); - display: flex; - flex-direction: column; - gap: var(--space-2); - pointer-events: none; -} - -.notification { - display: flex; - align-items: flex-start; - gap: var(--space-3); - min-width: 280px; - max-width: 400px; - padding: var(--space-3) var(--space-4); - background: var(--bg-overlay); - border: 1px solid var(--border-default); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-xl); - pointer-events: auto; - animation: notification-in var(--transition-slow) ease forwards; -} - -.notification.is-leaving { - animation: notification-out var(--transition-normal) ease forwards; -} - -@keyframes notification-in { - from { - opacity: 0; - transform: translateX(20px); - } - to { - opacity: 1; - transform: translateX(0); - } -} -@keyframes notification-out { - from { - opacity: 1; - transform: translateX(0); - } - to { - opacity: 0; - transform: translateX(20px); - } -} - -.notification-icon { - flex-shrink: 0; - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - font-size: 11px; - font-weight: var(--weight-bold); - line-height: 1; - color: var(--text-inverse); - background: var(--text-tertiary); -} - -.notification-success .notification-icon { - background: var(--success); -} -.notification-error .notification-icon { - background: var(--error); -} -.notification-warning .notification-icon { - background: var(--warning); -} -.notification-info .notification-icon { - background: var(--info); -} - -.notification-body { - flex: 1; - min-width: 0; -} - -.notification-title { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--text-primary); - margin-bottom: 2px; -} - -.notification-message { - font-size: var(--text-xs); - color: var(--text-secondary); - line-height: var(--leading-normal); -} - -.notification-close { - flex-shrink: 0; - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-sm); - color: var(--text-tertiary); - cursor: pointer; - transition: - background var(--transition-fast), - color var(--transition-fast); -} -.notification-close:hover { - background: var(--bg-hover); - color: var(--text-primary); -} - -/* Colour variants (left border accent) */ -.notification-success { - border-left: 3px solid var(--success); -} -.notification-error { - border-left: 3px solid var(--error); -} -.notification-warning { - border-left: 3px solid var(--warning); -} -.notification-info { - border-left: 3px solid var(--info); -} - -/* ======================================================================= - DISCLOSURE / ACCORDION - ======================================================================= */ - -.disclosure { - border-bottom: 1px solid var(--border-subtle); -} - -.disclosure-header { - display: flex; - align-items: center; - gap: var(--space-2); - width: 100%; - padding: var(--space-3) var(--space-4); - font-size: var(--text-sm); - font-weight: var(--weight-medium); - color: var(--text-secondary); - cursor: pointer; - transition: - background var(--transition-fast), - color var(--transition-fast); - user-select: none; - text-align: left; - background: none; - border: none; -} - -.disclosure-header:hover { - background: var(--bg-hover); - color: var(--text-primary); -} - -.disclosure-chevron { - width: 16px; - height: 16px; - flex-shrink: 0; - transition: transform var(--transition-normal); - color: var(--text-tertiary); -} - -.disclosure.is-open .disclosure-chevron { - transform: rotate(90deg); -} - -.disclosure-content { - overflow: hidden; - max-height: 0; - transition: max-height var(--transition-slow); -} - -.disclosure.is-open .disclosure-content { - max-height: 2000px; -} - -.disclosure-content-inner { - padding: 0 var(--space-4) var(--space-4); -} - -/* ======================================================================= - SEARCH INPUT - ======================================================================= */ - -.search-input { - width: 100%; - height: 30px; - padding: 0 var(--space-3) 0 var(--space-8); - font-size: var(--text-sm); - color: var(--text-primary); - background: var(--bg-input); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); - transition: - border-color var(--transition-fast), - box-shadow var(--transition-fast); -} - -.search-input::placeholder { - color: var(--text-disabled); -} - -.search-input:hover { - border-color: var(--border-default); -} - -.search-input:focus { - border-color: var(--accent); - box-shadow: 0 0 0 2px var(--accent-muted); - outline: none; -} - -/* Wrapper with icon */ -.search-wrapper { - position: relative; -} - -.search-wrapper::before { - content: "⌕"; - position: absolute; - left: var(--space-3); - top: 50%; - transform: translateY(-50%); - font-size: var(--text-sm); - color: var(--text-tertiary); - pointer-events: none; -} - -/* ======================================================================= - EMPTY STATE - ======================================================================= */ - -.empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - flex: 1; - padding: var(--space-8); - text-align: center; -} - -.empty-state-icon { - font-size: 48px; - color: var(--text-tertiary); - margin-bottom: var(--space-4); - opacity: 0.5; -} - -.empty-state h2 { - font-size: var(--text-lg); - font-weight: var(--weight-semibold); - color: var(--text-secondary); - margin-bottom: var(--space-2); -} - -.empty-state p { - font-size: var(--text-sm); - color: var(--text-tertiary); - max-width: 320px; - line-height: var(--leading-relaxed); - margin-bottom: var(--space-6); -} - -.empty-state-actions { - display: flex; - gap: var(--space-3); -} - -/* ======================================================================= - TOOLTIP - ======================================================================= */ - -.tooltip { - position: absolute; - z-index: var(--z-tooltip); - padding: var(--space-1) var(--space-2); - font-size: var(--text-xs); - font-weight: var(--weight-medium); - color: var(--text-primary); - background: var(--bg-overlay); - border: 1px solid var(--border-default); - border-radius: var(--radius-sm); - box-shadow: var(--shadow-md); - white-space: nowrap; - pointer-events: none; - opacity: 0; - transition: opacity var(--transition-fast); -} - -.tooltip.is-visible { - opacity: 1; -} - -/* ======================================================================= - DIVIDER - ======================================================================= */ - -.divider { - height: 1px; - background: var(--border-subtle); - margin: var(--space-2) 0; -} - -.divider-vertical { - width: 1px; - align-self: stretch; - background: var(--border-subtle); - margin: 0 var(--space-2); -} - -/* ======================================================================= - LOADING / SPINNER - ======================================================================= */ - -.spinner { - width: 16px; - height: 16px; - border: 2px solid var(--border-strong); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.6s linear infinite; -} - -.spinner-sm { - width: 12px; - height: 12px; - border-width: 1.5px; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -/* ======================================================================= - CONTEXT MENUS - ======================================================================= */ - -.context-menu { - position: fixed; - z-index: var(--z-modal, 1000); - min-width: 180px; - max-width: 260px; - padding: var(--space-1) 0; - background: var(--bg-elevated); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); - box-shadow: - 0 4px 16px rgba(0, 0, 0, 0.35), - 0 1px 4px rgba(0, 0, 0, 0.2); - overflow: hidden; -} - -.context-menu-item { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 6px var(--space-3); - border: none; - background: none; - color: var(--text-primary); - font-size: var(--text-sm); - text-align: left; - cursor: pointer; - transition: background var(--transition-fast); - gap: var(--space-3); -} - -.context-menu-item:hover { - background: var(--bg-hover); -} - -.context-menu-item:active { - background: var(--bg-active); -} - -.context-menu-item.context-menu-disabled { - color: var(--text-disabled); - cursor: not-allowed; -} - -.context-menu-item.context-menu-disabled:hover { - background: none; -} - -.context-menu-item.context-menu-danger { - color: var(--error); -} - -.context-menu-item.context-menu-danger:hover { - background: rgba(239, 83, 80, 0.1); -} - -.context-menu-label { - flex: 1; - min-width: 0; -} - -.context-menu-shortcut { - font-size: 11px; - color: var(--text-tertiary); - flex-shrink: 0; - margin-left: var(--space-3); -} - -.context-menu-divider { - height: 1px; - margin: var(--space-1) 0; - background: var(--border-subtle); -} - -/* ======================================================================= - SSH KEY MANAGER - ======================================================================= */ - -.sshkey-toolbar { - display: flex; - justify-content: flex-end; - margin-bottom: var(--space-3); -} - -.sshkey-list { - display: flex; - flex-direction: column; - gap: var(--space-2); - max-height: 400px; - overflow-y: auto; -} - -.sshkey-row { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-2) var(--space-3); - background: var(--bg-base); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); - gap: var(--space-3); -} - -.sshkey-row:hover { - border-color: var(--border-strong); -} - -.sshkey-info { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.sshkey-name { - font-weight: var(--weight-medium); - font-size: var(--text-sm); - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.sshkey-meta { - font-size: var(--text-xs); - color: var(--text-tertiary); -} - -.sshkey-actions { - display: flex; - gap: var(--space-2); - flex-shrink: 0; -} - -.sshkey-loading, -.sshkey-empty, -.sshkey-error { - padding: var(--space-4); - text-align: center; - color: var(--text-tertiary); - font-size: var(--text-sm); -} - -.sshkey-error { - color: var(--error); -} - -.sshkey-generate-form { - display: flex; - flex-direction: column; - gap: var(--space-3); - padding: var(--space-3); - background: var(--bg-base); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); - margin-bottom: var(--space-3); -} - -.sshkey-form-actions { - display: flex; - justify-content: flex-end; - gap: var(--space-2); -} - -/* ======================================================================= - TRIGGER RULES - ======================================================================= */ - -.trigger-toolbar { - display: flex; - justify-content: flex-end; - margin-bottom: var(--space-3); -} - -.trigger-list { - display: flex; - flex-direction: column; - gap: var(--space-2); - max-height: 400px; - overflow-y: auto; -} - -.trigger-empty { - padding: var(--space-4); - text-align: center; - color: var(--text-tertiary); - font-size: var(--text-sm); -} - -.trigger-row { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-2) var(--space-3); - background: var(--bg-base); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); -} - -.trigger-row:hover { - border-color: var(--border-strong); -} - -.trigger-toggle { - flex-shrink: 0; -} - -.trigger-info { - display: flex; - flex-direction: column; - gap: 2px; - flex: 1; - min-width: 0; -} - -.trigger-name { - font-weight: var(--weight-medium); - font-size: var(--text-sm); - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.trigger-meta { - font-size: var(--text-xs); - color: var(--text-tertiary); -} - -.trigger-pattern { - font-size: var(--text-xs); - color: var(--accent); - background: var(--bg-overlay); - padding: 2px 6px; - border-radius: var(--radius-sm); - max-width: 150px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.trigger-actions { - display: flex; - gap: var(--space-2); - flex-shrink: 0; -} - -.trigger-form { - display: flex; - flex-direction: column; - gap: var(--space-3); - padding: var(--space-3); - background: var(--bg-base); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); - margin-bottom: var(--space-3); -} - -.trigger-form .form-row { - display: flex; - gap: var(--space-3); -} - -.trigger-form-actions { - display: flex; - justify-content: flex-end; - gap: var(--space-2); -} diff --git a/frontend/src/css/forms.css b/frontend/src/css/forms.css deleted file mode 100644 index a28084b..0000000 --- a/frontend/src/css/forms.css +++ /dev/null @@ -1,745 +0,0 @@ -/* ========================================================================== - Alchementary Link — Profile Editor & Form Polish - Section headers, grouped fields, protocol-specific visibility, - inline validation, tag input, jump host list, key file selector. - ========================================================================== */ - -/* ----------------------------------------------------------------------- - Form panel (profile editor container) - ----------------------------------------------------------------------- */ -.form-panel { - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; -} - -.form-panel-header { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-3) var(--space-4); - border-bottom: 1px solid var(--border-subtle); - flex-shrink: 0; -} - -.form-panel-title { - font-size: var(--text-md); - font-weight: var(--weight-semibold); - color: var(--text-primary); - flex: 1; -} - -.form-panel-body { - flex: 1; - overflow-y: auto; - padding: var(--space-4); -} - -.form-panel-footer { - display: flex; - align-items: center; - justify-content: flex-end; - gap: var(--space-2); - padding: var(--space-3) var(--space-4); - border-top: 1px solid var(--border-subtle); - flex-shrink: 0; -} - -/* ----------------------------------------------------------------------- - Form sections (disclosure-based grouping) - ----------------------------------------------------------------------- */ -.form-section { - border: 1px solid var(--border-subtle); - border-radius: var(--radius-lg); - margin-bottom: var(--space-4); - overflow: hidden; -} - -.form-section-header { - display: flex; - align-items: center; - gap: var(--space-2); - width: 100%; - padding: var(--space-3) var(--space-4); - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--text-primary); - cursor: pointer; - user-select: none; - -webkit-user-select: none; - background: var(--bg-elevated); - border: none; - cursor: pointer; - user-select: none; - text-align: left; - transition: background var(--transition-fast); -} - -.form-section-header:hover { - background: var(--bg-hover); -} - -.form-section-chevron { - width: 14px; - height: 14px; - color: var(--text-tertiary); - transition: transform var(--transition-normal); - flex-shrink: 0; -} - -.form-section.is-open .form-section-chevron { - transform: rotate(90deg); -} - -.form-section-icon { - width: 16px; - text-align: center; - flex-shrink: 0; - font-size: var(--text-sm); - color: var(--text-tertiary); -} - -.form-section-body, -.form-section-content { - padding: var(--space-4); - display: none; -} - -.form-section.is-open .form-section-body, -.form-section.is-open .form-section-content { - display: block; -} - -/* ----------------------------------------------------------------------- - Field grid (two-column layout for compact forms) - ----------------------------------------------------------------------- */ -.form-fields { - display: flex; - flex-direction: column; - gap: var(--space-3); -} - -.form-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--space-3); -} - -/* Single-column override */ -.form-row.full-width { - grid-template-columns: 1fr; -} - -/* Three-column for port + protocol combos */ -.form-row.triple { - grid-template-columns: 1fr 100px 120px; -} - -/* ----------------------------------------------------------------------- - Protocol-specific field visibility - Fields are hidden unless their protocol class is active. - ----------------------------------------------------------------------- */ -.proto-only-ssh, -.proto-only-telnet, -.proto-only-raw { - display: none; -} - -.protocol-ssh .proto-only-ssh, -.protocol-telnet .proto-only-telnet, -.protocol-raw .proto-only-raw, -.protocol-github .proto-only-github { - display: flex; -} - -/* For block-level protocol fields */ -.protocol-ssh .proto-only-ssh.form-section, -.protocol-telnet .proto-only-telnet.form-section, -.protocol-raw .proto-only-raw.form-section, -.protocol-github .proto-only-github.form-section { - display: block; -} - -/* ----------------------------------------------------------------------- - Tag input - ----------------------------------------------------------------------- */ -.tag-input-wrapper { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-1); - min-height: 30px; - padding: var(--space-1) var(--space-2); - background: var(--bg-input); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); - cursor: text; - transition: border-color var(--transition-fast); -} - -.tag-input-wrapper:hover { - border-color: var(--border-strong); -} - -.tag-input-wrapper:focus-within { - border-color: var(--accent); - box-shadow: 0 0 0 2px var(--accent-muted); -} - -.tag-item { - display: inline-flex; - align-items: center; - gap: 3px; - height: 20px; - padding: 0 var(--space-2); - font-size: var(--text-xs); - color: var(--accent); - background: var(--accent-muted); - border-radius: var(--radius-sm); - white-space: nowrap; -} - -.tag-item-remove { - display: flex; - align-items: center; - justify-content: center; - width: 12px; - height: 12px; - font-size: 10px; - cursor: pointer; - border-radius: 50%; - color: var(--accent); - transition: background var(--transition-fast); -} - -.tag-item-remove:hover { - background: var(--accent-strong); -} - -.tag-input { - flex: 1; - min-width: 60px; - border: none; - background: transparent; - font-size: var(--text-sm); - color: var(--text-primary); - outline: none; - height: 22px; -} - -.tag-input::placeholder { - color: var(--text-disabled); -} - -/* ----------------------------------------------------------------------- - Jump host / proxy list - ----------------------------------------------------------------------- */ -.jump-host-list { - display: flex; - flex-direction: column; - gap: var(--space-2); -} - -.jump-host-item { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-2) var(--space-3); - background: var(--bg-input); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); - font-size: var(--text-sm); -} - -.jump-host-item .jump-host-order { - font-size: var(--text-xs); - color: var(--text-disabled); - min-width: 16px; - text-align: center; -} - -.jump-host-item .jump-host-value { - flex: 1; - font-family: var(--font-mono); - color: var(--text-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.jump-host-item .jump-host-actions { - display: flex; - gap: var(--space-1); - opacity: 0; - transition: opacity var(--transition-fast); -} - -.jump-host-item:hover .jump-host-actions { - opacity: 1; -} - -.jump-host-add { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-2); - font-size: var(--text-sm); - color: var(--text-tertiary); - border: 1px dashed var(--border-default); - border-radius: var(--radius-md); - cursor: pointer; - transition: - border-color var(--transition-fast), - color var(--transition-fast); -} - -.jump-host-add:hover { - border-color: var(--accent); - color: var(--accent); -} - -/* ----------------------------------------------------------------------- - Key file selector row - ----------------------------------------------------------------------- */ -.file-selector { - display: flex; - gap: var(--space-2); -} - -.file-selector .form-input { - flex: 1; -} - -.file-selector .btn { - flex-shrink: 0; -} - -/* File path display */ -.file-path { - font-family: var(--font-mono); - font-size: var(--text-xs); - color: var(--text-tertiary); - background: var(--bg-input); - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-sm); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* ----------------------------------------------------------------------- - Inline validation - ----------------------------------------------------------------------- */ -.form-group.is-valid .form-input { - border-color: var(--success); -} - -.form-group.is-valid .form-input:focus { - box-shadow: 0 0 0 2px rgba(102, 187, 106, 0.2); -} - -.form-group.is-invalid .form-input { - border-color: var(--error); -} - -.form-group.is-invalid .form-input:focus { - box-shadow: 0 0 0 2px rgba(239, 83, 80, 0.2); -} - -.form-validation-icon { - position: absolute; - right: var(--space-3); - top: 50%; - transform: translateY(-50%); - font-size: var(--text-sm); -} - -.form-group.is-valid .form-validation-icon { - color: var(--success); -} -.form-group.is-invalid .form-validation-icon { - color: var(--error); -} - -/* ----------------------------------------------------------------------- - Help text under sections - ----------------------------------------------------------------------- */ -.form-section-help { - font-size: var(--text-xs); - color: var(--text-tertiary); - padding: 0 var(--space-4) var(--space-3); - line-height: var(--leading-relaxed); -} - -/* ----------------------------------------------------------------------- - Colour preview (for terminal colour settings) - ----------------------------------------------------------------------- */ -.colour-preview { - display: inline-block; - width: 18px; - height: 18px; - border-radius: var(--radius-sm); - border: 1px solid var(--border-default); - vertical-align: middle; - cursor: pointer; -} - -/* ----------------------------------------------------------------------- - Number input with stepper - ----------------------------------------------------------------------- */ -.form-number { - display: flex; - align-items: center; -} - -.form-number .form-input { - border-radius: var(--radius-md) 0 0 var(--radius-md); - text-align: center; - width: 60px; - /* Hide browser spinners */ - -moz-appearance: textfield; -} -.form-number .form-input::-webkit-inner-spin-button, -.form-number .form-input::-webkit-outer-spin-button { - appearance: none; -} - -.form-number-btn { - width: 24px; - height: 30px; - display: flex; - align-items: center; - justify-content: center; - font-size: var(--text-sm); - color: var(--text-secondary); - background: var(--bg-input); - border: 1px solid var(--border-default); - border-left: none; - cursor: pointer; - transition: - background var(--transition-fast), - color var(--transition-fast); -} - -.form-number-btn:last-child { - border-radius: 0 var(--radius-md) var(--radius-md) 0; -} - -.form-number-btn:hover { - background: var(--bg-hover); - color: var(--text-primary); -} - -/* ----------------------------------------------------------------------- - Form action row (test connection, etc.) - ----------------------------------------------------------------------- */ -.form-actions-inline { - display: flex; - align-items: center; - gap: var(--space-2); - padding-top: var(--space-3); - border-top: 1px solid var(--border-subtle); - margin-top: var(--space-3); -} - -.form-actions-inline .btn { - flex-shrink: 0; -} - -.form-actions-inline .form-actions-status { - font-size: var(--text-xs); - color: var(--text-tertiary); - flex: 1; -} - -/* ----------------------------------------------------------------------- - Form hint — inline help text below fields - ----------------------------------------------------------------------- */ -.form-hint { - font-size: var(--text-xs); - color: var(--text-tertiary); - margin-top: var(--space-1); - line-height: var(--leading-relaxed); -} - -.form-hint.secret-error { - color: var(--danger); -} - -.settings-shell-row { - display: flex; - gap: var(--space-2); - align-items: center; -} - -.settings-shell-row .form-input { - flex: 1; -} - -.form-success { - font-size: var(--text-xs); - color: var(--success); - margin-top: var(--space-1); - line-height: var(--leading-relaxed); -} - -/* ----------------------------------------------------------------------- - Toggle wrapper — label + toggle switch in a row - ----------------------------------------------------------------------- */ -.form-toggle-wrapper { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-3); - padding: var(--space-1) 0; -} - -.form-toggle-label { - font-size: var(--text-sm); - color: var(--text-secondary); - flex: 1; -} - -/* ----------------------------------------------------------------------- - Jump host rows - ----------------------------------------------------------------------- */ -.jump-hosts-list { - display: flex; - flex-direction: column; - gap: var(--space-2); -} - -.jump-host-row { - display: flex; - flex-direction: column; - gap: var(--space-1); - padding: var(--space-2); - background: var(--surface-2); - border-radius: var(--radius-sm); - border: 1px solid var(--border); -} - -.jump-host-fields { - display: flex; - gap: var(--space-2); - align-items: center; -} - -/* ----------------------------------------------------------------------- - Port-forwarding rows - ----------------------------------------------------------------------- */ -.port-forwards-list { - display: flex; - flex-direction: column; - gap: var(--space-2); -} - -.port-forward-row { - display: flex; - flex-direction: column; - gap: var(--space-1); - padding: var(--space-2); - background: var(--surface-2); - border-radius: var(--radius-sm); - border: 1px solid var(--border); -} - -.port-forward-fields { - display: flex; - gap: var(--space-2); - align-items: center; -} - -.port-forward-arrow { - color: var(--text-muted); - font-size: var(--text-sm); - flex-shrink: 0; -} - -.port-forward-enabled-label { - display: flex; - gap: var(--space-1); - align-items: center; - font-size: var(--text-xs); - color: var(--text-secondary); - white-space: nowrap; - cursor: pointer; -} - -.port-forward-enabled-label input[type="checkbox"] { - accent-color: var(--accent); -} - -/* ----------------------------------------------------------------------- - Payload preset rows - ----------------------------------------------------------------------- */ -.payload-presets-list { - display: flex; - flex-direction: column; - gap: var(--space-2); -} - -.preset-row { - display: grid; - grid-template-columns: 120px 1fr 100px auto; - gap: var(--space-2); - align-items: center; -} - -/* ----------------------------------------------------------------------- - Small / compact form controls — used inside repeater rows - ----------------------------------------------------------------------- */ -.form-input-sm, -.form-select-sm { - font-size: var(--text-xs); - padding: var(--space-1) var(--space-2); - height: 30px; -} - -/* ----------------------------------------------------------------------- - Login Script Editor — expect-style send/wait steps - ----------------------------------------------------------------------- */ -.form-help { - font-size: var(--text-xs); - color: var(--text-muted); - margin-bottom: var(--space-3); - line-height: 1.5; -} - -.login-script-list { - display: flex; - flex-direction: column; - gap: var(--space-2); - margin-bottom: var(--space-3); -} - -.login-script-empty { - font-size: var(--text-xs); - color: var(--text-muted); - font-style: italic; - padding: var(--space-2) 0; -} - -.login-script-row { - display: flex; - align-items: center; - gap: var(--space-2); - background: var(--bg-base); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-sm); - padding: var(--space-2); -} - -.login-script-type { - width: 90px; - min-width: 90px; - font-size: var(--text-xs); - padding: var(--space-1) var(--space-2); - height: 30px; -} - -.login-script-value { - flex: 1; - font-size: var(--text-xs); - padding: var(--space-1) var(--space-2); - height: 30px; -} - -.login-script-timeout { - width: 60px; - min-width: 60px; - font-size: var(--text-xs); - padding: var(--space-1) var(--space-2); - height: 30px; -} - -.login-script-timeout-label { - font-size: var(--text-xs); - color: var(--text-muted); - min-width: 10px; -} - -.login-script-actions { - display: flex; - gap: 2px; - flex-shrink: 0; -} - -.login-script-btn { - background: var(--bg-elevated); - color: var(--text-secondary); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-xs); - width: 24px; - height: 24px; - font-size: 10px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: - background 0.15s, - color 0.15s; -} - -.login-script-btn:hover { - background: var(--bg-surface); - color: var(--text-primary); -} - -.login-script-btn-remove:hover { - background: var(--danger); - color: #fff; -} - -.login-script-add-row { - display: flex; - gap: var(--space-2); -} - -/* ----------------------------------------------------------------------- - TOTP / 2FA — secret input row with action buttons - ----------------------------------------------------------------------- */ -.totp-input-row { - display: flex; - gap: var(--space-2); - align-items: center; -} - -.totp-input-row .form-input { - flex: 1; -} - -/* SSH key path row */ -.key-path-row { - display: flex; - gap: var(--space-2); - align-items: center; -} - -.key-path-row .form-input { - flex: 1; -} - -.jump-hosts-list .form-error { - margin-bottom: var(--space-2); -} - -.totp-toggle, -.totp-test, -.totp-save, -.totp-delete { - flex-shrink: 0; - white-space: nowrap; -} - -.btn-danger { - background: var(--error); - color: #fff; - border: 1px solid transparent; -} - -.btn-danger:hover { - background: #d32f2f; -} diff --git a/frontend/src/css/github.css b/frontend/src/css/github.css deleted file mode 100644 index 05c4b9b..0000000 --- a/frontend/src/css/github.css +++ /dev/null @@ -1,304 +0,0 @@ -/* ========================================================================= - GitHub Repository Viewer - Styles for the non-terminal GitHub connection panel. - ========================================================================= */ - -.github-viewer { - display: flex; - flex-direction: column; - height: 100%; - background: var(--bg-primary); - color: var(--text-primary); - font-family: var(--font-family); -} - -/* ── Header bar ────────────────────────────────────────────────────────── */ - -.github-viewer-header { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-3) var(--space-4) var(--space-3) var(--space-5); - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-subtle); - flex-shrink: 0; -} - -.github-viewer-title-area { - display: flex; - align-items: center; - gap: var(--space-2); - flex-shrink: 0; -} - -.github-viewer-icon { - display: flex; - align-items: center; - color: var(--protocol-github); - opacity: 0.9; -} - -.github-viewer-title { - font-size: var(--font-size-lg); - font-weight: 600; - color: var(--text-primary); - white-space: nowrap; -} - -.github-viewer-search { - flex: 1; - max-width: 360px; -} - -.github-viewer-search-input { - width: 100%; - padding: var(--space-1) var(--space-3); - background: var(--bg-tertiary); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-sm); - color: var(--text-primary); - font-size: var(--font-size-sm); - outline: none; - transition: border-color 0.15s; -} - -.github-viewer-search-input:focus { - border-color: var(--protocol-github); -} - -.github-viewer-search-input::placeholder { - color: var(--text-muted); -} - -.github-viewer-refresh { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - background: var(--bg-tertiary); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-sm); - color: var(--text-secondary); - font-size: 16px; - cursor: pointer; - transition: all 0.15s; - flex-shrink: 0; -} - -.github-viewer-refresh:hover { - background: var(--bg-hover); - color: var(--protocol-github); - border-color: var(--protocol-github); -} - -/* ── Status bar ────────────────────────────────────────────────────────── */ - -.github-viewer-status { - padding: var(--space-1) var(--space-4) var(--space-1) var(--space-5); - font-size: var(--font-size-xs); - color: var(--text-muted); - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-subtle); - flex-shrink: 0; -} - -.github-viewer-status-error { - color: var(--red); -} - -/* ── Repo list ─────────────────────────────────────────────────────────── */ - -.github-viewer-list { - flex: 1; - overflow-y: auto; - padding: var(--space-3) var(--space-4) var(--space-3) var(--space-5); -} - -.github-viewer-empty { - padding: var(--space-8) var(--space-4); - text-align: center; - color: var(--text-muted); - font-size: var(--font-size-sm); -} - -/* ── Group ─────────────────────────────────────────────────────────────── */ - -.github-group { - margin-bottom: var(--space-4); -} - -.github-group-header { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-1) 0; - margin-bottom: var(--space-2); - border-bottom: 1px solid var(--border-subtle); -} - -.github-group-name { - font-size: var(--font-size-sm); - font-weight: 600; - color: var(--text-secondary); - text-transform: none; - letter-spacing: 0.01em; -} - -.github-group-count { - font-size: var(--font-size-xs); - color: var(--text-muted); - background: var(--bg-tertiary); - padding: 1px 6px; - border-radius: var(--radius-full); -} - -/* ── Repo card ─────────────────────────────────────────────────────────── */ - -.github-repo-card { - padding: var(--space-2) var(--space-3); - margin-bottom: 2px; - border-radius: var(--radius-sm); - cursor: pointer; - transition: background 0.12s; -} - -.github-repo-card:hover { - background: var(--bg-hover); -} - -.github-repo-top { - display: flex; - align-items: center; - gap: var(--space-2); - margin-bottom: 2px; -} - -.github-repo-commit { - font-size: var(--font-size-xs); - color: var(--text-muted); - margin-bottom: 4px; -} - -.github-repo-name { - font-size: var(--font-size-sm); - font-weight: 600; - color: var(--protocol-github); -} - -.github-repo-badge { - font-size: 10px; - padding: 0 5px; - border-radius: var(--radius-full); - line-height: 16px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.03em; -} - -.github-repo-actions { - margin-left: auto; - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 4px; -} - -.github-repo-download, -.github-repo-visit { - padding: 0 6px; - height: 18px; - border-radius: var(--radius-full); - border: 1px solid var(--border-subtle); - background: var(--bg-tertiary); - color: var(--text-secondary); - font-size: 10px; - letter-spacing: 0.04em; - cursor: pointer; - transition: - background 0.15s, - color 0.15s, - border-color 0.15s; -} - -.github-repo-download:hover, -.github-repo-visit:hover { - background: var(--bg-hover); - color: var(--protocol-github); - border-color: var(--protocol-github); -} - -.github-repo-private { - background: rgba(255, 167, 38, 0.12); - color: #ffa726; -} - -.github-repo-fork { - background: rgba(78, 205, 196, 0.12); - color: #4ecdc4; -} - -.github-repo-archived { - background: rgba(255, 255, 255, 0.06); - color: var(--text-muted); -} - -.github-repo-desc { - font-size: var(--font-size-xs); - color: var(--text-secondary); - line-height: 1.4; - margin-bottom: 4px; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.github-repo-meta { - display: flex; - align-items: center; - gap: var(--space-3); - font-size: 11px; - color: var(--text-muted); -} - -.github-repo-lang { - display: flex; - align-items: center; - gap: 4px; -} - -.github-lang-dot { - width: 8px; - height: 8px; - border-radius: 50%; - display: inline-block; - flex-shrink: 0; -} - -.github-repo-stars { - /* Star colour is inherent in the text */ -} - -.github-repo-pushed { - /* Relative time text */ -} - -/* ── Scrollbar styling ─────────────────────────────────────────────────── */ - -.github-viewer-list::-webkit-scrollbar { - width: 6px; -} - -.github-viewer-list::-webkit-scrollbar-track { - background: transparent; -} - -.github-viewer-list::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb); - border-radius: 3px; -} - -.github-viewer-list::-webkit-scrollbar-thumb:hover { - background: var(--scrollbar-thumb-hover); -} diff --git a/frontend/src/css/irc.css b/frontend/src/css/irc.css deleted file mode 100644 index 42f231f..0000000 --- a/frontend/src/css/irc.css +++ /dev/null @@ -1,289 +0,0 @@ -/* ========================================================================== - Alchementary Link - IRC viewer - ========================================================================== */ - -.irc-viewer-panel { - background: var(--bg-base); -} - -.irc-viewer { - display: grid; - grid-template-columns: minmax(0, 1fr) 260px; - width: 100%; - height: 100%; - min-width: 0; - min-height: 0; -} - -.irc-main { - display: grid; - grid-template-rows: 34px minmax(0, 1fr) 42px; - min-width: 0; - min-height: 0; -} - -.irc-room-tabs { - display: flex; - align-items: center; - gap: var(--space-1); - min-width: 0; - overflow-x: auto; - overflow-y: hidden; - padding: var(--space-1) var(--space-2); - background: var(--bg-surface); - border-bottom: 1px solid var(--border-subtle); -} - -.irc-room-tab { - height: 24px; - max-width: 180px; - min-width: 44px; - padding: 0 var(--space-2); - border: 1px solid transparent; - border-radius: var(--radius-sm); - background: transparent; - color: var(--text-secondary); - font-size: var(--text-xs); - line-height: 22px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - cursor: pointer; -} - -.irc-room-tab:hover { - background: var(--bg-hover); - color: var(--text-primary); -} - -.irc-room-tab.active { - background: var(--bg-active); - border-color: var(--border-default); - color: var(--text-primary); -} - -.irc-room-tab.has-unread::after { - content: ""; - display: inline-block; - width: 5px; - height: 5px; - margin-left: var(--space-1); - border-radius: 50%; - background: var(--protocol-irc); - vertical-align: middle; -} - -.irc-terminal { - position: relative; - min-width: 0; - min-height: 0; - overflow: hidden; - padding: var(--space-2); -} - -.irc-terminal .xterm { - height: 100%; -} - -.irc-composer { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: var(--space-2); - padding: var(--space-2); - background: var(--bg-surface); - border-top: 1px solid var(--border-subtle); -} - -.irc-input { - min-width: 0; - height: 26px; - padding: 0 var(--space-2); - border: 1px solid var(--border-default); - border-radius: var(--radius-sm); - background: var(--bg-input); - color: var(--text-primary); - font-family: var(--font-mono); - font-size: var(--text-sm); -} - -.irc-input:focus, -.irc-join-input:focus, -.irc-filter-input:focus { - outline: none; - border-color: var(--border-focus); -} - -.irc-send { - height: 26px; -} - -.irc-sidebar { - display: grid; - grid-template-rows: minmax(88px, auto) minmax(120px, 1fr) minmax(120px, 1fr); - min-width: 0; - min-height: 0; - background: var(--bg-surface); - border-left: 1px solid var(--border-subtle); -} - -.irc-sidebar-section { - min-height: 0; - padding: var(--space-2); - border-bottom: 1px solid var(--border-subtle); - overflow: hidden; -} - -.irc-users-section { - border-bottom: 0; -} - -.irc-sidebar-title, -.irc-sidebar-title-row { - min-height: 24px; - color: var(--text-tertiary); - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - text-transform: uppercase; - letter-spacing: 0.06em; -} - -.irc-sidebar-title-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-2); -} - -.irc-join-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: var(--space-1); - margin-bottom: var(--space-2); -} - -.irc-join-input, -.irc-filter-input { - min-width: 0; - height: 24px; - padding: 0 var(--space-2); - border: 1px solid var(--border-default); - border-radius: var(--radius-sm); - background: var(--bg-input); - color: var(--text-primary); - font-size: var(--text-xs); -} - -.irc-filter-input { - width: 100%; - margin-bottom: var(--space-2); -} - -.irc-sidebar-list, -.irc-room-list, -.irc-user-list { - min-height: 0; - max-height: 100%; - overflow-y: auto; -} - -.irc-room-row, -.irc-user-row, -.irc-listed-room-main { - width: 100%; - min-width: 0; - border: 0; - background: transparent; - color: var(--text-secondary); - cursor: pointer; - text-align: left; -} - -.irc-room-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: var(--space-2); - height: 26px; - padding: 0 var(--space-2); - border-radius: var(--radius-sm); -} - -.irc-room-row:hover, -.irc-user-row:hover, -.irc-listed-room:hover { - background: var(--bg-hover); - color: var(--text-primary); -} - -.irc-room-row.active { - background: var(--bg-active); - color: var(--text-primary); -} - -.irc-room-name, -.irc-listed-room-name, -.irc-listed-room-topic { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.irc-room-count, -.irc-listed-room-count { - color: var(--text-tertiary); - font-size: 10px; - font-variant-numeric: tabular-nums; -} - -.irc-listed-room { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: var(--space-2); - min-height: 34px; - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-sm); -} - -.irc-listed-room-main { - display: grid; - gap: 1px; -} - -.irc-listed-room-name { - color: var(--text-primary); - font-size: var(--text-xs); -} - -.irc-listed-room-topic { - color: var(--text-tertiary); - font-size: 10px; -} - -.irc-user-row { - display: block; - height: 24px; - padding: 0 var(--space-2); - border-radius: var(--radius-sm); - font-size: var(--text-xs); - line-height: 24px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.irc-empty { - padding: var(--space-2); - color: var(--text-tertiary); - font-size: var(--text-xs); -} - -@media (max-width: 900px) { - .irc-viewer { - grid-template-columns: minmax(0, 1fr); - } - - .irc-sidebar { - display: none; - } -} diff --git a/frontend/src/css/layout.css b/frontend/src/css/layout.css deleted file mode 100644 index 2b27d6d..0000000 --- a/frontend/src/css/layout.css +++ /dev/null @@ -1,241 +0,0 @@ -/* ========================================================================== - Alchementary Link — Application Layout - Grid shell, sidebar, tab bar, workspace, and status bar structure. - ========================================================================== */ - -/* ----------------------------------------------------------------------- - Main application grid - ----------------------------------------------------------------------- */ -.app { - display: grid; - grid-template-columns: var(--sidebar-width) 1fr; - grid-template-rows: var(--tab-bar-height) 1fr var(--status-bar-height); - height: 100vh; - overflow: hidden; - background: var(--bg-base); -} - -/* Collapsed sidebar variant */ -.app.sidebar-collapsed { - grid-template-columns: var(--sidebar-width-collapsed) 1fr; -} - -/* ----------------------------------------------------------------------- - Sidebar - ----------------------------------------------------------------------- */ -.sidebar { - grid-row: 1 / -1; - grid-column: 1; - background: var(--bg-surface); - border-right: 1px solid var(--border-subtle); - display: flex; - flex-direction: column; - overflow: hidden; - z-index: var(--z-sticky); -} - -/* ----------------------------------------------------------------------- - Tab bar - ----------------------------------------------------------------------- */ -.tab-bar { - grid-column: 2; - grid-row: 1; - position: relative; - background: var(--bg-surface); - border-bottom: 1px solid var(--border-subtle); - display: flex; - align-items: center; - height: var(--tab-bar-height); - min-height: var(--tab-bar-height); - overflow: hidden; -} - -.tab-list { - display: flex; - align-items: center; - flex: 1; - height: 100%; - min-width: 0; - overflow-x: auto; - overflow-y: hidden; - scrollbar-width: thin; - scrollbar-gutter: stable; - scroll-behavior: smooth; -} -.tab-list::-webkit-scrollbar { - height: 6px; -} -.tab-list::-webkit-scrollbar-thumb { - background: var(--border-strong); - border-radius: var(--radius-full); -} -.tab-list::-webkit-scrollbar-thumb:hover { - background: var(--text-tertiary); -} - -.tab-bar::before, -.tab-bar::after { - content: ""; - position: absolute; - top: 0; - bottom: 1px; - z-index: 2; - width: 18px; - pointer-events: none; - opacity: 0; - transition: opacity var(--transition-fast); -} - -.tab-bar::before { - left: 0; - background: linear-gradient(90deg, var(--bg-surface), transparent); -} - -.tab-bar::after { - right: calc(var(--tab-bar-height) * 2); - background: linear-gradient(-90deg, var(--bg-surface), transparent); -} - -.tab-bar.has-overflow-left::before, -.tab-bar.has-overflow-right::after { - opacity: 1; -} - -/* ----------------------------------------------------------------------- - Workspace - ----------------------------------------------------------------------- */ -.workspace { - grid-column: 2; - grid-row: 2; - position: relative; - overflow: hidden; - background: var(--bg-base); - display: flex; - flex-direction: column; -} - -/* Panel container — fills workspace, holds session panels */ -.panel-container { - flex: 1; - position: relative; - overflow: hidden; -} - -/* Individual session panel — one per tab, fills the panel container */ -.session-panel { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - overflow: hidden; -} - -/* Terminal container — fills workspace */ -.terminal-container { - position: absolute; - inset: 0; - padding: 0; -} - -/* ----------------------------------------------------------------------- - Status bar - ----------------------------------------------------------------------- */ -.status-bar { - grid-column: 2; - grid-row: 3; - background: var(--bg-surface); - border-top: 1px solid var(--border-subtle); - display: flex; - align-items: center; - padding: 0 var(--space-3); - font-size: var(--text-xs); - color: var(--text-tertiary); - gap: var(--space-4); - white-space: nowrap; - overflow: hidden; -} - -.status-bar span { - display: inline-flex; - align-items: center; - gap: var(--space-1); -} - -.status-spacer { - flex: 1; -} - -.status-update { - border: 1px solid var(--accent); - background: rgba(78, 205, 196, 0.12); - color: var(--accent); - font-size: var(--text-xs); - padding: 2px 8px; - border-radius: 999px; - cursor: pointer; - transition: - background 150ms ease, - color 150ms ease, - border-color 150ms ease; -} - -.status-update:hover { - background: rgba(78, 205, 196, 0.2); - color: var(--text-primary); - border-color: var(--accent-strong, var(--accent)); -} - -.status-update.is-hidden { - display: none; -} - -/* Status bar indicator dots */ -.status-bar .status-dot { - width: 6px; - height: 6px; - border-radius: 50%; - display: inline-block; -} -.status-dot.is-connected { - background: var(--success); -} -.status-dot.is-connecting { - background: var(--warning); -} -.status-dot.is-error { - background: var(--error); -} -.status-dot.is-disconnected { - background: var(--text-tertiary); -} - -/* Latency indicator */ -#status-latency { - font-variant-numeric: tabular-nums; -} -#status-latency.latency-good { - color: var(--success); -} -#status-latency.latency-warn { - color: var(--warning); -} -#status-latency.latency-bad { - color: var(--error); -} - -/* ----------------------------------------------------------------------- - Resize handle (between sidebar and workspace) - ----------------------------------------------------------------------- */ -.resize-handle { - position: absolute; - top: 0; - right: -3px; - width: 6px; - height: 100%; - cursor: col-resize; - z-index: var(--z-sticky); -} -.resize-handle:hover, -.resize-handle.is-dragging { - background: var(--accent-muted); -} diff --git a/frontend/src/css/leviathan.css b/frontend/src/css/leviathan.css deleted file mode 100644 index a84d5d9..0000000 --- a/frontend/src/css/leviathan.css +++ /dev/null @@ -1,752 +0,0 @@ -/* Leviathan mode inside the Link shell */ - -.leviathan-actions { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--space-2); - padding: var(--space-2) var(--space-3); - border-bottom: 1px solid var(--border-subtle); - background: var(--bg-surface); -} - -.leviathan-group { - margin: var(--space-3) var(--space-3) var(--space-1); - padding: 0; - color: var(--sidebar-group-title); - cursor: default; - font-size: 10px; - font-weight: var(--weight-semibold); - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.leviathan-tool { - display: grid; - grid-template-columns: 26px minmax(0, 1fr); - align-items: center; - gap: var(--space-2); - min-height: 30px; - margin: 1px var(--space-2); - border: 1px solid transparent; - border-radius: var(--radius-md); -} - -.leviathan-tool:hover { - border-color: var(--border-subtle); - background: var(--bg-hover); -} - -.leviathan-tool.sidebar-item-active { - border-color: rgba(78, 205, 196, 0.35); - background: var(--accent-muted); -} - -.leviathan-tool-ico { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 20px; - border: 1px solid var(--border-subtle); - border-radius: var(--radius-sm); - color: var(--warning); - background: rgba(255, 167, 38, 0.08); - font-family: var(--font-mono); - font-size: 10px; - font-weight: var(--weight-semibold); -} - -.leviathan-tool.sidebar-item-active .leviathan-tool-ico { - color: var(--accent); - border-color: rgba(78, 205, 196, 0.35); - background: rgba(78, 205, 196, 0.12); -} - -.badge-leviathan { - color: var(--warning); - border-color: rgba(255, 167, 38, 0.35); -} - -.leviathan-panel { - flex-direction: column; - min-width: 0; - overflow: hidden; - background: #181818; -} - -.leviathan-panel-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-3); - min-height: 48px; - padding: var(--space-2) var(--space-3); - border-bottom: 1px solid var(--border-subtle); - background: var(--bg-surface); -} - -.leviathan-title-wrap { - display: flex; - align-items: center; - gap: var(--space-3); - min-width: 0; -} - -.leviathan-tool-mark { - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - width: 34px; - height: 30px; - border: 1px solid rgba(255, 167, 38, 0.28); - border-radius: var(--radius-md); - color: var(--warning); - background: rgba(255, 167, 38, 0.08); - font-family: var(--font-mono); - font-size: var(--text-xs); - font-weight: var(--weight-semibold); -} - -.leviathan-heading { - display: grid; - gap: 1px; - min-width: 0; -} - -.leviathan-title-wrap h2 { - margin: 0; - overflow: hidden; - color: var(--text-primary); - font-size: var(--text-md); - font-weight: var(--weight-semibold); - line-height: var(--leading-tight); - text-overflow: ellipsis; - white-space: nowrap; -} - -.leviathan-eyebrow, -.leviathan-status { - overflow: hidden; - color: var(--text-tertiary); - font-size: var(--text-xs); - line-height: var(--leading-tight); - text-overflow: ellipsis; - white-space: nowrap; -} - -.leviathan-header-actions, -.leviathan-tool-toolbar { - display: flex; - align-items: center; - gap: var(--space-2); - flex-wrap: wrap; -} - -.leviathan-header-actions .btn, -.leviathan-tool-toolbar .btn { - height: 28px; -} - -.leviathan-panel-body { - min-height: 0; - overflow: hidden; - padding: 0; -} - -.leviathan-workbench { - display: grid; - grid-template-columns: minmax(260px, 330px) minmax(0, 1fr); - gap: var(--space-3); - height: 100%; - padding: var(--space-3); -} - -.leviathan-top-workbench { - grid-template-columns: 1fr; - grid-template-rows: auto minmax(0, 1fr); - gap: var(--space-2); -} - -.leviathan-hex-workbench { - grid-template-columns: 1fr; - grid-template-rows: auto minmax(0, 1fr); -} - -.leviathan-control-plane, -.leviathan-output-plane { - min-width: 0; - min-height: 0; - border: 1px solid var(--border-subtle); - border-radius: var(--radius-lg); - background: var(--bg-surface); -} - -.leviathan-control-plane { - display: flex; - flex-direction: column; - gap: var(--space-3); - overflow: auto; - padding: var(--space-3); -} - -.leviathan-top-workbench .leviathan-control-plane { - align-items: flex-end; - flex-direction: row; - flex-wrap: wrap; - gap: var(--space-2); - min-height: 42px; - overflow: visible; - padding: var(--space-2) var(--space-3); -} - -.leviathan-output-plane { - display: flex; - flex-direction: column; - overflow: hidden; - background: #151515; -} - -.leviathan-params { - display: grid; - grid-template-columns: 1fr; - gap: var(--space-2); -} - -.leviathan-top-workbench .leviathan-params { - display: flex; - align-items: flex-end; - flex: 1 1 auto; - flex-wrap: wrap; - gap: var(--space-2); - min-width: 180px; -} - -.leviathan-param, -.leviathan-param-inline { - display: grid; - gap: var(--space-1); - min-width: 0; - color: var(--text-tertiary); - font-size: var(--text-xs); -} - -.leviathan-param > span, -.leviathan-param-inline > span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.leviathan-top-workbench .leviathan-param, -.leviathan-top-workbench .leviathan-param-inline { - min-width: 96px; -} - -.leviathan-top-workbench .leviathan-param { - flex: 0 1 150px; -} - -.leviathan-top-workbench .leviathan-param-inline { - flex: 0 0 112px; -} - -.leviathan-hex-workbench .leviathan-param-inline { - flex-basis: 104px; -} - -.leviathan-param .form-input, -.leviathan-param-inline .form-input { - height: 30px; - border-color: var(--border-subtle); - background: var(--bg-input); - font-family: var(--font-mono); - font-size: var(--text-xs); -} - -.leviathan-param textarea.form-input { - min-height: 118px; - padding: var(--space-2); - line-height: var(--leading-normal); - resize: vertical; -} - -.leviathan-top-workbench .leviathan-tool-toolbar { - flex: 0 0 auto; -} - -.leviathan-checkbox { - width: 18px; - height: 18px; - min-height: 18px; - padding: 0; - accent-color: var(--accent); -} - -.leviathan-file-picker { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - align-items: center; - gap: var(--space-2); - min-width: 0; - padding-top: var(--space-2); - border-top: 1px solid var(--border-subtle); -} - -.leviathan-file-path { - min-width: 0; - overflow: hidden; - color: var(--text-tertiary); - font-family: var(--font-mono); - font-size: var(--text-xs); - text-overflow: ellipsis; - white-space: nowrap; -} - -.leviathan-summary { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); - gap: 1px; - flex: 0 0 auto; - min-height: 0; - border-bottom: 1px solid var(--border-subtle); - background: var(--border-subtle); -} - -.leviathan-summary:empty { - display: none; -} - -.leviathan-pill { - min-width: 0; - padding: var(--space-2) var(--space-3); - background: var(--bg-surface); -} - -.leviathan-pill span { - display: block; - overflow: hidden; - color: var(--text-tertiary); - font-size: 10px; - line-height: var(--leading-tight); - text-overflow: ellipsis; - text-transform: uppercase; - white-space: nowrap; -} - -.leviathan-pill strong { - display: block; - overflow: hidden; - margin-top: 2px; - color: var(--text-primary); - font-family: var(--font-mono); - font-size: var(--text-xs); - font-weight: var(--weight-medium); - text-overflow: ellipsis; - white-space: nowrap; -} - -.leviathan-results { - flex: 1; - min-width: 0; - min-height: 0; - overflow: auto; -} - -.leviathan-dashboard { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 1px; - border-bottom: 1px solid var(--border-subtle); - background: var(--border-subtle); -} - -.leviathan-metric { - min-width: 0; - padding: var(--space-3); - background: #151515; -} - -.leviathan-metric span, -.leviathan-kv-row span { - display: block; - overflow: hidden; - color: var(--text-tertiary); - font-size: 10px; - line-height: var(--leading-tight); - text-overflow: ellipsis; - text-transform: uppercase; - white-space: nowrap; -} - -.leviathan-metric strong { - display: block; - overflow: hidden; - margin-top: var(--space-1); - color: var(--text-primary); - font-family: var(--font-mono); - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - text-overflow: ellipsis; - white-space: nowrap; -} - -.leviathan-sections { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: var(--space-3); - padding: var(--space-3); -} - -.leviathan-section { - min-width: 0; - border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); - background: var(--bg-surface); -} - -.leviathan-section h3 { - margin: 0; - padding: var(--space-2) var(--space-3); - border-bottom: 1px solid var(--border-subtle); - color: var(--text-secondary); - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - text-transform: uppercase; -} - -.leviathan-kv-list { - display: grid; -} - -.leviathan-kv-row { - display: grid; - grid-template-columns: minmax(80px, 120px) minmax(0, 1fr); - gap: var(--space-2); - min-width: 0; - padding: var(--space-2) var(--space-3); - border-bottom: 1px solid var(--border-subtle); -} - -.leviathan-kv-row:last-child { - border-bottom: 0; -} - -.leviathan-kv-row strong { - overflow: hidden; - color: var(--text-primary); - font-family: var(--font-mono); - font-size: var(--text-xs); - font-weight: var(--weight-medium); - text-overflow: ellipsis; - white-space: nowrap; -} - -.leviathan-entropy-frame { - min-height: 100%; - padding: var(--space-3); - background: #101010; -} - -.leviathan-entropy-canvas { - width: 100%; - height: min(280px, 42vh); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); - background: #101010; - cursor: crosshair; -} - -.leviathan-spectrogram-frame { - min-height: 100%; - overflow: auto; - padding: var(--space-3); - background: #05070c; -} - -.leviathan-spectrogram-image { - display: block; - max-width: 100%; - height: auto; - image-rendering: pixelated; -} - -.leviathan-empty, -.leviathan-json { - min-height: 100%; - color: var(--text-secondary); - background: #151515; -} - -.leviathan-empty { - display: grid; - place-items: center; - padding: var(--space-6); - color: var(--text-tertiary); - font-size: var(--text-sm); - text-align: center; -} - -.leviathan-error-state { - display: grid; - place-items: center; - min-height: 100%; - gap: var(--space-2); - padding: var(--space-6); - color: var(--text-secondary); - text-align: center; -} - -.leviathan-error-icon { - position: relative; - display: grid; - place-items: center; - width: 58px; - height: 52px; - color: #161616; - font-size: 24px; - font-weight: var(--weight-bold); -} - -.leviathan-error-icon::before { - position: absolute; - inset: 0; - background: var(--warning); - clip-path: polygon(50% 0, 100% 100%, 0 100%); - content: ""; -} - -.leviathan-error-icon span { - position: relative; - top: 7px; -} - -.leviathan-error-state strong { - color: var(--text-primary); - font-size: var(--text-md); - font-weight: var(--weight-semibold); -} - -.leviathan-error-state p { - max-width: 520px; - margin: 0; - color: var(--text-tertiary); - font-size: var(--text-sm); - line-height: var(--leading-normal); -} - -.leviathan-json { - margin: 0; - padding: var(--space-3); - overflow: auto; - white-space: pre-wrap; - font-family: var(--font-mono); - font-size: var(--text-xs); - line-height: var(--leading-normal); - user-select: text; -} - -.leviathan-table { - width: 100%; - border-collapse: separate; - border-spacing: 0; - font-size: var(--text-xs); -} - -.leviathan-table th, -.leviathan-table td { - max-width: 320px; - border-bottom: 1px solid var(--border-subtle); - padding: var(--space-2) var(--space-3); - overflow: hidden; - text-align: left; - text-overflow: ellipsis; - vertical-align: top; - white-space: nowrap; -} - -.leviathan-offset-link { - color: var(--warning); - font-family: var(--font-mono); - font-size: var(--text-xs); - text-align: left; -} - -.leviathan-offset-link:hover { - color: var(--accent); -} - -.leviathan-export-found { - min-width: 58px; -} - -.leviathan-muted { - color: var(--text-tertiary); -} - -.leviathan-table th { - position: sticky; - top: 0; - z-index: 1; - color: var(--text-tertiary); - background: var(--bg-surface); - font-weight: var(--weight-semibold); - text-transform: uppercase; -} - -.leviathan-table tr:hover td { - background: rgba(255, 255, 255, 0.03); -} - -.leviathan-table-note { - padding: var(--space-2) var(--space-3); - border-top: 1px solid var(--border-subtle); - color: var(--text-tertiary); - font-size: var(--text-xs); -} - -.leviathan-tree { - padding: var(--space-2) var(--space-3); -} - -.leviathan-tree-node { - border-left: 1px solid var(--border-subtle); - padding: var(--space-1) 0 var(--space-1) var(--space-2); - font-family: var(--font-mono); - font-size: var(--text-xs); -} - -.leviathan-tree-node strong { - color: var(--text-primary); - font-weight: var(--weight-medium); -} - -.leviathan-tree-node span { - color: var(--text-tertiary); -} - -.leviathan-hex-grid { - --leviathan-hex-width: 16; - display: grid; - align-content: start; - gap: 0; - min-height: 100%; - padding: var(--space-1); - overflow: auto; - background: #101010; - font-family: var(--font-mono); - font-size: 10px; -} - -.leviathan-hex-row { - display: grid; - grid-template-columns: 82px max-content max-content; - align-items: center; - gap: var(--space-2); - min-height: 23px; - padding: 0 var(--space-1); - border-radius: var(--radius-sm); -} - -.leviathan-hex-row:hover { - background: rgba(255, 255, 255, 0.035); -} - -.leviathan-offset { - color: var(--warning); - font-variant-numeric: tabular-nums; -} - -.leviathan-byte-cells { - display: grid; - grid-template-columns: repeat(var(--leviathan-hex-width), 22px); - gap: 2px; -} - -.leviathan-byte { - width: 22px; - min-height: 20px; - padding: 0; - border: 1px solid var(--border-subtle); - border-radius: var(--radius-sm); - color: var(--text-primary); - background: #1d1d1d; - text-align: center; - font-family: var(--font-mono); - font-size: 10px; - line-height: 18px; - transition: - border-color var(--transition-fast), - background var(--transition-fast); -} - -.leviathan-byte:hover, -.leviathan-byte:focus { - border-color: var(--accent); - background: var(--bg-input); -} - -.leviathan-byte.is-editing { - border-color: var(--warning); -} - -.leviathan-byte.is-saving { - color: var(--accent); -} - -.leviathan-byte.is-search-hit { - border-color: var(--warning); - color: var(--text-inverse); - background: var(--warning); -} - -.leviathan-ascii { - overflow: hidden; - color: var(--text-tertiary); - font-size: 10px; - line-height: 20px; - text-overflow: clip; - white-space: pre; -} - -.leviathan-modal-fields { - display: grid; - grid-template-columns: 120px minmax(0, 1fr); - gap: var(--space-3); -} - -@media (max-width: 1120px) { - .leviathan-workbench { - grid-template-columns: 1fr; - grid-template-rows: auto minmax(0, 1fr); - } - - .leviathan-control-plane { - max-height: 260px; - } - - .leviathan-params { - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - } -} - -@media (max-width: 720px) { - .leviathan-panel-header { - align-items: stretch; - flex-direction: column; - } - - .leviathan-header-actions { - justify-content: flex-start; - } - - .leviathan-hex-row { - grid-template-columns: 1fr; - gap: var(--space-1); - padding: var(--space-2); - } - - .leviathan-byte-cells { - grid-template-columns: repeat(var(--leviathan-hex-width), 22px); - } -} diff --git a/frontend/src/css/lib/xterm.css b/frontend/src/css/lib/xterm.css deleted file mode 100644 index 819654e..0000000 --- a/frontend/src/css/lib/xterm.css +++ /dev/null @@ -1,285 +0,0 @@ -/** - * Copyright (c) 2014 The xterm.js authors. All rights reserved. - * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) - * https://github.com/chjj/term.js - * @license MIT - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - * Originally forked from (with the author's permission): - * Fabrice Bellard's javascript vt100 for jslinux: - * http://bellard.org/jslinux/ - * Copyright (c) 2011 Fabrice Bellard - * The original design remains. The terminal itself - * has been extended to include xterm CSI codes, among - * other features. - */ - -/** - * Default styles for xterm.js - */ - -.xterm { - cursor: text; - position: relative; - user-select: none; - -ms-user-select: none; - -webkit-user-select: none; -} - -.xterm.focus, -.xterm:focus { - outline: none; -} - -.xterm .xterm-helpers { - position: absolute; - top: 0; - /** - * The z-index of the helpers must be higher than the canvases in order for - * IMEs to appear on top. - */ - z-index: 5; -} - -.xterm .xterm-helper-textarea { - padding: 0; - border: 0; - margin: 0; - /* Move textarea out of the screen to the far left, so that the cursor is not visible */ - position: absolute; - opacity: 0; - left: -9999em; - top: 0; - width: 0; - height: 0; - z-index: -5; - /** Prevent wrapping so the IME appears against the textarea at the correct position */ - white-space: nowrap; - overflow: hidden; - resize: none; -} - -.xterm .composition-view { - /* TODO: Composition position got messed up somewhere */ - background: #000; - color: #FFF; - display: none; - position: absolute; - white-space: nowrap; - z-index: 1; -} - -.xterm .composition-view.active { - display: block; -} - -.xterm .xterm-viewport { - /* On OS X this is required in order for the scroll bar to appear fully opaque */ - background-color: #000; - overflow-y: scroll; - cursor: default; - position: absolute; - right: 0; - left: 0; - top: 0; - bottom: 0; -} - -.xterm .xterm-screen { - position: relative; -} - -.xterm .xterm-screen canvas { - position: absolute; - left: 0; - top: 0; -} - -.xterm-char-measure-element { - display: inline-block; - visibility: hidden; - position: absolute; - top: 0; - left: -9999em; - line-height: normal; -} - -.xterm.enable-mouse-events { - /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ - cursor: default; -} - -.xterm.xterm-cursor-pointer, -.xterm .xterm-cursor-pointer { - cursor: pointer; -} - -.xterm.column-select.focus { - /* Column selection mode */ - cursor: crosshair; -} - -.xterm .xterm-accessibility:not(.debug), -.xterm .xterm-message { - position: absolute; - left: 0; - top: 0; - bottom: 0; - right: 0; - z-index: 10; - color: transparent; - pointer-events: none; -} - -.xterm .xterm-accessibility-tree:not(.debug) *::selection { - color: transparent; -} - -.xterm .xterm-accessibility-tree { - font-family: monospace; - user-select: text; - white-space: pre; -} - -.xterm .xterm-accessibility-tree > div { - transform-origin: left; - width: fit-content; -} - -.xterm .live-region { - position: absolute; - left: -9999px; - width: 1px; - height: 1px; - overflow: hidden; -} - -.xterm-dim { - /* Dim should not apply to background, so the opacity of the foreground color is applied - * explicitly in the generated class and reset to 1 here */ - opacity: 1 !important; -} - -.xterm-underline-1 { text-decoration: underline; } -.xterm-underline-2 { text-decoration: double underline; } -.xterm-underline-3 { text-decoration: wavy underline; } -.xterm-underline-4 { text-decoration: dotted underline; } -.xterm-underline-5 { text-decoration: dashed underline; } - -.xterm-overline { - text-decoration: overline; -} - -.xterm-overline.xterm-underline-1 { text-decoration: overline underline; } -.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; } -.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; } -.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; } -.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; } - -.xterm-strikethrough { - text-decoration: line-through; -} - -.xterm-screen .xterm-decoration-container .xterm-decoration { - z-index: 6; - position: absolute; -} - -.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { - z-index: 7; -} - -.xterm-decoration-overview-ruler { - z-index: 8; - position: absolute; - top: 0; - right: 0; - pointer-events: none; -} - -.xterm-decoration-top { - z-index: 2; - position: relative; -} - - - -/* Derived from vs/base/browser/ui/scrollbar/media/scrollbar.css */ - -/* xterm.js customization: Override xterm's cursor style */ -.xterm .xterm-scrollable-element > .scrollbar { - cursor: default; -} - -/* Arrows */ -.xterm .xterm-scrollable-element > .scrollbar > .scra { - cursor: pointer; - font-size: 11px !important; -} - -.xterm .xterm-scrollable-element > .visible { - opacity: 1; - - /* Background rule added for IE9 - to allow clicks on dom node */ - background:rgba(0,0,0,0); - - transition: opacity 100ms linear; - /* In front of peek view */ - z-index: 11; -} -.xterm .xterm-scrollable-element > .invisible { - opacity: 0; - pointer-events: none; -} -.xterm .xterm-scrollable-element > .invisible.fade { - transition: opacity 800ms linear; -} - -/* Scrollable Content Inset Shadow */ -.xterm .xterm-scrollable-element > .shadow { - position: absolute; - display: none; -} -.xterm .xterm-scrollable-element > .shadow.top { - display: block; - top: 0; - left: 3px; - height: 3px; - width: 100%; - box-shadow: var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset; -} -.xterm .xterm-scrollable-element > .shadow.left { - display: block; - top: 3px; - left: 0; - height: 100%; - width: 3px; - box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset; -} -.xterm .xterm-scrollable-element > .shadow.top-left-corner { - display: block; - top: 0; - left: 0; - height: 3px; - width: 3px; -} -.xterm .xterm-scrollable-element > .shadow.top.left { - box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset; -} diff --git a/frontend/src/css/modals.css b/frontend/src/css/modals.css deleted file mode 100644 index f816c85..0000000 --- a/frontend/src/css/modals.css +++ /dev/null @@ -1,888 +0,0 @@ -/* ========================================================================== - Alchementary Link — Modals & Drawers - Profile editor drawer, settings modal, known hosts viewer, - confirmation dialogs, host key verification dialog. - ========================================================================== */ - -/* ----------------------------------------------------------------------- - Modal backdrop - ----------------------------------------------------------------------- */ -.modal-backdrop { - position: fixed; - inset: 0; - z-index: var(--z-modal-backdrop); - background: rgba(10, 10, 20, 0.6); - backdrop-filter: blur(2px); - opacity: 0; - visibility: hidden; - transition: - opacity var(--transition-normal), - visibility var(--transition-normal); -} - -.modal-backdrop.is-visible { - opacity: 1; - visibility: visible; -} - -/* ----------------------------------------------------------------------- - Modal (centred panel) - ----------------------------------------------------------------------- */ -.modal { - position: fixed; - z-index: var(--z-modal); - top: 50%; - left: 50%; - transform: translate(-50%, -50%) scale(0.96); - width: 480px; - max-width: calc(100vw - var(--space-8)); - max-height: calc(100vh - var(--space-8)); - display: flex; - flex-direction: column; - background: var(--bg-elevated); - border: 1px solid var(--border-default); - border-radius: var(--radius-xl); - box-shadow: var(--shadow-xl); - opacity: 0; - visibility: hidden; - transition: - opacity var(--transition-normal), - visibility var(--transition-normal), - transform var(--transition-normal); -} - -.modal.is-visible { - opacity: 1; - visibility: visible; - transform: translate(-50%, -50%) scale(1); -} - -/* Size variants */ -.modal-sm { - width: 360px; -} -.modal-lg { - width: 640px; -} -.modal-xl { - width: 800px; -} - -.modal-header { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-4) var(--space-5); - border-bottom: 1px solid var(--border-subtle); - flex-shrink: 0; -} - -.modal-header-icon { - width: 20px; - height: 20px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: var(--text-lg); - color: var(--text-tertiary); -} - -.modal-title { - flex: 1; - font-size: var(--text-md); - font-weight: var(--weight-semibold); - color: var(--text-primary); -} - -.modal-close { - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-md); - font-size: var(--text-md); - color: var(--text-tertiary); - cursor: pointer; - transition: - background var(--transition-fast), - color var(--transition-fast); - flex-shrink: 0; -} -.modal-close:hover { - background: var(--bg-hover); - color: var(--text-primary); -} - -.modal-body { - flex: 1; - overflow-y: auto; - padding: var(--space-5); -} - -.modal-body.sftp-picker-body { - display: flex; - flex-direction: column; - padding: 0; - min-height: 320px; -} - -.modal-body p { - font-size: var(--text-sm); - color: var(--text-secondary); - line-height: var(--leading-relaxed); -} - -.modal-body p + p { - margin-top: var(--space-3); -} - -.modal-footer { - display: flex; - align-items: center; - justify-content: flex-end; - gap: var(--space-2); - padding: var(--space-3) var(--space-5); - border-top: 1px solid var(--border-subtle); - flex-shrink: 0; -} - -.modal-footer-left { - margin-right: auto; -} - -/* ----------------------------------------------------------------------- - Drawer (slides in from the right) - Used for the profile editor. - ----------------------------------------------------------------------- */ -.drawer-backdrop { - position: fixed; - inset: 0; - z-index: var(--z-modal-backdrop); - background: rgba(10, 10, 20, 0.5); - opacity: 0; - visibility: hidden; - transition: - opacity var(--transition-normal), - visibility var(--transition-normal); -} - -.drawer-backdrop.is-visible { - opacity: 1; - visibility: visible; -} - -.drawer { - position: fixed; - z-index: var(--z-modal); - top: 0; - right: 0; - width: 480px; - max-width: 100vw; - height: 100vh; - display: flex; - flex-direction: column; - background: var(--bg-elevated); - border-left: 1px solid var(--border-default); - box-shadow: var(--shadow-xl); - transform: translateX(100%); - transition: transform var(--transition-slow); -} - -.drawer.is-visible { - transform: translateX(0); -} - -/* Drawer width variants */ -.drawer-wide { - width: 560px; -} -.drawer-narrow { - width: 380px; -} - -.drawer-header { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-4) var(--space-5); - border-bottom: 1px solid var(--border-subtle); - flex-shrink: 0; -} - -.drawer-title { - flex: 1; - font-size: var(--text-md); - font-weight: var(--weight-semibold); - color: var(--text-primary); -} - -.drawer-close { - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-md); - font-size: var(--text-md); - color: var(--text-tertiary); - cursor: pointer; - transition: - background var(--transition-fast), - color var(--transition-fast); - flex-shrink: 0; -} -.drawer-close:hover { - background: var(--bg-hover); - color: var(--text-primary); -} - -.drawer-body { - flex: 1; - overflow-y: auto; - padding: var(--space-5); -} - -.drawer-footer { - display: flex; - align-items: center; - justify-content: flex-end; - gap: var(--space-2); - padding: var(--space-3) var(--space-5); - border-top: 1px solid var(--border-subtle); - flex-shrink: 0; -} - -/* Protocol selector tabs inside the drawer */ -.drawer-protocol-tabs { - display: flex; - gap: 0; - padding: 0 var(--space-5); - border-bottom: 1px solid var(--border-subtle); - flex-shrink: 0; -} - -.drawer-proto-tab { - position: relative; - padding: var(--space-2) var(--space-4); - background: none; - border: none; - color: var(--text-secondary); - font-size: var(--text-sm); - font-weight: var(--weight-medium); - cursor: pointer; - letter-spacing: 0.04em; - transition: color var(--transition-fast); -} - -.drawer-proto-tab:hover { - color: var(--text-primary); -} - -.drawer-proto-tab.active { - color: var(--accent); -} - -.drawer-proto-tab.active::after { - content: ""; - position: absolute; - bottom: -1px; - left: var(--space-2); - right: var(--space-2); - height: 2px; - background: var(--accent); - border-radius: 1px; -} - -/* Settings tab bar */ -.settings-tab-bar { - display: flex; - gap: 0; - padding: 0 var(--space-5); - border-bottom: 1px solid var(--border-subtle); - flex-shrink: 0; -} - -.settings-tab { - position: relative; - padding: var(--space-2) var(--space-3); - background: none; - border: none; - color: var(--text-secondary); - font-size: var(--text-sm); - font-weight: var(--weight-medium); - cursor: pointer; - transition: color var(--transition-fast); -} - -.settings-tab:hover { - color: var(--text-primary); -} - -.settings-tab.active { - color: var(--accent); -} - -.settings-tab.active::after { - content: ""; - position: absolute; - bottom: -1px; - left: var(--space-1); - right: var(--space-1); - height: 2px; - background: var(--accent); - border-radius: 1px; -} - -.settings-body { - padding: var(--space-5); -} - -/* Help tab — two-column layout with navigation sidebar */ -.settings-body.help-layout { - display: flex; - gap: 0; - padding: 0; - overflow: hidden; -} - -.help-nav { - width: 180px; - min-width: 160px; - flex-shrink: 0; - display: flex; - flex-direction: column; - padding: var(--space-2) 0; - border-right: 1px solid var(--border-subtle); - overflow-y: auto; - background: var(--bg-surface); -} - -.help-nav-item { - display: block; - width: 100%; - padding: var(--space-2) var(--space-4); - font-size: var(--text-sm); - color: var(--text-secondary); - background: none; - border: none; - text-align: left; - cursor: pointer; - transition: - background var(--transition-fast), - color var(--transition-fast); - border-left: 2px solid transparent; -} - -.help-nav-item:hover { - background: var(--bg-hover); - color: var(--text-primary); -} - -.help-nav-item.active { - color: var(--accent); - background: var(--bg-elevated); - border-left-color: var(--accent); - font-weight: var(--weight-medium); -} - -.help-content { - flex: 1; - padding: var(--space-4) var(--space-5); - overflow-y: auto; - min-width: 0; - font-size: var(--text-sm); - line-height: 1.65; - color: var(--text-secondary); -} - -.help-section-title { - font-size: var(--text-lg); - font-weight: var(--weight-semibold); - color: var(--text-primary); - margin: 0 0 var(--space-3) 0; - padding-bottom: var(--space-2); - border-bottom: 1px solid var(--border-subtle); -} - -.help-content h4 { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--text-primary); - margin: var(--space-4) 0 var(--space-2) 0; -} - -.help-content p { - margin: 0 0 var(--space-2) 0; -} - -.help-content ul, -.help-content ol { - margin: 0 0 var(--space-3) 0; - padding-left: var(--space-5); -} - -.help-content li { - margin-bottom: var(--space-1); -} - -.help-content code { - font-family: "Menlo", "Consolas", "Courier New", monospace; - font-size: 0.9em; - background: var(--bg-base); - padding: 1px 5px; - border-radius: var(--radius-sm); - color: var(--accent); -} - -.help-content kbd { - display: inline-block; - font-family: "Menlo", "Consolas", "Courier New", monospace; - font-size: 0.8em; - padding: 1px 5px; - background: var(--bg-base); - border: 1px solid var(--border-default); - border-radius: var(--radius-sm); - color: var(--text-primary); - box-shadow: 0 1px 0 var(--border-strong, var(--border-default)); - line-height: 1.4; -} - -.help-content em { - color: var(--text-tertiary); - font-style: italic; -} - -.help-content strong { - color: var(--text-primary); - font-weight: var(--weight-medium); -} - -.help-shortcuts-table { - width: 100%; - border-collapse: collapse; - margin: var(--space-2) 0 var(--space-3) 0; -} - -.help-shortcuts-table td { - padding: var(--space-1) var(--space-2); - border-bottom: 1px solid var(--border-subtle); - font-size: var(--text-sm); -} - -.help-shortcuts-table td:first-child { - width: 140px; - white-space: nowrap; - color: var(--text-primary); -} - -/* Command palette */ -.palette-overlay { - position: fixed; - inset: 0; - z-index: var(--z-modal-backdrop); - background: rgba(10, 10, 20, 0.5); - display: flex; - align-items: flex-start; - justify-content: center; - padding-top: 20vh; -} - -.palette { - width: 520px; - max-width: calc(100vw - var(--space-8)); - max-height: 400px; - display: flex; - flex-direction: column; - background: var(--bg-elevated); - border: 1px solid var(--border-default); - border-radius: var(--radius-xl); - box-shadow: var(--shadow-xl); - overflow: hidden; -} - -.palette-input { - width: 100%; - padding: var(--space-3) var(--space-4); - background: transparent; - border: none; - border-bottom: 1px solid var(--border-subtle); - color: var(--text-primary); - font-size: var(--text-base); - outline: none; -} - -.palette-list { - flex: 1; - overflow-y: auto; - padding: var(--space-1) 0; -} - -.palette-item { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-2) var(--space-4); - cursor: pointer; - transition: background var(--transition-fast); -} - -.palette-item:hover, -.palette-item-active { - background: var(--bg-hover); -} - -.palette-item-label { - flex: 1; - font-size: var(--text-sm); - color: var(--text-primary); -} - -.palette-item-detail { - font-size: var(--text-xs); - color: var(--text-tertiary); -} - -.palette-item-shortcut { - font-size: var(--text-xs); - color: var(--text-tertiary); - background: var(--bg-surface); - padding: var(--space-half) var(--space-1); - border-radius: var(--radius-sm); - font-family: var(--font-mono); -} - -.palette-section { - padding: var(--space-1) var(--space-4); - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - color: var(--text-tertiary); - text-transform: uppercase; - letter-spacing: 0.06em; -} - -.palette-empty { - padding: var(--space-6) var(--space-4); - text-align: center; - font-size: var(--text-sm); - color: var(--text-tertiary); -} - -/* ----------------------------------------------------------------------- - Confirmation dialog - ----------------------------------------------------------------------- */ -.confirm-dialog { - width: 400px; -} - -.confirm-dialog .modal-body { - text-align: center; - padding: var(--space-6) var(--space-5); -} - -.confirm-dialog-icon { - width: 48px; - height: 48px; - margin: 0 auto var(--space-4); - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - font-size: var(--text-2xl); -} - -.confirm-dialog-icon.is-warning { - background: rgba(255, 167, 38, 0.12); - color: var(--warning); -} - -.confirm-dialog-icon.is-danger { - background: rgba(239, 83, 80, 0.12); - color: var(--error); -} - -.confirm-dialog-icon.is-info { - background: rgba(66, 165, 245, 0.12); - color: var(--info); -} - -.confirm-dialog-message { - font-size: var(--text-sm); - color: var(--text-secondary); - line-height: var(--leading-relaxed); - max-width: 300px; - margin: 0 auto; -} - -/* ----------------------------------------------------------------------- - Host key verification dialog - ----------------------------------------------------------------------- */ -.hostkey-dialog { - width: 520px; -} - -.hostkey-fingerprint { - font-family: var(--font-mono); - font-size: var(--text-sm); - color: var(--text-primary); - background: var(--bg-input); - padding: var(--space-3) var(--space-4); - border-radius: var(--radius-md); - border: 1px solid var(--border-subtle); - word-break: break-all; - line-height: var(--leading-relaxed); - margin: var(--space-3) 0; -} - -.hostkey-fingerprint-label { - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - color: var(--text-tertiary); - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: var(--space-1); -} - -.hostkey-mismatch-warning { - display: flex; - align-items: flex-start; - gap: var(--space-3); - padding: var(--space-3) var(--space-4); - background: rgba(239, 83, 80, 0.08); - border: 1px solid rgba(239, 83, 80, 0.2); - border-radius: var(--radius-md); - margin: var(--space-3) 0; -} - -.hostkey-mismatch-warning .warning-icon { - font-size: var(--text-lg); - color: var(--error); - flex-shrink: 0; - line-height: 1; - margin-top: 2px; -} - -.hostkey-mismatch-warning p { - font-size: var(--text-sm); - color: var(--text-secondary); - line-height: var(--leading-relaxed); -} - -/* ----------------------------------------------------------------------- - Settings modal - ----------------------------------------------------------------------- */ -.settings-modal { - width: 640px; - max-height: 80vh; -} - -.settings-nav { - display: flex; - gap: 1px; - background: var(--border-subtle); - border-radius: var(--radius-md); - overflow: hidden; - margin-bottom: var(--space-4); -} - -.settings-nav-item { - flex: 1; - padding: var(--space-2) var(--space-3); - font-size: var(--text-sm); - font-weight: var(--weight-medium); - color: var(--text-tertiary); - text-align: center; - background: var(--bg-surface); - border: none; - cursor: pointer; - transition: - background var(--transition-fast), - color var(--transition-fast); -} - -.settings-nav-item:hover { - color: var(--text-secondary); -} - -.settings-nav-item.active { - background: var(--bg-active); - color: var(--text-primary); -} - -.settings-section { - margin-bottom: var(--space-6); -} - -.settings-section-title { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--text-primary); - margin-bottom: var(--space-3); - padding-bottom: var(--space-2); - border-bottom: 1px solid var(--border-subtle); -} - -/* ----------------------------------------------------------------------- - Known hosts viewer - ----------------------------------------------------------------------- */ -.known-hosts-modal { - width: 700px; -} - -.known-hosts-list { - display: flex; - flex-direction: column; - gap: 1px; -} - -.known-hosts-item { - display: grid; - grid-template-columns: 1fr 140px 60px; - gap: var(--space-3); - align-items: center; - padding: var(--space-2) var(--space-3); - font-size: var(--text-sm); - background: var(--bg-surface); - border-radius: var(--radius-sm); - transition: background var(--transition-fast); -} - -.known-hosts-item:hover { - background: var(--bg-hover); -} - -.known-hosts-hostname { - font-family: var(--font-mono); - font-weight: var(--weight-medium); - color: var(--text-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.known-hosts-type { - font-size: var(--text-xs); - color: var(--text-tertiary); -} - -.known-hosts-actions { - display: flex; - justify-content: flex-end; -} - -.known-hosts-empty { - padding: var(--space-8); - text-align: center; - font-size: var(--text-sm); - color: var(--text-tertiary); -} - -/* ----------------------------------------------------------------------- - Quick connect popover - ----------------------------------------------------------------------- */ -.quick-connect { - width: 360px; -} - -.quick-connect .modal-body { - padding: var(--space-4); -} - -.quick-connect-input { - width: 100%; - height: 36px; - padding: 0 var(--space-3); - font-family: var(--font-mono); - font-size: var(--text-sm); - color: var(--text-primary); - background: var(--bg-input); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); -} - -.quick-connect-input::placeholder { - color: var(--text-disabled); - font-family: var(--font-sans); -} - -.quick-connect-input:focus { - border-color: var(--accent); - box-shadow: 0 0 0 2px var(--accent-muted); - outline: none; -} - -.quick-connect-help { - font-size: var(--text-xs); - color: var(--text-tertiary); - margin-top: var(--space-2); - line-height: var(--leading-relaxed); -} - -.quick-connect-help code { - font-size: var(--text-xs); - color: var(--text-secondary); - background: var(--bg-overlay); - padding: 1px 4px; - border-radius: 2px; -} - -/* ----------------------------------------------------------------------- - Password strength meter - ----------------------------------------------------------------------- */ -.password-strength-meter { - display: flex; - align-items: center; - gap: var(--space-2); - margin-bottom: var(--space-2); -} - -.strength-bar { - flex: 1; - height: 4px; - background: var(--surface-2); - border-radius: 2px; - overflow: hidden; -} - -.strength-fill { - height: 100%; - width: 0; - border-radius: 2px; - transition: - width 0.3s ease, - background-color 0.3s ease; -} - -.strength-fill.strength-weak { - background: var(--error); -} -.strength-fill.strength-fair { - background: var(--warning); -} -.strength-fill.strength-good { - background: #66bb6a; -} -.strength-fill.strength-strong { - background: var(--success); -} - -.strength-label { - font-size: var(--text-xs); - font-weight: 500; - min-width: 48px; - text-align: right; -} - -.strength-label.strength-weak { - color: var(--error); -} -.strength-label.strength-fair { - color: var(--warning); -} -.strength-label.strength-good { - color: #66bb6a; -} -.strength-label.strength-strong { - color: var(--success); -} diff --git a/frontend/src/css/recordings.css b/frontend/src/css/recordings.css deleted file mode 100644 index 0bff67c..0000000 --- a/frontend/src/css/recordings.css +++ /dev/null @@ -1,201 +0,0 @@ -/* ── Session Recording & Playback ────────────────────────────────────── */ - -/* Recording indicator dot on tabs */ -.recording-dot { - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--error, #ef4444); - margin-right: 6px; - flex-shrink: 0; - animation: recording-pulse 1.2s ease-in-out infinite; -} - -@keyframes recording-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.3; } -} - -/* Recordings list in browser modal */ -.recording-storage { - display: flex; - flex-direction: column; - gap: 8px; - padding: 0 0 12px; - margin-bottom: 12px; - border-bottom: 1px solid var(--border, #3f3f46); -} - -.recording-storage-summary { - display: flex; - justify-content: space-between; - gap: 12px; - font-size: 12px; - color: var(--text-secondary, #a1a1aa); -} - -.recording-cleanup-controls { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; - gap: 8px; - align-items: center; -} - -.recording-cleanup-status { - min-height: 16px; -} - -.recordings-list { - display: flex; - flex-direction: column; - gap: 4px; -} - -.recording-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; - background: var(--surface-2, #2a2a2e); - border-radius: 6px; - transition: background 0.15s ease; -} - -.recording-item:hover { - background: var(--surface-3, #333338); -} - -.recording-info { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.recording-title { - font-size: 13px; - font-weight: 500; - color: var(--text-primary, #e4e4e7); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.recording-meta { - font-size: 11px; - color: var(--text-tertiary, #71717a); -} - -.recording-actions { - display: flex; - gap: 4px; - flex-shrink: 0; - margin-left: 12px; -} - -@media (max-width: 640px) { - .recording-cleanup-controls { - grid-template-columns: 1fr; - } -} - -/* Playback modal — larger to fit a terminal */ -.modal-playback { - width: 90vw; - max-width: 900px; - max-height: 90vh; -} - -.playback-body { - display: flex; - flex-direction: column; - gap: 8px; - padding: 0 !important; -} - -.playback-terminal { - flex: 1; - min-height: 300px; - background: #1a1a1e; - border-radius: 4px; - padding: 4px; - margin: 0 12px; -} - -.playback-controls { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px 12px; - background: var(--surface-1, #1e1e22); - border-top: 1px solid var(--border, #3f3f46); -} - -.playback-progress { - flex: 1; - height: 4px; - cursor: pointer; - appearance: none; - -webkit-appearance: none; - background: var(--surface-3, #333338); - border-radius: 2px; - outline: none; -} - -.playback-progress::-webkit-slider-thumb { - -webkit-appearance: none; - width: 12px; - height: 12px; - border-radius: 50%; - background: var(--accent, #3b82f6); - cursor: pointer; - border: none; -} - -.playback-time { - font-size: 11px; - color: var(--text-tertiary, #71717a); - white-space: nowrap; - min-width: 80px; - text-align: right; -} - -.playback-speed { - font-size: 11px; - background: var(--surface-2, #2a2a2e); - color: var(--text-primary, #e4e4e7); - border: 1px solid var(--border, #3f3f46); - border-radius: 4px; -} - -/* Export/import profile list */ -.export-profile-list { - display: flex; - flex-direction: column; - gap: 4px; - padding: 8px; - background: var(--surface-2, #2a2a2e); - border-radius: 6px; - border: 1px solid var(--border, #3f3f46); -} - -.export-profile-item { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 8px; - font-size: 13px; - color: var(--text-primary, #e4e4e7); - cursor: pointer; - border-radius: 4px; - transition: background 0.1s ease; -} - -.export-profile-item:hover { - background: var(--surface-3, #333338); -} - -.export-profile-item input[type="checkbox"] { - accent-color: var(--accent, #3b82f6); -} diff --git a/frontend/src/css/sftp.css b/frontend/src/css/sftp.css deleted file mode 100644 index 62cdc8c..0000000 --- a/frontend/src/css/sftp.css +++ /dev/null @@ -1,514 +0,0 @@ -/* ========================================================================== - Alchementary Link — SFTP File Browser - Right-side panel for browsing remote files alongside the terminal. - ========================================================================== */ - -/* ----------------------------------------------------------------------- - Panel wrapper — overlays the terminal from the right side - ----------------------------------------------------------------------- */ -.sftp-panel { - position: absolute; - top: 0; - right: 0; - bottom: 0; - width: 300px; - min-width: 220px; - max-width: 50vw; - display: flex; - flex-direction: column; - background: var(--bg-surface); - border-left: 1px solid var(--border-subtle); - overflow: hidden; - z-index: var(--z-dropdown, 50); - box-shadow: -4px 0 16px rgba(0, 0, 0, 0.25); -} - -/* Resize handle — positioned at the left edge of the SFTP panel */ -.sftp-resize-handle { - position: absolute; - top: 0; - left: -3px; - bottom: 0; - width: 6px; - cursor: col-resize; - background: transparent; - z-index: var(--z-dropdown, 50); - transition: background var(--transition-fast); -} - -.sftp-resize-handle:hover, -.sftp-resize-handle.is-dragging { - background: var(--accent); -} - -/* ----------------------------------------------------------------------- - Toolbar (top bar with path breadcrumb + action buttons) - ----------------------------------------------------------------------- */ -.sftp-toolbar { - display: flex; - align-items: center; - gap: var(--space-1); - padding: var(--space-1) var(--space-2); - border-bottom: 1px solid var(--border-subtle); - background: var(--bg-elevated); - flex-shrink: 0; - min-height: 34px; -} - -.sftp-toolbar-actions { - display: flex; - align-items: center; - gap: 2px; - flex-shrink: 0; -} - -.sftp-toolbar-btn { - width: 26px; - height: 26px; - display: flex; - align-items: center; - justify-content: center; - border: none; - background: none; - color: var(--text-tertiary); - border-radius: var(--radius-sm); - cursor: pointer; - font-size: var(--text-sm); - transition: - background var(--transition-fast), - color var(--transition-fast); - flex-shrink: 0; - padding: 0; -} - -.sftp-toolbar-btn:hover { - background: var(--bg-hover); - color: var(--text-primary); -} - -.sftp-toolbar-btn.is-accent:hover { - color: var(--accent); -} - -.sftp-toolbar-btn.is-close:hover { - color: var(--error); -} - -/* ----------------------------------------------------------------------- - Breadcrumb path - ----------------------------------------------------------------------- */ -.sftp-breadcrumb { - flex: 1; - display: flex; - align-items: center; - min-width: 0; - overflow: hidden; - font-size: var(--text-xs); - color: var(--text-tertiary); - gap: 1px; -} - -.sftp-breadcrumb-segment { - background: none; - border: none; - color: var(--text-secondary); - font-size: var(--text-xs); - cursor: pointer; - padding: 2px 4px; - border-radius: var(--radius-sm); - white-space: nowrap; - transition: - background var(--transition-fast), - color var(--transition-fast); - max-width: 100px; - overflow: hidden; - text-overflow: ellipsis; -} - -.sftp-breadcrumb-segment:hover { - background: var(--bg-hover); - color: var(--text-primary); -} - -.sftp-breadcrumb-segment:last-child { - color: var(--text-primary); - font-weight: var(--weight-medium); -} - -.sftp-breadcrumb-sep { - color: var(--text-disabled); - font-size: 10px; - flex-shrink: 0; - user-select: none; -} - -/* ----------------------------------------------------------------------- - File list - ----------------------------------------------------------------------- */ -.sftp-file-list { - flex: 1; - overflow-y: auto; - overflow-x: hidden; -} - -/* Scrollbar styling */ -.sftp-file-list::-webkit-scrollbar { - width: 6px; -} -.sftp-file-list::-webkit-scrollbar-track { - background: transparent; -} -.sftp-file-list::-webkit-scrollbar-thumb { - background: var(--border-strong, var(--bg-active)); - border-radius: 3px; -} -.sftp-file-list::-webkit-scrollbar-thumb:hover { - background: var(--text-tertiary); -} - -/* ----------------------------------------------------------------------- - File row - ----------------------------------------------------------------------- */ -.sftp-file-row { - display: grid; - grid-template-columns: 20px 1fr auto; - align-items: center; - gap: var(--space-1); - padding: var(--space-1) var(--space-2); - cursor: pointer; - transition: background var(--transition-fast); - user-select: none; - min-height: 30px; -} - -.sftp-file-row:hover { - background: var(--bg-hover); -} - -.sftp-file-row.is-selected { - background: var(--accent-muted); -} - -.sftp-file-row.is-selected:hover { - background: var(--accent-strong); -} - -.sftp-file-row.is-readonly .sftp-file-meta { - color: var(--warning); - font-weight: var(--weight-semibold); -} - -.sftp-file-row.is-unreadable { - opacity: 0.5; - cursor: not-allowed; -} - -.sftp-file-row.is-unreadable .sftp-file-name { - text-decoration: line-through; -} - -.sftp-file-icon { - font-size: 13px; - text-align: center; - line-height: 1; - flex-shrink: 0; - color: var(--text-tertiary); -} - -.sftp-file-icon.is-dir { - color: var(--accent); -} - -.sftp-file-name { - font-size: var(--text-sm); - color: var(--text-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; -} - -.sftp-file-meta { - font-size: var(--text-xs); - color: var(--text-tertiary); - white-space: nowrap; - text-align: right; -} - -/* ----------------------------------------------------------------------- - Parent directory row (..) - ----------------------------------------------------------------------- */ -.sftp-file-row.is-parent .sftp-file-name { - color: var(--text-secondary); - font-style: italic; -} - -/* ----------------------------------------------------------------------- - Context actions bar (shown below file list when a file is selected) - ----------------------------------------------------------------------- */ -.sftp-context-bar { - display: flex; - align-items: center; - gap: var(--space-1); - padding: var(--space-1) var(--space-2); - border-top: 1px solid var(--border-subtle); - background: var(--bg-elevated); - flex-shrink: 0; - min-height: 32px; -} - -.sftp-context-bar .sftp-ctx-label { - flex: 1; - font-size: var(--text-xs); - color: var(--text-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; -} - -.sftp-context-bar .sftp-ctx-btn { - height: 24px; - padding: 0 var(--space-2); - font-size: var(--text-xs); - font-weight: var(--weight-medium); - border: none; - border-radius: var(--radius-sm); - cursor: pointer; - background: transparent; - color: var(--text-secondary); - transition: - background var(--transition-fast), - color var(--transition-fast); - white-space: nowrap; -} - -.sftp-context-bar .sftp-ctx-btn:hover { - background: var(--bg-hover); - color: var(--text-primary); -} - -.sftp-context-bar .sftp-ctx-btn.is-danger:hover { - background: rgba(239, 83, 80, 0.12); - color: var(--error); -} - -.sftp-context-bar .sftp-ctx-btn.is-accent:hover { - color: var(--accent); -} - -/* ----------------------------------------------------------------------- - States: loading, empty, error - ----------------------------------------------------------------------- */ -.sftp-state { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: var(--space-6) var(--space-4); - text-align: center; - gap: var(--space-2); -} - -.sftp-state-icon { - font-size: 28px; - color: var(--text-disabled); -} - -.sftp-state-msg { - font-size: var(--text-sm); - color: var(--text-tertiary); - line-height: var(--leading-relaxed); - max-width: 200px; -} - -.sftp-state-retry { - margin-top: var(--space-1); -} - -/* Loading spinner (pure CSS) */ -.sftp-spinner { - width: 20px; - height: 20px; - border: 2px solid var(--border-subtle); - border-top-color: var(--accent); - border-radius: 50%; - animation: sftp-spin 0.6s linear infinite; -} - -@keyframes sftp-spin { - to { - transform: rotate(360deg); - } -} - -/* ----------------------------------------------------------------------- - Inline input (for rename / mkdir) - ----------------------------------------------------------------------- */ -.sftp-inline-input { - width: 100%; - height: 26px; - padding: 0 var(--space-2); - font-size: var(--text-sm); - font-family: inherit; - color: var(--text-primary); - background: var(--bg-input); - border: 1px solid var(--accent); - border-radius: var(--radius-sm); - outline: none; - box-shadow: 0 0 0 2px var(--accent-muted); -} - -/* Picker-specific input in the toolbar */ -.sftp-picker-filter { - width: 160px; - height: 26px; - padding: 0 var(--space-2); - font-size: var(--text-xs); - border: 1px solid var(--border-default); - border-radius: var(--radius-sm); - background: var(--bg-input); - color: var(--text-primary); -} - -.sftp-picker-filter:focus { - border-color: var(--accent); - box-shadow: 0 0 0 2px var(--accent-muted); - outline: none; -} - -.sftp-picker-status { - font-size: var(--text-xs); - color: var(--text-tertiary); - margin-right: var(--space-2); -} - -.sftp-picker-badge { - display: inline-flex; - align-items: center; - justify-content: center; - height: 18px; - padding: 0 var(--space-2); - border-radius: var(--radius-sm); - font-size: 10px; - font-weight: var(--weight-semibold); - text-transform: uppercase; - letter-spacing: 0.04em; - border: 1px solid var(--border-default); - background: var(--bg-surface); - color: var(--text-tertiary); -} - -.sftp-picker-badge.is-readonly { - border-color: rgba(255, 167, 38, 0.35); - color: var(--warning); - background: rgba(255, 167, 38, 0.12); -} - -.sftp-picker-badge.is-noread { - border-color: rgba(239, 83, 80, 0.35); - color: var(--error); - background: rgba(239, 83, 80, 0.12); -} - -.sftp-picker-legend { - display: inline-flex; - align-items: center; - gap: var(--space-1); - font-size: 10px; - color: var(--text-tertiary); -} - -/* ----------------------------------------------------------------------- - Transfer progress queue - ----------------------------------------------------------------------- */ -.sftp-transfer-queue { - flex-shrink: 0; - border-bottom: 1px solid var(--border-subtle); - background: var(--bg-elevated); - max-height: 140px; - overflow-y: auto; -} - -.sftp-transfer-header { - font-size: 10px; - font-weight: var(--weight-semibold); - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-tertiary); - padding: var(--space-1) var(--space-2) 0; -} - -.sftp-transfer-row { - display: flex; - align-items: center; - gap: var(--space-1); - padding: 4px var(--space-2); -} - -.sftp-transfer-icon { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--accent); - flex-shrink: 0; - width: 16px; - text-align: center; -} - -.sftp-transfer-info { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} - -.sftp-transfer-name { - font-size: var(--text-xs); - color: var(--text-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.sftp-transfer-progress { - height: 3px; - background: var(--border-subtle); - border-radius: 2px; - overflow: hidden; -} - -.sftp-transfer-bar { - height: 100%; - background: var(--accent); - border-radius: 2px; - transition: width 0.15s ease-out; -} - -.sftp-transfer-meta { - font-size: 10px; - color: var(--text-tertiary); -} - -/* ----------------------------------------------------------------------- - Bookmark list - ----------------------------------------------------------------------- */ -.sftp-bookmark-list { - max-height: 250px; - overflow-y: auto; -} - -.sftp-bookmark-row { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-sm); - cursor: pointer; - transition: background var(--transition-fast); -} - -.sftp-bookmark-row:hover { - background: var(--bg-hover); -} diff --git a/frontend/src/css/sidebar.css b/frontend/src/css/sidebar.css deleted file mode 100644 index 58315b0..0000000 --- a/frontend/src/css/sidebar.css +++ /dev/null @@ -1,566 +0,0 @@ -/* ========================================================================== - Alchementary Link — Sidebar - Connection manager: logo, search, sections, profile list, footer. - ========================================================================== */ - -/* ----------------------------------------------------------------------- - Sidebar header (logo / title / drag region) - ----------------------------------------------------------------------- */ -.sidebar-header { - display: flex; - align-items: center; - padding: var(--space-3) var(--space-4); - height: var(--tab-bar-height); - min-height: var(--tab-bar-height); - border-bottom: 1px solid var(--border-subtle); - flex-shrink: 0; -} - -.sidebar-logo { - display: flex; - align-items: center; - gap: var(--space-2); - width: 100%; - min-width: 0; -} - -.sidebar-logo svg { - width: 18px; - height: 18px; - color: var(--accent); - flex-shrink: 0; -} - -.sidebar-title { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--text-primary); - letter-spacing: 0.02em; -} - -.sidebar-product-switch { - display: inline-flex; - align-items: center; - gap: var(--space-2); - flex: 1; - min-width: 0; - -webkit-user-select: none; - user-select: none; -} - -.sidebar-mode-toggle { - appearance: none; - border: 0; - background: transparent; - padding: 0; - height: auto; - color: var(--text-tertiary); - cursor: pointer; - white-space: nowrap; - -webkit-user-select: none; - user-select: none; -} - -#mode-leviathan { - margin-left: auto; - text-align: right; -} - -#mode-leviathan[hidden] { - display: none; -} - -.sidebar-mode-toggle.is-active { - color: var(--text-primary); -} - -.sidebar-brand-gold { - color: var(--sidebar-group-title); -} - -.sidebar-mode-toggle:hover { - color: var(--text-secondary); -} - -.sidebar-mode-toggle.is-active:hover { - color: var(--text-primary); -} - -.mode-leviathan .sidebar-search, -.mode-leviathan .sidebar-footer { - display: none; -} - -/* ----------------------------------------------------------------------- - Sidebar search - ----------------------------------------------------------------------- */ -.sidebar-search { - padding: var(--space-2) var(--space-3); - flex-shrink: 0; -} - -/* ----------------------------------------------------------------------- - Sidebar navigation (scrollable area) - ----------------------------------------------------------------------- */ -.sidebar-nav { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - padding-bottom: var(--space-2); -} - -/* ----------------------------------------------------------------------- - Bulk actions bar (multi-select) - ----------------------------------------------------------------------- */ -.sidebar-bulk-actions { - position: sticky; - top: 0; - z-index: 2; - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-2); - padding: var(--space-2) var(--space-3); - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-subtle); -} - -.sidebar-bulk-count { - font-size: var(--text-xs); - color: var(--text-secondary); - font-weight: var(--weight-semibold); -} - -.sidebar-bulk-buttons { - display: flex; - flex-wrap: wrap; - gap: var(--space-1); -} - -/* ----------------------------------------------------------------------- - Sections (Favourites, Recent, All Connections) - ----------------------------------------------------------------------- */ -.sidebar-section { - /* Each section can be collapsed */ -} - -.sidebar-section + .sidebar-section { - border-top: 1px solid var(--border-subtle); -} - -.sidebar-section-header { - display: flex; - align-items: center; - gap: var(--space-2); - width: 100%; - padding: var(--space-2) var(--space-3); - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - color: var(--text-tertiary); - text-transform: uppercase; - letter-spacing: 0.06em; - cursor: pointer; - transition: - color var(--transition-fast), - background var(--transition-fast); - -webkit-user-select: none; - user-select: none; - text-align: left; - background: none; - border: none; -} - -.sidebar-section-header.sidebar-section-header-group .section-title { - color: var(--sidebar-group-title); -} - -.sidebar-section-header:hover { - color: var(--text-secondary); - background: var(--bg-hover); -} - -.sidebar-section-header.sidebar-section-header-group:hover .section-title { - color: var(--sidebar-group-title); -} - -.sidebar-section-header .section-icon { - width: 14px; - text-align: center; - flex-shrink: 0; - font-size: var(--text-sm); -} - -.sidebar-section-header .section-chevron { - width: 12px; - height: 12px; - margin-right: var(--space-1); - flex-shrink: 0; - transition: transform var(--transition-normal); - color: var(--text-disabled); -} - -.sidebar-section.is-collapsed .section-chevron { - transform: rotate(-90deg); -} - -.section-count { - margin-left: auto; - font-size: 10px; - font-weight: var(--weight-normal); - color: var(--text-disabled); - background: var(--bg-overlay); - padding: 0 var(--space-1); - border-radius: var(--radius-sm); - min-width: 18px; - text-align: center; - line-height: 16px; -} - -/* ----------------------------------------------------------------------- - Sidebar list (container for items) - ----------------------------------------------------------------------- */ -.sidebar-list { - padding: 0 var(--space-2); -} - -.sidebar-section.is-collapsed .sidebar-list { - display: none; -} - -/* ----------------------------------------------------------------------- - Sidebar item (individual connection profile) - ----------------------------------------------------------------------- */ -.sidebar-item { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-2) var(--space-2); - margin-bottom: 0.5px; - border-radius: var(--radius-md); - cursor: pointer; - transition: - background var(--transition-fast), - box-shadow var(--transition-fast); - user-select: none; - position: relative; -} - -.sidebar-item:hover { - background: var(--bg-hover); -} - -.sidebar-item:active { - background: var(--bg-active); -} - -.sidebar-item-selected { - background: var(--accent-muted); - box-shadow: inset 0 0 0 1px var(--accent); -} - -.sidebar-item-selected .sidebar-item-name { - color: var(--text-primary); -} - -/* Active / selected state */ -.sidebar-item.active { - background: var(--accent-muted); -} -.sidebar-item.active::before { - content: ""; - position: absolute; - left: 0; - top: 4px; - bottom: 4px; - width: 2px; - border-radius: var(--radius-full); - background: var(--accent); -} - -/* Protocol indicator icon */ -.sidebar-item .protocol-indicator { - width: 14px; - height: 14px; - border-radius: var(--radius-sm); - flex-shrink: 0; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 8px; - font-weight: var(--weight-semibold); - line-height: 1; - background: var(--bg-overlay); - color: var(--text-tertiary); -} - -.sidebar-item .protocol-indicator::before { - content: "?"; -} - -.sidebar-item .protocol-indicator.proto-ssh::before { - content: "⌨"; -} -.sidebar-item .protocol-indicator.proto-telnet::before { - content: "⇄"; -} -.sidebar-item .protocol-indicator.proto-raw::before { - content: "≋"; -} -.sidebar-item .protocol-indicator.proto-github::before { - content: "GH"; -} -.sidebar-item .protocol-indicator.proto-vnc::before { - content: "⊞"; -} -.sidebar-item .protocol-indicator.proto-irc::before { - content: "#"; -} -.sidebar-item .protocol-indicator.proto-local::before { - content: "›_"; -} - -.sidebar-item .protocol-indicator.proto-ssh { - background: rgba(78, 205, 196, 0.18); - color: var(--protocol-ssh); -} -.sidebar-item .protocol-indicator.proto-telnet { - background: rgba(255, 167, 38, 0.18); - color: var(--protocol-telnet); -} -.sidebar-item .protocol-indicator.proto-raw { - background: rgba(171, 71, 188, 0.18); - color: var(--protocol-raw); -} -.sidebar-item .protocol-indicator.proto-github { - background: rgba(124, 138, 255, 0.18); - color: var(--protocol-github); -} -.sidebar-item .protocol-indicator.proto-vnc { - background: rgba(232, 93, 74, 0.18); - color: var(--protocol-vnc); -} -.sidebar-item .protocol-indicator.proto-irc { - background: rgba(216, 178, 76, 0.18); - color: var(--protocol-irc); -} -.sidebar-item .protocol-indicator.proto-local { - background: rgba(139, 195, 74, 0.18); - color: var(--protocol-local); -} - -/* Local machine item separator */ -.sidebar-item-local { - border-bottom: 1px solid var(--border-subtle); - margin: var(--space-2) var(--space-2) var(--space-2); - padding-bottom: var(--space-2); -} - -/* Text block (name + host) */ -.sidebar-item-text { - flex: 1; - min-width: 0; -} - -.sidebar-item-name { - font-size: var(--text-sm); - font-weight: var(--weight-medium); - color: var(--text-primary); - line-height: var(--leading-tight); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.sidebar-item-host { - font-size: var(--text-xs); - color: var(--text-tertiary); - font-family: var(--font-mono); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - line-height: var(--leading-tight); - margin-top: 1px; -} - -/* Edit button (appears on hover, beside name) */ -.sidebar-item-edit { - flex-shrink: 0; - width: 22px; - height: 22px; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - color: var(--text-disabled); - background: none; - border: none; - border-radius: var(--radius-sm); - cursor: pointer; - opacity: 0; - transition: - opacity var(--transition-fast), - color var(--transition-fast), - background var(--transition-fast); -} - -.sidebar-item:hover .sidebar-item-edit { - opacity: 1; -} - -.sidebar-item-edit:hover { - color: var(--accent); - background: var(--bg-hover); -} - -/* Favourite star */ -.sidebar-item-star { - flex-shrink: 0; - width: 16px; - height: 16px; - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - color: var(--text-disabled); - border-radius: var(--radius-sm); - opacity: 0; - transition: - opacity var(--transition-fast), - color var(--transition-fast); -} - -.sidebar-item:hover .sidebar-item-star { - opacity: 1; -} - -.sidebar-item-star.is-favourite { - opacity: 1; - color: var(--warning); -} - -.sidebar-item-star:hover { - color: var(--warning); -} - -/* Inline status dot (right side, tiny) */ -.sidebar-item-status { - width: 6px; - height: 6px; - border-radius: 50%; - flex-shrink: 0; - margin-right: var(--space-1); -} -.sidebar-item-status.is-connected { - background: var(--success); -} -.sidebar-item-status.is-connecting { - background: var(--warning); - animation: pulse-dot 1.2s ease infinite; -} -.sidebar-item-status.is-error { - background: var(--error); -} - -/* ----------------------------------------------------------------------- - Sidebar empty list placeholder - ----------------------------------------------------------------------- */ -.sidebar-empty { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--space-2); - margin: var(--space-6) var(--space-3) 0; - padding: var(--space-5) var(--space-3); - color: var(--text-tertiary); - text-align: center; -} - -.sidebar-empty-icon { - display: grid; - place-items: center; - width: 34px; - height: 34px; - border: 1px solid var(--border-default); - border-radius: var(--radius-lg); - background: var(--bg-elevated); - color: var(--accent); - font-size: var(--text-xl); - font-weight: var(--weight-semibold); - line-height: 1; -} - -.sidebar-empty-title { - color: var(--text-secondary); - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - line-height: var(--leading-tight); -} - -.sidebar-empty-text { - max-width: 190px; - margin: 0; - color: var(--text-tertiary); - font-size: var(--text-xs); - line-height: var(--leading-normal); -} - -.sidebar-empty-actions { - display: flex; - justify-content: center; - margin-top: var(--space-1); -} - -.sidebar-list-empty { - padding: var(--space-3) var(--space-2); - font-size: var(--text-xs); - color: var(--text-disabled); - text-align: center; - font-style: italic; -} - -/* ----------------------------------------------------------------------- - Sidebar footer (actions) - ----------------------------------------------------------------------- */ -.sidebar-footer { - display: flex; - align-items: center; - gap: var(--space-1); - padding: var(--space-2) var(--space-3); - border-top: 1px solid var(--border-subtle); - flex-shrink: 0; -} - -.sidebar-footer .btn:first-child { - flex: 1; -} - -/* ----------------------------------------------------------------------- - Sidebar collapsed state - ----------------------------------------------------------------------- */ -.sidebar-collapsed .sidebar-header { - justify-content: center; - padding: var(--space-3) var(--space-2); -} -.sidebar-collapsed .sidebar-title, -.sidebar-collapsed .sidebar-search, -.sidebar-collapsed .sidebar-section-header span:not(.section-icon), -.sidebar-collapsed .sidebar-item-text, -.sidebar-collapsed .sidebar-item-star, -.sidebar-collapsed .section-count, -.sidebar-collapsed .sidebar-footer .btn:first-child span { - display: none; -} - -.sidebar-collapsed .sidebar-item { - justify-content: center; - padding: var(--space-2); -} - -.sidebar-collapsed .sidebar-section-header { - justify-content: center; - padding: var(--space-2); -} - -.sidebar-collapsed .sidebar-footer { - flex-direction: column; -} diff --git a/frontend/src/css/splitpane.css b/frontend/src/css/splitpane.css deleted file mode 100644 index 8386f29..0000000 --- a/frontend/src/css/splitpane.css +++ /dev/null @@ -1,223 +0,0 @@ -/* ========================================================================== - Alchementary Link — Split Pane Layout - Draggable split panes within session panels for multi-terminal views. - ========================================================================== */ - -/* ----------------------------------------------------------------------- - Split container — replaces direct terminal-container when split - ----------------------------------------------------------------------- */ -.split-container { - display: flex; - width: 100%; - height: 100%; - overflow: hidden; -} - -.split-container.split-horizontal { - flex-direction: column; -} - -.split-container.split-vertical { - flex-direction: row; -} - -/* ----------------------------------------------------------------------- - Individual pane inside a split - ----------------------------------------------------------------------- */ -.split-pane { - position: relative; - overflow: hidden; - min-width: 100px; - min-height: 100px; - display: flex; - flex-direction: column; -} - -/* Active pane indicator (subtle border glow) */ -.split-pane.is-focused { - box-shadow: inset 0 0 0 1px var(--accent-muted); -} - -/* ----------------------------------------------------------------------- - Pane close button — only visible on hover - ----------------------------------------------------------------------- */ -.split-pane-close { - position: absolute; - top: var(--space-1); - right: var(--space-1); - width: 18px; - height: 18px; - display: flex; - align-items: center; - justify-content: center; - border: none; - border-radius: var(--radius-sm); - background: var(--bg-overlay); - color: var(--text-tertiary); - font-size: 11px; - line-height: 1; - cursor: pointer; - opacity: 0; - transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast); - z-index: 5; -} - -.split-pane:hover > .split-pane-close { - opacity: 1; -} - -.split-pane-close:hover { - background: var(--error); - color: var(--text-primary); -} - -/* ----------------------------------------------------------------------- - Draggable divider between panes - ----------------------------------------------------------------------- */ -.split-divider { - flex-shrink: 0; - background: var(--border-subtle); - position: relative; - z-index: 2; - transition: background var(--transition-fast); -} - -.split-divider:hover, -.split-divider.is-dragging { - background: var(--accent); -} - -/* Vertical split → side-by-side panes → vertical divider bar */ -.split-container.split-vertical > .split-divider { - width: 4px; - cursor: col-resize; -} - -/* Horizontal split → stacked panes → horizontal divider bar */ -.split-container.split-horizontal > .split-divider { - height: 4px; - cursor: row-resize; -} - -/* Invisible larger hit-area for easier grabbing */ -.split-divider::after { - content: ''; - position: absolute; -} - -.split-container.split-vertical > .split-divider::after { - top: 0; - bottom: 0; - left: -4px; - right: -4px; -} - -.split-container.split-horizontal > .split-divider::after { - left: 0; - right: 0; - top: -4px; - bottom: -4px; -} - -/* ----------------------------------------------------------------------- - Empty pane placeholder — shown when a new pane needs a session - ----------------------------------------------------------------------- */ -.split-pane-placeholder { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - width: 100%; - gap: var(--space-3); - color: var(--text-tertiary); - font-size: var(--text-sm); - background: var(--bg-base); - padding: var(--space-4); -} - -.split-pane-placeholder-icon { - opacity: 0.4; -} - -.split-pane-placeholder-text { - text-align: center; - max-width: 200px; - line-height: var(--leading-normal); -} - -/* Profile picker list in the placeholder */ -.split-pane-profile-list { - display: flex; - flex-direction: column; - gap: var(--space-1); - max-height: 200px; - overflow-y: auto; - width: 100%; - max-width: 220px; - margin-top: var(--space-2); -} - -.split-pane-profile-list::-webkit-scrollbar { - width: 4px; -} -.split-pane-profile-list::-webkit-scrollbar-track { - background: transparent; -} -.split-pane-profile-list::-webkit-scrollbar-thumb { - background: var(--border-strong); - border-radius: var(--radius-full); -} - -.split-pane-profile-btn { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-1) var(--space-2); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); - background: var(--bg-surface); - color: var(--text-secondary); - font-size: var(--text-sm); - cursor: pointer; - transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); - text-align: left; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.split-pane-profile-btn:hover { - background: var(--bg-hover); - color: var(--text-primary); - border-color: var(--accent-muted); -} - -.split-pane-profile-btn .protocol-badge { - font-size: 9px; - font-weight: var(--weight-semibold); - text-transform: uppercase; - opacity: 0.7; - flex-shrink: 0; -} - -/* ----------------------------------------------------------------------- - Prevent text selection while dragging dividers - ----------------------------------------------------------------------- */ -body.split-resizing { - cursor: col-resize !important; - user-select: none !important; - -webkit-user-select: none !important; -} - -body.split-resizing-row { - cursor: row-resize !important; - user-select: none !important; - -webkit-user-select: none !important; -} - -/* Disable pointer events on iframes / xterm canvases during drag */ -body.split-resizing .xterm, -body.split-resizing-row .xterm { - pointer-events: none; -} diff --git a/frontend/src/css/tabs.css b/frontend/src/css/tabs.css deleted file mode 100644 index 81d1121..0000000 --- a/frontend/src/css/tabs.css +++ /dev/null @@ -1,496 +0,0 @@ -/* ========================================================================== - Alchementary Link — Tab Bar & Tabs - Compact session tabs with status indicators, protocol badges, and - close / pin controls. - ========================================================================== */ - -/* ----------------------------------------------------------------------- - Individual tab - ----------------------------------------------------------------------- */ -.tab { - display: inline-flex; - align-items: center; - gap: var(--space-2); - height: var(--tab-bar-height); - padding: 0 var(--space-3); - font-size: var(--text-sm); - color: var(--text-secondary); - cursor: pointer; - white-space: nowrap; - position: relative; - flex-shrink: 0; - border-right: 1px solid var(--border-subtle); - transition: - background var(--transition-fast), - color var(--transition-fast); - user-select: none; - max-width: 200px; - min-width: 0; -} - -.tab:hover { - background: var(--bg-hover); - color: var(--text-primary); -} - -/* Active tab */ -.tab.tab-active { - background: var(--bg-active); - color: var(--text-primary); -} - -/* Active indicator — accent bar at the bottom */ -.tab.tab-active::after { - content: ""; - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 2px; - background: var(--accent); - border-radius: var(--radius-full) var(--radius-full) 0 0; -} - -/* ----------------------------------------------------------------------- - Tab icon (small terminal/protocol icon) - ----------------------------------------------------------------------- */ -.tab-icon { - width: 14px; - height: 14px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - color: var(--text-tertiary); -} - -.tab.tab-active .tab-icon { - color: var(--text-secondary); -} - -/* ----------------------------------------------------------------------- - Tab label - ----------------------------------------------------------------------- */ -.tab-label, -.tab-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; - flex: 1; -} - -/* ----------------------------------------------------------------------- - Protocol mini-badge inside tab - ----------------------------------------------------------------------- */ -.tab-badge { - display: inline-flex; - align-items: center; - height: 14px; - padding: 0 3px; - font-size: 9px; - font-weight: var(--weight-semibold); - text-transform: uppercase; - letter-spacing: 0.04em; - border-radius: 2px; - flex-shrink: 0; - line-height: 1; -} - -.tab-badge.badge-ssh { - background: rgba(78, 205, 196, 0.15); - color: var(--protocol-ssh); -} -.tab-badge.badge-telnet { - background: rgba(255, 167, 38, 0.15); - color: var(--protocol-telnet); -} -.tab-badge.badge-raw { - background: rgba(171, 71, 188, 0.15); - color: var(--protocol-raw); -} -.tab-badge.badge-github { - background: rgba(124, 138, 255, 0.15); - color: var(--protocol-github); -} -.tab-badge.badge-vnc { - background: rgba(232, 93, 74, 0.15); - color: var(--protocol-vnc); -} -.tab-badge.badge-irc { - background: rgba(216, 178, 76, 0.15); - color: var(--protocol-irc); -} -.tab-badge.badge-local { - background: rgba(139, 195, 74, 0.15); - color: var(--protocol-local); -} - -/* ----------------------------------------------------------------------- - Status dot (connection state) - ----------------------------------------------------------------------- */ -.tab-status { - width: 6px; - height: 6px; - border-radius: 50%; - flex-shrink: 0; - background: var(--text-tertiary); -} - -.tab-status.is-connected { - background: var(--success); -} -.tab-status.is-connecting { - background: var(--warning); - animation: pulse-dot 1.2s ease infinite; -} -.tab-status.is-reconnecting { - background: var(--warning); - animation: pulse-dot 0.8s ease infinite; -} -.tab-status.is-error { - background: var(--error); -} -.tab-status.is-disconnected { - background: var(--text-tertiary); -} - -/* Also match the status-* pattern used by updateSessionState */ -.tab-status.status-connected { - background: var(--success); -} -.tab-status.status-connecting { - background: var(--warning); - animation: pulse-dot 1.2s ease infinite; -} -.tab-status.status-reconnecting { - background: var(--warning); - animation: pulse-dot 0.8s ease infinite; -} -.tab-status.status-error { - background: var(--error); -} -.tab-status.status-disconnected { - background: var(--text-tertiary); -} - -/* ----------------------------------------------------------------------- - Activity indicator — pulses when background tab has new output - ----------------------------------------------------------------------- */ -.tab-activity { - width: 6px; - height: 6px; - border-radius: 50%; - flex-shrink: 0; - background: var(--accent); - opacity: 0; - transition: opacity 150ms ease; -} - -.tab-has-activity .tab-activity { - opacity: 1; - animation: pulse-dot 1.5s ease infinite; -} - -.tab-active .tab-activity { - opacity: 0; -} - -/* Trigger flash — brief glow when a trigger rule matches */ -.tab-trigger-flash { - animation: trigger-flash 2s ease; -} - -@keyframes trigger-flash { - 0%, - 100% { - background: var(--bg-hover); - } - 15% { - background: rgba(239, 83, 80, 0.25); - } - 30% { - background: var(--bg-hover); - } - 45% { - background: rgba(239, 83, 80, 0.15); - } -} - -/* ----------------------------------------------------------------------- - SFTP toggle button — revealed on hover (SSH tabs only) - ----------------------------------------------------------------------- */ -.tab-sftp { - width: 16px; - height: 16px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-sm); - font-size: 10px; - color: var(--text-tertiary); - flex-shrink: 0; - opacity: 0; - transition: - opacity var(--transition-fast), - background var(--transition-fast), - color var(--transition-fast); - cursor: pointer; - background: none; - border: none; - padding: 0; - margin-left: auto; -} - -.tab:hover .tab-sftp, -.tab.tab-active .tab-sftp { - opacity: 1; -} - -.tab-sftp:hover { - background: var(--bg-active); - color: var(--accent); -} - -/* ----------------------------------------------------------------------- - Close button — revealed on hover - ----------------------------------------------------------------------- */ -.tab-close { - width: 16px; - height: 16px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-sm); - font-size: 11px; - color: var(--text-tertiary); - flex-shrink: 0; - opacity: 0; - transition: - opacity var(--transition-fast), - background var(--transition-fast), - color var(--transition-fast); - cursor: pointer; - margin: 0 2px 0 0; - padding: 0; - border: 0; - background: none; - line-height: 1; -} - -.tab:hover .tab-close, -.tab.tab-active .tab-close { - opacity: 1; -} - -.tab-close:hover { - background: var(--bg-active); - color: var(--text-primary); -} - -/* ----------------------------------------------------------------------- - Pin indicator - ----------------------------------------------------------------------- */ -.tab-pin { - width: 12px; - height: 12px; - display: flex; - align-items: center; - justify-content: center; - font-size: 9px; - color: var(--text-disabled); - flex-shrink: 0; -} - -.tab.is-pinned .tab-pin { - color: var(--accent); -} - -/* Pinned tabs are always narrow */ -.tab.is-pinned { - max-width: 100px; - padding: 0 var(--space-2); -} - -.tab.is-pinned .tab-label { - /* Hide label on pinned tabs to save space */ - display: none; -} - -.tab.is-pinned .tab-close { - /* Pinned tabs don't show close by default */ - display: none; -} - -/* ----------------------------------------------------------------------- - Drag handle area (for tab reordering) - ----------------------------------------------------------------------- */ -.tab-drag-handle { - cursor: grab; - flex: 1; - display: flex; - align-items: center; - gap: var(--space-2); - min-width: 0; - height: 100%; -} - -.tab-drag-handle:active { - cursor: grabbing; -} - -/* Drag ghost styling */ -.tab.is-dragging { - opacity: 0.5; -} - -.tab.is-drag-over-left::before { - content: ""; - position: absolute; - left: -1px; - top: 4px; - bottom: 4px; - width: 2px; - background: var(--accent); - border-radius: var(--radius-full); -} - -.tab.is-drag-over-right::after { - content: ""; - position: absolute; - right: -1px; - top: 4px; - bottom: 4px; - width: 2px; - background: var(--accent); - border-radius: var(--radius-full); - /* Note: this re-uses ::after — active tab indicator is overridden while dragging */ -} - -/* ----------------------------------------------------------------------- - New-tab button (+) - ----------------------------------------------------------------------- */ -.tab-new { - display: flex; - align-items: center; - justify-content: center; - width: var(--tab-bar-height); - height: var(--tab-bar-height); - font-size: var(--text-lg); - color: var(--text-tertiary); - cursor: pointer; - flex-shrink: 0; - transition: - background var(--transition-fast), - color var(--transition-fast); - border: none; - background: none; -} - -.tab-new:hover { - background: var(--bg-hover); - color: var(--text-primary); -} - -.tab-new:active { - background: var(--bg-active); -} - -/* ----------------------------------------------------------------------- - Broadcast toggle button - ----------------------------------------------------------------------- */ -.tab-broadcast { - display: flex; - align-items: center; - justify-content: center; - width: var(--tab-bar-height); - height: var(--tab-bar-height); - color: var(--text-tertiary); - cursor: pointer; - flex-shrink: 0; - transition: - background var(--transition-fast), - color var(--transition-fast); - border: none; - background: none; - opacity: 0.5; -} - -.tab-broadcast:hover { - background: var(--bg-hover); - color: var(--text-primary); - opacity: 0.8; -} - -.tab-broadcast.is-active { - color: var(--error); - opacity: 1; - animation: pulse-dot 1.5s ease infinite; -} - -/* ----------------------------------------------------------------------- - Tab overflow indicators (chevrons at edges) - ----------------------------------------------------------------------- */ -.tab-bar-overflow-left, -.tab-bar-overflow-right { - display: none; - align-items: center; - justify-content: center; - width: 20px; - flex-shrink: 0; - font-size: var(--text-sm); - color: var(--text-tertiary); - cursor: pointer; - background: linear-gradient(90deg, var(--bg-surface), transparent); -} - -.tab-bar-overflow-right { - background: linear-gradient(-90deg, var(--bg-surface), transparent); -} - -.tab-bar.has-overflow-left .tab-bar-overflow-left, -.tab-bar.has-overflow-right .tab-bar-overflow-right { - display: flex; -} - -/* ----------------------------------------------------------------------- - Tab context menu trigger (on right-click) - ----------------------------------------------------------------------- */ -.tab-context-area { - position: absolute; - inset: 0; - z-index: 1; - /* Transparent hit area for right-click context menu */ -} - -/* ----------------------------------------------------------------------- - Tab hover tooltip - ----------------------------------------------------------------------- */ -.tab-tooltip { - max-width: 360px; - white-space: normal; -} - -.tab-tooltip-title { - max-width: 320px; - overflow: hidden; - color: var(--text-primary); - text-overflow: ellipsis; - white-space: nowrap; -} - -.tab-tooltip-meta { - margin-top: 2px; - color: var(--text-tertiary); - font-weight: var(--weight-normal); -} - -/* ----------------------------------------------------------------------- - Modified indicator (unsaved changes) - ----------------------------------------------------------------------- */ -.tab.is-modified .tab-close::before { - content: "●"; - font-size: 8px; - color: var(--accent); -} diff --git a/frontend/src/css/terminal.css b/frontend/src/css/terminal.css deleted file mode 100644 index 5e6f58e..0000000 --- a/frontend/src/css/terminal.css +++ /dev/null @@ -1,479 +0,0 @@ -/* ========================================================================== - Alchementary Link — Terminal Integration - xterm.js container, header info strip, RAW mode views, and send bar. - ========================================================================== */ - -/* ----------------------------------------------------------------------- - Terminal wrapper — fills the workspace panel - ----------------------------------------------------------------------- */ -.terminal-wrapper { - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; -} - -/* ----------------------------------------------------------------------- - Terminal header / info strip (optional) - Shows connection info above the terminal. - ----------------------------------------------------------------------- */ -.terminal-header { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-1) var(--space-3); - font-size: var(--text-xs); - color: var(--text-tertiary); - background: var(--bg-surface); - border-bottom: 1px solid var(--border-subtle); - flex-shrink: 0; - min-height: 24px; -} - -.terminal-header-label { - font-weight: var(--weight-medium); - color: var(--text-secondary); -} - -.terminal-header-value { - font-family: var(--font-mono); - color: var(--text-tertiary); -} - -.terminal-header-separator { - width: 1px; - height: 10px; - background: var(--border-subtle); -} - -.terminal-header-spacer { - flex: 1; -} - -.terminal-header-actions { - display: flex; - gap: var(--space-1); -} - -/* ----------------------------------------------------------------------- - xterm.js container - ----------------------------------------------------------------------- */ -.terminal-container { - flex: 1; - position: relative; - overflow: hidden; - padding: 0; -} - -/* ----------------------------------------------------------------------- - Disconnected overlay - ----------------------------------------------------------------------- */ -.session-panel.is-disconnected > *:not(.terminal-disconnect-overlay) { - filter: blur(2px); - pointer-events: none; -} - -.terminal-disconnect-overlay { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background: rgba(10, 14, 18, 0.68); - z-index: 6; - opacity: 0; - visibility: hidden; - transition: - opacity var(--transition-fast), - visibility var(--transition-fast); -} - -.terminal-disconnect-overlay.is-visible { - opacity: 1; - visibility: visible; -} - -.terminal-disconnect-content { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--space-3); - text-align: center; - padding: var(--space-4) var(--space-5); - background: var(--bg-elevated); - border: 1px solid var(--border-default); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-lg); -} - -.terminal-disconnect-title { - font-size: var(--text-lg); - letter-spacing: 0.45em; - text-indent: 0.45em; - text-transform: uppercase; - color: var(--text-primary); - font-weight: var(--weight-semibold); -} - -.terminal-disconnect-message { - font-size: var(--text-sm); - color: var(--text-secondary); - max-width: 360px; -} - -/* xterm.js overrides for consistent look */ -.terminal-container .xterm, -.session-panel .xterm { - flex: 1; - height: 100%; - min-height: 0; - padding: 3px 0 0 3px; -} - -.terminal-container .xterm-screen, -.session-panel .xterm-screen { - height: 100%; -} - -.terminal-container .xterm-viewport, -.session-panel .xterm-viewport { - /* Allow xterm's own scrollbar to work */ - overflow-y: auto !important; -} - -.terminal-container .xterm-viewport::-webkit-scrollbar, -.session-panel .xterm-viewport::-webkit-scrollbar { - width: 6px; -} -.terminal-container .xterm-viewport::-webkit-scrollbar-track, -.session-panel .xterm-viewport::-webkit-scrollbar-track { - background: transparent; -} -.terminal-container .xterm-viewport::-webkit-scrollbar-thumb, -.session-panel .xterm-viewport::-webkit-scrollbar-thumb { - background: var(--border-strong); - border-radius: var(--radius-full); -} -.terminal-container .xterm-viewport::-webkit-scrollbar-thumb:hover, -.session-panel .xterm-viewport::-webkit-scrollbar-thumb:hover { - background: var(--text-tertiary); -} - -/* Ensure cursor blink is visible */ -.terminal-container .xterm-cursor-layer, -.session-panel .xterm-cursor-layer { - z-index: 1; -} - -/* Selection styling within terminal */ -.terminal-container .xterm .xterm-selection div, -.session-panel .xterm .xterm-selection div { - background: var(--accent-strong) !important; -} - -/* ----------------------------------------------------------------------- - Terminal search overlay - ----------------------------------------------------------------------- */ -.terminal-search { - position: absolute; - top: var(--space-2); - right: var(--space-4); - display: flex; - align-items: center; - gap: var(--space-1); - padding: var(--space-1); - background: var(--bg-overlay); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); - box-shadow: var(--shadow-md); - z-index: 10; - opacity: 0; - visibility: hidden; - transform: translateY(-8px); - transition: - opacity var(--transition-fast), - visibility var(--transition-fast), - transform var(--transition-fast); -} - -.terminal-search.is-visible { - opacity: 1; - visibility: visible; - transform: translateY(0); -} - -.terminal-search input { - width: 200px; - height: 24px; - padding: 0 var(--space-2); - font-size: var(--text-sm); - font-family: var(--font-mono); - color: var(--text-primary); - background: var(--bg-input); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-sm); -} - -.terminal-search input:focus { - border-color: var(--accent); - outline: none; -} - -.terminal-search-count { - font-size: var(--text-xs); - color: var(--text-tertiary); - padding: 0 var(--space-1); - white-space: nowrap; - min-width: 70px; -} - -.terminal-search-toggle { - font-family: var(--font-mono); - font-size: var(--text-xs); - opacity: 0.5; - transition: - opacity var(--transition-fast), - color var(--transition-fast); -} - -.terminal-search-toggle.is-active { - opacity: 1; - color: var(--accent); -} - -/* ----------------------------------------------------------------------- - RAW mode containers - ----------------------------------------------------------------------- */ - -/* RAW text view — scrollable, monospace output */ -.raw-text-view { - flex: 1; - overflow-y: auto; - padding: var(--space-3); - font-family: var(--font-mono); - font-size: var(--text-sm); - line-height: var(--leading-relaxed); - color: var(--text-primary); - background: var(--bg-base); - white-space: pre-wrap; - word-break: break-all; -} - -/* RAW hex view */ -.raw-hex-view { - flex: 1; - overflow-y: auto; - padding: var(--space-3); - font-family: var(--font-mono); - font-size: var(--text-xs); - line-height: var(--leading-relaxed); - color: var(--text-primary); - background: var(--bg-base); -} - -.raw-hex-row { - display: flex; - gap: var(--space-4); -} - -.raw-hex-offset { - color: var(--text-tertiary); - min-width: 60px; -} - -.raw-hex-bytes { - min-width: 350px; - letter-spacing: 0.08em; -} - -.raw-hex-bytes .byte-highlight { - color: var(--accent); -} - -.raw-hex-ascii { - color: var(--text-secondary); -} - -.raw-hex-ascii .non-printable { - color: var(--text-disabled); -} - -/* RAW split view (text on left, hex on right) */ -.raw-split-view { - flex: 1; - display: flex; - overflow: hidden; -} - -.raw-split-view .raw-text-view, -.raw-split-view .raw-hex-view { - flex: 1; -} - -.raw-split-divider { - width: 1px; - background: var(--border-subtle); - flex-shrink: 0; -} - -/* ----------------------------------------------------------------------- - RAW mode view toggle buttons - ----------------------------------------------------------------------- */ -.raw-view-toggle { - display: flex; - gap: 1px; - background: var(--border-subtle); - border-radius: var(--radius-md); - overflow: hidden; -} - -.raw-view-toggle-btn { - padding: 0 var(--space-2); - height: 22px; - font-size: var(--text-xs); - font-weight: var(--weight-medium); - color: var(--text-tertiary); - background: var(--bg-surface); - border: none; - cursor: pointer; - transition: - background var(--transition-fast), - color var(--transition-fast); -} - -.raw-view-toggle-btn:hover { - color: var(--text-secondary); -} - -.raw-view-toggle-btn.active { - background: var(--bg-active); - color: var(--text-primary); -} - -/* ----------------------------------------------------------------------- - Send bar (RAW TCP mode — manual data entry) - ----------------------------------------------------------------------- */ -.send-bar { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-2) var(--space-3); - background: var(--bg-surface); - border-top: 1px solid var(--border-subtle); - flex-shrink: 0; -} - -.send-bar-input { - flex: 1; - height: 28px; - padding: 0 var(--space-3); - font-family: var(--font-mono); - font-size: var(--text-sm); - color: var(--text-primary); - background: var(--bg-input); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); - transition: border-color var(--transition-fast); -} - -.send-bar-input:focus { - border-color: var(--accent); - outline: none; -} - -.send-bar-input::placeholder { - color: var(--text-disabled); -} - -/* Line ending selector */ -.send-bar-eol { - height: 28px; - padding: 0 var(--space-2); - font-size: var(--text-xs); - color: var(--text-secondary); - background: var(--bg-input); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); - appearance: none; - cursor: pointer; - min-width: 60px; -} - -.send-bar-eol:focus { - border-color: var(--accent); - outline: none; -} - -/* Encoding selector */ -.send-bar-encoding { - font-size: var(--text-xs); - color: var(--text-tertiary); - padding: 0 var(--space-1); -} - -/* ----------------------------------------------------------------------- - Session overlay (connecting / error states) - ----------------------------------------------------------------------- */ -.session-overlay { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - background: rgba(28, 28, 28, 0.85); - z-index: 5; -} - -.session-overlay-connecting { - text-align: center; -} - -.session-overlay-connecting .spinner { - width: 24px; - height: 24px; - margin: 0 auto var(--space-4); -} - -.session-overlay-connecting p { - font-size: var(--text-sm); - color: var(--text-secondary); -} - -.session-overlay-error { - text-align: center; - max-width: 360px; -} - -.session-overlay-error h3 { - font-size: var(--text-md); - font-weight: var(--weight-semibold); - color: var(--error); - margin-bottom: var(--space-2); -} - -.session-overlay-error p { - font-size: var(--text-sm); - color: var(--text-tertiary); - line-height: var(--leading-relaxed); - margin-bottom: var(--space-4); -} - -.session-overlay-error .session-error-detail { - font-family: var(--font-mono); - font-size: var(--text-xs); - color: var(--text-disabled); - background: var(--bg-input); - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-md); - margin-bottom: var(--space-4); - text-align: left; - max-height: 120px; - overflow-y: auto; - word-break: break-all; -} - -.session-overlay-actions { - display: flex; - gap: var(--space-2); -} diff --git a/frontend/src/css/tokens.css b/frontend/src/css/tokens.css deleted file mode 100644 index 287de7f..0000000 --- a/frontend/src/css/tokens.css +++ /dev/null @@ -1,159 +0,0 @@ -/* ========================================================================== - Alchementary Link — Design Tokens - Central palette, spacing, typography, and layout constants. - All values consumed via var(--token-name) throughout the application. - ========================================================================== */ - -:root { - /* ----------------------------------------------------------------------- - Colour palette — dark charcoal theme - ----------------------------------------------------------------------- */ - --bg-base: #1c1c1c; /* deepest background */ - --bg-surface: #232323; /* primary surface (sidebar, bars) */ - --bg-elevated: #2a2a2a; /* elevated panels, cards */ - --bg-overlay: #303030; /* overlays, dropdowns */ - --bg-hover: #363636; /* hover state backgrounds */ - --bg-active: #3c3c3c; /* active / pressed state */ - --bg-input: #1e1e1e; /* input field backgrounds */ - - /* ----------------------------------------------------------------------- - Border colours - ----------------------------------------------------------------------- */ - --border-subtle: rgba(255, 255, 255, 0.07); - --border-default: rgba(255, 255, 255, 0.12); - --border-strong: rgba(255, 255, 255, 0.18); - --border-focus: var(--accent); - - /* ----------------------------------------------------------------------- - Text colours - ----------------------------------------------------------------------- */ - --text-primary: #e4e4e4; - --text-secondary: #a0a0a0; - --text-tertiary: #707070; - --text-disabled: #4a4a4a; - --text-inverse: #1c1c1c; - --sidebar-group-title: #b89d5f; - - /* ----------------------------------------------------------------------- - Accent colours — teal / cyan - ----------------------------------------------------------------------- */ - --accent: #4ecdc4; - --accent-hover: #45b7af; - --accent-muted: rgba(78, 205, 196, 0.12); - --accent-strong: rgba(78, 205, 196, 0.22); - - /* ----------------------------------------------------------------------- - Status colours - ----------------------------------------------------------------------- */ - --success: #66bb6a; - --warning: #ffa726; - --error: #ef5350; - --info: #42a5f5; - - /* ----------------------------------------------------------------------- - Protocol badge colours - ----------------------------------------------------------------------- */ - --protocol-ssh: #4ecdc4; - --protocol-telnet: #ffa726; - --protocol-raw: #ab47bc; - --protocol-github: #7c8aff; - --protocol-vnc: #e85d4a; - --protocol-irc: #d8b24c; - --protocol-local: #8bc34a; - - /* ----------------------------------------------------------------------- - Spacing scale (4 px base unit) - ----------------------------------------------------------------------- */ - --space-1: 4px; - --space-2: 8px; - --space-3: 12px; - --space-4: 16px; - --space-5: 20px; - --space-6: 24px; - --space-8: 32px; - --space-10: 40px; - --space-12: 48px; - --space-16: 64px; - - /* ----------------------------------------------------------------------- - Typography - ----------------------------------------------------------------------- */ - --font-sans: - "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, - sans-serif; - --font-mono: - "JetBrains Mono", "Fira Code", "SF Mono", Monaco, "Cascadia Code", monospace; - - --text-xs: 11px; - --text-sm: 12px; - --text-base: 13px; - --text-md: 14px; - --text-lg: 16px; - --text-xl: 18px; - --text-2xl: 22px; - - --leading-tight: 1.25; - --leading-normal: 1.5; - --leading-relaxed: 1.625; - - --weight-normal: 400; - --weight-medium: 500; - --weight-semibold: 600; - - /* ----------------------------------------------------------------------- - Border radius - ----------------------------------------------------------------------- */ - --radius-sm: 3px; - --radius-md: 5px; - --radius-lg: 8px; - --radius-xl: 12px; - --radius-full: 9999px; - - /* ----------------------------------------------------------------------- - Shadows - ----------------------------------------------------------------------- */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); - --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.4); - --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.5); - --shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.6); - - /* ----------------------------------------------------------------------- - Transitions - ----------------------------------------------------------------------- */ - --transition-fast: 100ms ease; - --transition-normal: 200ms ease; - --transition-slow: 300ms ease; - - /* ----------------------------------------------------------------------- - Z-index layers - ----------------------------------------------------------------------- */ - --z-base: 0; - --z-sticky: 100; - --z-dropdown: 200; - --z-modal-backdrop: 300; - --z-modal: 400; - --z-notification: 500; - --z-tooltip: 600; - - /* ----------------------------------------------------------------------- - Layout - ----------------------------------------------------------------------- */ - --sidebar-width: 260px; - --sidebar-width-collapsed: 48px; - --tab-bar-height: 44px; - --toolbar-height: 40px; - --status-bar-height: 24px; -} - -/* ----------------------------------------------------------------------- - Global input overrides — disable macOS/WebKit auto-capitalisation, - autocorrect, and spellcheck on all text fields. - ----------------------------------------------------------------------- */ -input, -textarea, -select { - autocapitalize: none; - autocorrect: off; - spellcheck: false; - text-transform: none; -} diff --git a/frontend/src/css/vnc.css b/frontend/src/css/vnc.css deleted file mode 100644 index 64016aa..0000000 --- a/frontend/src/css/vnc.css +++ /dev/null @@ -1,78 +0,0 @@ -/* VNC viewer styles */ - -.vnc-viewer-panel { - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; - background: var(--bg-base); -} - -.vnc-toolbar { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.375rem 0.75rem; - background: var(--bg-elevated); - border-bottom: 1px solid var(--border-subtle); - flex-shrink: 0; -} - -.vnc-status { - font-size: 0.75rem; - color: var(--text-secondary); - margin-right: auto; -} - -.vnc-status-connected { - color: var(--protocol-ssh); -} - -.vnc-status-error { - color: var(--danger); -} - -.btn-vnc-action { - font-size: 0.7rem; - padding: 0.2rem 0.5rem; - border-radius: var(--radius-sm); - background: var(--bg-surface); - border: 1px solid var(--border-subtle); - color: var(--text-secondary); - cursor: pointer; - transition: - background 0.15s, - color 0.15s; -} - -.btn-vnc-action:hover { - background: var(--bg-hover); - color: var(--text-primary); -} - -.vnc-display { - flex: 1; - position: relative; - overflow: hidden; - background: #000; -} - -.vnc-display canvas { - width: 100% !important; - height: 100% !important; - object-fit: contain; -} - -/* Protocol colour for VNC */ -.proto-vnc { - color: var(--protocol-vnc); -} - -.badge-vnc { - background: var(--protocol-vnc); - color: #fff; -} - -.sidebar-item-indicator--vnc { - background: var(--protocol-vnc); -} diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js deleted file mode 100644 index 5f758a4..0000000 --- a/frontend/src/js/app.js +++ /dev/null @@ -1,1958 +0,0 @@ -(function () { - /** - * Alchementary Link — main application entry point. - * Initialises the application shell, loads profiles, and sets up event listeners. - */ - - const { - State, - Sidebar, - TabManager, - TerminalManager, - ProfileEditor, - RawViewer, - Notifications, - Settings, - CommandPalette, - SecretPrompt, - SplitPaneManager, - RecordingManager, - ExportImportUI, - SFTPBrowser, - SnippetLibrary, - SSHKeyManager, - TriggerEngine, - LeviathanWorkbench, - stateLabel, - } = window.AlchementaryLink; - - class App { - constructor() { - this.state = new State(); - this.notifications = new Notifications(); - this.sidebar = new Sidebar(this); - this.tabs = new TabManager(this); - this.terminals = new TerminalManager(this); - this.profileEditor = new ProfileEditor(this); - this.rawViewer = new RawViewer(this); - window.AlchementaryLink.IRCViewer?.setApp(this); - this.settings = new Settings(this); - this.commandPalette = new CommandPalette(this); - this.secretPrompt = new SecretPrompt(this); - this.splitPanes = new SplitPaneManager(this); - this.recordings = new RecordingManager(this); - this.exportImport = new ExportImportUI(this); - this.sftp = new SFTPBrowser(this); - this.snippets = new SnippetLibrary(this); - this.sshKeys = new SSHKeyManager(this); - this.triggers = new TriggerEngine(this); - this.leviathan = new LeviathanWorkbench(this); - /** @type {Map>} Idempotent in-flight session closes. */ - this._closingSessions = new Map(); - this._latencySessionID = null; - this._latencyTimer = null; - this._latencyGeneration = 0; - this._latencyPingInFlight = false; - this.updateInfo = null; - this.mode = "link"; - } - - /** - * Bootstraps the application after the DOM is ready. - * UI event binding happens synchronously so buttons always work. - * Backend data loading waits for the Wails runtime to be available. - */ - async init() { - // Phase 1 — bind all UI event handlers immediately (DOM-only, no backend). - this.setupUI(); - this.setupKeyboard(); - this.updateStatusBar(); - - // Phase 2 — wait for the Wails runtime, then load backend data and bind events. - await this._waitForWailsRuntime(); - this.setupEvents(); - - await this.loadProfiles(); - await this.loadPreferences(); - await this.checkSecretStore(); - this.updateStatusBar(); - this.checkForUpdates(true); - } - - /** - * Polls for the Wails runtime and generated binding bridge. - * Application code uses wailsService as a small compatibility layer over - * the normal Wails backend global. - * @returns {Promise} - * @private - */ - _waitForWailsRuntime() { - return new Promise((resolve) => { - const maxWait = 5000; - const start = Date.now(); - const check = () => { - const hasBridge = - typeof window.AlchementaryLink?.hasWailsService === "function" - ? window.AlchementaryLink.hasWailsService() - : Boolean(window.AlchementaryLink?.wailsService && window.runtime); - if (window.AlchementaryLink?.wailsService && hasBridge) { - resolve(); - } else if (Date.now() - start < maxWait) { - setTimeout(check, 50); - } else { - console.warn( - "Wails runtime not detected after " + - maxWait + - "ms — proceeding anyway", - ); - resolve(); - } - }; - Promise.resolve(window.AlchementaryLink?.wailsServiceReady) - .catch(() => null) - .finally(check); - }); - } - - // ── Data loading ───────────────────────────────────────────────── - - /** - * Fetches all connection profiles from the backend and renders the sidebar. - */ - async loadProfiles() { - try { - const profiles = await window.AlchementaryLink.wailsService.ListProfiles(); - this.state.setProfiles(profiles || []); - this.sidebar.render(); - } catch (err) { - this.notifications.error("Failed to load profiles: " + err); - } - } - - /** - * Loads user preferences from the backend. - */ - async loadPreferences() { - try { - const prefs = await window.AlchementaryLink.wailsService.GetPreferences(); - this.state.setPreferences(prefs || {}); - this._applyPreferences(); - } catch (err) { - // Non-fatal — fall back to defaults - console.warn("Failed to load preferences:", err); - } - } - - /** - * Queries the secret store status (initialised / unlocked). - */ - async checkSecretStore() { - try { - const initialised = - await window.AlchementaryLink.wailsService.IsSecretStoreInitialised(); - this.state.setSecretStoreInitialised(!!initialised); - if (initialised) { - const unlocked = await window.AlchementaryLink.wailsService.IsSecretStoreUnlocked(); - this.state.setSecretStoreState(!!unlocked); - } else { - this.state.setSecretStoreState(false); - } - } catch (_) { - // Secret store may not be available yet - } - } - - // ── Wails event listeners ──────────────────────────────────────── - - /** - * Registers listeners for backend events emitted via the Wails runtime. - * Guarded: silently skips if the runtime is unavailable. - */ - setupEvents() { - if (!window.runtime || typeof window.runtime.EventsOn !== "function") { - console.warn( - "window.runtime not available — Wails event listeners not bound", - ); - return; - } - // Incoming session data (terminal output) - window.runtime.EventsOn("session:data", (payload) => { - this.terminals.handleData(payload.sessionID, payload.data); - this.tabs.markActivity(payload.sessionID); - // Check trigger rules against decoded text - try { - const raw = atob(payload.data); - this.triggers.check(payload.sessionID, raw); - } catch (_) { - /* noop */ - } - }); - - // Session state transitions (connecting → connected → reconnecting → disconnected) - window.runtime.EventsOn("session:state", (payload) => { - this.tabs.updateSessionState( - payload.sessionID, - payload.state, - payload.error, - ); - this.updateStatusBar(); - - const session = this.state.getSession(payload.sessionID); - const isLocal = session?.protocol === "local"; - const localExited = - isLocal && payload.state === "disconnected"; - const showDisconnectedOverlay = - !localExited && - (payload.state === "disconnected" || payload.state === "error"); - this.terminals.setDisconnectedOverlay( - payload.sessionID, - showDisconnectedOverlay, - payload.error, - ); - if (localExited) { - this.terminals.setLocalExited(payload.sessionID, true); - } else if (isLocal && payload.state === "connected") { - this.terminals.setLocalExited(payload.sessionID, false); - } - - if (payload.state === "error" && payload.error) { - this._handleSessionError(payload.sessionID, payload.error); - } - - // Show reconnecting notification - if (payload.state === "reconnecting") { - const attempt = payload.attempt || 1; - const maxRetries = payload.maxRetries || 5; - const delaySec = payload.delaySec || 1; - this.notifications.info( - `Reconnecting (attempt ${attempt}/${maxRetries}) in ${delaySec}s…`, - ); - } - - // Once connected, ensure the backend PTY has the correct dimensions. - // The initial fit happens before the SSH handshake completes, so the - // onResize callback fires when the backend isn't ready. By the time - // the connection is established, xterm already has the right cols/rows - // and fitAddon.fit() won't trigger a new resize event. We must - // explicitly send the current terminal size to the backend. - if (payload.state === "connected") { - const entry = this.terminals.terminals.get(payload.sessionID); - if (entry) { - const { cols, rows } = entry.term; - try { - window.AlchementaryLink.wailsService.ResizeSession( - payload.sessionID, - cols, - rows, - ); - } catch (_) { - /* noop */ - } - } - // Also re-fit to catch any layout changes that occurred while connecting. - // Multiple delays help byobu/tmux render correctly after its status - // bar initialisation completes. - const fit = () => this.terminals.fitSession(payload.sessionID); - setTimeout(fit, 50); - setTimeout(fit, 300); - setTimeout(fit, 800); - setTimeout(() => window.AlchementaryLink.IRCViewer?.fit(payload.sessionID), 50); - } - }); - - // Session closed by backend - window.runtime.EventsOn("session:closed", (payload) => { - this.sftp.close(payload.sessionID); - this.triggers.cleanup(payload.sessionID); - this.tabs.handleSessionClosed(payload.sessionID); - this.updateStatusBar(); - }); - - // RAW TCP data (separate channel for the raw viewer) - window.runtime.EventsOn("session:raw:data", (payload) => { - this.rawViewer.handleData(payload); - }); - - // GitHub repository data - window.runtime.EventsOn("session:github:repos", (payload) => { - window.AlchementaryLink.GitHubViewer.updateRepos( - payload.sessionID, - payload.login, - payload.repos, - ); - }); - - // GitHub refresh errors - window.runtime.EventsOn("session:github:error", (payload) => { - window.AlchementaryLink.GitHubViewer.showError( - payload.sessionID, - payload.error, - ); - }); - - // GitHub status updates - window.runtime.EventsOn("session:github:status", (payload) => { - window.AlchementaryLink.GitHubViewer.setStatus( - payload.sessionID, - payload.status, - ); - }); - - // VNC WebSocket proxy ready — connect the viewer - window.runtime.EventsOn("session:vnc:ready", (payload) => { - window.AlchementaryLink.VNCViewer.connect( - payload.sessionID, - payload.wsURL, - ); - }); - - // IRC events - window.runtime.EventsOn("session:irc:event", (payload) => { - window.AlchementaryLink.IRCViewer.handleEvent(payload); - this.tabs.markActivity(payload.sessionID); - }); - - // App-wide commands from the native menu - window.runtime.EventsOn("app:open-about", () => { - this.settings.openTab("about"); - }); - - // Port-forwarding events - window.runtime.EventsOn("session:forward:started", (payload) => { - const fwd = payload.forward || {}; - const label = - fwd.label || - `${fwd.bindHost || ""}:${fwd.bindPort} → ${fwd.destHost}:${fwd.destPort}`; - this.notifications.success(`Forward started: ${label}`); - }); - - window.runtime.EventsOn("session:forward:stopped", (payload) => { - this.notifications.info(`Forward stopped: ${payload.forwardID}`); - }); - - window.runtime.EventsOn("session:forward:error", (payload) => { - this.notifications.error(`Forward error: ${payload.error}`); - }); - - // Recording events - window.runtime.EventsOn("session:recording:started", (payload) => { - this.recordings.activeSessions.add(payload.sessionID); - this.recordings._updateRecordingIndicator(payload.sessionID, true); - }); - - window.runtime.EventsOn("session:recording:stopped", (payload) => { - this.recordings.activeSessions.delete(payload.sessionID); - this.recordings._updateRecordingIndicator(payload.sessionID, false); - }); - - // Telnet negotiation debug messages are intentionally ignored in release UI. - window.runtime.EventsOn("session:debug", () => {}); - - // Backend ready event (emitted from DomReady) - window.runtime.EventsOn("backend:ready", () => { - // Lifecycle signal only. - }); - - // Host key verification requests - window.runtime.EventsOn("session:hostkey:verify", async (payload) => { - try { - const accepted = await this.secretPrompt.promptHostKeyVerification( - payload.host, - payload.keyType, - payload.fingerprint, - ); - window.runtime.EventsEmit("session:hostkey:response", { - requestID: payload.requestID, - sessionID: payload.sessionID, - accepted, - }); - } catch (_) { - window.runtime.EventsEmit("session:hostkey:response", { - requestID: payload.requestID, - sessionID: payload.sessionID, - accepted: false, - }); - } - }); - - // SFTP transfer progress events - window.runtime.EventsOn("session:sftp:progress", (payload) => { - this.sftp.handleTransferProgress(payload); - }); - - // Password/passphrase prompt requests from the backend - window.runtime.EventsOn("session:secret:prompt", async (payload) => { - try { - const value = await this.secretPrompt.prompt( - payload.title || "Credentials Required", - payload.message || "Enter your password or passphrase.", - ); - window.runtime.EventsEmit("session:secret:response", { - requestID: payload.requestID, - value, - }); - } catch (_) { - window.runtime.EventsEmit("session:secret:response", { - requestID: payload.requestID, - value: "", - cancelled: true, - }); - } - }); - } - - /** - * Disconnects and removes a session without confirmation prompts. - * @param {string} sessionId - */ - closeSessionImmediate(sessionId, options = {}) { - const existing = this._closingSessions.get(sessionId); - if (existing) return existing; - - let resolveOperation; - let rejectOperation; - const operation = new Promise((resolve, reject) => { - resolveOperation = resolve; - rejectOperation = reject; - }); - this._closingSessions.set(sessionId, operation); - - try { - this._closeSessionResources(sessionId, options).then( - resolveOperation, - rejectOperation, - ); - } catch (err) { - rejectOperation(err); - } - - const clearClosing = () => { - if (this._closingSessions.get(sessionId) === operation) { - this._closingSessions.delete(sessionId); - } - }; - operation.then(clearClosing, clearClosing); - return operation; - } - - /** - * Releases every frontend and backend resource owned by a session. - * Local objects are dropped before awaiting the backend so a slow close - * cannot leave renderers, observers or viewers running invisibly. - * @param {string} sessionId - * @param {{removeTab?: boolean, cleanupSplits?: boolean}} options - * @private - */ - async _closeSessionResources(sessionId, options = {}) { - const removeTab = options.removeTab !== false; - const cleanupSplits = options.cleanupSplits !== false; - - let splitCleanup = Promise.resolve(); - if (cleanupSplits) { - try { - splitCleanup = Promise.resolve(this.splitPanes.cleanup(sessionId)); - } catch (_) { - // Continue with the rest of the idempotent local cleanup. - } - } - - const localCleanups = [ - () => this.sftp.close(sessionId), - () => this.triggers.cleanup(sessionId), - () => this.recordings.cleanup(sessionId), - () => this.terminals.destroyTerminal(sessionId), - () => this.rawViewer.destroyViewer(sessionId), - () => window.AlchementaryLink.GitHubViewer?.destroyViewer(sessionId), - () => window.AlchementaryLink.VNCViewer?.destroyViewer(sessionId), - () => window.AlchementaryLink.IRCViewer?.destroyViewer(sessionId), - () => this.leviathan?.destroyPanel(sessionId), - ]; - for (const cleanup of localCleanups) { - try { - cleanup(); - } catch (_) { - // One faulty viewer must not prevent the remaining resources closing. - } - } - - if (removeTab && this.tabs.order.includes(sessionId)) { - this.tabs.removeTab(sessionId); - } - if (this.state.getSession(sessionId)) { - this.state.removeSession(sessionId); - } - this.updateStatusBar(); - - let backendCleanup = Promise.resolve(); - try { - backendCleanup = Promise.resolve( - window.AlchementaryLink.wailsService.DisconnectSession(sessionId), - ); - } catch (_) { - // Ignore failures; all local resources have already been released. - } - - await Promise.allSettled([splitCleanup, backendCleanup]); - } - - // ── Keyboard shortcuts ─────────────────────────────────────────── - - /** - * Registers global keyboard shortcuts. - */ - setupKeyboard() { - document.addEventListener("keydown", (e) => { - const mod = e.ctrlKey || e.metaKey; - - // Ctrl/Cmd+K — command palette - if (mod && e.key === "k") { - e.preventDefault(); - this.commandPalette.toggle(); - return; - } - - // Ctrl/Cmd+N — new connection - if (mod && e.key === "n") { - e.preventDefault(); - this.profileEditor.openNew(); - return; - } - - // Ctrl/Cmd+W — close active tab - if (mod && e.key === "w") { - e.preventDefault(); - this.tabs.closeActive(); - return; - } - - // Ctrl/Cmd+Tab — next tab - if (mod && e.key === "Tab" && !e.shiftKey) { - e.preventDefault(); - const next = this.tabs.getNextTab(); - if (next) this.tabs.setActive(next); - return; - } - - // Ctrl/Cmd+Shift+Tab — previous tab - if (mod && e.key === "Tab" && e.shiftKey) { - e.preventDefault(); - const prev = this.tabs.getPrevTab(); - if (prev) this.tabs.setActive(prev); - return; - } - - // Ctrl/Cmd+1-9 — switch to tab by index - if (mod && e.key >= "1" && e.key <= "9") { - e.preventDefault(); - const idx = parseInt(e.key, 10) - 1; - const order = this.tabs.getTabOrder(); - if (idx < order.length) { - this.tabs.setActive(order[idx]); - } - return; - } - - // Ctrl/Cmd+, — settings - if (mod && e.key === ",") { - e.preventDefault(); - this.settings.open(); - return; - } - - // Ctrl/Cmd+F — find in terminal - if (mod && e.key === "f") { - e.preventDefault(); - this._openTerminalSearch(); - return; - } - - // F11 — toggle fullscreen (Windows/Linux convention) - if (e.key === "F11") { - e.preventDefault(); - if ( - window.runtime && - typeof window.runtime.WindowFullscreen === "function" - ) { - window.runtime.WindowFullscreen(); - } - return; - } - - // Escape — close any open overlays - if (e.key === "Escape") { - this.commandPalette.close(); - // Also exit fullscreen if active (Windows/Linux) - if ( - window.runtime && - typeof window.runtime.WindowUnfullscreen === "function" - ) { - window.runtime.WindowUnfullscreen(); - } - } - - // Ctrl/Cmd+Shift+D — split pane vertically (left/right) - if (mod && e.shiftKey && e.key === "D") { - e.preventDefault(); - this.splitPane("vertical"); - return; - } - - // Ctrl/Cmd+Shift+E — split pane horizontally (top/bottom) - if (mod && e.shiftKey && e.key === "E") { - e.preventDefault(); - this.splitPane("horizontal"); - return; - } - - // Ctrl/Cmd+Shift+R — toggle recording on active session - if (mod && e.shiftKey && e.key === "R") { - e.preventDefault(); - if (this.state.activeSessionId) { - this.recordings.toggle(this.state.activeSessionId); - } - return; - } - - // Ctrl/Cmd+Shift+B — toggle broadcast mode - if (mod && e.shiftKey && e.key === "B") { - e.preventDefault(); - this.toggleBroadcast(); - return; - } - - // Ctrl/Cmd+Shift+S — open snippet library - if (mod && e.shiftKey && e.key === "S") { - e.preventDefault(); - this.snippets.open(); - return; - } - }); - } - - // ── UI event wiring ────────────────────────────────────────────── - - /** - * Binds click handlers to static UI elements. - */ - setupUI() { - var btnEl = document.getElementById("btn-new-connection"); - this._bindClick("btn-new-connection", () => this.profileEditor.openNew()); - this._bindClick("btn-command-palette", () => - this.commandPalette.toggle(), - ); - this._bindClick("btn-settings", () => this.settings.open()); - this._bindClick("btn-import", () => this.importProfiles()); - this._bindClick("btn-export", () => this.exportProfiles()); - this._bindClick("btn-recordings", () => this.recordings.openBrowser()); - this._bindClick("btn-new-tab", () => this.profileEditor.openNew()); - this._bindClick("btn-broadcast", () => this.toggleBroadcast()); - this._bindClick("mode-link", () => this.setMode("link")); - this._bindClick("mode-leviathan", () => this.setMode("leviathan")); - - // Wire the "Local Machine" sidebar button - this.sidebar.onConnectLocal = () => this.connectLocal(); - - // Right-click on broadcast button opens target selector - const broadcastBtn = document.getElementById("btn-broadcast"); - if (broadcastBtn) { - broadcastBtn.addEventListener("contextmenu", (e) => { - e.preventDefault(); - this.selectBroadcastTargets(); - }); - } - this._bindClick("btn-empty-connect", () => this.profileEditor.openNew()); - this._bindClick("btn-empty-quick", () => this.quickConnect()); - - const updateBtn = document.getElementById("status-update"); - if (updateBtn) { - updateBtn.addEventListener("mousedown", (e) => e.preventDefault()); - updateBtn.addEventListener("click", () => - this.settings.openTab("about"), - ); - } - - const searchInput = document.getElementById("profile-search"); - if (searchInput) { - searchInput.addEventListener("input", (e) => - this.sidebar.search(e.target.value), - ); - } - - // Window resize → re-fit active terminal (debounced) - const { debounce } = window.AlchementaryLink; - window.addEventListener( - "resize", - debounce(() => { - this.terminals.fitActive(); - this.splitPanes.refitAll(); - }, 100), - ); - - window.addEventListener("focus", () => { - requestAnimationFrame(() => { - this._focusVisibleTerminal(); - }); - }); - } - - // ── App mode switching ───────────────────────────────────────── - - /** - * Switches the sidebar between Link profiles and Leviathan tools. - * @param {"link"|"leviathan"} mode - */ - setMode(mode) { - const leviathanEnabled = - this.state.preferences?.leviathanEnabled === true; - this.mode = - mode === "leviathan" && leviathanEnabled ? "leviathan" : "link"; - const root = document.getElementById("app"); - if (root) { - root.classList.toggle("mode-leviathan", this.mode === "leviathan"); - } - document - .getElementById("mode-link") - ?.classList.toggle("is-active", this.mode === "link"); - document - .getElementById("mode-leviathan") - ?.classList.toggle("is-active", this.mode === "leviathan"); - - if (this.mode === "leviathan") { - this.leviathan.renderSidebar(); - this.leviathan.openTool("hex", { reuseExisting: true }); - } else { - this.sidebar.render(); - } - } - - // ── Split pane ───────────────────────────────────────────────── - - /** - * Splits the active session panel in the given direction. - * @param {'horizontal'|'vertical'} direction - */ - splitPane(direction) { - if (!this.state.activeSessionId) return; - if (direction === "horizontal") { - this.splitPanes.splitHorizontal(); - } else { - this.splitPanes.splitVertical(); - } - } - - // ── Broadcast mode ───────────────────────────────────────────── - - /** - * Toggles broadcast mode — when active, keystrokes are sent to all - * connected sessions simultaneously. - */ - toggleBroadcast() { - this.state.broadcastMode = !this.state.broadcastMode; - if (!this.state.broadcastMode) { - this.state.broadcastTargets = null; - } - const btn = document.getElementById("btn-broadcast"); - if (btn) { - btn.classList.toggle("is-active", this.state.broadcastMode); - btn.title = this.state.broadcastMode - ? "Broadcast Mode ON — keystrokes sent to all sessions (Ctrl+Shift+B)" - : "Toggle Broadcast Mode (Ctrl+Shift+B)"; - } - this.notifications.info( - this.state.broadcastMode - ? "Broadcast mode enabled — input sent to all sessions" - : "Broadcast mode disabled", - ); - } - - /** - * Opens a modal to select which sessions receive broadcast input. - */ - selectBroadcastTargets() { - const { createElement, escapeHtml } = window.AlchementaryLink; - const sessions = this.state - .getAllSessions() - .filter( - (s) => s.state === "connected" && s.id !== this.state.activeSessionId, - ); - - if (sessions.length === 0) { - this.notifications.error("No other connected sessions to broadcast to"); - return; - } - - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-sm"); - const currentTargets = this.state.broadcastTargets || new Set(); - - let listHtml = ""; - for (const s of sessions) { - const profile = this.state.getProfile(s.profileId); - const label = profile - ? profile.displayName || profile.host - : s.id.substring(0, 8); - const checked = - currentTargets.size === 0 || currentTargets.has(s.id) - ? "checked" - : ""; - listHtml += ` - - `; - } - - modal.innerHTML = ` - - - - `; - - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - - const close = () => { - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(() => backdrop.remove(), 200); - }; - - modal.querySelector(".modal-close").addEventListener("click", close); - modal.querySelector(".broadcast-cancel").addEventListener("click", close); - backdrop.addEventListener("click", (e) => { - if (e.target === backdrop) close(); - }); - - modal.querySelector(".broadcast-all").addEventListener("click", () => { - modal.querySelectorAll(".broadcast-target-check").forEach((cb) => { - cb.checked = true; - }); - }); - - modal.querySelector(".broadcast-apply").addEventListener("click", () => { - const checks = modal.querySelectorAll(".broadcast-target-check"); - const selected = new Set(); - checks.forEach((cb) => { - if (cb.checked) selected.add(cb.dataset.sessionId); - }); - - if (selected.size === 0) { - this.notifications.error("Select at least one target session"); - return; - } - - // If all are selected, use null (broadcast to all) - this.state.broadcastTargets = - selected.size === sessions.length ? null : selected; - if (!this.state.broadcastMode) { - this.toggleBroadcast(); - } else { - const targetLabel = - selected.size === sessions.length - ? "all sessions" - : selected.size + " sessions"; - this.notifications.info("Broadcasting to " + targetLabel); - } - close(); - }); - } - - // ── Connection lifecycle ───────────────────────────────────────── - - /** - * Opens a new session for the given profile ID. - * @param {string} profileId - */ - async connectProfile(profileId, options = {}) { - const profile = this.state.getProfile(profileId); - if (!profile) { - this.notifications.error("Profile not found"); - return; - } - - const existingSessions = this.state.getAllSessions().filter((session) => { - if (session.profileId !== profileId) return false; - return ( - session.state === "connected" || - session.state === "connecting" || - session.state === "reconnecting" - ); - }); - if (existingSessions.length > 0) { - const preferred = - existingSessions.find((s) => s.id === this.state.activeSessionId) || - existingSessions[0]; - const decision = await this._confirmReuseSession(profile, preferred); - if (decision === "existing") { - this.tabs.setActive(preferred.id); - if ( - preferred.protocol !== "raw" && - preferred.protocol !== "github" && - preferred.protocol !== "vnc" && - preferred.protocol !== "irc" - ) { - this.terminals.focusTerminal(preferred.id); - } - return; - } - if (decision !== "new") return; - } - - if ( - !options.skipDirectVNCWarning && - this._requiresDirectVNCWarning(profile) - ) { - const ok = await this.confirmDirectVNCProfile(profile, "connect"); - if (!ok) return; - } - - try { - const sessionInfo = - await window.AlchementaryLink.wailsService.ConnectSession(profileId); - this.state.addSession(sessionInfo); - this.tabs.addTab(sessionInfo); - - // Create the appropriate viewer - const panelId = `panel-${sessionInfo.id}`; - if (profile.protocol === "raw") { - this.rawViewer.createViewer(sessionInfo.id, panelId, profile); - } else if (profile.protocol === "github") { - const container = document.getElementById(panelId); - if (container) { - window.AlchementaryLink.GitHubViewer.createViewer( - sessionInfo.id, - container, - profile.displayName || profile.host, - ); - } - } else if (profile.protocol === "vnc") { - const container = document.getElementById(panelId); - if (container) { - window.AlchementaryLink.VNCViewer.createViewer( - sessionInfo.id, - container, - profile, - ); - } - } else if (profile.protocol === "irc") { - const container = document.getElementById(panelId); - if (container) { - window.AlchementaryLink.IRCViewer.createViewer( - sessionInfo.id, - container, - profile, - ); - } - } else { - this.terminals.createTerminal(sessionInfo.id, panelId, profile); - } - - this.notifications.info( - `Connecting to ${profile.displayName || profile.host}…`, - ); - } catch (err) { - this.notifications.error("Connection failed: " + err); - } - } - - /** - * Opens a local terminal session. - */ - async connectLocal() { - try { - const sessionInfo = await window.AlchementaryLink.wailsService.ConnectLocal(); - this.state.addSession(sessionInfo); - this.tabs.addTab(sessionInfo); - - const panelId = `panel-${sessionInfo.id}`; - const prefs = this.state.preferences || {}; - this.terminals.createTerminal(sessionInfo.id, panelId, { - terminalFontFamily: prefs.defaultFontFamily, - terminalFontSize: prefs.defaultFontSize, - terminalScrollback: prefs.defaultScrollback, - terminalCursorStyle: prefs.defaultCursorStyle, - }); - - this.notifications.info("Opening local terminal…"); - } catch (err) { - this.notifications.error("Local terminal failed: " + err); - } - } - - /** - * Opens a quick connect dialog for ad-hoc connections without saving a profile. - */ - async quickConnect() { - const createElement = window.AlchementaryLink.createElement; - const result = await new Promise((resolve) => { - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-sm"); - modal.innerHTML = ` - - - - `; - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - - const protocolSelect = modal.querySelector(".qc-protocol"); - const portInput = modal.querySelector(".qc-port"); - const usernameGroup = modal.querySelector(".qc-username-group"); - const nickGroup = modal.querySelector(".qc-nick-group"); - const hostInput = modal.querySelector(".qc-host"); - - // Update port default and username visibility when protocol changes - protocolSelect.addEventListener("change", () => { - const proto = protocolSelect.value; - const defaultPorts = { ssh: 22, telnet: 23, raw: 23, irc: 6667 }; - portInput.value = defaultPorts[proto] || 22; - usernameGroup.style.display = - proto === "ssh" || proto === "irc" ? "" : "none"; - nickGroup.style.display = proto === "irc" ? "" : "none"; - }); - - const close = (value) => { - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(() => backdrop.remove(), 200); - resolve(value); - }; - - modal - .querySelector(".modal-close") - .addEventListener("click", () => close(null)); - modal - .querySelector(".qc-cancel") - .addEventListener("click", () => close(null)); - modal.querySelector(".qc-connect").addEventListener("click", () => { - const host = hostInput.value.trim(); - if (!host) { - hostInput.focus(); - return; - } - close({ - protocol: protocolSelect.value, - host, - port: parseInt(portInput.value, 10) || 22, - username: modal.querySelector(".qc-username").value.trim(), - nick: modal.querySelector(".qc-nick").value.trim(), - }); - }); - backdrop.addEventListener("click", (e) => { - if (e.target === backdrop) close(null); - }); - hostInput.addEventListener("keydown", (e) => { - if (e.key === "Enter") modal.querySelector(".qc-connect").click(); - if (e.key === "Escape") close(null); - }); - - setTimeout(() => hostInput.focus(), 50); - }); - - if (!result) return; - - try { - const profile = { - protocol: result.protocol, - host: result.host, - port: result.port, - username: result.username || "", - displayName: result.host, - tags: [], - }; - if (result.protocol === "irc") { - profile.irc = { - nick: result.nick || "link", - realName: "Alchementary Link", - autoJoinRooms: [], - }; - } - const sessionInfo = await window.AlchementaryLink.wailsService.ConnectQuick(profile); - this.state.addSession(sessionInfo); - this.tabs.addTab(sessionInfo); - - const panelId = `panel-${sessionInfo.id}`; - if (result.protocol === "raw") { - this.rawViewer.createViewer(sessionInfo.id, panelId, { - protocol: "raw", - raw: {}, - }); - } else if (result.protocol === "irc") { - const container = document.getElementById(panelId); - if (container) { - window.AlchementaryLink.IRCViewer.createViewer( - sessionInfo.id, - container, - profile, - ); - } - } else { - this.terminals.createTerminal(sessionInfo.id, panelId, { - protocol: result.protocol, - }); - } - - this.notifications.info(`Connecting to ${result.host}…`); - } catch (err) { - this.notifications.error("Quick connect failed: " + err); - } - } - - /** - * Prompts to reuse an existing session for a profile. - * @param {Object} profile - * @param {Object} session - * @returns {Promise<'existing'|'new'|null>} - * @private - */ - _confirmReuseSession(profile, session) { - const createElement = window.AlchementaryLink.createElement; - const label = window.AlchementaryLink.escapeHtml( - (profile.displayName || profile.host || "Untitled").trim(), - ); - const stateLabel = - session.state === "connected" ? "connected" : "connecting"; - - return new Promise((resolve) => { - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-sm"); - modal.innerHTML = ` - - - - `; - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - - const close = (val) => { - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(() => backdrop.remove(), 200); - resolve(val); - }; - - modal - .querySelector(".modal-close") - .addEventListener("click", () => close(null)); - modal - .querySelector(".confirm-cancel") - .addEventListener("click", () => close(null)); - modal - .querySelector(".confirm-ok") - .addEventListener("click", () => close("existing")); - modal - .querySelector(".confirm-new") - .addEventListener("click", () => close("new")); - backdrop.addEventListener("click", (e) => { - if (e.target === backdrop) close(null); - }); - }); - } - - /** - * Reports whether a profile connects directly to VNC without SSH tunnelling. - * @param {Object} profile - * @returns {boolean} - * @private - */ - _requiresDirectVNCWarning(profile) { - return !!( - profile && - profile.protocol === "vnc" && - profile.vnc && - !profile.vnc.sshTunnel - ); - } - - /** - * Warns before saving or connecting direct VNC profiles. - * @param {Object} profile - * @param {"save"|"connect"} action - * @returns {Promise} - */ - confirmDirectVNCProfile(profile, action) { - const { createElement, createModalShell } = window.AlchementaryLink; - const label = ( - profile.displayName || - profile.vnc?.vncHost || - profile.host || - "VNC" - ).trim(); - const actionLabel = action === "connect" ? "Connect" : "Save"; - - return new Promise((resolve) => { - const shell = createModalShell("Direct VNC Connection", "modal-sm"); - const bodyText = createElement("p", "", { - textContent: - label + - " is configured without an SSH tunnel. VNC traffic may be visible to the network unless the server is otherwise protected.", - }); - bodyText.style.fontSize = "var(--text-sm)"; - bodyText.style.color = "var(--text-secondary)"; - shell.body.appendChild(bodyText); - - const cancelBtn = createElement("button", "btn btn-sm", { - textContent: "Cancel", - }); - const okBtn = createElement( - "button", - "btn btn-sm btn-warning direct-vnc-confirm", - { textContent: actionLabel }, - ); - shell.footer.appendChild(cancelBtn); - shell.footer.appendChild(okBtn); - - const close = (value) => { - shell.remove(); - resolve(value); - }; - - shell.closeButton.addEventListener("click", () => close(false)); - cancelBtn.addEventListener("click", () => close(false)); - okBtn.addEventListener("click", () => close(true)); - shell.backdrop.addEventListener("click", (e) => { - if (e.target === shell.backdrop) close(false); - }); - shell.show(); - }); - } - - // ── Import / Export ────────────────────────────────────────────── - - /** - * Triggers the encrypted profile import dialog. - */ - async importProfiles() { - this.exportImport.showImportModal(); - } - - /** - * Triggers the encrypted profile export dialog. - */ - async exportProfiles() { - this.exportImport.showExportModal(); - } - - // ── Status bar ─────────────────────────────────────────────────── - - /** - * Updates the status bar with current session info. - */ - updateStatusBar() { - const connEl = document.getElementById("status-connection"); - const countEl = document.getElementById("status-session-count"); - const latencyEl = document.getElementById("status-latency"); - const vaultEl = document.getElementById("status-vault"); - const updateEl = document.getElementById("status-update"); - if (!connEl) return; - - const sessions = this.state.getAllSessions(); - const connected = sessions.filter((s) => s.state === "connected").length; - const total = sessions.length; - - const activeSession = this.state.activeSessionId - ? this.state.getSession(this.state.activeSessionId) - : null; - - if (activeSession) { - connEl.innerHTML = ` ${stateLabel(activeSession.state)}`; - } else { - connEl.innerHTML = - ' No active session'; - } - - // Latency indicator — only show for connected SSH sessions. - if (latencyEl) { - if ( - activeSession && - activeSession.state === "connected" && - activeSession.protocol === "ssh" - ) { - this._startLatencyPing(activeSession.id); - } else { - this._stopLatencyPing(); - latencyEl.textContent = ""; - } - } - - if (countEl) countEl.textContent = `${connected}/${total} sessions`; - if (vaultEl) { - vaultEl.textContent = this.state.secretStoreUnlocked - ? "🔓 Unlocked" - : "🔒 Locked"; - } - - if (updateEl) { - if (this.updateInfo && this.updateInfo.available) { - const version = this.updateInfo.latestVersion - ? ` (${this.updateInfo.latestVersion})` - : ""; - updateEl.textContent = `Update available${version}`; - updateEl.classList.remove("is-hidden"); - } else { - updateEl.textContent = "Update available"; - updateEl.classList.add("is-hidden"); - } - } - } - - _startLatencyPing(sessionID) { - // Do not queue overlapping backend requests for the active session. - if ( - this._latencySessionID === sessionID && - (this._latencyTimer || this._latencyPingInFlight) - ) { - return; - } - this._stopLatencyPing(); - this._latencySessionID = sessionID; - const generation = this._latencyGeneration; - - const isCurrent = () => - this._latencyGeneration === generation && - this._latencySessionID === sessionID; - - const scheduleNext = () => { - if (!isCurrent() || this._latencyTimer) return; - this._latencyTimer = setTimeout(() => { - this._latencyTimer = null; - void doPing(); - }, 10000); - }; - - const doPing = async () => { - if (!isCurrent()) return; - if (!window.AlchementaryLink?.wailsService) { - scheduleNext(); - return; - } - - this._latencyPingInFlight = true; - let succeeded = false; - try { - const ms = await window.AlchementaryLink.wailsService.PingSession(sessionID); - if (!isCurrent()) return; - succeeded = true; - const el = document.getElementById("status-latency"); - if (el) { - el.textContent = ms + " ms"; - el.className = - ms < 100 - ? "latency-good" - : ms < 250 - ? "latency-warn" - : "latency-bad"; - } - } catch (_) { - if (!isCurrent()) return; - // Session may have disconnected — clear indicator. - const el = document.getElementById("status-latency"); - if (el) { - el.textContent = ""; - el.className = ""; - } - this._stopLatencyPing(); - } finally { - if (isCurrent()) { - this._latencyPingInFlight = false; - if (succeeded) scheduleNext(); - } - } - }; - - void doPing(); - } - - _stopLatencyPing() { - this._latencyGeneration = (this._latencyGeneration || 0) + 1; - if (this._latencyTimer) { - clearTimeout(this._latencyTimer); - this._latencyTimer = null; - } - this._latencyPingInFlight = false; - this._latencySessionID = null; - } - - async checkForUpdates(silent) { - if (!window.AlchementaryLink?.wailsService) { - return; - } - - try { - const info = await window.AlchementaryLink.wailsService.CheckForUpdates(); - this.setUpdateInfo(info); - if (!silent && info && info.available) { - this.notifications.info( - `Update available: ${info.latestVersion || "new version"}`, - ); - } - } catch (err) { - if (!silent) { - this.notifications.error("Update check failed: " + err); - } - } - } - - setUpdateInfo(info) { - this.updateInfo = info || null; - this.updateStatusBar(); - } - - // ── Internal helpers ───────────────────────────────────────────── - - /** - * Applies loaded preferences to the UI. - * @private - */ - _applyPreferences() { - const prefs = this.state.preferences; - - // Sidebar width (drives the grid column width). - // Use the CSS variable so the workspace never sits underneath the sidebar. - const root = document.documentElement; - const sidebar = document.getElementById("sidebar"); - const parsedSidebarWidth = Number(prefs.sidebarWidth); - if (Number.isFinite(parsedSidebarWidth) && parsedSidebarWidth > 0) { - const clampedWidth = Math.max( - 180, - Math.min(500, Math.round(parsedSidebarWidth)), - ); - root.style.setProperty("--sidebar-width", `${clampedWidth}px`); - } else { - root.style.removeProperty("--sidebar-width"); - } - if (sidebar) { - sidebar.style.width = ""; - } - - // Status bar visibility - const statusBar = document.getElementById("status-bar"); - if (statusBar) { - statusBar.style.display = - prefs.showStatusBar === false ? "none" : "flex"; - } - - // Leviathan is an opt-in mode, revealed from the About window. - const leviathanEnabled = prefs.leviathanEnabled === true; - const leviathanButton = document.getElementById("mode-leviathan"); - if (leviathanButton) { - leviathanButton.hidden = !leviathanEnabled; - } - if (!leviathanEnabled && this.mode === "leviathan") { - this.setMode("link"); - } - - // Refit any visible terminal after geometry changes. - requestAnimationFrame(() => { - this.terminals.fitActive(); - this.splitPanes.refitAll(); - }); - } - - /** - * Determines whether an error is a common authentication or host key issue. - * @param {string} message - * @returns {Object|null} - * @private - */ - _classifySessionError(message) { - const msg = (message || "").toLowerCase(); - - const authPatterns = [ - "unable to authenticate", - "authentication failed", - "permission denied", - "no supported methods", - ]; - const hostKeyPatterns = ["host key", "fingerprint"]; - - if (authPatterns.some((p) => msg.includes(p))) { - return { - type: "auth", - title: "Authentication Failed", - message: - "The server rejected the provided credentials. Check username, password, keys, and auth methods.", - actions: ["edit", "retry"], - }; - } - - if (hostKeyPatterns.some((p) => msg.includes(p))) { - return { - type: "hostkey", - title: "Host Key Verification Failed", - message: - "The server's host key could not be verified. Manage known hosts or update the profile's policy.", - actions: ["knownhosts", "edit", "retry"], - }; - } - - return null; - } - - /** - * Shows a guided action modal for common session errors. - * @param {string} sessionId - * @param {string} message - * @private - */ - _handleSessionError(sessionId, message) { - const info = this._classifySessionError(message); - if (!info) return; - - const session = this.state.getSession(sessionId); - const label = session?.tabName || session?.host || "Session"; - const body = `${info.message}\n\n${message}`; - this._showGuidedErrorModal( - info.title, - label, - body, - (action) => { - if (action === "edit" && session?.profileId) { - this.profileEditor.openEdit(session.profileId); - } - if (action === "retry") { - this.tabs._reconnect(sessionId); - } - if (action === "knownhosts") { - this.settings.openTab("knownhosts"); - } - }, - info.actions, - ); - } - - /** - * Builds and displays a modal with guided action buttons. - * @param {string} title - * @param {string} label - * @param {string} message - * @param {Function} onAction - * @param {Array} actions - * @private - */ - _showGuidedErrorModal(title, label, message, onAction, actions) { - const createElement = window.AlchementaryLink.createElement; - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-sm"); - const safeLabel = window.AlchementaryLink.escapeHtml(label || "Session"); - - modal.innerHTML = ` - - - - `; - - const footer = modal.querySelector(".modal-footer"); - const addActionBtn = (label, action, primary) => { - const btn = createElement( - "button", - `btn btn-sm${primary ? " btn-primary" : ""}`, - { - textContent: label, - }, - ); - btn.addEventListener("click", () => close(action)); - footer.appendChild(btn); - }; - - addActionBtn("Close", null, false); - if (actions.includes("knownhosts")) - addActionBtn("Known Hosts", "knownhosts", false); - if (actions.includes("edit")) addActionBtn("Edit Profile", "edit", false); - if (actions.includes("retry")) addActionBtn("Retry", "retry", true); - - const close = (action) => { - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(() => backdrop.remove(), 200); - if (action) onAction(action); - }; - - modal - .querySelector(".modal-close") - .addEventListener("click", () => close(null)); - backdrop.addEventListener("click", (e) => { - if (e.target === backdrop) close(null); - }); - - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - } - - /** - * Opens an inline search bar for the active terminal. - * @private - */ - _openTerminalSearch() { - const sessionId = this.state.activeSessionId; - if (!sessionId) return; - - const panel = document.getElementById(`panel-${sessionId}`); - if (!panel) return; - - // If search bar already exists, just focus the input - const existing = panel.querySelector(".terminal-search"); - if (existing) { - const input = existing.querySelector("input"); - if (input) input.focus(); - return; - } - - const bar = document.createElement("div"); - bar.className = "terminal-search"; - - const input = document.createElement("input"); - input.type = "text"; - input.placeholder = "Find in terminal…"; - input.className = "terminal-search-input"; - - const count = document.createElement("span"); - count.className = "terminal-search-count"; - count.textContent = ""; - - const prevBtn = document.createElement("button"); - prevBtn.className = "btn btn-xs btn-ghost"; - prevBtn.textContent = "▲"; - prevBtn.title = "Previous match (Shift+Enter)"; - - const nextBtn = document.createElement("button"); - nextBtn.className = "btn btn-xs btn-ghost"; - nextBtn.textContent = "▼"; - nextBtn.title = "Next match (Enter)"; - - const caseSensBtn = document.createElement("button"); - caseSensBtn.className = "btn btn-xs btn-ghost terminal-search-toggle"; - caseSensBtn.textContent = "Aa"; - caseSensBtn.title = "Toggle case sensitivity"; - let caseSensitive = false; - - const regexBtn = document.createElement("button"); - regexBtn.className = "btn btn-xs btn-ghost terminal-search-toggle"; - regexBtn.textContent = ".*"; - regexBtn.title = "Toggle regular expression"; - let useRegex = false; - - const closeBtn = document.createElement("button"); - closeBtn.className = "btn btn-xs btn-ghost"; - closeBtn.textContent = "✕"; - closeBtn.title = "Close (Escape)"; - - bar.appendChild(input); - bar.appendChild(count); - bar.appendChild(prevBtn); - bar.appendChild(nextBtn); - bar.appendChild(caseSensBtn); - bar.appendChild(regexBtn); - bar.appendChild(closeBtn); - - const searchOpts = () => ({ caseSensitive, regex: useRegex }); - - let matchIndex = 0; - let totalMatches = 0; - - const updateCount = (found) => { - if (!input.value) { - count.textContent = ""; - matchIndex = 0; - totalMatches = 0; - return; - } - if (found) { - matchIndex++; - // Count total matches by searching the full buffer - const entry = this.terminals.terminals.get(sessionId); - if (entry) { - const buffer = entry.term.buffer.active; - let total = 0; - const query = input.value; - for (let i = 0; i < buffer.length; i++) { - const line = buffer.getLine(i); - if (!line) continue; - const text = line.translateToString(true); - if (useRegex) { - try { - const re = new RegExp(query, caseSensitive ? "g" : "gi"); - const m = text.match(re); - if (m) total += m.length; - } catch (_) { - /* invalid regex */ - } - } else { - const searchText = caseSensitive ? text : text.toLowerCase(); - const searchQuery = caseSensitive ? query : query.toLowerCase(); - let pos = 0; - while ((pos = searchText.indexOf(searchQuery, pos)) !== -1) { - total++; - pos += searchQuery.length; - } - } - } - totalMatches = total; - if (matchIndex > totalMatches) matchIndex = 1; - count.textContent = matchIndex + " of " + totalMatches; - } else { - count.textContent = "Match found"; - } - } else { - matchIndex = 0; - totalMatches = 0; - count.textContent = "No matches"; - } - count.style.color = found ? "" : "var(--error)"; - }; - - const doSearch = () => { - const found = this.terminals.search( - sessionId, - input.value, - searchOpts(), - ); - updateCount(found); - }; - const doPrev = () => { - matchIndex = Math.max(0, matchIndex - 2); - const found = this.terminals.searchPrevious( - sessionId, - input.value, - searchOpts(), - ); - updateCount(found); - }; - - const closeSearch = () => { - // Clear search decorations - const entry = this.terminals.terminals.get(sessionId); - if (entry && entry.searchAddon) { - entry.searchAddon.clearDecorations(); - } - bar.classList.remove("is-visible"); - setTimeout(() => bar.remove(), 200); - this.terminals.focusTerminal(sessionId); - }; - - input.addEventListener("input", doSearch); - input.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - e.shiftKey ? doPrev() : doSearch(); - } - if (e.key === "Escape") { - e.preventDefault(); - closeSearch(); - } - }); - - nextBtn.addEventListener("click", doSearch); - prevBtn.addEventListener("click", doPrev); - closeBtn.addEventListener("click", closeSearch); - - caseSensBtn.addEventListener("click", () => { - caseSensitive = !caseSensitive; - caseSensBtn.classList.toggle("is-active", caseSensitive); - doSearch(); - }); - - regexBtn.addEventListener("click", () => { - useRegex = !useRegex; - regexBtn.classList.toggle("is-active", useRegex); - doSearch(); - }); - - panel.appendChild(bar); - // Trigger the entrance animation - requestAnimationFrame(() => { - bar.classList.add("is-visible"); - input.focus(); - }); - } - - /** - * Safely binds a click handler to an element by ID (no-op if the element does not exist). - * @param {string} id - Element ID. - * @param {Function} handler - * @private - */ - _bindClick(id, handler) { - const el = document.getElementById(id); - if (el) { - el.addEventListener("click", (e) => { - try { - handler(e); - } catch (err) { - console.error("Click handler error for", id, err); - } - }); - } - } - - /** - * Focuses the currently visible xterm instance if one is active. - * @private - */ - _focusVisibleTerminal() { - const sessionId = this.state.activeSessionId; - if (!sessionId) return; - this.terminals.focusTerminal(sessionId); - } - - /** - * Shows a simple text-input prompt modal (replaces window.prompt which - * is unavailable in WKWebView). - * @param {string} title - Modal heading. - * @param {string} label - Label above the input. - * @param {string} [placeholder] - Placeholder text. - * @returns {Promise} Resolves with the value or null if cancelled. - * @private - */ - _inputPrompt(title, label, placeholder) { - const createElement = window.AlchementaryLink.createElement; - return new Promise((resolve) => { - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-sm"); - modal.innerHTML = ` - - - - `; - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => backdrop.classList.add("is-visible")); - - const input = modal.querySelector("input"); - const close = (value) => { - backdrop.classList.remove("is-visible"); - setTimeout(() => backdrop.remove(), 200); - resolve(value); - }; - - modal - .querySelector(".modal-close") - .addEventListener("click", () => close(null)); - modal - .querySelector(".prompt-cancel") - .addEventListener("click", () => close(null)); - modal - .querySelector(".prompt-ok") - .addEventListener("click", () => close(input.value)); - backdrop.addEventListener("click", (e) => { - if (e.target === backdrop) close(null); - }); - input.addEventListener("keydown", (e) => { - if (e.key === "Enter") close(input.value); - if (e.key === "Escape") close(null); - }); - - setTimeout(() => input.focus(), 50); - }); - } - } - - // ── Bootstrap ──────────────────────────────────────────────────────── - - window.AlchementaryLink.App = App; - - document.addEventListener("DOMContentLoaded", () => { - try { - const app = new App(); - app.init().catch((err) => { - console.error("App initialisation failed:", err); - }); - } catch (err) { - console.error("App constructor failed:", err); - } - }); -})(); diff --git a/frontend/src/js/commands.js b/frontend/src/js/commands.js deleted file mode 100644 index 0ad9a38..0000000 --- a/frontend/src/js/commands.js +++ /dev/null @@ -1,446 +0,0 @@ -(function () { - /** - * Alchementary Link — command palette (Ctrl+K). - * Provides a searchable overlay for quick access to application commands, - * profile connections, and utility actions. - */ - - const { createElement, escapeHtml, fuzzyMatch, $ } = window.AlchementaryLink; - - class CommandPalette { - /** - * @param {App} app - */ - constructor(app) { - this.app = app; - - /** @type {HTMLElement|null} */ - this._overlay = null; - - /** @type {string} Current filter query. */ - this._query = ""; - - /** @type {number} Index of the highlighted command. */ - this._selectedIndex = 0; - } - - // ── Public API ─────────────────────────────────────────────────── - - /** - * Toggles the command palette open/closed. - */ - toggle() { - if (this._overlay) { - this.close(); - } else { - this.open(); - } - } - - /** - * Opens the command palette. - */ - open() { - if (this._overlay) return; - this._query = ""; - this._selectedIndex = 0; - this._render(); - } - - /** - * Closes the command palette. - */ - close() { - if (this._overlay) { - this._overlay.remove(); - this._overlay = null; - } - } - - // ── Command definitions ────────────────────────────────────────── - - /** - * Returns the full list of available commands. - * @returns {Array<{id: string, label: string, shortcut?: string, section?: string, action: Function}>} - */ - getCommands() { - const cmds = []; - - // Profile connections - const profiles = this.app.state.profiles || []; - for (const p of profiles) { - cmds.push({ - id: `connect-${p.id}`, - label: `Connect to ${p.displayName || p.host}`, - section: "Connections", - detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`, - action: () => this.app.connectProfile(p.id), - }); - } - - // New connections - cmds.push({ - id: "new-ssh", - label: "New SSH Connection", - section: "Actions", - shortcut: "⌘N", - action: () => this.app.profileEditor.openNew("ssh"), - }); - cmds.push({ - id: "new-telnet", - label: "New Telnet Connection", - section: "Actions", - action: () => this.app.profileEditor.openNew("telnet"), - }); - cmds.push({ - id: "new-raw", - label: "New RAW Connection", - section: "Actions", - action: () => this.app.profileEditor.openNew("raw"), - }); - - // Quick connect - cmds.push({ - id: "quick-connect", - label: "Quick Connect…", - section: "Actions", - action: () => this.app.quickConnect(), - }); - - // Local terminal - cmds.push({ - id: "local-terminal", - label: "Open Local Terminal", - section: "Actions", - action: () => this.app.connectLocal(), - }); - - // Tab actions - cmds.push({ - id: "close-tab", - label: "Close Tab", - section: "Tabs", - shortcut: "⌘W", - action: () => this.app.tabs.closeActive(), - }); - cmds.push({ - id: "next-tab", - label: "Next Tab", - section: "Tabs", - shortcut: "⌘Tab", - action: () => { - const next = this.app.tabs.getNextTab(); - if (next) this.app.tabs.setActive(next); - }, - }); - cmds.push({ - id: "prev-tab", - label: "Previous Tab", - section: "Tabs", - shortcut: "⌘⇧Tab", - action: () => { - const prev = this.app.tabs.getPrevTab(); - if (prev) this.app.tabs.setActive(prev); - }, - }); - - // Settings & utility - cmds.push({ - id: "settings", - label: "Settings", - section: "Application", - action: () => this.app.settings.open(), - }); - cmds.push({ - id: "import-profiles", - label: "Import Profiles", - section: "Application", - action: () => this.app.importProfiles(), - }); - cmds.push({ - id: "export-profiles", - label: "Export Profiles", - section: "Application", - action: () => this.app.exportProfiles(), - }); - - // Secret store - if (this.app.state.secretStoreUnlocked) { - cmds.push({ - id: "lock-secrets", - label: "Lock Secret Store", - section: "Security", - action: async () => { - try { - await window.AlchementaryLink.wailsService.LockSecretStore(); - this.app.state.setSecretStoreState(false); - this.app.notifications.info("Secret store locked"); - } catch (err) { - this.app.notifications.error("Failed to lock: " + err); - } - }, - }); - } else { - cmds.push({ - id: "unlock-secrets", - label: "Unlock Secret Store", - section: "Security", - action: async () => { - try { - const pw = - await this.app.secretPrompt.promptMasterPassword(false); - await window.AlchementaryLink.wailsService.UnlockSecretStore(pw); - this.app.state.setSecretStoreState(true); - this.app.notifications.success("Secret store unlocked"); - } catch (err) { - if (err.message !== "User cancelled") { - this.app.notifications.error("Unlock failed: " + err); - } - } - }, - }); - } - - // SSH Key Manager - cmds.push({ - id: "ssh-keys", - label: "SSH Key Manager", - section: "Security", - action: () => this.app.sshKeys.open(), - }); - - // Trigger Rules - cmds.push({ - id: "trigger-rules", - label: "Trigger Rules", - section: "Terminal", - action: () => this.app.triggers.open(), - }); - - // Terminal actions (only when a session is active) - if (this.app.state.activeSessionId) { - cmds.push({ - id: "split-vertical", - label: "Split Pane Right", - section: "Terminal", - shortcut: "⌘⇧D", - action: () => this.app.splitPane("vertical"), - }); - cmds.push({ - id: "split-horizontal", - label: "Split Pane Down", - section: "Terminal", - shortcut: "⌘⇧E", - action: () => this.app.splitPane("horizontal"), - }); - cmds.push({ - id: "clear-terminal", - label: "Clear Terminal", - section: "Terminal", - action: () => - this.app.terminals.clearTerminal(this.app.state.activeSessionId), - }); - cmds.push({ - id: "reset-terminal", - label: "Reset Terminal", - section: "Terminal", - action: () => - this.app.terminals.resetTerminal(this.app.state.activeSessionId), - }); - } - - return cmds; - } - - /** - * Returns commands filtered and sorted by the current query. - * @param {string} query - * @returns {Array} - */ - filter(query) { - if (!query) return this.getCommands(); - return this.getCommands() - .map((cmd) => { - const result = fuzzyMatch( - cmd.label + " " + (cmd.detail || ""), - query, - ); - return { ...cmd, ...result }; - }) - .filter((c) => c.match) - .sort((a, b) => b.score - a.score); - } - - /** - * Executes a command and closes the palette. - * @param {Object} command - */ - execute(command) { - this.close(); - if (command && typeof command.action === "function") { - command.action(); - } - } - - // ── Rendering ──────────────────────────────────────────────────── - - /** @private */ - _render() { - const overlay = createElement("div", "palette-overlay"); - const palette = createElement("div", "palette"); - - // Search input - const input = createElement("input", "palette-input", { - type: "text", - placeholder: "Type a command…", - autocomplete: "off", - spellcheck: "false", - }); - - palette.appendChild(input); - - // Results list - const list = createElement("div", "palette-list"); - palette.appendChild(list); - - overlay.appendChild(palette); - document.body.appendChild(overlay); - this._overlay = overlay; - - // Initial render - this._renderList(list, this.filter("")); - - // Focus the input - input.focus(); - - // Event handlers - input.addEventListener("input", () => { - this._query = input.value; - this._selectedIndex = 0; - this._renderList(list, this.filter(this._query)); - }); - - input.addEventListener("keydown", (e) => { - const items = list.querySelectorAll(".palette-item"); - switch (e.key) { - case "ArrowDown": - e.preventDefault(); - this._selectedIndex = Math.min( - this._selectedIndex + 1, - items.length - 1, - ); - this._highlightItem(items); - break; - case "ArrowUp": - e.preventDefault(); - this._selectedIndex = Math.max(this._selectedIndex - 1, 0); - this._highlightItem(items); - break; - case "Enter": { - e.preventDefault(); - const selected = items[this._selectedIndex]; - if (selected && selected._command) { - this.execute(selected._command); - } - break; - } - case "Escape": - e.preventDefault(); - this.close(); - break; - } - }); - - // Click outside to close - overlay.addEventListener("click", (e) => { - if (e.target === overlay) this.close(); - }); - } - - /** - * Renders the filtered command list. - * @param {HTMLElement} list - * @param {Array} commands - * @private - */ - _renderList(list, commands) { - list.innerHTML = ""; - let currentSection = null; - - if (commands.length === 0) { - const empty = createElement("div", "palette-empty", { - textContent: "No commands found", - }); - list.appendChild(empty); - return; - } - - for (let i = 0; i < commands.length; i++) { - const cmd = commands[i]; - - // Section header - if (cmd.section && cmd.section !== currentSection) { - currentSection = cmd.section; - const sectionEl = createElement("div", "palette-section", { - textContent: cmd.section, - }); - list.appendChild(sectionEl); - } - - const item = createElement( - "div", - `palette-item ${i === this._selectedIndex ? "palette-item-active" : ""}`, - ); - item._command = cmd; - - // Label - const label = createElement("span", "palette-item-label", { - textContent: cmd.label, - }); - item.appendChild(label); - - // Detail - if (cmd.detail) { - const detail = createElement("span", "palette-item-detail", { - textContent: cmd.detail, - }); - item.appendChild(detail); - } - - // Shortcut badge - if (cmd.shortcut) { - const badge = createElement("span", "palette-item-shortcut", { - textContent: cmd.shortcut, - }); - item.appendChild(badge); - } - - item.addEventListener("click", () => this.execute(cmd)); - item.addEventListener("mouseenter", () => { - this._selectedIndex = i; - this._highlightItem(list.querySelectorAll(".palette-item")); - }); - - list.appendChild(item); - } - } - - /** - * Highlights the currently selected item and scrolls it into view. - * @param {NodeListOf} items - * @private - */ - _highlightItem(items) { - items.forEach((el, i) => { - el.classList.toggle("palette-item-active", i === this._selectedIndex); - }); - const active = items[this._selectedIndex]; - if (active) { - active.scrollIntoView({ block: "nearest" }); - } - } - } - - window.AlchementaryLink = window.AlchementaryLink || {}; - window.AlchementaryLink.CommandPalette = CommandPalette; -})(); diff --git a/frontend/src/js/github.js b/frontend/src/js/github.js deleted file mode 100644 index 3a6c052..0000000 --- a/frontend/src/js/github.js +++ /dev/null @@ -1,497 +0,0 @@ -(function () { - /** - * Alchementary Link — GitHub repository viewer. - * Renders a scrollable list of repositories grouped by owner/organisation, - * sorted by last commit date (newest first). Automatically refreshes via - * Wails events emitted by the Go backend. - */ - - const { createElement, escapeHtml, relativeTime, $ } = window.AlchementaryLink; - - class GitHubViewer { - constructor() { - /** @type {Map} sessionID → viewer */ - this._viewers = new Map(); - } - - /** - * Creates a viewer panel for a GitHub session inside the given container. - * @param {string} sessionID - * @param {HTMLElement} container - The .session-panel element. - * @param {string} profileName - */ - createViewer(sessionID, container, profileName) { - if (this._viewers.has(sessionID)) return; - - const viewer = new GitHubViewerInstance( - sessionID, - container, - profileName, - ); - this._viewers.set(sessionID, viewer); - viewer.render(); - } - - /** - * Destroys a viewer for the given session. - * @param {string} sessionID - */ - destroyViewer(sessionID) { - const viewer = this._viewers.get(sessionID); - if (viewer) { - viewer.destroy(); - this._viewers.delete(sessionID); - } - } - - /** - * Handles incoming repo data from the backend event. - * @param {string} sessionID - * @param {string} login - The authenticated GitHub username. - * @param {Array} repos - */ - updateRepos(sessionID, login, repos) { - const viewer = this._viewers.get(sessionID); - if (viewer) { - viewer.setData(login, repos); - } - } - - /** - * Updates the status line for a session. - * @param {string} sessionID - * @param {string} message - */ - setStatus(sessionID, message) { - const viewer = this._viewers.get(sessionID); - if (viewer) { - viewer.setStatus(message); - } - } - - /** - * Handles an error event for a session. - * @param {string} sessionID - * @param {string} message - */ - showError(sessionID, message) { - const viewer = this._viewers.get(sessionID); - if (viewer) { - viewer.showError(message); - } - } - } - - class GitHubViewerInstance { - /** - * @param {string} sessionID - * @param {HTMLElement} container - * @param {string} profileName - */ - constructor(sessionID, container, profileName) { - this.sessionID = sessionID; - this.container = container; - this.profileName = profileName; - - /** @type {string} */ - this._login = ""; - - /** @type {Array} */ - this._repos = []; - - /** @type {string} */ - this._filter = ""; - - /** @type {HTMLElement|null} */ - this._el = null; - - /** @type {HTMLElement|null} */ - this._listEl = null; - - /** @type {HTMLElement|null} */ - this._statusEl = null; - - /** @type {number|null} */ - this._refreshTimer = null; - } - - render() { - this.container.innerHTML = ""; - - const wrapper = createElement("div", "github-viewer"); - - // ── Header bar ────────────────────────────────────────── - const header = createElement("div", "github-viewer-header"); - - const titleArea = createElement("div", "github-viewer-title-area"); - const icon = createElement("span", "github-viewer-icon"); - icon.innerHTML = this._githubSVG(); - titleArea.appendChild(icon); - - const titleText = createElement("span", "github-viewer-title"); - titleText.textContent = this.profileName || "GitHub"; - titleArea.appendChild(titleText); - header.appendChild(titleArea); - - // Search / filter - const searchWrap = createElement("div", "github-viewer-search"); - const searchInput = createElement("input", "github-viewer-search-input", { - type: "text", - placeholder: "Filter repositories…", - }); - searchInput.addEventListener("input", (e) => { - this._filter = e.target.value.toLowerCase(); - this._renderList(); - }); - searchWrap.appendChild(searchInput); - header.appendChild(searchWrap); - - // Refresh button - const refreshBtn = createElement("button", "github-viewer-refresh", { - title: "Refresh now", - }); - refreshBtn.innerHTML = "↻"; - refreshBtn.addEventListener("click", () => this._manualRefresh()); - header.appendChild(refreshBtn); - - wrapper.appendChild(header); - - // ── Status bar ────────────────────────────────────────── - this._statusEl = createElement("div", "github-viewer-status"); - this._statusEl.textContent = "Connecting…"; - wrapper.appendChild(this._statusEl); - - // ── Repo list ─────────────────────────────────────────── - this._listEl = createElement("div", "github-viewer-list"); - wrapper.appendChild(this._listEl); - - this.container.appendChild(wrapper); - this._el = wrapper; - } - - /** - * Updates the viewer with fresh data. - * @param {string} login - * @param {Array} repos - */ - setData(login, repos) { - this._login = login; - this._repos = repos || []; - this._updateStatus(); - this._renderList(); - } - - /** - * Updates the status line with a custom message. - * @param {string} message - */ - setStatus(message) { - if (!this._statusEl) return; - this._statusEl.classList.remove("github-viewer-status-error"); - this._statusEl.textContent = message || ""; - } - - /** - * Displays an error message in the status bar. - * @param {string} message - */ - showError(message) { - if (this._statusEl) { - this._statusEl.textContent = `⚠ ${message}`; - this._statusEl.classList.add("github-viewer-status-error"); - } - } - - destroy() { - if (this._refreshTimer) { - clearInterval(this._refreshTimer); - this._refreshTimer = null; - } - if (this._el) { - this._el.remove(); - this._el = null; - } - } - - // ── Private ────────────────────────────────────────────────── - - _updateStatus() { - if (!this._statusEl) return; - this._statusEl.classList.remove("github-viewer-status-error"); - - const count = this._repos.length; - const now = new Date().toLocaleTimeString(); - this._statusEl.textContent = `${this._login} · ${count} repositor${count === 1 ? "y" : "ies"} · updated ${now}`; - } - - _renderList() { - if (!this._listEl) return; - this._listEl.innerHTML = ""; - - // Filter - let filtered = this._repos; - if (this._filter) { - filtered = this._repos.filter( - (r) => - r.fullName.toLowerCase().includes(this._filter) || - (r.description || "").toLowerCase().includes(this._filter) || - (r.language || "").toLowerCase().includes(this._filter), - ); - } - - if (filtered.length === 0) { - const empty = createElement("div", "github-viewer-empty"); - empty.textContent = this._filter - ? "No matching repositories." - : "No repositories found."; - this._listEl.appendChild(empty); - return; - } - - const commitTimestamp = (repo) => { - const raw = repo.latestCommitAt || repo.pushedAt || repo.updatedAt || ""; - const ts = new Date(raw).getTime(); - return Number.isNaN(ts) ? 0 : ts; - }; - - // Group by owner - const groups = new Map(); - for (const repo of filtered) { - const owner = repo.owner || "unknown"; - if (!groups.has(owner)) groups.set(owner, []); - groups.get(owner).push(repo); - } - - // Sort repos within each group by latest commit (newest first) - for (const repos of groups.values()) { - repos.sort((a, b) => commitTimestamp(b) - commitTimestamp(a)); - } - - // Sort groups by most recent commit in each group (login first on ties) - const sortedKeys = [...groups.keys()].sort((a, b) => { - const aRepos = groups.get(a) || []; - const bRepos = groups.get(b) || []; - const aMax = aRepos.length > 0 ? commitTimestamp(aRepos[0]) : 0; - const bMax = bRepos.length > 0 ? commitTimestamp(bRepos[0]) : 0; - if (aMax !== bMax) return bMax - aMax; - if (a === this._login) return -1; - if (b === this._login) return 1; - return a.localeCompare(b); - }); - - for (const owner of sortedKeys) { - const repos = groups.get(owner); - - // Group header - const groupEl = createElement("div", "github-group"); - const groupHeader = createElement("div", "github-group-header"); - groupHeader.innerHTML = ` - ${escapeHtml(owner)} - ${repos.length} - `; - groupEl.appendChild(groupHeader); - - // Repo cards - for (const repo of repos) { - groupEl.appendChild(this._renderRepoCard(repo)); - } - - this._listEl.appendChild(groupEl); - } - } - - /** - * Renders a single repository card. - * @param {Object} repo - * @returns {HTMLElement} - */ - _renderRepoCard(repo) { - const card = createElement("div", "github-repo-card"); - card.addEventListener("click", () => { - if (repo.htmlURL) window.open(repo.htmlURL, "_blank"); - }); - - // Top row: name + badges - const topRow = createElement("div", "github-repo-top"); - const nameEl = createElement("span", "github-repo-name"); - nameEl.textContent = repo.name; - topRow.appendChild(nameEl); - - if (repo.private) { - const badge = createElement( - "span", - "github-repo-badge github-repo-private", - ); - badge.textContent = "Private"; - topRow.appendChild(badge); - } - if (repo.fork) { - const badge = createElement( - "span", - "github-repo-badge github-repo-fork", - ); - badge.textContent = "Fork"; - topRow.appendChild(badge); - } - if (repo.archived) { - const badge = createElement( - "span", - "github-repo-badge github-repo-archived", - ); - badge.textContent = "Archived"; - topRow.appendChild(badge); - } - - const actions = createElement("div", "github-repo-actions"); - - const downloadBtn = createElement("button", "github-repo-download", { - textContent: "ZIP", - title: "Download repository as zip", - }); - downloadBtn.addEventListener("click", (e) => { - e.stopPropagation(); - this._downloadRepo(repo); - }); - actions.appendChild(downloadBtn); - - const visitLink = createElement("button", "github-repo-visit", { - textContent: "Visit", - title: "Open repository in browser", - }); - visitLink.addEventListener("click", (e) => { - e.stopPropagation(); - if (repo.htmlURL) { - if (window.runtime && window.runtime.BrowserOpenURL) { - window.runtime.BrowserOpenURL(repo.htmlURL); - } else { - window.open(repo.htmlURL, "_blank"); - } - } - }); - actions.appendChild(visitLink); - - topRow.appendChild(actions); - - card.appendChild(topRow); - - if (repo.latestCommitter && repo.latestCommitAt) { - const commitLine = createElement("div", "github-repo-commit"); - const when = new Date(repo.latestCommitAt); - const whenText = isNaN(when.getTime()) - ? repo.latestCommitAt - : when.toLocaleString(); - commitLine.textContent = `Last commit: ${whenText} by ${repo.latestCommitter}`; - card.appendChild(commitLine); - } - - // Description - if (repo.description) { - const desc = createElement("div", "github-repo-desc"); - desc.textContent = repo.description; - card.appendChild(desc); - } - - // Meta row: language, stars, pushed - const meta = createElement("div", "github-repo-meta"); - - if (repo.language) { - const lang = createElement("span", "github-repo-lang"); - lang.innerHTML = ` ${escapeHtml(repo.language)}`; - meta.appendChild(lang); - } - - if (repo.stars > 0) { - const stars = createElement("span", "github-repo-stars"); - stars.textContent = `★ ${repo.stars.toLocaleString()}`; - meta.appendChild(stars); - } - - if (repo.pushedAt) { - const pushed = createElement("span", "github-repo-pushed"); - pushed.textContent = relativeTime(repo.pushedAt); - pushed.title = new Date(repo.pushedAt).toLocaleString(); - meta.appendChild(pushed); - } - - card.appendChild(meta); - - return card; - } - - async _downloadRepo(repo) { - if (!this._statusEl) return; - const ref = repo.defaultBranch || ""; - this._statusEl.classList.remove("github-viewer-status-error"); - this._statusEl.textContent = `Downloading ${repo.fullName}...`; - - try { - await window.AlchementaryLink.wailsService.DownloadGitHubRepoArchive( - this.sessionID, - repo.fullName, - ref, - ); - this._updateStatus(); - } catch (err) { - this._statusEl.textContent = `⚠ Download failed: ${err}`; - this._statusEl.classList.add("github-viewer-status-error"); - } - } - - async _manualRefresh() { - if (!this._statusEl) return; - this._statusEl.textContent = "Refreshing…"; - try { - await window.AlchementaryLink.wailsService.RefreshGitHubRepos(this.sessionID); - } catch (err) { - this.showError(err.message || "Refresh failed"); - } - } - - /** - * Returns a colour for the given programming language. - * Uses a subset of GitHub's linguist colours. - * @param {string} lang - * @returns {string} CSS colour value. - */ - _langColour(lang) { - const colours = { - JavaScript: "#f1e05a", - TypeScript: "#3178c6", - Python: "#3572A5", - Go: "#00ADD8", - Rust: "#dea584", - Java: "#b07219", - C: "#555555", - "C++": "#f34b7d", - "C#": "#178600", - Ruby: "#701516", - PHP: "#4F5D95", - Swift: "#F05138", - Kotlin: "#A97BFF", - Shell: "#89e051", - HTML: "#e34c26", - CSS: "#563d7c", - Dart: "#00B4AB", - Lua: "#000080", - Scala: "#c22d40", - Elixir: "#6e4a7e", - Haskell: "#5e5086", - Zig: "#ec915c", - Vue: "#41b883", - SCSS: "#c6538c", - }; - return colours[lang] || "#8b949e"; - } - - /** - * GitHub Invertocat SVG icon. - * @returns {string} - */ - _githubSVG() { - return ''; - } - } - - window.AlchementaryLink = window.AlchementaryLink || {}; - window.AlchementaryLink.GitHubViewer = new GitHubViewer(); -})(); diff --git a/frontend/src/js/irc.js b/frontend/src/js/irc.js deleted file mode 100644 index 3532958..0000000 --- a/frontend/src/js/irc.js +++ /dev/null @@ -1,795 +0,0 @@ -(function () { - /** - * Alchementary Link - IRC viewer. - * Uses xterm.js as the chat stream, with room subtabs, a composer, and - * room/user sidebars. - */ - - /* global Terminal, FitAddon, SearchAddon, WebLinksAddon */ - - const { createElement } = window.AlchementaryLink; - const SERVER_TARGET = "server"; - const MAX_LINES_PER_TARGET = 2000; - const MAX_LISTED_ROOMS = 200; - const ANSI_RESET = "\x1b[0m"; - const ANSI_DIM = "\x1b[2m"; - const ANSI_BOLD = "\x1b[1m"; - const ANSI_ITALIC = "\x1b[3m"; - const ANSI_UNDERLINE = "\x1b[4m"; - const ANSI_REVERSE = "\x1b[7m"; - const ANSI_STRIKE = "\x1b[9m"; - const ANSI_GREY = "\x1b[38;5;244m"; - const ANSI_RED = "\x1b[38;5;203m"; - const ANSI_GREEN = "\x1b[38;5;83m"; - const ANSI_YELLOW = "\x1b[38;5;221m"; - const ANSI_CYAN = "\x1b[38;5;81m"; - const ANSI_MAGENTA = "\x1b[38;5;177m"; - const NICK_COLOURS = [33, 39, 45, 69, 75, 81, 111, 141, 177, 207, 214, 220]; - - // IRC colours 00-98 mapped to the xterm 256-colour palette described by - // the modern IRC formatting specification. Colour 99 means terminal default. - const IRC_ANSI_COLOURS = [ - 15, 0, 4, 2, 9, 1, 5, 130, 11, 10, 6, 14, 12, 13, 8, 7, - 52, 94, 100, 58, 22, 29, 23, 24, 17, 54, 53, 89, - 88, 130, 142, 64, 28, 35, 30, 25, 18, 91, 90, 125, - 124, 166, 184, 106, 34, 49, 37, 33, 19, 129, 127, 161, - 196, 208, 226, 154, 46, 86, 51, 75, 21, 171, 201, 198, - 203, 215, 227, 191, 83, 122, 87, 111, 63, 177, 207, 205, - 217, 223, 229, 193, 157, 158, 159, 153, 147, 183, 219, 212, - 16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231, - ]; - - function stripTerminalEscapes(value) { - return String(value || "") - .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/\x1b./g, "") - .replace(/[\x80-\x9f]/g, ""); - } - - function readDecimalColour(input, offset) { - const match = input.slice(offset).match(/^\d{1,2}/); - if (!match) return null; - return { value: Number(match[0]), length: match[0].length }; - } - - function readHexColour(input, offset) { - const value = input.slice(offset, offset + 6); - if (!/^[0-9a-f]{6}$/i.test(value)) return null; - return { value, length: 6 }; - } - - function indexedColour(code, background = false) { - if (code === 99 || code < 0 || code >= IRC_ANSI_COLOURS.length) { - return `\x1b[${background ? 49 : 39}m`; - } - return `\x1b[${background ? 48 : 38};5;${IRC_ANSI_COLOURS[code]}m`; - } - - function hexColour(value, background = false) { - const red = parseInt(value.slice(0, 2), 16); - const green = parseInt(value.slice(2, 4), 16); - const blue = parseInt(value.slice(4, 6), 16); - return `\x1b[${background ? 48 : 38};2;${red};${green};${blue}m`; - } - - function formatIRCText(value) { - const input = stripTerminalEscapes(value); - const toggles = { - "\x02": [ANSI_BOLD, "\x1b[22m", false], - "\x1d": [ANSI_ITALIC, "\x1b[23m", false], - "\x1e": [ANSI_STRIKE, "\x1b[29m", false], - "\x1f": [ANSI_UNDERLINE, "\x1b[24m", false], - "\x16": [ANSI_REVERSE, "\x1b[27m", false], - }; - let output = ""; - - for (let index = 0; index < input.length; index += 1) { - const char = input[index]; - if (toggles[char]) { - const toggle = toggles[char]; - toggle[2] = !toggle[2]; - output += toggle[toggle[2] ? 0 : 1]; - continue; - } - if (char === "\x0f") { - output += ANSI_RESET; - for (const toggle of Object.values(toggles)) toggle[2] = false; - continue; - } - if (char === "\x03" || char === "\x04") { - const readColour = char === "\x03" ? readDecimalColour : readHexColour; - const foreground = readColour(input, index + 1); - if (!foreground) { - output += "\x1b[39;49m"; - continue; - } - index += foreground.length; - output += - char === "\x03" - ? indexedColour(foreground.value) - : hexColour(foreground.value); - - if (input[index + 1] !== ",") continue; - const background = readColour(input, index + 2); - if (!background) { - output += ","; - index += 1; - continue; - } - index += background.length + 1; - output += - char === "\x03" - ? indexedColour(background.value, true) - : hexColour(background.value, true); - continue; - } - // xterm is already monospace, so the IRC monospace toggle needs no SGR. - if (char === "\x11") continue; - const code = char.charCodeAt(0); - if (code < 0x20 || code === 0x7f) continue; - output += char; - } - return output; - } - - function plainIRCText(value) { - return formatIRCText(value).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ""); - } - - function nickColour(nick) { - let hash = 0; - for (const char of String(nick || "")) { - hash = (hash * 31 + char.codePointAt(0)) >>> 0; - } - return `\x1b[38;5;${NICK_COLOURS[hash % NICK_COLOURS.length]}m`; - } - - function formatIRCEvent(event, time) { - const from = plainIRCText(event?.from || ""); - const text = formatIRCText(event?.text || ""); - const subject = plainIRCText(event?.command || ""); - const timestamp = `${ANSI_DIM}${ANSI_GREY}[${time}]${ANSI_RESET}`; - const styledNick = from - ? `${ANSI_BOLD}${nickColour(from)}${from}${ANSI_RESET}` - : ""; - let body; - - switch (event?.type) { - case "message": - body = `${ANSI_GREY}<${ANSI_RESET}${styledNick}${ANSI_GREY}>${ANSI_RESET} ${text}`; - break; - case "action": - body = `${ANSI_MAGENTA}${ANSI_ITALIC}* ${styledNick}${ANSI_MAGENTA}${ANSI_ITALIC} ${text}`; - break; - case "notice": - body = `${ANSI_YELLOW}-${styledNick || "notice"}${ANSI_YELLOW}-${ANSI_RESET} ${text}`; - break; - case "join": - body = `${ANSI_GREEN}-->${ANSI_RESET} ${styledNick || "user"} joined`; - break; - case "part": - body = `${ANSI_RED}<--${ANSI_RESET} ${styledNick || "user"} left${text ? ` (${text})` : ""}`; - break; - case "kick": - body = `${ANSI_RED}<--${ANSI_RESET} ${ANSI_BOLD}${subject || "user"}${ANSI_RESET} was kicked by ${styledNick || "server"}${text ? ` (${text})` : ""}`; - break; - case "quit": - body = `${ANSI_RED}<--${ANSI_RESET} ${styledNick || "user"} quit${text ? ` (${text})` : ""}`; - break; - case "nick": - body = `${styledNick || "user"} is now known as ${ANSI_BOLD}${plainIRCText(event?.text || "")}${ANSI_RESET}`; - break; - case "topic": - body = `${ANSI_CYAN}${ANSI_BOLD}topic:${ANSI_RESET} ${text}`; - break; - case "error": - body = `${ANSI_RED}${ANSI_BOLD}error:${ANSI_RESET} ${text}`; - break; - case "system": - default: - body = `${ANSI_CYAN}${text}`; - break; - } - return `${timestamp} ${body}${ANSI_RESET}`; - } - - class IRCViewer { - constructor() { - this._viewers = new Map(); - this.app = null; - } - - setApp(app) { - this.app = app; - } - - createViewer(sessionID, container, profile) { - if (this._viewers.has(sessionID) || !container) return; - const viewer = new IRCViewerInstance(sessionID, container, profile, this.app); - this._viewers.set(sessionID, viewer); - viewer.render(); - } - - destroyViewer(sessionID) { - const viewer = this._viewers.get(sessionID); - if (!viewer) return; - viewer.destroy(); - this._viewers.delete(sessionID); - } - - handleEvent(payload) { - const sessionID = payload?.sessionID; - const event = payload?.event || payload; - const viewer = this._viewers.get(sessionID); - if (viewer) viewer.handleEvent(event); - } - - fit(sessionID) { - const viewer = this._viewers.get(sessionID); - if (viewer) viewer.fit(); - } - } - - class IRCViewerInstance { - constructor(sessionID, container, profile, app) { - this.sessionID = sessionID; - this.container = container; - this.profile = profile || {}; - this.app = app || null; - - this.term = null; - this.fitAddon = null; - this.searchAddon = null; - this.observer = null; - - this.activeTarget = SERVER_TARGET; - this.targets = new Map(); - this.users = new Map(); - this.joinedRooms = []; - this.roomList = []; - this.roomFilter = ""; - this.unreadTargets = new Set(); - this.destroyed = false; - - this.tabsEl = null; - this.termHost = null; - this.inputEl = null; - this.roomsEl = null; - this.usersEl = null; - this.roomListEl = null; - this.roomFilterEl = null; - - this._ensureTarget(SERVER_TARGET, "Server"); - } - - render() { - this.container.innerHTML = ""; - this.container.classList.add("irc-viewer-panel"); - - const wrapper = createElement("div", "irc-viewer"); - const main = createElement("div", "irc-main"); - - this.tabsEl = createElement("div", "irc-room-tabs"); - main.appendChild(this.tabsEl); - - this.termHost = createElement("div", "irc-terminal"); - main.appendChild(this.termHost); - - const composer = createElement("form", "irc-composer"); - this.inputEl = createElement("input", "irc-input", { - type: "text", - placeholder: "Message or /command", - autocomplete: "off", - }); - const sendBtn = createElement("button", "btn btn-sm btn-primary irc-send", { - type: "submit", - textContent: "Send", - }); - composer.appendChild(this.inputEl); - composer.appendChild(sendBtn); - composer.addEventListener("submit", (event) => { - event.preventDefault(); - this._sendComposer(); - }); - this.inputEl.addEventListener("keydown", (event) => { - if (event.key === "Tab" && this._completeMention()) { - event.preventDefault(); - } - }); - main.appendChild(composer); - - const sidebar = this._buildSidebar(); - wrapper.appendChild(main); - wrapper.appendChild(sidebar); - this.container.appendChild(wrapper); - - this._openTerminal(); - this._renderTabs(); - this._renderRooms(); - this._renderUsers(); - this._renderRoomList(); - this._appendLine(SERVER_TARGET, { - type: "system", - text: "Connecting...", - timestamp: Date.now(), - }); - void this._refreshJoinedRooms(); - } - - handleEvent(event) { - if (!event || !event.type) return; - - if (event.type === "rooms") { - this.joinedRooms = Array.isArray(event.rooms) ? event.rooms : []; - for (const room of this.joinedRooms) { - this._ensureTarget(room.name, room.name); - } - this._renderTabs(); - this._renderRooms(); - return; - } - - if (event.type === "users") { - const target = event.target; - if (target) { - this.users.set(target, Array.isArray(event.users) ? event.users : []); - if (target === this.activeTarget) this._renderUsers(); - } - return; - } - - if (event.type === "room-list") { - this.roomList = Array.isArray(event.rooms) ? event.rooms : []; - this._renderRoomList(); - return; - } - - const target = this._eventTarget(event); - this._ensureTarget(target, this._targetLabel(target)); - this._appendLine(target, event); - - if (target !== this.activeTarget) { - this.unreadTargets.add(target); - this._renderTabs(); - } - - if (["join", "part", "kick", "topic"].includes(event.type)) { - this._renderRooms(); - } - } - - fit() { - if (!this.fitAddon) return; - requestAnimationFrame(() => { - try { - this.fitAddon.fit(); - } catch (_) { - /* noop */ - } - }); - } - - destroy() { - this.destroyed = true; - if (this.observer) { - this.observer.disconnect(); - this.observer = null; - } - if (this.term) { - this.term.dispose(); - this.term = null; - } - this.fitAddon = null; - this.searchAddon = null; - this.targets.clear(); - this.users.clear(); - this.unreadTargets.clear(); - this.joinedRooms = []; - this.roomList = []; - this.container.classList.remove("irc-viewer-panel"); - this.container.innerHTML = ""; - } - - _buildSidebar() { - const sidebar = createElement("aside", "irc-sidebar"); - - const joinSection = createElement("div", "irc-sidebar-section"); - joinSection.appendChild( - createElement("div", "irc-sidebar-title", { textContent: "Rooms" }), - ); - const joinRow = createElement("form", "irc-join-row"); - const joinInput = createElement("input", "irc-join-input", { - type: "text", - placeholder: "#room", - autocomplete: "off", - }); - const joinBtn = createElement("button", "btn btn-xs btn-secondary", { - type: "submit", - textContent: "Join", - }); - joinRow.appendChild(joinInput); - joinRow.appendChild(joinBtn); - joinRow.addEventListener("submit", async (event) => { - event.preventDefault(); - const room = joinInput.value.trim(); - if (!room) return; - try { - await window.AlchementaryLink.wailsService.IRCJoin(this.sessionID, room); - joinInput.value = ""; - } catch (err) { - this._notifyError("Join failed: " + err); - } - }); - joinSection.appendChild(joinRow); - this.roomsEl = createElement("div", "irc-sidebar-list"); - joinSection.appendChild(this.roomsEl); - sidebar.appendChild(joinSection); - - const listSection = createElement("div", "irc-sidebar-section"); - const listTitleRow = createElement("div", "irc-sidebar-title-row"); - listTitleRow.appendChild( - createElement("div", "irc-sidebar-title", { textContent: "Room List" }), - ); - const listBtn = createElement("button", "btn btn-xs btn-outline", { - type: "button", - textContent: "List", - }); - listBtn.addEventListener("click", async () => { - this.roomList = []; - this._renderRoomList("Loading rooms..."); - try { - await window.AlchementaryLink.wailsService.IRCListRooms(this.sessionID); - } catch (err) { - this._renderRoomList("Room list failed"); - this._notifyError("Room list failed: " + err); - } - }); - listTitleRow.appendChild(listBtn); - listSection.appendChild(listTitleRow); - this.roomFilterEl = createElement("input", "irc-filter-input", { - type: "text", - placeholder: "Filter rooms", - autocomplete: "off", - }); - this.roomFilterEl.addEventListener("input", () => { - this.roomFilter = this.roomFilterEl.value.toLowerCase(); - this._renderRoomList(); - }); - listSection.appendChild(this.roomFilterEl); - this.roomListEl = createElement("div", "irc-room-list"); - listSection.appendChild(this.roomListEl); - sidebar.appendChild(listSection); - - const usersSection = createElement("div", "irc-sidebar-section irc-users-section"); - usersSection.appendChild( - createElement("div", "irc-sidebar-title", { textContent: "Users" }), - ); - this.usersEl = createElement("div", "irc-user-list"); - usersSection.appendChild(this.usersEl); - sidebar.appendChild(usersSection); - - return sidebar; - } - - _openTerminal() { - const themeName = - (this.app?.state?.preferences || {}).terminalTheme || "charcoal"; - this.term = new Terminal({ - theme: window.AlchementaryLink.getTerminalTheme(themeName), - fontFamily: - this.profile.terminalFontFamily || - 'JetBrains Mono, Menlo, Monaco, Consolas, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace', - fontSize: this.profile.terminalFontSize || 14, - scrollback: this.profile.terminalScrollback || 10000, - cursorBlink: false, - convertEol: true, - disableStdin: true, - }); - this.fitAddon = new FitAddon.FitAddon(); - this.searchAddon = new SearchAddon.SearchAddon(); - this.term.loadAddon(this.fitAddon); - this.term.loadAddon(this.searchAddon); - try { - this.term.loadAddon(new WebLinksAddon.WebLinksAddon((_event, uri) => { - window.runtime.BrowserOpenURL(uri); - })); - } catch (_) { - /* optional */ - } - this.term.open(this.termHost); - this.fit(); - this.observer = new ResizeObserver(() => this.fit()); - this.observer.observe(this.termHost); - } - - _renderTabs() { - if (!this.tabsEl) return; - this.tabsEl.innerHTML = ""; - for (const [target, state] of this.targets) { - const btn = createElement( - "button", - `irc-room-tab ${target === this.activeTarget ? "active" : ""} ${ - this.unreadTargets.has(target) ? "has-unread" : "" - }`, - { - type: "button", - textContent: state.label, - title: state.label, - }, - ); - btn.addEventListener("click", () => this._setActiveTarget(target)); - this.tabsEl.appendChild(btn); - } - } - - _renderRooms() { - if (!this.roomsEl) return; - this.roomsEl.innerHTML = ""; - if (this.joinedRooms.length === 0) { - this.roomsEl.appendChild( - createElement("div", "irc-empty", { textContent: "No joined rooms" }), - ); - return; - } - for (const room of this.joinedRooms) { - const row = createElement("button", "irc-room-row", { - type: "button", - title: plainIRCText(room.topic || room.name), - }); - row.classList.toggle("active", room.name === this.activeTarget); - row.appendChild(createElement("span", "irc-room-name", { textContent: room.name })); - row.appendChild( - createElement("span", "irc-room-count", { - textContent: String(room.users || 0), - }), - ); - row.addEventListener("click", () => this._setActiveTarget(room.name)); - this.roomsEl.appendChild(row); - } - } - - _renderUsers() { - if (!this.usersEl) return; - this.usersEl.innerHTML = ""; - const users = this.users.get(this.activeTarget) || []; - if (!this._isRoom(this.activeTarget)) { - this.usersEl.appendChild( - createElement("div", "irc-empty", { textContent: "No room selected" }), - ); - return; - } - if (users.length === 0) { - this.usersEl.appendChild( - createElement("div", "irc-empty", { textContent: "No users yet" }), - ); - return; - } - for (const user of users) { - const row = createElement("button", "irc-user-row", { - type: "button", - textContent: this._displayNick(user), - title: user, - }); - row.addEventListener("click", () => { - this._insertMention(this._displayNick(user)); - }); - this.usersEl.appendChild(row); - } - } - - _renderRoomList(message = "") { - if (!this.roomListEl) return; - this.roomListEl.innerHTML = ""; - if (message) { - this.roomListEl.appendChild( - createElement("div", "irc-empty", { textContent: message }), - ); - return; - } - let rooms = this.roomList; - if (this.roomFilter) { - rooms = rooms.filter((room) => { - const haystack = `${room.name || ""} ${room.topic || ""}`.toLowerCase(); - return haystack.includes(this.roomFilter); - }); - } - rooms = rooms.slice(0, MAX_LISTED_ROOMS); - if (rooms.length === 0) { - this.roomListEl.appendChild( - createElement("div", "irc-empty", { textContent: "No rooms loaded" }), - ); - return; - } - for (const room of rooms) { - const row = createElement("div", "irc-listed-room"); - const meta = createElement("button", "irc-listed-room-main", { - type: "button", - title: plainIRCText(room.topic || room.name), - }); - meta.appendChild( - createElement("span", "irc-listed-room-name", { - textContent: room.name || "", - }), - ); - meta.appendChild( - createElement("span", "irc-listed-room-topic", { - textContent: plainIRCText(room.topic || ""), - }), - ); - meta.addEventListener("click", () => this._joinRoom(room.name)); - const count = createElement("span", "irc-listed-room-count", { - textContent: String(room.users || 0), - }); - row.appendChild(meta); - row.appendChild(count); - this.roomListEl.appendChild(row); - } - } - - _setActiveTarget(target) { - this._ensureTarget(target, this._targetLabel(target)); - this.activeTarget = target; - this.unreadTargets.delete(target); - this._renderTabs(); - this._renderRooms(); - this._renderUsers(); - this._replayActiveTarget(); - if (this.inputEl) this.inputEl.focus(); - } - - _appendLine(target, event) { - const state = this._ensureTarget(target, this._targetLabel(target)); - const line = this._formatEvent(event); - if (!line) return; - state.lines.push(line); - if (state.lines.length > MAX_LINES_PER_TARGET) { - state.lines.splice(0, state.lines.length - MAX_LINES_PER_TARGET); - } - if (target === this.activeTarget && this.term) { - this.term.writeln(line); - this.term.scrollToBottom(); - } - } - - _replayActiveTarget() { - if (!this.term) return; - this.term.clear(); - const state = this.targets.get(this.activeTarget); - for (const line of state?.lines || []) { - this.term.writeln(line); - } - this.term.scrollToBottom(); - this.fit(); - } - - async _sendComposer() { - const text = this.inputEl.value; - if (!text.trim()) return; - this.inputEl.value = ""; - try { - await window.AlchementaryLink.wailsService.IRCHandleInput( - this.sessionID, - this.activeTarget, - text, - ); - } catch (err) { - this._notifyError(String(err)); - this._appendLine(this.activeTarget, { - type: "error", - text: String(err), - timestamp: Date.now(), - }); - } - } - - async _joinRoom(room) { - if (!room) return; - try { - await window.AlchementaryLink.wailsService.IRCJoin(this.sessionID, room); - } catch (err) { - this._notifyError("Join failed: " + err); - } - } - - async _refreshJoinedRooms() { - try { - const rooms = await window.AlchementaryLink.wailsService.IRCRooms(this.sessionID); - if (this.destroyed) return; - this.handleEvent({ type: "rooms", rooms: Array.isArray(rooms) ? rooms : [] }); - } catch (_) { - // The session may have closed while the viewer was being created. - } - } - - _completeMention() { - if (!this.inputEl || !this._isRoom(this.activeTarget)) return false; - const start = this.inputEl.selectionStart || 0; - const before = this.inputEl.value.slice(0, start); - const match = before.match(/(^|\s)@([^\s@]*)$/); - if (!match) return false; - const prefix = match[2].toLowerCase(); - const users = this.users.get(this.activeTarget) || []; - const nick = users - .map((user) => this._displayNick(user)) - .find((user) => user.toLowerCase().startsWith(prefix)); - if (!nick) return false; - const replacement = `${match[1]}@${nick} `; - const nextValue = - this.inputEl.value.slice(0, start - match[0].length) + - replacement + - this.inputEl.value.slice(start); - const caret = start - match[0].length + replacement.length; - this.inputEl.value = nextValue; - this.inputEl.setSelectionRange(caret, caret); - return true; - } - - _insertMention(nick) { - if (!this.inputEl) return; - const current = this.inputEl.value; - const spacer = current && !/\s$/.test(current) ? " " : ""; - this.inputEl.value = `${current}${spacer}@${nick} `; - this.inputEl.focus(); - } - - _ensureTarget(target, label) { - const safeTarget = target || SERVER_TARGET; - if (!this.targets.has(safeTarget)) { - this.targets.set(safeTarget, { - label: label || this._targetLabel(safeTarget), - lines: [], - }); - } - return this.targets.get(safeTarget); - } - - _eventTarget(event) { - if (!event) return SERVER_TARGET; - if (event.target) return event.target; - return SERVER_TARGET; - } - - _targetLabel(target) { - return target === SERVER_TARGET ? "Server" : target; - } - - _formatEvent(event) { - return formatIRCEvent(event, this._time(event.timestamp)); - } - - _time(value) { - const date = value ? new Date(value) : new Date(); - const hours = String(date.getHours()).padStart(2, "0"); - const mins = String(date.getMinutes()).padStart(2, "0"); - const secs = String(date.getSeconds()).padStart(2, "0"); - return `${hours}:${mins}:${secs}`; - } - - _sanitize(value) { - return plainIRCText(value); - } - - _displayNick(user) { - return String(user || "").replace(/^[~&@%+]+/, ""); - } - - _isRoom(target) { - return /^[#&+!]/.test(target || ""); - } - - _notifyError(message) { - if (this.app?.notifications) { - this.app.notifications.error(message); - } - } - } - - window.AlchementaryLink = window.AlchementaryLink || {}; - window.AlchementaryLink.IRCFormatting = Object.freeze({ - formatEvent: formatIRCEvent, - formatText: formatIRCText, - plainText: plainIRCText, - }); - window.AlchementaryLink.IRCViewer = new IRCViewer(); -})(); diff --git a/frontend/src/js/leviathan.js b/frontend/src/js/leviathan.js deleted file mode 100644 index 2525aa2..0000000 --- a/frontend/src/js/leviathan.js +++ /dev/null @@ -1,1441 +0,0 @@ -(function () { - const { createElement, escapeHtml, formatBytes, generateId } = - window.AlchementaryLink; - - const tools = [ - { - group: "Editor", - id: "hex", - name: "Hex Editor", - ico: "HX", - desc: "Editable hex view over the loaded working copy.", - }, - { - group: "Triage", - id: "overview", - name: "Overview", - ico: "OV", - desc: "First-pass summary across hashes, entropy, strings, IOCs and carve candidates.", - }, - { group: "Triage", id: "hashes", name: "Hashes", ico: "#", desc: "CRC32, MD5, SHA-1, SHA-256 and SHA-512." }, - { group: "Triage", id: "entropy", name: "Entropy", ico: "EN", desc: "Windowed entropy for packed or encrypted regions." }, - { group: "Triage", id: "strings", name: "Strings", ico: "ST", desc: "ASCII and UTF-16 string extraction." }, - { group: "Triage", id: "iocs", name: "IOCs", ico: "IO", desc: "URLs, domains, paths, hashes and other indicators." }, - { group: "Find", id: "search", name: "Search", ico: "SR", desc: "Single text, hex or regular-expression search." }, - { group: "Find", id: "multi-search", name: "Multi-search", ico: "MS", desc: "Batch text, hex and regex searches." }, - { group: "Structure", id: "parse", name: "Structures", ico: "PX", desc: "Parsers for PE, ELF, Mach-O, ZIP, images, RIFF, ASN.1 and certs." }, - { group: "Structure", id: "disasm", name: "Disasm", ico: "DA", desc: "Heuristic disassembly for common CPU families." }, - { group: "Structure", id: "pdf", name: "PDF", ico: "PDF", desc: "PDF object, stream, xref and suspicious-surface inspection." }, - { group: "Structure", id: "squashfs", name: "SquashFS", ico: "SQ", desc: "SquashFS superblock and directory metadata." }, - { group: "Audio", id: "spectrogram-encode", name: "Spectrogram Encode", ico: "SE", desc: "Convert an image into WAV audio, or hide an image inside a carrier WAV/MP3." }, - { group: "Audio", id: "spectrogram-view", name: "Spectrogram View", ico: "SV", desc: "Render WAV or MP3 audio as a spectrogram image." }, - { group: "Compare", id: "diff", name: "Diff", ico: "DF", desc: "Changed byte regions against a compare file." }, - { group: "Recover", id: "carve", name: "Carve", ico: "CV", desc: "Embedded file carving candidates." }, - { group: "Recover", id: "keys", name: "Keys", ico: "KY", desc: "PEM, DER, OpenSSH and PKCS#12 artefact discovery." }, - { group: "Recover", id: "timestamps", name: "Timestamps", ico: "TS", desc: "UNIX, FILETIME, WebKit and OLE timestamp heuristics." }, - { group: "Recover", id: "stego", name: "Stego", ico: "SG", desc: "Appended payloads and embedded trailer formats." }, - { group: "Recover", id: "metadata", name: "Metadata", ico: "MD", desc: "PNG, JPEG and GIF textual metadata extraction." }, - { group: "Advanced", id: "binsnoop", name: "Binsnoop", ico: "BS", desc: "Deep signature scanning through the Binsnoop engine." }, - { group: "Advanced", id: "rules", name: "Rules", ico: "RL", desc: "YARA-like rule scans over the current document." }, - { group: "Edit", id: "patch-plan", name: "Patch Plan", ico: "PP", desc: "Validate patch scripts without modifying the working copy." }, - { group: "Edit", id: "patch-apply", name: "Patch Apply", ico: "PA", desc: "Apply a patch script to the working copy." }, - { group: "Edit", id: "transform", name: "Transforms", ico: "TR", desc: "Hex, Base64, URL, XOR, inflate and cipher pipelines." }, - { group: "Case", id: "case", name: "Case Export", ico: "CE", desc: "Build a case archive payload from the current working copy." }, - ]; - - const defaults = { - search: { mode: "text", query: "MZ", max: 1000 }, - "multi-search": { spec: "text MZ\nhex 7f454c46\nregex https?://", max: 1000 }, - parse: { kind: "auto" }, - disasm: { arch: "x86_64", start: 0, length: 4096, max: 1000 }, - entropy: { window: 4096, step: 4096 }, - strings: { min: 4, max: 5000 }, - iocs: { min: 5 }, - carve: { max: 256 }, - keys: { max: 200 }, - timestamps: { max: 1000 }, - rules: { source: 'rule mz { strings: $a = "MZ" condition: $a }', max: 1000 }, - "patch-plan": { source: "set 0x00 90 90" }, - "patch-apply": { source: "set 0x00 90 90" }, - transform: { pipeline: "hex.encode", start: 0, length: 1024 }, - "spectrogram-view": { fftSize: 2048, height: 1024, maxWidth: 1400, aspectRatio: 1, minHz: 0, maxHz: 0, brightness: 1, contrast: 1 }, - hex: { start: 0, bytes: 2048, width: 16 }, - }; - - class LeviathanWorkbench { - constructor(app) { - this.app = app; - this.state = null; - this.lastResults = new Map(); - this.autoRunners = new Map(); - this.hexSearches = new Map(); - } - - renderSidebar() { - const el = document.getElementById("sidebar-profiles"); - if (!el) return; - el.innerHTML = ""; - - const actions = createElement("div", "leviathan-actions"); - const load = createElement("button", "btn btn-primary btn-sm", { - type: "button", - textContent: "Load", - }); - const save = createElement("button", "btn btn-secondary btn-sm", { - type: "button", - textContent: "Save", - }); - load.addEventListener("click", () => this.loadFile()); - save.addEventListener("click", () => this.saveFile()); - actions.appendChild(load); - actions.appendChild(save); - el.appendChild(actions); - - let lastGroup = ""; - for (const tool of tools) { - if (tool.group !== lastGroup) { - el.appendChild( - createElement("div", "sidebar-section-header leviathan-group", { - textContent: tool.group, - }), - ); - lastGroup = tool.group; - } - const item = createElement("div", "sidebar-item leviathan-tool", { - dataset: { toolId: tool.id }, - title: tool.desc, - }); - item.appendChild(createElement("span", "leviathan-tool-ico", { textContent: tool.ico })); - const label = createElement("span", "sidebar-item-label", { textContent: tool.name }); - item.appendChild(label); - item.addEventListener("click", () => this.openTool(tool.id)); - el.appendChild(item); - } - this._highlightActiveTool(); - } - - async loadFile() { - try { - const payload = await window.AlchementaryLink.wailsService.LeviathanOpenFile(); - this.applyState(payload); - this.refreshOpenHexTabs(); - } catch (err) { - if (String(err || "").includes("cancel")) return; - this.app.notifications.error("Load failed: " + err); - } - } - - async saveFile() { - try { - const path = await window.AlchementaryLink.wailsService.LeviathanSaveWorking(); - if (path) this.app.notifications.success("Saved working copy"); - const state = await window.AlchementaryLink.wailsService.LeviathanState(); - this.applyState(state); - } catch (err) { - this.app.notifications.error("Save failed: " + err); - } - } - - openTool(toolId, options = {}) { - const tool = tools.find((t) => t.id === toolId) || tools[0]; - const existingId = options.reuseExisting - ? this._findExistingToolSession(tool.id) - : null; - const sessionId = existingId || `leviathan-${tool.id}-${generateId(8)}`; - - if (!existingId) { - const sessionInfo = { - id: sessionId, - profileId: `leviathan:${tool.id}`, - profileName: "Leviathan", - protocol: "leviathan", - host: "local", - port: 0, - state: "connected", - tabName: tool.name, - toolId: tool.id, - leviathanParams: options.params || null, - }; - this.app.state.addSession(sessionInfo); - this.app.tabs.addTab(sessionInfo); - const panel = document.getElementById(`panel-${sessionId}`); - if (panel) this.renderToolPanel(panel, sessionId, tool); - } else { - this.app.tabs.setActive(existingId); - } - this._highlightActiveTool(tool.id); - } - - renderToolPanel(panel, sessionId, tool) { - panel.classList.add("leviathan-panel"); - panel.innerHTML = ""; - - const header = createElement("div", "leviathan-panel-header"); - const titleWrap = createElement("div", "leviathan-title-wrap"); - titleWrap.appendChild(createElement("span", "leviathan-tool-mark", { textContent: tool.ico })); - const heading = createElement("div", "leviathan-heading"); - heading.appendChild(createElement("h2", "", { textContent: tool.name })); - heading.appendChild(createElement("span", "leviathan-eyebrow", { textContent: this._stateLabel() })); - titleWrap.appendChild(heading); - header.appendChild(titleWrap); - - const headerActions = createElement("div", "leviathan-header-actions"); - const undo = createElement("button", "btn btn-sm", { textContent: "Undo" }); - const redo = createElement("button", "btn btn-sm", { textContent: "Redo" }); - const revert = createElement("button", "btn btn-sm", { textContent: "Revert" }); - undo.addEventListener("click", () => this._stateAction("LeviathanUndo", sessionId)); - redo.addEventListener("click", () => this._stateAction("LeviathanRedo", sessionId)); - revert.addEventListener("click", () => this._stateAction("LeviathanRevert", sessionId)); - headerActions.appendChild(undo); - headerActions.appendChild(redo); - headerActions.appendChild(revert); - header.appendChild(headerActions); - panel.appendChild(header); - - const body = createElement("div", "leviathan-panel-body"); - panel.appendChild(body); - - if (tool.id === "hex") { - this.renderHexEditor(body, sessionId); - } else if (tool.id === "search") { - this.renderSearchTool(body, sessionId); - } else if (tool.id === "spectrogram-encode") { - this.renderSpectrogramEncode(body, sessionId); - } else if (tool.id === "spectrogram-view") { - this.renderSpectrogramView(body, sessionId); - } else { - this.renderToolRunner(body, sessionId, tool); - } - } - - renderHexEditor(body, sessionId) { - const { controls, output } = this._createWorkbench(body, "leviathan-hex-workbench", { topBar: true }); - const toolbar = createElement("div", "leviathan-tool-toolbar"); - const initial = this._sessionParams(sessionId); - const start = this._input("start", String(initial.start ?? 0)); - const bytes = this._input("bytes", String(initial.bytes ?? initial.length ?? 2048)); - const width = this._selectInput("row", [ - ["16", "16 bytes"], - ["32", "32 bytes"], - ], String(initial.width || 16)); - const search = createElement("button", "btn btn-sm", { type: "button", textContent: "Search" }); - const prev = createElement("button", "btn btn-sm", { type: "button", textContent: "Prev" }); - const next = createElement("button", "btn btn-sm", { type: "button", textContent: "Next" }); - toolbar.append(start.row, bytes.row, width.row, search, prev, next); - - const status = createElement("div", "leviathan-status", { textContent: this._stateLabel() }); - const grid = createElement("div", "leviathan-hex-grid"); - controls.append(toolbar, status); - output.appendChild(grid); - - const load = async () => { - try { - status.textContent = "Loading hex..."; - const result = await window.AlchementaryLink.wailsService.LeviathanRunTool("hex", { - start: Number(start.input.value || 0), - bytes: Number(bytes.input.value || 2048), - width: Number(width.input.value || 16), - }); - this.lastResults.set(sessionId, result); - this._renderHexRows(grid, result.rows || [], status, Number(width.input.value || 16), this._currentHexSearchHit(sessionId)); - status.textContent = this._stateLabel(); - } catch (err) { - status.textContent = String(err); - } - }; - [start.input, bytes.input, width.input].forEach((input) => { - input.addEventListener("change", load); - }); - search.addEventListener("click", () => this._showHexSearchModal(sessionId, start.input, width.input, load)); - prev.addEventListener("click", () => this._gotoHexSearchHit(sessionId, -1, start.input, width.input, load)); - next.addEventListener("click", () => this._gotoHexSearchHit(sessionId, 1, start.input, width.input, load)); - load(); - } - - renderSearchTool(body, sessionId) { - const { controls, output } = this._createWorkbench(body, "leviathan-compact-workbench", { topBar: true }); - const params = createElement("div", "leviathan-params"); - const mode = this._selectInput("mode", [ - ["text", "String"], - ["hex", "Hex"], - ["regex", "Regex"], - ], "text"); - const query = this._input("query", "MZ"); - const start = this._input("start", "0"); - const length = this._input("length", "-1"); - const max = this._input("max", "1000"); - params.append(mode.row, query.row, start.row, length.row, max.row); - - const toolbar = createElement("div", "leviathan-tool-toolbar"); - const search = createElement("button", "btn btn-sm btn-primary", { type: "button", textContent: "Search" }); - const exportBtn = createElement("button", "btn btn-sm", { type: "button", textContent: "Export JSON" }); - toolbar.append(search, exportBtn); - controls.append(params, toolbar); - - const summary = createElement("div", "leviathan-summary"); - const results = createElement("div", "leviathan-results"); - output.append(summary, results); - results.appendChild(createElement("div", "leviathan-empty", { textContent: "Search results will appear here." })); - - const runSearch = async () => { - try { - results.innerHTML = ""; - results.appendChild(createElement("div", "leviathan-empty", { textContent: "Searching..." })); - const payload = { - mode: mode.input.value, - query: query.input.value, - start: Number(start.input.value || 0), - length: Number(length.input.value || -1), - max: Number(max.input.value || 1000), - }; - const out = await window.AlchementaryLink.wailsService.LeviathanRunTool("search", payload); - this.lastResults.set(sessionId, out); - this._renderResult(summary, results, out, "search"); - } catch (err) { - results.innerHTML = ""; - this._renderToolError(results, "search", err); - } - }; - search.addEventListener("click", runSearch); - query.input.addEventListener("keydown", (event) => { - if (event.key === "Enter") runSearch(); - }); - exportBtn.addEventListener("click", async () => { - const out = this.lastResults.get(sessionId); - if (!out) return; - try { - await window.AlchementaryLink.wailsService.LeviathanExportJSON("search.json", out); - this.app.notifications.success("Exported JSON"); - } catch (err) { - this.app.notifications.error("Export failed: " + err); - } - }); - this.autoRunners.set(sessionId, runSearch); - requestAnimationFrame(runSearch); - } - - renderSpectrogramEncode(body, sessionId) { - const { controls, output } = this._createWorkbench(body); - const params = createElement("div", "leviathan-params"); - const fields = [ - this._paramInput(params, "durationSec", "Duration (s)", 5, { type: "number", min: "0.25", max: "60", step: "0.25" }), - this._paramInput(params, "sampleRate", "Sample rate", 44100, { type: "number", min: "8000", max: "96000", step: "1000" }), - this._paramInput(params, "minHz", "Min frequency", 200, { type: "number", min: "20", step: "10" }), - this._paramInput(params, "maxHz", "Max frequency", 20000, { type: "number", min: "120", step: "10" }), - this._paramInput(params, "bins", "Frequency bins", 160, { type: "number", min: "16", max: "384", step: "1" }), - this._paramInput(params, "columns", "Time columns", 768, { type: "number", min: "16", max: "2048", step: "1" }), - this._paramInput(params, "gain", "Output gain", 0.85, { type: "number", min: "0.05", max: "1", step: "0.05" }), - this._paramInput(params, "startSec", "Insert start (s)", 0, { type: "number", min: "0", step: "0.1" }), - this._paramInput(params, "mixLevel", "Insert mix", 0.35, { type: "number", min: "0.01", max: "1", step: "0.01" }), - this._paramInput(params, "preserveAspectRatio", "Preserve aspect", true, { type: "checkbox" }), - this._paramInput(params, "invert", "Invert image", true, { type: "checkbox" }), - ]; - - const imagePicker = createElement("div", "leviathan-file-picker"); - const chooseImage = createElement("button", "btn btn-sm", { type: "button", textContent: "Select Image" }); - const imagePath = createElement("span", "leviathan-file-path", { textContent: "No insertion image selected" }); - imagePicker.append(chooseImage, imagePath); - - let selectedImagePath = ""; - chooseImage.addEventListener("click", async () => { - try { - const path = await window.AlchementaryLink.wailsService.SelectFile("Select Image to Insert", ""); - if (!path) return; - selectedImagePath = path; - imagePath.textContent = path; - } catch (err) { - this.app.notifications.error("Image selection failed: " + err); - } - }); - - const toolbar = createElement("div", "leviathan-tool-toolbar"); - const encode = createElement("button", "btn btn-sm btn-primary", { type: "button", textContent: "Encode Loaded Image" }); - const insert = createElement("button", "btn btn-sm", { type: "button", textContent: "Insert Into Loaded Audio" }); - toolbar.append(encode, insert); - controls.append(params, imagePicker, toolbar); - - const summary = createElement("div", "leviathan-summary"); - const results = createElement("div", "leviathan-results"); - output.append(summary, results); - results.appendChild(createElement("div", "leviathan-empty", { textContent: "Load an image to encode, or load WAV/MP3 audio and select an image to insert." })); - - const run = async (toolId) => { - try { - results.innerHTML = ""; - results.appendChild(createElement("div", "leviathan-empty", { textContent: "Rendering audio..." })); - const payload = {}; - for (const field of fields) payload[field.key] = this._fieldValue(field); - if (toolId === "spectrogram-insert") { - if (!selectedImagePath) throw new Error("Select an image to insert first."); - payload.imagePath = selectedImagePath; - } - const out = await window.AlchementaryLink.wailsService.LeviathanRunTool(toolId, payload); - this.lastResults.set(sessionId, out); - summary.innerHTML = ""; - results.innerHTML = ""; - this._renderSummaryPills(summary, out.summary || out); - results.appendChild(createElement("pre", "leviathan-json", { textContent: JSON.stringify(out, null, 2) })); - if (out?.path) this.app.notifications.success("Saved " + out.path); - } catch (err) { - results.innerHTML = ""; - this._renderToolError(results, toolId, err); - } - }; - encode.addEventListener("click", () => run("spectrogram-encode")); - insert.addEventListener("click", () => run("spectrogram-insert")); - } - - renderSpectrogramView(body, sessionId) { - const { controls, output } = this._createWorkbench(body); - const params = createElement("div", "leviathan-params"); - const fields = [ - this._paramInput(params, "fftSize", "FFT size", 2048, { type: "number", min: "256", max: "8192", step: "256" }), - this._paramInput(params, "height", "Base height", 1024, { type: "number", min: "64", max: "1024", step: "16" }), - this._paramInput(params, "maxWidth", "Max width", 1400, { type: "number", min: "64", max: "2400", step: "32" }), - this._paramInput(params, "aspectRatio", "Aspect ratio", 1, { type: "number", min: "0.25", max: "4", step: "0.25" }), - this._paramInput(params, "minHz", "Min frequency", 0, { type: "number", min: "0", step: "10" }), - this._paramInput(params, "maxHz", "Max frequency", 0, { type: "number", min: "0", step: "10" }), - this._paramInput(params, "brightness", "Brightness", 1, { type: "number", min: "0.1", max: "4", step: "0.1" }), - this._paramInput(params, "contrast", "Contrast", 1, { type: "number", min: "0.1", max: "4", step: "0.1" }), - ]; - - const toolbar = createElement("div", "leviathan-tool-toolbar"); - const run = createElement("button", "btn btn-sm btn-primary", { type: "button", textContent: "Render Spectrogram" }); - const exportBtn = createElement("button", "btn btn-sm", { type: "button", textContent: "Export JSON" }); - toolbar.append(run, exportBtn); - controls.append(params, toolbar); - - const summary = createElement("div", "leviathan-summary"); - const results = createElement("div", "leviathan-results"); - output.append(summary, results); - results.appendChild(createElement("div", "leviathan-empty", { textContent: "Load a WAV or MP3 file, then render its spectrogram." })); - - const render = async () => { - try { - results.innerHTML = ""; - results.appendChild(createElement("div", "leviathan-empty", { textContent: "Rendering spectrogram..." })); - const payload = {}; - for (const field of fields) payload[field.key] = this._fieldValue(field); - const out = await window.AlchementaryLink.wailsService.LeviathanRunTool("spectrogram-view", payload); - this.lastResults.set(sessionId, out); - summary.innerHTML = ""; - results.innerHTML = ""; - this._renderSummaryPills(summary, out.summary || out); - if (out?.pngBase64) { - const frame = createElement("div", "leviathan-spectrogram-frame"); - const img = createElement("img", "leviathan-spectrogram-image", { - alt: "Rendered audio spectrogram", - }); - img.src = `data:${out.mime || "image/png"};base64,${out.pngBase64}`; - frame.appendChild(img); - results.appendChild(frame); - } else { - results.appendChild(createElement("pre", "leviathan-json", { textContent: JSON.stringify(out, null, 2) })); - } - } catch (err) { - results.innerHTML = ""; - this._renderToolError(results, "spectrogram-view", err); - } - }; - run.addEventListener("click", render); - exportBtn.addEventListener("click", async () => { - const out = this.lastResults.get(sessionId); - if (!out) return; - try { - await window.AlchementaryLink.wailsService.LeviathanExportJSON("spectrogram-view.json", out); - this.app.notifications.success("Exported JSON"); - } catch (err) { - this.app.notifications.error("Export failed: " + err); - } - }); - this.autoRunners.set(sessionId, render); - requestAnimationFrame(render); - } - - renderToolRunner(body, sessionId, tool) { - const toolbar = createElement("div", "leviathan-tool-toolbar"); - const params = createElement("div", "leviathan-params"); - const fields = this._renderParams(tool.id, params); - const autoRun = this._shouldAutoRunTool(tool.id, fields); - const topBar = fields.length === 0 || this._shouldUseTopBar(fields); - const { controls, output } = this._createWorkbench( - body, - topBar ? "leviathan-compact-workbench" : "", - { topBar }, - ); - const run = createElement("button", "btn btn-sm btn-primary", { textContent: this._toolActionLabel(tool.id) }); - const exportBtn = createElement("button", "btn btn-sm", { textContent: "Export JSON" }); - if (!autoRun || fields.length) toolbar.append(run); - toolbar.append(exportBtn); - if (fields.length) controls.append(params); - controls.append(toolbar); - - const summary = createElement("div", "leviathan-summary"); - const results = createElement("div", "leviathan-results"); - output.append(summary, results); - - let running = false; - const runTool = async () => { - if (running) return; - running = true; - try { - if (tool.id === "diff" && !this.state?.compare) { - const compare = await window.AlchementaryLink.wailsService.LeviathanOpenCompare(); - this.applyState(compare); - } - results.innerHTML = ""; - results.appendChild(createElement("div", "leviathan-empty", { textContent: "Running..." })); - const payload = {}; - for (const field of fields) { - payload[field.key] = this._fieldValue(field); - } - const out = await window.AlchementaryLink.wailsService.LeviathanRunTool(tool.id, payload); - this.lastResults.set(sessionId, out); - this._renderResult(summary, results, out, tool.id); - if (out && out.state) this.applyState(out.state); - } catch (err) { - results.innerHTML = ""; - this._renderToolError(results, tool.id, err); - } finally { - running = false; - } - }; - if (!autoRun || fields.length) run.addEventListener("click", runTool); - exportBtn.addEventListener("click", async () => { - const out = this.lastResults.get(sessionId); - if (!out) return; - try { - await window.AlchementaryLink.wailsService.LeviathanExportJSON(`${tool.id}.json`, out); - this.app.notifications.success("Exported JSON"); - } catch (err) { - this.app.notifications.error("Export failed: " + err); - } - }); - if (autoRun) { - this.autoRunners.set(sessionId, runTool); - requestAnimationFrame(runTool); - } else { - this.autoRunners.delete(sessionId); - results.appendChild( - createElement("div", "leviathan-empty", { - textContent: "Ready.", - }), - ); - } - } - - _createWorkbench(body, extraClass = "", options = {}) { - body.classList.add("leviathan-workbench"); - body.classList.toggle("leviathan-top-workbench", Boolean(options.topBar)); - if (extraClass) body.classList.add(extraClass); - const controls = createElement("section", "leviathan-control-plane"); - const output = createElement("section", "leviathan-output-plane"); - body.append(controls, output); - return { controls, output }; - } - - applyState(payload) { - this.state = payload?.state || payload || null; - this._updateOpenHeaders(); - } - - destroyPanel(sessionId) { - this.lastResults.delete(sessionId); - this.autoRunners.delete(sessionId); - this.hexSearches.delete(sessionId); - } - - handleTabFocus(sessionId) { - const session = this.app.state.getSession(sessionId); - if (session?.protocol !== "leviathan") return; - this._highlightActiveTool(session.toolId); - const runner = this.autoRunners.get(sessionId); - if (runner) runner(); - } - - refreshOpenHexTabs() { - for (const session of this.app.state.getAllSessions()) { - if (session.protocol !== "leviathan" || session.toolId !== "hex") continue; - const panel = document.getElementById(`panel-${session.id}`); - if (panel) this.renderToolPanel(panel, session.id, tools[0]); - } - } - - _renderHexRows(grid, rows, status, rowWidth = 16, activeHit = null) { - grid.innerHTML = ""; - grid.style.setProperty("--leviathan-hex-width", String(rowWidth)); - if (!rows.length) { - grid.appendChild(createElement("div", "leviathan-empty", { textContent: "Load a file to inspect bytes." })); - return; - } - for (const row of rows) { - const line = createElement("div", "leviathan-hex-row"); - line.appendChild(createElement("span", "leviathan-offset", { textContent: this._hexOffset(row.offset) })); - const bytes = createElement("span", "leviathan-byte-cells"); - const cells = row.bytes || String(row.hex || "").split(/\s+/).filter(Boolean); - const ascii = createElement("span", "leviathan-ascii", { textContent: row.ascii || "" }); - cells.forEach((value, idx) => { - const cellOffset = (row.offset || 0) + idx; - const hexValue = this._normaliseHexByte(value) || "00"; - const cell = createElement("input", "leviathan-byte", { - value: hexValue, - maxlength: "2", - inputmode: "text", - autocomplete: "off", - spellcheck: "false", - "aria-label": `Byte ${this._hexOffset(cellOffset)}`, - dataset: { - offset: String(cellOffset), - committedHex: hexValue, - }, - }); - if (activeHit && cellOffset >= activeHit.offset && cellOffset < activeHit.offset + activeHit.length) { - cell.classList.add("is-search-hit"); - } - this._wireHexByteCell(cell, bytes, ascii, idx, status); - bytes.appendChild(cell); - }); - line.appendChild(bytes); - line.appendChild(ascii); - grid.appendChild(line); - } - } - - _wireHexByteCell(cell, rowCells, ascii, asciiIndex, status) { - cell.addEventListener("focus", () => { - cell.select(); - }); - cell.addEventListener("keydown", (event) => { - if (event.key === "ArrowRight") { - event.preventDefault(); - this._focusHexByte(cell, 1); - } else if (event.key === "ArrowLeft") { - event.preventDefault(); - this._focusHexByte(cell, -1); - } else if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - this._finaliseHexByte(cell, rowCells, ascii, asciiIndex, status, { advance: 1 }); - } else if (event.key === "Backspace" && cell.value.length === 0) { - event.preventDefault(); - this._focusHexByte(cell, -1); - } - }); - cell.addEventListener("input", () => { - this._handleHexByteInput(cell, rowCells, ascii, asciiIndex, status); - }); - cell.addEventListener("paste", (event) => { - this._pasteHexBytes(event, cell, rowCells, ascii, asciiIndex, status); - }); - cell.addEventListener("blur", () => { - this._finaliseHexByte(cell, rowCells, ascii, asciiIndex, status, { advance: 0 }); - }); - } - - _handleHexByteInput(cell, rowCells, ascii, asciiIndex, status) { - const clean = this._normaliseHexByte(cell.value); - if (cell.value !== clean) cell.value = clean; - cell.classList.toggle("is-editing", clean.length === 1); - if (clean.length === 0) { - this._updateHexAsciiCell(ascii, asciiIndex, cell.dataset.committedHex || "00"); - rowCells.classList.toggle("is-editing", Boolean(rowCells.querySelector(".leviathan-byte.is-editing"))); - return; - } - if (clean.length === 2) { - this._updateHexAsciiCell(ascii, asciiIndex, clean); - this._commitHexByte(cell, ascii, asciiIndex, status); - this._focusHexByte(cell, 1); - } - rowCells.classList.toggle("is-editing", Boolean(rowCells.querySelector(".leviathan-byte.is-editing"))); - } - - _finaliseHexByte(cell, rowCells, ascii, asciiIndex, status, options = {}) { - let clean = this._normaliseHexByte(cell.value); - if (clean.length === 0) { - clean = cell.dataset.committedHex || "00"; - } else if (clean.length === 1) { - clean = clean.padStart(2, "0"); - } - cell.value = clean; - cell.classList.remove("is-editing"); - rowCells.classList.toggle("is-editing", Boolean(rowCells.querySelector(".leviathan-byte.is-editing"))); - this._updateHexAsciiCell(ascii, asciiIndex, clean); - this._commitHexByte(cell, ascii, asciiIndex, status); - if (options.advance) this._focusHexByte(cell, options.advance); - } - - _pasteHexBytes(event, cell, rowCells, ascii, asciiIndex, status) { - const text = event.clipboardData?.getData("text/plain") || ""; - const pasted = this._hexByteList(text); - if (!pasted.length) return; - event.preventDefault(); - const scope = cell.closest(".leviathan-hex-grid") || rowCells; - const cells = Array.from(scope.querySelectorAll(".leviathan-byte")); - const startIndex = cells.indexOf(cell); - if (startIndex < 0) return; - pasted.forEach((hex, idx) => { - const target = cells[startIndex + idx]; - if (!target) return; - const context = this._hexCellContext(target); - if (!context) return; - target.value = hex; - target.classList.remove("is-editing"); - this._updateHexAsciiCell(context.ascii, context.index, hex); - this._commitHexByte(target, context.ascii, context.index, status); - }); - const next = cells[Math.min(startIndex + pasted.length, cells.length - 1)]; - if (next) { - next.focus(); - next.select(); - } - } - - _commitHexByte(cell, ascii, asciiIndex, status) { - const hex = this._normaliseHexByte(cell.value); - if (hex.length !== 2) return; - if (hex === cell.dataset.committedHex || hex === cell.dataset.pendingHex) return; - const previous = cell.dataset.committedHex || "00"; - const offset = Number(cell.dataset.offset || 0); - cell.dataset.pendingHex = hex; - cell.classList.add("is-saving"); - window.AlchementaryLink.wailsService - .LeviathanSetByte(offset, hex) - .then((payload) => { - if (cell.dataset.pendingHex === hex) { - delete cell.dataset.pendingHex; - cell.dataset.committedHex = hex; - cell.classList.remove("is-saving"); - status.textContent = "Edited " + this._hexOffset(offset); - this.applyState(payload); - } - }) - .catch((err) => { - if (cell.dataset.pendingHex === hex) { - delete cell.dataset.pendingHex; - cell.classList.remove("is-saving"); - cell.value = previous; - this._updateHexAsciiCell(ascii, asciiIndex, previous); - status.textContent = String(err); - } - }); - } - - _focusHexByte(cell, direction) { - const scope = cell.closest(".leviathan-hex-grid") || cell.closest(".leviathan-byte-cells"); - if (!scope) return; - const cells = Array.from(scope.querySelectorAll(".leviathan-byte")); - const index = cells.indexOf(cell); - const next = cells[index + direction]; - if (!next) return; - next.focus(); - next.select(); - } - - _hexCellContext(cell) { - const row = cell.closest(".leviathan-hex-row"); - if (!row) return null; - const ascii = row.querySelector(".leviathan-ascii"); - const rowCells = Array.from(row.querySelectorAll(".leviathan-byte")); - const index = rowCells.indexOf(cell); - if (!ascii || index < 0) return null; - return { ascii, index }; - } - - _updateHexAsciiCell(ascii, index, hex) { - const chars = Array.from(ascii.textContent || ""); - while (chars.length <= index) chars.push(" "); - chars[index] = this._hexByteToAscii(hex); - ascii.textContent = chars.join(""); - } - - _hexByteToAscii(hex) { - const value = Number.parseInt(hex, 16); - if (!Number.isFinite(value)) return "."; - return value >= 32 && value < 127 ? String.fromCharCode(value) : "."; - } - - _normaliseHexByte(value) { - return String(value || "") - .replace(/[^0-9a-f]/gi, "") - .slice(0, 2) - .toLowerCase(); - } - - _hexByteList(value) { - const clean = String(value || "") - .replace(/0x/gi, "") - .replace(/[^0-9a-f]/gi, "") - .toLowerCase(); - const bytes = []; - for (let i = 0; i + 1 < clean.length; i += 2) { - bytes.push(clean.slice(i, i + 2)); - } - return bytes; - } - - _renderParams(toolId, container) { - container.innerHTML = ""; - const cfg = defaults[toolId] || {}; - return Object.entries(cfg).map(([key, value]) => { - const row = createElement("label", "leviathan-param"); - row.appendChild(createElement("span", "", { textContent: key })); - let input; - if (key === "source" || key === "spec" || key === "pipeline") { - input = createElement("textarea", "form-input"); - } else if (key === "mode") { - input = createElement("select", "form-input"); - ["text", "hex", "regex"].forEach((v) => input.add(new Option(v, v))); - } else if (key === "kind") { - input = createElement("select", "form-input"); - ["auto", "png", "jpeg", "gif", "bmp", "zip", "elf", "pe", "macho", "riff", "asn1", "cert"].forEach((v) => input.add(new Option(v, v))); - } else if (key === "arch") { - input = createElement("select", "form-input"); - ["x86_64", "x86", "aarch64", "arm", "thumb", "mips", "mipsle"].forEach((v) => input.add(new Option(v, v))); - } else { - input = createElement("input", "form-input"); - } - input.value = value; - row.appendChild(input); - container.appendChild(row); - return { key, input }; - }); - } - - _shouldUseTopBar(fields) { - return fields.length <= 3 && !fields.some((field) => field.input.tagName === "TEXTAREA"); - } - - _shouldAutoRunTool(toolId, fields) { - const autoRunTools = new Set([ - "overview", - "hashes", - "entropy", - "strings", - "iocs", - "search", - "multi-search", - "parse", - "disasm", - "pdf", - "squashfs", - "carve", - "keys", - "timestamps", - "stego", - "metadata", - "binsnoop", - "rules", - "patch-plan", - "transform", - "spectrogram-view", - ]); - return autoRunTools.has(toolId); - } - - _toolActionLabel(toolId) { - if (toolId === "case") return "Export Case"; - if (toolId === "diff") return this.state?.compare ? "Compare" : "Load Compare"; - return "Run"; - } - - _renderResult(summary, results, out, toolId = "") { - summary.innerHTML = ""; - results.innerHTML = ""; - this._renderSummaryPills(summary, this._flatSummary(out)); - if (toolId === "overview") return this._renderOverview(results, out); - if (toolId === "hashes") return this._renderHashes(results, out); - if (toolId === "entropy") return this._renderEntropy(summary, results, out); - if (toolId === "search") return this._renderSearchResults(summary, results, out); - if (toolId === "strings") return this._renderStrings(results, out.items || []); - if (toolId === "binsnoop") return this._renderBinsnoop(summary, results, out); - if (toolId === "carve") return this._renderCarve(summary, results, out); - if (out?.tree) { - this._renderTree(results, out.tree); - return; - } - const arr = this._firstArray(out); - if (arr) { - this._renderTable(results, arr); - return; - } - results.appendChild(createElement("pre", "leviathan-json", { textContent: JSON.stringify(out, null, 2) })); - } - - _renderOverview(container, out) { - const dashboard = createElement("div", "leviathan-dashboard"); - dashboard.appendChild(this._metricCard("File", out?.name || this.state?.name || "No file")); - dashboard.appendChild(this._metricCard("Size", formatBytes(out?.size || 0))); - dashboard.appendChild(this._metricCard("Entropy mean", this._formatNumber(out?.entropy?.mean, 2))); - dashboard.appendChild(this._metricCard("Strings", out?.strings?.total ?? 0)); - dashboard.appendChild(this._metricCard("Carve hits", out?.carve?.total ?? 0)); - dashboard.appendChild(this._metricCard("Valid carve", out?.carve?.valid ?? 0)); - container.appendChild(dashboard); - - const sections = createElement("div", "leviathan-sections"); - sections.appendChild(this._kvSection("Hashes", out?.hashes || {}, ["crc32", "md5", "sha1", "sha256", "sha512"])); - sections.appendChild(this._kvSection("IOC totals", out?.iocs || {}, Object.keys(out?.iocs || {}).sort())); - sections.appendChild(this._kvSection("Scan limits", out?.limits || {}, Object.keys(out?.limits || {}), true)); - container.appendChild(sections); - } - - _renderHashes(container, out) { - const rows = ["crc32", "md5", "sha1", "sha256", "sha512"] - .filter((key) => out?.[key]) - .map((key) => ({ algorithm: key.toUpperCase(), digest: out[key] })); - this._renderTable(container, rows, ["algorithm", "digest"]); - } - - _renderEntropy(summary, container, out) { - summary.innerHTML = ""; - this._renderSummaryPills(summary, { - samples: Array.isArray(out?.samples) ? out.samples.length : 0, - mean: this._formatNumber(out?.mean, 3), - min: this._formatNumber(out?.min, 3), - max: this._formatNumber(out?.max, 3), - high: out?.highCount ?? 0, - }); - - const frame = createElement("div", "leviathan-entropy-frame"); - const canvas = createElement("canvas", "leviathan-entropy-canvas", { - width: "1200", - height: "260", - }); - frame.appendChild(canvas); - container.appendChild(frame); - - const samples = (out?.samples || []).map((v) => Number(v) || 0); - const offsets = (out?.offsets || []).map((v) => Number(v) || 0); - const step = Number(out?.step || out?.windowSize || 4096); - let dragStart = null; - let dragEnd = null; - - const pointerIndex = (event) => { - const rect = canvas.getBoundingClientRect(); - const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / Math.max(rect.width, 1))); - return Math.min(samples.length - 1, Math.max(0, Math.round(ratio * (samples.length - 1)))); - }; - const draw = () => this._drawEntropyCanvas(canvas, samples, dragStart, dragEnd); - - canvas.addEventListener("mousedown", (event) => { - if (!samples.length) return; - dragStart = pointerIndex(event); - dragEnd = dragStart; - draw(); - }); - canvas.addEventListener("mousemove", (event) => { - if (dragStart == null) return; - dragEnd = pointerIndex(event); - draw(); - }); - canvas.addEventListener("mouseup", () => { - if (dragStart == null || dragEnd == null || !offsets.length) { - dragStart = null; - dragEnd = null; - draw(); - return; - } - const lo = Math.min(dragStart, dragEnd); - const hi = Math.max(dragStart, dragEnd); - const start = offsets[lo] || 0; - const end = (offsets[hi] || start) + step; - dragStart = null; - dragEnd = null; - draw(); - if (end > start) this.openHexRange(start, end - start); - }); - canvas.addEventListener("mouseleave", () => { - if (dragStart == null) return; - dragStart = null; - dragEnd = null; - draw(); - }); - requestAnimationFrame(draw); - } - - _renderSearchResults(summary, container, out) { - const hits = out?.hits || []; - summary.innerHTML = ""; - this._renderSummaryPills(summary, { hits: hits.length }); - this._renderTable(container, hits, ["offset", "length", "sample"]); - } - - _renderStrings(container, rows) { - this._renderTable(container, rows, ["offset", "length", "encoding", "value"]); - } - - _renderCarve(summary, container, out) { - const rows = out?.items || []; - summary.innerHTML = ""; - this._renderSummaryPills(summary, { - hits: rows.length, - exportable: rows.filter((row) => this._rowRange(row).length > 0).length, - }); - this._renderTable(container, rows, ["offset", "length", "kind", "extension", "confidence", "status"], { - exportRows: true, - exportName: (row) => this._foundFileName(row, "carve"), - }); - } - - _renderBinsnoop(summary, container, out) { - const results = out?.results || {}; - const rows = results.file_map || results.fileMap || []; - summary.innerHTML = ""; - this._renderSummaryPills(summary, { - hits: rows.length, - scanned: formatBytes(results.scanned_bytes || results.document_bytes || this.state?.size || 0), - mode: results.whole_file ? "whole file" : "range", - window: results.windowed ? formatBytes(results.window_bytes || 0) : "full", - }); - this._renderTable(container, rows, ["offset", "size", "name", "description", "confidence"], { - exportRows: true, - exportName: (row) => this._foundFileName(row, "binsnoop"), - }); - } - - _drawEntropyCanvas(canvas, samples, dragStart, dragEnd) { - const ctx = canvas.getContext("2d"); - const width = canvas.width; - const height = canvas.height; - ctx.clearRect(0, 0, width, height); - ctx.fillStyle = "#101010"; - ctx.fillRect(0, 0, width, height); - ctx.strokeStyle = "rgba(255,255,255,0.08)"; - ctx.lineWidth = 1; - for (let i = 1; i < 8; i++) { - const y = height - (i / 8) * height; - ctx.beginPath(); - ctx.moveTo(0, y); - ctx.lineTo(width, y); - ctx.stroke(); - } - if (dragStart != null && dragEnd != null) { - const lo = Math.min(dragStart, dragEnd); - const hi = Math.max(dragStart, dragEnd); - const x1 = (lo / Math.max(samples.length - 1, 1)) * width; - const x2 = (hi / Math.max(samples.length - 1, 1)) * width; - ctx.fillStyle = "rgba(78, 205, 196, 0.18)"; - ctx.fillRect(x1, 0, Math.max(2, x2 - x1), height); - } - if (!samples.length) return; - ctx.strokeStyle = "#4ecdc4"; - ctx.lineWidth = 2; - ctx.beginPath(); - samples.forEach((sample, index) => { - const x = (index / Math.max(samples.length - 1, 1)) * width; - const y = height - (Math.max(0, Math.min(8, sample)) / 8) * height; - if (index === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - }); - ctx.stroke(); - } - - _renderTable(container, rows, preferredColumns = null, options = {}) { - const sourceRows = rows || []; - const maxRows = options.maxRows || (options.exportRows ? sourceRows.length : 1000); - rows = sourceRows.slice(0, maxRows); - if (!rows.length) { - container.appendChild(createElement("div", "leviathan-empty", { textContent: "No rows." })); - return; - } - const preferred = preferredColumns || ["offset", "length", "size", "kind", "name", "category", "value", "sample", "summary", "description", "iso", "type"]; - const all = Array.from(new Set(rows.flatMap((row) => Object.keys(row || {})))); - const cols = [...preferred.filter((c) => all.includes(c)), ...all.filter((c) => !preferred.includes(c))].slice(0, 8); - if (options.exportRows) cols.push("export"); - const table = createElement("table", "leviathan-table"); - const thead = createElement("thead"); - const headRow = createElement("tr"); - cols.forEach((c) => headRow.appendChild(createElement("th", "", { textContent: c }))); - thead.appendChild(headRow); - const tbody = createElement("tbody"); - rows.forEach((row) => { - const tr = createElement("tr"); - cols.forEach((c) => { - const td = createElement("td"); - if (c === "export") { - const range = this._rowRange(row); - if (range.length > 0) { - const btn = createElement("button", "btn btn-xs leviathan-export-found", { - type: "button", - textContent: "Export", - }); - btn.addEventListener("click", () => this._exportFoundRange(row, options.exportName)); - td.appendChild(btn); - } else { - td.appendChild(createElement("span", "leviathan-muted", { textContent: "No length" })); - } - } else if (c === "offset" && row[c] != null) { - const offset = Number(row[c]); - const length = Number(row.length || row.size || 256); - const btn = createElement("button", "leviathan-offset-link", { - type: "button", - textContent: this._hexOffset(offset), - }); - btn.addEventListener("click", () => this.openHexRange(offset, Math.max(256, length || 256))); - td.appendChild(btn); - } else { - td.textContent = this._formatCell(row[c]); - } - tr.appendChild(td); - }); - tbody.appendChild(tr); - }); - table.append(thead, tbody); - container.appendChild(table); - if (sourceRows.length > rows.length) { - container.appendChild(createElement("div", "leviathan-table-note", { - textContent: `Showing ${rows.length} of ${sourceRows.length} rows.`, - })); - } - } - - _rowRange(row) { - const offset = Number(row?.offset); - if (!Number.isFinite(offset) || offset < 0) return { offset: 0, length: 0 }; - let length = Number(row.length ?? row.size ?? 0); - if ((!Number.isFinite(length) || length <= 0) && Number.isFinite(Number(row.end))) { - length = Number(row.end) - offset; - } - if ((!Number.isFinite(length) || length <= 0) && this.state?.size > offset) { - length = Number(this.state.size) - offset; - } - return { - offset, - length: Number.isFinite(length) ? Math.max(0, Math.floor(length)) : 0, - }; - } - - _foundFileName(row, prefix) { - const range = this._rowRange(row); - const label = String(row.kind || row.name || row.id || prefix || "found") - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 48) || "found"; - const ext = String(row.extension || row.ext || "bin") - .toLowerCase() - .replace(/[^a-z0-9]+/g, "") - .slice(0, 12) || "bin"; - return `${prefix || "found"}-${label}-${this._hexOffset(range.offset)}.${ext}`; - } - - async _exportFoundRange(row, nameBuilder) { - const range = this._rowRange(row); - if (range.length <= 0) { - this.app.notifications.error("Export failed: finding has no readable range"); - return; - } - const name = typeof nameBuilder === "function" ? nameBuilder(row) : this._foundFileName(row, "found"); - try { - const path = await window.AlchementaryLink.wailsService.LeviathanExportRange(name, range.offset, range.length); - if (path) this.app.notifications.success("Exported " + path); - } catch (err) { - this.app.notifications.error("Export failed: " + err); - } - } - - _renderToolError(container, toolId, err) { - const message = String(err || "The tool could not complete."); - const isSquashMagic = toolId === "squashfs" && /magic|superblock|squash/i.test(message); - const state = createElement("div", isSquashMagic ? "leviathan-error-state leviathan-error-state-warning" : "leviathan-error-state"); - const icon = createElement("div", "leviathan-error-icon", { "aria-hidden": "true" }); - icon.appendChild(createElement("span", "", { textContent: "!" })); - const title = isSquashMagic ? "Not a SquashFS image" : "Tool could not run"; - const body = isSquashMagic - ? "The loaded file does not start with a valid SquashFS superblock. Try another file, or inspect this one in the Hex Editor." - : message; - state.appendChild(icon); - state.appendChild(createElement("strong", "", { textContent: title })); - state.appendChild(createElement("p", "", { textContent: body })); - container.appendChild(state); - } - - _renderTree(container, root) { - const wrap = createElement("div", "leviathan-tree"); - const walk = (node, depth = 0) => { - const row = createElement("div", "leviathan-tree-node"); - row.style.paddingLeft = `${depth * 16}px`; - row.appendChild(createElement("strong", "", { textContent: node.name || "" })); - row.appendChild(createElement("span", "", { textContent: ` ${node.type || ""} ${node.offset != null ? this._hexOffset(node.offset) : ""} +${node.length || 0}` })); - if (node.value) row.appendChild(createElement("div", "", { textContent: node.value })); - wrap.appendChild(row); - (node.children || []).forEach((child) => walk(child, depth + 1)); - }; - walk(root); - container.appendChild(wrap); - } - - _stateAction(method, sessionId) { - return window.AlchementaryLink.wailsService[method]() - .then((state) => { - this.applyState(state); - const session = this.app.state.getSession(sessionId); - if (session?.toolId === "hex") { - const panel = document.getElementById(`panel-${sessionId}`); - if (panel) this.renderToolPanel(panel, sessionId, tools[0]); - } - }) - .catch((err) => this.app.notifications.error(String(err))); - } - - _input(label, value) { - const row = createElement("label", "leviathan-param-inline"); - row.appendChild(createElement("span", "", { textContent: label })); - const input = createElement("input", "form-input", { value }); - row.appendChild(input); - return { row, input }; - } - - _selectInput(label, options, value) { - const row = createElement("label", "leviathan-param-inline"); - row.appendChild(createElement("span", "", { textContent: label })); - const input = createElement("select", "form-input"); - options.forEach(([optionValue, text]) => input.add(new Option(text, optionValue))); - input.value = value; - row.appendChild(input); - return { row, input }; - } - - _paramInput(container, key, label, value, attrs = {}) { - const row = createElement("label", "leviathan-param"); - row.appendChild(createElement("span", "", { textContent: label })); - const input = createElement("input", "form-input"); - for (const [attr, attrValue] of Object.entries(attrs)) { - input.setAttribute(attr, attrValue); - } - if (attrs.type === "checkbox") { - input.checked = Boolean(value); - input.classList.add("leviathan-checkbox"); - } else { - input.value = value; - } - row.appendChild(input); - container.appendChild(row); - return { key, input }; - } - - _fieldValue(field) { - if (field.input.type === "checkbox") { - return field.input.checked; - } - const raw = field.input.value; - const numeric = Number(raw); - const stringKeys = ["query", "source", "spec", "pipeline", "kind", "arch", "mode"]; - return raw !== "" && Number.isFinite(numeric) && !stringKeys.includes(field.key) - ? numeric - : raw; - } - - openHexRange(start, length = 2048) { - const safeStart = Math.max(0, Math.floor(Number(start) || 0)); - const safeLength = Math.max(256, Math.min(8192, Math.floor(Number(length) || 2048))); - this.openTool("hex", { - params: { start: safeStart, bytes: safeLength, width: 16 }, - }); - } - - _sessionParams(sessionId) { - return this.app.state.getSession(sessionId)?.leviathanParams || {}; - } - - _currentHexSearchHit(sessionId) { - const state = this.hexSearches.get(sessionId); - if (!state || state.index < 0 || !state.hits?.length) return null; - const hit = state.hits[state.index]; - return { - offset: Number(hit.offset || 0), - length: Number(hit.length || 1), - }; - } - - _gotoHexSearchHit(sessionId, direction, startInput, widthInput, load) { - const state = this.hexSearches.get(sessionId); - if (!state || !state.hits?.length) { - this._showHexSearchModal(sessionId, startInput, widthInput, load); - return; - } - state.index = (state.index + direction + state.hits.length) % state.hits.length; - const hit = state.hits[state.index]; - const rowWidth = Number(widthInput.value || 16); - const offset = Number(hit.offset || 0); - startInput.value = String(Math.max(0, offset - (offset % rowWidth))); - load(); - } - - _showHexSearchModal(sessionId, startInput, widthInput, load) { - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-sm leviathan-search-modal"); - const header = createElement("div", "modal-header"); - header.appendChild(createElement("h3", "modal-title", { textContent: "Hex Search" })); - const closeBtn = createElement("button", "btn btn-icon btn-xs modal-close", { - type: "button", - textContent: "x", - }); - header.appendChild(closeBtn); - - const body = createElement("div", "modal-body"); - const fields = createElement("div", "leviathan-modal-fields"); - const mode = this._selectInput("mode", [ - ["text", "String"], - ["hex", "Hex"], - ["regex", "Regex"], - ], "text"); - const query = this._input("query", ""); - fields.append(mode.row, query.row); - const status = createElement("div", "leviathan-status", { textContent: "" }); - body.append(fields, status); - - const footer = createElement("div", "modal-footer"); - const cancel = createElement("button", "btn btn-sm", { type: "button", textContent: "Cancel" }); - const run = createElement("button", "btn btn-sm btn-primary", { type: "button", textContent: "Search" }); - footer.append(cancel, run); - modal.append(header, body, footer); - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - - const close = () => { - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(() => backdrop.remove(), 200); - }; - const search = async () => { - try { - status.textContent = "Searching..."; - const out = await window.AlchementaryLink.wailsService.LeviathanRunTool("search", { - mode: mode.input.value, - query: query.input.value, - start: 0, - length: -1, - max: 5000, - }); - const hits = (out.hits || []).map((hit) => ({ - offset: Number(hit.offset || 0), - length: Number(hit.length || 1), - sample: hit.sample || "", - })); - this.hexSearches.set(sessionId, { - mode: mode.input.value, - query: query.input.value, - hits, - index: -1, - }); - close(); - if (hits.length) this._gotoHexSearchHit(sessionId, 1, startInput, widthInput, load); - } catch (err) { - status.textContent = String(err); - } - }; - - closeBtn.addEventListener("click", close); - cancel.addEventListener("click", close); - run.addEventListener("click", search); - query.input.addEventListener("keydown", (event) => { - if (event.key === "Enter") search(); - }); - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - query.input.focus(); - }); - } - - _metricCard(label, value) { - const item = createElement("div", "leviathan-metric"); - item.appendChild(createElement("span", "", { textContent: label })); - item.appendChild(createElement("strong", "", { textContent: this._formatCell(value) })); - return item; - } - - _kvSection(title, values, keys, bytes = false) { - const section = createElement("section", "leviathan-section"); - section.appendChild(createElement("h3", "", { textContent: title })); - const list = createElement("div", "leviathan-kv-list"); - keys.forEach((key) => { - if (values?.[key] == null) return; - const row = createElement("div", "leviathan-kv-row"); - row.appendChild(createElement("span", "", { textContent: key })); - row.appendChild(createElement("strong", "", { - textContent: bytes ? formatBytes(Number(values[key]) || 0) : this._formatCell(values[key]), - })); - list.appendChild(row); - }); - section.appendChild(list); - return section; - } - - _formatNumber(value, digits = 2) { - const n = Number(value); - return Number.isFinite(n) ? n.toFixed(digits) : "0"; - } - - _renderSummaryPills(container, values) { - Object.entries(values || {}) - .slice(0, 10) - .forEach(([key, value]) => { - const item = createElement("div", "leviathan-pill"); - item.appendChild(createElement("span", "", { textContent: key })); - item.appendChild(createElement("strong", "", { textContent: this._formatCell(value) })); - container.appendChild(item); - }); - } - - _findExistingToolSession(toolId) { - const session = this.app.state - .getAllSessions() - .find((s) => s.protocol === "leviathan" && s.toolId === toolId); - return session?.id || null; - } - - _updateOpenHeaders() { - document.querySelectorAll(".leviathan-eyebrow").forEach((el) => { - el.textContent = this._stateLabel(); - }); - } - - _highlightActiveTool(toolId) { - document.querySelectorAll(".leviathan-tool").forEach((el) => { - el.classList.toggle("sidebar-item-active", el.dataset.toolId === toolId); - }); - } - - _stateLabel() { - if (!this.state?.name) return "No file loaded"; - const dirty = this.state.dirty ? "modified" : "clean"; - return `${this.state.name} · ${formatBytes(this.state.size || 0)} · ${dirty}`; - } - - _flatSummary(out) { - if (!out || typeof out !== "object") return {}; - const summary = {}; - for (const [key, value] of Object.entries(out)) { - if (Array.isArray(value)) summary[key] = value.length; - else if (value && typeof value === "object") { - if (key === "hashes") summary.hash = value.sha256 || value.md5 || "ready"; - else summary[key] = Object.keys(value).length + " keys"; - } else { - summary[key] = value; - } - } - return summary; - } - - _firstArray(out) { - if (!out || typeof out !== "object") return null; - for (const key of ["items", "hits", "all", "regions", "blocks", "entries", "metadata"]) { - if (Array.isArray(out[key])) return out[key]; - } - if (out.results?.Results && Array.isArray(out.results.Results)) return out.results.Results; - return null; - } - - _formatCell(value) { - return typeof value === "object" ? JSON.stringify(value) : String(value ?? ""); - } - - _hexOffset(value) { - return "0x" + Number(value || 0).toString(16).padStart(8, "0"); - } - } - - window.AlchementaryLink = window.AlchementaryLink || {}; - window.AlchementaryLink.LeviathanWorkbench = LeviathanWorkbench; - window.AlchementaryLink.LeviathanTools = tools; -})(); diff --git a/frontend/src/js/lib/xterm-addon-fit.min.js b/frontend/src/js/lib/xterm-addon-fit.min.js deleted file mode 100644 index 9b25be3..0000000 --- a/frontend/src/js/lib/xterm-addon-fit.min.js +++ /dev/null @@ -1,2 +0,0 @@ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})())); -//# sourceMappingURL=addon-fit.js.map \ No newline at end of file diff --git a/frontend/src/js/lib/xterm-addon-search.min.js b/frontend/src/js/lib/xterm-addon-search.min.js deleted file mode 100644 index 23bb5e7..0000000 --- a/frontend/src/js/lib/xterm-addon-search.min.js +++ /dev/null @@ -1,2 +0,0 @@ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.SearchAddon=t():e.SearchAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={864:(e,t,s)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationManager=void 0;const i=s(540);class r extends i.Disposable{constructor(e){super(),this._terminal=e,this._highlightDecorations=[],this._highlightedLines=new Set,this._register((0,i.toDisposable)((()=>this.clearHighlightDecorations())))}createHighlightDecorations(e,t){this.clearHighlightDecorations();for(const s of e){const e=this._createResultDecorations(s,t,!1);if(e)for(const t of e)this._storeDecoration(t,s)}}createActiveDecoration(e,t){const s=this._createResultDecorations(e,t,!0);if(s)return{decorations:s,match:e,dispose(){(0,i.dispose)(s)}}}clearHighlightDecorations(){(0,i.dispose)(this._highlightDecorations),this._highlightDecorations=[],this._highlightedLines.clear()}_storeDecoration(e,t){this._highlightedLines.add(e.marker.line),this._highlightDecorations.push({decoration:e,match:t,dispose(){e.dispose()}})}_applyStyles(e,t,s){e.classList.contains("xterm-find-result-decoration")||(e.classList.add("xterm-find-result-decoration"),t&&(e.style.outline=`1px solid ${t}`)),s&&e.classList.add("xterm-find-active-result-decoration")}_createResultDecorations(e,t,s){const r=[];let n=e.col,o=e.size,a=-this._terminal.buffer.active.baseY-this._terminal.buffer.active.cursorY+e.row;for(;o>0;){const e=Math.min(this._terminal.cols-n,o);r.push([a,n,e]),n=0,o-=e,a++}const l=[];for(const e of r){const r=this._terminal.registerMarker(e[0]),n=this._terminal.registerDecoration({marker:r,x:e[1],width:e[2],backgroundColor:s?t.activeMatchBackground:t.matchBackground,overviewRulerOptions:this._highlightedLines.has(r.line)?void 0:{color:s?t.activeMatchColorOverviewRuler:t.matchOverviewRuler,position:"center"}});if(n){const e=[];e.push(r),e.push(n.onRender((e=>this._applyStyles(e,s?t.activeMatchBorder:t.matchBorder,!1)))),e.push(n.onDispose((()=>(0,i.dispose)(e)))),l.push(n)}}return 0===l.length?void 0:l}}t.DecorationManager=r},615:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SearchEngine=void 0,t.SearchEngine=class{constructor(e,t){this._terminal=e,this._lineCache=t}find(e,t,s,i){if(!e||0===e.length)return void this._terminal.clearSelection();if(s>this._terminal.cols)throw new Error(`Invalid col: ${s} to search in terminal of ${this._terminal.cols} cols`);this._lineCache.initLinesCache();const r={startRow:t,startCol:s};let n=this._findInLine(e,r,i);if(!n)for(let s=t+1;s=0&&(a.startRow=s,l=this._findInLine(e,a,t,o),!l);s--);}if(!l&&r!==this._terminal.buffer.active.baseY+this._terminal.rows-1)for(let s=this._terminal.buffer.active.baseY+this._terminal.rows-1;s>=r&&(a.startRow=s,l=this._findInLine(e,a,t,o),!l);s--);return l}_isWholeWord(e,t,s){return(0===e||" ~!@#$%^&*()+`-=[]{}|\\;:\"',./<>?".includes(t[e-1]))&&(e+s.length===t.length||" ~!@#$%^&*()+`-=[]{}|\\;:\"',./<>?".includes(t[e+s.length]))}_findInLine(e,t,s={},i=!1){const r=t.startRow,n=t.startCol,o=this._terminal.buffer.active.getLine(r);if(o?.isWrapped)return i?void(t.startCol+=this._terminal.cols):(t.startRow--,t.startCol+=this._terminal.cols,this._findInLine(e,t,s));let a=this._lineCache.getLineFromCache(r);a||(a=this._lineCache.translateBufferLineToStringWithWrap(r,!0),this._lineCache.setLineInCache(r,a));const[l,h]=a,c=this._bufferColsToStringOffset(r,n);let u=e,d=l;s.regex||(u=s.caseSensitive?e:e.toLowerCase(),d=s.caseSensitive?l:l.toLowerCase());let f=-1;if(s.regex){const t=RegExp(u,s.caseSensitive?"g":"gi");let r;if(i)for(;r=t.exec(d.slice(0,c));)f=t.lastIndex-r[0].length,e=r[0],t.lastIndex-=e.length-1;else r=t.exec(d.slice(c)),r&&r[0].length>0&&(f=c+(t.lastIndex-r[0].length),e=r[0])}else i?c-u.length>=0&&(f=d.lastIndexOf(u,c-u.length)):f=d.indexOf(u,c);if(f>=0){if(s.wholeWord&&!this._isWholeWord(f,d,e))return;let t=0;for(;t=h[t+1];)t++;let i=t;for(;i=h[i+1];)i++;const n=f-h[t],o=f+e.length-h[i],a=this._stringLengthToBufferSize(r+t,n);return{term:e,col:a,row:r+t,size:this._stringLengthToBufferSize(r+i,o)-a+this._terminal.cols*(i-t)}}}_stringLengthToBufferSize(e,t){const s=this._terminal.buffer.active.getLine(e);if(!s)return 0;for(let e=0;e1&&(t-=r.length-1);const n=s.getCell(e+1);n&&0===n.getWidth()&&t++}return t}_bufferColsToStringOffset(e,t){let s=e,i=0,r=this._terminal.buffer.active.getLine(s);for(;t>0&&r;){for(let e=0;e{Object.defineProperty(t,"__esModule",{value:!0}),t.SearchLineCache=void 0;const i=s(540),r=s(940);class n extends i.Disposable{constructor(e){super(),this._terminal=e,this._linesCacheTimeout=this._register(new i.MutableDisposable),this._linesCacheDisposables=this._register(new i.MutableDisposable),this._register((0,i.toDisposable)((()=>this._destroyLinesCache())))}initLinesCache(){this._linesCache||(this._linesCache=new Array(this._terminal.buffer.active.length),this._linesCacheDisposables.value=(0,i.combinedDisposable)(this._terminal.onLineFeed((()=>this._destroyLinesCache())),this._terminal.onCursorMove((()=>this._destroyLinesCache())),this._terminal.onResize((()=>this._destroyLinesCache())))),this._linesCacheTimeout.value=(0,r.disposableTimeout)((()=>this._destroyLinesCache()),15e3)}_destroyLinesCache(){this._linesCache=void 0,this._linesCacheDisposables.clear(),this._linesCacheTimeout.clear()}getLineFromCache(e){return this._linesCache?.[e]}setLineInCache(e,t){this._linesCache&&(this._linesCache[e]=t)}translateBufferLineToStringWithWrap(e,t){const s=[],i=[0];let r=this._terminal.buffer.active.getLine(e);for(;r;){const n=this._terminal.buffer.active.getLine(e+1),o=!!n&&n.isWrapped;let a=r.translateToString(!o&&t);if(o&&n){const e=r.getCell(r.length-1);e&&0===e.getCode()&&1===e.getWidth()&&2===n.getCell(0)?.getWidth()&&(a=a.slice(0,-1))}if(s.push(a),!o)break;i.push(i[i.length-1]+a.length),e++,r=n}return[s.join(""),i]}}t.SearchLineCache=n},438:(e,t,s)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SearchResultTracker=void 0;const i=s(276),r=s(540);class n extends r.Disposable{constructor(){super(...arguments),this._searchResults=[],this._onDidChangeResults=this._register(new i.Emitter)}get onDidChangeResults(){return this._onDidChangeResults.event}get searchResults(){return this._searchResults}get selectedDecoration(){return this._selectedDecoration}set selectedDecoration(e){this._selectedDecoration=e}updateResults(e,t){this._searchResults=e.slice(0,t)}clearResults(){this._searchResults=[]}clearSelectedDecoration(){this._selectedDecoration&&(this._selectedDecoration.dispose(),this._selectedDecoration=void 0)}findResultIndex(e){for(let t=0;t{Object.defineProperty(t,"__esModule",{value:!0}),t.SearchState=void 0,t.SearchState=class{get cachedSearchTerm(){return this._cachedSearchTerm}set cachedSearchTerm(e){this._cachedSearchTerm=e}get lastSearchOptions(){return this._lastSearchOptions}set lastSearchOptions(e){this._lastSearchOptions=e}isValidSearchTerm(e){return!!(e&&e.length>0)}didOptionsChange(e){return!this._lastSearchOptions||!!e&&(this._lastSearchOptions.caseSensitive!==e.caseSensitive||this._lastSearchOptions.regex!==e.regex||this._lastSearchOptions.wholeWord!==e.wholeWord)}shouldUpdateHighlighting(e,t){return!!t?.decorations&&(void 0===this._cachedSearchTerm||e!==this._cachedSearchTerm||this.didOptionsChange(t))}clearCachedTerm(){this._cachedSearchTerm=void 0}reset(){this._cachedSearchTerm=void 0,this._lastSearchOptions=void 0}}},732:(e,t,s)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Permutation=t.CallbackIterable=t.ArrayQueue=t.booleanComparator=t.numberComparator=t.CompareResult=void 0,t.tail=function(e,t=0){return e[e.length-(1+t)]},t.tail2=function(e){if(0===e.length)throw new Error("Invalid tail call");return[e.slice(0,e.length-1),e[e.length-1]]},t.equals=function(e,t,s=(e,t)=>e===t){if(e===t)return!0;if(!e||!t)return!1;if(e.length!==t.length)return!1;for(let i=0,r=e.length;is(e[i],t)))},t.binarySearch2=n,t.quickSelect=function e(t,s,i){if((t|=0)>=s.length)throw new TypeError("invalid index");const r=s[Math.floor(s.length*Math.random())],n=[],o=[],a=[];for(const e of s){const t=i(e,r);t<0?n.push(e):t>0?o.push(e):a.push(e)}return t{(async()=>{const o=e.length,l=e.slice(0,s).sort(t);for(let h=s,c=Math.min(s+r,o);hs&&await new Promise((e=>setTimeout(e))),n&&n.isCancellationRequested)throw new i.CancellationError;a(e,t,l,h,c)}return l})().then(o,l)}))},t.coalesce=function(e){return e.filter((e=>!!e))},t.coalesceInPlace=function(e){let t=0;for(let s=0;s0},t.distinct=function(e,t=e=>e){const s=new Set;return e.filter((e=>{const i=t(e);return!s.has(i)&&(s.add(i),!0)}))},t.uniqueFilter=function(e){const t=new Set;return s=>{const i=e(s);return!t.has(i)&&(t.add(i),!0)}},t.firstOrDefault=function(e,t){return e.length>0?e[0]:t},t.lastOrDefault=function(e,t){return e.length>0?e[e.length-1]:t},t.commonPrefixLength=function(e,t,s=(e,t)=>e===t){let i=0;for(let r=0,n=Math.min(e.length,t.length);rt;e--)i.push(e);return i},t.index=function(e,t,s){return e.reduce(((e,i)=>(e[t(i)]=s?s(i):i,e)),Object.create(null))},t.insert=function(e,t){return e.push(t),()=>l(e,t)},t.remove=l,t.arrayInsert=function(e,t,s){const i=e.slice(0,t),r=e.slice(t);return i.concat(s,r)},t.shuffle=function(e,t){let s;if("number"==typeof t){let e=t;s=()=>{const t=179426549*Math.sin(e++);return t-Math.floor(t)}}else s=Math.random;for(let t=e.length-1;t>0;t-=1){const i=Math.floor(s()*(t+1)),r=e[t];e[t]=e[i],e[i]=r}},t.pushToStart=function(e,t){const s=e.indexOf(t);s>-1&&(e.splice(s,1),e.unshift(t))},t.pushToEnd=function(e,t){const s=e.indexOf(t);s>-1&&(e.splice(s,1),e.push(t))},t.pushMany=function(e,t){for(const s of t)e.push(s)},t.mapArrayOrNot=function(e,t){return Array.isArray(e)?e.map(t):t(e)},t.asArray=function(e){return Array.isArray(e)?e:[e]},t.getRandomElement=function(e){return e[Math.floor(Math.random()*e.length)]},t.insertInto=h,t.splice=function(e,t,s,i){const r=c(e,t);let n=e.splice(r,s);return void 0===n&&(n=[]),h(e,r,i),n},t.compareBy=function(e,t){return(s,i)=>t(e(s),e(i))},t.tieBreakComparators=function(...e){return(t,s)=>{for(const i of e){const e=i(t,s);if(!u.isNeitherLessOrGreaterThan(e))return e}return u.neitherLessOrGreaterThan}},t.reverseOrder=function(e){return(t,s)=>-e(t,s)};const i=s(577),r=s(411);function n(e,t){let s=0,i=e-1;for(;s<=i;){const e=(s+i)/2|0,r=t(e);if(r<0)s=e+1;else{if(!(r>0))return e;i=e-1}}return-(s+1)}function o(e,t,s){const i=[];function r(e,t,s){if(0===t&&0===s.length)return;const r=i[i.length-1];r&&r.start+r.deleteCount===e?(r.deleteCount+=t,r.toInsert.push(...s)):i.push({start:e,deleteCount:t,toInsert:s})}let n=0,o=0;for(;;){if(n===e.length){r(n,0,t.slice(o));break}if(o===t.length){r(n,e.length-n,[]);break}const i=e[n],a=t[o],l=s(i,a);0===l?(n+=1,o+=1):l<0?(r(n,1,[]),n+=1):l>0&&(r(n,0,[a]),o+=1)}return i}function a(e,t,s,i,n){for(const o=s.length;it(n,e)<0));s.splice(e,0,n)}}}function l(e,t){const s=e.indexOf(t);if(s>-1)return e.splice(s,1),t}function h(e,t,s){const i=c(e,t),r=e.length,n=s.length;e.length=r+n;for(let t=r-1;t>=i;t--)e[t+n]=e[t];for(let t=0;t0},e.isNeitherLessOrGreaterThan=function(e){return 0===e},e.greaterThan=1,e.lessThan=-1,e.neitherLessOrGreaterThan=0}(u||(t.CompareResult=u={})),t.numberComparator=(e,t)=>e-t,t.booleanComparator=(e,s)=>(0,t.numberComparator)(e?1:0,s?1:0),t.ArrayQueue=class{constructor(e){this.items=e,this.firstIdx=0,this.lastIdx=this.items.length-1}get length(){return this.lastIdx-this.firstIdx+1}takeWhile(e){let t=this.firstIdx;for(;t=0&&e(this.items[t]);)t--;const s=t===this.lastIdx?null:this.items.slice(t+1,this.lastIdx+1);return this.lastIdx=t,s}peek(){if(0!==this.length)return this.items[this.firstIdx]}peekLast(){if(0!==this.length)return this.items[this.lastIdx]}dequeue(){const e=this.items[this.firstIdx];return this.firstIdx++,e}removeLast(){const e=this.items[this.lastIdx];return this.lastIdx--,e}takeCount(e){const t=this.items.slice(this.firstIdx,this.firstIdx+e);return this.firstIdx+=e,t}};class d{static{this.empty=new d((e=>{}))}constructor(e){this.iterate=e}forEach(e){this.iterate((t=>(e(t),!0)))}toArray(){const e=[];return this.iterate((t=>(e.push(t),!0))),e}filter(e){return new d((t=>this.iterate((s=>!e(s)||t(s)))))}map(e){return new d((t=>this.iterate((s=>t(e(s))))))}some(e){let t=!1;return this.iterate((s=>(t=e(s),!t))),t}findFirst(e){let t;return this.iterate((s=>!e(s)||(t=s,!1))),t}findLast(e){let t;return this.iterate((s=>(e(s)&&(t=s),!0))),t}findLastMaxBy(e){let t,s=!0;return this.iterate((i=>((s||u.isGreaterThan(e(i,t)))&&(s=!1,t=i),!0))),t}}t.CallbackIterable=d;class f{constructor(e){this._indexMap=e}static createSortPermutation(e,t){const s=Array.from(e.keys()).sort(((s,i)=>t(e[s],e[i])));return new f(s)}apply(e){return e.map(((t,s)=>e[this._indexMap[s]]))}inverse(){const e=this._indexMap.slice();for(let t=0;t{function s(e,t,s=e.length-1){for(let i=s;i>=0;i--)if(t(e[i]))return i;return-1}function i(e,t,s=0,i=e.length){let r=s,n=i;for(;r=0&&(s=r)}return s},t.findFirstMin=function(e,t){return o(e,((e,s)=>-t(e,s)))},t.findMaxIdx=function(e,t){if(0===e.length)return-1;let s=0;for(let i=1;i0&&(s=i);return s},t.mapFindFirst=function(e,t){for(const s of e){const e=t(s);if(void 0!==e)return e}};class n{static{this.assertInvariants=!1}constructor(e){this._array=e,this._findLastMonotonousLastIdx=0}findLastMonotonous(e){if(n.assertInvariants){if(this._prevFindLastPredicate)for(const t of this._array)if(this._prevFindLastPredicate(t)&&!e(t))throw new Error("MonotonousArray: current predicate must be weaker than (or equal to) the previous predicate.");this._prevFindLastPredicate=e}const t=i(this._array,e,this._findLastMonotonousLastIdx);return this._findLastMonotonousLastIdx=t+1,-1===t?void 0:this._array[t]}}function o(e,t){if(0===e.length)return;let s=e[0];for(let i=1;i0&&(s=r)}return s}t.MonotonousArray=n},940:(e,t,s)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.AsyncIterableSource=t.CancelableAsyncIterableObject=t.AsyncIterableObject=t.LazyStatefulPromise=t.StatefulPromise=t.Promises=t.DeferredPromise=t.IntervalCounter=t.TaskSequentializer=t.GlobalIdleValue=t.AbstractIdleValue=t._runWhenIdle=t.runWhenGlobalIdle=t.ThrottledWorker=t.RunOnceWorker=t.ProcessTimeRunOnceScheduler=t.RunOnceScheduler=t.IntervalTimer=t.TimeoutTimer=t.LimitedQueue=t.Queue=t.Limiter=t.AutoOpenBarrier=t.Barrier=t.ThrottledDelayer=t.Delayer=t.SequencerByKey=t.Sequencer=t.Throttler=void 0,t.isThenable=c,t.createCancelablePromise=u,t.raceCancellation=function(e,t,s){return new Promise(((i,r)=>{const n=t.onCancellationRequested((()=>{n.dispose(),i(s)}));e.then(i,r).finally((()=>n.dispose()))}))},t.raceCancellationError=function(e,t){return new Promise(((s,i)=>{const n=t.onCancellationRequested((()=>{n.dispose(),i(new r.CancellationError)}));e.then(s,i).finally((()=>n.dispose()))}))},t.raceCancellablePromises=async function(e){let t=-1;const s=e.map(((e,s)=>e.then((e=>(t=s,e)))));try{return await Promise.race(s)}finally{e.forEach(((e,s)=>{s!==t&&e.cancel()}))}},t.raceTimeout=function(e,t,s){let i;const r=setTimeout((()=>{i?.(void 0),s?.()}),t);return Promise.race([e.finally((()=>clearTimeout(r))),new Promise((e=>i=e))])},t.asPromise=function(e){return new Promise(((t,s)=>{const i=e();c(i)?i.then(t,s):t(i)}))},t.promiseWithResolvers=d,t.timeout=m,t.disposableTimeout=function(e,t=0,s){const i=setTimeout((()=>{e(),s&&r.dispose()}),t),r=(0,o.toDisposable)((()=>{clearTimeout(i),s?.deleteAndLeak(r)}));return s?.add(r),r},t.sequence=function(e){const t=[];let s=0;const i=e.length;return Promise.resolve(null).then((function r(n){null!=n&&t.push(n);const o=s!!e,s=null){let i=0;const r=e.length,n=()=>{if(i>=r)return Promise.resolve(s);const o=e[i++];return Promise.resolve(o()).then((e=>t(e)?Promise.resolve(e):n()))};return n()},t.firstParallel=function(e,t=e=>!!e,s=null){if(0===e.length)return Promise.resolve(s);let i=e.length;const r=()=>{i=-1;for(const t of e)t.cancel?.()};return new Promise(((n,o)=>{for(const a of e)a.then((e=>{--i>=0&&t(e)?(r(),n(e)):0===i&&n(s)})).catch((e=>{--i>=0&&(r(),o(e))}))}))},t.retry=async function(e,t,s){let i;for(let r=0;r{const i=t.token.onCancellationRequested((()=>{i.dispose(),t.dispose(),e.reject(new r.CancellationError)}));try{for await(const i of s){if(t.token.isCancellationRequested)return;e.emitOne(i)}i.dispose(),t.dispose()}catch(s){i.dispose(),t.dispose(),e.reject(s)}}))};const i=s(473),r=s(577),n=s(276),o=s(540),a=s(973),l=s(329),h=s(764);function c(e){return!!e&&"function"==typeof e.then}function u(e){const t=new i.CancellationTokenSource,s=e(t.token),n=new Promise(((e,i)=>{const n=t.token.onCancellationRequested((()=>{n.dispose(),i(new r.CancellationError)}));Promise.resolve(s).then((s=>{n.dispose(),t.dispose(),e(s)}),(e=>{n.dispose(),t.dispose(),i(e)}))}));return new class{cancel(){t.cancel(),t.dispose()}then(e,t){return n.then(e,t)}catch(e){return this.then(void 0,e)}finally(e){return n.finally(e)}}}function d(){let e,t;return{promise:new Promise(((s,i)=>{e=s,t=i})),resolve:e,reject:t}}class f{constructor(){this.isDisposed=!1,this.activePromise=null,this.queuedPromise=null,this.queuedPromiseFactory=null}queue(e){if(this.isDisposed)return Promise.reject(new Error("Throttler is disposed"));if(this.activePromise){if(this.queuedPromiseFactory=e,!this.queuedPromise){const e=()=>{if(this.queuedPromise=null,this.isDisposed)return;const e=this.queue(this.queuedPromiseFactory);return this.queuedPromiseFactory=null,e};this.queuedPromise=new Promise((t=>{this.activePromise.then(e,e).then(t)}))}return new Promise(((e,t)=>{this.queuedPromise.then(e,t)}))}return this.activePromise=e(),new Promise(((e,t)=>{this.activePromise.then((t=>{this.activePromise=null,e(t)}),(e=>{this.activePromise=null,t(e)}))}))}dispose(){this.isDisposed=!0}}t.Throttler=f,t.Sequencer=class{constructor(){this.current=Promise.resolve(null)}queue(e){return this.current=this.current.then((()=>e()),(()=>e()))}},t.SequencerByKey=class{constructor(){this.promiseMap=new Map}queue(e,t){const s=(this.promiseMap.get(e)??Promise.resolve()).catch((()=>{})).then(t).finally((()=>{this.promiseMap.get(e)===s&&this.promiseMap.delete(e)}));return this.promiseMap.set(e,s),s}};class p{constructor(e){this.defaultDelay=e,this.deferred=null,this.completionPromise=null,this.doResolve=null,this.doReject=null,this.task=null}trigger(e,t=this.defaultDelay){this.task=e,this.cancelTimeout(),this.completionPromise||(this.completionPromise=new Promise(((e,t)=>{this.doResolve=e,this.doReject=t})).then((()=>{if(this.completionPromise=null,this.doResolve=null,this.task){const e=this.task;return this.task=null,e()}})));const s=()=>{this.deferred=null,this.doResolve?.(null)};return this.deferred=t===l.MicrotaskDelay?(e=>{let t=!0;return queueMicrotask((()=>{t&&(t=!1,e())})),{isTriggered:()=>t,dispose:()=>{t=!1}}})(s):((e,t)=>{let s=!0;const i=setTimeout((()=>{s=!1,t()}),e);return{isTriggered:()=>s,dispose:()=>{clearTimeout(i),s=!1}}})(t,s),this.completionPromise}isTriggered(){return!!this.deferred?.isTriggered()}cancel(){this.cancelTimeout(),this.completionPromise&&(this.doReject?.(new r.CancellationError),this.completionPromise=null)}cancelTimeout(){this.deferred?.dispose(),this.deferred=null}dispose(){this.cancel()}}t.Delayer=p,t.ThrottledDelayer=class{constructor(e){this.delayer=new p(e),this.throttler=new f}trigger(e,t){return this.delayer.trigger((()=>this.throttler.queue(e)),t)}isTriggered(){return this.delayer.isTriggered()}cancel(){this.delayer.cancel()}dispose(){this.delayer.dispose(),this.throttler.dispose()}};class _{constructor(){this._isOpen=!1,this._promise=new Promise(((e,t)=>{this._completePromise=e}))}isOpen(){return this._isOpen}open(){this._isOpen=!0,this._completePromise(!0)}wait(){return this._promise}}function m(e,t){return t?new Promise(((s,i)=>{const n=setTimeout((()=>{o.dispose(),s()}),e),o=t.onCancellationRequested((()=>{clearTimeout(n),o.dispose(),i(new r.CancellationError)}))})):u((t=>m(e,t)))}t.Barrier=_,t.AutoOpenBarrier=class extends _{constructor(e){super(),this._timeout=setTimeout((()=>this.open()),e)}open(){clearTimeout(this._timeout),super.open()}};class g{constructor(e){this._size=0,this._isDisposed=!1,this.maxDegreeOfParalellism=e,this.outstandingPromises=[],this.runningPromises=0,this._onDrained=new n.Emitter}whenIdle(){return this.size>0?n.Event.toPromise(this.onDrained):Promise.resolve()}get onDrained(){return this._onDrained.event}get size(){return this._size}queue(e){if(this._isDisposed)throw new Error("Object has been disposed");return this._size++,new Promise(((t,s)=>{this.outstandingPromises.push({factory:e,c:t,e:s}),this.consume()}))}consume(){for(;this.outstandingPromises.length&&this.runningPromisesthis.consumed()),(()=>this.consumed()))}}consumed(){this._isDisposed||(this.runningPromises--,0==--this._size&&this._onDrained.fire(),this.outstandingPromises.length>0&&this.consume())}clear(){if(this._isDisposed)throw new Error("Object has been disposed");this.outstandingPromises.length=0,this._size=this.runningPromises}dispose(){this._isDisposed=!0,this.outstandingPromises.length=0,this._size=0,this._onDrained.dispose()}}t.Limiter=g,t.Queue=class extends g{constructor(){super(1)}},t.LimitedQueue=class{constructor(){this.sequentializer=new w,this.tasks=0}queue(e){return this.sequentializer.isRunning()?this.sequentializer.queue((()=>this.sequentializer.run(this.tasks++,e()))):this.sequentializer.run(this.tasks++,e())}},t.TimeoutTimer=class{constructor(e,t){this._isDisposed=!1,this._token=-1,"function"==typeof e&&"number"==typeof t&&this.setIfNotSet(e,t)}dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(e,t){if(this._isDisposed)throw new r.BugIndicatingError("Calling 'cancelAndSet' on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout((()=>{this._token=-1,e()}),t)}setIfNotSet(e,t){if(this._isDisposed)throw new r.BugIndicatingError("Calling 'setIfNotSet' on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout((()=>{this._token=-1,e()}),t))}},t.IntervalTimer=class{constructor(){this.disposable=void 0,this.isDisposed=!1}cancel(){this.disposable?.dispose(),this.disposable=void 0}cancelAndSet(e,t,s=globalThis){if(this.isDisposed)throw new r.BugIndicatingError("Calling 'cancelAndSet' on a disposed IntervalTimer");this.cancel();const i=s.setInterval((()=>{e()}),t);this.disposable=(0,o.toDisposable)((()=>{s.clearInterval(i),this.disposable=void 0}))}dispose(){this.cancel(),this.isDisposed=!0}};class v{constructor(e,t){this.timeoutToken=-1,this.runner=e,this.timeout=t,this.timeoutHandler=this.onTimeout.bind(this)}dispose(){this.cancel(),this.runner=null}cancel(){this.isScheduled()&&(clearTimeout(this.timeoutToken),this.timeoutToken=-1)}schedule(e=this.timeout){this.cancel(),this.timeoutToken=setTimeout(this.timeoutHandler,e)}get delay(){return this.timeout}set delay(e){this.timeout=e}isScheduled(){return-1!==this.timeoutToken}flush(){this.isScheduled()&&(this.cancel(),this.doRun())}onTimeout(){this.timeoutToken=-1,this.runner&&this.doRun()}doRun(){this.runner?.()}}t.RunOnceScheduler=v,t.ProcessTimeRunOnceScheduler=class{constructor(e,t){t%1e3!=0&&console.warn(`ProcessTimeRunOnceScheduler resolution is 1s, ${t}ms is not a multiple of 1000ms.`),this.runner=e,this.timeout=t,this.counter=0,this.intervalToken=-1,this.intervalHandler=this.onInterval.bind(this)}dispose(){this.cancel(),this.runner=null}cancel(){this.isScheduled()&&(clearInterval(this.intervalToken),this.intervalToken=-1)}schedule(e=this.timeout){e%1e3!=0&&console.warn(`ProcessTimeRunOnceScheduler resolution is 1s, ${e}ms is not a multiple of 1000ms.`),this.cancel(),this.counter=Math.ceil(e/1e3),this.intervalToken=setInterval(this.intervalHandler,1e3)}isScheduled(){return-1!==this.intervalToken}onInterval(){this.counter--,this.counter>0||(clearInterval(this.intervalToken),this.intervalToken=-1,this.runner?.())}},t.RunOnceWorker=class extends v{constructor(e,t){super(e,t),this.units=[]}work(e){this.units.push(e),this.isScheduled()||this.schedule()}doRun(){const e=this.units;this.units=[],this.runner?.(e)}dispose(){this.units=[],super.dispose()}};class b extends o.Disposable{constructor(e,t){super(),this.options=e,this.handler=t,this.pendingWork=[],this.throttler=this._register(new o.MutableDisposable),this.disposed=!1}get pending(){return this.pendingWork.length}work(e){if(this.disposed)return!1;if("number"==typeof this.options.maxBufferedWork)if(this.throttler.value){if(this.pending+e.length>this.options.maxBufferedWork)return!1}else if(this.pending+e.length-this.options.maxWorkChunkSize>this.options.maxBufferedWork)return!1;for(const t of e)this.pendingWork.push(t);return this.throttler.value||this.doWork(),!0}doWork(){this.handler(this.pendingWork.splice(0,this.options.maxWorkChunkSize)),this.pendingWork.length>0&&(this.throttler.value=new v((()=>{this.throttler.clear(),this.doWork()}),this.options.throttleDelay),this.throttler.value.schedule())}dispose(){super.dispose(),this.disposed=!0}}t.ThrottledWorker=b,"function"!=typeof globalThis.requestIdleCallback||"function"!=typeof globalThis.cancelIdleCallback?t._runWhenIdle=(e,t)=>{(0,a.setTimeout0)((()=>{if(s)return;const e=Date.now()+15,i={didTimeout:!0,timeRemaining:()=>Math.max(0,e-Date.now())};t(Object.freeze(i))}));let s=!1;return{dispose(){s||(s=!0)}}}:t._runWhenIdle=(e,t,s)=>{const i=e.requestIdleCallback(t,"number"==typeof s?{timeout:s}:void 0);let r=!1;return{dispose(){r||(r=!0,e.cancelIdleCallback(i))}}},t.runWhenGlobalIdle=e=>(0,t._runWhenIdle)(globalThis,e);class y{constructor(e,s){this._didRun=!1,this._executor=()=>{try{this._value=s()}catch(e){this._error=e}finally{this._didRun=!0}},this._handle=(0,t._runWhenIdle)(e,(()=>this._executor()))}dispose(){this._handle.dispose()}get value(){if(this._didRun||(this._handle.dispose(),this._executor()),this._error)throw this._error;return this._value}get isInitialized(){return this._didRun}}t.AbstractIdleValue=y,t.GlobalIdleValue=class extends y{constructor(e){super(globalThis,e)}};class w{isRunning(e){return"number"==typeof e?this._running?.taskId===e:!!this._running}get running(){return this._running?.promise}cancelRunning(){this._running?.cancel()}run(e,t,s){return this._running={taskId:e,cancel:()=>s?.(),promise:t},t.then((()=>this.doneRunning(e)),(()=>this.doneRunning(e))),t}doneRunning(e){this._running&&e===this._running.taskId&&(this._running=void 0,this.runQueued())}runQueued(){if(this._queued){const e=this._queued;this._queued=void 0,e.run().then(e.promiseResolve,e.promiseReject)}}queue(e){if(this._queued)this._queued.run=e;else{const{promise:t,resolve:s,reject:i}=d();this._queued={run:e,promise:t,promiseResolve:s,promiseReject:i}}return this._queued.promise}hasQueued(){return!!this._queued}async join(){return this._queued?.promise??this._running?.promise}}var D,S,E;t.TaskSequentializer=w,t.IntervalCounter=class{constructor(e,t=()=>Date.now()){this.interval=e,this.nowFn=t,this.lastIncrementTime=0,this.value=0}increment(){const e=this.nowFn();return e-this.lastIncrementTime>this.interval&&(this.lastIncrementTime=e,this.value=0),this.value++,this.value}},function(e){e[e.Resolved=0]="Resolved",e[e.Rejected=1]="Rejected"}(D||(D={}));class k{get isRejected(){return this.outcome?.outcome===D.Rejected}get isResolved(){return this.outcome?.outcome===D.Resolved}get isSettled(){return!!this.outcome}get value(){return this.outcome?.outcome===D.Resolved?this.outcome?.value:void 0}constructor(){this.p=new Promise(((e,t)=>{this.completeCallback=e,this.errorCallback=t}))}complete(e){return new Promise((t=>{this.completeCallback(e),this.outcome={outcome:D.Resolved,value:e},t()}))}error(e){return new Promise((t=>{this.errorCallback(e),this.outcome={outcome:D.Rejected,value:e},t()}))}cancel(){return this.error(new r.CancellationError)}}t.DeferredPromise=k,function(e){e.settled=async function(e){let t;const s=await Promise.all(e.map((e=>e.then((e=>e),(e=>{t||(t=e)})))));if(void 0!==t)throw t;return s},e.withAsyncBody=function(e){return new Promise((async(t,s)=>{try{await e(t,s)}catch(e){s(e)}}))}}(S||(t.Promises=S={}));class C{get value(){return this._value}get error(){return this._error}get isResolved(){return this._isResolved}constructor(e){this._value=void 0,this._error=void 0,this._isResolved=!1,this.promise=e.then((e=>(this._value=e,this._isResolved=!0,e)),(e=>{throw this._error=e,this._isResolved=!0,e}))}requireValue(){if(!this._isResolved)throw new r.BugIndicatingError("Promise is not resolved yet");if(this._error)throw this._error;return this._value}}t.StatefulPromise=C,t.LazyStatefulPromise=class{constructor(e){this._compute=e,this._promise=new h.Lazy((()=>new C(this._compute())))}requireValue(){return this._promise.value.requireValue()}getPromise(){return this._promise.value.promise}get currentValue(){return this._promise.rawValue?.value}},function(e){e[e.Initial=0]="Initial",e[e.DoneOK=1]="DoneOK",e[e.DoneError=2]="DoneError"}(E||(E={}));class L{static fromArray(e){return new L((t=>{t.emitMany(e)}))}static fromPromise(e){return new L((async t=>{t.emitMany(await e)}))}static fromPromises(e){return new L((async t=>{await Promise.all(e.map((async e=>t.emitOne(await e))))}))}static merge(e){return new L((async t=>{await Promise.all(e.map((async e=>{for await(const s of e)t.emitOne(s)})))}))}static{this.EMPTY=L.fromArray([])}constructor(e,t){this._state=E.Initial,this._results=[],this._error=null,this._onReturn=t,this._onStateChanged=new n.Emitter,queueMicrotask((async()=>{const t={emitOne:e=>this.emitOne(e),emitMany:e=>this.emitMany(e),reject:e=>this.reject(e)};try{await Promise.resolve(e(t)),this.resolve()}catch(e){this.reject(e)}finally{t.emitOne=void 0,t.emitMany=void 0,t.reject=void 0}}))}[Symbol.asyncIterator](){let e=0;return{next:async()=>{for(;;){if(this._state===E.DoneError)throw this._error;if(e(this._onReturn?.(),{done:!0,value:void 0})}}static map(e,t){return new L((async s=>{for await(const i of e)s.emitOne(t(i))}))}map(e){return L.map(this,e)}static filter(e,t){return new L((async s=>{for await(const i of e)t(i)&&s.emitOne(i)}))}filter(e){return L.filter(this,e)}static coalesce(e){return L.filter(e,(e=>!!e))}coalesce(){return L.coalesce(this)}static async toPromise(e){const t=[];for await(const s of e)t.push(s);return t}toPromise(){return L.toPromise(this)}emitOne(e){this._state===E.Initial&&(this._results.push(e),this._onStateChanged.fire())}emitMany(e){this._state===E.Initial&&(this._results=this._results.concat(e),this._onStateChanged.fire())}resolve(){this._state===E.Initial&&(this._state=E.DoneOK,this._onStateChanged.fire())}reject(e){this._state===E.Initial&&(this._state=E.DoneError,this._error=e,this._onStateChanged.fire())}}t.AsyncIterableObject=L;class T extends L{constructor(e,t){super(t),this._source=e}cancel(){this._source.cancel()}}t.CancelableAsyncIterableObject=T,t.AsyncIterableSource=class{constructor(e){let t,s;this._deferred=new k,this._asyncIterable=new L((e=>{if(!t)return s&&e.emitMany(s),this._errorFn=t=>e.reject(t),this._emitFn=t=>e.emitOne(t),this._deferred.p;e.reject(t)}),e),this._emitFn=e=>{s||(s=[]),s.push(e)},this._errorFn=e=>{t||(t=e)}}get asyncIterable(){return this._asyncIterable}resolve(){this._deferred.complete()}reject(e){this._errorFn(e),this._deferred.complete()}emitOne(e){this._emitFn(e)}}},473:(e,t,s)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CancellationTokenSource=t.CancellationToken=void 0,t.cancelOnDispose=function(e){const t=new a;return e.add({dispose(){t.cancel()}}),t.token};const i=s(276),r=Object.freeze((function(e,t){const s=setTimeout(e.bind(t),0);return{dispose(){clearTimeout(s)}}}));var n;!function(e){e.isCancellationToken=function(t){return t===e.None||t===e.Cancelled||t instanceof o||!(!t||"object"!=typeof t)&&"boolean"==typeof t.isCancellationRequested&&"function"==typeof t.onCancellationRequested},e.None=Object.freeze({isCancellationRequested:!1,onCancellationRequested:i.Event.None}),e.Cancelled=Object.freeze({isCancellationRequested:!0,onCancellationRequested:r})}(n||(t.CancellationToken=n={}));class o{constructor(){this._isCancelled=!1,this._emitter=null}cancel(){this._isCancelled||(this._isCancelled=!0,this._emitter&&(this._emitter.fire(void 0),this.dispose()))}get isCancellationRequested(){return this._isCancelled}get onCancellationRequested(){return this._isCancelled?r:(this._emitter||(this._emitter=new i.Emitter),this._emitter.event)}dispose(){this._emitter&&(this._emitter.dispose(),this._emitter=null)}}class a{constructor(e){this._token=void 0,this._parentListener=void 0,this._parentListener=e&&e.onCancellationRequested(this.cancel,this)}get token(){return this._token||(this._token=new o),this._token}cancel(){this._token?this._token instanceof o&&this._token.cancel():this._token=n.Cancelled}dispose(e=!1){e&&this.cancel(),this._parentListener?.dispose(),this._token?this._token instanceof o&&this._token.dispose():this._token=n.None}}t.CancellationTokenSource=a},33:(e,t)=>{var s;Object.defineProperty(t,"__esModule",{value:!0}),t.SetWithKey=void 0,t.groupBy=function(e,t){const s=Object.create(null);for(const i of e){const e=t(i);let r=s[e];r||(r=s[e]=[]),r.push(i)}return s},t.diffSets=function(e,t){const s=[],i=[];for(const i of e)t.has(i)||s.push(i);for(const s of t)e.has(s)||i.push(s);return{removed:s,added:i}},t.diffMaps=function(e,t){const s=[],i=[];for(const[i,r]of e)t.has(i)||s.push(r);for(const[s,r]of t)e.has(s)||i.push(r);return{removed:s,added:i}},t.intersection=function(e,t){const s=new Set;for(const i of t)e.has(i)&&s.add(i);return s};class i{static{s=Symbol.toStringTag}constructor(e,t){this.toKey=t,this._map=new Map,this[s]="SetWithKey";for(const t of e)this.add(t)}get size(){return this._map.size}add(e){const t=this.toKey(e);return this._map.set(t,e),this}delete(e){return this._map.delete(this.toKey(e))}has(e){return this._map.has(this.toKey(e))}*entries(){for(const e of this._map.values())yield[e,e]}keys(){return this.values()}*values(){for(const e of this._map.values())yield e}clear(){this._map.clear()}forEach(e,t){this._map.forEach((s=>e.call(t,s,s,this)))}[Symbol.iterator](){return this.values()}}t.SetWithKey=i},577:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BugIndicatingError=t.ErrorNoTelemetry=t.ExpectedError=t.NotSupportedError=t.NotImplementedError=t.ReadonlyError=t.CancellationError=t.errorHandler=t.ErrorHandler=void 0,t.setUnexpectedErrorHandler=function(e){t.errorHandler.setUnexpectedErrorHandler(e)},t.isSigPipeError=function(e){if(!e||"object"!=typeof e)return!1;const t=e;return"EPIPE"===t.code&&"WRITE"===t.syscall?.toUpperCase()},t.onUnexpectedError=function(e){r(e)||t.errorHandler.onUnexpectedError(e)},t.onUnexpectedExternalError=function(e){r(e)||t.errorHandler.onUnexpectedExternalError(e)},t.transformErrorForSerialization=function(e){if(e instanceof Error){const{name:t,message:s}=e;return{$isError:!0,name:t,message:s,stack:e.stacktrace||e.stack,noTelemetry:c.isErrorNoTelemetry(e)}}return e},t.transformErrorFromSerialization=function(e){let t;return e.noTelemetry?t=new c:(t=new Error,t.name=e.name),t.message=e.message,t.stack=e.stack,t},t.isCancellationError=r,t.canceled=function(){const e=new Error(i);return e.name=e.message,e},t.illegalArgument=function(e){return e?new Error(`Illegal argument: ${e}`):new Error("Illegal argument")},t.illegalState=function(e){return e?new Error(`Illegal state: ${e}`):new Error("Illegal state")},t.getErrorMessage=function(e){return e?e.message?e.message:e.stack?e.stack.split("\n")[0]:String(e):"Error"};class s{constructor(){this.listeners=[],this.unexpectedErrorHandler=function(e){setTimeout((()=>{if(e.stack){if(c.isErrorNoTelemetry(e))throw new c(e.message+"\n\n"+e.stack);throw new Error(e.message+"\n\n"+e.stack)}throw e}),0)}}addListener(e){return this.listeners.push(e),()=>{this._removeListener(e)}}emit(e){this.listeners.forEach((t=>{t(e)}))}_removeListener(e){this.listeners.splice(this.listeners.indexOf(e),1)}setUnexpectedErrorHandler(e){this.unexpectedErrorHandler=e}getUnexpectedErrorHandler(){return this.unexpectedErrorHandler}onUnexpectedError(e){this.unexpectedErrorHandler(e),this.emit(e)}onUnexpectedExternalError(e){this.unexpectedErrorHandler(e)}}t.ErrorHandler=s,t.errorHandler=new s;const i="Canceled";function r(e){return e instanceof n||e instanceof Error&&e.name===i&&e.message===i}class n extends Error{constructor(){super(i),this.name=this.message}}t.CancellationError=n;class o extends TypeError{constructor(e){super(e?`${e} is read-only and cannot be changed`:"Cannot change read-only property")}}t.ReadonlyError=o;class a extends Error{constructor(e){super("NotImplemented"),e&&(this.message=e)}}t.NotImplementedError=a;class l extends Error{constructor(e){super("NotSupported"),e&&(this.message=e)}}t.NotSupportedError=l;class h extends Error{constructor(){super(...arguments),this.isExpected=!0}}t.ExpectedError=h;class c extends Error{constructor(e){super(e),this.name="CodeExpectedError"}static fromError(e){if(e instanceof c)return e;const t=new c;return t.message=e.message,t.stack=e.stack,t}static isErrorNoTelemetry(e){return"CodeExpectedError"===e.name}}t.ErrorNoTelemetry=c;class u extends Error{constructor(e){super(e||"An unexpected bug occurred."),Object.setPrototypeOf(this,u.prototype)}}t.BugIndicatingError=u},276:(e,t,s)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ValueWithChangeEvent=t.Relay=t.EventBufferer=t.DynamicListEventMultiplexer=t.EventMultiplexer=t.MicrotaskEmitter=t.DebounceEmitter=t.PauseableEmitter=t.AsyncEmitter=t.createEventDeliveryQueue=t.Emitter=t.ListenerRefusalError=t.ListenerLeakError=t.EventProfiling=t.Event=void 0,t.setGlobalLeakWarningThreshold=function(e){const t=c;return c=e,{dispose(){c=t}}};const i=s(577),r=s(355),n=s(540),o=s(711),a=s(79);var l;!function(e){function t(e){return(t,s=null,i)=>{let r,n=!1;return r=e((e=>{if(!n)return r?r.dispose():n=!0,t.call(s,e)}),null,i),n&&r.dispose(),r}}function s(e,t,s){return r(((s,i=null,r)=>e((e=>s.call(i,t(e))),null,r)),s)}function i(e,t,s){return r(((s,i=null,r)=>e((e=>t(e)&&s.call(i,e)),null,r)),s)}function r(e,t){let s;const i=new g({onWillAddFirstListener(){s=e(i.fire,i)},onDidRemoveLastListener(){s?.dispose()}});return t?.add(i),i.event}function o(e,t,s=100,i=!1,r=!1,n,o){let a,l,h,c,u=0;const d=new g({leakWarningThreshold:n,onWillAddFirstListener(){a=e((e=>{u++,l=t(l,e),i&&!h&&(d.fire(l),l=void 0),c=()=>{const e=l;l=void 0,h=void 0,(!i||u>1)&&d.fire(e),u=0},"number"==typeof s?(clearTimeout(h),h=setTimeout(c,s)):void 0===h&&(h=0,queueMicrotask(c))}))},onWillRemoveListener(){r&&u>0&&c?.()},onDidRemoveLastListener(){c=void 0,a.dispose()}});return o?.add(d),d.event}e.None=()=>n.Disposable.None,e.defer=function(e,t){return o(e,(()=>{}),0,void 0,!0,void 0,t)},e.once=t,e.map=s,e.forEach=function(e,t,s){return r(((s,i=null,r)=>e((e=>{t(e),s.call(i,e)}),null,r)),s)},e.filter=i,e.signal=function(e){return e},e.any=function(...e){return(t,s=null,i)=>{return r=(0,n.combinedDisposable)(...e.map((e=>e((e=>t.call(s,e)))))),(o=i)instanceof Array?o.push(r):o&&o.add(r),r;var r,o}},e.reduce=function(e,t,i,r){let n=i;return s(e,(e=>(n=t(n,e),n)),r)},e.debounce=o,e.accumulate=function(t,s=0,i){return e.debounce(t,((e,t)=>e?(e.push(t),e):[t]),s,void 0,!0,void 0,i)},e.latch=function(e,t=(e,t)=>e===t,s){let r,n=!0;return i(e,(e=>{const s=n||!t(e,r);return n=!1,r=e,s}),s)},e.split=function(t,s,i){return[e.filter(t,s,i),e.filter(t,(e=>!s(e)),i)]},e.buffer=function(e,t=!1,s=[],i){let r=s.slice(),n=e((e=>{r?r.push(e):a.fire(e)}));i&&i.add(n);const o=()=>{r?.forEach((e=>a.fire(e))),r=null},a=new g({onWillAddFirstListener(){n||(n=e((e=>a.fire(e))),i&&i.add(n))},onDidAddFirstListener(){r&&(t?setTimeout(o):o())},onDidRemoveLastListener(){n&&n.dispose(),n=null}});return i&&i.add(a),a.event},e.chain=function(e,t){return(s,i,r)=>{const n=t(new l);return e((function(e){const t=n.evaluate(e);t!==a&&s.call(i,t)}),void 0,r)}};const a=Symbol("HaltChainable");class l{constructor(){this.steps=[]}map(e){return this.steps.push(e),this}forEach(e){return this.steps.push((t=>(e(t),t))),this}filter(e){return this.steps.push((t=>e(t)?t:a)),this}reduce(e,t){let s=t;return this.steps.push((t=>(s=e(s,t),s))),this}latch(e=(e,t)=>e===t){let t,s=!0;return this.steps.push((i=>{const r=s||!e(i,t);return s=!1,t=i,r?i:a})),this}evaluate(e){for(const t of this.steps)if((e=t(e))===a)break;return e}}e.fromNodeEventEmitter=function(e,t,s=e=>e){const i=(...e)=>r.fire(s(...e)),r=new g({onWillAddFirstListener:()=>e.on(t,i),onDidRemoveLastListener:()=>e.removeListener(t,i)});return r.event},e.fromDOMEventEmitter=function(e,t,s=e=>e){const i=(...e)=>r.fire(s(...e)),r=new g({onWillAddFirstListener:()=>e.addEventListener(t,i),onDidRemoveLastListener:()=>e.removeEventListener(t,i)});return r.event},e.toPromise=function(e){return new Promise((s=>t(e)(s)))},e.fromPromise=function(e){const t=new g;return e.then((e=>{t.fire(e)}),(()=>{t.fire(void 0)})).finally((()=>{t.dispose()})),t.event},e.forward=function(e,t){return e((e=>t.fire(e)))},e.runAndSubscribe=function(e,t,s){return t(s),e((e=>t(e)))};class h{constructor(e,t){this._observable=e,this._counter=0,this._hasChanged=!1;const s={onWillAddFirstListener:()=>{e.addObserver(this)},onDidRemoveLastListener:()=>{e.removeObserver(this)}};this.emitter=new g(s),t&&t.add(this.emitter)}beginUpdate(e){this._counter++}handlePossibleChange(e){}handleChange(e,t){this._hasChanged=!0}endUpdate(e){this._counter--,0===this._counter&&(this._observable.reportChanges(),this._hasChanged&&(this._hasChanged=!1,this.emitter.fire(this._observable.get())))}}e.fromObservable=function(e,t){return new h(e,t).emitter.event},e.fromObservableLight=function(e){return(t,s,i)=>{let r=0,o=!1;const a={beginUpdate(){r++},endUpdate(){r--,0===r&&(e.reportChanges(),o&&(o=!1,t.call(s)))},handlePossibleChange(){},handleChange(){o=!0}};e.addObserver(a),e.reportChanges();const l={dispose(){e.removeObserver(a)}};return i instanceof n.DisposableStore?i.add(l):Array.isArray(i)&&i.push(l),l}}}(l||(t.Event=l={}));class h{static{this.all=new Set}static{this._idPool=0}constructor(e){this.listenerCount=0,this.invocationCount=0,this.elapsedOverall=0,this.durations=[],this.name=`${e}_${h._idPool++}`,h.all.add(this)}start(e){this._stopWatch=new a.StopWatch,this.listenerCount=e}stop(){if(this._stopWatch){const e=this._stopWatch.elapsed();this.durations.push(e),this.elapsedOverall+=e,this.invocationCount+=1,this._stopWatch=void 0}}}t.EventProfiling=h;let c=-1;class u{static{this._idPool=1}constructor(e,t,s=(u._idPool++).toString(16).padStart(3,"0")){this._errorHandler=e,this.threshold=t,this.name=s,this._warnCountdown=0}dispose(){this._stacks?.clear()}check(e,t){const s=this.threshold;if(s<=0||t{const t=this._stacks.get(e.value)||0;this._stacks.set(e.value,t-1)}}getMostFrequentStack(){if(!this._stacks)return;let e,t=0;for(const[s,i]of this._stacks)(!e||t0||this._options?.leakWarningThreshold?new u(e?.onListenerError??i.onUnexpectedError,this._options?.leakWarningThreshold??c):void 0,this._perfMon=this._options?._profName?new h(this._options._profName):void 0,this._deliveryQueue=this._options?.deliveryQueue}dispose(){this._disposed||(this._disposed=!0,this._deliveryQueue?.current===this&&this._deliveryQueue.reset(),this._listeners&&(this._listeners=void 0,this._size=0),this._options?.onDidRemoveLastListener?.(),this._leakageMon?.dispose())}get event(){return this._event??=(e,t,s)=>{if(this._leakageMon&&this._size>this._leakageMon.threshold**2){const e=`[${this._leakageMon.name}] REFUSES to accept new listeners because it exceeded its threshold by far (${this._size} vs ${this._leakageMon.threshold})`;console.warn(e);const t=this._leakageMon.getMostFrequentStack()??["UNKNOWN stack",-1],s=new p(`${e}. HINT: Stack shows most frequent listener (${t[1]}-times)`,t[0]);return(this._options?.onListenerError||i.onUnexpectedError)(s),n.Disposable.None}if(this._disposed)return n.Disposable.None;t&&(e=e.bind(t));const r=new m(e);let o;this._leakageMon&&this._size>=Math.ceil(.2*this._leakageMon.threshold)&&(r.stack=d.create(),o=this._leakageMon.check(r.stack,this._size+1)),this._listeners?this._listeners instanceof m?(this._deliveryQueue??=new v,this._listeners=[this._listeners,r]):this._listeners.push(r):(this._options?.onWillAddFirstListener?.(this),this._listeners=r,this._options?.onDidAddFirstListener?.(this)),this._size++;const a=(0,n.toDisposable)((()=>{o?.(),this._removeListener(r)}));return s instanceof n.DisposableStore?s.add(a):Array.isArray(s)&&s.push(a),a},this._event}_removeListener(e){if(this._options?.onWillRemoveListener?.(this),!this._listeners)return;if(1===this._size)return this._listeners=void 0,this._options?.onDidRemoveLastListener?.(this),void(this._size=0);const t=this._listeners,s=t.indexOf(e);if(-1===s)throw console.log("disposed?",this._disposed),console.log("size?",this._size),console.log("arr?",JSON.stringify(this._listeners)),new Error("Attempted to dispose unknown listener");this._size--,t[s]=void 0;const i=this._deliveryQueue.current===this;if(2*this._size<=t.length){let e=0;for(let s=0;s0}}t.Emitter=g,t.createEventDeliveryQueue=()=>new v;class v{constructor(){this.i=-1,this.end=0}enqueue(e,t,s){this.i=0,this.end=s,this.current=e,this.value=t}reset(){this.i=this.end,this.current=void 0,this.value=void 0}}t.AsyncEmitter=class extends g{async fireAsync(e,t,s){if(this._listeners)for(this._asyncDeliveryQueue||(this._asyncDeliveryQueue=new o.LinkedList),((e,t)=>{if(e instanceof m)t(e);else for(let s=0;sthis._asyncDeliveryQueue.push([t.value,e])));this._asyncDeliveryQueue.size>0&&!t.isCancellationRequested;){const[e,r]=this._asyncDeliveryQueue.shift(),n=[],o={...r,token:t,waitUntil:t=>{if(Object.isFrozen(n))throw new Error("waitUntil can NOT be called asynchronous");s&&(t=s(t,e)),n.push(t)}};try{e(o)}catch(e){(0,i.onUnexpectedError)(e);continue}Object.freeze(n),await Promise.allSettled(n).then((e=>{for(const t of e)"rejected"===t.status&&(0,i.onUnexpectedError)(t.reason)}))}}};class b extends g{get isPaused(){return 0!==this._isPaused}constructor(e){super(e),this._isPaused=0,this._eventQueue=new o.LinkedList,this._mergeFn=e?.merge}pause(){this._isPaused++}resume(){if(0!==this._isPaused&&0==--this._isPaused)if(this._mergeFn){if(this._eventQueue.size>0){const e=Array.from(this._eventQueue);this._eventQueue.clear(),super.fire(this._mergeFn(e))}}else for(;!this._isPaused&&0!==this._eventQueue.size;)super.fire(this._eventQueue.shift())}fire(e){this._size&&(0!==this._isPaused?this._eventQueue.push(e):super.fire(e))}}t.PauseableEmitter=b,t.DebounceEmitter=class extends b{constructor(e){super(e),this._delay=e.delay??100}fire(e){this._handle||(this.pause(),this._handle=setTimeout((()=>{this._handle=void 0,this.resume()}),this._delay)),super.fire(e)}},t.MicrotaskEmitter=class extends g{constructor(e){super(e),this._queuedEvents=[],this._mergeFn=e?.merge}fire(e){this.hasListeners()&&(this._queuedEvents.push(e),1===this._queuedEvents.length&&queueMicrotask((()=>{this._mergeFn?super.fire(this._mergeFn(this._queuedEvents)):this._queuedEvents.forEach((e=>super.fire(e))),this._queuedEvents=[]})))}};class y{constructor(){this.hasListeners=!1,this.events=[],this.emitter=new g({onWillAddFirstListener:()=>this.onFirstListenerAdd(),onDidRemoveLastListener:()=>this.onLastListenerRemove()})}get event(){return this.emitter.event}add(e){const t={event:e,listener:null};return this.events.push(t),this.hasListeners&&this.hook(t),(0,n.toDisposable)((0,r.createSingleCallFunction)((()=>{this.hasListeners&&this.unhook(t);const e=this.events.indexOf(t);this.events.splice(e,1)})))}onFirstListenerAdd(){this.hasListeners=!0,this.events.forEach((e=>this.hook(e)))}onLastListenerRemove(){this.hasListeners=!1,this.events.forEach((e=>this.unhook(e)))}hook(e){e.listener=e.event((e=>this.emitter.fire(e)))}unhook(e){e.listener?.dispose(),e.listener=null}dispose(){this.emitter.dispose();for(const e of this.events)e.listener?.dispose();this.events=[]}}t.EventMultiplexer=y,t.DynamicListEventMultiplexer=class{constructor(e,t,s,i){this._store=new n.DisposableStore;const r=this._store.add(new y),o=this._store.add(new n.DisposableMap);function a(e){o.set(e,r.add(i(e)))}for(const t of e)a(t);this._store.add(t((e=>{a(e)}))),this._store.add(s((e=>{o.deleteAndDispose(e)}))),this.event=r.event}dispose(){this._store.dispose()}},t.EventBufferer=class{constructor(){this.data=[]}wrapEvent(e,t,s){return(i,r,n)=>e((e=>{const n=this.data[this.data.length-1];if(!t)return void(n?n.buffers.push((()=>i.call(r,e))):i.call(r,e));const o=n;o?(o.items??=[],o.items.push(e),0===o.buffers.length&&n.buffers.push((()=>{o.reducedResult??=s?o.items.reduce(t,s):o.items.reduce(t),i.call(r,o.reducedResult)}))):i.call(r,t(s,e))}),void 0,n)}bufferEvents(e){const t={buffers:new Array};this.data.push(t);const s=e();return this.data.pop(),t.buffers.forEach((e=>e())),s}},t.Relay=class{constructor(){this.listening=!1,this.inputEvent=l.None,this.inputEventListener=n.Disposable.None,this.emitter=new g({onDidAddFirstListener:()=>{this.listening=!0,this.inputEventListener=this.inputEvent(this.emitter.fire,this.emitter)},onDidRemoveLastListener:()=>{this.listening=!1,this.inputEventListener.dispose()}}),this.event=this.emitter.event}set input(e){this.inputEvent=e,this.listening&&(this.inputEventListener.dispose(),this.inputEventListener=e(this.emitter.fire,this.emitter))}dispose(){this.inputEventListener.dispose(),this.emitter.dispose()}},t.ValueWithChangeEvent=class{static const(e){return new w(e)}constructor(e){this._value=e,this._onDidChange=new g,this.onDidChange=this._onDidChange.event}get value(){return this._value}set value(e){e!==this._value&&(this._value=e,this._onDidChange.fire(void 0))}};class w{constructor(e){this.value=e,this.onDidChange=l.None}}},355:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createSingleCallFunction=function(e,t){const s=this;let i,r=!1;return function(){if(r)return i;if(r=!0,t)try{i=e.apply(s,arguments)}finally{t()}else i=e.apply(s,arguments);return i}}},956:(e,t)=>{var s;Object.defineProperty(t,"__esModule",{value:!0}),t.Iterable=void 0,function(e){function t(e){return e&&"object"==typeof e&&"function"==typeof e[Symbol.iterator]}e.is=t;const s=Object.freeze([]);function*i(e){yield e}e.empty=function(){return s},e.single=i,e.wrap=function(e){return t(e)?e:i(e)},e.from=function(e){return e||s},e.reverse=function*(e){for(let t=e.length-1;t>=0;t--)yield e[t]},e.isEmpty=function(e){return!e||!0===e[Symbol.iterator]().next().done},e.first=function(e){return e[Symbol.iterator]().next().value},e.some=function(e,t){let s=0;for(const i of e)if(t(i,s++))return!0;return!1},e.find=function(e,t){for(const s of e)if(t(s))return s},e.filter=function*(e,t){for(const s of e)t(s)&&(yield s)},e.map=function*(e,t){let s=0;for(const i of e)yield t(i,s++)},e.flatMap=function*(e,t){let s=0;for(const i of e)yield*t(i,s++)},e.concat=function*(...e){for(const t of e)yield*t},e.reduce=function(e,t,s){let i=s;for(const s of e)i=t(i,s);return i},e.slice=function*(e,t,s=e.length){for(t<0&&(t+=e.length),s<0?s+=e.length:s>e.length&&(s=e.length);tr}]},e.asyncToArray=async function(e){const t=[];for await(const s of e)t.push(s);return Promise.resolve(t)}}(s||(t.Iterable=s={}))},764:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Lazy=void 0,t.Lazy=class{constructor(e){this.executor=e,this._didRun=!1}get hasValue(){return this._didRun}get value(){if(!this._didRun)try{this._value=this.executor()}catch(e){this._error=e}finally{this._didRun=!0}if(this._error)throw this._error;return this._value}get rawValue(){return this._value}}},540:(e,t,s)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DisposableMap=t.ImmortalReference=t.AsyncReferenceCollection=t.ReferenceCollection=t.SafeDisposable=t.RefCountedDisposable=t.MandatoryMutableDisposable=t.MutableDisposable=t.Disposable=t.DisposableStore=t.DisposableTracker=void 0,t.setDisposableTracker=function(e){l=e},t.trackDisposable=c,t.markAsDisposed=u,t.markAsSingleton=function(e){return l?.markAsSingleton(e),e},t.isDisposable=f,t.dispose=p,t.disposeIfDisposable=function(e){for(const t of e)f(t)&&t.dispose();return[]},t.combinedDisposable=function(...e){const t=_((()=>p(e)));return function(e,t){if(l)for(const s of e)l.setParent(s,t)}(e,t),t},t.toDisposable=_,t.disposeOnReturn=function(e){const t=new m;try{e(t)}finally{t.dispose()}};const i=s(732),r=s(33),n=s(714),o=s(355),a=s(956);let l=null;class h{constructor(){this.livingDisposables=new Map}static{this.idx=0}getDisposableData(e){let t=this.livingDisposables.get(e);return t||(t={parent:null,source:null,isSingleton:!1,value:e,idx:h.idx++},this.livingDisposables.set(e,t)),t}trackDisposable(e){const t=this.getDisposableData(e);t.source||(t.source=(new Error).stack)}setParent(e,t){this.getDisposableData(e).parent=t}markAsDisposed(e){this.livingDisposables.delete(e)}markAsSingleton(e){this.getDisposableData(e).isSingleton=!0}getRootParent(e,t){const s=t.get(e);if(s)return s;const i=e.parent?this.getRootParent(this.getDisposableData(e.parent),t):e;return t.set(e,i),i}getTrackedDisposables(){const e=new Map;return[...this.livingDisposables.entries()].filter((([,t])=>null!==t.source&&!this.getRootParent(t,e).isSingleton)).flatMap((([e])=>e))}computeLeakingDisposables(e=10,t){let s;if(t)s=t;else{const e=new Map,t=[...this.livingDisposables.values()].filter((t=>null!==t.source&&!this.getRootParent(t,e).isSingleton));if(0===t.length)return;const i=new Set(t.map((e=>e.value)));if(s=t.filter((e=>!(e.parent&&i.has(e.parent)))),0===s.length)throw new Error("There are cyclic diposable chains!")}if(!s)return;function o(e){const t=e.source.split("\n").map((e=>e.trim().replace("at ",""))).filter((e=>""!==e));return function(e,t){for(;e.length>0&&t.some((t=>"string"==typeof t?t===e[0]:e[0].match(t)));)e.shift()}(t,["Error",/^trackDisposable \(.*\)$/,/^DisposableTracker.trackDisposable \(.*\)$/]),t.reverse()}const a=new n.SetMap;for(const e of s){const t=o(e);for(let s=0;s<=t.length;s++)a.add(t.slice(0,s).join("\n"),e)}s.sort((0,i.compareBy)((e=>e.idx),i.numberComparator));let l="",h=0;for(const t of s.slice(0,e)){h++;const e=o(t),i=[];for(let t=0;to(e)[t])),(e=>e));delete h[e[t]];for(const[e,t]of Object.entries(h))i.unshift(` - stacktraces of ${t.length} other leaks continue with ${e}`);i.unshift(n)}l+=`\n\n\n==================== Leaking disposable ${h}/${s.length}: ${t.value.constructor.name} ====================\n${i.join("\n")}\n============================================================\n\n`}return s.length>e&&(l+=`\n\n\n... and ${s.length-e} more leaking disposables\n\n`),{leaks:s,details:l}}}function c(e){return l?.trackDisposable(e),e}function u(e){l?.markAsDisposed(e)}function d(e,t){l?.setParent(e,t)}function f(e){return"object"==typeof e&&null!==e&&"function"==typeof e.dispose&&0===e.dispose.length}function p(e){if(a.Iterable.is(e)){const t=[];for(const s of e)if(s)try{s.dispose()}catch(e){t.push(e)}if(1===t.length)throw t[0];if(t.length>1)throw new AggregateError(t,"Encountered errors while disposing of store");return Array.isArray(e)?[]:e}if(e)return e.dispose(),e}function _(e){const t=c({dispose:(0,o.createSingleCallFunction)((()=>{u(t),e()}))});return t}t.DisposableTracker=h;class m{static{this.DISABLE_DISPOSED_WARNING=!1}constructor(){this._toDispose=new Set,this._isDisposed=!1,c(this)}dispose(){this._isDisposed||(u(this),this._isDisposed=!0,this.clear())}get isDisposed(){return this._isDisposed}clear(){if(0!==this._toDispose.size)try{p(this._toDispose)}finally{this._toDispose.clear()}}add(e){if(!e)return e;if(e===this)throw new Error("Cannot register a disposable on itself!");return d(e,this),this._isDisposed?m.DISABLE_DISPOSED_WARNING||console.warn(new Error("Trying to add a disposable to a DisposableStore that has already been disposed of. The added object will be leaked!").stack):this._toDispose.add(e),e}delete(e){if(e){if(e===this)throw new Error("Cannot dispose a disposable on itself!");this._toDispose.delete(e),e.dispose()}}deleteAndLeak(e){e&&this._toDispose.has(e)&&(this._toDispose.delete(e),d(e,null))}}t.DisposableStore=m;class g{static{this.None=Object.freeze({dispose(){}})}constructor(){this._store=new m,c(this),d(this._store,this)}dispose(){u(this),this._store.dispose()}_register(e){if(e===this)throw new Error("Cannot register a disposable on itself!");return this._store.add(e)}}t.Disposable=g;class v{constructor(){this._isDisposed=!1,c(this)}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),e&&d(e,this),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,u(this),this._value?.dispose(),this._value=void 0}clearAndLeak(){const e=this._value;return this._value=void 0,e&&d(e,null),e}}t.MutableDisposable=v,t.MandatoryMutableDisposable=class{constructor(e){this._disposable=new v,this._isDisposed=!1,this._disposable.value=e}get value(){return this._disposable.value}set value(e){this._isDisposed||e===this._disposable.value||(this._disposable.value=e)}dispose(){this._isDisposed=!0,this._disposable.dispose()}},t.RefCountedDisposable=class{constructor(e){this._disposable=e,this._counter=1}acquire(){return this._counter++,this}release(){return 0==--this._counter&&this._disposable.dispose(),this}},t.SafeDisposable=class{constructor(){this.dispose=()=>{},this.unset=()=>{},this.isset=()=>!1,c(this)}set(e){let t=e;return this.unset=()=>t=void 0,this.isset=()=>void 0!==t,this.dispose=()=>{t&&(t(),t=void 0,u(this))},this}},t.ReferenceCollection=class{constructor(){this.references=new Map}acquire(e,...t){let s=this.references.get(e);s||(s={counter:0,object:this.createReferencedObject(e,...t)},this.references.set(e,s));const{object:i}=s,r=(0,o.createSingleCallFunction)((()=>{0==--s.counter&&(this.destroyReferencedObject(e,s.object),this.references.delete(e))}));return s.counter++,{object:i,dispose:r}}},t.AsyncReferenceCollection=class{constructor(e){this.referenceCollection=e}async acquire(e,...t){const s=this.referenceCollection.acquire(e,...t);try{return{object:await s.object,dispose:()=>s.dispose()}}catch(e){throw s.dispose(),e}}},t.ImmortalReference=class{constructor(e){this.object=e}dispose(){}};class b{constructor(){this._store=new Map,this._isDisposed=!1,c(this)}dispose(){u(this),this._isDisposed=!0,this.clearAndDisposeAll()}clearAndDisposeAll(){if(this._store.size)try{p(this._store.values())}finally{this._store.clear()}}has(e){return this._store.has(e)}get size(){return this._store.size}get(e){return this._store.get(e)}set(e,t,s=!1){this._isDisposed&&console.warn(new Error("Trying to add a disposable to a DisposableMap that has already been disposed of. The added object will be leaked!").stack),s||this._store.get(e)?.dispose(),this._store.set(e,t)}deleteAndDispose(e){this._store.get(e)?.dispose(),this._store.delete(e)}deleteAndLeak(e){const t=this._store.get(e);return this._store.delete(e),t}keys(){return this._store.keys()}values(){return this._store.values()}[Symbol.iterator](){return this._store[Symbol.iterator]()}}t.DisposableMap=b},711:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkedList=void 0;class s{static{this.Undefined=new s(void 0)}constructor(e){this.element=e,this.next=s.Undefined,this.prev=s.Undefined}}class i{constructor(){this._first=s.Undefined,this._last=s.Undefined,this._size=0}get size(){return this._size}isEmpty(){return this._first===s.Undefined}clear(){let e=this._first;for(;e!==s.Undefined;){const t=e.next;e.prev=s.Undefined,e.next=s.Undefined,e=t}this._first=s.Undefined,this._last=s.Undefined,this._size=0}unshift(e){return this._insert(e,!1)}push(e){return this._insert(e,!0)}_insert(e,t){const i=new s(e);if(this._first===s.Undefined)this._first=i,this._last=i;else if(t){const e=this._last;this._last=i,i.prev=e,e.next=i}else{const e=this._first;this._first=i,i.next=e,e.prev=i}this._size+=1;let r=!1;return()=>{r||(r=!0,this._remove(i))}}shift(){if(this._first!==s.Undefined){const e=this._first.element;return this._remove(this._first),e}}pop(){if(this._last!==s.Undefined){const e=this._last.element;return this._remove(this._last),e}}_remove(e){if(e.prev!==s.Undefined&&e.next!==s.Undefined){const t=e.prev;t.next=e.next,e.next.prev=t}else e.prev===s.Undefined&&e.next===s.Undefined?(this._first=s.Undefined,this._last=s.Undefined):e.next===s.Undefined?(this._last=this._last.prev,this._last.next=s.Undefined):e.prev===s.Undefined&&(this._first=this._first.next,this._first.prev=s.Undefined);this._size-=1}*[Symbol.iterator](){let e=this._first;for(;e!==s.Undefined;)yield e.element,e=e.next}}t.LinkedList=i},714:(e,t)=>{var s;Object.defineProperty(t,"__esModule",{value:!0}),t.SetMap=t.BidirectionalMap=t.CounterSet=t.Touch=void 0,t.getOrSet=function(e,t,s){let i=e.get(t);return void 0===i&&(i=s,e.set(t,i)),i},t.mapToString=function(e){const t=[];return e.forEach(((e,s)=>{t.push(`${s} => ${e}`)})),`Map(${e.size}) {${t.join(", ")}}`},t.setToString=function(e){const t=[];return e.forEach((e=>{t.push(e)})),`Set(${e.size}) {${t.join(", ")}}`},t.mapsStrictEqualIgnoreOrder=function(e,t){if(e===t)return!0;if(e.size!==t.size)return!1;for(const[s,i]of e)if(!t.has(s)||t.get(s)!==i)return!1;for(const[s]of t)if(!e.has(s))return!1;return!0},function(e){e[e.None=0]="None",e[e.AsOld=1]="AsOld",e[e.AsNew=2]="AsNew"}(s||(t.Touch=s={})),t.CounterSet=class{constructor(){this.map=new Map}add(e){return this.map.set(e,(this.map.get(e)||0)+1),this}delete(e){let t=this.map.get(e)||0;return 0!==t&&(t--,0===t?this.map.delete(e):this.map.set(e,t),!0)}has(e){return this.map.has(e)}},t.BidirectionalMap=class{constructor(e){if(this._m1=new Map,this._m2=new Map,e)for(const[t,s]of e)this.set(t,s)}clear(){this._m1.clear(),this._m2.clear()}set(e,t){this._m1.set(e,t),this._m2.set(t,e)}get(e){return this._m1.get(e)}getKey(e){return this._m2.get(e)}delete(e){const t=this._m1.get(e);return void 0!==t&&(this._m1.delete(e),this._m2.delete(t),!0)}forEach(e,t){this._m1.forEach(((s,i)=>{e.call(t,s,i,this)}))}keys(){return this._m1.keys()}values(){return this._m1.values()}},t.SetMap=class{constructor(){this.map=new Map}add(e,t){let s=this.map.get(e);s||(s=new Set,this.map.set(e,s)),s.add(t)}delete(e,t){const s=this.map.get(e);s&&(s.delete(t),0===s.size&&this.map.delete(e))}forEach(e,t){const s=this.map.get(e);s&&s.forEach(t)}get(e){return this.map.get(e)||new Set}}},973:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.isAndroid=t.isEdge=t.isSafari=t.isFirefox=t.isChrome=t.OS=t.OperatingSystem=t.setTimeout0=t.setTimeout0IsFaster=t.translationsConfigFile=t.platformLocale=t.locale=t.Language=t.language=t.userAgent=t.platform=t.isCI=t.isMobile=t.isIOS=t.webWorkerOrigin=t.isWebWorker=t.isWeb=t.isElectron=t.isNative=t.isLinuxSnap=t.isLinux=t.isMacintosh=t.isWindows=t.Platform=t.LANGUAGE_DEFAULT=void 0,t.PlatformToString=function(e){switch(e){case w.Web:return"Web";case w.Mac:return"Mac";case w.Linux:return"Linux";case w.Windows:return"Windows"}},t.isLittleEndian=function(){if(!C){C=!0;const e=new Uint8Array(2);e[0]=1,e[1]=2;const t=new Uint16Array(e.buffer);k=513===t[0]}return k},t.isBigSurOrNewer=function(e){return parseFloat(e)>=20},t.LANGUAGE_DEFAULT="en";let s,i,r,n=!1,o=!1,a=!1,l=!1,h=!1,c=!1,u=!1,d=!1,f=!1,p=!1,_=t.LANGUAGE_DEFAULT,m=t.LANGUAGE_DEFAULT;const g=globalThis;let v;void 0!==g.vscode&&void 0!==g.vscode.process?v=g.vscode.process:"undefined"!=typeof process&&"string"==typeof process?.versions?.node&&(v=process);const b="string"==typeof v?.versions?.electron,y=b&&"renderer"===v?.type;if("object"==typeof v){n="win32"===v.platform,o="darwin"===v.platform,a="linux"===v.platform,l=a&&!!v.env.SNAP&&!!v.env.SNAP_REVISION,u=b,f=!!v.env.CI||!!v.env.BUILD_ARTIFACTSTAGINGDIRECTORY,s=t.LANGUAGE_DEFAULT,_=t.LANGUAGE_DEFAULT;const e=v.env.VSCODE_NLS_CONFIG;if(e)try{const r=JSON.parse(e);s=r.userLocale,m=r.osLocale,_=r.resolvedLanguage||t.LANGUAGE_DEFAULT,i=r.languagePack?.translationsConfigFile}catch(e){}h=!0}else"object"!=typeof navigator||y?console.error("Unable to resolve platform."):(r=navigator.userAgent,n=r.indexOf("Windows")>=0,o=r.indexOf("Macintosh")>=0,d=(r.indexOf("Macintosh")>=0||r.indexOf("iPad")>=0||r.indexOf("iPhone")>=0)&&!!navigator.maxTouchPoints&&navigator.maxTouchPoints>0,a=r.indexOf("Linux")>=0,p=r?.indexOf("Mobi")>=0,c=!0,_=globalThis._VSCODE_NLS_LANGUAGE||t.LANGUAGE_DEFAULT,s=navigator.language.toLowerCase(),m=s);var w;!function(e){e[e.Web=0]="Web",e[e.Mac=1]="Mac",e[e.Linux=2]="Linux",e[e.Windows=3]="Windows"}(w||(t.Platform=w={}));let D=w.Web;var S,E;o?D=w.Mac:n?D=w.Windows:a&&(D=w.Linux),t.isWindows=n,t.isMacintosh=o,t.isLinux=a,t.isLinuxSnap=l,t.isNative=h,t.isElectron=u,t.isWeb=c,t.isWebWorker=c&&"function"==typeof g.importScripts,t.webWorkerOrigin=t.isWebWorker?g.origin:void 0,t.isIOS=d,t.isMobile=p,t.isCI=f,t.platform=D,t.userAgent=r,t.language=_,function(e){e.value=function(){return t.language},e.isDefaultVariant=function(){return 2===t.language.length?"en"===t.language:t.language.length>=3&&"e"===t.language[0]&&"n"===t.language[1]&&"-"===t.language[2]},e.isDefault=function(){return"en"===t.language}}(S||(t.Language=S={})),t.locale=s,t.platformLocale=m,t.translationsConfigFile=i,t.setTimeout0IsFaster="function"==typeof g.postMessage&&!g.importScripts,t.setTimeout0=(()=>{if(t.setTimeout0IsFaster){const e=[];g.addEventListener("message",(t=>{if(t.data&&t.data.vscodeScheduleAsyncWork)for(let s=0,i=e.length;s{const i=++t;e.push({id:i,callback:s}),g.postMessage({vscodeScheduleAsyncWork:i},"*")}}return e=>setTimeout(e)})(),function(e){e[e.Windows=1]="Windows",e[e.Macintosh=2]="Macintosh",e[e.Linux=3]="Linux"}(E||(t.OperatingSystem=E={})),t.OS=o||d?E.Macintosh:n?E.Windows:E.Linux;let k=!0,C=!1;t.isChrome=!!(t.userAgent&&t.userAgent.indexOf("Chrome")>=0),t.isFirefox=!!(t.userAgent&&t.userAgent.indexOf("Firefox")>=0),t.isSafari=!!(!t.isChrome&&t.userAgent&&t.userAgent.indexOf("Safari")>=0),t.isEdge=!!(t.userAgent&&t.userAgent.indexOf("Edg/")>=0),t.isAndroid=!!(t.userAgent&&t.userAgent.indexOf("Android")>=0)},79:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.StopWatch=void 0;const s=globalThis.performance&&"function"==typeof globalThis.performance.now;class i{static create(e){return new i(e)}constructor(e){this._now=s&&!1===e?Date.now:globalThis.performance.now.bind(globalThis.performance),this._startTime=this._now(),this._stopTime=-1}stop(){this._stopTime=this._now()}reset(){this._startTime=this._now(),this._stopTime=-1}elapsed(){return-1!==this._stopTime?this._stopTime-this._startTime:this._now()-this._startTime}}t.StopWatch=i},329:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.MicrotaskDelay=void 0,t.MicrotaskDelay=Symbol("MicrotaskDelay")}},t={};function s(i){var r=t[i];if(void 0!==r)return r.exports;var n=t[i]={exports:{}};return e[i](n,n.exports,s),n.exports}var i={};return(()=>{var e=i;Object.defineProperty(e,"__esModule",{value:!0}),e.SearchAddon=void 0;const t=s(540),r=s(940),n=s(149),o=s(772),a=s(615),l=s(864),h=s(438);class c extends t.Disposable{get onDidChangeResults(){return this._resultTracker.onDidChangeResults}constructor(e){super(),this._highlightTimeout=this._register(new t.MutableDisposable),this._lineCache=this._register(new t.MutableDisposable),this._state=new o.SearchState,this._resultTracker=this._register(new h.SearchResultTracker),this._highlightLimit=e?.highlightLimit??1e3}activate(e){this._terminal=e,this._lineCache.value=new n.SearchLineCache(e),this._engine=new a.SearchEngine(e,this._lineCache.value),this._decorationManager=new l.DecorationManager(e),this._register(this._terminal.onWriteParsed((()=>this._updateMatches()))),this._register(this._terminal.onResize((()=>this._updateMatches()))),this._register((0,t.toDisposable)((()=>this.clearDecorations())))}_updateMatches(){this._highlightTimeout.clear(),this._state.cachedSearchTerm&&this._state.lastSearchOptions?.decorations&&(this._highlightTimeout.value=(0,r.disposableTimeout)((()=>{const e=this._state.cachedSearchTerm;this._state.clearCachedTerm(),this.findPrevious(e,{...this._state.lastSearchOptions,incremental:!0},{noScroll:!0})}),200))}clearDecorations(e){this._resultTracker.clearSelectedDecoration(),this._decorationManager?.clearHighlightDecorations(),this._resultTracker.clearResults(),e||this._state.clearCachedTerm()}clearActiveDecoration(){this._resultTracker.clearSelectedDecoration()}findNext(e,t,s){if(!this._terminal||!this._engine)throw new Error("Cannot use addon until it has been loaded");this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);const i=this._findNextAndSelect(e,t,s);return this._fireResults(t),this._state.cachedSearchTerm=e,i}_highlightAllMatches(e,t){if(!this._terminal||!this._engine||!this._decorationManager)throw new Error("Cannot use addon until it has been loaded");if(!this._state.isValidSearchTerm(e))return void this.clearDecorations();this.clearDecorations(!0);const s=[];let i,r=this._engine.find(e,0,0,t);for(;r&&(i?.row!==r.row||i?.col!==r.col)&&!(s.length>=this._highlightLimit);)i=r,s.push(i),r=this._engine.find(e,i.col+i.term.length>=this._terminal.cols?i.row+1:i.row,i.col+i.term.length>=this._terminal.cols?0:i.col+1,t);this._resultTracker.updateResults(s,this._highlightLimit),t.decorations&&this._decorationManager.createHighlightDecorations(s,t.decorations)}_findNextAndSelect(e,t,s){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;const i=this._engine.findNextWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(i,t?.decorations,s?.noScroll)}findPrevious(e,t,s){if(!this._terminal||!this._engine)throw new Error("Cannot use addon until it has been loaded");this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);const i=this._findPreviousAndSelect(e,t,s);return this._fireResults(t),this._state.cachedSearchTerm=e,i}_fireResults(e){this._resultTracker.fireResultsChanged(!!e?.decorations)}_findPreviousAndSelect(e,t,s){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;const i=this._engine.findPreviousWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(i,t?.decorations,s?.noScroll)}_selectResult(e,t,s){if(!this._terminal||!this._decorationManager)return!1;if(this._resultTracker.clearSelectedDecoration(),!e)return this._terminal.clearSelection(),!1;if(this._terminal.select(e.col,e.row,e.size),t){const s=this._decorationManager.createActiveDecoration(e,t);s&&(this._resultTracker.selectedDecoration=s)}if(!s&&(e.row>=this._terminal.buffer.active.viewportY+this._terminal.rows||e.row(()=>{"use strict";var e={490:(e,t)=>{function n(e){try{const t=new URL(e),n=t.password&&t.username?`${t.protocol}//${t.username}:${t.password}@${t.host}`:t.username?`${t.protocol}//${t.username}@${t.host}`:`${t.protocol}//${t.host}`;return e.toLocaleLowerCase().startsWith(n.toLocaleLowerCase())}catch(e){return!1}}Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,o={}){this._terminal=e,this._regex=t,this._handler=n,this._options=o}provideLinks(e,t){const n=o.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(n))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:o}=e;this._options.hover(t,n,o)}},e)))}};class o{static computeLink(e,t,r,i){const s=new RegExp(t.source,(t.flags||"")+"g"),[a,c]=o._getWindowedLineStrings(e-1,r),l=a.join("");let d;const p=[];for(;d=s.exec(l);){const e=d[0];if(!n(e))continue;const[t,s]=o._mapStrIdx(r,c,0,d.index),[a,l]=o._mapStrIdx(r,t,s,e.length);if(-1===t||-1===s||-1===a||-1===l)continue;const h={start:{x:s+1,y:t+1},end:{x:l,y:a+1}};p.push({range:h,text:e,activate:i})}return p}static _getWindowedLineStrings(e,t){let n,o=e,r=e,i=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(i=0;(n=t.buffer.active.getLine(--o))&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),i=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,o]}static _mapStrIdx(e,t,n,o){const r=e.buffer.active,i=r.getNullCell();let s=n;for(;o;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n{var e=o;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(490),r=/(https?|HTTPS?):[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function i(e,t){const n=window.open();if(n){try{n.opener=null}catch{}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=i,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,o=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,o,this._handler,n))}dispose(){this._linkProvider?.dispose()}}})(),o})())); -//# sourceMappingURL=addon-web-links.js.map \ No newline at end of file diff --git a/frontend/src/js/lib/xterm.min.js b/frontend/src/js/lib/xterm.min.js deleted file mode 100644 index e47e2dd..0000000 --- a/frontend/src/js/lib/xterm.min.js +++ /dev/null @@ -1,2 +0,0 @@ -!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var e={2840:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(7721),o=i(4292),a=i(7150),l=i(7098),h=i(6501),c=i(7093);let d=class extends a.Disposable{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="";const r=this._coreBrowserService.mainDocument;this._accessibilityContainer=r.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=r.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;ethis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=r.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new o.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this._register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this._register(this._terminal.onScroll((()=>this._refreshRows()))),this._register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this._register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this._register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this._register(this._terminal.onKey((e=>this._handleKey(e.key)))),this._register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this._register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this._register((0,c.addDisposableListener)(r,"selectionchange",(()=>this._handleSelectionChange()))),this._register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRowsDimensions(),this._refreshRows(),this._register((0,a.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput.get())))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],n=e?.translateToString(!0,void 0,void 0,t)||"",o=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===n.length?(a.textContent=" ",this._rowColumns.set(a,[0,1])):(a.textContent=n,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",o),a.setAttribute("aria-setsize",s),this._alignRowWidth(a))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shift(),n=i,this._rowContainer.removeChild(r)),r.removeEventListener("focus",this._topBoundaryFocusListener),n.removeEventListener("focus",this._bottomBoundaryFocusListener),0===t){const e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement("afterbegin",e)}else{const e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(0===t?-1:1),this._rowElements[0===t?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(0===this._rowElements.length)return;const e=this._coreBrowserService.mainDocument.getSelection();if(!e)return;if(e.isCollapsed)return void(this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection());if(!e.anchorNode||!e.focusNode)return void console.error("anchorNode and/or focusNode are null");let t={node:e.anchorNode,offset:e.anchorOffset},i={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(i.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===i.node&&t.offset>i.offset)&&([t,i]=[i,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;const s=this._rowElements.slice(-1)[0];if(i.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(i={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(i.node))return;const r=({node:e,offset:t})=>{const i=e instanceof Text?e.parentNode:e;let s=parseInt(i?.getAttribute("aria-posinset"),10)-1;if(isNaN(s))return console.warn("row is invalid. Race condition?"),null;const r=this._rowColumns.get(i);if(!r)return console.warn("columns is null. Race condition?"),null;let n=t=this._terminal.cols&&(++s,n=0),{row:s,column:n}},n=r(t),o=r(i);if(n&&o){if(n.row>o.row||n.row===o.row&&n.column>=o.column)throw new Error("invalid range");this._terminal.select(n.column,n.row,(o.row-n.row)*this._terminal.cols-n.column+o.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){Object.assign(this._accessibilityContainer.style,{width:`${this._renderService.dimensions.css.canvas.width}px`,fontSize:`${this._terminal.options.fontSize}px`}),this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{function i(e){return e.replace(/\r?\n/g,"\r")}function s(e,t){return t?"[200~"+e+"[201~":e}function r(e,t,r,n){e=s(e=i(e),r.decPrivateModes.bracketedPasteMode&&!0!==n.rawOptions.ignoreBracketedPasteMode),r.triggerDataEvent(e,!0),t.value=""}function n(e,t,i){const s=i.getBoundingClientRect(),r=e.clientX-s.left-10,n=e.clientY-s.top-10;t.style.width="20px",t.style.height="20px",t.style.left=`${r}px`,t.style.top=`${n}px`,t.style.zIndex="1000",t.focus()}Object.defineProperty(t,"__esModule",{value:!0}),t.prepareTextForTerminal=i,t.bracketTextForPaste=s,t.copyHandler=function(e,t){e.clipboardData&&e.clipboardData.setData("text/plain",t.selectionText),e.preventDefault()},t.handlePasteEvent=function(e,t,i,s){e.stopPropagation(),e.clipboardData&&r(e.clipboardData.getData("text/plain"),t,i,s)},t.paste=r,t.moveTextAreaUnderMouseCursor=n,t.rightClickHandler=function(e,t,i,s,r){n(e,t,i),r&&s.rightClickSelect(e),t.value=s.selectionText,t.select()}},7174:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorContrastCache=void 0;const s=i(7710);t.ColorContrastCache=class{constructor(){this._color=new s.TwoKeyMap,this._css=new s.TwoKeyMap}setCss(e,t,i){this._css.set(e,t,i)}getCss(e,t){return this._css.get(e,t)}setColor(e,t,i){this._color.set(e,t,i)}getColor(e,t){return this._color.get(e,t)}clear(){this._color.clear(),this._css.clear()}}},1718:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreBrowserTerminal=void 0;const s=i(7861),r=i(7721),n=i(3285),o=i(4017),a=i(4196),l=i(9925),h=i(3618),c=i(3955),d=i(4792),u=i(945),_=i(9574),f=i(9820),p=i(9784),g=i(5783),m=i(2079),v=i(7098),S=i(9078),b=i(4103),C=i(5777),y=i(701),w=i(6107),E=i(3534),D=i(706),L=i(8693),R=i(4720),A=i(6501),T=i(2486),k=i(2840),M=i(8906),O=i(802),I=i(7093),P=i(7150);class x extends C.CoreTerminal{get linkifier(){return this._linkifier.value}get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}constructor(e={}){super(e),this._linkifier=this._register(new P.MutableDisposable),this.browser=y,this._keyDownHandled=!1,this._keyDownSeen=!1,this._keyPressHandled=!1,this._unprocessedDeadKey=!1,this._accessibilityManager=this._register(new P.MutableDisposable),this._onCursorMove=this._register(new O.Emitter),this.onCursorMove=this._onCursorMove.event,this._onKey=this._register(new O.Emitter),this.onKey=this._onKey.event,this._onRender=this._register(new O.Emitter),this.onRender=this._onRender.event,this._onSelectionChange=this._register(new O.Emitter),this.onSelectionChange=this._onSelectionChange.event,this._onTitleChange=this._register(new O.Emitter),this.onTitleChange=this._onTitleChange.event,this._onBell=this._register(new O.Emitter),this.onBell=this._onBell.event,this._onFocus=this._register(new O.Emitter),this._onBlur=this._register(new O.Emitter),this._onA11yCharEmitter=this._register(new O.Emitter),this._onA11yTabEmitter=this._register(new O.Emitter),this._onWillOpen=this._register(new O.Emitter),this._setup(),this._decorationService=this._instantiationService.createInstance(R.DecorationService),this._instantiationService.setService(A.IDecorationService,this._decorationService),this._linkProviderService=this._instantiationService.createInstance(f.LinkProviderService),this._instantiationService.setService(v.ILinkProviderService,this._linkProviderService),this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(n.OscLinkProvider)),this._register(this._inputHandler.onRequestBell((()=>this._onBell.fire()))),this._register(this._inputHandler.onRequestRefreshRows((e=>this.refresh(e?.start??0,e?.end??this.rows-1)))),this._register(this._inputHandler.onRequestSendFocus((()=>this._reportFocus()))),this._register(this._inputHandler.onRequestReset((()=>this.reset()))),this._register(this._inputHandler.onRequestWindowsOptionsReport((e=>this._reportWindowsOptions(e)))),this._register(this._inputHandler.onColor((e=>this._handleColorEvent(e)))),this._register(O.Event.forward(this._inputHandler.onCursorMove,this._onCursorMove)),this._register(O.Event.forward(this._inputHandler.onTitleChange,this._onTitleChange)),this._register(O.Event.forward(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this._register(O.Event.forward(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this._register(this._bufferService.onResize((e=>this._afterResize(e.cols,e.rows)))),this._register((0,P.toDisposable)((()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)})))}_handleColorEvent(e){if(this._themeService)for(const t of e){let e,i="";switch(t.index){case 256:e="foreground",i="10";break;case 257:e="background",i="11";break;case 258:e="cursor",i="12";break;default:e="ansi",i="4;"+t.index}switch(t.type){case 0:const s=b.color.toColorRGB("ansi"===e?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`${E.C0.ESC}]${i};${(0,L.toRgbString)(s)}${E.C1_ESCAPED.ST}`);break;case 1:if("ansi"===e)this._themeService.modifyColors((e=>e.ansi[t.index]=b.channels.toColor(...t.color)));else{const i=e;this._themeService.modifyColors((e=>e[i]=b.channels.toColor(...t.color)))}break;case 2:this._themeService.restoreColor(t.index)}}}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(k.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(E.C0.ESC+"[I"),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){return this.textarea?.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(E.C0.ESC+"[O"),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;const i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),n=this._renderService.dimensions.css.cell.width*r,o=this.buffer.y*this._renderService.dimensions.css.cell.height,a=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=a+"px",this.textarea.style.top=o+"px",this.textarea.style.width=n+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this._register((0,I.addDisposableListener)(this.element,"copy",(e=>{this.hasSelection()&&(0,s.copyHandler)(e,this._selectionService)})));const e=e=>(0,s.handlePasteEvent)(e,this.textarea,this.coreService,this.optionsService);this._register((0,I.addDisposableListener)(this.textarea,"paste",e)),this._register((0,I.addDisposableListener)(this.element,"paste",e)),y.isFirefox?this._register((0,I.addDisposableListener)(this.element,"mousedown",(e=>{2===e.button&&(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))):this._register((0,I.addDisposableListener)(this.element,"contextmenu",(e=>{(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))),y.isLinux&&this._register((0,I.addDisposableListener)(this.element,"auxclick",(e=>{1===e.button&&(0,s.moveTextAreaUnderMouseCursor)(e,this.textarea,this.screenElement)})))}_bindKeys(){this._register((0,I.addDisposableListener)(this.textarea,"keyup",(e=>this._keyUp(e)),!0)),this._register((0,I.addDisposableListener)(this.textarea,"keydown",(e=>this._keyDown(e)),!0)),this._register((0,I.addDisposableListener)(this.textarea,"keypress",(e=>this._keyPress(e)),!0)),this._register((0,I.addDisposableListener)(this.textarea,"compositionstart",(()=>this._compositionHelper.compositionstart()))),this._register((0,I.addDisposableListener)(this.textarea,"compositionupdate",(e=>this._compositionHelper.compositionupdate(e)))),this._register((0,I.addDisposableListener)(this.textarea,"compositionend",(()=>this._compositionHelper.compositionend()))),this._register((0,I.addDisposableListener)(this.textarea,"input",(e=>this._inputEvent(e)),!0)),this._register(this.onRender((()=>this._compositionHelper.updateCompositionElements())))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService)return void(this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView));this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),e.appendChild(this.element);const t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._register((0,I.addDisposableListener)(this.screenElement,"mousemove",(e=>this.updateCursorStyle(e)))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement);const i=this.textarea=this._document.createElement("textarea");this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",r.promptLabel.get()),y.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._register(this.optionsService.onSpecificOptionChange("disableStdin",(()=>i.readOnly=this.optionsService.rawOptions.disableStdin))),this.textarea.readOnly=this.optionsService.rawOptions.disableStdin,this._coreBrowserService=this._register(this._instantiationService.createInstance(_.CoreBrowserService,this.textarea,e.ownerDocument.defaultView??window,this._document??"undefined"!=typeof window?window.document:null)),this._instantiationService.setService(v.ICoreBrowserService,this._coreBrowserService),this._register((0,I.addDisposableListener)(this.textarea,"focus",(e=>this._handleTextAreaFocus(e)))),this._register((0,I.addDisposableListener)(this.textarea,"blur",(()=>this._handleTextAreaBlur()))),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(d.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(v.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(S.ThemeService),this._instantiationService.setService(v.IThemeService,this._themeService),this._characterJoinerService=this._instantiationService.createInstance(u.CharacterJoinerService),this._instantiationService.setService(v.ICharacterJoinerService,this._characterJoinerService),this._renderService=this._register(this._instantiationService.createInstance(g.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(v.IRenderService,this._renderService),this._register(this._renderService.onRenderedViewportChange((e=>this._onRender.fire(e)))),this.onResize((e=>this._renderService.resize(e.cols,e.rows))),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(h.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseService=this._instantiationService.createInstance(p.MouseService),this._instantiationService.setService(v.IMouseService,this._mouseService);const s=this._linkifier.value=this._register(this._instantiationService.createInstance(M.Linkifier,this.screenElement));this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch{}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._register(this.onCursorMove((()=>{this._renderService.handleCursorMove(),this._syncTextArea()}))),this._register(this.onResize((()=>this._renderService.handleResize(this.cols,this.rows)))),this._register(this.onBlur((()=>this._renderService.handleBlur()))),this._register(this.onFocus((()=>this._renderService.handleFocus()))),this._viewport=this._register(this._instantiationService.createInstance(o.Viewport,this.element,this.screenElement)),this._register(this._viewport.onRequestScrollLines((e=>{super.scrollLines(e,!1),this.refresh(0,this.rows-1)}))),this._selectionService=this._register(this._instantiationService.createInstance(m.SelectionService,this.element,this.screenElement,s)),this._instantiationService.setService(v.ISelectionService,this._selectionService),this._register(this._selectionService.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent)))),this._register(this._selectionService.onSelectionChange((()=>this._onSelectionChange.fire()))),this._register(this._selectionService.onRequestRedraw((e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode)))),this._register(this._selectionService.onLinuxMouseSelection((e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()}))),this._register(O.Event.any(this._onScroll.event,this._inputHandler.onScroll)((()=>{this._selectionService.refresh(),this._viewport?.queueSync()}))),this._register(this._instantiationService.createInstance(a.BufferDecorationRenderer,this.screenElement)),this._register((0,I.addDisposableListener)(this.element,"mousedown",(e=>this._selectionService.handleMouseDown(e)))),this.coreMouseService.areMouseEventsActive?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):this._selectionService.enable(),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(k.AccessibilityManager,this)),this._register(this.optionsService.onSpecificOptionChange("screenReaderMode",(e=>this._handleScreenReaderModeOptionChange(e)))),this.options.overviewRuler.width&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("overviewRuler",(e=>{!this._overviewRulerRenderer&&e&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement)))})),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this.bindMouse()}_createRenderer(){return this._instantiationService.createInstance(c.DomRenderer,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}bindMouse(){const e=this,t=this.element;function i(t){const i=e._mouseService.getMouseReportCoords(t,e.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,void 0===t.buttons?(s=3,void 0!==t.button&&(s=t.button<3?t.button:3)):s=1&t.buttons?0:4&t.buttons?1:2&t.buttons?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(e._customWheelEventHandler&&!1===e._customWheelEventHandler(t))return!1;const i=t.deltaY;if(0===i)return!1;if(0===e.coreMouseService.consumeWheelEvent(t,e._renderService?.dimensions?.device?.cell?.height,e._coreBrowserService?.dpr))return!1;r=i<0?0:1,s=4;break;default:return!1}return!(void 0===r||void 0===s||s>4)&&e.coreMouseService.triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:t.altKey,shift:t.shiftKey})}const s={mouseup:null,wheel:null,mousedrag:null,mousemove:null},r={mouseup:e=>(i(e),e.buttons||(this._document.removeEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.removeEventListener("mousemove",s.mousedrag)),this.cancel(e)),wheel:e=>(i(e),this.cancel(e,!0)),mousedrag:e=>{e.buttons&&i(e)},mousemove:e=>{e.buttons||i(e)}};this._register(this.coreMouseService.onProtocolChange((e=>{e?("debug"===this.optionsService.rawOptions.logLevel&&this._logService.debug("Binding to mouse events:",this.coreMouseService.explainEvents(e)),this.element.classList.add("enable-mouse-events"),this._selectionService.disable()):(this._logService.debug("Unbinding from mouse events."),this.element.classList.remove("enable-mouse-events"),this._selectionService.enable()),8&e?s.mousemove||(t.addEventListener("mousemove",r.mousemove),s.mousemove=r.mousemove):(t.removeEventListener("mousemove",s.mousemove),s.mousemove=null),16&e?s.wheel||(t.addEventListener("wheel",r.wheel,{passive:!1}),s.wheel=r.wheel):(t.removeEventListener("wheel",s.wheel),s.wheel=null),2&e?s.mouseup||(s.mouseup=r.mouseup):(this._document.removeEventListener("mouseup",s.mouseup),s.mouseup=null),4&e?s.mousedrag||(s.mousedrag=r.mousedrag):(this._document.removeEventListener("mousemove",s.mousedrag),s.mousedrag=null)}))),this.coreMouseService.activeProtocol=this.coreMouseService.activeProtocol,this._register((0,I.addDisposableListener)(t,"mousedown",(e=>{if(e.preventDefault(),this.focus(),this.coreMouseService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(e))return i(e),s.mouseup&&this._document.addEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.addEventListener("mousemove",s.mousedrag),this.cancel(e)}))),this._register((0,I.addDisposableListener)(t,"wheel",(t=>{if(!s.wheel){if(this._customWheelEventHandler&&!1===this._customWheelEventHandler(t))return!1;if(!this.buffer.hasScrollback){if(0===t.deltaY)return!1;if(0===e.coreMouseService.consumeWheelEvent(t,e._renderService?.dimensions?.device?.cell?.height,e._coreBrowserService?.dpr))return this.cancel(t,!0);const i=E.C0.ESC+(this.coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(t.deltaY<0?"A":"B");return this.coreService.triggerDataEvent(i,!0),this.cancel(t,!0)}}}),{passive:!1}))}refresh(e,t){this._renderService?.refreshRows(e,t)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t){this._viewport?this._viewport.scrollLines(e):super.scrollLines(e,t),this.refresh(0,this.rows-1)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){e&&this._viewport?this._viewport.scrollToLine(this.buffer.ybase,!0):this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}paste(e){(0,s.paste)(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this._customWheelEventHandler=e}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;const t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(!0),!1;t||"Dead"!==e.key&&"AltGraph"!==e.key||(this._unprocessedDeadKey=!0);const i=(0,D.evaluateKeyboardEvent)(e,this.coreService.decPrivateModes.applicationCursorKeys,this.browser.isMac,this.options.macOptionIsMeta);if(this.updateCursorStyle(e),3===i.type||2===i.type){const t=this.rows-1;return this.scrollLines(2===i.type?-t:t),this.cancel(e,!0)}return 1===i.type&&this.selectAll(),!!this._isThirdLevelShift(this.browser,e)||(i.cancel&&this.cancel(e,!0),!i.key||!!(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&1===e.key.length&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)||(this._unprocessedDeadKey?(this._unprocessedDeadKey=!1,!0):(i.key!==E.C0.ETX&&i.key!==E.C0.CR||(this.textarea.value=""),this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!0),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey?this.cancel(e,!0):void(this._keyDownHandled=!0))))}_isThirdLevelShift(e,t){const i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e)||(function(e){return 16===e.keyCode||17===e.keyCode||18===e.keyCode}(e)||this.focus(),this.updateCursorStyle(e),this._keyPressHandled=!1)}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(this.cancel(e),e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const t=e.data;return this.coreService.triggerDataEvent(t,!0),this.cancel(e),!0}return!1}resize(e,t){e!==this.cols||t!==this.rows?super.resize(e,t):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(e,t){this._charSizeService?.measure()}clear(){if(0!==this.buffer.ybase||0!==this.buffer.y){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier=void 0;const n=i(7150),o=i(6501),a=i(7098),l=i(802),h=i(7093);let c=class extends n.Disposable{get currentLink(){return this._currentLink}constructor(e,t,i,s,r){super(),this._element=e,this._mouseService=t,this._renderService=i,this._bufferService=s,this._linkProviderService=r,this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this._register(new l.Emitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this._register(new l.Emitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this._register((0,n.toDisposable)((()=>{(0,n.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0,this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()}))),this._register(this._bufferService.onResize((()=>{this._clearCurrentLink(),this._wasResized=!0}))),this._register((0,h.addDisposableListener)(this._element,"mouseleave",(()=>{this._isMouseOut=!0,this._clearCurrentLink()}))),this._register((0,h.addDisposableListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this._register((0,h.addDisposableListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this._register((0,h.addDisposableListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(e){this._lastMouseEvent=e;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);if(!t)return;this._isMouseOut=!1;const i=e.composedPath();for(let e=0;e{e?.forEach((e=>{e.link.dispose&&e.link.dispose()}))})),this._activeProviderReplies=new Map,this._activeLine=e.y);let i=!1;for(const[s,r]of this._linkProviderService.linkProviders.entries())if(t){const t=this._activeProviderReplies?.get(s);t&&(i=this._checkLinkProviderResult(s,e,i))}else r.provideLinks(e.y,(t=>{if(this._isMouseOut)return;const r=t?.map((e=>({link:e})));this._activeProviderReplies?.set(s,r),i=this._checkLinkProviderResult(s,e,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)}))}_removeIntersectingLinks(e,t){const i=new Set;for(let s=0;se?this._bufferService.cols:s.link.range.end.x;for(let e=n;e<=o;e++){if(i.has(e)){r.splice(t--,1);break}i.add(e)}}}}_checkLinkProviderResult(e,t,i){if(!this._activeProviderReplies)return i;const s=this._activeProviderReplies.get(e);let r=!1;for(let t=0;tthis._linkAtPosition(e.link,t)));e&&(i=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let e=0;ethis._linkAtPosition(e.link,t)));if(s){i=!0,this._handleNewLink(s);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);var i,s;t&&this._mouseDownLink&&(i=this._mouseDownLink.link,s=this._currentLink.link,i.text===s.text&&i.range.start.x===s.range.start.x&&i.range.start.y===s.range.start.y&&i.range.end.x===s.range.end.x&&i.range.end.y===s.range.end.y)&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){this._currentLink&&this._lastMouseEvent&&(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,n.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0)}_handleNewLink(e){if(!this._lastMouseEvent)return;const t=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:e=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",e))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:t=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange((e=>{if(!this._currentLink)return;const t=0===e.start?0:e.start+1+this._bufferService.buffer.ydisp,i=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=i&&(this._clearCurrentLink(t,i),this._lastMouseEvent)){const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);e&&this._askForLink(e,!1)}}))))}_linkHover(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){const i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){const i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t,i){const s=i.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(s)return{x:s[0],y:s[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};t.Linkifier=c,t.Linkifier=c=s([r(1,a.IMouseService),r(2,a.IRenderService),r(3,o.IBufferService),r(4,a.ILinkProviderService)],c)},7721:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0;let i="Terminal input";const s={get:()=>i,set:e=>i=e};t.promptLabel=s;let r="Too much output to announce, navigate to rows manually to read";const n={get:()=>r,set:e=>r=e};t.tooMuchOutput=n},3285:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkProvider=void 0;const n=i(3055),o=i(6501);let a=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i}provideLinks(e,t){const i=this._bufferService.buffer.lines.get(e-1);if(!i)return void t(void 0);const s=[],r=this._optionsService.rawOptions.linkHandler,o=new n.CellData,a=i.getTrimmedLength();let h=-1,c=-1,d=!1;for(let t=0;tr?r.activate(e,t,n):l(0,t),hover:(e,t)=>r?.hover?.(e,t,n),leave:(e,t)=>r?.leave?.(e,t,n)})}d=!1,o.hasExtendedAttrs()&&o.extended.urlId?(c=t,h=o.extended.urlId):(c=-1,h=-1)}}t(s)}};function l(e,t){if(confirm(`Do you want to navigate to ${t}?\n\nWARNING: This link could potentially be dangerous`)){const e=window.open();if(e){try{e.opener=null}catch{}e.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}}t.OscLinkProvider=a,t.OscLinkProvider=a=s([r(0,o.IBufferService),r(1,o.IOptionsService),r(2,o.IOscLinkService)],a)},4852:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.RenderDebouncer=void 0,t.RenderDebouncer=class{constructor(e,t){this._renderCallback=e,this._coreBrowserService=t,this._refreshCallbacks=[]}dispose(){this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(e){return this._refreshCallbacks.push(e),this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh()))),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._animationFrame=void 0,void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return void this._runRefreshCallbacks();const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}}},4292:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0,t.TimeBasedDebouncer=class{constructor(e,t=1e3){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&clearTimeout(this._refreshTimeoutID)}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;const s=performance.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){const e=s-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout((()=>{this._lastRefreshMs=performance.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0}),t)}}_innerRefresh(){if(void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return;const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}}},9302:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_ANSI_COLORS=void 0;const s=i(4103);t.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const e=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){const r=t[i/36%6|0],n=t[i/6%6|0],o=t[i%6];e.push({css:s.channels.toCss(r,n,o),rgba:s.channels.toRgba(r,n,o)})}for(let t=0;t<24;t++){const i=8+10*t;e.push({css:s.channels.toCss(i,i,i),rgba:s.channels.toRgba(i,i,i)})}return e})())},4017:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;const n=i(7098),o=i(7150),a=i(6501),l=i(7093),h=i(8234),c=i(802),d=i(9881);let u=class extends o.Disposable{constructor(e,t,i,s,r,n,a,u){super(),this._bufferService=i,this._optionsService=a,this._renderService=u,this._onRequestScrollLines=this._register(new c.Emitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._isSyncing=!1,this._isHandlingScroll=!1,this._suppressOnScrollHandler=!1;const _=this._register(new d.Scrollable({forceIntegerValues:!1,smoothScrollDuration:this._optionsService.rawOptions.smoothScrollDuration,scheduleAtNextAnimationFrame:e=>(0,l.scheduleAtNextAnimationFrame)(s.window,e)}));this._register(this._optionsService.onSpecificOptionChange("smoothScrollDuration",(()=>{_.setSmoothScrollDuration(this._optionsService.rawOptions.smoothScrollDuration)}))),this._scrollableElement=this._register(new h.SmoothScrollableElement(t,{vertical:1,horizontal:2,useShadows:!1,mouseWheelSmoothScroll:!0,...this._getChangeOptions()},_)),this._register(this._optionsService.onMultipleOptionChange(["scrollSensitivity","fastScrollSensitivity","overviewRuler"],(()=>this._scrollableElement.updateOptions(this._getChangeOptions())))),this._register(r.onProtocolChange((e=>{this._scrollableElement.updateOptions({handleMouseWheel:!(16&e)})}))),this._scrollableElement.setScrollDimensions({height:0,scrollHeight:0}),this._register(c.Event.runAndSubscribe(n.onChangeColors,(()=>{this._scrollableElement.getDomNode().style.backgroundColor=n.colors.background.css}))),e.appendChild(this._scrollableElement.getDomNode()),this._register((0,o.toDisposable)((()=>this._scrollableElement.getDomNode().remove()))),this._styleElement=s.mainDocument.createElement("style"),t.appendChild(this._styleElement),this._register((0,o.toDisposable)((()=>this._styleElement.remove()))),this._register(c.Event.runAndSubscribe(n.onChangeColors,(()=>{this._styleElement.textContent=[".xterm .xterm-scrollable-element > .scrollbar > .slider {",` background: ${n.colors.scrollbarSliderBackground.css};`,"}",".xterm .xterm-scrollable-element > .scrollbar > .slider:hover {",` background: ${n.colors.scrollbarSliderHoverBackground.css};`,"}",".xterm .xterm-scrollable-element > .scrollbar > .slider.active {",` background: ${n.colors.scrollbarSliderActiveBackground.css};`,"}"].join("\n")}))),this._register(this._bufferService.onResize((()=>this.queueSync()))),this._register(this._bufferService.buffers.onBufferActivate((()=>{this._latestYDisp=void 0,this.queueSync()}))),this._register(this._bufferService.onScroll((()=>this._sync()))),this._register(this._scrollableElement.onScroll((e=>this._handleScroll(e))))}scrollLines(e){const t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({reuseAnimation:!0,scrollTop:t.scrollTop+e*this._renderService.dimensions.css.cell.height})}scrollToLine(e,t){t&&(this._latestYDisp=e),this._scrollableElement.setScrollPosition({reuseAnimation:!t,scrollTop:e*this._renderService.dimensions.css.cell.height})}_getChangeOptions(){return{mouseWheelScrollSensitivity:this._optionsService.rawOptions.scrollSensitivity,fastScrollSensitivity:this._optionsService.rawOptions.fastScrollSensitivity,verticalScrollbarSize:this._optionsService.rawOptions.overviewRuler?.width||14}}queueSync(e){void 0!==e&&(this._latestYDisp=e),void 0===this._queuedAnimationFrame&&(this._queuedAnimationFrame=this._renderService.addRefreshCallback((()=>{this._queuedAnimationFrame=void 0,this._sync(this._latestYDisp)})))}_sync(e=this._bufferService.buffer.ydisp){this._renderService&&!this._isSyncing&&(this._isSyncing=!0,this._suppressOnScrollHandler=!0,this._scrollableElement.setScrollDimensions({height:this._renderService.dimensions.css.canvas.height,scrollHeight:this._renderService.dimensions.css.cell.height*this._bufferService.buffer.lines.length}),this._suppressOnScrollHandler=!1,e!==this._latestYDisp&&this._scrollableElement.setScrollPosition({scrollTop:e*this._renderService.dimensions.css.cell.height}),this._isSyncing=!1)}_handleScroll(e){if(!this._renderService)return;if(this._isHandlingScroll||this._suppressOnScrollHandler)return;this._isHandlingScroll=!0;const t=Math.round(e.scrollTop/this._renderService.dimensions.css.cell.height),i=t-this._bufferService.buffer.ydisp;0!==i&&(this._latestYDisp=t,this._onRequestScrollLines.fire(i)),this._isHandlingScroll=!1}};t.Viewport=u,t.Viewport=u=s([r(2,a.IBufferService),r(3,n.ICoreBrowserService),r(4,a.ICoreMouseService),r(5,n.IThemeService),r(6,a.IOptionsService),r(7,n.IRenderService)],u)},4196:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;const n=i(7098),o=i(7150),a=i(6501);let l=class extends o.Disposable{constructor(e,t,i,s,r){super(),this._screenElement=e,this._bufferService=t,this._coreBrowserService=i,this._decorationService=s,this._renderService=r,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this._register(this._renderService.onRenderedViewportChange((()=>this._doRefreshDecorations()))),this._register(this._renderService.onDimensionsChange((()=>{this._dimensionsChanged=!0,this._queueRefresh()}))),this._register(this._coreBrowserService.onDprChange((()=>this._queueRefresh()))),this._register(this._bufferService.buffers.onBufferActivate((()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt}))),this._register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh()))),this._register(this._decorationService.onDecorationRemoved((e=>this._removeDecoration(e)))),this._register((0,o.toDisposable)((()=>{this._container.remove(),this._decorationElements.clear()})))}_queueRefresh(){void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback((()=>{this._doRefreshDecorations(),this._animationFrame=void 0})))}_doRefreshDecorations(){for(const e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){const t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer","top"===e?.options?.layer),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",t.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const i=e.options.x??0;return i&&i>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){const t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose((()=>{this._decorationElements.delete(e),i.remove()}))),i.style.display=this._altBufferIsActive?"none":"block",this._altBufferIsActive||(i.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,i.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",i.style.top=t*this._renderService.dimensions.css.cell.height+"px",i.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`),e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){if(!t)return;const i=e.options.x??0;"right"===(e.options.anchor||"left")?t.style.right=i?i*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=i?i*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};t.BufferDecorationRenderer=l,t.BufferDecorationRenderer=l=s([r(1,a.IBufferService),r(2,n.ICoreBrowserService),r(3,a.IDecorationService),r(4,n.IRenderService)],l)},957:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0,t.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(const t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(t,e.marker.line)}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}}},9925:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;const n=i(957),o=i(7098),a=i(7150),l=i(6501),h={full:0,left:0,center:0,right:0},c={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0};let u=class extends a.Disposable{get _width(){return this._optionsService.options.overviewRuler?.width||0}constructor(e,t,i,s,r,o,l,h){super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=o,this._themeService=l,this._coreBrowserService=h,this._colorZoneStore=new n.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement),this._register((0,a.toDisposable)((()=>this._canvas?.remove())));const c=this._canvas.getContext("2d");if(!c)throw new Error("Ctx cannot be null");this._ctx=c,this._register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh(void 0,!0)))),this._register(this._decorationService.onDecorationRemoved((()=>this._queueRefresh(void 0,!0)))),this._register(this._renderService.onRenderedViewportChange((()=>this._queueRefresh()))),this._register(this._bufferService.buffers.onBufferActivate((()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"}))),this._register(this._bufferService.onScroll((()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())}))),this._register(this._renderService.onRender((()=>{this._containerHeight&&this._containerHeight===this._screenElement.clientHeight||(this._queueRefresh(!0),this._containerHeight=this._screenElement.clientHeight)}))),this._register(this._coreBrowserService.onDprChange((()=>this._queueRefresh(!0)))),this._register(this._optionsService.onSpecificOptionChange("overviewRuler",(()=>this._queueRefresh(!0)))),this._register(this._themeService.onChangeColors((()=>this._queueRefresh()))),this._queueRefresh(!0)}_refreshDrawConstants(){const e=Math.floor((this._canvas.width-1)/3),t=Math.ceil((this._canvas.width-1)/3);c.full=this._canvas.width,c.left=e,c.center=t,c.right=e,this._refreshDrawHeightConstants(),d.full=1,d.left=1,d.center=1+c.left,d.right=1+c.left+c.center}_refreshDrawHeightConstants(){h.full=Math.round(2*this._coreBrowserService.dpr);const e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);h.left=t,h.center=t,h.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${this._screenElement.clientHeight}px`,this._canvas.height=Math.round(this._screenElement.clientHeight*this._coreBrowserService.dpr),this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1,this._renderRulerOutline();const e=this._colorZoneStore.zones;for(const t of e)"full"!==t.position&&this._renderColorZone(t);for(const t of e)"full"===t.position&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderRulerOutline(){this._ctx.fillStyle=this._themeService.colors.overviewRulerBorder.css,this._ctx.fillRect(0,0,1,this._canvas.height),this._optionsService.rawOptions.overviewRuler.showTopBorder&&this._ctx.fillRect(1,0,this._canvas.width-1,1),this._optionsService.rawOptions.overviewRuler.showBottomBorder&&this._ctx.fillRect(1,this._canvas.height-1,this._canvas.width-1,this._canvas.height)}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(d[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-h[e.position||"full"]/2),c[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+h[e.position||"full"]))}_queueRefresh(e,t){this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>{this._refreshDecorations(),this._animationFrame=void 0})))}};t.OverviewRulerRenderer=u,t.OverviewRulerRenderer=u=s([r(2,l.IBufferService),r(3,l.IDecorationService),r(4,o.IRenderService),r(5,l.IOptionsService),r(6,o.IThemeService),r(7,o.ICoreBrowserService)],u)},3618:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;const n=i(7098),o=i(6501),a=i(3534);let l=class{get isComposing(){return this._isComposing}constructor(e,t,i,s,r,n){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=n,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._dataAlreadySent=""}compositionstart(){this._isComposing=!0,this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=e.data,this.updateCompositionElements(),setTimeout((()=>{this._compositionPosition.end=this._textarea.value.length}),0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(20===e.keyCode||229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0,setTimeout((()=>{if(this._isSendingComposition){let t;this._isSendingComposition=!1,e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&this._coreService.triggerDataEvent(t,!0)}}),0)}else{this._isSendingComposition=!1;const e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(e,!0)}}_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0)),0)}}};t.CompositionHelper=l,t.CompositionHelper=l=s([r(2,o.IBufferService),r(3,o.IOptionsService),r(4,o.ICoreService),r(5,n.IRenderService)],l)},5251:(e,t)=>{function i(e,t,i){const s=i.getBoundingClientRect(),r=e.getComputedStyle(i),n=parseInt(r.getPropertyValue("padding-left")),o=parseInt(r.getPropertyValue("padding-top"));return[t.clientX-s.left-n,t.clientY-s.top-o]}Object.defineProperty(t,"__esModule",{value:!0}),t.getCoordsRelativeToElement=i,t.getCoords=function(e,t,s,r,n,o,a,l,h){if(!o)return;const c=i(e,t,s);return c?(c[0]=Math.ceil((c[0]+(h?a/2:0))/a),c[1]=Math.ceil(c[1]/l),c[0]=Math.min(Math.max(c[0],1),r+(h?1:0)),c[1]=Math.min(Math.max(c[1],1),n),c):void 0}},9686:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.moveToCellSequence=function(e,t,i,s){const o=i.buffer.x,c=i.buffer.y;if(!i.buffer.hasScrollback)return function(e,t,i,s,o,c){return 0===r(t,s,o,c).length?"":h(a(e,t,e,t-n(t,o),!1,o).length,l("D",c))}(o,c,0,t,i,s)+r(c,t,i,s)+function(e,t,i,s,o,c){let d;d=r(t,s,o,c).length>0?s-n(s,o):t;const u=s,_=function(e,t,i,s,o,a){let l;return l=r(i,s,o,a).length>0?s-n(s,o):t,e=i&&le?"D":"C",h(Math.abs(o-e),l(d,s));d=c>t?"D":"C";const u=Math.abs(c-t);return h(function(e,t){return t.cols-e}(c>t?e:o,i)+(u-1)*i.cols+1+((c>t?o:e)-1),l(d,s))};const s=i(3534);function r(e,t,i,s){const r=e-n(e,i),a=t-n(t,i),c=Math.abs(r-a)-function(e,t,i){let s=0;const r=e-n(e,i),a=t-n(t,i);for(let n=0;n=0&&et?"A":"B"}function a(e,t,i,s,r,n){let o=e,a=t,l="";for(;(o!==i||a!==s)&&a>=0&&an.cols-1?(l+=n.buffer.translateBufferLineToString(a,!1,e,o),o=0,e=0,a++):!r&&o<0&&(l+=n.buffer.translateBufferLineToString(a,!1,0,e+1),o=n.cols-1,e=o,a--);return l+n.buffer.translateBufferLineToString(a,!1,e,o)}function l(e,t){const i=t?"O":"[";return s.C0.ESC+i+e}function h(e,t){e=Math.floor(e);let i="";for(let s=0;s=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;const n=i(1433),o=i(2744),a=i(9176),l=i(6181),h=i(2274),c=i(7098),d=i(4103),u=i(7150),_=i(6501),f=i(802),p="xterm-dom-renderer-owner-",g="xterm-rows",m="xterm-fg-",v="xterm-bg-",S="xterm-focus",b="xterm-selection";let C=1,y=class extends u.Disposable{constructor(e,t,i,s,r,a,c,d,_,m,v,S,y,w){super(),this._terminal=e,this._document=t,this._element=i,this._screenElement=s,this._viewportElement=r,this._helperContainer=a,this._linkifier2=c,this._charSizeService=_,this._optionsService=m,this._bufferService=v,this._coreService=S,this._coreBrowserService=y,this._themeService=w,this._terminalClass=C++,this._rowElements=[],this._selectionRenderModel=(0,h.createSelectionRenderModel)(),this.onRequestRedraw=this._register(new f.Emitter).event,this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add(g),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add(b),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,l.createRenderDimensions)(),this._updateDimensions(),this._register(this._optionsService.onOptionChange((()=>this._handleOptionsChanged()))),this._register(this._themeService.onChangeColors((e=>this._injectCss(e)))),this._injectCss(this._themeService.colors),this._rowFactory=d.createInstance(n.DomRendererRowFactory,document),this._element.classList.add(p+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this._register(this._linkifier2.onShowLinkUnderline((e=>this._handleLinkHover(e)))),this._register(this._linkifier2.onHideLinkUnderline((e=>this._handleLinkLeave(e)))),this._register((0,u.toDisposable)((()=>{this._element.classList.remove(p+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()}))),this._widthCache=new o.WidthCache(this._document,this._helperContainer),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const t=`${this._terminalSelector} .${g} span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .${g} { pointer-events: none; color: ${e.foreground.css}; font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`;t+=`${this._terminalSelector} .${g} .xterm-dim { color: ${d.color.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}`;const i=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,r=`blink_block_${this._terminalClass}`;t+=`@keyframes ${i} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${r} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .${g}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${i} 1s step-end infinite;}${this._terminalSelector} .${g}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .${g}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .${b} { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .${b} div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .${b} div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(const[i,s]of e.ansi.entries())t+=`${this._terminalSelector} .${m}${i} { color: ${s.css}; }${this._terminalSelector} .${m}${i}.xterm-dim { color: ${d.color.multiplyOpacity(s,.5).css}; }${this._terminalSelector} .${v}${i} { background-color: ${s.css}; }`;t+=`${this._terminalSelector} .${m}${a.INVERTED_DEFAULT_COLOR} { color: ${d.color.opaque(e.background).css}; }${this._terminalSelector} .${m}${a.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${d.color.multiplyOpacity(d.color.opaque(e.background),.5).css}; }${this._terminalSelector} .${v}${a.INVERTED_DEFAULT_COLOR} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){const e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){const e=this._document.createElement("div");this._rowContainer.appendChild(e),this._rowElements.push(e)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop())}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove(S),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add(S),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleSelectionChanged(e,t,i){if(this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i),this.renderRows(0,this._bufferService.rows-1),!e||!t)return;if(this._selectionRenderModel.update(this._terminal,e,t,i),!this._selectionRenderModel.hasSelection)return;const s=this._selectionRenderModel.viewportStartRow,r=this._selectionRenderModel.viewportEndRow,n=this._selectionRenderModel.viewportCappedStartRow,o=this._selectionRenderModel.viewportCappedEndRow,a=this._document.createDocumentFragment();if(i){const i=e[0]>t[0];a.appendChild(this._createSelectionElement(n,i?t[0]:e[0],i?e[0]:t[0],o-n+1))}else{const i=s===n?e[0]:0,l=n===r?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(n,i,l));const h=o-n-1;if(a.appendChild(this._createSelectionElement(n+1,0,this._bufferService.cols,h)),n!==o){const e=r===o?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(o,0,e))}}this._selectionContainer.appendChild(a)}_createSelectionElement(e,t,i,s=1){const r=this._document.createElement("div"),n=t*this.dimensions.css.cell.width;let o=this.dimensions.css.cell.width*(i-t);return n+o>this.dimensions.css.canvas.width&&(o=this.dimensions.css.canvas.width-n),r.style.height=s*this.dimensions.css.cell.height+"px",r.style.top=e*this.dimensions.css.cell.height+"px",r.style.left=`${n}px`,r.style.width=`${o}px`,r}handleCursorMove(){}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const e of this._rowElements)e.replaceChildren()}renderRows(e,t){const i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),n=this._coreService.decPrivateModes.cursorBlink??this._optionsService.rawOptions.cursorBlink,o=this._coreService.decPrivateModes.cursorStyle??this._optionsService.rawOptions.cursorStyle,a=this._optionsService.rawOptions.cursorInactiveStyle;for(let l=e;l<=t;l++){const e=l+i.ydisp,t=this._rowElements[l],h=i.lines.get(e);if(!t||!h)break;t.replaceChildren(...this._rowFactory.createRow(h,e,e===s,o,a,r,n,this.dimensions.css.cell.width,this._widthCache,-1,-1))}}get _terminalSelector(){return`.${p}${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,n){i<0&&(e=0),s<0&&(t=0);const o=this._bufferService.rows-1;i=Math.max(Math.min(i,o),0),s=Math.max(Math.min(s,o),0),r=Math.min(r,this._bufferService.cols);const a=this._bufferService.buffer,l=a.ybase+a.y,h=Math.min(a.x,r-1),c=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,u=this._optionsService.rawOptions.cursorInactiveStyle;for(let o=i;o<=s;++o){const _=o+a.ydisp,f=this._rowElements[o],p=a.lines.get(_);if(!f||!p)break;f.replaceChildren(...this._rowFactory.createRow(p,_,_===l,d,u,h,c,this.dimensions.css.cell.width,this._widthCache,n?o===i?e:0:-1,n?(o===s?t:r)-1:-1))}}};t.DomRenderer=y,t.DomRenderer=y=s([r(7,_.IInstantiationService),r(8,c.ICharSizeService),r(9,_.IOptionsService),r(10,_.IBufferService),r(11,_.ICoreService),r(12,c.ICoreBrowserService),r(13,c.IThemeService)],y)},1433:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=void 0;const n=i(9176),o=i(8938),a=i(3055),l=i(6501),h=i(4103),c=i(7098),d=i(945),u=i(6181),_=i(5451);let f=class{constructor(e,t,i,s,r,n,o){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=n,this._themeService=o,this._workCell=new a.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,a,l,c,u,f,g){const m=[],v=this._characterJoinerService.getJoinedCharacters(t),S=this._themeService.colors;let b,C=e.getNoBgTrimmedLength();i&&C=O,U=x,F=this._workCell;if(v.length>0&&x===v[0][0]&&N){const s=v.shift(),r=this._isCellInSelection(s[0],t);for(E=s[0]+1;E=s[1],N?(B=!0,F=new d.JoinedCellData(this._workCell,e.translateToString(!0,s[0],s[1]),s[1]-s[0]),U=s[1]-1,C=F.getWidth()):O=s[1]}const W=this._isCellInSelection(x,t),H=i&&x===a,K=P&&x>=f&&x<=g;let z=!1;this._decorationService.forEachDecorationAtCell(x,t,void 0,(e=>{z=!0}));let j=F.getChars()||o.WHITESPACE_CELL_CHAR;if(" "===j&&(F.isUnderline()||F.isOverline())&&(j=" "),M=C*c-u.get(j,F.isBold(),F.isItalic()),b){if(y&&(W&&k||!W&&!k&&F.bg===D)&&(W&&k&&S.selectionForeground||F.fg===L)&&F.extended.ext===R&&K===A&&M===T&&!H&&!B&&!z&&N){F.isInvisible()?w+=o.WHITESPACE_CELL_CHAR:w+=j,y++;continue}y&&(b.textContent=w),b=this._document.createElement("span"),y=0,w=""}else b=this._document.createElement("span");if(D=F.bg,L=F.fg,R=F.extended.ext,A=K,T=M,k=W,B&&a>=x&&a<=U&&(a=x),!this._coreService.isCursorHidden&&H&&this._coreService.isCursorInitialized)if(I.push("xterm-cursor"),this._coreBrowserService.isFocused)l&&I.push("xterm-cursor-blink"),I.push("bar"===s?"xterm-cursor-bar":"underline"===s?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":I.push("xterm-cursor-outline");break;case"block":I.push("xterm-cursor-block");break;case"bar":I.push("xterm-cursor-bar");break;case"underline":I.push("xterm-cursor-underline")}if(F.isBold()&&I.push("xterm-bold"),F.isItalic()&&I.push("xterm-italic"),F.isDim()&&I.push("xterm-dim"),w=F.isInvisible()?o.WHITESPACE_CELL_CHAR:F.getChars()||o.WHITESPACE_CELL_CHAR,F.isUnderline()&&(I.push(`xterm-underline-${F.extended.underlineStyle}`)," "===w&&(w=" "),!F.isUnderlineColorDefault()))if(F.isUnderlineColorRGB())b.style.textDecorationColor=`rgb(${_.AttributeData.toColorRGB(F.getUnderlineColor()).join(",")})`;else{let e=F.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&F.isBold()&&e<8&&(e+=8),b.style.textDecorationColor=S.ansi[e].css}F.isOverline()&&(I.push("xterm-overline")," "===w&&(w=" ")),F.isStrikethrough()&&I.push("xterm-strikethrough"),K&&(b.style.textDecoration="underline");let $=F.getFgColor(),V=F.getFgColorMode(),G=F.getBgColor(),q=F.getBgColorMode();const X=!!F.isInverse();if(X){const e=$;$=G,G=e;const t=V;V=q,q=t}let Y,Z,J,Q=!1;switch(this._decorationService.forEachDecorationAtCell(x,t,void 0,(e=>{"top"!==e.options.layer&&Q||(e.backgroundColorRGB&&(q=50331648,G=e.backgroundColorRGB.rgba>>8&16777215,Y=e.backgroundColorRGB),e.foregroundColorRGB&&(V=50331648,$=e.foregroundColorRGB.rgba>>8&16777215,Z=e.foregroundColorRGB),Q="top"===e.options.layer)})),!Q&&W&&(Y=this._coreBrowserService.isFocused?S.selectionBackgroundOpaque:S.selectionInactiveBackgroundOpaque,G=Y.rgba>>8&16777215,q=50331648,Q=!0,S.selectionForeground&&(V=50331648,$=S.selectionForeground.rgba>>8&16777215,Z=S.selectionForeground)),Q&&I.push("xterm-decoration-top"),q){case 16777216:case 33554432:J=S.ansi[G],I.push(`xterm-bg-${G}`);break;case 50331648:J=h.channels.toColor(G>>16,G>>8&255,255&G),this._addStyle(b,`background-color:#${p((G>>>0).toString(16),"0",6)}`);break;default:X?(J=S.foreground,I.push(`xterm-bg-${n.INVERTED_DEFAULT_COLOR}`)):J=S.background}switch(Y||F.isDim()&&(Y=h.color.multiplyOpacity(J,.5)),V){case 16777216:case 33554432:F.isBold()&&$<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&($+=8),this._applyMinimumContrast(b,J,S.ansi[$],F,Y,void 0)||I.push(`xterm-fg-${$}`);break;case 50331648:const e=h.channels.toColor($>>16&255,$>>8&255,255&$);this._applyMinimumContrast(b,J,e,F,Y,Z)||this._addStyle(b,`color:#${p($.toString(16),"0",6)}`);break;default:this._applyMinimumContrast(b,J,S.foreground,F,Y,Z)||X&&I.push(`xterm-fg-${n.INVERTED_DEFAULT_COLOR}`)}I.length&&(b.className=I.join(" "),I.length=0),H||B||z||!N?b.textContent=w:y++,M!==this.defaultSpacing&&(b.style.letterSpacing=`${M}px`),m.push(b),x=U}return b&&y&&(b.textContent=w),m}_applyMinimumContrast(e,t,i,s,r,n){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,u.treatGlyphAsBackgroundColor)(s.getCode()))return!1;const o=this._getContrastCache(s);let a;if(r||n||(a=o.getColor(t.rgba,i.rgba)),void 0===a){const e=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);a=h.color.ensureContrastRatio(r||t,n||i,e),o.setColor((r||t).rgba,(n||i).rgba,a??null)}return!!a&&(this._addStyle(e,`color:${a.css}`),!0)}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){const i=this._selectionStart,s=this._selectionEnd;return!(!i||!s)&&(this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0])}};function p(e,t,i){for(;e.length{Object.defineProperty(t,"__esModule",{value:!0}),t.WidthCache=void 0,t.WidthCache=class{constructor(e,t){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._measureElements=[],this._container=e.createElement("div"),this._container.classList.add("xterm-width-cache-measure-container"),this._container.setAttribute("aria-hidden","true"),this._container.style.whiteSpace="pre",this._container.style.fontKerning="none";const i=e.createElement("span");i.classList.add("xterm-char-measure-element");const s=e.createElement("span");s.classList.add("xterm-char-measure-element"),s.style.fontWeight="bold";const r=e.createElement("span");r.classList.add("xterm-char-measure-element"),r.style.fontStyle="italic";const n=e.createElement("span");n.classList.add("xterm-char-measure-element"),n.style.fontWeight="bold",n.style.fontStyle="italic",this._measureElements=[i,s,r,n],this._container.appendChild(i),this._container.appendChild(s),this._container.appendChild(r),this._container.appendChild(n),t.appendChild(this._container),this.clear()}dispose(){this._container.remove(),this._measureElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._container.style.fontFamily=this._font,this._container.style.fontSize=`${this._fontSize}px`,this._measureElements[0].style.fontWeight=`${i}`,this._measureElements[1].style.fontWeight=`${s}`,this._measureElements[2].style.fontWeight=`${i}`,this._measureElements[3].style.fontWeight=`${s}`,this.clear())}get(e,t,i){let s=0;if(!t&&!i&&1===e.length&&(s=e.charCodeAt(0))<256){if(-9999!==this._flat[s])return this._flat[s];const t=this._measure(e,0);return t>0&&(this._flat[s]=t),t}let r=e;t&&(r+="B"),i&&(r+="I");let n=this._holey.get(r);if(void 0===n){let s=0;t&&(s|=1),i&&(s|=2),n=this._measure(e,s),n>0&&this._holey.set(r,n)}return n}_measure(e,t){const i=this._measureElements[t];return i.textContent=e.repeat(32),i.offsetWidth/32}}},9176:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.INVERTED_DEFAULT_COLOR=void 0,t.INVERTED_DEFAULT_COLOR=257},6181:(e,t)=>{function i(e){return 57508<=e&&e<=57558}function s(e){return e>=128512&&e<=128591||e>=127744&&e<=128511||e>=128640&&e<=128767||e>=9728&&e<=9983||e>=9984&&e<=10175||e>=65024&&e<=65039||e>=129280&&e<=129535||e>=127462&&e<=127487}Object.defineProperty(t,"__esModule",{value:!0}),t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=i,t.isRestrictedPowerlineGlyph=function(e){return 57520<=e&&e<=57527},t.isEmoji=s,t.allowRescaling=function(e,t,r,n){return 1===t&&r>Math.ceil(1.5*n)&&void 0!==e&&e>255&&!s(e)&&!i(e)&&!function(e){return 57344<=e&&e<=63743}(e)},t.treatGlyphAsBackgroundColor=function(e){return i(e)||function(e){return 9472<=e&&e<=9631}(e)},t.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},t.computeNextVariantOffset=function(e,t,i=0){return(e-(2*Math.round(t)-i))%(2*Math.round(t))}},2274:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createSelectionRenderModel=function(){return new i};class i{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(e,t,i,s=!1){if(this.selectionStart=t,this.selectionEnd=i,!t||!i||t[0]===i[0]&&t[1]===i[1])return void this.clear();const r=e.buffers.active.ydisp,n=t[1]-r,o=i[1]-r,a=Math.max(n,0),l=Math.min(o,e.rows-1);a>=e.rows||l<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=n,this.viewportEndRow=o,this.viewportCappedStartRow=a,this.viewportCappedEndRow=l,this.startCol=t[0],this.endCol=i[0])}isCellSelected(e,t,i){return!!this.hasSelection&&(i-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&i>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&t=this.startCol)}}},5959:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0,t.SelectionModel=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols==0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):(this.selectionStart&&this.selectionStart[1]<0&&(this.selectionStart[1]=0),!1)}}},4792:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;const n=i(6501),o=i(7150),a=i(802);let l=class extends o.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this._register(new a.Emitter),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this._register(new d(this._optionsService))}catch{this._measureStrategy=this._register(new c(e,t,this._optionsService))}this._register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],(()=>this.measure())))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};t.CharSizeService=l,t.CharSizeService=l=s([r(2,n.IOptionsService)],l);class h extends o.Disposable{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(e,t){void 0!==e&&e>0&&void 0!==t&&t>0&&(this._result.width=e,this._result.height=t)}}class c extends h{constructor(e,t,i){super(),this._document=e,this._parentElement=t,this._optionsService=i,this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}}class d extends h{constructor(e){super(),this._optionsService=e,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");const t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;const e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}}},945:function(e,t,i){var s,r=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},n=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;const o=i(5451),a=i(8938),l=i(3055),h=i(6501);class c extends o.AttributeData{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.JoinedCellData=c;let d=s=class{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new l.CellData}register(e){const t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t1){const e=this._getJoinedRanges(s,o,n,t,r);for(let t=0;t1){const e=this._getJoinedRanges(s,o,n,t,r);for(let t=0;t{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreBrowserService=void 0;const s=i(802),r=i(7093),n=i(7150);class o extends n.Disposable{constructor(e,t,i){super(),this._textarea=e,this._window=t,this.mainDocument=i,this._isFocused=!1,this._cachedIsFocused=void 0,this._screenDprMonitor=this._register(new a(this._window)),this._onDprChange=this._register(new s.Emitter),this.onDprChange=this._onDprChange.event,this._onWindowChange=this._register(new s.Emitter),this.onWindowChange=this._onWindowChange.event,this._register(this.onWindowChange((e=>this._screenDprMonitor.setWindow(e)))),this._register(s.Event.forward(this._screenDprMonitor.onDprChange,this._onDprChange)),this._register((0,r.addDisposableListener)(this._textarea,"focus",(()=>this._isFocused=!0))),this._register((0,r.addDisposableListener)(this._textarea,"blur",(()=>this._isFocused=!1)))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return void 0===this._cachedIsFocused&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask((()=>this._cachedIsFocused=void 0))),this._cachedIsFocused}}t.CoreBrowserService=o;class a extends n.Disposable{constructor(e){super(),this._parentWindow=e,this._windowResizeListener=this._register(new n.MutableDisposable),this._onDprChange=this._register(new s.Emitter),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this._register((0,n.toDisposable)((()=>this.clearListener())))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=(0,r.addDisposableListener)(this._parentWindow,"resize",(()=>this._setDprAndFireIfDiffers()))}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}}},9820:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkProviderService=void 0;const s=i(7150);class r extends s.Disposable{constructor(){super(),this.linkProviders=[],this._register((0,s.toDisposable)((()=>this.linkProviders.length=0)))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{const t=this.linkProviders.indexOf(e);-1!==t&&this.linkProviders.splice(t,1)}}}}t.LinkProviderService=r},9784:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseService=void 0;const n=i(7098),o=i(5251);let a=class{constructor(e,t){this._renderService=e,this._charSizeService=t}getCoords(e,t,i,s,r){return(0,o.getCoords)(window,e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){const i=(0,o.getCoordsRelativeToElement)(window,e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};t.MouseService=a,t.MouseService=a=s([r(0,n.IRenderService),r(1,n.ICharSizeService)],a)},5783:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;const n=i(4852),o=i(7098),a=i(7150),l=i(6168),h=i(6501),c=i(802);let d=class extends a.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,t,i,s,r,o,h,d,_){super(),this._rowCount=e,this._optionsService=i,this._charSizeService=s,this._coreService=r,this._coreBrowserService=d,this._renderer=this._register(new a.MutableDisposable),this._pausedResizeTask=new l.DebouncedIdleTask,this._observerDisposable=this._register(new a.MutableDisposable),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this._register(new c.Emitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this._register(new c.Emitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this._register(new c.Emitter),this.onRender=this._onRender.event,this._onRefreshRequest=this._register(new c.Emitter),this.onRefreshRequest=this._onRefreshRequest.event,this._renderDebouncer=new n.RenderDebouncer(((e,t)=>this._renderRows(e,t)),this._coreBrowserService),this._register(this._renderDebouncer),this._syncOutputHandler=new u(this._coreBrowserService,this._coreService,(()=>this._fullRefresh())),this._register((0,a.toDisposable)((()=>this._syncOutputHandler.dispose()))),this._register(this._coreBrowserService.onDprChange((()=>this.handleDevicePixelRatioChange()))),this._register(h.onResize((()=>this._fullRefresh()))),this._register(h.buffers.onBufferActivate((()=>this._renderer.value?.clear()))),this._register(this._optionsService.onOptionChange((()=>this._handleOptionsChanged()))),this._register(this._charSizeService.onCharSizeChange((()=>this.handleCharSizeChanged()))),this._register(o.onDecorationRegistered((()=>this._fullRefresh()))),this._register(o.onDecorationRemoved((()=>this._fullRefresh()))),this._register(this._optionsService.onMultipleOptionChange(["customGlyphs","drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],(()=>{this.clear(),this.handleResize(h.cols,h.rows),this._fullRefresh()}))),this._register(this._optionsService.onMultipleOptionChange(["cursorBlink","cursorStyle"],(()=>this.refreshRows(h.buffer.y,h.buffer.y,!0)))),this._register(_.onChangeColors((()=>this._fullRefresh()))),this._registerIntersectionObserver(this._coreBrowserService.window,t),this._register(this._coreBrowserService.onWindowChange((e=>this._registerIntersectionObserver(e,t))))}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){const i=new e.IntersectionObserver((e=>this._handleIntersectionChange(e[e.length-1])),{threshold:0});i.observe(t),this._observerDisposable.value=(0,a.toDisposable)((()=>i.disconnect()))}}_handleIntersectionChange(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const s=this._syncOutputHandler.flush();s&&(e=Math.min(e,s.start),t=Math.max(t,s.end)),i||(this._isNextRenderRedrawOnly=!1),this._renderDebouncer.refresh(e,t,this._rowCount)}_renderRows(e,t){this._renderer.value&&(this._coreService.decPrivateModes.synchronizedOutput?this._syncOutputHandler.bufferRows(e,t):(e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0))}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw((e=>this.refreshRows(e.start,e.end,!0))),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set((()=>this._renderer.value?.handleResize(e,t))):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,i){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,this._renderer.value?.handleSelectionChanged(e,t,i)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};t.RenderService=d,t.RenderService=d=s([r(2,h.IOptionsService),r(3,o.ICharSizeService),r(4,h.ICoreService),r(5,h.IDecorationService),r(6,h.IBufferService),r(7,o.ICoreBrowserService),r(8,o.IThemeService)],d);class u{constructor(e,t,i){this._coreBrowserService=e,this._coreService=t,this._onTimeout=i,this._start=0,this._end=0,this._isBuffering=!1}bufferRows(e,t){this._isBuffering?(this._start=Math.min(this._start,e),this._end=Math.max(this._end,t)):(this._start=e,this._end=t,this._isBuffering=!0),void 0===this._timeout&&(this._timeout=this._coreBrowserService.window.setTimeout((()=>{this._timeout=void 0,this._coreService.decPrivateModes.synchronizedOutput=!1,this._onTimeout()}),1e3))}flush(){if(void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0),!this._isBuffering)return;const e={start:this._start,end:this._end};return this._isBuffering=!1,e}dispose(){void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0)}}},2079:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionService=void 0;const n=i(5251),o=i(9686),a=i(5959),l=i(7098),h=i(7150),c=i(701),d=i(9384),u=i(3055),_=i(6501),f=i(802),p=String.fromCharCode(160),g=new RegExp(p,"g");let m=class extends h.Disposable{constructor(e,t,i,s,r,n,o,l,c){super(),this._element=e,this._screenElement=t,this._linkifier=i,this._bufferService=s,this._coreService=r,this._mouseService=n,this._optionsService=o,this._renderService=l,this._coreBrowserService=c,this._dragScrollAmount=0,this._enabled=!0,this._workCell=new u.CellData,this._mouseDownTimeStamp=0,this._oldHasSelection=!1,this._oldSelectionStart=void 0,this._oldSelectionEnd=void 0,this._onLinuxMouseSelection=this._register(new f.Emitter),this.onLinuxMouseSelection=this._onLinuxMouseSelection.event,this._onRedrawRequest=this._register(new f.Emitter),this.onRequestRedraw=this._onRedrawRequest.event,this._onSelectionChange=this._register(new f.Emitter),this.onSelectionChange=this._onSelectionChange.event,this._onRequestScrollLines=this._register(new f.Emitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._mouseMoveListener=e=>this._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput((()=>{this.hasSelection&&this.clearSelection()})),this._trimListener=this._bufferService.buffer.lines.onTrim((e=>this._handleTrim(e))),this._register(this._bufferService.buffers.onBufferActivate((e=>this._handleBufferActivate(e)))),this.enable(),this._model=new a.SelectionModel(this._bufferService),this._activeSelectionMode=0,this._register((0,h.toDisposable)((()=>{this._removeMouseDownListeners()}))),this._register(this._bufferService.onResize((e=>{e.rowsChanged&&this.clearSelection()})))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])}get selectionText(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";const i=this._bufferService.buffer,s=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";const r=e[0]e.replace(g," "))).join(c.isWindows?"\r\n":"\n")}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._refresh()))),c.isLinux&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:3===this._activeSelectionMode})}_isClickInSelection(e){const t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!!(i&&s&&t)&&this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){const i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!(!i||!s)&&this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){const i=this._linkifier.currentLink?.link?.range;if(i)return this._model.selectionStart=[i.start.x-1,i.start.y-1],this._model.selectionStartLength=(0,d.getRangeLength)(i,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const s=this._getMouseBufferCoords(e);return!!s&&(this._selectWordAt(s,t),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){const t=this._mouseService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=(0,n.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[1];const i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(14*t))}shouldForceSelection(e){return c.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,(2!==e.button||!this.hasSelection)&&0===e.button){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):1===e.detail?this._handleSingleClick(e):2===e.detail?this._handleDoubleClick(e):3===e.detail&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval((()=>this._dragScroll()),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0;const t=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);t&&t.length!==this._model.selectionStart[0]&&0===t.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){const t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return e.altKey&&!(c.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;const t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd)return void this.refresh(!0);2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){const t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const t=this._mouseService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(t&&void 0!==t[0]&&void 0!==t[1]){const e=(0,o.moveToCellSequence)(t[0]-1,t[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(e,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);i?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,i)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.dispose(),this._trimListener=e.activeBuffer.lines.onTrim((e=>this._handleTrim(e)))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){const r=e.loadCell(s,this._workCell).getChars().length;0===this._workCell.getWidth()?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;const r=this._bufferService.buffer,n=r.lines.get(e[1]);if(!n)return;const o=r.translateBufferLineToString(e[1],!1);let a=this._convertViewportColToCharacterIndex(n,e[0]),l=a;const h=e[0]-a;let c=0,d=0,u=0,_=0;if(" "===o.charAt(a)){for(;a>0&&" "===o.charAt(a-1);)a--;for(;l1&&(_+=s-1,l+=s-1);t>0&&a>0&&!this._isCharWordSeparator(n.loadCell(t-1,this._workCell));){n.loadCell(t-1,this._workCell);const e=this._workCell.getChars().length;0===this._workCell.getWidth()?(c++,t--):e>1&&(u+=e-1,a-=e-1),a--,t--}for(;i1&&(_+=e-1,l+=e-1),l++,i++}}l++;let f=a+h-c+u,p=Math.min(this._bufferService.cols,l-a+c+d-u-_);if(t||""!==o.slice(a,l).trim()){if(i&&0===f&&32!==n.getCodePoint(0)){const t=r.lines.get(e[1]-1);if(t&&n.isWrapped&&32!==t.getCodePoint(this._bufferService.cols-1)){const t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){const e=this._bufferService.cols-t.start;f-=e,p+=e}}}if(s&&f+p===this._bufferService.cols&&32!==n.getCodePoint(this._bufferService.cols-1)){const t=r.lines.get(e[1]+1);if(t?.isWrapped&&32!==t.getCodePoint(0)){const t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(p+=t.length)}}return{start:f,length:p}}}_selectWordAt(e,t){const i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){const t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){const t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,d.getRangeLength)(i,this._bufferService.cols)}};t.SelectionService=m,t.SelectionService=m=s([r(3,_.IBufferService),r(4,_.ICoreService),r(5,l.IMouseService),r(6,_.IOptionsService),r(7,l.IRenderService),r(8,l.ICoreBrowserService)],m)},7098:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ILinkProviderService=t.IThemeService=t.ICharacterJoinerService=t.ISelectionService=t.IRenderService=t.IMouseService=t.ICoreBrowserService=t.ICharSizeService=void 0;const s=i(6201);t.ICharSizeService=(0,s.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,s.createDecorator)("CoreBrowserService"),t.IMouseService=(0,s.createDecorator)("MouseService"),t.IRenderService=(0,s.createDecorator)("RenderService"),t.ISelectionService=(0,s.createDecorator)("SelectionService"),t.ICharacterJoinerService=(0,s.createDecorator)("CharacterJoinerService"),t.IThemeService=(0,s.createDecorator)("ThemeService"),t.ILinkProviderService=(0,s.createDecorator)("LinkProviderService")},9078:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeService=void 0;const n=i(7174),o=i(9302),a=i(4103),l=i(7150),h=i(6501),c=i(802),d=a.css.toColor("#ffffff"),u=a.css.toColor("#000000"),_=a.css.toColor("#ffffff"),f=u,p={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117},g=d;let m=class extends l.Disposable{get colors(){return this._colors}constructor(e){super(),this._optionsService=e,this._contrastCache=new n.ColorContrastCache,this._halfContrastCache=new n.ColorContrastCache,this._onChangeColors=this._register(new c.Emitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:d,background:u,cursor:_,cursorAccent:f,selectionForeground:void 0,selectionBackgroundTransparent:p,selectionBackgroundOpaque:a.color.blend(u,p),selectionInactiveBackgroundTransparent:p,selectionInactiveBackgroundOpaque:a.color.blend(u,p),scrollbarSliderBackground:a.color.opacity(d,.2),scrollbarSliderHoverBackground:a.color.opacity(d,.4),scrollbarSliderActiveBackground:a.color.opacity(d,.5),overviewRulerBorder:d,ansi:o.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this._register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",(()=>this._contrastCache.clear()))),this._register(this._optionsService.onSpecificOptionChange("theme",(()=>this._setTheme(this._optionsService.rawOptions.theme))))}_setTheme(e={}){const t=this._colors;if(t.foreground=v(e.foreground,d),t.background=v(e.background,u),t.cursor=a.color.blend(t.background,v(e.cursor,_)),t.cursorAccent=a.color.blend(t.background,v(e.cursorAccent,f)),t.selectionBackgroundTransparent=v(e.selectionBackground,p),t.selectionBackgroundOpaque=a.color.blend(t.background,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundTransparent=v(e.selectionInactiveBackground,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundOpaque=a.color.blend(t.background,t.selectionInactiveBackgroundTransparent),t.selectionForeground=e.selectionForeground?v(e.selectionForeground,a.NULL_COLOR):void 0,t.selectionForeground===a.NULL_COLOR&&(t.selectionForeground=void 0),a.color.isOpaque(t.selectionBackgroundTransparent)){const e=.3;t.selectionBackgroundTransparent=a.color.opacity(t.selectionBackgroundTransparent,e)}if(a.color.isOpaque(t.selectionInactiveBackgroundTransparent)){const e=.3;t.selectionInactiveBackgroundTransparent=a.color.opacity(t.selectionInactiveBackgroundTransparent,e)}if(t.scrollbarSliderBackground=v(e.scrollbarSliderBackground,a.color.opacity(t.foreground,.2)),t.scrollbarSliderHoverBackground=v(e.scrollbarSliderHoverBackground,a.color.opacity(t.foreground,.4)),t.scrollbarSliderActiveBackground=v(e.scrollbarSliderActiveBackground,a.color.opacity(t.foreground,.5)),t.overviewRulerBorder=v(e.overviewRulerBorder,g),t.ansi=o.DEFAULT_ANSI_COLORS.slice(),t.ansi[0]=v(e.black,o.DEFAULT_ANSI_COLORS[0]),t.ansi[1]=v(e.red,o.DEFAULT_ANSI_COLORS[1]),t.ansi[2]=v(e.green,o.DEFAULT_ANSI_COLORS[2]),t.ansi[3]=v(e.yellow,o.DEFAULT_ANSI_COLORS[3]),t.ansi[4]=v(e.blue,o.DEFAULT_ANSI_COLORS[4]),t.ansi[5]=v(e.magenta,o.DEFAULT_ANSI_COLORS[5]),t.ansi[6]=v(e.cyan,o.DEFAULT_ANSI_COLORS[6]),t.ansi[7]=v(e.white,o.DEFAULT_ANSI_COLORS[7]),t.ansi[8]=v(e.brightBlack,o.DEFAULT_ANSI_COLORS[8]),t.ansi[9]=v(e.brightRed,o.DEFAULT_ANSI_COLORS[9]),t.ansi[10]=v(e.brightGreen,o.DEFAULT_ANSI_COLORS[10]),t.ansi[11]=v(e.brightYellow,o.DEFAULT_ANSI_COLORS[11]),t.ansi[12]=v(e.brightBlue,o.DEFAULT_ANSI_COLORS[12]),t.ansi[13]=v(e.brightMagenta,o.DEFAULT_ANSI_COLORS[13]),t.ansi[14]=v(e.brightCyan,o.DEFAULT_ANSI_COLORS[14]),t.ansi[15]=v(e.brightWhite,o.DEFAULT_ANSI_COLORS[15]),e.extendedAnsi){const i=Math.min(t.ansi.length-16,e.extendedAnsi.length);for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;const s=i(7150),r=i(802);class n extends s.Disposable{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this._register(new r.Emitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this._register(new r.Emitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this._register(new r.Emitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;const t=new Array(e);for(let i=0;ithis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+i.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const e=this._length+i.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let s=t-1;s>=0;s--)this.set(e+s+i,this.get(e+s));const s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.clone=function e(t,i=5){if("object"!=typeof t)return t;const s=Array.isArray(t)?[]:{};for(const r in t)s[r]=i<=1?t[r]:t[r]&&e(t[r],i-1);return s}},4103:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.rgba=t.rgb=t.css=t.color=t.channels=t.NULL_COLOR=void 0,t.toPaddedHex=d,t.contrastRatio=u;let i=0,s=0,r=0,n=0;var o,a,l,h,c;function d(e){const t=e.toString(16);return t.length<2?"0"+t:t}function u(e,t){return e>>0},e.toColor=function(t,i,s,r){return{css:e.toCss(t,i,s,r),rgba:e.toRgba(t,i,s,r)}}}(o||(t.channels=o={})),function(e){function t(e,t){return n=Math.round(255*t),[i,s,r]=c.toChannels(e.rgba),{css:o.toCss(i,s,r,n),rgba:o.toRgba(i,s,r,n)}}e.blend=function(e,t){if(n=(255&t.rgba)/255,1===n)return{css:t.css,rgba:t.rgba};const a=t.rgba>>24&255,l=t.rgba>>16&255,h=t.rgba>>8&255,c=e.rgba>>24&255,d=e.rgba>>16&255,u=e.rgba>>8&255;return i=c+Math.round((a-c)*n),s=d+Math.round((l-d)*n),r=u+Math.round((h-u)*n),{css:o.toCss(i,s,r),rgba:o.toRgba(i,s,r)}},e.isOpaque=function(e){return!(255&~e.rgba)},e.ensureContrastRatio=function(e,t,i){const s=c.ensureContrastRatio(e.rgba,t.rgba,i);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},e.opaque=function(e){const t=(255|e.rgba)>>>0;return[i,s,r]=c.toChannels(t),{css:o.toCss(i,s,r),rgba:t}},e.opacity=t,e.multiplyOpacity=function(e,i){return n=255&e.rgba,t(e,n*i/255)},e.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}}(a||(t.color=a={})),function(e){let t,a;try{const e=document.createElement("canvas");e.width=1,e.height=1;const i=e.getContext("2d",{willReadFrequently:!0});i&&(t=i,t.globalCompositeOperation="copy",a=t.createLinearGradient(0,0,1,1))}catch{}e.toColor=function(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),o.toColor(i,s,r);case 5:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),n=parseInt(e.slice(4,5).repeat(2),16),o.toColor(i,s,r,n);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}const l=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(l)return i=parseInt(l[1]),s=parseInt(l[2]),r=parseInt(l[3]),n=Math.round(255*(void 0===l[5]?1:parseFloat(l[5]))),o.toColor(i,s,r,n);if(!t||!a)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=a,t.fillStyle=e,"string"!=typeof t.fillStyle)throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[i,s,r,n]=t.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(i,s,r,n),css:e}}}(l||(t.css=l={})),function(e){function t(e,t,i){const s=e/255,r=t/255,n=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(h||(t.rgb=h={})),function(e){function t(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,l=t>>8&255,c=u(h.relativeLuminance2(o,a,l),h.relativeLuminance2(s,r,n));for(;c0||a>0||l>0);)o-=Math.max(0,Math.ceil(.1*o)),a-=Math.max(0,Math.ceil(.1*a)),l-=Math.max(0,Math.ceil(.1*l)),c=u(h.relativeLuminance2(o,a,l),h.relativeLuminance2(s,r,n));return(o<<24|a<<16|l<<8|255)>>>0}function a(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,l=t>>8&255,c=u(h.relativeLuminance2(o,a,l),h.relativeLuminance2(s,r,n));for(;c>>0}e.blend=function(e,t){if(n=(255&t)/255,1===n)return t;const a=t>>24&255,l=t>>16&255,h=t>>8&255,c=e>>24&255,d=e>>16&255,u=e>>8&255;return i=c+Math.round((a-c)*n),s=d+Math.round((l-d)*n),r=u+Math.round((h-u)*n),o.toRgba(i,s,r)},e.ensureContrastRatio=function(e,i,s){const r=h.relativeLuminance(e>>8),n=h.relativeLuminance(i>>8);if(u(r,n)>8));if(ou(r,h.relativeLuminance(t>>8))?n:t}return n}const o=a(e,i,s),l=u(r,h.relativeLuminance(o>>8));if(lu(r,h.relativeLuminance(n>>8))?o:n}return o}},e.reduceLuminance=t,e.increaseLuminance=a,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]}}(c||(t.rgba=c={}))},5777:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;const s=i(6501),r=i(6025),n=i(7276),o=i(9640),a=i(56),l=i(4071),h=i(7792),c=i(6415),d=i(5746),u=i(5882),_=i(2486),f=i(3562),p=i(8811),g=i(802),m=i(7150);let v=!1;class S extends m.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this._register(new g.Emitter),this._onScroll.event((e=>{this._onScrollApi?.fire(e.position)}))),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(const t in e)this.optionsService.options[t]=e[t]}constructor(e){super(),this._windowsWrappingHeuristics=this._register(new m.MutableDisposable),this._onBinary=this._register(new g.Emitter),this.onBinary=this._onBinary.event,this._onData=this._register(new g.Emitter),this.onData=this._onData.event,this._onLineFeed=this._register(new g.Emitter),this.onLineFeed=this._onLineFeed.event,this._onResize=this._register(new g.Emitter),this.onResize=this._onResize.event,this._onWriteParsed=this._register(new g.Emitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this._register(new g.Emitter),this._instantiationService=new r.InstantiationService,this.optionsService=this._register(new a.OptionsService(e)),this._instantiationService.setService(s.IOptionsService,this.optionsService),this._bufferService=this._register(this._instantiationService.createInstance(o.BufferService)),this._instantiationService.setService(s.IBufferService,this._bufferService),this._logService=this._register(this._instantiationService.createInstance(n.LogService)),this._instantiationService.setService(s.ILogService,this._logService),this.coreService=this._register(this._instantiationService.createInstance(l.CoreService)),this._instantiationService.setService(s.ICoreService,this.coreService),this.coreMouseService=this._register(this._instantiationService.createInstance(h.CoreMouseService)),this._instantiationService.setService(s.ICoreMouseService,this.coreMouseService),this.unicodeService=this._register(this._instantiationService.createInstance(c.UnicodeService)),this._instantiationService.setService(s.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(d.CharsetService),this._instantiationService.setService(s.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(p.OscLinkService),this._instantiationService.setService(s.IOscLinkService,this._oscLinkService),this._inputHandler=this._register(new _.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.coreMouseService,this.unicodeService)),this._register(g.Event.forward(this._inputHandler.onLineFeed,this._onLineFeed)),this._register(this._inputHandler),this._register(g.Event.forward(this._bufferService.onResize,this._onResize)),this._register(g.Event.forward(this.coreService.onData,this._onData)),this._register(g.Event.forward(this.coreService.onBinary,this._onBinary)),this._register(this.coreService.onRequestScrollToBottom((()=>this.scrollToBottom(!0)))),this._register(this.coreService.onUserInput((()=>this._writeBuffer.handleUserInput()))),this._register(this.optionsService.onMultipleOptionChange(["windowsMode","windowsPty"],(()=>this._handleWindowsPtyOptionChange()))),this._register(this._bufferService.onScroll((()=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this._writeBuffer=this._register(new f.WriteBuffer(((e,t)=>this._inputHandler.parse(e,t)))),this._register(g.Event.forward(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=s.LogLevelEnum.WARN&&!v&&(this._logService.warn("writeSync is unreliable and will be removed soon."),v=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,o.MINIMUM_COLS),t=Math.max(t,o.MINIMUM_ROWS),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t){this._bufferService.scrollLines(e,t)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.coreMouseService.reset()}_handleWindowsPtyOptionChange(){let e=!1;const t=this.optionsService.rawOptions.windowsPty;t&&void 0!==t.buildNumber&&void 0!==t.buildNumber?e=!!("conpty"===t.backend&&t.buildNumber<21376):this.optionsService.rawOptions.windowsMode&&(e=!0),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const e=[];e.push(this.onLineFeed(u.updateWindowsModeWrappedState.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},(()=>((0,u.updateWindowsModeWrappedState)(this._bufferService),!1)))),this._windowsWrappingHeuristics.value=(0,m.toDisposable)((()=>{for(const t of e)t.dispose()}))}}}t.CoreTerminal=S},2486:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.InputHandler=t.WindowsOptionsReportType=void 0,t.isValidColorIndex=R;const n=i(3534),o=i(6760),a=i(6717),l=i(7150),h=i(726),c=i(6107),d=i(8938),u=i(3055),_=i(5451),f=i(6501),p=i(6415),g=i(1346),m=i(9823),v=i(8693),S=i(802),b={"(":0,")":1,"*":2,"+":3,"-":1,".":2},C=131072;function y(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var w;!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(w||(t.WindowsOptionsReportType=w={}));let E=0;class D extends l.Disposable{getAttrData(){return this._curAttrData}constructor(e,t,i,s,r,l,d,u,_=new a.EscapeSequenceParser){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=l,this._coreMouseService=d,this._unicodeService=u,this._parser=_,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new h.StringToUtf32,this._utf8Decoder=new h.Utf8ToUtf32,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=c.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=c.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this._register(new S.Emitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this._register(new S.Emitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this._register(new S.Emitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this._register(new S.Emitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this._register(new S.Emitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this._register(new S.Emitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this._register(new S.Emitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this._register(new S.Emitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this._register(new S.Emitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this._register(new S.Emitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this._register(new S.Emitter),this.onScroll=this._onScroll.event,this._onTitleChange=this._register(new S.Emitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this._register(new S.Emitter),this.onColor=this._onColor.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this._register(this._parser),this._dirtyRowTracker=new L(this._bufferService),this._activeBuffer=this._bufferService.buffer,this._register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._parser.setCsiHandlerFallback(((e,t)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(e),params:t.toArray()})})),this._parser.setEscHandlerFallback((e=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(e)})})),this._parser.setExecuteHandlerFallback((e=>{this._logService.debug("Unknown EXECUTE code: ",{code:e})})),this._parser.setOscHandlerFallback(((e,t,i)=>{this._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:i})})),this._parser.setDcsHandlerFallback(((e,t,i)=>{"HOOK"===t&&(i=i.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(e),action:t,payload:i})})),this._parser.setPrintHandler(((e,t,i)=>this.print(e,t,i))),this._parser.registerCsiHandler({final:"@"},(e=>this.insertChars(e))),this._parser.registerCsiHandler({intermediates:" ",final:"@"},(e=>this.scrollLeft(e))),this._parser.registerCsiHandler({final:"A"},(e=>this.cursorUp(e))),this._parser.registerCsiHandler({intermediates:" ",final:"A"},(e=>this.scrollRight(e))),this._parser.registerCsiHandler({final:"B"},(e=>this.cursorDown(e))),this._parser.registerCsiHandler({final:"C"},(e=>this.cursorForward(e))),this._parser.registerCsiHandler({final:"D"},(e=>this.cursorBackward(e))),this._parser.registerCsiHandler({final:"E"},(e=>this.cursorNextLine(e))),this._parser.registerCsiHandler({final:"F"},(e=>this.cursorPrecedingLine(e))),this._parser.registerCsiHandler({final:"G"},(e=>this.cursorCharAbsolute(e))),this._parser.registerCsiHandler({final:"H"},(e=>this.cursorPosition(e))),this._parser.registerCsiHandler({final:"I"},(e=>this.cursorForwardTab(e))),this._parser.registerCsiHandler({final:"J"},(e=>this.eraseInDisplay(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"J"},(e=>this.eraseInDisplay(e,!0))),this._parser.registerCsiHandler({final:"K"},(e=>this.eraseInLine(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"K"},(e=>this.eraseInLine(e,!0))),this._parser.registerCsiHandler({final:"L"},(e=>this.insertLines(e))),this._parser.registerCsiHandler({final:"M"},(e=>this.deleteLines(e))),this._parser.registerCsiHandler({final:"P"},(e=>this.deleteChars(e))),this._parser.registerCsiHandler({final:"S"},(e=>this.scrollUp(e))),this._parser.registerCsiHandler({final:"T"},(e=>this.scrollDown(e))),this._parser.registerCsiHandler({final:"X"},(e=>this.eraseChars(e))),this._parser.registerCsiHandler({final:"Z"},(e=>this.cursorBackwardTab(e))),this._parser.registerCsiHandler({final:"`"},(e=>this.charPosAbsolute(e))),this._parser.registerCsiHandler({final:"a"},(e=>this.hPositionRelative(e))),this._parser.registerCsiHandler({final:"b"},(e=>this.repeatPrecedingCharacter(e))),this._parser.registerCsiHandler({final:"c"},(e=>this.sendDeviceAttributesPrimary(e))),this._parser.registerCsiHandler({prefix:">",final:"c"},(e=>this.sendDeviceAttributesSecondary(e))),this._parser.registerCsiHandler({final:"d"},(e=>this.linePosAbsolute(e))),this._parser.registerCsiHandler({final:"e"},(e=>this.vPositionRelative(e))),this._parser.registerCsiHandler({final:"f"},(e=>this.hVPosition(e))),this._parser.registerCsiHandler({final:"g"},(e=>this.tabClear(e))),this._parser.registerCsiHandler({final:"h"},(e=>this.setMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"h"},(e=>this.setModePrivate(e))),this._parser.registerCsiHandler({final:"l"},(e=>this.resetMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"l"},(e=>this.resetModePrivate(e))),this._parser.registerCsiHandler({final:"m"},(e=>this.charAttributes(e))),this._parser.registerCsiHandler({final:"n"},(e=>this.deviceStatus(e))),this._parser.registerCsiHandler({prefix:"?",final:"n"},(e=>this.deviceStatusPrivate(e))),this._parser.registerCsiHandler({intermediates:"!",final:"p"},(e=>this.softReset(e))),this._parser.registerCsiHandler({intermediates:" ",final:"q"},(e=>this.setCursorStyle(e))),this._parser.registerCsiHandler({final:"r"},(e=>this.setScrollRegion(e))),this._parser.registerCsiHandler({final:"s"},(e=>this.saveCursor(e))),this._parser.registerCsiHandler({final:"t"},(e=>this.windowOptions(e))),this._parser.registerCsiHandler({final:"u"},(e=>this.restoreCursor(e))),this._parser.registerCsiHandler({intermediates:"'",final:"}"},(e=>this.insertColumns(e))),this._parser.registerCsiHandler({intermediates:"'",final:"~"},(e=>this.deleteColumns(e))),this._parser.registerCsiHandler({intermediates:'"',final:"q"},(e=>this.selectProtected(e))),this._parser.registerCsiHandler({intermediates:"$",final:"p"},(e=>this.requestMode(e,!0))),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},(e=>this.requestMode(e,!1))),this._parser.setExecuteHandler(n.C0.BEL,(()=>this.bell())),this._parser.setExecuteHandler(n.C0.LF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.VT,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.FF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.CR,(()=>this.carriageReturn())),this._parser.setExecuteHandler(n.C0.BS,(()=>this.backspace())),this._parser.setExecuteHandler(n.C0.HT,(()=>this.tab())),this._parser.setExecuteHandler(n.C0.SO,(()=>this.shiftOut())),this._parser.setExecuteHandler(n.C0.SI,(()=>this.shiftIn())),this._parser.setExecuteHandler(n.C1.IND,(()=>this.index())),this._parser.setExecuteHandler(n.C1.NEL,(()=>this.nextLine())),this._parser.setExecuteHandler(n.C1.HTS,(()=>this.tabSet())),this._parser.registerOscHandler(0,new g.OscHandler((e=>(this.setTitle(e),this.setIconName(e),!0)))),this._parser.registerOscHandler(1,new g.OscHandler((e=>this.setIconName(e)))),this._parser.registerOscHandler(2,new g.OscHandler((e=>this.setTitle(e)))),this._parser.registerOscHandler(4,new g.OscHandler((e=>this.setOrReportIndexedColor(e)))),this._parser.registerOscHandler(8,new g.OscHandler((e=>this.setHyperlink(e)))),this._parser.registerOscHandler(10,new g.OscHandler((e=>this.setOrReportFgColor(e)))),this._parser.registerOscHandler(11,new g.OscHandler((e=>this.setOrReportBgColor(e)))),this._parser.registerOscHandler(12,new g.OscHandler((e=>this.setOrReportCursorColor(e)))),this._parser.registerOscHandler(104,new g.OscHandler((e=>this.restoreIndexedColor(e)))),this._parser.registerOscHandler(110,new g.OscHandler((e=>this.restoreFgColor(e)))),this._parser.registerOscHandler(111,new g.OscHandler((e=>this.restoreBgColor(e)))),this._parser.registerOscHandler(112,new g.OscHandler((e=>this.restoreCursorColor(e)))),this._parser.registerEscHandler({final:"7"},(()=>this.saveCursor())),this._parser.registerEscHandler({final:"8"},(()=>this.restoreCursor())),this._parser.registerEscHandler({final:"D"},(()=>this.index())),this._parser.registerEscHandler({final:"E"},(()=>this.nextLine())),this._parser.registerEscHandler({final:"H"},(()=>this.tabSet())),this._parser.registerEscHandler({final:"M"},(()=>this.reverseIndex())),this._parser.registerEscHandler({final:"="},(()=>this.keypadApplicationMode())),this._parser.registerEscHandler({final:">"},(()=>this.keypadNumericMode())),this._parser.registerEscHandler({final:"c"},(()=>this.fullReset())),this._parser.registerEscHandler({final:"n"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"o"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"|"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"}"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"~"},(()=>this.setgLevel(1))),this._parser.registerEscHandler({intermediates:"%",final:"@"},(()=>this.selectDefaultCharset())),this._parser.registerEscHandler({intermediates:"%",final:"G"},(()=>this.selectDefaultCharset()));for(const e in o.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:e},(()=>this.selectCharset("("+e))),this._parser.registerEscHandler({intermediates:")",final:e},(()=>this.selectCharset(")"+e))),this._parser.registerEscHandler({intermediates:"*",final:e},(()=>this.selectCharset("*"+e))),this._parser.registerEscHandler({intermediates:"+",final:e},(()=>this.selectCharset("+"+e))),this._parser.registerEscHandler({intermediates:"-",final:e},(()=>this.selectCharset("-"+e))),this._parser.registerEscHandler({intermediates:".",final:e},(()=>this.selectCharset("."+e))),this._parser.registerEscHandler({intermediates:"/",final:e},(()=>this.selectCharset("/"+e)));this._parser.registerEscHandler({intermediates:"#",final:"8"},(()=>this.screenAlignmentPattern())),this._parser.setErrorHandler((e=>(this._logService.error("Parsing error: ",e),e))),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new m.DcsHandler(((e,t)=>this.requestStatusString(e,t))))}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){this._logService.logLevel<=f.LogLevelEnum.WARN&&Promise.race([e,new Promise(((e,t)=>setTimeout((()=>t("#SLOW_TIMEOUT")),5e3)))]).catch((e=>{if("#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")}))}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,n=0;const o=this._parseStack.paused;if(o){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>C&&(n=this._parseStack.position+C)}if(this._logService.logLevel<=f.LogLevelEnum.DEBUG&&this._logService.debug("parsing data "+("string"==typeof e?` "${e}"`:` "${Array.prototype.map.call(e,(e=>String.fromCharCode(e))).join("")}"`)),this._logService.logLevel===f.LogLevelEnum.TRACE&&this._logService.trace("parsing data (codes)","string"==typeof e?e.split("").map((e=>e.charCodeAt(0))):e),this._parseBuffer.lengthC)for(let t=n;t0&&2===f.getWidth(this._activeBuffer.x-1)&&f.setCellFromCodepoint(this._activeBuffer.x-1,0,1,_);let g=this._parser.precedingJoinState;for(let m=t;ma)if(l){const e=f;let t=this._activeBuffer.x-v;for(this._activeBuffer.x=v,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),f=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),v>0&&f instanceof c.BufferLine&&f.copyCellsFrom(e,t,0,v,!1);t=0;)f.setCellFromCodepoint(this._activeBuffer.x++,0,0,_)}else if(u&&(f.insertCells(this._activeBuffer.x,r-v,this._activeBuffer.getNullCell(_)),2===f.getWidth(a-1)&&f.setCellFromCodepoint(a-1,d.NULL_CELL_CODE,d.NULL_CELL_WIDTH,_)),f.setCellFromCodepoint(this._activeBuffer.x++,s,r,_),r>0)for(;--r;)f.setCellFromCodepoint(this._activeBuffer.x++,0,0,_)}this._parser.precedingJoinState=g,this._activeBuffer.x0&&0===f.getWidth(this._activeBuffer.x)&&!f.hasContent(this._activeBuffer.x)&&f.setCellFromCodepoint(this._activeBuffer.x,0,1,_),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,(e=>!y(e.params[0],this._optionsService.rawOptions.windowOptions)||t(e)))}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new m.DcsHandler(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new g.OscHandler(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){const t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){const t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){const t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){const t=e.params[0];return 1===t&&(this._curAttrData.bg|=536870912),2!==t&&0!==t||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){const n=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);n.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),r),s&&(n.isWrapped=!1)}_resetBufferLine(e,t=!1){const i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){let i;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);i=this._bufferService.cols&&(this._activeBuffer.lines.get(i+1).isWrapped=!1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:if(this._optionsService.rawOptions.scrollOnEraseInDisplay){for(i=this._bufferService.rows,this._dirtyRowTracker.markRangeDirty(0,i-1);i--;){const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+i);if(e?.getTrimmedLength())break}for(;i>=0;i--)this._bufferService.scroll(this._eraseAttrData())}else{for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0)}break;case 3:const e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0))}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let l=a;for(let e=1;e0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent(n.C0.ESC+"[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent(n.C0.ESC+"[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent(n.C0.ESC+"[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent(n.C0.ESC+"[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent(n.C0.ESC+"[>83;40003;0c")),!0}_is(e){return 0===(this._optionsService.rawOptions.termName+"").indexOf(e)}setMode(e){for(let t=0;te?1:2,_=e.params[0];return f=_,p=t?2===_?4:4===_?u(o.modes.insertMode):12===_?3:20===_?u(d.convertEol):0:1===_?u(i.applicationCursorKeys):3===_?d.windowOptions.setWinLines?80===l?2:132===l?1:0:0:6===_?u(i.origin):7===_?u(i.wraparound):8===_?3:9===_?u("X10"===s):12===_?u(d.cursorBlink):25===_?u(!o.isCursorHidden):45===_?u(i.reverseWraparound):66===_?u(i.applicationKeypad):67===_?4:1e3===_?u("VT200"===s):1002===_?u("DRAG"===s):1003===_?u("ANY"===s):1004===_?u(i.sendFocus):1005===_?4:1006===_?u("SGR"===r):1015===_?4:1016===_?u("SGR_PIXELS"===r):1048===_?1:47===_||1047===_||1049===_?u(h===c):2004===_?u(i.bracketedPasteMode):2026===_?u(i.synchronizedOutput):0,o.triggerDataEvent(`${n.C0.ESC}[${t?"":"?"}${f};${p}$y`),!0;var f,p}_updateAttrColor(e,t,i,s,r){return 2===t?(e|=50331648,e&=-16777216,e|=_.AttributeData.fromColorRGB([i,s,r])):5===t&&(e&=-50331904,e|=33554432|255&i),e}_extractColor(e,t,i){const s=[0,0,-1,0,0,0];let r=0,n=0;do{if(s[n+r]=e.params[t+n],e.hasSubParams(t+n)){const i=e.getSubParams(t+n);let o=0;do{5===s[1]&&(r=1),s[n+o+1+r]=i[o]}while(++o=2||2===s[1]&&n+r>=5)break;s[1]&&(r=1)}while(++n+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=c.DEFAULT_ATTR_DATA.fg,e.bg=c.DEFAULT_ATTR_DATA.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(1===e.length&&0===e.params[0])return this._processSGR0(this._curAttrData),!0;const t=e.length;let i;const s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-50331904,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-50331904,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-50331904,s.fg|=16777224|i-90):i>=100&&i<=107?(s.bg&=-50331904,s.bg|=16777224|i-100):0===i?this._processSGR0(s):1===i?s.fg|=134217728:3===i?s.bg|=67108864:4===i?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):5===i?s.fg|=536870912:7===i?s.fg|=67108864:8===i?s.fg|=1073741824:9===i?s.fg|=2147483648:2===i?s.bg|=134217728:21===i?this._processUnderline(2,s):22===i?(s.fg&=-134217729,s.bg&=-134217729):23===i?s.bg&=-67108865:24===i?(s.fg&=-268435457,this._processUnderline(0,s)):25===i?s.fg&=-536870913:27===i?s.fg&=-67108865:28===i?s.fg&=-1073741825:29===i?s.fg&=2147483647:39===i?(s.fg&=-67108864,s.fg|=16777215&c.DEFAULT_ATTR_DATA.fg):49===i?(s.bg&=-67108864,s.bg|=16777215&c.DEFAULT_ATTR_DATA.bg):38===i||48===i||58===i?r+=this._extractColor(e,r,s):53===i?s.bg|=1073741824:55===i?s.bg&=-1073741825:59===i?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):100===i?(s.fg&=-67108864,s.fg|=16777215&c.DEFAULT_ATTR_DATA.fg,s.bg&=-67108864,s.bg|=16777215&c.DEFAULT_ATTR_DATA.bg):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent(`${n.C0.ESC}[0n`);break;case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[${e};${t}R`)}return!0}deviceStatusPrivate(e){if(6===e.params[0]){const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[?${e};${t}R`)}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=c.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){const t=0===e.length?1:e.params[0];if(0===t)this._coreService.decPrivateModes.cursorStyle=void 0,this._coreService.decPrivateModes.cursorBlink=void 0;else{switch(t){case 1:case 2:this._coreService.decPrivateModes.cursorStyle="block";break;case 3:case 4:this._coreService.decPrivateModes.cursorStyle="underline";break;case 5:case 6:this._coreService.decPrivateModes.cursorStyle="bar"}const e=t%2==1;this._coreService.decPrivateModes.cursorBlink=e}return!0}setScrollRegion(e){const t=e.params[0]||1;let i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||0===i)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!y(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;const t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(w.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(w.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`${n.C0.ESC}[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,!0}restoreCursor(e){return this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg,this._charsetService.charset=this._savedCharset,this._activeBuffer.savedCharset&&(this._charsetService.charset=this._activeBuffer.savedCharset),this._restrictCursor(),!0}setTitle(e){return this._windowTitle=e,this._onTitleChange.fire(e),!0}setIconName(e){return this._iconName=e,!0}setOrReportIndexedColor(e){const t=[],i=e.split(";");for(;i.length>1;){const e=i.shift(),s=i.shift();if(/^\d+$/.exec(e)){const i=parseInt(e);if(R(i))if("?"===s)t.push({type:0,index:i});else{const e=(0,v.parseColor)(s);e&&t.push({type:1,index:i,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){const t=e.indexOf(";");if(-1===t)return!0;const i=e.slice(0,t).trim(),s=e.slice(t+1);return s?this._createHyperlink(i,s):!i.trim()&&this._finishHyperlink()}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();const i=e.split(":");let s;const r=i.findIndex((e=>e.startsWith("id=")));return-1!==r&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){const i=e.split(";");for(let e=0;e=this._specialColors.length);++e,++t)if("?"===i[e])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{const s=(0,v.parseColor)(i[e]);s&&this._onColor.fire([{type:1,index:this._specialColors[t],color:s}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;const t=[],i=e.split(";");for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=c.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=c.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){const e=new u.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${n.C0.ESC}${e}${n.C0.ESC}\\`),!0))('"q'===e?`P1$r${this._curAttrData.isProtected()?1:0}"q`:'"p'===e?'P1$r61;1"p':"r"===e?`P1$r${i.scrollTop+1};${i.scrollBottom+1}r`:"m"===e?"P1$r0m":" q"===e?`P1$r${{block:2,underline:4,bar:6}[s.cursorStyle]-(s.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}}t.InputHandler=D;let L=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(E=e,e=t,t=E),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function R(e){return 0<=e&&e<256}L=s([r(0,f.IBufferService)],L)},7710:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FourKeyMap=t.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}}t.TwoKeyMap=i,t.FourKeyMap=class{constructor(){this._data=new i}set(e,t,s,r,n){this._data.get(e,t)||this._data.set(e,t,new i),this._data.get(e,t).set(s,r,n)}get(e,t,i,s){return this._data.get(e,t)?.get(i,s)}clear(){this._data.clear()}}},701:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.isChromeOS=t.isLinux=t.isWindows=t.isIphone=t.isIpad=t.isMac=t.isSafari=t.isLegacyEdge=t.isFirefox=t.isNode=void 0,t.getSafariVersion=function(){if(!t.isSafari)return 0;const e=i.match(/Version\/(\d+)/);return null===e||e.length<2?0:parseInt(e[1])},t.isNode="undefined"!=typeof process&&"title"in process;const i=t.isNode?"node":navigator.userAgent,s=t.isNode?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),t.isIpad="iPad"===s,t.isIphone="iPhone"===s,t.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),t.isLinux=s.indexOf("Linux")>=0,t.isChromeOS=/\bCrOS\b/.test(i)},3087:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SortedList=void 0;const s=i(6168);let r=0;t.SortedList=class{constructor(e){this._getKey=e,this._array=[],this._insertedValues=[],this._flushInsertedTask=new s.IdleTaskQueue,this._isFlushingInserted=!1,this._deletedIndices=[],this._flushDeletedTask=new s.IdleTaskQueue,this._isFlushingDeleted=!1}clear(){this._array.length=0,this._insertedValues.length=0,this._flushInsertedTask.clear(),this._isFlushingInserted=!1,this._deletedIndices.length=0,this._flushDeletedTask.clear(),this._isFlushingDeleted=!1}insert(e){this._flushCleanupDeleted(),0===this._insertedValues.length&&this._flushInsertedTask.enqueue((()=>this._flushInserted())),this._insertedValues.push(e)}_flushInserted(){const e=this._insertedValues.sort(((e,t)=>this._getKey(e)-this._getKey(t)));let t=0,i=0;const s=new Array(this._array.length+this._insertedValues.length);for(let r=0;r=this._array.length||this._getKey(e[t])<=this._getKey(this._array[i])?(s[r]=e[t],t++):s[r]=this._array[i++];this._array=s,this._insertedValues.length=0}_flushCleanupInserted(){!this._isFlushingInserted&&this._insertedValues.length>0&&this._flushInsertedTask.flush()}delete(e){if(this._flushCleanupInserted(),0===this._array.length)return!1;const t=this._getKey(e);if(void 0===t)return!1;if(r=this._search(t),-1===r)return!1;if(this._getKey(this._array[r])!==t)return!1;do{if(this._array[r]===e)return 0===this._deletedIndices.length&&this._flushDeletedTask.enqueue((()=>this._flushDeleted())),this._deletedIndices.push(r),!0}while(++re-t));let t=0;const i=new Array(this._array.length-e.length);let s=0;for(let r=0;r0&&this._flushDeletedTask.flush()}*getKeyIterator(e){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),0!==this._array.length&&(r=this._search(e),!(r<0||r>=this._array.length)&&this._getKey(this._array[r])===e))do{yield this._array[r]}while(++r=this._array.length)&&this._getKey(this._array[r])===e))do{t(this._array[r])}while(++r=t;){let s=t+i>>1;const r=this._getKey(this._array[s]);if(r>e)i=s-1;else{if(!(r0&&this._getKey(this._array[s-1])===e;)s--;return s}t=s+1}}return t}}},6168:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DebouncedIdleTask=t.IdleTaskQueue=t.PriorityTaskQueue=void 0;const s=i(701);class r{constructor(){this._tasks=[],this._i=0}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ir)return s-t<-20&&console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),void this._start();s=r}this.clear()}}class n extends r{_requestCallback(e){return setTimeout((()=>e(this._createDeadline(16))))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){const t=performance.now()+e;return{timeRemaining:()=>Math.max(0,t-performance.now())}}}t.PriorityTaskQueue=n,t.IdleTaskQueue=!s.isNode&&"requestIdleCallback"in window?class extends r{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}}:n,t.DebouncedIdleTask=class{constructor(){this._queue=new t.IdleTaskQueue}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}}},5882:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=function(e){const t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),i=t?.get(e.cols-1),r=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);r&&i&&(r.isWrapped=i[s.CHAR_DATA_CODE_INDEX]!==s.NULL_CELL_CODE&&i[s.CHAR_DATA_CODE_INDEX]!==s.WHITESPACE_CELL_CODE)};const s=i(8938)},5451:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(e){return[e>>>16&255,e>>>8&255,255&e]}static fromColorRGB(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]}clone(){const e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return!(50331648&~this.fg)}isBgRGB(){return!(50331648&~this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return!(50331648&this.fg)}isBgDefault(){return!(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&~this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}t.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}t.ExtendedAttrs=s},1073:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Buffer=t.MAX_BUFFER_SIZE=void 0;const s=i(5639),r=i(6168),n=i(5451),o=i(6107),a=i(732),l=i(3055),h=i(8938),c=i(8158),d=i(6760);t.MAX_BUFFER_SIZE=4294967295,t.Buffer=class{constructor(e,t,i){this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=o.DEFAULT_ATTR_DATA.clone(),this.savedCharset=d.DEFAULT_CHARSET,this.markers=[],this._nullCell=l.CellData.fromCharData([0,h.NULL_CELL_CHAR,h.NULL_CELL_WIDTH,h.NULL_CELL_CODE]),this._whitespaceCell=l.CellData.fromCharData([0,h.WHITESPACE_CELL_CHAR,h.WHITESPACE_CELL_WIDTH,h.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupQueue=new r.IdleTaskQueue,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,t){return new o.BufferLine(this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(0===this.lines.length){void 0===e&&(e=o.DEFAULT_ATTR_DATA);let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){const i=this.getNullCell(o.DEFAULT_ATTR_DATA);let s=0;const r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+n+1?(this.ybase--,n++,this.ydisp>0&&this.ydisp--):this.lines.push(new o.BufferLine(e,i)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),n&&(this.y+=n),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue((()=>this._batchedMemoryCleanup())))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&"conpty"===e.backend&&e.buildNumber>=21376:this._hasScrollback&&!this._optionsService.rawOptions.windowsMode}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){const i=this._optionsService.rawOptions.reflowCursorLine,s=(0,a.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(o.DEFAULT_ATTR_DATA),i);if(s.length>0){const i=(0,a.reflowLargerCreateNewLayout)(this.lines,s);(0,a.reflowLargerApplyNewLayout)(this.lines,i.layout),this._reflowLargerAdjustViewport(e,t,i.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){const s=this.getNullCell(o.DEFAULT_ATTR_DATA);let r=i;for(;r-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;l--){let h=this.lines.get(l);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;const c=[h];for(;h.isWrapped&&l>0;)h=this.lines.get(--l),c.unshift(h);if(!i){const e=this.ybase+this.y;if(e>=l&&e0&&(r.push({start:l+c.length+n,newLines:p}),n+=p.length),c.push(...p);let g=u.length-1,m=u[g];0===m&&(g--,m=u[g]);let v=c.length-_-1,S=d;for(;v>=0;){const e=Math.min(S,m);if(void 0===c[g])break;if(c[g].copyCellsFrom(c[v],S-e,m-e,e,!0),m-=e,0===m&&(g--,m=u[g]),S-=e,0===S){v--;const e=Math.max(v,0);S=(0,a.getWrappedLineTrimmedLength)(c,e,this._cols)}}for(let t=0;t0;)0===this.ybase?this.y0){const e=[],t=[];for(let e=0;e=0;h--)if(a&&a.start>s+l){for(let e=a.newLines.length-1;e>=0;e--)this.lines.set(h--,a.newLines[e]);h++,e.push({index:s+1,amount:a.newLines.length}),l+=a.newLines.length,a=r[++o]}else this.lines.set(h,t[s--]);let h=0;for(let t=e.length-1;t>=0;t--)e[t].index+=h,this.lines.onInsertEmitter.fire(e[t]),h+=e[t].amount;const c=Math.max(0,i+n-this.lines.maxLength);c>0&&this.lines.onTrimEmitter.fire(c)}}translateBufferLineToString(e,t,i=0,s){const r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(null==e&&(e=this.x);!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()}))),t.register(this.lines.onInsert((e=>{t.line>=e.index&&(t.line+=e.amount)}))),t.register(this.lines.onDelete((e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)}))),t.register(t.onDispose((()=>this._removeMarker(t)))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}},6107:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;const s=i(5451),r=i(3055),n=i(8938),o=i(726);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);let a=0;class l{constructor(e,t,i=!1){this.isWrapped=i,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*e);const s=t||r.CellData.fromCharData([0,n.NULL_CELL_CHAR,n.NULL_CELL_WIDTH,n.NULL_CELL_CODE]);for(let t=0;t>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):i]}set(e,t){this._data[3*e+1]=t[n.CHAR_DATA_ATTR_INDEX],t[n.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[n.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[n.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[n.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(e){return this._data[3*e+0]>>22}hasWidth(e){return 12582912&this._data[3*e+0]}getFg(e){return this._data[3*e+1]}getBg(e){return this._data[3*e+2]}hasContent(e){return 4194303&this._data[3*e+0]}getCodePoint(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t}isCombined(e){return 2097152&this._data[3*e+0]}getString(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,o.stringFromCodePoint)(2097151&t):""}isProtected(e){return 536870912&this._data[3*e+2]}loadCell(e,t){return a=3*e,t.content=this._data[a+0],t.fg=this._data[a+1],t.bg=this._data[a+2],2097152&t.content&&(t.combinedData=this._combined[e]),268435456&t.bg&&(t.extended=this._extendedAttrs[e]),t}setCell(e,t){2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg}setCellFromCodepoint(e,t,i,s){268435456&s.bg&&(this._extendedAttrs[e]=s.extended),this._data[3*e+0]=t|i<<22,this._data[3*e+1]=s.fg,this._data[3*e+2]=s.bg}addCodepointToCell(e,t,i){let s=this._data[3*e+0];2097152&s?this._combined[e]+=(0,o.stringFromCodePoint)(t):2097151&s?(this._combined[e]=(0,o.stringFromCodePoint)(2097151&s)+(0,o.stringFromCodePoint)(t),s&=-2097152,s|=2097152):s=t|1<<22,i&&(s&=-12582913,s|=i<<22),this._data[3*e+0]=s}insertCells(e,t,i){if((e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodepoint(e-1,0,1,i),t=0;--i)this.setCell(e+t+i,this.loadCell(e+i,s));for(let s=0;sthis.length){if(this._data.buffer.byteLength>=4*i)this._data=new Uint32Array(this._data.buffer,0,i);else{const e=new Uint32Array(i);e.set(this._data),this._data=e}for(let i=this.length;i=e&&delete this._combined[s]}const s=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[i]}}return this.length=e,4*i*2=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(4194303&this._data[3*e+0]||50331648&this._data[3*e+2])return e+(this._data[3*e+0]>>22);return 0}copyCellsFrom(e,t,i,s,r){const n=e._data;if(r)for(let r=s-1;r>=0;r--){for(let e=0;e<3;e++)this._data[3*(i+r)+e]=n[3*(t+r)+e];268435456&n[3*(t+r)+2]&&(this._extendedAttrs[i+r]=e._extendedAttrs[t+r])}else for(let r=0;r=t&&(this._combined[r-t+i]=e._combined[r])}}translateToString(e,t,i,s){t=t??0,i=i??this.length,e&&(i=Math.min(i,this.getTrimmedLength())),s&&(s.length=0);let r="";for(;t>22||1}return s&&s.push(t),r}}t.BufferLine=l},9384:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},732:(e,t)=>{function i(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();const s=!e[t].hasContent(i-1)&&1===e[t].getWidth(i-1),r=2===e[t+1].getWidth(0);return s&&r?i-1:i}Object.defineProperty(t,"__esModule",{value:!0}),t.reflowLargerGetLinesToRemove=function(e,t,s,r,n,o){const a=[];for(let l=0;l=l&&r0&&(e>u||0===d[e].getTrimmedLength());e--)g++;g>0&&(a.push(l+d.length-g),a.push(g)),l+=d.length-1}return a},t.reflowLargerCreateNewLayout=function(e,t){const i=[];let s=0,r=t[s],n=0;for(let o=0;oi(e,r,t))).reduce(((e,t)=>e+t));let o=0,a=0,l=0;for(;lh&&(o-=h,a++);const c=2===e[a].getWidth(o-1);c&&o--;const d=c?s-1:s;r.push(d),l+=d}return r},t.getWrappedLineTrimmedLength=i},4097:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;const s=i(7150),r=i(1073),n=i(802);class o extends s.Disposable{constructor(e,t){super(),this._optionsService=e,this._bufferService=t,this._onBufferActivate=this._register(new n.Emitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this._register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.resize(this._bufferService.cols,this._bufferService.rows)))),this._register(this._optionsService.onSpecificOptionChange("tabStopWidth",(()=>this.setupTabStops())))}reset(){this._normal=new r.Buffer(!0,this._optionsService,this._bufferService),this._normal.fillViewportRows(),this._alt=new r.Buffer(!1,this._optionsService,this._bufferService),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}}t.BufferSet=o},3055:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;const s=i(726),r=i(8938),n=i(5451);class o extends n.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new n.ExtendedAttrs,this.combinedData=""}static fromCharData(e){const t=new o;return t.setFromCharData(e),t}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(e){this.fg=e[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let t=!1;if(e[r.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[r.CHAR_DATA_CHAR_INDEX].length){const i=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|e[r.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[r.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.CellData=o},8938:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_EXT=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=0,t.DEFAULT_ATTR=t.DEFAULT_COLOR<<9|256,t.DEFAULT_EXT=0,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},8158:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;const s=i(802),r=i(7150);class n{get id(){return this._id}constructor(e){this.line=e,this.isDisposed=!1,this._disposables=[],this._id=n._nextId++,this._onDispose=this.register(new s.Emitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,r.dispose)(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}}t.Marker=n,n._nextId=1},6760:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},3534:(e,t)=>{var i,s,r;Object.defineProperty(t,"__esModule",{value:!0}),t.C1_ESCAPED=t.C1=t.C0=void 0,function(e){e.NUL="\0",e.SOH="",e.STX="",e.ETX="",e.EOT="",e.ENQ="",e.ACK="",e.BEL="",e.BS="\b",e.HT="\t",e.LF="\n",e.VT="\v",e.FF="\f",e.CR="\r",e.SO="",e.SI="",e.DLE="",e.DC1="",e.DC2="",e.DC3="",e.DC4="",e.NAK="",e.SYN="",e.ETB="",e.CAN="",e.EM="",e.SUB="",e.ESC="",e.FS="",e.GS="",e.RS="",e.US="",e.SP=" ",e.DEL=""}(i||(t.C0=i={})),function(e){e.PAD="€",e.HOP="",e.BPH="‚",e.NBH="ƒ",e.IND="„",e.NEL="…",e.SSA="†",e.ESA="‡",e.HTS="ˆ",e.HTJ="‰",e.VTS="Š",e.PLD="‹",e.PLU="Œ",e.RI="",e.SS2="Ž",e.SS3="",e.DCS="",e.PU1="‘",e.PU2="’",e.STS="“",e.CCH="”",e.MW="•",e.SPA="–",e.EPA="—",e.SOS="˜",e.SGCI="™",e.SCI="š",e.CSI="›",e.ST="œ",e.OSC="",e.PM="ž",e.APC="Ÿ"}(s||(t.C1=s={})),function(e){e.ST=`${i.ESC}\\`}(r||(t.C1_ESCAPED=r={}))},706:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=function(e,t,i,n){const o={type:0,cancel:!1,key:void 0},a=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A":"UIKeyInputLeftArrow"===e.key?o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D":"UIKeyInputRightArrow"===e.key?o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C":"UIKeyInputDownArrow"===e.key&&(o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B");break;case 8:o.key=e.ctrlKey?"\b":s.C0.DEL,e.altKey&&(o.key=s.C0.ESC+o.key);break;case 9:if(e.shiftKey){o.key=s.C0.ESC+"[Z";break}o.key=s.C0.HT,o.cancel=!0;break;case 13:o.key=e.altKey?s.C0.ESC+s.C0.CR:s.C0.CR,o.cancel=!0;break;case 27:o.key=s.C0.ESC,e.altKey&&(o.key=s.C0.ESC+s.C0.ESC),o.cancel=!0;break;case 37:if(e.metaKey)break;o.key=a?s.C0.ESC+"[1;"+(a+1)+"D":t?s.C0.ESC+"OD":s.C0.ESC+"[D";break;case 39:if(e.metaKey)break;o.key=a?s.C0.ESC+"[1;"+(a+1)+"C":t?s.C0.ESC+"OC":s.C0.ESC+"[C";break;case 38:if(e.metaKey)break;o.key=a?s.C0.ESC+"[1;"+(a+1)+"A":t?s.C0.ESC+"OA":s.C0.ESC+"[A";break;case 40:if(e.metaKey)break;o.key=a?s.C0.ESC+"[1;"+(a+1)+"B":t?s.C0.ESC+"OB":s.C0.ESC+"[B";break;case 45:e.shiftKey||e.ctrlKey||(o.key=s.C0.ESC+"[2~");break;case 46:o.key=a?s.C0.ESC+"[3;"+(a+1)+"~":s.C0.ESC+"[3~";break;case 36:o.key=a?s.C0.ESC+"[1;"+(a+1)+"H":t?s.C0.ESC+"OH":s.C0.ESC+"[H";break;case 35:o.key=a?s.C0.ESC+"[1;"+(a+1)+"F":t?s.C0.ESC+"OF":s.C0.ESC+"[F";break;case 33:e.shiftKey?o.type=2:e.ctrlKey?o.key=s.C0.ESC+"[5;"+(a+1)+"~":o.key=s.C0.ESC+"[5~";break;case 34:e.shiftKey?o.type=3:e.ctrlKey?o.key=s.C0.ESC+"[6;"+(a+1)+"~":o.key=s.C0.ESC+"[6~";break;case 112:o.key=a?s.C0.ESC+"[1;"+(a+1)+"P":s.C0.ESC+"OP";break;case 113:o.key=a?s.C0.ESC+"[1;"+(a+1)+"Q":s.C0.ESC+"OQ";break;case 114:o.key=a?s.C0.ESC+"[1;"+(a+1)+"R":s.C0.ESC+"OR";break;case 115:o.key=a?s.C0.ESC+"[1;"+(a+1)+"S":s.C0.ESC+"OS";break;case 116:o.key=a?s.C0.ESC+"[15;"+(a+1)+"~":s.C0.ESC+"[15~";break;case 117:o.key=a?s.C0.ESC+"[17;"+(a+1)+"~":s.C0.ESC+"[17~";break;case 118:o.key=a?s.C0.ESC+"[18;"+(a+1)+"~":s.C0.ESC+"[18~";break;case 119:o.key=a?s.C0.ESC+"[19;"+(a+1)+"~":s.C0.ESC+"[19~";break;case 120:o.key=a?s.C0.ESC+"[20;"+(a+1)+"~":s.C0.ESC+"[20~";break;case 121:o.key=a?s.C0.ESC+"[21;"+(a+1)+"~":s.C0.ESC+"[21~";break;case 122:o.key=a?s.C0.ESC+"[23;"+(a+1)+"~":s.C0.ESC+"[23~";break;case 123:o.key=a?s.C0.ESC+"[24;"+(a+1)+"~":s.C0.ESC+"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(i&&!n||!e.altKey||e.metaKey)!i||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey?e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length?o.key=e.key:e.key&&e.ctrlKey&&("_"===e.key&&(o.key=s.C0.US),"@"===e.key&&(o.key=s.C0.NUL)):65===e.keyCode&&(o.type=1);else{const t=r[e.keyCode],i=t?.[e.shiftKey?1:0];if(i)o.key=s.C0.ESC+i;else if(e.keyCode>=65&&e.keyCode<=90){const t=e.ctrlKey?e.keyCode-64:e.keyCode+32;let i=String.fromCharCode(t);e.shiftKey&&(i=i.toUpperCase()),o.key=s.C0.ESC+i}else if(32===e.keyCode)o.key=s.C0.ESC+(e.ctrlKey?s.C0.NUL:" ");else if("Dead"===e.key&&e.code.startsWith("Key")){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),o.key=s.C0.ESC+t,o.cancel=!0}}else e.keyCode>=65&&e.keyCode<=90?o.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?o.key=s.C0.NUL:e.keyCode>=51&&e.keyCode<=55?o.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?o.key=s.C0.DEL:219===e.keyCode?o.key=s.C0.ESC:220===e.keyCode?o.key=s.C0.FS:221===e.keyCode&&(o.key=s.C0.GS)}return o};const s=i(3534),r={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']}},726:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t=0,i=e.length){let s="";for(let r=t;r65535?(t-=65536,s+=String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):s+=String.fromCharCode(t)}return s},t.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){const i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=e.charCodeAt(r++);56320<=i&&i<=57343?t[s++]=1024*(this._interim-55296)+i-56320+65536:(t[s++]=this._interim,t[s++]=i),this._interim=0}for(let n=r;n=i)return this._interim=r,s;const o=e.charCodeAt(n);56320<=o&&o<=57343?t[s++]=1024*(r-55296)+o-56320+65536:(t[s++]=r,t[s++]=o)}else 65279!==r&&(t[s++]=r)}return s}},t.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){const i=e.length;if(!i)return 0;let s,r,n,o,a=0,l=0,h=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let n,o=0;for(;(n=63&this.interim[++o])&&o<4;)r<<=6,r|=n;const l=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,c=l-o;for(;h=i)return 0;if(n=e[h++],128!=(192&n)){h--,s=!0;break}this.interim[o++]=n,r<<=6,r|=63&n}s||(2===l?r<128?h--:t[a++]=r:3===l?r<2048||r>=55296&&r<=57343||65279===r||(t[a++]=r):r<65536||r>1114111||(t[a++]=r)),this.interim.fill(0)}const c=i-4;let d=h;for(;d=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(l=(31&s)<<6|63&r,l<128){d--;continue}t[a++]=l}else if(224==(240&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(l=(15&s)<<12|(63&r)<<6|63&n,l<2048||l>=55296&&l<=57343||65279===l)continue;t[a++]=l}else if(240==(248&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=n,a;if(o=e[d++],128!=(192&o)){d--;continue}if(l=(7&s)<<18|(63&r)<<12|(63&n)<<6|63&o,l<65536||l>1114111)continue;t[a++]=l}}return a}}},7428:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;const s=i(6415),r=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],n=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let o;t.UnicodeV6=class{constructor(){if(this.version="6",!o){o=new Uint8Array(65536),o.fill(1),o[0]=0,o.fill(0,1,32),o.fill(0,127,160),o.fill(2,4352,4448),o[9001]=2,o[9002]=2,o.fill(2,11904,42192),o[12351]=1,o.fill(2,44032,55204),o.fill(2,63744,64256),o.fill(2,65040,65050),o.fill(2,65072,65136),o.fill(2,65280,65377),o.fill(2,65504,65511);for(let e=0;et[r][1])return!1;for(;r>=s;)if(i=s+r>>1,e>t[i][1])s=i+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let i=this.wcwidth(e),r=0===i&&0!==t;if(r){const e=s.UnicodeService.extractWidth(t);0===e?r=!1:e>i&&(i=e)}return s.UnicodeService.createPropertyValue(0,i,r)}}},3562:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;const s=i(7150),r=i(802);class n extends s.Disposable{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._onWriteParsed=this._register(new r.Emitter),this.onWriteParsed=this._onWriteParsed.event}handleUserInput(){this._didUserInput=!0}writeSync(e,t){if(void 0!==t&&this._syncCalls>t)return void(this._syncCalls=0);if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let i;for(this._isSyncWriting=!0;i=this._writeBuffer.shift();){this._action(i);const e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),void this._innerWrite();setTimeout((()=>this._innerWrite()))}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}_innerWrite(e=0,t=!0){const i=e||performance.now();for(;this._writeBuffer.length>this._bufferOffset;){const e=this._writeBuffer[this._bufferOffset],s=this._action(e,t);if(s){const e=e=>performance.now()-i>=12?setTimeout((()=>this._innerWrite(0,e))):this._innerWrite(i,e);return void s.catch((e=>(queueMicrotask((()=>{throw e})),Promise.resolve(!1)))).then(e)}const r=this._callbacks[this._bufferOffset];if(r&&r(),this._bufferOffset++,this._pendingData-=e.length,performance.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),setTimeout((()=>this._innerWrite()))):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}t.WriteBuffer=n},8693:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.parseColor=function(e){if(!e)return;let t=e.toLowerCase();if(0===t.indexOf("rgb:")){t=t.slice(4);const e=i.exec(t);if(e){const t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(0===t.indexOf("#")&&(t=t.slice(1),s.exec(t)&&[3,6,9,12].includes(t.length))){const e=t.length/3,i=[0,0,0];for(let s=0;s<3;++s){const r=parseInt(t.slice(e*s,e*s+e),16);i[s]=1===e?r<<4:2===e?r:3===e?r>>4:r>>8}return i}},t.toRgbString=function(e,t=16){const[i,s,n]=e;return`rgb:${r(i,t)}/${r(s,t)}/${r(n,t)}`};const i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,s=/^[\da-f]+$/;function r(e,t){const i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}},1263:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.PAYLOAD_LIMIT=void 0,t.PAYLOAD_LIMIT=1e7},9823:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;const s=i(726),r=i(7262),n=i(1263),o=[];t.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=o,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=o,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t);else this._handlerFb(this._ident,"HOOK",t)}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}unhook(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].unhook(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"UNHOOK",e);this._active=o,this._ident=0}};const a=new r.Params;a.addParam(0),t.DcsHandler=class{constructor(e){this._handler=e,this._data="",this._params=a,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():a,this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,s.utf32ToString)(e,t,i),this._data.length>n.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data,this._params),t instanceof Promise))return t.then((e=>(this._params=a,this._data="",this._hitLimit=!1,e)));return this._params=a,this._data="",this._hitLimit=!1,t}}},6717:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;const s=i(7150),r=i(7262),n=i(1346),o=i(9823);class a{constructor(e){this.table=new Uint8Array(e)}setDefault(e,t){this.table.fill(e<<4|t)}add(e,t,i,s){this.table[t<<8|e]=i<<4|s}addMany(e,t,i,s){for(let r=0;rt)),i=(e,i)=>t.slice(e,i),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));const n=i(0,14);let o;for(o in e.setDefault(1,0),e.addMany(s,0,2,0),n)e.addMany([24,26,153,154],o,3,0),e.addMany(i(128,144),o,3,0),e.addMany(i(144,152),o,3,0),e.add(156,o,0,0),e.add(27,o,11,1),e.add(157,o,4,8),e.addMany([152,158,159],o,0,7),e.add(155,o,11,3),e.add(144,o,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94,95],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(28,32),9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(i(28,32),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(28,32),10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(28,32),12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(l,0,2,0),e.add(l,8,5,8),e.add(l,6,0,6),e.add(l,11,0,11),e.add(l,13,13,13),e}();class h extends s.Disposable{constructor(e=t.VT500_TRANSITION_TABLE){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new r.Params,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(e,t,i)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this._register((0,s.toDisposable)((()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._escHandlers=Object.create(null)}))),this._oscParser=this._register(new n.OscParser),this._dcsParser=this._register(new o.DcsParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},(()=>!0))}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i&&60>i||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let t=0;ts||s>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=s}}if(1!==e.final.length)throw new Error("final must be a single byte");const s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){const t=[];for(;e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){const i=this._identifier(e,[48,126]);void 0===this._escHandlers[i]&&(this._escHandlers[i]=[]);const s=this._escHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){this._executeHandlers[e.charCodeAt(0)]=t}clearExecuteHandler(e){this._executeHandlers[e.charCodeAt(0)]&&delete this._executeHandlers[e.charCodeAt(0)]}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){const i=this._identifier(e);void 0===this._csiHandlers[i]&&(this._csiHandlers[i]=[]);const s=this._csiHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._params.reset(),this._params.addParam(0),this._collect=0,this.precedingJoinState=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r=0,n=0,o=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,o=this._parseStack.chunkPos+1;else{if(void 0===i||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const t=this._parseStack.handlers;let n=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](this._params),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 4:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 6:if(r=e[this._parseStack.chunkPos],s=this._dcsParser.unhook(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break;case 5:if(r=e[this._parseStack.chunkPos],s=this._oscParser.end(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0}this._parseStack.state=0,o=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=15&this._parseStack.transition}for(let i=o;i>4){case 2:for(let s=i+1;;++s){if(s>=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=0&&(s=o[a](this._params),!0!==s);a--)if(s instanceof Promise)return this._preserveStack(3,o,a,n,i),s;a<0&&this._csiHandlerFb(this._collect<<8|r,this._params),this.precedingJoinState=0;break;case 8:do{switch(r){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(r-48)}}while(++i47&&r<60);i--;break;case 9:this._collect<<=8,this._collect|=r;break;case 10:const h=this._escHandlers[this._collect<<8|r];let c=h?h.length-1:-1;for(;c>=0&&(s=h[c](),!0!==s);c--)if(s instanceof Promise)return this._preserveStack(4,h,c,n,i),s;c<0&&this._escHandlerFb(this._collect<<8|r),this.precedingJoinState=0;break;case 11:this._params.reset(),this._params.addParam(0),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|r,this._params);break;case 13:for(let s=i+1;;++s)if(s>=t||24===(r=e[s])||26===r||27===r||r>127&&r=t||(r=e[s])<32||r>127&&r{Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;const s=i(1263),r=i(726),n=[];t.OscParser=class{constructor(){this._state=0,this._active=n,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}reset(){if(2===this._state)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=n,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||n,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")}_put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._id,"PUT",(0,r.utf32ToString)(e,t,i))}start(){this.reset(),this._state=1}put(e,t,i){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._id,"END",e);this._active=n,this._id=-1,this._state=0}}},t.OscHandler=class{constructor(e){this._handler=e,this._data="",this._hitLimit=!1}start(){this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,r.utf32ToString)(e,t,i),this._data.length>s.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data),t instanceof Promise))return t.then((e=>(this._data="",this._hitLimit=!1,e)));return this._data="",this._hitLimit=!1,t}}},7262:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;const i=2147483647;class s{static fromArray(e){const t=new s;if(!e.length)return t;for(let i=Array.isArray(e[0])?1:0;i256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const e=new s(this.maxLength,this.maxSubParamsLength);return e.params.set(this.params),e.length=this.length,e._subParams.set(this._subParams),e._subParamsLength=this._subParamsLength,e._subParamsIdx.set(this._subParamsIdx),e._rejectDigits=this._rejectDigits,e._rejectSubDigits=this._rejectSubDigits,e._digitIsSub=this._digitIsSub,e}toArray(){const e=[];for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&e.push(Array.prototype.slice.call(this._subParams,i,s))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>i?i:e}}addSubParam(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParams[this._subParamsLength++]=e>i?i:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0}getSubParams(e){const t=this._subParamsIdx[e]>>8,i=255&this._subParamsIdx[e];return i-t>0?this._subParams.subarray(t,i):null}getSubParamsAll(){const e={};for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&(e[t]=this._subParams.slice(i,s))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const s=this._digitIsSub?this._subParams:this.params,r=s[t-1];s[t-1]=~r?Math.min(10*r+e,i):e}}t.Params=s},3027:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0,t.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){const i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferApiView=void 0;const s=i(793),r=i(3055);t.BufferApiView=class{constructor(e,t){this._buffer=e,this.type=t}init(e){return this._buffer=e,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(e){const t=this._buffer.lines.get(e);if(t)return new s.BufferLineApiView(t)}getNullCell(){return new r.CellData}}},793:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLineApiView=void 0;const s=i(3055);t.BufferLineApiView=class{constructor(e){this._line=e}get isWrapped(){return this._line.isWrapped}get length(){return this._line.length}getCell(e,t){if(!(e<0||e>=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new s.CellData)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}}},5101:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;const s=i(3235),r=i(7150),n=i(802);class o extends r.Disposable{constructor(e){super(),this._core=e,this._onBufferChange=this._register(new n.Emitter),this.onBufferChange=this._onBufferChange.event,this._normal=new s.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new s.BufferApiView(this._core.buffers.alt,"alternate"),this._core.buffers.onBufferActivate((()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}t.BufferNamespaceApi=o},6097:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0,t.ParserApi=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,(e=>t(e.toArray())))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,((e,i)=>t(e,i.toArray())))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}}},4335:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0,t.UnicodeApi=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}}},9640:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=t.MINIMUM_ROWS=t.MINIMUM_COLS=void 0;const n=i(7150),o=i(4097),a=i(6501),l=i(802);t.MINIMUM_COLS=2,t.MINIMUM_ROWS=1;let h=class extends n.Disposable{get buffer(){return this.buffers.active}constructor(e){super(),this.isUserScrolling=!1,this._onResize=this._register(new l.Emitter),this.onResize=this._onResize.event,this._onScroll=this._register(new l.Emitter),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,t.MINIMUM_COLS),this.rows=Math.max(e.rawOptions.rows||0,t.MINIMUM_ROWS),this.buffers=this._register(new o.BufferSet(e,this)),this._register(this.buffers.onBufferActivate((e=>{this._onScroll.fire(e.activeBuffer.ydisp)})))}resize(e,t){const i=this.cols!==e,s=this.rows!==t;this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t,colsChanged:i,rowsChanged:s})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){const i=this.buffer;let s;s=this._cachedBlankLine,s&&s.length===this.cols&&s.getFg(0)===e.fg&&s.getBg(0)===e.bg||(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;const r=i.ybase+i.scrollTop,n=i.ybase+i.scrollBottom;if(0===i.scrollTop){const e=i.lines.isFull;n===i.lines.length-1?e?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(n+1,0,s.clone()),e?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{const e=n-r+1;i.lines.shiftElements(r+1,e-1,-1),i.lines.set(n,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t){const i=this.buffer;if(e<0){if(0===i.ydisp)return;this.isUserScrolling=!0}else e+i.ydisp>=i.ybase&&(this.isUserScrolling=!1);const s=i.ydisp;i.ydisp=Math.max(Math.min(i.ydisp+e,i.ybase),0),s!==i.ydisp&&(t||this._onScroll.fire(i.ydisp))}};t.BufferService=h,t.BufferService=h=s([r(0,a.IOptionsService)],h)},5746:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0,t.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}}},7792:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreMouseService=void 0;const n=i(6501),o=i(7150),a=i(802),l={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:e=>4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>32!==e.action},DRAG:{events:23,restrict:e=>32!==e.action||3!==e.button},ANY:{events:31,restrict:e=>!0}};function h(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(i|=64,i|=e.action):(i|=3&e.button,4&e.button&&(i|=64),8&e.button&&(i|=128),32===e.action?i|=32:0!==e.action||t||(i|=3)),i}const c=String.fromCharCode,d={DEFAULT:e=>{const t=[h(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`${c(t[0])}${c(t[1])}${c(t[2])}`},SGR:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${h(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${h(e,!0)};${e.x};${e.y}${t}`}};let u=class extends o.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._coreService=t,this._optionsService=i,this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._lastEvent=null,this._wheelPartialScroll=0,this._onProtocolChange=this._register(new a.Emitter),this.onProtocolChange=this._onProtocolChange.event;for(const e of Object.keys(l))this.addProtocol(e,l[e]);for(const e of Object.keys(d))this.addEncoding(e,d[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return 0!==this._protocols[this._activeProtocol].events}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT",this._lastEvent=null,this._wheelPartialScroll=0}consumeWheelEvent(e,t,i){if(0===e.deltaY||e.shiftKey)return 0;if(void 0===t||void 0===i)return 0;const s=t/i;let r=this._applyScrollModifier(e.deltaY,e);return e.deltaMode===WheelEvent.DOM_DELTA_PIXEL?(r/=s+0,Math.abs(e.deltaY)<50&&(r*=.3),this._wheelPartialScroll+=r,r=Math.floor(Math.abs(this._wheelPartialScroll))*(this._wheelPartialScroll>0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(r*=this._bufferService.rows),r}_applyScrollModifier(e,t){return t.altKey||t.ctrlKey||t.shiftKey?e*this._optionsService.rawOptions.fastScrollSensitivity*this._optionsService.rawOptions.scrollSensitivity:e*this._optionsService.rawOptions.scrollSensitivity}triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._equalEvents(this._lastEvent,e,"SGR_PIXELS"===this._activeEncoding))return!1;if(!this._protocols[this._activeProtocol].restrict(e))return!1;const t=this._encodings[this._activeEncoding](e);return t&&("DEFAULT"===this._activeEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}explainEvents(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x)return!1;if(e.y!==t.y)return!1}else{if(e.col!==t.col)return!1;if(e.row!==t.row)return!1}return e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift}};t.CoreMouseService=u,t.CoreMouseService=u=s([r(0,n.IBufferService),r(1,n.ICoreService),r(2,n.IOptionsService)],u)},4071:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;const n=i(7453),o=i(7150),a=i(6501),l=i(802),h=Object.freeze({insertMode:!1}),c=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,cursorBlink:void 0,cursorStyle:void 0,origin:!1,reverseWraparound:!1,sendFocus:!1,synchronizedOutput:!1,wraparound:!0});let d=class extends o.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorInitialized=!1,this.isCursorHidden=!1,this._onData=this._register(new l.Emitter),this.onData=this._onData.event,this._onUserInput=this._register(new l.Emitter),this.onUserInput=this._onUserInput.event,this._onBinary=this._register(new l.Emitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this._register(new l.Emitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.modes=(0,n.clone)(h),this.decPrivateModes=(0,n.clone)(c)}reset(){this.modes=(0,n.clone)(h),this.decPrivateModes=(0,n.clone)(c)}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;const i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`),this._logService.trace("sending data (codes)",(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`),this._logService.trace("sending binary (codes)",(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onBinary.fire(e))}};t.CoreService=d,t.CoreService=d=s([r(0,a.IBufferService),r(1,a.ILogService),r(2,a.IOptionsService)],d)},4720:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationService=void 0;const s=i(4103),r=i(7150),n=i(3087),o=i(802);let a=0,l=0;class h extends r.Disposable{get decorations(){return this._decorations.values()}constructor(){super(),this._decorations=new n.SortedList((e=>e?.marker.line)),this._onDecorationRegistered=this._register(new o.Emitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this._register(new o.Emitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this._register((0,r.toDisposable)((()=>this.reset())))}registerDecoration(e){if(e.marker.isDisposed)return;const t=new c(e);if(t){const e=t.marker.onDispose((()=>t.dispose())),i=t.onDispose((()=>{i.dispose(),t&&(this._decorations.delete(t)&&this._onDecorationRemoved.fire(t),e.dispose())}));this._decorations.insert(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear()}*getDecorationsAtCell(e,t,i){let s=0,r=0;for(const n of this._decorations.getKeyIterator(t))s=n.options.x??0,r=s+(n.options.width??1),e>=s&&e{a=t.options.x??0,l=a+(t.options.width??1),e>=a&&e{Object.defineProperty(t,"__esModule",{value:!0}),t.InstantiationService=t.ServiceCollection=void 0;const s=i(6501),r=i(6201);class n{constructor(...e){this._entries=new Map;for(const[t,i]of e)this.set(t,i)}set(e,t){const i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(const[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}}t.ServiceCollection=n,t.InstantiationService=class{constructor(){this._services=new n,this._services.set(s.IInstantiationService,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){const i=(0,r.getServiceDependencies)(e).sort(((e,t)=>e.index-t.index)),s=[];for(const t of i){const i=this._services.get(t.id);if(!i)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id._id}.`);s.push(i)}const n=i.length>0?i[0].index:t.length;if(t.length!==n)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${n+1} conflicts with ${t.length} static arguments`);return new e(...[...t,...s])}}},7276:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.LogService=void 0,t.setTraceLogger=function(e){l=e},t.traceCall=function(e,t,i){if("function"!=typeof i.value)throw new Error("not supported");const s=i.value;i.value=function(...e){if(l.logLevel!==o.LogLevelEnum.TRACE)return s.apply(this,e);l.trace(`GlyphRenderer#${s.name}(${e.map((e=>JSON.stringify(e))).join(", ")})`);const t=s.apply(this,e);return l.trace(`GlyphRenderer#${s.name} return`,t),t}};const n=i(7150),o=i(6501),a={trace:o.LogLevelEnum.TRACE,debug:o.LogLevelEnum.DEBUG,info:o.LogLevelEnum.INFO,warn:o.LogLevelEnum.WARN,error:o.LogLevelEnum.ERROR,off:o.LogLevelEnum.OFF};let l,h=class extends n.Disposable{get logLevel(){return this._logLevel}constructor(e){super(),this._optionsService=e,this._logLevel=o.LogLevelEnum.OFF,this._updateLogLevel(),this._register(this._optionsService.onSpecificOptionChange("logLevel",(()=>this._updateLogLevel()))),l=this}_updateLogLevel(){this._logLevel=a[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;t{Object.defineProperty(t,"__esModule",{value:!0}),t.OptionsService=t.DEFAULT_OPTIONS=void 0;const s=i(7150),r=i(701),n=i(802);t.DEFAULT_OPTIONS={cols:80,rows:24,cursorBlink:!1,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",customGlyphs:!0,drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollModifier:"alt",fastScrollSensitivity:5,fontFamily:"monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollOnEraseInDisplay:!1,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},reflowCursorLine:!1,rescaleOverlappingGlyphs:!1,rightClickSelectsWord:r.isMac,windowOptions:{},windowsMode:!1,windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",cancelEvents:!1,overviewRuler:{}};const o=["normal","bold","100","200","300","400","500","600","700","800","900"];class a extends s.Disposable{constructor(e){super(),this._onOptionChange=this._register(new n.Emitter),this.onOptionChange=this._onOptionChange.event;const i={...t.DEFAULT_OPTIONS};for(const t in e)if(t in i)try{const s=e[t];i[t]=this._sanitizeAndValidateOption(t,s)}catch(e){console.error(e)}this.rawOptions=i,this.options={...i},this._setupOptions(),this._register((0,s.toDisposable)((()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null})))}onSpecificOptionChange(e,t){return this.onOptionChange((i=>{i===e&&t(this.rawOptions[e])}))}onMultipleOptionChange(e,t){return this.onOptionChange((i=>{-1!==e.indexOf(i)&&t()}))}_setupOptions(){const e=e=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);return this.rawOptions[e]},i=(e,i)=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);i=this._sanitizeAndValidateOption(e,i),this.rawOptions[e]!==i&&(this.rawOptions[e]=i,this._onOptionChange.fire(e))};for(const t in this.rawOptions){const s={get:e.bind(this,t),set:i.bind(this,t)};Object.defineProperty(this.options,t,s)}}_sanitizeAndValidateOption(e,i){switch(e){case"cursorStyle":if(i||(i=t.DEFAULT_OPTIONS[e]),!function(e){return"block"===e||"underline"===e||"bar"===e}(i))throw new Error(`"${i}" is not a valid value for ${e}`);break;case"wordSeparator":i||(i=t.DEFAULT_OPTIONS[e]);break;case"fontWeight":case"fontWeightBold":if("number"==typeof i&&1<=i&&i<=1e3)break;i=o.includes(i)?i:t.DEFAULT_OPTIONS[e];break;case"cursorWidth":i=Math.floor(i);case"lineHeight":case"tabStopWidth":if(i<1)throw new Error(`${e} cannot be less than 1, value: ${i}`);break;case"minimumContrastRatio":i=Math.max(1,Math.min(21,Math.round(10*i)/10));break;case"scrollback":if((i=Math.min(i,4294967295))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(i<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${i}`);break;case"rows":case"cols":if(!i&&0!==i)throw new Error(`${e} must be numeric, value: ${i}`);break;case"windowsPty":i=i??{}}return i}}t.OptionsService=a},8811:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkService=void 0;const n=i(6501);let o=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){const t=this._bufferService.buffer;if(void 0===e.id){const i=t.addMarker(t.ybase+t.y),s={data:e,id:this._nextId++,lines:[i]};return i.onDispose((()=>this._removeMarkerFromLink(s,i))),this._dataByLinkId.set(s.id,s),s.id}const i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;const n=t.addMarker(t.ybase+t.y),o={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[n]};return n.onDispose((()=>this._removeMarkerFromLink(o,n))),this._entriesWithId.set(o.key,o),this._dataByLinkId.set(o.id,o),o.id}addLineToLink(e,t){const i=this._dataByLinkId.get(e);if(i&&i.lines.every((e=>e.line!==t))){const e=this._bufferService.buffer.addMarker(t);i.lines.push(e),e.onDispose((()=>this._removeMarkerFromLink(i,e)))}}getLinkData(e){return this._dataByLinkId.get(e)?.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){const i=e.lines.indexOf(t);-1!==i&&(e.lines.splice(i,1),0===e.lines.length&&(void 0!==e.data.id&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};t.OscLinkService=o,t.OscLinkService=o=s([r(0,n.IBufferService)],o)},6201:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.serviceRegistry=void 0,t.getServiceDependencies=function(e){return e[s]||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);const r=function(e,t,n){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");!function(e,t,r){t[i]===t?t[s].push({id:e,index:r}):(t[s]=[{id:e,index:r}],t[i]=t)}(r,e,n)};return r._id=e,t.serviceRegistry.set(e,r),r};const i="di$target",s="di$dependencies";t.serviceRegistry=new Map},6501:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOscLinkService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.ICharsetService=t.ICoreService=t.ICoreMouseService=t.IBufferService=void 0;const s=i(6201);var r;t.IBufferService=(0,s.createDecorator)("BufferService"),t.ICoreMouseService=(0,s.createDecorator)("CoreMouseService"),t.ICoreService=(0,s.createDecorator)("CoreService"),t.ICharsetService=(0,s.createDecorator)("CharsetService"),t.IInstantiationService=(0,s.createDecorator)("InstantiationService"),function(e){e[e.TRACE=0]="TRACE",e[e.DEBUG=1]="DEBUG",e[e.INFO=2]="INFO",e[e.WARN=3]="WARN",e[e.ERROR=4]="ERROR",e[e.OFF=5]="OFF"}(r||(t.LogLevelEnum=r={})),t.ILogService=(0,s.createDecorator)("LogService"),t.IOptionsService=(0,s.createDecorator)("OptionsService"),t.IOscLinkService=(0,s.createDecorator)("OscLinkService"),t.IUnicodeService=(0,s.createDecorator)("UnicodeService"),t.IDecorationService=(0,s.createDecorator)("DecorationService")},6415:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;const s=i(7428),r=i(802);class n{static extractShouldJoin(e){return!!(1&e)}static extractWidth(e){return e>>1&3}static extractCharKind(e){return e>>3}static createPropertyValue(e,t,i=!1){return(16777215&e)<<3|(3&t)<<1|(i?1:0)}constructor(){this._providers=Object.create(null),this._active="",this._onChange=new r.Emitter,this.onChange=this._onChange.event;const e=new s.UnicodeV6;this.register(e),this._active=e.version,this._activeProvider=e}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw new Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(e){let t=0,i=0;const s=e.length;for(let r=0;r=s)return t+this.wcwidth(o);const i=e.charCodeAt(r);56320<=i&&i<=57343?o=1024*(o-55296)+i-56320+65536:t+=this.wcwidth(i)}const a=this.charProperties(o,i);let l=n.extractWidth(a);n.extractShouldJoin(a)&&(l-=n.extractWidth(i)),t+=l,i=a}return t}charProperties(e,t){return this._activeProvider.charProperties(e,t)}}t.UnicodeService=n},4333:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.isAndroid=t.isElectron=t.isWebkitWebView=t.isSafari=t.isChrome=t.isWebKit=t.isFirefox=t.onDidChangeFullscreen=t.onDidChangeZoomLevel=void 0,t.addMatchMediaChangeListener=o,t.setZoomLevel=function(e,t){n.INSTANCE.setZoomLevel(e,t)},t.getZoomLevel=function(e){return n.INSTANCE.getZoomLevel(e)},t.getZoomFactor=function(e){return n.INSTANCE.getZoomFactor(e)},t.setZoomFactor=function(e,t){n.INSTANCE.setZoomFactor(e,t)},t.setFullscreen=function(e,t){n.INSTANCE.setFullscreen(e,t)},t.isFullscreen=function(e){return n.INSTANCE.isFullscreen(e)},t.isStandalone=function(){return l},t.isWCOEnabled=function(){return navigator?.windowControlsOverlay?.visible},t.getWCOBoundingRect=function(){return navigator?.windowControlsOverlay?.getTitlebarAreaRect()};const s=i(4693),r=i(802);class n{constructor(){this.mapWindowIdToZoomLevel=new Map,this._onDidChangeZoomLevel=new r.Emitter,this.onDidChangeZoomLevel=this._onDidChangeZoomLevel.event,this.mapWindowIdToZoomFactor=new Map,this._onDidChangeFullscreen=new r.Emitter,this.onDidChangeFullscreen=this._onDidChangeFullscreen.event,this.mapWindowIdToFullScreen=new Map}static{this.INSTANCE=new n}getZoomLevel(e){return this.mapWindowIdToZoomLevel.get(this.getWindowId(e))??0}setZoomLevel(e,t){if(this.getZoomLevel(t)===e)return;const i=this.getWindowId(t);this.mapWindowIdToZoomLevel.set(i,e),this._onDidChangeZoomLevel.fire(i)}getZoomFactor(e){return this.mapWindowIdToZoomFactor.get(this.getWindowId(e))??1}setZoomFactor(e,t){this.mapWindowIdToZoomFactor.set(this.getWindowId(t),e)}setFullscreen(e,t){if(this.isFullscreen(t)===e)return;const i=this.getWindowId(t);this.mapWindowIdToFullScreen.set(i,e),this._onDidChangeFullscreen.fire(i)}isFullscreen(e){return!!this.mapWindowIdToFullScreen.get(this.getWindowId(e))}getWindowId(e){return e.vscodeWindowId}}function o(e,t,i){"string"==typeof t&&(t=e.matchMedia(t)),t.addEventListener("change",i)}t.onDidChangeZoomLevel=n.INSTANCE.onDidChangeZoomLevel,t.onDidChangeFullscreen=n.INSTANCE.onDidChangeFullscreen;const a="object"==typeof navigator?navigator.userAgent:"";t.isFirefox=a.indexOf("Firefox")>=0,t.isWebKit=a.indexOf("AppleWebKit")>=0,t.isChrome=a.indexOf("Chrome")>=0,t.isSafari=!t.isChrome&&a.indexOf("Safari")>=0,t.isWebkitWebView=!t.isChrome&&!t.isSafari&&t.isWebKit,t.isElectron=a.indexOf("Electron/")>=0,t.isAndroid=a.indexOf("Android")>=0;let l=!1;if("function"==typeof s.mainWindow.matchMedia){const e=s.mainWindow.matchMedia("(display-mode: standalone) or (display-mode: window-controls-overlay)"),t=s.mainWindow.matchMedia("(display-mode: fullscreen)");l=e.matches,o(s.mainWindow,e,(({matches:e})=>{l&&t.matches||(l=e)}))}},7745:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.BrowserFeatures=t.KeyboardSupport=void 0;const o=n(i(4333)),a=i(4693),l=n(i(8163));var h;!function(e){e[e.Always=0]="Always",e[e.FullScreen=1]="FullScreen",e[e.None=2]="None"}(h||(t.KeyboardSupport=h={}));const c="object"==typeof navigator?navigator:{};t.BrowserFeatures={clipboard:{writeText:l.isNative||document.queryCommandSupported&&document.queryCommandSupported("copy")||!!(c&&c.clipboard&&c.clipboard.writeText),readText:l.isNative||!!(c&&c.clipboard&&c.clipboard.readText)},keyboard:l.isNative||o.isStandalone()?h.Always:c.keyboard||o.isSafari?h.FullScreen:h.None,touch:"ontouchstart"in a.mainWindow||c.maxTouchPoints>0,pointerEvents:a.mainWindow.PointerEvent&&("ontouchstart"in a.mainWindow||navigator.maxTouchPoints>0)}},7093:function(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),n=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),o=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&r(t,e,i);return n(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.SafeTriangle=t.DragAndDropObserver=t.ModifierKeyEmitter=t.DetectedFullscreenMode=t.Namespace=t.EventHelper=t.EventType=t.sharedMutationObserver=t.Dimension=t.WindowIntervalTimer=t.scheduleAtNextAnimationFrame=t.runAtThisOrScheduleAtNextAnimationFrame=t.WindowIdleValue=t.addStandardDisposableGenericMouseUpListener=t.addStandardDisposableGenericMouseDownListener=t.addStandardDisposableListener=t.onDidUnregisterWindow=t.onWillUnregisterWindow=t.onDidRegisterWindow=t.hasWindow=t.getWindowById=t.getWindowId=t.getWindowsCount=t.getWindows=t.getDocument=t.getWindow=t.registerWindow=void 0,t.clearNode=function(e){for(;e.firstChild;)e.firstChild.remove()},t.clearNodeRecursively=function e(t){for(;t.firstChild;){const i=t.firstChild;i.remove(),e(i)}},t.addDisposableListener=C,t.addDisposableGenericMouseDownListener=w,t.addDisposableGenericMouseMoveListener=function(e,i,s){return C(e,g.isIOS&&l.BrowserFeatures.pointerEvents?t.EventType.POINTER_MOVE:t.EventType.MOUSE_MOVE,i,s)},t.addDisposableGenericMouseUpListener=E,t.runWhenWindowIdle=function(e,t,i){return(0,d._runWhenIdle)(e,t,i)},t.disposableWindowInterval=function(e,t,i,s){let r=0;const n=e.setInterval((()=>{r++,("number"==typeof s&&r>=s||!0===t())&&o.dispose()}),i),o=(0,p.toDisposable)((()=>{e.clearInterval(n)}));return o},t.measure=function(e,i){return(0,t.scheduleAtNextAnimationFrame)(e,i,1e4)},t.modify=function(e,i){return(0,t.scheduleAtNextAnimationFrame)(e,i,-1e4)},t.addDisposableThrottledListener=function(e,t,i,s,r){return new T(e,t,i,s,r)},t.getComputedStyle=k,t.getClientArea=function e(i,s){const r=(0,t.getWindow)(i),n=r.document;if(i!==n.body)return new O(i.clientWidth,i.clientHeight);if(g.isIOS&&r?.visualViewport)return new O(r.visualViewport.width,r.visualViewport.height);if(r?.innerWidth&&r.innerHeight)return new O(r.innerWidth,r.innerHeight);if(n.body&&n.body.clientWidth&&n.body.clientHeight)return new O(n.body.clientWidth,n.body.clientHeight);if(n.documentElement&&n.documentElement.clientWidth&&n.documentElement.clientHeight)return new O(n.documentElement.clientWidth,n.documentElement.clientHeight);if(s)return e(s);throw new Error("Unable to figure out browser width and height")},t.getTopLeftOffset=I,t.size=function(e,t,i){"number"==typeof t&&(e.style.width=`${t}px`),"number"==typeof i&&(e.style.height=`${i}px`)},t.position=function(e,t,i,s,r,n="absolute"){"number"==typeof t&&(e.style.top=`${t}px`),"number"==typeof i&&(e.style.right=`${i}px`),"number"==typeof s&&(e.style.bottom=`${s}px`),"number"==typeof r&&(e.style.left=`${r}px`),e.style.position=n},t.getDomNodePagePosition=function(e){const i=e.getBoundingClientRect(),s=(0,t.getWindow)(e);return{left:i.left+s.scrollX,top:i.top+s.scrollY,width:i.width,height:i.height}},t.getDomNodeZoomLevel=function(e){let t=e,i=1;do{const e=k(t).zoom;null!=e&&"1"!==e&&(i*=e),t=t.parentElement}while(null!==t&&t!==t.ownerDocument.documentElement);return i},t.getTotalWidth=P,t.getContentWidth=function(e){const t=M.getBorderLeftWidth(e)+M.getBorderRightWidth(e),i=M.getPaddingLeft(e)+M.getPaddingRight(e);return e.offsetWidth-t-i},t.getTotalScrollWidth=x,t.getContentHeight=function(e){const t=M.getBorderTopWidth(e)+M.getBorderBottomWidth(e),i=M.getPaddingTop(e)+M.getPaddingBottom(e);return e.offsetHeight-t-i},t.getTotalHeight=function(e){const t=M.getMarginTop(e)+M.getMarginBottom(e);return e.offsetHeight+t},t.getLargestChildWidth=function(e,t){const i=t.map((t=>Math.max(x(t),P(t))+function(e,t){if(null===e)return 0;const i=I(e),s=I(t);return i.left-s.left}(t,e)||0));return Math.max(...i)},t.isAncestor=B,t.setParentFlowTo=function(e,t){e.dataset[N]=t.id},t.isAncestorUsingFlowTo=function(e,t){let i=e;for(;i;){if(i===t)return!0;if(Q(i)){const e=U(i);if(e){i=e;continue}}i=i.parentNode}return!1},t.findParentWithClass=F,t.hasParentWithClass=function(e,t,i){return!!F(e,t,i)},t.isShadowRoot=W,t.isInShadowDOM=function(e){return!!H(e)},t.getShadowRoot=H,t.getActiveElement=K,t.isActiveElement=function(e){return K()===e},t.isAncestorOfActiveElement=function(e){return B(K(),e)},t.isActiveDocument=function(e){return e.ownerDocument===z()},t.getActiveDocument=z,t.getActiveWindow=function(){const e=z();return e.defaultView?.window??v.mainWindow},t.isGlobalStylesheet=function(e){return j.has(e)},t.createStyleSheet2=function(){return new $},t.createStyleSheet=V,t.cloneGlobalStylesheets=function(e){const t=new p.DisposableStore;for(const[i,s]of j)t.add(G(i,s,e));return t},t.createMetaElement=function(e=v.mainWindow.document.head){return q("meta",e)},t.createLinkElement=function(e=v.mainWindow.document.head){return q("link",e)},t.createCSSRule=function e(t,i,s=Y()){if(s&&i){s.sheet?.insertRule(`${t} {${i}}`,0);for(const r of j.get(s)??[])e(t,i,r)}},t.removeCSSRulesContainingSelector=function e(t,i=Y()){if(!i)return;const s=Z(i),r=[];for(let e=0;e=0;e--)i.sheet?.deleteRule(r[e]);for(const s of j.get(i)??[])e(t,s)},t.isHTMLElement=Q,t.isHTMLAnchorElement=function(e){return e instanceof HTMLAnchorElement||e instanceof(0,t.getWindow)(e).HTMLAnchorElement},t.isHTMLSpanElement=function(e){return e instanceof HTMLSpanElement||e instanceof(0,t.getWindow)(e).HTMLSpanElement},t.isHTMLTextAreaElement=function(e){return e instanceof HTMLTextAreaElement||e instanceof(0,t.getWindow)(e).HTMLTextAreaElement},t.isHTMLInputElement=function(e){return e instanceof HTMLInputElement||e instanceof(0,t.getWindow)(e).HTMLInputElement},t.isHTMLButtonElement=function(e){return e instanceof HTMLButtonElement||e instanceof(0,t.getWindow)(e).HTMLButtonElement},t.isHTMLDivElement=function(e){return e instanceof HTMLDivElement||e instanceof(0,t.getWindow)(e).HTMLDivElement},t.isSVGElement=function(e){return e instanceof SVGElement||e instanceof(0,t.getWindow)(e).SVGElement},t.isMouseEvent=function(e){return e instanceof MouseEvent||e instanceof(0,t.getWindow)(e).MouseEvent},t.isKeyboardEvent=function(e){return e instanceof KeyboardEvent||e instanceof(0,t.getWindow)(e).KeyboardEvent},t.isPointerEvent=function(e){return e instanceof PointerEvent||e instanceof(0,t.getWindow)(e).PointerEvent},t.isDragEvent=function(e){return e instanceof DragEvent||e instanceof(0,t.getWindow)(e).DragEvent},t.isEventLike=function(e){const t=e;return!(!t||"function"!=typeof t.preventDefault||"function"!=typeof t.stopPropagation)},t.saveParentsScrollTop=function(e){const t=[];for(let i=0;e&&e.nodeType===e.ELEMENT_NODE;i++)t[i]=e.scrollTop,e=e.parentNode;return t},t.restoreParentsScrollTop=function(e,t){for(let i=0;e&&e.nodeType===e.ELEMENT_NODE;i++)e.scrollTop!==t[i]&&(e.scrollTop=t[i]),e=e.parentNode},t.trackFocus=function(e){return new ee(e)},t.after=function(e,t){return e.after(t),t},t.append=te,t.prepend=function(e,t){return e.insertBefore(t,e.firstChild),t},t.reset=function(e,...t){e.innerText="",te(e,...t)},t.$=ne,t.join=function(e,t){const i=[];return e.forEach(((e,s)=>{s>0&&(t instanceof Node?i.push(t.cloneNode()):i.push(document.createTextNode(t))),i.push(e)})),i},t.setVisibility=function(e,...t){e?oe(...t):ae(...t)},t.show=oe,t.hide=ae,t.removeTabIndexAndUpdateFocus=function(e){if(e&&e.hasAttribute("tabIndex")){if(e.ownerDocument.activeElement===e){const t=function(e){for(;e&&e.nodeType===e.ELEMENT_NODE;){if(Q(e)&&e.hasAttribute("tabIndex"))return e;e=e.parentNode}return null}(e.parentElement);t?.focus()}e.removeAttribute("tabindex")}},t.finalHandler=function(e){return t=>{t.preventDefault(),t.stopPropagation(),e(t)}},t.domContentLoaded=function(e){return new Promise((t=>{if("complete"===e.document.readyState||e.document&&null!==e.document.body)t(void 0);else{const i=()=>{e.window.removeEventListener("DOMContentLoaded",i,!1),t()};e.window.addEventListener("DOMContentLoaded",i,!1)}}))},t.computeScreenAwareSize=function(e,t){const i=e.devicePixelRatio*t;return Math.max(1,Math.floor(i))/e.devicePixelRatio},t.windowOpenNoOpener=function(e){v.mainWindow.open(e,"_blank","noopener")},t.windowOpenPopup=function(e){const t=Math.floor(v.mainWindow.screenLeft+v.mainWindow.innerWidth/2-le/2),i=Math.floor(v.mainWindow.screenTop+v.mainWindow.innerHeight/2-he/2);v.mainWindow.open(e,"_blank",`width=${le},height=${he},top=${i},left=${t}`)},t.windowOpenWithSuccess=function(e,t=!0){const i=v.mainWindow.open();return!!i&&(t&&(i.opener=null),i.location.href=e,!0)},t.animate=function(e,i){const s=()=>{i(),r=(0,t.scheduleAtNextAnimationFrame)(e,s)};let r=(0,t.scheduleAtNextAnimationFrame)(e,s);return(0,p.toDisposable)((()=>r.dispose()))},t.asCSSPropertyValue=function(e){return`'${e.replace(/'/g,"%27")}'`},t.asCssValueWithDefault=function e(t,i){if(void 0!==t){const s=t.match(/^\s*var\((.+)\)$/);if(s){const t=s[1].split(",",2);return 2===t.length&&(i=e(t[1].trim(),i)),`var(${t[0]}, ${i})`}return t}return i},t.detectFullscreen=function(e){return e.document.fullscreenElement||e.document.webkitFullscreenElement||e.document.webkitIsFullScreen?{mode:ce.DOCUMENT,guess:!1}:e.innerHeight===e.screen.height?{mode:ce.BROWSER,guess:!1}:(g.isMacintosh||g.isLinux)&&e.outerHeight===e.screen.height&&e.outerWidth===e.screen.width?{mode:ce.BROWSER,guess:!0}:null},t.multibyteAwareBtoa=function(e){return btoa(function(e){const t=new Uint16Array(e.length);for(let i=0;i0&&(o.className=a.join(" "));const l={};if(r.groups.name&&(l[r.groups.name]=o),s)for(const e of s)Q(e)?o.appendChild(e):"string"==typeof e?o.append(e):"root"in e&&(Object.assign(l,e),o.appendChild(e.root));for(const[e,t]of Object.entries(i))if("className"!==e)if("style"===e)for(const[e,i]of Object.entries(t))o.style.setProperty(fe(e),"number"==typeof i?i+"px":""+i);else"tabIndex"===e?o.tabIndex=t:o.setAttribute(fe(e),t.toString());return l.root=o,l},t.svgElem=function(e,...t){let i,s;Array.isArray(t[0])?(i={},s=t[0]):(i=t[0]||{},s=t[1]);const r=_e.exec(e);if(!r||!r.groups)throw new Error("Bad use of h");const n=r.groups.tag||"div",o=document.createElementNS("http://www.w3.org/2000/svg",n);r.groups.id&&(o.id=r.groups.id);const a=[];if(r.groups.class)for(const e of r.groups.class.split("."))""!==e&&a.push(e);if(void 0!==i.className)for(const e of i.className.split("."))""!==e&&a.push(e);a.length>0&&(o.className=a.join(" "));const l={};if(r.groups.name&&(l[r.groups.name]=o),s)for(const e of s)Q(e)?o.appendChild(e):"string"==typeof e?o.append(e):"root"in e&&(Object.assign(l,e),o.appendChild(e.root));for(const[e,t]of Object.entries(i))if("className"!==e)if("style"===e)for(const[e,i]of Object.entries(t))o.style.setProperty(fe(e),"number"==typeof i?i+"px":""+i);else"tabIndex"===e?o.tabIndex=t:o.setAttribute(fe(e),t.toString());return l.root=o,l},t.copyAttributes=pe,t.trackAttributes=function(e,i,s){pe(e,i,s);const r=new p.DisposableStore;return r.add(t.sharedMutationObserver.observe(e,r,{attributes:!0,attributeFilter:s})((t=>{for(const s of t)"attributes"===s.type&&s.attributeName&&ge(e,i,s.attributeName)}))),r};const a=o(i(4333)),l=i(7745),h=i(5394),c=i(5964),d=i(1758),u=i(9807),_=o(i(802)),f=i(7883),p=i(7150),g=o(i(8163)),m=i(6304),v=i(4693),S=i(7704);s=function(){const e=new Map;(0,v.ensureCodeWindow)(v.mainWindow,1);const i={window:v.mainWindow,disposables:new p.DisposableStore};e.set(v.mainWindow.vscodeWindowId,i);const s=new _.Emitter,r=new _.Emitter,n=new _.Emitter;return{onDidRegisterWindow:s.event,onWillUnregisterWindow:n.event,onDidUnregisterWindow:r.event,registerWindow(i){if(e.has(i.vscodeWindowId))return p.Disposable.None;const o=new p.DisposableStore,a={window:i,disposables:o.add(new p.DisposableStore)};return e.set(i.vscodeWindowId,a),o.add((0,p.toDisposable)((()=>{e.delete(i.vscodeWindowId),r.fire(i)}))),o.add(C(i,t.EventType.BEFORE_UNLOAD,(()=>{n.fire(i)}))),s.fire(a),o},getWindows:()=>e.values(),getWindowsCount:()=>e.size,getWindowId:e=>e.vscodeWindowId,hasWindow:t=>e.has(t),getWindowById:function(t,s){return("number"==typeof t?e.get(t):void 0)??(s?i:void 0)},getWindow(e){const t=e;if(t?.ownerDocument?.defaultView)return t.ownerDocument.defaultView.window;const i=e;return i?.view?i.view.window:v.mainWindow},getDocument(e){const i=e;return(0,t.getWindow)(i).document}}}(),t.registerWindow=s.registerWindow,t.getWindow=s.getWindow,t.getDocument=s.getDocument,t.getWindows=s.getWindows,t.getWindowsCount=s.getWindowsCount,t.getWindowId=s.getWindowId,t.getWindowById=s.getWindowById,t.hasWindow=s.hasWindow,t.onDidRegisterWindow=s.onDidRegisterWindow,t.onWillUnregisterWindow=s.onWillUnregisterWindow,t.onDidUnregisterWindow=s.onDidUnregisterWindow;class b{constructor(e,t,i,s){this._node=e,this._type=t,this._handler=i,this._options=s||!1,this._node.addEventListener(this._type,this._handler,this._options)}dispose(){this._handler&&(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}}function C(e,t,i,s){return new b(e,t,i,s)}function y(e,t){return function(i){return t(new c.StandardMouseEvent(e,i))}}function w(e,i,s){return C(e,g.isIOS&&l.BrowserFeatures.pointerEvents?t.EventType.POINTER_DOWN:t.EventType.MOUSE_DOWN,i,s)}function E(e,i,s){return C(e,g.isIOS&&l.BrowserFeatures.pointerEvents?t.EventType.POINTER_UP:t.EventType.MOUSE_UP,i,s)}t.addStandardDisposableListener=function(e,i,s,r){let n=s;return"click"===i||"mousedown"===i||"contextmenu"===i?n=y((0,t.getWindow)(e),s):"keydown"!==i&&"keypress"!==i&&"keyup"!==i||(n=function(e){return function(t){return e(new h.StandardKeyboardEvent(t))}}(s)),C(e,i,n,r)},t.addStandardDisposableGenericMouseDownListener=function(e,i,s){return w(e,y((0,t.getWindow)(e),i),s)},t.addStandardDisposableGenericMouseUpListener=function(e,i,s){return E(e,y((0,t.getWindow)(e),i),s)};class D extends d.AbstractIdleValue{constructor(e,t){super(e,t)}}t.WindowIdleValue=D;class L extends d.IntervalTimer{constructor(e){super(),this.defaultTarget=e&&(0,t.getWindow)(e)}cancelAndSet(e,t,i){return super.cancelAndSet(e,t,i??this.defaultTarget)}}t.WindowIntervalTimer=L;class R{constructor(e,t=0){this._runner=e,this.priority=t,this._canceled=!1}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(e){(0,u.onUnexpectedError)(e)}}static sort(e,t){return t.priority-e.priority}}!function(){const e=new Map,i=new Map,s=new Map,r=new Map;t.scheduleAtNextAnimationFrame=(n,o,a=0)=>{const l=(0,t.getWindowId)(n),h=new R(o,a);let c=e.get(l);return c||(c=[],e.set(l,c)),c.push(h),s.get(l)||(s.set(l,!0),n.requestAnimationFrame((()=>(t=>{s.set(t,!1);const n=e.get(t)??[];for(i.set(t,n),e.set(t,[]),r.set(t,!0);n.length>0;)n.sort(R.sort),n.shift().execute();r.set(t,!1)})(l)))),h},t.runAtThisOrScheduleAtNextAnimationFrame=(e,s,n)=>{const o=(0,t.getWindowId)(e);if(r.get(o)){const e=new R(s,n);let t=i.get(o);return t||(t=[],i.set(o,t)),t.push(e),e}return(0,t.scheduleAtNextAnimationFrame)(e,s,n)}}();const A=function(e,t){return t};class T extends p.Disposable{constructor(e,t,i,s=A,r=8){super();let n=null,o=0;const a=this._register(new d.TimeoutTimer),l=()=>{o=(new Date).getTime(),i(n),n=null};this._register(C(e,t,(e=>{n=s(n,e);const t=(new Date).getTime()-o;t>=r?(a.cancel(),l()):a.setIfNotSet(l,r-t)})))}}function k(e){return(0,t.getWindow)(e).getComputedStyle(e,null)}class M{static convertToPixels(e,t){return parseFloat(t)||0}static getDimension(e,t,i){const s=k(e),r=s?s.getPropertyValue(t):"0";return M.convertToPixels(e,r)}static getBorderLeftWidth(e){return M.getDimension(e,"border-left-width","borderLeftWidth")}static getBorderRightWidth(e){return M.getDimension(e,"border-right-width","borderRightWidth")}static getBorderTopWidth(e){return M.getDimension(e,"border-top-width","borderTopWidth")}static getBorderBottomWidth(e){return M.getDimension(e,"border-bottom-width","borderBottomWidth")}static getPaddingLeft(e){return M.getDimension(e,"padding-left","paddingLeft")}static getPaddingRight(e){return M.getDimension(e,"padding-right","paddingRight")}static getPaddingTop(e){return M.getDimension(e,"padding-top","paddingTop")}static getPaddingBottom(e){return M.getDimension(e,"padding-bottom","paddingBottom")}static getMarginLeft(e){return M.getDimension(e,"margin-left","marginLeft")}static getMarginTop(e){return M.getDimension(e,"margin-top","marginTop")}static getMarginRight(e){return M.getDimension(e,"margin-right","marginRight")}static getMarginBottom(e){return M.getDimension(e,"margin-bottom","marginBottom")}}class O{static{this.None=new O(0,0)}constructor(e,t){this.width=e,this.height=t}with(e=this.width,t=this.height){return e!==this.width||t!==this.height?new O(e,t):this}static is(e){return"object"==typeof e&&"number"==typeof e.height&&"number"==typeof e.width}static lift(e){return e instanceof O?e:new O(e.width,e.height)}static equals(e,t){return e===t||!(!e||!t)&&e.width===t.width&&e.height===t.height}}function I(e){let t=e.offsetParent,i=e.offsetTop,s=e.offsetLeft;for(;null!==(e=e.parentNode)&&e!==e.ownerDocument.body&&e!==e.ownerDocument.documentElement;){i-=e.scrollTop;const r=W(e)?null:k(e);r&&(s-="rtl"!==r.direction?e.scrollLeft:-e.scrollLeft),e===t&&(s+=M.getBorderLeftWidth(e),i+=M.getBorderTopWidth(e),i+=e.offsetTop,s+=e.offsetLeft,t=e.offsetParent)}return{left:s,top:i}}function P(e){const t=M.getMarginLeft(e)+M.getMarginRight(e);return e.offsetWidth+t}function x(e){const t=M.getMarginLeft(e)+M.getMarginRight(e);return e.scrollWidth+t}function B(e,t){return Boolean(t?.contains(e))}t.Dimension=O;const N="parentFlowToElementId";function U(e){const t=e.dataset[N];return"string"==typeof t?e.ownerDocument.getElementById(t):null}function F(e,t,i){for(;e&&e.nodeType===e.ELEMENT_NODE;){if(e.classList.contains(t))return e;if(i)if("string"==typeof i){if(e.classList.contains(i))return null}else if(e===i)return null;e=e.parentNode}return null}function W(e){return e&&!!e.host&&!!e.mode}function H(e){for(;e.parentNode;){if(e===e.ownerDocument?.body)return null;e=e.parentNode}return W(e)?e:null}function K(){let e=z().activeElement;for(;e?.shadowRoot;)e=e.shadowRoot.activeElement;return e}function z(){return(0,t.getWindowsCount)()<=1?v.mainWindow.document:Array.from((0,t.getWindows)()).map((({window:e})=>e.document)).find((e=>e.hasFocus()))??v.mainWindow.document}const j=new Map;class ${constructor(){this._currentCssStyle="",this._styleSheet=void 0}setStyle(e){e!==this._currentCssStyle&&(this._currentCssStyle=e,this._styleSheet?this._styleSheet.innerText=e:this._styleSheet=V(v.mainWindow.document.head,(t=>t.innerText=e)))}dispose(){this._styleSheet&&(this._styleSheet.remove(),this._styleSheet=void 0)}}function V(e=v.mainWindow.document.head,i,s){const r=document.createElement("style");if(r.type="text/css",r.media="screen",i?.(r),e.appendChild(r),s&&s.add((0,p.toDisposable)((()=>r.remove()))),e===v.mainWindow.document.head){const e=new Set;j.set(r,e);for(const{window:i,disposables:n}of(0,t.getWindows)()){if(i===v.mainWindow)continue;const t=n.add(G(r,e,i));s?.add(t)}}return r}function G(e,i,s){const r=new p.DisposableStore,n=e.cloneNode(!0);s.document.head.appendChild(n),r.add((0,p.toDisposable)((()=>n.remove())));for(const t of Z(e))n.sheet?.insertRule(t.cssText,n.sheet?.cssRules.length);return r.add(t.sharedMutationObserver.observe(e,r,{childList:!0})((()=>{n.textContent=e.textContent}))),i.add(n),r.add((0,p.toDisposable)((()=>i.delete(n)))),r}function q(e,t=v.mainWindow.document.head){const i=document.createElement(e);return t.appendChild(i),i}t.sharedMutationObserver=new class{constructor(){this.mutationObservers=new Map}observe(e,t,i){let s=this.mutationObservers.get(e);s||(s=new Map,this.mutationObservers.set(e,s));const r=(0,m.hash)(i);let n=s.get(r);if(n)n.users+=1;else{const o=new _.Emitter,a=new MutationObserver((e=>o.fire(e)));a.observe(e,i);const l=n={users:1,observer:a,onDidMutate:o.event};t.add((0,p.toDisposable)((()=>{l.users-=1,0===l.users&&(o.dispose(),a.disconnect(),s?.delete(r),0===s?.size&&this.mutationObservers.delete(e))}))),s.set(r,n)}return n.onDidMutate}};let X=null;function Y(){return X||(X=V()),X}function Z(e){return e?.sheet?.rules?e.sheet.rules:e?.sheet?.cssRules?e.sheet.cssRules:[]}function J(e){return"string"==typeof e.selectorText}function Q(e){return e instanceof HTMLElement||e instanceof(0,t.getWindow)(e).HTMLElement}t.EventType={CLICK:"click",AUXCLICK:"auxclick",DBLCLICK:"dblclick",MOUSE_UP:"mouseup",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_MOVE:"mousemove",MOUSE_OUT:"mouseout",MOUSE_ENTER:"mouseenter",MOUSE_LEAVE:"mouseleave",MOUSE_WHEEL:"wheel",POINTER_UP:"pointerup",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_LEAVE:"pointerleave",CONTEXT_MENU:"contextmenu",WHEEL:"wheel",KEY_DOWN:"keydown",KEY_PRESS:"keypress",KEY_UP:"keyup",LOAD:"load",BEFORE_UNLOAD:"beforeunload",UNLOAD:"unload",PAGE_SHOW:"pageshow",PAGE_HIDE:"pagehide",PASTE:"paste",ABORT:"abort",ERROR:"error",RESIZE:"resize",SCROLL:"scroll",FULLSCREEN_CHANGE:"fullscreenchange",WK_FULLSCREEN_CHANGE:"webkitfullscreenchange",SELECT:"select",CHANGE:"change",SUBMIT:"submit",RESET:"reset",FOCUS:"focus",FOCUS_IN:"focusin",FOCUS_OUT:"focusout",BLUR:"blur",INPUT:"input",STORAGE:"storage",DRAG_START:"dragstart",DRAG:"drag",DRAG_ENTER:"dragenter",DRAG_LEAVE:"dragleave",DRAG_OVER:"dragover",DROP:"drop",DRAG_END:"dragend",ANIMATION_START:a.isWebKit?"webkitAnimationStart":"animationstart",ANIMATION_END:a.isWebKit?"webkitAnimationEnd":"animationend",ANIMATION_ITERATION:a.isWebKit?"webkitAnimationIteration":"animationiteration"},t.EventHelper={stop:(e,t)=>(e.preventDefault(),t&&e.stopPropagation(),e)};class ee extends p.Disposable{static hasFocusWithin(e){if(Q(e)){const t=H(e);return B(t?t.activeElement:e.ownerDocument.activeElement,e)}{const t=e;return B(t.document.activeElement,t.document)}}constructor(e){super(),this._onDidFocus=this._register(new _.Emitter),this.onDidFocus=this._onDidFocus.event,this._onDidBlur=this._register(new _.Emitter),this.onDidBlur=this._onDidBlur.event;let i=ee.hasFocusWithin(e),s=!1;const r=()=>{s=!1,i||(i=!0,this._onDidFocus.fire())},n=()=>{i&&(s=!0,(Q(e)?(0,t.getWindow)(e):e).setTimeout((()=>{s&&(s=!1,i=!1,this._onDidBlur.fire())}),0))};this._refreshStateHandler=()=>{ee.hasFocusWithin(e)!==i&&(i?n():r())},this._register(C(e,t.EventType.FOCUS,r,!0)),this._register(C(e,t.EventType.BLUR,n,!0)),Q(e)&&(this._register(C(e,t.EventType.FOCUS_IN,(()=>this._refreshStateHandler()))),this._register(C(e,t.EventType.FOCUS_OUT,(()=>this._refreshStateHandler()))))}refreshState(){this._refreshStateHandler()}}function te(e,...t){if(e.append(...t),1===t.length&&"string"!=typeof t[0])return t[0]}const ie=/([\w\-]+)?(#([\w\-]+))?((\.([\w\-]+))*)/;var se;function re(e,t,i,...s){const r=ie.exec(t);if(!r)throw new Error("Bad use of emmet");const n=r[1]||"div";let o;return o=e!==se.HTML?document.createElementNS(e,n):document.createElement(n),r[3]&&(o.id=r[3]),r[4]&&(o.className=r[4].replace(/\./g," ").trim()),i&&Object.entries(i).forEach((([e,t])=>{void 0!==t&&(/^on\w+$/.test(e)?o[e]=t:"selected"===e?t&&o.setAttribute(e,"true"):o.setAttribute(e,t))})),o.append(...s),o}function ne(e,t,...i){return re(se.HTML,e,t,...i)}function oe(...e){for(const t of e)t.style.display="",t.removeAttribute("aria-hidden")}function ae(...e){for(const t of e)t.style.display="none",t.setAttribute("aria-hidden","true")}!function(e){e.HTML="http://www.w3.org/1999/xhtml",e.SVG="http://www.w3.org/2000/svg"}(se||(t.Namespace=se={})),ne.SVG=function(e,t,...i){return re(se.SVG,e,t,...i)};const le=780,he=640;var ce;!function(e){e[e.DOCUMENT=1]="DOCUMENT",e[e.BROWSER=2]="BROWSER"}(ce||(t.DetectedFullscreenMode=ce={}));class de extends _.Emitter{constructor(){super(),this._subscriptions=new p.DisposableStore,this._keyStatus={altKey:!1,shiftKey:!1,ctrlKey:!1,metaKey:!1},this._subscriptions.add(_.Event.runAndSubscribe(t.onDidRegisterWindow,(({window:e,disposables:t})=>this.registerListeners(e,t)),{window:v.mainWindow,disposables:this._subscriptions}))}registerListeners(e,t){t.add(C(e,"keydown",(e=>{if(e.defaultPrevented)return;const t=new h.StandardKeyboardEvent(e);if(t.keyCode!==f.KeyCode.Alt||!e.repeat){if(e.altKey&&!this._keyStatus.altKey)this._keyStatus.lastKeyPressed="alt";else if(e.ctrlKey&&!this._keyStatus.ctrlKey)this._keyStatus.lastKeyPressed="ctrl";else if(e.metaKey&&!this._keyStatus.metaKey)this._keyStatus.lastKeyPressed="meta";else if(e.shiftKey&&!this._keyStatus.shiftKey)this._keyStatus.lastKeyPressed="shift";else{if(t.keyCode===f.KeyCode.Alt)return;this._keyStatus.lastKeyPressed=void 0}this._keyStatus.altKey=e.altKey,this._keyStatus.ctrlKey=e.ctrlKey,this._keyStatus.metaKey=e.metaKey,this._keyStatus.shiftKey=e.shiftKey,this._keyStatus.lastKeyPressed&&(this._keyStatus.event=e,this.fire(this._keyStatus))}}),!0)),t.add(C(e,"keyup",(e=>{e.defaultPrevented||(!e.altKey&&this._keyStatus.altKey?this._keyStatus.lastKeyReleased="alt":!e.ctrlKey&&this._keyStatus.ctrlKey?this._keyStatus.lastKeyReleased="ctrl":!e.metaKey&&this._keyStatus.metaKey?this._keyStatus.lastKeyReleased="meta":!e.shiftKey&&this._keyStatus.shiftKey?this._keyStatus.lastKeyReleased="shift":this._keyStatus.lastKeyReleased=void 0,this._keyStatus.lastKeyPressed!==this._keyStatus.lastKeyReleased&&(this._keyStatus.lastKeyPressed=void 0),this._keyStatus.altKey=e.altKey,this._keyStatus.ctrlKey=e.ctrlKey,this._keyStatus.metaKey=e.metaKey,this._keyStatus.shiftKey=e.shiftKey,this._keyStatus.lastKeyReleased&&(this._keyStatus.event=e,this.fire(this._keyStatus)))}),!0)),t.add(C(e.document.body,"mousedown",(()=>{this._keyStatus.lastKeyPressed=void 0}),!0)),t.add(C(e.document.body,"mouseup",(()=>{this._keyStatus.lastKeyPressed=void 0}),!0)),t.add(C(e.document.body,"mousemove",(e=>{e.buttons&&(this._keyStatus.lastKeyPressed=void 0)}),!0)),t.add(C(e,"blur",(()=>{this.resetKeyStatus()})))}get keyStatus(){return this._keyStatus}get isModifierPressed(){return this._keyStatus.altKey||this._keyStatus.ctrlKey||this._keyStatus.metaKey||this._keyStatus.shiftKey}resetKeyStatus(){this.doResetKeyStatus(),this.fire(this._keyStatus)}doResetKeyStatus(){this._keyStatus={altKey:!1,shiftKey:!1,ctrlKey:!1,metaKey:!1}}static getInstance(){return de.instance||(de.instance=new de),de.instance}dispose(){super.dispose(),this._subscriptions.dispose()}}t.ModifierKeyEmitter=de;class ue extends p.Disposable{constructor(e,t){super(),this.element=e,this.callbacks=t,this.counter=0,this.dragStartTime=0,this.registerListeners()}registerListeners(){this.callbacks.onDragStart&&this._register(C(this.element,t.EventType.DRAG_START,(e=>{this.callbacks.onDragStart?.(e)}))),this.callbacks.onDrag&&this._register(C(this.element,t.EventType.DRAG,(e=>{this.callbacks.onDrag?.(e)}))),this._register(C(this.element,t.EventType.DRAG_ENTER,(e=>{this.counter++,this.dragStartTime=e.timeStamp,this.callbacks.onDragEnter?.(e)}))),this._register(C(this.element,t.EventType.DRAG_OVER,(e=>{e.preventDefault(),this.callbacks.onDragOver?.(e,e.timeStamp-this.dragStartTime)}))),this._register(C(this.element,t.EventType.DRAG_LEAVE,(e=>{this.counter--,0===this.counter&&(this.dragStartTime=0,this.callbacks.onDragLeave?.(e))}))),this._register(C(this.element,t.EventType.DRAG_END,(e=>{this.counter=0,this.dragStartTime=0,this.callbacks.onDragEnd?.(e)}))),this._register(C(this.element,t.EventType.DROP,(e=>{this.counter=0,this.dragStartTime=0,this.callbacks.onDrop?.(e)})))}}t.DragAndDropObserver=ue;const _e=/(?[\w\-]+)?(?:#(?[\w\-]+))?(?(?:\.(?:[\w\-]+))*)(?:@(?(?:[\w\_])+))?/;function fe(e){return e.replace(/([a-z])([A-Z])/g,"$1-$2").toLowerCase()}function pe(e,t,i){for(const{name:s,value:r}of e.attributes)i&&!i.includes(s)||t.setAttribute(s,r)}function ge(e,t,i){const s=e.getAttribute(i);s?t.setAttribute(i,s):t.removeAttribute(i)}t.SafeTriangle=class{constructor(e,t,i){this.originX=e,this.originY=t,this.triangles=[];const{top:s,left:r,right:n,bottom:o}=i.getBoundingClientRect(),a=this.triangles;let l=0;a[l++]=r,a[l++]=s,a[l++]=n,a[l++]=s,a[l++]=r,a[l++]=s,a[l++]=r,a[l++]=o,a[l++]=n,a[l++]=s,a[l++]=n,a[l++]=o,a[l++]=r,a[l++]=o,a[l++]=n,a[l++]=o}contains(e,t){const{triangles:i,originX:s,originY:r}=this;for(let n=0;n<4;n++)if((0,S.isPointWithinTriangle)(e,t,s,r,i[2*n],i[2*n+1],i[2*n+2],i[2*n+3]))return!0;return!1}}},9675:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FastDomNode=void 0,t.createFastDomNode=function(e){return new i(e)};class i{constructor(e){this.domNode=e,this._maxWidth="",this._width="",this._height="",this._top="",this._left="",this._bottom="",this._right="",this._paddingTop="",this._paddingLeft="",this._paddingBottom="",this._paddingRight="",this._fontFamily="",this._fontWeight="",this._fontSize="",this._fontStyle="",this._fontFeatureSettings="",this._fontVariationSettings="",this._textDecoration="",this._lineHeight="",this._letterSpacing="",this._className="",this._display="",this._position="",this._visibility="",this._color="",this._backgroundColor="",this._layerHint=!1,this._contain="none",this._boxShadow=""}setMaxWidth(e){const t=s(e);this._maxWidth!==t&&(this._maxWidth=t,this.domNode.style.maxWidth=this._maxWidth)}setWidth(e){const t=s(e);this._width!==t&&(this._width=t,this.domNode.style.width=this._width)}setHeight(e){const t=s(e);this._height!==t&&(this._height=t,this.domNode.style.height=this._height)}setTop(e){const t=s(e);this._top!==t&&(this._top=t,this.domNode.style.top=this._top)}setLeft(e){const t=s(e);this._left!==t&&(this._left=t,this.domNode.style.left=this._left)}setBottom(e){const t=s(e);this._bottom!==t&&(this._bottom=t,this.domNode.style.bottom=this._bottom)}setRight(e){const t=s(e);this._right!==t&&(this._right=t,this.domNode.style.right=this._right)}setPaddingTop(e){const t=s(e);this._paddingTop!==t&&(this._paddingTop=t,this.domNode.style.paddingTop=this._paddingTop)}setPaddingLeft(e){const t=s(e);this._paddingLeft!==t&&(this._paddingLeft=t,this.domNode.style.paddingLeft=this._paddingLeft)}setPaddingBottom(e){const t=s(e);this._paddingBottom!==t&&(this._paddingBottom=t,this.domNode.style.paddingBottom=this._paddingBottom)}setPaddingRight(e){const t=s(e);this._paddingRight!==t&&(this._paddingRight=t,this.domNode.style.paddingRight=this._paddingRight)}setFontFamily(e){this._fontFamily!==e&&(this._fontFamily=e,this.domNode.style.fontFamily=this._fontFamily)}setFontWeight(e){this._fontWeight!==e&&(this._fontWeight=e,this.domNode.style.fontWeight=this._fontWeight)}setFontSize(e){const t=s(e);this._fontSize!==t&&(this._fontSize=t,this.domNode.style.fontSize=this._fontSize)}setFontStyle(e){this._fontStyle!==e&&(this._fontStyle=e,this.domNode.style.fontStyle=this._fontStyle)}setFontFeatureSettings(e){this._fontFeatureSettings!==e&&(this._fontFeatureSettings=e,this.domNode.style.fontFeatureSettings=this._fontFeatureSettings)}setFontVariationSettings(e){this._fontVariationSettings!==e&&(this._fontVariationSettings=e,this.domNode.style.fontVariationSettings=this._fontVariationSettings)}setTextDecoration(e){this._textDecoration!==e&&(this._textDecoration=e,this.domNode.style.textDecoration=this._textDecoration)}setLineHeight(e){const t=s(e);this._lineHeight!==t&&(this._lineHeight=t,this.domNode.style.lineHeight=this._lineHeight)}setLetterSpacing(e){const t=s(e);this._letterSpacing!==t&&(this._letterSpacing=t,this.domNode.style.letterSpacing=this._letterSpacing)}setClassName(e){this._className!==e&&(this._className=e,this.domNode.className=this._className)}toggleClassName(e,t){this.domNode.classList.toggle(e,t),this._className=this.domNode.className}setDisplay(e){this._display!==e&&(this._display=e,this.domNode.style.display=this._display)}setPosition(e){this._position!==e&&(this._position=e,this.domNode.style.position=this._position)}setVisibility(e){this._visibility!==e&&(this._visibility=e,this.domNode.style.visibility=this._visibility)}setColor(e){this._color!==e&&(this._color=e,this.domNode.style.color=this._color)}setBackgroundColor(e){this._backgroundColor!==e&&(this._backgroundColor=e,this.domNode.style.backgroundColor=this._backgroundColor)}setLayerHinting(e){this._layerHint!==e&&(this._layerHint=e,this.domNode.style.transform=this._layerHint?"translate3d(0px, 0px, 0px)":"")}setBoxShadow(e){this._boxShadow!==e&&(this._boxShadow=e,this.domNode.style.boxShadow=e)}setContain(e){this._contain!==e&&(this._contain=e,this.domNode.style.contain=this._contain)}setAttribute(e,t){this.domNode.setAttribute(e,t)}removeAttribute(e){this.domNode.removeAttribute(e)}appendChild(e){this.domNode.appendChild(e.domNode)}removeChild(e){this.domNode.removeChild(e.domNode)}}function s(e){return"number"==typeof e?`${e}px`:e}t.FastDomNode=i},8328:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.GlobalPointerMoveMonitor=void 0;const o=n(i(7093)),a=i(7150);t.GlobalPointerMoveMonitor=class{constructor(){this._hooks=new a.DisposableStore,this._pointerMoveCallback=null,this._onStopCallback=null}dispose(){this.stopMonitoring(!1),this._hooks.dispose()}stopMonitoring(e,t){if(!this.isMonitoring())return;this._hooks.clear(),this._pointerMoveCallback=null;const i=this._onStopCallback;this._onStopCallback=null,e&&i&&i(t)}isMonitoring(){return!!this._pointerMoveCallback}startMonitoring(e,t,i,s,r){this.isMonitoring()&&this.stopMonitoring(!1),this._pointerMoveCallback=s,this._onStopCallback=r;let n=e;try{e.setPointerCapture(t),this._hooks.add((0,a.toDisposable)((()=>{try{e.releasePointerCapture(t)}catch(e){}})))}catch(t){n=o.getWindow(e)}this._hooks.add(o.addDisposableListener(n,o.EventType.POINTER_MOVE,(e=>{e.buttons===i?(e.preventDefault(),this._pointerMoveCallback(e)):this.stopMonitoring(!0)}))),this._hooks.add(o.addDisposableListener(n,o.EventType.POINTER_UP,(e=>this.stopMonitoring(!0))))}}},6609:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IframeUtils=void 0,t.parentOriginHash=async function(e,t){if(!crypto.subtle)throw new Error("'crypto.subtle' is not available so webviews will not work. This is likely because the editor is not running in a secure context (https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts).");const i=JSON.stringify({parentOrigin:e,salt:t}),s=(new TextEncoder).encode(i);return function(e){const t=Array.from(new Uint8Array(e)).map((e=>e.toString(16).padStart(2,"0"))).join("");return BigInt(`0x${t}`).toString(32).padStart(52,"0")}(await crypto.subtle.digest("sha-256",s))};const i=new WeakMap;function s(e){if(!e.parent||e.parent===e)return null;try{const t=e.location,i=e.parent.location;if("null"!==t.origin&&"null"!==i.origin&&t.origin!==i.origin)return null}catch(e){return null}return e.parent}t.IframeUtils=class{static getSameOriginWindowChain(e){let t=i.get(e);if(!t){t=[],i.set(e,t);let r,n=e;do{r=s(n),r?t.push({window:new WeakRef(n),iframeElement:n.frameElement||null}):t.push({window:new WeakRef(n),iframeElement:null}),n=r}while(n)}return t.slice(0)}static getPositionOfChildWindowRelativeToAncestorWindow(e,t){if(!t||e===t)return{top:0,left:0};let i=0,s=0;const r=this.getSameOriginWindowChain(e);for(const e of r){const r=e.window.deref();if(i+=r?.scrollY??0,s+=r?.scrollX??0,r===t)break;if(!e.iframeElement)break;const n=e.iframeElement.getBoundingClientRect();i+=n.top,s+=n.left}return{top:i,left:s}}}},5394:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.StandardKeyboardEvent=void 0,t.printKeyboardEvent=function(e){const t=[];return e.ctrlKey&&t.push("ctrl"),e.shiftKey&&t.push("shift"),e.altKey&&t.push("alt"),e.metaKey&&t.push("meta"),`modifiers: [${t.join(",")}], code: ${e.code}, keyCode: ${e.keyCode}, key: ${e.key}`},t.printStandardKeyboardEvent=function(e){const t=[];return e.ctrlKey&&t.push("ctrl"),e.shiftKey&&t.push("shift"),e.altKey&&t.push("alt"),e.metaKey&&t.push("meta"),`modifiers: [${t.join(",")}], code: ${e.code}, keyCode: ${e.keyCode} ('${a.KeyCodeUtils.toString(e.keyCode)}')`};const o=n(i(4333)),a=i(7883),l=i(2811),h=n(i(8163)),c=h.isMacintosh?a.KeyMod.WinCtrl:a.KeyMod.CtrlCmd,d=a.KeyMod.Alt,u=a.KeyMod.Shift,_=h.isMacintosh?a.KeyMod.CtrlCmd:a.KeyMod.WinCtrl;t.StandardKeyboardEvent=class{constructor(e){this._standardKeyboardEventBrand=!0;const t=e;this.browserEvent=t,this.target=t.target,this.ctrlKey=t.ctrlKey,this.shiftKey=t.shiftKey,this.altKey=t.altKey,this.metaKey=t.metaKey,this.altGraphKey=t.getModifierState?.("AltGraph"),this.keyCode=function(e){if(e.charCode){const t=String.fromCharCode(e.charCode).toUpperCase();return a.KeyCodeUtils.fromString(t)}const t=e.keyCode;if(3===t)return a.KeyCode.PauseBreak;if(o.isFirefox)switch(t){case 59:return a.KeyCode.Semicolon;case 60:if(h.isLinux)return a.KeyCode.IntlBackslash;break;case 61:return a.KeyCode.Equal;case 107:return a.KeyCode.NumpadAdd;case 109:return a.KeyCode.NumpadSubtract;case 173:return a.KeyCode.Minus;case 224:if(h.isMacintosh)return a.KeyCode.Meta}else if(o.isWebKit){if(h.isMacintosh&&93===t)return a.KeyCode.Meta;if(!h.isMacintosh&&92===t)return a.KeyCode.Meta}return a.EVENT_KEY_CODE_MAP[t]||a.KeyCode.Unknown}(t),this.code=t.code,this.ctrlKey=this.ctrlKey||this.keyCode===a.KeyCode.Ctrl,this.altKey=this.altKey||this.keyCode===a.KeyCode.Alt,this.shiftKey=this.shiftKey||this.keyCode===a.KeyCode.Shift,this.metaKey=this.metaKey||this.keyCode===a.KeyCode.Meta,this._asKeybinding=this._computeKeybinding(),this._asKeyCodeChord=this._computeKeyCodeChord()}preventDefault(){this.browserEvent&&this.browserEvent.preventDefault&&this.browserEvent.preventDefault()}stopPropagation(){this.browserEvent&&this.browserEvent.stopPropagation&&this.browserEvent.stopPropagation()}toKeyCodeChord(){return this._asKeyCodeChord}equals(e){return this._asKeybinding===e}_computeKeybinding(){let e=a.KeyCode.Unknown;this.keyCode!==a.KeyCode.Ctrl&&this.keyCode!==a.KeyCode.Shift&&this.keyCode!==a.KeyCode.Alt&&this.keyCode!==a.KeyCode.Meta&&(e=this.keyCode);let t=0;return this.ctrlKey&&(t|=c),this.altKey&&(t|=d),this.shiftKey&&(t|=u),this.metaKey&&(t|=_),t|=e,t}_computeKeyCodeChord(){let e=a.KeyCode.Unknown;return this.keyCode!==a.KeyCode.Ctrl&&this.keyCode!==a.KeyCode.Shift&&this.keyCode!==a.KeyCode.Alt&&this.keyCode!==a.KeyCode.Meta&&(e=this.keyCode),new l.KeyCodeChord(this.ctrlKey,this.shiftKey,this.altKey,this.metaKey,e)}}},5964:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.StandardWheelEvent=t.DragMouseEvent=t.StandardMouseEvent=void 0;const o=n(i(4333)),a=i(6609),l=n(i(8163));class h{constructor(e,t){this.timestamp=Date.now(),this.browserEvent=t,this.leftButton=0===t.button,this.middleButton=1===t.button,this.rightButton=2===t.button,this.buttons=t.buttons,this.target=t.target,this.detail=t.detail||1,"dblclick"===t.type&&(this.detail=2),this.ctrlKey=t.ctrlKey,this.shiftKey=t.shiftKey,this.altKey=t.altKey,this.metaKey=t.metaKey,"number"==typeof t.pageX?(this.posx=t.pageX,this.posy=t.pageY):(this.posx=t.clientX+this.target.ownerDocument.body.scrollLeft+this.target.ownerDocument.documentElement.scrollLeft,this.posy=t.clientY+this.target.ownerDocument.body.scrollTop+this.target.ownerDocument.documentElement.scrollTop);const i=a.IframeUtils.getPositionOfChildWindowRelativeToAncestorWindow(e,t.view);this.posx-=i.left,this.posy-=i.top}preventDefault(){this.browserEvent.preventDefault()}stopPropagation(){this.browserEvent.stopPropagation()}}t.StandardMouseEvent=h,t.DragMouseEvent=class extends h{constructor(e,t){super(e,t),this.dataTransfer=t.dataTransfer}},t.StandardWheelEvent=class{constructor(e,t=0,i=0){this.browserEvent=e||null,this.target=e?e.target||e.targetNode||e.srcElement:null,this.deltaY=i,this.deltaX=t;let s=!1;if(o.isChrome){const e=navigator.userAgent.match(/Chrome\/(\d+)/);s=(e?parseInt(e[1]):123)<=122}if(e){const t=e,i=e,r=e.view?.devicePixelRatio||1;if(void 0!==t.wheelDeltaY)this.deltaY=s?t.wheelDeltaY/(120*r):t.wheelDeltaY/120;else if(void 0!==i.VERTICAL_AXIS&&i.axis===i.VERTICAL_AXIS)this.deltaY=-i.detail/3;else if("wheel"===e.type){const t=e;t.deltaMode===t.DOM_DELTA_LINE?o.isFirefox&&!l.isMacintosh?this.deltaY=-e.deltaY/3:this.deltaY=-e.deltaY:this.deltaY=-e.deltaY/40}if(void 0!==t.wheelDeltaX)o.isSafari&&l.isWindows?this.deltaX=-t.wheelDeltaX/120:this.deltaX=s?t.wheelDeltaX/(120*r):t.wheelDeltaX/120;else if(void 0!==i.HORIZONTAL_AXIS&&i.axis===i.HORIZONTAL_AXIS)this.deltaX=-e.detail/3;else if("wheel"===e.type){const t=e;t.deltaMode===t.DOM_DELTA_LINE?o.isFirefox&&!l.isMacintosh?this.deltaX=-e.deltaX/3:this.deltaX=-e.deltaX:this.deltaX=-e.deltaX/40}0===this.deltaY&&0===this.deltaX&&e.wheelDelta&&(this.deltaY=s?e.wheelDelta/(120*r):e.wheelDelta/120)}}preventDefault(){this.browserEvent?.preventDefault()}stopPropagation(){this.browserEvent?.stopPropagation()}}},8594:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},o=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.Gesture=t.EventType=void 0;const a=o(i(7093)),l=i(4693),h=o(i(3058)),c=i(4838),d=i(802),u=i(7150),_=i(6317);var f;!function(e){e.Tap="-xterm-gesturetap",e.Change="-xterm-gesturechange",e.Start="-xterm-gesturestart",e.End="-xterm-gesturesend",e.Contextmenu="-xterm-gesturecontextmenu"}(f||(t.EventType=f={}));class p extends u.Disposable{static{this.SCROLL_FRICTION=-.005}static{this.HOLD_DELAY=700}static{this.CLEAR_TAP_COUNT_TIME=400}constructor(){super(),this.dispatched=!1,this.targets=new _.LinkedList,this.ignoreTargets=new _.LinkedList,this.activeTouches={},this.handle=null,this._lastSetTapCountTime=0,this._register(d.Event.runAndSubscribe(a.onDidRegisterWindow,(({window:e,disposables:t})=>{t.add(a.addDisposableListener(e.document,"touchstart",(e=>this.onTouchStart(e)),{passive:!1})),t.add(a.addDisposableListener(e.document,"touchend",(t=>this.onTouchEnd(e,t)))),t.add(a.addDisposableListener(e.document,"touchmove",(e=>this.onTouchMove(e)),{passive:!1}))}),{window:l.mainWindow,disposables:this._store}))}static addTarget(e){if(!p.isTouchDevice())return u.Disposable.None;p.INSTANCE||(p.INSTANCE=(0,u.markAsSingleton)(new p));const t=p.INSTANCE.targets.push(e);return(0,u.toDisposable)(t)}static ignoreTarget(e){if(!p.isTouchDevice())return u.Disposable.None;p.INSTANCE||(p.INSTANCE=(0,u.markAsSingleton)(new p));const t=p.INSTANCE.ignoreTargets.push(e);return(0,u.toDisposable)(t)}static isTouchDevice(){return"ontouchstart"in l.mainWindow||navigator.maxTouchPoints>0}dispose(){this.handle&&(this.handle.dispose(),this.handle=null),super.dispose()}onTouchStart(e){const t=Date.now();this.handle&&(this.handle.dispose(),this.handle=null);for(let i=0,s=e.targetTouches.length;i=p.HOLD_DELAY&&Math.abs(o.initialPageX-h.tail(o.rollingPageX))<30&&Math.abs(o.initialPageY-h.tail(o.rollingPageY))<30){const e=this.newGestureEvent(f.Contextmenu,o.initialTarget);e.pageX=h.tail(o.rollingPageX),e.pageY=h.tail(o.rollingPageY),this.dispatchEvent(e)}else if(1===s){const t=h.tail(o.rollingPageX),s=h.tail(o.rollingPageY),r=h.tail(o.rollingTimestamps)-o.rollingTimestamps[0],n=t-o.rollingPageX[0],a=s-o.rollingPageY[0],l=[...this.targets].filter((e=>o.initialTarget instanceof Node&&e.contains(o.initialTarget)));this.inertia(e,l,i,Math.abs(n)/r,n>0?1:-1,t,Math.abs(a)/r,a>0?1:-1,s)}this.dispatchEvent(this.newGestureEvent(f.End,o.initialTarget)),delete this.activeTouches[n.identifier]}this.dispatched&&(t.preventDefault(),t.stopPropagation(),this.dispatched=!1)}newGestureEvent(e,t){const i=document.createEvent("CustomEvent");return i.initEvent(e,!1,!0),i.initialTarget=t,i.tapCount=0,i}dispatchEvent(e){if(e.type===f.Tap){const t=(new Date).getTime();let i=0;i=t-this._lastSetTapCountTime>p.CLEAR_TAP_COUNT_TIME?1:2,this._lastSetTapCountTime=t,e.tapCount=i}else e.type!==f.Change&&e.type!==f.Contextmenu||(this._lastSetTapCountTime=0);if(e.initialTarget instanceof Node){for(const t of this.ignoreTargets)if(t.contains(e.initialTarget))return;const t=[];for(const i of this.targets)if(i.contains(e.initialTarget)){let s=0,r=e.initialTarget;for(;r&&r!==i;)s++,r=r.parentElement;t.push([s,i])}t.sort(((e,t)=>e[0]-t[0]));for(const[i,s]of t)s.dispatchEvent(e),this.dispatched=!0}}inertia(e,t,i,s,r,n,o,l,h){this.handle=a.scheduleAtNextAnimationFrame(e,(()=>{const a=Date.now(),c=a-i;let d=0,u=0,_=!0;s+=p.SCROLL_FRICTION*c,o+=p.SCROLL_FRICTION*c,s>0&&(_=!1,d=r*s*c),o>0&&(_=!1,u=l*o*c);const g=this.newGestureEvent(f.Change);g.translationX=d,g.translationY=u,t.forEach((e=>e.dispatchEvent(g))),_||this.inertia(e,t,a,s,r,n+d,o,l,h+u)}))}onTouchMove(e){const t=Date.now();for(let i=0,s=e.changedTouches.length;i3&&(r.rollingPageX.shift(),r.rollingPageY.shift(),r.rollingTimestamps.shift()),r.rollingPageX.push(s.pageX),r.rollingPageY.push(s.pageY),r.rollingTimestamps.push(t)}this.dispatched&&(e.preventDefault(),e.stopPropagation(),this.dispatched=!1)}}t.Gesture=p,n([c.memoize],p,"isTouchDevice",null)},8801:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.AbstractScrollbar=void 0;const o=n(i(7093)),a=i(9675),l=i(8328),h=i(8974),c=i(79),d=i(8286),u=n(i(8163));class _ extends d.Widget{constructor(e){super(),this._lazyRender=e.lazyRender,this._host=e.host,this._scrollable=e.scrollable,this._scrollByPage=e.scrollByPage,this._scrollbarState=e.scrollbarState,this._visibilityController=this._register(new c.ScrollbarVisibilityController(e.visibility,"visible scrollbar "+e.extraScrollbarClassName,"invisible scrollbar "+e.extraScrollbarClassName)),this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._pointerMoveMonitor=this._register(new l.GlobalPointerMoveMonitor),this._shouldRender=!0,this.domNode=(0,a.createFastDomNode)(document.createElement("div")),this.domNode.setAttribute("role","presentation"),this.domNode.setAttribute("aria-hidden","true"),this._visibilityController.setDomNode(this.domNode),this.domNode.setPosition("absolute"),this._register(o.addDisposableListener(this.domNode.domNode,o.EventType.POINTER_DOWN,(e=>this._domNodePointerDown(e))))}_createArrow(e){const t=this._register(new h.ScrollbarArrow(e));this.domNode.domNode.appendChild(t.bgDomNode),this.domNode.domNode.appendChild(t.domNode)}_createSlider(e,t,i,s){this.slider=(0,a.createFastDomNode)(document.createElement("div")),this.slider.setClassName("slider"),this.slider.setPosition("absolute"),this.slider.setTop(e),this.slider.setLeft(t),"number"==typeof i&&this.slider.setWidth(i),"number"==typeof s&&this.slider.setHeight(s),this.slider.setLayerHinting(!0),this.slider.setContain("strict"),this.domNode.domNode.appendChild(this.slider.domNode),this._register(o.addDisposableListener(this.slider.domNode,o.EventType.POINTER_DOWN,(e=>{0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))}))),this.onclick(this.slider.domNode,(e=>{e.leftButton&&e.stopPropagation()}))}_onElementSize(e){return this._scrollbarState.setVisibleSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_onElementScrollSize(e){return this._scrollbarState.setScrollSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_onElementScrollPosition(e){return this._scrollbarState.setScrollPosition(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}beginReveal(){this._visibilityController.setShouldBeVisible(!0)}beginHide(){this._visibilityController.setShouldBeVisible(!1)}render(){this._shouldRender&&(this._shouldRender=!1,this._renderDomNode(this._scrollbarState.getRectangleLargeSize(),this._scrollbarState.getRectangleSmallSize()),this._updateSlider(this._scrollbarState.getSliderSize(),this._scrollbarState.getArrowSize()+this._scrollbarState.getSliderPosition()))}_domNodePointerDown(e){e.target===this.domNode.domNode&&this._onPointerDown(e)}delegatePointerDown(e){const t=this.domNode.domNode.getClientRects()[0].top,i=t+this._scrollbarState.getSliderPosition(),s=t+this._scrollbarState.getSliderPosition()+this._scrollbarState.getSliderSize(),r=this._sliderPointerPosition(e);i<=r&&r<=s?0===e.button&&(e.preventDefault(),this._sliderPointerDown(e)):this._onPointerDown(e)}_onPointerDown(e){let t,i;if(e.target===this.domNode.domNode&&"number"==typeof e.offsetX&&"number"==typeof e.offsetY)t=e.offsetX,i=e.offsetY;else{const s=o.getDomNodePagePosition(this.domNode.domNode);t=e.pageX-s.left,i=e.pageY-s.top}const s=this._pointerDownRelativePosition(t,i);this._setDesiredScrollPositionNow(this._scrollByPage?this._scrollbarState.getDesiredScrollPositionFromOffsetPaged(s):this._scrollbarState.getDesiredScrollPositionFromOffset(s)),0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))}_sliderPointerDown(e){if(!(e.target&&e.target instanceof Element))return;const t=this._sliderPointerPosition(e),i=this._sliderOrthogonalPointerPosition(e),s=this._scrollbarState.clone();this.slider.toggleClassName("active",!0),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,(e=>{const r=this._sliderOrthogonalPointerPosition(e),n=Math.abs(r-i);if(u.isWindows&&n>140)return void this._setDesiredScrollPositionNow(s.getScrollPosition());const o=this._sliderPointerPosition(e)-t;this._setDesiredScrollPositionNow(s.getDesiredScrollPositionFromDelta(o))}),(()=>{this.slider.toggleClassName("active",!1),this._host.onDragEnd()})),this._host.onDragStart()}_setDesiredScrollPositionNow(e){const t={};this.writeScrollPosition(t,e),this._scrollable.setScrollPositionNow(t)}updateScrollbarSize(e){this._updateScrollbarSize(e),this._scrollbarState.setScrollbarSize(e),this._shouldRender=!0,this._lazyRender||this.render()}isNeeded(){return this._scrollbarState.isNeeded()}}t.AbstractScrollbar=_},151:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.HorizontalScrollbar=void 0;const s=i(8801),r=i(8245),n=i(9881);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition();if(super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(t.horizontalHasArrows?t.arrowSize:0,t.horizontal===n.ScrollbarVisibility.Hidden?0:t.horizontalScrollbarSize,t.vertical===n.ScrollbarVisibility.Hidden?0:t.verticalScrollbarSize,s.width,s.scrollWidth,o.scrollLeft),visibility:t.horizontal,extraScrollbarClassName:"horizontal",scrollable:e,scrollByPage:t.scrollByPage}),t.horizontalHasArrows)throw new Error("horizontalHasArrows is not supported in xterm.js");this._createSlider(Math.floor((t.horizontalScrollbarSize-t.horizontalSliderSize)/2),0,void 0,t.horizontalSliderSize)}_updateSlider(e,t){this.slider.setWidth(e),this.slider.setLeft(t)}_renderDomNode(e,t){this.domNode.setWidth(e),this.domNode.setHeight(t),this.domNode.setLeft(0),this.domNode.setBottom(0)}onDidScroll(e){return this._shouldRender=this._onElementScrollSize(e.scrollWidth)||this._shouldRender,this._shouldRender=this._onElementScrollPosition(e.scrollLeft)||this._shouldRender,this._shouldRender=this._onElementSize(e.width)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return e}_sliderPointerPosition(e){return e.pageX}_sliderOrthogonalPointerPosition(e){return e.pageY}_updateScrollbarSize(e){this.slider.setHeight(e)}writeScrollPosition(e,t){e.scrollLeft=t}updateOptions(e){this.updateScrollbarSize(e.horizontal===n.ScrollbarVisibility.Hidden?0:e.horizontalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(e.vertical===n.ScrollbarVisibility.Hidden?0:e.verticalScrollbarSize),this._visibilityController.setVisibility(e.horizontal),this._scrollByPage=e.scrollByPage}}t.HorizontalScrollbar=o},8234:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.DomScrollableElement=t.SmoothScrollableElement=t.ScrollableElement=t.AbstractScrollableElement=t.MouseWheelClassifier=void 0;const o=i(4333),a=n(i(7093)),l=i(9675),h=i(5964),c=i(151),d=i(5473),u=i(8286),_=i(1758),f=i(802),p=i(7150),g=n(i(8163)),m=i(9881);class v{constructor(e,t,i){this.timestamp=e,this.deltaX=t,this.deltaY=i,this.score=0}}class S{static{this.INSTANCE=new S}constructor(){this._capacity=5,this._memory=[],this._front=-1,this._rear=-1}isPhysicalMouseWheel(){if(-1===this._front&&-1===this._rear)return!1;let e=1,t=0,i=1,s=this._rear;for(;;){const r=s===this._front?e:Math.pow(2,-i);if(e-=r,t+=this._memory[s].score*r,s===this._front)break;s=(this._capacity+s-1)%this._capacity,i++}return t<=.5}acceptStandardWheelEvent(e){if(o.isChrome){const t=a.getWindow(e.browserEvent),i=(0,o.getZoomFactor)(t);this.accept(Date.now(),e.deltaX*i,e.deltaY*i)}else this.accept(Date.now(),e.deltaX,e.deltaY)}accept(e,t,i){let s=null;const r=new v(e,t,i);-1===this._front&&-1===this._rear?(this._memory[0]=r,this._front=0,this._rear=0):(s=this._memory[this._rear],this._rear=(this._rear+1)%this._capacity,this._rear===this._front&&(this._front=(this._front+1)%this._capacity),this._memory[this._rear]=r),r.score=this._computeScore(r,s)}_computeScore(e,t){if(Math.abs(e.deltaX)>0&&Math.abs(e.deltaY)>0)return 1;let i=.5;if(this._isAlmostInt(e.deltaX)&&this._isAlmostInt(e.deltaY)||(i+=.25),t){const s=Math.abs(e.deltaX),r=Math.abs(e.deltaY),n=Math.abs(t.deltaX),o=Math.abs(t.deltaY),a=Math.max(Math.min(s,n),1),l=Math.max(Math.min(r,o),1),h=Math.max(s,n),c=Math.max(r,o);h%a==0&&c%l==0&&(i-=.5)}return Math.min(Math.max(i,0),1)}_isAlmostInt(e){return Math.abs(Math.round(e)-e)<.01}}t.MouseWheelClassifier=S;class b extends u.Widget{get options(){return this._options}constructor(e,t,i){super(),this._onScroll=this._register(new f.Emitter),this.onScroll=this._onScroll.event,this._onWillScroll=this._register(new f.Emitter),this.onWillScroll=this._onWillScroll.event,this._options=function(e){const t={lazyRender:void 0!==e.lazyRender&&e.lazyRender,className:void 0!==e.className?e.className:"",useShadows:void 0===e.useShadows||e.useShadows,handleMouseWheel:void 0===e.handleMouseWheel||e.handleMouseWheel,flipAxes:void 0!==e.flipAxes&&e.flipAxes,consumeMouseWheelIfScrollbarIsNeeded:void 0!==e.consumeMouseWheelIfScrollbarIsNeeded&&e.consumeMouseWheelIfScrollbarIsNeeded,alwaysConsumeMouseWheel:void 0!==e.alwaysConsumeMouseWheel&&e.alwaysConsumeMouseWheel,scrollYToX:void 0!==e.scrollYToX&&e.scrollYToX,mouseWheelScrollSensitivity:void 0!==e.mouseWheelScrollSensitivity?e.mouseWheelScrollSensitivity:1,fastScrollSensitivity:void 0!==e.fastScrollSensitivity?e.fastScrollSensitivity:5,scrollPredominantAxis:void 0===e.scrollPredominantAxis||e.scrollPredominantAxis,mouseWheelSmoothScroll:void 0===e.mouseWheelSmoothScroll||e.mouseWheelSmoothScroll,arrowSize:void 0!==e.arrowSize?e.arrowSize:11,listenOnDomNode:void 0!==e.listenOnDomNode?e.listenOnDomNode:null,horizontal:void 0!==e.horizontal?e.horizontal:m.ScrollbarVisibility.Auto,horizontalScrollbarSize:void 0!==e.horizontalScrollbarSize?e.horizontalScrollbarSize:10,horizontalSliderSize:void 0!==e.horizontalSliderSize?e.horizontalSliderSize:0,horizontalHasArrows:void 0!==e.horizontalHasArrows&&e.horizontalHasArrows,vertical:void 0!==e.vertical?e.vertical:m.ScrollbarVisibility.Auto,verticalScrollbarSize:void 0!==e.verticalScrollbarSize?e.verticalScrollbarSize:10,verticalHasArrows:void 0!==e.verticalHasArrows&&e.verticalHasArrows,verticalSliderSize:void 0!==e.verticalSliderSize?e.verticalSliderSize:0,scrollByPage:void 0!==e.scrollByPage&&e.scrollByPage};return t.horizontalSliderSize=void 0!==e.horizontalSliderSize?e.horizontalSliderSize:t.horizontalScrollbarSize,t.verticalSliderSize=void 0!==e.verticalSliderSize?e.verticalSliderSize:t.verticalScrollbarSize,g.isMacintosh&&(t.className+=" mac"),t}(t),this._scrollable=i,this._register(this._scrollable.onScroll((e=>{this._onWillScroll.fire(e),this._onDidScroll(e),this._onScroll.fire(e)})));const s={onMouseWheel:e=>this._onMouseWheel(e),onDragStart:()=>this._onDragStart(),onDragEnd:()=>this._onDragEnd()};this._verticalScrollbar=this._register(new d.VerticalScrollbar(this._scrollable,this._options,s)),this._horizontalScrollbar=this._register(new c.HorizontalScrollbar(this._scrollable,this._options,s)),this._domNode=document.createElement("div"),this._domNode.className="xterm-scrollable-element "+this._options.className,this._domNode.setAttribute("role","presentation"),this._domNode.style.position="relative",this._domNode.appendChild(e),this._domNode.appendChild(this._horizontalScrollbar.domNode.domNode),this._domNode.appendChild(this._verticalScrollbar.domNode.domNode),this._options.useShadows?(this._leftShadowDomNode=(0,l.createFastDomNode)(document.createElement("div")),this._leftShadowDomNode.setClassName("shadow"),this._domNode.appendChild(this._leftShadowDomNode.domNode),this._topShadowDomNode=(0,l.createFastDomNode)(document.createElement("div")),this._topShadowDomNode.setClassName("shadow"),this._domNode.appendChild(this._topShadowDomNode.domNode),this._topLeftShadowDomNode=(0,l.createFastDomNode)(document.createElement("div")),this._topLeftShadowDomNode.setClassName("shadow"),this._domNode.appendChild(this._topLeftShadowDomNode.domNode)):(this._leftShadowDomNode=null,this._topShadowDomNode=null,this._topLeftShadowDomNode=null),this._listenOnDomNode=this._options.listenOnDomNode||this._domNode,this._mouseWheelToDispose=[],this._setListeningToMouseWheel(this._options.handleMouseWheel),this.onmouseover(this._listenOnDomNode,(e=>this._onMouseOver(e))),this.onmouseleave(this._listenOnDomNode,(e=>this._onMouseLeave(e))),this._hideTimeout=this._register(new _.TimeoutTimer),this._isDragging=!1,this._mouseIsOver=!1,this._shouldRender=!0,this._revealOnScroll=!0}dispose(){this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),super.dispose()}getDomNode(){return this._domNode}getOverviewRulerLayoutInfo(){return{parent:this._domNode,insertBefore:this._verticalScrollbar.domNode.domNode}}delegateVerticalScrollbarPointerDown(e){this._verticalScrollbar.delegatePointerDown(e)}getScrollDimensions(){return this._scrollable.getScrollDimensions()}setScrollDimensions(e){this._scrollable.setScrollDimensions(e,!1)}updateClassName(e){this._options.className=e,g.isMacintosh&&(this._options.className+=" mac"),this._domNode.className="xterm-scrollable-element "+this._options.className}updateOptions(e){void 0!==e.handleMouseWheel&&(this._options.handleMouseWheel=e.handleMouseWheel,this._setListeningToMouseWheel(this._options.handleMouseWheel)),void 0!==e.mouseWheelScrollSensitivity&&(this._options.mouseWheelScrollSensitivity=e.mouseWheelScrollSensitivity),void 0!==e.fastScrollSensitivity&&(this._options.fastScrollSensitivity=e.fastScrollSensitivity),void 0!==e.scrollPredominantAxis&&(this._options.scrollPredominantAxis=e.scrollPredominantAxis),void 0!==e.horizontal&&(this._options.horizontal=e.horizontal),void 0!==e.vertical&&(this._options.vertical=e.vertical),void 0!==e.horizontalScrollbarSize&&(this._options.horizontalScrollbarSize=e.horizontalScrollbarSize),void 0!==e.verticalScrollbarSize&&(this._options.verticalScrollbarSize=e.verticalScrollbarSize),void 0!==e.scrollByPage&&(this._options.scrollByPage=e.scrollByPage),this._horizontalScrollbar.updateOptions(this._options),this._verticalScrollbar.updateOptions(this._options),this._options.lazyRender||this._render()}setRevealOnScroll(e){this._revealOnScroll=e}delegateScrollFromMouseWheelEvent(e){this._onMouseWheel(new h.StandardWheelEvent(e))}_setListeningToMouseWheel(e){if(this._mouseWheelToDispose.length>0!==e&&(this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),e)){const e=e=>{this._onMouseWheel(new h.StandardWheelEvent(e))};this._mouseWheelToDispose.push(a.addDisposableListener(this._listenOnDomNode,a.EventType.MOUSE_WHEEL,e,{passive:!1}))}}_onMouseWheel(e){if(e.browserEvent?.defaultPrevented)return;const t=S.INSTANCE;t.acceptStandardWheelEvent(e);let i=!1;if(e.deltaY||e.deltaX){let s=e.deltaY*this._options.mouseWheelScrollSensitivity,r=e.deltaX*this._options.mouseWheelScrollSensitivity;this._options.scrollPredominantAxis&&(this._options.scrollYToX&&r+s===0?r=s=0:Math.abs(s)>=Math.abs(r)?r=0:s=0),this._options.flipAxes&&([s,r]=[r,s]);const n=!g.isMacintosh&&e.browserEvent&&e.browserEvent.shiftKey;!this._options.scrollYToX&&!n||r||(r=s,s=0),e.browserEvent&&e.browserEvent.altKey&&(r*=this._options.fastScrollSensitivity,s*=this._options.fastScrollSensitivity);const o=this._scrollable.getFutureScrollPosition();let a={};if(s){const e=50*s,t=o.scrollTop-(e<0?Math.floor(e):Math.ceil(e));this._verticalScrollbar.writeScrollPosition(a,t)}if(r){const e=50*r,t=o.scrollLeft-(e<0?Math.floor(e):Math.ceil(e));this._horizontalScrollbar.writeScrollPosition(a,t)}a=this._scrollable.validateScrollPosition(a),(o.scrollLeft!==a.scrollLeft||o.scrollTop!==a.scrollTop)&&(this._options.mouseWheelSmoothScroll&&t.isPhysicalMouseWheel()?this._scrollable.setScrollPositionSmooth(a):this._scrollable.setScrollPositionNow(a),i=!0)}let s=i;!s&&this._options.alwaysConsumeMouseWheel&&(s=!0),!s&&this._options.consumeMouseWheelIfScrollbarIsNeeded&&(this._verticalScrollbar.isNeeded()||this._horizontalScrollbar.isNeeded())&&(s=!0),s&&(e.preventDefault(),e.stopPropagation())}_onDidScroll(e){this._shouldRender=this._horizontalScrollbar.onDidScroll(e)||this._shouldRender,this._shouldRender=this._verticalScrollbar.onDidScroll(e)||this._shouldRender,this._options.useShadows&&(this._shouldRender=!0),this._revealOnScroll&&this._reveal(),this._options.lazyRender||this._render()}renderNow(){if(!this._options.lazyRender)throw new Error("Please use `lazyRender` together with `renderNow`!");this._render()}_render(){if(this._shouldRender&&(this._shouldRender=!1,this._horizontalScrollbar.render(),this._verticalScrollbar.render(),this._options.useShadows)){const e=this._scrollable.getCurrentScrollPosition(),t=e.scrollTop>0,i=e.scrollLeft>0,s=i?" left":"",r=t?" top":"",n=i||t?" top-left-corner":"";this._leftShadowDomNode.setClassName(`shadow${s}`),this._topShadowDomNode.setClassName(`shadow${r}`),this._topLeftShadowDomNode.setClassName(`shadow${n}${r}${s}`)}}_onDragStart(){this._isDragging=!0,this._reveal()}_onDragEnd(){this._isDragging=!1,this._hide()}_onMouseLeave(e){this._mouseIsOver=!1,this._hide()}_onMouseOver(e){this._mouseIsOver=!0,this._reveal()}_reveal(){this._verticalScrollbar.beginReveal(),this._horizontalScrollbar.beginReveal(),this._scheduleHide()}_hide(){this._mouseIsOver||this._isDragging||(this._verticalScrollbar.beginHide(),this._horizontalScrollbar.beginHide())}_scheduleHide(){this._mouseIsOver||this._isDragging||this._hideTimeout.cancelAndSet((()=>this._hide()),500)}}t.AbstractScrollableElement=b,t.ScrollableElement=class extends b{constructor(e,t){(t=t||{}).mouseWheelSmoothScroll=!1;const i=new m.Scrollable({forceIntegerValues:!0,smoothScrollDuration:0,scheduleAtNextAnimationFrame:t=>a.scheduleAtNextAnimationFrame(a.getWindow(e),t)});super(e,t,i),this._register(i)}setScrollPosition(e){this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}},t.SmoothScrollableElement=class extends b{constructor(e,t,i){super(e,t,i)}setScrollPosition(e){e.reuseAnimation?this._scrollable.setScrollPositionSmooth(e,e.reuseAnimation):this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}},t.DomScrollableElement=class extends b{constructor(e,t){(t=t||{}).mouseWheelSmoothScroll=!1;const i=new m.Scrollable({forceIntegerValues:!1,smoothScrollDuration:0,scheduleAtNextAnimationFrame:t=>a.scheduleAtNextAnimationFrame(a.getWindow(e),t)});super(e,t,i),this._register(i),this._element=e,this._register(this.onScroll((e=>{e.scrollTopChanged&&(this._element.scrollTop=e.scrollTop),e.scrollLeftChanged&&(this._element.scrollLeft=e.scrollLeft)}))),this.scanDomNode()}setScrollPosition(e){this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}scanDomNode(){this.setScrollDimensions({width:this._element.clientWidth,scrollWidth:this._element.scrollWidth,height:this._element.clientHeight,scrollHeight:this._element.scrollHeight}),this.setScrollPosition({scrollLeft:this._element.scrollLeft,scrollTop:this._element.scrollTop})}}},8974:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.ScrollbarArrow=t.ARROW_IMG_SIZE=void 0;const o=i(8328),a=i(8286),l=i(1758),h=n(i(7093));t.ARROW_IMG_SIZE=11;class c extends a.Widget{constructor(e){super(),this._onActivate=e.onActivate,this.bgDomNode=document.createElement("div"),this.bgDomNode.className="arrow-background",this.bgDomNode.style.position="absolute",this.bgDomNode.style.width=e.bgWidth+"px",this.bgDomNode.style.height=e.bgHeight+"px",void 0!==e.top&&(this.bgDomNode.style.top="0px"),void 0!==e.left&&(this.bgDomNode.style.left="0px"),void 0!==e.bottom&&(this.bgDomNode.style.bottom="0px"),void 0!==e.right&&(this.bgDomNode.style.right="0px"),this.domNode=document.createElement("div"),this.domNode.className=e.className,this.domNode.style.position="absolute",this.domNode.style.width=t.ARROW_IMG_SIZE+"px",this.domNode.style.height=t.ARROW_IMG_SIZE+"px",void 0!==e.top&&(this.domNode.style.top=e.top+"px"),void 0!==e.left&&(this.domNode.style.left=e.left+"px"),void 0!==e.bottom&&(this.domNode.style.bottom=e.bottom+"px"),void 0!==e.right&&(this.domNode.style.right=e.right+"px"),this._pointerMoveMonitor=this._register(new o.GlobalPointerMoveMonitor),this._register(h.addStandardDisposableListener(this.bgDomNode,h.EventType.POINTER_DOWN,(e=>this._arrowPointerDown(e)))),this._register(h.addStandardDisposableListener(this.domNode,h.EventType.POINTER_DOWN,(e=>this._arrowPointerDown(e)))),this._pointerdownRepeatTimer=this._register(new h.WindowIntervalTimer),this._pointerdownScheduleRepeatTimer=this._register(new l.TimeoutTimer)}_arrowPointerDown(e){e.target&&e.target instanceof Element&&(this._onActivate(),this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancelAndSet((()=>{this._pointerdownRepeatTimer.cancelAndSet((()=>this._onActivate()),1e3/24,h.getWindow(e))}),200),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,(e=>{}),(()=>{this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancel()})),e.preventDefault())}}t.ScrollbarArrow=c},8245:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ScrollbarState=void 0;class i{constructor(e,t,i,s,r,n){this._scrollbarSize=Math.round(t),this._oppositeScrollbarSize=Math.round(i),this._arrowSize=Math.round(e),this._visibleSize=s,this._scrollSize=r,this._scrollPosition=n,this._computedAvailableSize=0,this._computedIsNeeded=!1,this._computedSliderSize=0,this._computedSliderRatio=0,this._computedSliderPosition=0,this._refreshComputedValues()}clone(){return new i(this._arrowSize,this._scrollbarSize,this._oppositeScrollbarSize,this._visibleSize,this._scrollSize,this._scrollPosition)}setVisibleSize(e){const t=Math.round(e);return this._visibleSize!==t&&(this._visibleSize=t,this._refreshComputedValues(),!0)}setScrollSize(e){const t=Math.round(e);return this._scrollSize!==t&&(this._scrollSize=t,this._refreshComputedValues(),!0)}setScrollPosition(e){const t=Math.round(e);return this._scrollPosition!==t&&(this._scrollPosition=t,this._refreshComputedValues(),!0)}setScrollbarSize(e){this._scrollbarSize=Math.round(e)}setOppositeScrollbarSize(e){this._oppositeScrollbarSize=Math.round(e)}static _computeValues(e,t,i,s,r){const n=Math.max(0,i-e),o=Math.max(0,n-2*t),a=s>0&&s>i;if(!a)return{computedAvailableSize:Math.round(n),computedIsNeeded:a,computedSliderSize:Math.round(o),computedSliderRatio:0,computedSliderPosition:0};const l=Math.round(Math.max(20,Math.floor(i*o/s))),h=(o-l)/(s-i),c=r*h;return{computedAvailableSize:Math.round(n),computedIsNeeded:a,computedSliderSize:Math.round(l),computedSliderRatio:h,computedSliderPosition:Math.round(c)}}_refreshComputedValues(){const e=i._computeValues(this._oppositeScrollbarSize,this._arrowSize,this._visibleSize,this._scrollSize,this._scrollPosition);this._computedAvailableSize=e.computedAvailableSize,this._computedIsNeeded=e.computedIsNeeded,this._computedSliderSize=e.computedSliderSize,this._computedSliderRatio=e.computedSliderRatio,this._computedSliderPosition=e.computedSliderPosition}getArrowSize(){return this._arrowSize}getScrollPosition(){return this._scrollPosition}getRectangleLargeSize(){return this._computedAvailableSize}getRectangleSmallSize(){return this._scrollbarSize}isNeeded(){return this._computedIsNeeded}getSliderSize(){return this._computedSliderSize}getSliderPosition(){return this._computedSliderPosition}getDesiredScrollPositionFromOffset(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize-this._computedSliderSize/2;return Math.round(t/this._computedSliderRatio)}getDesiredScrollPositionFromOffsetPaged(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize;let i=this._scrollPosition;return t{Object.defineProperty(t,"__esModule",{value:!0}),t.ScrollbarVisibilityController=void 0;const s=i(1758),r=i(7150),n=i(9881);class o extends r.Disposable{constructor(e,t,i){super(),this._visibility=e,this._visibleClassName=t,this._invisibleClassName=i,this._domNode=null,this._isVisible=!1,this._isNeeded=!1,this._rawShouldBeVisible=!1,this._shouldBeVisible=!1,this._revealTimer=this._register(new s.TimeoutTimer)}setVisibility(e){this._visibility!==e&&(this._visibility=e,this._updateShouldBeVisible())}setShouldBeVisible(e){this._rawShouldBeVisible=e,this._updateShouldBeVisible()}_applyVisibilitySetting(){return this._visibility!==n.ScrollbarVisibility.Hidden&&(this._visibility===n.ScrollbarVisibility.Visible||this._rawShouldBeVisible)}_updateShouldBeVisible(){const e=this._applyVisibilitySetting();this._shouldBeVisible!==e&&(this._shouldBeVisible=e,this.ensureVisibility())}setIsNeeded(e){this._isNeeded!==e&&(this._isNeeded=e,this.ensureVisibility())}setDomNode(e){this._domNode=e,this._domNode.setClassName(this._invisibleClassName),this.setShouldBeVisible(!1)}ensureVisibility(){this._isNeeded?this._shouldBeVisible?this._reveal():this._hide(!0):this._hide(!1)}_reveal(){this._isVisible||(this._isVisible=!0,this._revealTimer.setIfNotSet((()=>{this._domNode?.setClassName(this._visibleClassName)}),0))}_hide(e){this._revealTimer.cancel(),this._isVisible&&(this._isVisible=!1,this._domNode?.setClassName(this._invisibleClassName+(e?" fade":"")))}}t.ScrollbarVisibilityController=o},5473:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.VerticalScrollbar=void 0;const s=i(8801),r=i(8245),n=i(9881);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition();if(super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(t.verticalHasArrows?t.arrowSize:0,t.vertical===n.ScrollbarVisibility.Hidden?0:t.verticalScrollbarSize,0,s.height,s.scrollHeight,o.scrollTop),visibility:t.vertical,extraScrollbarClassName:"vertical",scrollable:e,scrollByPage:t.scrollByPage}),t.verticalHasArrows)throw new Error("horizontalHasArrows is not supported in xterm.js");this._createSlider(0,Math.floor((t.verticalScrollbarSize-t.verticalSliderSize)/2),t.verticalSliderSize,void 0)}_updateSlider(e,t){this.slider.setHeight(e),this.slider.setTop(t)}_renderDomNode(e,t){this.domNode.setWidth(t),this.domNode.setHeight(e),this.domNode.setRight(0),this.domNode.setTop(0)}onDidScroll(e){return this._shouldRender=this._onElementScrollSize(e.scrollHeight)||this._shouldRender,this._shouldRender=this._onElementScrollPosition(e.scrollTop)||this._shouldRender,this._shouldRender=this._onElementSize(e.height)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return t}_sliderPointerPosition(e){return e.pageY}_sliderOrthogonalPointerPosition(e){return e.pageX}_updateScrollbarSize(e){this.slider.setWidth(e)}writeScrollPosition(e,t){e.scrollTop=t}updateOptions(e){this.updateScrollbarSize(e.vertical===n.ScrollbarVisibility.Hidden?0:e.verticalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(0),this._visibilityController.setVisibility(e.vertical),this._scrollByPage=e.scrollByPage}}t.VerticalScrollbar=o},8286:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.Widget=void 0;const o=n(i(7093)),a=i(5394),l=i(5964),h=i(8594),c=i(7150);class d extends c.Disposable{onclick(e,t){this._register(o.addDisposableListener(e,o.EventType.CLICK,(i=>t(new l.StandardMouseEvent(o.getWindow(e),i)))))}onmousedown(e,t){this._register(o.addDisposableListener(e,o.EventType.MOUSE_DOWN,(i=>t(new l.StandardMouseEvent(o.getWindow(e),i)))))}onmouseover(e,t){this._register(o.addDisposableListener(e,o.EventType.MOUSE_OVER,(i=>t(new l.StandardMouseEvent(o.getWindow(e),i)))))}onmouseleave(e,t){this._register(o.addDisposableListener(e,o.EventType.MOUSE_LEAVE,(i=>t(new l.StandardMouseEvent(o.getWindow(e),i)))))}onkeydown(e,t){this._register(o.addDisposableListener(e,o.EventType.KEY_DOWN,(e=>t(new a.StandardKeyboardEvent(e)))))}onkeyup(e,t){this._register(o.addDisposableListener(e,o.EventType.KEY_UP,(e=>t(new a.StandardKeyboardEvent(e)))))}oninput(e,t){this._register(o.addDisposableListener(e,o.EventType.INPUT,t))}onblur(e,t){this._register(o.addDisposableListener(e,o.EventType.BLUR,t))}onfocus(e,t){this._register(o.addDisposableListener(e,o.EventType.FOCUS,t))}onchange(e,t){this._register(o.addDisposableListener(e,o.EventType.CHANGE,t))}ignoreGesture(e){return h.Gesture.ignoreTarget(e)}}t.Widget=d},4693:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.mainWindow=void 0,t.ensureCodeWindow=function(e,t){},t.mainWindow="object"==typeof window?window:globalThis},3058:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Permutation=t.CallbackIterable=t.ArrayQueue=t.booleanComparator=t.numberComparator=t.CompareResult=void 0,t.tail=function(e,t=0){return e[e.length-(1+t)]},t.tail2=function(e){if(0===e.length)throw new Error("Invalid tail call");return[e.slice(0,e.length-1),e[e.length-1]]},t.equals=function(e,t,i=(e,t)=>e===t){if(e===t)return!0;if(!e||!t)return!1;if(e.length!==t.length)return!1;for(let s=0,r=e.length;si(e[s],t)))},t.binarySearch2=n,t.quickSelect=function e(t,i,s){if((t|=0)>=i.length)throw new TypeError("invalid index");const r=i[Math.floor(i.length*Math.random())],n=[],o=[],a=[];for(const e of i){const t=s(e,r);t<0?n.push(e):t>0?o.push(e):a.push(e)}return t{(async()=>{const o=e.length,l=e.slice(0,i).sort(t);for(let h=i,c=Math.min(i+r,o);hi&&await new Promise((e=>setTimeout(e))),n&&n.isCancellationRequested)throw new s.CancellationError;a(e,t,l,h,c)}return l})().then(o,l)}))},t.coalesce=function(e){return e.filter((e=>!!e))},t.coalesceInPlace=function(e){let t=0;for(let i=0;i0},t.distinct=function(e,t=e=>e){const i=new Set;return e.filter((e=>{const s=t(e);return!i.has(s)&&(i.add(s),!0)}))},t.uniqueFilter=function(e){const t=new Set;return i=>{const s=e(i);return!t.has(s)&&(t.add(s),!0)}},t.firstOrDefault=function(e,t){return e.length>0?e[0]:t},t.lastOrDefault=function(e,t){return e.length>0?e[e.length-1]:t},t.commonPrefixLength=function(e,t,i=(e,t)=>e===t){let s=0;for(let r=0,n=Math.min(e.length,t.length);rt;e--)s.push(e);return s},t.index=function(e,t,i){return e.reduce(((e,s)=>(e[t(s)]=i?i(s):s,e)),Object.create(null))},t.insert=function(e,t){return e.push(t),()=>l(e,t)},t.remove=l,t.arrayInsert=function(e,t,i){const s=e.slice(0,t),r=e.slice(t);return s.concat(i,r)},t.shuffle=function(e,t){let i;if("number"==typeof t){let e=t;i=()=>{const t=179426549*Math.sin(e++);return t-Math.floor(t)}}else i=Math.random;for(let t=e.length-1;t>0;t-=1){const s=Math.floor(i()*(t+1)),r=e[t];e[t]=e[s],e[s]=r}},t.pushToStart=function(e,t){const i=e.indexOf(t);i>-1&&(e.splice(i,1),e.unshift(t))},t.pushToEnd=function(e,t){const i=e.indexOf(t);i>-1&&(e.splice(i,1),e.push(t))},t.pushMany=function(e,t){for(const i of t)e.push(i)},t.mapArrayOrNot=function(e,t){return Array.isArray(e)?e.map(t):t(e)},t.asArray=function(e){return Array.isArray(e)?e:[e]},t.getRandomElement=function(e){return e[Math.floor(Math.random()*e.length)]},t.insertInto=h,t.splice=function(e,t,i,s){const r=c(e,t);let n=e.splice(r,i);return void 0===n&&(n=[]),h(e,r,s),n},t.compareBy=function(e,t){return(i,s)=>t(e(i),e(s))},t.tieBreakComparators=function(...e){return(t,i)=>{for(const s of e){const e=s(t,i);if(!d.isNeitherLessOrGreaterThan(e))return e}return d.neitherLessOrGreaterThan}},t.reverseOrder=function(e){return(t,i)=>-e(t,i)};const s=i(9807),r=i(8297);function n(e,t){let i=0,s=e-1;for(;i<=s;){const e=(i+s)/2|0,r=t(e);if(r<0)i=e+1;else{if(!(r>0))return e;s=e-1}}return-(i+1)}function o(e,t,i){const s=[];function r(e,t,i){if(0===t&&0===i.length)return;const r=s[s.length-1];r&&r.start+r.deleteCount===e?(r.deleteCount+=t,r.toInsert.push(...i)):s.push({start:e,deleteCount:t,toInsert:i})}let n=0,o=0;for(;;){if(n===e.length){r(n,0,t.slice(o));break}if(o===t.length){r(n,e.length-n,[]);break}const s=e[n],a=t[o],l=i(s,a);0===l?(n+=1,o+=1):l<0?(r(n,1,[]),n+=1):l>0&&(r(n,0,[a]),o+=1)}return s}function a(e,t,i,s,n){for(const o=i.length;st(n,e)<0));i.splice(e,0,n)}}}function l(e,t){const i=e.indexOf(t);if(i>-1)return e.splice(i,1),t}function h(e,t,i){const s=c(e,t),r=e.length,n=i.length;e.length=r+n;for(let t=r-1;t>=s;t--)e[t+n]=e[t];for(let t=0;t0},e.isNeitherLessOrGreaterThan=function(e){return 0===e},e.greaterThan=1,e.lessThan=-1,e.neitherLessOrGreaterThan=0}(d||(t.CompareResult=d={})),t.numberComparator=(e,t)=>e-t,t.booleanComparator=(e,i)=>(0,t.numberComparator)(e?1:0,i?1:0),t.ArrayQueue=class{constructor(e){this.items=e,this.firstIdx=0,this.lastIdx=this.items.length-1}get length(){return this.lastIdx-this.firstIdx+1}takeWhile(e){let t=this.firstIdx;for(;t=0&&e(this.items[t]);)t--;const i=t===this.lastIdx?null:this.items.slice(t+1,this.lastIdx+1);return this.lastIdx=t,i}peek(){if(0!==this.length)return this.items[this.firstIdx]}peekLast(){if(0!==this.length)return this.items[this.lastIdx]}dequeue(){const e=this.items[this.firstIdx];return this.firstIdx++,e}removeLast(){const e=this.items[this.lastIdx];return this.lastIdx--,e}takeCount(e){const t=this.items.slice(this.firstIdx,this.firstIdx+e);return this.firstIdx+=e,t}};class u{static{this.empty=new u((e=>{}))}constructor(e){this.iterate=e}forEach(e){this.iterate((t=>(e(t),!0)))}toArray(){const e=[];return this.iterate((t=>(e.push(t),!0))),e}filter(e){return new u((t=>this.iterate((i=>!e(i)||t(i)))))}map(e){return new u((t=>this.iterate((i=>t(e(i))))))}some(e){let t=!1;return this.iterate((i=>(t=e(i),!t))),t}findFirst(e){let t;return this.iterate((i=>!e(i)||(t=i,!1))),t}findLast(e){let t;return this.iterate((i=>(e(i)&&(t=i),!0))),t}findLastMaxBy(e){let t,i=!0;return this.iterate((s=>((i||d.isGreaterThan(e(s,t)))&&(i=!1,t=s),!0))),t}}t.CallbackIterable=u;class _{constructor(e){this._indexMap=e}static createSortPermutation(e,t){const i=Array.from(e.keys()).sort(((i,s)=>t(e[i],e[s])));return new _(i)}apply(e){return e.map(((t,i)=>e[this._indexMap[i]]))}inverse(){const e=this._indexMap.slice();for(let t=0;t{function i(e,t,i=e.length-1){for(let s=i;s>=0;s--)if(t(e[s]))return s;return-1}function s(e,t,i=0,s=e.length){let r=i,n=s;for(;r=0&&(i=r)}return i},t.findFirstMin=function(e,t){return o(e,((e,i)=>-t(e,i)))},t.findMaxIdx=function(e,t){if(0===e.length)return-1;let i=0;for(let s=1;s0&&(i=s);return i},t.mapFindFirst=function(e,t){for(const i of e){const e=t(i);if(void 0!==e)return e}};class n{static{this.assertInvariants=!1}constructor(e){this._array=e,this._findLastMonotonousLastIdx=0}findLastMonotonous(e){if(n.assertInvariants){if(this._prevFindLastPredicate)for(const t of this._array)if(this._prevFindLastPredicate(t)&&!e(t))throw new Error("MonotonousArray: current predicate must be weaker than (or equal to) the previous predicate.");this._prevFindLastPredicate=e}const t=s(this._array,e,this._findLastMonotonousLastIdx);return this._findLastMonotonousLastIdx=t+1,-1===t?void 0:this._array[t]}}function o(e,t){if(0===e.length)return;let i=e[0];for(let s=1;s0&&(i=r)}return i}t.MonotonousArray=n},1758:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.AsyncIterableSource=t.CancelableAsyncIterableObject=t.AsyncIterableObject=t.LazyStatefulPromise=t.StatefulPromise=t.Promises=t.DeferredPromise=t.IntervalCounter=t.TaskSequentializer=t.GlobalIdleValue=t.AbstractIdleValue=t._runWhenIdle=t.runWhenGlobalIdle=t.ThrottledWorker=t.RunOnceWorker=t.ProcessTimeRunOnceScheduler=t.RunOnceScheduler=t.IntervalTimer=t.TimeoutTimer=t.LimitedQueue=t.Queue=t.Limiter=t.AutoOpenBarrier=t.Barrier=t.ThrottledDelayer=t.Delayer=t.SequencerByKey=t.Sequencer=t.Throttler=void 0,t.isThenable=c,t.createCancelablePromise=d,t.raceCancellation=function(e,t,i){return new Promise(((s,r)=>{const n=t.onCancellationRequested((()=>{n.dispose(),s(i)}));e.then(s,r).finally((()=>n.dispose()))}))},t.raceCancellationError=function(e,t){return new Promise(((i,s)=>{const n=t.onCancellationRequested((()=>{n.dispose(),s(new r.CancellationError)}));e.then(i,s).finally((()=>n.dispose()))}))},t.raceCancellablePromises=async function(e){let t=-1;const i=e.map(((e,i)=>e.then((e=>(t=i,e)))));try{return await Promise.race(i)}finally{e.forEach(((e,i)=>{i!==t&&e.cancel()}))}},t.raceTimeout=function(e,t,i){let s;const r=setTimeout((()=>{s?.(void 0),i?.()}),t);return Promise.race([e.finally((()=>clearTimeout(r))),new Promise((e=>s=e))])},t.asPromise=function(e){return new Promise(((t,i)=>{const s=e();c(s)?s.then(t,i):t(s)}))},t.promiseWithResolvers=u,t.timeout=g,t.disposableTimeout=function(e,t=0,i){const s=setTimeout((()=>{e(),i&&r.dispose()}),t),r=(0,o.toDisposable)((()=>{clearTimeout(s),i?.deleteAndLeak(r)}));return i?.add(r),r},t.sequence=function(e){const t=[];let i=0;const s=e.length;return Promise.resolve(null).then((function r(n){null!=n&&t.push(n);const o=i!!e,i=null){let s=0;const r=e.length,n=()=>{if(s>=r)return Promise.resolve(i);const o=e[s++];return Promise.resolve(o()).then((e=>t(e)?Promise.resolve(e):n()))};return n()},t.firstParallel=function(e,t=e=>!!e,i=null){if(0===e.length)return Promise.resolve(i);let s=e.length;const r=()=>{s=-1;for(const t of e)t.cancel?.()};return new Promise(((n,o)=>{for(const a of e)a.then((e=>{--s>=0&&t(e)?(r(),n(e)):0===s&&n(i)})).catch((e=>{--s>=0&&(r(),o(e))}))}))},t.retry=async function(e,t,i){let s;for(let r=0;r{const s=t.token.onCancellationRequested((()=>{s.dispose(),t.dispose(),e.reject(new r.CancellationError)}));try{for await(const s of i){if(t.token.isCancellationRequested)return;e.emitOne(s)}s.dispose(),t.dispose()}catch(i){s.dispose(),t.dispose(),e.reject(i)}}))};const s=i(8447),r=i(9807),n=i(802),o=i(7150),a=i(8163),l=i(5015),h=i(626);function c(e){return!!e&&"function"==typeof e.then}function d(e){const t=new s.CancellationTokenSource,i=e(t.token),n=new Promise(((e,s)=>{const n=t.token.onCancellationRequested((()=>{n.dispose(),s(new r.CancellationError)}));Promise.resolve(i).then((i=>{n.dispose(),t.dispose(),e(i)}),(e=>{n.dispose(),t.dispose(),s(e)}))}));return new class{cancel(){t.cancel(),t.dispose()}then(e,t){return n.then(e,t)}catch(e){return this.then(void 0,e)}finally(e){return n.finally(e)}}}function u(){let e,t;return{promise:new Promise(((i,s)=>{e=i,t=s})),resolve:e,reject:t}}class _{constructor(){this.isDisposed=!1,this.activePromise=null,this.queuedPromise=null,this.queuedPromiseFactory=null}queue(e){if(this.isDisposed)return Promise.reject(new Error("Throttler is disposed"));if(this.activePromise){if(this.queuedPromiseFactory=e,!this.queuedPromise){const e=()=>{if(this.queuedPromise=null,this.isDisposed)return;const e=this.queue(this.queuedPromiseFactory);return this.queuedPromiseFactory=null,e};this.queuedPromise=new Promise((t=>{this.activePromise.then(e,e).then(t)}))}return new Promise(((e,t)=>{this.queuedPromise.then(e,t)}))}return this.activePromise=e(),new Promise(((e,t)=>{this.activePromise.then((t=>{this.activePromise=null,e(t)}),(e=>{this.activePromise=null,t(e)}))}))}dispose(){this.isDisposed=!0}}t.Throttler=_,t.Sequencer=class{constructor(){this.current=Promise.resolve(null)}queue(e){return this.current=this.current.then((()=>e()),(()=>e()))}},t.SequencerByKey=class{constructor(){this.promiseMap=new Map}queue(e,t){const i=(this.promiseMap.get(e)??Promise.resolve()).catch((()=>{})).then(t).finally((()=>{this.promiseMap.get(e)===i&&this.promiseMap.delete(e)}));return this.promiseMap.set(e,i),i}};class f{constructor(e){this.defaultDelay=e,this.deferred=null,this.completionPromise=null,this.doResolve=null,this.doReject=null,this.task=null}trigger(e,t=this.defaultDelay){this.task=e,this.cancelTimeout(),this.completionPromise||(this.completionPromise=new Promise(((e,t)=>{this.doResolve=e,this.doReject=t})).then((()=>{if(this.completionPromise=null,this.doResolve=null,this.task){const e=this.task;return this.task=null,e()}})));const i=()=>{this.deferred=null,this.doResolve?.(null)};return this.deferred=t===l.MicrotaskDelay?(e=>{let t=!0;return queueMicrotask((()=>{t&&(t=!1,e())})),{isTriggered:()=>t,dispose:()=>{t=!1}}})(i):((e,t)=>{let i=!0;const s=setTimeout((()=>{i=!1,t()}),e);return{isTriggered:()=>i,dispose:()=>{clearTimeout(s),i=!1}}})(t,i),this.completionPromise}isTriggered(){return!!this.deferred?.isTriggered()}cancel(){this.cancelTimeout(),this.completionPromise&&(this.doReject?.(new r.CancellationError),this.completionPromise=null)}cancelTimeout(){this.deferred?.dispose(),this.deferred=null}dispose(){this.cancel()}}t.Delayer=f,t.ThrottledDelayer=class{constructor(e){this.delayer=new f(e),this.throttler=new _}trigger(e,t){return this.delayer.trigger((()=>this.throttler.queue(e)),t)}isTriggered(){return this.delayer.isTriggered()}cancel(){this.delayer.cancel()}dispose(){this.delayer.dispose(),this.throttler.dispose()}};class p{constructor(){this._isOpen=!1,this._promise=new Promise(((e,t)=>{this._completePromise=e}))}isOpen(){return this._isOpen}open(){this._isOpen=!0,this._completePromise(!0)}wait(){return this._promise}}function g(e,t){return t?new Promise(((i,s)=>{const n=setTimeout((()=>{o.dispose(),i()}),e),o=t.onCancellationRequested((()=>{clearTimeout(n),o.dispose(),s(new r.CancellationError)}))})):d((t=>g(e,t)))}t.Barrier=p,t.AutoOpenBarrier=class extends p{constructor(e){super(),this._timeout=setTimeout((()=>this.open()),e)}open(){clearTimeout(this._timeout),super.open()}};class m{constructor(e){this._size=0,this._isDisposed=!1,this.maxDegreeOfParalellism=e,this.outstandingPromises=[],this.runningPromises=0,this._onDrained=new n.Emitter}whenIdle(){return this.size>0?n.Event.toPromise(this.onDrained):Promise.resolve()}get onDrained(){return this._onDrained.event}get size(){return this._size}queue(e){if(this._isDisposed)throw new Error("Object has been disposed");return this._size++,new Promise(((t,i)=>{this.outstandingPromises.push({factory:e,c:t,e:i}),this.consume()}))}consume(){for(;this.outstandingPromises.length&&this.runningPromisesthis.consumed()),(()=>this.consumed()))}}consumed(){this._isDisposed||(this.runningPromises--,0==--this._size&&this._onDrained.fire(),this.outstandingPromises.length>0&&this.consume())}clear(){if(this._isDisposed)throw new Error("Object has been disposed");this.outstandingPromises.length=0,this._size=this.runningPromises}dispose(){this._isDisposed=!0,this.outstandingPromises.length=0,this._size=0,this._onDrained.dispose()}}t.Limiter=m,t.Queue=class extends m{constructor(){super(1)}},t.LimitedQueue=class{constructor(){this.sequentializer=new C,this.tasks=0}queue(e){return this.sequentializer.isRunning()?this.sequentializer.queue((()=>this.sequentializer.run(this.tasks++,e()))):this.sequentializer.run(this.tasks++,e())}},t.TimeoutTimer=class{constructor(e,t){this._isDisposed=!1,this._token=-1,"function"==typeof e&&"number"==typeof t&&this.setIfNotSet(e,t)}dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(e,t){if(this._isDisposed)throw new r.BugIndicatingError("Calling 'cancelAndSet' on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout((()=>{this._token=-1,e()}),t)}setIfNotSet(e,t){if(this._isDisposed)throw new r.BugIndicatingError("Calling 'setIfNotSet' on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout((()=>{this._token=-1,e()}),t))}},t.IntervalTimer=class{constructor(){this.disposable=void 0,this.isDisposed=!1}cancel(){this.disposable?.dispose(),this.disposable=void 0}cancelAndSet(e,t,i=globalThis){if(this.isDisposed)throw new r.BugIndicatingError("Calling 'cancelAndSet' on a disposed IntervalTimer");this.cancel();const s=i.setInterval((()=>{e()}),t);this.disposable=(0,o.toDisposable)((()=>{i.clearInterval(s),this.disposable=void 0}))}dispose(){this.cancel(),this.isDisposed=!0}};class v{constructor(e,t){this.timeoutToken=-1,this.runner=e,this.timeout=t,this.timeoutHandler=this.onTimeout.bind(this)}dispose(){this.cancel(),this.runner=null}cancel(){this.isScheduled()&&(clearTimeout(this.timeoutToken),this.timeoutToken=-1)}schedule(e=this.timeout){this.cancel(),this.timeoutToken=setTimeout(this.timeoutHandler,e)}get delay(){return this.timeout}set delay(e){this.timeout=e}isScheduled(){return-1!==this.timeoutToken}flush(){this.isScheduled()&&(this.cancel(),this.doRun())}onTimeout(){this.timeoutToken=-1,this.runner&&this.doRun()}doRun(){this.runner?.()}}t.RunOnceScheduler=v,t.ProcessTimeRunOnceScheduler=class{constructor(e,t){t%1e3!=0&&console.warn(`ProcessTimeRunOnceScheduler resolution is 1s, ${t}ms is not a multiple of 1000ms.`),this.runner=e,this.timeout=t,this.counter=0,this.intervalToken=-1,this.intervalHandler=this.onInterval.bind(this)}dispose(){this.cancel(),this.runner=null}cancel(){this.isScheduled()&&(clearInterval(this.intervalToken),this.intervalToken=-1)}schedule(e=this.timeout){e%1e3!=0&&console.warn(`ProcessTimeRunOnceScheduler resolution is 1s, ${e}ms is not a multiple of 1000ms.`),this.cancel(),this.counter=Math.ceil(e/1e3),this.intervalToken=setInterval(this.intervalHandler,1e3)}isScheduled(){return-1!==this.intervalToken}onInterval(){this.counter--,this.counter>0||(clearInterval(this.intervalToken),this.intervalToken=-1,this.runner?.())}},t.RunOnceWorker=class extends v{constructor(e,t){super(e,t),this.units=[]}work(e){this.units.push(e),this.isScheduled()||this.schedule()}doRun(){const e=this.units;this.units=[],this.runner?.(e)}dispose(){this.units=[],super.dispose()}};class S extends o.Disposable{constructor(e,t){super(),this.options=e,this.handler=t,this.pendingWork=[],this.throttler=this._register(new o.MutableDisposable),this.disposed=!1}get pending(){return this.pendingWork.length}work(e){if(this.disposed)return!1;if("number"==typeof this.options.maxBufferedWork)if(this.throttler.value){if(this.pending+e.length>this.options.maxBufferedWork)return!1}else if(this.pending+e.length-this.options.maxWorkChunkSize>this.options.maxBufferedWork)return!1;for(const t of e)this.pendingWork.push(t);return this.throttler.value||this.doWork(),!0}doWork(){this.handler(this.pendingWork.splice(0,this.options.maxWorkChunkSize)),this.pendingWork.length>0&&(this.throttler.value=new v((()=>{this.throttler.clear(),this.doWork()}),this.options.throttleDelay),this.throttler.value.schedule())}dispose(){super.dispose(),this.disposed=!0}}t.ThrottledWorker=S,"function"!=typeof globalThis.requestIdleCallback||"function"!=typeof globalThis.cancelIdleCallback?t._runWhenIdle=(e,t)=>{(0,a.setTimeout0)((()=>{if(i)return;const e=Date.now()+15,s={didTimeout:!0,timeRemaining:()=>Math.max(0,e-Date.now())};t(Object.freeze(s))}));let i=!1;return{dispose(){i||(i=!0)}}}:t._runWhenIdle=(e,t,i)=>{const s=e.requestIdleCallback(t,"number"==typeof i?{timeout:i}:void 0);let r=!1;return{dispose(){r||(r=!0,e.cancelIdleCallback(s))}}},t.runWhenGlobalIdle=e=>(0,t._runWhenIdle)(globalThis,e);class b{constructor(e,i){this._didRun=!1,this._executor=()=>{try{this._value=i()}catch(e){this._error=e}finally{this._didRun=!0}},this._handle=(0,t._runWhenIdle)(e,(()=>this._executor()))}dispose(){this._handle.dispose()}get value(){if(this._didRun||(this._handle.dispose(),this._executor()),this._error)throw this._error;return this._value}get isInitialized(){return this._didRun}}t.AbstractIdleValue=b,t.GlobalIdleValue=class extends b{constructor(e){super(globalThis,e)}};class C{isRunning(e){return"number"==typeof e?this._running?.taskId===e:!!this._running}get running(){return this._running?.promise}cancelRunning(){this._running?.cancel()}run(e,t,i){return this._running={taskId:e,cancel:()=>i?.(),promise:t},t.then((()=>this.doneRunning(e)),(()=>this.doneRunning(e))),t}doneRunning(e){this._running&&e===this._running.taskId&&(this._running=void 0,this.runQueued())}runQueued(){if(this._queued){const e=this._queued;this._queued=void 0,e.run().then(e.promiseResolve,e.promiseReject)}}queue(e){if(this._queued)this._queued.run=e;else{const{promise:t,resolve:i,reject:s}=u();this._queued={run:e,promise:t,promiseResolve:i,promiseReject:s}}return this._queued.promise}hasQueued(){return!!this._queued}async join(){return this._queued?.promise??this._running?.promise}}var y,w,E;t.TaskSequentializer=C,t.IntervalCounter=class{constructor(e,t=()=>Date.now()){this.interval=e,this.nowFn=t,this.lastIncrementTime=0,this.value=0}increment(){const e=this.nowFn();return e-this.lastIncrementTime>this.interval&&(this.lastIncrementTime=e,this.value=0),this.value++,this.value}},function(e){e[e.Resolved=0]="Resolved",e[e.Rejected=1]="Rejected"}(y||(y={}));class D{get isRejected(){return this.outcome?.outcome===y.Rejected}get isResolved(){return this.outcome?.outcome===y.Resolved}get isSettled(){return!!this.outcome}get value(){return this.outcome?.outcome===y.Resolved?this.outcome?.value:void 0}constructor(){this.p=new Promise(((e,t)=>{this.completeCallback=e,this.errorCallback=t}))}complete(e){return new Promise((t=>{this.completeCallback(e),this.outcome={outcome:y.Resolved,value:e},t()}))}error(e){return new Promise((t=>{this.errorCallback(e),this.outcome={outcome:y.Rejected,value:e},t()}))}cancel(){return this.error(new r.CancellationError)}}t.DeferredPromise=D,function(e){e.settled=async function(e){let t;const i=await Promise.all(e.map((e=>e.then((e=>e),(e=>{t||(t=e)})))));if(void 0!==t)throw t;return i},e.withAsyncBody=function(e){return new Promise((async(t,i)=>{try{await e(t,i)}catch(e){i(e)}}))}}(w||(t.Promises=w={}));class L{get value(){return this._value}get error(){return this._error}get isResolved(){return this._isResolved}constructor(e){this._value=void 0,this._error=void 0,this._isResolved=!1,this.promise=e.then((e=>(this._value=e,this._isResolved=!0,e)),(e=>{throw this._error=e,this._isResolved=!0,e}))}requireValue(){if(!this._isResolved)throw new r.BugIndicatingError("Promise is not resolved yet");if(this._error)throw this._error;return this._value}}t.StatefulPromise=L,t.LazyStatefulPromise=class{constructor(e){this._compute=e,this._promise=new h.Lazy((()=>new L(this._compute())))}requireValue(){return this._promise.value.requireValue()}getPromise(){return this._promise.value.promise}get currentValue(){return this._promise.rawValue?.value}},function(e){e[e.Initial=0]="Initial",e[e.DoneOK=1]="DoneOK",e[e.DoneError=2]="DoneError"}(E||(E={}));class R{static fromArray(e){return new R((t=>{t.emitMany(e)}))}static fromPromise(e){return new R((async t=>{t.emitMany(await e)}))}static fromPromises(e){return new R((async t=>{await Promise.all(e.map((async e=>t.emitOne(await e))))}))}static merge(e){return new R((async t=>{await Promise.all(e.map((async e=>{for await(const i of e)t.emitOne(i)})))}))}static{this.EMPTY=R.fromArray([])}constructor(e,t){this._state=E.Initial,this._results=[],this._error=null,this._onReturn=t,this._onStateChanged=new n.Emitter,queueMicrotask((async()=>{const t={emitOne:e=>this.emitOne(e),emitMany:e=>this.emitMany(e),reject:e=>this.reject(e)};try{await Promise.resolve(e(t)),this.resolve()}catch(e){this.reject(e)}finally{t.emitOne=void 0,t.emitMany=void 0,t.reject=void 0}}))}[Symbol.asyncIterator](){let e=0;return{next:async()=>{for(;;){if(this._state===E.DoneError)throw this._error;if(e(this._onReturn?.(),{done:!0,value:void 0})}}static map(e,t){return new R((async i=>{for await(const s of e)i.emitOne(t(s))}))}map(e){return R.map(this,e)}static filter(e,t){return new R((async i=>{for await(const s of e)t(s)&&i.emitOne(s)}))}filter(e){return R.filter(this,e)}static coalesce(e){return R.filter(e,(e=>!!e))}coalesce(){return R.coalesce(this)}static async toPromise(e){const t=[];for await(const i of e)t.push(i);return t}toPromise(){return R.toPromise(this)}emitOne(e){this._state===E.Initial&&(this._results.push(e),this._onStateChanged.fire())}emitMany(e){this._state===E.Initial&&(this._results=this._results.concat(e),this._onStateChanged.fire())}resolve(){this._state===E.Initial&&(this._state=E.DoneOK,this._onStateChanged.fire())}reject(e){this._state===E.Initial&&(this._state=E.DoneError,this._error=e,this._onStateChanged.fire())}}t.AsyncIterableObject=R;class A extends R{constructor(e,t){super(t),this._source=e}cancel(){this._source.cancel()}}t.CancelableAsyncIterableObject=A,t.AsyncIterableSource=class{constructor(e){let t,i;this._deferred=new D,this._asyncIterable=new R((e=>{if(!t)return i&&e.emitMany(i),this._errorFn=t=>e.reject(t),this._emitFn=t=>e.emitOne(t),this._deferred.p;e.reject(t)}),e),this._emitFn=e=>{i||(i=[]),i.push(e)},this._errorFn=e=>{t||(t=e)}}get asyncIterable(){return this._asyncIterable}resolve(){this._deferred.complete()}reject(e){this._errorFn(e),this._deferred.complete()}emitOne(e){this._emitFn(e)}}},8447:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CancellationTokenSource=t.CancellationToken=void 0,t.cancelOnDispose=function(e){const t=new a;return e.add({dispose(){t.cancel()}}),t.token};const s=i(802),r=Object.freeze((function(e,t){const i=setTimeout(e.bind(t),0);return{dispose(){clearTimeout(i)}}}));var n;!function(e){e.isCancellationToken=function(t){return t===e.None||t===e.Cancelled||t instanceof o||!(!t||"object"!=typeof t)&&"boolean"==typeof t.isCancellationRequested&&"function"==typeof t.onCancellationRequested},e.None=Object.freeze({isCancellationRequested:!1,onCancellationRequested:s.Event.None}),e.Cancelled=Object.freeze({isCancellationRequested:!0,onCancellationRequested:r})}(n||(t.CancellationToken=n={}));class o{constructor(){this._isCancelled=!1,this._emitter=null}cancel(){this._isCancelled||(this._isCancelled=!0,this._emitter&&(this._emitter.fire(void 0),this.dispose()))}get isCancellationRequested(){return this._isCancelled}get onCancellationRequested(){return this._isCancelled?r:(this._emitter||(this._emitter=new s.Emitter),this._emitter.event)}dispose(){this._emitter&&(this._emitter.dispose(),this._emitter=null)}}class a{constructor(e){this._token=void 0,this._parentListener=void 0,this._parentListener=e&&e.onCancellationRequested(this.cancel,this)}get token(){return this._token||(this._token=new o),this._token}cancel(){this._token?this._token instanceof o&&this._token.cancel():this._token=n.Cancelled}dispose(e=!1){e&&this.cancel(),this._parentListener?.dispose(),this._token?this._token instanceof o&&this._token.dispose():this._token=n.None}}t.CancellationTokenSource=a},4869:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.CharCode=void 0,function(e){e[e.Null=0]="Null",e[e.Backspace=8]="Backspace",e[e.Tab=9]="Tab",e[e.LineFeed=10]="LineFeed",e[e.CarriageReturn=13]="CarriageReturn",e[e.Space=32]="Space",e[e.ExclamationMark=33]="ExclamationMark",e[e.DoubleQuote=34]="DoubleQuote",e[e.Hash=35]="Hash",e[e.DollarSign=36]="DollarSign",e[e.PercentSign=37]="PercentSign",e[e.Ampersand=38]="Ampersand",e[e.SingleQuote=39]="SingleQuote",e[e.OpenParen=40]="OpenParen",e[e.CloseParen=41]="CloseParen",e[e.Asterisk=42]="Asterisk",e[e.Plus=43]="Plus",e[e.Comma=44]="Comma",e[e.Dash=45]="Dash",e[e.Period=46]="Period",e[e.Slash=47]="Slash",e[e.Digit0=48]="Digit0",e[e.Digit1=49]="Digit1",e[e.Digit2=50]="Digit2",e[e.Digit3=51]="Digit3",e[e.Digit4=52]="Digit4",e[e.Digit5=53]="Digit5",e[e.Digit6=54]="Digit6",e[e.Digit7=55]="Digit7",e[e.Digit8=56]="Digit8",e[e.Digit9=57]="Digit9",e[e.Colon=58]="Colon",e[e.Semicolon=59]="Semicolon",e[e.LessThan=60]="LessThan",e[e.Equals=61]="Equals",e[e.GreaterThan=62]="GreaterThan",e[e.QuestionMark=63]="QuestionMark",e[e.AtSign=64]="AtSign",e[e.A=65]="A",e[e.B=66]="B",e[e.C=67]="C",e[e.D=68]="D",e[e.E=69]="E",e[e.F=70]="F",e[e.G=71]="G",e[e.H=72]="H",e[e.I=73]="I",e[e.J=74]="J",e[e.K=75]="K",e[e.L=76]="L",e[e.M=77]="M",e[e.N=78]="N",e[e.O=79]="O",e[e.P=80]="P",e[e.Q=81]="Q",e[e.R=82]="R",e[e.S=83]="S",e[e.T=84]="T",e[e.U=85]="U",e[e.V=86]="V",e[e.W=87]="W",e[e.X=88]="X",e[e.Y=89]="Y",e[e.Z=90]="Z",e[e.OpenSquareBracket=91]="OpenSquareBracket",e[e.Backslash=92]="Backslash",e[e.CloseSquareBracket=93]="CloseSquareBracket",e[e.Caret=94]="Caret",e[e.Underline=95]="Underline",e[e.BackTick=96]="BackTick",e[e.a=97]="a",e[e.b=98]="b",e[e.c=99]="c",e[e.d=100]="d",e[e.e=101]="e",e[e.f=102]="f",e[e.g=103]="g",e[e.h=104]="h",e[e.i=105]="i",e[e.j=106]="j",e[e.k=107]="k",e[e.l=108]="l",e[e.m=109]="m",e[e.n=110]="n",e[e.o=111]="o",e[e.p=112]="p",e[e.q=113]="q",e[e.r=114]="r",e[e.s=115]="s",e[e.t=116]="t",e[e.u=117]="u",e[e.v=118]="v",e[e.w=119]="w",e[e.x=120]="x",e[e.y=121]="y",e[e.z=122]="z",e[e.OpenCurlyBrace=123]="OpenCurlyBrace",e[e.Pipe=124]="Pipe",e[e.CloseCurlyBrace=125]="CloseCurlyBrace",e[e.Tilde=126]="Tilde",e[e.NoBreakSpace=160]="NoBreakSpace",e[e.U_Combining_Grave_Accent=768]="U_Combining_Grave_Accent",e[e.U_Combining_Acute_Accent=769]="U_Combining_Acute_Accent",e[e.U_Combining_Circumflex_Accent=770]="U_Combining_Circumflex_Accent",e[e.U_Combining_Tilde=771]="U_Combining_Tilde",e[e.U_Combining_Macron=772]="U_Combining_Macron",e[e.U_Combining_Overline=773]="U_Combining_Overline",e[e.U_Combining_Breve=774]="U_Combining_Breve",e[e.U_Combining_Dot_Above=775]="U_Combining_Dot_Above",e[e.U_Combining_Diaeresis=776]="U_Combining_Diaeresis",e[e.U_Combining_Hook_Above=777]="U_Combining_Hook_Above",e[e.U_Combining_Ring_Above=778]="U_Combining_Ring_Above",e[e.U_Combining_Double_Acute_Accent=779]="U_Combining_Double_Acute_Accent",e[e.U_Combining_Caron=780]="U_Combining_Caron",e[e.U_Combining_Vertical_Line_Above=781]="U_Combining_Vertical_Line_Above",e[e.U_Combining_Double_Vertical_Line_Above=782]="U_Combining_Double_Vertical_Line_Above",e[e.U_Combining_Double_Grave_Accent=783]="U_Combining_Double_Grave_Accent",e[e.U_Combining_Candrabindu=784]="U_Combining_Candrabindu",e[e.U_Combining_Inverted_Breve=785]="U_Combining_Inverted_Breve",e[e.U_Combining_Turned_Comma_Above=786]="U_Combining_Turned_Comma_Above",e[e.U_Combining_Comma_Above=787]="U_Combining_Comma_Above",e[e.U_Combining_Reversed_Comma_Above=788]="U_Combining_Reversed_Comma_Above",e[e.U_Combining_Comma_Above_Right=789]="U_Combining_Comma_Above_Right",e[e.U_Combining_Grave_Accent_Below=790]="U_Combining_Grave_Accent_Below",e[e.U_Combining_Acute_Accent_Below=791]="U_Combining_Acute_Accent_Below",e[e.U_Combining_Left_Tack_Below=792]="U_Combining_Left_Tack_Below",e[e.U_Combining_Right_Tack_Below=793]="U_Combining_Right_Tack_Below",e[e.U_Combining_Left_Angle_Above=794]="U_Combining_Left_Angle_Above",e[e.U_Combining_Horn=795]="U_Combining_Horn",e[e.U_Combining_Left_Half_Ring_Below=796]="U_Combining_Left_Half_Ring_Below",e[e.U_Combining_Up_Tack_Below=797]="U_Combining_Up_Tack_Below",e[e.U_Combining_Down_Tack_Below=798]="U_Combining_Down_Tack_Below",e[e.U_Combining_Plus_Sign_Below=799]="U_Combining_Plus_Sign_Below",e[e.U_Combining_Minus_Sign_Below=800]="U_Combining_Minus_Sign_Below",e[e.U_Combining_Palatalized_Hook_Below=801]="U_Combining_Palatalized_Hook_Below",e[e.U_Combining_Retroflex_Hook_Below=802]="U_Combining_Retroflex_Hook_Below",e[e.U_Combining_Dot_Below=803]="U_Combining_Dot_Below",e[e.U_Combining_Diaeresis_Below=804]="U_Combining_Diaeresis_Below",e[e.U_Combining_Ring_Below=805]="U_Combining_Ring_Below",e[e.U_Combining_Comma_Below=806]="U_Combining_Comma_Below",e[e.U_Combining_Cedilla=807]="U_Combining_Cedilla",e[e.U_Combining_Ogonek=808]="U_Combining_Ogonek",e[e.U_Combining_Vertical_Line_Below=809]="U_Combining_Vertical_Line_Below",e[e.U_Combining_Bridge_Below=810]="U_Combining_Bridge_Below",e[e.U_Combining_Inverted_Double_Arch_Below=811]="U_Combining_Inverted_Double_Arch_Below",e[e.U_Combining_Caron_Below=812]="U_Combining_Caron_Below",e[e.U_Combining_Circumflex_Accent_Below=813]="U_Combining_Circumflex_Accent_Below",e[e.U_Combining_Breve_Below=814]="U_Combining_Breve_Below",e[e.U_Combining_Inverted_Breve_Below=815]="U_Combining_Inverted_Breve_Below",e[e.U_Combining_Tilde_Below=816]="U_Combining_Tilde_Below",e[e.U_Combining_Macron_Below=817]="U_Combining_Macron_Below",e[e.U_Combining_Low_Line=818]="U_Combining_Low_Line",e[e.U_Combining_Double_Low_Line=819]="U_Combining_Double_Low_Line",e[e.U_Combining_Tilde_Overlay=820]="U_Combining_Tilde_Overlay",e[e.U_Combining_Short_Stroke_Overlay=821]="U_Combining_Short_Stroke_Overlay",e[e.U_Combining_Long_Stroke_Overlay=822]="U_Combining_Long_Stroke_Overlay",e[e.U_Combining_Short_Solidus_Overlay=823]="U_Combining_Short_Solidus_Overlay",e[e.U_Combining_Long_Solidus_Overlay=824]="U_Combining_Long_Solidus_Overlay",e[e.U_Combining_Right_Half_Ring_Below=825]="U_Combining_Right_Half_Ring_Below",e[e.U_Combining_Inverted_Bridge_Below=826]="U_Combining_Inverted_Bridge_Below",e[e.U_Combining_Square_Below=827]="U_Combining_Square_Below",e[e.U_Combining_Seagull_Below=828]="U_Combining_Seagull_Below",e[e.U_Combining_X_Above=829]="U_Combining_X_Above",e[e.U_Combining_Vertical_Tilde=830]="U_Combining_Vertical_Tilde",e[e.U_Combining_Double_Overline=831]="U_Combining_Double_Overline",e[e.U_Combining_Grave_Tone_Mark=832]="U_Combining_Grave_Tone_Mark",e[e.U_Combining_Acute_Tone_Mark=833]="U_Combining_Acute_Tone_Mark",e[e.U_Combining_Greek_Perispomeni=834]="U_Combining_Greek_Perispomeni",e[e.U_Combining_Greek_Koronis=835]="U_Combining_Greek_Koronis",e[e.U_Combining_Greek_Dialytika_Tonos=836]="U_Combining_Greek_Dialytika_Tonos",e[e.U_Combining_Greek_Ypogegrammeni=837]="U_Combining_Greek_Ypogegrammeni",e[e.U_Combining_Bridge_Above=838]="U_Combining_Bridge_Above",e[e.U_Combining_Equals_Sign_Below=839]="U_Combining_Equals_Sign_Below",e[e.U_Combining_Double_Vertical_Line_Below=840]="U_Combining_Double_Vertical_Line_Below",e[e.U_Combining_Left_Angle_Below=841]="U_Combining_Left_Angle_Below",e[e.U_Combining_Not_Tilde_Above=842]="U_Combining_Not_Tilde_Above",e[e.U_Combining_Homothetic_Above=843]="U_Combining_Homothetic_Above",e[e.U_Combining_Almost_Equal_To_Above=844]="U_Combining_Almost_Equal_To_Above",e[e.U_Combining_Left_Right_Arrow_Below=845]="U_Combining_Left_Right_Arrow_Below",e[e.U_Combining_Upwards_Arrow_Below=846]="U_Combining_Upwards_Arrow_Below",e[e.U_Combining_Grapheme_Joiner=847]="U_Combining_Grapheme_Joiner",e[e.U_Combining_Right_Arrowhead_Above=848]="U_Combining_Right_Arrowhead_Above",e[e.U_Combining_Left_Half_Ring_Above=849]="U_Combining_Left_Half_Ring_Above",e[e.U_Combining_Fermata=850]="U_Combining_Fermata",e[e.U_Combining_X_Below=851]="U_Combining_X_Below",e[e.U_Combining_Left_Arrowhead_Below=852]="U_Combining_Left_Arrowhead_Below",e[e.U_Combining_Right_Arrowhead_Below=853]="U_Combining_Right_Arrowhead_Below",e[e.U_Combining_Right_Arrowhead_And_Up_Arrowhead_Below=854]="U_Combining_Right_Arrowhead_And_Up_Arrowhead_Below",e[e.U_Combining_Right_Half_Ring_Above=855]="U_Combining_Right_Half_Ring_Above",e[e.U_Combining_Dot_Above_Right=856]="U_Combining_Dot_Above_Right",e[e.U_Combining_Asterisk_Below=857]="U_Combining_Asterisk_Below",e[e.U_Combining_Double_Ring_Below=858]="U_Combining_Double_Ring_Below",e[e.U_Combining_Zigzag_Above=859]="U_Combining_Zigzag_Above",e[e.U_Combining_Double_Breve_Below=860]="U_Combining_Double_Breve_Below",e[e.U_Combining_Double_Breve=861]="U_Combining_Double_Breve",e[e.U_Combining_Double_Macron=862]="U_Combining_Double_Macron",e[e.U_Combining_Double_Macron_Below=863]="U_Combining_Double_Macron_Below",e[e.U_Combining_Double_Tilde=864]="U_Combining_Double_Tilde",e[e.U_Combining_Double_Inverted_Breve=865]="U_Combining_Double_Inverted_Breve",e[e.U_Combining_Double_Rightwards_Arrow_Below=866]="U_Combining_Double_Rightwards_Arrow_Below",e[e.U_Combining_Latin_Small_Letter_A=867]="U_Combining_Latin_Small_Letter_A",e[e.U_Combining_Latin_Small_Letter_E=868]="U_Combining_Latin_Small_Letter_E",e[e.U_Combining_Latin_Small_Letter_I=869]="U_Combining_Latin_Small_Letter_I",e[e.U_Combining_Latin_Small_Letter_O=870]="U_Combining_Latin_Small_Letter_O",e[e.U_Combining_Latin_Small_Letter_U=871]="U_Combining_Latin_Small_Letter_U",e[e.U_Combining_Latin_Small_Letter_C=872]="U_Combining_Latin_Small_Letter_C",e[e.U_Combining_Latin_Small_Letter_D=873]="U_Combining_Latin_Small_Letter_D",e[e.U_Combining_Latin_Small_Letter_H=874]="U_Combining_Latin_Small_Letter_H",e[e.U_Combining_Latin_Small_Letter_M=875]="U_Combining_Latin_Small_Letter_M",e[e.U_Combining_Latin_Small_Letter_R=876]="U_Combining_Latin_Small_Letter_R",e[e.U_Combining_Latin_Small_Letter_T=877]="U_Combining_Latin_Small_Letter_T",e[e.U_Combining_Latin_Small_Letter_V=878]="U_Combining_Latin_Small_Letter_V",e[e.U_Combining_Latin_Small_Letter_X=879]="U_Combining_Latin_Small_Letter_X",e[e.LINE_SEPARATOR=8232]="LINE_SEPARATOR",e[e.PARAGRAPH_SEPARATOR=8233]="PARAGRAPH_SEPARATOR",e[e.NEXT_LINE=133]="NEXT_LINE",e[e.U_CIRCUMFLEX=94]="U_CIRCUMFLEX",e[e.U_GRAVE_ACCENT=96]="U_GRAVE_ACCENT",e[e.U_DIAERESIS=168]="U_DIAERESIS",e[e.U_MACRON=175]="U_MACRON",e[e.U_ACUTE_ACCENT=180]="U_ACUTE_ACCENT",e[e.U_CEDILLA=184]="U_CEDILLA",e[e.U_MODIFIER_LETTER_LEFT_ARROWHEAD=706]="U_MODIFIER_LETTER_LEFT_ARROWHEAD",e[e.U_MODIFIER_LETTER_RIGHT_ARROWHEAD=707]="U_MODIFIER_LETTER_RIGHT_ARROWHEAD",e[e.U_MODIFIER_LETTER_UP_ARROWHEAD=708]="U_MODIFIER_LETTER_UP_ARROWHEAD",e[e.U_MODIFIER_LETTER_DOWN_ARROWHEAD=709]="U_MODIFIER_LETTER_DOWN_ARROWHEAD",e[e.U_MODIFIER_LETTER_CENTRED_RIGHT_HALF_RING=722]="U_MODIFIER_LETTER_CENTRED_RIGHT_HALF_RING",e[e.U_MODIFIER_LETTER_CENTRED_LEFT_HALF_RING=723]="U_MODIFIER_LETTER_CENTRED_LEFT_HALF_RING",e[e.U_MODIFIER_LETTER_UP_TACK=724]="U_MODIFIER_LETTER_UP_TACK",e[e.U_MODIFIER_LETTER_DOWN_TACK=725]="U_MODIFIER_LETTER_DOWN_TACK",e[e.U_MODIFIER_LETTER_PLUS_SIGN=726]="U_MODIFIER_LETTER_PLUS_SIGN",e[e.U_MODIFIER_LETTER_MINUS_SIGN=727]="U_MODIFIER_LETTER_MINUS_SIGN",e[e.U_BREVE=728]="U_BREVE",e[e.U_DOT_ABOVE=729]="U_DOT_ABOVE",e[e.U_RING_ABOVE=730]="U_RING_ABOVE",e[e.U_OGONEK=731]="U_OGONEK",e[e.U_SMALL_TILDE=732]="U_SMALL_TILDE",e[e.U_DOUBLE_ACUTE_ACCENT=733]="U_DOUBLE_ACUTE_ACCENT",e[e.U_MODIFIER_LETTER_RHOTIC_HOOK=734]="U_MODIFIER_LETTER_RHOTIC_HOOK",e[e.U_MODIFIER_LETTER_CROSS_ACCENT=735]="U_MODIFIER_LETTER_CROSS_ACCENT",e[e.U_MODIFIER_LETTER_EXTRA_HIGH_TONE_BAR=741]="U_MODIFIER_LETTER_EXTRA_HIGH_TONE_BAR",e[e.U_MODIFIER_LETTER_HIGH_TONE_BAR=742]="U_MODIFIER_LETTER_HIGH_TONE_BAR",e[e.U_MODIFIER_LETTER_MID_TONE_BAR=743]="U_MODIFIER_LETTER_MID_TONE_BAR",e[e.U_MODIFIER_LETTER_LOW_TONE_BAR=744]="U_MODIFIER_LETTER_LOW_TONE_BAR",e[e.U_MODIFIER_LETTER_EXTRA_LOW_TONE_BAR=745]="U_MODIFIER_LETTER_EXTRA_LOW_TONE_BAR",e[e.U_MODIFIER_LETTER_YIN_DEPARTING_TONE_MARK=746]="U_MODIFIER_LETTER_YIN_DEPARTING_TONE_MARK",e[e.U_MODIFIER_LETTER_YANG_DEPARTING_TONE_MARK=747]="U_MODIFIER_LETTER_YANG_DEPARTING_TONE_MARK",e[e.U_MODIFIER_LETTER_UNASPIRATED=749]="U_MODIFIER_LETTER_UNASPIRATED",e[e.U_MODIFIER_LETTER_LOW_DOWN_ARROWHEAD=751]="U_MODIFIER_LETTER_LOW_DOWN_ARROWHEAD",e[e.U_MODIFIER_LETTER_LOW_UP_ARROWHEAD=752]="U_MODIFIER_LETTER_LOW_UP_ARROWHEAD",e[e.U_MODIFIER_LETTER_LOW_LEFT_ARROWHEAD=753]="U_MODIFIER_LETTER_LOW_LEFT_ARROWHEAD",e[e.U_MODIFIER_LETTER_LOW_RIGHT_ARROWHEAD=754]="U_MODIFIER_LETTER_LOW_RIGHT_ARROWHEAD",e[e.U_MODIFIER_LETTER_LOW_RING=755]="U_MODIFIER_LETTER_LOW_RING",e[e.U_MODIFIER_LETTER_MIDDLE_GRAVE_ACCENT=756]="U_MODIFIER_LETTER_MIDDLE_GRAVE_ACCENT",e[e.U_MODIFIER_LETTER_MIDDLE_DOUBLE_GRAVE_ACCENT=757]="U_MODIFIER_LETTER_MIDDLE_DOUBLE_GRAVE_ACCENT",e[e.U_MODIFIER_LETTER_MIDDLE_DOUBLE_ACUTE_ACCENT=758]="U_MODIFIER_LETTER_MIDDLE_DOUBLE_ACUTE_ACCENT",e[e.U_MODIFIER_LETTER_LOW_TILDE=759]="U_MODIFIER_LETTER_LOW_TILDE",e[e.U_MODIFIER_LETTER_RAISED_COLON=760]="U_MODIFIER_LETTER_RAISED_COLON",e[e.U_MODIFIER_LETTER_BEGIN_HIGH_TONE=761]="U_MODIFIER_LETTER_BEGIN_HIGH_TONE",e[e.U_MODIFIER_LETTER_END_HIGH_TONE=762]="U_MODIFIER_LETTER_END_HIGH_TONE",e[e.U_MODIFIER_LETTER_BEGIN_LOW_TONE=763]="U_MODIFIER_LETTER_BEGIN_LOW_TONE",e[e.U_MODIFIER_LETTER_END_LOW_TONE=764]="U_MODIFIER_LETTER_END_LOW_TONE",e[e.U_MODIFIER_LETTER_SHELF=765]="U_MODIFIER_LETTER_SHELF",e[e.U_MODIFIER_LETTER_OPEN_SHELF=766]="U_MODIFIER_LETTER_OPEN_SHELF",e[e.U_MODIFIER_LETTER_LOW_LEFT_ARROW=767]="U_MODIFIER_LETTER_LOW_LEFT_ARROW",e[e.U_GREEK_LOWER_NUMERAL_SIGN=885]="U_GREEK_LOWER_NUMERAL_SIGN",e[e.U_GREEK_TONOS=900]="U_GREEK_TONOS",e[e.U_GREEK_DIALYTIKA_TONOS=901]="U_GREEK_DIALYTIKA_TONOS",e[e.U_GREEK_KORONIS=8125]="U_GREEK_KORONIS",e[e.U_GREEK_PSILI=8127]="U_GREEK_PSILI",e[e.U_GREEK_PERISPOMENI=8128]="U_GREEK_PERISPOMENI",e[e.U_GREEK_DIALYTIKA_AND_PERISPOMENI=8129]="U_GREEK_DIALYTIKA_AND_PERISPOMENI",e[e.U_GREEK_PSILI_AND_VARIA=8141]="U_GREEK_PSILI_AND_VARIA",e[e.U_GREEK_PSILI_AND_OXIA=8142]="U_GREEK_PSILI_AND_OXIA",e[e.U_GREEK_PSILI_AND_PERISPOMENI=8143]="U_GREEK_PSILI_AND_PERISPOMENI",e[e.U_GREEK_DASIA_AND_VARIA=8157]="U_GREEK_DASIA_AND_VARIA",e[e.U_GREEK_DASIA_AND_OXIA=8158]="U_GREEK_DASIA_AND_OXIA",e[e.U_GREEK_DASIA_AND_PERISPOMENI=8159]="U_GREEK_DASIA_AND_PERISPOMENI",e[e.U_GREEK_DIALYTIKA_AND_VARIA=8173]="U_GREEK_DIALYTIKA_AND_VARIA",e[e.U_GREEK_DIALYTIKA_AND_OXIA=8174]="U_GREEK_DIALYTIKA_AND_OXIA",e[e.U_GREEK_VARIA=8175]="U_GREEK_VARIA",e[e.U_GREEK_OXIA=8189]="U_GREEK_OXIA",e[e.U_GREEK_DASIA=8190]="U_GREEK_DASIA",e[e.U_IDEOGRAPHIC_FULL_STOP=12290]="U_IDEOGRAPHIC_FULL_STOP",e[e.U_LEFT_CORNER_BRACKET=12300]="U_LEFT_CORNER_BRACKET",e[e.U_RIGHT_CORNER_BRACKET=12301]="U_RIGHT_CORNER_BRACKET",e[e.U_LEFT_BLACK_LENTICULAR_BRACKET=12304]="U_LEFT_BLACK_LENTICULAR_BRACKET",e[e.U_RIGHT_BLACK_LENTICULAR_BRACKET=12305]="U_RIGHT_BLACK_LENTICULAR_BRACKET",e[e.U_OVERLINE=8254]="U_OVERLINE",e[e.UTF8_BOM=65279]="UTF8_BOM",e[e.U_FULLWIDTH_SEMICOLON=65307]="U_FULLWIDTH_SEMICOLON",e[e.U_FULLWIDTH_COMMA=65292]="U_FULLWIDTH_COMMA"}(i||(t.CharCode=i={}))},9087:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.SetWithKey=void 0,t.groupBy=function(e,t){const i=Object.create(null);for(const s of e){const e=t(s);let r=i[e];r||(r=i[e]=[]),r.push(s)}return i},t.diffSets=function(e,t){const i=[],s=[];for(const s of e)t.has(s)||i.push(s);for(const i of t)e.has(i)||s.push(i);return{removed:i,added:s}},t.diffMaps=function(e,t){const i=[],s=[];for(const[s,r]of e)t.has(s)||i.push(r);for(const[i,r]of t)e.has(i)||s.push(r);return{removed:i,added:s}},t.intersection=function(e,t){const i=new Set;for(const s of t)e.has(s)&&i.add(s);return i};class s{static{i=Symbol.toStringTag}constructor(e,t){this.toKey=t,this._map=new Map,this[i]="SetWithKey";for(const t of e)this.add(t)}get size(){return this._map.size}add(e){const t=this.toKey(e);return this._map.set(t,e),this}delete(e){return this._map.delete(this.toKey(e))}has(e){return this._map.has(this.toKey(e))}*entries(){for(const e of this._map.values())yield[e,e]}keys(){return this.values()}*values(){for(const e of this._map.values())yield e}clear(){this._map.clear()}forEach(e,t){this._map.forEach((i=>e.call(t,i,i,this)))}[Symbol.iterator](){return this.values()}}t.SetWithKey=s},4838:(e,t)=>{function i(e){return(t,i,s)=>{let r=null,n=null;if("function"==typeof s.value?(r="value",n=s.value):"function"==typeof s.get&&(r="get",n=s.get),!n)throw new Error("not supported");s[r]=e(n,i)}}Object.defineProperty(t,"__esModule",{value:!0}),t.memoize=function(e,t,i){let s=null,r=null;if("function"==typeof i.value?(s="value",r=i.value,0!==r.length&&console.warn("Memoize should only be used in functions with zero parameters")):"function"==typeof i.get&&(s="get",r=i.get),!r)throw new Error("not supported");const n=`$memoize$${t}`;i[s]=function(...e){return this.hasOwnProperty(n)||Object.defineProperty(this,n,{configurable:!1,enumerable:!1,writable:!1,value:r.apply(this,e)}),this[n]}},t.debounce=function(e,t,s){return i(((i,r)=>{const n=`$debounce$${r}`,o=`$debounce$result$${r}`;return function(...r){this[o]||(this[o]=s?s():void 0),clearTimeout(this[n]),t&&(this[o]=t(this[o],...r),r=[this[o]]),this[n]=setTimeout((()=>{i.apply(this,r),this[o]=s?s():void 0}),e)}}))},t.throttle=function(e,t,s){return i(((i,r)=>{const n=`$throttle$timer$${r}`,o=`$throttle$result$${r}`,a=`$throttle$lastRun$${r}`,l=`$throttle$pending$${r}`;return function(...r){if(this[o]||(this[o]=s?s():void 0),null!==this[a]&&void 0!==this[a]||(this[a]=-Number.MAX_VALUE),t&&(this[o]=t(this[o],...r)),this[l])return;const h=this[a]+e;h<=Date.now()?(this[a]=Date.now(),i.apply(this,[this[o]]),this[o]=s?s():void 0):(this[l]=!0,this[n]=setTimeout((()=>{this[l]=!1,this[a]=Date.now(),i.apply(this,[this[o]]),this[o]=s?s():void 0}),h-Date.now()))}}))}},9807:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BugIndicatingError=t.ErrorNoTelemetry=t.ExpectedError=t.NotSupportedError=t.NotImplementedError=t.ReadonlyError=t.CancellationError=t.errorHandler=t.ErrorHandler=void 0,t.setUnexpectedErrorHandler=function(e){t.errorHandler.setUnexpectedErrorHandler(e)},t.isSigPipeError=function(e){if(!e||"object"!=typeof e)return!1;const t=e;return"EPIPE"===t.code&&"WRITE"===t.syscall?.toUpperCase()},t.onUnexpectedError=function(e){r(e)||t.errorHandler.onUnexpectedError(e)},t.onUnexpectedExternalError=function(e){r(e)||t.errorHandler.onUnexpectedExternalError(e)},t.transformErrorForSerialization=function(e){if(e instanceof Error){const{name:t,message:i}=e;return{$isError:!0,name:t,message:i,stack:e.stacktrace||e.stack,noTelemetry:c.isErrorNoTelemetry(e)}}return e},t.transformErrorFromSerialization=function(e){let t;return e.noTelemetry?t=new c:(t=new Error,t.name=e.name),t.message=e.message,t.stack=e.stack,t},t.isCancellationError=r,t.canceled=function(){const e=new Error(s);return e.name=e.message,e},t.illegalArgument=function(e){return e?new Error(`Illegal argument: ${e}`):new Error("Illegal argument")},t.illegalState=function(e){return e?new Error(`Illegal state: ${e}`):new Error("Illegal state")},t.getErrorMessage=function(e){return e?e.message?e.message:e.stack?e.stack.split("\n")[0]:String(e):"Error"};class i{constructor(){this.listeners=[],this.unexpectedErrorHandler=function(e){setTimeout((()=>{if(e.stack){if(c.isErrorNoTelemetry(e))throw new c(e.message+"\n\n"+e.stack);throw new Error(e.message+"\n\n"+e.stack)}throw e}),0)}}addListener(e){return this.listeners.push(e),()=>{this._removeListener(e)}}emit(e){this.listeners.forEach((t=>{t(e)}))}_removeListener(e){this.listeners.splice(this.listeners.indexOf(e),1)}setUnexpectedErrorHandler(e){this.unexpectedErrorHandler=e}getUnexpectedErrorHandler(){return this.unexpectedErrorHandler}onUnexpectedError(e){this.unexpectedErrorHandler(e),this.emit(e)}onUnexpectedExternalError(e){this.unexpectedErrorHandler(e)}}t.ErrorHandler=i,t.errorHandler=new i;const s="Canceled";function r(e){return e instanceof n||e instanceof Error&&e.name===s&&e.message===s}class n extends Error{constructor(){super(s),this.name=this.message}}t.CancellationError=n;class o extends TypeError{constructor(e){super(e?`${e} is read-only and cannot be changed`:"Cannot change read-only property")}}t.ReadonlyError=o;class a extends Error{constructor(e){super("NotImplemented"),e&&(this.message=e)}}t.NotImplementedError=a;class l extends Error{constructor(e){super("NotSupported"),e&&(this.message=e)}}t.NotSupportedError=l;class h extends Error{constructor(){super(...arguments),this.isExpected=!0}}t.ExpectedError=h;class c extends Error{constructor(e){super(e),this.name="CodeExpectedError"}static fromError(e){if(e instanceof c)return e;const t=new c;return t.message=e.message,t.stack=e.stack,t}static isErrorNoTelemetry(e){return"CodeExpectedError"===e.name}}t.ErrorNoTelemetry=c;class d extends Error{constructor(e){super(e||"An unexpected bug occurred."),Object.setPrototypeOf(this,d.prototype)}}t.BugIndicatingError=d},802:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ValueWithChangeEvent=t.Relay=t.EventBufferer=t.DynamicListEventMultiplexer=t.EventMultiplexer=t.MicrotaskEmitter=t.DebounceEmitter=t.PauseableEmitter=t.AsyncEmitter=t.createEventDeliveryQueue=t.Emitter=t.ListenerRefusalError=t.ListenerLeakError=t.EventProfiling=t.Event=void 0,t.setGlobalLeakWarningThreshold=function(e){const t=c;return c=e,{dispose(){c=t}}};const s=i(9807),r=i(8841),n=i(7150),o=i(6317),a=i(9725);var l;!function(e){function t(e){return(t,i=null,s)=>{let r,n=!1;return r=e((e=>{if(!n)return r?r.dispose():n=!0,t.call(i,e)}),null,s),n&&r.dispose(),r}}function i(e,t,i){return r(((i,s=null,r)=>e((e=>i.call(s,t(e))),null,r)),i)}function s(e,t,i){return r(((i,s=null,r)=>e((e=>t(e)&&i.call(s,e)),null,r)),i)}function r(e,t){let i;const s=new m({onWillAddFirstListener(){i=e(s.fire,s)},onDidRemoveLastListener(){i?.dispose()}});return t?.add(s),s.event}function o(e,t,i=100,s=!1,r=!1,n,o){let a,l,h,c,d=0;const u=new m({leakWarningThreshold:n,onWillAddFirstListener(){a=e((e=>{d++,l=t(l,e),s&&!h&&(u.fire(l),l=void 0),c=()=>{const e=l;l=void 0,h=void 0,(!s||d>1)&&u.fire(e),d=0},"number"==typeof i?(clearTimeout(h),h=setTimeout(c,i)):void 0===h&&(h=0,queueMicrotask(c))}))},onWillRemoveListener(){r&&d>0&&c?.()},onDidRemoveLastListener(){c=void 0,a.dispose()}});return o?.add(u),u.event}e.None=()=>n.Disposable.None,e.defer=function(e,t){return o(e,(()=>{}),0,void 0,!0,void 0,t)},e.once=t,e.map=i,e.forEach=function(e,t,i){return r(((i,s=null,r)=>e((e=>{t(e),i.call(s,e)}),null,r)),i)},e.filter=s,e.signal=function(e){return e},e.any=function(...e){return(t,i=null,s)=>{return r=(0,n.combinedDisposable)(...e.map((e=>e((e=>t.call(i,e)))))),(o=s)instanceof Array?o.push(r):o&&o.add(r),r;var r,o}},e.reduce=function(e,t,s,r){let n=s;return i(e,(e=>(n=t(n,e),n)),r)},e.debounce=o,e.accumulate=function(t,i=0,s){return e.debounce(t,((e,t)=>e?(e.push(t),e):[t]),i,void 0,!0,void 0,s)},e.latch=function(e,t=(e,t)=>e===t,i){let r,n=!0;return s(e,(e=>{const i=n||!t(e,r);return n=!1,r=e,i}),i)},e.split=function(t,i,s){return[e.filter(t,i,s),e.filter(t,(e=>!i(e)),s)]},e.buffer=function(e,t=!1,i=[],s){let r=i.slice(),n=e((e=>{r?r.push(e):a.fire(e)}));s&&s.add(n);const o=()=>{r?.forEach((e=>a.fire(e))),r=null},a=new m({onWillAddFirstListener(){n||(n=e((e=>a.fire(e))),s&&s.add(n))},onDidAddFirstListener(){r&&(t?setTimeout(o):o())},onDidRemoveLastListener(){n&&n.dispose(),n=null}});return s&&s.add(a),a.event},e.chain=function(e,t){return(i,s,r)=>{const n=t(new l);return e((function(e){const t=n.evaluate(e);t!==a&&i.call(s,t)}),void 0,r)}};const a=Symbol("HaltChainable");class l{constructor(){this.steps=[]}map(e){return this.steps.push(e),this}forEach(e){return this.steps.push((t=>(e(t),t))),this}filter(e){return this.steps.push((t=>e(t)?t:a)),this}reduce(e,t){let i=t;return this.steps.push((t=>(i=e(i,t),i))),this}latch(e=(e,t)=>e===t){let t,i=!0;return this.steps.push((s=>{const r=i||!e(s,t);return i=!1,t=s,r?s:a})),this}evaluate(e){for(const t of this.steps)if((e=t(e))===a)break;return e}}e.fromNodeEventEmitter=function(e,t,i=e=>e){const s=(...e)=>r.fire(i(...e)),r=new m({onWillAddFirstListener:()=>e.on(t,s),onDidRemoveLastListener:()=>e.removeListener(t,s)});return r.event},e.fromDOMEventEmitter=function(e,t,i=e=>e){const s=(...e)=>r.fire(i(...e)),r=new m({onWillAddFirstListener:()=>e.addEventListener(t,s),onDidRemoveLastListener:()=>e.removeEventListener(t,s)});return r.event},e.toPromise=function(e){return new Promise((i=>t(e)(i)))},e.fromPromise=function(e){const t=new m;return e.then((e=>{t.fire(e)}),(()=>{t.fire(void 0)})).finally((()=>{t.dispose()})),t.event},e.forward=function(e,t){return e((e=>t.fire(e)))},e.runAndSubscribe=function(e,t,i){return t(i),e((e=>t(e)))};class h{constructor(e,t){this._observable=e,this._counter=0,this._hasChanged=!1;const i={onWillAddFirstListener:()=>{e.addObserver(this)},onDidRemoveLastListener:()=>{e.removeObserver(this)}};this.emitter=new m(i),t&&t.add(this.emitter)}beginUpdate(e){this._counter++}handlePossibleChange(e){}handleChange(e,t){this._hasChanged=!0}endUpdate(e){this._counter--,0===this._counter&&(this._observable.reportChanges(),this._hasChanged&&(this._hasChanged=!1,this.emitter.fire(this._observable.get())))}}e.fromObservable=function(e,t){return new h(e,t).emitter.event},e.fromObservableLight=function(e){return(t,i,s)=>{let r=0,o=!1;const a={beginUpdate(){r++},endUpdate(){r--,0===r&&(e.reportChanges(),o&&(o=!1,t.call(i)))},handlePossibleChange(){},handleChange(){o=!0}};e.addObserver(a),e.reportChanges();const l={dispose(){e.removeObserver(a)}};return s instanceof n.DisposableStore?s.add(l):Array.isArray(s)&&s.push(l),l}}}(l||(t.Event=l={}));class h{static{this.all=new Set}static{this._idPool=0}constructor(e){this.listenerCount=0,this.invocationCount=0,this.elapsedOverall=0,this.durations=[],this.name=`${e}_${h._idPool++}`,h.all.add(this)}start(e){this._stopWatch=new a.StopWatch,this.listenerCount=e}stop(){if(this._stopWatch){const e=this._stopWatch.elapsed();this.durations.push(e),this.elapsedOverall+=e,this.invocationCount+=1,this._stopWatch=void 0}}}t.EventProfiling=h;let c=-1;class d{static{this._idPool=1}constructor(e,t,i=(d._idPool++).toString(16).padStart(3,"0")){this._errorHandler=e,this.threshold=t,this.name=i,this._warnCountdown=0}dispose(){this._stacks?.clear()}check(e,t){const i=this.threshold;if(i<=0||t{const t=this._stacks.get(e.value)||0;this._stacks.set(e.value,t-1)}}getMostFrequentStack(){if(!this._stacks)return;let e,t=0;for(const[i,s]of this._stacks)(!e||t0||this._options?.leakWarningThreshold?new d(e?.onListenerError??s.onUnexpectedError,this._options?.leakWarningThreshold??c):void 0,this._perfMon=this._options?._profName?new h(this._options._profName):void 0,this._deliveryQueue=this._options?.deliveryQueue}dispose(){this._disposed||(this._disposed=!0,this._deliveryQueue?.current===this&&this._deliveryQueue.reset(),this._listeners&&(this._listeners=void 0,this._size=0),this._options?.onDidRemoveLastListener?.(),this._leakageMon?.dispose())}get event(){return this._event??=(e,t,i)=>{if(this._leakageMon&&this._size>this._leakageMon.threshold**2){const e=`[${this._leakageMon.name}] REFUSES to accept new listeners because it exceeded its threshold by far (${this._size} vs ${this._leakageMon.threshold})`;console.warn(e);const t=this._leakageMon.getMostFrequentStack()??["UNKNOWN stack",-1],i=new f(`${e}. HINT: Stack shows most frequent listener (${t[1]}-times)`,t[0]);return(this._options?.onListenerError||s.onUnexpectedError)(i),n.Disposable.None}if(this._disposed)return n.Disposable.None;t&&(e=e.bind(t));const r=new g(e);let o;this._leakageMon&&this._size>=Math.ceil(.2*this._leakageMon.threshold)&&(r.stack=u.create(),o=this._leakageMon.check(r.stack,this._size+1)),this._listeners?this._listeners instanceof g?(this._deliveryQueue??=new v,this._listeners=[this._listeners,r]):this._listeners.push(r):(this._options?.onWillAddFirstListener?.(this),this._listeners=r,this._options?.onDidAddFirstListener?.(this)),this._size++;const a=(0,n.toDisposable)((()=>{o?.(),this._removeListener(r)}));return i instanceof n.DisposableStore?i.add(a):Array.isArray(i)&&i.push(a),a},this._event}_removeListener(e){if(this._options?.onWillRemoveListener?.(this),!this._listeners)return;if(1===this._size)return this._listeners=void 0,this._options?.onDidRemoveLastListener?.(this),void(this._size=0);const t=this._listeners,i=t.indexOf(e);if(-1===i)throw console.log("disposed?",this._disposed),console.log("size?",this._size),console.log("arr?",JSON.stringify(this._listeners)),new Error("Attempted to dispose unknown listener");this._size--,t[i]=void 0;const s=this._deliveryQueue.current===this;if(2*this._size<=t.length){let e=0;for(let i=0;i0}}t.Emitter=m,t.createEventDeliveryQueue=()=>new v;class v{constructor(){this.i=-1,this.end=0}enqueue(e,t,i){this.i=0,this.end=i,this.current=e,this.value=t}reset(){this.i=this.end,this.current=void 0,this.value=void 0}}t.AsyncEmitter=class extends m{async fireAsync(e,t,i){if(this._listeners)for(this._asyncDeliveryQueue||(this._asyncDeliveryQueue=new o.LinkedList),((e,t)=>{if(e instanceof g)t(e);else for(let i=0;ithis._asyncDeliveryQueue.push([t.value,e])));this._asyncDeliveryQueue.size>0&&!t.isCancellationRequested;){const[e,r]=this._asyncDeliveryQueue.shift(),n=[],o={...r,token:t,waitUntil:t=>{if(Object.isFrozen(n))throw new Error("waitUntil can NOT be called asynchronous");i&&(t=i(t,e)),n.push(t)}};try{e(o)}catch(e){(0,s.onUnexpectedError)(e);continue}Object.freeze(n),await Promise.allSettled(n).then((e=>{for(const t of e)"rejected"===t.status&&(0,s.onUnexpectedError)(t.reason)}))}}};class S extends m{get isPaused(){return 0!==this._isPaused}constructor(e){super(e),this._isPaused=0,this._eventQueue=new o.LinkedList,this._mergeFn=e?.merge}pause(){this._isPaused++}resume(){if(0!==this._isPaused&&0==--this._isPaused)if(this._mergeFn){if(this._eventQueue.size>0){const e=Array.from(this._eventQueue);this._eventQueue.clear(),super.fire(this._mergeFn(e))}}else for(;!this._isPaused&&0!==this._eventQueue.size;)super.fire(this._eventQueue.shift())}fire(e){this._size&&(0!==this._isPaused?this._eventQueue.push(e):super.fire(e))}}t.PauseableEmitter=S,t.DebounceEmitter=class extends S{constructor(e){super(e),this._delay=e.delay??100}fire(e){this._handle||(this.pause(),this._handle=setTimeout((()=>{this._handle=void 0,this.resume()}),this._delay)),super.fire(e)}},t.MicrotaskEmitter=class extends m{constructor(e){super(e),this._queuedEvents=[],this._mergeFn=e?.merge}fire(e){this.hasListeners()&&(this._queuedEvents.push(e),1===this._queuedEvents.length&&queueMicrotask((()=>{this._mergeFn?super.fire(this._mergeFn(this._queuedEvents)):this._queuedEvents.forEach((e=>super.fire(e))),this._queuedEvents=[]})))}};class b{constructor(){this.hasListeners=!1,this.events=[],this.emitter=new m({onWillAddFirstListener:()=>this.onFirstListenerAdd(),onDidRemoveLastListener:()=>this.onLastListenerRemove()})}get event(){return this.emitter.event}add(e){const t={event:e,listener:null};return this.events.push(t),this.hasListeners&&this.hook(t),(0,n.toDisposable)((0,r.createSingleCallFunction)((()=>{this.hasListeners&&this.unhook(t);const e=this.events.indexOf(t);this.events.splice(e,1)})))}onFirstListenerAdd(){this.hasListeners=!0,this.events.forEach((e=>this.hook(e)))}onLastListenerRemove(){this.hasListeners=!1,this.events.forEach((e=>this.unhook(e)))}hook(e){e.listener=e.event((e=>this.emitter.fire(e)))}unhook(e){e.listener?.dispose(),e.listener=null}dispose(){this.emitter.dispose();for(const e of this.events)e.listener?.dispose();this.events=[]}}t.EventMultiplexer=b,t.DynamicListEventMultiplexer=class{constructor(e,t,i,s){this._store=new n.DisposableStore;const r=this._store.add(new b),o=this._store.add(new n.DisposableMap);function a(e){o.set(e,r.add(s(e)))}for(const t of e)a(t);this._store.add(t((e=>{a(e)}))),this._store.add(i((e=>{o.deleteAndDispose(e)}))),this.event=r.event}dispose(){this._store.dispose()}},t.EventBufferer=class{constructor(){this.data=[]}wrapEvent(e,t,i){return(s,r,n)=>e((e=>{const n=this.data[this.data.length-1];if(!t)return void(n?n.buffers.push((()=>s.call(r,e))):s.call(r,e));const o=n;o?(o.items??=[],o.items.push(e),0===o.buffers.length&&n.buffers.push((()=>{o.reducedResult??=i?o.items.reduce(t,i):o.items.reduce(t),s.call(r,o.reducedResult)}))):s.call(r,t(i,e))}),void 0,n)}bufferEvents(e){const t={buffers:new Array};this.data.push(t);const i=e();return this.data.pop(),t.buffers.forEach((e=>e())),i}},t.Relay=class{constructor(){this.listening=!1,this.inputEvent=l.None,this.inputEventListener=n.Disposable.None,this.emitter=new m({onDidAddFirstListener:()=>{this.listening=!0,this.inputEventListener=this.inputEvent(this.emitter.fire,this.emitter)},onDidRemoveLastListener:()=>{this.listening=!1,this.inputEventListener.dispose()}}),this.event=this.emitter.event}set input(e){this.inputEvent=e,this.listening&&(this.inputEventListener.dispose(),this.inputEventListener=e(this.emitter.fire,this.emitter))}dispose(){this.inputEventListener.dispose(),this.emitter.dispose()}},t.ValueWithChangeEvent=class{static const(e){return new C(e)}constructor(e){this._value=e,this._onDidChange=new m,this.onDidChange=this._onDidChange.event}get value(){return this._value}set value(e){e!==this._value&&(this._value=e,this._onDidChange.fire(void 0))}};class C{constructor(e){this.value=e,this.onDidChange=l.None}}},8841:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createSingleCallFunction=function(e,t){const i=this;let s,r=!1;return function(){if(r)return s;if(r=!0,t)try{s=e.apply(i,arguments)}finally{t()}else s=e.apply(i,arguments);return s}}},6304:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.StringSHA1=t.Hasher=void 0,t.hash=function(e){return a(e,0)},t.doHash=a,t.numberHash=l,t.stringHash=h,t.toHexString=_;const o=n(i(1316));function a(e,t){switch(typeof e){case"object":return null===e?l(349,t):Array.isArray(e)?(i=e,s=l(104579,s=t),i.reduce(((e,t)=>a(t,e)),s)):function(e,t){return t=l(181387,t),Object.keys(e).sort().reduce(((t,i)=>(t=h(i,t),a(e[i],t))),t)}(e,t);case"string":return h(e,t);case"boolean":return function(e,t){return l(e?433:863,t)}(e,t);case"number":return l(e,t);case"undefined":return l(937,t);default:return l(617,t)}var i,s}function l(e,t){return(t<<5)-t+e|0}function h(e,t){t=l(149417,t);for(let i=0,s=e.length;i>>s)>>>0}function u(e,t=0,i=e.byteLength,s=0){for(let r=0;re.toString(16).padStart(2,"0"))).join(""):function(e,t,i="0"){for(;e.length>>0).toString(16),t/4)}t.Hasher=class{constructor(){this._value=0}get value(){return this._value}hash(e){return this._value=a(e,this._value),this._value}},function(e){e[e.BLOCK_SIZE=64]="BLOCK_SIZE",e[e.UNICODE_REPLACEMENT=65533]="UNICODE_REPLACEMENT"}(c||(c={}));class f{static{this._bigBlock32=new DataView(new ArrayBuffer(320))}constructor(){this._h0=1732584193,this._h1=4023233417,this._h2=2562383102,this._h3=271733878,this._h4=3285377520,this._buff=new Uint8Array(c.BLOCK_SIZE+3),this._buffDV=new DataView(this._buff.buffer),this._buffLen=0,this._totalLen=0,this._leftoverHighSurrogate=0,this._finished=!1}update(e){const t=e.length;if(0===t)return;const i=this._buff;let s,r,n=this._buffLen,a=this._leftoverHighSurrogate;for(0!==a?(s=a,r=-1,a=0):(s=e.charCodeAt(0),r=0);;){let l=s;if(o.isHighSurrogate(s)){if(!(r+1>>6,e[t++]=128|(63&i)>>>0):i<65536?(e[t++]=224|(61440&i)>>>12,e[t++]=128|(4032&i)>>>6,e[t++]=128|(63&i)>>>0):(e[t++]=240|(1835008&i)>>>18,e[t++]=128|(258048&i)>>>12,e[t++]=128|(4032&i)>>>6,e[t++]=128|(63&i)>>>0),t>=c.BLOCK_SIZE&&(this._step(),t-=c.BLOCK_SIZE,this._totalLen+=c.BLOCK_SIZE,e[0]=e[c.BLOCK_SIZE+0],e[1]=e[c.BLOCK_SIZE+1],e[2]=e[c.BLOCK_SIZE+2]),t}digest(){return this._finished||(this._finished=!0,this._leftoverHighSurrogate&&(this._leftoverHighSurrogate=0,this._buffLen=this._push(this._buff,this._buffLen,c.UNICODE_REPLACEMENT)),this._totalLen+=this._buffLen,this._wrapUp()),_(this._h0)+_(this._h1)+_(this._h2)+_(this._h3)+_(this._h4)}_wrapUp(){this._buff[this._buffLen++]=128,u(this._buff,this._buffLen),this._buffLen>56&&(this._step(),u(this._buff));const e=8*this._totalLen;this._buffDV.setUint32(56,Math.floor(e/4294967296),!1),this._buffDV.setUint32(60,e%4294967296,!1),this._step()}_step(){const e=f._bigBlock32,t=this._buffDV;for(let i=0;i<64;i+=4)e.setUint32(i,t.getUint32(i,!1),!1);for(let t=64;t<320;t+=4)e.setUint32(t,d(e.getUint32(t-12,!1)^e.getUint32(t-32,!1)^e.getUint32(t-56,!1)^e.getUint32(t-64,!1),1),!1);let i,s,r,n=this._h0,o=this._h1,a=this._h2,l=this._h3,h=this._h4;for(let t=0;t<80;t++)t<20?(i=o&a|~o&l,s=1518500249):t<40?(i=o^a^l,s=1859775393):t<60?(i=o&a|o&l|a&l,s=2400959708):(i=o^a^l,s=3395469782),r=d(n,5)+i+h+s+e.getUint32(4*t,!1)&4294967295,h=l,l=a,a=d(o,30),o=n,n=r;this._h0=this._h0+n&4294967295,this._h1=this._h1+o&4294967295,this._h2=this._h2+a&4294967295,this._h3=this._h3+l&4294967295,this._h4=this._h4+h&4294967295}}t.StringSHA1=f},4218:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.Iterable=void 0,function(e){function t(e){return e&&"object"==typeof e&&"function"==typeof e[Symbol.iterator]}e.is=t;const i=Object.freeze([]);function*s(e){yield e}e.empty=function(){return i},e.single=s,e.wrap=function(e){return t(e)?e:s(e)},e.from=function(e){return e||i},e.reverse=function*(e){for(let t=e.length-1;t>=0;t--)yield e[t]},e.isEmpty=function(e){return!e||!0===e[Symbol.iterator]().next().done},e.first=function(e){return e[Symbol.iterator]().next().value},e.some=function(e,t){let i=0;for(const s of e)if(t(s,i++))return!0;return!1},e.find=function(e,t){for(const i of e)if(t(i))return i},e.filter=function*(e,t){for(const i of e)t(i)&&(yield i)},e.map=function*(e,t){let i=0;for(const s of e)yield t(s,i++)},e.flatMap=function*(e,t){let i=0;for(const s of e)yield*t(s,i++)},e.concat=function*(...e){for(const t of e)yield*t},e.reduce=function(e,t,i){let s=i;for(const i of e)s=t(s,i);return s},e.slice=function*(e,t,i=e.length){for(t<0&&(t+=e.length),i<0?i+=e.length:i>e.length&&(i=e.length);tr}]},e.asyncToArray=async function(e){const t=[];for await(const i of e)t.push(i);return Promise.resolve(t)}}(i||(t.Iterable=i={}))},7883:(e,t)=>{var i,s;Object.defineProperty(t,"__esModule",{value:!0}),t.KeyMod=t.KeyCodeUtils=t.ScanCodeUtils=t.NATIVE_WINDOWS_KEY_CODE_TO_KEY_CODE=t.EVENT_KEY_CODE_MAP=t.ScanCode=t.KeyCode=void 0,t.KeyChord=function(e,t){return(e|(65535&t)<<16>>>0)>>>0},function(e){e[e.DependsOnKbLayout=-1]="DependsOnKbLayout",e[e.Unknown=0]="Unknown",e[e.Backspace=1]="Backspace",e[e.Tab=2]="Tab",e[e.Enter=3]="Enter",e[e.Shift=4]="Shift",e[e.Ctrl=5]="Ctrl",e[e.Alt=6]="Alt",e[e.PauseBreak=7]="PauseBreak",e[e.CapsLock=8]="CapsLock",e[e.Escape=9]="Escape",e[e.Space=10]="Space",e[e.PageUp=11]="PageUp",e[e.PageDown=12]="PageDown",e[e.End=13]="End",e[e.Home=14]="Home",e[e.LeftArrow=15]="LeftArrow",e[e.UpArrow=16]="UpArrow",e[e.RightArrow=17]="RightArrow",e[e.DownArrow=18]="DownArrow",e[e.Insert=19]="Insert",e[e.Delete=20]="Delete",e[e.Digit0=21]="Digit0",e[e.Digit1=22]="Digit1",e[e.Digit2=23]="Digit2",e[e.Digit3=24]="Digit3",e[e.Digit4=25]="Digit4",e[e.Digit5=26]="Digit5",e[e.Digit6=27]="Digit6",e[e.Digit7=28]="Digit7",e[e.Digit8=29]="Digit8",e[e.Digit9=30]="Digit9",e[e.KeyA=31]="KeyA",e[e.KeyB=32]="KeyB",e[e.KeyC=33]="KeyC",e[e.KeyD=34]="KeyD",e[e.KeyE=35]="KeyE",e[e.KeyF=36]="KeyF",e[e.KeyG=37]="KeyG",e[e.KeyH=38]="KeyH",e[e.KeyI=39]="KeyI",e[e.KeyJ=40]="KeyJ",e[e.KeyK=41]="KeyK",e[e.KeyL=42]="KeyL",e[e.KeyM=43]="KeyM",e[e.KeyN=44]="KeyN",e[e.KeyO=45]="KeyO",e[e.KeyP=46]="KeyP",e[e.KeyQ=47]="KeyQ",e[e.KeyR=48]="KeyR",e[e.KeyS=49]="KeyS",e[e.KeyT=50]="KeyT",e[e.KeyU=51]="KeyU",e[e.KeyV=52]="KeyV",e[e.KeyW=53]="KeyW",e[e.KeyX=54]="KeyX",e[e.KeyY=55]="KeyY",e[e.KeyZ=56]="KeyZ",e[e.Meta=57]="Meta",e[e.ContextMenu=58]="ContextMenu",e[e.F1=59]="F1",e[e.F2=60]="F2",e[e.F3=61]="F3",e[e.F4=62]="F4",e[e.F5=63]="F5",e[e.F6=64]="F6",e[e.F7=65]="F7",e[e.F8=66]="F8",e[e.F9=67]="F9",e[e.F10=68]="F10",e[e.F11=69]="F11",e[e.F12=70]="F12",e[e.F13=71]="F13",e[e.F14=72]="F14",e[e.F15=73]="F15",e[e.F16=74]="F16",e[e.F17=75]="F17",e[e.F18=76]="F18",e[e.F19=77]="F19",e[e.F20=78]="F20",e[e.F21=79]="F21",e[e.F22=80]="F22",e[e.F23=81]="F23",e[e.F24=82]="F24",e[e.NumLock=83]="NumLock",e[e.ScrollLock=84]="ScrollLock",e[e.Semicolon=85]="Semicolon",e[e.Equal=86]="Equal",e[e.Comma=87]="Comma",e[e.Minus=88]="Minus",e[e.Period=89]="Period",e[e.Slash=90]="Slash",e[e.Backquote=91]="Backquote",e[e.BracketLeft=92]="BracketLeft",e[e.Backslash=93]="Backslash",e[e.BracketRight=94]="BracketRight",e[e.Quote=95]="Quote",e[e.OEM_8=96]="OEM_8",e[e.IntlBackslash=97]="IntlBackslash",e[e.Numpad0=98]="Numpad0",e[e.Numpad1=99]="Numpad1",e[e.Numpad2=100]="Numpad2",e[e.Numpad3=101]="Numpad3",e[e.Numpad4=102]="Numpad4",e[e.Numpad5=103]="Numpad5",e[e.Numpad6=104]="Numpad6",e[e.Numpad7=105]="Numpad7",e[e.Numpad8=106]="Numpad8",e[e.Numpad9=107]="Numpad9",e[e.NumpadMultiply=108]="NumpadMultiply",e[e.NumpadAdd=109]="NumpadAdd",e[e.NUMPAD_SEPARATOR=110]="NUMPAD_SEPARATOR",e[e.NumpadSubtract=111]="NumpadSubtract",e[e.NumpadDecimal=112]="NumpadDecimal",e[e.NumpadDivide=113]="NumpadDivide",e[e.KEY_IN_COMPOSITION=114]="KEY_IN_COMPOSITION",e[e.ABNT_C1=115]="ABNT_C1",e[e.ABNT_C2=116]="ABNT_C2",e[e.AudioVolumeMute=117]="AudioVolumeMute",e[e.AudioVolumeUp=118]="AudioVolumeUp",e[e.AudioVolumeDown=119]="AudioVolumeDown",e[e.BrowserSearch=120]="BrowserSearch",e[e.BrowserHome=121]="BrowserHome",e[e.BrowserBack=122]="BrowserBack",e[e.BrowserForward=123]="BrowserForward",e[e.MediaTrackNext=124]="MediaTrackNext",e[e.MediaTrackPrevious=125]="MediaTrackPrevious",e[e.MediaStop=126]="MediaStop",e[e.MediaPlayPause=127]="MediaPlayPause",e[e.LaunchMediaPlayer=128]="LaunchMediaPlayer",e[e.LaunchMail=129]="LaunchMail",e[e.LaunchApp2=130]="LaunchApp2",e[e.Clear=131]="Clear",e[e.MAX_VALUE=132]="MAX_VALUE"}(i||(t.KeyCode=i={})),function(e){e[e.DependsOnKbLayout=-1]="DependsOnKbLayout",e[e.None=0]="None",e[e.Hyper=1]="Hyper",e[e.Super=2]="Super",e[e.Fn=3]="Fn",e[e.FnLock=4]="FnLock",e[e.Suspend=5]="Suspend",e[e.Resume=6]="Resume",e[e.Turbo=7]="Turbo",e[e.Sleep=8]="Sleep",e[e.WakeUp=9]="WakeUp",e[e.KeyA=10]="KeyA",e[e.KeyB=11]="KeyB",e[e.KeyC=12]="KeyC",e[e.KeyD=13]="KeyD",e[e.KeyE=14]="KeyE",e[e.KeyF=15]="KeyF",e[e.KeyG=16]="KeyG",e[e.KeyH=17]="KeyH",e[e.KeyI=18]="KeyI",e[e.KeyJ=19]="KeyJ",e[e.KeyK=20]="KeyK",e[e.KeyL=21]="KeyL",e[e.KeyM=22]="KeyM",e[e.KeyN=23]="KeyN",e[e.KeyO=24]="KeyO",e[e.KeyP=25]="KeyP",e[e.KeyQ=26]="KeyQ",e[e.KeyR=27]="KeyR",e[e.KeyS=28]="KeyS",e[e.KeyT=29]="KeyT",e[e.KeyU=30]="KeyU",e[e.KeyV=31]="KeyV",e[e.KeyW=32]="KeyW",e[e.KeyX=33]="KeyX",e[e.KeyY=34]="KeyY",e[e.KeyZ=35]="KeyZ",e[e.Digit1=36]="Digit1",e[e.Digit2=37]="Digit2",e[e.Digit3=38]="Digit3",e[e.Digit4=39]="Digit4",e[e.Digit5=40]="Digit5",e[e.Digit6=41]="Digit6",e[e.Digit7=42]="Digit7",e[e.Digit8=43]="Digit8",e[e.Digit9=44]="Digit9",e[e.Digit0=45]="Digit0",e[e.Enter=46]="Enter",e[e.Escape=47]="Escape",e[e.Backspace=48]="Backspace",e[e.Tab=49]="Tab",e[e.Space=50]="Space",e[e.Minus=51]="Minus",e[e.Equal=52]="Equal",e[e.BracketLeft=53]="BracketLeft",e[e.BracketRight=54]="BracketRight",e[e.Backslash=55]="Backslash",e[e.IntlHash=56]="IntlHash",e[e.Semicolon=57]="Semicolon",e[e.Quote=58]="Quote",e[e.Backquote=59]="Backquote",e[e.Comma=60]="Comma",e[e.Period=61]="Period",e[e.Slash=62]="Slash",e[e.CapsLock=63]="CapsLock",e[e.F1=64]="F1",e[e.F2=65]="F2",e[e.F3=66]="F3",e[e.F4=67]="F4",e[e.F5=68]="F5",e[e.F6=69]="F6",e[e.F7=70]="F7",e[e.F8=71]="F8",e[e.F9=72]="F9",e[e.F10=73]="F10",e[e.F11=74]="F11",e[e.F12=75]="F12",e[e.PrintScreen=76]="PrintScreen",e[e.ScrollLock=77]="ScrollLock",e[e.Pause=78]="Pause",e[e.Insert=79]="Insert",e[e.Home=80]="Home",e[e.PageUp=81]="PageUp",e[e.Delete=82]="Delete",e[e.End=83]="End",e[e.PageDown=84]="PageDown",e[e.ArrowRight=85]="ArrowRight",e[e.ArrowLeft=86]="ArrowLeft",e[e.ArrowDown=87]="ArrowDown",e[e.ArrowUp=88]="ArrowUp",e[e.NumLock=89]="NumLock",e[e.NumpadDivide=90]="NumpadDivide",e[e.NumpadMultiply=91]="NumpadMultiply",e[e.NumpadSubtract=92]="NumpadSubtract",e[e.NumpadAdd=93]="NumpadAdd",e[e.NumpadEnter=94]="NumpadEnter",e[e.Numpad1=95]="Numpad1",e[e.Numpad2=96]="Numpad2",e[e.Numpad3=97]="Numpad3",e[e.Numpad4=98]="Numpad4",e[e.Numpad5=99]="Numpad5",e[e.Numpad6=100]="Numpad6",e[e.Numpad7=101]="Numpad7",e[e.Numpad8=102]="Numpad8",e[e.Numpad9=103]="Numpad9",e[e.Numpad0=104]="Numpad0",e[e.NumpadDecimal=105]="NumpadDecimal",e[e.IntlBackslash=106]="IntlBackslash",e[e.ContextMenu=107]="ContextMenu",e[e.Power=108]="Power",e[e.NumpadEqual=109]="NumpadEqual",e[e.F13=110]="F13",e[e.F14=111]="F14",e[e.F15=112]="F15",e[e.F16=113]="F16",e[e.F17=114]="F17",e[e.F18=115]="F18",e[e.F19=116]="F19",e[e.F20=117]="F20",e[e.F21=118]="F21",e[e.F22=119]="F22",e[e.F23=120]="F23",e[e.F24=121]="F24",e[e.Open=122]="Open",e[e.Help=123]="Help",e[e.Select=124]="Select",e[e.Again=125]="Again",e[e.Undo=126]="Undo",e[e.Cut=127]="Cut",e[e.Copy=128]="Copy",e[e.Paste=129]="Paste",e[e.Find=130]="Find",e[e.AudioVolumeMute=131]="AudioVolumeMute",e[e.AudioVolumeUp=132]="AudioVolumeUp",e[e.AudioVolumeDown=133]="AudioVolumeDown",e[e.NumpadComma=134]="NumpadComma",e[e.IntlRo=135]="IntlRo",e[e.KanaMode=136]="KanaMode",e[e.IntlYen=137]="IntlYen",e[e.Convert=138]="Convert",e[e.NonConvert=139]="NonConvert",e[e.Lang1=140]="Lang1",e[e.Lang2=141]="Lang2",e[e.Lang3=142]="Lang3",e[e.Lang4=143]="Lang4",e[e.Lang5=144]="Lang5",e[e.Abort=145]="Abort",e[e.Props=146]="Props",e[e.NumpadParenLeft=147]="NumpadParenLeft",e[e.NumpadParenRight=148]="NumpadParenRight",e[e.NumpadBackspace=149]="NumpadBackspace",e[e.NumpadMemoryStore=150]="NumpadMemoryStore",e[e.NumpadMemoryRecall=151]="NumpadMemoryRecall",e[e.NumpadMemoryClear=152]="NumpadMemoryClear",e[e.NumpadMemoryAdd=153]="NumpadMemoryAdd",e[e.NumpadMemorySubtract=154]="NumpadMemorySubtract",e[e.NumpadClear=155]="NumpadClear",e[e.NumpadClearEntry=156]="NumpadClearEntry",e[e.ControlLeft=157]="ControlLeft",e[e.ShiftLeft=158]="ShiftLeft",e[e.AltLeft=159]="AltLeft",e[e.MetaLeft=160]="MetaLeft",e[e.ControlRight=161]="ControlRight",e[e.ShiftRight=162]="ShiftRight",e[e.AltRight=163]="AltRight",e[e.MetaRight=164]="MetaRight",e[e.BrightnessUp=165]="BrightnessUp",e[e.BrightnessDown=166]="BrightnessDown",e[e.MediaPlay=167]="MediaPlay",e[e.MediaRecord=168]="MediaRecord",e[e.MediaFastForward=169]="MediaFastForward",e[e.MediaRewind=170]="MediaRewind",e[e.MediaTrackNext=171]="MediaTrackNext",e[e.MediaTrackPrevious=172]="MediaTrackPrevious",e[e.MediaStop=173]="MediaStop",e[e.Eject=174]="Eject",e[e.MediaPlayPause=175]="MediaPlayPause",e[e.MediaSelect=176]="MediaSelect",e[e.LaunchMail=177]="LaunchMail",e[e.LaunchApp2=178]="LaunchApp2",e[e.LaunchApp1=179]="LaunchApp1",e[e.SelectTask=180]="SelectTask",e[e.LaunchScreenSaver=181]="LaunchScreenSaver",e[e.BrowserSearch=182]="BrowserSearch",e[e.BrowserHome=183]="BrowserHome",e[e.BrowserBack=184]="BrowserBack",e[e.BrowserForward=185]="BrowserForward",e[e.BrowserStop=186]="BrowserStop",e[e.BrowserRefresh=187]="BrowserRefresh",e[e.BrowserFavorites=188]="BrowserFavorites",e[e.ZoomToggle=189]="ZoomToggle",e[e.MailReply=190]="MailReply",e[e.MailForward=191]="MailForward",e[e.MailSend=192]="MailSend",e[e.MAX_VALUE=193]="MAX_VALUE"}(s||(t.ScanCode=s={}));class r{constructor(){this._keyCodeToStr=[],this._strToKeyCode=Object.create(null)}define(e,t){this._keyCodeToStr[e]=t,this._strToKeyCode[t.toLowerCase()]=e}keyCodeToStr(e){return this._keyCodeToStr[e]}strToKeyCode(e){return this._strToKeyCode[e.toLowerCase()]||i.Unknown}}const n=new r,o=new r,a=new r;t.EVENT_KEY_CODE_MAP=new Array(230),t.NATIVE_WINDOWS_KEY_CODE_TO_KEY_CODE={};const l=[],h=Object.create(null),c=Object.create(null);var d,u;t.ScanCodeUtils={lowerCaseToEnum:e=>c[e]||s.None,toEnum:e=>h[e]||s.None,toString:e=>l[e]||"None"},function(e){e.toString=function(e){return n.keyCodeToStr(e)},e.fromString=function(e){return n.strToKeyCode(e)},e.toUserSettingsUS=function(e){return o.keyCodeToStr(e)},e.toUserSettingsGeneral=function(e){return a.keyCodeToStr(e)},e.fromUserSettings=function(e){return o.strToKeyCode(e)||a.strToKeyCode(e)},e.toElectronAccelerator=function(e){if(e>=i.Numpad0&&e<=i.NumpadDivide)return null;switch(e){case i.UpArrow:return"Up";case i.DownArrow:return"Down";case i.LeftArrow:return"Left";case i.RightArrow:return"Right"}return n.keyCodeToStr(e)}}(d||(t.KeyCodeUtils=d={})),function(e){e[e.CtrlCmd=2048]="CtrlCmd",e[e.Shift=1024]="Shift",e[e.Alt=512]="Alt",e[e.WinCtrl=256]="WinCtrl"}(u||(t.KeyMod=u={}))},2811:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ResolvedKeybinding=t.ResolvedChord=t.Keybinding=t.ScanCodeChord=t.KeyCodeChord=void 0,t.decodeKeybinding=function(e,t){if("number"==typeof e){if(0===e)return null;const i=(65535&e)>>>0,s=(4294901760&e)>>>16;return new c(0!==s?[a(i,t),a(s,t)]:[a(i,t)])}{const i=[];for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.Lazy=void 0,t.Lazy=class{constructor(e){this.executor=e,this._didRun=!1}get hasValue(){return this._didRun}get value(){if(!this._didRun)try{this._value=this.executor()}catch(e){this._error=e}finally{this._didRun=!0}if(this._error)throw this._error;return this._value}get rawValue(){return this._value}}},7150:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DisposableMap=t.ImmortalReference=t.AsyncReferenceCollection=t.ReferenceCollection=t.SafeDisposable=t.RefCountedDisposable=t.MandatoryMutableDisposable=t.MutableDisposable=t.Disposable=t.DisposableStore=t.DisposableTracker=void 0,t.setDisposableTracker=function(e){l=e},t.trackDisposable=c,t.markAsDisposed=d,t.markAsSingleton=function(e){return l?.markAsSingleton(e),e},t.isDisposable=_,t.dispose=f,t.disposeIfDisposable=function(e){for(const t of e)_(t)&&t.dispose();return[]},t.combinedDisposable=function(...e){const t=p((()=>f(e)));return function(e,t){if(l)for(const i of e)l.setParent(i,t)}(e,t),t},t.toDisposable=p,t.disposeOnReturn=function(e){const t=new g;try{e(t)}finally{t.dispose()}};const s=i(3058),r=i(9087),n=i(2608),o=i(8841),a=i(4218);let l=null;class h{constructor(){this.livingDisposables=new Map}static{this.idx=0}getDisposableData(e){let t=this.livingDisposables.get(e);return t||(t={parent:null,source:null,isSingleton:!1,value:e,idx:h.idx++},this.livingDisposables.set(e,t)),t}trackDisposable(e){const t=this.getDisposableData(e);t.source||(t.source=(new Error).stack)}setParent(e,t){this.getDisposableData(e).parent=t}markAsDisposed(e){this.livingDisposables.delete(e)}markAsSingleton(e){this.getDisposableData(e).isSingleton=!0}getRootParent(e,t){const i=t.get(e);if(i)return i;const s=e.parent?this.getRootParent(this.getDisposableData(e.parent),t):e;return t.set(e,s),s}getTrackedDisposables(){const e=new Map;return[...this.livingDisposables.entries()].filter((([,t])=>null!==t.source&&!this.getRootParent(t,e).isSingleton)).flatMap((([e])=>e))}computeLeakingDisposables(e=10,t){let i;if(t)i=t;else{const e=new Map,t=[...this.livingDisposables.values()].filter((t=>null!==t.source&&!this.getRootParent(t,e).isSingleton));if(0===t.length)return;const s=new Set(t.map((e=>e.value)));if(i=t.filter((e=>!(e.parent&&s.has(e.parent)))),0===i.length)throw new Error("There are cyclic diposable chains!")}if(!i)return;function o(e){const t=e.source.split("\n").map((e=>e.trim().replace("at ",""))).filter((e=>""!==e));return function(e,t){for(;e.length>0&&t.some((t=>"string"==typeof t?t===e[0]:e[0].match(t)));)e.shift()}(t,["Error",/^trackDisposable \(.*\)$/,/^DisposableTracker.trackDisposable \(.*\)$/]),t.reverse()}const a=new n.SetMap;for(const e of i){const t=o(e);for(let i=0;i<=t.length;i++)a.add(t.slice(0,i).join("\n"),e)}i.sort((0,s.compareBy)((e=>e.idx),s.numberComparator));let l="",h=0;for(const t of i.slice(0,e)){h++;const e=o(t),s=[];for(let t=0;to(e)[t])),(e=>e));delete h[e[t]];for(const[e,t]of Object.entries(h))s.unshift(` - stacktraces of ${t.length} other leaks continue with ${e}`);s.unshift(n)}l+=`\n\n\n==================== Leaking disposable ${h}/${i.length}: ${t.value.constructor.name} ====================\n${s.join("\n")}\n============================================================\n\n`}return i.length>e&&(l+=`\n\n\n... and ${i.length-e} more leaking disposables\n\n`),{leaks:i,details:l}}}function c(e){return l?.trackDisposable(e),e}function d(e){l?.markAsDisposed(e)}function u(e,t){l?.setParent(e,t)}function _(e){return"object"==typeof e&&null!==e&&"function"==typeof e.dispose&&0===e.dispose.length}function f(e){if(a.Iterable.is(e)){const t=[];for(const i of e)if(i)try{i.dispose()}catch(e){t.push(e)}if(1===t.length)throw t[0];if(t.length>1)throw new AggregateError(t,"Encountered errors while disposing of store");return Array.isArray(e)?[]:e}if(e)return e.dispose(),e}function p(e){const t=c({dispose:(0,o.createSingleCallFunction)((()=>{d(t),e()}))});return t}t.DisposableTracker=h;class g{static{this.DISABLE_DISPOSED_WARNING=!1}constructor(){this._toDispose=new Set,this._isDisposed=!1,c(this)}dispose(){this._isDisposed||(d(this),this._isDisposed=!0,this.clear())}get isDisposed(){return this._isDisposed}clear(){if(0!==this._toDispose.size)try{f(this._toDispose)}finally{this._toDispose.clear()}}add(e){if(!e)return e;if(e===this)throw new Error("Cannot register a disposable on itself!");return u(e,this),this._isDisposed?g.DISABLE_DISPOSED_WARNING||console.warn(new Error("Trying to add a disposable to a DisposableStore that has already been disposed of. The added object will be leaked!").stack):this._toDispose.add(e),e}delete(e){if(e){if(e===this)throw new Error("Cannot dispose a disposable on itself!");this._toDispose.delete(e),e.dispose()}}deleteAndLeak(e){e&&this._toDispose.has(e)&&(this._toDispose.delete(e),u(e,null))}}t.DisposableStore=g;class m{static{this.None=Object.freeze({dispose(){}})}constructor(){this._store=new g,c(this),u(this._store,this)}dispose(){d(this),this._store.dispose()}_register(e){if(e===this)throw new Error("Cannot register a disposable on itself!");return this._store.add(e)}}t.Disposable=m;class v{constructor(){this._isDisposed=!1,c(this)}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),e&&u(e,this),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,d(this),this._value?.dispose(),this._value=void 0}clearAndLeak(){const e=this._value;return this._value=void 0,e&&u(e,null),e}}t.MutableDisposable=v,t.MandatoryMutableDisposable=class{constructor(e){this._disposable=new v,this._isDisposed=!1,this._disposable.value=e}get value(){return this._disposable.value}set value(e){this._isDisposed||e===this._disposable.value||(this._disposable.value=e)}dispose(){this._isDisposed=!0,this._disposable.dispose()}},t.RefCountedDisposable=class{constructor(e){this._disposable=e,this._counter=1}acquire(){return this._counter++,this}release(){return 0==--this._counter&&this._disposable.dispose(),this}},t.SafeDisposable=class{constructor(){this.dispose=()=>{},this.unset=()=>{},this.isset=()=>!1,c(this)}set(e){let t=e;return this.unset=()=>t=void 0,this.isset=()=>void 0!==t,this.dispose=()=>{t&&(t(),t=void 0,d(this))},this}},t.ReferenceCollection=class{constructor(){this.references=new Map}acquire(e,...t){let i=this.references.get(e);i||(i={counter:0,object:this.createReferencedObject(e,...t)},this.references.set(e,i));const{object:s}=i,r=(0,o.createSingleCallFunction)((()=>{0==--i.counter&&(this.destroyReferencedObject(e,i.object),this.references.delete(e))}));return i.counter++,{object:s,dispose:r}}},t.AsyncReferenceCollection=class{constructor(e){this.referenceCollection=e}async acquire(e,...t){const i=this.referenceCollection.acquire(e,...t);try{return{object:await i.object,dispose:()=>i.dispose()}}catch(e){throw i.dispose(),e}}},t.ImmortalReference=class{constructor(e){this.object=e}dispose(){}};class S{constructor(){this._store=new Map,this._isDisposed=!1,c(this)}dispose(){d(this),this._isDisposed=!0,this.clearAndDisposeAll()}clearAndDisposeAll(){if(this._store.size)try{f(this._store.values())}finally{this._store.clear()}}has(e){return this._store.has(e)}get size(){return this._store.size}get(e){return this._store.get(e)}set(e,t,i=!1){this._isDisposed&&console.warn(new Error("Trying to add a disposable to a DisposableMap that has already been disposed of. The added object will be leaked!").stack),i||this._store.get(e)?.dispose(),this._store.set(e,t)}deleteAndDispose(e){this._store.get(e)?.dispose(),this._store.delete(e)}deleteAndLeak(e){const t=this._store.get(e);return this._store.delete(e),t}keys(){return this._store.keys()}values(){return this._store.values()}[Symbol.iterator](){return this._store[Symbol.iterator]()}}t.DisposableMap=S},6317:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkedList=void 0;class i{static{this.Undefined=new i(void 0)}constructor(e){this.element=e,this.next=i.Undefined,this.prev=i.Undefined}}class s{constructor(){this._first=i.Undefined,this._last=i.Undefined,this._size=0}get size(){return this._size}isEmpty(){return this._first===i.Undefined}clear(){let e=this._first;for(;e!==i.Undefined;){const t=e.next;e.prev=i.Undefined,e.next=i.Undefined,e=t}this._first=i.Undefined,this._last=i.Undefined,this._size=0}unshift(e){return this._insert(e,!1)}push(e){return this._insert(e,!0)}_insert(e,t){const s=new i(e);if(this._first===i.Undefined)this._first=s,this._last=s;else if(t){const e=this._last;this._last=s,s.prev=e,e.next=s}else{const e=this._first;this._first=s,s.next=e,e.prev=s}this._size+=1;let r=!1;return()=>{r||(r=!0,this._remove(s))}}shift(){if(this._first!==i.Undefined){const e=this._first.element;return this._remove(this._first),e}}pop(){if(this._last!==i.Undefined){const e=this._last.element;return this._remove(this._last),e}}_remove(e){if(e.prev!==i.Undefined&&e.next!==i.Undefined){const t=e.prev;t.next=e.next,e.next.prev=t}else e.prev===i.Undefined&&e.next===i.Undefined?(this._first=i.Undefined,this._last=i.Undefined):e.next===i.Undefined?(this._last=this._last.prev,this._last.next=i.Undefined):e.prev===i.Undefined&&(this._first=this._first.next,this._first.prev=i.Undefined);this._size-=1}*[Symbol.iterator](){let e=this._first;for(;e!==i.Undefined;)yield e.element,e=e.next}}t.LinkedList=s},2608:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.SetMap=t.BidirectionalMap=t.CounterSet=t.Touch=void 0,t.getOrSet=function(e,t,i){let s=e.get(t);return void 0===s&&(s=i,e.set(t,s)),s},t.mapToString=function(e){const t=[];return e.forEach(((e,i)=>{t.push(`${i} => ${e}`)})),`Map(${e.size}) {${t.join(", ")}}`},t.setToString=function(e){const t=[];return e.forEach((e=>{t.push(e)})),`Set(${e.size}) {${t.join(", ")}}`},t.mapsStrictEqualIgnoreOrder=function(e,t){if(e===t)return!0;if(e.size!==t.size)return!1;for(const[i,s]of e)if(!t.has(i)||t.get(i)!==s)return!1;for(const[i]of t)if(!e.has(i))return!1;return!0},function(e){e[e.None=0]="None",e[e.AsOld=1]="AsOld",e[e.AsNew=2]="AsNew"}(i||(t.Touch=i={})),t.CounterSet=class{constructor(){this.map=new Map}add(e){return this.map.set(e,(this.map.get(e)||0)+1),this}delete(e){let t=this.map.get(e)||0;return 0!==t&&(t--,0===t?this.map.delete(e):this.map.set(e,t),!0)}has(e){return this.map.has(e)}},t.BidirectionalMap=class{constructor(e){if(this._m1=new Map,this._m2=new Map,e)for(const[t,i]of e)this.set(t,i)}clear(){this._m1.clear(),this._m2.clear()}set(e,t){this._m1.set(e,t),this._m2.set(t,e)}get(e){return this._m1.get(e)}getKey(e){return this._m2.get(e)}delete(e){const t=this._m1.get(e);return void 0!==t&&(this._m1.delete(e),this._m2.delete(t),!0)}forEach(e,t){this._m1.forEach(((i,s)=>{e.call(t,i,s,this)}))}keys(){return this._m1.keys()}values(){return this._m1.values()}},t.SetMap=class{constructor(){this.map=new Map}add(e,t){let i=this.map.get(e);i||(i=new Set,this.map.set(e,i)),i.add(t)}delete(e,t){const i=this.map.get(e);i&&(i.delete(t),0===i.size&&this.map.delete(e))}forEach(e,t){const i=this.map.get(e);i&&i.forEach(t)}get(e){return this.map.get(e)||new Set}}},7704:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SlidingWindowAverage=t.MovingAverage=t.Counter=void 0,t.clamp=function(e,t,i){return Math.min(Math.max(e,t),i)},t.rot=function(e,t){return(t+e%t)%t},t.isPointWithinTriangle=function(e,t,i,s,r,n,o,a){const l=o-i,h=a-s,c=r-i,d=n-s,u=e-i,_=t-s,f=l*l+h*h,p=l*c+h*d,g=l*u+h*_,m=c*c+d*d,v=c*u+d*_,S=1/(f*m-p*p),b=(m*g-p*v)*S,C=(f*v-p*g)*S;return b>=0&&C>=0&&b+C<1},t.Counter=class{constructor(){this._next=0}getNext(){return this._next++}},t.MovingAverage=class{constructor(){this._n=1,this._val=0}update(e){return this._val=this._val+(e-this._val)/this._n,this._n+=1,this._val}get value(){return this._val}},t.SlidingWindowAverage=class{constructor(e){this._n=0,this._val=0,this._values=[],this._index=0,this._sum=0,this._values=new Array(e),this._values.fill(0,0,e)}update(e){const t=this._values[this._index];return this._values[this._index]=e,this._index=(this._index+1)%this._values.length,this._sum-=t,this._sum+=e,this._n{Object.defineProperty(t,"__esModule",{value:!0}),t.isAndroid=t.isEdge=t.isSafari=t.isFirefox=t.isChrome=t.OS=t.OperatingSystem=t.setTimeout0=t.setTimeout0IsFaster=t.translationsConfigFile=t.platformLocale=t.locale=t.Language=t.language=t.userAgent=t.platform=t.isCI=t.isMobile=t.isIOS=t.webWorkerOrigin=t.isWebWorker=t.isWeb=t.isElectron=t.isNative=t.isLinuxSnap=t.isLinux=t.isMacintosh=t.isWindows=t.Platform=t.LANGUAGE_DEFAULT=void 0,t.PlatformToString=function(e){switch(e){case C.Web:return"Web";case C.Mac:return"Mac";case C.Linux:return"Linux";case C.Windows:return"Windows"}},t.isLittleEndian=function(){if(!L){L=!0;const e=new Uint8Array(2);e[0]=1,e[1]=2;const t=new Uint16Array(e.buffer);D=513===t[0]}return D},t.isBigSurOrNewer=function(e){return parseFloat(e)>=20},t.LANGUAGE_DEFAULT="en";let i,s,r,n=!1,o=!1,a=!1,l=!1,h=!1,c=!1,d=!1,u=!1,_=!1,f=!1,p=t.LANGUAGE_DEFAULT,g=t.LANGUAGE_DEFAULT;const m=globalThis;let v;void 0!==m.vscode&&void 0!==m.vscode.process?v=m.vscode.process:"undefined"!=typeof process&&"string"==typeof process?.versions?.node&&(v=process);const S="string"==typeof v?.versions?.electron,b=S&&"renderer"===v?.type;if("object"==typeof v){n="win32"===v.platform,o="darwin"===v.platform,a="linux"===v.platform,l=a&&!!v.env.SNAP&&!!v.env.SNAP_REVISION,d=S,_=!!v.env.CI||!!v.env.BUILD_ARTIFACTSTAGINGDIRECTORY,i=t.LANGUAGE_DEFAULT,p=t.LANGUAGE_DEFAULT;const e=v.env.VSCODE_NLS_CONFIG;if(e)try{const r=JSON.parse(e);i=r.userLocale,g=r.osLocale,p=r.resolvedLanguage||t.LANGUAGE_DEFAULT,s=r.languagePack?.translationsConfigFile}catch(e){}h=!0}else"object"!=typeof navigator||b?console.error("Unable to resolve platform."):(r=navigator.userAgent,n=r.indexOf("Windows")>=0,o=r.indexOf("Macintosh")>=0,u=(r.indexOf("Macintosh")>=0||r.indexOf("iPad")>=0||r.indexOf("iPhone")>=0)&&!!navigator.maxTouchPoints&&navigator.maxTouchPoints>0,a=r.indexOf("Linux")>=0,f=r?.indexOf("Mobi")>=0,c=!0,p=globalThis._VSCODE_NLS_LANGUAGE||t.LANGUAGE_DEFAULT,i=navigator.language.toLowerCase(),g=i);var C;!function(e){e[e.Web=0]="Web",e[e.Mac=1]="Mac",e[e.Linux=2]="Linux",e[e.Windows=3]="Windows"}(C||(t.Platform=C={}));let y=C.Web;var w,E;o?y=C.Mac:n?y=C.Windows:a&&(y=C.Linux),t.isWindows=n,t.isMacintosh=o,t.isLinux=a,t.isLinuxSnap=l,t.isNative=h,t.isElectron=d,t.isWeb=c,t.isWebWorker=c&&"function"==typeof m.importScripts,t.webWorkerOrigin=t.isWebWorker?m.origin:void 0,t.isIOS=u,t.isMobile=f,t.isCI=_,t.platform=y,t.userAgent=r,t.language=p,function(e){e.value=function(){return t.language},e.isDefaultVariant=function(){return 2===t.language.length?"en"===t.language:t.language.length>=3&&"e"===t.language[0]&&"n"===t.language[1]&&"-"===t.language[2]},e.isDefault=function(){return"en"===t.language}}(w||(t.Language=w={})),t.locale=i,t.platformLocale=g,t.translationsConfigFile=s,t.setTimeout0IsFaster="function"==typeof m.postMessage&&!m.importScripts,t.setTimeout0=(()=>{if(t.setTimeout0IsFaster){const e=[];m.addEventListener("message",(t=>{if(t.data&&t.data.vscodeScheduleAsyncWork)for(let i=0,s=e.length;i{const s=++t;e.push({id:s,callback:i}),m.postMessage({vscodeScheduleAsyncWork:s},"*")}}return e=>setTimeout(e)})(),function(e){e[e.Windows=1]="Windows",e[e.Macintosh=2]="Macintosh",e[e.Linux=3]="Linux"}(E||(t.OperatingSystem=E={})),t.OS=o||u?E.Macintosh:n?E.Windows:E.Linux;let D=!0,L=!1;t.isChrome=!!(t.userAgent&&t.userAgent.indexOf("Chrome")>=0),t.isFirefox=!!(t.userAgent&&t.userAgent.indexOf("Firefox")>=0),t.isSafari=!!(!t.isChrome&&t.userAgent&&t.userAgent.indexOf("Safari")>=0),t.isEdge=!!(t.userAgent&&t.userAgent.indexOf("Edg/")>=0),t.isAndroid=!!(t.userAgent&&t.userAgent.indexOf("Android")>=0)},9881:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SmoothScrollingOperation=t.SmoothScrollingUpdate=t.Scrollable=t.ScrollState=t.ScrollbarVisibility=void 0;const s=i(802),r=i(7150);var n;!function(e){e[e.Auto=1]="Auto",e[e.Hidden=2]="Hidden",e[e.Visible=3]="Visible"}(n||(t.ScrollbarVisibility=n={}));class o{constructor(e,t,i,s,r,n,o){this._forceIntegerValues=e,this._scrollStateBrand=void 0,this._forceIntegerValues&&(t|=0,i|=0,s|=0,r|=0,n|=0,o|=0),this.rawScrollLeft=s,this.rawScrollTop=o,t<0&&(t=0),s+t>i&&(s=i-t),s<0&&(s=0),r<0&&(r=0),o+r>n&&(o=n-r),o<0&&(o=0),this.width=t,this.scrollWidth=i,this.scrollLeft=s,this.height=r,this.scrollHeight=n,this.scrollTop=o}equals(e){return this.rawScrollLeft===e.rawScrollLeft&&this.rawScrollTop===e.rawScrollTop&&this.width===e.width&&this.scrollWidth===e.scrollWidth&&this.scrollLeft===e.scrollLeft&&this.height===e.height&&this.scrollHeight===e.scrollHeight&&this.scrollTop===e.scrollTop}withScrollDimensions(e,t){return new o(this._forceIntegerValues,void 0!==e.width?e.width:this.width,void 0!==e.scrollWidth?e.scrollWidth:this.scrollWidth,t?this.rawScrollLeft:this.scrollLeft,void 0!==e.height?e.height:this.height,void 0!==e.scrollHeight?e.scrollHeight:this.scrollHeight,t?this.rawScrollTop:this.scrollTop)}withScrollPosition(e){return new o(this._forceIntegerValues,this.width,this.scrollWidth,void 0!==e.scrollLeft?e.scrollLeft:this.rawScrollLeft,this.height,this.scrollHeight,void 0!==e.scrollTop?e.scrollTop:this.rawScrollTop)}createScrollEvent(e,t){const i=this.width!==e.width,s=this.scrollWidth!==e.scrollWidth,r=this.scrollLeft!==e.scrollLeft,n=this.height!==e.height,o=this.scrollHeight!==e.scrollHeight,a=this.scrollTop!==e.scrollTop;return{inSmoothScrolling:t,oldWidth:e.width,oldScrollWidth:e.scrollWidth,oldScrollLeft:e.scrollLeft,width:this.width,scrollWidth:this.scrollWidth,scrollLeft:this.scrollLeft,oldHeight:e.height,oldScrollHeight:e.scrollHeight,oldScrollTop:e.scrollTop,height:this.height,scrollHeight:this.scrollHeight,scrollTop:this.scrollTop,widthChanged:i,scrollWidthChanged:s,scrollLeftChanged:r,heightChanged:n,scrollHeightChanged:o,scrollTopChanged:a}}}t.ScrollState=o;class a extends r.Disposable{constructor(e){super(),this._scrollableBrand=void 0,this._onScroll=this._register(new s.Emitter),this.onScroll=this._onScroll.event,this._smoothScrollDuration=e.smoothScrollDuration,this._scheduleAtNextAnimationFrame=e.scheduleAtNextAnimationFrame,this._state=new o(e.forceIntegerValues,0,0,0,0,0,0),this._smoothScrolling=null}dispose(){this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),super.dispose()}setSmoothScrollDuration(e){this._smoothScrollDuration=e}validateScrollPosition(e){return this._state.withScrollPosition(e)}getScrollDimensions(){return this._state}setScrollDimensions(e,t){const i=this._state.withScrollDimensions(e,t);this._setState(i,Boolean(this._smoothScrolling)),this._smoothScrolling?.acceptScrollDimensions(this._state)}getFutureScrollPosition(){return this._smoothScrolling?this._smoothScrolling.to:this._state}getCurrentScrollPosition(){return this._state}setScrollPositionNow(e){const t=this._state.withScrollPosition(e);this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),this._setState(t,!1)}setScrollPositionSmooth(e,t){if(0===this._smoothScrollDuration)return this.setScrollPositionNow(e);if(this._smoothScrolling){e={scrollLeft:void 0===e.scrollLeft?this._smoothScrolling.to.scrollLeft:e.scrollLeft,scrollTop:void 0===e.scrollTop?this._smoothScrolling.to.scrollTop:e.scrollTop};const i=this._state.withScrollPosition(e);if(this._smoothScrolling.to.scrollLeft===i.scrollLeft&&this._smoothScrolling.to.scrollTop===i.scrollTop)return;let s;s=t?new c(this._smoothScrolling.from,i,this._smoothScrolling.startTime,this._smoothScrolling.duration):this._smoothScrolling.combine(this._state,i,this._smoothScrollDuration),this._smoothScrolling.dispose(),this._smoothScrolling=s}else{const t=this._state.withScrollPosition(e);this._smoothScrolling=c.start(this._state,t,this._smoothScrollDuration)}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame((()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())}))}hasPendingScrollAnimation(){return Boolean(this._smoothScrolling)}_performSmoothScrolling(){if(!this._smoothScrolling)return;const e=this._smoothScrolling.tick(),t=this._state.withScrollPosition(e);return this._setState(t,!0),this._smoothScrolling?e.isDone?(this._smoothScrolling.dispose(),void(this._smoothScrolling=null)):void(this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame((()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())}))):void 0}_setState(e,t){const i=this._state;i.equals(e)||(this._state=e,this._onScroll.fire(this._state.createScrollEvent(i,t)))}}t.Scrollable=a;class l{constructor(e,t,i){this.scrollLeft=e,this.scrollTop=t,this.isDone=i}}function h(e,t){const i=t-e;return function(t){return e+i*(1-(s=1-t,Math.pow(s,3)));var s}}t.SmoothScrollingUpdate=l;class c{constructor(e,t,i,s){this.from=e,this.to=t,this.duration=s,this.startTime=i,this.animationFrameDisposable=null,this._initAnimations()}_initAnimations(){this.scrollLeft=this._initAnimation(this.from.scrollLeft,this.to.scrollLeft,this.to.width),this.scrollTop=this._initAnimation(this.from.scrollTop,this.to.scrollTop,this.to.height)}_initAnimation(e,t,i){if(Math.abs(e-t)>2.5*i){let o,a;return e{Object.defineProperty(t,"__esModule",{value:!0}),t.StopWatch=void 0;const i=globalThis.performance&&"function"==typeof globalThis.performance.now;class s{static create(e){return new s(e)}constructor(e){this._now=i&&!1===e?Date.now:globalThis.performance.now.bind(globalThis.performance),this._startTime=this._now(),this._stopTime=-1}stop(){this._stopTime=this._now()}reset(){this._startTime=this._now(),this._stopTime=-1}elapsed(){return-1!==this._stopTime?this._stopTime-this._startTime:this._now()-this._startTime}}t.StopWatch=s},1316:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.noBreakWhitespace=t.CodePointIterator=void 0,t.isFalsyOrWhitespace=function(e){return!e||"string"!=typeof e||0===e.trim().length},t.format=function(e,...t){return 0===t.length?e:e.replace(n,(function(e,i){const s=parseInt(i,10);return isNaN(s)||s<0||s>=t.length?e:t[s]}))},t.format2=function(e,t){return 0===Object.keys(t).length?e:e.replace(o,((e,i)=>t[i]??e))},t.htmlAttributeEncodeValue=function(e){return e.replace(/[<>"'&]/g,(e=>{switch(e){case"<":return"<";case">":return">";case'"':return""";case"'":return"'";case"&":return"&"}return e}))},t.escape=function(e){return e.replace(/[<>&]/g,(function(e){switch(e){case"<":return"<";case">":return">";case"&":return"&";default:return e}}))},t.escapeRegExpCharacters=a,t.count=function(e,t){let i=0,s=e.indexOf(t);for(;-1!==s;)i++,s=e.indexOf(t,s+t.length);return i},t.truncate=function(e,t,i="…"){return e.length<=t?e:`${e.substr(0,t)}${i}`},t.truncateMiddle=function(e,t,i="…"){if(e.length<=t)return e;const s=Math.ceil(t/2)-i.length/2,r=Math.floor(t/2)-i.length/2;return`${e.substr(0,s)}${i}${e.substr(e.length-r)}`},t.trim=function(e,t=" "){return h(l(e,t),t)},t.ltrim=l,t.rtrim=h,t.convertSimple2RegExpPattern=function(e){return e.replace(/[\-\\\{\}\+\?\|\^\$\.\,\[\]\(\)\#\s]/g,"\\$&").replace(/[\*]/g,".*")},t.stripWildcards=function(e){return e.replace(/\*/g,"")},t.createRegExp=function(e,t,i={}){if(!e)throw new Error("Cannot create regex from empty string");t||(e=a(e)),i.wholeWord&&(/\B/.test(e.charAt(0))||(e="\\b"+e),/\B/.test(e.charAt(e.length-1))||(e+="\\b"));let s="";return i.global&&(s+="g"),i.matchCase||(s+="i"),i.multiline&&(s+="m"),i.unicode&&(s+="u"),new RegExp(e,s)},t.regExpLeadsToEndlessLoop=function(e){return"^"!==e.source&&"^$"!==e.source&&"$"!==e.source&&"^\\s*$"!==e.source&&!(!e.exec("")||0!==e.lastIndex)},t.splitLines=function(e){return e.split(/\r\n|\r|\n/)},t.splitLinesIncludeSeparators=function(e){const t=[],i=e.split(/(\r\n|\r|\n)/);for(let e=0;e=0;i--){const t=e.charCodeAt(i);if(t!==s.CharCode.Space&&t!==s.CharCode.Tab)return i}return-1},t.replaceAsync=function(e,t,i){const s=[];let r=0;for(const n of e.matchAll(t)){if(s.push(e.slice(r,n.index)),void 0===n.index)throw new Error("match.index should be defined");r=n.index+n[0].length,s.push(i(n[0],...n.slice(1),n.index,e,n.groups))}return s.push(e.slice(r)),Promise.all(s).then((e=>e.join("")))},t.compare=function(e,t){return et?1:0},t.compareSubstring=c,t.compareIgnoreCase=function(e,t){return d(e,t,0,e.length,0,t.length)},t.compareSubstringIgnoreCase=d,t.isAsciiDigit=function(e){return e>=s.CharCode.Digit0&&e<=s.CharCode.Digit9},t.isLowerAsciiLetter=u,t.isUpperAsciiLetter=function(e){return e>=s.CharCode.A&&e<=s.CharCode.Z},t.equalsIgnoreCase=function(e,t){return e.length===t.length&&0===d(e,t)},t.startsWithIgnoreCase=function(e,t){const i=t.length;return!(t.length>e.length)&&0===d(e,t,0,i)},t.commonPrefixLength=function(e,t){const i=Math.min(e.length,t.length);let s;for(s=0;sn)return 1}const o=s-i,a=n-r;return oa?1:0}function d(e,t,i=0,s=e.length,r=0,n=t.length){for(;i=128||a>=128)return c(e.toLowerCase(),t.toLowerCase(),i,s,r,n);u(o)&&(o-=32),u(a)&&(a-=32);const l=o-a;if(0!==l)return l}const o=s-i,a=n-r;return oa?1:0}function u(e){return e>=s.CharCode.a&&e<=s.CharCode.z}function _(e){return 55296<=e&&e<=56319}function f(e){return 56320<=e&&e<=57343}function p(e,t){return t-56320+(e-55296<<10)+65536}function g(e,t,i){const s=e.charCodeAt(i);if(_(s)&&i+11){const s=e.charCodeAt(t-2);if(_(s))return p(s,i)}return i}(this._str,this._offset);return this._offset-=e>=r.Constants.UNICODE_SUPPLEMENTARY_PLANE_BEGIN?2:1,e}nextCodePoint(){const e=g(this._str,this._len,this._offset);return this._offset+=e>=r.Constants.UNICODE_SUPPLEMENTARY_PLANE_BEGIN?2:1,e}eol(){return this._offset>=this._len}},t.noBreakWhitespace=" "},5015:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.MicrotaskDelay=void 0,t.MicrotaskDelay=Symbol("MicrotaskDelay")},8960:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.Constants=void 0,t.toUint8=function(e){return e<0?0:e>i.MAX_UINT_8?i.MAX_UINT_8:0|e},t.toUint32=function(e){return e<0?0:e>i.MAX_UINT_32?i.MAX_UINT_32:0|e},function(e){e[e.MAX_SAFE_SMALL_INTEGER=1073741824]="MAX_SAFE_SMALL_INTEGER",e[e.MIN_SAFE_SMALL_INTEGER=-1073741824]="MIN_SAFE_SMALL_INTEGER",e[e.MAX_UINT_8=255]="MAX_UINT_8",e[e.MAX_UINT_16=65535]="MAX_UINT_16",e[e.MAX_UINT_32=4294967295]="MAX_UINT_32",e[e.UNICODE_SUPPLEMENTARY_PLANE_BEGIN=65536]="UNICODE_SUPPLEMENTARY_PLANE_BEGIN"}(i||(t.Constants=i={}))}},t={};function i(s){var r=t[s];if(void 0!==r)return r.exports;var n=t[s]={exports:{}};return e[s].call(n.exports,n,n.exports,i),n.exports}var s={};return(()=>{var e=s;Object.defineProperty(e,"__esModule",{value:!0}),e.Terminal=void 0;const t=i(7721),r=i(1718),n=i(7150),o=i(3027),a=i(5101),l=i(6097),h=i(4335),c=["cols","rows"];let d=0;class u extends n.Disposable{constructor(e){super(),this._core=this._register(new r.CoreBrowserTerminal(e)),this._addonManager=this._register(new o.AddonManager),this._publicOptions={...this._core.options};const t=e=>this._core.options[e],i=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(const e in this._core.options){const s={get:t.bind(this,e),set:i.bind(this,e)};Object.defineProperty(this._publicOptions,e,s)}}_checkReadonlyOptions(e){if(c.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get element(){return this._core.element}get parser(){return this._parser||(this._parser=new l.ParserApi(this._core)),this._parser}get unicode(){return this._checkProposedApi(),new h.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer||(this._buffer=this._register(new a.BufferNamespaceApi(this._core))),this._buffer}get markers(){return this._checkProposedApi(),this._core.markers}get modes(){const e=this._core.coreService.decPrivateModes;let t="none";switch(this._core.coreMouseService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,synchronizedOutputMode:e.synchronizedOutput,wraparoundMode:e.wraparound}}get options(){return this._publicOptions}set options(e){for(const t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}input(e,t=!0){this._core.input(e,t)}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}attachCustomWheelEventHandler(e){this._core.attachCustomWheelEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._checkProposedApi(),this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._checkProposedApi(),this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){return this._checkProposedApi(),this._verifyPositiveIntegers(e.x??0,e.width??0,e.height??0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write("\r\n",t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return{get promptLabel(){return t.promptLabel.get()},set promptLabel(e){t.promptLabel.set(e)},get tooMuchOutput(){return t.tooMuchOutput.get()},set tooMuchOutput(e){t.tooMuchOutput.set(e)}}}_verifyIntegers(...e){for(d of e)if(d===1/0||isNaN(d)||d%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(d of e)if(d&&(d===1/0||isNaN(d)||d%1!=0||d<0))throw new Error("This API only accepts positive integers")}}e.Terminal=u})(),s})())); -//# sourceMappingURL=xterm.js.map \ No newline at end of file diff --git a/frontend/src/js/notifications.js b/frontend/src/js/notifications.js deleted file mode 100644 index 34a417e..0000000 --- a/frontend/src/js/notifications.js +++ /dev/null @@ -1,101 +0,0 @@ -(function() { -/** - * Alchementary Link — toast notification system. - * Provides non-blocking, auto-dismissing notifications that stack - * in the top-right corner of the viewport. - */ - -const { createElement, escapeHtml } = window.AlchementaryLink; - -/** Small SVG icons for each notification type (16×16 viewBox). */ -const ICONS = { - success: '✓', - error: '✕', - warning: '!', - info: 'i', -}; - -class Notifications { - constructor() { - /** @type {HTMLElement} */ - this.container = document.getElementById('notifications'); - if (!this.container) { - this.container = createElement('div', 'notifications-container', { id: 'notifications' }); - document.body.appendChild(this.container); - } - } - - /** - * Displays a notification. - * @param {string} message - Notification text. - * @param {'info'|'success'|'warning'|'error'} [type='info'] - Severity. - * @param {number} [duration=4000] - Auto-dismiss delay in ms. - */ - show(message, type = 'info', duration = 4000) { - const el = createElement('div', `notification notification-${type}`); - - // Icon - const icon = createElement('span', 'notification-icon', { - textContent: ICONS[type] || ICONS.info, - }); - el.appendChild(icon); - - // Body - const body = createElement('div', 'notification-body'); - const msg = createElement('span', 'notification-message', { textContent: message }); - body.appendChild(msg); - el.appendChild(body); - - // Dismiss button - const close = createElement('button', 'notification-close', { - textContent: '×', - 'aria-label': 'Dismiss', - }); - close.addEventListener('click', () => this._dismiss(el)); - el.appendChild(close); - - // Insert at the top of the stack - this.container.prepend(el); - - // Auto-dismiss - if (duration > 0) { - el._timer = setTimeout(() => this._dismiss(el), duration); - } - } - - /** @param {string} message */ - success(message) { this.show(message, 'success'); } - - /** @param {string} message */ - error(message) { this.show(message, 'error', 6000); } - - /** @param {string} message */ - warning(message) { this.show(message, 'warning'); } - - /** @param {string} message */ - info(message) { this.show(message, 'info'); } - - /** - * Animates out and removes a notification element. - * @param {HTMLElement} el - * @private - */ - _dismiss(el) { - if (el._dismissed) return; - el._dismissed = true; - clearTimeout(el._timer); - - el.classList.add('is-leaving'); - el.addEventListener('animationend', () => el.remove(), { once: true }); - - // Safety net — remove after 500 ms even if animation event does not fire - setTimeout(() => { - if (el.parentNode) el.remove(); - }, 500); - } -} - -window.AlchementaryLink = window.AlchementaryLink || {}; -window.AlchementaryLink.Notifications = Notifications; - -})(); diff --git a/frontend/src/js/profiles.js b/frontend/src/js/profiles.js deleted file mode 100644 index 679531f..0000000 --- a/frontend/src/js/profiles.js +++ /dev/null @@ -1,2769 +0,0 @@ -(function () { - /** - * Alchementary Link — connection profile editor. - * Renders a slide-out drawer for creating and editing connection profiles. - * Dynamically switches form sections based on the selected protocol. - */ - - const { createElement, escapeHtml, generateId, $ } = window.AlchementaryLink; - - /** - * Default values mirroring the Go backend defaults. - */ - const DEFAULTS = { - ssh: { - port: 22, - protocol: "ssh", - ssh: { - authMethods: ["publickey", "password", "keyboard-interactive"], - secretMode: "saved", - keySecretMode: "saved", - hostKeyPolicy: "ask", - terminalType: "xterm-256color", - keepaliveInterval: 30, - keepaliveCountMax: 3, - compression: false, - jumpHosts: [], - portForwards: [], - startupCommand: "", - envVars: {}, - loginScripts: [], - }, - }, - telnet: { - port: 23, - protocol: "telnet", - telnet: { - localEcho: false, - lineEnding: "crlf", - terminalType: "xterm-256color", - encoding: "utf-8", - negotiationDebug: false, - lineMode: false, - loginScripts: [], - }, - }, - raw: { - port: 23, - protocol: "raw", - raw: { - autoReconnect: false, - lineEnding: "crlf", - encoding: "utf-8", - showTimestamps: false, - showDirection: false, - payloadPresets: [], - }, - }, - github: { - port: 443, - protocol: "github", - host: "github.com", - github: { - baseURL: "https://api.github.com", - secretMode: "saved", - refreshIntervalSecs: 300, - }, - }, - vnc: { - port: 5900, - protocol: "vnc", - vnc: { - vncHost: "", - vncPort: 5900, - secretMode: "prompt", - viewOnly: false, - scaleToFit: true, - quality: 6, - sharedSession: true, - sshTunnel: false, - sshHost: "", - sshPort: 22, - sshUsername: "", - sshAuthMethod: "publickey", - sshSecretMode: "saved", - sshPrivateKey: "", - sshAgentAuth: false, - }, - }, - irc: { - port: 6667, - protocol: "irc", - irc: { - nick: "link", - realName: "Alchementary Link", - useTLS: false, - serverPasswordRequired: false, - serverPasswordMode: "ask-every-time", - nickServIdentify: false, - nickServAccount: "", - nickServMode: "saved", - autoJoinRooms: [], - sshTunnel: false, - sshHost: "", - sshPort: 22, - sshUsername: "", - sshAuthMethod: "publickey", - sshSecretMode: "saved", - sshPrivateKey: "", - sshAgentAuth: false, - autoReconnect: true, - maxReconnectTries: 5, - }, - }, - }; - - class ProfileEditor { - /** - * @param {App} app - */ - constructor(app) { - this.app = app; - - /** @type {HTMLElement|null} */ - this.drawer = null; - - /** @type {Object|null} The profile currently being edited. */ - this._profile = null; - - /** @type {boolean} */ - this._isNew = false; - - /** @type {string[]} */ - this._sshKeyOptions = []; - - /** @type {boolean} */ - this._sshKeyOptionsLoaded = false; - - /** @type {boolean} */ - this._sshKeyOptionsLoading = false; - } - - // ── Public API ─────────────────────────────────────────────────── - - /** - * Opens the editor with a blank profile of the given protocol. - * @param {string} [protocol='ssh'] - */ - openNew(protocol = "ssh") { - const profile = this._defaultProfile(protocol); - this._isNew = true; - this._profile = profile; - this.render(profile, true); - } - - /** - * Opens the editor pre-populated with an existing profile. - * @param {string} profileId - */ - openEdit(profileId) { - const profile = this.app.state.getProfile(profileId); - if (!profile) { - this.app.notifications.error("Profile not found"); - return; - } - this._isNew = false; - this._profile = JSON.parse(JSON.stringify(profile)); // deep clone - this.render(this._profile, false); - } - - /** - * Renders (or re-renders) the editor drawer. - * @param {Object} profile - * @param {boolean} isNew - */ - render(profile, isNew) { - this.close(); - - const overlay = createElement("div", "drawer-backdrop"); - const drawer = createElement("div", "drawer"); - - // ── Header ────────────────────────────────────────────── - const header = createElement("div", "drawer-header"); - header.innerHTML = ` -

${isNew ? "New Connection" : "Edit Connection"}

- - `; - drawer.appendChild(header); - - // ── Protocol selector tabs ────────────────────────────── - const protocols = ["ssh", "telnet", "raw", "github", "vnc", "irc"]; - const tabBar = createElement("div", "drawer-protocol-tabs"); - for (const proto of protocols) { - const label = - proto === "github" ? "GitHub" : proto === "irc" ? "IRC" : proto.toUpperCase(); - const btn = createElement( - "button", - `drawer-proto-tab ${profile.protocol === proto ? "active" : ""}`, - { - textContent: label, - dataset: { proto }, - }, - ); - btn.addEventListener("click", () => this.handleProtocolChange(proto)); - tabBar.appendChild(btn); - } - drawer.appendChild(tabBar); - - // ── Scrollable body ───────────────────────────────────── - const body = createElement("div", "drawer-body"); - const sshKeyDatalist = this._buildSSHKeyDatalist(); - - // General section (common to all protocols) - body.appendChild(this._renderGeneralSection(profile)); - - // Protocol-specific sections - switch (profile.protocol) { - case "ssh": - this._renderSSHSections(profile).forEach((s) => body.appendChild(s)); - body.appendChild(sshKeyDatalist); - break; - case "telnet": - this._renderTelnetSections(profile).forEach((s) => - body.appendChild(s), - ); - break; - case "raw": - this._renderRAWSections(profile).forEach((s) => body.appendChild(s)); - break; - case "github": - this._renderGitHubSections(profile).forEach((s) => - body.appendChild(s), - ); - break; - case "vnc": - this._renderVNCSections(profile).forEach((s) => body.appendChild(s)); - break; - case "irc": - this._renderIRCSections(profile).forEach((s) => body.appendChild(s)); - body.appendChild(sshKeyDatalist); - break; - } - - // Terminal section (SSH and Telnet only) - if ( - profile.protocol !== "raw" && - profile.protocol !== "github" && - profile.protocol !== "vnc" - ) { - body.appendChild(this._renderTerminalSection(profile)); - } - - // Notes & Tags section (common) - body.appendChild(this._renderNotesSection(profile)); - - drawer.appendChild(body); - - // ── Footer ────────────────────────────────────────────── - const footer = createElement("div", "drawer-footer"); - const cancelBtn = createElement("button", "btn btn-secondary", { - textContent: "Cancel", - }); - const testBtn = createElement("button", "btn btn-outline", { - textContent: "Test", - }); - const saveBtn = createElement("button", "btn btn-primary", { - textContent: isNew ? "Create" : "Save", - }); - const connectBtn = createElement("button", "btn btn-accent", { - textContent: isNew ? "Create & Connect" : "Save & Connect", - }); - const deleteBtn = !isNew - ? createElement("button", "btn btn-danger", { textContent: "Delete" }) - : null; - - cancelBtn.addEventListener("click", () => this.close()); - testBtn.addEventListener("click", () => this._testConnection()); - saveBtn.addEventListener("click", () => this._handleSave(false)); - connectBtn.addEventListener("click", () => this._handleSave(true)); - if (deleteBtn) { - deleteBtn.addEventListener("click", () => this._handleDelete()); - } - - if (deleteBtn) footer.appendChild(deleteBtn); - footer.appendChild(cancelBtn); - footer.appendChild(testBtn); - footer.appendChild(saveBtn); - footer.appendChild(connectBtn); - drawer.appendChild(footer); - - // ── Events ────────────────────────────────────────────── - header - .querySelector(".drawer-close") - .addEventListener("click", () => this.close()); - overlay.addEventListener("click", () => this.close()); - - overlay.appendChild(drawer); - document.body.appendChild(overlay); - this.drawer = overlay; - - // Trigger visibility after append so CSS transitions animate - requestAnimationFrame(() => { - overlay.classList.add("is-visible"); - drawer.classList.add("is-visible"); - }); - - // Prevent overlay click from reaching through the drawer - drawer.addEventListener("click", (e) => e.stopPropagation()); - - // Focus the first input - const firstInput = drawer.querySelector("input, select, textarea"); - if (firstInput) firstInput.focus(); - - this._loadSSHKeyOptions(); - } - - /** - * Closes the editor drawer. - */ - close() { - if (this.drawer) { - this.drawer.remove(); - this.drawer = null; - } - } - - /** - * Handles protocol change from the tab bar. - * @param {string} protocol - */ - handleProtocolChange(protocol) { - if (this._profile.protocol === protocol) return; - - // Preserve common fields, reset protocol-specific ones - this._collectFormValues(); - const base = this._defaultProfile(protocol); - base.id = this._profile.id; - base.displayName = this._profile.displayName; - base.description = this._profile.description; - base.host = this._profile.host; - base.username = this._profile.username; - base.tags = this._profile.tags; - base.notes = this._profile.notes; - base.favourite = this._profile.favourite; - - this._profile = base; - this.render(this._profile, this._isNew); - } - - // ── Section renderers ──────────────────────────────────────────── - - /** @private */ - _renderGeneralSection(profile) { - const fields = [ - this._field( - "text", - "displayName", - "Display Name", - profile.displayName, - { placeholder: "My Server" }, - ), - this._field("text", "host", "Host", profile.host, { - placeholder: - profile.protocol === "github" - ? "github.com" - : "192.168.1.1 or example.com", - required: true, - }), - ]; - - // Port and username not relevant for GitHub connections - if (profile.protocol !== "github") { - fields.push( - this._field("number", "port", "Port", profile.port, { - min: 1, - max: 65535, - required: true, - }), - ); - fields.push( - this._field("text", "username", "Username", profile.username, { - placeholder: "root", - }), - ); - } - - fields.push(this._renderGroupField(profile)); - fields.push( - this._field("text", "description", "Description", profile.description, { - placeholder: "Optional description", - }), - ); - - return this._renderSection("General", fields); - } - - /** - * Renders a combo-box for the Group field — a text input backed by a - * datalist of existing group names. Typing a new name creates a group. - * @param {Object} profile - * @returns {HTMLElement} - * @private - */ - _renderGroupField(profile) { - const group = createElement("div", "form-group"); - const label = createElement("label", "form-label", { - textContent: "Group", - for: "field-folder", - }); - group.appendChild(label); - - // Collect distinct group names from all profiles - const existingGroups = [ - ...new Set( - this.app.state.profiles - .map((p) => (p.folder || "").trim()) - .filter(Boolean), - ), - ].sort((a, b) => a.localeCompare(b)); - - // Create the datalist so the browser offers autocomplete - const listId = "folder-suggestions"; - const datalist = document.createElement("datalist"); - datalist.id = listId; - for (const g of existingGroups) { - const opt = document.createElement("option"); - opt.value = g; - datalist.appendChild(opt); - } - group.appendChild(datalist); - - const input = createElement("input", "form-input", { - type: "text", - id: "field-folder", - name: "folder", - value: profile.folder || "", - placeholder: "e.g. Production, Staging, Home Lab", - }); - input.setAttribute("list", listId); - input.setAttribute("autocomplete", "off"); - group.appendChild(input); - - const hint = createElement("span", "form-hint", { - textContent: - "Choose an existing group or type a new name to create one", - }); - group.appendChild(hint); - - return group; - } - - _buildSSHKeyDatalist() { - const list = createElement("datalist", "", { - id: "ssh-key-path-options", - }); - this._applySSHKeyOptionsToList(list); - return list; - } - - _applySSHKeyOptionsToList(listEl) { - if (!listEl) return; - listEl.innerHTML = ""; - for (const keyPath of this._sshKeyOptions) { - const opt = createElement("option", "", { value: keyPath }); - listEl.appendChild(opt); - } - } - - _loadSSHKeyOptions() { - if (this._sshKeyOptionsLoading || this._sshKeyOptionsLoaded) return; - if (!window.AlchementaryLink?.wailsService) return; - - this._sshKeyOptionsLoading = true; - window.AlchementaryLink.wailsService.ListSSHKeys() - .then((keys) => { - this._sshKeyOptions = Array.isArray(keys) ? keys : []; - this._sshKeyOptionsLoaded = true; - this._sshKeyOptionsLoading = false; - if (!this.drawer) return; - const listEl = this.drawer.querySelector("#ssh-key-path-options"); - this._applySSHKeyOptionsToList(listEl); - }) - .catch(() => { - this._sshKeyOptionsLoading = false; - }); - } - - /** @private */ - _renderSSHSections(profile) { - const ssh = profile.ssh || {}; - const sections = []; - - // Authentication - sections.push( - this._renderSection("Authentication", [ - this._field( - "select", - "ssh.secretMode", - "Password Mode", - ssh.secretMode, - { - options: [ - { value: "saved", label: "Saved in vault" }, - { value: "session-only", label: "Session only" }, - { value: "ask-every-time", label: "Ask every time" }, - ], - }, - ), - this._field( - "tags", - "ssh.authMethods", - "Auth Methods", - (ssh.authMethods || []).join(", "), - { - help: "Comma-separated: publickey, password, keyboard-interactive", - }, - ), - ]), - ); - - // Key & Passphrase - sections.push( - this._renderSection("Key & Passphrase", [ - this._renderSSHKeyPathField(ssh.privateKeyPath), - this._field( - "select", - "ssh.keySecretMode", - "Passphrase Mode", - ssh.keySecretMode, - { - options: [ - { value: "saved", label: "Saved in vault" }, - { value: "session-only", label: "Session only" }, - { value: "ask-every-time", label: "Ask every time" }, - ], - }, - ), - ]), - ); - - // Host Verification - sections.push( - this._renderSection("Host Verification", [ - this._field( - "select", - "ssh.hostKeyPolicy", - "Host Key Policy", - ssh.hostKeyPolicy, - { - options: [ - { value: "strict", label: "Strict (reject unknown)" }, - { value: "ask", label: "Ask (prompt on unknown)" }, - { value: "accept-new", label: "Accept new keys" }, - { value: "insecure", label: "Insecure (accept all)" }, - ], - }, - ), - ]), - ); - - // Jump Hosts - sections.push( - this._renderSection( - "Jump Hosts", - [this._renderJumpHostsList(ssh.jumpHosts || [])], - true, - ), - ); - - // Port Forwarding - sections.push( - this._renderSection( - "Port Forwarding", - [this._renderPortForwardsList(ssh.portForwards || [])], - true, - ), - ); - - // Startup - sections.push( - this._renderSection("Startup", [ - this._field( - "text", - "ssh.startupCommand", - "Startup Command", - ssh.startupCommand, - { - placeholder: "Optional command to run after connecting", - }, - ), - this._field( - "textarea", - "ssh.envVarsText", - "Environment Variables", - this._envVarsToText(ssh.envVars), - { - placeholder: "KEY=VALUE (one per line)", - rows: 3, - }, - ), - ]), - ); - - // Login Scripts (expect-style) - sections.push( - this._renderLoginScriptSection("ssh", ssh.loginScripts || []), - ); - - // SSH Agent - sections.push( - this._renderSection( - "SSH Agent", - [ - this._field( - "toggle", - "ssh.agentAuth", - "Use SSH Agent for Authentication", - ssh.agentAuth, - { - help: "Authenticate using keys from the local SSH agent (ssh-agent)", - }, - ), - this._field( - "toggle", - "ssh.agentForwarding", - "Forward Agent to Remote", - ssh.agentForwarding, - { - help: "Allow the remote host to use your local SSH agent keys (equivalent to ssh -A)", - }, - ), - ], - true, - ), - ); - - // TOTP / 2FA - sections.push(this._renderTOTPSection(profile)); - - // Proxy - sections.push( - this._renderSection( - "Proxy", - [ - this._field( - "select", - "ssh.proxyType", - "Proxy Type", - ssh.proxyType || "none", - { - options: [ - { value: "none", label: "None (direct connection)" }, - { value: "socks5", label: "SOCKS5" }, - { value: "http", label: "HTTP CONNECT" }, - ], - }, - ), - this._field( - "text", - "ssh.proxyHost", - "Proxy Host", - ssh.proxyHost || "", - { - placeholder: "proxy.example.com", - }, - ), - this._field( - "number", - "ssh.proxyPort", - "Proxy Port", - ssh.proxyPort || 1080, - { min: 1, max: 65535 }, - ), - this._field( - "text", - "ssh.proxyUsername", - "Proxy Username", - ssh.proxyUsername || "", - { - placeholder: "Optional", - }, - ), - this._field( - "text", - "ssh.proxyPassword", - "Proxy Password", - ssh.proxyPassword || "", - { - placeholder: "Optional", - }, - ), - ], - true, - ), - ); - - // Advanced - sections.push( - this._renderSection( - "Advanced", - [ - this._field( - "text", - "ssh.terminalType", - "Terminal Type", - ssh.terminalType, - { placeholder: "xterm-256color" }, - ), - this._field( - "number", - "ssh.keepaliveInterval", - "Keepalive Interval (s)", - ssh.keepaliveInterval, - { min: 0 }, - ), - this._field( - "number", - "ssh.keepaliveCountMax", - "Max Missed Keepalives", - ssh.keepaliveCountMax, - { min: 0 }, - ), - this._field( - "toggle", - "ssh.compression", - "Compression", - ssh.compression, - ), - this._field( - "toggle", - "ssh.autoReconnect", - "Auto-Reconnect", - ssh.autoReconnect, - { - help: "Automatically reconnect with exponential backoff when the connection drops", - }, - ), - this._field( - "number", - "ssh.maxReconnectTries", - "Max Reconnect Attempts", - ssh.maxReconnectTries || 5, - { min: 1, max: 20 }, - ), - ], - true, - ), - ); - - return sections; - } - - /** @private */ - _renderTelnetSections(profile) { - const tel = profile.telnet || {}; - const sections = []; - - sections.push( - this._renderSection("Login", [ - this._field( - "toggle", - "telnet.localEcho", - "Local Echo", - tel.localEcho, - ), - this._field("toggle", "telnet.lineMode", "Line Mode", tel.lineMode), - ]), - ); - - sections.push( - this._renderSection("Terminal & Encoding", [ - this._field( - "text", - "telnet.terminalType", - "Terminal Type", - tel.terminalType, - { placeholder: "xterm-256color" }, - ), - this._field("select", "telnet.encoding", "Encoding", tel.encoding, { - options: [ - { value: "utf-8", label: "UTF-8" }, - { value: "ascii", label: "ASCII" }, - { value: "iso-8859-1", label: "ISO 8859-1" }, - ], - }), - ]), - ); - - sections.push( - this._renderSection("Echo & Line Endings", [ - this._field( - "select", - "telnet.lineEnding", - "Line Ending", - tel.lineEnding, - { - options: [ - { value: "crlf", label: "CR+LF (\\r\\n)" }, - { value: "lf", label: "LF (\\n)" }, - { value: "cr", label: "CR (\\r)" }, - { value: "none", label: "None" }, - ], - }, - ), - ]), - ); - - sections.push( - this._renderSection( - "Negotiation", - [ - this._field( - "toggle", - "telnet.negotiationDebug", - "Debug Negotiation", - tel.negotiationDebug, - ), - ], - true, - ), - ); - - // Login Scripts (expect-style) - sections.push( - this._renderLoginScriptSection("telnet", tel.loginScripts || []), - ); - - sections.push( - this._renderSection( - "Reconnection", - [ - this._field( - "toggle", - "telnet.autoReconnect", - "Auto-Reconnect", - tel.autoReconnect, - { - help: "Automatically reconnect with exponential backoff when the connection drops", - }, - ), - this._field( - "number", - "telnet.maxReconnectTries", - "Max Reconnect Attempts", - tel.maxReconnectTries || 5, - { min: 1, max: 20 }, - ), - ], - true, - ), - ); - - return sections; - } - - /** @private */ - _renderRAWSections(profile) { - const raw = profile.raw || {}; - const sections = []; - - sections.push( - this._renderSection("Encoding & Framing", [ - this._field("select", "raw.encoding", "Encoding", raw.encoding, { - options: [ - { value: "utf-8", label: "UTF-8" }, - { value: "ascii", label: "ASCII" }, - { value: "hex", label: "Hex" }, - ], - }), - this._field( - "select", - "raw.lineEnding", - "Line Ending", - raw.lineEnding, - { - options: [ - { value: "crlf", label: "CR+LF (\\r\\n)" }, - { value: "lf", label: "LF (\\n)" }, - { value: "cr", label: "CR (\\r)" }, - { value: "none", label: "None" }, - ], - }, - ), - ]), - ); - - sections.push( - this._renderSection("Display", [ - this._field( - "toggle", - "raw.showTimestamps", - "Show Timestamps", - raw.showTimestamps, - ), - this._field( - "toggle", - "raw.showDirection", - "Show Direction", - raw.showDirection, - ), - ]), - ); - - sections.push( - this._renderSection( - "Payload Presets", - [this._renderPayloadPresetsList(raw.payloadPresets || [])], - true, - ), - ); - - sections.push( - this._renderSection("Timeouts & Reconnect", [ - this._field( - "number", - "connectTimeout", - "Connect Timeout (s)", - profile.connectTimeout, - { min: 0 }, - ), - this._field( - "number", - "readTimeout", - "Read Timeout (s)", - profile.readTimeout, - { min: 0 }, - ), - this._field( - "toggle", - "raw.autoReconnect", - "Auto-reconnect", - raw.autoReconnect, - ), - ]), - ); - - return sections; - } - - /** @private */ - _renderGitHubSections(profile) { - const sections = []; - const gh = profile.github || DEFAULTS.github.github; - - sections.push( - this._renderSection("API", [ - this._field("text", "github.baseURL", "API Base URL", gh.baseURL, { - placeholder: "https://api.github.com", - }), - this._field( - "select", - "github.secretMode", - "Token Storage", - gh.secretMode, - { - options: [ - { value: "saved", label: "Save to keychain" }, - { value: "session-only", label: "Remember for session" }, - { value: "ask-every-time", label: "Ask every time" }, - ], - }, - ), - ]), - ); - - sections.push( - this._renderSection("Refresh", [ - this._field( - "number", - "github.refreshIntervalSecs", - "Refresh Interval (seconds)", - gh.refreshIntervalSecs, - { - min: 60, - max: 3600, - }, - ), - ]), - ); - - return sections; - } - - /** @private */ - _renderVNCSections(profile) { - const sections = []; - const vnc = profile.vnc || DEFAULTS.vnc.vnc; - - // VNC connection settings - sections.push( - this._renderSection("VNC Server", [ - this._field("text", "vnc.vncHost", "VNC Host", vnc.vncHost, { - placeholder: "192.168.1.100", - }), - this._field("number", "vnc.vncPort", "VNC Port", vnc.vncPort, { - min: 1, - max: 65535, - }), - this._field( - "select", - "vnc.secretMode", - "Password Storage", - vnc.secretMode, - { - options: [ - { value: "saved", label: "Save to keychain" }, - { value: "session-only", label: "Remember for session" }, - { value: "prompt", label: "Ask every time" }, - ], - }, - ), - ]), - ); - - // Display settings - sections.push( - this._renderSection("Display", [ - this._field("checkbox", "vnc.viewOnly", "View Only", vnc.viewOnly), - this._field( - "checkbox", - "vnc.scaleToFit", - "Scale to Fit", - vnc.scaleToFit, - ), - this._field( - "checkbox", - "vnc.sharedSession", - "Shared Session", - vnc.sharedSession, - ), - this._field("number", "vnc.quality", "Quality (1-9)", vnc.quality, { - min: 1, - max: 9, - }), - ]), - ); - - // SSH tunnel section - sections.push( - this._renderSection("SSH Tunnel", [ - this._field( - "checkbox", - "vnc.sshTunnel", - "Enable SSH Tunnel", - vnc.sshTunnel, - ), - this._field("text", "vnc.sshHost", "SSH Host", vnc.sshHost, { - placeholder: "jump.example.com", - }), - this._field("number", "vnc.sshPort", "SSH Port", vnc.sshPort, { - min: 1, - max: 65535, - }), - this._field( - "text", - "vnc.sshUsername", - "SSH Username", - vnc.sshUsername, - { - placeholder: "admin", - }, - ), - this._field( - "select", - "vnc.sshAuthMethod", - "SSH Auth Method", - vnc.sshAuthMethod, - { - options: [ - { value: "publickey", label: "Public Key" }, - { value: "password", label: "Password" }, - ], - }, - ), - this._field( - "text", - "vnc.sshPrivateKey", - "SSH Private Key Path", - vnc.sshPrivateKey, - { - placeholder: "~/.ssh/id_ed25519", - }, - ), - this._field( - "checkbox", - "vnc.sshAgentAuth", - "Use SSH Agent", - vnc.sshAgentAuth, - ), - this._field( - "select", - "vnc.sshSecretMode", - "SSH Secret Storage", - vnc.sshSecretMode, - { - options: [ - { value: "saved", label: "Save to keychain" }, - { value: "session-only", label: "Remember for session" }, - { value: "prompt", label: "Ask every time" }, - ], - }, - ), - ]), - ); - - return sections; - } - - /** @private */ - _renderIRCSections(profile) { - const sections = []; - const irc = profile.irc || DEFAULTS.irc.irc; - - sections.push( - this._renderSection("IRC", [ - this._field("text", "irc.nick", "Nick", irc.nick, { - placeholder: "yourNick", - }), - this._field("text", "irc.realName", "Real Name", irc.realName, { - placeholder: "Shown in WHOIS", - }), - this._field("toggle", "irc.useTLS", "Use TLS", irc.useTLS, { - help: "Use port 6697 for most TLS IRC networks.", - }), - this._field( - "toggle", - "irc.serverPasswordRequired", - "Server Password", - irc.serverPasswordRequired, - { - help: "Prompt for an IRC PASS secret before registration.", - }, - ), - this._field( - "select", - "irc.serverPasswordMode", - "Server Password Storage", - irc.serverPasswordMode, - { - options: [ - { value: "saved", label: "Save to keychain" }, - { value: "session-only", label: "Remember for session" }, - { value: "ask-every-time", label: "Ask every time" }, - ], - }, - ), - ]), - ); - - sections.push( - this._renderSection("NickServ", [ - this._field( - "toggle", - "irc.nickServIdentify", - "Identify on Connect", - irc.nickServIdentify, - ), - this._field( - "text", - "irc.nickServAccount", - "Account", - irc.nickServAccount, - { - placeholder: "Optional if it matches your nick", - }, - ), - this._field( - "select", - "irc.nickServMode", - "Password Storage", - irc.nickServMode, - { - options: [ - { value: "saved", label: "Save to keychain" }, - { value: "session-only", label: "Remember for session" }, - { value: "ask-every-time", label: "Ask every time" }, - ], - }, - ), - ]), - ); - - sections.push( - this._renderSection("Rooms", [ - this._field( - "textarea", - "irc.autoJoinRoomsText", - "Auto-Join Rooms", - this._roomsToText(irc.autoJoinRooms), - { - placeholder: "#general\n#ops", - rows: 4, - help: "One room per line or comma-separated.", - }, - ), - ]), - ); - - sections.push( - this._renderSection("SSH Tunnel", [ - this._field( - "toggle", - "irc.sshTunnel", - "Enable SSH Tunnel", - irc.sshTunnel, - ), - this._field("text", "irc.sshHost", "SSH Host", irc.sshHost, { - placeholder: "jump.example.com", - }), - this._field("number", "irc.sshPort", "SSH Port", irc.sshPort, { - min: 1, - max: 65535, - }), - this._field( - "text", - "irc.sshUsername", - "SSH Username", - irc.sshUsername, - { - placeholder: "admin", - }, - ), - this._field( - "select", - "irc.sshAuthMethod", - "SSH Auth Method", - irc.sshAuthMethod, - { - options: [ - { value: "publickey", label: "Public Key" }, - { value: "password", label: "Password" }, - ], - }, - ), - this._field( - "text", - "irc.sshPrivateKey", - "SSH Private Key Path", - irc.sshPrivateKey, - { - placeholder: "~/.ssh/id_ed25519", - datalistId: "ssh-key-path-options", - }, - ), - this._field( - "toggle", - "irc.sshAgentAuth", - "Use SSH Agent", - irc.sshAgentAuth, - ), - this._field( - "select", - "irc.sshSecretMode", - "SSH Secret Storage", - irc.sshSecretMode, - { - options: [ - { value: "saved", label: "Save to keychain" }, - { value: "session-only", label: "Remember for session" }, - { value: "ask-every-time", label: "Ask every time" }, - ], - }, - ), - ]), - ); - - sections.push( - this._renderSection("Reconnection", [ - this._field( - "toggle", - "irc.autoReconnect", - "Auto-Reconnect", - irc.autoReconnect, - ), - this._field( - "number", - "irc.maxReconnectTries", - "Max Reconnect Attempts", - irc.maxReconnectTries || 5, - { min: 1, max: 20 }, - ), - ]), - ); - - return sections; - } - - /** @private */ - _renderTerminalSection(profile) { - return this._renderSection("Terminal", [ - this._field( - "text", - "terminalFontFamily", - "Font Family", - profile.terminalFontFamily, - { - placeholder: "JetBrains Mono, Menlo, Monaco, Consolas, monospace", - }, - ), - this._field( - "number", - "terminalFontSize", - "Font Size", - profile.terminalFontSize, - { min: 8, max: 36 }, - ), - this._field( - "number", - "terminalScrollback", - "Scrollback Lines", - profile.terminalScrollback, - { min: 0, max: 100000 }, - ), - this._field( - "select", - "terminalCursorStyle", - "Cursor Style", - profile.terminalCursorStyle, - { - options: [ - { value: "block", label: "Block" }, - { value: "underline", label: "Underline" }, - { value: "bar", label: "Bar" }, - ], - }, - ), - ]); - } - - /** @private */ - _renderNotesSection(profile) { - return this._renderSection("Notes & Tags", [ - this._field("tags", "tags", "Tags", (profile.tags || []).join(", "), { - placeholder: "production, london, vpn", - help: "Comma-separated tags for filtering", - }), - this._field("textarea", "notes", "Notes", profile.notes, { - placeholder: "Free-form notes…", - rows: 4, - }), - ]); - } - - // ── Form helpers ───────────────────────────────────────────────── - - /** - * Renders a collapsible form section. - * @param {string} title - * @param {HTMLElement[]} fields - * @returns {HTMLElement} - * @private - */ - /** - * Renders the TOTP/2FA section with secret management and test button. - * @param {Object} profile - * @returns {HTMLElement} - * @private - */ - _renderTOTPSection(profile) { - const section = createElement("div", "form-section"); - const header = createElement("div", "form-section-header"); - let collapsed = false; - - header.innerHTML = ` - ▾ - TOTP / 2FA - `; - - const content = createElement("div", "form-section-content"); - - const helpText = createElement("div", "form-help", { - textContent: - "Configure a TOTP secret to auto-fill 2FA codes during SSH keyboard-interactive authentication. The secret is stored encrypted.", - }); - content.appendChild(helpText); - - // TOTP secret input - const group = createElement("div", "form-group"); - const label = createElement("label", "form-label", { - textContent: "TOTP Secret", - }); - group.appendChild(label); - - const row = createElement("div", "totp-input-row"); - - const secretInput = createElement("input", "form-input", { - type: "password", - placeholder: - "Base32-encoded secret (e.g. from authenticator app setup)", - id: "field-ssh.totpSecret", - dataset: { field: "ssh.totpSecret" }, - autocapitalize: "off", - autocorrect: "off", - spellcheck: false, - }); - - // Show/hide toggle - const toggleBtn = createElement( - "button", - "btn btn-small btn-secondary totp-toggle", - { - textContent: "👁", - title: "Show/hide secret", - type: "button", - }, - ); - toggleBtn.addEventListener("mousedown", (e) => e.preventDefault()); - toggleBtn.addEventListener("click", () => { - secretInput.type = - secretInput.type === "password" ? "text" : "password"; - }); - - // Test button — generates a live code - const testBtn = createElement( - "button", - "btn btn-small btn-accent totp-test", - { - textContent: "Test", - title: "Generate a code with this secret", - type: "button", - }, - ); - testBtn.addEventListener("mousedown", (e) => e.preventDefault()); - testBtn.addEventListener("click", async () => { - const profileId = profile.id; - if (profileId) { - try { - const code = await window.AlchementaryLink.wailsService.GetTOTPCode(profileId); - testBtn.textContent = code; - setTimeout(() => { - testBtn.textContent = "Test"; - }, 5000); - } catch (err) { - // If no saved secret yet, try computing locally using the input value - testBtn.textContent = "Save first"; - setTimeout(() => { - testBtn.textContent = "Test"; - }, 2000); - } - } - }); - - // Save button - const saveBtn = createElement( - "button", - "btn btn-small btn-primary totp-save", - { - textContent: "Save", - title: "Encrypt and save the TOTP secret", - type: "button", - }, - ); - saveBtn.addEventListener("mousedown", (e) => e.preventDefault()); - saveBtn.addEventListener("click", async () => { - const profileId = profile.id; - const secret = secretInput.value.trim(); - if (!profileId) { - AlchementaryLink.Notifications.show( - "Save the profile first, then set the TOTP secret.", - "warning", - ); - return; - } - if (!secret) { - // Delete existing secret - try { - await window.AlchementaryLink.wailsService.DeleteTOTPSecret(profileId); - AlchementaryLink.Notifications.show("TOTP secret removed.", "info"); - } catch (err) { - AlchementaryLink.Notifications.show( - "Failed to remove TOTP secret.", - "error", - ); - } - return; - } - try { - await window.AlchementaryLink.wailsService.SetTOTPSecret(profileId, secret); - AlchementaryLink.Notifications.show( - "TOTP secret saved securely.", - "success", - ); - secretInput.value = ""; - secretInput.placeholder = "••••••••• (secret saved)"; - } catch (err) { - AlchementaryLink.Notifications.show( - "Failed to save TOTP secret: " + err, - "error", - ); - } - }); - - // Delete button - const deleteBtn = createElement( - "button", - "btn btn-small btn-danger totp-delete", - { - textContent: "Delete", - title: "Remove the TOTP secret", - type: "button", - }, - ); - deleteBtn.addEventListener("mousedown", (e) => e.preventDefault()); - deleteBtn.addEventListener("click", async () => { - const profileId = profile.id; - if (!profileId) return; - try { - await window.AlchementaryLink.wailsService.DeleteTOTPSecret(profileId); - AlchementaryLink.Notifications.show("TOTP secret removed.", "info"); - secretInput.placeholder = - "Base32-encoded secret (e.g. from authenticator app setup)"; - } catch (err) { - AlchementaryLink.Notifications.show( - "Failed to remove TOTP secret.", - "error", - ); - } - }); - - row.appendChild(secretInput); - row.appendChild(toggleBtn); - row.appendChild(testBtn); - row.appendChild(saveBtn); - row.appendChild(deleteBtn); - group.appendChild(row); - content.appendChild(group); - - // Check if secret is already saved and show placeholder - if (profile.id) { - (async () => { - try { - const has = await window.AlchementaryLink.wailsService.HasTOTPSecret(profile.id); - if (has) { - secretInput.placeholder = "••••••••• (secret saved)"; - } - } catch (_) { - // Ignore — might not be unlocked. - } - })(); - } - - const toggleSection = () => { - collapsed = !collapsed; - content.style.display = collapsed ? "none" : "block"; - header.querySelector(".form-section-chevron").textContent = collapsed - ? "▸" - : "▾"; - }; - header.addEventListener("mousedown", (e) => { - if (e.button !== 0) return; - e.preventDefault(); - toggleSection(); - }); - - section.appendChild(header); - section.appendChild(content); - return section; - } - - /** - * Renders the login script section (expect-style send/wait steps). - * @param {string} prefix - 'ssh' or 'telnet' - * @param {Array} steps - Array of {type, value, timeout} - * @returns {HTMLElement} - * @private - */ - _renderLoginScriptSection(prefix, steps) { - const section = createElement("div", "form-section"); - const header = createElement("div", "form-section-header"); - let collapsed = false; - - header.innerHTML = ` - ▾ - Login Script - `; - - const content = createElement("div", "form-section-content"); - - const helpText = createElement("div", "form-help", { - textContent: - "Automate login sequences: wait for text from the server, then send a response. Steps execute in order after connection.", - }); - content.appendChild(helpText); - - // Hidden input to hold JSON - const hiddenInput = createElement("input", "", { - type: "hidden", - id: `field-${prefix}.loginScripts`, - dataset: { field: `${prefix}.loginScripts` }, - }); - content.appendChild(hiddenInput); - - // List container - const listContainer = createElement("div", "login-script-list"); - content.appendChild(listContainer); - - // Working copy - let currentSteps = JSON.parse(JSON.stringify(steps || [])); - - const renderSteps = () => { - listContainer.innerHTML = ""; - hiddenInput.value = JSON.stringify(currentSteps); - - if (currentSteps.length === 0) { - const empty = createElement("div", "login-script-empty", { - textContent: "No login script steps configured.", - }); - listContainer.appendChild(empty); - } - - currentSteps.forEach((step, idx) => { - const row = createElement("div", "login-script-row"); - - const typeSelect = createElement( - "select", - "form-input login-script-type", - ); - ["wait", "send"].forEach((t) => { - const opt = createElement("option", "", { - value: t, - textContent: t === "wait" ? "Wait for" : "Send", - }); - if (t === step.type) opt.selected = true; - typeSelect.appendChild(opt); - }); - typeSelect.addEventListener("change", () => { - currentSteps[idx].type = typeSelect.value; - hiddenInput.value = JSON.stringify(currentSteps); - renderSteps(); - }); - - const valueInput = createElement( - "input", - "form-input login-script-value", - { - type: "text", - value: step.value || "", - placeholder: - step.type === "wait" ? "Text to match..." : "Text to send...", - autocapitalize: "off", - autocorrect: "off", - spellcheck: false, - }, - ); - valueInput.addEventListener("input", () => { - currentSteps[idx].value = valueInput.value; - hiddenInput.value = JSON.stringify(currentSteps); - }); - - row.appendChild(typeSelect); - row.appendChild(valueInput); - - // Timeout field for wait steps - if (step.type === "wait") { - const timeoutInput = createElement( - "input", - "form-input login-script-timeout", - { - type: "number", - value: step.timeout || 10, - min: 1, - max: 120, - title: "Timeout (seconds)", - }, - ); - timeoutInput.style.width = "60px"; - timeoutInput.addEventListener("input", () => { - currentSteps[idx].timeout = - parseInt(timeoutInput.value, 10) || 10; - hiddenInput.value = JSON.stringify(currentSteps); - }); - const timeoutLabel = createElement( - "span", - "login-script-timeout-label", - { textContent: "s" }, - ); - row.appendChild(timeoutInput); - row.appendChild(timeoutLabel); - } - - // Move up/down/remove buttons - const actions = createElement("div", "login-script-actions"); - - if (idx > 0) { - const upBtn = createElement("button", "login-script-btn", { - textContent: "▲", - title: "Move up", - type: "button", - }); - upBtn.addEventListener("mousedown", (e) => e.preventDefault()); - upBtn.addEventListener("click", () => { - [currentSteps[idx - 1], currentSteps[idx]] = [ - currentSteps[idx], - currentSteps[idx - 1], - ]; - renderSteps(); - }); - actions.appendChild(upBtn); - } - - if (idx < currentSteps.length - 1) { - const downBtn = createElement("button", "login-script-btn", { - textContent: "▼", - title: "Move down", - type: "button", - }); - downBtn.addEventListener("mousedown", (e) => e.preventDefault()); - downBtn.addEventListener("click", () => { - [currentSteps[idx], currentSteps[idx + 1]] = [ - currentSteps[idx + 1], - currentSteps[idx], - ]; - renderSteps(); - }); - actions.appendChild(downBtn); - } - - const removeBtn = createElement( - "button", - "login-script-btn login-script-btn-remove", - { textContent: "✕", title: "Remove", type: "button" }, - ); - removeBtn.addEventListener("mousedown", (e) => e.preventDefault()); - removeBtn.addEventListener("click", () => { - currentSteps.splice(idx, 1); - renderSteps(); - }); - actions.appendChild(removeBtn); - - row.appendChild(actions); - listContainer.appendChild(row); - }); - }; - - renderSteps(); - - // Add step buttons - const addRow = createElement("div", "login-script-add-row"); - const addWaitBtn = createElement( - "button", - "btn btn-small btn-secondary", - { textContent: "+ Wait", type: "button" }, - ); - addWaitBtn.addEventListener("mousedown", (e) => e.preventDefault()); - addWaitBtn.addEventListener("click", () => { - currentSteps.push({ type: "wait", value: "", timeout: 10 }); - renderSteps(); - }); - const addSendBtn = createElement( - "button", - "btn btn-small btn-secondary", - { textContent: "+ Send", type: "button" }, - ); - addSendBtn.addEventListener("mousedown", (e) => e.preventDefault()); - addSendBtn.addEventListener("click", () => { - currentSteps.push({ type: "send", value: "", timeout: 0 }); - renderSteps(); - }); - addRow.appendChild(addWaitBtn); - addRow.appendChild(addSendBtn); - content.appendChild(addRow); - - const toggleSection = () => { - collapsed = !collapsed; - content.style.display = collapsed ? "none" : "block"; - header.querySelector(".form-section-chevron").textContent = collapsed - ? "▸" - : "▾"; - }; - header.addEventListener("mousedown", (e) => { - if (e.button !== 0) return; - e.preventDefault(); - toggleSection(); - }); - - section.appendChild(header); - section.appendChild(content); - return section; - } - - _renderSection(title, fields) { - const section = createElement("div", "form-section"); - const header = createElement("div", "form-section-header"); - let collapsed = false; - - header.innerHTML = ` - ${collapsed ? "▸" : "▾"} - ${escapeHtml(title)} - `; - - const content = createElement("div", "form-section-content"); - if (collapsed) content.style.display = "none"; - - for (const field of fields) { - if (field instanceof HTMLElement) { - content.appendChild(field); - } - } - - const toggleSection = () => { - collapsed = !collapsed; - content.style.display = collapsed ? "none" : "block"; - header.querySelector(".form-section-chevron").textContent = collapsed - ? "▸" - : "▾"; - }; - // Handle toggle on mousedown to avoid first-click swallowing in WKWebView. - header.addEventListener("mousedown", (e) => { - if (e.button !== 0) return; - e.preventDefault(); - toggleSection(); - }); - - section.appendChild(header); - section.appendChild(content); - return section; - } - - /** - * Renders a single form field. - * @param {string} type - text | number | select | toggle | textarea | tags | file - * @param {string} name - Dot-notated field name, e.g. "ssh.secretMode". - * @param {string} label - * @param {*} value - * @param {Object} [opts] - * @returns {HTMLElement} - * @private - */ - _field(type, name, label, value, opts = {}) { - const group = createElement("div", "form-group"); - - // Label - const labelEl = createElement("label", "form-label", { - textContent: label, - for: `field-${name}`, - }); - group.appendChild(labelEl); - - // Input element based on type - let input; - - switch (type) { - case "text": - case "file": - input = createElement("input", "form-input", { - type: type === "file" ? "text" : "text", - id: `field-${name}`, - name, - value: value || "", - placeholder: opts.placeholder || "", - }); - if (opts.required) input.required = true; - break; - - case "number": - input = createElement("input", "form-input", { - type: "number", - id: `field-${name}`, - name, - value: value != null ? String(value) : "", - }); - if (opts.min != null) input.min = opts.min; - if (opts.max != null) input.max = opts.max; - if (opts.required) input.required = true; - break; - - case "select": { - input = createElement("select", "form-select", { - id: `field-${name}`, - name, - }); - for (const opt of opts.options || []) { - const option = createElement("option", "", { - value: opt.value, - textContent: opt.label, - }); - if (opt.value === value) option.selected = true; - input.appendChild(option); - } - break; - } - - case "toggle": { - const wrapper = createElement("div", "form-toggle-wrapper"); - input = createElement("input", "form-toggle", { - type: "checkbox", - id: `field-${name}`, - name, - }); - input.checked = !!value; - const slider = createElement("label", "form-toggle-label", { - for: `field-${name}`, - }); - wrapper.appendChild(input); - wrapper.appendChild(slider); - group.appendChild(wrapper); - // Help text - if (opts.help) { - group.appendChild( - createElement("span", "form-hint", { textContent: opts.help }), - ); - } - return group; - } - - case "textarea": - input = createElement("textarea", "form-textarea", { - id: `field-${name}`, - name, - placeholder: opts.placeholder || "", - }); - input.rows = opts.rows || 3; - input.textContent = value || ""; - break; - - case "tags": - input = createElement("input", "form-input", { - type: "text", - id: `field-${name}`, - name, - value: value || "", - placeholder: opts.placeholder || "", - }); - break; - - default: - input = createElement("input", "form-input", { - type: "text", - id: `field-${name}`, - name, - value: value || "", - }); - } - - group.appendChild(input); - - if (opts.datalistId && input && input.tagName === "INPUT") { - input.setAttribute("list", opts.datalistId); - } - - // Help text - if (opts.help) { - group.appendChild( - createElement("span", "form-hint", { textContent: opts.help }), - ); - } - - return group; - } - - _renderSSHKeyPathField(value) { - const group = createElement("div", "form-group"); - const labelEl = createElement("label", "form-label", { - textContent: "Private Key Path", - for: "field-ssh.privateKeyPath", - }); - group.appendChild(labelEl); - - const row = createElement("div", "key-path-row"); - const input = createElement("input", "form-input", { - type: "text", - id: "field-ssh.privateKeyPath", - name: "ssh.privateKeyPath", - value: value || "", - placeholder: "/Users/username/.ssh/id_ed25519", - }); - input.setAttribute("list", "ssh-key-path-options"); - row.appendChild(input); - - const browseBtn = createElement("button", "btn btn-sm btn-outline", { - textContent: "Browse", - type: "button", - }); - browseBtn.addEventListener("mousedown", (e) => e.preventDefault()); - browseBtn.addEventListener("click", () => { - if (!window.AlchementaryLink?.wailsService) return; - window.AlchementaryLink.wailsService.SelectFile("Select SSH Private Key", "*") - .then((path) => { - if (path) input.value = path; - }) - .catch((err) => { - this.app.notifications.error( - "Failed to open file dialog: " + (err.message || err), - ); - }); - }); - row.appendChild(browseBtn); - group.appendChild(row); - - const error = createElement("span", "form-error"); - error.style.display = "none"; - group.appendChild(error); - - const success = createElement("span", "form-success"); - success.style.display = "none"; - group.appendChild(success); - - this._attachKeyPathValidation(input, error, success); - - group.appendChild( - createElement("span", "form-hint", { - textContent: "Select a key from your .ssh folder or type a full path", - }), - ); - - return group; - } - - /** - * Renders the jump-hosts list editor. - * @param {Array} jumpHosts - * @returns {HTMLElement} - * @private - */ - _renderJumpHostsList(jumpHosts) { - const wrapper = createElement("div", "jump-hosts-list"); - - for (let i = 0; i < jumpHosts.length; i++) { - const jh = jumpHosts[i]; - const row = createElement("div", "jump-host-row"); - row.innerHTML = ` -
- - - -
-
- - - -
- `; - const keyInput = row.querySelector( - `input[name="jumpHost-${i}-privateKeyPath"]`, - ); - const browseBtn = row.querySelector(".jump-host-browse"); - const error = createElement("span", "form-error"); - error.style.display = "none"; - const success = createElement("span", "form-success"); - success.style.display = "none"; - - browseBtn.addEventListener("mousedown", (e) => e.preventDefault()); - browseBtn.addEventListener("click", () => { - if (!window.AlchementaryLink?.wailsService) return; - window.AlchementaryLink.wailsService.SelectFile("Select SSH Private Key", "*") - .then((path) => { - if (path) keyInput.value = path; - this._validateKeyPath(keyInput, error); - }) - .catch((err) => { - this.app.notifications.error( - "Failed to open file dialog: " + (err.message || err), - ); - }); - }); - - this._attachKeyPathValidation(keyInput, error, success); - row.querySelector(".jump-host-remove").addEventListener("click", () => { - jumpHosts.splice(i, 1); - this.render(this._profile, this._isNew); - }); - wrapper.appendChild(row); - wrapper.appendChild(error); - wrapper.appendChild(success); - } - - const addBtn = createElement("button", "btn btn-sm btn-outline", { - textContent: "+ Add Jump Host", - }); - addBtn.addEventListener("click", () => { - jumpHosts.push({ - host: "", - port: 22, - username: "", - authMethod: "publickey", - secretMode: "ask-every-time", - privateKeyPath: "", - }); - this.render(this._profile, this._isNew); - }); - wrapper.appendChild(addBtn); - - return wrapper; - } - - _attachKeyPathValidation(input, errorEl, successEl) { - if (!input || !errorEl) return; - input.addEventListener("blur", () => - this._validateKeyPath(input, errorEl, successEl), - ); - input.addEventListener("input", () => { - errorEl.style.display = "none"; - errorEl.textContent = ""; - if (successEl) { - successEl.style.display = "none"; - successEl.textContent = ""; - } - }); - } - - _validateKeyPath(input, errorEl, successEl) { - const value = (input.value || "").trim(); - if (!value) { - errorEl.style.display = "none"; - errorEl.textContent = ""; - if (successEl) { - successEl.style.display = "none"; - successEl.textContent = ""; - } - return; - } - if (value.startsWith("~")) { - errorEl.textContent = "Please use a full path (no ~)."; - errorEl.style.display = "block"; - if (successEl) { - successEl.style.display = "none"; - successEl.textContent = ""; - } - return; - } - if (!window.AlchementaryLink?.wailsService) { - return; - } - window.AlchementaryLink.wailsService.FileExists(value) - .then((exists) => { - if (!exists) { - errorEl.textContent = "Key file not found."; - errorEl.style.display = "block"; - if (successEl) { - successEl.style.display = "none"; - successEl.textContent = ""; - } - return; - } - return window.AlchementaryLink.wailsService.FileIsPrivateKey(value).then((isKey) => { - if (!isKey) { - errorEl.textContent = "File is not a recognised private key."; - errorEl.style.display = "block"; - if (successEl) { - successEl.style.display = "none"; - successEl.textContent = ""; - } - return; - } - errorEl.style.display = "none"; - errorEl.textContent = ""; - if (successEl) { - successEl.textContent = "Key verified."; - successEl.style.display = "block"; - } - }); - }) - .catch(() => { - errorEl.textContent = "Could not verify this path."; - errorEl.style.display = "block"; - if (successEl) { - successEl.style.display = "none"; - successEl.textContent = ""; - } - }); - } - - /** - * Renders the port-forwarding rules list editor. - * @param {Array} portForwards - * @returns {HTMLElement} - * @private - */ - _renderPortForwardsList(portForwards) { - const wrapper = createElement("div", "port-forwards-list"); - - for (let i = 0; i < portForwards.length; i++) { - const pf = portForwards[i]; - const row = createElement("div", "port-forward-row"); - row.innerHTML = ` -
- - - - ⟶ - - -
-
- - - -
- `; - row - .querySelector(".port-forward-remove") - .addEventListener("click", () => { - portForwards.splice(i, 1); - this.render(this._profile, this._isNew); - }); - wrapper.appendChild(row); - } - - const addBtn = createElement("button", "btn btn-sm btn-outline", { - textContent: "+ Add Port Forward", - }); - addBtn.addEventListener("click", () => { - portForwards.push({ - id: generateId(), - direction: "local", - bindHost: "127.0.0.1", - bindPort: 0, - destHost: "", - destPort: 0, - enabled: true, - label: "", - }); - this.render(this._profile, this._isNew); - }); - wrapper.appendChild(addBtn); - - return wrapper; - } - - /** - * Renders the payload-presets list editor. - * @param {Array} presets - * @returns {HTMLElement} - * @private - */ - _renderPayloadPresetsList(presets) { - const wrapper = createElement("div", "payload-presets-list"); - - for (let i = 0; i < presets.length; i++) { - const ps = presets[i]; - const row = createElement("div", "preset-row"); - row.innerHTML = ` - - - - - `; - row.querySelector(".preset-remove").addEventListener("click", () => { - presets.splice(i, 1); - this.render(this._profile, this._isNew); - }); - wrapper.appendChild(row); - } - - const addBtn = createElement("button", "btn btn-sm btn-outline", { - textContent: "+ Add Preset", - }); - addBtn.addEventListener("click", () => { - presets.push({ - id: generateId(), - name: "", - payload: "", - format: "text", - }); - this.render(this._profile, this._isNew); - }); - wrapper.appendChild(addBtn); - - return wrapper; - } - - // ── Form data collection ───────────────────────────────────────── - - /** - * Reads current form values into this._profile. - * @private - */ - _collectFormValues() { - if (!this.drawer) return; - - const val = (name) => { - const el = this.drawer.querySelector(`[name="${name}"]`); - if (!el) return undefined; - if (el.type === "checkbox") return el.checked; - if (el.type === "number") return el.value !== "" ? Number(el.value) : 0; - return el.value; - }; - - const p = this._profile; - - // General - p.displayName = val("displayName") || ""; - p.host = val("host") || ""; - p.port = val("port") || p.port; - p.username = val("username") || ""; - p.description = val("description") || ""; - p.folder = (val("folder") || "").trim(); - p.terminalFontFamily = val("terminalFontFamily") || ""; - p.terminalFontSize = val("terminalFontSize") || 14; - p.terminalScrollback = val("terminalScrollback") || 10000; - p.terminalCursorStyle = val("terminalCursorStyle") || "block"; - p.connectTimeout = val("connectTimeout") || 30; - p.readTimeout = val("readTimeout") || 0; - - // Tags - const tagsStr = val("tags"); - if (tagsStr !== undefined) { - p.tags = tagsStr - .split(",") - .map((t) => t.trim()) - .filter(Boolean); - } - - // Notes - const notes = val("notes"); - if (notes !== undefined) p.notes = notes; - - // SSH-specific - if (p.protocol === "ssh" && p.ssh) { - p.ssh.secretMode = val("ssh.secretMode") || p.ssh.secretMode; - p.ssh.privateKeyPath = val("ssh.privateKeyPath") || ""; - p.ssh.keySecretMode = val("ssh.keySecretMode") || p.ssh.keySecretMode; - p.ssh.hostKeyPolicy = val("ssh.hostKeyPolicy") || p.ssh.hostKeyPolicy; - p.ssh.terminalType = val("ssh.terminalType") || "xterm-256color"; - p.ssh.keepaliveInterval = val("ssh.keepaliveInterval") ?? 30; - p.ssh.keepaliveCountMax = val("ssh.keepaliveCountMax") ?? 3; - p.ssh.compression = val("ssh.compression") || false; - p.ssh.agentAuth = val("ssh.agentAuth") || false; - p.ssh.agentForwarding = val("ssh.agentForwarding") || false; - p.ssh.autoReconnect = val("ssh.autoReconnect") || false; - p.ssh.maxReconnectTries = val("ssh.maxReconnectTries") ?? 5; - p.ssh.startupCommand = val("ssh.startupCommand") || ""; - - // Proxy settings - p.ssh.proxyType = val("ssh.proxyType") || "none"; - p.ssh.proxyHost = val("ssh.proxyHost") || ""; - p.ssh.proxyPort = val("ssh.proxyPort") || 1080; - p.ssh.proxyUsername = val("ssh.proxyUsername") || ""; - p.ssh.proxyPassword = val("ssh.proxyPassword") || ""; - - const authStr = val("ssh.authMethods"); - if (authStr !== undefined) { - p.ssh.authMethods = authStr - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - } - - // Env vars from textarea - const envText = val("ssh.envVarsText"); - if (envText !== undefined) { - p.ssh.envVars = {}; - envText.split("\n").forEach((line) => { - const eq = line.indexOf("="); - if (eq > 0) { - p.ssh.envVars[line.slice(0, eq).trim()] = line - .slice(eq + 1) - .trim(); - } - }); - } - - // Jump hosts - const jh = []; - let ji = 0; - while (this.drawer.querySelector(`[name="jumpHost-${ji}-host"]`)) { - jh.push({ - host: val(`jumpHost-${ji}-host`) || "", - port: val(`jumpHost-${ji}-port`) || 22, - username: val(`jumpHost-${ji}-username`) || "", - authMethod: "publickey", - secretMode: "ask-every-time", - privateKeyPath: val(`jumpHost-${ji}-privateKeyPath`) || "", - }); - ji++; - } - p.ssh.jumpHosts = jh; - - // Port forwards - const pfs = []; - let fi = 0; - while (this.drawer.querySelector(`[name="portFwd-${fi}-direction"]`)) { - pfs.push({ - id: - (p.ssh.portForwards && p.ssh.portForwards[fi]?.id) || - generateId(), - direction: val(`portFwd-${fi}-direction`) || "local", - bindHost: val(`portFwd-${fi}-bindHost`) || "127.0.0.1", - bindPort: val(`portFwd-${fi}-bindPort`) || 0, - destHost: val(`portFwd-${fi}-destHost`) || "", - destPort: val(`portFwd-${fi}-destPort`) || 0, - enabled: val(`portFwd-${fi}-enabled`) !== false, - label: val(`portFwd-${fi}-label`) || "", - }); - fi++; - } - p.ssh.portForwards = pfs; - - // Login scripts - const loginScriptsRaw = val("ssh.loginScripts"); - if (loginScriptsRaw) { - try { - p.ssh.loginScripts = JSON.parse(loginScriptsRaw); - } catch (_) { - p.ssh.loginScripts = []; - } - } else { - p.ssh.loginScripts = []; - } - } - - // Telnet-specific - if (p.protocol === "telnet" && p.telnet) { - p.telnet.localEcho = val("telnet.localEcho") || false; - p.telnet.lineMode = val("telnet.lineMode") || false; - p.telnet.terminalType = val("telnet.terminalType") || "xterm-256color"; - p.telnet.encoding = val("telnet.encoding") || "utf-8"; - p.telnet.lineEnding = val("telnet.lineEnding") || "crlf"; - p.telnet.negotiationDebug = val("telnet.negotiationDebug") || false; - p.telnet.autoReconnect = val("telnet.autoReconnect") || false; - p.telnet.maxReconnectTries = val("telnet.maxReconnectTries") ?? 5; - - // Login scripts - const telLoginScriptsRaw = val("telnet.loginScripts"); - if (telLoginScriptsRaw) { - try { - p.telnet.loginScripts = JSON.parse(telLoginScriptsRaw); - } catch (_) { - p.telnet.loginScripts = []; - } - } else { - p.telnet.loginScripts = []; - } - } - - // RAW-specific - if (p.protocol === "raw" && p.raw) { - p.raw.encoding = val("raw.encoding") || "utf-8"; - p.raw.lineEnding = val("raw.lineEnding") || "crlf"; - p.raw.showTimestamps = val("raw.showTimestamps") || false; - p.raw.showDirection = val("raw.showDirection") || false; - p.raw.autoReconnect = val("raw.autoReconnect") || false; - - // Presets - const presets = []; - let pi = 0; - while (this.drawer.querySelector(`[name="preset-${pi}-name"]`)) { - presets.push({ - id: - (p.raw.payloadPresets && p.raw.payloadPresets[pi]?.id) || - generateId(), - name: val(`preset-${pi}-name`) || "", - payload: val(`preset-${pi}-payload`) || "", - format: val(`preset-${pi}-format`) || "text", - }); - pi++; - } - p.raw.payloadPresets = presets; - } - - // GitHub-specific - if (p.protocol === "github") { - if (!p.github) p.github = {}; - p.github.baseURL = val("github.baseURL") || "https://api.github.com"; - p.github.secretMode = val("github.secretMode") || "saved"; - p.github.refreshIntervalSecs = val("github.refreshIntervalSecs") || 300; - } - - // VNC-specific - if (p.protocol === "vnc") { - if (!p.vnc) p.vnc = {}; - p.vnc.vncHost = val("vnc.vncHost") || ""; - p.vnc.vncPort = val("vnc.vncPort") || 5900; - p.vnc.secretMode = val("vnc.secretMode") || "prompt"; - p.vnc.viewOnly = val("vnc.viewOnly") || false; - p.vnc.scaleToFit = val("vnc.scaleToFit") !== false; - p.vnc.quality = val("vnc.quality") || 6; - p.vnc.sharedSession = val("vnc.sharedSession") !== false; - p.vnc.sshTunnel = val("vnc.sshTunnel") || false; - p.vnc.sshHost = val("vnc.sshHost") || ""; - p.vnc.sshPort = val("vnc.sshPort") || 22; - p.vnc.sshUsername = val("vnc.sshUsername") || ""; - p.vnc.sshAuthMethod = val("vnc.sshAuthMethod") || "publickey"; - p.vnc.sshPrivateKey = val("vnc.sshPrivateKey") || ""; - p.vnc.sshAgentAuth = val("vnc.sshAgentAuth") || false; - p.vnc.sshSecretMode = val("vnc.sshSecretMode") || "saved"; - } - - // IRC-specific - if (p.protocol === "irc") { - if (!p.irc) p.irc = {}; - p.irc.nick = val("irc.nick") || "link"; - p.irc.realName = val("irc.realName") || "Alchementary Link"; - p.irc.useTLS = val("irc.useTLS") || false; - p.irc.serverPasswordRequired = - val("irc.serverPasswordRequired") || false; - p.irc.serverPasswordMode = - val("irc.serverPasswordMode") || "ask-every-time"; - p.irc.nickServIdentify = val("irc.nickServIdentify") || false; - p.irc.nickServAccount = val("irc.nickServAccount") || ""; - p.irc.nickServMode = val("irc.nickServMode") || "saved"; - p.irc.autoJoinRooms = this._parseRoomList( - val("irc.autoJoinRoomsText") || "", - ); - p.irc.sshTunnel = val("irc.sshTunnel") || false; - p.irc.sshHost = val("irc.sshHost") || ""; - p.irc.sshPort = val("irc.sshPort") || 22; - p.irc.sshUsername = val("irc.sshUsername") || ""; - p.irc.sshAuthMethod = val("irc.sshAuthMethod") || "publickey"; - p.irc.sshPrivateKey = val("irc.sshPrivateKey") || ""; - p.irc.sshAgentAuth = val("irc.sshAgentAuth") || false; - p.irc.sshSecretMode = val("irc.sshSecretMode") || "saved"; - p.irc.autoReconnect = val("irc.autoReconnect") !== false; - p.irc.maxReconnectTries = val("irc.maxReconnectTries") || 5; - } - } - - // ── Validation ─────────────────────────────────────────────────── - - /** - * Validates the collected profile. Returns an array of error strings (empty = valid). - * @param {Object} profile - * @returns {string[]} - * @private - */ - _validate(profile) { - const errors = []; - if (profile.protocol === "vnc") { - const vncHost = (profile.vnc && profile.vnc.vncHost) || profile.host; - if (!vncHost || !vncHost.trim()) { - errors.push( - "VNC Host is required (in General or VNC Server section).", - ); - } - } else if (!profile.host || !profile.host.trim()) { - errors.push("Host is required."); - } - if (!profile.port || profile.port < 1 || profile.port > 65535) { - errors.push("Port must be between 1 and 65535."); - } - if (profile.protocol === "irc") { - const nick = profile.irc?.nick || ""; - if (!nick.trim()) { - errors.push("IRC nick is required."); - } - for (const room of profile.irc?.autoJoinRooms || []) { - if (!/^[#&+!][^\s,]+$/.test(room)) { - errors.push(`Invalid IRC room "${room}".`); - } - } - if (profile.irc?.sshTunnel) { - if (!profile.irc.sshHost?.trim()) { - errors.push("SSH Host is required when tunnelling IRC."); - } - if (!profile.irc.sshUsername?.trim()) { - errors.push("SSH Username is required when tunnelling IRC."); - } - } - } - return errors; - } - - // ── Actions ────────────────────────────────────────────────────── - - /** @private */ - async _handleSave(andConnect = false) { - this._collectFormValues(); - const errors = this._validate(this._profile); - if (errors.length > 0) { - this.app.notifications.warning(errors.join(" ")); - return; - } - - if ( - this.app._requiresDirectVNCWarning && - this.app._requiresDirectVNCWarning(this._profile) - ) { - const ok = await this.app.confirmDirectVNCProfile( - this._profile, - andConnect ? "connect" : "save", - ); - if (!ok) return; - } - - try { - let saved; - if (this._isNew) { - saved = await window.AlchementaryLink.wailsService.CreateProfile(this._profile); - this.app.state.addProfile(saved); - this.app.notifications.success("Profile created"); - } else { - saved = await window.AlchementaryLink.wailsService.UpdateProfile(this._profile); - this.app.state.updateProfile(saved); - this.app.notifications.success("Profile updated"); - } - this.app.sidebar.render(); - this.close(); - - if (andConnect && saved && saved.id) { - this.app.connectProfile(saved.id, { skipDirectVNCWarning: true }); - } - } catch (err) { - this.app.notifications.error("Save failed: " + err); - } - } - - /** @private */ - async _handleDelete() { - const ok = await this._confirmDelete(); - if (!ok) return; - - try { - await window.AlchementaryLink.wailsService.DeleteProfile(this._profile.id); - this.app.state.removeProfile(this._profile.id); - this.app.sidebar.render(); - this.app.notifications.success("Profile deleted"); - this.close(); - } catch (err) { - this.app.notifications.error("Failed to delete profile: " + err); - } - } - - /** @private */ - async _testConnection() { - this._collectFormValues(); - const errors = this._validate(this._profile); - if (errors.length > 0) { - this.app.notifications.warning(errors.join(" ")); - return; - } - - const host = this._profile.host; - const port = this._profile.port; - this.app.notifications.info(`Testing connection to ${host}:${port}…`); - - try { - const result = await window.AlchementaryLink.wailsService.TestConnection(host, port); - this.app.notifications.success( - `Reachable — ${result.latencyMs}ms latency`, - ); - } catch (err) { - this.app.notifications.error("Test failed: " + err); - } - } - - /** - * Shows a confirmation modal for profile deletion. - * @returns {Promise} - * @private - */ - _confirmDelete() { - return new Promise((resolve) => { - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-sm"); - const label = escapeHtml( - this._profile.displayName || this._profile.host || "Untitled", - ); - modal.innerHTML = ` - - - - `; - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - - const close = (val) => { - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(() => backdrop.remove(), 200); - resolve(val); - }; - modal - .querySelector(".modal-close") - .addEventListener("click", () => close(false)); - modal - .querySelector(".confirm-cancel") - .addEventListener("click", () => close(false)); - modal - .querySelector(".confirm-ok") - .addEventListener("click", () => close(true)); - backdrop.addEventListener("click", (e) => { - if (e.target === backdrop) close(false); - }); - }); - } - - // ── Utilities ──────────────────────────────────────────────────── - - /** - * Returns a default profile object for the given protocol. - * @param {string} protocol - * @returns {Object} - * @private - */ - _defaultProfile(protocol) { - const base = { - id: generateId(), - displayName: "", - description: "", - protocol, - host: "", - port: 0, - username: "", - tags: [], - folder: "", - environment: "", - notes: "", - favourite: false, - lastConnected: null, - connectTimeout: 30, - readTimeout: 0, - terminalFontFamily: - "JetBrains Mono, Menlo, Monaco, Consolas, monospace", - terminalFontSize: 14, - terminalScrollback: 10000, - terminalCursorStyle: "block", - ssh: null, - telnet: null, - raw: null, - github: null, - vnc: null, - irc: null, - }; - - const defaults = DEFAULTS[protocol] || {}; - return { ...base, ...defaults }; - } - - /** - * Converts an envVars map into "KEY=VALUE\n" text. - * @param {Object} envVars - * @returns {string} - * @private - */ - _envVarsToText(envVars) { - if (!envVars) return ""; - return Object.entries(envVars) - .map(([k, v]) => `${k}=${v}`) - .join("\n"); - } - - _roomsToText(rooms) { - return Array.isArray(rooms) ? rooms.join("\n") : ""; - } - - _parseRoomList(value) { - return String(value || "") - .split(/[\n,]/) - .map((room) => room.trim()) - .filter(Boolean); - } - } - - window.AlchementaryLink = window.AlchementaryLink || {}; - window.AlchementaryLink.ProfileEditor = ProfileEditor; -})(); diff --git a/frontend/src/js/raw.js b/frontend/src/js/raw.js deleted file mode 100644 index 073e5c7..0000000 --- a/frontend/src/js/raw.js +++ /dev/null @@ -1,625 +0,0 @@ -(function () { - /** - * Alchementary Link — RAW TCP data viewer. - * Provides text, hex, and split display modes for raw socket data, - * along with a send bar, pause/resume, and capture saving. - */ - - const { createElement, escapeHtml, formatTimestamp, formatBytes } = - window.AlchementaryLink; - - /** - * Maximum number of data entries retained in the scrollback buffer. - * @type {number} - */ - const MAX_BUFFER_ENTRIES = 5000; - - class RawViewer { - /** - * @param {App} app - */ - constructor(app) { - this.app = app; - - /** - * Map of sessionId → viewer state. - * @type {Map} - */ - this.viewers = new Map(); - } - - // ── Public API ─────────────────────────────────────────────────── - - /** - * Creates a RAW viewer inside the given panel container. - * @param {string} sessionId - * @param {string} containerId - DOM id of the panel element. - * @param {Object} profile - */ - createViewer(sessionId, containerId, profile) { - const container = document.getElementById(containerId); - if (!container) return; - - const state = { - mode: "text", // text | hex | split - paused: false, - buffer: [], - totalBytes: 0, - sendFormat: "text", // text | hex | escaped - }; - - // ── Toolbar ────────────────────────────────────────────── - const toolbar = createElement("div", "raw-toolbar"); - - // Mode toggle - const modeGroup = createElement("div", "raw-mode-group"); - for (const mode of ["text", "hex", "split"]) { - const btn = createElement( - "button", - `raw-mode-btn ${mode === state.mode ? "active" : ""}`, - { - textContent: mode.charAt(0).toUpperCase() + mode.slice(1), - dataset: { mode }, - }, - ); - btn.addEventListener("click", () => this._setMode(sessionId, mode)); - modeGroup.appendChild(btn); - } - toolbar.appendChild(modeGroup); - - // Controls - const controls = createElement("div", "raw-controls"); - - const pauseBtn = createElement( - "button", - "btn btn-sm btn-outline raw-pause", - { textContent: "⏸ Pause" }, - ); - pauseBtn.addEventListener("click", () => this.togglePause(sessionId)); - controls.appendChild(pauseBtn); - - const clearBtn = createElement("button", "btn btn-sm btn-outline", { - textContent: "✕ Clear", - }); - clearBtn.addEventListener("click", () => this.clearBuffer(sessionId)); - controls.appendChild(clearBtn); - - const saveBtn = createElement("button", "btn btn-sm btn-outline", { - textContent: "💾 Save", - }); - saveBtn.addEventListener("click", () => this.saveCapture(sessionId)); - controls.appendChild(saveBtn); - - // Byte counter - const counter = createElement("span", "raw-counter", { - textContent: "0 B", - }); - controls.appendChild(counter); - - toolbar.appendChild(controls); - container.appendChild(toolbar); - - // ── Data display ───────────────────────────────────────── - const display = createElement("div", "raw-display"); - container.appendChild(display); - - // ── Send bar ───────────────────────────────────────────── - const sendBar = this._renderSendBar(sessionId, profile); - container.appendChild(sendBar); - - this.viewers.set(sessionId, { - ...state, - container, - display, - toolbar, - pauseBtn, - counter, - profile, - }); - } - - /** - * Processes incoming data from the backend. - * @param {Object} payload - { sessionID, data, direction, timestamp } - */ - handleData(payload) { - const viewer = this.viewers.get(payload.sessionID); - if (!viewer) return; - - // Decode base64 payload - let rawStr; - try { - rawStr = atob(payload.data); - } catch (_) { - rawStr = payload.data || ""; - } - - const bytes = new Uint8Array(rawStr.length); - for (let i = 0; i < rawStr.length; i++) { - bytes[i] = rawStr.charCodeAt(i); - } - - const entry = { - direction: payload.direction || "recv", // 'send' | 'recv' - timestamp: payload.timestamp ? new Date(payload.timestamp) : new Date(), - data: rawStr, - bytes, - }; - - viewer.buffer.push(entry); - viewer.totalBytes += bytes.length; - - // Trim buffer if it exceeds the maximum - if (viewer.buffer.length > MAX_BUFFER_ENTRIES) { - viewer.buffer.splice(0, viewer.buffer.length - MAX_BUFFER_ENTRIES); - } - - // Update counter - viewer.counter.textContent = formatBytes(viewer.totalBytes); - - // Render unless paused - if (!viewer.paused) { - this._renderEntry(viewer, entry); - this._scrollToBottom(viewer); - } - } - - /** - * Toggles pause/resume on the data display. - * @param {string} sessionId - */ - togglePause(sessionId) { - const viewer = this.viewers.get(sessionId); - if (!viewer) return; - - viewer.paused = !viewer.paused; - viewer.pauseBtn.textContent = viewer.paused ? "▶ Resume" : "⏸ Pause"; - viewer.pauseBtn.classList.toggle("active", viewer.paused); - - // On resume, flush any buffered entries - if (!viewer.paused) { - this._renderAllEntries(viewer); - } - } - - /** - * Clears the data buffer and display. - * @param {string} sessionId - */ - clearBuffer(sessionId) { - const viewer = this.viewers.get(sessionId); - if (!viewer) return; - - viewer.buffer = []; - viewer.totalBytes = 0; - viewer.display.innerHTML = ""; - viewer.counter.textContent = "0 B"; - } - - /** - * Prompts the user to save the capture buffer. - * @param {string} sessionId - */ - async saveCapture(sessionId) { - const viewer = this.viewers.get(sessionId); - if (!viewer || viewer.buffer.length === 0) { - this.app.notifications.warning("Nothing to save"); - return; - } - - try { - const lines = viewer.buffer.map((e) => { - const ts = formatTimestamp(e.timestamp); - const dir = e.direction === "send" ? "TX" : "RX"; - return `[${ts}] [${dir}] ${e.data}`; - }); - const content = lines.join("\n"); - await window.AlchementaryLink.wailsService.SaveSessionCapture(sessionId, content); - this.app.notifications.success("Capture saved"); - } catch (err) { - this.app.notifications.error("Save failed: " + err); - } - } - - /** - * Sends data from the send bar to the backend. - * @param {string} sessionId - */ - async send(sessionId) { - const viewer = this.viewers.get(sessionId); - if (!viewer) return; - - const input = viewer.container.querySelector(".raw-send-input"); - const formatSelect = viewer.container.querySelector(".raw-send-format"); - if (!input) return; - - const rawValue = input.value; - if (!rawValue) return; - - const format = formatSelect?.value || "text"; - let dataToSend; - - switch (format) { - case "hex": - dataToSend = this._hexToString(rawValue); - break; - case "escaped": - dataToSend = this._unescapeString(rawValue); - break; - default: - dataToSend = rawValue; - } - - try { - // For hex/escaped, data is already raw bytes (0-255) so btoa is correct. - // For text mode, UTF-8 encode so non-ASCII characters are transmitted properly. - const b64 = - format === "hex" || format === "escaped" - ? btoa(dataToSend) - : window.AlchementaryLink.utf8ToBase64(dataToSend); - await window.AlchementaryLink.wailsService.WriteSession(sessionId, b64); - input.value = ""; - - // Also show in viewer as sent data - this.handleData({ - sessionID: sessionId, - data: b64, - direction: "send", - timestamp: new Date().toISOString(), - }); - } catch (err) { - this.app.notifications.error("Send failed: " + err); - } - } - - /** - * Tears down a viewer and frees resources. - * @param {string} sessionId - */ - destroyViewer(sessionId) { - this.viewers.delete(sessionId); - } - - // ── Rendering ──────────────────────────────────────────────────── - - /** - * Renders the send bar at the bottom of the viewer. - * @param {string} sessionId - * @param {Object} profile - * @returns {HTMLElement} - * @private - */ - _renderSendBar(sessionId, profile) { - const bar = createElement("div", "raw-send-bar"); - - // Format selector - const formatSelect = createElement( - "select", - "form-select form-select-sm raw-send-format", - ); - for (const [value, label] of [ - ["text", "Text"], - ["hex", "Hex"], - ["escaped", "Escaped"], - ]) { - const opt = createElement("option", "", { value, textContent: label }); - formatSelect.appendChild(opt); - } - bar.appendChild(formatSelect); - - // Input - const input = createElement("input", "form-input raw-send-input", { - type: "text", - placeholder: "Type data to send…", - }); - input.addEventListener("keydown", (e) => { - if (e.key === "Enter") this.send(sessionId); - }); - bar.appendChild(input); - - // Quick-insert buttons for common control bytes - const quickBtns = createElement("div", "raw-quick-btns"); - const controlBytes = [ - { label: "NUL", value: "\\x00" }, - { label: "CR", value: "\\r" }, - { label: "LF", value: "\\n" }, - { label: "ETX", value: "\\x03" }, - { label: "EOT", value: "\\x04" }, - ]; - for (const cb of controlBytes) { - const btn = createElement("button", "btn btn-xs btn-outline", { - textContent: cb.label, - title: cb.value, - }); - btn.addEventListener("click", () => { - input.value += cb.value; - input.focus(); - }); - quickBtns.appendChild(btn); - } - bar.appendChild(quickBtns); - - // Payload preset dropdown - const presets = profile?.raw?.payloadPresets || []; - if (presets.length > 0) { - const presetSelect = createElement( - "select", - "form-select form-select-sm raw-preset-select", - ); - const defaultOpt = createElement("option", "", { - value: "", - textContent: "Presets…", - }); - presetSelect.appendChild(defaultOpt); - for (const ps of presets) { - const opt = createElement("option", "", { - value: ps.payload, - textContent: ps.name, - }); - opt.dataset.format = ps.format; - presetSelect.appendChild(opt); - } - presetSelect.addEventListener("change", () => { - if (presetSelect.value) { - input.value = presetSelect.value; - const selOpt = presetSelect.selectedOptions[0]; - if (selOpt?.dataset.format) { - formatSelect.value = selOpt.dataset.format; - } - presetSelect.value = ""; - input.focus(); - } - }); - bar.appendChild(presetSelect); - } - - // Send button - const sendBtn = createElement("button", "btn btn-sm btn-primary", { - textContent: "Send", - }); - sendBtn.addEventListener("click", () => this.send(sessionId)); - bar.appendChild(sendBtn); - - return bar; - } - - /** - * Renders a single data entry into the display area. - * @param {Object} viewer - * @param {Object} entry - * @private - */ - _renderEntry(viewer, entry) { - let el; - switch (viewer.mode) { - case "hex": - el = this._renderHexView(entry); - break; - case "split": - el = this._renderSplitView(entry, viewer.profile); - break; - default: - el = this._renderTextView(entry, viewer.profile); - } - viewer.display.appendChild(el); - } - - /** - * Re-renders all buffered entries (e.g. after mode change or resume). - * @param {Object} viewer - * @private - */ - _renderAllEntries(viewer) { - viewer.display.innerHTML = ""; - for (const entry of viewer.buffer) { - this._renderEntry(viewer, entry); - } - this._scrollToBottom(viewer); - } - - /** - * Renders a text-mode entry. - * @param {Object} entry - * @param {Object} profile - * @returns {HTMLElement} - * @private - */ - _renderTextView(entry, profile) { - const row = createElement( - "div", - `raw-entry raw-entry-${entry.direction}`, - ); - const raw = profile?.raw || {}; - - let prefix = ""; - if (raw.showTimestamps) { - prefix += `[${formatTimestamp(entry.timestamp)}] `; - } - if (raw.showDirection) { - prefix += entry.direction === "send" ? "[TX] " : "[RX] "; - } - - const pre = createElement("pre", "raw-text"); - pre.textContent = prefix + entry.data; - row.appendChild(pre); - return row; - } - - /** - * Renders a hex-mode entry. - * @param {Object} entry - * @returns {HTMLElement} - * @private - */ - _renderHexView(entry) { - const row = createElement( - "div", - `raw-entry raw-entry-${entry.direction}`, - ); - const pre = createElement("pre", "raw-hex"); - pre.textContent = this.formatHexDump(entry.bytes); - row.appendChild(pre); - return row; - } - - /** - * Renders a split-mode entry (text + hex side by side). - * @param {Object} entry - * @param {Object} profile - * @returns {HTMLElement} - * @private - */ - _renderSplitView(entry, profile) { - const row = createElement( - "div", - `raw-entry raw-entry-split raw-entry-${entry.direction}`, - ); - const raw = profile?.raw || {}; - - // Direction/timestamp header - const header = createElement("div", "raw-split-header"); - let headerText = ""; - if (raw.showTimestamps) - headerText += formatTimestamp(entry.timestamp) + " "; - headerText += entry.direction === "send" ? "▲ TX" : "▼ RX"; - header.textContent = headerText; - row.appendChild(header); - - // Split panes - const panes = createElement("div", "raw-split-panes"); - - const textPane = createElement("pre", "raw-text raw-split-text"); - textPane.textContent = entry.data; - panes.appendChild(textPane); - - const hexPane = createElement("pre", "raw-hex raw-split-hex"); - hexPane.textContent = this.formatHexDump(entry.bytes); - panes.appendChild(hexPane); - - row.appendChild(panes); - return row; - } - - /** - * Formats a Uint8Array into a traditional hex dump: - * offset | hex bytes | ASCII representation. - * @param {Uint8Array} bytes - * @returns {string} - */ - formatHexDump(bytes) { - const lines = []; - const bytesPerLine = 16; - - for (let offset = 0; offset < bytes.length; offset += bytesPerLine) { - const chunk = bytes.slice(offset, offset + bytesPerLine); - - // Offset column - const offsetStr = offset.toString(16).padStart(8, "0"); - - // Hex column - const hexParts = []; - for (let i = 0; i < bytesPerLine; i++) { - if (i < chunk.length) { - hexParts.push(chunk[i].toString(16).padStart(2, "0")); - } else { - hexParts.push(" "); - } - // Extra space after 8th byte for readability - if (i === 7) hexParts.push(""); - } - const hexStr = hexParts.join(" "); - - // ASCII column - let ascii = ""; - for (let i = 0; i < chunk.length; i++) { - const b = chunk[i]; - ascii += b >= 0x20 && b <= 0x7e ? String.fromCharCode(b) : "."; - } - - lines.push(`${offsetStr} ${hexStr} |${ascii}|`); - } - - return lines.join("\n"); - } - - // ── Internal helpers ───────────────────────────────────────────── - - /** - * Changes the display mode (text, hex, split) and re-renders. - * @param {string} sessionId - * @param {string} mode - * @private - */ - _setMode(sessionId, mode) { - const viewer = this.viewers.get(sessionId); - if (!viewer) return; - - viewer.mode = mode; - - // Update active button - viewer.toolbar.querySelectorAll(".raw-mode-btn").forEach((btn) => { - btn.classList.toggle("active", btn.dataset.mode === mode); - }); - - this._renderAllEntries(viewer); - } - - /** - * Scrolls the data display to the bottom. - * @param {Object} viewer - * @private - */ - _scrollToBottom(viewer) { - requestAnimationFrame(() => { - viewer.display.scrollTop = viewer.display.scrollHeight; - }); - } - - /** - * Converts a hex string (e.g. "48656c6c6f") into a character string. - * @param {string} hex - * @returns {string} - * @private - */ - _hexToString(hex) { - const cleaned = hex.replace(/\s+/g, ""); - let str = ""; - for (let i = 0; i < cleaned.length; i += 2) { - str += String.fromCharCode(parseInt(cleaned.substring(i, i + 2), 16)); - } - return str; - } - - /** - * Converts C-style escape sequences (\\n, \\r, \\x00, etc.) into their literal bytes. - * @param {string} str - * @returns {string} - * @private - */ - _unescapeString(str) { - return str.replace(/\\(n|r|t|0|x[0-9a-fA-F]{2}|\\)/g, (match, seq) => { - switch (seq) { - case "n": - return "\n"; - case "r": - return "\r"; - case "t": - return "\t"; - case "0": - return "\0"; - case "\\": - return "\\"; - default: - // \\xHH - if (seq.startsWith("x")) { - return String.fromCharCode(parseInt(seq.slice(1), 16)); - } - return match; - } - }); - } - } - - window.AlchementaryLink = window.AlchementaryLink || {}; - window.AlchementaryLink.RawViewer = RawViewer; -})(); diff --git a/frontend/src/js/recordings.js b/frontend/src/js/recordings.js deleted file mode 100644 index d418651..0000000 --- a/frontend/src/js/recordings.js +++ /dev/null @@ -1,701 +0,0 @@ -(function() { -/** - * Alchementary Link — Session Recording & Playback. - * Provides controls for starting/stopping recordings on active sessions, - * listing saved recordings, and a terminal-based playback viewer with - * speed controls and a progress scrubber. - */ - -const { createElement, formatDuration } = window.AlchementaryLink; - -// --------------------------------------------------------------------------- -// Recording Manager -// --------------------------------------------------------------------------- - -class RecordingManager { - constructor(app) { - this.app = app; - /** @type {Set} Session IDs currently being recorded. */ - this.activeSessions = new Set(); - } - - /** - * Toggles recording for the given session. - * @param {string} sessionId - */ - async toggle(sessionId) { - if (this.activeSessions.has(sessionId)) { - await this.stop(sessionId); - } else { - await this.start(sessionId); - } - } - - /** - * Starts recording a session. - * @param {string} sessionId - */ - async start(sessionId) { - try { - const entry = this.app.terminals.terminals.get(sessionId); - const cols = entry ? entry.term.cols : 80; - const rows = entry ? entry.term.rows : 24; - - await window.AlchementaryLink.wailsService.StartRecording(sessionId, cols, rows); - this.activeSessions.add(sessionId); - this._updateRecordingIndicator(sessionId, true); - this.app.notifications.success('Recording started'); - } catch (err) { - this.app.notifications.error('Failed to start recording: ' + err); - } - } - - /** - * Stops recording a session. - * @param {string} sessionId - */ - async stop(sessionId) { - try { - await window.AlchementaryLink.wailsService.StopRecording(sessionId); - this.activeSessions.delete(sessionId); - this._updateRecordingIndicator(sessionId, false); - this.app.notifications.success('Recording saved'); - } catch (err) { - this.app.notifications.error('Failed to stop recording: ' + err); - } - } - - /** - * Drops frontend recording state when a session is closed. The backend - * finalises the recorder as part of DisconnectSession. - * @param {string} sessionId - */ - cleanup(sessionId) { - this.activeSessions.delete(sessionId); - this._updateRecordingIndicator(sessionId, false); - } - - /** - * Opens the recordings browser modal. - */ - async openBrowser() { - try { - const [recordings, storageInfo] = await Promise.all([ - window.AlchementaryLink.wailsService.ListRecordings(), - window.AlchementaryLink.wailsService.GetRecordingStorageInfo(), - ]); - this._showBrowserModal(recordings || [], storageInfo || {}); - } catch (err) { - this.app.notifications.error('Failed to list recordings: ' + err); - } - } - - /** - * Plays back a recording in a modal terminal viewer. - * @param {string} id — Recording ID. - */ - async playback(id) { - try { - const data = await window.AlchementaryLink.wailsService.GetRecording(id); - this._showPlaybackModal(data); - } catch (err) { - this.app.notifications.error('Failed to load recording: ' + err); - } - } - - /** - * Deletes a recording by ID. - * @param {string} id - */ - async deleteRecording(id) { - try { - await window.AlchementaryLink.wailsService.DeleteRecording(id); - this.app.notifications.success('Recording deleted'); - } catch (err) { - this.app.notifications.error('Failed to delete recording: ' + err); - } - } - - // ── Recording indicator on tabs ────────────────────────────────── - - /** - * Updates the recording indicator on a session tab. - * @param {string} sessionId - * @param {boolean} recording - * @private - */ - _updateRecordingIndicator(sessionId, recording) { - const tab = document.querySelector(`.session-tab[data-session-id="${sessionId}"]`); - if (!tab) return; - - let dot = tab.querySelector('.recording-dot'); - if (recording) { - if (!dot) { - dot = createElement('span', 'recording-dot'); - dot.title = 'Recording'; - const label = tab.querySelector('.tab-label'); - if (label) label.parentNode.insertBefore(dot, label); - } - } else { - if (dot) dot.remove(); - } - } - - // ── Recordings browser modal ───────────────────────────────────── - - /** - * Renders a modal listing all saved recordings. - * @param {Array} recordings - * @param {Object} storageInfo - * @private - */ - _showBrowserModal(recordings, storageInfo = {}) { - const backdrop = createElement('div', 'modal-backdrop'); - const modal = createElement('div', 'modal modal-lg'); - - let listHTML = ''; - if (recordings.length === 0) { - listHTML = '

No recordings yet. Use the record button on a terminal tab to capture a session.

'; - } else { - listHTML = '
'; - for (const rec of recordings) { - const date = new Date(rec.createdAt).toLocaleString(); - const dur = _formatSeconds(rec.duration); - const size = _formatBytes(rec.size); - listHTML += ` -
-
- ${_esc(rec.title || rec.id)} - ${date} · ${dur} · ${size} · ${rec.width}×${rec.height} -
-
- - -
-
- `; - } - listHTML += '
'; - } - - const totalSize = _formatBytes(storageInfo.totalBytes || 0); - const count = storageInfo.count ?? recordings.length; - - modal.innerHTML = ` - - - - `; - - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => backdrop.classList.add('is-visible')); - - const close = () => { - backdrop.classList.remove('is-visible'); - setTimeout(() => backdrop.remove(), 200); - }; - - modal.querySelector('.modal-close').addEventListener('click', close); - modal.querySelector('.modal-close-btn').addEventListener('click', close); - - modal.querySelector('.recording-cleanup').addEventListener('click', async () => { - const ageDays = parseInt(modal.querySelector('.recording-cleanup-age').value, 10) || 0; - const maxBytes = parseInt(modal.querySelector('.recording-cleanup-size').value, 10) || 0; - const status = modal.querySelector('.recording-cleanup-status'); - const btn = modal.querySelector('.recording-cleanup'); - status.textContent = ''; - btn.disabled = true; - try { - const result = await window.AlchementaryLink.wailsService.CleanupRecordings(ageDays, maxBytes); - status.textContent = `Deleted ${result.deleted || 0}; freed ${_formatBytes(result.freedBytes || 0)}.`; - const fresh = await window.AlchementaryLink.wailsService.ListRecordings(); - close(); - this._showBrowserModal(fresh || [], await window.AlchementaryLink.wailsService.GetRecordingStorageInfo()); - } catch (err) { - status.textContent = 'Cleanup failed: ' + err; - } finally { - btn.disabled = false; - } - }); - - // Play buttons - modal.querySelectorAll('.rec-play').forEach(btn => { - btn.addEventListener('click', () => { - close(); - this.playback(btn.dataset.id); - }); - }); - - // Delete buttons - modal.querySelectorAll('.rec-delete').forEach(btn => { - btn.addEventListener('click', async () => { - await this.deleteRecording(btn.dataset.id); - const item = btn.closest('.recording-item'); - if (item) item.remove(); - }); - }); - } - - // ── Playback modal with embedded terminal ──────────────────────── - - /** - * Shows a playback modal with an xterm.js terminal, progress bar, and speed controls. - * @param {Object} data — { header, events } - * @private - */ - _showPlaybackModal(data) { - const header = data.header; - const events = data.events || []; - if (events.length === 0) { - this.app.notifications.info('Recording is empty — nothing to play back.'); - return; - } - - const totalDuration = events[events.length - 1][0] || 1; - - const backdrop = createElement('div', 'modal-backdrop'); - const modal = createElement('div', 'modal modal-playback'); - - modal.innerHTML = ` - - - `; - - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => backdrop.classList.add('is-visible')); - - // Create xterm instance for playback. - const termContainer = modal.querySelector('#playback-terminal'); - const { getTerminalTheme } = window.AlchementaryLink; - const themeName = (this.app.state.preferences && this.app.state.preferences.terminalTheme) || 'charcoal'; - const theme = getTerminalTheme ? getTerminalTheme(themeName) : {}; - - const term = new window.Terminal({ - cols: header.width || 80, - rows: header.height || 24, - theme: theme, - fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", "Menlo", monospace', - fontSize: 13, - cursorBlink: false, - disableStdin: true, - scrollback: 10000, - }); - term.open(termContainer); - - const fitAddon = new window.FitAddon.FitAddon(); - term.loadAddon(fitAddon); - setTimeout(() => fitAddon.fit(), 50); - - // Playback state. - let playing = false; - let speed = 1; - let eventIndex = 0; - let playbackTimer = null; - let startRealTime = 0; - let pausedAt = 0; // elapsed virtual time when paused - - const playPauseBtn = modal.querySelector('.playback-play-pause'); - const restartBtn = modal.querySelector('.playback-restart'); - const progressInput = modal.querySelector('.playback-progress'); - const timeLabel = modal.querySelector('.playback-time'); - const speedSelect = modal.querySelector('.playback-speed'); - - const updateTimeDisplay = (elapsed) => { - timeLabel.textContent = `${_formatSeconds(elapsed)} / ${_formatSeconds(totalDuration)}`; - progressInput.value = Math.round((elapsed / totalDuration) * 1000); - }; - - const scheduleNext = () => { - if (!playing || eventIndex >= events.length) { - if (eventIndex >= events.length) { - playing = false; - playPauseBtn.textContent = '▶'; - } - return; - } - - const evt = events[eventIndex]; - const evtTime = evt[0]; // seconds from start - const realElapsed = (Date.now() - startRealTime) / 1000; - const virtualElapsed = pausedAt + realElapsed * speed; - const delay = Math.max(0, (evtTime - virtualElapsed) / speed * 1000); - - playbackTimer = setTimeout(() => { - if (!playing) return; - - // Write output events to the terminal. - if (evt[1] === 'o') { - term.write(evt[2]); - } - - eventIndex++; - const currentVirtual = pausedAt + ((Date.now() - startRealTime) / 1000) * speed; - updateTimeDisplay(Math.min(currentVirtual, totalDuration)); - scheduleNext(); - }, delay); - }; - - const play = () => { - if (eventIndex >= events.length) { - // Restart from beginning. - eventIndex = 0; - pausedAt = 0; - term.reset(); - } - playing = true; - playPauseBtn.textContent = '⏸'; - startRealTime = Date.now(); - scheduleNext(); - }; - - const pause = () => { - playing = false; - playPauseBtn.textContent = '▶'; - if (playbackTimer) { - clearTimeout(playbackTimer); - playbackTimer = null; - } - const realElapsed = (Date.now() - startRealTime) / 1000; - pausedAt = pausedAt + realElapsed * speed; - }; - - const seekTo = (fraction) => { - const wasPlaying = playing; - if (playing) pause(); - - const targetTime = fraction * totalDuration; - term.reset(); - eventIndex = 0; - - // Replay all events up to targetTime. - for (let i = 0; i < events.length; i++) { - if (events[i][0] > targetTime) break; - if (events[i][1] === 'o') { - term.write(events[i][2]); - } - eventIndex = i + 1; - } - - pausedAt = targetTime; - updateTimeDisplay(targetTime); - - if (wasPlaying) play(); - }; - - playPauseBtn.addEventListener('click', () => { - if (playing) pause(); else play(); - }); - - restartBtn.addEventListener('click', () => { - if (playing) pause(); - term.reset(); - eventIndex = 0; - pausedAt = 0; - updateTimeDisplay(0); - }); - - progressInput.addEventListener('input', () => { - seekTo(parseInt(progressInput.value, 10) / 1000); - }); - - speedSelect.addEventListener('change', () => { - const newSpeed = parseFloat(speedSelect.value); - if (playing) { - const realElapsed = (Date.now() - startRealTime) / 1000; - pausedAt = pausedAt + realElapsed * speed; - speed = newSpeed; - startRealTime = Date.now(); - if (playbackTimer) clearTimeout(playbackTimer); - scheduleNext(); - } else { - speed = newSpeed; - } - }); - - // Auto-play on open. - setTimeout(play, 200); - - // Close handler. - const close = () => { - if (playing) pause(); - term.dispose(); - backdrop.classList.remove('is-visible'); - setTimeout(() => backdrop.remove(), 200); - }; - - modal.querySelector('.modal-close').addEventListener('click', close); - } -} - -// --------------------------------------------------------------------------- -// Encrypted Export/Import UI -// --------------------------------------------------------------------------- - -class ExportImportUI { - constructor(app) { - this.app = app; - } - - /** - * Shows the encrypted export modal. - */ - async showExportModal() { - const profiles = this.app.state.profiles || []; - if (profiles.length === 0) { - this.app.notifications.info('No profiles to export.'); - return; - } - - const backdrop = createElement('div', 'modal-backdrop'); - const modal = createElement('div', 'modal modal-md'); - - let profileListHTML = profiles.map(p => ` - - `).join(''); - - modal.innerHTML = ` - - - - `; - - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => backdrop.classList.add('is-visible')); - - const close = () => { - backdrop.classList.remove('is-visible'); - setTimeout(() => backdrop.remove(), 200); - }; - - modal.querySelector('.modal-close').addEventListener('click', close); - modal.querySelector('.export-cancel').addEventListener('click', close); - - modal.querySelector('.export-confirm').addEventListener('click', async () => { - const password = modal.querySelector('.export-password').value; - const confirm = modal.querySelector('.export-password-confirm').value; - - if (!password) { - this.app.notifications.error('Please enter a password.'); - return; - } - if (password !== confirm) { - this.app.notifications.error('Passwords do not match.'); - return; - } - - const checkboxes = modal.querySelectorAll('.export-profile-list input[type="checkbox"]:checked'); - const selectedIds = Array.from(checkboxes).map(cb => cb.value); - - if (selectedIds.length === 0) { - this.app.notifications.error('Please select at least one profile.'); - return; - } - - try { - const path = await window.AlchementaryLink.wailsService.ExportProfilesEncrypted(selectedIds, password); - this.app.notifications.success(`Exported ${selectedIds.length} profile(s) to ${path}`); - close(); - } catch (err) { - if (String(err).includes('cancelled')) { - return; - } - this.app.notifications.error('Export failed: ' + err); - } - }); - } - - /** - * Shows the encrypted import modal. - */ - async showImportModal() { - const backdrop = createElement('div', 'modal-backdrop'); - const modal = createElement('div', 'modal modal-md'); - - modal.innerHTML = ` - - - - `; - - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => backdrop.classList.add('is-visible')); - - const close = () => { - backdrop.classList.remove('is-visible'); - setTimeout(() => backdrop.remove(), 200); - }; - - modal.querySelector('.modal-close').addEventListener('click', close); - modal.querySelector('.import-cancel').addEventListener('click', close); - - modal.querySelector('.import-confirm').addEventListener('click', async () => { - const password = modal.querySelector('.import-password').value; - const strategy = modal.querySelector('.import-strategy').value; - const status = modal.querySelector('.import-status'); - const confirmBtn = modal.querySelector('.import-confirm'); - status.textContent = ''; - - if (!password) { - status.textContent = 'Please enter the decryption password.'; - return; - } - - try { - confirmBtn.disabled = true; - confirmBtn.textContent = 'Importing...'; - const count = await window.AlchementaryLink.wailsService.ImportProfilesEncrypted(password, strategy); - this.app.notifications.success(`Imported ${count} profile(s)`); - await this.app.loadProfiles(); - close(); - } catch (err) { - if (String(err).includes('cancelled')) { - return; - } - status.textContent = this._formatImportError(err); - this.app.notifications.error('Import failed'); - } finally { - confirmBtn.disabled = false; - confirmBtn.textContent = 'Import'; - } - }); - } - - _formatImportError(err) { - const msg = String(err || 'Unknown import error'); - return msg.replace(/\s+/g, ' ').trim(); - } -} - -// --------------------------------------------------------------------------- -// Utility helpers -// --------------------------------------------------------------------------- - -function _esc(s) { - const el = document.createElement('span'); - el.textContent = s || ''; - return el.innerHTML; -} - -function _formatSeconds(secs) { - if (!secs || secs <= 0) return '0:00'; - const m = Math.floor(secs / 60); - const s = Math.floor(secs % 60); - return `${m}:${s.toString().padStart(2, '0')}`; -} - -function _formatBytes(bytes) { - if (!bytes || bytes <= 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB']; - let i = 0; - let val = bytes; - while (val >= 1024 && i < units.length - 1) { - val /= 1024; - i++; - } - return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; -} - -// --------------------------------------------------------------------------- -// Export to global namespace -// --------------------------------------------------------------------------- - -window.AlchementaryLink = window.AlchementaryLink || {}; -window.AlchementaryLink.RecordingManager = RecordingManager; -window.AlchementaryLink.ExportImportUI = ExportImportUI; - -})(); diff --git a/frontend/src/js/secrets.js b/frontend/src/js/secrets.js deleted file mode 100644 index 33f9840..0000000 --- a/frontend/src/js/secrets.js +++ /dev/null @@ -1,373 +0,0 @@ -(function() { -/** - * Alchementary Link — secret/passphrase prompt dialogs. - * Provides modal prompts for collecting passwords, master-vault passphrases, - * and SSH host-key verification confirmations. - */ - -const { createElement, escapeHtml } = window.AlchementaryLink; - -class SecretPrompt { - constructor(app) { - /** @type {App} */ - this.app = app; - - /** @type {HTMLElement|null} Currently open overlay. */ - this._overlay = null; - } - - // ── Generic password prompt ────────────────────────────────────── - - /** - * Displays a modal with a single password field and returns the entered value. - * Rejects if the user cancels. - * @param {string} title - Dialog heading. - * @param {string} message - Instructional text shown above the input. - * @returns {Promise} - */ - prompt(title, message) { - return new Promise((resolve, reject) => { - this._close(); // dismiss any existing prompt - - const overlay = createElement('div', 'modal-backdrop'); - const modal = createElement('div', 'modal modal-secret'); - - // Header - const header = createElement('div', 'modal-header'); - header.innerHTML = ` - - - `; - modal.appendChild(header); - - // Body - const body = createElement('div', 'modal-body'); - body.innerHTML = ` -

${escapeHtml(message)}

-
- -
- `; - modal.appendChild(body); - - // Footer - const footer = createElement('div', 'modal-footer'); - const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' }); - const submitBtn = createElement('button', 'btn btn-primary', { textContent: 'Submit' }); - footer.appendChild(cancelBtn); - footer.appendChild(submitBtn); - modal.appendChild(footer); - - overlay.appendChild(modal); - document.body.appendChild(overlay); - this._overlay = overlay; - - requestAnimationFrame(() => { - overlay.classList.add('is-visible'); - modal.classList.add('is-visible'); - }); - - const input = modal.querySelector('.secret-input'); - input.focus(); - - const handleSubmit = () => { - const value = input.value; - this._close(); - resolve(value); - }; - - const handleCancel = () => { - this._close(); - reject(new Error('User cancelled')); - }; - - submitBtn.addEventListener('click', handleSubmit); - cancelBtn.addEventListener('click', handleCancel); - header.querySelector('.modal-close').addEventListener('click', handleCancel); - overlay.addEventListener('click', (e) => { - if (e.target === overlay) handleCancel(); - }); - - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') handleSubmit(); - if (e.key === 'Escape') handleCancel(); - }); - }); - } - - // ── Master password (vault initialisation / unlock) ────────────── - - /** - * Prompts for the master vault password. - * When `isNew` is true, a confirmation field is shown. - * @param {boolean} isNew - Whether this is the first time (create vault). - * @returns {Promise} The master password. - */ - promptMasterPassword(isNew) { - return new Promise((resolve, reject) => { - this._close(); - - const overlay = createElement('div', 'modal-backdrop'); - const modal = createElement('div', 'modal modal-secret'); - - const title = isNew ? 'Create Master Password' : 'Unlock Secret Store'; - const description = isNew - ? 'Choose a strong master password. This protects all saved credentials.' - : 'Enter your master password to unlock the secret store.'; - - const header = createElement('div', 'modal-header'); - header.innerHTML = ` - - - `; - modal.appendChild(header); - - const body = createElement('div', 'modal-body'); - let formHtml = ` -

${escapeHtml(description)}

-
- - -
- `; - if (isNew) { - formHtml += ` -
-
- -
-
- - -
- - `; - } - body.innerHTML = formHtml; - modal.appendChild(body); - - const footer = createElement('div', 'modal-footer'); - const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' }); - const submitBtn = createElement('button', 'btn btn-primary', { textContent: isNew ? 'Create' : 'Unlock' }); - footer.appendChild(cancelBtn); - footer.appendChild(submitBtn); - modal.appendChild(footer); - - overlay.appendChild(modal); - document.body.appendChild(overlay); - this._overlay = overlay; - - requestAnimationFrame(() => { - overlay.classList.add('is-visible'); - modal.classList.add('is-visible'); - }); - - const passInput = modal.querySelector('.master-password'); - const confirmInput = modal.querySelector('.master-confirm'); - const errorEl = modal.querySelector('.secret-error'); - passInput.focus(); - - // Wire up the password strength meter for new vault creation. - if (isNew) { - const strengthFill = modal.querySelector('.strength-fill'); - const strengthLabel = modal.querySelector('.strength-label'); - passInput.addEventListener('input', () => { - const result = _measureStrength(passInput.value); - strengthFill.style.width = result.percent + '%'; - strengthFill.className = 'strength-fill strength-' + result.level; - strengthLabel.textContent = result.label; - strengthLabel.className = 'strength-label strength-' + result.level; - }); - } - - const handleSubmit = () => { - const pw = passInput.value; - if (!pw) { - passInput.classList.add('input-error'); - return; - } - if (isNew) { - const confirm = confirmInput.value; - if (pw !== confirm) { - errorEl.textContent = 'Passwords do not match.'; - errorEl.style.display = 'block'; - confirmInput.classList.add('input-error'); - return; - } - } - this._close(); - resolve(pw); - }; - - const handleCancel = () => { - this._close(); - reject(new Error('User cancelled')); - }; - - submitBtn.addEventListener('click', handleSubmit); - cancelBtn.addEventListener('click', handleCancel); - header.querySelector('.modal-close').addEventListener('click', handleCancel); - overlay.addEventListener('click', (e) => { - if (e.target === overlay) handleCancel(); - }); - - const handleKeydown = (e) => { - if (e.key === 'Enter') handleSubmit(); - if (e.key === 'Escape') handleCancel(); - }; - passInput.addEventListener('keydown', handleKeydown); - if (confirmInput) { - confirmInput.addEventListener('keydown', handleKeydown); - } - }); - } - - // ── SSH host-key verification ──────────────────────────────────── - - /** - * Asks the user whether to accept an SSH host key. - * @param {string} host - * @param {string} keyType - e.g. "ssh-ed25519" - * @param {string} fingerprint - SHA256 fingerprint - * @returns {Promise} true = accept, false = reject. - */ - promptHostKeyVerification(host, keyType, fingerprint) { - return new Promise((resolve) => { - this._close(); - - const overlay = createElement('div', 'modal-backdrop'); - const modal = createElement('div', 'modal modal-secret modal-hostkey'); - - const header = createElement('div', 'modal-header'); - header.innerHTML = ` - - - `; - modal.appendChild(header); - - const body = createElement('div', 'modal-body'); - body.innerHTML = ` -

- The authenticity of host ${escapeHtml(host)} cannot be established. -

-
-
- Key type - ${escapeHtml(keyType)} -
-
- Fingerprint - ${escapeHtml(fingerprint)} -
-
-

- Are you sure you want to continue connecting? -

- `; - modal.appendChild(body); - - const footer = createElement('div', 'modal-footer'); - const rejectBtn = createElement('button', 'btn btn-secondary', { textContent: 'Reject' }); - const acceptBtn = createElement('button', 'btn btn-primary', { textContent: 'Accept & Save' }); - footer.appendChild(rejectBtn); - footer.appendChild(acceptBtn); - modal.appendChild(footer); - - overlay.appendChild(modal); - document.body.appendChild(overlay); - this._overlay = overlay; - - requestAnimationFrame(() => { - overlay.classList.add('is-visible'); - modal.classList.add('is-visible'); - acceptBtn.focus(); - }); - - acceptBtn.addEventListener('click', () => { - this._close(); - resolve(true); - }); - - rejectBtn.addEventListener('click', () => { - this._close(); - resolve(false); - }); - - header.querySelector('.modal-close').addEventListener('click', () => { - this._close(); - resolve(false); - }); - - overlay.addEventListener('click', (e) => { - if (e.target === overlay) { - this._close(); - resolve(false); - } - }); - - overlay.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - this._close(); - resolve(false); - } - }); - }); - } - - // ── Internal helpers ───────────────────────────────────────────── - - /** - * Closes and removes the current overlay, if any. - * @private - */ - _close() { - if (this._overlay) { - this._overlay.remove(); - this._overlay = null; - } - } -} - -/** - * Estimates password strength based on length and character diversity. - * Returns an object with { percent, level, label } for the strength meter. - * @param {string} pw - * @returns {{ percent: number, level: string, label: string }} - * @private - */ -function _measureStrength(pw) { - if (!pw) return { percent: 0, level: 'none', label: '' }; - - let score = 0; - const len = pw.length; - - // Length contribution (0–40 points) - score += Math.min(len * 3, 40); - - // Character-class diversity (0–40 points) - const hasLower = /[a-z]/.test(pw); - const hasUpper = /[A-Z]/.test(pw); - const hasDigit = /[0-9]/.test(pw); - const hasSymbol = /[^a-zA-Z0-9]/.test(pw); - const classes = [hasLower, hasUpper, hasDigit, hasSymbol].filter(Boolean).length; - score += classes * 10; - - // Unique-character ratio bonus (0–20 points) - const unique = new Set(pw).size; - score += Math.min(Math.floor((unique / Math.max(len, 1)) * 20), 20); - - score = Math.min(score, 100); - - if (score < 25) return { percent: score, level: 'weak', label: 'Weak' }; - if (score < 50) return { percent: score, level: 'fair', label: 'Fair' }; - if (score < 75) return { percent: score, level: 'good', label: 'Good' }; - return { percent: score, level: 'strong', label: 'Strong' }; -} - -window.AlchementaryLink = window.AlchementaryLink || {}; -window.AlchementaryLink.SecretPrompt = SecretPrompt; - -})(); diff --git a/frontend/src/js/settings.js b/frontend/src/js/settings.js deleted file mode 100644 index 3bf6e37..0000000 --- a/frontend/src/js/settings.js +++ /dev/null @@ -1,1276 +0,0 @@ -(function () { - /** - * Alchementary Link — settings modal. - * Renders a tabbed settings dialog for application-wide preferences, - * terminal defaults, security, known-hosts management, and about info. - */ - - const { createElement, escapeHtml, formatTimestamp, $ } = - window.AlchementaryLink; - - /** Settings tab definitions. */ - const TABS = [ - { key: "general", label: "General" }, - { key: "terminal", label: "Terminal" }, - { key: "security", label: "Security" }, - { key: "knownhosts", label: "Known Hosts" }, - { key: "help", label: "Help" }, - { key: "about", label: "About" }, - ]; - - const KONAMI_SEQUENCE = [ - "ArrowUp", - "ArrowUp", - "ArrowDown", - "ArrowDown", - "ArrowLeft", - "ArrowRight", - "ArrowLeft", - "ArrowRight", - "b", - "a", - ]; - - class Settings { - /** - * @param {App} app - */ - constructor(app) { - this.app = app; - - /** @type {HTMLElement|null} */ - this._overlay = null; - - /** @type {string} Currently active tab key. */ - this._activeTab = "general"; - - /** @type {Object} Working copy of preferences. */ - this._prefs = {}; - - /** @type {Array} Cached known hosts list. */ - this._knownHosts = []; - - this._aboutKonamiHandler = null; - this._konamiIndex = 0; - this._konamiTogglePending = false; - } - - // ── Public API ─────────────────────────────────────────────────── - - /** - * Opens the settings modal. - */ - async open(tabKey) { - this._prefs = JSON.parse(JSON.stringify(this.app.state.preferences)); - this._activeTab = tabKey || this._activeTab || "general"; - - try { - this._knownHosts = (await window.AlchementaryLink.wailsService.ListKnownHosts()) || []; - } catch (_) { - this._knownHosts = []; - } - - try { - this._defaultShell = await window.AlchementaryLink.wailsService.GetDefaultShell(); - } catch (_) { - this._defaultShell = ""; - } - - this._render(); - } - - /** - * Opens the settings modal on a specific tab. - * @param {string} tabKey - */ - async openTab(tabKey) { - this._activeTab = tabKey || "general"; - return this.open(this._activeTab); - } - - /** - * Closes the settings modal. - */ - close() { - this._detachAboutKonamiListener(); - if (this._overlay) { - this._overlay.remove(); - this._overlay = null; - } - } - - /** - * Saves the modified preferences to the backend. - */ - async save() { - this._collectValues(); - - try { - await window.AlchementaryLink.wailsService.SavePreferences(this._prefs); - this.app.state.setPreferences(this._prefs); - - // Apply terminal colour theme change immediately. - if (this.app.terminals && this._prefs.terminalTheme) { - this.app.terminals.applyTheme(this._prefs.terminalTheme); - } - if (this.app && typeof this.app._applyPreferences === "function") { - this.app._applyPreferences(); - } - - this.app.notifications.success("Settings saved"); - this.close(); - } catch (err) { - this.app.notifications.error("Failed to save settings: " + err); - } - } - - // ── Rendering ──────────────────────────────────────────────────── - - /** @private */ - _render() { - this.close(); - - const overlay = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-settings"); - - // Header - const header = createElement("div", "modal-header"); - header.innerHTML = ` - - - `; - header - .querySelector(".modal-close") - .addEventListener("click", () => this.close()); - modal.appendChild(header); - - // Tab bar - const tabBar = createElement("div", "settings-tab-bar"); - for (const tab of TABS) { - const btn = createElement( - "button", - `settings-tab ${tab.key === this._activeTab ? "active" : ""}`, - { - textContent: tab.label, - dataset: { tab: tab.key }, - }, - ); - btn.addEventListener("click", () => { - this._activeTab = tab.key; - this._renderBody(modal); - tabBar.querySelectorAll(".settings-tab").forEach((t) => { - t.classList.toggle("active", t.dataset.tab === tab.key); - }); - }); - tabBar.appendChild(btn); - } - modal.appendChild(tabBar); - - // Body (tab content) - const body = createElement("div", "modal-body settings-body"); - modal.appendChild(body); - this._renderTabContent(body); - - // Footer - const footer = createElement("div", "modal-footer"); - const cancelBtn = createElement("button", "btn btn-secondary", { - textContent: "Cancel", - }); - const saveBtn = createElement("button", "btn btn-primary", { - textContent: "Save", - }); - cancelBtn.addEventListener("click", () => this.close()); - saveBtn.addEventListener("click", () => this.save()); - footer.appendChild(cancelBtn); - footer.appendChild(saveBtn); - modal.appendChild(footer); - - overlay.appendChild(modal); - overlay.addEventListener("click", (e) => { - if (e.target === overlay) this.close(); - }); - modal.addEventListener("click", (e) => e.stopPropagation()); - document.body.appendChild(overlay); - this._overlay = overlay; - - // Trigger visibility after append so CSS transitions animate - requestAnimationFrame(() => { - overlay.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - } - - /** - * Re-renders only the body when the active tab changes. - * @param {HTMLElement} modal - * @private - */ - _renderBody(modal) { - const body = modal.querySelector(".settings-body"); - if (body) this._renderTabContent(body); - } - - /** - * Renders the content for the currently active tab. - * @param {HTMLElement} body - * @private - */ - _renderTabContent(body) { - this._detachAboutKonamiListener(); - body.innerHTML = ""; - body.classList.remove("help-layout"); - switch (this._activeTab) { - case "general": - this._renderGeneralTab(body); - break; - case "terminal": - this._renderTerminalTab(body); - break; - case "security": - this._renderSecurityTab(body); - break; - case "knownhosts": - this._renderKnownHostsTab(body); - break; - case "help": - this._renderHelpTab(body); - break; - case "about": - this._renderAboutTab(body); - break; - } - } - - // ── Tab content renderers ──────────────────────────────────────── - - /** - * General preferences tab. - * @param {HTMLElement} body - * @private - */ - _renderGeneralTab(body) { - const p = this._prefs; - body.appendChild( - this._settingField("select", "theme", "Theme", p.theme, { - options: [ - { value: "dark", label: "Dark" }, - { value: "light", label: "Light" }, - ], - }), - ); - body.appendChild( - this._settingField( - "select", - "defaultProtocol", - "Default Protocol", - p.defaultProtocol, - { - options: [ - { value: "ssh", label: "SSH" }, - { value: "telnet", label: "Telnet" }, - { value: "raw", label: "RAW" }, - ], - }, - ), - ); - body.appendChild( - this._settingField( - "toggle", - "confirmOnClose", - "Confirm on close", - p.confirmOnClose, - ), - ); - body.appendChild( - this._settingField( - "toggle", - "confirmOnDisconnect", - "Confirm on disconnect", - p.confirmOnDisconnect, - ), - ); - body.appendChild( - this._settingField( - "toggle", - "showStatusBar", - "Show status bar", - p.showStatusBar, - ), - ); - body.appendChild( - this._settingField( - "number", - "sidebarWidth", - "Sidebar width (px)", - p.sidebarWidth, - { min: 180, max: 500 }, - ), - ); - - // Local shell path with browse button - const shellGroup = createElement("div", "form-group settings-field"); - const shellLabel = createElement("label", "form-label", { - textContent: "Local shell", - for: "setting-localShell", - }); - shellGroup.appendChild(shellLabel); - - const shellRow = createElement("div", "settings-shell-row"); - const shellInput = createElement("input", "form-input", { - type: "text", - id: "setting-localShell", - name: "setting-localShell", - value: p.localShell || "", - placeholder: this._defaultShell || "/bin/zsh", - }); - shellRow.appendChild(shellInput); - - const browseBtn = createElement("button", "btn btn-sm btn-secondary", { - textContent: "Browse…", - type: "button", - }); - browseBtn.addEventListener("click", async () => { - try { - const path = await window.AlchementaryLink.wailsService.BrowseForShell(); - if (path) { - shellInput.value = path; - } - } catch (_) {} - }); - shellRow.appendChild(browseBtn); - shellGroup.appendChild(shellRow); - - const shellHint = createElement("span", "form-hint", { - textContent: "Leave empty to use system default", - }); - shellGroup.appendChild(shellHint); - body.appendChild(shellGroup); - } - - /** - * Terminal defaults tab. - * @param {HTMLElement} body - * @private - */ - _renderTerminalTab(body) { - const p = this._prefs; - - // Terminal colour theme selector - const themes = window.AlchementaryLink.listTerminalThemes(); - body.appendChild( - this._settingField( - "select", - "terminalTheme", - "Colour scheme", - p.terminalTheme || "charcoal", - { - options: themes, - }, - ), - ); - - body.appendChild( - this._settingField( - "text", - "defaultFontFamily", - "Font family", - p.defaultFontFamily, - { - placeholder: "JetBrains Mono, Menlo, Monaco, Consolas, monospace", - }, - ), - ); - body.appendChild( - this._settingField( - "number", - "defaultFontSize", - "Font size", - p.defaultFontSize, - { min: 8, max: 36 }, - ), - ); - body.appendChild( - this._settingField( - "number", - "defaultScrollback", - "Scrollback lines", - p.defaultScrollback, - { min: 0, max: 100000 }, - ), - ); - body.appendChild( - this._settingField( - "select", - "defaultCursorStyle", - "Cursor style", - p.defaultCursorStyle, - { - options: [ - { value: "block", label: "Block" }, - { value: "underline", label: "Underline" }, - { value: "bar", label: "Bar" }, - ], - }, - ), - ); - body.appendChild( - this._settingField( - "toggle", - "useNativePaste", - "Use native paste", - p.useNativePaste === true, - ), - ); - } - - /** - * Security settings tab. - * @param {HTMLElement} body - * @private - */ - _renderSecurityTab(body) { - const storeState = this.app.state.secretStoreUnlocked - ? "Unlocked" - : "Locked"; - const storeInit = this.app.state.secretStoreInitialised; - - const statusRow = createElement("div", "settings-row"); - statusRow.innerHTML = ` - Secret store - ${storeState} - `; - body.appendChild(statusRow); - - if (!storeInit) { - const initBtn = createElement("button", "btn btn-primary btn-sm", { - textContent: "Initialise Secret Store", - }); - initBtn.addEventListener("click", async () => { - try { - const pw = await this.app.secretPrompt.promptMasterPassword(true); - await window.AlchementaryLink.wailsService.InitialiseSecretStore(pw); - this.app.state.setSecretStoreInitialised(true); - this.app.state.setSecretStoreState(true); - this.app.notifications.success("Secret store created"); - this._renderTabContent(body); - } catch (err) { - if (err.message !== "User cancelled") { - this.app.notifications.error("Failed to initialise: " + err); - } - } - }); - body.appendChild(initBtn); - } else if (!this.app.state.secretStoreUnlocked) { - const unlockBtn = createElement("button", "btn btn-primary btn-sm", { - textContent: "Unlock", - }); - unlockBtn.addEventListener("click", async () => { - try { - const pw = await this.app.secretPrompt.promptMasterPassword(false); - await window.AlchementaryLink.wailsService.UnlockSecretStore(pw); - this.app.state.setSecretStoreState(true); - this.app.notifications.success("Secret store unlocked"); - this._renderTabContent(body); - } catch (err) { - if (err.message !== "User cancelled") { - this.app.notifications.error("Unlock failed: " + err); - } - } - }); - body.appendChild(unlockBtn); - } else { - const lockBtn = createElement("button", "btn btn-outline btn-sm", { - textContent: "Lock", - }); - lockBtn.addEventListener("click", async () => { - try { - await window.AlchementaryLink.wailsService.LockSecretStore(); - this.app.state.setSecretStoreState(false); - this.app.notifications.info("Secret store locked"); - this._renderTabContent(body); - } catch (err) { - this.app.notifications.error("Lock failed: " + err); - } - }); - body.appendChild(lockBtn); - } - } - - /** - * Known-hosts management tab. - * @param {HTMLElement} body - * @private - */ - _renderKnownHostsTab(body) { - if (this._knownHosts.length === 0) { - body.appendChild( - createElement("p", "text-muted", { - textContent: "No known hosts recorded.", - }), - ); - return; - } - - const table = createElement("table", "settings-table"); - table.innerHTML = ` - - - Host - Port - Key Type - Fingerprint - First Seen - - - - `; - const tbody = createElement("tbody"); - - for (const kh of this._knownHosts) { - const tr = createElement("tr"); - tr.innerHTML = ` - ${escapeHtml(kh.host)} - ${kh.port} - ${escapeHtml(kh.keyType)} - ${escapeHtml(kh.fingerprint.slice(0, 20))}… - ${formatTimestamp(kh.firstSeen)} - - `; - const deleteBtn = createElement("button", "btn btn-xs btn-danger", { - textContent: "Remove", - }); - deleteBtn.addEventListener("click", async () => { - try { - await window.AlchementaryLink.wailsService.DeleteKnownHost(kh.id); - this._knownHosts = this._knownHosts.filter((h) => h.id !== kh.id); - this._renderTabContent(body); - this.app.notifications.success("Known host removed"); - } catch (err) { - this.app.notifications.error("Failed to remove: " + err); - } - }); - tr.querySelector("td:last-child").appendChild(deleteBtn); - tbody.appendChild(tr); - } - - table.appendChild(tbody); - body.appendChild(table); - } - - /** - * In-app help and documentation tab. - * @param {HTMLElement} body - * @private - */ - _renderHelpTab(body) { - const HELP_SECTIONS = [ - { - id: "getting-started", - title: "Getting Started", - content: ` -

Welcome to Alchementary Link — a desktop application for SSH, Telnet, RAW TCP, VNC, GitHub, local terminal, and Leviathan binary-analysis workflows.

-

Creating Your First Connection

-
    -
  1. Click the + button at the top of the sidebar, or press ⌘N (Mac) / Ctrl+N (Windows/Linux).
  2. -
  3. Choose a protocol (SSH, Telnet, RAW, VNC, or GitHub).
  4. -
  5. Fill in the hostname, port, and any credentials required.
  6. -
  7. Optionally assign the connection to a group to keep your sidebar organised.
  8. -
  9. Click Save, then double-click the entry in the sidebar to connect.
  10. -
-

Leviathan Tools

-

Click Leviathan beside Alchementary Link in the sidebar header to switch from connection profiles to binary-analysis tools. Use Load to choose a local file, then open tools such as Hex Editor, Overview, Entropy, Carve, BinSnoop, Structures, and Spectrogram View.

-

Quick Connect

-

Need to connect quickly without saving a profile? Press ⌘⇧Q / Ctrl+Shift+Q to open the quick connect dialog. Choose SSH, Telnet, or RAW, enter the host and port, and you're connected immediately.

-

Managing Connections

-

Right-click any connection in the sidebar to edit, duplicate, or delete it. You can drag connections between groups, and use the search box to filter by name, host, or tag.

- `, - }, - { - id: "ssh", - title: "SSH Connections", - content: ` -

SSH (Secure Shell) is the most common way to connect to remote servers. Alchementary Link supports:

-
    -
  • Password authentication — enter your password when prompted, or save it in the encrypted vault.
  • -
  • Public key authentication — point to your private key file (e.g. ~/.ssh/id_rsa). If the key has a passphrase, you will be prompted.
  • -
  • Keyboard-interactive — for servers that use challenge-response (e.g. two-factor prompts).
  • -
  • SSH agent — tick "Use SSH Agent" to authenticate via your system's SSH agent (e.g. ssh-agent or 1Password).
  • -
-

TOTP / 2FA Auto-Fill

-

If your server requires a time-based one-time password (TOTP) during login, you can store the TOTP secret in the profile editor under the TOTP / 2FA section. When connecting, Alchementary Link will detect 2FA prompts (such as "Verification code" or "OTP") and automatically generate and submit a 6-digit code. Use the Test button to verify your secret generates valid codes before connecting.

-

Jump Hosts (Bastions)

-

If you need to reach a server through an intermediate host, add one or more jump hosts in the connection editor. Alchementary Link will tunnel through each hop automatically.

-

Port Forwarding

-

You can set up local, remote, and dynamic (SOCKS proxy) port forwards in the connection editor. These start automatically when the session connects.

-

Proxy Support

-

If you need to connect through a proxy, open the Proxy section in the profile editor. Alchementary Link supports SOCKS5 and HTTP CONNECT proxies, with optional username/password authentication. The proxy is used to reach the SSH server (or the first jump host if jump hosts are configured).

-

SFTP File Browser

-

Once connected to an SSH server, click the 📁 icon on the tab to open the SFTP file browser. You can browse directories, upload, download, rename, and delete files from a side panel.

-

SSH Agent Forwarding

-

Tick "Agent Forwarding" in the SSH settings to forward your local SSH agent to the remote server. This lets you use your local keys when connecting onward from that server.

-

Auto-Login Scripts

-

Define expect-style login scripts to automate interactions after connecting. In the Login Script section of the profile editor, add a sequence of Wait for (match text from the server) and Send (send text + newline) steps. For example:

-
    -
  1. Wait for: login:
  2. -
  3. Send: admin
  4. -
  5. Wait for: Password:
  6. -
  7. Send: mysecretpassword
  8. -
-

Each wait step has a configurable timeout (default 10 seconds). Steps execute in order after the connection is established.

-

Auto-Reconnect

-

Enable Auto-Reconnect in the Advanced section to automatically reconnect when the connection drops unexpectedly. Reconnection uses exponential backoff (1s, 2s, 4s, 8s... up to 30s). You can configure the maximum number of attempts (default 5).

- `, - }, - { - id: "telnet", - title: "Telnet Connections", - content: ` -

Telnet is an older, unencrypted protocol still used for network equipment, legacy systems, and some fun internet services.

-
    -
  • Option negotiation — Alchementary Link handles standard Telnet options (echo, terminal type, window size) automatically.
  • -
  • Character encoding — choose UTF-8, ASCII, or Latin-1 for the connection.
  • -
  • Local echo — enable this if the remote host does not echo back what you type.
  • -
  • Line mode — send data line-by-line (press Enter to send) rather than character-by-character.
  • -
  • Auto-login scripts — same expect-style send/wait sequences as SSH (see the Login Script section).
  • -
  • Auto-reconnect — automatically reconnect with exponential backoff when the connection drops.
  • -
-

Note: Telnet sends everything in plain text. Avoid using it for anything sensitive over an untrusted network.

- `, - }, - { - id: "raw", - title: "RAW TCP Connections", - content: ` -

RAW TCP lets you connect to any TCP port and exchange data directly — useful for debugging network services, testing APIs, or working with custom protocols.

-
    -
  • Text view — shows incoming data as readable text.
  • -
  • Hex view — shows raw bytes in a hex dump format.
  • -
  • Split view — text and hex side by side.
  • -
  • Payload presets — save frequently-used commands or data packets for quick sending.
  • -
  • Line endings — choose CR, LF, or CRLF to match the remote service.
  • -
  • Auto-reconnect — automatically reconnect if the connection drops.
  • -
- `, - }, - { - id: "vnc", - title: "VNC Connections", - content: ` -

VNC profiles open a remote desktop viewer inside the Link workspace.

-
    -
  • Password handling — ask each time, use the session only, or save the password in the encrypted secret store.
  • -
  • View-only mode — observe a desktop without sending mouse or keyboard input.
  • -
  • Scale-to-fit — resize the remote desktop to fit the available workspace.
  • -
  • Shared session — request a shared VNC session where the server supports it.
  • -
  • Quality — tune image quality for slower links.
  • -
  • SSH tunnel — carry VNC traffic through an SSH connection using password, public key, or SSH agent authentication.
  • -
-

Prefer SSH tunnel for VNC outside trusted networks. Direct VNC is supported for isolated or trusted networks, but many classic VNC deployments do not provide strong transport encryption.

- `, - }, - { - id: "github", - title: "GitHub Connections", - content: ` -

The GitHub connection type lets you browse your repositories without leaving Alchementary Link.

-

Setting Up

-
    -
  1. Create a new connection and choose GitHub as the protocol.
  2. -
  3. Enter your Personal Access Token (create one at GitHub → Settings → Developer settings → Personal access tokens). The token needs the repo scope.
  4. -
  5. Save and connect.
  6. -
-

What You'll See

-

Your repositories are listed grouped by organisation or owner, sorted by last commit date (newest first). The list refreshes automatically every few minutes.

- `, - }, - { - id: "leviathan", - title: "Leviathan Binary Tools", - content: ` -

Leviathan is the built-in binary-analysis and editing workbench. It is useful for firmware images, unknown binaries, archives, captures, embedded files, documents, and audio spectrogram experiments.

-

Opening and Saving Files

-

Switch to Leviathan in the sidebar header, click Load, and select a local file. The file becomes the active working document. Use Save to write the patched working copy to a destination you choose.

-

Leviathan tools open in normal tabs beside terminal and VNC sessions. They close immediately without a disconnect confirmation.

-

Hex Editor

-
    -
  • Edit byte cells directly; two hex digits commit the byte and move to the next cell.
  • -
  • The ASCII preview updates while editing.
  • -
  • Switch between 16-byte and 32-byte rows.
  • -
  • Search for strings or hex values and move through matches with previous/next.
  • -
  • Paste runs such as de ad be ef or deadbeef into consecutive cells.
  • -
  • Use Undo, Redo, and Revert from the Leviathan panel header.
  • -
-

Triage Tools

-

Overview, Hashes, Entropy, Strings, IOCs, Search, and Multi-search provide a first pass over the current document. The Entropy tool renders the whole-file graph and lets you open selected ranges in the Hex Editor.

-

Structure and Recovery Tools

-

Structures, Disasm, PDF, SquashFS, Carve, Keys, Timestamps, Stego, Metadata, BinSnoop, and Rules help identify embedded formats, files, signatures, strings, keys, and suspicious structures. Carve and BinSnoop can export discovered files or ranges.

-

Edit, Transform, and Case Tools

-

Patch Plan validates patch scripts, Patch Apply applies them, Transforms runs hex/Base64/URL/XOR/inflate/cipher pipelines, and Case Export packages investigation artefacts.

-

Spectrogram Tools

-

Spectrogram Encode turns an image into WAV audio or inserts an encoded image into an existing WAV/MP3 carrier. Spectrogram View visualises WAV or MP3 audio as a spectrogram image with controls for FFT size, height, aspect ratio, frequency range, brightness, and contrast.

-

Large Files

-

Leviathan keeps loaded files file-backed where possible and saves patched working copies in chunks. Tools that require complete image, audio, PDF, or other parser buffers enforce explicit size limits and show a clear message if an input is too large.

- `, - }, - { - id: "sessions", - title: "Sessions & Tabs", - content: ` -

Tab Management

-
    -
  • Rename — right-click a tab and choose "Rename".
  • -
  • Reorder — drag tabs left or right to rearrange.
  • -
  • Pin — right-click and pin a tab so it stays at the left of the bar.
  • -
  • Scroll — use the tab bar scrollbar or mouse wheel/trackpad over the tab strip when many tabs are open.
  • -
  • Hover hints — hover a tab to see its full name and connected-since time where available.
  • -
  • Close — click the × on the left side of a tab, or press ⌘W / Ctrl+W. Leviathan tabs close without a disconnect confirmation.
  • -
-

Split Panes

-

You can split the terminal into multiple panes:

-
    -
  • ⌘⇧D / Ctrl+Shift+D — split vertically (side by side).
  • -
  • ⌘⇧E / Ctrl+Shift+E — split horizontally (top and bottom).
  • -
-

Drag the divider between panes to resize them.

-

Broadcast Input

-

Press ⌘⇧B / Ctrl+Shift+B to toggle broadcast mode. When active, everything you type is sent to all connected sessions simultaneously — perfect for running the same command across multiple servers. The broadcast button in the tab bar pulses red to remind you it's active.

-

Session Recording

-

Press ⌘⇧R / Ctrl+Shift+R to start recording a session. A red dot will appear on the tab. Press the same shortcut again to stop. Browse and replay recordings from the ⏺ button in the sidebar.

-

Right-Click Context Menu

-

Right-click in the terminal to access a context menu with:

-
    -
  • Copy — copy selected text to the clipboard
  • -
  • Paste — paste from the clipboard into the terminal
  • -
  • Select All — select all terminal content
  • -
  • Find — open the terminal search overlay
  • -
  • Send Snippet — quickly send a saved command from the snippet library
  • -
  • Clear / Reset — clear the terminal buffer
  • -
- `, - }, - { - id: "power", - title: "Power Features", - content: ` -

Snippet Library

-

Press ⌘⇧S / Ctrl+Shift+S to open the snippet library. Save frequently-used commands with a name, description, and optional folder and tags. Click Send to Session to inject a command into the active terminal. You can also access snippets from the right-click context menu.

-

Terminal Search

-

Press ⌘F / Ctrl+F to open the search overlay on the active terminal. Features include:

-
    -
  • Aa button — toggle case-sensitive matching
  • -
  • .* button — toggle regular expression mode
  • -
  • Match count indicator showing current/total matches
  • -
  • Navigate matches with the up/down arrows or Enter / Shift+Enter
  • -
-

Quick Connect

-

Press ⌘⇧Q / Ctrl+Shift+Q to open a lightweight connect dialog. Choose SSH, Telnet, or RAW; enter the host and port; and connect immediately without creating a saved profile.

-

Auto-Login Scripts

-

For SSH and Telnet connections, you can define expect-style login scripts — a sequence of "wait for text" and "send text" steps that execute automatically after connecting. Configure them in the Login Script section of the profile editor.

-

Auto-Reconnect

-

Enable auto-reconnect in a profile's settings to automatically reconnect when a connection drops. Uses exponential backoff with a configurable maximum number of attempts.

- `, - }, - { - id: "security", - title: "Security & Credentials", - content: ` -

How Your Passwords Are Stored

-

Alchementary Link uses a two-tier approach to keep your credentials safe:

-
    -
  1. OS keychain (preferred) — your operating system's built-in credential store (macOS Keychain, Windows Credential Manager, or Linux Secret Service). If available, this is used automatically.
  2. -
  3. Encrypted vault (fallback) — if the OS keychain is not available, Alchementary Link creates a local vault encrypted with AES-256-GCM. You set a master password to unlock it.
  4. -
-

Encryption Details

-

The vault uses Argon2id to derive the encryption key from your master password (128 MiB memory, making brute-force attacks very expensive). Each secret is individually encrypted and never stored in plain text.

-

TOTP / 2FA Secrets

-

TOTP secrets for SSH two-factor authentication are stored alongside your other credentials — encrypted in the OS keychain or the file vault. They are never saved in the profile database in plain text.

-

Known Hosts

-

When you connect to an SSH server for the first time, Alchementary Link records the server's fingerprint. If the fingerprint changes on a subsequent connection, you'll be warned — this protects against man-in-the-middle attacks.

-

You can manage known hosts and change the verification policy in Settings → Known Hosts.

-

Encrypted Export

-

When exporting profiles, you can choose encrypted export (.link file) which protects the file with a password. This uses the same strong AES-256-GCM encryption as the vault.

- `, - }, - { - id: "shortcuts", - title: "Keyboard Shortcuts", - content: ` -

On macOS, use ⌘ (Cmd). On Windows and Linux, use Ctrl.

- - - - - - - - - - - - - - - - - - -
⌘KOpen the command palette
⌘NNew connection
⌘WClose the active tab
⌘TabNext tab
⌘⇧TabPrevious tab
⌘1–⌘9Switch to tab by number
⌘,Open Settings
⌘FSearch in terminal
⌘⇧BToggle broadcast input mode
⌘⇧DSplit pane vertically
⌘⇧ESplit pane horizontally
⌘⇧QQuick connect (no saved profile)
⌘⇧RToggle session recording
⌘⇧SOpen snippet library
F11Toggle fullscreen (Windows/Linux)
- `, - }, - { - id: "import-export", - title: "Import & Export", - content: ` -

Exporting Profiles

-

Click the ↗ export button in the sidebar footer. You can choose which profiles to include, and set a password to encrypt the file. Exported files use the .link extension.

-

Importing Profiles

-

Click the ↙ import button. Select a .link file and enter the password used when exporting. Imported profiles will appear in your sidebar immediately.

-

Importing from OpenSSH

-

If you have an existing ~/.ssh/config file, go to Settings → About and click Import ~/.ssh/config. Alchementary Link will create profiles from your SSH Host blocks, including hostnames, usernames, ports, identity files, and jump hosts.

- `, - }, - { - id: "troubleshooting", - title: "Troubleshooting", - content: ` -

I can't connect to my server

-
    -
  • Double-check the hostname and port — a common mistake is using port 22 for Telnet (which is usually 23) or vice versa.
  • -
  • Make sure the server is reachable from your machine. Try ping hostname from another terminal.
  • -
  • If using SSH keys, check that the key file path is correct and that the file permissions are not too open (should be 600 or 400).
  • -
  • If you're behind a firewall or VPN, ensure the relevant port is allowed through.
  • -
-

SSH connection fails through a proxy

-
    -
  • Check the proxy host, port, and type (SOCKS5 or HTTP CONNECT) are correct in the profile's Proxy section.
  • -
  • If the proxy requires authentication, make sure the username and password are filled in.
  • -
  • SOCKS5 defaults to port 1080; HTTP CONNECT defaults to port 8080.
  • -
  • Some proxies may block SSH traffic — check with your network administrator.
  • -
-

TOTP/2FA code is rejected

-
    -
  • Ensure the TOTP secret is correct (base32-encoded, as shown during authenticator app setup).
  • -
  • Check that your system clock is accurate — TOTP codes are only valid for 30-second windows.
  • -
  • Use the Test button in the profile's TOTP section to verify the secret generates valid codes.
  • -
-

Login script times out

-
    -
  • Each "wait" step has a configurable timeout (default 10 seconds). Increase it in the profile editor if the server takes longer.
  • -
  • Check that the wait pattern matches exactly — it's a plain text substring match, not a regular expression.
  • -
  • Make sure the server actually sends the expected text. Some servers may use different prompts depending on locale or configuration.
  • -
-

Auto-reconnect keeps failing

-
    -
  • Auto-reconnect only works after a successful initial connection.
  • -
  • If the server is genuinely unreachable, attempts will exhaust after the configured maximum (default 5).
  • -
  • Check the reconnection settings in the profile's Advanced section.
  • -
-

VNC will not connect

-
    -
  • Check the VNC host and port. The common port is 5900 plus the display number.
  • -
  • If a password is required, check whether the profile is set to saved, session-only, or ask-every-time mode.
  • -
  • If using an SSH tunnel, verify the tunnel host, username, authentication method, private key, and host-key policy.
  • -
  • If using direct VNC, test from a trusted network and prefer SSH tunnelling on untrusted networks.
  • -
-

The terminal looks garbled or the wrong size

-
    -
  • Try resizing the window — the terminal should re-fit automatically.
  • -
  • If you're using split panes, drag the divider to give the terminal more space.
  • -
  • Some older servers may not support modern terminal features. Try a different terminal theme in Settings → Terminal.
  • -
-

Leviathan cannot load, save, or run a tool

-
    -
  • Check that the selected file still exists and that your account can read it.
  • -
  • When saving, choose a writable destination and avoid files that are locked by another process.
  • -
  • Some tools have explicit size limits because their parsers or decoders need a complete buffer. Use a smaller file or selected range when available.
  • -
  • If SquashFS reports bad magic, the file is probably not a SquashFS image; inspect the header in Hex Editor or try Overview/BinSnoop first.
  • -
-

"Host key verification failed"

-

This means the server's fingerprint has changed since your last connection. This can happen if the server was reinstalled, but it could also indicate a security threat. If you're certain it's safe, go to Settings → Known Hosts, find the entry, and remove it. You'll be prompted to accept the new key on your next connection.

-

I forgot my vault master password

-

Unfortunately, the vault encryption is designed so that without the master password, the stored credentials cannot be recovered. You would need to delete the vault file and set up a new one. Your connection profiles (without saved passwords) will remain intact.

-

SFTP panel doesn't open

-

SFTP is only available for SSH connections (not Telnet, RAW, or GitHub). Make sure you are connected to an SSH session first, then click the 📁 icon on the tab.

-

The application looks blurry on my display

-

Make sure your system display scaling is set correctly. On macOS, Alchementary Link is fully Retina-aware. On Windows, check your DPI settings in Display preferences.

-

Export/import isn't working

-

Ensure you're using the same password to import that was used to export. The encrypted .link files cannot be opened with the wrong password.

- `, - }, - { - id: "fun", - title: "Fun Things to Try", - content: ` -

Alchementary Link isn't just for work! Here are some entertaining things you can connect to:

-

🌟 Star Wars in ASCII Art

-

Create a Telnet connection to towel.blinkenlights.nl on port 23. Sit back and enjoy the original Star Wars film retold entirely in ASCII art.

-

🗺️ MapSCII — World Map in Your Terminal

-

Connect via SSH to mapscii.me on port 22. Use the arrow keys to pan and a/z to zoom in and out of a fully interactive world map rendered in text.

-

🎮 Telnet BBS Systems

-

Connect via Telnet to particlesbbs.dyndns.org on port 23 for a retro bulletin board system experience, complete with door games, message boards, and ASCII art.

-

🐄 Fortune Cookies

-

Connect via Telnet to djxmmx.net on port 17 (the Quote of the Day protocol). You'll receive a random quote each time you connect — great for testing RAW TCP connections too.

-

🔒 Test Your SSH Setup

-

Connect via SSH to ssh.github.com on port 443 (or github.com on port 22) with your GitHub username. GitHub will greet you by name if your key is set up correctly — a quick way to verify your SSH configuration.

-

📡 RAW TCP Experiments

-

Create a RAW TCP connection to any HTTP server on port 80, then type GET / HTTP/1.0 followed by two blank lines. You'll see the raw HTTP response — a great way to understand how the web works under the hood.

-

📢 Broadcast Mode Fun

-

Open two or more sessions (e.g. to different servers) and press ⌘⇧B / Ctrl+Shift+B to turn on broadcast mode. Everything you type goes to all sessions at once — try running hostname and watch them all respond with different names!

- `, - }, - ]; - - body.innerHTML = ""; - - // Navigation sidebar - body.classList.add("help-layout"); - var nav = createElement("div", "help-nav"); - var content = createElement("div", "help-content"); - - for (var i = 0; i < HELP_SECTIONS.length; i++) { - var section = HELP_SECTIONS[i]; - var navItem = createElement("button", "help-nav-item", { - textContent: section.title, - dataset: { section: section.id }, - }); - navItem.addEventListener("mousedown", function (e) { - e.preventDefault(); - }); - (function (sec) { - navItem.addEventListener("click", function () { - nav.querySelectorAll(".help-nav-item").forEach(function (n) { - n.classList.remove("active"); - }); - this.classList.add("active"); - content.innerHTML = - '

' + - sec.title + - "

" + - sec.content; - }); - })(section); - nav.appendChild(navItem); - } - - body.appendChild(nav); - body.appendChild(content); - - // Show first section by default - if (nav.firstChild) { - nav.firstChild.classList.add("active"); - content.innerHTML = - '

' + - HELP_SECTIONS[0].title + - "

" + - HELP_SECTIONS[0].content; - } - } - - /** - * About tab showing application info. - * @param {HTMLElement} body - * @private - */ - _renderAboutTab(body) { - body.innerHTML = ` -
-

Alchementary Link

-

Version …

-

Developed by Andy Dixon - alchementary.co.uk

- -
- - -
-
-
- `; - - const versionEl = body.querySelector("[data-role='app-version']"); - if (versionEl) { - if (window.AlchementaryLink?.wailsService) { - window.AlchementaryLink.wailsService.GetAppInfo() - .then((info) => { - const version = (info && info.version) || "dev"; - versionEl.textContent = `Version ${version}`; - }) - .catch(() => { - versionEl.textContent = "Version unavailable"; - }); - } else { - versionEl.textContent = "Version unavailable"; - } - } - - const updateBtn = body.querySelector("#settings-check-update"); - if (updateBtn) { - updateBtn.addEventListener("click", async () => { - updateBtn.disabled = true; - updateBtn.textContent = "Checking…"; - try { - const info = await window.AlchementaryLink.wailsService.CheckForUpdates(); - const resultEl = body.querySelector("#settings-update-result"); - if (this.app && typeof this.app.setUpdateInfo === "function") { - this.app.setUpdateInfo(info); - } - if (info.available) { - resultEl.textContent = ""; - const updateText = createElement("span"); - updateText.style.color = "var(--accent)"; - updateText.appendChild(document.createTextNode("Update available: ")); - const versionText = createElement("strong"); - versionText.textContent = String(info.latestVersion || ""); - updateText.appendChild(versionText); - resultEl.appendChild(updateText); - if (info.releaseURL) { - resultEl.appendChild(document.createTextNode(" - ")); - const releaseLink = createElement("a", "", { - href: "#", - textContent: "View release", - }); - releaseLink.style.color = "var(--accent)"; - releaseLink.addEventListener("click", (e) => { - e.preventDefault(); - if (window.runtime && window.runtime.BrowserOpenURL) { - window.runtime.BrowserOpenURL(String(info.releaseURL)); - } - }); - resultEl.appendChild(releaseLink); - } - } else { - resultEl.textContent = "You are running the latest version."; - } - } catch (err) { - const resultEl = body.querySelector("#settings-update-result"); - resultEl.textContent = "Update check failed: " + err; - } finally { - updateBtn.disabled = false; - updateBtn.textContent = "Check for Updates"; - } - }); - } - - const importBtn = body.querySelector("#settings-import-sshconfig"); - if (importBtn) { - importBtn.addEventListener("click", async () => { - importBtn.disabled = true; - importBtn.textContent = "Importing…"; - try { - const count = await window.AlchementaryLink.wailsService.ImportSSHConfig( - "", - "SSH Config", - ); - this.app.notifications.success( - `Imported ${count} host(s) from ~/.ssh/config`, - ); - await this.app.loadProfiles(); - } catch (err) { - this.app.notifications.error("SSH config import failed: " + err); - } finally { - importBtn.disabled = false; - importBtn.textContent = "Import ~/.ssh/config"; - } - }); - } - - this._attachAboutKonamiListener(); - } - - // ── Form helpers ───────────────────────────────────────────────── - - _attachAboutKonamiListener() { - this._detachAboutKonamiListener(); - this._konamiIndex = 0; - this._aboutKonamiHandler = (event) => { - if (!this._overlay || this._activeTab !== "about") return; - if (event.ctrlKey || event.metaKey || event.altKey) { - this._konamiIndex = 0; - return; - } - - const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; - if (key === KONAMI_SEQUENCE[this._konamiIndex]) { - event.preventDefault(); - this._konamiIndex += 1; - if (this._konamiIndex === KONAMI_SEQUENCE.length) { - this._konamiIndex = 0; - void this._toggleLeviathanFromKonami(); - } - return; - } - - this._konamiIndex = key === KONAMI_SEQUENCE[0] ? 1 : 0; - }; - window.addEventListener("keydown", this._aboutKonamiHandler); - } - - _detachAboutKonamiListener() { - if (this._aboutKonamiHandler) { - window.removeEventListener("keydown", this._aboutKonamiHandler); - this._aboutKonamiHandler = null; - } - this._konamiIndex = 0; - } - - async _toggleLeviathanFromKonami() { - if (this._konamiTogglePending) return; - this._konamiTogglePending = true; - - const enabled = - this.app.state.preferences?.leviathanEnabled !== true; - const prefs = { - ...this.app.state.preferences, - leviathanEnabled: enabled, - }; - - try { - await window.AlchementaryLink.wailsService.SavePreferences(prefs); - this._prefs.leviathanEnabled = enabled; - this.app.state.setPreferences(prefs); - this.app._applyPreferences?.(); - this.app.notifications.success( - enabled ? "Leviathan enabled" : "Leviathan disabled", - ); - } catch (err) { - this.app.notifications.error( - "Failed to update Leviathan setting: " + err, - ); - } finally { - this._konamiTogglePending = false; - } - } - - /** - * Renders a single settings form field. - * @param {string} type - * @param {string} name - * @param {string} label - * @param {*} value - * @param {Object} [opts] - * @returns {HTMLElement} - * @private - */ - _settingField(type, name, label, value, opts = {}) { - const group = createElement("div", "form-group settings-field"); - const labelEl = createElement("label", "form-label", { - textContent: label, - for: `setting-${name}`, - }); - group.appendChild(labelEl); - - let input; - switch (type) { - case "text": - input = createElement("input", "form-input", { - type: "text", - id: `setting-${name}`, - name: `setting-${name}`, - value: value || "", - placeholder: opts.placeholder || "", - }); - break; - - case "number": - input = createElement("input", "form-input", { - type: "number", - id: `setting-${name}`, - name: `setting-${name}`, - value: value != null ? String(value) : "", - }); - if (opts.min != null) input.min = opts.min; - if (opts.max != null) input.max = opts.max; - break; - - case "select": - input = createElement("select", "form-select", { - id: `setting-${name}`, - name: `setting-${name}`, - }); - for (const opt of opts.options || []) { - const option = createElement("option", "", { - value: opt.value, - textContent: opt.label, - }); - if (opt.value === value) option.selected = true; - input.appendChild(option); - } - break; - - case "toggle": { - const wrapper = createElement("div", "form-toggle-wrapper"); - input = createElement("input", "form-toggle", { - type: "checkbox", - id: `setting-${name}`, - name: `setting-${name}`, - }); - input.checked = !!value; - const slider = createElement("label", "form-toggle-label", { - for: `setting-${name}`, - }); - wrapper.appendChild(input); - wrapper.appendChild(slider); - group.appendChild(wrapper); - return group; - } - - default: - input = createElement("input", "form-input", { - type: "text", - id: `setting-${name}`, - name: `setting-${name}`, - value: value || "", - }); - } - - group.appendChild(input); - return group; - } - - /** - * Reads current form field values back into the working preferences copy. - * @private - */ - _collectValues() { - if (!this._overlay) return; - - const val = (name) => { - const el = this._overlay.querySelector(`[name="setting-${name}"]`); - if (!el) return undefined; - if (el.type === "checkbox") return el.checked; - if (el.type === "number") return el.value !== "" ? Number(el.value) : 0; - return el.value; - }; - - const fields = [ - "theme", - "terminalTheme", - "defaultProtocol", - "confirmOnClose", - "confirmOnDisconnect", - "showStatusBar", - "sidebarWidth", - "defaultFontFamily", - "defaultFontSize", - "defaultScrollback", - "defaultCursorStyle", - "useNativePaste", - "localShell", - ]; - - for (const field of fields) { - const v = val(field); - if (v !== undefined) { - this._prefs[field] = v; - } - } - } - } - - window.AlchementaryLink = window.AlchementaryLink || {}; - window.AlchementaryLink.Settings = Settings; -})(); diff --git a/frontend/src/js/sftp.js b/frontend/src/js/sftp.js deleted file mode 100644 index 8d370d7..0000000 --- a/frontend/src/js/sftp.js +++ /dev/null @@ -1,1247 +0,0 @@ -(function () { - /** - * Alchementary Link — SFTP File Browser - * - * Provides a right-side panel for browsing, uploading, downloading, - * renaming and deleting remote files alongside the terminal. - */ - - const { - createElement, - escapeHtml, - formatBytes, - relativeTime, - dismissContextMenus, - } = window.AlchementaryLink; - - /* ── Helpers ──────────────────────────────────────────────────────── */ - - function pathJoin(base, name) { - if (base === "/") return "/" + name; - return base.replace(/\/+$/, "") + "/" + name; - } - - function pathSegments(p) { - const parts = p.split("/").filter(Boolean); - var segs = [{ label: "/", path: "/" }]; - var acc = ""; - for (var i = 0; i < parts.length; i++) { - acc += "/" + parts[i]; - segs.push({ label: parts[i], path: acc }); - } - return segs; - } - - function parentPath(p) { - if (p === "/") return "/"; - var trimmed = p.replace(/\/+$/, ""); - var idx = trimmed.lastIndexOf("/"); - return idx <= 0 ? "/" : trimmed.substring(0, idx); - } - - function fileIcon(entry) { - if (entry.isDir) return "📁"; - var ext = (entry.name.split(".").pop() || "").toLowerCase(); - switch (ext) { - case "jpg": - case "jpeg": - case "png": - case "gif": - case "svg": - case "webp": - return "🖼"; - case "mp3": - case "wav": - case "ogg": - case "flac": - return "🎵"; - case "mp4": - case "avi": - case "mkv": - case "mov": - return "🎬"; - case "zip": - case "tar": - case "gz": - case "bz2": - case "xz": - case "7z": - return "📦"; - case "sh": - case "bash": - case "zsh": - return "⚙"; - case "py": - case "js": - case "ts": - case "go": - case "rs": - case "c": - case "h": - case "cpp": - case "java": - return "◇"; - case "md": - case "txt": - case "log": - case "csv": - return "📝"; - case "json": - case "yaml": - case "yml": - case "toml": - case "xml": - return "⚙"; - case "key": - case "pem": - case "crt": - case "pub": - return "🔑"; - default: - return "📄"; - } - } - - function modeString(mode) { - if (!mode && mode !== 0) return ""; - var s = ""; - var perms = ["---", "--x", "-w-", "-wx", "r--", "r-x", "rw-", "rwx"]; - s += perms[(mode >> 6) & 7]; - s += perms[(mode >> 3) & 7]; - s += perms[mode & 7]; - return s; - } - - /* ── SFTPBrowser class ────────────────────────────────────────────── */ - - class SFTPBrowser { - /** - * @param {App} app — the application instance - */ - constructor(app) { - this.app = app; - /** @type {Map} */ - this.panels = new Map(); - } - - /* ── Public API ───────────────────────────────────────────────── */ - - /** - * Opens (or focuses) the SFTP panel for a session. - * @param {string} sessionId - */ - async open(sessionId) { - if (this.panels.has(sessionId)) return; - - var sessionPanel = document.getElementById("panel-" + sessionId); - if (!sessionPanel) { - this.app.notifications.error( - "Cannot open file browser: session panel not found.", - ); - return; - } - - var state = { - el: null, - handle: null, - cwd: "/", - files: [], - selected: null, - loading: false, - }; - - // Build the panel DOM - var panel = createElement("div", "sftp-panel"); - var handle = createElement("div", "sftp-resize-handle"); - panel.appendChild(handle); - state.el = panel; - state.handle = handle; - - // Toolbar - var toolbar = this._buildToolbar(sessionId, state); - panel.appendChild(toolbar); - - // File list container - var fileList = createElement("div", "sftp-file-list"); - panel.appendChild(fileList); - - // Context action bar (hidden initially) - var ctxBar = createElement("div", "sftp-context-bar"); - ctxBar.style.display = "none"; - panel.appendChild(ctxBar); - - this.panels.set(sessionId, state); - - // Append the overlay panel into the session panel - sessionPanel.appendChild(panel); - - this._setupResize(sessionId, state); - - // Fetch initial directory - await this._navigate(sessionId, null); - - // Refit terminal after layout change - this._refitTerminal(sessionId); - } - - /** - * Closes the SFTP panel for a session. - * @param {string} sessionId - */ - close(sessionId) { - var state = this.panels.get(sessionId); - if (!state) return; - - if (state.cleanupResize) state.cleanupResize(); - state.el.remove(); - - this.panels.delete(sessionId); - this._refitTerminal(sessionId); - } - - /** - * Toggles the SFTP panel for a session. - * @param {string} sessionId - */ - toggle(sessionId) { - if (this.panels.has(sessionId)) { - this.close(sessionId); - } else { - this.open(sessionId); - } - } - - /** - * Returns the current directory for an open SFTP panel, or an empty string. - * @param {string} sessionId - * @returns {string} - */ - getCwd(sessionId) { - var state = this.panels.get(sessionId); - return state ? state.cwd : ""; - } - - /* ── Toolbar ──────────────────────────────────────────────────── */ - - _buildToolbar(sessionId, state) { - var toolbar = createElement("div", "sftp-toolbar"); - - var breadcrumb = createElement("div", "sftp-breadcrumb"); - breadcrumb.dataset.role = "breadcrumb"; - toolbar.appendChild(breadcrumb); - - var actions = createElement("div", "sftp-toolbar-actions"); - - var goToBtn = this._toolbarBtn("⤳", "Go to path", ""); - goToBtn.addEventListener("click", () => this._promptGoToPath(sessionId)); - actions.appendChild(goToBtn); - - var bookmarkBtn = this._toolbarBtn("★", "Bookmarks", ""); - bookmarkBtn.addEventListener("click", () => - this._showBookmarks(sessionId), - ); - actions.appendChild(bookmarkBtn); - - var mkdirBtn = this._toolbarBtn("+", "New folder", "is-accent"); - mkdirBtn.addEventListener("click", () => this._promptMkdir(sessionId)); - actions.appendChild(mkdirBtn); - - var uploadBtn = this._toolbarBtn("↑", "Upload file", "is-accent"); - uploadBtn.addEventListener("click", () => this._upload(sessionId)); - actions.appendChild(uploadBtn); - - var refreshBtn = this._toolbarBtn("⟳", "Refresh"); - refreshBtn.addEventListener("click", () => this._refresh(sessionId)); - actions.appendChild(refreshBtn); - - var closeBtn = this._toolbarBtn("✕", "Close", "is-close"); - closeBtn.addEventListener("click", () => this.close(sessionId)); - actions.appendChild(closeBtn); - - toolbar.appendChild(actions); - return toolbar; - } - - _toolbarBtn(label, title, extraClass) { - var cls = "sftp-toolbar-btn" + (extraClass ? " " + extraClass : ""); - return createElement("button", cls, { textContent: label, title: title }); - } - - /* ── Navigation ───────────────────────────────────────────────── */ - - /** - * Navigates to a directory path. If path is null, fetches pwd first. - */ - async _navigate(sessionId, path) { - var state = this.panels.get(sessionId); - if (!state || state.loading) return; - - state.loading = true; - state.selected = null; - this._renderLoading(sessionId); - - try { - if (path === null) { - path = await window.AlchementaryLink.wailsService.SFTPPwd(sessionId); - } - var files = await window.AlchementaryLink.wailsService.SFTPList(sessionId, path); - state.cwd = path; - state.files = this._sortFiles(files || []); - state.loading = false; - this._renderBreadcrumb(sessionId); - this._renderFiles(sessionId); - } catch (err) { - state.loading = false; - this._renderError(sessionId, err.message || String(err)); - } - } - - async _refresh(sessionId) { - var state = this.panels.get(sessionId); - if (!state) return; - await this._navigate(sessionId, state.cwd); - } - - _sortFiles(files) { - return files.slice().sort(function (a, b) { - // Directories first - if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; - return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); - }); - } - - /* ── Rendering ────────────────────────────────────────────────── */ - - _getFileList(sessionId) { - var state = this.panels.get(sessionId); - if (!state) return null; - return state.el.querySelector(".sftp-file-list"); - } - - _getCtxBar(sessionId) { - var state = this.panels.get(sessionId); - if (!state) return null; - return state.el.querySelector(".sftp-context-bar"); - } - - _renderBreadcrumb(sessionId) { - var state = this.panels.get(sessionId); - if (!state) return; - var container = state.el.querySelector('[data-role="breadcrumb"]'); - container.innerHTML = ""; - - var segs = pathSegments(state.cwd); - var self = this; - for (var i = 0; i < segs.length; i++) { - if (i > 0) { - container.appendChild( - createElement("span", "sftp-breadcrumb-sep", { textContent: "›" }), - ); - } - var btn = createElement("button", "sftp-breadcrumb-segment", { - textContent: segs[i].label, - title: segs[i].path, - }); - (function (p) { - btn.addEventListener("click", function () { - self._navigate(sessionId, p); - }); - })(segs[i].path); - container.appendChild(btn); - } - } - - _renderFiles(sessionId) { - var state = this.panels.get(sessionId); - if (!state) return; - var list = this._getFileList(sessionId); - list.innerHTML = ""; - - // Hide context bar - var ctxBar = this._getCtxBar(sessionId); - if (ctxBar) ctxBar.style.display = "none"; - - // Parent directory row if not at root - if (state.cwd !== "/") { - var parentRow = this._buildFileRow( - { - name: "..", - isDir: true, - path: parentPath(state.cwd), - size: 0, - modifiedAt: 0, - }, - sessionId, - true, - ); - parentRow.classList.add("is-parent"); - list.appendChild(parentRow); - } - - if (state.files.length === 0) { - var empty = createElement("div", "sftp-state"); - empty.innerHTML = - '
📂
Empty directory
'; - list.appendChild(empty); - return; - } - - var self = this; - for (var i = 0; i < state.files.length; i++) { - list.appendChild(this._buildFileRow(state.files[i], sessionId, false)); - } - } - - _buildFileRow(entry, sessionId, isParent) { - var row = createElement("div", "sftp-file-row"); - var self = this; - - // Icon - var icon = createElement( - "span", - "sftp-file-icon" + (entry.isDir ? " is-dir" : ""), - ); - icon.textContent = isParent ? "↩" : fileIcon(entry); - row.appendChild(icon); - - // Name - var name = createElement("span", "sftp-file-name", { - textContent: entry.name, - title: entry.name, - }); - row.appendChild(name); - - // Meta (size or date) - var meta = createElement("span", "sftp-file-meta"); - if (!isParent && !entry.isDir) { - meta.textContent = formatBytes(entry.size || 0); - meta.title = entry.modifiedAt - ? new Date(entry.modifiedAt).toLocaleString() - : ""; - } else if (!isParent && entry.isDir) { - meta.textContent = ""; - } - row.appendChild(meta); - - // Double-click: navigate into directory - if (entry.isDir) { - row.addEventListener("dblclick", function () { - var target = isParent - ? parentPath(self.panels.get(sessionId).cwd) - : entry.path; - self._navigate(sessionId, target); - }); - } - - // Single click: select (files only, or directories too for context actions) - if (!isParent) { - row.addEventListener("click", function (e) { - e.stopPropagation(); - self._selectFile(sessionId, entry, row, e); - }); - } - - return row; - } - - _renderLoading(sessionId) { - var list = this._getFileList(sessionId); - if (!list) return; - list.innerHTML = ""; - var s = createElement("div", "sftp-state"); - s.innerHTML = - '
Loading…
'; - list.appendChild(s); - var ctxBar = this._getCtxBar(sessionId); - if (ctxBar) ctxBar.style.display = "none"; - } - - _renderError(sessionId, msg) { - var list = this._getFileList(sessionId); - if (!list) return; - list.innerHTML = ""; - var s = createElement("div", "sftp-state"); - s.innerHTML = - '
⚠
' + - '
' + - escapeHtml(msg) + - "
" + - ''; - var self = this; - var retryBtn = s.querySelector(".sftp-state-retry"); - if (retryBtn) { - retryBtn.addEventListener("click", function () { - self._refresh(sessionId); - }); - } - list.appendChild(s); - } - - /* ── Selection & context actions ──────────────────────────────── */ - - _selectFile(sessionId, entry, rowEl, event) { - var state = this.panels.get(sessionId); - if (!state) return; - - // Initialise multi-select set if needed - if (!state.selectedFiles) state.selectedFiles = new Map(); - - var multiKey = event && (event.metaKey || event.ctrlKey); - - if (multiKey) { - // Toggle this entry in the multi-selection - if (state.selectedFiles.has(entry.path)) { - state.selectedFiles.delete(entry.path); - rowEl.classList.remove("is-selected"); - } else { - state.selectedFiles.set(entry.path, entry); - rowEl.classList.add("is-selected"); - } - } else { - // Single click without modifier — clear all, select this one - state.el - .querySelectorAll(".sftp-file-row.is-selected") - .forEach(function (r) { - r.classList.remove("is-selected"); - }); - - // If clicking the same entry (and it was the only selection), deselect - if ( - state.selectedFiles.size === 1 && - state.selectedFiles.has(entry.path) - ) { - state.selectedFiles.clear(); - state.selected = null; - var ctxBar = this._getCtxBar(sessionId); - if (ctxBar) ctxBar.style.display = "none"; - return; - } - - state.selectedFiles.clear(); - state.selectedFiles.set(entry.path, entry); - rowEl.classList.add("is-selected"); - } - - state.selected = state.selectedFiles.size > 0 ? entry.path : null; - this._showContextBar(sessionId, null); - } - - _showContextBar(sessionId, _unused) { - var state = this.panels.get(sessionId); - if (!state) return; - var ctxBar = this._getCtxBar(sessionId); - if (!ctxBar) return; - - if (!state.selectedFiles || state.selectedFiles.size === 0) { - ctxBar.style.display = "none"; - return; - } - - ctxBar.innerHTML = ""; - ctxBar.style.display = "flex"; - - var self = this; - var entries = Array.from(state.selectedFiles.values()); - var count = entries.length; - var isSingle = count === 1; - var entry = entries[0]; - - var label = createElement("span", "sftp-ctx-label", { - textContent: isSingle ? entry.name : count + " items selected", - title: isSingle - ? entry.path - : entries - .map(function (e) { - return e.name; - }) - .join(", "), - }); - ctxBar.appendChild(label); - - // Download button — only for files (single or batch) - var downloadableFiles = entries.filter(function (e) { - return !e.isDir; - }); - if (downloadableFiles.length > 0) { - var dlLabel = - downloadableFiles.length === 1 - ? "↓ Download" - : "↓ Download (" + downloadableFiles.length + ")"; - var dlBtn = createElement("button", "sftp-ctx-btn is-accent", { - textContent: dlLabel, - }); - dlBtn.addEventListener("click", function () { - self._downloadBatch(sessionId, downloadableFiles); - }); - ctxBar.appendChild(dlBtn); - } - - if (isSingle) { - var renameBtn = createElement("button", "sftp-ctx-btn", { - textContent: "✎ Rename", - }); - renameBtn.addEventListener("click", function () { - self._promptRename(sessionId, entry); - }); - ctxBar.appendChild(renameBtn); - } - - var delLabel = isSingle ? "✕ Delete" : "✕ Delete (" + count + ")"; - var delBtn = createElement("button", "sftp-ctx-btn is-danger", { - textContent: delLabel, - }); - delBtn.addEventListener("click", function () { - if (isSingle) { - self._confirmDelete(sessionId, entry); - } else { - self._confirmDeleteBatch(sessionId, entries); - } - }); - ctxBar.appendChild(delBtn); - } - - /* ── File operations ──────────────────────────────────────────── */ - - async _download(sessionId, filePath) { - try { - await window.AlchementaryLink.wailsService.SFTPDownload(sessionId, filePath); - this.app.notifications.success("Download started"); - } catch (err) { - this.app.notifications.error( - "Download failed: " + (err.message || err), - ); - } - } - - async _upload(sessionId) { - var state = this.panels.get(sessionId); - if (!state) return; - try { - await window.AlchementaryLink.wailsService.SFTPUpload(sessionId, state.cwd); - this.app.notifications.success("Upload complete"); - await this._refresh(sessionId); - } catch (err) { - // User may have cancelled the dialog — only show error if meaningful - var msg = err.message || String(err); - if (msg && msg.toLowerCase().indexOf("cancel") === -1) { - this.app.notifications.error("Upload failed: " + msg); - } - } - } - - async _mkdir(sessionId, name) { - var state = this.panels.get(sessionId); - if (!state) return; - var fullPath = pathJoin(state.cwd, name); - try { - await window.AlchementaryLink.wailsService.SFTPMkdir(sessionId, fullPath); - this.app.notifications.success("Directory created"); - await this._refresh(sessionId); - } catch (err) { - this.app.notifications.error("mkdir failed: " + (err.message || err)); - } - } - - async _rename(sessionId, oldPath, newName) { - var state = this.panels.get(sessionId); - if (!state) return; - var newPath = pathJoin(parentPath(oldPath), newName); - try { - await window.AlchementaryLink.wailsService.SFTPRename(sessionId, oldPath, newPath); - this.app.notifications.success("Renamed"); - await this._refresh(sessionId); - } catch (err) { - this.app.notifications.error("Rename failed: " + (err.message || err)); - } - } - - async _delete(sessionId, entry) { - try { - await window.AlchementaryLink.wailsService.SFTPRemove(sessionId, entry.path); - this.app.notifications.success( - (entry.isDir ? "Directory" : "File") + " deleted", - ); - await this._refresh(sessionId); - } catch (err) { - this.app.notifications.error("Delete failed: " + (err.message || err)); - } - } - - async _downloadBatch(sessionId, files) { - this.app.notifications.info("Downloading " + files.length + " files…"); - var failed = 0; - for (var i = 0; i < files.length; i++) { - try { - await window.AlchementaryLink.wailsService.SFTPDownload(sessionId, files[i].path); - } catch (err) { - failed++; - this.app.notifications.error( - "Download failed: " + files[i].name + " — " + (err.message || err), - ); - } - } - if (failed === 0) { - this.app.notifications.success( - "All " + files.length + " files downloaded", - ); - } - } - - _confirmDeleteBatch(sessionId, entries) { - this._showConfirmModal({ - title: "Delete " + entries.length + " Items", - message: - "Permanently delete " + - entries.length + - " selected items?", - confirmLabel: "Delete All", - isDanger: true, - onConfirm: function () { - this._deleteBatch(sessionId, entries); - }.bind(this), - }); - } - - async _deleteBatch(sessionId, entries) { - var failed = 0; - for (var i = 0; i < entries.length; i++) { - try { - await window.AlchementaryLink.wailsService.SFTPRemove(sessionId, entries[i].path); - } catch (err) { - failed++; - this.app.notifications.error( - "Delete failed: " + entries[i].name + " — " + (err.message || err), - ); - } - } - if (failed === 0) { - this.app.notifications.success("Deleted " + entries.length + " items"); - } - await this._refresh(sessionId); - } - - /* ── Modal dialogs (no prompt/confirm/alert) ──────────────────── */ - - _promptMkdir(sessionId) { - this._showInputModal({ - title: "New Folder", - placeholder: "Folder name", - confirmLabel: "Create", - onConfirm: function (value) { - if (value.trim()) this._mkdir(sessionId, value.trim()); - }.bind(this), - }); - } - - _promptRename(sessionId, entry) { - this._showInputModal({ - title: "Rename", - placeholder: "New name", - initialValue: entry.name, - confirmLabel: "Rename", - onConfirm: function (value) { - var v = value.trim(); - if (v && v !== entry.name) this._rename(sessionId, entry.path, v); - }.bind(this), - }); - } - - _confirmDelete(sessionId, entry) { - this._showConfirmModal({ - title: "Delete " + (entry.isDir ? "Folder" : "File"), - message: - "Permanently delete " + escapeHtml(entry.name) + "?", - confirmLabel: "Delete", - isDanger: true, - onConfirm: function () { - this._delete(sessionId, entry); - }.bind(this), - }); - } - - /** - * Displays a modal with a text input field. - */ - _showInputModal(opts) { - var backdrop = createElement("div", "modal-backdrop"); - var modal = createElement("div", "modal modal-sm"); - modal.innerHTML = - '" + - '" + - '"; - - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - - var input = modal.querySelector(".sftp-inline-input"); - requestAnimationFrame(function () { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - input.focus(); - input.select(); - }); - - var closed = false; - function close(confirmed) { - if (closed) return; - closed = true; - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(function () { - backdrop.remove(); - }, 200); - if (confirmed && opts.onConfirm) { - opts.onConfirm(input.value); - } - } - - modal - .querySelector(".modal-close") - .addEventListener("click", function () { - close(false); - }); - modal - .querySelector(".sftp-modal-cancel") - .addEventListener("click", function () { - close(false); - }); - modal - .querySelector(".sftp-modal-confirm") - .addEventListener("click", function () { - close(true); - }); - backdrop.addEventListener("click", function (e) { - if (e.target === backdrop) close(false); - }); - input.addEventListener("keydown", function (e) { - if (e.key === "Enter") close(true); - if (e.key === "Escape") close(false); - }); - } - - /** - * Displays a confirmation modal (no native confirm). - */ - _showConfirmModal(opts) { - var backdrop = createElement("div", "modal-backdrop"); - var modal = createElement("div", "modal modal-sm confirm-dialog"); - modal.innerHTML = - '" + - '" + - '"; - - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - - requestAnimationFrame(function () { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - - var closed = false; - function close(confirmed) { - if (closed) return; - closed = true; - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(function () { - backdrop.remove(); - }, 200); - if (confirmed && opts.onConfirm) { - opts.onConfirm(); - } - } - - modal - .querySelector(".modal-close") - .addEventListener("click", function () { - close(false); - }); - modal - .querySelector(".sftp-modal-cancel") - .addEventListener("click", function () { - close(false); - }); - modal - .querySelector(".sftp-modal-confirm") - .addEventListener("click", function () { - close(true); - }); - backdrop.addEventListener("click", function (e) { - if (e.target === backdrop) close(false); - }); - } - - /* ── Go to path & Bookmarks ───────────────────────────────────── */ - - _promptGoToPath(sessionId) { - var state = this.panels.get(sessionId); - if (!state) return; - this._showInputModal({ - title: "Go to Path", - placeholder: "/path/to/directory", - initialValue: state.cwd, - confirmLabel: "Navigate", - onConfirm: function (value) { - var path = value.trim(); - if (path) this._navigate(sessionId, path); - }.bind(this), - }); - } - - _showBookmarks(sessionId) { - var state = this.panels.get(sessionId); - if (!state) return; - - var self = this; - var bookmarks = this._getBookmarks(sessionId); - - var backdrop = createElement("div", "modal-backdrop"); - var modal = createElement("div", "modal modal-sm"); - - var listHtml = ""; - if (bookmarks.length === 0) { - listHtml = - '
No bookmarks yet. Navigate to a directory and click "Bookmark Current".
'; - } else { - for (var i = 0; i < bookmarks.length; i++) { - listHtml += - '
'; - listHtml += - '' + - escapeHtml(bookmarks[i]) + - ""; - listHtml += - ''; - listHtml += "
"; - } - } - - modal.innerHTML = - '" + - '" + - '"; - - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - - requestAnimationFrame(function () { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - - var closed = false; - function close() { - if (closed) return; - closed = true; - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(function () { - backdrop.remove(); - }, 200); - } - - modal.querySelector(".modal-close").addEventListener("click", close); - modal - .querySelector(".sftp-modal-cancel") - .addEventListener("click", close); - backdrop.addEventListener("click", function (e) { - if (e.target === backdrop) close(); - }); - - // Navigate to bookmark on click - modal.querySelectorAll(".sftp-bookmark-row").forEach(function (row) { - row.addEventListener("click", function (e) { - if (e.target.classList.contains("sftp-bookmark-del")) return; - var path = row.dataset.path; - close(); - self._navigate(sessionId, path); - }); - row.addEventListener("mousedown", function (e) { - e.preventDefault(); - }); - }); - - // Delete bookmark - modal.querySelectorAll(".sftp-bookmark-del").forEach(function (btn) { - btn.addEventListener("click", function (e) { - e.stopPropagation(); - var idx = parseInt(btn.dataset.index, 10); - self._removeBookmark(sessionId, idx); - close(); - self._showBookmarks(sessionId); - }); - }); - - // Add current directory as bookmark - modal - .querySelector(".sftp-bookmark-add") - .addEventListener("click", function () { - self._addBookmark(sessionId, state.cwd); - close(); - self._showBookmarks(sessionId); - }); - } - - _getBookmarks(sessionId) { - try { - var key = "sftp-bookmarks-" + sessionId; - var stored = localStorage.getItem(key); - return stored ? JSON.parse(stored) : []; - } catch (_) { - return []; - } - } - - _addBookmark(sessionId, path) { - var bookmarks = this._getBookmarks(sessionId); - if (bookmarks.indexOf(path) === -1) { - bookmarks.push(path); - localStorage.setItem( - "sftp-bookmarks-" + sessionId, - JSON.stringify(bookmarks), - ); - this.app.notifications.success("Bookmarked: " + path); - } else { - this.app.notifications.info("Already bookmarked"); - } - } - - _removeBookmark(sessionId, index) { - var bookmarks = this._getBookmarks(sessionId); - bookmarks.splice(index, 1); - localStorage.setItem( - "sftp-bookmarks-" + sessionId, - JSON.stringify(bookmarks), - ); - } - - /* ── Transfer progress queue ─────────────────────────────────── */ - - /** - * Handles a transfer progress event from the backend. - * @param {Object} payload - { sessionID, direction, transferred, total, path, done } - */ - handleTransferProgress(payload) { - var state = this.panels.get(payload.sessionID); - if (!state) { - // Panel not open — still track so we can show a notification on completion - if (payload.done) { - var dir = - payload.direction === "upload" ? "↑ Uploaded" : "↓ Downloaded"; - var name = payload.path.split("/").pop() || payload.path; - this.app.notifications.success(dir + " " + name); - } - return; - } - - // Initialise transfers map if needed - if (!state.transfers) state.transfers = new Map(); - - var key = payload.direction + ":" + payload.path; - - if (payload.done) { - state.transfers.delete(key); - this._renderTransferQueue(payload.sessionID); - // Refresh file list after upload completes - if (payload.direction === "upload") { - this._refresh(payload.sessionID); - } - return; - } - - state.transfers.set(key, { - direction: payload.direction, - path: payload.path, - name: payload.path.split("/").pop() || payload.path, - transferred: payload.transferred, - total: payload.total, - }); - - this._renderTransferQueue(payload.sessionID); - } - - /** - * Renders the transfer queue panel showing active transfers. - */ - _renderTransferQueue(sessionId) { - var state = this.panels.get(sessionId); - if (!state) return; - - var queue = state.el.querySelector(".sftp-transfer-queue"); - - if (!state.transfers || state.transfers.size === 0) { - if (queue) queue.remove(); - return; - } - - if (!queue) { - queue = createElement("div", "sftp-transfer-queue"); - // Insert between toolbar and file-list - var fileList = state.el.querySelector(".sftp-file-list"); - state.el.insertBefore(queue, fileList); - } - - queue.innerHTML = ""; - - var header = createElement("div", "sftp-transfer-header"); - header.textContent = "Transfers (" + state.transfers.size + ")"; - queue.appendChild(header); - - var self = this; - state.transfers.forEach(function (transfer) { - var row = createElement("div", "sftp-transfer-row"); - - var icon = createElement("span", "sftp-transfer-icon"); - icon.textContent = transfer.direction === "upload" ? "↑" : "↓"; - row.appendChild(icon); - - var info = createElement("div", "sftp-transfer-info"); - - var nameEl = createElement("span", "sftp-transfer-name", { - textContent: transfer.name, - title: transfer.path, - }); - info.appendChild(nameEl); - - var progress = createElement("div", "sftp-transfer-progress"); - var bar = createElement("div", "sftp-transfer-bar"); - var pct = - transfer.total > 0 - ? Math.min( - 100, - Math.round((transfer.transferred / transfer.total) * 100), - ) - : 0; - bar.style.width = pct + "%"; - progress.appendChild(bar); - info.appendChild(progress); - - var meta = createElement("span", "sftp-transfer-meta"); - var transferredStr = formatBytes(transfer.transferred); - var totalStr = transfer.total > 0 ? formatBytes(transfer.total) : "?"; - meta.textContent = - transferredStr + " / " + totalStr + " (" + pct + "%)"; - info.appendChild(meta); - - row.appendChild(info); - queue.appendChild(row); - }); - } - - /* ── Resize handle ────────────────────────────────────────────── */ - - _setupResize(sessionId, state) { - var handle = state.handle; - var panel = state.el; - var self = this; - var startX, startWidth; - - function onMouseMove(e) { - var delta = startX - e.clientX; - var newWidth = Math.max( - 180, - Math.min(startWidth + delta, window.innerWidth * 0.5), - ); - panel.style.width = newWidth + "px"; - } - - function onMouseUp() { - cleanupResize(); - self._refitTerminal(sessionId); - } - - function cleanupResize() { - handle.classList.remove("is-dragging"); - document.body.style.userSelect = ""; - document.body.style.cursor = ""; - document.removeEventListener("mousemove", onMouseMove); - document.removeEventListener("mouseup", onMouseUp); - } - - state.cleanupResize = cleanupResize; - - handle.addEventListener("mousedown", function (e) { - e.preventDefault(); - startX = e.clientX; - startWidth = panel.offsetWidth; - handle.classList.add("is-dragging"); - document.body.style.userSelect = "none"; - document.body.style.cursor = "col-resize"; - document.addEventListener("mousemove", onMouseMove); - document.addEventListener("mouseup", onMouseUp); - }); - } - - /* ── Terminal refit ────────────────────────────────────────────── */ - - _refitTerminal(sessionId) { - if ( - this.app.terminals && - typeof this.app.terminals.fitSession === "function" - ) { - // Small delay lets the layout settle before measuring - setTimeout( - function () { - this.app.terminals.fitSession(sessionId); - }.bind(this), - 50, - ); - } - } - } - - /* ── Export ────────────────────────────────────────────────────────── */ - window.AlchementaryLink = window.AlchementaryLink || {}; - window.AlchementaryLink.SFTPBrowser = SFTPBrowser; -})(); diff --git a/frontend/src/js/sidebar.js b/frontend/src/js/sidebar.js deleted file mode 100644 index 03739d0..0000000 --- a/frontend/src/js/sidebar.js +++ /dev/null @@ -1,939 +0,0 @@ -(function () { - /** - * Alchementary Link — sidebar module. - * Renders the profile list with search, sections (favourites, recent, all), - * and context menus. - */ - - const { - createElement, - escapeHtml, - fuzzyMatch, - protocolColour, - dismissContextMenus, - truncate, - } = window.AlchementaryLink; - - /** Maximum number of profiles shown in the "Recent" section. */ - const RECENT_LIMIT = 5; - - class Sidebar { - /** - * @param {App} app - */ - constructor(app) { - this.app = app; - this.el = document.getElementById("sidebar-profiles"); - this.searchQuery = ""; - - /** @type {Set} */ - this.selectedProfiles = new Set(); - - // Section collapse state - this.collapsedSections = new Set(); - - // Dismiss context menus on any outside click - document.addEventListener("click", () => dismissContextMenus()); - - if (this.el) { - this.el.addEventListener("click", (e) => { - if ( - !e.target.closest(".sidebar-item") && - this.selectedProfiles.size > 0 - ) { - this._clearSelection(); - } - }); - } - } - - // ── Public API ─────────────────────────────────────────────────── - - /** - * Full re-render of the sidebar profile list. - * Profiles are organised into: - * 1. Favourites (if any) - * 2. Recent (last 5 connected) - * 3. One section per group (sorted alphabetically) - * 4. Ungrouped (profiles with no group assigned) - */ - render() { - if (!this.el) return; - if (this.app.mode === "leviathan") { - this.app.leviathan.renderSidebar(); - return; - } - this.el.innerHTML = ""; - - const profiles = this._filteredProfiles(); - this._syncSelection(profiles); - - if (this.selectedProfiles.size > 0) { - this.el.appendChild(this._renderBulkActions()); - } - - // Local Machine quick-connect button - this.el.appendChild(this._renderLocalMachineItem()); - - // Favourites - const favourites = profiles.filter((p) => p.favourite); - if (favourites.length > 0) { - this._renderSection("★ Favourites", "favourites", favourites); - } - - // Recent - const recent = profiles - .filter((p) => p.lastConnected) - .sort((a, b) => new Date(b.lastConnected) - new Date(a.lastConnected)) - .slice(0, RECENT_LIMIT); - if (recent.length > 0) { - this._renderSection("Recent", "recent", recent); - } - - // ── Group-based sections ───────────────────────────────────── - // Collect distinct groups and sort them alphabetically. - const groupMap = new Map(); - const ungrouped = []; - - for (const p of profiles) { - const grp = (p.folder || "").trim(); - if (grp) { - if (!groupMap.has(grp)) groupMap.set(grp, []); - groupMap.get(grp).push(p); - } else { - ungrouped.push(p); - } - } - - // Render each group as its own collapsible section - const sortedGroups = [...groupMap.keys()].sort((a, b) => - a.localeCompare(b), - ); - for (const grp of sortedGroups) { - this._renderSection(grp, `group:${grp}`, groupMap.get(grp)); - } - - // Ungrouped connections - if (ungrouped.length > 0) { - const label = sortedGroups.length > 0 ? "Ungrouped" : "All Connections"; - this._renderSection(label, "ungrouped", ungrouped); - } - - // Empty state - if (profiles.length === 0) { - this.el.appendChild(this._renderEmptyState()); - } - } - - /** - * Filters the displayed profiles by query. - * @param {string} query - */ - search(query) { - this.searchQuery = query.trim(); - this.render(); - } - - /** - * Highlights the sidebar item for the currently active profile. - * @param {string|null} profileId - */ - highlightActive(profileId) { - this.el.querySelectorAll(".sidebar-item").forEach((item) => { - item.classList.toggle( - "sidebar-item-active", - item.dataset.profileId === profileId, - ); - }); - } - - /** - * Toggles the collapsed state of a named section. - * @param {string} sectionName - */ - toggleSection(sectionName) { - if (this.collapsedSections.has(sectionName)) { - this.collapsedSections.delete(sectionName); - } else { - this.collapsedSections.add(sectionName); - } - this.render(); - } - - // ── Rendering helpers ──────────────────────────────────────────── - - /** - * Renders the sidebar empty state for first-run and no-search-results cases. - * @returns {HTMLElement} - * @private - */ - _renderEmptyState() { - const isSearching = this.searchQuery.length > 0; - const empty = createElement("div", "sidebar-empty"); - - const icon = createElement("div", "sidebar-empty-icon", { - textContent: isSearching ? "⌕" : "+", - "aria-hidden": "true", - }); - empty.appendChild(icon); - - empty.appendChild( - createElement("div", "sidebar-empty-title", { - textContent: isSearching ? "No matching profiles" : "No saved profiles", - }), - ); - - empty.appendChild( - createElement("p", "sidebar-empty-text", { - textContent: isSearching - ? "Try a different name, host, group, or protocol." - : "Create a profile for the servers and tools you use often.", - }), - ); - - if (!isSearching) { - const actions = createElement("div", "sidebar-empty-actions"); - const addButton = createElement("button", "btn btn-primary btn-sm", { - type: "button", - textContent: "New Profile", - }); - addButton.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - this.app.profileEditor.openNew(); - }); - actions.appendChild(addButton); - empty.appendChild(actions); - } - - return empty; - } - - /** - * Renders a collapsible section of profiles. - * @param {string} title - * @param {string} key - * @param {Array} profiles - * @private - */ - _renderSection(title, key, profiles) { - const section = createElement("div", "sidebar-section"); - const isCollapsed = this.collapsedSections.has(key); - const isGroupSection = key.startsWith("group:"); - - // Section header - const header = createElement( - "div", - `sidebar-section-header ${isGroupSection ? "sidebar-section-header-group" : ""}`.trim(), - ); - header.innerHTML = ` - ${isCollapsed ? "▸" : "▾"} - ${escapeHtml(title)} - ${profiles.length} - `; - // Prevent mousedown from starting a text selection gesture in WKWebView, - // which would swallow the first click and require a double-click. - header.addEventListener("mousedown", (e) => e.preventDefault()); - header.addEventListener("click", () => this.toggleSection(key)); - section.appendChild(header); - - // Items - if (!isCollapsed) { - const list = createElement("div", "sidebar-list"); - for (const profile of profiles) { - list.appendChild(this.renderProfileItem(profile)); - } - section.appendChild(list); - } - - this.el.appendChild(section); - } - - /** - * Creates a single sidebar list item for a profile. - * @param {Object} profile - * @returns {HTMLElement} - */ - renderProfileItem(profile) { - const item = createElement("div", "sidebar-item", { - dataset: { profileId: profile.id }, - }); - - if (this.selectedProfiles.has(profile.id)) { - item.classList.add("sidebar-item-selected"); - } - - // Highlight if this profile's session is active - const activeSession = this.app.state.activeSessionId - ? this.app.state.getSession(this.app.state.activeSessionId) - : null; - if (activeSession && activeSession.profileId === profile.id) { - item.classList.add("sidebar-item-active"); - } - - // Protocol icon - const protocolLabel = ""; // Seemed pretty naff to have this as well as the little icon badboy this._protocolLabel(profile.protocol); - const indicator = createElement( - "span", - `protocol-indicator ${protocolColour(profile.protocol)}`, - { - title: protocolLabel, - "aria-label": protocolLabel, - }, - ); - item.appendChild(indicator); - - // Text content - const textWrapper = createElement("div", "sidebar-item-text"); - const displayName = (profile.displayName || "").trim(); - const primaryLabel = displayName || profile.host || "Untitled"; - const nameEl = createElement("span", "sidebar-item-name", { - textContent: primaryLabel, - }); - textWrapper.appendChild(nameEl); - - const subtitle = protocolLabel; - const subEl = createElement("span", "sidebar-item-host", { - textContent: truncate(subtitle, 30), - }); - textWrapper.appendChild(subEl); - item.appendChild(textWrapper); - - // Edit button (visible on hover) - const editBtn = createElement("button", "sidebar-item-edit", { - textContent: "✎", - title: "Edit profile", - type: "button", - "aria-label": "Edit profile", - }); - let editTriggered = false; - const triggerEdit = (e) => { - e.preventDefault(); - e.stopPropagation(); - if (editTriggered) return; - editTriggered = true; - requestAnimationFrame(() => { - editTriggered = false; - }); - this.app.profileEditor.openEdit(profile.id); - }; - editBtn.addEventListener("mousedown", triggerEdit); - editBtn.addEventListener("click", triggerEdit); - item.appendChild(editBtn); - - // Favourite star - if (profile.favourite) { - const star = createElement("span", "sidebar-item-star is-favourite", { - textContent: "★", - }); - item.appendChild(star); - } - - // Prevent mousedown text selection in WKWebView, but allow real controls. - item.addEventListener("mousedown", (e) => { - if (e.target.closest("button, input, select, textarea")) return; - e.preventDefault(); - }); - - // Click → connect / multi-select - item.addEventListener("click", (e) => { - const multi = e.metaKey || e.ctrlKey; - if (multi) { - e.preventDefault(); - this._toggleSelection(profile.id); - return; - } - - if (this.selectedProfiles.size > 0) { - this._clearSelection(); - } - this.app.connectProfile(profile.id); - }); - - // Double-click → edit - item.addEventListener("dblclick", (e) => { - e.stopPropagation(); - this.app.profileEditor.openEdit(profile.id); - }); - - // Right-click → context menu - item.addEventListener("contextmenu", (e) => { - e.preventDefault(); - e.stopPropagation(); - this.renderContextMenu(profile, e.clientX, e.clientY); - }); - - return item; - } - - /** - * Shows a right-click context menu for a profile. - * @param {Object} profile - * @param {number} x - Viewport X. - * @param {number} y - Viewport Y. - */ - renderContextMenu(profile, x, y) { - dismissContextMenus(); - - const menu = createElement("div", "context-menu"); - menu.style.left = `${x}px`; - menu.style.top = `${y}px`; - - const items = [ - { label: "Connect", action: () => this.app.connectProfile(profile.id) }, - { - label: "Edit", - action: () => this.app.profileEditor.openEdit(profile.id), - }, - { label: "Duplicate", action: () => this._duplicateProfile(profile) }, - { divider: true }, - { - label: profile.favourite - ? "Remove from Favourites" - : "Add to Favourites", - action: () => this._toggleFavourite(profile), - }, - { divider: true }, - { - label: "Delete", - className: "context-menu-danger", - action: () => this._deleteProfile(profile), - }, - ]; - - for (const entry of items) { - if (entry.divider) { - menu.appendChild(createElement("div", "context-menu-divider")); - continue; - } - const btn = createElement( - "button", - `context-menu-item ${entry.className || ""}`, - { - textContent: entry.label, - }, - ); - btn.addEventListener("click", () => { - dismissContextMenus(); - entry.action(); - }); - menu.appendChild(btn); - } - - // Keep menu within viewport - document.body.appendChild(menu); - const rect = menu.getBoundingClientRect(); - if (rect.right > window.innerWidth) { - menu.style.left = `${window.innerWidth - rect.width - 8}px`; - } - if (rect.bottom > window.innerHeight) { - menu.style.top = `${window.innerHeight - rect.height - 8}px`; - } - } - - // ── Actions ────────────────────────────────────────────────────── - - /** - * Toggles the favourite status of a profile. - * @param {Object} profile - * @private - */ - async _toggleFavourite(profile) { - try { - const newVal = !profile.favourite; - await window.AlchementaryLink.wailsService.SetFavourite(profile.id, newVal); - profile.favourite = newVal; - this.app.state.updateProfile(profile); - this.render(); - } catch (err) { - this.app.notifications.error("Failed to update favourite: " + err); - } - } - - /** - * Duplicates a profile. - * @param {Object} profile - * @private - */ - async _duplicateProfile(profile) { - try { - const dup = await window.AlchementaryLink.wailsService.DuplicateProfile(profile.id); - this.app.state.addProfile(dup); - this.render(); - this.app.notifications.success(`Duplicated "${profile.displayName}"`); - } catch (err) { - this.app.notifications.error("Failed to duplicate profile: " + err); - } - } - - /** - * Deletes a profile after confirmation. - * @param {Object} profile - * @private - */ - async _deleteProfile(profile) { - // Use a custom confirmation modal — window.confirm() is silently - // blocked in WKWebView and always returns false. - const ok = await this._confirmDelete(profile); - if (!ok) return; - - try { - await window.AlchementaryLink.wailsService.DeleteProfile(profile.id); - this.app.state.removeProfile(profile.id); - this.render(); - this.app.notifications.success("Profile deleted"); - } catch (err) { - this.app.notifications.error("Failed to delete profile: " + err); - } - } - - /** - * Shows a confirmation modal for profile deletion. - * @param {Object} profile - * @returns {Promise} - * @private - */ - _confirmDelete(profile) { - return new Promise((resolve) => { - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-sm"); - const label = escapeHtml( - profile.displayName || profile.host || "Untitled", - ); - modal.innerHTML = ` - - - - `; - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - - const close = (val) => { - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(() => backdrop.remove(), 200); - resolve(val); - }; - modal - .querySelector(".modal-close") - .addEventListener("click", () => close(false)); - modal - .querySelector(".confirm-cancel") - .addEventListener("click", () => close(false)); - modal - .querySelector(".confirm-ok") - .addEventListener("click", () => close(true)); - backdrop.addEventListener("click", (e) => { - if (e.target === backdrop) close(false); - }); - }); - } - - // ── Multi-select + bulk actions ───────────────────────────────── - - /** - * Removes selections that are no longer visible in the filtered list. - * @param {Array} profiles - * @private - */ - _syncSelection(profiles) { - const valid = new Set(profiles.map((p) => p.id)); - for (const id of [...this.selectedProfiles]) { - if (!valid.has(id)) this.selectedProfiles.delete(id); - } - } - - /** @private */ - _toggleSelection(profileId) { - if (this.selectedProfiles.has(profileId)) { - this.selectedProfiles.delete(profileId); - } else { - this.selectedProfiles.add(profileId); - } - this.render(); - } - - /** @private */ - _clearSelection() { - if (this.selectedProfiles.size === 0) return; - this.selectedProfiles.clear(); - this.render(); - } - - /** @private */ - _getSelectedProfiles() { - return this.app.state.profiles.filter((p) => - this.selectedProfiles.has(p.id), - ); - } - - /** @private */ - _renderLocalMachineItem() { - const item = createElement("div", "sidebar-item sidebar-item-local"); - item.addEventListener("mousedown", (e) => e.preventDefault()); - - const indicator = createElement( - "span", - "protocol-indicator proto-local", - { - title: "Local", - }, - ); - item.appendChild(indicator); - - const textWrapper = createElement("div", "sidebar-item-text"); - const nameEl = createElement("span", "sidebar-item-name", { - textContent: "Local Machine", - }); - textWrapper.appendChild(nameEl); - item.appendChild(textWrapper); - - item.addEventListener("click", () => { - if (this.onConnectLocal) { - this.onConnectLocal(); - } - }); - - return item; - } - - /** @private */ - _renderBulkActions() { - const bar = createElement("div", "sidebar-bulk-actions"); - const count = createElement("span", "sidebar-bulk-count", { - textContent: `${this.selectedProfiles.size} selected`, - }); - bar.appendChild(count); - - const actions = createElement("div", "sidebar-bulk-buttons"); - const makeBtn = (label, handler, className) => { - const btn = createElement( - "button", - `btn btn-xs ${className || ""}`.trim(), - { - textContent: label, - }, - ); - btn.addEventListener("click", handler); - actions.appendChild(btn); - }; - - makeBtn("Connect", () => this._bulkConnect()); - makeBtn("Disconnect", () => this._bulkDisconnect(), "btn-outline"); - makeBtn("Tags", () => this._bulkEditTags(), "btn-outline"); - makeBtn("Move Group", () => this._bulkMoveGroup(), "btn-outline"); - makeBtn("Clear", () => this._clearSelection(), "btn-ghost"); - - bar.appendChild(actions); - return bar; - } - - /** @private */ - _bulkConnect() { - const profiles = this._getSelectedProfiles(); - for (const p of profiles) { - this.app.connectProfile(p.id); - } - this._clearSelection(); - } - - /** @private */ - async _bulkDisconnect() { - const sessions = this.app.state - .getAllSessions() - .filter((s) => this.selectedProfiles.has(s.profileId)); - if (sessions.length === 0) { - this.app.notifications.info("No active sessions to disconnect"); - return; - } - - const ok = await this._confirmBulkDisconnect(sessions.length); - if (!ok) return; - - for (const s of sessions) { - await this.app.closeSessionImmediate(s.id); - } - this._clearSelection(); - } - - /** @private */ - async _bulkEditTags() { - const result = await this._promptTagEdit(); - if (!result) return; - - const profiles = this._getSelectedProfiles(); - for (const p of profiles) { - const updated = JSON.parse(JSON.stringify(p)); - const existing = updated.tags || []; - if (result.mode === "replace") { - updated.tags = result.tags; - } else { - const merged = new Set([...existing, ...result.tags]); - updated.tags = [...merged]; - } - - try { - const saved = await window.AlchementaryLink.wailsService.UpdateProfile(updated); - this.app.state.updateProfile(saved); - } catch (err) { - this.app.notifications.error("Failed to update tags: " + err); - } - } - - this.render(); - this._clearSelection(); - } - - /** @private */ - async _bulkMoveGroup() { - const folder = await this._promptGroupMove(); - if (folder === null) return; - - const profiles = this._getSelectedProfiles(); - for (const p of profiles) { - const updated = JSON.parse(JSON.stringify(p)); - updated.folder = folder; - try { - const saved = await window.AlchementaryLink.wailsService.UpdateProfile(updated); - this.app.state.updateProfile(saved); - } catch (err) { - this.app.notifications.error("Failed to move group: " + err); - } - } - - this.render(); - this._clearSelection(); - } - - /** @private */ - _confirmBulkDisconnect(count) { - return new Promise((resolve) => { - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-sm"); - modal.innerHTML = ` - - - - `; - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - - const close = (val) => { - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(() => backdrop.remove(), 200); - resolve(val); - }; - modal - .querySelector(".modal-close") - .addEventListener("click", () => close(false)); - modal - .querySelector(".confirm-cancel") - .addEventListener("click", () => close(false)); - modal - .querySelector(".confirm-ok") - .addEventListener("click", () => close(true)); - backdrop.addEventListener("click", (e) => { - if (e.target === backdrop) close(false); - }); - }); - } - - /** @private */ - _promptTagEdit() { - return new Promise((resolve) => { - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-sm"); - modal.innerHTML = ` - - - - `; - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - - const input = modal.querySelector(".tag-input"); - const parseTags = () => { - const raw = (input.value || "").split(","); - return raw.map((t) => t.trim()).filter((t) => t.length > 0); - }; - - const close = (val) => { - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(() => backdrop.remove(), 200); - resolve(val); - }; - modal - .querySelector(".modal-close") - .addEventListener("click", () => close(null)); - modal - .querySelector(".confirm-cancel") - .addEventListener("click", () => close(null)); - modal.querySelector(".confirm-add").addEventListener("click", () => { - close({ mode: "add", tags: parseTags() }); - }); - modal - .querySelector(".confirm-replace") - .addEventListener("click", () => { - close({ mode: "replace", tags: parseTags() }); - }); - backdrop.addEventListener("click", (e) => { - if (e.target === backdrop) close(null); - }); - setTimeout(() => input.focus(), 50); - }); - } - - /** @private */ - _promptGroupMove() { - return new Promise((resolve) => { - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-sm"); - modal.innerHTML = ` - - - - `; - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - - const input = modal.querySelector(".group-input"); - const close = (val) => { - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(() => backdrop.remove(), 200); - resolve(val); - }; - modal - .querySelector(".modal-close") - .addEventListener("click", () => close(null)); - modal - .querySelector(".confirm-cancel") - .addEventListener("click", () => close(null)); - modal - .querySelector(".confirm-ok") - .addEventListener("click", () => close(input.value.trim())); - backdrop.addEventListener("click", (e) => { - if (e.target === backdrop) close(null); - }); - setTimeout(() => input.focus(), 50); - }); - } - - // ── Filtering ──────────────────────────────────────────────────── - - /** - * Returns profiles filtered by the current search query. - * @returns {Array} - * @private - */ - _filteredProfiles() { - if (!this.searchQuery) return this.app.state.profiles; - - return this.app.state.profiles - .map((p) => { - // Match against display name, host, tags, and protocol - const fields = [ - p.displayName || "", - p.host || "", - (p.tags || []).join(" "), - p.protocol || "", - ].join(" "); - const result = fuzzyMatch(fields, this.searchQuery); - return { profile: p, ...result }; - }) - .filter((r) => r.match) - .sort((a, b) => b.score - a.score) - .map((r) => r.profile); - } - - /** - * Returns a display label for a protocol. - * @param {string} protocol - * @returns {string} - * @private - */ - _protocolLabel(protocol) { - switch (protocol) { - case "github": - return "GitHub"; - case "ssh": - return "SSH"; - case "telnet": - return "TELNET"; - case "raw": - return "RAW"; - case "irc": - return "IRC"; - default: - return (protocol || "").toUpperCase() || "UNKNOWN"; - } - } - } - - window.AlchementaryLink = window.AlchementaryLink || {}; - window.AlchementaryLink.Sidebar = Sidebar; -})(); diff --git a/frontend/src/js/snippets.js b/frontend/src/js/snippets.js deleted file mode 100644 index 172ceae..0000000 --- a/frontend/src/js/snippets.js +++ /dev/null @@ -1,454 +0,0 @@ -(function () { - /** - * Alchementary Link — snippet / command library. - * Provides a panel for managing and sending reusable commands to active sessions. - */ - - const { createElement, escapeHtml, dismissContextMenus } = - window.AlchementaryLink; - - class SnippetLibrary { - /** - * @param {App} app - */ - constructor(app) { - this.app = app; - /** @type {Array} */ - this.snippets = []; - this.searchQuery = ""; - } - - // ── Public API ─────────────────────────────────────────────────── - - /** - * Opens the snippet library modal. - */ - async open() { - await this._loadSnippets(); - this._showModal(); - } - - /** - * Sends a snippet command to the active session. - * Supports {{variable}} placeholders that prompt the user for values. - * Built-in variables: {{hostname}}, {{date}}, {{time}}, {{timestamp}}. - * @param {string} command - */ - sendToActiveSession(command) { - const sessionId = this.app.state.activeSessionId; - if (!sessionId) { - this.app.notifications.error("No active session"); - return; - } - const session = this.app.state.getSession(sessionId); - if (!session || session.state !== "connected") { - this.app.notifications.error("Session is not connected"); - return; - } - - // Check for template variables - const varPattern = /\{\{([^}]+)\}\}/g; - const matches = []; - let match; - while ((match = varPattern.exec(command)) !== null) { - matches.push(match[1].trim()); - } - - // Resolve built-in variables first - const builtins = { - hostname: session.host || "unknown", - date: new Date().toISOString().split("T")[0], - time: new Date().toTimeString().split(" ")[0], - timestamp: Math.floor(Date.now() / 1000).toString(), - }; - - let resolved = command; - const userVars = []; - for (const varName of matches) { - const lower = varName.toLowerCase(); - if (builtins[lower] !== undefined) { - resolved = resolved.replace("{{" + varName + "}}", builtins[lower]); - } else if (!userVars.includes(varName)) { - userVars.push(varName); - } - } - - if (userVars.length === 0) { - this._sendCommand(sessionId, resolved); - return; - } - - // Prompt for user-defined variables - this._promptVariables(userVars, (values) => { - let final = resolved; - for (let i = 0; i < userVars.length; i++) { - final = final.split("{{" + userVars[i] + "}}").join(values[i] || ""); - } - this._sendCommand(sessionId, final); - }); - } - - /** @private */ - _sendCommand(sessionId, command) { - try { - const b64 = window.AlchementaryLink.utf8ToBase64(command + "\n"); - window.AlchementaryLink.wailsService.WriteSession(sessionId, b64); - this.app.notifications.info("Snippet sent"); - } catch (err) { - this.app.notifications.error("Failed to send snippet: " + err); - } - } - - /** @private */ - _promptVariables(varNames, onConfirm) { - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-sm"); - - let inputsHtml = ""; - for (const v of varNames) { - inputsHtml += ` -
- - -
- `; - } - - modal.innerHTML = ` - - - - `; - - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - const firstInput = modal.querySelector(".snippet-var-input"); - if (firstInput) firstInput.focus(); - }); - - const close = () => { - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(() => backdrop.remove(), 200); - }; - - modal.querySelector(".modal-close").addEventListener("click", close); - modal - .querySelector(".snippet-var-cancel") - .addEventListener("click", close); - backdrop.addEventListener("click", (e) => { - if (e.target === backdrop) close(); - }); - - modal.querySelector(".snippet-var-send").addEventListener("click", () => { - const inputs = modal.querySelectorAll(".snippet-var-input"); - const values = Array.from(inputs).map((inp) => inp.value); - close(); - onConfirm(values); - }); - - // Enter to send - modal.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - modal.querySelector(".snippet-var-send").click(); - } - if (e.key === "Escape") close(); - }); - } - - // ── Data ───────────────────────────────────────────────────────── - - /** @private */ - async _loadSnippets() { - try { - this.snippets = (await window.AlchementaryLink.wailsService.ListSnippets()) || []; - } catch (err) { - this.snippets = []; - console.error("Failed to load snippets:", err); - } - } - - // ── Modal ──────────────────────────────────────────────────────── - - /** @private */ - _showModal() { - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-lg"); - - modal.innerHTML = ` - - - `; - - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - - const close = () => { - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(() => backdrop.remove(), 200); - }; - - modal.querySelector(".modal-close").addEventListener("click", close); - backdrop.addEventListener("click", (e) => { - if (e.target === backdrop) close(); - }); - - // Search - const searchInput = modal.querySelector(".snippet-search"); - searchInput.addEventListener("input", () => { - this.searchQuery = searchInput.value.trim().toLowerCase(); - this._renderList(modal, close); - }); - - // New snippet button - modal.querySelector(".snippet-add-btn").addEventListener("click", () => { - this._showEditor(modal, null, close); - }); - - this._renderList(modal, close); - } - - /** @private */ - _renderList(modal, closeFn) { - const listEl = modal.querySelector(".snippet-list"); - if (!listEl) return; - listEl.innerHTML = ""; - - const filtered = this.snippets.filter((sn) => { - if (!this.searchQuery) return true; - return ( - sn.name.toLowerCase().includes(this.searchQuery) || - sn.command.toLowerCase().includes(this.searchQuery) || - (sn.description || "").toLowerCase().includes(this.searchQuery) - ); - }); - - if (filtered.length === 0) { - listEl.innerHTML = - '
No snippets found
'; - return; - } - - for (const sn of filtered) { - const item = createElement("div", "snippet-item", { - dataset: { id: sn.id }, - }); - item.style.cssText = - "padding:var(--space-2);border-radius:var(--radius-md);cursor:pointer;border:1px solid var(--border-subtle);"; - item.innerHTML = ` -
${escapeHtml(sn.name)}
-
${escapeHtml(sn.command)}
- `; - item.addEventListener("click", () => - this._showDetail(modal, sn, closeFn), - ); - item.addEventListener("mousedown", (e) => e.preventDefault()); - item.addEventListener("mouseenter", () => { - item.style.background = "var(--bg-hover)"; - }); - item.addEventListener("mouseleave", () => { - item.style.background = ""; - }); - listEl.appendChild(item); - } - } - - /** @private */ - _showDetail(modal, snippet, closeFn) { - const detailPanel = modal.querySelector(".snippet-detail-panel"); - if (!detailPanel) return; - detailPanel.innerHTML = ""; - - const nameEl = createElement("div", "", { - innerHTML: `${escapeHtml(snippet.name)}`, - }); - detailPanel.appendChild(nameEl); - - if (snippet.description) { - const descEl = createElement("div", "", { - textContent: snippet.description, - }); - descEl.style.cssText = - "font-size:var(--text-sm);color:var(--text-secondary);"; - detailPanel.appendChild(descEl); - } - - const cmdEl = createElement("pre", "", { - textContent: snippet.command, - }); - cmdEl.style.cssText = - "flex:1;font-family:var(--font-mono);font-size:var(--text-sm);background:var(--bg-base);padding:var(--space-3);border-radius:var(--radius-md);overflow:auto;color:var(--text-primary);margin:0;white-space:pre-wrap;word-break:break-all;"; - detailPanel.appendChild(cmdEl); - - // Action buttons - const actions = createElement("div", "", {}); - actions.style.cssText = "display:flex;gap:var(--space-2);"; - - const sendBtn = createElement("button", "btn btn-sm btn-primary", { - textContent: "Send to Session", - }); - sendBtn.addEventListener("click", () => { - this.sendToActiveSession(snippet.command); - }); - - const editBtn = createElement("button", "btn btn-sm btn-secondary", { - textContent: "Edit", - }); - editBtn.addEventListener("click", () => { - this._showEditor(modal, snippet, closeFn); - }); - - const deleteBtn = createElement("button", "btn btn-sm btn-danger", { - textContent: "Delete", - }); - deleteBtn.addEventListener("click", async () => { - try { - await window.AlchementaryLink.wailsService.DeleteSnippet(snippet.id); - this.snippets = this.snippets.filter((s) => s.id !== snippet.id); - this._renderList(modal, closeFn); - detailPanel.innerHTML = - '
Snippet deleted
'; - } catch (err) { - this.app.notifications.error("Failed to delete: " + err); - } - }); - - actions.appendChild(sendBtn); - actions.appendChild(editBtn); - actions.appendChild(deleteBtn); - detailPanel.appendChild(actions); - } - - /** @private */ - _showEditor(modal, snippet, closeFn) { - const detailPanel = modal.querySelector(".snippet-detail-panel"); - if (!detailPanel) return; - detailPanel.innerHTML = ""; - - const isNew = !snippet; - const title = isNew ? "New Snippet" : "Edit Snippet"; - - detailPanel.innerHTML = ` -
${title}
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- `; - - detailPanel - .querySelector(".snippet-save-btn") - .addEventListener("click", async () => { - const name = detailPanel - .querySelector(".snippet-ed-name") - .value.trim(); - const command = detailPanel.querySelector(".snippet-ed-cmd").value; - const description = detailPanel - .querySelector(".snippet-ed-desc") - .value.trim(); - const folder = detailPanel - .querySelector(".snippet-ed-folder") - .value.trim(); - - if (!name) { - this.app.notifications.error("Name is required"); - return; - } - if (!command) { - this.app.notifications.error("Command is required"); - return; - } - - try { - const data = { - id: isNew ? "" : snippet.id, - name, - command, - description, - folder, - tags: isNew ? [] : snippet.tags || [], - }; - - let saved; - if (isNew) { - saved = await window.AlchementaryLink.wailsService.CreateSnippet(data); - this.snippets.push(saved); - } else { - saved = await window.AlchementaryLink.wailsService.UpdateSnippet(data); - const idx = this.snippets.findIndex((s) => s.id === saved.id); - if (idx !== -1) this.snippets[idx] = saved; - } - - this._renderList(modal, closeFn); - this._showDetail(modal, saved, closeFn); - this.app.notifications.info( - isNew ? "Snippet created" : "Snippet updated", - ); - } catch (err) { - this.app.notifications.error("Failed to save snippet: " + err); - } - }); - - detailPanel - .querySelector(".snippet-cancel-btn") - .addEventListener("click", () => { - if (snippet) { - this._showDetail(modal, snippet, closeFn); - } else { - detailPanel.innerHTML = - '
Select a snippet or create a new one
'; - } - }); - } - } - - window.AlchementaryLink = window.AlchementaryLink || {}; - window.AlchementaryLink.SnippetLibrary = SnippetLibrary; -})(); diff --git a/frontend/src/js/splitpane.js b/frontend/src/js/splitpane.js deleted file mode 100644 index dedc35b..0000000 --- a/frontend/src/js/splitpane.js +++ /dev/null @@ -1,797 +0,0 @@ -(function() { -/** - * Alchementary Link — Split Pane Manager. - * Enables splitting session panels into multiple panes, each hosting - * its own terminal session. Supports horizontal (top/bottom) and - * vertical (left/right) splits with draggable resize dividers. - */ - -var MIN_PANE_SIZE = 100; // px - -/** - * Unique pane id counter. - * @type {number} - */ -var _paneIdCounter = 0; - -function nextPaneId() { - return 'pane-' + (++_paneIdCounter); -} - -class SplitPaneManager { - /** - * @param {App} app - */ - constructor(app) { - this.app = app; - - /** - * Map of sessionId → split tree root (or null if unsplit). - * The tree is a simple object: - * Leaf: { type: 'leaf', paneId, el, sessionId } - * Branch: { type: 'branch', direction, first, second, el, divider, ratio } - * @type {Map} - */ - this.trees = new Map(); - - /** - * Connections started from placeholders but not yet attached to a pane. - * Closing a pane/tab marks its token cancelled so a late backend result - * is disconnected instead of becoming an invisible session. - * @type {Map} - */ - this.pendingConnections = new Map(); - } - - // ── Public API ─────────────────────────────────────────────────── - - /** - * Splits the active session panel vertically (left | right). - */ - splitVertical() { - this._splitActive('vertical'); - } - - /** - * Splits the active session panel horizontally (top / bottom). - */ - splitHorizontal() { - this._splitActive('horizontal'); - } - - /** - * Removes all split state for a session (used on tab close). - * @param {string} sessionId - */ - async cleanup(sessionId) { - var root = this.trees.get(sessionId); - this.trees.delete(sessionId); - - var childSessionIds = new Set(); - var self = this; - function releaseNode(node) { - if (!node) return; - if (node.type === 'leaf') { - self._cancelPendingConnection(node.paneId); - if (node.sessionId && node.sessionId !== sessionId) { - childSessionIds.add(node.sessionId); - } - if (node.el) node.el._splitNode = null; - node.parent = null; - return; - } - if (node.cancelDrag) node.cancelDrag(); - releaseNode(node.first); - releaseNode(node.second); - if (node.el) node.el._splitNode = null; - node.parent = null; - } - releaseNode(root); - - // A connection may still be resolving before its leaf has a session ID. - // Its token remains captured by the promise and will disconnect any late - // result after _cancelPendingConnection marks it cancelled. - for (var [paneId, token] of this.pendingConnections) { - if (token.ownerSessionId === sessionId) { - token.cancelled = true; - this.pendingConnections.delete(paneId); - } - } - - await Promise.allSettled(Array.from(childSessionIds, function(childId) { - return self.app.closeSessionImmediate(childId, { - removeTab: false, - cleanupSplits: false - }); - })); - } - - /** - * Re-fits all terminals visible in the active session's pane tree. - */ - refitAll() { - var activeId = this.app.state.activeSessionId; - if (!activeId) return; - var tree = this.trees.get(activeId); - if (tree) this._refitTree(tree); - } - - // ── Core split logic ───────────────────────────────────────────── - - /** - * Splits the active session panel. - * @param {'horizontal'|'vertical'} direction - * @private - */ - _splitActive(direction) { - var sessionId = this.app.state.activeSessionId; - if (!sessionId) return; - - var panel = document.getElementById('panel-' + sessionId); - if (!panel) return; - - var tree = this.trees.get(sessionId); - - if (!tree) { - // First split — wrap existing content - tree = this._initTree(panel, sessionId); - } - - // Find the focused leaf (or the first leaf) - var leaf = this._findFocusedLeaf(tree) || this._firstLeaf(tree); - if (!leaf) return; - - this._splitLeaf(leaf, direction, sessionId); - } - - /** - * Creates the initial tree structure from an unsplit panel. - * Wraps the existing terminal-container in a split-pane leaf. - * @param {HTMLElement} panel - * @param {string} sessionId - * @returns {Object} root leaf node - * @private - */ - _initTree(panel, sessionId) { - var termContainer = panel.querySelector('.terminal-container'); - // For non-terminal panels (raw, github), wrap whatever content exists - var contentChildren = Array.from(panel.children); - - var paneId = nextPaneId(); - var paneEl = document.createElement('div'); - paneEl.className = 'split-pane'; - paneEl.dataset.paneId = paneId; - paneEl.style.flex = '1'; - - // Move existing content into the pane - contentChildren.forEach(function(child) { - paneEl.appendChild(child); - }); - - // Reset terminal-container from absolute to relative positioning inside a pane - if (termContainer) { - termContainer.style.position = 'relative'; - termContainer.style.inset = 'auto'; - termContainer.style.flex = '1'; - termContainer.style.width = '100%'; - termContainer.style.height = '100%'; - } - - panel.appendChild(paneEl); - - var leaf = { - type: 'leaf', - paneId: paneId, - el: paneEl, - sessionId: sessionId, - parent: null - }; - - paneEl._splitNode = leaf; - this.trees.set(sessionId, leaf); - - // Add focus tracking - this._addFocusTracking(paneEl); - - return leaf; - } - - /** - * Splits a leaf node into two panes. - * @param {Object} leaf - The leaf node to split. - * @param {'horizontal'|'vertical'} direction - * @param {string} ownerSessionId - The session that owns this panel. - * @private - */ - _splitLeaf(leaf, direction, ownerSessionId) { - var parentBranch = leaf.parent; - var panel = this._findPanel(ownerSessionId); - if (!panel) return; - - // Create a container for the two panes - var containerEl = document.createElement('div'); - containerEl.className = 'split-container split-' + direction; - containerEl.style.flex = leaf.el.style.flex || '1'; - - // The existing leaf becomes the first child - leaf.el.style.flex = '1'; - - // Create divider - var divider = document.createElement('div'); - divider.className = 'split-divider'; - - // Create new empty pane - var newPaneId = nextPaneId(); - var newPaneEl = document.createElement('div'); - newPaneEl.className = 'split-pane'; - newPaneEl.dataset.paneId = newPaneId; - newPaneEl.style.flex = '1'; - - // Add a placeholder with session picker to the new pane - this._renderPlaceholder(newPaneEl, ownerSessionId); - - // Add close buttons to both panes - this._addCloseButton(leaf.el, ownerSessionId); - this._addCloseButton(newPaneEl, ownerSessionId); - - // Add focus tracking - this._addFocusTracking(newPaneEl); - - // Build the new branch node - var newLeaf = { - type: 'leaf', - paneId: newPaneId, - el: newPaneEl, - sessionId: null, - parent: null - }; - newPaneEl._splitNode = newLeaf; - - var branch = { - type: 'branch', - direction: direction, - first: leaf, - second: newLeaf, - el: containerEl, - divider: divider, - ratio: 0.5, - parent: parentBranch - }; - - leaf.parent = branch; - newLeaf.parent = branch; - containerEl._splitNode = branch; - - // Replace the leaf element in the DOM - var leafParent = leaf.el.parentNode; - leafParent.insertBefore(containerEl, leaf.el); - containerEl.appendChild(leaf.el); - containerEl.appendChild(divider); - containerEl.appendChild(newPaneEl); - - // Update the tree structure - if (parentBranch) { - if (parentBranch.first === leaf) { - parentBranch.first = branch; - } else { - parentBranch.second = branch; - } - } else { - // This was the root leaf — the branch becomes the new root - this.trees.set(ownerSessionId, branch); - } - - // Set up divider dragging - this._setupDividerDrag(divider, branch); - - // Refit terminals after layout change - var self = this; - requestAnimationFrame(function() { - self._refitTree(self.trees.get(ownerSessionId)); - }); - } - - // ── Pane closing ───────────────────────────────────────────────── - - /** - * Closes a pane and unsplits, keeping the sibling. - * @param {string} paneId - * @param {string} ownerSessionId - * @private - */ - _closePane(paneId, ownerSessionId) { - var root = this.trees.get(ownerSessionId); - if (!root) return; - - var leaf = this._findLeafById(root, paneId); - if (!leaf || !leaf.parent) { - // Only one pane left — can't close it - return; - } - - var branch = leaf.parent; - var sibling = (branch.first === leaf) ? branch.second : branch.first; - - this._cancelPendingConnection(leaf.paneId); - - // If the closing pane has a child session, release both its frontend - // renderer and backend connection. The owner pane cannot be closed - // independently because it provides the tab's identity. - if (leaf.sessionId && leaf.sessionId !== ownerSessionId) { - this.app.closeSessionImmediate(leaf.sessionId, { - removeTab: false, - cleanupSplits: false - }); - leaf.sessionId = null; - } - - if (branch.cancelDrag) branch.cancelDrag(); - - // Replace the branch element with the sibling element in the DOM - sibling.el.style.flex = branch.el.style.flex || '1'; - branch.el.parentNode.insertBefore(sibling.el, branch.el); - branch.el.remove(); - leaf.el._splitNode = null; - branch.el._splitNode = null; - - // Remove close button from sibling if it's now the only pane - sibling.parent = branch.parent; - if (branch.parent) { - if (branch.parent.first === branch) { - branch.parent.first = sibling; - } else { - branch.parent.second = sibling; - } - } else { - // Sibling is now root - this.trees.set(ownerSessionId, sibling); - } - - // Remove close button if we're back to a single leaf - var newRoot = this.trees.get(ownerSessionId); - if (newRoot && newRoot.type === 'leaf') { - var closeBtn = newRoot.el.querySelector(':scope > .split-pane-close'); - if (closeBtn) closeBtn.remove(); - } - - // Refit terminals - var self = this; - requestAnimationFrame(function() { - self._refitTree(self.trees.get(ownerSessionId)); - }); - } - - // ── Divider drag ───────────────────────────────────────────────── - - /** - * Sets up mouse drag behaviour on a divider. - * @param {HTMLElement} divider - * @param {Object} branch - * @private - */ - _setupDividerDrag(divider, branch) { - var self = this; - var onMouseMove = null; - var onMouseUp = null; - var bodyClass = null; - - function stopDrag(refit) { - if (bodyClass) document.body.classList.remove(bodyClass); - divider.classList.remove('is-dragging'); - if (onMouseMove) document.removeEventListener('mousemove', onMouseMove); - if (onMouseUp) document.removeEventListener('mouseup', onMouseUp); - onMouseMove = null; - onMouseUp = null; - bodyClass = null; - if (refit) self._refitTree(branch); - } - - branch.cancelDrag = function() { - stopDrag(false); - }; - - divider.addEventListener('mousedown', function(e) { - e.preventDefault(); - stopDrag(false); - var isHorizontal = branch.direction === 'horizontal'; - bodyClass = isHorizontal ? 'split-resizing-row' : 'split-resizing'; - document.body.classList.add(bodyClass); - divider.classList.add('is-dragging'); - - var containerRect = branch.el.getBoundingClientRect(); - var dividerSize = isHorizontal ? 4 : 4; // divider thickness - - onMouseMove = function(e) { - var totalSize, mousePos; - if (isHorizontal) { - totalSize = containerRect.height - dividerSize; - mousePos = e.clientY - containerRect.top; - } else { - totalSize = containerRect.width - dividerSize; - mousePos = e.clientX - containerRect.left; - } - - var ratio = mousePos / (totalSize + dividerSize); - ratio = Math.max(MIN_PANE_SIZE / (totalSize + dividerSize), ratio); - ratio = Math.min(1 - MIN_PANE_SIZE / (totalSize + dividerSize), ratio); - - branch.ratio = ratio; - branch.first.el.style.flex = ratio + ''; - branch.second.el.style.flex = (1 - ratio) + ''; - - // Refit terminals in this branch - self._refitTree(branch); - }; - - onMouseUp = function() { - stopDrag(true); - }; - - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - }); - } - - // ── Placeholder / session picker ───────────────────────────────── - - /** - * Renders a placeholder with a list of available profiles to connect. - * @param {HTMLElement} paneEl - * @param {string} ownerSessionId - * @private - */ - _renderPlaceholder(paneEl, ownerSessionId) { - var placeholder = document.createElement('div'); - placeholder.className = 'split-pane-placeholder'; - - var icon = document.createElement('div'); - icon.className = 'split-pane-placeholder-icon'; - icon.innerHTML = ''; - - var text = document.createElement('div'); - text.className = 'split-pane-placeholder-text'; - text.textContent = 'Select a connection for this pane'; - - placeholder.appendChild(icon); - placeholder.appendChild(text); - - // Build profile list - var profiles = this.app.state.profiles || []; - var list = document.createElement('div'); - list.className = 'split-pane-profile-list'; - - var self = this; - var localBtn = document.createElement('button'); - localBtn.className = 'split-pane-profile-btn'; - - var localBadge = document.createElement('span'); - localBadge.className = 'protocol-badge'; - localBadge.textContent = 'LOCAL'; - localBadge.style.color = self._protocolColor('local'); - - var localLabel = document.createElement('span'); - localLabel.textContent = 'Local Machine'; - - localBtn.appendChild(localBadge); - localBtn.appendChild(localLabel); - localBtn.addEventListener('click', function() { - self._connectLocalInPane(paneEl, ownerSessionId); - }); - list.appendChild(localBtn); - - profiles.forEach(function(profile) { - var btn = document.createElement('button'); - btn.className = 'split-pane-profile-btn'; - - var badge = document.createElement('span'); - badge.className = 'protocol-badge'; - badge.textContent = (profile.protocol || 'ssh').toUpperCase(); - badge.style.color = self._protocolColor(profile.protocol); - - var label = document.createElement('span'); - label.textContent = profile.displayName || profile.host || profile.id; - - btn.appendChild(badge); - btn.appendChild(label); - - btn.addEventListener('click', function() { - self._connectInPane(paneEl, profile, ownerSessionId); - }); - - list.appendChild(btn); - }); - - placeholder.appendChild(list); - - paneEl.appendChild(placeholder); - } - - /** - * Connects a profile in a specific pane. - * @param {HTMLElement} paneEl - * @param {Object} profile - * @param {string} ownerSessionId - * @private - */ - _connectInPane(paneEl, profile, ownerSessionId) { - var self = this; - var token = this._beginPendingConnection(paneEl, ownerSessionId); - - // Remove placeholder - var placeholder = paneEl.querySelector('.split-pane-placeholder'); - if (placeholder) placeholder.remove(); - - // Connect via backend - window.AlchementaryLink.wailsService.ConnectSession(profile.id).then(async function(sessionInfo) { - if (!self._completePendingConnection(paneEl, ownerSessionId, token)) { - try { - await window.AlchementaryLink.wailsService.DisconnectSession(sessionInfo.id); - } catch (_) { - // The backend may already have completed the close. - } - return; - } - self._attachTerminalToPane(paneEl, sessionInfo, profile); - - self.app.notifications.info('Connecting to ' + (profile.displayName || profile.host) + '…'); - }).catch(function(err) { - if (!self._completePendingConnection(paneEl, ownerSessionId, token)) { - return; - } - self.app.notifications.error('Connection failed: ' + err); - // Re-render placeholder on failure - self._renderPlaceholder(paneEl, ownerSessionId); - }); - } - - /** - * Opens a local shell in a specific pane. - * @param {HTMLElement} paneEl - * @param {string} ownerSessionId - * @private - */ - _connectLocalInPane(paneEl, ownerSessionId) { - var self = this; - var token = this._beginPendingConnection(paneEl, ownerSessionId); - var placeholder = paneEl.querySelector('.split-pane-placeholder'); - if (placeholder) placeholder.remove(); - - window.AlchementaryLink.wailsService.ConnectLocal().then(async function(sessionInfo) { - if (!self._completePendingConnection(paneEl, ownerSessionId, token)) { - try { - await window.AlchementaryLink.wailsService.DisconnectSession(sessionInfo.id); - } catch (_) { - // The backend may already have completed the close. - } - return; - } - var prefs = self.app.state.preferences || {}; - self._attachTerminalToPane(paneEl, sessionInfo, { - terminalFontFamily: prefs.defaultFontFamily, - terminalFontSize: prefs.defaultFontSize, - terminalScrollback: prefs.defaultScrollback, - terminalCursorStyle: prefs.defaultCursorStyle - }); - self.app.notifications.info('Opening local terminal…'); - }).catch(function(err) { - if (!self._completePendingConnection(paneEl, ownerSessionId, token)) { - return; - } - self.app.notifications.error('Local terminal failed: ' + err); - self._renderPlaceholder(paneEl, ownerSessionId); - }); - } - - /** - * Attaches a connected terminal session to a split pane. - * @param {HTMLElement} paneEl - * @param {Object} sessionInfo - * @param {Object} terminalProfile - * @private - */ - _attachTerminalToPane(paneEl, sessionInfo, terminalProfile) { - this.app.state.addSession(sessionInfo); - - var termContainer = document.createElement('div'); - termContainer.className = 'terminal-container'; - termContainer.id = 'split-term-' + sessionInfo.id; - termContainer.style.position = 'relative'; - termContainer.style.flex = '1'; - termContainer.style.width = '100%'; - termContainer.style.height = '100%'; - paneEl.appendChild(termContainer); - - var node = paneEl._splitNode; - if (node) { - node.sessionId = sessionInfo.id; - } - - this.app.terminals.createTerminal(sessionInfo.id, termContainer.id, terminalProfile); - } - - /** - * Tracks an asynchronous connection attempt owned by a pane. - * @private - */ - _beginPendingConnection(paneEl, ownerSessionId) { - var paneId = paneEl.dataset.paneId; - this._cancelPendingConnection(paneId); - var token = { - ownerSessionId: ownerSessionId, - cancelled: false, - completed: false - }; - this.pendingConnections.set(paneId, token); - return token; - } - - /** - * Returns true only if a connection result still belongs to a live pane. - * @private - */ - _completePendingConnection(paneEl, ownerSessionId, token) { - if (token.completed) return false; - token.completed = true; - var paneId = paneEl.dataset.paneId; - if (this.pendingConnections.get(paneId) === token) { - this.pendingConnections.delete(paneId); - } - var root = this.trees.get(ownerSessionId); - var leaf = this._findLeafById(root, paneId); - return !token.cancelled && - paneEl.isConnected !== false && - leaf && - leaf.el === paneEl; - } - - /** - * Cancels a pending pane connection without waiting for the backend call. - * A late successful result will disconnect itself. - * @private - */ - _cancelPendingConnection(paneId) { - var token = this.pendingConnections.get(paneId); - if (!token) return; - token.cancelled = true; - this.pendingConnections.delete(paneId); - } - - // ── Tree traversal helpers ─────────────────────────────────────── - - /** - * Finds a leaf by paneId in the tree. - * @param {Object} node - * @param {string} paneId - * @returns {Object|null} - * @private - */ - _findLeafById(node, paneId) { - if (!node) return null; - if (node.type === 'leaf') { - return node.paneId === paneId ? node : null; - } - return this._findLeafById(node.first, paneId) || this._findLeafById(node.second, paneId); - } - - /** - * Returns the first leaf node in the tree. - * @param {Object} node - * @returns {Object|null} - * @private - */ - _firstLeaf(node) { - if (!node) return null; - if (node.type === 'leaf') return node; - return this._firstLeaf(node.first); - } - - /** - * Returns the focused leaf node (one whose pane has .is-focused). - * @param {Object} node - * @returns {Object|null} - * @private - */ - _findFocusedLeaf(node) { - if (!node) return null; - if (node.type === 'leaf') { - return node.el.classList.contains('is-focused') ? node : null; - } - return this._findFocusedLeaf(node.first) || this._findFocusedLeaf(node.second); - } - - /** - * Recursively refits all terminals in a tree node. - * @param {Object} node - * @private - */ - _refitTree(node) { - if (!node) return; - if (node.type === 'leaf') { - if (node.sessionId) { - this.app.terminals.fitSession(node.sessionId); - } - return; - } - this._refitTree(node.first); - this._refitTree(node.second); - } - - /** - * Finds the panel element for a session. - * @param {string} sessionId - * @returns {HTMLElement|null} - * @private - */ - _findPanel(sessionId) { - return document.getElementById('panel-' + sessionId); - } - - // ── UI helpers ─────────────────────────────────────────────────── - - /** - * Adds a close button to a pane element. - * @param {HTMLElement} paneEl - * @param {string} ownerSessionId - * @private - */ - _addCloseButton(paneEl, ownerSessionId) { - // Don't add if already present - if (paneEl.querySelector(':scope > .split-pane-close')) return; - // The owner session supplies the tab/panel identity. Allowing that leaf - // to close would leave an invisible backend session behind. - if (paneEl._splitNode && paneEl._splitNode.sessionId === ownerSessionId) return; - - var btn = document.createElement('button'); - btn.className = 'split-pane-close'; - btn.textContent = '✕'; - btn.title = 'Close pane'; - - var self = this; - btn.addEventListener('click', function(e) { - e.stopPropagation(); - self._closePane(paneEl.dataset.paneId, ownerSessionId); - }); - - paneEl.appendChild(btn); - } - - /** - * Adds focus tracking to a pane — marks it as focused when clicked. - * @param {HTMLElement} paneEl - * @private - */ - _addFocusTracking(paneEl) { - paneEl.addEventListener('mousedown', function() { - // Remove focus from all sibling panes - var allPanes = paneEl.closest('.session-panel'); - if (allPanes) { - allPanes.querySelectorAll('.split-pane.is-focused').forEach(function(el) { - el.classList.remove('is-focused'); - }); - } - paneEl.classList.add('is-focused'); - }); - } - - /** - * Returns a colour for a protocol badge. - * @param {string} protocol - * @returns {string} - * @private - */ - _protocolColor(protocol) { - var colors = { - ssh: '#4ecdc4', - telnet: '#ffa726', - raw: '#ab47bc', - github: '#7c8aff', - local: '#66bb6a' - }; - return colors[protocol] || '#4ecdc4'; - } -} - -window.AlchementaryLink = window.AlchementaryLink || {}; -window.AlchementaryLink.SplitPaneManager = SplitPaneManager; - -})(); diff --git a/frontend/src/js/sshkeys.js b/frontend/src/js/sshkeys.js deleted file mode 100644 index 03051e9..0000000 --- a/frontend/src/js/sshkeys.js +++ /dev/null @@ -1,337 +0,0 @@ -(function () { - /** - * Alchementary Link — SSH Key Manager. - * Panel for listing, generating, copying, and deleting SSH key pairs. - */ - - const { createElement, dismissContextMenus } = window.AlchementaryLink; - - class SSHKeyManager { - constructor(app) { - this.app = app; - this._backdrop = null; - } - - // ── Public API ─────────────────────────────────────────────────── - - open() { - if (this._backdrop) return; - this._render(); - this._loadKeys(); - } - - close() { - if (this._backdrop) { - this._backdrop.classList.remove("is-visible"); - const modal = this._backdrop.querySelector(".modal"); - if (modal) modal.classList.remove("is-visible"); - setTimeout(() => { - if (this._backdrop) { - this._backdrop.remove(); - this._backdrop = null; - } - }, 200); - } - } - - // ── Rendering ──────────────────────────────────────────────────── - - _render() { - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-lg"); - - modal.innerHTML = ` - - - `; - - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - this._backdrop = backdrop; - - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - - // Events - modal - .querySelector(".modal-close") - .addEventListener("click", () => this.close()); - backdrop.addEventListener("click", (e) => { - if (e.target === backdrop) this.close(); - }); - modal - .querySelector(".sshkey-generate-btn") - .addEventListener("click", () => this._showGenerateForm()); - } - - async _loadKeys() { - const list = this._backdrop.querySelector(".sshkey-list"); - if (!list) return; - - list.innerHTML = '
Loading keys…
'; - - try { - const keys = await window.AlchementaryLink.wailsService.ListSSHKeyPairs(); - if (keys.length === 0) { - list.innerHTML = - '
No SSH keys found in ~/.ssh/
'; - return; - } - list.innerHTML = ""; - for (const key of keys) { - list.appendChild(this._renderKeyRow(key)); - } - } catch (err) { - list.innerHTML = ""; - list.appendChild( - createElement("div", "sshkey-error", { - textContent: "Failed to load keys: " + String(err), - }), - ); - } - } - - _renderKeyRow(key) { - const row = createElement("div", "sshkey-row"); - - const info = createElement("div", "sshkey-info"); - const name = createElement("div", "sshkey-name", { - textContent: key.name, - }); - const meta = createElement("div", "sshkey-meta", { - textContent: key.type.toUpperCase() + (key.bits ? ` ${key.bits}` : ""), - }); - info.appendChild(name); - info.appendChild(meta); - - const actions = createElement("div", "sshkey-actions"); - - const copyBtn = createElement("button", "btn btn-xs", { - textContent: "Copy Public Key", - }); - copyBtn.addEventListener("mousedown", (e) => e.preventDefault()); - copyBtn.addEventListener("click", () => this._copyPublicKey(key)); - - const deleteBtn = createElement("button", "btn btn-xs btn-danger", { - textContent: "Delete", - }); - deleteBtn.addEventListener("mousedown", (e) => e.preventDefault()); - deleteBtn.addEventListener("click", () => this._confirmDelete(key)); - - actions.appendChild(copyBtn); - actions.appendChild(deleteBtn); - - row.appendChild(info); - row.appendChild(actions); - return row; - } - - async _copyPublicKey(key) { - try { - const pubKey = await window.AlchementaryLink.wailsService.GetSSHPublicKey(key.name); - if (window.runtime && window.runtime.ClipboardSetText) { - window.runtime.ClipboardSetText(pubKey); - } else { - await navigator.clipboard.writeText(pubKey); - } - this.app.notifications.success("Public key copied to clipboard"); - } catch (err) { - this.app.notifications.error("Failed to copy: " + err); - } - } - - _confirmDelete(key) { - const confirmed = this._showConfirm( - "Delete Key", - `Delete key pair "${key.name}"? This cannot be undone.`, - "Delete", - ); - confirmed.then((ok) => { - if (ok) this._deleteKey(key); - }); - } - - async _deleteKey(key) { - try { - await window.AlchementaryLink.wailsService.DeleteSSHKey(key.name); - this.app.notifications.success(`Key "${key.name}" deleted`); - this._loadKeys(); - } catch (err) { - this.app.notifications.error("Delete failed: " + err); - } - } - - // ── Generate form ──────────────────────────────────────────────── - - _showGenerateForm() { - const list = this._backdrop.querySelector(".sshkey-list"); - if (!list) return; - - const form = createElement("div", "sshkey-generate-form"); - form.innerHTML = ` -
- - -
-
- - -
- -
- - -
-
- - -
- `; - - // Insert form above the key list - list.parentNode.insertBefore(form, list); - list.style.display = "none"; - - // Show bits field for RSA - const typeSelect = form.querySelector(".sshkey-type-select"); - const bitsGroup = form.querySelector(".sshkey-bits-group"); - typeSelect.addEventListener("change", () => { - bitsGroup.style.display = typeSelect.value === "rsa" ? "" : "none"; - }); - - form.querySelector(".sshkey-cancel-btn").addEventListener("click", () => { - form.remove(); - list.style.display = ""; - }); - - form - .querySelector(".sshkey-create-btn") - .addEventListener("click", async () => { - const nameInput = form.querySelector(".sshkey-name-input"); - const name = nameInput.value.trim(); - const keyType = typeSelect.value; - const bits = parseInt( - form.querySelector(".sshkey-bits-select").value, - 10, - ); - const passphrase = form.querySelector(".sshkey-pass-input").value; - - if (!name) { - this.app.notifications.error("Key name is required"); - return; - } - - try { - await window.AlchementaryLink.wailsService.GenerateSSHKey( - name, - keyType, - bits, - passphrase, - ); - this.app.notifications.success( - `Key "${name}" generated successfully`, - ); - form.remove(); - list.style.display = ""; - this._loadKeys(); - } catch (err) { - this.app.notifications.error("Generation failed: " + err); - } - }); - - form.querySelector(".sshkey-name-input").focus(); - } - - // ── Confirmation helper ────────────────────────────────────────── - - _showConfirm(title, message, confirmLabel) { - return new Promise((resolve) => { - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-sm"); - const header = createElement("div", "modal-header"); - header.appendChild( - createElement("h3", "modal-title", { textContent: title }), - ); - header.appendChild( - createElement("button", "btn btn-icon btn-xs modal-close", { - textContent: "x", - type: "button", - "aria-label": "Close", - }), - ); - - const body = createElement("div", "modal-body"); - const messageEl = createElement("p", "", { textContent: message }); - messageEl.style.fontSize = "var(--text-sm)"; - messageEl.style.color = "var(--text-secondary)"; - body.appendChild(messageEl); - - const footer = createElement("div", "modal-footer"); - footer.appendChild( - createElement("button", "btn btn-sm confirm-cancel", { - textContent: "Cancel", - type: "button", - }), - ); - footer.appendChild( - createElement("button", "btn btn-sm btn-danger confirm-ok", { - textContent: confirmLabel, - type: "button", - }), - ); - - modal.appendChild(header); - modal.appendChild(body); - modal.appendChild(footer); - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - - const close = (val) => { - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(() => backdrop.remove(), 200); - resolve(val); - }; - modal - .querySelector(".modal-close") - .addEventListener("click", () => close(false)); - modal - .querySelector(".confirm-cancel") - .addEventListener("click", () => close(false)); - modal - .querySelector(".confirm-ok") - .addEventListener("click", () => close(true)); - backdrop.addEventListener("click", (e) => { - if (e.target === backdrop) close(false); - }); - }); - } - } - - window.AlchementaryLink = window.AlchementaryLink || {}; - window.AlchementaryLink.SSHKeyManager = SSHKeyManager; -})(); diff --git a/frontend/src/js/state.js b/frontend/src/js/state.js deleted file mode 100644 index cdf72db..0000000 --- a/frontend/src/js/state.js +++ /dev/null @@ -1,240 +0,0 @@ -(function () { - /** - * Alchementary Link — application state management. - * Provides a centralised store for profiles, sessions, and preferences, - * emitting change events so the UI stays in sync. - */ - - class State { - constructor() { - /** @type {Array} */ - this.profiles = []; - - /** @type {Map} sessionId → SessionInfo */ - this.sessions = new Map(); - - /** @type {string|null} */ - this.activeSessionId = null; - - /** @type {Object} */ - this.preferences = {}; - - /** @type {boolean} */ - this.secretStoreUnlocked = false; - - /** @type {boolean} */ - this.secretStoreInitialised = false; - - /** @type {boolean} Whether broadcast mode is active (keystrokes to all sessions). */ - this.broadcastMode = false; - - /** @type {Set|null} Session IDs to broadcast to. Null means all. */ - this.broadcastTargets = null; - - /** @type {Map>} event name → callbacks */ - this._listeners = new Map(); - } - - // ── Event emitter ──────────────────────────────────────────────── - - /** - * Subscribes to a state change event. - * @param {string} event - * @param {Function} callback - */ - on(event, callback) { - if (!this._listeners.has(event)) { - this._listeners.set(event, new Set()); - } - this._listeners.get(event).add(callback); - } - - /** - * Unsubscribes from a state change event. - * @param {string} event - * @param {Function} callback - */ - off(event, callback) { - const set = this._listeners.get(event); - if (set) { - set.delete(callback); - } - } - - /** - * Emits a state change event to all subscribers. - * @param {string} event - * @param {*} data - */ - emit(event, data) { - const set = this._listeners.get(event); - if (set) { - for (const cb of set) { - try { - cb(data); - } catch (err) { - console.error(`State listener error [${event}]:`, err); - } - } - } - } - - // ── Profiles ───────────────────────────────────────────────────── - - /** - * Replaces the entire profiles list. - * @param {Array} profiles - */ - setProfiles(profiles) { - this.profiles = profiles; - this.emit("profiles:changed", this.profiles); - } - - /** - * Adds a single profile. - * @param {Object} profile - */ - addProfile(profile) { - this.profiles.push(profile); - this.emit("profiles:changed", this.profiles); - this.emit("profile:added", profile); - } - - /** - * Updates an existing profile in place. - * @param {Object} profile - */ - updateProfile(profile) { - const idx = this.profiles.findIndex((p) => p.id === profile.id); - if (idx !== -1) { - this.profiles[idx] = profile; - } - this.emit("profiles:changed", this.profiles); - this.emit("profile:updated", profile); - } - - /** - * Removes a profile by ID. - * @param {string} id - */ - removeProfile(id) { - this.profiles = this.profiles.filter((p) => p.id !== id); - this.emit("profiles:changed", this.profiles); - this.emit("profile:removed", id); - } - - /** - * Returns a profile by ID, or undefined. - * @param {string} id - * @returns {Object|undefined} - */ - getProfile(id) { - return this.profiles.find((p) => p.id === id); - } - - // ── Sessions ───────────────────────────────────────────────────── - - /** - * Registers a new session. - * @param {Object} sessionInfo - */ - addSession(sessionInfo) { - this.sessions.set(sessionInfo.id, sessionInfo); - this.emit("sessions:changed", this.sessions); - this.emit("session:added", sessionInfo); - } - - /** - * Merges partial updates into an existing session. - * @param {string} id - * @param {Object} updates - */ - updateSession(id, updates) { - const existing = this.sessions.get(id); - if (existing) { - Object.assign(existing, updates); - this.emit("sessions:changed", this.sessions); - this.emit("session:updated", existing); - } - } - - /** - * Removes a session. - * @param {string} id - */ - removeSession(id) { - this.sessions.delete(id); - if (this.broadcastTargets) { - this.broadcastTargets.delete(id); - } - if (this.activeSessionId === id) { - // Activate the most recent remaining session, or null - const remaining = [...this.sessions.keys()]; - this.activeSessionId = - remaining.length > 0 ? remaining[remaining.length - 1] : null; - } - this.emit("sessions:changed", this.sessions); - this.emit("session:removed", id); - } - - /** - * Returns session info by ID, or undefined. - * @param {string} id - * @returns {Object|undefined} - */ - getSession(id) { - return this.sessions.get(id); - } - - /** - * Sets the currently visible/active session. - * @param {string|null} id - */ - setActiveSession(id) { - this.activeSessionId = id; - this.emit("session:activated", id); - } - - /** - * Returns an ordered array of all sessions. - * @returns {Array} - */ - getAllSessions() { - return [...this.sessions.values()]; - } - - // ── Preferences ────────────────────────────────────────────────── - - /** - * Replaces the entire preferences object. - * @param {Object} prefs - */ - setPreferences(prefs) { - this.preferences = prefs; - this.emit("preferences:changed", this.preferences); - } - - // ── Secret store ───────────────────────────────────────────────── - - /** - * Updates the secret store state. - * @param {boolean} unlocked - */ - setSecretStoreState(unlocked) { - this.secretStoreUnlocked = unlocked; - this.emit("secretStore:changed", unlocked); - } - - /** - * Marks whether the secret store has been initialised (vault exists). - * @param {boolean} initialised - */ - setSecretStoreInitialised(initialised) { - this.secretStoreInitialised = initialised; - this.emit("secretStore:init", initialised); - } - } - - window.AlchementaryLink = window.AlchementaryLink || {}; - window.AlchementaryLink.State = State; -})(); diff --git a/frontend/src/js/tabs.js b/frontend/src/js/tabs.js deleted file mode 100644 index 42477fe..0000000 --- a/frontend/src/js/tabs.js +++ /dev/null @@ -1,933 +0,0 @@ -(function () { - /** - * Alchementary Link — tab management. - * Handles the tab bar: adding, removing, reordering, renaming, - * pinning tabs, and context menus. - */ - - const { - createElement, - escapeHtml, - protocolColour, - stateLabel, - dismissContextMenus, - } = window.AlchementaryLink; - - class TabManager { - /** - * @param {App} app - */ - constructor(app) { - this.app = app; - - /** @type {HTMLElement} The inner tab list where tab elements are inserted. */ - this.tabList = document.getElementById("tab-list"); - - /** @type {HTMLElement} The outer tab bar wrapper (used for drag-and-drop). */ - this.tabBar = document.getElementById("tab-bar"); - - /** @type {HTMLElement} The container for terminal/viewer panels. */ - this.panelContainer = document.getElementById("panel-container"); - - /** @type {string[]} Ordered list of session IDs. */ - this.order = []; - - this.tooltip = null; - this.tooltipTimer = null; - this.tooltipSessionId = null; - - this._setupDragAndDrop(); - this._setupScrolling(); - this._setupTooltip(); - } - - // ── Public API ─────────────────────────────────────────────────── - - /** - * Adds a new tab for a session and makes it active. - * @param {Object} sessionInfo - */ - addTab(sessionInfo) { - this.order.push(sessionInfo.id); - this._renderTab(sessionInfo); - - // Create panel container for this session's terminal/viewer - const panel = createElement("div", "session-panel", { - id: `panel-${sessionInfo.id}`, - dataset: { sessionId: sessionInfo.id }, - }); - panel.style.display = "none"; - this.panelContainer.appendChild(panel); - - // Hide the empty-state BEFORE activating the panel so the - // panel-container gets the full workspace height immediately. - this._updateEmptyState(); - this._updateScrollState(); - this.setActive(sessionInfo.id); - } - - /** - * Removes a tab and its panel. - * @param {string} sessionId - */ - removeTab(sessionId) { - this._hideTabTooltip(); - const idx = this.order.indexOf(sessionId); - const wasActive = this.app.state.activeSessionId === sessionId; - if (idx !== -1) this.order.splice(idx, 1); - - // Remove tab element - const tabEl = this.tabList.querySelector( - `[data-session-id="${sessionId}"]`, - ); - if (tabEl) tabEl.remove(); - - // Remove panel - const panel = document.getElementById(`panel-${sessionId}`); - if (panel) panel.remove(); - - // Activate the nearest neighbour - if (wasActive) { - const next = - this.order.length > 0 - ? this.order[Math.min(idx, this.order.length - 1)] - : null; - if (next) { - this.setActive(next); - } else { - this.app.state.setActiveSession(null); - } - } - - this._updateEmptyState(); - this._updateScrollState(); - } - - /** - * Switches the visible tab/panel to the given session. - * @param {string} sessionId - */ - setActive(sessionId) { - this.app.state.setActiveSession(sessionId); - - // Highlight active tab and clear activity indicator - this.tabList.querySelectorAll(".tab").forEach((t) => { - t.classList.toggle("tab-active", t.dataset.sessionId === sessionId); - if (t.dataset.sessionId === sessionId) { - t.classList.remove("tab-has-activity"); - } - }); - - // Show corresponding panel, hide others - this.panelContainer.querySelectorAll(".session-panel").forEach((p) => { - p.style.display = p.dataset.sessionId === sessionId ? "flex" : "none"; - }); - - // Notify sidebar - const session = this.app.state.getSession(sessionId); - if (session) { - this.app.sidebar.highlightActive(session.profileId); - } - this.app.leviathan?.handleTabFocus?.(sessionId); - this._scrollTabIntoView(sessionId); - - // Fit and refresh the terminal for the newly active session. - // Double-RAF ensures the panel has layout dimensions before fitting. - requestAnimationFrame(() => { - this.app.terminals.fitSession(sessionId); - const entry = this.app.terminals.terminals.get(sessionId); - if (entry) { - entry.term.refresh(0, entry.term.rows - 1); - } - requestAnimationFrame(() => { - this.app.terminals.focusTerminal(sessionId); - }); - }); - } - - /** - * Closes the currently active tab. - */ - closeActive() { - if (this.app.state.activeSessionId) { - this.closeTab(this.app.state.activeSessionId); - } - } - - /** - * Reconnects a session by ID. - * @param {string} sessionId - */ - reconnectSession(sessionId) { - return this._reconnect(sessionId); - } - - /** - * Disconnects and closes a tab by session ID. - * @param {string} sessionId - */ - async closeTab(sessionId) { - this._hideTabTooltip(); - const session = this.app.state.getSession(sessionId); - - // Ask for confirmation if connected and the preference is set - if ( - session && - session.state === "connected" && - session.protocol !== "leviathan" && - session.protocol !== "github" && - this.app.state.preferences.confirmOnDisconnect - ) { - const ok = await this._confirmClose(sessionId); - if (!ok) return; - } - - await this.app.closeSessionImmediate(sessionId); - } - - /** - * Shows a confirmation modal before closing a connected session. - * @param {string} sessionId - * @returns {Promise} - * @private - */ - _confirmClose(sessionId) { - const createElement = window.AlchementaryLink.createElement; - const label = escapeHtml(this._tabLabel(sessionId)); - return new Promise((resolve) => { - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-sm"); - modal.innerHTML = ` - - - - `; - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - - const close = (val) => { - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(() => backdrop.remove(), 200); - resolve(val); - }; - modal - .querySelector(".modal-close") - .addEventListener("click", () => close(false)); - modal - .querySelector(".confirm-cancel") - .addEventListener("click", () => close(false)); - modal - .querySelector(".confirm-ok") - .addEventListener("click", () => close(true)); - backdrop.addEventListener("click", (e) => { - if (e.target === backdrop) close(false); - }); - }); - } - - /** - * Renames a tab. - * @param {string} sessionId - * @param {string} name - */ - renameTab(sessionId, name) { - this.app.state.updateSession(sessionId, { tabName: name }); - const tabEl = this.tabList.querySelector( - `[data-session-id="${sessionId}"]`, - ); - if (tabEl) { - tabEl.querySelector(".tab-name").textContent = name; - } - } - - /** - * Pins a tab (moves it to the front). - * @param {string} sessionId - */ - pinTab(sessionId) { - this.app.state.updateSession(sessionId, { pinned: true }); - this._reorderDOM(); - this._updateTabStyles(sessionId); - } - - /** - * Unpins a tab. - * @param {string} sessionId - */ - unpinTab(sessionId) { - this.app.state.updateSession(sessionId, { pinned: false }); - this._reorderDOM(); - this._updateTabStyles(sessionId); - } - - /** - * Moves a tab one position to the left. - * @param {string} sessionId - */ - moveLeft(sessionId) { - const idx = this.order.indexOf(sessionId); - if (idx > 0) { - [this.order[idx - 1], this.order[idx]] = [ - this.order[idx], - this.order[idx - 1], - ]; - this._reorderDOM(); - } - } - - /** - * Moves a tab one position to the right. - * @param {string} sessionId - */ - moveRight(sessionId) { - const idx = this.order.indexOf(sessionId); - if (idx >= 0 && idx < this.order.length - 1) { - [this.order[idx], this.order[idx + 1]] = [ - this.order[idx + 1], - this.order[idx], - ]; - this._reorderDOM(); - } - } - - /** - * Opens a new session with the same profile as the given tab. - * @param {string} sessionId - */ - async duplicateTab(sessionId) { - const session = this.app.state.getSession(sessionId); - if (session && session.profileId) { - await this.app.connectProfile(session.profileId); - } - } - - /** - * Updates the visual state of a tab when session state changes. - * @param {string} sessionId - * @param {string} state - connecting | connected | disconnected | error - * @param {string} [error] - */ - updateSessionState(sessionId, state, error) { - this.app.state.updateSession(sessionId, { state, error: error || "" }); - - const tabEl = this.tabList.querySelector( - `[data-session-id="${sessionId}"]`, - ); - if (!tabEl) return; - - const dot = tabEl.querySelector(".tab-status"); - if (dot) { - dot.className = `tab-status status-${state}`; - dot.title = stateLabel(state); - } - - if (state === "error" && error) { - this.app.notifications.error(`${this._tabLabel(sessionId)}: ${error}`); - } - } - - /** - * Handles a session closed event from the backend. - * @param {string} sessionId - */ - handleSessionClosed(sessionId) { - this.updateSessionState(sessionId, "disconnected"); - } - - /** - * Shows a right-click context menu for a tab. - * @param {string} sessionId - * @param {number} x - * @param {number} y - */ - showContextMenu(sessionId, x, y) { - dismissContextMenus(); - const session = this.app.state.getSession(sessionId); - if (!session) return; - - const menu = createElement("div", "context-menu"); - menu.style.left = `${x}px`; - menu.style.top = `${y}px`; - - const isPinned = session.pinned; - - const items = [ - { label: "Rename…", action: () => this._promptRename(sessionId) }, - { label: "Duplicate", action: () => this.duplicateTab(sessionId) }, - { divider: true }, - { - label: isPinned ? "Unpin" : "Pin", - action: () => - isPinned ? this.unpinTab(sessionId) : this.pinTab(sessionId), - }, - { label: "Move Left", action: () => this.moveLeft(sessionId) }, - { label: "Move Right", action: () => this.moveRight(sessionId) }, - { divider: true }, - { - label: "Reconnect", - action: () => this._reconnect(sessionId), - disabled: - session.state === "connected" || session.state === "connecting", - }, - { divider: true }, - { - label: "Close", - className: "context-menu-danger", - action: () => this.closeTab(sessionId), - }, - ]; - - for (const entry of items) { - if (entry.divider) { - menu.appendChild(createElement("div", "context-menu-divider")); - continue; - } - const btn = createElement( - "button", - `context-menu-item ${entry.className || ""}`, - { - textContent: entry.label, - }, - ); - if (entry.disabled) { - btn.disabled = true; - btn.classList.add("context-menu-disabled"); - } - btn.addEventListener("click", () => { - dismissContextMenus(); - entry.action(); - }); - menu.appendChild(btn); - } - - document.body.appendChild(menu); - - // Keep within viewport - const rect = menu.getBoundingClientRect(); - if (rect.right > window.innerWidth) - menu.style.left = `${window.innerWidth - rect.width - 8}px`; - if (rect.bottom > window.innerHeight) - menu.style.top = `${window.innerHeight - rect.height - 8}px`; - } - - /** - * Returns the current tab ordering. - * @returns {string[]} - */ - getTabOrder() { - return [...this.order]; - } - - /** - * Marks a background tab as having new activity. - * Does nothing if the session is currently active. - * @param {string} sessionId - */ - markActivity(sessionId) { - if (sessionId === this.app.state.activeSessionId) return; - const tabEl = this.tabList.querySelector( - `[data-session-id="${sessionId}"]`, - ); - if (tabEl && !tabEl.classList.contains("tab-has-activity")) { - tabEl.classList.add("tab-has-activity"); - } - } - - /** - * Returns the session ID of the next tab (wrapping round). - * @returns {string|null} - */ - getNextTab() { - if (this.order.length === 0) return null; - const idx = this.order.indexOf(this.app.state.activeSessionId); - return this.order[(idx + 1) % this.order.length]; - } - - /** - * Returns the session ID of the previous tab (wrapping round). - * @returns {string|null} - */ - getPrevTab() { - if (this.order.length === 0) return null; - const idx = this.order.indexOf(this.app.state.activeSessionId); - return this.order[(idx - 1 + this.order.length) % this.order.length]; - } - - // ── Rendering ──────────────────────────────────────────────────── - - /** - * Creates and appends a tab element. - * @param {Object} sessionInfo - * @private - */ - _renderTab(sessionInfo) { - const tab = createElement("div", "tab", { - dataset: { sessionId: sessionInfo.id }, - draggable: "true", - }); - - // Close button - const closeBtn = createElement("button", "tab-close", { - textContent: "×", - "aria-label": "Close tab", - }); - closeBtn.addEventListener("click", (e) => { - e.stopPropagation(); - this._hideTabTooltip(); - this.closeTab(sessionInfo.id); - }); - tab.appendChild(closeBtn); - - // Protocol badge - const protoLabel = - sessionInfo.protocol === "github" - ? "GitHub" - : sessionInfo.protocol === "local" - ? "Local" - : sessionInfo.protocol.toUpperCase(); - const badge = createElement( - "span", - `tab-badge badge-${sessionInfo.protocol}`, - { - textContent: protoLabel, - }, - ); - tab.appendChild(badge); - - // Name - const name = createElement("span", "tab-name", { - textContent: sessionInfo.tabName || sessionInfo.protocol.toUpperCase(), - }); - tab.appendChild(name); - - // Status dot - const status = createElement( - "span", - `tab-status status-${sessionInfo.state || "connecting"}`, - { - title: stateLabel(sessionInfo.state), - }, - ); - tab.appendChild(status); - - // Activity indicator (hidden by default, shown when background tab has output) - const activity = createElement("span", "tab-activity"); - tab.appendChild(activity); - - // Pin indicator - if (sessionInfo.pinned) { - tab.classList.add("tab-pinned"); - } - - // SFTP toggle button (SSH sessions only) - if (sessionInfo.protocol === "ssh") { - const sftpBtn = createElement("button", "tab-sftp", { - textContent: "📁", - title: "Toggle SFTP file browser", - "aria-label": "Toggle SFTP", - }); - sftpBtn.addEventListener("click", (e) => { - e.stopPropagation(); - this.app.sftp.toggle(sessionInfo.id); - }); - tab.appendChild(sftpBtn); - } - - // Click → activate - tab.addEventListener("click", () => this.setActive(sessionInfo.id)); - - // Right-click → context menu - tab.addEventListener("contextmenu", (e) => { - e.preventDefault(); - this.showContextMenu(sessionInfo.id, e.clientX, e.clientY); - }); - - this.tabList.appendChild(tab); - } - - // ── Internal helpers ───────────────────────────────────────────── - - /** - * Reorders the tab-bar DOM elements to match `this.order`, - * with pinned tabs always first. - * @private - */ - _reorderDOM() { - // Sort: pinned first, then by order index - const sorted = [...this.order].sort((a, b) => { - const sa = this.app.state.getSession(a); - const sb = this.app.state.getSession(b); - const pinnedA = sa?.pinned ? 0 : 1; - const pinnedB = sb?.pinned ? 0 : 1; - if (pinnedA !== pinnedB) return pinnedA - pinnedB; - return this.order.indexOf(a) - this.order.indexOf(b); - }); - - for (const id of sorted) { - const el = this.tabList.querySelector(`[data-session-id="${id}"]`); - if (el) this.tabList.appendChild(el); - } - this._updateScrollState(); - } - - /** - * Updates the visual pin state on a tab element. - * @param {string} sessionId - * @private - */ - _updateTabStyles(sessionId) { - const session = this.app.state.getSession(sessionId); - const tabEl = this.tabList.querySelector( - `[data-session-id="${sessionId}"]`, - ); - if (tabEl && session) { - tabEl.classList.toggle("tab-pinned", !!session.pinned); - } - } - - /** - * Makes the tab strip usable when the tab list overflows horizontally. - * @private - */ - _setupScrolling() { - if (!this.tabList) return; - this.tabList.addEventListener( - "wheel", - (event) => { - if (this.tabList.scrollWidth <= this.tabList.clientWidth) return; - const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) - ? event.deltaX - : event.deltaY; - if (!delta) return; - event.preventDefault(); - this.tabList.scrollLeft += delta; - this._updateScrollState(); - }, - { passive: false }, - ); - this.tabList.addEventListener("scroll", () => this._updateScrollState(), { - passive: true, - }); - window.addEventListener("resize", () => this._updateScrollState()); - } - - /** - * Keeps the active tab visible inside the scrollable tab list. - * @param {string} sessionId - * @private - */ - _scrollTabIntoView(sessionId) { - const tab = this.tabList?.querySelector(`[data-session-id="${sessionId}"]`); - if (!tab) return; - requestAnimationFrame(() => { - tab.scrollIntoView({ - behavior: "smooth", - block: "nearest", - inline: "nearest", - }); - this._updateScrollState(); - }); - } - - /** - * Reflects whether tab overflow exists at either edge. - * @private - */ - _updateScrollState() { - if (!this.tabList || !this.tabBar) return; - const max = this.tabList.scrollWidth - this.tabList.clientWidth; - this.tabBar.classList.toggle("has-tab-overflow", max > 1); - this.tabBar.classList.toggle("has-overflow-left", this.tabList.scrollLeft > 1); - this.tabBar.classList.toggle( - "has-overflow-right", - max > 1 && this.tabList.scrollLeft < max - 1, - ); - } - - /** - * Shows a custom hover hint for tabs with full names and connection start. - * @private - */ - _setupTooltip() { - if (!this.tabList) return; - this.tooltip = createElement("div", "tooltip tab-tooltip", { - role: "tooltip", - }); - document.body.appendChild(this.tooltip); - - this.tabList.addEventListener("pointerover", (event) => { - const tab = event.target.closest(".tab"); - if (!tab || !this.tabList.contains(tab)) return; - if (tab.contains(event.relatedTarget)) return; - this._clearTabTooltipTimer(); - this.tooltipTimer = setTimeout(() => { - this.tooltipTimer = null; - this._showTabTooltip(tab.dataset.sessionId, tab); - }, 250); - }); - - this.tabList.addEventListener("pointerdown", () => this._hideTabTooltip()); - - this.tabList.addEventListener("pointermove", (event) => { - const tab = event.target.closest(".tab"); - if (!tab || !this.tooltip?.classList.contains("is-visible")) return; - this._positionTabTooltip(tab); - }); - - this.tabList.addEventListener("pointerout", (event) => { - const tab = event.target.closest(".tab"); - if (!tab || tab.contains(event.relatedTarget)) return; - this._hideTabTooltip(); - }); - - this.tabList.addEventListener("scroll", () => this._hideTabTooltip(), { - passive: true, - }); - window.addEventListener("blur", () => this._hideTabTooltip()); - document.addEventListener("visibilitychange", () => { - if (document.hidden) this._hideTabTooltip(); - }); - } - - /** - * Renders and positions the tab hover tooltip. - * @param {string} sessionId - * @param {HTMLElement} tab - * @private - */ - _showTabTooltip(sessionId, tab) { - const session = this.app.state.getSession(sessionId); - if (!session || !this.tooltip || !tab?.isConnected || !this.tabList.contains(tab)) { - this._hideTabTooltip(); - return; - } - this.tooltip.replaceChildren(); - this.tooltip.appendChild( - createElement("div", "tab-tooltip-title", { - textContent: this._tabLabel(sessionId), - }), - ); - if ( - session.protocol !== "leviathan" && - session.state === "connected" && - session.connectedAt - ) { - const connected = this._formatConnectedSince(session.connectedAt); - if (connected) { - this.tooltip.appendChild( - createElement("div", "tab-tooltip-meta", { - textContent: `Connected since: ${connected}`, - }), - ); - } - } - this._positionTabTooltip(tab); - this.tooltipSessionId = sessionId; - this.tooltip.classList.add("is-visible"); - } - - /** - * Positions the tab tooltip under the hovered tab. - * @param {HTMLElement} tab - * @private - */ - _positionTabTooltip(tab) { - if (!this.tooltip) return; - if (!tab?.isConnected || !this.tabList.contains(tab)) { - this._hideTabTooltip(); - return; - } - const rect = tab.getBoundingClientRect(); - const tooltipRect = this.tooltip.getBoundingClientRect(); - const left = Math.min( - Math.max(8, rect.left + rect.width / 2 - tooltipRect.width / 2), - window.innerWidth - tooltipRect.width - 8, - ); - const top = Math.min(rect.bottom + 8, window.innerHeight - tooltipRect.height - 8); - this.tooltip.style.left = `${left}px`; - this.tooltip.style.top = `${top}px`; - } - - /** - * Hides the tab hover tooltip. - * @private - */ - _hideTabTooltip() { - this._clearTabTooltipTimer(); - this.tooltipSessionId = null; - if (!this.tooltip) return; - this.tooltip.classList.remove("is-visible"); - this.tooltip.replaceChildren(); - } - - /** - * Cancels any pending tab tooltip show timer. - * @private - */ - _clearTabTooltipTimer() { - if (!this.tooltipTimer) return; - clearTimeout(this.tooltipTimer); - this.tooltipTimer = null; - } - - /** - * Formats a session connection timestamp for the tab tooltip. - * @param {string} value - * @returns {string} - * @private - */ - _formatConnectedSince(value) { - const date = new Date(value); - if (Number.isNaN(date.getTime()) || date.getFullYear() <= 1) return ""; - try { - return date.toLocaleString([], { - dateStyle: "medium", - timeStyle: "short", - }); - } catch (_) { - return date.toLocaleString(); - } - } - - /** - * Shows/hides the empty-state panel based on whether tabs exist. - * @private - */ - _updateEmptyState() { - const empty = document.getElementById("empty-state"); - if (empty) { - empty.style.display = this.order.length === 0 ? "flex" : "none"; - } - } - - /** - * Prompts the user to rename a tab. - * @param {string} sessionId - * @private - */ - _promptRename(sessionId) { - const session = this.app.state.getSession(sessionId); - const current = session?.tabName || ""; - // Use the app's custom prompt modal (window.prompt is unavailable in WKWebView) - this.app._inputPrompt("Rename Tab", "Tab name", current).then((name) => { - if (name !== null && name.trim()) { - this.renameTab(sessionId, name.trim()); - } - }); - } - - /** - * Attempts to reconnect a disconnected session. - * @param {string} sessionId - * @private - */ - async _reconnect(sessionId) { - const session = this.app.state.getSession(sessionId); - if (!session || !session.profileId) return; - - // Fully close the old session before opening a fresh connection. - await this.app.closeSessionImmediate(sessionId); - await this.app.connectProfile(session.profileId); - } - - /** - * Returns the display label for a session's tab. - * @param {string} sessionId - * @returns {string} - * @private - */ - _tabLabel(sessionId) { - const session = this.app.state.getSession(sessionId); - return session?.tabName || "Session"; - } - - // ── Drag and drop ──────────────────────────────────────────────── - - /** - * Sets up drag-and-drop reordering on the tab bar. - * @private - */ - _setupDragAndDrop() { - if (!this.tabList) return; - - let draggedId = null; - - this.tabList.addEventListener("dragstart", (e) => { - const tab = e.target.closest(".tab"); - if (!tab) return; - draggedId = tab.dataset.sessionId; - tab.classList.add("tab-dragging"); - e.dataTransfer.effectAllowed = "move"; - }); - - this.tabList.addEventListener("dragover", (e) => { - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - - const target = e.target.closest(".tab"); - if (!target || target.dataset.sessionId === draggedId) return; - - const rect = target.getBoundingClientRect(); - const midX = rect.left + rect.width / 2; - target.classList.toggle("tab-drop-before", e.clientX < midX); - target.classList.toggle("tab-drop-after", e.clientX >= midX); - }); - - this.tabList.addEventListener("dragleave", (e) => { - const target = e.target.closest(".tab"); - if (target) { - target.classList.remove("tab-drop-before", "tab-drop-after"); - } - }); - - this.tabList.addEventListener("drop", (e) => { - e.preventDefault(); - const target = e.target.closest(".tab"); - if (!target || !draggedId) return; - - target.classList.remove("tab-drop-before", "tab-drop-after"); - - const targetId = target.dataset.sessionId; - if (targetId === draggedId) return; - - // Reorder - const fromIdx = this.order.indexOf(draggedId); - const toIdx = this.order.indexOf(targetId); - if (fromIdx === -1 || toIdx === -1) return; - - this.order.splice(fromIdx, 1); - this.order.splice(toIdx, 0, draggedId); - this._reorderDOM(); - }); - - this.tabList.addEventListener("dragend", () => { - this.tabList.querySelectorAll(".tab").forEach((t) => { - t.classList.remove( - "tab-dragging", - "tab-drop-before", - "tab-drop-after", - ); - }); - draggedId = null; - }); - } - } - - window.AlchementaryLink = window.AlchementaryLink || {}; - window.AlchementaryLink.TabManager = TabManager; -})(); diff --git a/frontend/src/js/terminal.js b/frontend/src/js/terminal.js deleted file mode 100644 index 257e35f..0000000 --- a/frontend/src/js/terminal.js +++ /dev/null @@ -1,1613 +0,0 @@ -(function () { - /** - * Alchementary Link — xterm.js terminal manager. - * Creates, manages, and destroys xterm.js instances for SSH and Telnet sessions. - * Handles data routing, resizing, and search. - */ - - /* global Terminal, FitAddon, SearchAddon, WebLinksAddon, Unicode11Addon */ - - const UPLOAD_CHUNK_BYTES = 768 * 1024; - - /** - * Encode a string as UTF-8 bytes then base64-encode them. - * Unlike bare btoa(), this handles characters above U+007F correctly - * (e.g. degree symbol °, emoji) and won't throw InvalidCharacterError. - */ - function utf8ToBase64(str) { - const bytes = new TextEncoder().encode(str); - let binary = ""; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); - } - - class TerminalManager { - /** - * @param {App} app - */ - constructor(app) { - this.app = app; - - /** - * Map of sessionId → { term, fitAddon, searchAddon, observer }. - * @type {Map} - */ - this.terminals = new Map(); - } - - // ── Public API ─────────────────────────────────────────────────── - - /** - * Creates and opens an xterm.js terminal inside the given container. - * Wires up data input → backend and container resizing. - * - * @param {string} sessionId - * @param {string} containerId - DOM id of the panel element. - * @param {Object} profile - The connection profile (used for font/theme prefs). - * @returns {Terminal} The xterm.js Terminal instance. - */ - createTerminal(sessionId, containerId, profile) { - const themeName = - (this.app.state.preferences || {}).terminalTheme || "charcoal"; - const term = new Terminal({ - theme: window.AlchementaryLink.getTerminalTheme(themeName), - fontFamily: - profile.terminalFontFamily || - 'JetBrains Mono, Menlo, Monaco, Consolas, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace', - fontSize: profile.terminalFontSize || 14, - scrollback: profile.terminalScrollback || 10000, - cursorStyle: profile.terminalCursorStyle || "block", - cursorBlink: true, - allowProposedApi: true, - convertEol: false, - windowsMode: false, - scrollOnUserInput: true, - altClickMovesCursor: true, - }); - - // Load addons - const fitAddon = new FitAddon.FitAddon(); - const searchAddon = new SearchAddon.SearchAddon(); - let webLinksAddon = null; - let unicode11Addon = null; - - term.loadAddon(fitAddon); - term.loadAddon(searchAddon); - - try { - unicode11Addon = new Unicode11Addon.Unicode11Addon(); - term.loadAddon(unicode11Addon); - term.unicode.activeVersion = "11"; - } catch (_) { - // Unicode 11 addon is optional; fall back to default unicode handling. - } - - // WebLinksAddon may not always be available - try { - webLinksAddon = new WebLinksAddon.WebLinksAddon((_event, uri) => { - window.runtime.BrowserOpenURL(uri); - }); - term.loadAddon(webLinksAddon); - } catch (_) { - // Not critical — links just won't be clickable - } - - // Open the terminal in the DOM container - const container = document.getElementById(containerId); - term.open(container); - - let suppressPasteEvent = false; - const attachPasteGuard = () => { - const inputEl = term.textarea || container.querySelector("textarea"); - if (!inputEl) return; - inputEl.addEventListener("keydown", (e) => { - const mod = e.ctrlKey || e.metaKey; - if (!mod || e.key !== "v") return; - // Block the native paste when we handle Cmd/Ctrl+V ourselves. - if (!suppressPasteEvent) return; - e.preventDefault(); - e.stopPropagation(); - }); - inputEl.addEventListener("paste", (e) => { - if (!suppressPasteEvent) return; - e.preventDefault(); - e.stopPropagation(); - suppressPasteEvent = false; - }); - }; - attachPasteGuard(); - - // Right-click context menu on the terminal viewport - const termEl = container.querySelector(".xterm"); - if (termEl) { - termEl.addEventListener("contextmenu", (e) => { - e.preventDefault(); - this._showTerminalContextMenu(sessionId, term, e.clientX, e.clientY); - }); - } - - // Alternate-screen scroll passthrough for screen/tmux/byobu. - // When the terminal is in the alternate buffer (used by multiplexers), - // convert mouse-wheel events into arrow-key sequences so the remote - // application handles scrollback instead of xterm.js (which has no - // scrollback content in alternate mode). - const viewportEl = container.querySelector(".xterm-viewport"); - if (viewportEl) { - viewportEl.addEventListener( - "wheel", - (e) => { - if (term.buffer.active !== term.buffer.alternate) return; - // Only intercept when mouse tracking is NOT active (if mouse mode - // is on, xterm.js already forwards events correctly). - if ( - term.modes && - term.modes.mouseTrackingMode && - term.modes.mouseTrackingMode !== "none" - ) - return; - - e.preventDefault(); - e.stopPropagation(); - const lines = Math.max(1, Math.round(Math.abs(e.deltaY) / 40)); - const seq = e.deltaY < 0 ? "\x1b[A" : "\x1b[B"; - const data = seq.repeat(lines); - const b64 = utf8ToBase64(data); - try { - window.AlchementaryLink.wailsService.WriteSession(sessionId, b64); - } catch (_) { - /* non-critical */ - } - }, - { passive: false }, - ); - } - - // Drag-and-drop file handling - container.addEventListener("dragover", (e) => { - if ( - e.dataTransfer && - Array.from(e.dataTransfer.types || []).includes("Files") - ) { - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; - } - }); - container.addEventListener("drop", (e) => { - const files = e.dataTransfer ? e.dataTransfer.files : null; - if (!files || files.length === 0) return; - e.preventDefault(); - e.stopPropagation(); - this._handleFileDrop(sessionId, term, files); - }); - - let fitRaf = 0; - let forceNextFit = false; - let lastFitWidth = 0; - let lastFitHeight = 0; - - const cancelScheduledFit = () => { - if (!fitRaf) return; - cancelAnimationFrame(fitRaf); - fitRaf = 0; - }; - - const scheduleFit = (force = false) => { - forceNextFit = forceNextFit || force; - if (fitRaf) return; - - fitRaf = requestAnimationFrame(() => { - fitRaf = 0; - - if (!container.isConnected) { - forceNextFit = false; - return; - } - - const rect = container.getBoundingClientRect(); - const width = Math.round(rect.width); - const height = Math.round(rect.height); - if (width <= 0 || height <= 0) { - return; - } - - const dimensionsChanged = - width !== lastFitWidth || height !== lastFitHeight; - if (!forceNextFit && !dimensionsChanged) { - return; - } - - lastFitWidth = width; - lastFitHeight = height; - forceNextFit = false; - - try { - fitAddon.fit(); - // Forced fits do not emit onResize when xterm's geometry is - // unchanged. Always publish the fitted size so a new local PTY - // cannot remain at its 80x24 fallback dimensions. - this._sendTerminalSize(sessionId, term); - } catch (_) { - /* noop */ - } - }); - }; - - // Force a synchronous layout reflow so the container reports its - // true dimensions, then fit the terminal to fill the space. - // eslint-disable-next-line no-unused-expressions - container.offsetHeight; - scheduleFit(true); - // Belt-and-braces: retry once after a paint in case the initial - // reflow dimensions were still stale. - requestAnimationFrame(() => scheduleFit(true)); - - const refreshTerminal = () => { - try { - term.refresh(0, term.rows - 1); - } catch (_) { - /* noop */ - } - }; - - // Forward user keystrokes to the backend - term.onData((data) => { - const entry = this.terminals.get(sessionId); - if (entry?.localExited) { - if (data === "\r" || data === "\n") { - this._restartLocalTerminal(sessionId, entry); - } - return; - } - - try { - const b64 = utf8ToBase64(data); - window.AlchementaryLink.wailsService.WriteSession(sessionId, b64); - - // Broadcast to other connected sessions if enabled - if (this.app.state.broadcastMode) { - const targets = this.app.state.broadcastTargets; - for (const [otherId] of this.terminals) { - if (otherId === sessionId) continue; - // If targets are set, only broadcast to selected sessions - if (targets && !targets.has(otherId)) continue; - const otherSession = this.app.state.getSession(otherId); - if (otherSession && otherSession.state === "connected") { - try { - window.AlchementaryLink.wailsService.WriteSession(otherId, b64); - } catch (_) { - /* non-critical */ - } - } - } - } - } catch (err) { - console.error("WriteSession error:", err); - } - }); - - // Clipboard: copy selection on Cmd/Ctrl+C, paste on Cmd/Ctrl+V - term.attachCustomKeyEventHandler((e) => { - const mod = e.ctrlKey || e.metaKey; - - // Cmd/Ctrl+C — copy selected text (let xterm handle it normally if no selection) - if (mod && e.key === "c" && e.type === "keydown") { - const selection = term.getSelection(); - if (selection) { - if (window.runtime && window.runtime.ClipboardSetText) { - window.runtime.ClipboardSetText(selection); - } else { - navigator.clipboard.writeText(selection).catch(function () {}); - } - return false; // Prevent xterm from sending Ctrl+C - } - // No selection — let Ctrl+C pass through as SIGINT - return true; - } - - // Cmd/Ctrl+V — paste from clipboard - if (mod && e.key === "v" && e.type === "keydown") { - const prefs = this.app.state.preferences || {}; - if (prefs.useNativePaste === true) { - suppressPasteEvent = false; - return true; - } - suppressPasteEvent = true; - e.preventDefault(); - e.stopPropagation(); - setTimeout(() => { - suppressPasteEvent = false; - }, 250); - if (window.runtime && window.runtime.ClipboardGetText) { - window.runtime - .ClipboardGetText() - .then(function (text) { - if (text) term.paste(text); - }) - .catch(function () {}); - } else { - navigator.clipboard - .readText() - .then(function (text) { - if (text) term.paste(text); - }) - .catch(function () {}); - } - return false; - } - - return true; - }); - - // Notify backend when terminal dimensions change - term.onResize(({ cols, rows }) => { - this._sendTerminalSize(sessionId, { cols, rows }); - }); - - // Re-fit when the container itself resizes. ResizeObserver can fire - // repeatedly while xterm mutates its DOM, so schedule at most one fit - // per frame and skip work when the container dimensions are unchanged. - const observer = new ResizeObserver(() => { - scheduleFit(); - }); - observer.observe(container); - - this.terminals.set(sessionId, { - term, - fitAddon, - searchAddon, - webLinksAddon, - observer, - scheduleFit, - cancelScheduledFit, - refreshTerminal, - localExited: false, - localRestarting: false, - decoder: - typeof TextDecoder !== "undefined" ? new TextDecoder("utf-8") : null, - }); - - return term; - } - - /** - * Writes incoming backend data (base64-encoded) to the appropriate terminal. - * @param {string} sessionId - * @param {string} base64Data - */ - handleData(sessionId, base64Data) { - const entry = this.terminals.get(sessionId); - if (!entry) return; - - try { - const raw = atob(base64Data); - if (entry.decoder) { - const bytes = new Uint8Array(raw.length); - for (let i = 0; i < raw.length; i++) { - bytes[i] = raw.charCodeAt(i); - } - const text = entry.decoder.decode(bytes, { stream: true }); - entry.term.write(text); - } else { - entry.term.write(raw); - } - } catch (err) { - console.error("Failed to decode session data:", err); - } - } - - /** - * Tears down a terminal instance and frees resources. - * @param {string} sessionId - */ - destroyTerminal(sessionId) { - const entry = this.terminals.get(sessionId); - if (!entry) return; - - entry.observer.disconnect(); - entry.cancelScheduledFit(); - entry.term.dispose(); - this.terminals.delete(sessionId); - } - - /** - * Shows the terminal for a given session, hiding all others. - * @param {string} sessionId - */ - showTerminal(sessionId) { - for (const [id, entry] of this.terminals) { - const panel = document.getElementById(`panel-${id}`); - if (panel) { - panel.style.display = id === sessionId ? "flex" : "none"; - } - } - - // Re-fit the newly visible terminal - const entry = this.terminals.get(sessionId); - if (entry) { - entry.scheduleFit(true); - } - } - - /** - * Re-fits the currently active terminal to its container. - */ - fitActive() { - const id = this.app.state.activeSessionId; - if (id) this.fitSession(id); - } - - /** - * Re-fits a specific session's terminal to its container. - * @param {string} sessionId - */ - fitSession(sessionId) { - const entry = this.terminals.get(sessionId); - if (entry) { - entry.scheduleFit(true); - } - } - - /** - * Shows or hides the disconnected overlay for a session panel. - * @param {string} sessionId - * @param {boolean} isDisconnected - * @param {string} [error] - */ - setDisconnectedOverlay(sessionId, isDisconnected, error) { - const panel = document.getElementById(`panel-${sessionId}`); - if (!panel) return; - - const overlay = this._ensureDisconnectOverlay(panel, sessionId); - if (!overlay) return; - - const message = overlay.querySelector(".terminal-disconnect-message"); - if (message) { - message.textContent = error ? error : "Connection lost."; - } - - panel.classList.toggle("is-disconnected", isDisconnected); - overlay.classList.toggle("is-visible", isDisconnected); - } - - /** - * Marks a local shell as exited without obscuring its terminal buffer. - * Enter is intercepted by the terminal input handler and restarts it. - * @param {string} sessionId - * @param {boolean} isExited - */ - setLocalExited(sessionId, isExited) { - const entry = this.terminals.get(sessionId); - if (!entry) return; - - if (isExited && !entry.localExited) { - entry.term.write("\r\n[Exited - press enter to restart]\r\n"); - } - entry.localExited = isExited; - entry.localRestarting = false; - if (isExited) entry.term.focus(); - } - - /** - * Searches within a terminal using the search addon. - * @param {string} sessionId - * @param {string} query - * @param {Object} [options] - * @param {boolean} [options.regex=false] - * @param {boolean} [options.caseSensitive=false] - * @param {boolean} [options.wholeWord=false] - * @returns {boolean} Whether a match was found. - */ - search(sessionId, query, options = {}) { - const entry = this.terminals.get(sessionId); - if (!entry) return false; - - if (!query) { - entry.searchAddon.clearDecorations(); - return false; - } - - return entry.searchAddon.findNext(query, { - regex: options.regex || false, - caseSensitive: options.caseSensitive || false, - wholeWord: options.wholeWord || false, - decorations: { - matchBackground: "#ffa72633", - activeMatchBackground: "#4ecdc466", - matchBorder: "#ffa726", - activeMatchBorder: "#4ecdc4", - }, - }); - } - - /** - * Finds the previous search result. - * @param {string} sessionId - * @param {string} query - * @param {Object} [options] - * @returns {boolean} - */ - searchPrevious(sessionId, query, options = {}) { - const entry = this.terminals.get(sessionId); - if (!entry || !query) return false; - return entry.searchAddon.findPrevious(query, { - regex: options.regex || false, - caseSensitive: options.caseSensitive || false, - wholeWord: options.wholeWord || false, - }); - } - - /** - * Clears the terminal buffer and viewport. - * @param {string} sessionId - */ - clearTerminal(sessionId) { - const entry = this.terminals.get(sessionId); - if (entry) entry.term.clear(); - } - - /** - * Fully resets the terminal (clears buffer, restores default state). - * @param {string} sessionId - */ - resetTerminal(sessionId) { - const entry = this.terminals.get(sessionId); - if (entry) entry.term.reset(); - } - - /** - * Focuses the terminal so it can receive keyboard input. - * @param {string} sessionId - */ - focusTerminal(sessionId) { - const entry = this.terminals.get(sessionId); - if (entry) entry.term.focus(); - } - - /** - * Applies a named colour theme to all active terminal instances. - * @param {string} themeName - */ - applyTheme(themeName) { - const themeObj = window.AlchementaryLink.getTerminalTheme(themeName); - for (const [, entry] of this.terminals) { - entry.term.options.theme = themeObj; - } - } - - // ── Private helpers ────────────────────────────────────────────── - - _sendTerminalSize(sessionId, dimensions) { - const cols = Number(dimensions?.cols || 0); - const rows = Number(dimensions?.rows || 0); - if (cols <= 0 || rows <= 0) return; - - Promise.resolve( - window.AlchementaryLink.wailsService.ResizeSession(sessionId, cols, rows), - ).catch((err) => console.error("ResizeSession error:", err)); - } - - async _restartLocalTerminal(sessionId, entry) { - if (entry.localRestarting) return; - entry.localRestarting = true; - - try { - await window.AlchementaryLink.wailsService.RestartLocalSession( - sessionId, - entry.term.cols, - entry.term.rows, - ); - } catch (err) { - entry.localRestarting = false; - entry.term.write( - `\r\n[Restart failed: ${String(err)} - press enter to retry]\r\n`, - ); - } - } - - /** - * Ensures the disconnect overlay exists for a session panel. - * @param {HTMLElement} panel - * @param {string} sessionId - * @returns {HTMLElement|null} - * @private - */ - _ensureDisconnectOverlay(panel, sessionId) { - let overlay = panel.querySelector(".terminal-disconnect-overlay"); - if (overlay) return overlay; - - const { createElement } = window.AlchementaryLink; - if (!createElement) return null; - - overlay = createElement("div", "terminal-disconnect-overlay"); - const content = createElement("div", "terminal-disconnect-content"); - const title = createElement("div", "terminal-disconnect-title", { - textContent: "DISCONNECTED", - }); - const message = createElement("div", "terminal-disconnect-message", { - textContent: "Connection lost.", - }); - const button = createElement("button", "btn btn-sm btn-primary", { - textContent: "Reconnect", - }); - button.addEventListener("click", () => { - this.app.tabs.reconnectSession(sessionId); - }); - - content.appendChild(title); - content.appendChild(message); - content.appendChild(button); - overlay.appendChild(content); - panel.appendChild(overlay); - - return overlay; - } - - /** - * Shows a right-click context menu for a terminal. - * @param {string} sessionId - * @param {Terminal} term - * @param {number} x - * @param {number} y - * @private - */ - _showTerminalContextMenu(sessionId, term, x, y) { - const { createElement, dismissContextMenus } = window.AlchementaryLink; - dismissContextMenus(); - - const isMac = navigator.platform.indexOf("Mac") !== -1; - const modKey = isMac ? "⌘" : "Ctrl+"; - const hasSelection = term.hasSelection(); - const menu = createElement("div", "context-menu"); - menu.style.left = `${x}px`; - menu.style.top = `${y}px`; - - const items = [ - { - label: "Copy", - shortcut: modKey + "C", - disabled: !hasSelection, - action: () => { - const text = term.getSelection(); - if (text) { - if (window.runtime && window.runtime.ClipboardSetText) { - window.runtime.ClipboardSetText(text); - } else { - navigator.clipboard.writeText(text).catch(function () {}); - } - this.app.notifications.info("Copied to clipboard"); - } - }, - }, - { - label: "Paste", - shortcut: modKey + "V", - action: () => { - if (window.runtime && window.runtime.ClipboardGetText) { - window.runtime - .ClipboardGetText() - .then((text) => { - if (text) term.paste(text); - }) - .catch(function () {}); - } else { - navigator.clipboard - .readText() - .then((text) => { - if (text) term.paste(text); - }) - .catch(function () {}); - } - }, - }, - { divider: true }, - { - label: "Select All", - shortcut: modKey + "A", - action: () => term.selectAll(), - }, - { - label: "Find…", - shortcut: modKey + "F", - action: () => this.app._openTerminalSearch(), - }, - { - label: "Send Snippet…", - action: () => this.app.snippets.open(), - }, - { - label: "Port Forwards…", - action: () => this._showPortForwardPanel(sessionId), - }, - { divider: true }, - { - label: "Split Right", - shortcut: modKey + "⇧D", - action: () => this.app.splitPane("vertical"), - }, - { - label: "Split Down", - shortcut: modKey + "⇧E", - action: () => this.app.splitPane("horizontal"), - }, - { divider: true }, - { - label: "Export Output", - action: () => this._exportTerminalOutput(sessionId, term), - }, - { - label: "Clear Terminal", - shortcut: modKey + "K", - action: () => term.clear(), - }, - { - label: "Reset Terminal", - action: () => term.reset(), - }, - ]; - - for (const entry of items) { - if (entry.divider) { - menu.appendChild(createElement("div", "context-menu-divider")); - continue; - } - const btn = createElement("button", "context-menu-item"); - const labelSpan = createElement("span", "context-menu-label", { - textContent: entry.label, - }); - btn.appendChild(labelSpan); - if (entry.shortcut) { - const shortcutSpan = createElement("span", "context-menu-shortcut", { - textContent: entry.shortcut, - }); - btn.appendChild(shortcutSpan); - } - if (entry.disabled) { - btn.disabled = true; - btn.classList.add("context-menu-disabled"); - } - btn.addEventListener("click", () => { - dismissContextMenus(); - entry.action(); - }); - // Prevent mousedown from causing text deselection in WKWebView - btn.addEventListener("mousedown", (e) => e.preventDefault()); - menu.appendChild(btn); - } - - document.body.appendChild(menu); - - // Keep within viewport - const rect = menu.getBoundingClientRect(); - if (rect.right > window.innerWidth) - menu.style.left = `${window.innerWidth - rect.width - 8}px`; - if (rect.bottom > window.innerHeight) - menu.style.top = `${window.innerHeight - rect.height - 8}px`; - } - - _handleFileDrop(sessionId, term, fileList) { - const session = this.app.state.getSession(sessionId); - if (!session) return; - - const files = Array.from(fileList || []); - if (files.length === 0) return; - - const canUpload = session.protocol === "ssh"; - this._promptFileDropAction(files, canUpload).then((choice) => { - if (!choice) return; - if (choice === "paste") { - this._pasteFileContents(files, term); - return; - } - if (choice === "upload") { - if (!canUpload) { - this.app.notifications.error( - "Upload is only available for SSH sessions.", - ); - return; - } - this._uploadDroppedFiles(sessionId, files); - } - }); - } - - _promptFileDropAction(files, canUpload) { - const { createElement, escapeHtml, formatBytes } = window.AlchementaryLink; - return new Promise((resolve) => { - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-sm"); - const count = files.length; - const first = files[0]; - const sizeLabel = - first && typeof first.size === "number" - ? formatBytes(first.size) - : ""; - const title = - count === 1 ? (first ? first.name : "File") : count + " files"; - - modal.innerHTML = ` - - - - `; - - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - - const uploadBtn = modal.querySelector(".action-upload"); - if (!canUpload) { - uploadBtn.disabled = true; - uploadBtn.title = "Upload is only available for SSH sessions."; - } - - const close = (value) => { - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(() => backdrop.remove(), 200); - resolve(value); - }; - - modal - .querySelector(".modal-close") - .addEventListener("click", () => close(null)); - modal - .querySelector(".prompt-cancel") - .addEventListener("click", () => close(null)); - modal - .querySelector(".action-paste") - .addEventListener("click", () => close("paste")); - uploadBtn.addEventListener("click", () => close("upload")); - backdrop.addEventListener("click", (e) => { - if (e.target === backdrop) close(null); - }); - }); - } - - _pasteFileContents(files, term) { - const readFile = (file) => - new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result || ""); - reader.onerror = () => reject(new Error("read failed")); - reader.readAsText(file); - }); - - const run = async () => { - try { - let combined = ""; - for (let i = 0; i < files.length; i += 1) { - const text = await readFile(files[i]); - if (text) { - if (combined) combined += "\n"; - combined += text; - } - } - if (combined) term.paste(combined); - } catch (_) { - this.app.notifications.error("Failed to read file contents."); - } - }; - - run(); - } - - _uploadDroppedFiles(sessionId, files) { - const run = async () => { - const remoteDir = await this._promptUploadDirectory(sessionId); - if (!remoteDir) return; - - if (files.length > 1) { - this.app.notifications.info( - "Uploading " + files.length + " files...", - ); - } - - for (let i = 0; i < files.length; i += 1) { - const file = files[i]; - try { - await this._uploadDroppedFileStream(sessionId, remoteDir, file); - } catch (err) { - const msg = err && err.message ? err.message : String(err); - this.app.notifications.error( - "Upload failed for " + file.name + ": " + msg, - ); - return; - } - } - - if (files.length === 1) { - this.app.notifications.success("Uploaded " + files[0].name); - } else { - this.app.notifications.success("Uploaded " + files.length + " files"); - } - }; - - run().catch((err) => { - const msg = err && err.message ? err.message : String(err); - this.app.notifications.error("Upload failed: " + msg); - }); - } - - async _uploadDroppedFileStream(sessionId, remoteDir, file) { - const totalBytes = typeof file.size === "number" ? file.size : 0; - let uploadId = ""; - try { - uploadId = await window.AlchementaryLink.wailsService.SFTPUploadStart( - sessionId, - remoteDir, - file.name, - totalBytes, - ); - - for (let offset = 0; offset < totalBytes; offset += UPLOAD_CHUNK_BYTES) { - const end = Math.min(offset + UPLOAD_CHUNK_BYTES, totalBytes); - const buffer = await this._readFileSlice(file, offset, end); - const base64Data = this._arrayBufferToBase64(buffer); - await window.AlchementaryLink.wailsService.SFTPUploadChunk(uploadId, base64Data); - } - - await window.AlchementaryLink.wailsService.SFTPUploadFinish(uploadId); - } catch (err) { - if (uploadId) { - try { - await window.AlchementaryLink.wailsService.SFTPUploadCancel(uploadId); - } catch (_) { - /* noop */ - } - } - throw err; - } - } - - _readFileSlice(file, start, end) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = () => reject(new Error("read failed")); - reader.readAsArrayBuffer(file.slice(start, end)); - }); - } - - _readFileBuffer(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = () => reject(new Error("read failed")); - reader.readAsArrayBuffer(file); - }); - } - - async _promptUploadDirectory(sessionId) { - return this._openUploadDirectoryPicker(sessionId); - } - - _openUploadDirectoryPicker(sessionId) { - const { createElement, escapeHtml } = window.AlchementaryLink; - return new Promise((resolve) => { - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-lg"); - modal.innerHTML = ` - - - - `; - - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - - const listEl = modal.querySelector(".sftp-picker-list"); - const breadcrumbEl = modal.querySelector('[data-role="breadcrumb"]'); - const pathLabel = modal.querySelector(".sftp-picker-path"); - const upBtn = modal.querySelector(".sftp-picker-up"); - const refreshBtn = modal.querySelector(".sftp-picker-refresh"); - const useBtn = modal.querySelector(".prompt-ok"); - const filterInput = modal.querySelector(".sftp-picker-filter"); - const statusLabel = modal.querySelector(".sftp-picker-status"); - - const state = { - cwd: "", - selected: "", - selectedWritable: true, - cwdWritable: true, - entries: [], - visibleEntries: [], - loading: false, - filter: "", - cursorIndex: -1, - }; - - const close = (value) => { - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(() => backdrop.remove(), 200); - resolve(value); - }; - - modal - .querySelector(".modal-close") - .addEventListener("click", () => close(null)); - modal - .querySelector(".prompt-cancel") - .addEventListener("click", () => close(null)); - useBtn.addEventListener("click", () => - close(state.selected || state.cwd), - ); - backdrop.addEventListener("click", (e) => { - if (e.target === backdrop) close(null); - }); - - const renderBreadcrumb = () => { - breadcrumbEl.innerHTML = ""; - const segments = this._pathSegments(state.cwd); - for (let i = 0; i < segments.length; i += 1) { - if (i > 0) { - breadcrumbEl.appendChild( - createElement("span", "sftp-breadcrumb-sep", { - textContent: ">", - }), - ); - } - const seg = segments[i]; - const btn = createElement("button", "sftp-breadcrumb-segment", { - textContent: seg.label, - title: seg.path, - }); - btn.addEventListener("click", () => loadDir(seg.path)); - breadcrumbEl.appendChild(btn); - } - }; - - const updateStatus = () => { - if (state.selected) { - statusLabel.textContent = state.selectedWritable - ? "" - : "No write permission"; - } else { - statusLabel.textContent = state.cwdWritable - ? "" - : "Current folder is read only"; - } - }; - - const setSelection = (entry, index) => { - listEl.querySelectorAll(".sftp-file-row").forEach((el) => { - el.classList.remove("is-selected"); - }); - const row = listEl.querySelector( - `.sftp-file-row[data-index="${index}"]`, - ); - if (row) row.classList.add("is-selected"); - state.selected = entry.path; - state.selectedWritable = entry.canWrite !== false; - state.cursorIndex = index; - pathLabel.textContent = entry.path; - useBtn.disabled = !state.selectedWritable; - useBtn.title = state.selectedWritable ? "" : "No write permission"; - updateStatus(); - }; - - const renderList = () => { - listEl.innerHTML = ""; - const basePath = state.selected || state.cwd || ""; - const suffix = state.selected - ? "" - : state.cwdWritable - ? "" - : " (read only)"; - pathLabel.textContent = basePath + suffix; - - if (state.cwd && state.cwd !== "/") { - const parentRow = this._buildPickerRow({ - name: "..", - isDir: true, - path: this._parentPath(state.cwd), - }); - parentRow.classList.add("is-parent"); - parentRow.addEventListener("dblclick", () => - loadDir(parentRow.dataset.path), - ); - listEl.appendChild(parentRow); - } - - const filtered = state.filter - ? state.entries.filter((entry) => - entry.name.toLowerCase().includes(state.filter), - ) - : state.entries; - state.visibleEntries = filtered; - - if (!filtered.length) { - const empty = createElement("div", "sftp-state"); - empty.innerHTML = - '
D
No folders found
'; - listEl.appendChild(empty); - state.cursorIndex = -1; - updateStatus(); - return; - } - - filtered.forEach((entry, index) => { - const row = this._buildPickerRow(entry, index); - if (entry.canRead === false) { - row.classList.add("is-unreadable"); - } - if (entry.canWrite === false) { - row.classList.add("is-readonly"); - } - row.addEventListener("click", () => { - if (entry.canRead === false) return; - setSelection(entry, index); - }); - row.addEventListener("dblclick", () => { - if (entry.canRead === false) return; - loadDir(entry.path); - }); - listEl.appendChild(row); - }); - updateStatus(); - }; - - const renderLoading = () => { - listEl.innerHTML = ""; - const s = createElement("div", "sftp-state"); - s.innerHTML = - '
Loading...
'; - listEl.appendChild(s); - }; - - const renderError = (msg) => { - listEl.innerHTML = ""; - const s = createElement("div", "sftp-state"); - s.innerHTML = - '
!
' + - '
' + - escapeHtml(msg) + - "
" + - ''; - const retryBtn = s.querySelector(".sftp-state-retry"); - if (retryBtn) - retryBtn.addEventListener("click", () => loadDir(state.cwd)); - listEl.appendChild(s); - }; - - const loadDir = async (path) => { - if (state.loading) return; - state.loading = true; - renderLoading(); - try { - const targetPath = - path || (await window.AlchementaryLink.wailsService.SFTPPwd(sessionId)); - const files = await window.AlchementaryLink.wailsService.SFTPList( - sessionId, - targetPath, - ); - const stat = await window.AlchementaryLink.wailsService.SFTPStat( - sessionId, - targetPath, - ); - state.cwd = targetPath; - state.selected = ""; - state.selectedWritable = true; - state.cwdWritable = stat && this._modeCanWrite(stat.mode); - state.entries = (files || []) - .filter((e) => e.isDir) - .sort((a, b) => - a.name.localeCompare(b.name, undefined, { - sensitivity: "base", - }), - ); - state.entries = state.entries.map((entry) => { - const canRead = this._modeCanRead(entry.mode); - const canWrite = this._modeCanWrite(entry.mode); - return Object.assign({}, entry, { canRead, canWrite }); - }); - useBtn.disabled = !state.cwdWritable; - useBtn.title = state.cwdWritable ? "" : "No write permission"; - renderBreadcrumb(); - renderList(); - } catch (err) { - renderError(err.message || String(err)); - } finally { - state.loading = false; - } - }; - - upBtn.addEventListener("click", () => { - if (state.cwd) loadDir(this._parentPath(state.cwd)); - }); - refreshBtn.addEventListener("click", () => loadDir(state.cwd)); - - filterInput.addEventListener("input", () => { - state.filter = filterInput.value.trim().toLowerCase(); - renderList(); - }); - - const moveSelection = (delta) => { - if (!state.visibleEntries.length) return; - let idx = state.cursorIndex; - const max = state.visibleEntries.length - 1; - let attempts = 0; - do { - idx = idx + delta; - if (idx < 0) idx = max; - if (idx > max) idx = 0; - const entry = state.visibleEntries[idx]; - if (entry && entry.canRead !== false) { - setSelection(entry, idx); - return; - } - attempts += 1; - } while (attempts <= max); - }; - - modal.addEventListener("keydown", (e) => { - const isInput = e.target === filterInput; - if (isInput && e.key !== "ArrowDown" && e.key !== "ArrowUp") return; - - if (e.key === "ArrowDown") { - e.preventDefault(); - moveSelection(1); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - moveSelection(-1); - } else if (e.key === "Enter") { - if (state.selected && state.selectedWritable) { - e.preventDefault(); - close(state.selected); - } else if (!state.selected && state.cwd && state.cwdWritable) { - e.preventDefault(); - close(state.cwd); - } - } else if (e.key === "Escape") { - e.preventDefault(); - close(null); - } - }); - - const initial = - this.app.sftp && typeof this.app.sftp.getCwd === "function" - ? this.app.sftp.getCwd(sessionId) - : ""; - loadDir(initial || ""); - }); - } - - _buildPickerRow(entry, index) { - const { createElement } = window.AlchementaryLink; - const dataset = { path: entry.path || "" }; - if (typeof index === "number") { - dataset.index = String(index); - } - const row = createElement("div", "sftp-file-row", { dataset }); - const icon = createElement("span", "sftp-file-icon is-dir", { - textContent: "DIR", - }); - const name = createElement("span", "sftp-file-name", { - textContent: entry.name || entry.path || "", - title: entry.name || entry.path || "", - }); - let metaLabel = ""; - if (entry.canRead === false) { - metaLabel = "NO READ"; - } else if (entry.canWrite === false) { - metaLabel = "READ ONLY"; - } - const meta = createElement("span", "sftp-file-meta", { - textContent: metaLabel, - }); - row.appendChild(icon); - row.appendChild(name); - row.appendChild(meta); - return row; - } - - _modeCanRead(mode) { - if (!mode || typeof mode !== "string") return true; - return mode.indexOf("r") !== -1; - } - - _modeCanWrite(mode) { - if (!mode || typeof mode !== "string") return true; - return mode.indexOf("w") !== -1; - } - - _pathSegments(p) { - const parts = (p || "").split("/").filter(Boolean); - const segs = [{ label: "/", path: "/" }]; - let acc = ""; - for (let i = 0; i < parts.length; i += 1) { - acc += "/" + parts[i]; - segs.push({ label: parts[i], path: acc }); - } - return segs; - } - - _parentPath(p) { - if (!p || p === "/") return "/"; - const trimmed = p.replace(/\/+$/, ""); - const idx = trimmed.lastIndexOf("/"); - return idx <= 0 ? "/" : trimmed.substring(0, idx); - } - - _arrayBufferToBase64(buffer) { - if (!buffer) return ""; - const bytes = new Uint8Array(buffer); - let binary = ""; - const chunkSize = 0x8000; - for (let i = 0; i < bytes.length; i += chunkSize) { - const chunk = bytes.subarray(i, i + chunkSize); - binary += String.fromCharCode.apply(null, chunk); - } - return btoa(binary); - } - - /** - * Exports the terminal scrollback buffer as plain text. - * @param {string} sessionId - * @param {Terminal} term - * @private - */ - _exportTerminalOutput(sessionId, term) { - const { createElement } = window.AlchementaryLink; - const buffer = term.buffer.active; - const lines = []; - for (let i = 0; i < buffer.length; i++) { - const line = buffer.getLine(i); - if (line) lines.push(line.translateToString(true)); - } - const text = lines.join("\n").trimEnd(); - - // Use Wails save dialog if available - if (window.runtime && window.runtime.SaveFileDialog) { - // Copy to clipboard as a fallback - } - - // Copy to clipboard - if (window.runtime && window.runtime.ClipboardSetText) { - window.runtime.ClipboardSetText(text); - } else { - navigator.clipboard.writeText(text).catch(function () {}); - } - - // Also offer download via blob - const blob = new Blob([text], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "terminal-output-" + sessionId.substring(0, 8) + ".txt"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - this.app.notifications.success( - "Terminal output exported & copied to clipboard", - ); - } - - /** - * Shows the port forwarding management panel for an SSH session. - * @param {string} sessionId - * @private - */ - _showPortForwardPanel(sessionId) { - const { createElement, escapeHtml } = window.AlchementaryLink; - const session = this.app.state.getSession(sessionId); - if (!session || session.protocol !== "ssh") { - this.app.notifications.error( - "Port forwarding is only available for SSH sessions", - ); - return; - } - - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-lg"); - - modal.innerHTML = ` - - - `; - - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - - const close = () => { - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(() => backdrop.remove(), 200); - }; - - modal.querySelector(".modal-close").addEventListener("click", close); - backdrop.addEventListener("click", (e) => { - if (e.target === backdrop) close(); - }); - - const listEl = modal.querySelector(".portfwd-list"); - const self = this; - - const renderForwards = async () => { - try { - const forwards = - (await window.AlchementaryLink.wailsService.ListPortForwards(sessionId)) || []; - listEl.innerHTML = ""; - if (forwards.length === 0) { - listEl.innerHTML = - '
No active port forwards
'; - return; - } - for (const fwd of forwards) { - const row = createElement("div", "", {}); - row.style.cssText = - "display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) 0;border-bottom:1px solid var(--border-subtle);"; - const dirLabel = fwd.direction === "local" ? "L" : "R"; - const dirColor = - fwd.direction === "local" ? "var(--accent)" : "var(--warning)"; - row.innerHTML = ` - ${dirLabel} - - ${escapeHtml(fwd.bindHost || "127.0.0.1")}:${fwd.bindPort} → ${escapeHtml(fwd.destHost || "localhost")}:${fwd.destPort} - - ${fwd.label ? `${escapeHtml(fwd.label)}` : ""} - `; - const stopBtn = createElement("button", "btn btn-xs btn-danger", { - textContent: "Stop", - }); - stopBtn.addEventListener("click", async () => { - try { - await window.AlchementaryLink.wailsService.StopPortForward(sessionId, fwd.id); - self.app.notifications.info("Forward stopped"); - renderForwards(); - } catch (err) { - self.app.notifications.error( - "Failed to stop: " + (err.message || err), - ); - } - }); - row.appendChild(stopBtn); - listEl.appendChild(row); - } - } catch (err) { - listEl.innerHTML = - '
Failed to load forwards
'; - } - }; - - renderForwards(); - - // Add forward button - modal.querySelector(".pf-add-btn").addEventListener("click", async () => { - const direction = modal.querySelector(".pf-direction").value; - const bindHost = - modal.querySelector(".pf-bind-host").value.trim() || "127.0.0.1"; - const bindPort = parseInt( - modal.querySelector(".pf-bind-port").value, - 10, - ); - const destHost = - modal.querySelector(".pf-dest-host").value.trim() || "localhost"; - const destPort = parseInt( - modal.querySelector(".pf-dest-port").value, - 10, - ); - const label = modal.querySelector(".pf-label").value.trim(); - - if (!bindPort || !destPort) { - self.app.notifications.error( - "Bind port and destination port are required", - ); - return; - } - - try { - await window.AlchementaryLink.wailsService.StartPortForward(sessionId, { - id: "", - direction: direction, - bindHost: bindHost, - bindPort: bindPort, - destHost: destHost, - destPort: destPort, - enabled: true, - label: label, - }); - self.app.notifications.success("Forward started"); - modal.querySelector(".pf-bind-port").value = ""; - modal.querySelector(".pf-dest-port").value = ""; - modal.querySelector(".pf-label").value = ""; - renderForwards(); - } catch (err) { - self.app.notifications.error( - "Failed to start forward: " + (err.message || err), - ); - } - }); - } - - /** - * Builds the xterm.js colour theme. - * @returns {Object} - * @private - */ - _buildTheme() { - const themeName = - (this.app.state.preferences || {}).terminalTheme || "charcoal"; - return window.AlchementaryLink.getTerminalTheme(themeName); - } - } - - window.AlchementaryLink = window.AlchementaryLink || {}; - window.AlchementaryLink.TerminalManager = TerminalManager; -})(); diff --git a/frontend/src/js/themes.js b/frontend/src/js/themes.js deleted file mode 100644 index eff2d27..0000000 --- a/frontend/src/js/themes.js +++ /dev/null @@ -1,347 +0,0 @@ -(function () { - /** - * Alchementary Link — terminal colour themes. - * Provides a collection of preset xterm.js colour schemes and a manager - * for applying them to terminal instances. - */ - - /** - * Preset terminal colour themes. - * Each entry contains the full xterm.js theme object. - */ - const THEMES = { - charcoal: { - label: "Charcoal (Default)", - theme: { - background: "#1c1c1c", - foreground: "#e4e4e4", - cursor: "#4ecdc4", - cursorAccent: "#1c1c1c", - selectionBackground: "rgba(78, 205, 196, 0.3)", - selectionForeground: "#ffffff", - black: "#1c1c1c", - red: "#ef5350", - green: "#66bb6a", - yellow: "#ffa726", - blue: "#42a5f5", - magenta: "#ab47bc", - cyan: "#4ecdc4", - white: "#e4e4e4", - brightBlack: "#555555", - brightRed: "#ff7043", - brightGreen: "#81c784", - brightYellow: "#ffcc02", - brightBlue: "#64b5f6", - brightMagenta: "#ce93d8", - brightCyan: "#80deea", - brightWhite: "#ffffff", - }, - }, - - "solarized-dark": { - label: "Solarized Dark", - theme: { - background: "#002b36", - foreground: "#839496", - cursor: "#93a1a1", - cursorAccent: "#002b36", - selectionBackground: "rgba(147, 161, 161, 0.3)", - selectionForeground: "#fdf6e3", - black: "#073642", - red: "#dc322f", - green: "#859900", - yellow: "#b58900", - blue: "#268bd2", - magenta: "#d33682", - cyan: "#2aa198", - white: "#eee8d5", - brightBlack: "#586e75", - brightRed: "#cb4b16", - brightGreen: "#586e75", - brightYellow: "#657b83", - brightBlue: "#839496", - brightMagenta: "#6c71c4", - brightCyan: "#93a1a1", - brightWhite: "#fdf6e3", - }, - }, - - monokai: { - label: "Monokai", - theme: { - background: "#272822", - foreground: "#f8f8f2", - cursor: "#f8f8f0", - cursorAccent: "#272822", - selectionBackground: "rgba(73, 72, 62, 0.6)", - selectionForeground: "#f8f8f2", - black: "#272822", - red: "#f92672", - green: "#a6e22e", - yellow: "#f4bf75", - blue: "#66d9ef", - magenta: "#ae81ff", - cyan: "#a1efe4", - white: "#f8f8f2", - brightBlack: "#75715e", - brightRed: "#f92672", - brightGreen: "#a6e22e", - brightYellow: "#f4bf75", - brightBlue: "#66d9ef", - brightMagenta: "#ae81ff", - brightCyan: "#a1efe4", - brightWhite: "#f9f8f5", - }, - }, - - nord: { - label: "Nord", - theme: { - background: "#2e3440", - foreground: "#d8dee9", - cursor: "#d8dee9", - cursorAccent: "#2e3440", - selectionBackground: "rgba(136, 192, 208, 0.3)", - selectionForeground: "#eceff4", - black: "#3b4252", - red: "#bf616a", - green: "#a3be8c", - yellow: "#ebcb8b", - blue: "#81a1c1", - magenta: "#b48ead", - cyan: "#88c0d0", - white: "#e5e9f0", - brightBlack: "#4c566a", - brightRed: "#bf616a", - brightGreen: "#a3be8c", - brightYellow: "#ebcb8b", - brightBlue: "#81a1c1", - brightMagenta: "#b48ead", - brightCyan: "#8fbcbb", - brightWhite: "#eceff4", - }, - }, - - dracula: { - label: "Dracula", - theme: { - background: "#282a36", - foreground: "#f8f8f2", - cursor: "#f8f8f2", - cursorAccent: "#282a36", - selectionBackground: "rgba(68, 71, 90, 0.6)", - selectionForeground: "#f8f8f2", - black: "#21222c", - red: "#ff5555", - green: "#50fa7b", - yellow: "#f1fa8c", - blue: "#bd93f9", - magenta: "#ff79c6", - cyan: "#8be9fd", - white: "#f8f8f2", - brightBlack: "#6272a4", - brightRed: "#ff6e6e", - brightGreen: "#69ff94", - brightYellow: "#ffffa5", - brightBlue: "#d6acff", - brightMagenta: "#ff92df", - brightCyan: "#a4ffff", - brightWhite: "#ffffff", - }, - }, - - "one-dark": { - label: "One Dark", - theme: { - background: "#282c34", - foreground: "#abb2bf", - cursor: "#528bff", - cursorAccent: "#282c34", - selectionBackground: "rgba(82, 139, 255, 0.3)", - selectionForeground: "#abb2bf", - black: "#282c34", - red: "#e06c75", - green: "#98c379", - yellow: "#e5c07b", - blue: "#61afef", - magenta: "#c678dd", - cyan: "#56b6c2", - white: "#abb2bf", - brightBlack: "#5c6370", - brightRed: "#e06c75", - brightGreen: "#98c379", - brightYellow: "#e5c07b", - brightBlue: "#61afef", - brightMagenta: "#c678dd", - brightCyan: "#56b6c2", - brightWhite: "#ffffff", - }, - }, - - "gruvbox-dark": { - label: "Gruvbox Dark", - theme: { - background: "#282828", - foreground: "#ebdbb2", - cursor: "#ebdbb2", - cursorAccent: "#282828", - selectionBackground: "rgba(214, 93, 14, 0.3)", - selectionForeground: "#ebdbb2", - black: "#282828", - red: "#cc241d", - green: "#98971a", - yellow: "#d79921", - blue: "#458588", - magenta: "#b16286", - cyan: "#689d6a", - white: "#a89984", - brightBlack: "#928374", - brightRed: "#fb4934", - brightGreen: "#b8bb26", - brightYellow: "#fabd2f", - brightBlue: "#83a598", - brightMagenta: "#d3869b", - brightCyan: "#8ec07c", - brightWhite: "#ebdbb2", - }, - }, - - "tokyo-night": { - label: "Tokyo Night", - theme: { - background: "#1a1b26", - foreground: "#a9b1d6", - cursor: "#c0caf5", - cursorAccent: "#1a1b26", - selectionBackground: "rgba(41, 46, 66, 0.9)", - selectionForeground: "#c0caf5", - black: "#15161e", - red: "#f7768e", - green: "#9ece6a", - yellow: "#e0af68", - blue: "#7aa2f7", - magenta: "#bb9af7", - cyan: "#7dcfff", - white: "#a9b1d6", - brightBlack: "#414868", - brightRed: "#f7768e", - brightGreen: "#9ece6a", - brightYellow: "#e0af68", - brightBlue: "#7aa2f7", - brightMagenta: "#bb9af7", - brightCyan: "#7dcfff", - brightWhite: "#c0caf5", - }, - }, - - "catppuccin-mocha": { - label: "Catppuccin Mocha", - theme: { - background: "#1e1e2e", - foreground: "#cdd6f4", - cursor: "#f5e0dc", - cursorAccent: "#1e1e2e", - selectionBackground: "rgba(88, 91, 112, 0.5)", - selectionForeground: "#cdd6f4", - black: "#45475a", - red: "#f38ba8", - green: "#a6e3a1", - yellow: "#f9e2af", - blue: "#89b4fa", - magenta: "#f5c2e7", - cyan: "#94e2d5", - white: "#bac2de", - brightBlack: "#585b70", - brightRed: "#f38ba8", - brightGreen: "#a6e3a1", - brightYellow: "#f9e2af", - brightBlue: "#89b4fa", - brightMagenta: "#f5c2e7", - brightCyan: "#94e2d5", - brightWhite: "#a6adc8", - }, - }, - - "rose-pine": { - label: "Rosé Pine", - theme: { - background: "#191724", - foreground: "#e0def4", - cursor: "#524f67", - cursorAccent: "#e0def4", - selectionBackground: "rgba(110, 106, 134, 0.4)", - selectionForeground: "#e0def4", - black: "#26233a", - red: "#eb6f92", - green: "#31748f", - yellow: "#f6c177", - blue: "#9ccfd8", - magenta: "#c4a7e7", - cyan: "#ebbcba", - white: "#e0def4", - brightBlack: "#6e6a86", - brightRed: "#eb6f92", - brightGreen: "#31748f", - brightYellow: "#f6c177", - brightBlue: "#9ccfd8", - brightMagenta: "#c4a7e7", - brightCyan: "#ebbcba", - brightWhite: "#e0def4", - }, - }, - - "solarized-light": { - label: "Solarized Light", - theme: { - background: "#fdf6e3", - foreground: "#657b83", - cursor: "#586e75", - cursorAccent: "#fdf6e3", - selectionBackground: "rgba(7, 54, 66, 0.2)", - selectionForeground: "#073642", - black: "#073642", - red: "#dc322f", - green: "#859900", - yellow: "#b58900", - blue: "#268bd2", - magenta: "#d33682", - cyan: "#2aa198", - white: "#eee8d5", - brightBlack: "#002b36", - brightRed: "#cb4b16", - brightGreen: "#586e75", - brightYellow: "#657b83", - brightBlue: "#839496", - brightMagenta: "#6c71c4", - brightCyan: "#93a1a1", - brightWhite: "#fdf6e3", - }, - }, - }; - - /** - * Returns the theme object for the given theme name. - * Falls back to 'charcoal' if not found. - * @param {string} name - * @returns {Object} xterm.js theme object - */ - function getTerminalTheme(name) { - const entry = THEMES[name || "charcoal"]; - return entry ? entry.theme : THEMES.charcoal.theme; - } - - /** - * Returns all theme names and labels for building a selector. - * @returns {Array<{value: string, label: string}>} - */ - function listTerminalThemes() { - return Object.entries(THEMES).map(([value, entry]) => ({ - value, - label: entry.label, - })); - } - - window.AlchementaryLink = window.AlchementaryLink || {}; - window.AlchementaryLink.getTerminalTheme = getTerminalTheme; - window.AlchementaryLink.listTerminalThemes = listTerminalThemes; -})(); diff --git a/frontend/src/js/triggers.js b/frontend/src/js/triggers.js deleted file mode 100644 index 46eed8e..0000000 --- a/frontend/src/js/triggers.js +++ /dev/null @@ -1,381 +0,0 @@ -(function () { - /** - * Alchementary Link — Trigger Rules Engine. - * Monitors terminal output and fires actions (highlight, notify, sound) - * when patterns match. Rules are stored in localStorage. - */ - - const { createElement } = window.AlchementaryLink; - - const STORAGE_KEY = "link:trigger-rules"; - - class TriggerEngine { - constructor(app) { - this.app = app; - this._rules = this._load(); - this._backdrop = null; - // Buffer per session to accumulate partial lines - this._buffers = new Map(); - } - - // ── Public API ─────────────────────────────────────────────────── - - /** - * Checks incoming terminal data against all enabled rules. - * Called from the session:data event handler. - * @param {string} sessionId - * @param {string} text - decoded terminal text - */ - check(sessionId, text) { - if (this._rules.length === 0) return; - - // Accumulate text and check line by line - const buf = (this._buffers.get(sessionId) || "") + text; - const lines = buf.split("\n"); - // Keep the last incomplete line in the buffer - this._buffers.set(sessionId, lines.pop()); - - for (const line of lines) { - this._checkLine(sessionId, line); - } - } - - /** - * Opens the trigger rules management panel. - */ - open() { - if (this._backdrop) return; - this._render(); - } - - /** - * Closes the trigger rules panel. - */ - close() { - if (this._backdrop) { - this._backdrop.classList.remove("is-visible"); - const modal = this._backdrop.querySelector(".modal"); - if (modal) modal.classList.remove("is-visible"); - setTimeout(() => { - if (this._backdrop) { - this._backdrop.remove(); - this._backdrop = null; - } - }, 200); - } - } - - /** - * Cleans up session buffer on disconnect. - * @param {string} sessionId - */ - cleanup(sessionId) { - this._buffers.delete(sessionId); - } - - // ── Rule matching ──────────────────────────────────────────────── - - _checkLine(sessionId, line) { - // Strip ANSI escape sequences for matching - const clean = line.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); - - for (const rule of this._rules) { - if (!rule.enabled) continue; - - let matched = false; - if (rule.isRegex) { - try { - const re = new RegExp(rule.pattern, rule.caseSensitive ? "" : "i"); - matched = re.test(clean); - } catch (_) { - // Invalid regex — skip - } - } else { - const haystack = rule.caseSensitive ? clean : clean.toLowerCase(); - const needle = rule.caseSensitive - ? rule.pattern - : rule.pattern.toLowerCase(); - matched = haystack.includes(needle); - } - - if (!matched) continue; - - // Fire actions - if (rule.action === "notify" || rule.action === "both") { - const session = this.app.state.getSession(sessionId); - const label = session - ? session.tabName || session.profileName - : sessionId; - this.app.notifications.info( - `[${label}] Trigger: ${rule.name || rule.pattern}`, - ); - } - - if (rule.action === "highlight" || rule.action === "both") { - // Flash the tab briefly - const tabEl = document.querySelector( - `.tab[data-session-id="${sessionId}"]`, - ); - if (tabEl) { - tabEl.classList.add("tab-trigger-flash"); - setTimeout(() => tabEl.classList.remove("tab-trigger-flash"), 2000); - } - } - - if (rule.action === "sound") { - this._playBeep(); - } - } - } - - _playBeep() { - try { - const ctx = new (window.AudioContext || window.webkitAudioContext)(); - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.connect(gain); - gain.connect(ctx.destination); - osc.frequency.value = 880; - gain.gain.value = 0.1; - osc.start(); - osc.stop(ctx.currentTime + 0.15); - } catch (_) { - // Audio not available — silent - } - } - - // ── Storage ────────────────────────────────────────────────────── - - _load() { - try { - const raw = localStorage.getItem(STORAGE_KEY); - return raw ? JSON.parse(raw) : []; - } catch (_) { - return []; - } - } - - _save() { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(this._rules)); - } catch (_) { - // localStorage full or unavailable - } - } - - // ── UI ─────────────────────────────────────────────────────────── - - _render() { - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-lg"); - - modal.innerHTML = ` - - - `; - - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - this._backdrop = backdrop; - - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - - modal - .querySelector(".modal-close") - .addEventListener("click", () => this.close()); - backdrop.addEventListener("click", (e) => { - if (e.target === backdrop) this.close(); - }); - modal - .querySelector(".trigger-add-btn") - .addEventListener("click", () => this._addRule()); - - this._renderList(); - } - - _renderList() { - const list = this._backdrop.querySelector(".trigger-list"); - if (!list) return; - list.innerHTML = ""; - - if (this._rules.length === 0) { - list.innerHTML = - '
No trigger rules defined
'; - return; - } - - for (let i = 0; i < this._rules.length; i++) { - list.appendChild(this._renderRuleRow(this._rules[i], i)); - } - } - - _renderRuleRow(rule, index) { - const row = createElement("div", "trigger-row"); - - const toggle = createElement("input", "trigger-toggle"); - toggle.type = "checkbox"; - toggle.checked = rule.enabled; - toggle.addEventListener("change", () => { - this._rules[index].enabled = toggle.checked; - this._save(); - }); - - const info = createElement("div", "trigger-info"); - const name = createElement("div", "trigger-name", { - textContent: rule.name || rule.pattern, - }); - const meta = createElement("div", "trigger-meta", { - textContent: `${rule.isRegex ? "Regex" : "Text"} · ${rule.action} · ${rule.caseSensitive ? "Case-sensitive" : "Case-insensitive"}`, - }); - info.appendChild(name); - info.appendChild(meta); - - const pattern = createElement("code", "trigger-pattern", { - textContent: rule.pattern, - }); - - const actions = createElement("div", "trigger-actions"); - const editBtn = createElement("button", "btn btn-xs", { - textContent: "Edit", - }); - editBtn.addEventListener("mousedown", (e) => e.preventDefault()); - editBtn.addEventListener("click", () => this._editRule(index)); - - const delBtn = createElement("button", "btn btn-xs btn-danger", { - textContent: "✕", - }); - delBtn.addEventListener("mousedown", (e) => e.preventDefault()); - delBtn.addEventListener("click", () => { - this._rules.splice(index, 1); - this._save(); - this._renderList(); - }); - - actions.appendChild(editBtn); - actions.appendChild(delBtn); - - row.appendChild(toggle); - row.appendChild(info); - row.appendChild(pattern); - row.appendChild(actions); - return row; - } - - _addRule() { - this._showRuleForm(null, (rule) => { - this._rules.push(rule); - this._save(); - this._renderList(); - }); - } - - _editRule(index) { - this._showRuleForm(this._rules[index], (rule) => { - this._rules[index] = rule; - this._save(); - this._renderList(); - }); - } - - _showRuleForm(existing, onSave) { - const list = this._backdrop.querySelector(".trigger-list"); - const toolbar = this._backdrop.querySelector(".trigger-toolbar"); - if (!list) return; - - // Hide list and toolbar - list.style.display = "none"; - if (toolbar) toolbar.style.display = "none"; - - const form = createElement("div", "trigger-form"); - form.innerHTML = ` -
- - -
-
- - -
-
-
- - -
-
- - -
-
-
- -
-
- - -
- `; - - list.parentNode.insertBefore(form, list); - - form - .querySelector(".trigger-cancel-btn") - .addEventListener("click", () => { - form.remove(); - list.style.display = ""; - if (toolbar) toolbar.style.display = ""; - }); - - form.querySelector(".trigger-save-btn").addEventListener("click", () => { - const pattern = form - .querySelector(".trigger-pattern-input") - .value.trim(); - if (!pattern) { - this.app.notifications.error("Pattern is required"); - return; - } - const rule = { - name: form.querySelector(".trigger-name-input").value.trim(), - pattern: pattern, - isRegex: form.querySelector(".trigger-type-select").value === "regex", - action: form.querySelector(".trigger-action-select").value, - caseSensitive: form.querySelector(".trigger-case-check").checked, - enabled: existing ? existing.enabled : true, - }; - form.remove(); - list.style.display = ""; - if (toolbar) toolbar.style.display = ""; - onSave(rule); - }); - - form.querySelector(".trigger-pattern-input").focus(); - } - } - - window.AlchementaryLink = window.AlchementaryLink || {}; - window.AlchementaryLink.TriggerEngine = TriggerEngine; -})(); diff --git a/frontend/src/js/utils.js b/frontend/src/js/utils.js deleted file mode 100644 index 338c26e..0000000 --- a/frontend/src/js/utils.js +++ /dev/null @@ -1,402 +0,0 @@ -(function () { - /** - * Alchementary Link — utility functions. - * General-purpose helpers used throughout the application. - */ - - /** - * Shorthand for document.querySelector. - * @param {string} selector - CSS selector. - * @returns {Element|null} - */ - function $(selector) { - return document.querySelector(selector); - } - - /** - * Shorthand for document.querySelectorAll. - * @param {string} selector - CSS selector. - * @returns {NodeListOf} - */ - function $$(selector) { - return document.querySelectorAll(selector); - } - - /** - * Creates a DOM element with optional class name and attributes. - * @param {string} tag - HTML tag name. - * @param {string} [className] - Space-separated class names. - * @param {Object} [attrs] - Attribute key/value pairs. - * @returns {HTMLElement} - */ - function createElement(tag, className, attrs = {}) { - const el = document.createElement(tag); - if (className) { - el.className = className; - } - for (const [key, value] of Object.entries(attrs)) { - if (key === "textContent") { - el.textContent = value; - } else if (key === "innerHTML") { - el.innerHTML = value; - } else if (key === "dataset") { - for (const [dKey, dVal] of Object.entries(value)) { - el.dataset[dKey] = dVal; - } - } else if (key.startsWith("on") && typeof value === "function") { - el.addEventListener(key.slice(2).toLowerCase(), value); - } else { - el.setAttribute(key, value); - } - } - return el; - } - - /** - * Creates a standard modal skeleton without HTML string interpolation. - * @param {string} title - * @param {string} [sizeClass] - * @returns {{backdrop: HTMLElement, modal: HTMLElement, body: HTMLElement, footer: HTMLElement, closeButton: HTMLElement, show: Function, remove: Function}} - */ - function createModalShell(title, sizeClass = "modal-sm") { - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", `modal ${sizeClass}`); - const header = createElement("div", "modal-header"); - const titleEl = createElement("h3", "modal-title", { textContent: title }); - const closeButton = createElement("button", "btn btn-icon btn-xs modal-close", { - textContent: "x", - "aria-label": "Close", - }); - const body = createElement("div", "modal-body"); - const footer = createElement("div", "modal-footer"); - - header.appendChild(titleEl); - header.appendChild(closeButton); - modal.appendChild(header); - modal.appendChild(body); - modal.appendChild(footer); - backdrop.appendChild(modal); - - return { - backdrop, - modal, - body, - footer, - closeButton, - show() { - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - }, - remove() { - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - setTimeout(() => backdrop.remove(), 200); - }, - }; - } - - /** - * Escapes HTML special characters to prevent XSS. - * @param {string} str - Raw string. - * @returns {string} Escaped string safe for innerHTML. - */ - function escapeHtml(str) { - if (!str) return ""; - const map = { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - }; - return str.replace(/[&<>"']/g, (ch) => map[ch]); - } - - /** - * Formats a Date (or ISO string) into a concise timestamp. - * @param {Date|string} date - * @returns {string} e.g. "14:32:07" or "2 Jan 14:32". - */ - function formatTimestamp(date) { - if (!date) return ""; - const d = date instanceof Date ? date : new Date(date); - if (isNaN(d.getTime())) return ""; - - const now = new Date(); - const isToday = d.toDateString() === now.toDateString(); - const hours = String(d.getHours()).padStart(2, "0"); - const mins = String(d.getMinutes()).padStart(2, "0"); - const secs = String(d.getSeconds()).padStart(2, "0"); - - if (isToday) { - return `${hours}:${mins}:${secs}`; - } - const months = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ]; - return `${d.getDate()} ${months[d.getMonth()]} ${hours}:${mins}`; - } - - /** - * Returns a debounced version of the given function. - * @param {Function} fn - * @param {number} ms - Delay in milliseconds. - * @returns {Function} - */ - function debounce(fn, ms) { - let timer = null; - return function (...args) { - clearTimeout(timer); - timer = setTimeout(() => fn.apply(this, args), ms); - }; - } - - /** - * Performs a simple fuzzy match — checks whether every character in - * the query appears in order within the text (case-insensitive). - * @param {string} text - * @param {string} query - * @returns {{ match: boolean, score: number }} - */ - function fuzzyMatch(text, query) { - if (!query) return { match: true, score: 1 }; - const lowerText = text.toLowerCase(); - const lowerQuery = query.toLowerCase(); - - let textIdx = 0; - let score = 0; - let consecutiveBonus = 0; - - for (let qi = 0; qi < lowerQuery.length; qi++) { - const ch = lowerQuery[qi]; - const foundAt = lowerText.indexOf(ch, textIdx); - if (foundAt === -1) { - return { match: false, score: 0 }; - } - // Reward consecutive character matches and early matches - if (foundAt === textIdx) { - consecutiveBonus += 1; - } else { - consecutiveBonus = 0; - } - score += 1 + consecutiveBonus + (foundAt === 0 ? 2 : 0); - textIdx = foundAt + 1; - } - - return { match: true, score }; - } - - /** - * Base64-encodes a string. - * @param {string} str - * @returns {string} - */ - function base64Encode(str) { - return btoa(str); - } - - /** - * UTF-8-encodes a string and then base64-encodes the bytes. - * Unlike bare btoa(), this handles characters above U+007F correctly. - * @param {string} str - * @returns {string} - */ - function utf8ToBase64(str) { - const bytes = new TextEncoder().encode(str); - let binary = ""; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); - } - - /** - * Base64-decodes a string. - * @param {string} str - * @returns {string} - */ - function base64Decode(str) { - return atob(str); - } - - /** - * Generates a new UUID v4 using the browser crypto API. - * @returns {string} - */ - function generateId() { - return crypto.randomUUID(); - } - - /** - * Formats a byte count into a human-friendly string. - * @param {number} bytes - * @returns {string} e.g. "1.23 KB" - */ - function formatBytes(bytes) { - if (bytes === 0) return "0 B"; - const units = ["B", "KB", "MB", "GB", "TB"]; - const k = 1024; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - const value = bytes / Math.pow(k, i); - return `${value.toFixed(i === 0 ? 0 : 2)} ${units[i]}`; - } - - /** - * Truncates a string to the given length, appending an ellipsis if needed. - * @param {string} str - * @param {number} len - * @returns {string} - */ - function truncate(str, len) { - if (!str || str.length <= len) return str || ""; - return str.slice(0, len) + "…"; - } - - /** - * Removes any open context menus from the DOM. - */ - function dismissContextMenus() { - document.querySelectorAll(".context-menu").forEach((m) => m.remove()); - } - - /** - * Returns the protocol badge colour class. - * @param {string} protocol - 'ssh', 'telnet', 'raw', or 'github'. - * @returns {string} - */ - function protocolColour(protocol) { - switch (protocol) { - case "ssh": - return "proto-ssh"; - case "telnet": - return "proto-telnet"; - case "raw": - return "proto-raw"; - case "github": - return "proto-github"; - case "vnc": - return "proto-vnc"; - case "irc": - return "proto-irc"; - case "local": - return "proto-local"; - default: - return ""; - } - } - - /** - * Returns a human-friendly relative time string for an ISO date. - * @param {string} isoDate - ISO 8601 date string. - * @returns {string} e.g. "3 hours ago", "2 days ago". - */ - function relativeTime(isoDate) { - if (!isoDate) return ""; - const d = new Date(isoDate); - if (isNaN(d.getTime())) return ""; - - const now = Date.now(); - const diff = now - d.getTime(); - const secs = Math.floor(diff / 1000); - if (secs < 60) return "just now"; - const mins = Math.floor(secs / 60); - if (mins < 60) return mins === 1 ? "1 minute ago" : `${mins} minutes ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return hrs === 1 ? "1 hour ago" : `${hrs} hours ago`; - const days = Math.floor(hrs / 24); - if (days < 30) return days === 1 ? "1 day ago" : `${days} days ago`; - const months = Math.floor(days / 30); - if (months < 12) - return months === 1 ? "1 month ago" : `${months} months ago`; - const years = Math.floor(months / 12); - return years === 1 ? "1 year ago" : `${years} years ago`; - } - - /** - * Returns a human-friendly label for a session state. - * @param {string} state - * @returns {string} - */ - function stateLabel(state) { - switch (state) { - case "connecting": - return "Connecting"; - case "connected": - return "Connected"; - case "disconnected": - return "Disconnected"; - case "reconnecting": - return "Reconnecting"; - case "error": - return "Error"; - default: - return state || "Unknown"; - } - } - - window.AlchementaryLink = window.AlchementaryLink || {}; - Object.assign(window.AlchementaryLink, { - $, - $$, - createElement, - createModalShell, - escapeHtml, - formatTimestamp, - debounce, - fuzzyMatch, - base64Encode, - utf8ToBase64, - base64Decode, - generateId, - formatBytes, - truncate, - dismissContextMenus, - protocolColour, - stateLabel, - relativeTime, - }); - - /* ── Disable macOS/WebKit auto-capitalisation on all text inputs ───────── */ - function disableAutoCapitalise(el) { - el.setAttribute("autocapitalize", "none"); - el.setAttribute("autocorrect", "off"); - el.setAttribute("spellcheck", "false"); - } - - // Apply to existing inputs - document.querySelectorAll("input, textarea").forEach(disableAutoCapitalise); - - // Observe for dynamically added inputs - var inputObserver = new MutationObserver(function (mutations) { - for (var i = 0; i < mutations.length; i++) { - var nodes = mutations[i].addedNodes; - for (var j = 0; j < nodes.length; j++) { - var node = nodes[j]; - if (node.nodeType !== 1) continue; - if (node.tagName === "INPUT" || node.tagName === "TEXTAREA") { - disableAutoCapitalise(node); - } - if (node.querySelectorAll) { - node - .querySelectorAll("input, textarea") - .forEach(disableAutoCapitalise); - } - } - } - }); - inputObserver.observe(document.body, { childList: true, subtree: true }); -})(); diff --git a/frontend/src/js/vnc.js b/frontend/src/js/vnc.js deleted file mode 100644 index 418f38f..0000000 --- a/frontend/src/js/vnc.js +++ /dev/null @@ -1,331 +0,0 @@ -(function () { - /** - * Alchementary Link — VNC viewer. - * Renders a noVNC canvas inside a session panel, connecting to the local - * WebSocket proxy started by the Go backend. - */ - - const { createElement, $ } = window.AlchementaryLink; - - class VNCViewer { - constructor() { - /** @type {Map} sessionID → viewer */ - this._viewers = new Map(); - } - - /** - * Creates a VNC viewer panel for a session inside the given container. - * @param {string} sessionID - * @param {HTMLElement} container - The .session-panel element. - * @param {object} profile - The profile object with VNC config. - */ - createViewer(sessionID, container, profile) { - if (this._viewers.has(sessionID)) return; - if (!container) return; - - const viewer = new VNCViewerInstance(sessionID, container, profile); - this._viewers.set(sessionID, viewer); - } - - /** - * Connects the viewer to the WebSocket URL from the backend. - * @param {string} sessionID - * @param {string} wsURL - */ - connect(sessionID, wsURL) { - const viewer = this._viewers.get(sessionID); - if (viewer) { - viewer.connect(wsURL); - } - } - - /** - * Destroys a viewer for the given session. - * @param {string} sessionID - */ - destroyViewer(sessionID) { - const viewer = this._viewers.get(sessionID); - if (viewer) { - viewer.destroy(); - this._viewers.delete(sessionID); - } - } - - /** - * Resizes the VNC display for the given session. - * @param {string} sessionID - */ - resize(sessionID) { - const viewer = this._viewers.get(sessionID); - if (viewer) { - viewer.resize(); - } - } - } - - class VNCViewerInstance { - /** - * @param {string} sessionID - * @param {HTMLElement} container - * @param {object} profile - */ - constructor(sessionID, container, profile) { - this.sessionID = sessionID; - this.container = container; - this.profile = profile; - this.rfb = null; - this._rfbListeners = []; - this._credentialDialog = null; - this._destroyed = false; - - this._buildUI(); - } - - _buildUI() { - this.container.innerHTML = ""; - this.container.classList.add("vnc-viewer-panel"); - - // Toolbar - this.toolbar = createElement("div", "vnc-toolbar"); - - const ctrlAltDel = createElement("button", "btn btn-sm btn-vnc-action", { - textContent: "Ctrl+Alt+Del", - title: "Send Ctrl+Alt+Del", - }); - ctrlAltDel.addEventListener("click", () => this._sendCtrlAltDel()); - - const clipboardBtn = createElement( - "button", - "btn btn-sm btn-vnc-action", - { - textContent: "Clipboard", - title: "Sync clipboard", - }, - ); - clipboardBtn.addEventListener("click", () => this._syncClipboard()); - - const fullscreenBtn = createElement( - "button", - "btn btn-sm btn-vnc-action", - { - textContent: "Fullscreen", - title: "Toggle fullscreen", - }, - ); - fullscreenBtn.addEventListener("click", () => this._toggleFullscreen()); - - this.statusEl = createElement("span", "vnc-status", { - textContent: "Connecting…", - }); - - this.toolbar.appendChild(this.statusEl); - this.toolbar.appendChild(ctrlAltDel); - this.toolbar.appendChild(clipboardBtn); - this.toolbar.appendChild(fullscreenBtn); - - // VNC display container - this.displayEl = createElement("div", "vnc-display"); - - this.container.appendChild(this.toolbar); - this.container.appendChild(this.displayEl); - } - - /** - * Connects to the VNC WebSocket proxy. - * @param {string} wsURL - WebSocket URL (ws://127.0.0.1:) - */ - connect(wsURL) { - if (this._destroyed) return; - if (!window.noVNC || !window.noVNC.RFB) { - this.statusEl.textContent = "Error: noVNC not loaded"; - this.statusEl.classList.add("vnc-status-error"); - return; - } - - try { - const vncConfig = this.profile.vnc || {}; - - this.rfb = new window.noVNC.RFB(this.displayEl, wsURL, { - shared: vncConfig.sharedSession !== false, - credentials: {}, - }); - - this.rfb.viewOnly = !!vncConfig.viewOnly; - this.rfb.scaleViewport = vncConfig.scaleToFit !== false; - this.rfb.resizeSession = false; - this.rfb.clipViewport = false; - this.rfb.qualityLevel = vncConfig.quality || 6; - this.rfb.compressionLevel = 2; - - // Event handlers - this._addRFBListener("connect", () => { - this.statusEl.textContent = "Connected"; - this.statusEl.classList.remove("vnc-status-error"); - this.statusEl.classList.add("vnc-status-connected"); - }); - - this._addRFBListener("disconnect", (ev) => { - const clean = ev.detail.clean; - this.statusEl.textContent = clean - ? "Disconnected" - : "Connection lost"; - this.statusEl.classList.remove("vnc-status-connected"); - if (!clean) { - this.statusEl.classList.add("vnc-status-error"); - } - }); - - this._addRFBListener("credentialsrequired", () => { - this.statusEl.textContent = "Credentials required"; - // TODO: prompt for VNC password via modal - this._promptCredentials(); - }); - - this._addRFBListener("desktopname", (ev) => { - this.statusEl.textContent = "Connected — " + ev.detail.name; - }); - } catch (err) { - this.statusEl.textContent = "Error: " + err.message; - this.statusEl.classList.add("vnc-status-error"); - } - } - - _sendCtrlAltDel() { - if (this.rfb) { - this.rfb.sendCtrlAltDel(); - } - } - - _addRFBListener(name, handler) { - this.rfb.addEventListener(name, handler); - this._rfbListeners.push({ name, handler }); - } - - async _syncClipboard() { - if (!this.rfb) return; - try { - const text = await navigator.clipboard.readText(); - if (text) { - this.rfb.clipboardPasteFrom(text); - } - } catch (_) { - // Clipboard API may not be available in WKWebView - } - } - - _toggleFullscreen() { - if (!document.fullscreenElement) { - this.container.requestFullscreen().catch(() => {}); - } else { - document.exitFullscreen().catch(() => {}); - } - } - - _promptCredentials() { - if (this._destroyed) return; - this._closeCredentialDialog(); - - // Build a simple modal for VNC password - const backdrop = createElement("div", "modal-backdrop"); - const modal = createElement("div", "modal modal-sm"); - modal.innerHTML = ` - - - - `; - - document.body.appendChild(backdrop); - document.body.appendChild(modal); - requestAnimationFrame(() => { - backdrop.classList.add("is-visible"); - modal.classList.add("is-visible"); - }); - - const input = modal.querySelector("#vnc-password-input"); - const submit = modal.querySelector("#vnc-password-submit"); - const cancel = modal.querySelector("#vnc-password-cancel"); - const dialog = { - backdrop, - modal, - removeTimer: null, - focusTimer: null, - }; - this._credentialDialog = dialog; - - const close = () => { - backdrop.classList.remove("is-visible"); - modal.classList.remove("is-visible"); - dialog.removeTimer = setTimeout(() => { - if (this._credentialDialog === dialog) { - this._closeCredentialDialog(); - } - }, 200); - }; - - submit.addEventListener("click", () => { - const pw = input.value; - close(); - if (this.rfb && pw) { - this.rfb.sendCredentials({ password: pw }); - } - }); - - cancel.addEventListener("click", () => { - close(); - }); - - input.addEventListener("keydown", (e) => { - if (e.key === "Enter") submit.click(); - if (e.key === "Escape") cancel.click(); - }); - - dialog.focusTimer = setTimeout(() => input.focus(), 50); - } - - _closeCredentialDialog() { - const dialog = this._credentialDialog; - if (!dialog) return; - if (dialog.removeTimer) clearTimeout(dialog.removeTimer); - if (dialog.focusTimer) clearTimeout(dialog.focusTimer); - dialog.backdrop.remove(); - dialog.modal.remove(); - this._credentialDialog = null; - } - - resize() { - // noVNC handles resizing internally via ResizeObserver on its canvas, - // but we can trigger a re-scale if needed. - if (this.rfb && this.rfb.scaleViewport) { - this.rfb.scaleViewport = true; - } - } - - destroy() { - this._destroyed = true; - this._closeCredentialDialog(); - if (this.rfb) { - for (const { name, handler } of this._rfbListeners) { - this.rfb.removeEventListener(name, handler); - } - this._rfbListeners = []; - this.rfb.disconnect(); - this.rfb = null; - } - this.container.innerHTML = ""; - this.container.classList.remove("vnc-viewer-panel"); - } - } - - // Export to global namespace. - window.AlchementaryLink.VNCViewer = new VNCViewer(); -})(); diff --git a/frontend/src/js/wails-service.js b/frontend/src/js/wails-service.js deleted file mode 100644 index f5bddb6..0000000 --- a/frontend/src/js/wails-service.js +++ /dev/null @@ -1,28 +0,0 @@ -(function () { - const root = (window.AlchementaryLink = window.AlchementaryLink || {}); - - const directService = () => window.go?.api?.Service || null; - const unavailable = (name) => - Promise.reject(new Error(`Wails service method is not available: ${String(name)}`)); - - root.wailsService = new Proxy( - {}, - { - get(_target, prop) { - if (prop === "then") return undefined; - const service = directService(); - const value = service?.[prop]; - if (typeof value === "function") return value.bind(service); - if (value !== undefined) return value; - return () => unavailable(prop); - }, - has(_target, prop) { - return prop in (directService() || {}); - }, - }, - ); - - root.wailsServiceReady = Promise.resolve(root.wailsService); - root.hasWailsService = () => - Boolean(root.wailsService && window.runtime && directService()); -})(); diff --git a/frontend/wailsjs/go/api/Service.d.ts b/frontend/wailsjs/go/api/Service.d.ts deleted file mode 100755 index 3f4904b..0000000 --- a/frontend/wailsjs/go/api/Service.d.ts +++ /dev/null @@ -1,261 +0,0 @@ -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL -// This file is automatically generated. DO NOT EDIT -import {models} from '../models'; -import {api} from '../models'; -import {context} from '../models'; - -export function BrowseForShell():Promise; - -export function CancelReconnect(arg1:string):Promise; - -export function CheckForUpdates():Promise>; - -export function CleanupRecordings(arg1:number,arg2:number):Promise>; - -export function ClearSessionHistory(arg1:string):Promise; - -export function CloseAllSessions():Promise; - -export function ConnectLocal():Promise; - -export function ConnectQuick(arg1:models.Profile):Promise; - -export function ConnectSession(arg1:string):Promise; - -export function CreateProfile(arg1:models.Profile):Promise; - -export function CreateSnippet(arg1:models.Snippet):Promise; - -export function DeleteKnownHost(arg1:string):Promise; - -export function DeleteProfile(arg1:string):Promise; - -export function DeleteProfileSecrets(arg1:string):Promise; - -export function DeleteRecording(arg1:string):Promise; - -export function DeleteSSHKey(arg1:string):Promise; - -export function DeleteSnippet(arg1:string):Promise; - -export function DeleteTOTPSecret(arg1:string):Promise; - -export function DisconnectSession(arg1:string):Promise; - -export function DownloadGitHubRepoArchive(arg1:string,arg2:string,arg3:string):Promise; - -export function DownloadGitHubRepoArchiveToPath(arg1:string,arg2:string,arg3:string,arg4:string):Promise; - -export function DuplicateProfile(arg1:string):Promise; - -export function ExportProfiles(arg1:boolean):Promise; - -export function ExportProfilesEncrypted(arg1:Array,arg2:string):Promise; - -export function FileExists(arg1:string):Promise; - -export function FileIsPrivateKey(arg1:string):Promise; - -export function GenerateSSHKey(arg1:string,arg2:string,arg3:number,arg4:string):Promise; - -export function GetAppInfo():Promise>; - -export function GetDefaultShell():Promise; - -export function GetPreferences():Promise; - -export function GetProfile(arg1:string):Promise; - -export function GetRecording(arg1:string):Promise>; - -export function GetRecordingStorageInfo():Promise>; - -export function GetSSHPublicKey(arg1:string):Promise; - -export function GetSessionCwd(arg1:string):Promise; - -export function GetSessionHistory(arg1:string):Promise>>; - -export function GetTOTPCode(arg1:string):Promise; - -export function GetVNCWebSocketURL(arg1:string):Promise; - -export function HasTOTPSecret(arg1:string):Promise; - -export function IRCHandleInput(arg1:string,arg2:string,arg3:string):Promise; - -export function IRCJoin(arg1:string,arg2:string):Promise; - -export function IRCListRooms(arg1:string):Promise; - -export function IRCPart(arg1:string,arg2:string):Promise; - -export function IRCRooms(arg1:string):Promise>>; - -export function IRCUsers(arg1:string,arg2:string):Promise>; - -export function ImportProfiles(arg1:string,arg2:string):Promise; - -export function ImportProfilesEncrypted(arg1:string,arg2:string):Promise; - -export function ImportSSHConfig(arg1:string,arg2:string):Promise; - -export function InitialiseSecretStore(arg1:string):Promise; - -export function IsRecording(arg1:string):Promise; - -export function IsSecretStoreInitialised():Promise; - -export function IsSecretStoreUnlocked():Promise; - -export function LeviathanExportJSON(arg1:string,arg2:Record):Promise; - -export function LeviathanExportRange(arg1:string,arg2:number,arg3:number):Promise; - -export function LeviathanLoadFile(arg1:string,arg2:boolean):Promise>; - -export function LeviathanOpenCompare():Promise>; - -export function LeviathanOpenFile():Promise>; - -export function LeviathanRedo():Promise; - -export function LeviathanRevert():Promise; - -export function LeviathanRunTool(arg1:string,arg2:Record):Promise>; - -export function LeviathanSaveWorking():Promise; - -export function LeviathanSaveWorkingToPath(arg1:string):Promise; - -export function LeviathanSetByte(arg1:number,arg2:string):Promise>; - -export function LeviathanState():Promise; - -export function LeviathanUndo():Promise; - -export function ListAuditEntries(arg1:number):Promise>; - -export function ListFavourites():Promise>; - -export function ListKnownHosts():Promise>; - -export function ListPortForwards(arg1:string):Promise>; - -export function ListProfiles():Promise>; - -export function ListProfilesByFolder(arg1:string):Promise>; - -export function ListProfilesByProtocol(arg1:string):Promise>; - -export function ListProfilesByTag(arg1:string):Promise>; - -export function ListRecordings():Promise>>; - -export function ListSSHKeyPairs():Promise>; - -export function ListSSHKeys():Promise>; - -export function ListSessions():Promise>; - -export function ListSnippets():Promise>; - -export function LockSecretStore():Promise; - -export function NewProfile(arg1:string):Promise; - -export function PinSession(arg1:string,arg2:boolean):Promise; - -export function PingSession(arg1:string):Promise; - -export function ReconnectSession(arg1:string):Promise; - -export function RefreshGitHubRepos(arg1:string):Promise>; - -export function RegisterSecretResponseListener():Promise; - -export function RenameSession(arg1:string,arg2:string):Promise; - -export function ResizeSession(arg1:string,arg2:number,arg3:number):Promise; - -export function RespondSecret(arg1:string,arg2:string,arg3:boolean):Promise; - -export function RestartLocalSession(arg1:string,arg2:number,arg3:number):Promise; - -export function SFTPDownload(arg1:string,arg2:string):Promise; - -export function SFTPDownloadToPath(arg1:string,arg2:string,arg3:string):Promise; - -export function SFTPList(arg1:string,arg2:string):Promise>>; - -export function SFTPMkdir(arg1:string,arg2:string):Promise; - -export function SFTPPwd(arg1:string):Promise; - -export function SFTPRemove(arg1:string,arg2:string):Promise; - -export function SFTPRename(arg1:string,arg2:string,arg3:string):Promise; - -export function SFTPStat(arg1:string,arg2:string):Promise>; - -export function SFTPUpload(arg1:string,arg2:string):Promise; - -export function SFTPUploadCancel(arg1:string):Promise; - -export function SFTPUploadChunk(arg1:string,arg2:string):Promise; - -export function SFTPUploadData(arg1:string,arg2:string,arg3:string,arg4:string):Promise; - -export function SFTPUploadFinish(arg1:string):Promise; - -export function SFTPUploadPath(arg1:string,arg2:string,arg3:string):Promise; - -export function SFTPUploadStart(arg1:string,arg2:string,arg3:string,arg4:number):Promise; - -export function SaveFile(arg1:string,arg2:string):Promise; - -export function SavePreferences(arg1:models.AppPreferences):Promise; - -export function SaveSessionCapture(arg1:string,arg2:string):Promise; - -export function SearchProfiles(arg1:string):Promise>; - -export function SelectFile(arg1:string,arg2:string):Promise; - -export function SendBreak(arg1:string):Promise; - -export function SetContext(arg1:context.Context):Promise; - -export function SetEventSink(arg1:any):Promise; - -export function SetFavourite(arg1:string,arg2:boolean):Promise; - -export function SetProfileSecret(arg1:string,arg2:string,arg3:string):Promise; - -export function SetTOTPSecret(arg1:string,arg2:string):Promise; - -export function Shutdown():Promise; - -export function StartPortForward(arg1:string,arg2:models.PortForward):Promise; - -export function StartRecording(arg1:string,arg2:number,arg3:number):Promise; - -export function StopPortForward(arg1:string,arg2:string):Promise; - -export function StopRecording(arg1:string):Promise; - -export function TestConnection(arg1:string,arg2:number):Promise>; - -export function UnlockSecretStore(arg1:string):Promise; - -export function UpdateProfile(arg1:models.Profile):Promise; - -export function UpdateSnippet(arg1:models.Snippet):Promise; - -export function WriteSession(arg1:string,arg2:string):Promise; - -export function WriteSessionEscaped(arg1:string,arg2:string):Promise; - -export function WriteSessionHex(arg1:string,arg2:string):Promise; - -export function WriteSessionText(arg1:string,arg2:string):Promise; diff --git a/frontend/wailsjs/go/api/Service.js b/frontend/wailsjs/go/api/Service.js deleted file mode 100755 index fb48e57..0000000 --- a/frontend/wailsjs/go/api/Service.js +++ /dev/null @@ -1,515 +0,0 @@ -// @ts-check -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL -// This file is automatically generated. DO NOT EDIT - -export function BrowseForShell() { - return window['go']['api']['Service']['BrowseForShell'](); -} - -export function CancelReconnect(arg1) { - return window['go']['api']['Service']['CancelReconnect'](arg1); -} - -export function CheckForUpdates() { - return window['go']['api']['Service']['CheckForUpdates'](); -} - -export function CleanupRecordings(arg1, arg2) { - return window['go']['api']['Service']['CleanupRecordings'](arg1, arg2); -} - -export function ClearSessionHistory(arg1) { - return window['go']['api']['Service']['ClearSessionHistory'](arg1); -} - -export function CloseAllSessions() { - return window['go']['api']['Service']['CloseAllSessions'](); -} - -export function ConnectLocal() { - return window['go']['api']['Service']['ConnectLocal'](); -} - -export function ConnectQuick(arg1) { - return window['go']['api']['Service']['ConnectQuick'](arg1); -} - -export function ConnectSession(arg1) { - return window['go']['api']['Service']['ConnectSession'](arg1); -} - -export function CreateProfile(arg1) { - return window['go']['api']['Service']['CreateProfile'](arg1); -} - -export function CreateSnippet(arg1) { - return window['go']['api']['Service']['CreateSnippet'](arg1); -} - -export function DeleteKnownHost(arg1) { - return window['go']['api']['Service']['DeleteKnownHost'](arg1); -} - -export function DeleteProfile(arg1) { - return window['go']['api']['Service']['DeleteProfile'](arg1); -} - -export function DeleteProfileSecrets(arg1) { - return window['go']['api']['Service']['DeleteProfileSecrets'](arg1); -} - -export function DeleteRecording(arg1) { - return window['go']['api']['Service']['DeleteRecording'](arg1); -} - -export function DeleteSSHKey(arg1) { - return window['go']['api']['Service']['DeleteSSHKey'](arg1); -} - -export function DeleteSnippet(arg1) { - return window['go']['api']['Service']['DeleteSnippet'](arg1); -} - -export function DeleteTOTPSecret(arg1) { - return window['go']['api']['Service']['DeleteTOTPSecret'](arg1); -} - -export function DisconnectSession(arg1) { - return window['go']['api']['Service']['DisconnectSession'](arg1); -} - -export function DownloadGitHubRepoArchive(arg1, arg2, arg3) { - return window['go']['api']['Service']['DownloadGitHubRepoArchive'](arg1, arg2, arg3); -} - -export function DownloadGitHubRepoArchiveToPath(arg1, arg2, arg3, arg4) { - return window['go']['api']['Service']['DownloadGitHubRepoArchiveToPath'](arg1, arg2, arg3, arg4); -} - -export function DuplicateProfile(arg1) { - return window['go']['api']['Service']['DuplicateProfile'](arg1); -} - -export function ExportProfiles(arg1) { - return window['go']['api']['Service']['ExportProfiles'](arg1); -} - -export function ExportProfilesEncrypted(arg1, arg2) { - return window['go']['api']['Service']['ExportProfilesEncrypted'](arg1, arg2); -} - -export function FileExists(arg1) { - return window['go']['api']['Service']['FileExists'](arg1); -} - -export function FileIsPrivateKey(arg1) { - return window['go']['api']['Service']['FileIsPrivateKey'](arg1); -} - -export function GenerateSSHKey(arg1, arg2, arg3, arg4) { - return window['go']['api']['Service']['GenerateSSHKey'](arg1, arg2, arg3, arg4); -} - -export function GetAppInfo() { - return window['go']['api']['Service']['GetAppInfo'](); -} - -export function GetDefaultShell() { - return window['go']['api']['Service']['GetDefaultShell'](); -} - -export function GetPreferences() { - return window['go']['api']['Service']['GetPreferences'](); -} - -export function GetProfile(arg1) { - return window['go']['api']['Service']['GetProfile'](arg1); -} - -export function GetRecording(arg1) { - return window['go']['api']['Service']['GetRecording'](arg1); -} - -export function GetRecordingStorageInfo() { - return window['go']['api']['Service']['GetRecordingStorageInfo'](); -} - -export function GetSSHPublicKey(arg1) { - return window['go']['api']['Service']['GetSSHPublicKey'](arg1); -} - -export function GetSessionCwd(arg1) { - return window['go']['api']['Service']['GetSessionCwd'](arg1); -} - -export function GetSessionHistory(arg1) { - return window['go']['api']['Service']['GetSessionHistory'](arg1); -} - -export function GetTOTPCode(arg1) { - return window['go']['api']['Service']['GetTOTPCode'](arg1); -} - -export function GetVNCWebSocketURL(arg1) { - return window['go']['api']['Service']['GetVNCWebSocketURL'](arg1); -} - -export function HasTOTPSecret(arg1) { - return window['go']['api']['Service']['HasTOTPSecret'](arg1); -} - -export function IRCHandleInput(arg1, arg2, arg3) { - return window['go']['api']['Service']['IRCHandleInput'](arg1, arg2, arg3); -} - -export function IRCJoin(arg1, arg2) { - return window['go']['api']['Service']['IRCJoin'](arg1, arg2); -} - -export function IRCListRooms(arg1) { - return window['go']['api']['Service']['IRCListRooms'](arg1); -} - -export function IRCPart(arg1, arg2) { - return window['go']['api']['Service']['IRCPart'](arg1, arg2); -} - -export function IRCRooms(arg1) { - return window['go']['api']['Service']['IRCRooms'](arg1); -} - -export function IRCUsers(arg1, arg2) { - return window['go']['api']['Service']['IRCUsers'](arg1, arg2); -} - -export function ImportProfiles(arg1, arg2) { - return window['go']['api']['Service']['ImportProfiles'](arg1, arg2); -} - -export function ImportProfilesEncrypted(arg1, arg2) { - return window['go']['api']['Service']['ImportProfilesEncrypted'](arg1, arg2); -} - -export function ImportSSHConfig(arg1, arg2) { - return window['go']['api']['Service']['ImportSSHConfig'](arg1, arg2); -} - -export function InitialiseSecretStore(arg1) { - return window['go']['api']['Service']['InitialiseSecretStore'](arg1); -} - -export function IsRecording(arg1) { - return window['go']['api']['Service']['IsRecording'](arg1); -} - -export function IsSecretStoreInitialised() { - return window['go']['api']['Service']['IsSecretStoreInitialised'](); -} - -export function IsSecretStoreUnlocked() { - return window['go']['api']['Service']['IsSecretStoreUnlocked'](); -} - -export function LeviathanExportJSON(arg1, arg2) { - return window['go']['api']['Service']['LeviathanExportJSON'](arg1, arg2); -} - -export function LeviathanExportRange(arg1, arg2, arg3) { - return window['go']['api']['Service']['LeviathanExportRange'](arg1, arg2, arg3); -} - -export function LeviathanLoadFile(arg1, arg2) { - return window['go']['api']['Service']['LeviathanLoadFile'](arg1, arg2); -} - -export function LeviathanOpenCompare() { - return window['go']['api']['Service']['LeviathanOpenCompare'](); -} - -export function LeviathanOpenFile() { - return window['go']['api']['Service']['LeviathanOpenFile'](); -} - -export function LeviathanRedo() { - return window['go']['api']['Service']['LeviathanRedo'](); -} - -export function LeviathanRevert() { - return window['go']['api']['Service']['LeviathanRevert'](); -} - -export function LeviathanRunTool(arg1, arg2) { - return window['go']['api']['Service']['LeviathanRunTool'](arg1, arg2); -} - -export function LeviathanSaveWorking() { - return window['go']['api']['Service']['LeviathanSaveWorking'](); -} - -export function LeviathanSaveWorkingToPath(arg1) { - return window['go']['api']['Service']['LeviathanSaveWorkingToPath'](arg1); -} - -export function LeviathanSetByte(arg1, arg2) { - return window['go']['api']['Service']['LeviathanSetByte'](arg1, arg2); -} - -export function LeviathanState() { - return window['go']['api']['Service']['LeviathanState'](); -} - -export function LeviathanUndo() { - return window['go']['api']['Service']['LeviathanUndo'](); -} - -export function ListAuditEntries(arg1) { - return window['go']['api']['Service']['ListAuditEntries'](arg1); -} - -export function ListFavourites() { - return window['go']['api']['Service']['ListFavourites'](); -} - -export function ListKnownHosts() { - return window['go']['api']['Service']['ListKnownHosts'](); -} - -export function ListPortForwards(arg1) { - return window['go']['api']['Service']['ListPortForwards'](arg1); -} - -export function ListProfiles() { - return window['go']['api']['Service']['ListProfiles'](); -} - -export function ListProfilesByFolder(arg1) { - return window['go']['api']['Service']['ListProfilesByFolder'](arg1); -} - -export function ListProfilesByProtocol(arg1) { - return window['go']['api']['Service']['ListProfilesByProtocol'](arg1); -} - -export function ListProfilesByTag(arg1) { - return window['go']['api']['Service']['ListProfilesByTag'](arg1); -} - -export function ListRecordings() { - return window['go']['api']['Service']['ListRecordings'](); -} - -export function ListSSHKeyPairs() { - return window['go']['api']['Service']['ListSSHKeyPairs'](); -} - -export function ListSSHKeys() { - return window['go']['api']['Service']['ListSSHKeys'](); -} - -export function ListSessions() { - return window['go']['api']['Service']['ListSessions'](); -} - -export function ListSnippets() { - return window['go']['api']['Service']['ListSnippets'](); -} - -export function LockSecretStore() { - return window['go']['api']['Service']['LockSecretStore'](); -} - -export function NewProfile(arg1) { - return window['go']['api']['Service']['NewProfile'](arg1); -} - -export function PinSession(arg1, arg2) { - return window['go']['api']['Service']['PinSession'](arg1, arg2); -} - -export function PingSession(arg1) { - return window['go']['api']['Service']['PingSession'](arg1); -} - -export function ReconnectSession(arg1) { - return window['go']['api']['Service']['ReconnectSession'](arg1); -} - -export function RefreshGitHubRepos(arg1) { - return window['go']['api']['Service']['RefreshGitHubRepos'](arg1); -} - -export function RegisterSecretResponseListener() { - return window['go']['api']['Service']['RegisterSecretResponseListener'](); -} - -export function RenameSession(arg1, arg2) { - return window['go']['api']['Service']['RenameSession'](arg1, arg2); -} - -export function ResizeSession(arg1, arg2, arg3) { - return window['go']['api']['Service']['ResizeSession'](arg1, arg2, arg3); -} - -export function RespondSecret(arg1, arg2, arg3) { - return window['go']['api']['Service']['RespondSecret'](arg1, arg2, arg3); -} - -export function RestartLocalSession(arg1, arg2, arg3) { - return window['go']['api']['Service']['RestartLocalSession'](arg1, arg2, arg3); -} - -export function SFTPDownload(arg1, arg2) { - return window['go']['api']['Service']['SFTPDownload'](arg1, arg2); -} - -export function SFTPDownloadToPath(arg1, arg2, arg3) { - return window['go']['api']['Service']['SFTPDownloadToPath'](arg1, arg2, arg3); -} - -export function SFTPList(arg1, arg2) { - return window['go']['api']['Service']['SFTPList'](arg1, arg2); -} - -export function SFTPMkdir(arg1, arg2) { - return window['go']['api']['Service']['SFTPMkdir'](arg1, arg2); -} - -export function SFTPPwd(arg1) { - return window['go']['api']['Service']['SFTPPwd'](arg1); -} - -export function SFTPRemove(arg1, arg2) { - return window['go']['api']['Service']['SFTPRemove'](arg1, arg2); -} - -export function SFTPRename(arg1, arg2, arg3) { - return window['go']['api']['Service']['SFTPRename'](arg1, arg2, arg3); -} - -export function SFTPStat(arg1, arg2) { - return window['go']['api']['Service']['SFTPStat'](arg1, arg2); -} - -export function SFTPUpload(arg1, arg2) { - return window['go']['api']['Service']['SFTPUpload'](arg1, arg2); -} - -export function SFTPUploadCancel(arg1) { - return window['go']['api']['Service']['SFTPUploadCancel'](arg1); -} - -export function SFTPUploadChunk(arg1, arg2) { - return window['go']['api']['Service']['SFTPUploadChunk'](arg1, arg2); -} - -export function SFTPUploadData(arg1, arg2, arg3, arg4) { - return window['go']['api']['Service']['SFTPUploadData'](arg1, arg2, arg3, arg4); -} - -export function SFTPUploadFinish(arg1) { - return window['go']['api']['Service']['SFTPUploadFinish'](arg1); -} - -export function SFTPUploadPath(arg1, arg2, arg3) { - return window['go']['api']['Service']['SFTPUploadPath'](arg1, arg2, arg3); -} - -export function SFTPUploadStart(arg1, arg2, arg3, arg4) { - return window['go']['api']['Service']['SFTPUploadStart'](arg1, arg2, arg3, arg4); -} - -export function SaveFile(arg1, arg2) { - return window['go']['api']['Service']['SaveFile'](arg1, arg2); -} - -export function SavePreferences(arg1) { - return window['go']['api']['Service']['SavePreferences'](arg1); -} - -export function SaveSessionCapture(arg1, arg2) { - return window['go']['api']['Service']['SaveSessionCapture'](arg1, arg2); -} - -export function SearchProfiles(arg1) { - return window['go']['api']['Service']['SearchProfiles'](arg1); -} - -export function SelectFile(arg1, arg2) { - return window['go']['api']['Service']['SelectFile'](arg1, arg2); -} - -export function SendBreak(arg1) { - return window['go']['api']['Service']['SendBreak'](arg1); -} - -export function SetContext(arg1) { - return window['go']['api']['Service']['SetContext'](arg1); -} - -export function SetEventSink(arg1) { - return window['go']['api']['Service']['SetEventSink'](arg1); -} - -export function SetFavourite(arg1, arg2) { - return window['go']['api']['Service']['SetFavourite'](arg1, arg2); -} - -export function SetProfileSecret(arg1, arg2, arg3) { - return window['go']['api']['Service']['SetProfileSecret'](arg1, arg2, arg3); -} - -export function SetTOTPSecret(arg1, arg2) { - return window['go']['api']['Service']['SetTOTPSecret'](arg1, arg2); -} - -export function Shutdown() { - return window['go']['api']['Service']['Shutdown'](); -} - -export function StartPortForward(arg1, arg2) { - return window['go']['api']['Service']['StartPortForward'](arg1, arg2); -} - -export function StartRecording(arg1, arg2, arg3) { - return window['go']['api']['Service']['StartRecording'](arg1, arg2, arg3); -} - -export function StopPortForward(arg1, arg2) { - return window['go']['api']['Service']['StopPortForward'](arg1, arg2); -} - -export function StopRecording(arg1) { - return window['go']['api']['Service']['StopRecording'](arg1); -} - -export function TestConnection(arg1, arg2) { - return window['go']['api']['Service']['TestConnection'](arg1, arg2); -} - -export function UnlockSecretStore(arg1) { - return window['go']['api']['Service']['UnlockSecretStore'](arg1); -} - -export function UpdateProfile(arg1) { - return window['go']['api']['Service']['UpdateProfile'](arg1); -} - -export function UpdateSnippet(arg1) { - return window['go']['api']['Service']['UpdateSnippet'](arg1); -} - -export function WriteSession(arg1, arg2) { - return window['go']['api']['Service']['WriteSession'](arg1, arg2); -} - -export function WriteSessionEscaped(arg1, arg2) { - return window['go']['api']['Service']['WriteSessionEscaped'](arg1, arg2); -} - -export function WriteSessionHex(arg1, arg2) { - return window['go']['api']['Service']['WriteSessionHex'](arg1, arg2); -} - -export function WriteSessionText(arg1, arg2) { - return window['go']['api']['Service']['WriteSessionText'](arg1, arg2); -} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts deleted file mode 100755 index b66a789..0000000 --- a/frontend/wailsjs/go/models.ts +++ /dev/null @@ -1,691 +0,0 @@ -export namespace api { - - export class SSHKeyInfo { - name: string; - type: string; - bits: string; - publicKey: string; - privateKeyPath: string; - - static createFrom(source: any = {}) { - return new SSHKeyInfo(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.name = source["name"]; - this.type = source["type"]; - this.bits = source["bits"]; - this.publicKey = source["publicKey"]; - this.privateKeyPath = source["privateKeyPath"]; - } - } - export class leviathanDocState { - name: string; - size: number; - dirty: boolean; - undoLen: number; - redoLen: number; - compare?: leviathanDocState; - previewHex: any[]; - - static createFrom(source: any = {}) { - return new leviathanDocState(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.name = source["name"]; - this.size = source["size"]; - this.dirty = source["dirty"]; - this.undoLen = source["undoLen"]; - this.redoLen = source["redoLen"]; - this.compare = this.convertValues(source["compare"], leviathanDocState); - this.previewHex = source["previewHex"]; - } - - convertValues(a: any, classs: any, asMap: boolean = false): any { - if (!a) { - return a; - } - if (a.slice && a.map) { - return (a as any[]).map(elem => this.convertValues(elem, classs)); - } else if ("object" === typeof a) { - if (asMap) { - for (const key of Object.keys(a)) { - a[key] = new classs(a[key]); - } - return a; - } - return new classs(a); - } - return a; - } - } - -} - -export namespace models { - - export class AppPreferences { - theme: string; - terminalTheme: string; - defaultProtocol: string; - confirmOnClose: boolean; - confirmOnDisconnect: boolean; - showStatusBar: boolean; - sidebarWidth: number; - defaultFontFamily: string; - defaultFontSize: number; - defaultScrollback: number; - defaultCursorStyle: string; - useNativePaste: boolean; - localShell: string; - leviathanEnabled: boolean; - - static createFrom(source: any = {}) { - return new AppPreferences(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.theme = source["theme"]; - this.terminalTheme = source["terminalTheme"]; - this.defaultProtocol = source["defaultProtocol"]; - this.confirmOnClose = source["confirmOnClose"]; - this.confirmOnDisconnect = source["confirmOnDisconnect"]; - this.showStatusBar = source["showStatusBar"]; - this.sidebarWidth = source["sidebarWidth"]; - this.defaultFontFamily = source["defaultFontFamily"]; - this.defaultFontSize = source["defaultFontSize"]; - this.defaultScrollback = source["defaultScrollback"]; - this.defaultCursorStyle = source["defaultCursorStyle"]; - this.useNativePaste = source["useNativePaste"]; - this.localShell = source["localShell"]; - this.leviathanEnabled = source["leviathanEnabled"]; - } - } - export class AuditEntry { - id: string; - timestamp: string; - action: string; - profileId?: string; - sessionId?: string; - details: string; - - static createFrom(source: any = {}) { - return new AuditEntry(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.id = source["id"]; - this.timestamp = source["timestamp"]; - this.action = source["action"]; - this.profileId = source["profileId"]; - this.sessionId = source["sessionId"]; - this.details = source["details"]; - } - } - export class GitHubConfig { - baseURL: string; - secretMode: string; - refreshIntervalSecs: number; - - static createFrom(source: any = {}) { - return new GitHubConfig(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.baseURL = source["baseURL"]; - this.secretMode = source["secretMode"]; - this.refreshIntervalSecs = source["refreshIntervalSecs"]; - } - } - export class GitHubRepo { - fullName: string; - name: string; - owner: string; - description: string; - htmlURL: string; - defaultBranch: string; - latestCommitter: string; - latestCommitAt: string; - private: boolean; - fork: boolean; - archived: boolean; - language: string; - stars: number; - pushedAt: string; - updatedAt: string; - - static createFrom(source: any = {}) { - return new GitHubRepo(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.fullName = source["fullName"]; - this.name = source["name"]; - this.owner = source["owner"]; - this.description = source["description"]; - this.htmlURL = source["htmlURL"]; - this.defaultBranch = source["defaultBranch"]; - this.latestCommitter = source["latestCommitter"]; - this.latestCommitAt = source["latestCommitAt"]; - this.private = source["private"]; - this.fork = source["fork"]; - this.archived = source["archived"]; - this.language = source["language"]; - this.stars = source["stars"]; - this.pushedAt = source["pushedAt"]; - this.updatedAt = source["updatedAt"]; - } - } - export class IRCConfig { - nick: string; - realName: string; - useTLS: boolean; - serverPasswordRequired: boolean; - serverPasswordMode: string; - nickServIdentify: boolean; - nickServAccount: string; - nickServMode: string; - autoJoinRooms: string[]; - sshTunnel: boolean; - sshHost: string; - sshPort: number; - sshUsername: string; - sshAuthMethod: string; - sshSecretMode: string; - sshPrivateKeyPath: string; - sshAgentAuth: boolean; - autoReconnect: boolean; - maxReconnectTries: number; - - static createFrom(source: any = {}) { - return new IRCConfig(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.nick = source["nick"]; - this.realName = source["realName"]; - this.useTLS = source["useTLS"]; - this.serverPasswordRequired = source["serverPasswordRequired"]; - this.serverPasswordMode = source["serverPasswordMode"]; - this.nickServIdentify = source["nickServIdentify"]; - this.nickServAccount = source["nickServAccount"]; - this.nickServMode = source["nickServMode"]; - this.autoJoinRooms = source["autoJoinRooms"]; - this.sshTunnel = source["sshTunnel"]; - this.sshHost = source["sshHost"]; - this.sshPort = source["sshPort"]; - this.sshUsername = source["sshUsername"]; - this.sshAuthMethod = source["sshAuthMethod"]; - this.sshSecretMode = source["sshSecretMode"]; - this.sshPrivateKeyPath = source["sshPrivateKeyPath"]; - this.sshAgentAuth = source["sshAgentAuth"]; - this.autoReconnect = source["autoReconnect"]; - this.maxReconnectTries = source["maxReconnectTries"]; - } - } - export class JumpHost { - host: string; - port: number; - username: string; - authMethod: string; - secretMode: string; - privateKeyPath: string; - - static createFrom(source: any = {}) { - return new JumpHost(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.host = source["host"]; - this.port = source["port"]; - this.username = source["username"]; - this.authMethod = source["authMethod"]; - this.secretMode = source["secretMode"]; - this.privateKeyPath = source["privateKeyPath"]; - } - } - export class KnownHost { - id: string; - host: string; - port: number; - keyType: string; - fingerprint: string; - publicKey: string; - firstSeen: string; - lastSeen: string; - trusted: boolean; - - static createFrom(source: any = {}) { - return new KnownHost(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.id = source["id"]; - this.host = source["host"]; - this.port = source["port"]; - this.keyType = source["keyType"]; - this.fingerprint = source["fingerprint"]; - this.publicKey = source["publicKey"]; - this.firstSeen = source["firstSeen"]; - this.lastSeen = source["lastSeen"]; - this.trusted = source["trusted"]; - } - } - export class LoginScriptStep { - type: string; - value: string; - timeout: number; - - static createFrom(source: any = {}) { - return new LoginScriptStep(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.type = source["type"]; - this.value = source["value"]; - this.timeout = source["timeout"]; - } - } - export class PayloadPreset { - id: string; - name: string; - payload: string; - format: string; - - static createFrom(source: any = {}) { - return new PayloadPreset(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.id = source["id"]; - this.name = source["name"]; - this.payload = source["payload"]; - this.format = source["format"]; - } - } - export class PortForward { - id: string; - direction: string; - bindHost: string; - bindPort: number; - destHost: string; - destPort: number; - enabled: boolean; - label: string; - - static createFrom(source: any = {}) { - return new PortForward(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.id = source["id"]; - this.direction = source["direction"]; - this.bindHost = source["bindHost"]; - this.bindPort = source["bindPort"]; - this.destHost = source["destHost"]; - this.destPort = source["destPort"]; - this.enabled = source["enabled"]; - this.label = source["label"]; - } - } - export class VNCConfig { - vncHost: string; - vncPort: number; - secretMode: string; - viewOnly: boolean; - scaleToFit: boolean; - quality: number; - sharedSession: boolean; - sshTunnel: boolean; - sshHost: string; - sshPort: number; - sshUsername: string; - sshAuthMethod: string; - sshSecretMode: string; - sshPrivateKeyPath: string; - sshAgentAuth: boolean; - - static createFrom(source: any = {}) { - return new VNCConfig(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.vncHost = source["vncHost"]; - this.vncPort = source["vncPort"]; - this.secretMode = source["secretMode"]; - this.viewOnly = source["viewOnly"]; - this.scaleToFit = source["scaleToFit"]; - this.quality = source["quality"]; - this.sharedSession = source["sharedSession"]; - this.sshTunnel = source["sshTunnel"]; - this.sshHost = source["sshHost"]; - this.sshPort = source["sshPort"]; - this.sshUsername = source["sshUsername"]; - this.sshAuthMethod = source["sshAuthMethod"]; - this.sshSecretMode = source["sshSecretMode"]; - this.sshPrivateKeyPath = source["sshPrivateKeyPath"]; - this.sshAgentAuth = source["sshAgentAuth"]; - } - } - export class RAWConfig { - autoReconnect: boolean; - lineEnding: string; - encoding: string; - showTimestamps: boolean; - showDirection: boolean; - payloadPresets: PayloadPreset[]; - - static createFrom(source: any = {}) { - return new RAWConfig(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.autoReconnect = source["autoReconnect"]; - this.lineEnding = source["lineEnding"]; - this.encoding = source["encoding"]; - this.showTimestamps = source["showTimestamps"]; - this.showDirection = source["showDirection"]; - this.payloadPresets = this.convertValues(source["payloadPresets"], PayloadPreset); - } - - convertValues(a: any, classs: any, asMap: boolean = false): any { - if (!a) { - return a; - } - if (a.slice && a.map) { - return (a as any[]).map(elem => this.convertValues(elem, classs)); - } else if ("object" === typeof a) { - if (asMap) { - for (const key of Object.keys(a)) { - a[key] = new classs(a[key]); - } - return a; - } - return new classs(a); - } - return a; - } - } - export class TelnetConfig { - localEcho: boolean; - lineEnding: string; - terminalType: string; - encoding: string; - negotiationDebug: boolean; - lineMode: boolean; - autoReconnect: boolean; - maxReconnectTries: number; - loginScripts: LoginScriptStep[]; - - static createFrom(source: any = {}) { - return new TelnetConfig(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.localEcho = source["localEcho"]; - this.lineEnding = source["lineEnding"]; - this.terminalType = source["terminalType"]; - this.encoding = source["encoding"]; - this.negotiationDebug = source["negotiationDebug"]; - this.lineMode = source["lineMode"]; - this.autoReconnect = source["autoReconnect"]; - this.maxReconnectTries = source["maxReconnectTries"]; - this.loginScripts = this.convertValues(source["loginScripts"], LoginScriptStep); - } - - convertValues(a: any, classs: any, asMap: boolean = false): any { - if (!a) { - return a; - } - if (a.slice && a.map) { - return (a as any[]).map(elem => this.convertValues(elem, classs)); - } else if ("object" === typeof a) { - if (asMap) { - for (const key of Object.keys(a)) { - a[key] = new classs(a[key]); - } - return a; - } - return new classs(a); - } - return a; - } - } - export class SSHConfig { - authMethods: string[]; - secretMode: string; - privateKeyPath: string; - keySecretMode: string; - hostKeyPolicy: string; - terminalType: string; - keepaliveInterval: number; - keepaliveCountMax: number; - compression: boolean; - agentAuth: boolean; - agentForwarding: boolean; - autoReconnect: boolean; - maxReconnectTries: number; - loginScripts: LoginScriptStep[]; - totpSecret: string; - proxyType: string; - proxyHost: string; - proxyPort: number; - proxyUsername: string; - proxyPassword: string; - jumpHosts: JumpHost[]; - portForwards: PortForward[]; - startupCommand: string; - envVars: Record; - - static createFrom(source: any = {}) { - return new SSHConfig(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.authMethods = source["authMethods"]; - this.secretMode = source["secretMode"]; - this.privateKeyPath = source["privateKeyPath"]; - this.keySecretMode = source["keySecretMode"]; - this.hostKeyPolicy = source["hostKeyPolicy"]; - this.terminalType = source["terminalType"]; - this.keepaliveInterval = source["keepaliveInterval"]; - this.keepaliveCountMax = source["keepaliveCountMax"]; - this.compression = source["compression"]; - this.agentAuth = source["agentAuth"]; - this.agentForwarding = source["agentForwarding"]; - this.autoReconnect = source["autoReconnect"]; - this.maxReconnectTries = source["maxReconnectTries"]; - this.loginScripts = this.convertValues(source["loginScripts"], LoginScriptStep); - this.totpSecret = source["totpSecret"]; - this.proxyType = source["proxyType"]; - this.proxyHost = source["proxyHost"]; - this.proxyPort = source["proxyPort"]; - this.proxyUsername = source["proxyUsername"]; - this.proxyPassword = source["proxyPassword"]; - this.jumpHosts = this.convertValues(source["jumpHosts"], JumpHost); - this.portForwards = this.convertValues(source["portForwards"], PortForward); - this.startupCommand = source["startupCommand"]; - this.envVars = source["envVars"]; - } - - convertValues(a: any, classs: any, asMap: boolean = false): any { - if (!a) { - return a; - } - if (a.slice && a.map) { - return (a as any[]).map(elem => this.convertValues(elem, classs)); - } else if ("object" === typeof a) { - if (asMap) { - for (const key of Object.keys(a)) { - a[key] = new classs(a[key]); - } - return a; - } - return new classs(a); - } - return a; - } - } - export class Profile { - id: string; - displayName: string; - description: string; - protocol: string; - host: string; - port: number; - username: string; - tags: string[]; - folder: string; - environment: string; - notes: string; - favourite: boolean; - lastConnected?: string; - connectTimeout: number; - readTimeout: number; - createdAt: string; - updatedAt: string; - terminalFontFamily: string; - terminalFontSize: number; - terminalScrollback: number; - terminalCursorStyle: string; - ssh?: SSHConfig; - telnet?: TelnetConfig; - raw?: RAWConfig; - github?: GitHubConfig; - vnc?: VNCConfig; - irc?: IRCConfig; - - static createFrom(source: any = {}) { - return new Profile(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.id = source["id"]; - this.displayName = source["displayName"]; - this.description = source["description"]; - this.protocol = source["protocol"]; - this.host = source["host"]; - this.port = source["port"]; - this.username = source["username"]; - this.tags = source["tags"]; - this.folder = source["folder"]; - this.environment = source["environment"]; - this.notes = source["notes"]; - this.favourite = source["favourite"]; - this.lastConnected = source["lastConnected"]; - this.connectTimeout = source["connectTimeout"]; - this.readTimeout = source["readTimeout"]; - this.createdAt = source["createdAt"]; - this.updatedAt = source["updatedAt"]; - this.terminalFontFamily = source["terminalFontFamily"]; - this.terminalFontSize = source["terminalFontSize"]; - this.terminalScrollback = source["terminalScrollback"]; - this.terminalCursorStyle = source["terminalCursorStyle"]; - this.ssh = this.convertValues(source["ssh"], SSHConfig); - this.telnet = this.convertValues(source["telnet"], TelnetConfig); - this.raw = this.convertValues(source["raw"], RAWConfig); - this.github = this.convertValues(source["github"], GitHubConfig); - this.vnc = this.convertValues(source["vnc"], VNCConfig); - this.irc = this.convertValues(source["irc"], IRCConfig); - } - - convertValues(a: any, classs: any, asMap: boolean = false): any { - if (!a) { - return a; - } - if (a.slice && a.map) { - return (a as any[]).map(elem => this.convertValues(elem, classs)); - } else if ("object" === typeof a) { - if (asMap) { - for (const key of Object.keys(a)) { - a[key] = new classs(a[key]); - } - return a; - } - return new classs(a); - } - return a; - } - } - - - export class SessionInfo { - id: string; - profileId: string; - profileName: string; - protocol: string; - host: string; - port: number; - state: string; - tabName: string; - pinned: boolean; - connectedAt: string; - error?: string; - - static createFrom(source: any = {}) { - return new SessionInfo(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.id = source["id"]; - this.profileId = source["profileId"]; - this.profileName = source["profileName"]; - this.protocol = source["protocol"]; - this.host = source["host"]; - this.port = source["port"]; - this.state = source["state"]; - this.tabName = source["tabName"]; - this.pinned = source["pinned"]; - this.connectedAt = source["connectedAt"]; - this.error = source["error"]; - } - } - export class Snippet { - id: string; - name: string; - command: string; - description: string; - folder: string; - tags: string[]; - createdAt: string; - updatedAt: string; - - static createFrom(source: any = {}) { - return new Snippet(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.id = source["id"]; - this.name = source["name"]; - this.command = source["command"]; - this.description = source["description"]; - this.folder = source["folder"]; - this.tags = source["tags"]; - this.createdAt = source["createdAt"]; - this.updatedAt = source["updatedAt"]; - } - } - - -} - diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json deleted file mode 100644 index 1e7c8a5..0000000 --- a/frontend/wailsjs/runtime/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@wailsapp/runtime", - "version": "2.0.0", - "description": "Wails Javascript runtime library", - "main": "runtime.js", - "types": "runtime.d.ts", - "scripts": { - }, - "repository": { - "type": "git", - "url": "git+https://github.com/wailsapp/wails.git" - }, - "keywords": [ - "Wails", - "Javascript", - "Go" - ], - "author": "Lea Anthony ", - "license": "MIT", - "bugs": { - "url": "https://github.com/wailsapp/wails/issues" - }, - "homepage": "https://github.com/wailsapp/wails#readme" -} diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts deleted file mode 100644 index 3bbea84..0000000 --- a/frontend/wailsjs/runtime/runtime.d.ts +++ /dev/null @@ -1,330 +0,0 @@ -/* - _ __ _ __ -| | / /___ _(_) /____ -| | /| / / __ `/ / / ___/ -| |/ |/ / /_/ / / (__ ) -|__/|__/\__,_/_/_/____/ -The electron alternative for Go -(c) Lea Anthony 2019-present -*/ - -export interface Position { - x: number; - y: number; -} - -export interface Size { - w: number; - h: number; -} - -export interface Screen { - isCurrent: boolean; - isPrimary: boolean; - width : number - height : number -} - -// Environment information such as platform, buildtype, ... -export interface EnvironmentInfo { - buildType: string; - platform: string; - arch: string; -} - -// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit) -// emits the given event. Optional data may be passed with the event. -// This will trigger any event listeners. -export function EventsEmit(eventName: string, ...data: any): void; - -// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name. -export function EventsOn(eventName: string, callback: (...data: any) => void): () => void; - -// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple) -// sets up a listener for the given event name, but will only trigger a given number times. -export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void; - -// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce) -// sets up a listener for the given event name, but will only trigger once. -export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void; - -// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff) -// unregisters the listener for the given event name. -export function EventsOff(eventName: string, ...additionalEventNames: string[]): void; - -// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall) -// unregisters all listeners. -export function EventsOffAll(): void; - -// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint) -// logs the given message as a raw message -export function LogPrint(message: string): void; - -// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace) -// logs the given message at the `trace` log level. -export function LogTrace(message: string): void; - -// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug) -// logs the given message at the `debug` log level. -export function LogDebug(message: string): void; - -// [LogError](https://wails.io/docs/reference/runtime/log#logerror) -// logs the given message at the `error` log level. -export function LogError(message: string): void; - -// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal) -// logs the given message at the `fatal` log level. -// The application will quit after calling this method. -export function LogFatal(message: string): void; - -// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo) -// logs the given message at the `info` log level. -export function LogInfo(message: string): void; - -// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning) -// logs the given message at the `warning` log level. -export function LogWarning(message: string): void; - -// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload) -// Forces a reload by the main application as well as connected browsers. -export function WindowReload(): void; - -// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp) -// Reloads the application frontend. -export function WindowReloadApp(): void; - -// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop) -// Sets the window AlwaysOnTop or not on top. -export function WindowSetAlwaysOnTop(b: boolean): void; - -// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme) -// *Windows only* -// Sets window theme to system default (dark/light). -export function WindowSetSystemDefaultTheme(): void; - -// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme) -// *Windows only* -// Sets window to light theme. -export function WindowSetLightTheme(): void; - -// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme) -// *Windows only* -// Sets window to dark theme. -export function WindowSetDarkTheme(): void; - -// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter) -// Centers the window on the monitor the window is currently on. -export function WindowCenter(): void; - -// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle) -// Sets the text in the window title bar. -export function WindowSetTitle(title: string): void; - -// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen) -// Makes the window full screen. -export function WindowFullscreen(): void; - -// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen) -// Restores the previous window dimensions and position prior to full screen. -export function WindowUnfullscreen(): void; - -// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen) -// Returns the state of the window, i.e. whether the window is in full screen mode or not. -export function WindowIsFullscreen(): Promise; - -// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize) -// Sets the width and height of the window. -export function WindowSetSize(width: number, height: number): void; - -// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize) -// Gets the width and height of the window. -export function WindowGetSize(): Promise; - -// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize) -// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions. -// Setting a size of 0,0 will disable this constraint. -export function WindowSetMaxSize(width: number, height: number): void; - -// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize) -// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions. -// Setting a size of 0,0 will disable this constraint. -export function WindowSetMinSize(width: number, height: number): void; - -// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition) -// Sets the window position relative to the monitor the window is currently on. -export function WindowSetPosition(x: number, y: number): void; - -// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition) -// Gets the window position relative to the monitor the window is currently on. -export function WindowGetPosition(): Promise; - -// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide) -// Hides the window. -export function WindowHide(): void; - -// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow) -// Shows the window, if it is currently hidden. -export function WindowShow(): void; - -// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise) -// Maximises the window to fill the screen. -export function WindowMaximise(): void; - -// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise) -// Toggles between Maximised and UnMaximised. -export function WindowToggleMaximise(): void; - -// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise) -// Restores the window to the dimensions and position prior to maximising. -export function WindowUnmaximise(): void; - -// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised) -// Returns the state of the window, i.e. whether the window is maximised or not. -export function WindowIsMaximised(): Promise; - -// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise) -// Minimises the window. -export function WindowMinimise(): void; - -// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise) -// Restores the window to the dimensions and position prior to minimising. -export function WindowUnminimise(): void; - -// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised) -// Returns the state of the window, i.e. whether the window is minimised or not. -export function WindowIsMinimised(): Promise; - -// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal) -// Returns the state of the window, i.e. whether the window is normal or not. -export function WindowIsNormal(): Promise; - -// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour) -// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels. -export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void; - -// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall) -// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system. -export function ScreenGetAll(): Promise; - -// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl) -// Opens the given URL in the system browser. -export function BrowserOpenURL(url: string): void; - -// [Environment](https://wails.io/docs/reference/runtime/intro#environment) -// Returns information about the environment -export function Environment(): Promise; - -// [Quit](https://wails.io/docs/reference/runtime/intro#quit) -// Quits the application. -export function Quit(): void; - -// [Hide](https://wails.io/docs/reference/runtime/intro#hide) -// Hides the application. -export function Hide(): void; - -// [Show](https://wails.io/docs/reference/runtime/intro#show) -// Shows the application. -export function Show(): void; - -// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext) -// Returns the current text stored on clipboard -export function ClipboardGetText(): Promise; - -// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext) -// Sets a text on the clipboard -export function ClipboardSetText(text: string): Promise; - -// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop) -// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. -export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void - -// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff) -// OnFileDropOff removes the drag and drop listeners and handlers. -export function OnFileDropOff() :void - -// Check if the file path resolver is available -export function CanResolveFilePaths(): boolean; - -// Resolves file paths for an array of files -export function ResolveFilePaths(files: File[]): void - -// Notification types -export interface NotificationOptions { - id: string; - title: string; - subtitle?: string; // macOS and Linux only - body?: string; - categoryId?: string; - data?: { [key: string]: any }; -} - -export interface NotificationAction { - id?: string; - title?: string; - destructive?: boolean; // macOS-specific -} - -export interface NotificationCategory { - id?: string; - actions?: NotificationAction[]; - hasReplyField?: boolean; - replyPlaceholder?: string; - replyButtonTitle?: string; -} - -// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications) -// Initializes the notification service for the application. -// This must be called before sending any notifications. -export function InitializeNotifications(): Promise; - -// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications) -// Cleans up notification resources and releases any held connections. -export function CleanupNotifications(): Promise; - -// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable) -// Checks if notifications are available on the current platform. -export function IsNotificationAvailable(): Promise; - -// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization) -// Requests notification authorization from the user (macOS only). -export function RequestNotificationAuthorization(): Promise; - -// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization) -// Checks the current notification authorization status (macOS only). -export function CheckNotificationAuthorization(): Promise; - -// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification) -// Sends a basic notification with the given options. -export function SendNotification(options: NotificationOptions): Promise; - -// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions) -// Sends a notification with action buttons. Requires a registered category. -export function SendNotificationWithActions(options: NotificationOptions): Promise; - -// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory) -// Registers a notification category that can be used with SendNotificationWithActions. -export function RegisterNotificationCategory(category: NotificationCategory): Promise; - -// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory) -// Removes a previously registered notification category. -export function RemoveNotificationCategory(categoryId: string): Promise; - -// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications) -// Removes all pending notifications from the notification center. -export function RemoveAllPendingNotifications(): Promise; - -// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification) -// Removes a specific pending notification by its identifier. -export function RemovePendingNotification(identifier: string): Promise; - -// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications) -// Removes all delivered notifications from the notification center. -export function RemoveAllDeliveredNotifications(): Promise; - -// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification) -// Removes a specific delivered notification by its identifier. -export function RemoveDeliveredNotification(identifier: string): Promise; - -// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification) -// Removes a notification by its identifier (cross-platform convenience function). -export function RemoveNotification(identifier: string): Promise; \ No newline at end of file diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js deleted file mode 100644 index 556621e..0000000 --- a/frontend/wailsjs/runtime/runtime.js +++ /dev/null @@ -1,298 +0,0 @@ -/* - _ __ _ __ -| | / /___ _(_) /____ -| | /| / / __ `/ / / ___/ -| |/ |/ / /_/ / / (__ ) -|__/|__/\__,_/_/_/____/ -The electron alternative for Go -(c) Lea Anthony 2019-present -*/ - -export function LogPrint(message) { - window.runtime.LogPrint(message); -} - -export function LogTrace(message) { - window.runtime.LogTrace(message); -} - -export function LogDebug(message) { - window.runtime.LogDebug(message); -} - -export function LogInfo(message) { - window.runtime.LogInfo(message); -} - -export function LogWarning(message) { - window.runtime.LogWarning(message); -} - -export function LogError(message) { - window.runtime.LogError(message); -} - -export function LogFatal(message) { - window.runtime.LogFatal(message); -} - -export function EventsOnMultiple(eventName, callback, maxCallbacks) { - return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks); -} - -export function EventsOn(eventName, callback) { - return EventsOnMultiple(eventName, callback, -1); -} - -export function EventsOff(eventName, ...additionalEventNames) { - return window.runtime.EventsOff(eventName, ...additionalEventNames); -} - -export function EventsOffAll() { - return window.runtime.EventsOffAll(); -} - -export function EventsOnce(eventName, callback) { - return EventsOnMultiple(eventName, callback, 1); -} - -export function EventsEmit(eventName) { - let args = [eventName].slice.call(arguments); - return window.runtime.EventsEmit.apply(null, args); -} - -export function WindowReload() { - window.runtime.WindowReload(); -} - -export function WindowReloadApp() { - window.runtime.WindowReloadApp(); -} - -export function WindowSetAlwaysOnTop(b) { - window.runtime.WindowSetAlwaysOnTop(b); -} - -export function WindowSetSystemDefaultTheme() { - window.runtime.WindowSetSystemDefaultTheme(); -} - -export function WindowSetLightTheme() { - window.runtime.WindowSetLightTheme(); -} - -export function WindowSetDarkTheme() { - window.runtime.WindowSetDarkTheme(); -} - -export function WindowCenter() { - window.runtime.WindowCenter(); -} - -export function WindowSetTitle(title) { - window.runtime.WindowSetTitle(title); -} - -export function WindowFullscreen() { - window.runtime.WindowFullscreen(); -} - -export function WindowUnfullscreen() { - window.runtime.WindowUnfullscreen(); -} - -export function WindowIsFullscreen() { - return window.runtime.WindowIsFullscreen(); -} - -export function WindowGetSize() { - return window.runtime.WindowGetSize(); -} - -export function WindowSetSize(width, height) { - window.runtime.WindowSetSize(width, height); -} - -export function WindowSetMaxSize(width, height) { - window.runtime.WindowSetMaxSize(width, height); -} - -export function WindowSetMinSize(width, height) { - window.runtime.WindowSetMinSize(width, height); -} - -export function WindowSetPosition(x, y) { - window.runtime.WindowSetPosition(x, y); -} - -export function WindowGetPosition() { - return window.runtime.WindowGetPosition(); -} - -export function WindowHide() { - window.runtime.WindowHide(); -} - -export function WindowShow() { - window.runtime.WindowShow(); -} - -export function WindowMaximise() { - window.runtime.WindowMaximise(); -} - -export function WindowToggleMaximise() { - window.runtime.WindowToggleMaximise(); -} - -export function WindowUnmaximise() { - window.runtime.WindowUnmaximise(); -} - -export function WindowIsMaximised() { - return window.runtime.WindowIsMaximised(); -} - -export function WindowMinimise() { - window.runtime.WindowMinimise(); -} - -export function WindowUnminimise() { - window.runtime.WindowUnminimise(); -} - -export function WindowSetBackgroundColour(R, G, B, A) { - window.runtime.WindowSetBackgroundColour(R, G, B, A); -} - -export function ScreenGetAll() { - return window.runtime.ScreenGetAll(); -} - -export function WindowIsMinimised() { - return window.runtime.WindowIsMinimised(); -} - -export function WindowIsNormal() { - return window.runtime.WindowIsNormal(); -} - -export function BrowserOpenURL(url) { - window.runtime.BrowserOpenURL(url); -} - -export function Environment() { - return window.runtime.Environment(); -} - -export function Quit() { - window.runtime.Quit(); -} - -export function Hide() { - window.runtime.Hide(); -} - -export function Show() { - window.runtime.Show(); -} - -export function ClipboardGetText() { - return window.runtime.ClipboardGetText(); -} - -export function ClipboardSetText(text) { - return window.runtime.ClipboardSetText(text); -} - -/** - * Callback for OnFileDrop returns a slice of file path strings when a drop is finished. - * - * @export - * @callback OnFileDropCallback - * @param {number} x - x coordinate of the drop - * @param {number} y - y coordinate of the drop - * @param {string[]} paths - A list of file paths. - */ - -/** - * OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. - * - * @export - * @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished. - * @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target) - */ -export function OnFileDrop(callback, useDropTarget) { - return window.runtime.OnFileDrop(callback, useDropTarget); -} - -/** - * OnFileDropOff removes the drag and drop listeners and handlers. - */ -export function OnFileDropOff() { - return window.runtime.OnFileDropOff(); -} - -export function CanResolveFilePaths() { - return window.runtime.CanResolveFilePaths(); -} - -export function ResolveFilePaths(files) { - return window.runtime.ResolveFilePaths(files); -} - -export function InitializeNotifications() { - return window.runtime.InitializeNotifications(); -} - -export function CleanupNotifications() { - return window.runtime.CleanupNotifications(); -} - -export function IsNotificationAvailable() { - return window.runtime.IsNotificationAvailable(); -} - -export function RequestNotificationAuthorization() { - return window.runtime.RequestNotificationAuthorization(); -} - -export function CheckNotificationAuthorization() { - return window.runtime.CheckNotificationAuthorization(); -} - -export function SendNotification(options) { - return window.runtime.SendNotification(options); -} - -export function SendNotificationWithActions(options) { - return window.runtime.SendNotificationWithActions(options); -} - -export function RegisterNotificationCategory(category) { - return window.runtime.RegisterNotificationCategory(category); -} - -export function RemoveNotificationCategory(categoryId) { - return window.runtime.RemoveNotificationCategory(categoryId); -} - -export function RemoveAllPendingNotifications() { - return window.runtime.RemoveAllPendingNotifications(); -} - -export function RemovePendingNotification(identifier) { - return window.runtime.RemovePendingNotification(identifier); -} - -export function RemoveAllDeliveredNotifications() { - return window.runtime.RemoveAllDeliveredNotifications(); -} - -export function RemoveDeliveredNotification(identifier) { - return window.runtime.RemoveDeliveredNotification(identifier); -} - -export function RemoveNotification(identifier) { - return window.runtime.RemoveNotification(identifier); -} \ No newline at end of file diff --git a/go.mod b/go.mod deleted file mode 100644 index facfaba..0000000 --- a/go.mod +++ /dev/null @@ -1,58 +0,0 @@ -module github.com/andydixon/link - -go 1.26.0 - -toolchain go1.26.5 - -require ( - github.com/creack/pty v1.1.24 - github.com/google/uuid v1.6.0 - github.com/gorilla/websocket v1.5.3 - github.com/hajimehoshi/go-mp3 v0.3.4 - github.com/klauspost/compress v1.17.11 - github.com/lrstanley/girc v1.1.1 - github.com/pkg/sftp v1.13.10 - github.com/pquerna/otp v1.5.0 - github.com/ulikunitz/xz v0.5.15 - github.com/wailsapp/wails/v2 v2.12.0 - github.com/zalando/go-keyring v0.2.8 - golang.org/x/crypto v0.56.0 - golang.org/x/net v0.57.0 - golang.org/x/text v0.41.0 - modernc.org/sqlite v1.53.0 -) - -require ( - git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect - github.com/bep/debounce v1.2.1 // indirect - github.com/boombuler/barcode v1.1.0 // indirect - github.com/danieljoos/wincred v1.2.3 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/go-ole/go-ole v1.3.0 // indirect - github.com/godbus/dbus/v5 v5.2.2 // indirect - github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect - github.com/kr/fs v0.1.0 // indirect - github.com/labstack/echo/v4 v4.15.2 // indirect - github.com/labstack/gommon v0.5.0 // indirect - github.com/leaanthony/go-ansi-parser v1.6.1 // indirect - github.com/leaanthony/gosod v1.0.4 // indirect - github.com/leaanthony/slicer v1.6.0 // indirect - github.com/leaanthony/u v1.1.1 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.22 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/samber/lo v1.53.0 // indirect - github.com/tkrajina/go-reflector v0.5.8 // indirect - github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasttemplate v1.2.2 // indirect - github.com/wailsapp/go-webview2 v1.0.23 // indirect - github.com/wailsapp/mimetype v1.4.1 // indirect - golang.org/x/sys v0.47.0 // indirect - modernc.org/libc v1.73.4 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 39b096b..0000000 --- a/go.sum +++ /dev/null @@ -1,156 +0,0 @@ -git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= -git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= -github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= -github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= -github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= -github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= -github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= -github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= -github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= -github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= -github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= -github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= -github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= -github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4= -github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI= -github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c= -github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0= -github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= -github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= -github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= -github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= -github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= -github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= -github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= -github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= -github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= -github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= -github.com/lrstanley/girc v1.1.1 h1:0Y8a2tqQGDeFXfBQkAYOu5DbWqlydCJsi+4N+td4azk= -github.com/lrstanley/girc v1.1.1/go.mod h1:lgrnhcF8bg/Bd5HA5DOb4Z+uGqUqGnp4skr+J2GwVgI= -github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= -github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= -github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= -github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= -github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= -github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= -github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= -github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= -github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= -github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0= -github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= -github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= -github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= -github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= -github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= -github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= -github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= -golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= -golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= -golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= -modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= -modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= -modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= -modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= -modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= -modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= -modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= -modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/api/api.go b/internal/api/api.go deleted file mode 100644 index 8e5d3ae..0000000 --- a/internal/api/api.go +++ /dev/null @@ -1,2262 +0,0 @@ -// Package api provides the Wails-bound API surface that the frontend calls. -// All methods are called from JavaScript and must return JSON-serialisable types. -package api - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net" - "os" - "os/exec" - "path" - "path/filepath" - "runtime" - "sort" - "strconv" - "strings" - "sync" - "time" - - "github.com/pquerna/otp/totp" - wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" - - "github.com/andydixon/link/internal/audit" - "github.com/andydixon/link/internal/exportimport" - "github.com/andydixon/link/internal/localshell" - "github.com/andydixon/link/internal/models" - "github.com/andydixon/link/internal/preferences" - "github.com/andydixon/link/internal/secrets" - "github.com/andydixon/link/internal/sessions" - "github.com/andydixon/link/internal/sshconfig" - "github.com/andydixon/link/internal/storage" - "github.com/andydixon/link/internal/updater" - "github.com/andydixon/link/internal/validation" - "github.com/google/uuid" -) - -// appVersion is set at build time via ldflags. -var appVersion = "dev" - -// appUpdateURL is set at build time via ldflags. -var appUpdateURL = "" - -const ( - maxProfileImportJSONBytes = 5 * 1024 * 1024 - maxInlineSFTPUploadBytes = 25 * 1024 * 1024 - maxSFTPUploadChunkBytes = 1024 * 1024 - maxStreamingSFTPUploadSize = int64(2 * 1024 * 1024 * 1024) - maxConcurrentSFTPUploads = 16 - maxSSHPublicKeyBytes = 64 * 1024 - sshKeygenTimeout = 30 * time.Second -) - -// Service is the main API surface exposed to the frontend via Wails bindings. -type Service struct { - store *storage.Store - secretStore *secrets.SecretStore - keychainStore *secrets.KeychainStore - sessions *sessions.Manager - auditLog *audit.Logger - prefs *preferences.Manager - ctx context.Context - eventSink func(string, interface{}) - - // secretChans maps request IDs to channels awaiting user secret input. - secretMu sync.Mutex - secretChans map[string]chan secretResponse - secretListenerRegistered bool - secretListenerCancel func() - - uploadMu sync.Mutex - uploads map[string]*sftpUpload - - leviathan *leviathanState -} - -type sftpUpload struct { - mu sync.Mutex - writer io.WriteCloser - sessionID string - remotePath string - written int64 -} - -// NewService creates a Service wired to the given backing components. -func NewService( - store *storage.Store, - secretStore *secrets.SecretStore, - keychainStore *secrets.KeychainStore, - sess *sessions.Manager, - auditLog *audit.Logger, - prefs *preferences.Manager, -) *Service { - return &Service{ - store: store, - secretStore: secretStore, - keychainStore: keychainStore, - sessions: sess, - auditLog: auditLog, - prefs: prefs, - uploads: make(map[string]*sftpUpload), - leviathan: &leviathanState{}, - } -} - -// SetContext stores the Wails runtime context and registers event listeners. -func (s *Service) SetContext(ctx context.Context) { - s.ctx = ctx - s.RegisterSecretResponseListener() -} - -// Shutdown releases active uploads, prompt waiters, and app-wide Wails -// listeners. It is safe to call when no Wails context was configured. -func (s *Service) Shutdown() { - s.cancelSFTPUploads("") - - s.secretMu.Lock() - cancel := s.secretListenerCancel - s.secretListenerCancel = nil - s.secretListenerRegistered = false - waiters := s.secretChans - s.secretChans = make(map[string]chan secretResponse) - s.secretMu.Unlock() - - if cancel != nil { - cancel() - } - for _, ch := range waiters { - select { - case ch <- secretResponse{cancelled: true}: - default: - } - } -} - -// SetEventSink registers an alternate UI event sink for non-Wails hosts such -// as the VS Code bridge. -func (s *Service) SetEventSink(sink func(string, interface{})) { - s.eventSink = sink -} - -// --------------------------------------------------------------------------- -// Profile operations -// --------------------------------------------------------------------------- - -// CreateProfile validates and persists a new connection profile. -func (s *Service) CreateProfile(profile models.Profile) (models.Profile, error) { - if profile.ID == "" { - profile.ID = uuid.New().String() - } - normaliseProfileForSave(&profile) - now := time.Now() - profile.CreatedAt = now - profile.UpdatedAt = now - - if profile.Tags == nil { - profile.Tags = []string{} - } - - if err := validation.ValidateProfile(&profile); err != nil { - return models.Profile{}, err - } - if err := s.store.CreateProfile(&profile); err != nil { - return models.Profile{}, fmt.Errorf("failed to create profile: %w", err) - } - s.auditLog.LogProfileCreate(profile.ID, profile.DisplayName) - return profile, nil -} - -// GetProfile retrieves a profile by ID. -func (s *Service) GetProfile(id string) (models.Profile, error) { - p, err := s.store.GetProfile(id) - if err != nil { - return models.Profile{}, fmt.Errorf("profile not found: %w", err) - } - return *p, nil -} - -// UpdateProfile persists changes to an existing profile. -func (s *Service) UpdateProfile(profile models.Profile) (models.Profile, error) { - normaliseProfileForSave(&profile) - profile.UpdatedAt = time.Now() - if err := validation.ValidateProfile(&profile); err != nil { - return models.Profile{}, err - } - if err := s.store.UpdateProfile(&profile); err != nil { - return models.Profile{}, fmt.Errorf("failed to update profile: %w", err) - } - s.auditLog.LogProfileUpdate(profile.ID, profile.DisplayName) - return profile, nil -} - -// DeleteProfile removes a profile and its associated secrets. -func (s *Service) DeleteProfile(id string) error { - p, err := s.store.GetProfile(id) - if err != nil { - return fmt.Errorf("profile not found: %w", err) - } - - if err := s.store.DeleteProfile(id); err != nil { - return fmt.Errorf("failed to delete profile: %w", err) - } - - // Best-effort secret cleanup. - if s.secretStore != nil && s.secretStore.IsUnlocked() { - _ = s.secretStore.DeleteProfileSecrets(id) - } - - s.auditLog.LogProfileDelete(id, p.DisplayName) - return nil -} - -// ListProfiles returns all stored profiles. -func (s *Service) ListProfiles() ([]models.Profile, error) { - profiles, err := s.store.ListProfiles() - if err != nil { - return nil, err - } - return derefProfiles(profiles), nil -} - -// ListSSHKeys returns absolute paths to candidate SSH private keys in ~/.ssh. -func (s *Service) ListSSHKeys() ([]string, error) { - home, err := os.UserHomeDir() - if err != nil { - return []string{}, nil - } - - dir := filepath.Join(home, ".ssh") - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return []string{}, nil - } - return nil, err - } - - ignored := map[string]bool{ - "authorized_keys": true, - "authorized_keys2": true, - "config": true, - "known_hosts": true, - "known_hosts.old": true, - "ssh_config": true, - "ssh_known_hosts": true, - "ssh_known_hosts2": true, - } - - keys := make([]string, 0) - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - if ignored[name] { - continue - } - if strings.HasSuffix(name, ".pub") { - continue - } - if strings.HasSuffix(name, ".old") { - continue - } - fullPath := filepath.Join(dir, name) - if !isLikelyPrivateKey(fullPath) { - continue - } - keys = append(keys, fullPath) - } - - sort.Strings(keys) - return keys, nil -} - -func isLikelyPrivateKey(path string) bool { - file, err := os.Open(path) - if err != nil { - return false - } - defer file.Close() - - buf := make([]byte, 512) - n, err := file.Read(buf) - if err != nil && n == 0 { - return false - } - head := buf[:n] - - if bytes.HasPrefix(head, []byte("openssh-key-v1")) { - return true - } - - if bytes.Contains(head, []byte("BEGIN OPENSSH PRIVATE KEY")) { - return true - } - if bytes.Contains(head, []byte("BEGIN RSA PRIVATE KEY")) { - return true - } - if bytes.Contains(head, []byte("BEGIN DSA PRIVATE KEY")) { - return true - } - if bytes.Contains(head, []byte("BEGIN EC PRIVATE KEY")) { - return true - } - if bytes.Contains(head, []byte("BEGIN ED25519 PRIVATE KEY")) { - return true - } - if bytes.Contains(head, []byte("BEGIN PRIVATE KEY")) { - return true - } - - return false -} - -// SearchProfiles searches profiles by display name, host, or tags. -func (s *Service) SearchProfiles(query string) ([]models.Profile, error) { - profiles, err := s.store.SearchProfiles(query) - if err != nil { - return nil, err - } - return derefProfiles(profiles), nil -} - -// ListProfilesByProtocol returns profiles filtered by protocol type. -func (s *Service) ListProfilesByProtocol(protocol string) ([]models.Profile, error) { - profiles, err := s.store.ListProfilesByProtocol(protocol) - if err != nil { - return nil, err - } - return derefProfiles(profiles), nil -} - -// ListProfilesByTag returns profiles that contain the given tag. -func (s *Service) ListProfilesByTag(tag string) ([]models.Profile, error) { - profiles, err := s.store.ListProfilesByTag(tag) - if err != nil { - return nil, err - } - return derefProfiles(profiles), nil -} - -// ListProfilesByFolder returns profiles in the specified folder. -func (s *Service) ListProfilesByFolder(folder string) ([]models.Profile, error) { - profiles, err := s.store.ListProfilesByFolder(folder) - if err != nil { - return nil, err - } - return derefProfiles(profiles), nil -} - -// ListFavourites returns all favourite profiles. -func (s *Service) ListFavourites() ([]models.Profile, error) { - profiles, err := s.store.ListFavourites() - if err != nil { - return nil, err - } - return derefProfiles(profiles), nil -} - -// SetFavourite updates the favourite status of a profile. -func (s *Service) SetFavourite(id string, favourite bool) error { - return s.store.SetFavourite(id, favourite) -} - -// DuplicateProfile creates a copy of an existing profile with a new ID. -func (s *Service) DuplicateProfile(id string) (models.Profile, error) { - orig, err := s.store.GetProfile(id) - if err != nil { - return models.Profile{}, fmt.Errorf("profile not found: %w", err) - } - - dup := *orig - dup.ID = uuid.New().String() - dup.DisplayName = orig.DisplayName + " (copy)" - now := time.Now() - dup.CreatedAt = now - dup.UpdatedAt = now - dup.LastConnected = nil - - if err := s.store.CreateProfile(&dup); err != nil { - return models.Profile{}, fmt.Errorf("failed to duplicate profile: %w", err) - } - s.auditLog.LogProfileCreate(dup.ID, dup.DisplayName) - return dup, nil -} - -// NewProfile returns a new profile with sensible defaults for the given protocol. -func (s *Service) NewProfile(protocol string) models.Profile { - return models.NewProfile(models.Protocol(protocol)) -} - -// --------------------------------------------------------------------------- -// Session operations -// --------------------------------------------------------------------------- - -// ConnectSession loads a profile by ID and starts a session asynchronously. -// Returns immediately with session info in "connecting" state; the frontend -// should listen for session:state events for status updates. -func (s *Service) ConnectSession(profileID string) (models.SessionInfo, error) { - profile, err := s.store.GetProfile(profileID) - if err != nil { - return models.SessionInfo{}, fmt.Errorf("profile not found: %w", err) - } - - sessionID, err := s.sessions.Connect(profile, s.promptSecret) - if err != nil { - return models.SessionInfo{}, err - } - - // Update last-connected timestamp. - now := time.Now() - profile.LastConnected = &now - profile.UpdatedAt = now - _ = s.store.UpdateProfile(profile) - - info := s.sessions.GetSessionInfo(sessionID) - if info == nil { - return models.SessionInfo{}, fmt.Errorf("session created but info not found") - } - return *info, nil -} - -// ConnectQuick opens a session from an ad-hoc (unsaved) profile. -func (s *Service) ConnectQuick(profile models.Profile) (models.SessionInfo, error) { - if profile.ID == "" { - profile.ID = uuid.New().String() - } - normaliseProfileForSave(&profile) - sessionID, err := s.sessions.Connect(&profile, s.promptSecret) - if err != nil { - return models.SessionInfo{}, err - } - info := s.sessions.GetSessionInfo(sessionID) - if info == nil { - return models.SessionInfo{}, fmt.Errorf("session created but info not found") - } - return *info, nil -} - -// ConnectLocal opens a local shell session using the configured (or default) shell. -func (s *Service) ConnectLocal() (models.SessionInfo, error) { - shell := "" - if s.prefs != nil { - prefs := s.prefs.Get() - shell = prefs.LocalShell - } - sessionID, err := s.sessions.ConnectLocal(shell) - if err != nil { - return models.SessionInfo{}, err - } - info := s.sessions.GetSessionInfo(sessionID) - if info == nil { - return models.SessionInfo{}, fmt.Errorf("session created but info not found") - } - return *info, nil -} - -// RestartLocalSession starts a new shell in an exited local terminal while -// preserving its tab, scrollback, terminal geometry, and best-effort cwd. -func (s *Service) RestartLocalSession(sessionID string, cols int, rows int) error { - return s.sessions.RestartLocal(sessionID, cols, rows) -} - -// DisconnectSession closes a session by ID. -func (s *Service) DisconnectSession(sessionID string) error { - s.cancelSFTPUploads(sessionID) - return s.sessions.Disconnect(sessionID) -} - -// ReconnectSession closes and re-establishes a session. -func (s *Service) ReconnectSession(sessionID string) error { - s.cancelSFTPUploads(sessionID) - return s.sessions.Reconnect(sessionID, s.promptSecret) -} - -// CancelReconnect cancels an in-progress auto-reconnect for a session. -func (s *Service) CancelReconnect(sessionID string) { - s.sessions.CancelReconnect(sessionID) -} - -// WriteSession writes base64-encoded data to a session. -func (s *Service) WriteSession(sessionID string, data string) error { - decoded, err := base64.StdEncoding.DecodeString(data) - if err != nil { - return fmt.Errorf("invalid base64 data: %w", err) - } - return s.sessions.WriteToSession(sessionID, decoded) -} - -// ResizeSession sends a terminal size change to a session. -func (s *Service) ResizeSession(sessionID string, cols int, rows int) error { - return s.sessions.ResizeSession(sessionID, cols, rows) -} - -// ListSessions returns all active sessions. -func (s *Service) ListSessions() []models.SessionInfo { - return s.sessions.ListSessions() -} - -// RenameSession changes the display name of a session tab. -func (s *Service) RenameSession(sessionID string, name string) error { - return s.sessions.RenameSession(sessionID, name) -} - -// PinSession toggles the pinned state of a session tab. -func (s *Service) PinSession(sessionID string, pinned bool) error { - return s.sessions.PinSession(sessionID, pinned) -} - -// CloseAllSessions terminates all active sessions. -func (s *Service) CloseAllSessions() { - s.cancelSFTPUploads("") - s.sessions.CloseAll() -} - -// --------------------------------------------------------------------------- -// RAW-specific -// --------------------------------------------------------------------------- - -// WriteSessionText sends plain text to a RAW session. -func (s *Service) WriteSessionText(sessionID string, text string) error { - return s.sessions.WriteSessionText(sessionID, text) -} - -// WriteSessionHex sends hex-encoded data to a RAW session. -func (s *Service) WriteSessionHex(sessionID string, hexStr string) error { - return s.sessions.WriteSessionHex(sessionID, hexStr) -} - -// WriteSessionEscaped sends text with C-style escape sequences to a RAW session. -func (s *Service) WriteSessionEscaped(sessionID string, text string) error { - return s.sessions.WriteSessionEscaped(sessionID, text) -} - -// GetSessionHistory returns the data history for a RAW session. -func (s *Service) GetSessionHistory(sessionID string) ([]map[string]interface{}, error) { - entries, err := s.sessions.GetSessionHistory(sessionID) - if err != nil { - return nil, err - } - - result := make([]map[string]interface{}, len(entries)) - for i, e := range entries { - result[i] = map[string]interface{}{ - "timestamp": e.Timestamp.UnixMilli(), - "direction": e.Direction, - "data": base64.StdEncoding.EncodeToString(e.Data), - } - } - return result, nil -} - -// ClearSessionHistory clears the data history for a RAW session. -func (s *Service) ClearSessionHistory(sessionID string) error { - return s.sessions.ClearSessionHistory(sessionID) -} - -// SaveSessionCapture saves the RAW session data to a file. -func (s *Service) SaveSessionCapture(sessionID string, path string) error { - return s.sessions.SaveSessionCapture(sessionID, path) -} - -// --------------------------------------------------------------------------- -// Telnet-specific -// --------------------------------------------------------------------------- - -// SendBreak transmits a Telnet BRK command to a session. -func (s *Service) SendBreak(sessionID string) error { - return s.sessions.SendBreak(sessionID) -} - -// --------------------------------------------------------------------------- -// IRC-specific -// --------------------------------------------------------------------------- - -// IRCHandleInput sends an IRC composer line, including slash commands. -func (s *Service) IRCHandleInput(sessionID string, target string, input string) error { - return s.sessions.IRCHandleInput(sessionID, target, input) -} - -// IRCJoin joins an IRC room. -func (s *Service) IRCJoin(sessionID string, room string) error { - return s.sessions.IRCJoin(sessionID, room) -} - -// IRCPart leaves an IRC room. -func (s *Service) IRCPart(sessionID string, room string) error { - return s.sessions.IRCPart(sessionID, room) -} - -// IRCListRooms requests the server room list. Results are emitted as events. -func (s *Service) IRCListRooms(sessionID string) error { - return s.sessions.IRCListRooms(sessionID) -} - -// IRCRooms returns currently joined IRC rooms. -func (s *Service) IRCRooms(sessionID string) ([]map[string]interface{}, error) { - rooms, err := s.sessions.IRCRooms(sessionID) - if err != nil { - return nil, err - } - result := make([]map[string]interface{}, len(rooms)) - for i, room := range rooms { - result[i] = map[string]interface{}{ - "name": room.Name, - "topic": room.Topic, - "users": room.Users, - } - } - return result, nil -} - -// IRCUsers returns the known users in a joined IRC room. -func (s *Service) IRCUsers(sessionID string, room string) ([]string, error) { - return s.sessions.IRCUsers(sessionID, room) -} - -// --------------------------------------------------------------------------- -// Secret store -// --------------------------------------------------------------------------- - -// IsSecretStoreInitialised reports whether the encrypted vault has been created. -func (s *Service) IsSecretStoreInitialised() bool { - if s.secretStore == nil { - return false - } - return s.secretStore.IsInitialised() -} - -// InitialiseSecretStore creates a new encrypted vault with the given master password. -func (s *Service) InitialiseSecretStore(masterPassword string) error { - if s.secretStore == nil { - return fmt.Errorf("secret store is not configured") - } - return s.secretStore.Initialise(masterPassword) -} - -// UnlockSecretStore decrypts the vault using the master password. -func (s *Service) UnlockSecretStore(masterPassword string) error { - if s.secretStore == nil { - return fmt.Errorf("secret store is not configured") - } - return s.secretStore.Unlock(masterPassword) -} - -// LockSecretStore zeroes key material and locks the vault. -func (s *Service) LockSecretStore() { - if s.secretStore != nil { - s.secretStore.Lock() - } -} - -// IsSecretStoreUnlocked reports whether the vault is currently unlocked. -func (s *Service) IsSecretStoreUnlocked() bool { - if s.secretStore == nil { - return false - } - return s.secretStore.IsUnlocked() -} - -// SetProfileSecret encrypts and stores a secret for the given profile. -func (s *Service) SetProfileSecret(profileID string, secretType string, value string) error { - key := fmt.Sprintf("profile:%s:%s", profileID, secretType) - - // Try the OS keychain first. - if s.keychainStore != nil && s.keychainStore.Available() { - return s.keychainStore.SetSecret(key, value) - } - - // Fall back to the encrypted file store. - if s.secretStore == nil || !s.secretStore.IsUnlocked() { - return fmt.Errorf("secret store is locked; unlock it first") - } - return s.secretStore.SetSecret(key, value) -} - -// DeleteProfileSecrets removes all secrets associated with a profile. -func (s *Service) DeleteProfileSecrets(profileID string) error { - // Remove from both stores where possible. - if s.secretStore != nil && s.secretStore.IsUnlocked() { - _ = s.secretStore.DeleteProfileSecrets(profileID) - } - // OS keychain entries need individual deletion — handled at a higher level - // because we cannot enumerate keychain keys by prefix portably. - return nil -} - -// --------------------------------------------------------------------------- -// TOTP / 2FA -// --------------------------------------------------------------------------- - -// SetTOTPSecret stores a TOTP secret for the given profile, encrypted in -// the secret vault (or OS keychain). -func (s *Service) SetTOTPSecret(profileID string, secret string) error { - return s.SetProfileSecret(profileID, "totp", secret) -} - -// GetTOTPCode generates the current 6-digit TOTP code for the given profile. -// Used for the "Test" button in the profile editor. -func (s *Service) GetTOTPCode(profileID string) (string, error) { - // Look up the TOTP secret from the vault. - totpKey := fmt.Sprintf("profile:%s:totp", profileID) - var secret string - - if s.keychainStore != nil && s.keychainStore.Available() { - if val, err := s.keychainStore.GetSecret(totpKey); err == nil && val != "" { - secret = val - } - } - if secret == "" && s.secretStore != nil && s.secretStore.IsUnlocked() { - if val, err := s.secretStore.GetSecret(totpKey); err == nil && val != "" { - secret = val - } - } - if secret == "" { - return "", fmt.Errorf("no TOTP secret configured for this profile") - } - - code, err := totp.GenerateCode(secret, time.Now()) - if err != nil { - return "", fmt.Errorf("failed to generate TOTP code: %w", err) - } - return code, nil -} - -// HasTOTPSecret checks whether a profile has a TOTP secret stored. -func (s *Service) HasTOTPSecret(profileID string) bool { - totpKey := fmt.Sprintf("profile:%s:totp", profileID) - if s.keychainStore != nil && s.keychainStore.Available() { - if val, err := s.keychainStore.GetSecret(totpKey); err == nil && val != "" { - return true - } - } - if s.secretStore != nil && s.secretStore.IsUnlocked() { - if val, err := s.secretStore.GetSecret(totpKey); err == nil && val != "" { - return true - } - } - return false -} - -// DeleteTOTPSecret removes the TOTP secret for the given profile. -func (s *Service) DeleteTOTPSecret(profileID string) error { - totpKey := fmt.Sprintf("profile:%s:totp", profileID) - if s.keychainStore != nil && s.keychainStore.Available() { - _ = s.keychainStore.DeleteSecret(totpKey) - } - if s.secretStore != nil && s.secretStore.IsUnlocked() { - _ = s.secretStore.DeleteSecret(totpKey) - } - return nil -} - -// --------------------------------------------------------------------------- -// Known hosts -// --------------------------------------------------------------------------- - -// ListKnownHosts returns all stored host key entries. -func (s *Service) ListKnownHosts() ([]models.KnownHost, error) { - hosts, err := s.store.ListKnownHosts() - if err != nil { - return nil, err - } - result := make([]models.KnownHost, len(hosts)) - for i, h := range hosts { - result[i] = *h - } - return result, nil -} - -// DeleteKnownHost removes a known host entry by ID. -func (s *Service) DeleteKnownHost(id string) error { - return s.store.DeleteKnownHost(id) -} - -// --------------------------------------------------------------------------- -// Preferences -// --------------------------------------------------------------------------- - -// GetPreferences returns the current application preferences. -func (s *Service) GetPreferences() (models.AppPreferences, error) { - p := s.prefs.Get() - if p == nil { - return models.DefaultAppPreferences(), nil - } - return *p, nil -} - -// SavePreferences persists the given application preferences. -func (s *Service) SavePreferences(prefs models.AppPreferences) error { - return s.prefs.Save(&prefs) -} - -// --------------------------------------------------------------------------- -// Import / Export -// --------------------------------------------------------------------------- - -// ExportProfiles serialises all profiles to a JSON string. When includeSecrets -// is true, decrypted credentials are embedded (use with caution). -func (s *Service) ExportProfiles(includeSecrets bool) (string, error) { - profiles, err := s.store.ListProfiles() - if err != nil { - return "", fmt.Errorf("failed to list profiles for export: %w", err) - } - if !includeSecrets { - profiles = redactProfileSecrets(profiles) - } - - data, err := json.MarshalIndent(profiles, "", " ") - if err != nil { - return "", fmt.Errorf("failed to serialise profiles: %w", err) - } - - s.auditLog.LogExport(len(profiles), includeSecrets) - return string(data), nil -} - -// ImportProfiles deserialises profiles from a JSON string and persists them. -// The strategy parameter controls conflict resolution: -// - "skip" ignores profiles whose ID already exists -// - "overwrite" replaces existing profiles -// - "duplicate" always creates a new profile with a fresh ID -func (s *Service) ImportProfiles(jsonData string, strategy string) (int, error) { - if len(jsonData) > maxProfileImportJSONBytes { - return 0, fmt.Errorf("import data is too large (max %d bytes)", maxProfileImportJSONBytes) - } - var profiles []*models.Profile - if err := json.Unmarshal([]byte(jsonData), &profiles); err != nil { - return 0, fmt.Errorf("invalid JSON: %w", err) - } - - if err := validateImportStrategy(strategy); err != nil { - return 0, err - } - if err := validateImportProfiles(profiles); err != nil { - return 0, err - } - - imported := 0 - for _, p := range profiles { - switch strategy { - case "skip": - if existing, _ := s.store.GetProfile(p.ID); existing != nil { - continue - } - if err := s.store.CreateProfile(p); err != nil { - continue - } - imported++ - - case "overwrite": - p.UpdatedAt = time.Now() - if existing, _ := s.store.GetProfile(p.ID); existing != nil { - if err := s.store.UpdateProfile(p); err != nil { - continue - } - } else { - if err := s.store.CreateProfile(p); err != nil { - continue - } - } - imported++ - - case "duplicate": - p.ID = uuid.New().String() - now := time.Now() - p.CreatedAt = now - p.UpdatedAt = now - if err := s.store.CreateProfile(p); err != nil { - continue - } - imported++ - - default: - return 0, fmt.Errorf("unknown import strategy %q (expected skip, overwrite, or duplicate)", strategy) - } - } - - s.auditLog.LogImport(imported) - return imported, nil -} - -// --------------------------------------------------------------------------- -// Audit -// --------------------------------------------------------------------------- - -// ListAuditEntries returns the most recent audit entries. -func (s *Service) ListAuditEntries(limit int) ([]models.AuditEntry, error) { - entries, err := s.auditLog.ListEntries(limit) - if err != nil { - return nil, err - } - result := make([]models.AuditEntry, len(entries)) - for i, e := range entries { - result[i] = *e - } - return result, nil -} - -// --------------------------------------------------------------------------- -// Snippets (command library) -// --------------------------------------------------------------------------- - -// ListSnippets returns all saved command snippets. -func (s *Service) ListSnippets() ([]models.Snippet, error) { - snippets, err := s.store.ListSnippets() - if err != nil { - return nil, err - } - result := make([]models.Snippet, len(snippets)) - for i, sn := range snippets { - result[i] = *sn - } - return result, nil -} - -// CreateSnippet saves a new command snippet. -func (s *Service) CreateSnippet(snippet models.Snippet) (models.Snippet, error) { - if snippet.ID == "" { - snippet.ID = uuid.New().String() - } - now := time.Now() - snippet.CreatedAt = now - snippet.UpdatedAt = now - if snippet.Tags == nil { - snippet.Tags = []string{} - } - if err := s.store.CreateSnippet(&snippet); err != nil { - return models.Snippet{}, fmt.Errorf("failed to create snippet: %w", err) - } - return snippet, nil -} - -// UpdateSnippet updates an existing command snippet. -func (s *Service) UpdateSnippet(snippet models.Snippet) (models.Snippet, error) { - snippet.UpdatedAt = time.Now() - if err := s.store.UpdateSnippet(&snippet); err != nil { - return models.Snippet{}, fmt.Errorf("failed to update snippet: %w", err) - } - return snippet, nil -} - -// DeleteSnippet removes a command snippet by ID. -func (s *Service) DeleteSnippet(id string) error { - return s.store.DeleteSnippet(id) -} - -// --------------------------------------------------------------------------- -// Utility -// --------------------------------------------------------------------------- - -// SelectFile opens a native file dialog and returns the chosen path. -func (s *Service) SelectFile(title string, filters string) (string, error) { - if s.ctx == nil { - return "", fmt.Errorf("application context not set") - } - - opts := wailsRuntime.OpenDialogOptions{ - Title: title, - } - if filters != "" { - opts.Filters = []wailsRuntime.FileFilter{ - {DisplayName: "Files", Pattern: filters}, - } - } - - path, err := wailsRuntime.OpenFileDialog(s.ctx, opts) - if err != nil { - return "", fmt.Errorf("file dialog failed: %w", err) - } - return path, nil -} - -// SaveFile opens a native save dialog and returns the chosen path. -func (s *Service) SaveFile(title string, defaultName string) (string, error) { - if s.ctx == nil { - return "", fmt.Errorf("application context not set") - } - - path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{ - Title: title, - DefaultFilename: defaultName, - }) - if err != nil { - return "", fmt.Errorf("save dialog failed: %w", err) - } - return path, nil -} - -// FileExists returns true if the path exists and is not a directory. -func (s *Service) FileExists(path string) (bool, error) { - if path == "" { - return false, nil - } - info, err := os.Stat(path) - if err != nil { - if os.IsNotExist(err) { - return false, nil - } - return false, err - } - return !info.IsDir(), nil -} - -// FileIsPrivateKey returns true if the path appears to be an SSH private key. -func (s *Service) FileIsPrivateKey(path string) (bool, error) { - if path == "" { - return false, nil - } - return isLikelyPrivateKey(path), nil -} - -// GetAppInfo returns build and environment information. -func (s *Service) GetAppInfo() map[string]string { - return map[string]string{ - "version": appVersion, - "go": runtime.Version(), - "os": runtime.GOOS, - "arch": runtime.GOARCH, - "platform": runtime.GOOS + "/" + runtime.GOARCH, - } -} - -// RefreshGitHubRepos triggers an on-demand refresh of GitHub repositories for -// the given session. The updated list is also emitted as a Wails event. -func (s *Service) RefreshGitHubRepos(sessionID string) ([]models.GitHubRepo, error) { - return s.sessions.FetchGitHubRepos(sessionID) -} - -// DownloadGitHubRepoArchive downloads a repository zip archive to a local file. -func (s *Service) DownloadGitHubRepoArchive(sessionID string, fullName string, ref string) (string, error) { - if s.ctx == nil { - return "", fmt.Errorf("application context not set") - } - if fullName == "" { - return "", fmt.Errorf("repository name cannot be empty") - } - - parts := strings.Split(fullName, "/") - repoName := parts[len(parts)-1] - defaultName := repoName + ".zip" - if ref != "" { - safeRef := strings.ReplaceAll(ref, "/", "-") - defaultName = fmt.Sprintf("%s-%s.zip", repoName, safeRef) - } - - path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{ - Title: "Download Repository", - DefaultFilename: defaultName, - Filters: []wailsRuntime.FileFilter{ - {DisplayName: "Zip Archive (*.zip)", Pattern: "*.zip"}, - }, - }) - if err != nil { - return "", fmt.Errorf("save dialog failed: %w", err) - } - if path == "" { - return "", fmt.Errorf("download cancelled") - } - if filepath.Ext(path) != ".zip" { - path += ".zip" - } - - if err := s.sessions.DownloadGitHubArchive(sessionID, fullName, ref, path); err != nil { - return "", err - } - return path, nil -} - -// DownloadGitHubRepoArchiveToPath downloads a repository zip archive to an -// explicit local destination. Non-Wails hosts provide the save path through -// their own file picker. -func (s *Service) DownloadGitHubRepoArchiveToPath(sessionID string, fullName string, ref string, destPath string) (string, error) { - if strings.TrimSpace(fullName) == "" { - return "", fmt.Errorf("repository name cannot be empty") - } - destPath = strings.TrimSpace(destPath) - if destPath == "" { - return "", fmt.Errorf("destination path cannot be empty") - } - if filepath.Ext(destPath) != ".zip" { - destPath += ".zip" - } - if err := s.sessions.DownloadGitHubArchive(sessionID, fullName, ref, destPath); err != nil { - return "", err - } - return destPath, nil -} - -// --------------------------------------------------------------------------- -// VNC -// --------------------------------------------------------------------------- - -// GetVNCWebSocketURL returns the local WebSocket proxy URL for a VNC session. -// The frontend connects noVNC to this URL for display. -func (s *Service) GetVNCWebSocketURL(sessionID string) (string, error) { - return s.sessions.GetVNCWebSocketURL(sessionID) -} - -// --------------------------------------------------------------------------- -// Local shell -// --------------------------------------------------------------------------- - -// GetDefaultShell returns the system's default shell path. -func (s *Service) GetDefaultShell() string { - return localshell.DefaultShell() -} - -// BrowseForShell opens a file dialog to select a shell binary. -func (s *Service) BrowseForShell() (string, error) { - if s.ctx == nil { - return "", fmt.Errorf("application context not set") - } - path, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{ - Title: "Select Shell Binary", - }) - if err != nil { - return "", fmt.Errorf("file dialog failed: %w", err) - } - return path, nil -} - -// --------------------------------------------------------------------------- -// Port forwarding -// --------------------------------------------------------------------------- - -// ListPortForwards returns the active port-forwarding tunnels for an SSH session. -func (s *Service) ListPortForwards(sessionID string) ([]models.PortForward, error) { - return s.sessions.ListPortForwards(sessionID) -} - -// StartPortForward activates a new forwarding tunnel on a live SSH session. -func (s *Service) StartPortForward(sessionID string, rule models.PortForward) error { - return s.sessions.StartPortForward(sessionID, rule) -} - -// StopPortForward terminates an active forwarding tunnel. -func (s *Service) StopPortForward(sessionID string, forwardID string) error { - return s.sessions.StopPortForward(sessionID, forwardID) -} - -// PingSession sends a keepalive request and returns the round-trip time in milliseconds. -func (s *Service) PingSession(sessionID string) (int64, error) { - return s.sessions.PingSession(sessionID) -} - -// --------------------------------------------------------------------------- -// Session recording -// --------------------------------------------------------------------------- - -// StartRecording begins recording terminal I/O for a session. -func (s *Service) StartRecording(sessionID string, cols int, rows int) (string, error) { - return s.sessions.StartRecording(sessionID, cols, rows) -} - -// StopRecording stops an active recording for a session. -func (s *Service) StopRecording(sessionID string) error { - return s.sessions.StopRecording(sessionID) -} - -// IsRecording reports whether a session is currently being recorded. -func (s *Service) IsRecording(sessionID string) bool { - return s.sessions.IsRecording(sessionID) -} - -// ListRecordings returns metadata for all saved recordings. -func (s *Service) ListRecordings() ([]map[string]interface{}, error) { - metas, err := s.sessions.ListRecordings() - if err != nil { - return nil, err - } - - result := make([]map[string]interface{}, len(metas)) - for i, m := range metas { - result[i] = map[string]interface{}{ - "id": m.ID, - "sessionId": m.SessionID, - "title": m.Title, - "width": m.Width, - "height": m.Height, - "duration": m.Duration, - "size": m.Size, - "createdAt": m.CreatedAt.Format(time.RFC3339), - "filePath": m.FilePath, - } - } - return result, nil -} - -// GetRecording returns the full recording data for playback. -func (s *Service) GetRecording(id string) (map[string]interface{}, error) { - hdr, events, err := s.sessions.GetRecording(id) - if err != nil { - return nil, err - } - - evtList := make([][]interface{}, len(events)) - for i, e := range events { - evtList[i] = []interface{}{e.Elapsed, string(e.Type), e.Data} - } - - return map[string]interface{}{ - "header": map[string]interface{}{ - "version": hdr.Version, - "width": hdr.Width, - "height": hdr.Height, - "timestamp": hdr.Timestamp, - "title": hdr.Title, - }, - "events": evtList, - }, nil -} - -// DeleteRecording removes a saved recording. -func (s *Service) DeleteRecording(id string) error { - return s.sessions.DeleteRecording(id) -} - -// GetRecordingStorageInfo returns aggregate recording storage usage and limits. -func (s *Service) GetRecordingStorageInfo() (map[string]interface{}, error) { - info, err := s.sessions.RecordingStorageInfo() - if err != nil { - return nil, err - } - result := map[string]interface{}{ - "count": info.Count, - "totalBytes": info.TotalBytes, - "maxRecordingBytes": info.MaxRecordingBytes, - "maxPlaybackBytes": info.MaxPlaybackBytes, - } - if info.OldestCreatedAt != nil { - result["oldestCreatedAt"] = info.OldestCreatedAt.Format(time.RFC3339) - } - if info.NewestCreatedAt != nil { - result["newestCreatedAt"] = info.NewestCreatedAt.Format(time.RFC3339) - } - return result, nil -} - -// CleanupRecordings removes recordings by age and total storage limit. -func (s *Service) CleanupRecordings(maxAgeDays int, maxTotalBytes int64) (map[string]interface{}, error) { - result, err := s.sessions.CleanupRecordings(maxAgeDays, maxTotalBytes) - if err != nil { - return nil, err - } - return map[string]interface{}{ - "deleted": result.Deleted, - "freedBytes": result.FreedBytes, - }, nil -} - -// --------------------------------------------------------------------------- -// Encrypted export / import -// --------------------------------------------------------------------------- - -// ExportProfilesEncrypted encrypts and exports selected profiles to a file. -// The profileIDs parameter selects which profiles to include; if empty, all -// profiles are exported. Returns the file path on success. -func (s *Service) ExportProfilesEncrypted(profileIDs []string, password string) (string, error) { - if s.ctx == nil { - return "", fmt.Errorf("application context not set") - } - - var profiles []models.Profile - if len(profileIDs) == 0 { - // Export all profiles. - all, err := s.store.ListProfiles() - if err != nil { - return "", fmt.Errorf("failed to list profiles: %w", err) - } - for _, p := range all { - profiles = append(profiles, *p) - } - } else { - for _, id := range profileIDs { - p, err := s.store.GetProfile(id) - if err != nil { - continue // skip missing profiles - } - profiles = append(profiles, *p) - } - } - - if len(profiles) == 0 { - return "", fmt.Errorf("no profiles to export") - } - - // Prompt for save location. - path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{ - Title: "Export Encrypted Profiles", - DefaultFilename: "link-profiles.link", - Filters: []wailsRuntime.FileFilter{ - {DisplayName: "Link Export (*.link)", Pattern: "*.link"}, - }, - }) - if err != nil { - return "", fmt.Errorf("save dialog failed: %w", err) - } - if path == "" { - return "", fmt.Errorf("export cancelled") - } - - // Ensure .link extension. - if filepath.Ext(path) != ".link" { - path += ".link" - } - - if err := exportimport.EncryptAndExport(profiles, password, path); err != nil { - return "", err - } - - s.auditLog.LogExport(len(profiles), false) - return path, nil -} - -// ImportProfilesEncrypted decrypts and imports profiles from an encrypted file. -// Returns the number of profiles successfully imported. -func (s *Service) ImportProfilesEncrypted(password string, strategy string) (int, error) { - if s.ctx == nil { - return 0, fmt.Errorf("application context not set") - } - - // Prompt for file selection. - path, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{ - Title: "Import Encrypted Profiles", - Filters: []wailsRuntime.FileFilter{ - {DisplayName: "Link Export (*.link)", Pattern: "*.link"}, - }, - }) - if err != nil { - return 0, fmt.Errorf("open dialog failed: %w", err) - } - if path == "" { - return 0, fmt.Errorf("import cancelled") - } - - profiles, err := exportimport.DecryptAndImport(path, password) - if err != nil { - return 0, err - } - - if err := validateImportStrategy(strategy); err != nil { - return 0, err - } - profilePtrs := make([]*models.Profile, len(profiles)) - for i := range profiles { - profilePtrs[i] = &profiles[i] - } - if err := validateImportProfiles(profilePtrs); err != nil { - return 0, err - } - - // Import using the existing strategy logic. - imported := 0 - for _, p := range profiles { - switch strategy { - case "skip": - if existing, _ := s.store.GetProfile(p.ID); existing != nil { - continue - } - if err := s.store.CreateProfile(&p); err != nil { - continue - } - imported++ - - case "overwrite": - p.UpdatedAt = time.Now() - if existing, _ := s.store.GetProfile(p.ID); existing != nil { - if err := s.store.UpdateProfile(&p); err != nil { - continue - } - } else { - if err := s.store.CreateProfile(&p); err != nil { - continue - } - } - imported++ - - case "duplicate": - p.ID = uuid.New().String() - now := time.Now() - p.CreatedAt = now - p.UpdatedAt = now - if err := s.store.CreateProfile(&p); err != nil { - continue - } - imported++ - - default: - return 0, fmt.Errorf("unknown import strategy %q", strategy) - } - } - - s.auditLog.LogImport(imported) - return imported, nil -} - -// --------------------------------------------------------------------------- -// SFTP file browser -// --------------------------------------------------------------------------- - -// SFTPList lists the contents of a remote directory. -func (s *Service) SFTPList(sessionID string, remotePath string) ([]map[string]interface{}, error) { - entries, err := s.sessions.SFTPList(sessionID, remotePath) - if err != nil { - return nil, err - } - result := make([]map[string]interface{}, len(entries)) - for i, e := range entries { - result[i] = map[string]interface{}{ - "name": e.Name, - "path": e.Path, - "size": e.Size, - "isDir": e.IsDir, - "mode": e.Mode, - "modifiedAt": e.ModifiedAt, - } - } - return result, nil -} - -// SFTPMkdir creates a remote directory (including parents). -func (s *Service) SFTPMkdir(sessionID string, remotePath string) error { - return s.sessions.SFTPMkdir(sessionID, remotePath) -} - -// SFTPStat returns information about a remote path. -func (s *Service) SFTPStat(sessionID string, remotePath string) (map[string]interface{}, error) { - entry, err := s.sessions.SFTPStat(sessionID, remotePath) - if err != nil { - return nil, err - } - return map[string]interface{}{ - "name": entry.Name, - "path": entry.Path, - "size": entry.Size, - "isDir": entry.IsDir, - "mode": entry.Mode, - "modifiedAt": entry.ModifiedAt, - }, nil -} - -// SFTPRemove deletes a remote file or empty directory. -func (s *Service) SFTPRemove(sessionID string, remotePath string) error { - return s.sessions.SFTPRemove(sessionID, remotePath) -} - -// SFTPRename moves or renames a remote file/directory. -func (s *Service) SFTPRename(sessionID string, oldPath string, newPath string) error { - return s.sessions.SFTPRename(sessionID, oldPath, newPath) -} - -// SFTPDownload downloads a remote file. Opens a native save dialog for the -// local destination. -func (s *Service) SFTPDownload(sessionID string, remotePath string) error { - if s.ctx == nil { - return fmt.Errorf("application context not set") - } - - localPath, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{ - Title: "Save File", - DefaultFilename: filepath.Base(remotePath), - }) - if err != nil { - return fmt.Errorf("save dialog failed: %w", err) - } - if localPath == "" { - return fmt.Errorf("download cancelled") - } - - return s.sessions.SFTPDownload(sessionID, remotePath, localPath) -} - -// SFTPDownloadToPath downloads a remote file to an explicit local path. This -// is used by non-Wails hosts, where the UI owns the save dialog. -func (s *Service) SFTPDownloadToPath(sessionID string, remotePath string, localPath string) error { - if strings.TrimSpace(remotePath) == "" { - return fmt.Errorf("remote path cannot be empty") - } - localPath = strings.TrimSpace(localPath) - if localPath == "" { - return fmt.Errorf("local path cannot be empty") - } - return s.sessions.SFTPDownload(sessionID, remotePath, localPath) -} - -// SFTPUpload uploads a local file to a remote directory. Opens a native file -// dialog to select the local file. -func (s *Service) SFTPUpload(sessionID string, remotePath string) error { - if s.ctx == nil { - return fmt.Errorf("application context not set") - } - - localPath, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{ - Title: "Select File to Upload", - }) - if err != nil { - return fmt.Errorf("open dialog failed: %w", err) - } - if localPath == "" { - return fmt.Errorf("upload cancelled") - } - - destPath := remotePath + "/" + filepath.Base(localPath) - return s.sessions.SFTPUpload(sessionID, localPath, destPath) -} - -// SFTPUploadPath uploads an explicit local file path to a remote directory. -// The remote filename is derived from the local basename and sanitised through -// the same upload-name validation used by browser uploads. -func (s *Service) SFTPUploadPath(sessionID string, remoteDir string, localPath string) error { - localPath = strings.TrimSpace(localPath) - if localPath == "" { - return fmt.Errorf("local path cannot be empty") - } - info, err := os.Stat(localPath) - if err != nil { - return fmt.Errorf("reading local file metadata: %w", err) - } - if info.IsDir() { - return fmt.Errorf("local path is a directory") - } - safeName, err := safeSFTPUploadName(filepath.Base(localPath)) - if err != nil { - return err - } - destPath := path.Join(remoteDir, safeName) - return s.sessions.SFTPUpload(sessionID, localPath, destPath) -} - -// SFTPUploadData uploads raw file data to a remote directory. -// Data is expected as base64-encoded bytes from the frontend. -func (s *Service) SFTPUploadData(sessionID string, remoteDir string, fileName string, base64Data string) error { - if base64Data == "" { - return fmt.Errorf("empty upload payload") - } - if base64.StdEncoding.DecodedLen(len(base64Data)) > maxInlineSFTPUploadBytes { - return fmt.Errorf("upload payload is too large (max %d bytes)", maxInlineSFTPUploadBytes) - } - - data, err := base64.StdEncoding.DecodeString(base64Data) - if err != nil { - return fmt.Errorf("failed to decode upload data: %w", err) - } - if len(data) > maxInlineSFTPUploadBytes { - return fmt.Errorf("upload payload is too large (max %d bytes)", maxInlineSFTPUploadBytes) - } - - safeName, err := safeSFTPUploadName(fileName) - if err != nil { - return err - } - - destPath := path.Join(remoteDir, safeName) - return s.sessions.SFTPUploadBytes(sessionID, destPath, data) -} - -// SFTPUploadStart opens a remote SFTP file for chunked browser uploads. -func (s *Service) SFTPUploadStart(sessionID string, remoteDir string, fileName string, totalBytes int64) (string, error) { - if s.sessions == nil { - return "", fmt.Errorf("sessions service is not configured") - } - if totalBytes < 0 { - return "", fmt.Errorf("upload size must not be negative") - } - if totalBytes > maxStreamingSFTPUploadSize { - return "", fmt.Errorf("upload is too large (max %d bytes)", maxStreamingSFTPUploadSize) - } - if err := s.checkSFTPUploadCapacity(); err != nil { - return "", err - } - - safeName, err := safeSFTPUploadName(fileName) - if err != nil { - return "", err - } - remotePath := path.Join(remoteDir, safeName) - writer, err := s.sessions.SFTPOpenUpload(sessionID, remotePath) - if err != nil { - return "", err - } - - uploadID := uuid.New().String() - s.uploadMu.Lock() - if len(s.uploads) >= maxConcurrentSFTPUploads { - s.uploadMu.Unlock() - _ = writer.Close() - if s.sessions != nil { - _ = s.sessions.SFTPRemove(sessionID, remotePath) - } - return "", fmt.Errorf("too many active uploads (max %d)", maxConcurrentSFTPUploads) - } - s.uploads[uploadID] = &sftpUpload{ - writer: writer, - sessionID: sessionID, - remotePath: remotePath, - } - s.uploadMu.Unlock() - return uploadID, nil -} - -// SFTPUploadChunk writes a base64-encoded chunk to an active browser upload. -func (s *Service) SFTPUploadChunk(uploadID string, base64Chunk string) error { - if base64Chunk == "" { - return fmt.Errorf("empty upload chunk") - } - if base64.StdEncoding.DecodedLen(len(base64Chunk)) > maxSFTPUploadChunkBytes { - return fmt.Errorf("upload chunk is too large (max %d bytes)", maxSFTPUploadChunkBytes) - } - data, err := base64.StdEncoding.DecodeString(base64Chunk) - if err != nil { - return fmt.Errorf("failed to decode upload chunk: %w", err) - } - if len(data) > maxSFTPUploadChunkBytes { - return fmt.Errorf("upload chunk is too large (max %d bytes)", maxSFTPUploadChunkBytes) - } - - upload, err := s.lookupSFTPUpload(uploadID) - if err != nil { - return err - } - upload.mu.Lock() - defer upload.mu.Unlock() - if upload.written+int64(len(data)) > maxStreamingSFTPUploadSize { - return fmt.Errorf("upload is too large (max %d bytes)", maxStreamingSFTPUploadSize) - } - n, err := upload.writer.Write(data) - upload.written += int64(n) - if err != nil { - s.removeSFTPUpload(uploadID) - _ = upload.writer.Close() - if s.sessions != nil { - _ = s.sessions.SFTPRemove(upload.sessionID, upload.remotePath) - } - return fmt.Errorf("failed to write upload chunk: %w", err) - } - if n != len(data) { - s.removeSFTPUpload(uploadID) - _ = upload.writer.Close() - if s.sessions != nil { - _ = s.sessions.SFTPRemove(upload.sessionID, upload.remotePath) - } - return io.ErrShortWrite - } - return nil -} - -// SFTPUploadFinish closes an active browser upload. -func (s *Service) SFTPUploadFinish(uploadID string) error { - upload, err := s.takeSFTPUpload(uploadID) - if err != nil { - return err - } - upload.mu.Lock() - defer upload.mu.Unlock() - return upload.writer.Close() -} - -// SFTPUploadCancel closes and removes a partially uploaded remote file. -func (s *Service) SFTPUploadCancel(uploadID string) error { - upload, err := s.takeSFTPUpload(uploadID) - if err != nil { - return err - } - upload.mu.Lock() - closeErr := upload.writer.Close() - upload.mu.Unlock() - if s.sessions != nil { - _ = s.sessions.SFTPRemove(upload.sessionID, upload.remotePath) - } - return closeErr -} - -// SFTPPwd returns the current working directory on the remote host. -func (s *Service) SFTPPwd(sessionID string) (string, error) { - return s.sessions.SFTPPwd(sessionID) -} - -// GetSessionCwd returns a best-effort working directory for SSH sessions. -func (s *Service) GetSessionCwd(sessionID string) (string, error) { - return s.sessions.GetSessionCwd(sessionID) -} - -// --------------------------------------------------------------------------- -// OpenSSH config import -// --------------------------------------------------------------------------- - -// ImportSSHConfig parses the given SSH config file (or the default -// ~/.ssh/config if path is empty) and imports the Host blocks as profiles. -func (s *Service) ImportSSHConfig(configPath string, folder string) (int, error) { - if configPath == "" { - configPath = sshconfig.DefaultConfigPath() - } - - blocks, err := sshconfig.ParseFile(configPath) - if err != nil { - return 0, err - } - - profiles := sshconfig.ToProfiles(blocks, folder) - imported := 0 - for _, p := range profiles { - if err := s.store.CreateProfile(&p); err != nil { - continue // skip duplicates or errors - } - imported++ - } - - s.auditLog.LogImport(imported) - return imported, nil -} - -// --------------------------------------------------------------------------- -// Automatic update checking -// --------------------------------------------------------------------------- - -// CheckForUpdates queries GitHub Releases for a newer version. -func (s *Service) CheckForUpdates() (map[string]interface{}, error) { - ctx := context.Background() - var info *updater.UpdateInfo - var err error - - if appUpdateURL != "" { - url := strings.ReplaceAll(appUpdateURL, "{platform}", runtime.GOOS) - url = strings.ReplaceAll(url, "{os}", runtime.GOOS) - url = strings.ReplaceAll(url, "{arch}", runtime.GOARCH) - checker := updater.NewURLChecker(url, appVersion) - info, err = checker.Check(ctx) - } else { - checker := updater.NewChecker(appVersion) - info, err = checker.Check(ctx) - } - if err != nil { - return nil, err - } - - return map[string]interface{}{ - "available": info.Available, - "currentVersion": info.CurrentVersion, - "latestVersion": info.LatestVersion, - "releaseURL": info.ReleaseURL, - "releaseName": info.ReleaseName, - "releaseNotes": info.ReleaseNotes, - "publishedAt": info.PublishedAt, - }, nil -} - -// TestConnection performs a basic TCP reachability check to the given host and port. -// This verifies the network path without establishing a full protocol session. -func (s *Service) TestConnection(host string, port int) (map[string]interface{}, error) { - if host == "" { - return nil, fmt.Errorf("host is required") - } - if port <= 0 || port > 65535 { - return nil, fmt.Errorf("port must be between 1 and 65535") - } - - addr := net.JoinHostPort(host, strconv.Itoa(port)) - start := time.Now() - - conn, err := net.DialTimeout("tcp", addr, 10*time.Second) - elapsed := time.Since(start) - - if err != nil { - return nil, fmt.Errorf("connection to %s failed after %dms: %w", addr, elapsed.Milliseconds(), err) - } - conn.Close() - - return map[string]interface{}{ - "success": true, - "host": host, - "port": port, - "latencyMs": elapsed.Milliseconds(), - "remoteAddr": addr, - }, nil -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -// promptSecret emits a Wails event to request a secret from the user, then -// blocks until the frontend responds via the session:secret:response event. -// This bridges the synchronous Go SSH auth callback with the asynchronous -// frontend prompt UI. -func (s *Service) promptSecret(prompt string) (string, error) { - if s.ctx == nil && s.eventSink == nil { - return "", fmt.Errorf("application context not available") - } - - // Generate a unique request ID so we can correlate the response. - requestID := uuid.New().String() - ch := make(chan secretResponse, 1) - - // Register a one-shot listener for the response. - s.secretMu.Lock() - if s.secretChans == nil { - s.secretChans = make(map[string]chan secretResponse) - } - s.secretChans[requestID] = ch - s.secretMu.Unlock() - - // Emit the prompt event to the frontend. - s.emitEvent("session:secret:prompt", map[string]interface{}{ - "requestID": requestID, - "message": prompt, - "title": "Credentials Required", - }) - - // Block until we receive a response (with a generous timeout). - select { - case resp := <-ch: - s.secretMu.Lock() - delete(s.secretChans, requestID) - s.secretMu.Unlock() - if resp.cancelled { - return "", fmt.Errorf("user cancelled secret prompt") - } - return resp.value, nil - case <-time.After(5 * time.Minute): - s.secretMu.Lock() - delete(s.secretChans, requestID) - s.secretMu.Unlock() - return "", fmt.Errorf("secret prompt timed out") - } -} - -// secretResponse carries the result of a frontend secret prompt. -type secretResponse struct { - value string - cancelled bool -} - -// RegisterSecretResponseListener sets up the Wails event listener that receives -// secrets entered by the user. Must be called after SetContext. -func (s *Service) RegisterSecretResponseListener() { - if s.ctx == nil { - return - } - - s.secretMu.Lock() - if s.secretListenerRegistered { - s.secretMu.Unlock() - return - } - s.secretListenerRegistered = true - s.secretMu.Unlock() - - cancel := wailsRuntime.EventsOn(s.ctx, "session:secret:response", func(optionalData ...interface{}) { - if len(optionalData) == 0 { - return - } - data, ok := optionalData[0].(map[string]interface{}) - if !ok { - return - } - requestID, _ := data["requestID"].(string) - value, _ := data["value"].(string) - cancelled, _ := data["cancelled"].(bool) - - s.secretMu.Lock() - ch, exists := s.secretChans[requestID] - s.secretMu.Unlock() - - if exists { - select { - case ch <- secretResponse{value: value, cancelled: cancelled}: - default: - } - } - }) - s.secretMu.Lock() - s.secretListenerCancel = cancel - s.secretMu.Unlock() -} - -// RespondSecret supplies a credential prompt response for non-Wails hosts. -func (s *Service) RespondSecret(requestID string, value string, cancelled bool) { - s.secretMu.Lock() - ch, exists := s.secretChans[requestID] - s.secretMu.Unlock() - - if exists { - select { - case ch <- secretResponse{value: value, cancelled: cancelled}: - default: - } - } -} - -func (s *Service) emitEvent(eventName string, data interface{}) { - if s.eventSink != nil { - s.eventSink(eventName, data) - } - if s.ctx != nil { - wailsRuntime.EventsEmit(s.ctx, eventName, data) - } -} - -// derefProfiles converts a slice of profile pointers to values. -func derefProfiles(ptrs []*models.Profile) []models.Profile { - result := make([]models.Profile, len(ptrs)) - for i, p := range ptrs { - result[i] = *p - } - return result -} - -// --------------------------------------------------------------------------- -// SSH Key Management -// --------------------------------------------------------------------------- - -// SSHKeyInfo describes a key found in ~/.ssh/. -type SSHKeyInfo struct { - Name string `json:"name"` - Type string `json:"type"` - Bits string `json:"bits"` - PublicKey string `json:"publicKey"` - PrivateKey string `json:"privateKeyPath"` -} - -// ListSSHKeyPairs returns detailed info about SSH key pairs in ~/.ssh/. -func (s *Service) ListSSHKeyPairs() ([]SSHKeyInfo, error) { - home, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("unable to determine home directory: %w", err) - } - sshDir := filepath.Join(home, ".ssh") - - entries, err := os.ReadDir(sshDir) - if err != nil { - if os.IsNotExist(err) { - return []SSHKeyInfo{}, nil - } - return nil, fmt.Errorf("unable to read .ssh directory: %w", err) - } - - var keys []SSHKeyInfo - seen := make(map[string]bool) - - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - // Look for .pub files and match them with private keys - if !strings.HasSuffix(name, ".pub") { - continue - } - baseName := strings.TrimSuffix(name, ".pub") - privPath := filepath.Join(sshDir, baseName) - - // Verify private key exists - if _, err := os.Stat(privPath); err != nil { - continue - } - - if seen[baseName] { - continue - } - seen[baseName] = true - - // Read public key content - pubBytes, err := readSSHPublicKeyFile(filepath.Join(sshDir, name)) - if err != nil { - continue - } - pubContent := strings.TrimSpace(string(pubBytes)) - - // Determine key type from public key content - keyType := "unknown" - parts := strings.Fields(pubContent) - if len(parts) >= 1 { - switch { - case strings.Contains(parts[0], "ed25519"): - keyType = "ed25519" - case strings.Contains(parts[0], "ecdsa"): - keyType = "ecdsa" - case strings.Contains(parts[0], "rsa"): - keyType = "rsa" - case strings.Contains(parts[0], "dsa"): - keyType = "dsa" - } - } - - keys = append(keys, SSHKeyInfo{ - Name: baseName, - Type: keyType, - PublicKey: pubContent, - PrivateKey: privPath, - }) - } - - if keys == nil { - keys = []SSHKeyInfo{} - } - return keys, nil -} - -// GenerateSSHKey generates a new SSH key pair. -func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphrase string) error { - keyPath, _, err := sshKeyPairPaths(name) - if err != nil { - return err - } - keyType, bits, err = validateSSHKeyGenerationOptions(keyType, bits) - if err != nil { - return err - } - sshDir := filepath.Dir(keyPath) - - // Ensure .ssh directory exists - if err := os.MkdirAll(sshDir, 0700); err != nil { - return fmt.Errorf("unable to create .ssh directory: %w", err) - } - - // Check if key already exists - if _, err := os.Stat(keyPath); err == nil { - return fmt.Errorf("key %q already exists", name) - } - - // Build ssh-keygen arguments - args := []string{"-t", keyType, "-f", keyPath, "-C", name + "@alchementary-link"} - if passphrase != "" { - args = append(args, "-N", passphrase) - } else { - args = append(args, "-N", "") - } - if keyType == "rsa" && bits > 0 { - args = append(args, "-b", fmt.Sprintf("%d", bits)) - } - - // #nosec G204 — arguments are sanitised above - ctx, cancel := context.WithTimeout(context.Background(), sshKeygenTimeout) - defer cancel() - cmd := exec.CommandContext(ctx, "ssh-keygen", args...) - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - if ctx.Err() == context.DeadlineExceeded { - return fmt.Errorf("ssh-keygen timed out after %s", sshKeygenTimeout) - } - return fmt.Errorf("ssh-keygen failed: %s", stderr.String()) - } - - return nil -} - -// GetSSHPublicKey returns the content of a public key file. -func (s *Service) GetSSHPublicKey(name string) (string, error) { - _, pubPath, err := sshKeyPairPaths(name) - if err != nil { - return "", err - } - data, err := readSSHPublicKeyFile(pubPath) - if err != nil { - return "", fmt.Errorf("unable to read public key: %w", err) - } - return strings.TrimSpace(string(data)), nil -} - -// DeleteSSHKey removes an SSH key pair. -func (s *Service) DeleteSSHKey(name string) error { - privPath, pubPath, err := sshKeyPairPaths(name) - if err != nil { - return err - } - - var errs []string - if err := os.Remove(privPath); err != nil && !os.IsNotExist(err) { - errs = append(errs, "private key: "+err.Error()) - } - if err := os.Remove(pubPath); err != nil && !os.IsNotExist(err) { - errs = append(errs, "public key: "+err.Error()) - } - if len(errs) > 0 { - return fmt.Errorf("failed to delete: %s", strings.Join(errs, "; ")) - } - return nil -} - -func sshKeyPairPaths(name string) (string, string, error) { - keyName, err := validateSSHKeyName(name) - if err != nil { - return "", "", err - } - home, err := os.UserHomeDir() - if err != nil { - return "", "", fmt.Errorf("unable to determine home directory: %w", err) - } - sshDir := filepath.Join(home, ".ssh") - privPath := filepath.Join(sshDir, keyName) - pubPath := privPath + ".pub" - return privPath, pubPath, nil -} - -func readSSHPublicKeyFile(path string) ([]byte, error) { - info, err := os.Stat(path) - if err != nil { - return nil, err - } - if info.IsDir() { - return nil, fmt.Errorf("public key path is a directory") - } - if info.Size() > maxSSHPublicKeyBytes { - return nil, fmt.Errorf("public key file is too large: %d bytes exceeds %d bytes", info.Size(), maxSSHPublicKeyBytes) - } - return os.ReadFile(path) -} - -func validateSSHKeyName(name string) (string, error) { - name = strings.TrimSpace(name) - if name == "" { - return "", fmt.Errorf("key name is required") - } - for _, ch := range name { - if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_') { - return "", fmt.Errorf("key name contains invalid characters (use alphanumeric, hyphens, underscores)") - } - } - return name, nil -} - -func safeSFTPUploadName(fileName string) (string, error) { - name := strings.TrimSpace(fileName) - if name == "" { - return "", fmt.Errorf("invalid file name") - } - name = strings.ReplaceAll(name, "\\", "/") - safeName := path.Base(name) - if safeName == "" || safeName == "." || safeName == ".." || safeName == "/" || strings.ContainsRune(safeName, 0) { - return "", fmt.Errorf("invalid file name") - } - return safeName, nil -} - -func validateSSHKeyGenerationOptions(keyType string, bits int) (string, int, error) { - keyType = strings.ToLower(strings.TrimSpace(keyType)) - if keyType == "" { - keyType = "ed25519" - } - switch keyType { - case "ed25519", "ecdsa": - return keyType, bits, nil - case "rsa": - if bits == 0 { - bits = 4096 - } - switch bits { - case 2048, 3072, 4096: - return keyType, bits, nil - default: - return "", 0, fmt.Errorf("unsupported RSA key size %d (use 2048, 3072, or 4096)", bits) - } - default: - return "", 0, fmt.Errorf("unsupported SSH key type %q", keyType) - } -} - -func (s *Service) lookupSFTPUpload(uploadID string) (*sftpUpload, error) { - s.uploadMu.Lock() - defer s.uploadMu.Unlock() - upload := s.uploads[uploadID] - if upload == nil { - return nil, fmt.Errorf("upload session not found") - } - return upload, nil -} - -func (s *Service) takeSFTPUpload(uploadID string) (*sftpUpload, error) { - s.uploadMu.Lock() - defer s.uploadMu.Unlock() - upload := s.uploads[uploadID] - if upload == nil { - return nil, fmt.Errorf("upload session not found") - } - delete(s.uploads, uploadID) - return upload, nil -} - -func (s *Service) removeSFTPUpload(uploadID string) { - s.uploadMu.Lock() - defer s.uploadMu.Unlock() - delete(s.uploads, uploadID) -} - -func (s *Service) checkSFTPUploadCapacity() error { - s.uploadMu.Lock() - defer s.uploadMu.Unlock() - if len(s.uploads) >= maxConcurrentSFTPUploads { - return fmt.Errorf("too many active uploads (max %d)", maxConcurrentSFTPUploads) - } - return nil -} - -// cancelSFTPUploads closes and removes active uploads. An empty session ID -// cancels every upload; otherwise only uploads owned by that session are -// affected. -func (s *Service) cancelSFTPUploads(sessionID string) { - s.uploadMu.Lock() - uploads := make([]*sftpUpload, 0) - for uploadID, upload := range s.uploads { - if sessionID == "" || upload.sessionID == sessionID { - delete(s.uploads, uploadID) - uploads = append(uploads, upload) - } - } - s.uploadMu.Unlock() - - for _, upload := range uploads { - upload.mu.Lock() - _ = upload.writer.Close() - upload.mu.Unlock() - if s.sessions != nil { - _ = s.sessions.SFTPRemove(upload.sessionID, upload.remotePath) - } - } -} - -func validateImportStrategy(strategy string) error { - switch strategy { - case "skip", "overwrite", "duplicate": - return nil - default: - return fmt.Errorf("unknown import strategy %q (expected skip, overwrite, or duplicate)", strategy) - } -} - -func validateImportProfiles(profiles []*models.Profile) error { - for i, p := range profiles { - if p == nil { - return fmt.Errorf("profile import entry %d is null", i) - } - normaliseProfileForSave(p) - if err := validation.ValidateProfile(p); err != nil { - return fmt.Errorf("profile import entry %d is invalid: %w", i, err) - } - } - return nil -} - -func normaliseProfileForSave(profile *models.Profile) { - models.NormalizeProfileDefaults(profile) -} - -func redactProfileSecrets(profiles []*models.Profile) []*models.Profile { - redacted := make([]*models.Profile, len(profiles)) - for i, profile := range profiles { - cp := *profile - if cp.SSH != nil { - sshCfg := *cp.SSH - sshCfg.ProxyPassword = "" - sshCfg.TOTPSecret = "" - cp.SSH = &sshCfg - } - redacted[i] = &cp - } - return redacted -} diff --git a/internal/api/api_test.go b/internal/api/api_test.go deleted file mode 100644 index 6871682..0000000 --- a/internal/api/api_test.go +++ /dev/null @@ -1,387 +0,0 @@ -package api - -import ( - "encoding/base64" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/andydixon/link/internal/audit" - "github.com/andydixon/link/internal/leviathan/document" - "github.com/andydixon/link/internal/models" - "github.com/andydixon/link/internal/storage" -) - -type closeTrackingWriter struct { - closed int -} - -func (w *closeTrackingWriter) Write(p []byte) (int, error) { - return len(p), nil -} - -func (w *closeTrackingWriter) Close() error { - w.closed++ - return nil -} - -func TestSSHKeyPairPathsAcceptsSafeName(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - privPath, pubPath, err := sshKeyPairPaths("deploy_key-01") - if err != nil { - t.Fatalf("sshKeyPairPaths() error = %v", err) - } - - wantPriv := filepath.Join(home, ".ssh", "deploy_key-01") - if privPath != wantPriv { - t.Fatalf("private path = %q, want %q", privPath, wantPriv) - } - if pubPath != wantPriv+".pub" { - t.Fatalf("public path = %q, want %q", pubPath, wantPriv+".pub") - } -} - -func TestSSHKeyPairPathsRejectsTraversal(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - _, _, err := sshKeyPairPaths("../outside") - if err == nil { - t.Fatal("expected traversal key name to be rejected") - } - if !strings.Contains(err.Error(), "invalid characters") { - t.Fatalf("error = %q, want invalid characters", err.Error()) - } -} - -func TestSSHKeyPairPathsRejectsNestedPath(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - _, _, err := sshKeyPairPaths("nested/key") - if err == nil { - t.Fatal("expected nested key path to be rejected") - } -} - -func TestRedactProfileSecretsClearsInlineSSHSecrets(t *testing.T) { - profile := models.NewProfile(models.ProtocolSSH) - profile.SSH.ProxyPassword = "proxy-secret" - profile.SSH.TOTPSecret = "totp-secret" - - got := redactProfileSecrets([]*models.Profile{&profile}) - if got[0].SSH.ProxyPassword != "" { - t.Fatalf("ProxyPassword = %q, want empty", got[0].SSH.ProxyPassword) - } - if got[0].SSH.TOTPSecret != "" { - t.Fatalf("TOTPSecret = %q, want empty", got[0].SSH.TOTPSecret) - } - if profile.SSH.ProxyPassword == "" || profile.SSH.TOTPSecret == "" { - t.Fatal("redaction mutated the original profile") - } -} - -func TestCreateProfileRejectsUnsafeGitHubBaseURL(t *testing.T) { - svc := newTestService(t) - profile := models.NewProfile(models.ProtocolGitHub) - profile.DisplayName = "Unsafe GitHub" - profile.GitHub.BaseURL = "http://evil.example/api/v3" - - if _, err := svc.CreateProfile(profile); err == nil { - t.Fatal("expected unsafe GitHub base URL to be rejected") - } -} - -func TestImportProfilesRejectsNullEntry(t *testing.T) { - svc := newTestService(t) - _, err := svc.ImportProfiles(`[null]`, "skip") - if err == nil { - t.Fatal("expected null import entry to be rejected") - } - if !strings.Contains(err.Error(), "entry 0 is null") { - t.Fatalf("error = %q, want null entry detail", err.Error()) - } -} - -func TestImportProfilesRejectsOversizedJSON(t *testing.T) { - svc := newTestService(t) - oversized := strings.Repeat(" ", maxProfileImportJSONBytes+1) - _, err := svc.ImportProfiles(oversized, "skip") - if err == nil { - t.Fatal("expected oversized import JSON to be rejected") - } -} - -func TestSFTPUploadDataRejectsOversizedPayloadBeforeSessionLookup(t *testing.T) { - svc := newTestService(t) - payload := base64.StdEncoding.EncodeToString(make([]byte, maxInlineSFTPUploadBytes+1)) - - err := svc.SFTPUploadData("missing-session", "/", "large.bin", payload) - if err == nil { - t.Fatal("expected oversized upload payload to be rejected") - } - if !strings.Contains(err.Error(), "too large") { - t.Fatalf("error = %q, want too large", err.Error()) - } -} - -func TestSFTPUploadChunkRejectsOversizedChunkBeforeUploadLookup(t *testing.T) { - svc := newTestService(t) - payload := base64.StdEncoding.EncodeToString(make([]byte, maxSFTPUploadChunkBytes+1)) - - err := svc.SFTPUploadChunk("missing-upload", payload) - if err == nil { - t.Fatal("expected oversized upload chunk to be rejected") - } - if !strings.Contains(err.Error(), "chunk is too large") { - t.Fatalf("error = %q, want chunk is too large", err.Error()) - } -} - -func TestCancelSFTPUploadsClosesOnlyMatchingSession(t *testing.T) { - first := &closeTrackingWriter{} - second := &closeTrackingWriter{} - svc := &Service{ - uploads: map[string]*sftpUpload{ - "first": {writer: first, sessionID: "session-1", remotePath: "/first"}, - "second": {writer: second, sessionID: "session-2", remotePath: "/second"}, - }, - } - - svc.cancelSFTPUploads("session-1") - - if first.closed != 1 { - t.Fatalf("matching writer closed %d times, want 1", first.closed) - } - if second.closed != 0 { - t.Fatalf("unrelated writer closed %d times, want 0", second.closed) - } - if len(svc.uploads) != 1 || svc.uploads["second"] == nil { - t.Fatalf("remaining uploads = %#v, want only session-2", svc.uploads) - } -} - -func TestSFTPUploadCapacityIsBounded(t *testing.T) { - svc := &Service{uploads: make(map[string]*sftpUpload)} - for i := 0; i < maxConcurrentSFTPUploads; i++ { - svc.uploads[string(rune('a'+i))] = &sftpUpload{writer: &closeTrackingWriter{}} - } - if err := svc.checkSFTPUploadCapacity(); err == nil { - t.Fatal("expected active upload limit to be enforced") - } -} - -func TestServiceShutdownReleasesUploadsAndSecretWaiters(t *testing.T) { - writer := &closeTrackingWriter{} - waiter := make(chan secretResponse, 1) - svc := &Service{ - uploads: map[string]*sftpUpload{"upload": {writer: writer}}, - secretChans: map[string]chan secretResponse{"prompt": waiter}, - } - - svc.Shutdown() - - if writer.closed != 1 { - t.Fatalf("writer closed %d times, want 1", writer.closed) - } - if len(svc.uploads) != 0 { - t.Fatalf("active uploads = %d, want 0", len(svc.uploads)) - } - select { - case response := <-waiter: - if !response.cancelled { - t.Fatal("shutdown response was not cancelled") - } - default: - t.Fatal("shutdown did not release secret prompt waiter") - } -} - -func TestSafeSFTPUploadNamePreservesHTMLCharacters(t *testing.T) { - got, err := safeSFTPUploadName(`../report .txt`) - if err != nil { - t.Fatalf("safeSFTPUploadName() error = %v", err) - } - if got != "report .txt" { - t.Fatalf("safe name = %q, want HTML characters preserved in base name", got) - } -} - -func TestSafeSFTPUploadNameRejectsParentDirectory(t *testing.T) { - for _, name := range []string{"..", "folder/..", `folder\..`} { - t.Run(name, func(t *testing.T) { - if _, err := safeSFTPUploadName(name); err == nil { - t.Fatal("expected parent directory upload name to be rejected") - } - }) - } -} - -func TestValidateSSHKeyGenerationOptions(t *testing.T) { - keyType, bits, err := validateSSHKeyGenerationOptions("", 0) - if err != nil { - t.Fatalf("default options error = %v", err) - } - if keyType != "ed25519" { - t.Fatalf("default key type = %q, want ed25519", keyType) - } - if bits != 0 { - t.Fatalf("default bits = %d, want 0", bits) - } - - keyType, bits, err = validateSSHKeyGenerationOptions("rsa", 0) - if err != nil { - t.Fatalf("rsa default options error = %v", err) - } - if keyType != "rsa" || bits != 4096 { - t.Fatalf("rsa defaults = %q/%d, want rsa/4096", keyType, bits) - } - - if _, _, err := validateSSHKeyGenerationOptions("rsa", 999999); err == nil { - t.Fatal("expected oversized RSA key size to be rejected") - } - if _, _, err := validateSSHKeyGenerationOptions("dsa", 0); err == nil { - t.Fatal("expected unsupported key type to be rejected") - } -} - -func TestImportProfilesNormalizesSparseRAWProfile(t *testing.T) { - svc := newTestService(t) - data := `[ - { - "displayName": "Raw Device", - "protocol": "raw", - "host": "device.local", - "raw": {} - } - ]` - - count, err := svc.ImportProfiles(data, "skip") - if err != nil { - t.Fatalf("ImportProfiles() error = %v", err) - } - if count != 1 { - t.Fatalf("imported = %d, want 1", count) - } - profiles, err := svc.ListProfiles() - if err != nil { - t.Fatalf("ListProfiles() error = %v", err) - } - if len(profiles) != 1 { - t.Fatalf("profiles = %d, want 1", len(profiles)) - } - if profiles[0].Port != models.DefaultRAWPort { - t.Fatalf("port = %d, want %d", profiles[0].Port, models.DefaultRAWPort) - } - if profiles[0].RAW.Encoding != "utf-8" { - t.Fatalf("raw encoding = %q, want utf-8", profiles[0].RAW.Encoding) - } -} - -func TestLeviathanDocumentBytesRejectsOversizedDocument(t *testing.T) { - doc := document.New("large.bin", []byte("abcd")) - _, err := leviathanDocumentBytes(doc, 3, "test") - if err == nil { - t.Fatal("expected oversized document to be rejected") - } - if !strings.Contains(err.Error(), "too large") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestWriteLeviathanDocumentFileWritesPatchedBytes(t *testing.T) { - doc := document.New("patched.bin", []byte("abcd")) - if _, err := doc.Set(1, 'Z'); err != nil { - t.Fatalf("Set() failed: %v", err) - } - path := filepath.Join(t.TempDir(), "out.bin") - if err := writeLeviathanDocumentFile(path, doc); err != nil { - t.Fatalf("writeLeviathanDocumentFile() failed: %v", err) - } - got, err := os.ReadFile(path) - if err != nil { - t.Fatalf("ReadFile() failed: %v", err) - } - if string(got) != "aZcd" { - t.Fatalf("written bytes = %q, want %q", string(got), "aZcd") - } -} - -func TestWriteLeviathanDocumentRangeFileWritesSelectedPatchedBytes(t *testing.T) { - doc := document.New("patched.bin", []byte("abcdef")) - if _, err := doc.Set(3, 'Z'); err != nil { - t.Fatalf("Set() failed: %v", err) - } - path := filepath.Join(t.TempDir(), "range.bin") - if err := writeLeviathanDocumentRangeFile(path, doc, 2, 3); err != nil { - t.Fatalf("writeLeviathanDocumentRangeFile() failed: %v", err) - } - got, err := os.ReadFile(path) - if err != nil { - t.Fatalf("ReadFile() failed: %v", err) - } - if string(got) != "cZe" { - t.Fatalf("written bytes = %q, want %q", string(got), "cZe") - } -} - -func TestReadLeviathanSmallFileRejectsOversizedFile(t *testing.T) { - path := filepath.Join(t.TempDir(), "big.img") - if err := os.WriteFile(path, []byte("abcd"), 0600); err != nil { - t.Fatalf("WriteFile() failed: %v", err) - } - _, err := readLeviathanSmallFile(path, 3, "image") - if err == nil { - t.Fatal("expected oversized file to be rejected") - } - if !strings.Contains(err.Error(), "too large") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestReadSSHPublicKeyFileRejectsOversizedFile(t *testing.T) { - path := filepath.Join(t.TempDir(), "big.pub") - if err := os.WriteFile(path, []byte(strings.Repeat("x", maxSSHPublicKeyBytes+1)), 0600); err != nil { - t.Fatalf("WriteFile() failed: %v", err) - } - _, err := readSSHPublicKeyFile(path) - if err == nil { - t.Fatal("expected oversized public key to be rejected") - } - if !strings.Contains(err.Error(), "too large") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestCreateProfileNormalizesLegacyVNCSecretMode(t *testing.T) { - svc := newTestService(t) - profile := models.NewProfile(models.ProtocolVNC) - profile.DisplayName = "VNC" - profile.Host = "vnc.local" - profile.VNC.SecretMode = "prompt" - profile.VNC.SSHSecretMode = "prompt" - - got, err := svc.CreateProfile(profile) - if err != nil { - t.Fatalf("CreateProfile() error = %v", err) - } - if got.VNC.SecretMode != models.SecretModeAskEveryTime { - t.Fatalf("secret mode = %q, want %q", got.VNC.SecretMode, models.SecretModeAskEveryTime) - } - if got.VNC.SSHSecretMode != models.SecretModeAskEveryTime { - t.Fatalf("ssh secret mode = %q, want %q", got.VNC.SSHSecretMode, models.SecretModeAskEveryTime) - } -} - -func newTestService(t *testing.T) *Service { - t.Helper() - store, err := storage.NewStore(filepath.Join(t.TempDir(), "test.db")) - if err != nil { - t.Fatalf("NewStore() error = %v", err) - } - t.Cleanup(func() { store.Close() }) - return NewService(store, nil, nil, nil, audit.NewLogger(store), nil) -} diff --git a/internal/api/leviathan.go b/internal/api/leviathan.go deleted file mode 100644 index 224f05f..0000000 --- a/internal/api/leviathan.go +++ /dev/null @@ -1,1035 +0,0 @@ -package api - -import ( - "archive/zip" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "sync" - "time" - - wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" - - "github.com/andydixon/link/internal/leviathan/analysis" - "github.com/andydixon/link/internal/leviathan/binsnoop" - "github.com/andydixon/link/internal/leviathan/disasm" - "github.com/andydixon/link/internal/leviathan/document" - "github.com/andydixon/link/internal/leviathan/parsers" - "github.com/andydixon/link/internal/leviathan/patch" - "github.com/andydixon/link/internal/leviathan/pdf" - "github.com/andydixon/link/internal/leviathan/spectrogram" - "github.com/andydixon/link/internal/leviathan/squashfs" - "github.com/andydixon/link/internal/leviathan/stego" - "github.com/andydixon/link/internal/leviathan/transforms" -) - -type leviathanState struct { - mu sync.Mutex - doc *document.Document - cmp *document.Document -} - -type leviathanDocState struct { - Name string `json:"name"` - Size int64 `json:"size"` - Dirty bool `json:"dirty"` - UndoLen int `json:"undoLen"` - RedoLen int `json:"redoLen"` - Compare *leviathanDocState `json:"compare,omitempty"` - PreviewHex []map[string]any `json:"previewHex"` -} - -func (s *Service) leviathanEngine() *leviathanState { - if s.leviathan == nil { - s.leviathan = &leviathanState{} - } - return s.leviathan -} - -// LeviathanOpenFile prompts for a binary and loads it into the Leviathan workbench. -func (s *Service) LeviathanOpenFile() (map[string]any, error) { - if s.ctx == nil { - return nil, fmt.Errorf("application context not set") - } - path, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{ - Title: "Open Binary", - }) - if err != nil || path == "" { - return nil, err - } - return s.LeviathanLoadFile(path, false) -} - -// LeviathanOpenCompare prompts for a binary to compare against the working file. -func (s *Service) LeviathanOpenCompare() (map[string]any, error) { - if s.ctx == nil { - return nil, fmt.Errorf("application context not set") - } - path, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{ - Title: "Open Compare Binary", - }) - if err != nil || path == "" { - return nil, err - } - return s.LeviathanLoadFile(path, true) -} - -// LeviathanLoadFile loads a path supplied by drag/drop or the native file picker. -func (s *Service) LeviathanLoadFile(path string, compare bool) (map[string]any, error) { - d, err := document.Open(path) - if err != nil { - return nil, err - } - engine := s.leviathanEngine() - engine.mu.Lock() - if compare { - engine.cmp = d - } else { - engine.doc = d - } - state := engine.stateLocked() - engine.mu.Unlock() - return map[string]any{"state": state}, nil -} - -// LeviathanState returns the current binary workbench state. -func (s *Service) LeviathanState() (leviathanDocState, error) { - engine := s.leviathanEngine() - engine.mu.Lock() - defer engine.mu.Unlock() - return engine.stateLocked(), nil -} - -// LeviathanRunTool runs one Leviathan analysis/editing tool. -func (s *Service) LeviathanRunTool(tool string, params map[string]any) (map[string]any, error) { - engine := s.leviathanEngine() - engine.mu.Lock() - if engine.doc == nil { - engine.mu.Unlock() - return nil, errors.New("open a file first") - } - if tool == "spectrogram-encode" || tool == "spectrogram-insert" || tool == "spectrogram-view" || tool == "case" { - doc := engine.doc - engine.mu.Unlock() - switch tool { - case "spectrogram-encode": - return s.leviathanSpectrogramEncode(doc, params) - case "spectrogram-insert": - return s.leviathanSpectrogramInsert(doc, params) - case "case": - return s.leviathanExportCase(doc) - default: - return leviathanSpectrogramView(doc, params) - } - } - defer engine.mu.Unlock() - return engine.runToolLocked(tool, params) -} - -// LeviathanSetByte patches one byte in the working document. -func (s *Service) LeviathanSetByte(offset int64, value string) (map[string]any, error) { - engine := s.leviathanEngine() - engine.mu.Lock() - defer engine.mu.Unlock() - if engine.doc == nil { - return nil, errors.New("open a file first") - } - value = strings.TrimSpace(value) - value = strings.TrimPrefix(strings.TrimPrefix(value, "0x"), "0X") - if len(value) == 1 { - value = "0" + value - } - if len(value) != 2 { - return nil, errors.New("byte value must be two hex digits") - } - b, err := hex.DecodeString(value) - if err != nil { - return nil, err - } - if _, err := engine.doc.Set(offset, b[0]); err != nil { - return nil, err - } - return map[string]any{"state": engine.stateLocked()}, nil -} - -// LeviathanUndo reverts the latest byte edit. -func (s *Service) LeviathanUndo() (leviathanDocState, error) { - engine := s.leviathanEngine() - engine.mu.Lock() - defer engine.mu.Unlock() - if engine.doc == nil { - return leviathanDocState{}, errors.New("open a file first") - } - engine.doc.Undo() - return engine.stateLocked(), nil -} - -// LeviathanRedo reapplies the latest undone byte edit. -func (s *Service) LeviathanRedo() (leviathanDocState, error) { - engine := s.leviathanEngine() - engine.mu.Lock() - defer engine.mu.Unlock() - if engine.doc == nil { - return leviathanDocState{}, errors.New("open a file first") - } - engine.doc.Redo() - return engine.stateLocked(), nil -} - -// LeviathanRevert clears all working edits. -func (s *Service) LeviathanRevert() (leviathanDocState, error) { - engine := s.leviathanEngine() - engine.mu.Lock() - defer engine.mu.Unlock() - if engine.doc == nil { - return leviathanDocState{}, errors.New("open a file first") - } - engine.doc.Revert() - return engine.stateLocked(), nil -} - -// LeviathanSaveWorking prompts for a destination and writes the patched bytes. -func (s *Service) LeviathanSaveWorking() (string, error) { - engine := s.leviathanEngine() - engine.mu.Lock() - if engine.doc == nil { - engine.mu.Unlock() - return "", errors.New("open a file first") - } - doc := engine.doc - engine.mu.Unlock() - if s.ctx == nil { - return "", fmt.Errorf("application context not set") - } - path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{ - Title: "Save Working Copy", - DefaultFilename: doc.Name() + ".patched", - }) - if err != nil || path == "" { - return "", err - } - return path, writeLeviathanDocumentFile(path, doc) -} - -// LeviathanSaveWorkingToPath writes the patched bytes to an explicit path. -// This is used by non-Wails hosts, where file picking is owned by the host UI. -func (s *Service) LeviathanSaveWorkingToPath(path string) (string, error) { - engine := s.leviathanEngine() - engine.mu.Lock() - if engine.doc == nil { - engine.mu.Unlock() - return "", errors.New("open a file first") - } - doc := engine.doc - engine.mu.Unlock() - if strings.TrimSpace(path) == "" { - return "", errors.New("save path is required") - } - return path, writeLeviathanDocumentFile(path, doc) -} - -// LeviathanExportJSON writes a tool result to disk. -func (s *Service) LeviathanExportJSON(name string, value map[string]any) (string, error) { - if s.ctx == nil { - return "", fmt.Errorf("application context not set") - } - path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{ - Title: "Export JSON", - DefaultFilename: safeExportName(name, "leviathan-result.json"), - }) - if err != nil || path == "" { - return "", err - } - b, err := json.MarshalIndent(value, "", " ") - if err != nil { - return "", err - } - return path, os.WriteFile(path, b, 0600) -} - -// LeviathanExportRange prompts for a destination and streams a document range to disk. -func (s *Service) LeviathanExportRange(name string, start, length int64) (string, error) { - engine := s.leviathanEngine() - engine.mu.Lock() - if engine.doc == nil { - engine.mu.Unlock() - return "", errors.New("open a file first") - } - doc := engine.doc - engine.mu.Unlock() - - if s.ctx == nil { - return "", fmt.Errorf("application context not set") - } - if start < 0 { - return "", errors.New("export start must be non-negative") - } - if length <= 0 { - return "", errors.New("this finding does not have a known exportable length") - } - size := doc.Size() - if start >= size { - return "", errors.New("export start is outside the document") - } - if length > size-start { - length = size - start - } - defaultName := fmt.Sprintf("leviathan-%08x.bin", uint64(start)) - path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{ - Title: "Export Found File", - DefaultFilename: safeExportName(name, defaultName), - }) - if err != nil || path == "" { - return "", err - } - return path, writeLeviathanDocumentRangeFile(path, doc, start, length) -} - -func (l *leviathanState) stateLocked() leviathanDocState { - state := leviathanDocState{} - if l.doc != nil { - state = leviathanDocStateFor(l.doc, true) - } - if l.cmp != nil { - cmp := leviathanDocStateFor(l.cmp, false) - state.Compare = &cmp - } - return state -} - -func leviathanDocStateFor(d *document.Document, preview bool) leviathanDocState { - s := leviathanDocState{ - Name: d.Name(), - Size: d.Size(), - Dirty: d.IsDirty(), - UndoLen: d.HistoryLen(), - RedoLen: d.RedoLen(), - } - if preview { - s.PreviewHex = leviathanHexRows(d, 0, 512, 8) - } - return s -} - -func (l *leviathanState) runToolLocked(tool string, params map[string]any) (map[string]any, error) { - start := int64Param(params, "start", 0) - length := int64Param(params, "length", -1) - switch tool { - case "overview": - return analysis.Overview(l.doc) - case "hashes": - return analysis.Hashes(l.doc, start, length) - case "entropy": - return analysis.Entropy(l.doc, start, length, int64Param(params, "window", 4096), int64Param(params, "step", 4096)) - case "strings": - items, err := analysis.ExtractStrings(l.doc, start, length, intParam(params, "min", 4), []string{"ascii", "utf16le", "utf16be"}, intParam(params, "max", 5000)) - return map[string]any{"items": items}, err - case "iocs": - return analysis.ExtractIOCs(l.doc, start, length, intParam(params, "min", 5)) - case "carve": - items, err := analysis.Carve(l.doc, start, length, intParam(params, "max", 256)) - return map[string]any{"items": items}, err - case "keys": - items, err := analysis.ScanKeys(l.doc) - return map[string]any{"items": items}, err - case "timestamps": - return analysis.ScanTimestamps(l.doc, start, length, intParam(params, "max", 1000)) - case "search": - return l.runSearch(params, start, length) - case "multi-search": - return analysis.MultiSearch(l.doc, start, length, stringParam(params, "spec", ""), intParam(params, "max", 1000)) - case "parse": - name, tree, err := parseLeviathanDocument(l.doc, stringParam(params, "kind", "auto")) - if err != nil { - return nil, err - } - return map[string]any{"parser": name, "tree": tree.ToMap()}, nil - case "disasm": - return l.runDisasm(params, start, length) - case "diff": - if l.cmp == nil { - return nil, errors.New("open a compare file first") - } - return analysis.Diff(l.doc, l.cmp, start, length) - case "stego": - out, err := stego.AnalyseAppended(l.doc) - if err != nil { - return nil, err - } - meta, _ := stego.ScanMetadata(l.doc) - out["metadata"] = meta - return out, nil - case "metadata": - items, err := stego.ScanMetadata(l.doc) - return map[string]any{"items": items}, err - case "pdf": - return pdf.Parse(l.doc) - case "squashfs": - fs, err := squashfs.Open(l.doc) - if err != nil { - return nil, err - } - entries, err := fs.Walk() - return map[string]any{"superblock": fs.Superblock(), "compression": fs.CompressionName(), "entries": entries}, err - case "binsnoop": - results, err := leviathanBinsnoopDocument(l.doc, intParam(params, "max", 10000)) - return map[string]any{"results": results, "signatures": binsnoop.ListSignatures()}, err - case "rules": - rules, err := analysis.ParseRules(stringParam(params, "source", "")) - if err != nil { - return nil, err - } - items, err := analysis.RunRules(l.doc, start, length, rules, intParam(params, "max", 1000)) - return map[string]any{"items": items}, err - case "patch-plan": - steps, err := patch.Parse(stringParam(params, "source", "")) - if err != nil { - return nil, err - } - plan, err := patch.Plan(l.doc, steps) - return map[string]any{"plan": plan}, err - case "patch-apply": - steps, err := patch.Parse(stringParam(params, "source", "")) - if err != nil { - return nil, err - } - if _, err := patch.Plan(l.doc, steps); err != nil { - return nil, err - } - return map[string]any{"state": l.stateLocked()}, patch.Apply(l.doc, steps) - case "transform": - return l.runTransform(params, start, length) - case "hex": - return map[string]any{"rows": leviathanHexRows(l.doc, start, int64Param(params, "bytes", 1024), intParam(params, "width", 16))}, nil - case "case": - return nil, errors.New("case export must run through the service") - default: - return nil, errors.New("unknown tool: " + tool) - } -} - -func (l *leviathanState) runSearch(params map[string]any, start, length int64) (map[string]any, error) { - mode := stringParam(params, "mode", "text") - query := stringParam(params, "query", "") - max := intParam(params, "max", 1000) - switch mode { - case "hex": - b, err := hex.DecodeString(strings.ReplaceAll(query, " ", "")) - if err != nil { - return nil, err - } - hits, err := analysis.SearchBytes(l.doc, start, length, b, max) - return map[string]any{"hits": analysis.HitsToMaps(hits)}, err - case "regex": - hits, err := analysis.SearchRegex(l.doc, start, length, query, max) - return map[string]any{"hits": analysis.HitsToMaps(hits)}, err - default: - hits, err := analysis.SearchBytes(l.doc, start, length, []byte(query), max) - return map[string]any{"hits": analysis.HitsToMaps(hits)}, err - } -} - -func (l *leviathanState) runDisasm(params map[string]any, start, length int64) (map[string]any, error) { - if length < 0 || length > 65536 { - length = 4096 - } - buf, err := l.rangeBytes(start, length) - if err != nil { - return nil, err - } - return map[string]any{"items": disasm.Disassemble(buf, start, stringParam(params, "arch", "x86_64"), intParam(params, "max", 1000))}, nil -} - -func (l *leviathanState) runTransform(params map[string]any, start, length int64) (map[string]any, error) { - if length < 0 || length > 8<<20 { - length = min64(l.doc.Size()-start, 1<<20) - } - buf, err := l.rangeBytes(start, length) - if err != nil { - return nil, err - } - steps := parseLeviathanTransformPipeline(stringParam(params, "pipeline", "hex.encode")) - results, err := transforms.ApplyPipeline(buf, steps) - if err != nil { - return nil, err - } - out := make([]map[string]any, 0, len(results)) - for i, r := range results { - out = append(out, map[string]any{"step": i, "length": len(r), "preview": previewLeviathanBytes(r)}) - } - return map[string]any{"steps": steps, "results": out}, nil -} - -func leviathanSpectrogramView(doc *document.Document, params map[string]any) (map[string]any, error) { - buf, err := leviathanDocumentBytes(doc, spectrogram.MaxAudioBytes, "audio") - if err != nil { - return nil, err - } - pngBytes, summary, err := spectrogram.AudioSpectrogramPNG(buf, doc.Name(), leviathanSpectrogramViewOptions(params)) - if err != nil { - return nil, err - } - return map[string]any{ - "mime": "image/png", - "pngBase64": base64.StdEncoding.EncodeToString(pngBytes), - "summary": summary, - }, nil -} - -func (s *Service) leviathanExportCase(doc *document.Document) (map[string]any, error) { - if s.ctx == nil { - return nil, fmt.Errorf("application context not set") - } - filename := strings.TrimSuffix(doc.Name(), filepath.Ext(doc.Name())) + ".case.zip" - path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{ - Title: "Export Leviathan Case", - DefaultFilename: filename, - }) - if err != nil || path == "" { - return nil, err - } - - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) - if err != nil { - return nil, err - } - defer f.Close() - - zw := zip.NewWriter(f) - add := func(name string, data []byte) error { - w, err := zw.Create(name) - if err != nil { - return err - } - _, err = w.Write(data) - return err - } - hashes, _ := analysis.Hashes(doc, 0, -1) - overview, _ := analysis.Overview(doc) - manifest, _ := json.MarshalIndent(map[string]any{"name": doc.Name(), "size": doc.Size(), "exportedAt": time.Now().Format(time.RFC3339), "hashes": hashes}, "", " ") - overviewJSON, _ := json.MarshalIndent(overview, "", " ") - patchesJSON, _ := json.MarshalIndent(doc.Patches(), "", " ") - if err := add("manifest.json", manifest); err != nil { - return nil, err - } - if err := addDocument("working.bin", doc, zw); err != nil { - return nil, err - } - if err := add("overview.json", overviewJSON); err != nil { - return nil, err - } - if err := add("patches.json", patchesJSON); err != nil { - return nil, err - } - if err := zw.Close(); err != nil { - return nil, err - } - if err := f.Sync(); err != nil { - return nil, err - } - info, err := f.Stat() - if err != nil { - return nil, err - } - return map[string]any{"path": path, "bytes": info.Size()}, nil -} - -func (s *Service) leviathanSpectrogramEncode(doc *document.Document, params map[string]any) (map[string]any, error) { - if s.ctx == nil { - return nil, fmt.Errorf("application context not set") - } - imageBytes, err := leviathanDocumentBytes(doc, spectrogram.MaxImageBytes, "image") - if err != nil { - return nil, err - } - wavBytes, summary, err := spectrogram.ImageToWAV(imageBytes, leviathanSpectrogramEncodeOptions(params)) - if err != nil { - return nil, err - } - defaultName := strings.TrimSuffix(doc.Name(), filepath.Ext(doc.Name())) + ".spectrogram.wav" - path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{ - Title: "Save Spectrogram WAV", - DefaultFilename: safeExportName(defaultName, "spectrogram.wav"), - Filters: []wailsRuntime.FileFilter{ - {DisplayName: "WAV Audio (*.wav)", Pattern: "*.wav"}, - }, - }) - if err != nil || path == "" { - return nil, err - } - if filepath.Ext(strings.ToLower(path)) != ".wav" { - path += ".wav" - } - if err := os.WriteFile(path, wavBytes, 0600); err != nil { - return nil, err - } - summary["path"] = path - return map[string]any{"path": path, "summary": summary}, nil -} - -func (s *Service) leviathanSpectrogramInsert(doc *document.Document, params map[string]any) (map[string]any, error) { - if s.ctx == nil { - return nil, fmt.Errorf("application context not set") - } - imagePath := strings.TrimSpace(stringParam(params, "imagePath", "")) - if imagePath == "" { - return nil, errors.New("select an image to insert") - } - imageBytes, err := readLeviathanSmallFile(imagePath, spectrogram.MaxImageBytes, "image") - if err != nil { - return nil, fmt.Errorf("read image: %w", err) - } - audioBytes, err := leviathanDocumentBytes(doc, spectrogram.MaxAudioBytes, "audio") - if err != nil { - return nil, err - } - wavBytes, summary, err := spectrogram.InsertImageIntoAudio(imageBytes, audioBytes, doc.Name(), leviathanSpectrogramInsertOptions(params)) - if err != nil { - return nil, err - } - defaultName := strings.TrimSuffix(doc.Name(), filepath.Ext(doc.Name())) + ".with-spectrogram.wav" - path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{ - Title: "Save Audio With Spectrogram Image", - DefaultFilename: safeExportName(defaultName, "with-spectrogram.wav"), - Filters: []wailsRuntime.FileFilter{ - {DisplayName: "WAV Audio (*.wav)", Pattern: "*.wav"}, - }, - }) - if err != nil || path == "" { - return nil, err - } - if filepath.Ext(strings.ToLower(path)) != ".wav" { - path += ".wav" - } - if err := os.WriteFile(path, wavBytes, 0600); err != nil { - return nil, err - } - summary["path"] = path - summary["imagePath"] = imagePath - return map[string]any{"path": path, "summary": summary}, nil -} - -func writeLeviathanDocumentFile(path string, doc *document.Document) error { - return writeLeviathanDocumentRangeFile(path, doc, 0, doc.Size()) -} - -func writeLeviathanDocumentRangeFile(path string, doc *document.Document, start, length int64) error { - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) - if err != nil { - return err - } - defer f.Close() - if err := doc.ForEachChunk(start, length, func(_ int64, chunk []byte) error { - _, err := f.Write(chunk) - return err - }); err != nil { - return err - } - return f.Sync() -} - -func addDocument(name string, doc *document.Document, zw *zip.Writer) error { - w, err := zw.Create(name) - if err != nil { - return err - } - return doc.ForEachChunk(0, doc.Size(), func(_ int64, chunk []byte) error { - _, err := w.Write(chunk) - return err - }) -} - -func leviathanDocumentBytes(doc *document.Document, maxBytes int64, label string) ([]byte, error) { - size := doc.Size() - if maxBytes > 0 && size > maxBytes { - return nil, fmt.Errorf("%s file is too large for this operation: %s exceeds %s", label, humanBytes(size), humanBytes(maxBytes)) - } - return doc.Range(0, size) -} - -func readLeviathanSmallFile(path string, maxBytes int64, label string) ([]byte, error) { - info, err := os.Stat(path) - if err != nil { - return nil, err - } - if info.IsDir() { - return nil, fmt.Errorf("%s path is a directory", label) - } - if maxBytes > 0 && info.Size() > maxBytes { - return nil, fmt.Errorf("%s file is too large for this operation: %s exceeds %s", label, humanBytes(info.Size()), humanBytes(maxBytes)) - } - return os.ReadFile(path) -} - -func humanBytes(n int64) string { - const unit = 1024 - if n < unit { - return fmt.Sprintf("%d B", n) - } - value := float64(n) - for _, suffix := range []string{"KiB", "MiB", "GiB", "TiB"} { - value /= unit - if value < unit { - return fmt.Sprintf("%.1f %s", value, suffix) - } - } - return fmt.Sprintf("%.1f PiB", value/unit) -} - -func (l *leviathanState) rangeBytes(start, length int64) ([]byte, error) { - if start < 0 { - start = 0 - } - if length < 0 || start+length > l.doc.Size() { - length = l.doc.Size() - start - } - if length < 0 { - length = 0 - } - return l.doc.Range(start, length) -} - -func parseLeviathanDocument(d *document.Document, kind string) (string, *parsers.Field, error) { - kind = strings.ToLower(strings.TrimSpace(kind)) - if kind == "" || kind == "auto" { - return parsers.DetectAndParse(d) - } - var tree *parsers.Field - var err error - switch kind { - case "png": - tree, err = parsers.ParsePNG(d) - case "jpeg", "jpg": - tree, err = parsers.ParseJPEG(d) - kind = "jpeg" - case "gif": - tree, err = parsers.ParseGIF(d) - case "bmp": - tree, err = parsers.ParseBMP(d) - case "zip": - tree, err = parsers.ParseZIP(d) - case "elf": - tree, err = parsers.ParseELF(d) - case "pe": - tree, err = parsers.ParsePE(d) - case "macho": - tree, err = parsers.ParseMachO(d) - case "riff": - tree, err = parsers.ParseRIFF(d) - case "asn1": - tree, err = parsers.ParseASN1(d, 0, d.Size()) - case "cert": - tree, err = parsers.ParseCerts(d) - default: - return "", nil, errors.New("unknown parser: " + kind) - } - return kind, tree, err -} - -func leviathanHexRows(d *document.Document, start, length int64, width int) []map[string]any { - if width <= 0 { - width = 16 - } - if length < 0 || length > 8192 { - length = 8192 - } - rows := []map[string]any{} - for off := start; off < start+length && off < d.Size(); off += int64(width) { - n := int64(width) - if off+n > d.Size() { - n = d.Size() - off - } - buf, err := d.Range(off, n) - if err != nil { - break - } - rows = append(rows, map[string]any{"offset": off, "hex": spacedLeviathanHex(buf), "bytes": byteHexCells(buf), "ascii": asciiLeviathanPreview(buf)}) - } - return rows -} - -func parseLeviathanTransformPipeline(src string) []transforms.Step { - parts := strings.Split(src, "|") - steps := []transforms.Step{} - for _, part := range parts { - fields := strings.Fields(strings.TrimSpace(part)) - if len(fields) == 0 { - continue - } - op := fields[0] - arg := "" - if len(fields) > 1 { - arg = strings.Join(fields[1:], " ") - } - switch op { - case "hex": - op = "hex.encode" - case "unhex": - op = "hex.decode" - case "base64", "b64": - op = "base64.encode" - case "unbase64", "unb64": - op = "base64.decode" - case "url": - op = "url.encode" - case "unurl": - op = "url.decode" - } - steps = append(steps, transforms.Step{Op: op, Arg: arg}) - } - return steps -} - -func spacedLeviathanHex(buf []byte) string { - return strings.Join(byteHexCells(buf), " ") -} - -func byteHexCells(buf []byte) []string { - parts := make([]string, len(buf)) - for i, b := range buf { - parts[i] = fmt.Sprintf("%02x", b) - } - return parts -} - -func asciiLeviathanPreview(buf []byte) string { - var b strings.Builder - for _, c := range buf { - if c >= 32 && c < 127 { - b.WriteByte(c) - } else { - b.WriteByte('.') - } - } - return b.String() -} - -func previewLeviathanBytes(buf []byte) string { - if len(buf) > 2048 { - buf = buf[:2048] - } - if isMostlyLeviathanText(buf) { - return string(buf) - } - return spacedLeviathanHex(buf) -} - -func isMostlyLeviathanText(buf []byte) bool { - if len(buf) == 0 { - return true - } - printable := 0 - for _, b := range buf { - if b == '\n' || b == '\r' || b == '\t' || (b >= 32 && b < 127) { - printable++ - } - } - return printable*100/len(buf) > 85 -} - -func intParam(m map[string]any, key string, def int) int { - return int(int64Param(m, key, int64(def))) -} - -func int64Param(m map[string]any, key string, def int64) int64 { - v, ok := m[key] - if !ok || v == nil { - return def - } - switch n := v.(type) { - case int64: - return n - case int: - return int64(n) - case float64: - return int64(n) - case string: - if n == "" { - return def - } - if parsed, err := strconv.ParseInt(n, 0, 64); err == nil { - return parsed - } - } - return def -} - -func stringParam(m map[string]any, key, def string) string { - if v, ok := m[key]; ok && v != nil { - return fmt.Sprint(v) - } - return def -} - -func floatParam(m map[string]any, key string, def float64) float64 { - v, ok := m[key] - if !ok || v == nil { - return def - } - switch n := v.(type) { - case float64: - return n - case float32: - return float64(n) - case int: - return float64(n) - case int64: - return float64(n) - case string: - if n == "" { - return def - } - if parsed, err := strconv.ParseFloat(n, 64); err == nil { - return parsed - } - } - return def -} - -func boolParam(m map[string]any, key string, def bool) bool { - v, ok := m[key] - if !ok || v == nil { - return def - } - switch b := v.(type) { - case bool: - return b - case string: - if b == "" { - return def - } - if parsed, err := strconv.ParseBool(b); err == nil { - return parsed - } - } - return def -} - -func leviathanBinsnoopDocument(d *document.Document, maxResults int) (map[string]any, error) { - if maxResults <= 0 { - maxResults = 10000 - } - const windowSize int64 = 32 << 20 - const overlapSize int64 = 1 << 20 - - scanner := binsnoop.New() - size := d.Size() - seen := make(map[string]bool) - fileMap := make([]binsnoop.SignatureResult, 0, 128) - truncated := false - - maxInt := int64(^uint(0) >> 1) - for off := int64(0); off < size; { - readLen := windowSize + overlapSize - if off+readLen > size { - readLen = size - off - } - buf, err := d.Range(off, readLen) - if err != nil { - return nil, err - } - for _, hit := range scanner.Scan(buf) { - abs := off + int64(hit.Offset) - if abs < 0 || abs > maxInt || abs >= size { - continue - } - if hit.Size < 0 || int64(hit.Size) > size-abs { - continue - } - hit.Offset = int(abs) - key := fmt.Sprintf("%d:%s", hit.Offset, hit.Name) - if seen[key] { - continue - } - seen[key] = true - fileMap = append(fileMap, hit) - if len(fileMap) >= maxResults { - truncated = true - break - } - } - if truncated || off+windowSize >= size { - break - } - off += windowSize - } - - sort.Slice(fileMap, func(i, j int) bool { - if fileMap[i].Offset == fileMap[j].Offset { - return fileMap[i].Confidence > fileMap[j].Confidence - } - return fileMap[i].Offset < fileMap[j].Offset - }) - - return map[string]any{ - "file_path": d.Name(), - "file_map": fileMap, - "scanned_bytes": size, - "window_bytes": windowSize, - "overlap_bytes": overlapSize, - "whole_file": true, - "windowed": true, - "truncated": truncated, - "max_results": maxResults, - "extractions": map[string]any{}, - "result_count": len(fileMap), - "document_bytes": size, - }, nil -} - -func leviathanSpectrogramEncodeOptions(params map[string]any) spectrogram.EncodeOptions { - return spectrogram.EncodeOptions{ - DurationSec: floatParam(params, "durationSec", 5), - SampleRate: intParam(params, "sampleRate", 44100), - MinHz: floatParam(params, "minHz", 200), - MaxHz: floatParam(params, "maxHz", 20000), - Bins: intParam(params, "bins", 160), - Columns: intParam(params, "columns", 768), - Invert: boolParam(params, "invert", true), - PreserveAR: boolParam(params, "preserveAspectRatio", true), - Gain: floatParam(params, "gain", 0.85), - } -} - -func leviathanSpectrogramInsertOptions(params map[string]any) spectrogram.InsertOptions { - return spectrogram.InsertOptions{ - EncodeOptions: leviathanSpectrogramEncodeOptions(params), - StartSec: floatParam(params, "startSec", 0), - MixLevel: floatParam(params, "mixLevel", 0.35), - } -} - -func leviathanSpectrogramViewOptions(params map[string]any) spectrogram.ViewOptions { - return spectrogram.ViewOptions{ - FFTSize: intParam(params, "fftSize", 2048), - Height: intParam(params, "height", 1024), - MaxWidth: intParam(params, "maxWidth", 1400), - Aspect: floatParam(params, "aspectRatio", 1), - MinHz: floatParam(params, "minHz", 0), - MaxHz: floatParam(params, "maxHz", 0), - Brightness: floatParam(params, "brightness", 1), - Contrast: floatParam(params, "contrast", 1), - } -} - -func min64(a, b int64) int64 { - if a < b { - return a - } - return b -} - -func safeExportName(name, fallback string) string { - name = filepath.Base(strings.TrimSpace(name)) - if name == "" || name == "." || strings.ContainsRune(name, 0) { - return fallback - } - return name -} diff --git a/internal/app/app.go b/internal/app/app.go deleted file mode 100644 index 4c16caf..0000000 --- a/internal/app/app.go +++ /dev/null @@ -1,171 +0,0 @@ -// Package app provides the core application lifecycle for Alchementary Link. -// It wires together storage, secrets, sessions, preferences, known hosts, -// and the audit log, and exposes Wails lifecycle hooks. -package app - -import ( - "context" - "log" - "path/filepath" - "sync" - - "github.com/wailsapp/wails/v2/pkg/runtime" - - "github.com/andydixon/link/internal/api" - "github.com/andydixon/link/internal/audit" - "github.com/andydixon/link/internal/config" - "github.com/andydixon/link/internal/knownhosts" - "github.com/andydixon/link/internal/preferences" - "github.com/andydixon/link/internal/recording" - "github.com/andydixon/link/internal/secrets" - "github.com/andydixon/link/internal/sessions" - "github.com/andydixon/link/internal/storage" -) - -// App is the central application struct. Wails binds its lifecycle methods -// (Startup, Shutdown, DomReady) and it holds references to every subsystem. -type App struct { - ctx context.Context - mu sync.Mutex - - Config *config.AppConfig - Store *storage.Store - SecretStore *secrets.SecretStore - KeychainStore *secrets.KeychainStore - SessionManager *sessions.Manager - Prefs *preferences.Manager - KnownHosts *knownhosts.Store - AuditLog *audit.Logger - Service *api.Service -} - -// NewApp creates a new App instance. Subsystems are not initialised until -// Startup is called by the Wails runtime. The API service is pre-created -// so it can be bound by Wails before Startup runs. -func NewApp() *App { - a := &App{} - // The API service is created eagerly so Wails can bind it. Its backing - // stores are nil at this point; they are wired during Startup. - a.Service = &api.Service{} - return a -} - -// APIService returns the Wails-bound API service instance. This must be -// called before wails.Run so the binding is registered. -func (a *App) APIService() *api.Service { - return a.Service -} - -// Startup is called by Wails when the application starts. It loads -// configuration, ensures required directories exist, and initialises all -// subsystems (storage, secret store, session manager, preferences). -func (a *App) Startup(ctx context.Context) { - a.mu.Lock() - defer a.mu.Unlock() - - a.ctx = ctx - - // Load platform-specific configuration paths. - cfg, err := config.Load() - if err != nil { - log.Fatalf("failed to load configuration: %v", err) - } - a.Config = cfg - - if err := cfg.EnsureDirectories(); err != nil { - log.Fatalf("failed to create application directories: %v", err) - } - - // Initialise SQLite storage (migrations run automatically on open). - store, err := storage.NewStore(cfg.DBPath) - if err != nil { - log.Fatalf("failed to open storage: %v", err) - } - a.Store = store - - // Initialise secret stores (keychain preferred, encrypted file fallback). - a.SecretStore = secrets.NewSecretStore(cfg.SecretPath) - a.KeychainStore = secrets.NewKeychainStore("AlchementaryLink") - - // Initialise the audit logger backed by SQLite. - a.AuditLog = audit.NewLogger(store) - - // Initialise known hosts store. - a.KnownHosts = knownhosts.NewStore(store) - - // Initialise preferences manager. - a.Prefs = preferences.NewManager(store) - if err := a.Prefs.Load(); err != nil { - log.Printf("warning: failed to load preferences, using defaults: %v", err) - } - - // Initialise session recording store. - recordDir := filepath.Join(cfg.DataDir, "recordings") - recordStore := recording.NewStore(recordDir) - - // Initialise the session manager. - a.SessionManager = sessions.NewManager(store, a.SecretStore, a.KeychainStore, a.AuditLog, recordStore) - a.SessionManager.SetContext(ctx) - - // Wire the API service with all backing stores now that they exist. - *a.Service = *api.NewService(store, a.SecretStore, a.KeychainStore, a.SessionManager, a.AuditLog, a.Prefs) - a.Service.SetContext(ctx) -} - -// Shutdown is called by Wails when the application is closing. It ensures all -// sessions are cleanly terminated and persistent storage is flushed. -func (a *App) Shutdown(ctx context.Context) { - a.mu.Lock() - defer a.mu.Unlock() - - if a.Service != nil { - a.Service.Shutdown() - } - - if a.SessionManager != nil { - a.SessionManager.Shutdown() - } - - if a.Store != nil { - if err := a.Store.Close(); err != nil { - log.Printf("warning: error closing storage: %v", err) - } - } - - // Lock the secret store to zero key material. - if a.SecretStore != nil { - a.SecretStore.Lock() - } -} - -// DomReady is called by Wails once the frontend DOM is fully loaded. It emits -// a "backend:ready" event so the frontend knows the backend is prepared. -func (a *App) DomReady(ctx context.Context) { - runtime.EventsEmit(ctx, "backend:ready", map[string]interface{}{ - "message": "Backend is ready", - }) -} - -// EmitOpenAbout tells the frontend to open the About tab. -func (a *App) EmitOpenAbout() { - a.mu.Lock() - ctx := a.ctx - a.mu.Unlock() - if ctx == nil { - log.Println("unable to open About: application context not initialised") - return - } - runtime.EventsEmit(ctx, "app:open-about", nil) -} - -// RequestQuit asks the runtime to quit the application. -func (a *App) RequestQuit() { - a.mu.Lock() - ctx := a.ctx - a.mu.Unlock() - if ctx == nil { - log.Println("unable to quit: application context not initialised") - return - } - runtime.Quit(ctx) -} diff --git a/internal/audit/audit.go b/internal/audit/audit.go deleted file mode 100644 index 0d46c95..0000000 --- a/internal/audit/audit.go +++ /dev/null @@ -1,100 +0,0 @@ -// Package audit provides structured audit logging for security-relevant -// operations. Secrets are never included in audit entries. -package audit - -import ( - "fmt" - "time" - - "github.com/andydixon/link/internal/models" - "github.com/google/uuid" -) - -// AuditStore is the persistence interface used by the Logger. -type AuditStore interface { - AddAuditEntry(entry *models.AuditEntry) error - ListAuditEntries(limit int) ([]*models.AuditEntry, error) -} - -// Logger writes structured audit entries for connection events, profile -// changes, secret access, and import/export operations. -type Logger struct { - store AuditStore -} - -// NewLogger creates a Logger backed by the given store. -func NewLogger(store AuditStore) *Logger { - return &Logger{store: store} -} - -// Log records a generic audit entry. Fields left empty are stored as-is. -func (l *Logger) Log(action string, profileID string, sessionID string, details string) { - entry := &models.AuditEntry{ - ID: uuid.New().String(), - Timestamp: time.Now(), - Action: action, - ProfileID: profileID, - SessionID: sessionID, - Details: details, - } - // Best-effort logging — errors are silently discarded to avoid - // disrupting the caller's operation. - _ = l.store.AddAuditEntry(entry) -} - -// LogConnect records a session connection event. -func (l *Logger) LogConnect(profileID string, sessionID string, protocol string, host string) { - l.Log("connect", profileID, sessionID, - fmt.Sprintf("Connected via %s to %s", protocol, host)) -} - -// LogDisconnect records a session disconnection event. -func (l *Logger) LogDisconnect(sessionID string) { - l.Log("disconnect", "", sessionID, "Session disconnected") -} - -// LogProfileCreate records the creation of a new profile. -func (l *Logger) LogProfileCreate(profileID string, name string) { - l.Log("profile.create", profileID, "", - fmt.Sprintf("Created profile %q", name)) -} - -// LogProfileUpdate records an update to an existing profile. -func (l *Logger) LogProfileUpdate(profileID string, name string) { - l.Log("profile.update", profileID, "", - fmt.Sprintf("Updated profile %q", name)) -} - -// LogProfileDelete records the deletion of a profile. -func (l *Logger) LogProfileDelete(profileID string, name string) { - l.Log("profile.delete", profileID, "", - fmt.Sprintf("Deleted profile %q", name)) -} - -// LogSecretAccess records access to stored credentials. The secret value is -// never recorded — only the type of secret accessed. -func (l *Logger) LogSecretAccess(profileID string, secretType string) { - l.Log("secret.access", profileID, "", - fmt.Sprintf("Accessed %s credential", secretType)) -} - -// LogImport records a bulk profile import. -func (l *Logger) LogImport(count int) { - l.Log("import", "", "", - fmt.Sprintf("Imported %d profile(s)", count)) -} - -// LogExport records a profile export, noting whether secrets were included. -func (l *Logger) LogExport(count int, includeSecrets bool) { - secretsNote := "without secrets" - if includeSecrets { - secretsNote = "with secrets" - } - l.Log("export", "", "", - fmt.Sprintf("Exported %d profile(s) %s", count, secretsNote)) -} - -// ListEntries returns recent audit entries, delegating to the backing store. -func (l *Logger) ListEntries(limit int) ([]*models.AuditEntry, error) { - return l.store.ListAuditEntries(limit) -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 8856c9f..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,87 +0,0 @@ -// Package config resolves platform-specific application paths and configuration. -package config - -import ( - "os" - "path/filepath" - "runtime" -) - -const appName = "AlchementaryLink" - -// AppConfig holds the resolved application configuration paths. -type AppConfig struct { - DataDir string // base data directory - DBPath string // SQLite database path - SecretPath string // encrypted vault directory - LogPath string // log file path -} - -// Load resolves the application configuration. -// Data directory follows platform conventions: -// - macOS: ~/Library/Application Support/AlchementaryLink -// - Linux: ~/.config/AlchementaryLink -// - Windows: %APPDATA%/AlchementaryLink -func Load() (*AppConfig, error) { - dataDir, err := resolveDataDir() - if err != nil { - return nil, err - } - - cfg := &AppConfig{ - DataDir: dataDir, - DBPath: filepath.Join(dataDir, "link.db"), - SecretPath: filepath.Join(dataDir, "vault"), - LogPath: filepath.Join(dataDir, "link.log"), - } - return cfg, nil -} - -// EnsureDirectories creates required directories if they don't exist. -func (c *AppConfig) EnsureDirectories() error { - dirs := []string{ - c.DataDir, - c.SecretPath, - filepath.Dir(c.LogPath), - } - for _, d := range dirs { - if err := os.MkdirAll(d, 0700); err != nil { - return err - } - } - return nil -} - -// resolveDataDir determines the base data directory for the current platform. -func resolveDataDir() (string, error) { - var base string - - switch runtime.GOOS { - case "darwin": - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - base = filepath.Join(home, "Library", "Application Support") - case "windows": - base = os.Getenv("APPDATA") - if base == "" { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - base = filepath.Join(home, "AppData", "Roaming") - } - default: // Linux and other UNIX-likes - base = os.Getenv("XDG_CONFIG_HOME") - if base == "" { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - base = filepath.Join(home, ".config") - } - } - - return filepath.Join(base, appName), nil -} diff --git a/internal/exportimport/exportimport.go b/internal/exportimport/exportimport.go deleted file mode 100644 index 4486c7d..0000000 --- a/internal/exportimport/exportimport.go +++ /dev/null @@ -1,209 +0,0 @@ -// Package exportimport provides encrypted profile export and import. -// Profiles are serialised to JSON, then encrypted with AES-256-GCM using -// an Argon2id-derived key from a user-supplied password. No hard-coded -// secrets are used. The file format includes a version byte, salt, nonce, -// and ciphertext — everything needed for self-contained decryption. -package exportimport - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "time" - - "golang.org/x/crypto/argon2" - - "github.com/andydixon/link/internal/models" -) - -const ( - // File format version. Increment if the envelope format changes. - formatVersion byte = 1 - - // Argon2id parameters for export encryption (same strength as the vault). - exportArgonTime = 3 - exportArgonMemory = 128 * 1024 // 128 MiB - exportArgonThreads = 4 - exportArgonKeyLen = 32 // AES-256 - - saltLength = 32 - nonceLength = 12 // AES-GCM standard nonce size - - maxEncryptedImportBytes = 10 * 1024 * 1024 -) - -// ExportBundle is the cleartext payload that gets encrypted. -type ExportBundle struct { - Version int `json:"version"` - ExportedAt string `json:"exportedAt"` // ISO 8601 - Profiles []models.Profile `json:"profiles"` - Count int `json:"count"` -} - -// EncryptAndExport serialises the given profiles, encrypts the JSON with -// AES-256-GCM (Argon2id-derived key), and writes the result to the file. -// -// File layout: [1 byte version][32 bytes salt][12 bytes nonce][ciphertext] -func EncryptAndExport(profiles []models.Profile, password, filePath string) error { - if password == "" { - return errors.New("exportimport: password cannot be empty") - } - if len(profiles) == 0 { - return errors.New("exportimport: no profiles to export") - } - - bundle := ExportBundle{ - Version: 1, - ExportedAt: time.Now().UTC().Format(time.RFC3339), - Profiles: profiles, - Count: len(profiles), - } - - plaintext, err := json.MarshalIndent(bundle, "", " ") - if err != nil { - return fmt.Errorf("exportimport: failed to serialise profiles: %w", err) - } - defer clear(plaintext) - - // Generate random salt and nonce. - salt := make([]byte, saltLength) - if _, err := io.ReadFull(rand.Reader, salt); err != nil { - return fmt.Errorf("exportimport: failed to generate salt: %w", err) - } - - nonce := make([]byte, nonceLength) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return fmt.Errorf("exportimport: failed to generate nonce: %w", err) - } - - // Derive encryption key from password. - passwordBytes := []byte(password) - defer clear(passwordBytes) - key := argon2.IDKey( - passwordBytes, - salt, - exportArgonTime, - exportArgonMemory, - exportArgonThreads, - exportArgonKeyLen, - ) - defer clear(key) - - // Encrypt with AES-256-GCM. - block, err := aes.NewCipher(key) - if err != nil { - return fmt.Errorf("exportimport: cipher init failed: %w", err) - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return fmt.Errorf("exportimport: GCM init failed: %w", err) - } - - ciphertext := gcm.Seal(nil, nonce, plaintext, nil) - - // Write the file: version + salt + nonce + ciphertext. - f, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) - if err != nil { - return fmt.Errorf("exportimport: failed to create file: %w", err) - } - defer f.Close() - if err := f.Chmod(0600); err != nil { - return fmt.Errorf("exportimport: failed to restrict file permissions: %w", err) - } - - if _, err := f.Write([]byte{formatVersion}); err != nil { - return fmt.Errorf("exportimport: write version failed: %w", err) - } - if _, err := f.Write(salt); err != nil { - return fmt.Errorf("exportimport: write salt failed: %w", err) - } - if _, err := f.Write(nonce); err != nil { - return fmt.Errorf("exportimport: write nonce failed: %w", err) - } - if _, err := f.Write(ciphertext); err != nil { - return fmt.Errorf("exportimport: write ciphertext failed: %w", err) - } - - return f.Sync() -} - -// DecryptAndImport reads an encrypted export file, decrypts it using the -// given password, and returns the contained profiles. -func DecryptAndImport(filePath, password string) ([]models.Profile, error) { - if password == "" { - return nil, errors.New("exportimport: password cannot be empty") - } - - info, err := os.Stat(filePath) - if err != nil { - return nil, fmt.Errorf("exportimport: failed to stat file: %w", err) - } - if info.Size() > maxEncryptedImportBytes { - return nil, fmt.Errorf("exportimport: file is too large (max %d bytes)", maxEncryptedImportBytes) - } - - data, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("exportimport: failed to read file: %w", err) - } - - // Minimum size: version(1) + salt(32) + nonce(12) + at least 1 byte ciphertext + GCM tag(16). - minSize := 1 + saltLength + nonceLength + 1 + 16 - if len(data) < minSize { - return nil, errors.New("exportimport: file is too small or corrupt") - } - - // Parse envelope. - version := data[0] - if version != formatVersion { - return nil, fmt.Errorf("exportimport: unsupported format version %d", version) - } - - salt := data[1 : 1+saltLength] - nonce := data[1+saltLength : 1+saltLength+nonceLength] - ciphertext := data[1+saltLength+nonceLength:] - - // Derive decryption key. - passwordBytes := []byte(password) - defer clear(passwordBytes) - key := argon2.IDKey( - passwordBytes, - salt, - exportArgonTime, - exportArgonMemory, - exportArgonThreads, - exportArgonKeyLen, - ) - defer clear(key) - - // Decrypt with AES-256-GCM. - block, err := aes.NewCipher(key) - if err != nil { - return nil, fmt.Errorf("exportimport: cipher init failed: %w", err) - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, fmt.Errorf("exportimport: GCM init failed: %w", err) - } - - plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return nil, errors.New("exportimport: decryption failed — wrong password or corrupt file") - } - defer clear(plaintext) - - // Parse the JSON bundle. - var bundle ExportBundle - if err := json.Unmarshal(plaintext, &bundle); err != nil { - return nil, fmt.Errorf("exportimport: invalid bundle JSON: %w", err) - } - - return bundle.Profiles, nil -} diff --git a/internal/exportimport/exportimport_test.go b/internal/exportimport/exportimport_test.go deleted file mode 100644 index 7535b9d..0000000 --- a/internal/exportimport/exportimport_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package exportimport - -import ( - "os" - "path/filepath" - "runtime" - "testing" - - "github.com/andydixon/link/internal/models" -) - -func TestEncryptDecryptRoundTrip(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "test-export.link") - - profiles := []models.Profile{ - { - ID: "test-1", - DisplayName: "Test Server", - Protocol: models.ProtocolSSH, - Host: "example.com", - Port: 22, - Username: "admin", - }, - { - ID: "test-2", - DisplayName: "Dev Box", - Protocol: models.ProtocolSSH, - Host: "dev.example.com", - Port: 2222, - Username: "developer", - }, - } - - password := "strong-test-password-2024!" - - // Encrypt and export. - if err := EncryptAndExport(profiles, password, path); err != nil { - t.Fatalf("EncryptAndExport failed: %v", err) - } - - // Verify file exists and has content. - info, err := os.Stat(path) - if err != nil { - t.Fatalf("exported file not found: %v", err) - } - if info.Size() == 0 { - t.Fatal("exported file is empty") - } - - // Decrypt and import. - imported, err := DecryptAndImport(path, password) - if err != nil { - t.Fatalf("DecryptAndImport failed: %v", err) - } - - if len(imported) != len(profiles) { - t.Fatalf("expected %d profiles, got %d", len(profiles), len(imported)) - } - - for i, p := range imported { - if p.ID != profiles[i].ID { - t.Errorf("profile %d: expected ID %q, got %q", i, profiles[i].ID, p.ID) - } - if p.DisplayName != profiles[i].DisplayName { - t.Errorf("profile %d: expected DisplayName %q, got %q", i, profiles[i].DisplayName, p.DisplayName) - } - if p.Host != profiles[i].Host { - t.Errorf("profile %d: expected Host %q, got %q", i, profiles[i].Host, p.Host) - } - } -} - -func TestDecryptWrongPassword(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "test-export.link") - - profiles := []models.Profile{ - { - ID: "test-1", - DisplayName: "Test", - Protocol: models.ProtocolSSH, - Host: "example.com", - Port: 22, - }, - } - - if err := EncryptAndExport(profiles, "correct-password", path); err != nil { - t.Fatalf("EncryptAndExport failed: %v", err) - } - - _, err := DecryptAndImport(path, "wrong-password") - if err == nil { - t.Fatal("expected error with wrong password, got nil") - } -} - -func TestEncryptEmptyProfiles(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "test.link") - - err := EncryptAndExport(nil, "password", path) - if err == nil { - t.Fatal("expected error for empty profiles, got nil") - } -} - -func TestEncryptAndExportRestrictsExistingFilePermissions(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("POSIX permission bits are not enforced on Windows") - } - path := filepath.Join(t.TempDir(), "profiles.link") - if err := os.WriteFile(path, []byte("old"), 0644); err != nil { - t.Fatal(err) - } - if err := EncryptAndExport([]models.Profile{{ID: "1"}}, "password", path); err != nil { - t.Fatal(err) - } - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - if got := info.Mode().Perm(); got != 0600 { - t.Fatalf("export permissions = %04o, want 0600", got) - } -} - -func TestEncryptEmptyPassword(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "test.link") - - profiles := []models.Profile{{ID: "1"}} - err := EncryptAndExport(profiles, "", path) - if err == nil { - t.Fatal("expected error for empty password, got nil") - } -} - -func TestDecryptCorruptFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "corrupt.link") - - // Write garbage data. - if err := os.WriteFile(path, []byte("corrupt"), 0600); err != nil { - t.Fatal(err) - } - - _, err := DecryptAndImport(path, "password") - if err == nil { - t.Fatal("expected error for corrupt file, got nil") - } -} - -func TestDecryptRejectsOversizedFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "huge.link") - f, err := os.Create(path) - if err != nil { - t.Fatal(err) - } - if err := f.Truncate(maxEncryptedImportBytes + 1); err != nil { - f.Close() - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - - _, err = DecryptAndImport(path, "password") - if err == nil { - t.Fatal("expected error for oversized file, got nil") - } -} diff --git a/internal/githubclient/githubclient.go b/internal/githubclient/githubclient.go deleted file mode 100644 index 1f18b34..0000000 --- a/internal/githubclient/githubclient.go +++ /dev/null @@ -1,408 +0,0 @@ -// Package githubclient provides a lightweight GitHub REST API client for -// fetching repository metadata. It supports both github.com and GitHub -// Enterprise instances via a configurable base URL. -package githubclient - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "sort" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/andydixon/link/internal/models" -) - -const ( - maxGitHubJSONBodyBytes = 4 << 20 - maxGitHubErrorBodyBytes = 64 << 10 - maxGitHubArchiveBytes = int64(2 << 30) -) - -// Client wraps the GitHub REST API for repository listing. -type Client struct { - baseURL string - token string - httpClient *http.Client -} - -// New creates a Client for the given API base URL and personal access token. -// baseURL should be the API root, e.g. "https://api.github.com" or -// "https://ghe.example.com/api/v3". -func New(baseURL, token string) *Client { - return &Client{ - baseURL: strings.TrimRight(baseURL, "/"), - token: token, - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, - } -} - -// apiRepo is the subset of the GitHub API response we care about. -type apiRepo struct { - FullName string `json:"full_name"` - Name string `json:"name"` - Description string `json:"description"` - HTMLURL string `json:"html_url"` - DefaultBranch string `json:"default_branch"` - Private bool `json:"private"` - Fork bool `json:"fork"` - Archived bool `json:"archived"` - Language string `json:"language"` - Stars int `json:"stargazers_count"` - PushedAt time.Time `json:"pushed_at"` - UpdatedAt time.Time `json:"updated_at"` - Owner struct { - Login string `json:"login"` - } `json:"owner"` -} - -// FetchRepos retrieves all repositories visible to the authenticated user, -// paginating through the API. Results are sorted by pushed_at descending -// (most recently active first). -func (c *Client) FetchRepos(ctx context.Context) ([]models.GitHubRepo, error) { - return c.fetchRepos(ctx, nil) -} - -// FetchReposWithProgress fetches repositories and reports enrichment progress. -func (c *Client) FetchReposWithProgress(ctx context.Context, progress func(done int, total int)) ([]models.GitHubRepo, error) { - return c.fetchRepos(ctx, progress) -} - -func (c *Client) fetchRepos(ctx context.Context, progress func(done int, total int)) ([]models.GitHubRepo, error) { - var all []apiRepo - - page := 1 - for { - url := fmt.Sprintf("%s/user/repos?per_page=100&page=%d&sort=pushed&direction=desc", c.baseURL, page) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("building request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("User-Agent", "AlchementaryLink/1.0") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("fetching repos page %d: %w", page, err) - } - - body, readErr := readLimitedGitHubBody(resp.Body, maxGitHubJSONBodyBytes) - resp.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("reading response body: %w", readErr) - } - - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - return nil, fmt.Errorf("authentication failed (HTTP %d) — check your access token", resp.StatusCode) - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, string(body)) - } - - var batch []apiRepo - if err := json.Unmarshal(body, &batch); err != nil { - return nil, fmt.Errorf("decoding repos: %w", err) - } - - if len(batch) == 0 { - break - } - all = append(all, batch...) - - // Safety cap — 5000 repos should be more than enough. - if len(all) >= 5000 || len(batch) < 100 { - break - } - page++ - } - - // Sort by pushed_at descending (newest first). - sort.Slice(all, func(i, j int) bool { - return all[i].PushedAt.After(all[j].PushedAt) - }) - - result := make([]models.GitHubRepo, len(all)) - for i, r := range all { - result[i] = models.GitHubRepo{ - FullName: r.FullName, - Name: r.Name, - Owner: r.Owner.Login, - Description: r.Description, - HTMLURL: r.HTMLURL, - DefaultBranch: r.DefaultBranch, - Private: r.Private, - Fork: r.Fork, - Archived: r.Archived, - Language: r.Language, - Stars: r.Stars, - PushedAt: r.PushedAt.Format(time.RFC3339), - UpdatedAt: r.UpdatedAt.Format(time.RFC3339), - } - } - - if progress != nil { - progress(0, len(result)) - } - - // Enrich with latest commit metadata (best-effort). - sem := make(chan struct{}, 5) - var wg sync.WaitGroup - var done int32 - for i := range result { - wg.Add(1) - go func(idx int) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() - - ref := result[idx].DefaultBranch - if ref == "" { - ref = "HEAD" - } - committer, committedAt, err := c.FetchLatestCommit(ctx, result[idx].FullName, ref) - if err != nil { - if progress != nil { - current := int(atomic.AddInt32(&done, 1)) - progress(current, len(result)) - } - return - } - result[idx].LatestCommitter = committer - result[idx].LatestCommitAt = committedAt - if progress != nil { - current := int(atomic.AddInt32(&done, 1)) - progress(current, len(result)) - } - }(i) - } - wg.Wait() - return result, nil -} - -// FetchLatestCommit returns the committer name and commit time for a repo/ref. -func (c *Client) FetchLatestCommit(ctx context.Context, fullName string, ref string) (string, string, error) { - if ref == "" { - ref = "HEAD" - } - owner, repo, err := splitRepoFullName(fullName) - if err != nil { - return "", "", err - } - url, err := c.endpointURL("repos", owner, repo, "commits", ref) - if err != nil { - return "", "", err - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return "", "", fmt.Errorf("building request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("User-Agent", "AlchementaryLink/1.0") - - resp, err := c.httpClient.Do(req) - if err != nil { - return "", "", fmt.Errorf("fetching commit: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - return "", "", fmt.Errorf("authentication failed (HTTP %d)", resp.StatusCode) - } - if resp.StatusCode != http.StatusOK { - return "", "", fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, readGitHubErrorBody(resp.Body)) - } - - var payload struct { - Commit struct { - Committer struct { - Name string `json:"name"` - Date time.Time `json:"date"` - } `json:"committer"` - Author struct { - Name string `json:"name"` - Date time.Time `json:"date"` - } `json:"author"` - } `json:"commit"` - } - - body, err := readLimitedGitHubBody(resp.Body, maxGitHubJSONBodyBytes) - if err != nil { - return "", "", fmt.Errorf("reading commit response: %w", err) - } - if err := json.Unmarshal(body, &payload); err != nil { - return "", "", fmt.Errorf("decoding commit: %w", err) - } - - name := payload.Commit.Committer.Name - date := payload.Commit.Committer.Date - if name == "" { - name = payload.Commit.Author.Name - date = payload.Commit.Author.Date - } - if name == "" || date.IsZero() { - return "", "", fmt.Errorf("commit metadata missing") - } - - return name, date.Format(time.RFC3339), nil -} - -// DownloadRepoArchive downloads a repository zip archive to the given path. -func (c *Client) DownloadRepoArchive(ctx context.Context, fullName string, ref string, destPath string) error { - owner, repo, err := splitRepoFullName(fullName) - if err != nil { - return err - } - segments := []string{"repos", owner, repo, "zipball"} - if ref != "" { - segments = append(segments, ref) - } - url, err := c.endpointURL(segments...) - if err != nil { - return err - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return fmt.Errorf("building request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("User-Agent", "AlchementaryLink/1.0") - - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("downloading archive: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - return fmt.Errorf("authentication failed (HTTP %d) — check your access token", resp.StatusCode) - } - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, readGitHubErrorBody(resp.Body)) - } - if resp.ContentLength > maxGitHubArchiveBytes { - return fmt.Errorf("archive is too large: %d bytes exceeds %d bytes", resp.ContentLength, maxGitHubArchiveBytes) - } - - file, err := os.Create(destPath) - if err != nil { - return fmt.Errorf("creating file: %w", err) - } - defer file.Close() - - if _, err := copyLimitedArchive(file, resp.Body, maxGitHubArchiveBytes); err != nil { - return fmt.Errorf("writing archive: %w", err) - } - return nil -} - -// ValidateToken makes a lightweight call to /user to confirm the token works. -func (c *Client) ValidateToken(ctx context.Context) (string, error) { - url := c.baseURL + "/user" - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return "", fmt.Errorf("building request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("User-Agent", "AlchementaryLink/1.0") - - resp, err := c.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("contacting GitHub API: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("token validation failed (HTTP %d)", resp.StatusCode) - } - - var user struct { - Login string `json:"login"` - } - body, err := readLimitedGitHubBody(resp.Body, maxGitHubJSONBodyBytes) - if err != nil { - return "", fmt.Errorf("reading user response: %w", err) - } - if err := json.Unmarshal(body, &user); err != nil { - return "", fmt.Errorf("decoding user: %w", err) - } - return user.Login, nil -} - -func readLimitedGitHubBody(r io.Reader, maxBytes int64) ([]byte, error) { - body, err := io.ReadAll(io.LimitReader(r, maxBytes+1)) - if err != nil { - return nil, err - } - if int64(len(body)) > maxBytes { - return nil, fmt.Errorf("response body exceeds %d bytes", maxBytes) - } - return body, nil -} - -func readGitHubErrorBody(r io.Reader) string { - body, err := readLimitedGitHubBody(r, maxGitHubErrorBodyBytes) - if err != nil { - return "" - } - return strings.TrimSpace(string(body)) -} - -func splitRepoFullName(fullName string) (string, string, error) { - parts := strings.Split(strings.TrimSpace(fullName), "/") - if len(parts) != 2 { - return "", "", fmt.Errorf("repository name must be in owner/repo format") - } - owner := strings.TrimSpace(parts[0]) - repo := strings.TrimSpace(parts[1]) - if owner == "" || repo == "" { - return "", "", fmt.Errorf("repository name must be in owner/repo format") - } - return owner, repo, nil -} - -func (c *Client) endpointURL(segments ...string) (string, error) { - u, err := url.Parse(c.baseURL) - if err != nil { - return "", fmt.Errorf("invalid GitHub base URL: %w", err) - } - plainPath := strings.TrimRight(u.Path, "/") - escapedPath := strings.TrimRight(u.EscapedPath(), "/") - for _, segment := range segments { - if segment == "" { - return "", fmt.Errorf("empty GitHub API path segment") - } - plainPath += "/" + segment - escapedPath += "/" + url.PathEscape(segment) - } - u.Path = plainPath - u.RawPath = escapedPath - return u.String(), nil -} - -func copyLimitedArchive(dst io.Writer, src io.Reader, maxBytes int64) (int64, error) { - limited := &io.LimitedReader{R: src, N: maxBytes + 1} - n, err := io.Copy(dst, limited) - if err != nil { - return n, err - } - if n > maxBytes { - return n, fmt.Errorf("archive exceeds %d bytes", maxBytes) - } - return n, nil -} diff --git a/internal/githubclient/githubclient_test.go b/internal/githubclient/githubclient_test.go deleted file mode 100644 index 3a26f89..0000000 --- a/internal/githubclient/githubclient_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package githubclient - -import ( - "bytes" - "io" - "strings" - "testing" -) - -func TestReadLimitedGitHubBodyRejectsOversizedResponse(t *testing.T) { - _, err := readLimitedGitHubBody(strings.NewReader("abcd"), 3) - if err == nil { - t.Fatal("expected oversized response to be rejected") - } - if !strings.Contains(err.Error(), "response body exceeds") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestReadLimitedGitHubBodyAllowsResponseAtLimit(t *testing.T) { - got, err := readLimitedGitHubBody(strings.NewReader("abc"), 3) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if string(got) != "abc" { - t.Fatalf("body = %q, want abc", string(got)) - } -} - -func TestEndpointURLEscapesRepoPathSegments(t *testing.T) { - client := New("https://ghe.example.com/api/v3", "") - - got, err := client.endpointURL("repos", "owner", "repo", "commits", "feature/a b") - if err != nil { - t.Fatalf("endpointURL() error = %v", err) - } - want := "https://ghe.example.com/api/v3/repos/owner/repo/commits/feature%2Fa%20b" - if got != want { - t.Fatalf("endpointURL() = %q, want %q", got, want) - } -} - -func TestSplitRepoFullNameRejectsExtraSegments(t *testing.T) { - if _, _, err := splitRepoFullName("owner/repo/../../user"); err == nil { - t.Fatal("expected malformed repository name to be rejected") - } -} - -func TestCopyLimitedArchiveRejectsOversizedStream(t *testing.T) { - var dst bytes.Buffer - n, err := copyLimitedArchive(&dst, strings.NewReader("abcd"), 3) - if err == nil { - t.Fatal("expected oversized archive stream to be rejected") - } - if n != 4 { - t.Fatalf("copied = %d, want 4", n) - } - if !strings.Contains(err.Error(), "archive exceeds") { - t.Fatalf("error = %q, want archive exceeds", err.Error()) - } -} - -func TestCopyLimitedArchiveAllowsStreamAtLimit(t *testing.T) { - var dst bytes.Buffer - n, err := copyLimitedArchive(&dst, strings.NewReader("abc"), 3) - if err != nil { - t.Fatalf("copyLimitedArchive() error = %v", err) - } - if n != 3 { - t.Fatalf("copied = %d, want 3", n) - } - if got, err := io.ReadAll(&dst); err != nil || string(got) != "abc" { - t.Fatalf("dst = %q, err = %v; want abc", string(got), err) - } -} diff --git a/internal/ircclient/ircclient.go b/internal/ircclient/ircclient.go deleted file mode 100644 index f94a14e..0000000 --- a/internal/ircclient/ircclient.go +++ /dev/null @@ -1,833 +0,0 @@ -// Package ircclient wraps a full IRC client with Link-specific event and -// command handling, including optional SSH tunnelling. -package ircclient - -import ( - "crypto/tls" - "fmt" - "net" - "sort" - "strconv" - "strings" - "sync" - "time" - - "github.com/lrstanley/girc" - "golang.org/x/crypto/ssh" - "golang.org/x/crypto/ssh/agent" -) - -const ServerTarget = "server" - -const maxRoomListEntries = 2000 - -// Config describes an IRC connection. -type Config struct { - Server string - Port int - Nick string - User string - RealName string - UseTLS bool - ServerPass string - AutoJoinRooms []string - - NickServIdentify bool - NickServAccount string - NickServPassword string - - SSHTunnel bool - SSHHost string - SSHPort int - SSHUsername string - SSHAuthMethod string - SSHPassword string - SSHPrivateKey []byte - SSHPassphrase string - SSHAgentAuth bool - SSHAgentConn net.Conn - SSHHostKeyCallback ssh.HostKeyCallback - - ConnectTimeout time.Duration - OnEvent func(Event) - OnError func(error) - OnClose func() - OnRoomsChanged func([]string) -} - -// Event is a typed IRC event suitable for JSON delivery to the frontend. -type Event struct { - Type string `json:"type"` - Target string `json:"target,omitempty"` - From string `json:"from,omitempty"` - Text string `json:"text,omitempty"` - Command string `json:"command,omitempty"` - Timestamp int64 `json:"timestamp"` - Rooms []RoomInfo `json:"rooms,omitempty"` - Users []string `json:"users,omitempty"` - Topic string `json:"topic,omitempty"` -} - -// RoomInfo describes a joined or listed IRC room. -type RoomInfo struct { - Name string `json:"name"` - Topic string `json:"topic,omitempty"` - Users int `json:"users,omitempty"` -} - -// Client owns a girc client and optional SSH tunnel. -type Client struct { - cfg Config - client *girc.Client - sshClient *ssh.Client - agentConn net.Conn - - mu sync.Mutex - connected bool - closing bool - listRooms []RoomInfo - roomNames []string -} - -// New creates an IRC client wrapper. -func New(cfg Config) *Client { - if cfg.ConnectTimeout <= 0 { - cfg.ConnectTimeout = 30 * time.Second - } - if cfg.User == "" { - cfg.User = cfg.Nick - } - if cfg.RealName == "" { - cfg.RealName = cfg.Nick - } - - c := &Client{cfg: cfg} - c.client = girc.New(girc.Config{ - Server: cfg.Server, - ServerPass: cfg.ServerPass, - Port: cfg.Port, - Nick: cfg.Nick, - User: cfg.User, - Name: cfg.RealName, - SSL: cfg.UseTLS && !cfg.SSHTunnel, - TLSConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, - ServerName: cfg.Server, - }, - AllowFlood: false, - }) - c.registerHandlers() - return c -} - -// Connect starts the IRC connection and returns once registration succeeds. -func (c *Client) Connect() error { - connectedCh := make(chan struct{}, 1) - errCh := make(chan error, 1) - var connectedOnce sync.Once - - c.client.Handlers.Add(girc.CONNECTED, func(gc *girc.Client, e girc.Event) { - c.mu.Lock() - c.connected = true - c.mu.Unlock() - - c.emit(Event{ - Type: "system", - Target: ServerTarget, - Text: fmt.Sprintf("Connected to %s", gc.Server()), - Timestamp: eventTime(e), - }) - - c.identifyWithNickServ(gc) - if len(c.cfg.AutoJoinRooms) > 0 { - gc.Cmd.Join(c.cfg.AutoJoinRooms...) - } - c.emitRooms() - - connectedOnce.Do(func() { - select { - case connectedCh <- struct{}{}: - default: - } - }) - }) - - go func() { - var err error - if c.cfg.SSHTunnel { - dialer, dialerErr := c.newSSHDialer() - if dialerErr != nil { - select { - case errCh <- dialerErr: - default: - } - return - } - err = c.client.DialerConnect(dialer) - } else { - err = c.client.Connect() - } - - c.mu.Lock() - wasClosing := c.closing - wasConnected := c.connected - c.connected = false - c.mu.Unlock() - - if err != nil && !wasClosing { - c.emit(Event{ - Type: "error", - Target: ServerTarget, - Text: err.Error(), - Timestamp: time.Now().UnixMilli(), - }) - if c.cfg.OnError != nil { - c.cfg.OnError(err) - } - select { - case errCh <- err: - default: - } - } - if wasConnected && !wasClosing && c.cfg.OnClose != nil { - c.cfg.OnClose() - } - }() - - select { - case <-connectedCh: - return nil - case err := <-errCh: - return err - case <-time.After(c.cfg.ConnectTimeout): - c.Close() - return fmt.Errorf("IRC connection timed out after %s", c.cfg.ConnectTimeout) - } -} - -// Close disconnects from IRC and closes any SSH tunnel resources. -func (c *Client) Close() { - c.mu.Lock() - if c.closing { - c.mu.Unlock() - return - } - c.closing = true - c.mu.Unlock() - - if c.client != nil { - c.client.Quit("Alchementary Link disconnected") - c.client.Close() - } - if c.sshClient != nil { - _ = c.sshClient.Close() - c.sshClient = nil - } - if c.agentConn != nil { - _ = c.agentConn.Close() - c.agentConn = nil - } -} - -// HandleInput parses a user-entered IRC composer line. -func (c *Client) HandleInput(target string, input string) error { - text := strings.TrimSpace(input) - if text == "" { - return nil - } - if target == "" { - target = ServerTarget - } - - if !strings.HasPrefix(text, "/") || text == "/" { - if target == ServerTarget { - return fmt.Errorf("select a room or use /msg ") - } - c.client.Cmd.Message(target, text) - c.emitLocalMessage("message", target, text) - return nil - } - - commandLine := strings.TrimSpace(strings.TrimPrefix(text, "/")) - if commandLine == "" { - return nil - } - fields := strings.Fields(commandLine) - cmd := strings.ToLower(fields[0]) - args := strings.TrimSpace(commandLine[len(fields[0]):]) - - switch cmd { - case "me", "action": - if target == ServerTarget { - return fmt.Errorf("/me requires an active room or private tab") - } - if args == "" { - return fmt.Errorf("/me requires an action") - } - c.client.Cmd.Action(target, args) - c.emitLocalMessage("action", target, args) - case "msg", "query": - recipient, body, ok := splitFirstArg(args) - if !ok || body == "" { - return fmt.Errorf("/msg requires a target and message") - } - c.client.Cmd.Message(recipient, body) - c.emitLocalMessage("message", recipient, body) - case "notice": - recipient, body, ok := splitFirstArg(args) - if !ok || body == "" { - return fmt.Errorf("/notice requires a target and message") - } - c.client.Cmd.Notice(recipient, body) - c.emitLocalMessage("notice", recipient, body) - case "join", "j": - room, key, ok := splitFirstArg(args) - if !ok { - return fmt.Errorf("/join requires a room") - } - if key != "" { - c.client.Cmd.JoinKey(room, key) - } else { - c.client.Cmd.Join(room) - } - case "part", "leave": - room, msg, _ := splitFirstArg(args) - if room == "" { - room = target - } - if room == "" || room == ServerTarget { - return fmt.Errorf("/part requires a room") - } - if msg != "" { - c.client.Cmd.PartMessage(room, msg) - } else { - c.client.Cmd.Part(room) - } - case "quit": - if args == "" { - args = "Alchementary Link disconnected" - } - c.client.Quit(args) - c.client.Close() - case "nick": - if args == "" { - return fmt.Errorf("/nick requires a nickname") - } - c.client.Cmd.Nick(strings.Fields(args)[0]) - case "topic": - room, topic, _ := splitFirstArg(args) - if room == "" { - room = target - } - if room == "" || room == ServerTarget { - return fmt.Errorf("/topic requires a room") - } - c.client.Cmd.Topic(room, topic) - case "list": - if args == "" { - c.client.Cmd.List() - } else { - c.client.Cmd.List(strings.Fields(args)...) - } - case "names": - room := args - if room == "" && target != ServerTarget { - room = target - } - if room == "" { - return c.client.Cmd.SendRaw("NAMES") - } - return c.client.Cmd.SendRaw("NAMES " + room) - case "whois": - if args == "" { - return fmt.Errorf("/whois requires a nickname") - } - c.client.Cmd.Whois(strings.Fields(args)...) - case "who": - if args == "" { - if target == ServerTarget { - return fmt.Errorf("/who requires a target") - } - args = target - } - c.client.Cmd.Who(strings.Fields(args)...) - case "raw", "quote": - if args == "" { - return fmt.Errorf("/raw requires an IRC command") - } - return c.client.Cmd.SendRawNoSplit(args) - case "help": - c.emit(Event{ - Type: "system", - Target: target, - Text: "Commands: /join, /part, /msg, /notice, /me, /nick, /topic, /list, /names, /whois, /who, /raw, /quit", - Timestamp: time.Now().UnixMilli(), - }) - default: - raw := strings.ToUpper(fields[0]) - if args != "" { - raw += " " + args - } - return c.client.Cmd.SendRawNoSplit(raw) - } - - return nil -} - -// Join requests a room join. -func (c *Client) Join(room string) error { - room = strings.TrimSpace(room) - if room == "" { - return fmt.Errorf("room is required") - } - c.client.Cmd.Join(room) - return nil -} - -// Part leaves a room. -func (c *Client) Part(room string) error { - room = strings.TrimSpace(room) - if room == "" { - return fmt.Errorf("room is required") - } - c.client.Cmd.Part(room) - return nil -} - -// ListRooms requests a server-side room list. Results arrive as events. -func (c *Client) ListRooms() error { - c.mu.Lock() - c.listRooms = nil - c.mu.Unlock() - c.client.Cmd.List() - return nil -} - -// Rooms returns currently joined rooms. -func (c *Client) Rooms() []RoomInfo { - if c.client == nil { - return nil - } - c.mu.Lock() - roomNames := append([]string{}, c.roomNames...) - c.mu.Unlock() - rooms := make([]RoomInfo, 0, len(roomNames)) - for _, roomName := range roomNames { - ch := c.client.LookupChannel(roomName) - if ch == nil { - rooms = append(rooms, RoomInfo{Name: roomName}) - continue - } - rooms = append(rooms, RoomInfo{ - Name: ch.Name, - Topic: ch.Topic, - Users: len(ch.UserList), - }) - } - sort.Slice(rooms, func(i, j int) bool { - return strings.ToLower(rooms[i].Name) < strings.ToLower(rooms[j].Name) - }) - return rooms -} - -// Users returns known users in a room. -func (c *Client) Users(room string) []string { - if c.client == nil { - return nil - } - ch := c.client.LookupChannel(room) - if ch == nil { - return []string{} - } - users := append([]string{}, ch.UserList...) - sort.Slice(users, func(i, j int) bool { - return strings.TrimLeft(users[i], "~&@%+") < strings.TrimLeft(users[j], "~&@%+") - }) - return users -} - -func (c *Client) registerHandlers() { - c.client.Handlers.Add(girc.PRIVMSG, c.handleMessage) - c.client.Handlers.Add(girc.NOTICE, c.handleNotice) - c.client.Handlers.Add(girc.JOIN, c.handleJoin) - c.client.Handlers.Add(girc.PART, c.handlePart) - c.client.Handlers.Add(girc.KICK, c.handleKick) - c.client.Handlers.Add(girc.QUIT, c.handleQuit) - c.client.Handlers.Add(girc.NICK, c.handleNick) - c.client.Handlers.Add(girc.TOPIC, c.handleTopic) - c.client.Handlers.Add(girc.RPL_TOPIC, c.handleTopic) - c.client.Handlers.Add(girc.RPL_NAMREPLY, c.handleNames) - c.client.Handlers.Add(girc.RPL_ENDOFNAMES, c.handleEndOfNames) - c.client.Handlers.Add(girc.RPL_LIST, c.handleList) - c.client.Handlers.Add(girc.RPL_LISTEND, c.handleListEnd) - c.client.Handlers.Add(girc.ERROR, func(_ *girc.Client, e girc.Event) { - c.emit(Event{Type: "error", Target: ServerTarget, Text: e.Last(), Timestamp: eventTime(e)}) - }) -} - -func (c *Client) handleMessage(gc *girc.Client, e girc.Event) { - if len(e.Params) < 2 || e.Source == nil { - return - } - target := e.Params[0] - if strings.EqualFold(target, gc.GetNick()) { - target = e.Source.Name - } - eventType := "message" - text := e.Last() - if e.IsAction() { - eventType = "action" - text = e.StripAction() - } - c.emit(Event{Type: eventType, Target: target, From: e.Source.Name, Text: text, Timestamp: eventTime(e)}) -} - -func (c *Client) handleNotice(gc *girc.Client, e girc.Event) { - target := ServerTarget - if len(e.Params) > 0 { - target = e.Params[0] - if target == "*" { - target = ServerTarget - } - if strings.EqualFold(target, gc.GetNick()) && e.Source != nil { - target = e.Source.Name - } - } - from := "" - if e.Source != nil { - from = e.Source.Name - } - c.emit(Event{Type: "notice", Target: target, From: from, Text: e.Last(), Timestamp: eventTime(e)}) -} - -func (c *Client) handleJoin(gc *girc.Client, e girc.Event) { - room := eventRoom(e) - if room == "" { - return - } - from := sourceName(e) - if strings.EqualFold(from, gc.GetNick()) { - c.rememberRoomMembership(room, true) - } - c.emit(Event{Type: "join", Target: room, From: from, Text: fmt.Sprintf("%s joined %s", from, room), Timestamp: eventTime(e)}) - c.emitRooms() - c.emitUsers(room) -} - -func (c *Client) handlePart(gc *girc.Client, e girc.Event) { - room := eventRoom(e) - if room == "" { - return - } - from := sourceName(e) - message := "" - if len(e.Params) > 1 { - message = strings.TrimSpace(e.Last()) - } - if strings.EqualFold(from, gc.GetNick()) { - c.rememberRoomMembership(room, false) - } - c.emit(Event{Type: "part", Target: room, From: from, Text: message, Timestamp: eventTime(e)}) - c.emitRooms() - c.emitUsers(room) -} - -func (c *Client) handleKick(gc *girc.Client, e girc.Event) { - if len(e.Params) < 2 { - return - } - room := e.Params[0] - kicked := e.Params[1] - if strings.EqualFold(kicked, gc.GetNick()) { - c.rememberRoomMembership(room, false) - } - c.emit(Event{ - Type: "kick", - Target: room, - From: sourceName(e), - Command: kicked, - Text: strings.TrimSpace(e.Last()), - Timestamp: eventTime(e), - }) - c.emitRooms() - c.emitUsers(room) -} - -func (c *Client) handleQuit(gc *girc.Client, e girc.Event) { - from := sourceName(e) - c.emit(Event{Type: "quit", Target: ServerTarget, From: from, Text: strings.TrimSpace(e.Last()), Timestamp: eventTime(e)}) - c.emitUsersForAll() -} - -func (c *Client) handleNick(gc *girc.Client, e girc.Event) { - from := sourceName(e) - newNick := e.Last() - c.emit(Event{Type: "nick", Target: ServerTarget, From: from, Text: newNick, Timestamp: eventTime(e)}) - c.emitUsersForAll() -} - -func (c *Client) handleTopic(gc *girc.Client, e girc.Event) { - room := "" - topic := e.Last() - if e.Command == girc.RPL_TOPIC && len(e.Params) >= 3 { - room = e.Params[1] - topic = e.Params[2] - } else if len(e.Params) >= 2 { - room = e.Params[0] - topic = e.Params[1] - } - if room == "" { - return - } - c.emit(Event{Type: "topic", Target: room, From: sourceName(e), Text: topic, Topic: topic, Timestamp: eventTime(e)}) - c.emitRooms() -} - -func (c *Client) handleNames(gc *girc.Client, e girc.Event) { - if len(e.Params) >= 4 { - c.emit(Event{Type: "users", Target: e.Params[2], Users: splitNames(e.Params[3]), Timestamp: eventTime(e)}) - } -} - -func (c *Client) handleEndOfNames(gc *girc.Client, e girc.Event) { - if len(e.Params) >= 2 { - c.emitUsers(e.Params[1]) - } -} - -func (c *Client) handleList(gc *girc.Client, e girc.Event) { - if len(e.Params) < 4 { - return - } - users, _ := strconv.Atoi(e.Params[2]) - room := RoomInfo{Name: e.Params[1], Users: users, Topic: e.Params[3]} - c.mu.Lock() - if len(c.listRooms) < maxRoomListEntries { - c.listRooms = append(c.listRooms, room) - } - c.mu.Unlock() -} - -func (c *Client) handleListEnd(gc *girc.Client, e girc.Event) { - c.mu.Lock() - rooms := append([]RoomInfo{}, c.listRooms...) - c.listRooms = nil - c.mu.Unlock() - sort.Slice(rooms, func(i, j int) bool { - if rooms[i].Users == rooms[j].Users { - return strings.ToLower(rooms[i].Name) < strings.ToLower(rooms[j].Name) - } - return rooms[i].Users > rooms[j].Users - }) - c.emit(Event{Type: "room-list", Target: ServerTarget, Rooms: rooms, Timestamp: eventTime(e)}) -} - -func (c *Client) identifyWithNickServ(gc *girc.Client) { - if !c.cfg.NickServIdentify || c.cfg.NickServPassword == "" { - return - } - if c.cfg.NickServAccount != "" { - gc.Cmd.Message("NickServ", fmt.Sprintf("IDENTIFY %s %s", c.cfg.NickServAccount, c.cfg.NickServPassword)) - } else { - gc.Cmd.Message("NickServ", "IDENTIFY "+c.cfg.NickServPassword) - } - c.emit(Event{Type: "system", Target: ServerTarget, Text: "Sent NickServ identify request", Timestamp: time.Now().UnixMilli()}) -} - -func (c *Client) newSSHDialer() (*sshDialer, error) { - if c.cfg.SSHHostKeyCallback == nil { - return nil, fmt.Errorf("SSH host key callback is required") - } - authMethods, err := c.sshAuthMethods() - if err != nil { - return nil, err - } - sshAddr := net.JoinHostPort(c.cfg.SSHHost, strconv.Itoa(c.cfg.SSHPort)) - sshConfig := &ssh.ClientConfig{ - User: c.cfg.SSHUsername, - Auth: authMethods, - HostKeyCallback: c.cfg.SSHHostKeyCallback, - Timeout: c.cfg.ConnectTimeout, - } - sshClient, err := ssh.Dial("tcp", sshAddr, sshConfig) - if err != nil { - return nil, fmt.Errorf("SSH connection failed: %w", err) - } - c.sshClient = sshClient - return &sshDialer{ - client: sshClient, - useTLS: c.cfg.UseTLS, - serverName: c.cfg.Server, - timeout: c.cfg.ConnectTimeout, - }, nil -} - -func (c *Client) sshAuthMethods() ([]ssh.AuthMethod, error) { - var authMethods []ssh.AuthMethod - if c.cfg.SSHAgentAuth && c.cfg.SSHAgentConn != nil { - c.agentConn = c.cfg.SSHAgentConn - ag := agent.NewClient(c.cfg.SSHAgentConn) - signers, err := ag.Signers() - if err == nil && len(signers) > 0 { - authMethods = append(authMethods, ssh.PublicKeys(signers...)) - } - } - if len(c.cfg.SSHPrivateKey) > 0 { - var signer ssh.Signer - var err error - if c.cfg.SSHPassphrase != "" { - signer, err = ssh.ParsePrivateKeyWithPassphrase(c.cfg.SSHPrivateKey, []byte(c.cfg.SSHPassphrase)) - } else { - signer, err = ssh.ParsePrivateKey(c.cfg.SSHPrivateKey) - } - if err != nil { - return nil, fmt.Errorf("parsing SSH private key: %w", err) - } - authMethods = append(authMethods, ssh.PublicKeys(signer)) - } - if c.cfg.SSHPassword != "" { - authMethods = append(authMethods, ssh.Password(c.cfg.SSHPassword)) - } - if len(authMethods) == 0 { - return nil, fmt.Errorf("no SSH authentication method available") - } - return authMethods, nil -} - -func (c *Client) emitRooms() { - rooms := c.Rooms() - c.emit(Event{Type: "rooms", Target: ServerTarget, Rooms: rooms, Timestamp: time.Now().UnixMilli()}) -} - -func (c *Client) rememberRoomMembership(room string, joined bool) { - room = strings.TrimSpace(room) - if room == "" { - return - } - - c.mu.Lock() - position := -1 - changed := false - for i, existing := range c.roomNames { - if strings.EqualFold(existing, room) { - position = i - break - } - } - if joined && position == -1 { - c.roomNames = append(c.roomNames, room) - changed = true - } - if !joined && position >= 0 { - c.roomNames = append(c.roomNames[:position], c.roomNames[position+1:]...) - changed = true - } - names := append([]string{}, c.roomNames...) - c.mu.Unlock() - - if changed && c.cfg.OnRoomsChanged != nil { - c.cfg.OnRoomsChanged(names) - } -} - -func (c *Client) emitUsers(room string) { - if room == "" { - return - } - c.emit(Event{Type: "users", Target: room, Users: c.Users(room), Timestamp: time.Now().UnixMilli()}) -} - -func (c *Client) emitUsersForAll() { - for _, room := range c.Rooms() { - c.emitUsers(room.Name) - } -} - -func (c *Client) emitLocalMessage(eventType string, target string, text string) { - c.emit(Event{Type: eventType, Target: target, From: c.client.GetNick(), Text: text, Timestamp: time.Now().UnixMilli()}) -} - -func (c *Client) emit(event Event) { - if event.Timestamp == 0 { - event.Timestamp = time.Now().UnixMilli() - } - if event.Target == "" { - event.Target = ServerTarget - } - if c.cfg.OnEvent != nil { - c.cfg.OnEvent(event) - } -} - -type sshDialer struct { - client *ssh.Client - useTLS bool - serverName string - timeout time.Duration -} - -func (d *sshDialer) Dial(network, address string) (net.Conn, error) { - conn, err := d.client.Dial(network, address) - if err != nil { - return nil, err - } - if !d.useTLS { - return conn, nil - } - tlsConn := tls.Client(conn, &tls.Config{ - MinVersion: tls.VersionTLS12, - ServerName: d.serverName, - }) - if d.timeout > 0 { - _ = tlsConn.SetDeadline(time.Now().Add(d.timeout)) - } - if err := tlsConn.Handshake(); err != nil { - _ = conn.Close() - return nil, fmt.Errorf("IRC TLS handshake failed: %w", err) - } - _ = tlsConn.SetDeadline(time.Time{}) - return tlsConn, nil -} - -func eventTime(e girc.Event) int64 { - if e.Timestamp.IsZero() { - return time.Now().UnixMilli() - } - return e.Timestamp.UnixMilli() -} - -func eventRoom(e girc.Event) string { - if len(e.Params) == 0 { - return "" - } - return e.Params[0] -} - -func sourceName(e girc.Event) string { - if e.Source == nil { - return "" - } - return e.Source.Name -} - -func splitFirstArg(input string) (first string, rest string, ok bool) { - fields := strings.Fields(input) - if len(fields) == 0 { - return "", "", false - } - first = fields[0] - rest = strings.TrimSpace(input[len(first):]) - return first, rest, true -} - -func splitNames(input string) []string { - fields := strings.Fields(input) - names := make([]string, 0, len(fields)) - for _, name := range fields { - name = strings.TrimSpace(name) - if name != "" { - names = append(names, name) - } - } - return names -} diff --git a/internal/ircclient/ircclient_test.go b/internal/ircclient/ircclient_test.go deleted file mode 100644 index d050cd3..0000000 --- a/internal/ircclient/ircclient_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package ircclient - -import ( - "strings" - "testing" -) - -func TestRememberRoomMembershipTracksConfirmedChannels(t *testing.T) { - var updates []string - client := &Client{cfg: Config{ - OnRoomsChanged: func(rooms []string) { - updates = append(updates, strings.Join(rooms, ",")) - }, - }} - - client.rememberRoomMembership("#One", true) - client.rememberRoomMembership("#two", true) - client.rememberRoomMembership("#one", true) - client.rememberRoomMembership("#missing", false) - client.rememberRoomMembership("#ONE", false) - - if got, want := strings.Join(updates, "|"), "#One|#One,#two|#two"; got != want { - t.Fatalf("room updates = %q, want %q", got, want) - } - if got := strings.Join(client.roomNames, ","); got != "#two" { - t.Fatalf("remembered rooms = %q, want %q", got, "#two") - } -} diff --git a/internal/knownhosts/knownhosts.go b/internal/knownhosts/knownhosts.go deleted file mode 100644 index 928dbe1..0000000 --- a/internal/knownhosts/knownhosts.go +++ /dev/null @@ -1,98 +0,0 @@ -// Package knownhosts manages SSH known host entries, providing verification, -// storage, and management of host key fingerprints. -package knownhosts - -import ( - "fmt" - "time" - - "github.com/andydixon/link/internal/models" - "github.com/google/uuid" -) - -// KnownHostStorage is the persistence interface used by the Store. -type KnownHostStorage interface { - GetKnownHost(host string, port int) (*models.KnownHost, error) - SaveKnownHost(kh *models.KnownHost) error - ListKnownHosts() ([]*models.KnownHost, error) - DeleteKnownHost(id string) error -} - -// Store manages SSH known host entries. It provides verification against -// previously accepted host keys and allows new keys to be trusted. -type Store struct { - storage KnownHostStorage -} - -// NewStore creates a Store backed by the given storage implementation. -func NewStore(storage KnownHostStorage) *Store { - return &Store{storage: storage} -} - -// Verify checks whether the presented host key matches a previously accepted -// entry. Returns true if the fingerprint matches, false if there is no record, -// and an error if the key has changed (possible MITM). -func (s *Store) Verify(host string, port int, keyType string, fingerprint string, publicKey string) (bool, error) { - existing, err := s.storage.GetKnownHost(host, port) - if err != nil { - return false, fmt.Errorf("knownhosts: lookup failed: %w", err) - } - if existing == nil { - // No prior record — the caller should prompt the user. - return false, nil - } - - if existing.Fingerprint == fingerprint { - // Key matches — update the last-seen timestamp. - existing.LastSeen = time.Now() - _ = s.storage.SaveKnownHost(existing) - return true, nil - } - - // Fingerprint mismatch — the host key has changed. - return false, fmt.Errorf( - "knownhosts: host key for %s:%d has changed (was %s %s, now %s %s); "+ - "this could indicate a man-in-the-middle attack", - host, port, - existing.KeyType, existing.Fingerprint, - keyType, fingerprint, - ) -} - -// Accept stores a new host key as trusted. If an entry already exists for the -// host/port combination it is overwritten. -func (s *Store) Accept(host string, port int, keyType string, fingerprint string, publicKey string) error { - now := time.Now() - kh := &models.KnownHost{ - ID: uuid.New().String(), - Host: host, - Port: port, - KeyType: keyType, - Fingerprint: fingerprint, - PublicKey: publicKey, - FirstSeen: now, - LastSeen: now, - Trusted: true, - } - if err := s.storage.SaveKnownHost(kh); err != nil { - return fmt.Errorf("knownhosts: failed to accept key: %w", err) - } - return nil -} - -// List returns all stored known host entries. -func (s *Store) List() ([]*models.KnownHost, error) { - entries, err := s.storage.ListKnownHosts() - if err != nil { - return nil, fmt.Errorf("knownhosts: failed to list entries: %w", err) - } - return entries, nil -} - -// Delete removes a known host entry by ID. -func (s *Store) Delete(id string) error { - if err := s.storage.DeleteKnownHost(id); err != nil { - return fmt.Errorf("knownhosts: failed to delete entry: %w", err) - } - return nil -} diff --git a/internal/knownhosts/knownhosts_test.go b/internal/knownhosts/knownhosts_test.go deleted file mode 100644 index bf018d7..0000000 --- a/internal/knownhosts/knownhosts_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package knownhosts - -import ( - "testing" - - "github.com/andydixon/link/internal/models" -) - -// --------------------------------------------------------------------------- -// Mock storage implementation -// --------------------------------------------------------------------------- - -type mockStorage struct { - hosts map[string]*models.KnownHost // keyed by "host:port" -} - -func newMockStorage() *mockStorage { - return &mockStorage{hosts: make(map[string]*models.KnownHost)} -} - -func (m *mockStorage) key(host string, port int) string { - return host + ":" + string(rune(port)) -} - -func (m *mockStorage) GetKnownHost(host string, port int) (*models.KnownHost, error) { - kh, ok := m.hosts[m.key(host, port)] - if !ok { - return nil, nil - } - return kh, nil -} - -func (m *mockStorage) SaveKnownHost(kh *models.KnownHost) error { - m.hosts[m.key(kh.Host, kh.Port)] = kh - return nil -} - -func (m *mockStorage) ListKnownHosts() ([]*models.KnownHost, error) { - result := make([]*models.KnownHost, 0, len(m.hosts)) - for _, kh := range m.hosts { - result = append(result, kh) - } - return result, nil -} - -func (m *mockStorage) DeleteKnownHost(id string) error { - for k, kh := range m.hosts { - if kh.ID == id { - delete(m.hosts, k) - return nil - } - } - return nil -} - -// --------------------------------------------------------------------------- -// Verify unknown host returns false (no error) -// --------------------------------------------------------------------------- - -func TestVerifyUnknownHost(t *testing.T) { - store := NewStore(newMockStorage()) - - trusted, err := store.Verify("unknown.example.com", 22, "ssh-ed25519", "SHA256:abc", "pubkey") - if err != nil { - t.Fatalf("Verify() unexpected error = %v", err) - } - if trusted { - t.Error("expected trusted = false for unknown host") - } -} - -// --------------------------------------------------------------------------- -// Accept then verify returns true -// --------------------------------------------------------------------------- - -func TestAcceptThenVerify(t *testing.T) { - store := NewStore(newMockStorage()) - - if err := store.Accept("server.example.com", 22, "ssh-ed25519", "SHA256:abc", "pubkey"); err != nil { - t.Fatalf("Accept() error = %v", err) - } - - trusted, err := store.Verify("server.example.com", 22, "ssh-ed25519", "SHA256:abc", "pubkey") - if err != nil { - t.Fatalf("Verify() error = %v", err) - } - if !trusted { - t.Error("expected trusted = true after Accept()") - } -} - -// --------------------------------------------------------------------------- -// Changed fingerprint returns error -// --------------------------------------------------------------------------- - -func TestVerifyChangedFingerprint(t *testing.T) { - store := NewStore(newMockStorage()) - - // Accept with fingerprint A. - if err := store.Accept("host.local", 22, "ssh-rsa", "SHA256:original", "pubkeyA"); err != nil { - t.Fatalf("Accept() error = %v", err) - } - - // Verify with fingerprint B — should return an error indicating a change. - trusted, err := store.Verify("host.local", 22, "ssh-rsa", "SHA256:changed", "pubkeyB") - if err == nil { - t.Fatal("expected error for changed fingerprint") - } - if trusted { - t.Error("expected trusted = false for changed fingerprint") - } -} - -// --------------------------------------------------------------------------- -// List and Delete operations -// --------------------------------------------------------------------------- - -func TestListAndDelete(t *testing.T) { - mock := newMockStorage() - store := NewStore(mock) - - _ = store.Accept("host-a.local", 22, "ssh-ed25519", "fp-a", "pk-a") - _ = store.Accept("host-b.local", 22, "ssh-rsa", "fp-b", "pk-b") - - entries, err := store.List() - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(entries) != 2 { - t.Fatalf("expected 2 entries, got %d", len(entries)) - } - - // Delete the first entry. - if err := store.Delete(entries[0].ID); err != nil { - t.Fatalf("Delete() error = %v", err) - } - - entries, _ = store.List() - if len(entries) != 1 { - t.Errorf("expected 1 entry after delete, got %d", len(entries)) - } -} diff --git a/internal/leviathan/analysis/carve.go b/internal/leviathan/analysis/carve.go deleted file mode 100644 index 31e50c7..0000000 --- a/internal/leviathan/analysis/carve.go +++ /dev/null @@ -1,876 +0,0 @@ -package analysis - -import ( - "bytes" - "compress/gzip" - "context" - "encoding/binary" - "io" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// Signature describes a carveable object. `validate` is run against -// the candidate start and returns the object length if it is a real -// object, or 0 to reject. `trailer`, if present, must appear before -// the object ends (for simple trailer-based terminations). -type Signature struct { - Kind string - Magic []byte - Extension string - // Anchor is how many bytes into the object the magic appears. It is 0 for - // almost every format (magic at offset 0), but a few — notably ISO-BMFF - // (MP4/MOV), whose `ftyp` magic follows a 4-byte box size — carry it. - Anchor int64 - Validate func(d *document.Document, start, max int64) (length int64, confidence float64) -} - -// carveSignatures is the phase-1 signature set. We stay conservative -// and only declare a carve when we can determine length reliably. -var carveSignatures = []Signature{ - {Kind: "png", Magic: []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}, Extension: "png", Validate: validatePNG}, - {Kind: "jpeg", Magic: []byte{0xff, 0xd8, 0xff}, Extension: "jpg", Validate: validateJPEG}, - {Kind: "gif87a", Magic: []byte("GIF87a"), Extension: "gif", Validate: validateGIF}, - {Kind: "gif89a", Magic: []byte("GIF89a"), Extension: "gif", Validate: validateGIF}, - {Kind: "zip", Magic: []byte{'P', 'K', 0x03, 0x04}, Extension: "zip", Validate: validateZIP}, - {Kind: "pdf", Magic: []byte("%PDF-"), Extension: "pdf", Validate: validatePDF}, - {Kind: "elf", Magic: []byte{0x7f, 'E', 'L', 'F'}, Extension: "elf", Validate: validateELF}, - {Kind: "mz", Magic: []byte{'M', 'Z'}, Extension: "exe", Validate: validatePE}, - {Kind: "gzip", Magic: []byte{0x1f, 0x8b, 0x08}, Extension: "gz", Validate: validateGZIP}, - {Kind: "bzip2", Magic: []byte{'B', 'Z', 'h'}, Extension: "bz2", Validate: validateBzip2}, - {Kind: "sevenz", Magic: []byte{'7', 'z', 0xbc, 0xaf, 0x27, 0x1c}, Extension: "7z", Validate: validate7z}, - {Kind: "rar4", Magic: []byte{'R', 'a', 'r', '!', 0x1a, 0x07, 0x00}, Extension: "rar", Validate: validateRAR}, - {Kind: "rar5", Magic: []byte{'R', 'a', 'r', '!', 0x1a, 0x07, 0x01, 0x00}, Extension: "rar", Validate: validateRAR}, - {Kind: "wav", Magic: []byte("RIFF"), Extension: "wav", Validate: validateRIFF}, - {Kind: "mach-o-le32", Magic: []byte{0xce, 0xfa, 0xed, 0xfe}, Extension: "macho", Validate: validateMachO}, - {Kind: "mach-o-le64", Magic: []byte{0xcf, 0xfa, 0xed, 0xfe}, Extension: "macho", Validate: validateMachO}, - {Kind: "mach-o-be32", Magic: []byte{0xfe, 0xed, 0xfa, 0xce}, Extension: "macho", Validate: validateMachO}, - {Kind: "mach-o-be64", Magic: []byte{0xfe, 0xed, 0xfa, 0xcf}, Extension: "macho", Validate: validateMachO}, - {Kind: "sqlite", Magic: []byte("SQLite format 3\x00"), Extension: "sqlite", Validate: validateSQLite}, - {Kind: "mp3", Magic: []byte("ID3"), Extension: "mp3", Validate: validateID3}, - {Kind: "ogg", Magic: []byte("OggS"), Extension: "ogg", Validate: validateOgg}, - {Kind: "matroska", Magic: []byte{0x1a, 0x45, 0xdf, 0xa3}, Extension: "mkv", Validate: validateMatroska}, - {Kind: "flac", Magic: []byte("fLaC"), Extension: "flac", Validate: validateFLAC}, - {Kind: "bmp", Magic: []byte{'B', 'M'}, Extension: "bmp", Validate: validateBMP}, - {Kind: "mp4", Magic: []byte("ftyp"), Anchor: 4, Extension: "mp4", Validate: validateMP4}, -} - -// Carve scans the document for known signatures and returns a list of -// discovered objects with offset, length, kind, and confidence. -func Carve(d *document.Document, start, length int64, maxHits int) ([]map[string]any, error) { - return CarveContext(context.Background(), d, start, length, maxHits) -} - -func CarveContext(ctx context.Context, d *document.Document, start, length int64, maxHits int) ([]map[string]any, error) { - if length < 0 { - length = d.Size() - start - } - if maxHits <= 0 { - maxHits = 10_000 - } - results := make([]map[string]any, 0, 64) - - // Build a flat list of (magicBytes, sigIndex) for a simple first-pass scan. - // Phase 1 uses per-signature naive scan chunks. - for idx, sig := range carveSignatures { - if err := ctx.Err(); err != nil { - return nil, err - } - hits, err := SearchBytesContext(ctx, d, start, length, sig.Magic, maxHits) - if err != nil { - return nil, err - } - for _, h := range hits { - if err := ctx.Err(); err != nil { - return nil, err - } - // The object begins Anchor bytes before the magic (0 for most formats). - objStart := h.Offset - sig.Anchor - if objStart < start { - continue - } - ln, conf := int64(0), 0.4 - if sig.Validate != nil { - ln, conf = sig.Validate(d, objStart, start+length) - } - if ln <= 0 { - // record low-confidence magic hit only for kinds where length is not determinable - if sig.Kind == "mz" || sig.Kind == "elf" || sig.Kind == "mach-o-le32" || - sig.Kind == "mach-o-le64" || sig.Kind == "mach-o-be32" || sig.Kind == "mach-o-be64" || - sig.Kind == "sqlite" || sig.Kind == "mp3" || sig.Kind == "matroska" || - sig.Kind == "flac" || sig.Kind == "bzip2" || sig.Kind == "rar4" || sig.Kind == "rar5" { - results = append(results, map[string]any{ - "kind": sig.Kind, - "extension": sig.Extension, - "offset": objStart, - "length": int64(0), - "confidence": 0.3, - "status": "magic-only", - "sigIndex": idx, - }) - } - continue - } - results = append(results, map[string]any{ - "kind": sig.Kind, - "extension": sig.Extension, - "offset": objStart, - "length": ln, - "confidence": conf, - "status": "valid", - "sigIndex": idx, - }) - if len(results) >= maxHits { - return results, nil - } - } - } - return results, nil -} - -// --- validators --- - -func validatePNG(d *document.Document, start, max int64) (int64, float64) { - // Walk chunks until IEND - off := start + 8 - for off < max { - hdr, err := d.Range(off, 8) - if err != nil || len(hdr) < 8 { - return 0, 0 - } - size := int64(binary.BigEndian.Uint32(hdr[0:4])) - typ := string(hdr[4:8]) - // Skip payload + CRC (4 bytes) - off += 8 + size + 4 - if typ == "IEND" { - return off - start, 0.95 - } - if off > max { - return 0, 0 - } - } - return 0, 0 -} - -func validateJPEG(d *document.Document, start, max int64) (int64, float64) { - // Walk segments until EOI (0xFFD9) - off := start + 2 - for off < max-1 { - buf, err := d.Range(off, 2) - if err != nil || len(buf) < 2 { - return 0, 0 - } - if buf[0] != 0xff { - return 0, 0 - } - marker := buf[1] - off += 2 - // No-length markers - if marker == 0xd9 { // EOI - return off - start, 0.95 - } - if marker == 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker == 0x00 { - continue - } - // Read length - lb, err := d.Range(off, 2) - if err != nil || len(lb) < 2 { - return 0, 0 - } - ln := int64(binary.BigEndian.Uint16(lb)) - off += ln - if marker == 0xda { // SOS, scan until next 0xff!=0 - for off < max { - b, err := d.Range(off, 2) - if err != nil || len(b) < 2 { - return 0, 0 - } - if b[0] == 0xff && b[1] != 0x00 && !(b[1] >= 0xd0 && b[1] <= 0xd7) { - break - } - off++ - } - } - } - return 0, 0 -} - -func validateGIF(d *document.Document, start, max int64) (int64, float64) { - // Minimal: locate trailer 0x3B - buf, err := d.Range(start, min64(max-start, 1<<20)) - if err != nil { - return 0, 0 - } - idx := bytes.IndexByte(buf[13:], 0x3b) - if idx < 0 { - return 0, 0 - } - return int64(idx + 13 + 1), 0.7 -} - -func validateZIP(d *document.Document, start, max int64) (int64, float64) { - // Search forward for End of Central Directory record 0x06054b50. - const sig = "\x50\x4b\x05\x06" - limit := min64(max-start, 64<<20) - buf, err := d.Range(start, limit) - if err != nil { - return 0, 0 - } - idx := bytes.LastIndex(buf, []byte(sig)) - if idx < 0 { - return 0, 0 - } - // EOCD record is at least 22 bytes; read comment length. - if idx+22 > len(buf) { - return 0, 0 - } - commentLen := int(binary.LittleEndian.Uint16(buf[idx+20 : idx+22])) - end := int64(idx + 22 + commentLen) - return end, 0.9 -} - -func validatePDF(d *document.Document, start, max int64) (int64, float64) { - limit := min64(max-start, 64<<20) - buf, err := d.Range(start, limit) - if err != nil { - return 0, 0 - } - idx := bytes.LastIndex(buf, []byte("%%EOF")) - if idx < 0 { - return 0, 0 - } - return int64(idx + 5), 0.85 -} - -func validateELF(d *document.Document, start, max int64) (int64, float64) { - hdr, err := d.Range(start, 64) - if err != nil || len(hdr) < 52 { - return 0, 0 - } - if hdr[4] != 1 && hdr[4] != 2 { - return 0, 0 - } - is64 := hdr[4] == 2 - little := hdr[5] == 1 - var end int64 - if is64 { - if len(hdr) < 64 { - return 0, 0 - } - // e_shoff at 40, e_shentsize at 58, e_shnum at 60 - off := binary.LittleEndian.Uint64(hdr[40:48]) - entSz := uint64(binary.LittleEndian.Uint16(hdr[58:60])) - num := uint64(binary.LittleEndian.Uint16(hdr[60:62])) - if !little { - off = binary.BigEndian.Uint64(hdr[40:48]) - entSz = uint64(binary.BigEndian.Uint16(hdr[58:60])) - num = uint64(binary.BigEndian.Uint16(hdr[60:62])) - } - end = int64(off + entSz*num) - } else { - off := binary.LittleEndian.Uint32(hdr[32:36]) - entSz := uint32(binary.LittleEndian.Uint16(hdr[46:48])) - num := uint32(binary.LittleEndian.Uint16(hdr[48:50])) - if !little { - off = binary.BigEndian.Uint32(hdr[32:36]) - entSz = uint32(binary.BigEndian.Uint16(hdr[46:48])) - num = uint32(binary.BigEndian.Uint16(hdr[48:50])) - } - end = int64(off) + int64(entSz)*int64(num) - } - if end <= 0 || start+end > max { - return 0, 0.5 - } - return end, 0.9 -} - -func validatePE(d *document.Document, start, max int64) (int64, float64) { - // e_lfanew at offset 0x3C points to PE header - hdr, err := d.Range(start, 64) - if err != nil || len(hdr) < 64 { - return 0, 0 - } - peOff := int64(binary.LittleEndian.Uint32(hdr[0x3c:0x40])) - if peOff < 0x40 || start+peOff+4 > max { - return 0, 0 - } - sig, err := d.Range(start+peOff, 4) - if err != nil || string(sig) != "PE\x00\x00" { - return 0, 0.3 - } - coff, err := d.Range(start+peOff+4, 20) - if err != nil || len(coff) < 20 { - return 0, 0.4 - } - numSections := int(binary.LittleEndian.Uint16(coff[2:4])) - sizeOpt := int64(binary.LittleEndian.Uint16(coff[16:18])) - if numSections <= 0 || numSections > 96 || sizeOpt < 0 || sizeOpt > 4096 { - return 0, 0.5 - } - sectOff := start + peOff + 24 + sizeOpt - end := peOff + 24 + sizeOpt + int64(numSections*40) - for i := 0; i < numSections; i++ { - sh, err := d.Range(sectOff+int64(i*40), 40) - if err != nil || len(sh) < 40 { - return 0, 0.5 - } - rawSize := int64(binary.LittleEndian.Uint32(sh[16:20])) - rawPtr := int64(binary.LittleEndian.Uint32(sh[20:24])) - if rawSize < 0 || rawPtr < 0 { - return 0, 0.5 - } - if rawSize > 0 && rawPtr+rawSize > end { - end = rawPtr + rawSize - } - } - if end <= peOff || start+end > max { - return 0, 0.5 - } - return end, 0.85 -} - -func validateGZIP(d *document.Document, start, max int64) (int64, float64) { - limit := min64(max-start, 64<<20) - buf, err := d.Range(start, limit) - if err != nil || len(buf) < 18 { - return 0, 0 - } - br := bytes.NewReader(buf) - zr, err := gzip.NewReader(br) - if err != nil { - return 0, 0 - } - zr.Multistream(false) - if _, err := io.Copy(io.Discard, io.LimitReader(zr, 512<<20)); err != nil { - _ = zr.Close() - return 0, 0 - } - if err := zr.Close(); err != nil { - return 0, 0 - } - consumed := int64(len(buf)) - int64(br.Len()) - if consumed <= 10 || start+consumed > max { - return 0, 0 - } - return consumed, 0.9 -} - -// validateBzip2 locates the bit-aligned end-of-stream marker (0x177245385090, -// the "sqrt(pi)" magic) and returns the stream length including the trailing -// 32-bit combined CRC. bzip2 carries no length in its header, so this is the -// only reliable way to bound the object. -func validateBzip2(d *document.Document, start, max int64) (int64, float64) { - if start+4 > max { - return 0, 0.4 - } - hdr, err := d.Range(start, 4) - if err != nil || len(hdr) < 4 || hdr[0] != 'B' || hdr[1] != 'Z' || hdr[2] != 'h' || hdr[3] < '1' || hdr[3] > '9' { - return 0, 0 - } - limit := min64(max-start, 64<<20) - buf, err := d.Range(start, limit) - if err != nil || len(buf) < 14 { - return 0, 0.4 - } - const eos uint64 = 0x177245385090 - const mask48 uint64 = (1 << 48) - 1 - var w uint64 - for i := 0; i < len(buf); i++ { - w = (w << 8) | uint64(buf[i]) - // Check all eight bit alignments ending at or before this byte. - for s := 0; s < 8; s++ { - if (w>>uint(s))&mask48 == eos { - endBit := (int64(i)+1)*8 - int64(s) + 32 // include stream CRC - endByte := (endBit + 7) / 8 - if endByte > 14 && start+endByte <= max { - return endByte, 0.85 - } - } - } - } - return 0, 0.4 -} - -func validate7z(d *document.Document, start, max int64) (int64, float64) { - hdr, err := d.Range(start, 32) - if err != nil || len(hdr) < 32 { - return 0, 0 - } - nextHdrOff := binary.LittleEndian.Uint64(hdr[12:20]) - nextHdrSz := binary.LittleEndian.Uint64(hdr[20:28]) - end := int64(32 + nextHdrOff + nextHdrSz) - if end <= 0 || start+end > max { - return 0, 0.5 - } - return end, 0.85 -} - -// validateRAR walks the block headers of a RAR4 or RAR5 archive to the -// end-of-archive marker. The two formats differ structurally, so the version -// is detected from the signature byte at offset 6. -func validateRAR(d *document.Document, start, max int64) (int64, float64) { - sig, err := d.Range(start, 8) - if err != nil || len(sig) < 8 { - return 0, 0 - } - if sig[6] == 0x01 { - return validateRAR5(d, start, max) - } - return validateRAR4(d, start, max) -} - -func validateRAR4(d *document.Document, start, max int64) (int64, float64) { - pos := start + 7 // 7-byte signature - for n := 0; n < 100000; n++ { - h, err := d.Range(pos, 11) - if err != nil || len(h) < 7 { - return 0, 0.4 - } - flags := binary.LittleEndian.Uint16(h[3:5]) - headSize := int64(binary.LittleEndian.Uint16(h[5:7])) - if headSize < 7 { - return 0, 0.4 - } - blockSize := headSize - if flags&0x8000 != 0 { // ADD_SIZE present - if len(h) < 11 { - return 0, 0.4 - } - blockSize += int64(binary.LittleEndian.Uint32(h[7:11])) - } - pos += blockSize - if h[2] == 0x7b { // terminator block - if pos > start && pos <= max { - return pos - start, 0.85 - } - return 0, 0.4 - } - if pos > max { - return 0, 0.4 - } - } - return 0, 0.4 -} - -func validateRAR5(d *document.Document, start, max int64) (int64, float64) { - pos := start + 8 // 8-byte signature - for n := 0; n < 100000; n++ { - // Each block: CRC32(4) + vint(header_size) + header_size bytes [+ data]. - h, err := d.Range(pos, 4+11) - if err != nil || len(h) < 5 { - return 0, 0.4 - } - headerSize, vlen := rar5Vint(h, 4) - if vlen == 0 || headerSize == 0 { - return 0, 0.4 - } - hdrStart := pos + 4 + int64(vlen) - body, err := d.Range(hdrStart, int64(headerSize)) - if err != nil || int64(len(body)) < int64(headerSize) { - return 0, 0.4 - } - htype, t1 := rar5Vint(body, 0) - hflags, t2 := rar5Vint(body, t1) - p := t1 + t2 - var dataSize uint64 - if hflags&0x0001 != 0 { // extra area present - _, te := rar5Vint(body, p) - p += te - } - if hflags&0x0002 != 0 { // data area present - dataSize, _ = rar5Vint(body, p) - } - pos = hdrStart + int64(headerSize) + int64(dataSize) - if htype == 5 { // end-of-archive header - if pos > start && pos <= max { - return pos - start, 0.85 - } - return 0, 0.4 - } - if pos > max { - return 0, 0.4 - } - } - return 0, 0.4 -} - -// rar5Vint decodes a RAR5 little-endian base-128 variable integer. -func rar5Vint(buf []byte, pos int) (uint64, int) { - var v uint64 - shift := uint(0) - for n := 0; pos+n < len(buf) && n < 10; n++ { - b := buf[pos+n] - v |= uint64(b&0x7f) << shift - if b&0x80 == 0 { - return v, n + 1 - } - shift += 7 - } - return 0, 0 -} - -func validateRIFF(d *document.Document, start, max int64) (int64, float64) { - hdr, err := d.Range(start, 12) - if err != nil || len(hdr) < 12 { - return 0, 0 - } - size := int64(binary.LittleEndian.Uint32(hdr[4:8])) - return size + 8, 0.9 -} - -func validateMachO(d *document.Document, start, max int64) (int64, float64) { - hdr, err := d.Range(start, 32) - if err != nil || len(hdr) < 28 { - return 0, 0 - } - var ( - bo binary.ByteOrder - is64 bool - hdrSize int64 = 28 - ) - switch binary.BigEndian.Uint32(hdr[0:4]) { - case 0xfeedface: - bo = binary.BigEndian - case 0xfeedfacf: - bo = binary.BigEndian - is64 = true - hdrSize = 32 - case 0xcefaedfe: - bo = binary.LittleEndian - case 0xcffaedfe: - bo = binary.LittleEndian - is64 = true - hdrSize = 32 - default: - return 0, 0 - } - if is64 && len(hdr) < 32 { - return 0, 0 - } - ncmds := bo.Uint32(hdr[16:20]) - sizeofcmds := bo.Uint32(hdr[20:24]) - if ncmds == 0 || ncmds > 10000 || sizeofcmds > 128<<20 { - return 0, 0 - } - cmdBytes, err := d.Range(start+hdrSize, int64(sizeofcmds)) - if err != nil || len(cmdBytes) < int(sizeofcmds) { - return 0, 0 - } - end := hdrSize + int64(sizeofcmds) - off := 0 - for i := uint32(0); i < ncmds; i++ { - if off+8 > len(cmdBytes) { - return 0, 0 - } - cmd := bo.Uint32(cmdBytes[off : off+4]) - cmdSize := int(bo.Uint32(cmdBytes[off+4 : off+8])) - if cmdSize < 8 || off+cmdSize > len(cmdBytes) { - return 0, 0 - } - switch cmd { - case 0x1: // LC_SEGMENT - if cmdSize >= 56 { - fileOff := int64(bo.Uint32(cmdBytes[off+32 : off+36])) - fileSize := int64(bo.Uint32(cmdBytes[off+36 : off+40])) - if fileOff >= 0 && fileSize >= 0 && fileOff+fileSize > end { - end = fileOff + fileSize - } - } - case 0x19: // LC_SEGMENT_64 - if cmdSize >= 72 { - fileOff := int64(bo.Uint64(cmdBytes[off+40 : off+48])) - fileSize := int64(bo.Uint64(cmdBytes[off+48 : off+56])) - if fileOff >= 0 && fileSize >= 0 && fileOff+fileSize > end { - end = fileOff + fileSize - } - } - } - off += cmdSize - } - if end <= hdrSize || start+end > max { - return 0, 0 - } - return end, 0.85 -} - -func validateID3(d *document.Document, start, max int64) (int64, float64) { - // ID3v2 header: "ID3" + ver(2) + flags(1) + size(4, synchsafe). - hdr, err := d.Range(start, 10) - if err != nil || len(hdr) < 10 { - return 0, 0.3 - } - if hdr[3] == 0xff || hdr[4] == 0xff { - return 0, 0.3 - } - // Synchsafe 28-bit size (7 bits per byte). - sz := (int64(hdr[6]&0x7f) << 21) | (int64(hdr[7]&0x7f) << 14) | - (int64(hdr[8]&0x7f) << 7) | int64(hdr[9]&0x7f) - if sz < 0 || sz > 32*1024*1024 { - return 0, 0.4 - } - // Skip the ID3v2 tag (header + synchsafe body, plus a 10-byte footer if the - // footer-present flag is set), then walk MPEG audio frames to the end. - audioStart := start + 10 + sz - if hdr[5]&0x10 != 0 { - audioStart += 10 - } - if end := walkMP3Frames(d, audioStart, max); end > audioStart { - return end - start, 0.85 - } - return 0, 0.7 -} - -// walkMP3Frames follows MPEG-1/2/2.5 Layer I/II/III frame headers from start -// until a byte that is not a valid frame sync, returning the offset just past -// the last frame. APE/Lyrics3 trailers are not stripped — the audio extent is -// what matters for carving. -func walkMP3Frames(d *document.Document, start, max int64) int64 { - pos := start - frames := 0 - for pos+4 <= max && frames < 5_000_000 { - h, err := d.Range(pos, 4) - if err != nil || len(h) < 4 { - break - } - ln := mp3FrameLen(h) - if ln <= 0 { - break - } - pos += ln - frames++ - } - if frames == 0 { - return 0 - } - if pos > max { - return max - } - return pos -} - -var mp3BitrateV1 = [16]int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0} -var mp3BitrateV2 = [16]int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0} -var mp3SampleRate = map[int][3]int{ // [version]{rate index 0,1,2} - 3: {44100, 48000, 32000}, // MPEG1 - 2: {22050, 24000, 16000}, // MPEG2 - 0: {11025, 12000, 8000}, // MPEG2.5 -} - -// mp3FrameLen returns the byte length of the MPEG audio frame whose 4-byte -// header is h, or 0 if h is not a valid frame header. -func mp3FrameLen(h []byte) int64 { - if h[0] != 0xff || h[1]&0xe0 != 0xe0 { - return 0 - } - version := int(h[1] >> 3 & 0x03) // 3=MPEG1, 2=MPEG2, 0=MPEG2.5 - layer := int(h[1] >> 1 & 0x03) // 3=I, 2=II, 1=III - if version == 1 || layer == 0 { - return 0 // reserved - } - brIndex := int(h[2] >> 4 & 0x0f) - srIndex := int(h[2] >> 2 & 0x03) - padding := int(h[2] >> 1 & 0x01) - if brIndex == 0 || brIndex == 15 || srIndex == 3 { - return 0 - } - var bitrate int - if version == 3 { - bitrate = mp3BitrateV1[brIndex] - } else { - bitrate = mp3BitrateV2[brIndex] - } - rates, ok := mp3SampleRate[version] - if !ok || bitrate == 0 { - return 0 - } - sampleRate := rates[srIndex] - if sampleRate == 0 { - return 0 - } - bps := bitrate * 1000 - if layer == 3 { // Layer I - return int64(((12 * bps / sampleRate) + padding) * 4) - } - // Layer II & III. MPEG2/2.5 Layer III uses 72 samples-per-byte factor. - coef := 144 - if version != 3 && layer == 1 { - coef = 72 - } - return int64(coef*bps/sampleRate + padding) -} - -func validateOgg(d *document.Document, start, max int64) (int64, float64) { - off := start - first := true - for off+27 <= max { - hdr, err := d.Range(off, 27) - if err != nil || len(hdr) < 27 || string(hdr[:4]) != "OggS" || hdr[4] != 0 { - return 0, 0 - } - headerType := hdr[5] - if first && headerType&0x02 == 0 { - return 0, 0.4 - } - segCount := int(hdr[26]) - lacing, err := d.Range(off+27, int64(segCount)) - if err != nil || len(lacing) < segCount { - return 0, 0 - } - payload := int64(0) - for _, v := range lacing { - payload += int64(v) - } - pageLen := int64(27 + segCount) - if payload < 0 || off+pageLen+payload > max { - return 0, 0 - } - off += pageLen + payload - if headerType&0x04 != 0 { - return off - start, 0.9 - } - first = false - } - return 0, 0.6 -} - -// validateMatroska reads the EBML header element and the following Segment -// element's size to bound a Matroska/WebM stream. A Segment with "unknown size" -// (all size bits set, as live-streamed files use) cannot be bounded this way. -func validateMatroska(d *document.Document, start, max int64) (int64, float64) { - buf, err := d.Range(start, min64(max-start, 4096)) - if err != nil || len(buf) < 8 { - return 0, 0 - } - // EBML header: ID 0x1A45DFA3, then a vint size. - if !(buf[0] == 0x1a && buf[1] == 0x45 && buf[2] == 0xdf && buf[3] == 0xa3) { - return 0, 0 - } - hsz, n, unknown := ebmlSize(buf, 4) - if n == 0 || unknown { - return 0, 0.6 - } - pos := 4 + n + int(hsz) // start of the Segment element - if pos+4 > len(buf) { - return 0, 0.6 - } - // Segment: ID 0x18538067, then a vint size. - if !(buf[pos] == 0x18 && buf[pos+1] == 0x53 && buf[pos+2] == 0x80 && buf[pos+3] == 0x67) { - return 0, 0.6 - } - segSz, sn, segUnknown := ebmlSize(buf, pos+4) - if sn == 0 || segUnknown { - return 0, 0.6 // live/streamed segment without a known size - } - end := int64(pos+4+sn) + segSz - if end <= 0 || start+end > max { - return 0, 0.6 - } - return end, 0.9 -} - -// ebmlSize decodes an EBML variable-length integer (data size form). The length -// is the number of leading zero bits in the first byte plus one; the value is -// the remaining bits. unknown is true when every value bit is set. -func ebmlSize(buf []byte, pos int) (val int64, n int, unknown bool) { - if pos >= len(buf) || buf[pos] == 0 { - return 0, 0, false - } - first := buf[pos] - mask := byte(0x80) - length := 1 - for first&mask == 0 { - mask >>= 1 - length++ - } - if length > 8 || pos+length > len(buf) { - return 0, 0, false - } - low := mask - 1 - v := int64(first & low) - allOnes := (first & low) == low - for i := 1; i < length; i++ { - b := buf[pos+i] - v = (v << 8) | int64(b) - if b != 0xff { - allOnes = false - } - } - return v, length, allOnes -} - -func validateFLAC(d *document.Document, start, max int64) (int64, float64) { - return 0, 0.8 -} - -// validateBMP reads the BITMAPFILEHEADER size field (bfSize), sanity-checking -// the embedded pixel-data offset against the declared length. -func validateBMP(d *document.Document, start, max int64) (int64, float64) { - hdr, err := d.Range(start, 14) - if err != nil || len(hdr) < 14 { - return 0, 0 - } - size := int64(binary.LittleEndian.Uint32(hdr[2:6])) - dataOff := int64(binary.LittleEndian.Uint32(hdr[10:14])) - if size < 26 || dataOff < 26 || dataOff > size || start+size > max { - return 0, 0 - } - return size, 0.8 -} - -// validateMP4 walks the top-level boxes of an ISO base-media file (MP4/MOV/M4A) -// from the file start (the ftyp box). 64-bit box sizes are honoured. -func validateMP4(d *document.Document, start, max int64) (int64, float64) { - pos := start - boxes := 0 - for pos+8 <= max && boxes < 100000 { - h, err := d.Range(pos, 16) - if err != nil || len(h) < 8 { - break - } - size := int64(binary.BigEndian.Uint32(h[0:4])) - switch { - case size == 1: // 64-bit largesize follows the type - if len(h) < 16 { - return 0, 0.4 - } - size = int64(binary.BigEndian.Uint64(h[8:16])) - case size == 0: // box extends to end of file - return max - start, 0.8 - } - if size < 8 { - break - } - pos += size - boxes++ - if pos > max { - return 0, 0.4 - } - } - if pos > start && pos <= max && boxes > 0 { - return pos - start, 0.85 - } - return 0, 0.4 -} - -func validateSQLite(d *document.Document, start, max int64) (int64, float64) { - hdr, err := d.Range(start, 32) - if err != nil || len(hdr) < 32 { - return 0, 0 - } - pageSize := int64(binary.BigEndian.Uint16(hdr[16:18])) - if pageSize == 1 { - pageSize = 65536 - } - pages := int64(binary.BigEndian.Uint32(hdr[28:32])) - if pages == 0 || pageSize == 0 { - return 0, 0.5 - } - return pageSize * pages, 0.9 -} - -func min64(a, b int64) int64 { - if a < b { - return a - } - return b -} diff --git a/internal/leviathan/analysis/diff.go b/internal/leviathan/analysis/diff.go deleted file mode 100644 index 0b412b8..0000000 --- a/internal/leviathan/analysis/diff.go +++ /dev/null @@ -1,244 +0,0 @@ -package analysis - -import ( - "context" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// DiffRegion describes a contiguous run of differing bytes. -type DiffRegion struct { - Offset int64 `json:"offset"` - Length int64 `json:"length"` - // kinds: "edit" (both sides have bytes), "add" (only B), "remove" (only A) - Kind string `json:"kind"` -} - -type DiffBlock struct { - Offset int64 `json:"offset"` - Length int64 `json:"length"` - ChangedBytes int64 `json:"changedBytes"` - EditBytes int64 `json:"editBytes"` - AddBytes int64 `json:"addBytes"` - RemoveBytes int64 `json:"removeBytes"` - Similarity float64 `json:"similarity"` -} - -// Diff compares two documents by byte. It handles documents of -// different sizes by treating the tail of the longer as add/remove. -// Chunks at both sides. Returns a compact list of regions. -func Diff(a, b *document.Document, start, length int64) (map[string]any, error) { - return DiffContext(context.Background(), a, b, start, length) -} - -func DiffContext(ctx context.Context, a, b *document.Document, start, length int64) (map[string]any, error) { - aSize := a.Size() - bSize := b.Size() - maxSize := aSize - if bSize > maxSize { - maxSize = bSize - } - if length < 0 { - length = maxSize - start - } - end := start + length - if end > maxSize { - end = maxSize - } - - const chunk = 1 << 20 - regions := make([]DiffRegion, 0, 64) - - var inRun bool - var runStart int64 - var runKind string - - flush := func(off int64) { - if inRun { - regions = append(regions, DiffRegion{Offset: runStart, Length: off - runStart, Kind: runKind}) - inRun = false - } - } - - for off := start; off < end; { - if err := ctx.Err(); err != nil { - return nil, err - } - winEnd := off + chunk - if winEnd > end { - winEnd = end - } - // Read both sides, tolerating short reads for the shorter file. - var ab, bb []byte - if off < aSize { - n := winEnd - off - if off+n > aSize { - n = aSize - off - } - var err error - ab, err = a.Range(off, n) - if err != nil { - return nil, err - } - } - if off < bSize { - n := winEnd - off - if off+n > bSize { - n = bSize - off - } - var err error - bb, err = b.Range(off, n) - if err != nil { - return nil, err - } - } - for i := int64(0); i < winEnd-off; i++ { - if i%4096 == 0 { - if err := ctx.Err(); err != nil { - return nil, err - } - } - curOff := off + i - var av, bv byte - var av_ok, bv_ok bool - if curOff < aSize && int64(len(ab)) > i { - av = ab[i] - av_ok = true - } - if curOff < bSize && int64(len(bb)) > i { - bv = bb[i] - bv_ok = true - } - kind := "" - if av_ok && bv_ok { - if av != bv { - kind = "edit" - } - } else if av_ok && !bv_ok { - kind = "remove" - } else if !av_ok && bv_ok { - kind = "add" - } - if kind == "" { - flush(curOff) - continue - } - if !inRun { - inRun = true - runStart = curOff - runKind = kind - } else if kind != runKind { - flush(curOff) - inRun = true - runStart = curOff - runKind = kind - } - } - off = winEnd - } - flush(end) - - // Convert to JS-friendly output + summary - out := make([]map[string]any, len(regions)) - editBytes := int64(0) - addBytes := int64(0) - removeBytes := int64(0) - for i, r := range regions { - out[i] = map[string]any{ - "offset": r.Offset, - "length": r.Length, - "kind": r.Kind, - } - switch r.Kind { - case "edit": - editBytes += r.Length - case "add": - addBytes += r.Length - case "remove": - removeBytes += r.Length - } - } - const blockSize int64 = 4096 - blocks := diffBlocksFromRegions(regions, start, end, blockSize) - blockOut := make([]map[string]any, len(blocks)) - for i, b := range blocks { - blockOut[i] = map[string]any{ - "offset": b.Offset, - "length": b.Length, - "changedBytes": b.ChangedBytes, - "editBytes": b.EditBytes, - "addBytes": b.AddBytes, - "removeBytes": b.RemoveBytes, - "similarity": b.Similarity, - } - } - return map[string]any{ - "regions": out, - "regionCount": len(regions), - "blocks": blockOut, - "blockCount": len(blocks), - "blockSize": blockSize, - "editBytes": editBytes, - "addBytes": addBytes, - "removeBytes": removeBytes, - "aSize": aSize, - "bSize": bSize, - }, nil -} - -func diffBlocksFromRegions(regions []DiffRegion, start, end, blockSize int64) []DiffBlock { - if blockSize <= 0 || end <= start || len(regions) == 0 { - return nil - } - out := []DiffBlock{} - for alignedStart := start - start%blockSize; alignedStart < end; alignedStart += blockSize { - blockStart := alignedStart - blockEnd := alignedStart + blockSize - if blockEnd > end { - blockEnd = end - } - if blockEnd <= start { - continue - } - if blockStart < start { - blockStart = start - } - b := DiffBlock{Offset: blockStart, Length: blockEnd - blockStart} - for _, r := range regions { - rEnd := r.Offset + r.Length - if rEnd <= blockStart { - continue - } - if r.Offset >= blockEnd { - break - } - ovStart := r.Offset - if ovStart < blockStart { - ovStart = blockStart - } - ovEnd := rEnd - if ovEnd > blockEnd { - ovEnd = blockEnd - } - changed := ovEnd - ovStart - if changed <= 0 { - continue - } - b.ChangedBytes += changed - switch r.Kind { - case "edit": - b.EditBytes += changed - case "add": - b.AddBytes += changed - case "remove": - b.RemoveBytes += changed - } - } - if b.ChangedBytes == 0 { - continue - } - b.Similarity = 1 - float64(b.ChangedBytes)/float64(b.Length) - out = append(out, b) - } - return out -} diff --git a/internal/leviathan/analysis/entropy.go b/internal/leviathan/analysis/entropy.go deleted file mode 100644 index 905493f..0000000 --- a/internal/leviathan/analysis/entropy.go +++ /dev/null @@ -1,125 +0,0 @@ -package analysis - -import ( - "context" - "math" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// Entropy computes Shannon entropy per sliding window over the range. -// -// The returned map has: -// -// windowSize: int -// step: int -// samples: []float64 — entropy per window (0..8) -// offsets: []int64 — start offset of each window -// mean/min/max: summary stats -// highCount: number of windows with entropy >= 7.5 (candidate compressed/encrypted) -func Entropy(d *document.Document, start, length, windowSize, step int64) (map[string]any, error) { - return EntropyContext(context.Background(), d, start, length, windowSize, step) -} - -func EntropyContext(ctx context.Context, d *document.Document, start, length, windowSize, step int64) (map[string]any, error) { - if windowSize <= 0 { - windowSize = 4096 - } - if step <= 0 { - step = windowSize - } - if length < 0 { - length = d.Size() - start - } - if length <= 0 { - return map[string]any{ - "windowSize": windowSize, "step": step, - "samples": []float64{}, "offsets": []int64{}, - "mean": 0.0, "min": 0.0, "max": 0.0, "highCount": 0, - }, nil - } - - // We run in a streaming fashion: advance through chunks, refill - // a rolling window, and emit a sample whenever the cursor hits - // a step boundary. - samples := make([]float64, 0, length/step+1) - offsets := make([]int64, 0, length/step+1) - - // For bounded memory we read the document range chunk by chunk - // but we still need a window of size `windowSize`. Windows can be - // as small as 256 or as large as 1MB; we cap to length. - if windowSize > length { - windowSize = length - } - - // Simple approach: iterate in step-sized hops, reading window - // bytes at each hop. This scales O(n * (w/step)) but window and - // step are usually the same for UI. - mean := 0.0 - minE := math.Inf(1) - maxE := math.Inf(-1) - high := 0 - - for off := start; off < start+length; off += step { - if err := ctx.Err(); err != nil { - return nil, err - } - winLen := windowSize - if off+winLen > start+length { - winLen = start + length - off - } - buf, err := d.Range(off, winLen) - if err != nil { - return nil, err - } - e := shannon(buf) - samples = append(samples, e) - offsets = append(offsets, off) - mean += e - if e < minE { - minE = e - } - if e > maxE { - maxE = e - } - if e >= 7.5 { - high++ - } - } - if len(samples) > 0 { - mean /= float64(len(samples)) - } else { - minE, maxE = 0, 0 - } - - return map[string]any{ - "windowSize": windowSize, - "step": step, - "samples": samples, - "offsets": offsets, - "mean": mean, - "min": minE, - "max": maxE, - "highCount": high, - }, nil -} - -func shannon(buf []byte) float64 { - if len(buf) == 0 { - return 0 - } - var counts [256]int - for _, b := range buf { - counts[b]++ - } - n := float64(len(buf)) - e := 0.0 - for _, c := range counts { - if c == 0 { - continue - } - p := float64(c) / n - e -= p * math.Log2(p) - } - return e -} diff --git a/internal/leviathan/analysis/fuzzy.go b/internal/leviathan/analysis/fuzzy.go deleted file mode 100644 index 076f9b5..0000000 --- a/internal/leviathan/analysis/fuzzy.go +++ /dev/null @@ -1,224 +0,0 @@ -package analysis - -import ( - "fmt" - "strconv" - "strings" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// fuzzy.go implements context-triggered piecewise hashing (CTPH) compatible -// with ssdeep. CTPH produces a short signature of the form -// "blocksize:hash1:hash2" that stays similar when the input changes only a -// little, so investigators can cluster related samples or match a file -// against a known-bad ssdeep hash even after minor edits. -// -// The signature format and the rolling/sum hashes follow Andrew Tridgell's -// spamsum / Jesse Kornblum's ssdeep so the output interoperates with the -// standard tooling (e.g. an empty input hashes to "3::"). - -const ( - spamsumLength = 64 - rollingWindow = 7 - minBlocksize = 3 - hashInit = uint32(0x28021967) - hashPrime = uint32(0x01000193) -) - -const b64alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" - -type rollState struct { - h1, h2, h3 uint32 - window [rollingWindow]byte - n int -} - -func (r *rollState) roll(c byte) uint32 { - r.h2 -= r.h1 - r.h2 += rollingWindow * uint32(c) - r.h1 += uint32(c) - r.h1 -= uint32(r.window[r.n%rollingWindow]) - r.window[r.n%rollingWindow] = c - r.n++ - r.h3 = r.h3 << 5 - r.h3 ^= uint32(c) - return r.h1 + r.h2 + r.h3 -} - -func sumHash(c byte, h uint32) uint32 { return (h * hashPrime) ^ uint32(c) } - -// FuzzyDigest computes the ssdeep-compatible signature for data. -func FuzzyDigest(data []byte) string { - bs := uint32(minBlocksize) - for uint64(bs)*spamsumLength < uint64(len(data)) { - bs *= 2 - } - for { - sig1, sig2 := ctphSigs(data, bs) - if bs > minBlocksize && len(sig1) < spamsumLength/2 { - bs /= 2 - continue - } - return fmt.Sprintf("%d:%s:%s", bs, sig1, sig2) - } -} - -func ctphSigs(data []byte, bs uint32) (string, string) { - var rs rollState - h1, h2 := hashInit, hashInit - var sig1, sig2 strings.Builder - var lastRoll uint32 - - for _, c := range data { - h1 = sumHash(c, h1) - h2 = sumHash(c, h2) - rh := rs.roll(c) - lastRoll = rh - - if rh%bs == bs-1 && sig1.Len() < spamsumLength-1 { - sig1.WriteByte(b64alphabet[h1%64]) - h1 = hashInit - } - if rh%(bs*2) == bs*2-1 && sig2.Len() < spamsumLength/2-1 { - sig2.WriteByte(b64alphabet[h2%64]) - h2 = hashInit - } - } - if lastRoll != 0 { - sig1.WriteByte(b64alphabet[h1%64]) - sig2.WriteByte(b64alphabet[h2%64]) - } - return sig1.String(), sig2.String() -} - -// FuzzyHash computes the CTPH signature over a document range. -func FuzzyHash(d *document.Document, start, length int64) (map[string]any, error) { - if length < 0 { - length = d.Size() - start - } - buf, err := d.Range(start, length) - if err != nil { - return nil, err - } - sig := FuzzyDigest(buf) - bs := strings.SplitN(sig, ":", 2)[0] - return map[string]any{ - "algo": "ssdeep", - "signature": sig, - "blocksize": bs, - "length": length, - }, nil -} - -// FuzzyCompare returns a 0-100 similarity score between two ssdeep -// signatures. 0 means unrelated (or incomparable block sizes); 100 means the -// signatures are identical. -func FuzzyCompare(a, b string) int { - bs1, a1, a2, ok1 := parseSig(a) - bs2, b1, b2, ok2 := parseSig(b) - if !ok1 || !ok2 { - return 0 - } - if bs1 != bs2 && bs1 != bs2*2 && bs2 != bs1*2 { - return 0 - } - switch { - case bs1 == bs2: - s1 := scoreStrings(a1, b1, bs1) - s2 := scoreStrings(a2, b2, bs1*2) - if s2 > s1 { - return s2 - } - return s1 - case bs1 == bs2*2: - return scoreStrings(a1, b2, bs1) - default: // bs2 == bs1*2 - return scoreStrings(a2, b1, bs2) - } -} - -func parseSig(s string) (uint32, string, string, bool) { - parts := strings.SplitN(s, ":", 3) - if len(parts) != 3 { - return 0, "", "", false - } - bs, err := strconv.ParseUint(parts[0], 10, 32) - if err != nil || bs == 0 { - return 0, "", "", false - } - return uint32(bs), parts[1], parts[2], true -} - -func scoreStrings(s1, s2 string, blocksize uint32) int { - if !hasCommonSubstring(s1, s2, rollingWindow) { - return 0 - } - if s1 == s2 { - return 100 - } - d := editDistance(s1, s2) - if len(s1)+len(s2) == 0 { - return 0 - } - score := (d * spamsumLength) / (len(s1) + len(s2)) - score = (100 * score) / spamsumLength - score = 100 - score - // Small block sizes describe little data, so cap the score they can claim. - cap := int(blocksize) / minBlocksize * minInt(len(s1), len(s2)) - if score > cap { - score = cap - } - if score > 100 { - score = 100 - } - if score < 0 { - score = 0 - } - return score -} - -func hasCommonSubstring(s1, s2 string, n int) bool { - if len(s1) < n || len(s2) < n { - return false - } - seen := make(map[string]struct{}, len(s1)) - for i := 0; i+n <= len(s1); i++ { - seen[s1[i:i+n]] = struct{}{} - } - for i := 0; i+n <= len(s2); i++ { - if _, ok := seen[s2[i:i+n]]; ok { - return true - } - } - return false -} - -// editDistance is classic Levenshtein with unit insert/delete/substitute cost. -func editDistance(s1, s2 string) int { - m, n := len(s1), len(s2) - prev := make([]int, n+1) - cur := make([]int, n+1) - for j := 0; j <= n; j++ { - prev[j] = j - } - for i := 1; i <= m; i++ { - cur[0] = i - for j := 1; j <= n; j++ { - cost := 1 - if s1[i-1] == s2[j-1] { - cost = 0 - } - cur[j] = minInt(minInt(cur[j-1]+1, prev[j]+1), prev[j-1]+cost) - } - prev, cur = cur, prev - } - return prev[n] -} - -func minInt(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/internal/leviathan/analysis/hash.go b/internal/leviathan/analysis/hash.go deleted file mode 100644 index 47a3740..0000000 --- a/internal/leviathan/analysis/hash.go +++ /dev/null @@ -1,63 +0,0 @@ -package analysis - -import ( - "crypto/md5" - "crypto/sha1" - "crypto/sha256" - "crypto/sha512" - "encoding/hex" - "hash" - "hash/crc32" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// HashSet contains CRC32/MD5/SHA-1/SHA-256/SHA-512 for a range. -type HashSet struct { - Length int64 `json:"length"` - CRC32 string `json:"crc32"` - MD5 string `json:"md5"` - SHA1 string `json:"sha1"` - SHA256 string `json:"sha256"` - SHA512 string `json:"sha512"` -} - -// Hashes computes all supported hashes over the document range. -// The computation runs chunk-by-chunk so large files do not force a -// full-buffer duplication. -func Hashes(d *document.Document, start, length int64) (map[string]any, error) { - if length < 0 { - length = d.Size() - start - } - - crc := crc32.NewIEEE() - mh := md5.New() - s1 := sha1.New() - s256 := sha256.New() - s512 := sha512.New() - - writers := []hash.Hash{mh, s1, s256, s512} - - total := int64(0) - err := d.ForEachChunk(start, length, func(_ int64, chunk []byte) error { - crc.Write(chunk) - for _, w := range writers { - w.Write(chunk) - } - total += int64(len(chunk)) - return nil - }) - if err != nil { - return nil, err - } - return map[string]any{ - "length": total, - "crc32": toHex(crc.Sum(nil)), - "md5": toHex(mh.Sum(nil)), - "sha1": toHex(s1.Sum(nil)), - "sha256": toHex(s256.Sum(nil)), - "sha512": toHex(s512.Sum(nil)), - }, nil -} - -func toHex(b []byte) string { return hex.EncodeToString(b) } diff --git a/internal/leviathan/analysis/inspector.go b/internal/leviathan/analysis/inspector.go deleted file mode 100644 index 298aa83..0000000 --- a/internal/leviathan/analysis/inspector.go +++ /dev/null @@ -1,162 +0,0 @@ -package analysis - -import ( - "encoding/binary" - "fmt" - "math" - "time" - "unicode/utf16" - "unicode/utf8" -) - -// Inspect interprets bytes starting at `buf[0]` as many candidate -// types. It is resilient: types that don't have enough bytes simply -// return empty strings. -func Inspect(buf []byte, offset int64) map[string]any { - out := map[string]any{ - "offset": offset, - "length": len(buf), - } - - // 8-bit - if len(buf) >= 1 { - out["u8"] = int(buf[0]) - out["i8"] = int(int8(buf[0])) - out["char"] = printable(buf[0]) - out["bin8"] = fmt.Sprintf("%08b", buf[0]) - } - - // 16-bit - if len(buf) >= 2 { - le16 := binary.LittleEndian.Uint16(buf) - be16 := binary.BigEndian.Uint16(buf) - out["u16_le"] = int(le16) - out["u16_be"] = int(be16) - out["i16_le"] = int(int16(le16)) - out["i16_be"] = int(int16(be16)) - } - - // 32-bit - if len(buf) >= 4 { - le32 := binary.LittleEndian.Uint32(buf) - be32 := binary.BigEndian.Uint32(buf) - out["u32_le"] = uint(le32) - out["u32_be"] = uint(be32) - out["i32_le"] = int(int32(le32)) - out["i32_be"] = int(int32(be32)) - out["f32_le"] = float64(math.Float32frombits(le32)) - out["f32_be"] = float64(math.Float32frombits(be32)) - } - - // 64-bit - if len(buf) >= 8 { - le64 := binary.LittleEndian.Uint64(buf) - be64 := binary.BigEndian.Uint64(buf) - out["u64_le"] = float64(le64) - out["u64_be"] = float64(be64) - out["i64_le"] = float64(int64(le64)) - out["i64_be"] = float64(int64(be64)) - out["f64_le"] = math.Float64frombits(le64) - out["f64_be"] = math.Float64frombits(be64) - } - - // Timestamp guesses - if len(buf) >= 4 { - le := binary.LittleEndian.Uint32(buf) - out["unix_le"] = formatUnix(int64(le)) - } - if len(buf) >= 8 { - le := binary.LittleEndian.Uint64(buf) - out["filetime_le"] = formatFiletime(le) - out["webkit_le"] = formatWebkit(le) - out["oletime_le"] = formatOLEDate(math.Float64frombits(le)) - } - - // ASCII preview (up to 32 bytes) - n := len(buf) - if n > 32 { - n = 32 - } - ascii := make([]byte, n) - for i := 0; i < n; i++ { - if buf[i] >= 0x20 && buf[i] <= 0x7e { - ascii[i] = buf[i] - } else { - ascii[i] = '.' - } - } - out["ascii"] = string(ascii) - - // UTF-8 attempt - if utf8.Valid(buf[:n]) { - out["utf8"] = string(buf[:n]) - } - - // UTF-16 LE/BE preview - if len(buf) >= 2 { - lu := n / 2 - if lu > 0 { - u := make([]uint16, lu) - for i := 0; i < lu; i++ { - u[i] = uint16(buf[i*2]) | uint16(buf[i*2+1])<<8 - } - out["utf16le"] = string(utf16.Decode(u)) - for i := 0; i < lu; i++ { - u[i] = uint16(buf[i*2])<<8 | uint16(buf[i*2+1]) - } - out["utf16be"] = string(utf16.Decode(u)) - } - } - - return out -} - -func printable(b byte) string { - if b >= 0x20 && b <= 0x7e { - return string([]byte{b}) - } - return "." -} - -func formatUnix(sec int64) string { - if sec < 0 || sec > 4102444800 { // past 2100 - return "" - } - return time.Unix(sec, 0).UTC().Format(time.RFC3339) -} - -// Windows FILETIME epoch 1601-01-01 in 100ns ticks. -const filetimeEpochDelta = 11644473600 - -func formatFiletime(v uint64) string { - if v == 0 { - return "" - } - sec := int64(v/10_000_000) - filetimeEpochDelta - if sec < 0 || sec > 4102444800 { - return "" - } - return time.Unix(sec, 0).UTC().Format(time.RFC3339) -} - -// WebKit/Chrome epoch 1601-01-01 in microseconds. -func formatWebkit(v uint64) string { - if v == 0 { - return "" - } - sec := int64(v/1_000_000) - filetimeEpochDelta - if sec < 0 || sec > 4102444800 { - return "" - } - return time.Unix(sec, 0).UTC().Format(time.RFC3339) -} - -// OLE Automation date: days since 1899-12-30, as float64. -func formatOLEDate(days float64) string { - if days < 2 || days > 75000 { - return "" - } - base := time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC) - sec := int64(days * 86400) - return base.Add(time.Duration(sec) * time.Second).Format(time.RFC3339) -} diff --git a/internal/leviathan/analysis/ioc.go b/internal/leviathan/analysis/ioc.go deleted file mode 100644 index dc4eb26..0000000 --- a/internal/leviathan/analysis/ioc.go +++ /dev/null @@ -1,89 +0,0 @@ -package analysis - -import ( - "context" - "regexp" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// ioc regexes. They run against printable ASCII strings extracted -// from the document, which is the standard approach for IOC hunting -// and avoids matching noise in raw binary data. -var iocRegexes = map[string]*regexp.Regexp{ - "url": regexp.MustCompile(`(?i)\b(?:https?|ftp)://[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+`), - "domain": regexp.MustCompile(`(?i)\b(?:[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?\.)+(?:com|net|org|io|co|uk|de|fr|ru|cn|info|xyz|app|dev|sh|me|gov|mil|edu|biz)\b`), - "email": regexp.MustCompile(`(?i)\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b`), - "ipv4": regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`), - "ipv6": regexp.MustCompile(`\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b`), - "guid": regexp.MustCompile(`\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b`), - "md5": regexp.MustCompile(`\b[a-fA-F0-9]{32}\b`), - "sha1": regexp.MustCompile(`\b[a-fA-F0-9]{40}\b`), - "sha256": regexp.MustCompile(`\b[a-fA-F0-9]{64}\b`), - "winpath": regexp.MustCompile(`[A-Za-z]:\\(?:[^\\/:*?"<>|\r\n]+\\)*[^\\/:*?"<>|\r\n]*`), - "unc": regexp.MustCompile(`\\\\[A-Za-z0-9._\-]+(?:\\[^\\/:*?"<>|\r\n]+)+`), - "unixpath": regexp.MustCompile(`(?:/(?:etc|var|usr|opt|home|tmp|root|srv|bin|sbin|lib|lib64)(?:/[A-Za-z0-9._\-]+)+)`), - "regkey": regexp.MustCompile(`(?i)\b(?:HKLM|HKCU|HKCR|HKU|HKCC|HKEY_[A-Z_]+)\\[^\s"<>]+`), - "onion": regexp.MustCompile(`(?i)\b[a-z2-7]{16,56}\.onion\b`), - "btc": regexp.MustCompile(`\b(?:[13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[a-z0-9]{20,90})\b`), -} - -// ExtractIOCs runs the IOC regexes over ASCII strings first and then -// UTF-16 LE/BE strings. Returns a map of category -> slice of hits -// with offsets into the original file. -func ExtractIOCs(d *document.Document, start, length int64, minLen int) (map[string]any, error) { - return ExtractIOCsContext(context.Background(), d, start, length, minLen) -} - -func ExtractIOCsContext(ctx context.Context, d *document.Document, start, length int64, minLen int) (map[string]any, error) { - if length < 0 { - length = d.Size() - start - } - if minLen <= 0 { - minLen = 5 - } - - strings, err := ExtractStringsContext(ctx, d, start, length, minLen, []string{"ascii", "utf16le", "utf16be"}, 200_000) - if err != nil { - return nil, err - } - - result := map[string]any{} - totals := map[string]int{} - for category, re := range iocRegexes { - if err := ctx.Err(); err != nil { - return nil, err - } - hits := make([]map[string]any, 0) - for _, s := range strings { - if err := ctx.Err(); err != nil { - return nil, err - } - str, _ := s["value"].(string) - base, _ := s["offset"].(int64) - for _, loc := range re.FindAllStringIndex(str, -1) { - hits = append(hits, map[string]any{ - "category": category, - "offset": base + int64(loc[0]), - "length": loc[1] - loc[0], - "value": str[loc[0]:loc[1]], - }) - } - } - result[category] = hits - totals[category] = len(hits) - } - // flat list for UI convenience - all := []map[string]any{} - for _, v := range result { - if s, ok := v.([]map[string]any); ok { - all = append(all, s...) - } - } - return map[string]any{ - "byCategory": result, - "all": all, - "totals": totals, - "totalStrings": len(strings), - }, nil -} diff --git a/internal/leviathan/analysis/keys.go b/internal/leviathan/analysis/keys.go deleted file mode 100644 index 12f14b3..0000000 --- a/internal/leviathan/analysis/keys.go +++ /dev/null @@ -1,367 +0,0 @@ -package analysis - -import ( - "bytes" - "crypto/ecdsa" - "crypto/ed25519" - "crypto/rsa" - "crypto/x509" - "encoding/pem" - "fmt" - "strings" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// KeyArtefact is a single key-ish object we've found in the raw file. -// Length is the span we recognised; Note carries any decoding detail. -type KeyArtefact struct { - Kind string `json:"kind"` - Format string `json:"format"` // "pem" | "der" | "ssh" | "p12" - Offset int64 `json:"offset"` - Length int64 `json:"length"` - Summary string `json:"summary"` - Note string `json:"note,omitempty"` -} - -// ScanKeys walks the whole document looking for cryptographic artefacts: -// - PEM blocks with common key-related labels -// - OpenSSH keys (authorized_keys lines and the "openssh-key-v1" private-key envelope) -// - PKCS#12 magic (1.2.840.113549.1.12.1 appears in headers) -// - Loose DER SEQUENCEs that parse as RSA/EC/PKCS#8 keys -// -// We cap the scan at 64 MiB. Users with larger blobs can select a range -// and call this against that subset via a future "keys.scanRange" bridge. -func ScanKeys(d *document.Document) ([]KeyArtefact, error) { - size := d.Size() - if size > 64*1024*1024 { - return nil, fmt.Errorf("keys.scan: file > 64 MiB; use selection") - } - buf, err := d.Range(0, size) - if err != nil { - return nil, err - } - out := []KeyArtefact{} - out = append(out, scanPEMKeys(buf)...) - out = append(out, scanOpenSSHPubKeys(buf)...) - out = append(out, scanOpenSSHPrivKeys(buf)...) - out = append(out, scanPKCS12(buf)...) - // Loose DER pass — for raw PKCS#8 / PKCS#1 blobs that aren't wrapped in PEM. - out = append(out, scanLooseDERKeys(buf)...) - return out, nil -} - -// Known PEM labels that indicate key-ish content. -var pemKeyLabels = map[string]string{ - "RSA PRIVATE KEY": "rsa-private", - "RSA PUBLIC KEY": "rsa-public", - "EC PRIVATE KEY": "ec-private", - "EC PUBLIC KEY": "ec-public", - "DSA PRIVATE KEY": "dsa-private", - "PRIVATE KEY": "pkcs8-private", - "ENCRYPTED PRIVATE KEY": "pkcs8-encrypted", - "PUBLIC KEY": "pkix-public", - "OPENSSH PRIVATE KEY": "openssh-private", - "SSH2 PUBLIC KEY": "ssh2-public", - "PGP PRIVATE KEY BLOCK": "pgp-private", - "PGP PUBLIC KEY BLOCK": "pgp-public", - "CERTIFICATE REQUEST": "csr", - "NEW CERTIFICATE REQUEST": "csr", - "DH PARAMETERS": "dh-params", - "PARAMETERS": "params", -} - -func scanPEMKeys(buf []byte) []KeyArtefact { - out := []KeyArtefact{} - rest := buf - origLen := int64(len(buf)) - for { - block, next := pem.Decode(rest) - if block == nil { - break - } - beginIdx := bytes.Index(rest, []byte("-----BEGIN")) - if beginIdx < 0 { - break - } - start := origLen - int64(len(rest)) + int64(beginIdx) - end := origLen - int64(len(next)) - kind, match := pemKeyLabels[block.Type] - if !match { - rest = next - continue - } - art := KeyArtefact{ - Kind: kind, Format: "pem", - Offset: start, Length: end - start, - } - art.Summary = describePEMBlock(block) - out = append(out, art) - rest = next - } - return out -} - -func describePEMBlock(b *pem.Block) string { - name := b.Type - switch b.Type { - case "RSA PRIVATE KEY": - if k, err := x509.ParsePKCS1PrivateKey(b.Bytes); err == nil { - return fmt.Sprintf("%s — RSA %d-bit", name, k.N.BitLen()) - } - case "RSA PUBLIC KEY": - if k, err := x509.ParsePKCS1PublicKey(b.Bytes); err == nil { - return fmt.Sprintf("%s — RSA %d-bit", name, k.N.BitLen()) - } - case "EC PRIVATE KEY": - if k, err := x509.ParseECPrivateKey(b.Bytes); err == nil { - return fmt.Sprintf("%s — curve %s", name, k.Curve.Params().Name) - } - case "PRIVATE KEY": - if k, err := x509.ParsePKCS8PrivateKey(b.Bytes); err == nil { - return fmt.Sprintf("%s — %s", name, describePrivateKey(k)) - } - case "PUBLIC KEY": - if k, err := x509.ParsePKIXPublicKey(b.Bytes); err == nil { - return fmt.Sprintf("%s — %s", name, describePublicKey(k)) - } - case "OPENSSH PRIVATE KEY": - return fmt.Sprintf("%s (%d B payload)", name, len(b.Bytes)) - case "ENCRYPTED PRIVATE KEY": - return fmt.Sprintf("%s — PKCS#8 encrypted (%d B)", name, len(b.Bytes)) - } - return fmt.Sprintf("%s (%d B)", name, len(b.Bytes)) -} - -func describePrivateKey(k any) string { - switch key := k.(type) { - case *rsa.PrivateKey: - return fmt.Sprintf("RSA %d-bit", key.N.BitLen()) - case *ecdsa.PrivateKey: - return fmt.Sprintf("ECDSA %s", key.Curve.Params().Name) - case ed25519.PrivateKey: - return "Ed25519" - } - return fmt.Sprintf("%T", k) -} - -func describePublicKey(k any) string { - switch key := k.(type) { - case *rsa.PublicKey: - return fmt.Sprintf("RSA %d-bit", key.N.BitLen()) - case *ecdsa.PublicKey: - return fmt.Sprintf("ECDSA %s", key.Curve.Params().Name) - case ed25519.PublicKey: - return "Ed25519" - } - return fmt.Sprintf("%T", k) -} - -// OpenSSH authorized_keys / id_*.pub format is one line per key: -// " [comment]\n". We search for well-known algos and -// delimit by the next newline. False positives on text logs are possible, -// so we validate by base64-decoding. -var sshAlgos = []string{ - "ssh-rsa", "ssh-dss", "ssh-ed25519", - "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", - "sk-ssh-ed25519@openssh.com", "sk-ecdsa-sha2-nistp256@openssh.com", -} - -func scanOpenSSHPubKeys(buf []byte) []KeyArtefact { - out := []KeyArtefact{} - for _, algo := range sshAlgos { - needle := []byte(algo + " ") - off := 0 - for { - idx := bytes.Index(buf[off:], needle) - if idx < 0 { - break - } - absStart := off + idx - // Must be at start of line or after whitespace. - if absStart > 0 { - prev := buf[absStart-1] - if prev != '\n' && prev != '\r' && prev != ' ' && prev != '\t' { - off = absStart + 1 - continue - } - } - // End of line. - endOfLine := absStart + len(needle) - for endOfLine < len(buf) && buf[endOfLine] != '\n' && buf[endOfLine] != '\r' { - endOfLine++ - } - line := buf[absStart:endOfLine] - // Expect at least one space-separated payload that decodes. - parts := bytes.SplitN(line, []byte(" "), 3) - if len(parts) < 2 || len(parts[1]) < 16 || !isBase64Like(parts[1]) { - off = absStart + 1 - continue - } - art := KeyArtefact{ - Kind: "openssh-public", Format: "ssh", - Offset: int64(absStart), Length: int64(len(line)), - Summary: fmt.Sprintf("%s (%d-byte payload b64)", algo, len(parts[1])), - } - if len(parts) == 3 && len(parts[2]) > 0 { - art.Note = "comment: " + string(parts[2]) - } - out = append(out, art) - off = endOfLine - } - } - return out -} - -func scanOpenSSHPrivKeys(buf []byte) []KeyArtefact { - // Already caught by PEM scan (label "OPENSSH PRIVATE KEY"). We also - // look for the magic string inside the PEM body — "openssh-key-v1\0". - out := []KeyArtefact{} - needle := []byte("openssh-key-v1\x00") - off := 0 - for { - idx := bytes.Index(buf[off:], needle) - if idx < 0 { - break - } - absStart := off + idx - out = append(out, KeyArtefact{ - Kind: "openssh-private-magic", Format: "ssh", - Offset: int64(absStart), Length: int64(len(needle)), - Summary: "OpenSSH v1 private-key magic", - Note: "magic only; no envelope length inferred", - }) - off = absStart + len(needle) - } - return out -} - -// PKCS#12 (.p12 / .pfx) files begin with an ASN.1 SEQUENCE; the second -// element is an OID for the bag type. Its encoded form is reasonably -// distinctive: "\x06\x0b\x2a\x86\x48\x86\xf7\x0d\x01\x07\x01" (data). -// The simplest reliable magic for our purposes is the OID for -// pkcs-12 (1.2.840.113549.1.12.1): "\x06\x0a\x2a\x86\x48\x86\xf7\x0d\x01\x0c\x01". -func scanPKCS12(buf []byte) []KeyArtefact { - out := []KeyArtefact{} - needle := []byte{0x06, 0x0a, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x0c, 0x01} - off := 0 - for { - idx := bytes.Index(buf[off:], needle) - if idx < 0 { - break - } - absStart := off + idx - out = append(out, KeyArtefact{ - Kind: "pkcs12-oid", Format: "p12", - Offset: int64(absStart), Length: int64(len(needle)), - Summary: "pkcs-12 OID (1.2.840.113549.1.12.1)", - Note: "bag-type marker; may indicate a .p12 or .pfx container", - }) - off = absStart + len(needle) - } - return out -} - -// scanLooseDERKeys walks DER SEQUENCE headers and tries to parse each -// candidate as a known key format. False negatives are fine; we only -// emit an artefact on a positive parse. -func scanLooseDERKeys(buf []byte) []KeyArtefact { - out := []KeyArtefact{} - // Use the same SEQUENCE 0x30 0x82 xx yy (long-form, 2 length bytes) - // pattern as the cert scanner. Minimum useful key length ~200 bytes. - // - // Each candidate runs up to six x509 parse attempts over a blob up to - // 64 KiB, so an unfiltered scan of a buffer dense with "30 82" pairs is - // O(n) expensive parses and could freeze the UI. A cheap structural - // pre-filter (a DER key body begins with a SEQUENCE 0x30 or an INTEGER - // 0x02) rejects almost all false candidates without parsing, and a hard - // cap bounds adversarial input that defeats the pre-filter. - const maxDERAttempts = 1 << 16 - derAttempts := 0 - for i := 0; i+4 < len(buf); i++ { - if buf[i] != 0x30 || buf[i+1] != 0x82 { - continue - } - if c := buf[i+4]; c != 0x30 && c != 0x02 { - continue - } - total := int(buf[i+2])<<8 | int(buf[i+3]) - if total < 80 || i+4+total > len(buf) { - continue - } - if derAttempts++; derAttempts > maxDERAttempts { - break - } - blob := buf[i : i+4+total] - // Skip anything that looks like a certificate (already caught by certs panel). - if _, err := x509.ParseCertificate(blob); err == nil { - i += 4 + total - 1 - continue - } - if _, err := x509.ParsePKCS1PrivateKey(blob); err == nil { - out = append(out, KeyArtefact{ - Kind: "rsa-private", Format: "der", - Offset: int64(i), Length: int64(4 + total), - Summary: "RSA private key (PKCS#1 DER)", - }) - i += 4 + total - 1 - continue - } - if _, err := x509.ParsePKCS1PublicKey(blob); err == nil { - out = append(out, KeyArtefact{ - Kind: "rsa-public", Format: "der", - Offset: int64(i), Length: int64(4 + total), - Summary: "RSA public key (PKCS#1 DER)", - }) - i += 4 + total - 1 - continue - } - if k, err := x509.ParseECPrivateKey(blob); err == nil { - out = append(out, KeyArtefact{ - Kind: "ec-private", Format: "der", - Offset: int64(i), Length: int64(4 + total), - Summary: "EC private key (SEC1 DER) curve=" + k.Curve.Params().Name, - }) - i += 4 + total - 1 - continue - } - if k, err := x509.ParsePKCS8PrivateKey(blob); err == nil { - out = append(out, KeyArtefact{ - Kind: "pkcs8-private", Format: "der", - Offset: int64(i), Length: int64(4 + total), - Summary: "PKCS#8 private key — " + describePrivateKey(k), - }) - i += 4 + total - 1 - continue - } - if k, err := x509.ParsePKIXPublicKey(blob); err == nil { - out = append(out, KeyArtefact{ - Kind: "pkix-public", Format: "der", - Offset: int64(i), Length: int64(4 + total), - Summary: "PKIX public key — " + describePublicKey(k), - }) - i += 4 + total - 1 - continue - } - } - return out -} - -func isBase64Like(b []byte) bool { - if len(b) == 0 { - return false - } - for _, c := range b { - if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || - c == '+' || c == '/' || c == '=' { - continue - } - return false - } - return true -} - -// Stringer helper used by the bridge; avoids leaking internal types to JS. -func (a KeyArtefact) String() string { - return strings.TrimSpace(fmt.Sprintf("%s %s @0x%x %s", a.Kind, a.Format, a.Offset, a.Summary)) -} diff --git a/internal/leviathan/analysis/multisearch.go b/internal/leviathan/analysis/multisearch.go deleted file mode 100644 index 9a12677..0000000 --- a/internal/leviathan/analysis/multisearch.go +++ /dev/null @@ -1,113 +0,0 @@ -package analysis - -import ( - "encoding/hex" - "errors" - "fmt" - "regexp" - "strings" - - "github.com/andydixon/link/internal/leviathan/document" -) - -type MultiSearchQuery struct { - Mode string - Query string -} - -func ParseMultiSearchSpec(spec string) ([]MultiSearchQuery, error) { - spec = strings.ReplaceAll(spec, ";", "\n") - out := []MultiSearchQuery{} - for _, line := range strings.Split(spec, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - parts := strings.Fields(line) - if len(parts) < 2 { - return nil, errors.New("multi-search lines require: text|hex|regex ") - } - mode := strings.ToLower(parts[0]) - query := strings.TrimSpace(strings.TrimPrefix(line, parts[0])) - switch mode { - case "text", "hex", "regex": - default: - return nil, fmt.Errorf("unknown multi-search mode %q", mode) - } - if query == "" { - return nil, errors.New("empty multi-search query") - } - out = append(out, MultiSearchQuery{Mode: mode, Query: query}) - } - if len(out) == 0 { - return nil, errors.New("no multi-search queries") - } - return out, nil -} - -func MultiSearch(d *document.Document, start, length int64, spec string, maxPerQuery int) (map[string]any, error) { - queries, err := ParseMultiSearchSpec(spec) - if err != nil { - return nil, err - } - if maxPerQuery <= 0 { - maxPerQuery = 1000 - } - results := make([]map[string]any, 0, len(queries)) - all := []map[string]any{} - for i, q := range queries { - var hits []SearchHit - switch q.Mode { - case "text": - hits, err = SearchBytes(d, start, length, []byte(q.Query), maxPerQuery) - case "hex": - var needle []byte - needle, err = parseMultiSearchHex(q.Query) - if err == nil { - hits, err = SearchBytes(d, start, length, needle, maxPerQuery) - } - case "regex": - if _, err = regexp.Compile(q.Query); err == nil { - hits, err = SearchRegex(d, start, length, q.Query, maxPerQuery) - } - } - if err != nil { - return nil, fmt.Errorf("query %d: %w", i+1, err) - } - maps := HitsToMaps(hits) - name := fmt.Sprintf("%s:%s", q.Mode, q.Query) - for _, h := range maps { - h["mode"] = q.Mode - h["query"] = q.Query - h["name"] = name - all = append(all, h) - } - results = append(results, map[string]any{ - "mode": q.Mode, - "query": q.Query, - "count": len(hits), - "hits": maps, - }) - } - return map[string]any{ - "queries": results, - "all": all, - "total": len(all), - }, nil -} - -func parseMultiSearchHex(s string) ([]byte, error) { - s = strings.Map(func(r rune) rune { - switch r { - case ' ', '\t', '\n', '\r', ':', '-': - return -1 - default: - return r - } - }, strings.TrimSpace(s)) - s = strings.TrimPrefix(strings.ToLower(s), "0x") - if len(s)%2 != 0 { - s = "0" + s - } - return hex.DecodeString(s) -} diff --git a/internal/leviathan/analysis/overview.go b/internal/leviathan/analysis/overview.go deleted file mode 100644 index 4a626f4..0000000 --- a/internal/leviathan/analysis/overview.go +++ /dev/null @@ -1,88 +0,0 @@ -package analysis - -import ( - "github.com/andydixon/link/internal/leviathan/document" -) - -// Overview returns a compact first-pass report intended for both UI -// surfaces. It favours fast, bounded summaries over exhaustive output. -func Overview(d *document.Document) (map[string]any, error) { - size := d.Size() - hashes, err := Hashes(d, 0, -1) - if err != nil { - return nil, err - } - entropy, err := Entropy(d, 0, min64(size, 8<<20), 4096, 0) - if err != nil { - return nil, err - } - stringsOut, err := ExtractStrings(d, 0, min64(size, 16<<20), 4, []string{"ascii", "utf16le", "utf16be"}, 5000) - if err != nil { - return nil, err - } - iocs, err := ExtractIOCs(d, 0, min64(size, 16<<20), 5) - if err != nil { - return nil, err - } - carves, err := Carve(d, 0, min64(size, 64<<20), 128) - if err != nil { - return nil, err - } - return map[string]any{ - "name": d.Name(), - "size": size, - "hashes": hashes, - "entropy": overviewEntropy(entropy), - "strings": overviewStrings(stringsOut), - "iocs": iocs["totals"], - "carve": overviewCarve(carves), - "limits": map[string]any{ - "entropyBytes": min64(size, 8<<20), - "stringsBytes": min64(size, 16<<20), - "iocBytes": min64(size, 16<<20), - "carveBytes": min64(size, 64<<20), - }, - }, nil -} - -func overviewEntropy(in map[string]any) map[string]any { - return map[string]any{ - "windowSize": in["windowSize"], - "step": in["step"], - "mean": in["mean"], - "min": in["min"], - "max": in["max"], - "highCount": in["highCount"], - } -} - -func overviewStrings(items []map[string]any) map[string]any { - counts := map[string]int{} - for _, item := range items { - if enc, ok := item["encoding"].(string); ok { - counts[enc]++ - } - } - return map[string]any{ - "total": len(items), - "byEncoding": counts, - } -} - -func overviewCarve(items []map[string]any) map[string]any { - counts := map[string]int{} - valid := 0 - for _, item := range items { - if kind, ok := item["kind"].(string); ok { - counts[kind]++ - } - if status, ok := item["status"].(string); ok && status == "valid" { - valid++ - } - } - return map[string]any{ - "total": len(items), - "valid": valid, - "byKind": counts, - } -} diff --git a/internal/leviathan/analysis/rules.go b/internal/leviathan/analysis/rules.go deleted file mode 100644 index 7381788..0000000 --- a/internal/leviathan/analysis/rules.go +++ /dev/null @@ -1,648 +0,0 @@ -package analysis - -import ( - "context" - "encoding/hex" - "errors" - "regexp" - "strconv" - "strings" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// Rule represents a lightweight YARA-like rule. A rule matches when -// Condition evaluates to true for the document. Phase 1 supports: -// - "all of them" (every string pattern must appear) -// - "any of them" (at least one pattern must appear) -// - " of them" (at least N patterns) -// - named identifiers referencing specific strings with $x or !$x -type Rule struct { - Name string - Tags []string - Strings []RuleString - Condition string -} - -// RuleString is one string directive inside a rule. -type RuleString struct { - ID string - Kind string // "ascii", "hex", "regex" - Value string // original source text - // compiled: - raw []byte - regex *regexp.Regexp - nocase bool - ascii bool - wide bool - fullword bool -} - -// ParseRules parses a simplified YARA-like source: -// -// rule Name : tag1 tag2 { -// strings: -// $a = "hello" nocase -// $b = { 4d 5a ?? ?? } -// $c = /foo[0-9]+/ -// condition: -// any of them -// } -// -// Hex patterns support `??` wildcards by being converted to a regex. -func ParseRules(src string) ([]Rule, error) { - rules := []Rule{} - s := strings.TrimSpace(src) - if s == "" { - return rules, nil - } - // Split on "rule " keywords from start of line. - entries := splitRules(s) - for _, e := range entries { - r, err := parseRule(e) - if err != nil { - return rules, err - } - rules = append(rules, r) - } - return rules, nil -} - -func splitRules(src string) []string { - out := []string{} - i := 0 - for i < len(src) { - idx := strings.Index(src[i:], "rule ") - if idx < 0 { - break - } - j := i + idx - // find matching close brace (naïve but workable for phase 1) - depth := 0 - start := j - k := j - for k < len(src) { - c := src[k] - if c == '{' { - depth++ - } else if c == '}' { - depth-- - if depth == 0 { - out = append(out, src[start:k+1]) - k++ - break - } - } - k++ - } - i = k - } - return out -} - -var ruleHeaderRe = regexp.MustCompile(`(?s)^\s*rule\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*:\s*([^{]*))?\s*\{(.*)\}\s*$`) - -func parseRule(src string) (Rule, error) { - m := ruleHeaderRe.FindStringSubmatch(src) - if m == nil { - return Rule{}, errors.New("malformed rule") - } - r := Rule{Name: m[1]} - if tags := strings.TrimSpace(m[2]); tags != "" { - r.Tags = strings.Fields(tags) - } - body := m[3] - if i := strings.Index(body, "strings:"); i >= 0 { - sEnd := strings.Index(body, "condition:") - sect := body[i+len("strings:") : sEnd] - for _, line := range strings.Split(sect, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "//") { - continue - } - rs, err := parseRuleString(line) - if err != nil { - return r, err - } - r.Strings = append(r.Strings, rs) - } - } - if i := strings.Index(body, "condition:"); i >= 0 { - r.Condition = strings.TrimSpace(body[i+len("condition:"):]) - } else { - return r, errors.New("rule missing condition") - } - return r, nil -} - -var ruleStringRe = regexp.MustCompile(`^\s*(\$[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$`) - -func parseRuleString(line string) (RuleString, error) { - m := ruleStringRe.FindStringSubmatch(line) - if m == nil { - return RuleString{}, errors.New("bad string line: " + line) - } - id := m[1] - rest := strings.TrimSpace(m[2]) - value, modifiers, err := splitRuleValueModifiers(rest) - if err != nil { - return RuleString{}, err - } - rs := RuleString{ID: id, Value: value} - for _, mod := range modifiers { - switch strings.ToLower(mod) { - case "nocase": - rs.nocase = true - case "ascii": - rs.ascii = true - case "wide": - rs.wide = true - case "fullword": - rs.fullword = true - default: - return rs, errors.New("unknown string modifier: " + mod) - } - } - switch { - case strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) && len(value) >= 2: - rs.Kind = "ascii" - rs.raw = []byte(value[1 : len(value)-1]) - if !rs.ascii && !rs.wide { - rs.ascii = true - } - case strings.HasPrefix(value, "{") && strings.HasSuffix(value, "}"): - rs.Kind = "hex" - pattern := regexFromHexPattern(value[1 : len(value)-1]) - re, err := regexp.Compile("(?s)" + pattern) - if err != nil { - return rs, err - } - rs.regex = re - case strings.HasPrefix(value, "/") && strings.HasSuffix(value, "/"): - rs.Kind = "regex" - pat := value[1 : len(value)-1] - if rs.nocase { - pat = "(?i)" + pat - } - re, err := regexp.Compile(pat) - if err != nil { - return rs, err - } - rs.regex = re - default: - return rs, errors.New("unknown string form: " + value) - } - return rs, nil -} - -func splitRuleValueModifiers(rest string) (string, []string, error) { - rest = strings.TrimSpace(rest) - if rest == "" { - return "", nil, errors.New("empty string value") - } - var end int - switch rest[0] { - case '"': - escaped := false - end = -1 - for i := 1; i < len(rest); i++ { - c := rest[i] - if escaped { - escaped = false - continue - } - if c == '\\' { - escaped = true - continue - } - if c == '"' { - end = i + 1 - break - } - } - if end < 0 { - return "", nil, errors.New("unterminated string literal") - } - case '{': - idx := strings.IndexByte(rest, '}') - if idx < 0 { - return "", nil, errors.New("unterminated hex string") - } - end = idx + 1 - case '/': - escaped := false - end = -1 - for i := 1; i < len(rest); i++ { - c := rest[i] - if escaped { - escaped = false - continue - } - if c == '\\' { - escaped = true - continue - } - if c == '/' { - end = i + 1 - break - } - } - if end < 0 { - return "", nil, errors.New("unterminated regex") - } - default: - return "", nil, errors.New("unknown string form: " + rest) - } - return strings.TrimSpace(rest[:end]), strings.Fields(strings.TrimSpace(rest[end:])), nil -} - -func regexFromHexPattern(hexPat string) string { - var sb strings.Builder - hexPat = strings.ReplaceAll(hexPat, " ", "") - for i := 0; i < len(hexPat); { - if i+1 >= len(hexPat) { - break - } - a, b := hexPat[i], hexPat[i+1] - if a == '?' && b == '?' { - sb.WriteString(`[\x00-\xff]`) - } else { - decoded, err := hex.DecodeString(string([]byte{a, b})) - if err != nil { - // bail to literal to avoid panic - sb.WriteString(regexp.QuoteMeta(string([]byte{a, b}))) - } else { - sb.WriteString(regexp.QuoteMeta(string(decoded))) - } - } - i += 2 - } - return sb.String() -} - -// RuleMatch is a per-rule result. -type RuleMatch struct { - Name string - Tags []string - Matched bool - Hits map[string][]SearchHit -} - -// RunRules scans rules against the document range and returns matches. -func RunRules(d *document.Document, start, length int64, rules []Rule, maxPerString int) ([]map[string]any, error) { - return RunRulesContext(context.Background(), d, start, length, rules, maxPerString) -} - -func RunRulesContext(ctx context.Context, d *document.Document, start, length int64, rules []Rule, maxPerString int) ([]map[string]any, error) { - if maxPerString <= 0 { - maxPerString = 1000 - } - out := make([]map[string]any, 0, len(rules)) - for _, r := range rules { - if err := ctx.Err(); err != nil { - return nil, err - } - hits := map[string][]SearchHit{} - for _, s := range r.Strings { - if err := ctx.Err(); err != nil { - return nil, err - } - switch s.Kind { - case "ascii": - found, err := searchRuleTextContext(ctx, d, start, length, s, maxPerString) - if err != nil { - return nil, err - } - hits[s.ID] = found - case "hex", "regex": - found, err := SearchRegexContext(ctx, d, start, length, s.regex.String(), maxPerString) - if err != nil { - return nil, err - } - if s.fullword { - found = filterFullWordHits(d, found, false) - } - hits[s.ID] = found - } - } - matched := evalCondition(r, hits) - var hitFlat []map[string]any - for id, hs := range hits { - for _, h := range hs { - hitFlat = append(hitFlat, map[string]any{ - "id": id, - "offset": h.Offset, - "length": h.Length, - "sample": h.Sample, - }) - } - } - out = append(out, map[string]any{ - "name": r.Name, - "tags": toAnySlice(r.Tags), - "matched": matched, - "hits": hitFlat, - }) - } - return out, nil -} - -func searchRuleText(d *document.Document, start, length int64, s RuleString, max int) ([]SearchHit, error) { - return searchRuleTextContext(context.Background(), d, start, length, s, max) -} - -func searchRuleTextContext(ctx context.Context, d *document.Document, start, length int64, s RuleString, max int) ([]SearchHit, error) { - out := []SearchHit{} - if s.ascii { - var ( - found []SearchHit - err error - ) - if s.nocase { - found, err = SearchRegexContext(ctx, d, start, length, "(?i)"+regexp.QuoteMeta(string(s.raw)), max) - } else { - found, err = SearchBytesContext(ctx, d, start, length, s.raw, max) - } - if err != nil { - return nil, err - } - if s.fullword { - found = filterFullWordHits(d, found, false) - } - out = append(out, found...) - } - if s.wide { - wide := utf16LEBytes(s.raw) - var ( - found []SearchHit - err error - ) - if s.nocase { - pat := wideNoCasePattern(s.raw) - found, err = SearchRegexContext(ctx, d, start, length, pat, max) - } else { - found, err = SearchBytesContext(ctx, d, start, length, wide, max) - } - if err != nil { - return nil, err - } - if s.fullword { - found = filterFullWordHits(d, found, true) - } - out = append(out, found...) - } - if len(out) > max { - out = out[:max] - } - return out, nil -} - -func utf16LEBytes(in []byte) []byte { - out := make([]byte, 0, len(in)*2) - for _, b := range in { - out = append(out, b, 0) - } - return out -} - -func wideNoCasePattern(in []byte) string { - var sb strings.Builder - sb.WriteString("(?i)") - for _, b := range in { - sb.WriteString(regexp.QuoteMeta(string([]byte{b, 0}))) - } - return sb.String() -} - -func filterFullWordHits(d *document.Document, hits []SearchHit, wide bool) []SearchHit { - out := hits[:0] - for _, h := range hits { - if isFullWordHit(d, h, wide) { - out = append(out, h) - } - } - return out -} - -func isFullWordHit(d *document.Document, h SearchHit, wide bool) bool { - if wide { - beforeOff := h.Offset - 2 - afterOff := h.Offset + int64(h.Length) - beforeWord := false - afterWord := false - if beforeOff >= 0 { - if b, err := d.Range(beforeOff, 2); err == nil && len(b) == 2 && b[1] == 0 { - beforeWord = isWordByte(b[0]) - } - } - if afterOff+1 < d.Size() { - if b, err := d.Range(afterOff, 2); err == nil && len(b) == 2 && b[1] == 0 { - afterWord = isWordByte(b[0]) - } - } - return !beforeWord && !afterWord - } - beforeWord := false - afterWord := false - if h.Offset > 0 { - if b, err := d.At(h.Offset - 1); err == nil { - beforeWord = isWordByte(b) - } - } - if h.Offset+int64(h.Length) < d.Size() { - if b, err := d.At(h.Offset + int64(h.Length)); err == nil { - afterWord = isWordByte(b) - } - } - return !beforeWord && !afterWord -} - -func isWordByte(b byte) bool { - return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '_' -} - -func toAnySlice(in []string) []any { - out := make([]any, len(in)) - for i, s := range in { - out[i] = s - } - return out -} - -func evalCondition(r Rule, hits map[string][]SearchHit) bool { - cond := strings.TrimSpace(r.Condition) - condLower := strings.ToLower(cond) - present := 0 - for _, s := range r.Strings { - if len(hits[s.ID]) > 0 { - present++ - } - } - switch { - case condLower == "any of them": - return present > 0 - case condLower == "all of them": - return present == len(r.Strings) - case strings.HasSuffix(condLower, "of them"): - // " of them" - parts := strings.Fields(condLower) - if len(parts) >= 3 { - n, ok := parseRuleCount(parts[0]) - return ok && present >= n - } - } - if ok, matched := evalRuleSetCondition(cond, hits); ok { - return matched - } - p := conditionParser{tokens: tokenizeCondition(cond), hits: hits} - return p.parse() -} - -func evalRuleSetCondition(cond string, hits map[string][]SearchHit) (bool, bool) { - tokens := tokenizeCondition(cond) - if len(tokens) < 4 || strings.ToLower(tokens[1]) != "of" || tokens[2] != "(" || tokens[len(tokens)-1] != ")" { - return false, false - } - ids := []string{} - for _, tok := range tokens[3 : len(tokens)-1] { - if tok == "," { - continue - } - if strings.HasPrefix(tok, "$") { - ids = append(ids, tok) - } - } - if len(ids) == 0 { - return true, false - } - count := 0 - for _, id := range ids { - if len(hits[id]) > 0 { - count++ - } - } - switch strings.ToLower(tokens[0]) { - case "any": - return true, count > 0 - case "all": - return true, count == len(ids) - default: - n, ok := parseRuleCount(strings.ToLower(tokens[0])) - return true, ok && count >= n - } -} - -func parseRuleCount(s string) (int, bool) { - switch s { - case "1", "one": - return 1, true - case "2", "two": - return 2, true - case "3", "three": - return 3, true - default: - n, err := strconv.Atoi(s) - return n, err == nil - } -} - -type conditionParser struct { - tokens []string - pos int - hits map[string][]SearchHit -} - -func (p *conditionParser) parse() bool { - v := p.parseOr() - return v -} - -func (p *conditionParser) parseOr() bool { - v := p.parseAnd() - for strings.EqualFold(p.peek(), "or") { - p.next() - v = p.parseAnd() || v - } - return v -} - -func (p *conditionParser) parseAnd() bool { - v := p.parseNot() - for strings.EqualFold(p.peek(), "and") { - p.next() - v = p.parseNot() && v - } - return v -} - -func (p *conditionParser) parseNot() bool { - if strings.EqualFold(p.peek(), "not") || p.peek() == "!" { - p.next() - return !p.parseNot() - } - return p.parsePrimary() -} - -func (p *conditionParser) parsePrimary() bool { - tok := p.next() - switch { - case tok == "": - return false - case tok == "(": - v := p.parseOr() - if p.peek() == ")" { - p.next() - } - return v - case strings.EqualFold(tok, "true"): - return true - case strings.EqualFold(tok, "false"): - return false - case strings.HasPrefix(tok, "$"): - return len(p.hits[tok]) > 0 - default: - return false - } -} - -func (p *conditionParser) peek() string { - if p.pos >= len(p.tokens) { - return "" - } - return p.tokens[p.pos] -} - -func (p *conditionParser) next() string { - if p.pos >= len(p.tokens) { - return "" - } - tok := p.tokens[p.pos] - p.pos++ - return tok -} - -func tokenizeCondition(cond string) []string { - out := []string{} - for i := 0; i < len(cond); { - c := cond[i] - if c == ' ' || c == '\t' || c == '\n' || c == '\r' { - i++ - continue - } - if strings.ContainsRune("(),!", rune(c)) { - out = append(out, string(c)) - i++ - continue - } - start := i - for i < len(cond) { - c = cond[i] - if c == ' ' || c == '\t' || c == '\n' || c == '\r' || strings.ContainsRune("(),!", rune(c)) { - break - } - i++ - } - out = append(out, cond[start:i]) - } - return out -} diff --git a/internal/leviathan/analysis/search.go b/internal/leviathan/analysis/search.go deleted file mode 100644 index 9818d93..0000000 --- a/internal/leviathan/analysis/search.go +++ /dev/null @@ -1,189 +0,0 @@ -package analysis - -import ( - "bytes" - "context" - "errors" - "regexp" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// SearchHit describes one search result. -type SearchHit struct { - Offset int64 `json:"offset"` - Length int `json:"length"` - Sample string `json:"sample"` // short hex preview -} - -// SearchBytes finds `needle` in the document range and returns hit -// offsets. Scans chunk by chunk with an overlap equal to len(needle)-1 -// so cross-chunk matches are not missed. -func SearchBytes(d *document.Document, start, length int64, needle []byte, max int) ([]SearchHit, error) { - return SearchBytesContext(context.Background(), d, start, length, needle, max) -} - -func SearchBytesContext(ctx context.Context, d *document.Document, start, length int64, needle []byte, max int) ([]SearchHit, error) { - if len(needle) == 0 { - return nil, errors.New("empty needle") - } - if max <= 0 { - max = 10_000 - } - if length < 0 { - length = d.Size() - start - } - - hits := make([]SearchHit, 0, 64) - overlap := int64(len(needle) - 1) - - // We chunk in windows of chunkSize + overlap. - const chunkSize = 1 << 20 // 1MB - off := start - endExclusive := start + length - - for off < endExclusive && len(hits) < max { - if err := ctx.Err(); err != nil { - return nil, err - } - winEnd := off + chunkSize - if winEnd > endExclusive { - winEnd = endExclusive - } - readEnd := winEnd + overlap - if readEnd > endExclusive { - readEnd = endExclusive - } - buf, err := d.Range(off, readEnd-off) - if err != nil { - return nil, err - } - // Limit search to bytes whose start lies in [0, winEnd-off) - searchLimit := winEnd - off - scan := 0 - for { - if err := ctx.Err(); err != nil { - return nil, err - } - idx := bytes.Index(buf[scan:], needle) - if idx < 0 { - break - } - abs := int64(scan+idx) + off - relStart := abs - off - if relStart >= searchLimit { - break - } - hits = append(hits, SearchHit{ - Offset: abs, - Length: len(needle), - Sample: hexPreview(buf[scan+idx:], len(needle)), - }) - if len(hits) >= max { - break - } - scan = scan + idx + 1 - if scan >= len(buf) { - break - } - } - off = winEnd - } - return hits, nil -} - -// SearchRegex runs a Go regexp across the document range, matching -// byte-wise. It decodes matches chunk by chunk with overlap, bounded -// by a generous max hit count. -func SearchRegex(d *document.Document, start, length int64, pattern string, max int) ([]SearchHit, error) { - return SearchRegexContext(context.Background(), d, start, length, pattern, max) -} - -func SearchRegexContext(ctx context.Context, d *document.Document, start, length int64, pattern string, max int) ([]SearchHit, error) { - re, err := regexp.Compile(pattern) - if err != nil { - return nil, err - } - if max <= 0 { - max = 10_000 - } - if length < 0 { - length = d.Size() - start - } - - // For regex, a conservative overlap is 4 KiB so matches spanning - // chunk boundaries are still caught. Very long matches may still - // be missed in extreme cases. - const chunkSize = 1 << 20 - const overlap = 4096 - - hits := make([]SearchHit, 0, 64) - off := start - endExclusive := start + length - - for off < endExclusive && len(hits) < max { - if err := ctx.Err(); err != nil { - return nil, err - } - winEnd := off + chunkSize - if winEnd > endExclusive { - winEnd = endExclusive - } - readEnd := winEnd + overlap - if readEnd > endExclusive { - readEnd = endExclusive - } - buf, err := d.Range(off, readEnd-off) - if err != nil { - return nil, err - } - searchLimit := winEnd - off - locs := re.FindAllIndex(buf, max-len(hits)) - for _, loc := range locs { - if err := ctx.Err(); err != nil { - return nil, err - } - relStart := int64(loc[0]) - if relStart >= searchLimit { - continue - } - abs := relStart + off - hits = append(hits, SearchHit{ - Offset: abs, - Length: loc[1] - loc[0], - Sample: hexPreview(buf[loc[0]:loc[1]], loc[1]-loc[0]), - }) - } - off = winEnd - } - return hits, nil -} - -func hexPreview(b []byte, n int) string { - if n > 32 { - n = 32 - } - if n > len(b) { - n = len(b) - } - const hexchars = "0123456789abcdef" - out := make([]byte, n*2) - for i := 0; i < n; i++ { - out[i*2] = hexchars[b[i]>>4] - out[i*2+1] = hexchars[b[i]&0x0f] - } - return string(out) -} - -// HitsToMaps converts hits for bridge return. -func HitsToMaps(hits []SearchHit) []map[string]any { - out := make([]map[string]any, len(hits)) - for i, h := range hits { - out[i] = map[string]any{ - "offset": h.Offset, - "length": h.Length, - "sample": h.Sample, - } - } - return out -} diff --git a/internal/leviathan/analysis/strings.go b/internal/leviathan/analysis/strings.go deleted file mode 100644 index f6a9f1a..0000000 --- a/internal/leviathan/analysis/strings.go +++ /dev/null @@ -1,198 +0,0 @@ -package analysis - -import ( - "context" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// StringHit represents one extracted string. -type StringHit struct { - Offset int64 `json:"offset"` - Length int `json:"length"` - Encoding string `json:"encoding"` // ascii | utf16le | utf16be - Value string `json:"value"` -} - -// ExtractStrings finds printable runs in the specified range. -// Supports ASCII and UTF-16 LE/BE. minLen is in characters. -func ExtractStrings(d *document.Document, start, length int64, minLen int, encodings []string, max int) ([]map[string]any, error) { - return ExtractStringsContext(context.Background(), d, start, length, minLen, encodings, max) -} - -func ExtractStringsContext(ctx context.Context, d *document.Document, start, length int64, minLen int, encodings []string, max int) ([]map[string]any, error) { - if minLen <= 0 { - minLen = 4 - } - if max <= 0 { - max = 50_000 - } - if length < 0 { - length = d.Size() - start - } - - doASCII, doLE, doBE := false, false, false - if len(encodings) == 0 { - doASCII, doLE, doBE = true, true, true - } else { - for _, e := range encodings { - switch e { - case "ascii": - doASCII = true - case "utf16le": - doLE = true - case "utf16be": - doBE = true - } - } - } - - out := make([]map[string]any, 0, 256) - - // ASCII - if doASCII { - if err := appendASCII(ctx, d, start, length, minLen, &out, max); err != nil { - return nil, err - } - } - // UTF-16 - if len(out) < max && doLE { - if err := appendUTF16(ctx, d, start, length, minLen, true, &out, max); err != nil { - return nil, err - } - } - if len(out) < max && doBE { - if err := appendUTF16(ctx, d, start, length, minLen, false, &out, max); err != nil { - return nil, err - } - } - return out, nil -} - -func appendASCII(ctx context.Context, d *document.Document, start, length int64, minLen int, out *[]map[string]any, max int) error { - // Chunked scan with a carry for runs crossing chunk boundaries. - var runStart int64 = -1 - var runBuf []byte - emit := func() { - if runStart >= 0 && len(runBuf) >= minLen { - *out = append(*out, map[string]any{ - "offset": runStart, - "length": len(runBuf), - "encoding": "ascii", - "value": string(runBuf), - }) - } - runStart = -1 - runBuf = runBuf[:0] - } - - if err := d.ForEachChunk(start, length, func(off int64, chunk []byte) error { - if err := ctx.Err(); err != nil { - return err - } - for i, b := range chunk { - if i%4096 == 0 { - if err := ctx.Err(); err != nil { - return err - } - } - if isPrintableASCII(b) { - if runStart < 0 { - runStart = off + int64(i) - runBuf = make([]byte, 0, 32) - } - runBuf = append(runBuf, b) - } else { - if len(*out) >= max { - return nil - } - emit() - } - } - return nil - }); err != nil { - return err - } - emit() - return nil -} - -func appendUTF16(ctx context.Context, d *document.Document, start, length int64, minLen int, le bool, out *[]map[string]any, max int) error { - // Step by 2. Produce one string for each contiguous run of printable code units. - if length < 2 { - return nil - } - // Read chunks aligned to 2 bytes so pairs don't split. - const chunkSize = 1 << 20 - end := start + length - var runStart int64 = -1 - var runBuf []rune - - enc := "utf16le" - if !le { - enc = "utf16be" - } - - emit := func() { - if runStart >= 0 && len(runBuf) >= minLen { - *out = append(*out, map[string]any{ - "offset": runStart, - "length": len(runBuf) * 2, - "encoding": enc, - "value": string(runBuf), - }) - } - runStart = -1 - runBuf = runBuf[:0] - } - - for off := start; off+1 < end; { - if err := ctx.Err(); err != nil { - return err - } - winEnd := off + chunkSize - if winEnd > end { - winEnd = end - } - if (winEnd-off)%2 != 0 { - winEnd-- // keep pair alignment - } - buf, err := d.Range(off, winEnd-off) - if err != nil { - return err - } - for i := 0; i+1 < len(buf); i += 2 { - if i%4096 == 0 { - if err := ctx.Err(); err != nil { - return err - } - } - var cu uint16 - if le { - cu = uint16(buf[i]) | uint16(buf[i+1])<<8 - } else { - cu = uint16(buf[i])<<8 | uint16(buf[i+1]) - } - if cu >= 0x20 && cu <= 0x7e { - if runStart < 0 { - runStart = off + int64(i) - runBuf = make([]rune, 0, 32) - } - runBuf = append(runBuf, rune(cu)) - } else { - if len(*out) >= max { - emit() - return nil - } - emit() - } - } - off = winEnd - } - emit() - return nil -} - -func isPrintableASCII(b byte) bool { - return b == '\t' || (b >= 0x20 && b <= 0x7e) -} diff --git a/internal/leviathan/analysis/timestamps.go b/internal/leviathan/analysis/timestamps.go deleted file mode 100644 index 439d917..0000000 --- a/internal/leviathan/analysis/timestamps.go +++ /dev/null @@ -1,106 +0,0 @@ -package analysis - -import ( - "encoding/binary" - "math" - "time" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// ScanTimestamps walks the range looking for plausible timestamps in -// common encodings. This is a heuristic: it reports any 4/8-byte -// window whose decoded time falls in [1990-01-01, 2050-12-31]. -func ScanTimestamps(d *document.Document, start, length int64, maxHits int) (map[string]any, error) { - if length < 0 { - length = d.Size() - start - } - if maxHits <= 0 { - maxHits = 20_000 - } - - minT := time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC).Unix() - maxT := time.Date(2050, 12, 31, 0, 0, 0, 0, time.UTC).Unix() - ok := func(sec int64) bool { return sec >= minT && sec <= maxT } - - hits := make([]map[string]any, 0, 256) - counts := map[string]int{} - - const chunk = 1 << 20 - end := start + length - overlap := int64(7) // enough for 8-byte types - for off := start; off < end && len(hits) < maxHits; { - winEnd := off + chunk - if winEnd > end { - winEnd = end - } - readEnd := winEnd + overlap - if readEnd > end { - readEnd = end - } - buf, err := d.Range(off, readEnd-off) - if err != nil { - return nil, err - } - limit := int(winEnd - off) - for i := 0; i < limit && len(hits) < maxHits; i += 4 { - if i+4 <= len(buf) { - vLE := int64(int32(binary.LittleEndian.Uint32(buf[i:]))) - if ok(vLE) { - hits = append(hits, hit("unix_le", off+int64(i), 4, time.Unix(vLE, 0).UTC())) - counts["unix_le"]++ - continue - } - vBE := int64(int32(binary.BigEndian.Uint32(buf[i:]))) - if ok(vBE) { - hits = append(hits, hit("unix_be", off+int64(i), 4, time.Unix(vBE, 0).UTC())) - counts["unix_be"]++ - continue - } - } - if i+8 <= len(buf) { - v := binary.LittleEndian.Uint64(buf[i:]) - // Windows FILETIME - sec := int64(v/10_000_000) - filetimeEpochDelta - if ok(sec) { - hits = append(hits, hit("filetime_le", off+int64(i), 8, time.Unix(sec, 0).UTC())) - counts["filetime_le"]++ - continue - } - // WebKit (microseconds since 1601) - sec = int64(v/1_000_000) - filetimeEpochDelta - if ok(sec) { - hits = append(hits, hit("webkit_le", off+int64(i), 8, time.Unix(sec, 0).UTC())) - counts["webkit_le"]++ - continue - } - // OLE automation date - fdays := math.Float64frombits(v) - if fdays > 2 && fdays < 75000 { - base := time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC) - t := base.Add(time.Duration(fdays*86400) * time.Second) - if t.Unix() >= minT && t.Unix() <= maxT { - hits = append(hits, hit("ole_le", off+int64(i), 8, t)) - counts["ole_le"]++ - continue - } - } - } - } - off = winEnd - } - return map[string]any{ - "hits": hits, - "counts": counts, - }, nil -} - -func hit(kind string, off int64, size int, t time.Time) map[string]any { - return map[string]any{ - "kind": kind, - "offset": off, - "length": size, - "iso": t.Format(time.RFC3339), - "unix": t.Unix(), - } -} diff --git a/internal/leviathan/binsnoop/README.md b/internal/leviathan/binsnoop/README.md deleted file mode 100644 index c37c8e3..0000000 --- a/internal/leviathan/binsnoop/README.md +++ /dev/null @@ -1,277 +0,0 @@ -# binsnoop - -A pure-Go binary analysis library for detecting and extracting embedded files, firmware images, compressed data, filesystems, and more from binary blobs. Ported from the [binwalk](https://github.com/ReFirmLabs/binwalk) Rust codebase with like-for-like scanning logic. - -**Zero external dependencies** — everything is self-contained, including the Aho-Corasick multi-pattern matcher, CRC32 validation, and binary structure parsing. - -## Features - -- **111 built-in signatures** covering archives, compression, filesystems, images, executables, firmware headers, cryptographic constructs, and more -- **Aho-Corasick** multi-pattern scanning with overlapping match support -- **Signature validation** — every magic byte match is verified by a dedicated parser that checks headers, checksums, and structural integrity -- **Confidence scoring** — results are ranked Low / Medium / High so callers can filter noise -- **Extraction support** — carve identified objects out of a binary, or invoke external tools (7z, unsquashfs, etc.) to fully extract them -- **Filtering** — include/exclude signatures by name, restrict short signatures to offset 0, or add custom signatures -- **JSON-friendly** — all result types carry `json` struct tags for easy serialisation - -## Installation - -```bash -go get binsnoop -``` - -Or add to your `go.mod`: - -``` -require binsnoop v0.0.0 -``` - -## Quick Start - -```go -package main - -import ( - "fmt" - "binsnoop" -) - -func main() { - scanner := binsnoop.New() - - // Scan a file - results, err := scanner.ScanFile("firmware.bin") - if err != nil { - panic(err) - } - - for _, r := range results { - fmt.Printf("[0x%08X] %s (confidence: %d, size: %d)\n", - r.Offset, r.Description, r.Confidence, r.Size) - } -} -``` - -## API Reference - -### Creating a Scanner - -```go -// Default scanner with all 111 signatures -scanner := binsnoop.New() - -// Scanner with options -scanner, err := binsnoop.NewWithOptions(binsnoop.Options{ - Include: []string{"gzip", "squashfs", "uimage"}, // only these signatures - Exclude: []string{"copyright"}, // remove these signatures - FullSearch: true, // match short sigs at any offset - AdditionalSignatures: []binsnoop.Signature{...}, // add custom signatures -}) -``` - -### Scanning - -```go -// Scan raw bytes -results := scanner.ScanBytes(data) - -// Scan a file on disk -results, err := scanner.ScanFile("/path/to/firmware.bin") -``` - -Both return `[]SignatureResult` sorted by offset. - -### Analysis (Scan + Extraction) - -```go -// Analyse raw bytes (with optional extraction) -analysis := scanner.AnalyzeBytes(data, "firmware.bin", true) - -// Analyse a file on disk -analysis, err := scanner.AnalyzeFile("/path/to/firmware.bin", true) -``` - -Returns `*AnalysisResults` containing the file map and extraction outcomes. - -### Carving - -Extract a single identified object from raw data: - -```go -err := binsnoop.CarveResult(data, results[0], "/tmp/output") -``` - -This writes the bytes at the result's offset/size to a file in the output directory. - -### Listing Signatures - -```go -names := binsnoop.ListSignatures() // []string of all registered signature names -``` - -### Scanner Info - -```go -scanner.SignatureCount() // number of registered signatures (111) -scanner.PatternCount() // total magic byte patterns across all signatures -``` - -## Types - -### `SignatureResult` - -```go -type SignatureResult struct { - Offset int `json:"offset"` // byte offset in the input data - ID string `json:"id"` // unique identifier for this match - Size int `json:"size"` // size in bytes (0 = unknown) - Name string `json:"name"` // signature name (e.g. "gzip") - Confidence uint8 `json:"confidence"` // 0=Low, 128=Medium, 250=High - Description string `json:"description"` // human-readable description - AlwaysDisplay bool `json:"always_display"` // show even if low confidence - ExtractionDeclined bool `json:"extraction_declined"` // parser advises against extraction - PreferredExtractor *Extractor `json:"-"` // extractor to use for this result -} -``` - -### `Options` - -```go -type Options struct { - Include []string // restrict to these signature names - Exclude []string // remove these signature names - FullSearch bool // short sigs match at any offset - AdditionalSignatures []Signature // user-defined signatures -} -``` - -### `AnalysisResults` - -```go -type AnalysisResults struct { - FilePath string `json:"file_path"` - FileMap []SignatureResult `json:"file_map"` - Extractions map[string]*ExtractionResult `json:"extractions"` -} -``` - -### Confidence Levels - -| Constant | Value | Meaning | -|----------|-------|---------| -| `ConfidenceLow` | 0 | Magic matched but weak validation | -| `ConfidenceMedium` | 128 | Header parsed and mostly valid | -| `ConfidenceHigh` | 250 | Full structural validation passed | - -## Custom Signatures - -You can add your own signatures at scanner creation: - -```go -mySignature := binsnoop.Signature{ - Name: "my_format", - Magic: [][]byte{[]byte("MYMAGIC")}, - Parser: func(data []byte, offset int) (*binsnoop.SignatureResult, error) { - // Validate the match and return a result - if offset+16 > len(data) { - return nil, fmt.Errorf("insufficient data") - } - return &binsnoop.SignatureResult{ - Offset: offset, - Size: 1024, - Description: "My custom format, size: 1024 bytes", - Confidence: binsnoop.ConfidenceHigh, - }, nil - }, - Description: "My custom file format", -} - -scanner, _ := binsnoop.NewWithOptions(binsnoop.Options{ - AdditionalSignatures: []binsnoop.Signature{mySignature}, -}) -``` - -## Supported Signatures - -### Archives (12) -gzip, zip, tarball, 7zip, rar, cab, deb, cpio, arj, iso9660, dahua\_zip - -### Compression (10) -lzma, bzip2, xz, zstd, lz4, lzop, lzfse, zlib, compress'd - -### Filesystems (13) -squashfs, cramfs, jffs2, yaffs, romfs, ext, fat, ntfs, apfs, btrfs, logfs, ubi, ubifs - -### Images & Media (7) -png, jpeg, bmp, gif, svg, riff, pdf - -### Executables (2) -elf, pe - -### Firmware (32) -uimage, uboot, trx, tplink, tplink\_rtos, dlob, arcadyan, chk, seama, cfe, jboot\_arm, jboot\_stag, jboot\_sch2, packimg, binhdr, autel, android\_sparse, android\_bootimg, dtb, dlink\_tlv, dlke, shrs, dkbs, dms, encfw, encrpted\_img, wince, mh01, csman, dxbc, matter\_ota, rtk - -### Cryptographic (17) -aes\_sbox, aes\_forward\_table, aes\_reverse\_table, aes\_rcon, aes\_acceleration\_table, rsa, openssl, gpg\_signed, pem\_certificate, pem\_public\_key, pem\_private\_key, pkcs\_der\_hash, luks, crc32, sha256, md5 - -### Miscellaneous (18) -copyright, wind\_kernel, vxworks\_symtab, ecos, linux\_kernel, linux\_boot\_image, linux\_arm\_zimage, linux\_arm64\_boot\_image, efigpt, mbr, uefi\_pi\_volume, uefi\_capsule, pchrom, pcapng, pjl, srecord, dmg, qnx\_ifs, dpapi, qcow - -## Extraction - -Many signatures have associated extractors that invoke external command-line tools. When you call `AnalyzeFile` or `AnalyzeBytes` with `extract: true`, binsnoop will: - -1. Carve the identified data to a temporary file -2. Run the configured external tool (e.g. `7z x`, `unsquashfs`, `jefferson`) -3. Return the extraction results including output directory and success/failure status - -**Common external tools used:** - -| Tool | Signatures | -|------|-----------| -| `7z` | zip, cpio, arj, iso9660, xz, lzma, squashfs, dmg, efigpt | -| `gzip` | gzip | -| `bzip2` | bzip2 | -| `zstd` | zstd | -| `lz4` | lz4 | -| `unsquashfs` | squashfs | -| `jefferson` | jffs2 | -| `unyaffs` | yaffs | -| `tsk_recover` | ext, fat, ntfs | -| `uefi-firmware-parser` | uefi\_pi\_volume, uefi\_capsule, pchrom | -| `simg2img` | android\_sparse | -| `dtc` | dtb | - -## How It Works - -### Scanning Algorithm - -1. **Short-signature pass** — signatures marked `Short` (e.g. zlib, MBR) are checked only at offset 0 (unless `FullSearch` is enabled) -2. **Aho-Corasick scan** — all magic byte patterns are compiled into an Aho-Corasick automaton for efficient multi-pattern matching. When a high-confidence sized match is found, scanning restarts from the end of that match -3. **Validation** — every magic match is passed to its signature parser which validates headers, checksums, and structure -4. **Post-processing** — results are sorted by offset, overlapping/duplicate matches are resolved (higher confidence wins), and unknown sizes are estimated based on the next significant match or EOF - -### Architecture - -``` -binsnoop.go Public API (New, ScanBytes, ScanFile, AnalyzeFile, CarveResult, etc.) -scanner.go Core scan engine (Aho-Corasick integration, post-processing) -match.go Self-contained Aho-Corasick trie with overlapping match support -parse.go Generic binary structure parser (u8/u16/u24/u32/u64, big/little endian) -types.go Core types (SignatureResult, Signature, Extractor, Options, etc.) -common.go Utility functions (CRC32, endian helpers, string helpers) -extract.go Carving and external tool execution -magic.go Signature registry — wires all 111 signatures together -sig_archives.go Archive signature parsers -sig_compression.go Compression signature parsers -sig_filesystems.go Filesystem signature parsers -sig_images.go Image/media signature parsers -sig_executables.go Executable signature parsers -sig_firmware.go Firmware header signature parsers -sig_crypto.go Cryptographic signature parsers -sig_misc.go Miscellaneous signature parsers -``` - -## License - -This project is a Go port of [binwalk](https://github.com/ReFirmLabs/binwalk) by ReFirmLabs. See the original project for licensing details. diff --git a/internal/leviathan/binsnoop/binsnoop.go b/internal/leviathan/binsnoop/binsnoop.go deleted file mode 100644 index 3c38222..0000000 --- a/internal/leviathan/binsnoop/binsnoop.go +++ /dev/null @@ -1,177 +0,0 @@ -// Package binsnoop identifies and optionally extracts files embedded inside binary data. -// -// It is a Go port of the binwalk scanning engine, implementing all magic byte signatures, -// structure parsers, and the Aho-Corasick based multi-pattern matching algorithm. -// -// # Quick Start -// -// scanner := binsnoop.New() -// data, _ := os.ReadFile("firmware.bin") -// results := scanner.Scan(data) -// for _, r := range results { -// fmt.Printf("0x%08X %s\n", r.Offset, r.Description) -// } -// -// # Extraction -// -// // Extract a specific result -// err := binsnoop.CarveResult(data, results[0], "/tmp/output") -// -// # Filtering -// -// // Only scan for specific signatures -// scanner := binsnoop.NewWithOptions(Options{Include: []string{"gzip", "squashfs"}}) -package binsnoop - -import ( - "fmt" - "os" - "path/filepath" - "strings" -) - -// Options configures the Scanner. -type Options struct { - // Include restricts scanning to only these signature names. - Include []string - // Exclude removes these signature names from scanning. - Exclude []string - // FullSearch makes short signatures match at any offset, not just offset 0. - FullSearch bool - // AdditionalSignatures adds user-defined signatures to the scanner. - AdditionalSignatures []Signature -} - -// New creates a Scanner with all default signatures enabled. -func New() *Scanner { - s, _ := NewWithOptions(Options{}) - return s -} - -// NewWithOptions creates a Scanner with the specified options. -func NewWithOptions(opts Options) (*Scanner, error) { - s := &Scanner{ - patternSigTable: make(map[int]Signature), - extractorLookup: make(map[string]*Extractor), - } - - allSigs := AllSignatures() - if opts.AdditionalSignatures != nil { - allSigs = append(allSigs, opts.AdditionalSignatures...) - } - - for _, sig := range allSigs { - if !includeSig(&sig, opts.Include, opts.Exclude) { - continue - } - - s.signatureCount++ - s.patternCount += len(sig.Magic) - - // Register extractor - var ext *Extractor - if sig.Extractor != nil { - extCopy := *sig.Extractor - ext = &extCopy - } - s.extractorLookup[sig.Name] = ext - - for _, pattern := range sig.Magic { - if sig.Short && !opts.FullSearch { - s.shortSignatures = append(s.shortSignatures, sig) - break - } - s.patternSigTable[len(s.patterns)] = sig - s.patterns = append(s.patterns, pattern) - } - } - - s.compile() - return s, nil -} - -// ScanBytes scans raw bytes for signatures and returns all matches. -func (s *Scanner) ScanBytes(data []byte) []SignatureResult { - return s.Scan(data) -} - -// ScanFile reads a file from disk and scans it for signatures. -func (s *Scanner) ScanFile(path string) ([]SignatureResult, error) { - data, err := ReadFile(path) - if err != nil { - return nil, fmt.Errorf("binsnoop: read file: %w", err) - } - return s.Scan(data), nil -} - -// AnalyzeBytes scans and optionally extracts from raw bytes. -func (s *Scanner) AnalyzeBytes(data []byte, filePath string, extract bool) *AnalysisResults { - results := &AnalysisResults{ - FilePath: filePath, - Extractions: make(map[string]*ExtractionResult), - } - - results.FileMap = s.Scan(data) - - if extract && len(results.FileMap) > 0 { - results.Extractions = s.Extract(data, filePath, results.FileMap) - } - - return results -} - -// AnalyzeFile reads a file from disk, scans, and optionally extracts. -func (s *Scanner) AnalyzeFile(path string, extract bool) (*AnalysisResults, error) { - data, err := ReadFile(path) - if err != nil { - return nil, fmt.Errorf("binsnoop: read file: %w", err) - } - return s.AnalyzeBytes(data, path, extract), nil -} - -// CarveResult extracts raw bytes for a specific signature result to a file. -func CarveResult(data []byte, result SignatureResult, outputDir string) error { - if result.Offset < 0 || result.Offset+result.Size > len(data) { - return fmt.Errorf("binsnoop: result offset/size exceeds data bounds") - } - if err := os.MkdirAll(outputDir, 0700); err != nil { - return fmt.Errorf("binsnoop: mkdir: %w", err) - } - outPath := filepath.Join(outputDir, fmt.Sprintf("%x_%s.bin", result.Offset, result.Name)) - return os.WriteFile(outPath, data[result.Offset:result.Offset+result.Size], 0600) -} - -// SignatureCount returns the number of loaded signatures. -func (s *Scanner) SignatureCount() int { return s.signatureCount } - -// PatternCount returns the total number of magic byte patterns. -func (s *Scanner) PatternCount() int { return s.patternCount } - -// ListSignatures returns the names of all available signatures. -func ListSignatures() []string { - sigs := AllSignatures() - names := make([]string, len(sigs)) - for i, s := range sigs { - names[i] = s.Name - } - return names -} - -func includeSig(sig *Signature, include, exclude []string) bool { - if len(include) > 0 { - for _, inc := range include { - if strings.EqualFold(sig.Name, inc) { - return true - } - } - return false - } - if len(exclude) > 0 { - for _, exc := range exclude { - if strings.EqualFold(sig.Name, exc) { - return false - } - } - } - return true -} diff --git a/internal/leviathan/binsnoop/common.go b/internal/leviathan/binsnoop/common.go deleted file mode 100644 index 8e33b12..0000000 --- a/internal/leviathan/binsnoop/common.go +++ /dev/null @@ -1,127 +0,0 @@ -package binsnoop - -import ( - "encoding/binary" - "fmt" - "hash/crc32" - "os" - "time" -) - -const MaxFileReadBytes int64 = 64 << 20 - -// CRC32 calculates the CRC32 checksum using the IEEE polynomial. -func CRC32(data []byte) uint32 { - return crc32.ChecksumIEEE(data) -} - -// EpochToString converts a Unix timestamp to a formatted time string. -func EpochToString(epoch uint32) string { - t := time.Unix(int64(epoch), 0).UTC() - return t.Format("2006-01-02 15:04:05") -} - -// GetCString extracts a NULL-terminated C string from raw bytes. -func GetCString(data []byte) string { - for i, b := range data { - if b == 0 { - s := string(data[:i]) - // Validate UTF-8 printability - for _, c := range s { - if c == 0 { - return "" - } - } - return s - } - } - return string(data) -} - -// IsASCIINumber returns true if the byte is an ASCII digit (0-9). -func IsASCIINumber(b byte) bool { - return b >= '0' && b <= '9' -} - -// IsPrintableASCII returns true if the byte is a printable ASCII character. -func IsPrintableASCII(b byte) bool { - return b >= 0x0A && b <= 0x7E -} - -// IsOffsetSafe validates that the next offset is within bounds and progressing forward. -func IsOffsetSafe(availableData, nextOffset int, lastOffset *int) bool { - if lastOffset != nil { - if *lastOffset >= nextOffset { - return false - } - } - return nextOffset < availableData -} - -// ReadFile reads a bounded file into memory for scanner APIs that require a -// byte slice. Larger callers should use a streaming document path. -func ReadFile(path string) ([]byte, error) { - return ReadFileLimited(path, MaxFileReadBytes) -} - -func ReadFileLimited(path string, maxBytes int64) ([]byte, error) { - info, err := os.Stat(path) - if err != nil { - return nil, err - } - if info.IsDir() { - return nil, fmt.Errorf("binsnoop: path is a directory") - } - if maxBytes > 0 && info.Size() > maxBytes { - return nil, fmt.Errorf("binsnoop: file is too large: %d bytes exceeds %d bytes", info.Size(), maxBytes) - } - return os.ReadFile(path) -} - -// LE16 reads a little-endian uint16 from data at the given offset. -func LE16(data []byte, offset int) uint16 { - if offset+2 > len(data) { - return 0 - } - return binary.LittleEndian.Uint16(data[offset:]) -} - -// BE16 reads a big-endian uint16 from data at the given offset. -func BE16(data []byte, offset int) uint16 { - if offset+2 > len(data) { - return 0 - } - return binary.BigEndian.Uint16(data[offset:]) -} - -// LE32 reads a little-endian uint32 from data at the given offset. -func LE32(data []byte, offset int) uint32 { - if offset+4 > len(data) { - return 0 - } - return binary.LittleEndian.Uint32(data[offset:]) -} - -// BE32 reads a big-endian uint32 from data at the given offset. -func BE32(data []byte, offset int) uint32 { - if offset+4 > len(data) { - return 0 - } - return binary.BigEndian.Uint32(data[offset:]) -} - -// LE64 reads a little-endian uint64 from data at the given offset. -func LE64(data []byte, offset int) uint64 { - if offset+8 > len(data) { - return 0 - } - return binary.LittleEndian.Uint64(data[offset:]) -} - -// BE64 reads a big-endian uint64 from data at the given offset. -func BE64(data []byte, offset int) uint64 { - if offset+8 > len(data) { - return 0 - } - return binary.BigEndian.Uint64(data[offset:]) -} diff --git a/internal/leviathan/binsnoop/common_test.go b/internal/leviathan/binsnoop/common_test.go deleted file mode 100644 index d686b0c..0000000 --- a/internal/leviathan/binsnoop/common_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package binsnoop - -import ( - "os" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func TestReadFileLimitedRejectsOversizedFile(t *testing.T) { - path := filepath.Join(t.TempDir(), "firmware.bin") - if err := os.WriteFile(path, []byte("abcd"), 0600); err != nil { - t.Fatalf("WriteFile() failed: %v", err) - } - _, err := ReadFileLimited(path, 3) - if err == nil { - t.Fatal("expected oversized file to be rejected") - } - if !strings.Contains(err.Error(), "file is too large") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestCarveUsesPrivatePermissions(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("POSIX permission bits are not enforced on Windows") - } - path := filepath.Join(t.TempDir(), "private", "carved.bin") - if err := Carve(path, []byte("secret"), 0, 6); err != nil { - t.Fatal(err) - } - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - if got := info.Mode().Perm(); got != 0600 { - t.Fatalf("carved file permissions = %04o, want 0600", got) - } - dirInfo, err := os.Stat(filepath.Dir(path)) - if err != nil { - t.Fatal(err) - } - if got := dirInfo.Mode().Perm(); got != 0700 { - t.Fatalf("carve directory permissions = %04o, want 0700", got) - } -} diff --git a/internal/leviathan/binsnoop/extract.go b/internal/leviathan/binsnoop/extract.go deleted file mode 100644 index 4e0318a..0000000 --- a/internal/leviathan/binsnoop/extract.go +++ /dev/null @@ -1,124 +0,0 @@ -package binsnoop - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" -) - -// Carve writes a slice of data to a file on disk. -func Carve(outputPath string, data []byte, offset, size int) error { - if offset < 0 || offset+size > len(data) { - return fmt.Errorf("carve: offset %d + size %d exceeds data length %d", offset, size, len(data)) - } - dir := filepath.Dir(outputPath) - if err := os.MkdirAll(dir, 0700); err != nil { - return fmt.Errorf("carve: mkdir: %w", err) - } - return os.WriteFile(outputPath, data[offset:offset+size], 0600) -} - -// executeExternal runs an external extraction tool. -func executeExternal(ext *Extractor, carvedFilePath, outputDir string) *ExtractionResult { - result := &ExtractionResult{ - ExtractorName: ext.Command, - OutputDirectory: outputDir, - } - - if err := os.MkdirAll(outputDir, 0700); err != nil { - return result - } - - // Build arguments, replacing the source file placeholder - args := make([]string, len(ext.Arguments)) - for i, arg := range ext.Arguments { - args[i] = strings.ReplaceAll(arg, SourceFilePlaceholder, carvedFilePath) - } - - cmd := exec.Command(ext.Command, args...) - cmd.Dir = outputDir - - output, err := cmd.CombinedOutput() - _ = output - - exitCode := 0 - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - } else { - return result - } - } - - // Check if exit code is in the accepted list - for _, code := range ext.ExitCodes { - if exitCode == code { - result.Success = true - break - } - } - - result.DoNotRecurse = ext.DoNotRecurse - return result -} - -// executeInternal runs an internal extraction function. -func executeInternal(ext *Extractor, data []byte, offset int, outputDir string) *ExtractionResult { - if ext.Function == nil { - return &ExtractionResult{} - } - result := ext.Function(data, offset, outputDir) - if result == nil { - result = &ExtractionResult{} - } - result.DoNotRecurse = ext.DoNotRecurse - return result -} - -// ExecuteExtraction performs extraction for a signature result. -func ExecuteExtraction(data []byte, filePath string, sig *SignatureResult, ext *Extractor) *ExtractionResult { - if ext == nil { - return &ExtractionResult{} - } - - // Use preferred extractor if specified - actualExt := ext - if sig.PreferredExtractor != nil { - actualExt = sig.PreferredExtractor - } - - // Build output directory path - baseName := filepath.Base(filePath) - outputDir := filepath.Join(filepath.Dir(filePath), baseName+".extracted", - fmt.Sprintf("%x", sig.Offset)) - - switch actualExt.Type { - case ExtractorTypeInternal: - return executeInternal(actualExt, data, sig.Offset, outputDir) - - case ExtractorTypeExternal: - // Carve data to a temp file for external tools - if err := os.MkdirAll(outputDir, 0700); err != nil { - return &ExtractionResult{OutputDirectory: outputDir} - } - - carvedFile := filepath.Join(outputDir, fmt.Sprintf("carved.%s", actualExt.Extension)) - endOffset := sig.Offset + sig.Size - if endOffset > len(data) { - endOffset = len(data) - } - if err := os.WriteFile(carvedFile, data[sig.Offset:endOffset], 0600); err != nil { - return &ExtractionResult{OutputDirectory: outputDir} - } - - result := executeExternal(actualExt, carvedFile, outputDir) - result.OutputDirectory = outputDir - result.ExtractorName = actualExt.Command - return result - - default: - return &ExtractionResult{} - } -} diff --git a/internal/leviathan/binsnoop/magic.go b/internal/leviathan/binsnoop/magic.go deleted file mode 100644 index 50153d9..0000000 --- a/internal/leviathan/binsnoop/magic.go +++ /dev/null @@ -1,268 +0,0 @@ -package binsnoop - -// AllSignatures returns every supported signature definition. -// This is the equivalent of magic::patterns() in the Rust binwalk codebase. -func AllSignatures() []Signature { - return []Signature{ - // ===================== Archives ===================== - {Name: "gzip", Magic: gzipMagic(), Parser: gzipParser, Description: gzipDescription, Extractor: extGzip()}, - {Name: "deb", Magic: debMagic(), Parser: debParser, Description: debDescription}, - {Name: "7zip", Magic: sevenzipMagic(), Parser: sevenzipParser, Description: sevenzipDescription, Extractor: extSevenzip()}, - {Name: "xz", Magic: XZMagic(), Parser: XZParser, Description: XZDescription, Extractor: extLzma()}, - {Name: "tarball", Magic: tarballMagic(), Parser: tarballParser, Description: tarballDescription, Extractor: extTar()}, - {Name: "zip", Magic: zipMagic(), Parser: zipParser, Description: zipDescription, Extractor: extSevenzip()}, - {Name: "cpio", Magic: cpioMagic(), Parser: cpioParser, Description: cpioDescription, Extractor: extSevenzip()}, - {Name: "iso9660", Magic: iso9660Magic(), Parser: iso9660Parser, Description: iso9660Description, Extractor: extSevenzip()}, - {Name: "rar", Magic: rarMagic(), Parser: rarParser, Description: rarDescription, Extractor: extRar()}, - {Name: "cab", Magic: cabMagic(), Parser: cabParser, Description: cabDescription, Extractor: extCab()}, - {Name: "arj", Magic: arjMagic(), Parser: arjParser, Description: arjDescription, Extractor: extSevenzip()}, - {Name: "dahua_zip", Magic: dahuaZipMagic(), Parser: dahuaZipParser, Description: dahuaZipDescription, Extractor: extSevenzip()}, - - // ===================== Compression ===================== - {Name: "lzma", Magic: LZMAMagic(), Parser: LZMAParser, Description: LZMADescription, Extractor: extLzma()}, - {Name: "bzip2", Magic: Bzip2Magic(), Parser: Bzip2Parser, Description: Bzip2Description, Extractor: extBzip2()}, - {Name: "zstd", Magic: ZSTDMagic(), Parser: ZSTDParser, Description: ZSTDDescription, Extractor: extZstd()}, - {Name: "lz4", Magic: LZ4Magic(), Parser: LZ4Parser, Description: LZ4Description, Extractor: extLz4()}, - {Name: "lzop", Magic: LZOPMagic(), Parser: LZOPParser, Description: LZOPDescription, Extractor: extLzop()}, - {Name: "lzfse", Magic: LZFSEMagic(), Parser: LZFSEParser, Description: LZFSEDescription, Extractor: extLzfse()}, - {Name: "zlib", Short: true, Magic: ZlibMagic(), Parser: ZlibParser, Description: ZlibDescription, Extractor: extZlib()}, - {Name: "compressd", Short: true, Magic: CompressdMagic(), Parser: CompressdParser, Description: CompressdDescription, Extractor: extSevenzip()}, - - // ===================== Filesystems ===================== - {Name: "squashfs", Magic: squashfs_magic(), Parser: squashfs_parser, Description: SquashFSDescription, Extractor: extSquashfs()}, - {Name: "cramfs", Magic: cramfs_magic(), Parser: cramfs_parser, Description: CramFSDescription, Extractor: extSevenzip()}, - {Name: "jffs2", Magic: jffs2_magic(), Parser: jffs2_parser, Description: JFFS2Description, Extractor: extJffs2()}, - {Name: "yaffs", Magic: yaffs_magic(), Parser: yaffs_parser, Description: YAFFSDescription, Extractor: extYaffs2()}, - {Name: "romfs", Magic: romfs_magic(), Parser: romfs_parser, Description: RomFSDescription, Extractor: extRomfs()}, - {Name: "ext", Magic: ext_magic(), Parser: ext_parser, Description: EXTDescription, Extractor: extTsk()}, - {Name: "fat", Short: true, MagicOffset: FATMagicOffset, Magic: fat_magic(), Parser: fat_parser, Description: FATDescription, Extractor: extTsk()}, - {Name: "ntfs", Magic: ntfs_magic(), Parser: ntfs_parser, Description: NTFSDescription, Extractor: extTsk()}, - {Name: "apfs", Magic: apfs_magic(), Parser: apfs_parser, Description: APFSDescription, Extractor: extSevenzip()}, - {Name: "btrfs", AlwaysDisplay: true, Magic: btrfs_magic(), Parser: btrfs_parser, Description: BTRFSDescription}, - {Name: "logfs", Magic: logfs_magic(), Parser: logfs_parser, Description: LogFSDescription}, - {Name: "ubi", Magic: ubi_magic(), Parser: ubi_parser, Description: UBIImageDescription, Extractor: extUbi()}, - {Name: "ubifs", Magic: ubifs_magic(), Parser: ubifs_parser, Description: UBIFSDescription, Extractor: extUbifs()}, - - // ===================== Images & Media ===================== - {Name: "png", Magic: PNGMagic(), Parser: PNGParser, Description: PNGDescription, Extractor: extPng()}, - {Name: "jpeg", Magic: JPEGMagic(), Parser: JPEGParser, Description: JPEGDescription, Extractor: extJpeg()}, - {Name: "bmp", Magic: BMPMagic(), Parser: BMPParser, Description: BMPDescription, Extractor: extBmp()}, - {Name: "gif", Magic: GIFMagic(), Parser: GIFParser, Description: GIFDescription, Extractor: extGif()}, - {Name: "svg", Magic: SVGMagic(), Parser: SVGParser, Description: SVGDescription, Extractor: extSvg()}, - {Name: "riff", Magic: RIFFMagic(), Parser: RIFFParser, Description: RIFFDescription, Extractor: extRiff()}, - {Name: "pdf", Magic: PDFMagic(), Parser: PDFParser, Description: PDFDescription}, - - // ===================== Executables ===================== - {Name: "elf", Magic: ELFMagic(), Parser: ELFParser, Description: ELFDescription}, - {Name: "pe", Magic: PEMagic(), Parser: PEParser, Description: PEDescription}, - - // ===================== Firmware Headers ===================== - {Name: "uimage", Magic: uimageMagic(), Parser: uimageParser, Description: uimageDescription, Extractor: extUimage()}, - {Name: "packimg", Magic: packimgMagic(), Parser: packimgParser, Description: packimgDescription}, - {Name: "uboot", AlwaysDisplay: true, Magic: ubootMagic(), Parser: ubootParser, Description: ubootDescription}, - {Name: "trx", Magic: trxMagic(), Parser: trxParser, Description: trxDescription, Extractor: extTrx()}, - {Name: "tplink", Magic: tplinkMagic(), Parser: tplinkParser, Description: tplinkDescription}, - {Name: "tplink_rtos", Magic: tplinkRTOSMagic(), Parser: tplinkRTOSParser, Description: tplinkRTOSDescription}, - {Name: "dlob", Magic: dlobMagic(), Parser: dlobParser, Description: dlobDescription}, - {Name: "arcadyan", Magic: arcadyanMagic(), Parser: arcadyanParser, Description: arcadyanDescription}, - {Name: "chk", Magic: chkMagic(), Parser: chkParser, Description: chkDescription}, - {Name: "seama", AlwaysDisplay: true, Magic: seamaMagic(), Parser: seamaParser, Description: seamaDescription}, - {Name: "cfe", AlwaysDisplay: true, Magic: cfeMagic(), Parser: cfeParser, Description: cfeDescription}, - {Name: "jboot_arm", Magic: jbootARMMagic(), Parser: jbootARMParser, Description: jbootARMDescription}, - {Name: "jboot_stag", Magic: jbootSTAGMagic(), Parser: jbootSTAGParser, Description: jbootSTAGDescription}, - {Name: "jboot_sch2", Magic: jbootSCH2Magic(), Parser: jbootSCH2Parser, Description: jbootSCH2Description, Extractor: extJboot()}, - {Name: "binhdr", Magic: binhdrMagic(), Parser: binhdrParser, Description: binhdrDescription}, - {Name: "autel", Magic: autelMagic(), Parser: autelParser, Description: autelDescription, Extractor: extAutel()}, - {Name: "android_sparse", Magic: androidSparseMagic(), Parser: androidSparseParser, Description: androidSparseDescription, Extractor: extAndroidSparse()}, - {Name: "dtb", Magic: dtbMagic(), Parser: dtbParser, Description: dtbDescription, Extractor: extDtb()}, - {Name: "android_bootimg", Magic: androidBootimgMagic(), Parser: androidBootimgParser, Description: androidBootimgDescription}, - {Name: "dlink_tlv", Magic: dlinkTLVMagic(), Parser: dlinkTLVParser, Description: dlinkTLVDescription, Extractor: extEncfw()}, - {Name: "dlke", Magic: dlkeMagic(), Parser: dlkeParser, Description: dlkeDescription, Extractor: extEncfw()}, - {Name: "shrs", Magic: shrsMagic(), Parser: shrsParser, Description: shrsDescription, Extractor: extEncfw()}, - {Name: "dkbs", Magic: dkbsMagic(), Parser: dkbsParser, Description: dkbsDescription, Extractor: extEncfw()}, - {Name: "dms", Magic: dmsMagic(), Parser: dmsParser, Description: dmsDescription}, - {Name: "encfw", Short: true, AlwaysDisplay: true, Magic: encfwMagic(), Parser: encfwParser, Description: encfwDescription, Extractor: extEncfw()}, - {Name: "encrpted_img", Short: true, Magic: encrptedImgMagic(), Parser: encrptedImgParser, Description: encrptedImgDescription, Extractor: extEncfw()}, - {Name: "wince", Magic: winceMagic(), Parser: winceParser, Description: winceDescription}, - {Name: "mh01", Magic: mh01Magic(), Parser: mh01Parser, Description: mh01Description}, - {Name: "csman", Short: true, Magic: csmanMagic(), Parser: csmanParser, Description: csmanDescription}, - {Name: "dxbc", Magic: dxbcMagic(), Parser: dxbcParser, Description: dxbcDescription}, - {Name: "matter_ota", Short: true, Magic: matterOTAMagic(), Parser: matterOTAParser, Description: matterOTADescription}, - {Name: "rtk", Short: true, Magic: rtkMagic(), Parser: rtkParser, Description: rtkDescription}, - {Name: "dpapi", Short: true, AlwaysDisplay: true, Magic: dpapiMagic(), Parser: dpapiParser, Description: dpapiDescription}, - {Name: "qcow", Short: true, AlwaysDisplay: true, Magic: qcowMagic(), Parser: qcowParser, Description: qcowDescription}, - - // ===================== Crypto & Hashes ===================== - {Name: "aes_sbox", Magic: aesSBoxMagic(), Parser: aesSBoxParser, Description: DescriptionAESSBox}, - {Name: "aes_forward_table", Magic: aesForwardTableMagic(), Parser: aesForwardTableParser, Description: DescriptionAESFT}, - {Name: "aes_reverse_table", Magic: aesReverseTableMagic(), Parser: aesReverseTableParser, Description: DescriptionAESRT}, - {Name: "aes_rcon", Magic: aesRconMagic(), Parser: aesRconParser, Description: DescriptionAESRCON}, - {Name: "aes_acceleration_table", Magic: aesAccelerationTableMagic(), Parser: aesAccelerationTableParser, Description: DescriptionAESAcc}, - {Name: "rsa", AlwaysDisplay: true, Magic: rsaMagic(), Parser: rsaParser, Description: DescriptionRSA}, - {Name: "openssl", AlwaysDisplay: true, Magic: opensslMagic(), Parser: opensslParser, Description: DescriptionOpenSSL, Extractor: extEncfw()}, - {Name: "gpg_signed", Short: true, Magic: gpgSignedMagic(), Parser: gpgSignedParser, Description: DescriptionGPGSigned, Extractor: extGpg()}, - {Name: "pem_certificate", Magic: pemCertificateMagic(), Parser: pemParser, Description: DescriptionPEMCertificate, Extractor: extPemCert()}, - {Name: "pem_public_key", AlwaysDisplay: true, Magic: pemPublicKeyMagic(), Parser: pemParser, Description: DescriptionPEMPublicKey, Extractor: extPemKey()}, - {Name: "pem_private_key", AlwaysDisplay: true, Magic: pemPrivateKeyMagic(), Parser: pemParser, Description: DescriptionPEMPrivateKey, Extractor: extPemKey()}, - {Name: "pkcs_der_hash", Magic: derHashMagic(), Parser: derHashParser, Description: DescriptionDERHash}, - {Name: "luks", Magic: luksMagic(), Parser: luksParser, Description: DescriptionLUKS}, - {Name: "crc32", Magic: crc32ConstMagic(), Parser: crc32Parser, Description: DescriptionCRC32}, - {Name: "sha256", Magic: sha256ConstMagic(), Parser: sha256Parser, Description: DescriptionSHA256}, - {Name: "md5", Magic: md5ConstMagic(), Parser: md5Parser, Description: DescriptionMD5}, - - // ===================== Misc ===================== - {Name: "copyright", Magic: copyrightMagic(), Parser: copyrightParser, Description: DescriptionCopyright}, - {Name: "wind_kernel", AlwaysDisplay: true, Magic: windKernelMagic(), Parser: windKernelParser, Description: DescriptionWINDKernelVersion}, - {Name: "vxworks_symtab", AlwaysDisplay: true, Magic: vxworksSymtabMagic(), Parser: vxworksSymtabParser, Description: DescriptionVxWorksSymTab}, - {Name: "ecos", AlwaysDisplay: true, Magic: ecosExceptionHandlerMagic(), Parser: ecosExceptionHandlerParser, Description: DescriptionECosExceptionHandler}, - {Name: "linux_kernel", AlwaysDisplay: true, Magic: linuxKernelVersionMagic(), Parser: linuxKernelVersionParser, Description: DescriptionLinuxKernelVersion, Extractor: extLinuxKernel()}, - {Name: "linux_boot_image", Magic: linuxBootImageMagic(), Parser: linuxBootImageParser, Description: DescriptionLinuxBootImage}, - {Name: "linux_arm_zimage", Magic: linuxARMzImageMagic(), Parser: linuxARMzImageParser, Description: DescriptionLinuxARMzImage}, - {Name: "linux_arm64_boot_image", Magic: linuxARM64BootImageMagic(), Parser: linuxARM64BootImageParser, Description: DescriptionLinuxARM64BootImage}, - {Name: "efigpt", Magic: efigptMagic(), Parser: efigptParser, Description: DescriptionEFIGPT, Extractor: extSevenzip()}, - {Name: "mbr", Short: true, MagicOffset: MBRMagicOffset, AlwaysDisplay: true, Magic: mbrMagic(), Parser: mbrParser, Description: DescriptionMBR, Extractor: extMbr()}, - {Name: "uefi_pi_volume", Magic: uefiVolumeMagic(), Parser: uefiVolumeParser, Description: DescriptionUEFIVolume, Extractor: extUefi()}, - {Name: "uefi_capsule", Magic: uefiCapsuleMagic(), Parser: uefiCapsuleParser, Description: DescriptionUEFICapsule, Extractor: extUefi()}, - {Name: "pchrom", Magic: pchRomMagic(), Parser: pchRomParser, Description: DescriptionPCHRom, Extractor: extUefi()}, - {Name: "pcapng", Magic: pcapngMagic(), Parser: pcapngParser, Description: DescriptionPcapNG, Extractor: extPcap()}, - {Name: "pjl", Magic: pjlMagic(), Parser: pjlParser, Description: DescriptionPJL}, - {Name: "srecord", Magic: srecordMagic(), Parser: srecordParser, Description: DescriptionSRecord, Extractor: extSrec()}, - {Name: "srecord_generic", Short: true, Magic: srecordGenericMagic(), Parser: srecordParser, Description: DescriptionSRecordGeneric, Extractor: extSrec()}, - {Name: "dmg", Magic: dmgMagic(), Parser: dmgParser, Description: DescriptionDMG, Extractor: extDmg()}, - {Name: "qnx_ifs", Magic: qnxIFSMagic(), Parser: qnxIFSParser, Description: DescriptionQNXIFS, Extractor: extDumpifs()}, - } -} - -// ============================================================================ -// External extractor definitions (command-line tools) -// ============================================================================ - -func extSevenzip() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "bin", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}} -} -func extGzip() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "gzip", Extension: "gz", Arguments: []string{"-d", "-k", "-f", "%e"}, ExitCodes: []int{0}} -} -func extLzma() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "xz", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}} -} -func extTar() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "tar", Extension: "tar", Arguments: []string{"xf", "%e"}, ExitCodes: []int{0}} -} -func extBzip2() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "bzip2", Extension: "bz2", Arguments: []string{"-d", "-k", "-f", "%e"}, ExitCodes: []int{0}} -} -func extZstd() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "zstd", Extension: "zst", Arguments: []string{"-d", "-f", "%e"}, ExitCodes: []int{0}} -} -func extLz4() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "lz4", Extension: "lz4", Arguments: []string{"-d", "-f", "%e", "decompressed.bin"}, ExitCodes: []int{0}} -} -func extLzop() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "lzop", Extension: "lzo", Arguments: []string{"-d", "-f", "%e"}, ExitCodes: []int{0}} -} -func extLzfse() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "lzfse", Extension: "lzfse", Arguments: []string{"-decode", "-i", "%e", "-o", "decompressed.bin"}, ExitCodes: []int{0}} -} -func extZlib() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "bin", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}} -} -func extSquashfs() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "unsquashfs", Extension: "sqsh", Arguments: []string{"-f", "-d", "squashfs-root", "%e"}, ExitCodes: []int{0}} -} -func extJffs2() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "jefferson", Extension: "jffs2", Arguments: []string{"-d", "jffs2-root", "%e"}, ExitCodes: []int{0}} -} -func extYaffs2() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "unyaffs", Extension: "yaffs", Arguments: []string{"%e", "yaffs-root"}, ExitCodes: []int{0}} -} -func extRomfs() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "romfs", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}} -} -func extTsk() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "tsk_recover", Extension: "img", Arguments: []string{"-a", "-e", "%e", "tsk-root"}, ExitCodes: []int{0}} -} -func extCab() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "cabextract", Extension: "cab", Arguments: []string{"%e"}, ExitCodes: []int{0}} -} -func extRar() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "unrar", Extension: "rar", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0}} -} -func extUbi() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "ubireader_extract_images", Extension: "ubi", Arguments: []string{"%e"}, ExitCodes: []int{0}} -} -func extUbifs() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "ubireader_extract_files", Extension: "ubifs", Arguments: []string{"%e"}, ExitCodes: []int{0}} -} -func extPng() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "png", Arguments: []string{"%e", "image.png"}, ExitCodes: []int{0}} -} -func extJpeg() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "jpg", Arguments: []string{"%e", "image.jpg"}, ExitCodes: []int{0}} -} -func extBmp() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "bmp", Arguments: []string{"%e", "image.bmp"}, ExitCodes: []int{0}} -} -func extGif() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "gif", Arguments: []string{"%e", "image.gif"}, ExitCodes: []int{0}} -} -func extSvg() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "svg", Arguments: []string{"%e", "image.svg"}, ExitCodes: []int{0}} -} -func extRiff() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "riff", Arguments: []string{"%e", "media.riff"}, ExitCodes: []int{0}} -} -func extUimage() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "bin", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}} -} -func extTrx() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "bin", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}} -} -func extJboot() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "bin", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}} -} -func extAutel() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "bin", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}} -} -func extAndroidSparse() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "simg2img", Extension: "simg", Arguments: []string{"%e", "output.img"}, ExitCodes: []int{0}} -} -func extDtb() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "dtc", Extension: "dtb", Arguments: []string{"-I", "dtb", "-O", "dts", "-o", "device_tree.dts", "%e"}, ExitCodes: []int{0}} -} -func extEncfw() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "bin", Arguments: []string{"%e", "encrypted_firmware.bin"}, ExitCodes: []int{0}} -} -func extGpg() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "gpg", Extension: "gpg", Arguments: []string{"--decrypt", "-o", "decrypted.bin", "%e"}, ExitCodes: []int{0}} -} -func extPemCert() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "pem", Arguments: []string{"%e", "certificate.pem"}, ExitCodes: []int{0}} -} -func extPemKey() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "pem", Arguments: []string{"%e", "key.pem"}, ExitCodes: []int{0}} -} -func extLinuxKernel() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "bin", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}} -} -func extMbr() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "bin", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}} -} -func extUefi() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "uefi-firmware-parser", Extension: "bin", Arguments: []string{"-e", "-O", "%e"}, ExitCodes: []int{0}} -} -func extPcap() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "pcapng", Arguments: []string{"%e", "capture.pcapng"}, ExitCodes: []int{0}} -} -func extSrec() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "srec_cat", Extension: "srec", Arguments: []string{"%e", "-o", "output.bin", "-binary"}, ExitCodes: []int{0}} -} -func extDmg() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "dmg", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}} -} -func extDumpifs() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "dumpifs", Extension: "ifs", Arguments: []string{"-x", "%e"}, ExitCodes: []int{0}} -} -func extIso9660() *Extractor { - return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "iso", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}} -} diff --git a/internal/leviathan/binsnoop/match.go b/internal/leviathan/binsnoop/match.go deleted file mode 100644 index 264820a..0000000 --- a/internal/leviathan/binsnoop/match.go +++ /dev/null @@ -1,134 +0,0 @@ -package binsnoop - -// ahoCorasick implements the Aho-Corasick multi-pattern byte matching algorithm. -type ahoCorasick struct { - gotoFn []map[byte]int - failFn []int - outputFn [][]int - patterns [][]byte -} - -// ahoMatch represents a single pattern match. -type ahoMatch struct { - PatternIndex int - Start int -} - -// newAhoCorasick builds an Aho-Corasick automaton from the given byte patterns. -func newAhoCorasick(patterns [][]byte) *ahoCorasick { - ac := &ahoCorasick{ - gotoFn: []map[byte]int{make(map[byte]int)}, - failFn: []int{0}, - outputFn: [][]int{nil}, - patterns: patterns, - } - - // Build the trie (goto function) - for i, pattern := range patterns { - state := 0 - for _, b := range pattern { - if next, ok := ac.gotoFn[state][b]; ok { - state = next - } else { - newState := len(ac.gotoFn) - ac.gotoFn[state][b] = newState - ac.gotoFn = append(ac.gotoFn, make(map[byte]int)) - ac.failFn = append(ac.failFn, 0) - ac.outputFn = append(ac.outputFn, nil) - state = newState - } - } - ac.outputFn[state] = append(ac.outputFn[state], i) - } - - // Build failure links (BFS) - queue := make([]int, 0) - for _, next := range ac.gotoFn[0] { - queue = append(queue, next) - } - - for len(queue) > 0 { - r := queue[0] - queue = queue[1:] - - for b, s := range ac.gotoFn[r] { - queue = append(queue, s) - state := ac.failFn[r] - for { - if _, ok := ac.gotoFn[state][b]; ok || state == 0 { - break - } - state = ac.failFn[state] - } - if next, ok := ac.gotoFn[state][b]; ok && next != s { - ac.failFn[s] = next - } else { - ac.failFn[s] = 0 - } - // Merge output from failure state - if ac.outputFn[ac.failFn[s]] != nil { - ac.outputFn[s] = append(ac.outputFn[s], ac.outputFn[ac.failFn[s]]...) - } - } - } - - return ac -} - -// findOverlapping searches data starting at startOffset and returns all overlapping matches. -func (ac *ahoCorasick) findOverlapping(data []byte, startOffset int) []ahoMatch { - var matches []ahoMatch - state := 0 - - for i := startOffset; i < len(data); i++ { - b := data[i] - for { - if _, ok := ac.gotoFn[state][b]; ok || state == 0 { - break - } - state = ac.failFn[state] - } - if next, ok := ac.gotoFn[state][b]; ok { - state = next - } - for _, patIdx := range ac.outputFn[state] { - matchStart := i - len(ac.patterns[patIdx]) + 1 - matches = append(matches, ahoMatch{ - PatternIndex: patIdx, - Start: matchStart, - }) - } - } - - return matches -} - -// findOverlappingLimit is the bounded variant used by deep scans. It stops -// collecting once limit matches have been found so adversarial input cannot -// force an unbounded match slice. -func (ac *ahoCorasick) findOverlappingLimit(data []byte, startOffset, limit int) ([]ahoMatch, bool) { - if limit <= 0 { - return nil, false - } - matches := make([]ahoMatch, 0, min(limit, 256)) - state := 0 - for i := startOffset; i < len(data); i++ { - b := data[i] - for { - if _, ok := ac.gotoFn[state][b]; ok || state == 0 { - break - } - state = ac.failFn[state] - } - if next, ok := ac.gotoFn[state][b]; ok { - state = next - } - for _, patIdx := range ac.outputFn[state] { - matches = append(matches, ahoMatch{PatternIndex: patIdx, Start: i - len(ac.patterns[patIdx]) + 1}) - if len(matches) >= limit { - return matches, true - } - } - } - return matches, false -} diff --git a/internal/leviathan/binsnoop/parse.go b/internal/leviathan/binsnoop/parse.go deleted file mode 100644 index 4057cd4..0000000 --- a/internal/leviathan/binsnoop/parse.go +++ /dev/null @@ -1,107 +0,0 @@ -package binsnoop - -import ( - "errors" - "fmt" -) - -// ErrParse is returned when structure parsing fails. -var ErrParse = errors.New("structure parse error") - -// StructField defines a single field in a binary structure. -type StructField struct { - Name string - Type string // "u8", "u16", "u24", "u32", "u64" -} - -// typeSize returns the byte size for a type string. -func typeSize(ctype string) (int, error) { - switch ctype { - case "u8": - return 1, nil - case "u16": - return 2, nil - case "u24": - return 3, nil - case "u32": - return 4, nil - case "u64": - return 8, nil - default: - return 0, fmt.Errorf("unknown type: %s", ctype) - } -} - -// StructSize returns the total byte size of a structure definition. -func StructSize(fields []StructField) int { - total := 0 - for _, f := range fields { - sz, err := typeSize(f.Type) - if err != nil { - continue - } - total += sz - } - return total -} - -// Parse parses a binary structure from data according to the field definitions. -// Endianness must be "big" or "little". All values are returned as int. -func Parse(data []byte, fields []StructField, endianness string) (map[string]int, error) { - structSize := StructSize(fields) - if len(data) < structSize { - return nil, ErrParse - } - - result := make(map[string]int, len(fields)) - offset := 0 - - for _, f := range fields { - sz, err := typeSize(f.Type) - if err != nil { - return nil, ErrParse - } - - var value int - - switch sz { - case 1: - value = int(data[offset]) - case 2: - if endianness == "big" { - value = int(data[offset])<<8 | int(data[offset+1]) - } else { - value = int(data[offset+1])<<8 | int(data[offset]) - } - case 3: - if endianness == "big" { - value = int(data[offset])<<16 | int(data[offset+1])<<8 | int(data[offset+2]) - } else { - value = int(data[offset+2])<<16 | int(data[offset+1])<<8 | int(data[offset]) - } - case 4: - if endianness == "big" { - value = int(data[offset])<<24 | int(data[offset+1])<<16 | int(data[offset+2])<<8 | int(data[offset+3]) - } else { - value = int(data[offset+3])<<24 | int(data[offset+2])<<16 | int(data[offset+1])<<8 | int(data[offset]) - } - case 8: - if endianness == "big" { - value = int(uint64(data[offset])<<56 | uint64(data[offset+1])<<48 | - uint64(data[offset+2])<<40 | uint64(data[offset+3])<<32 | - uint64(data[offset+4])<<24 | uint64(data[offset+5])<<16 | - uint64(data[offset+6])<<8 | uint64(data[offset+7])) - } else { - value = int(uint64(data[offset+7])<<56 | uint64(data[offset+6])<<48 | - uint64(data[offset+5])<<40 | uint64(data[offset+4])<<32 | - uint64(data[offset+3])<<24 | uint64(data[offset+2])<<16 | - uint64(data[offset+1])<<8 | uint64(data[offset])) - } - } - - offset += sz - result[f.Name] = value - } - - return result, nil -} diff --git a/internal/leviathan/binsnoop/scanner.go b/internal/leviathan/binsnoop/scanner.go deleted file mode 100644 index 4afb117..0000000 --- a/internal/leviathan/binsnoop/scanner.go +++ /dev/null @@ -1,352 +0,0 @@ -package binsnoop - -import "sort" - -// Scanner is the core scanning engine. It holds compiled signature patterns -// and performs Aho-Corasick based multi-pattern matching on binary data. -type Scanner struct { - signatureCount int - patternCount int - shortSignatures []Signature - patterns [][]byte - patternSigTable map[int]Signature - extractorLookup map[string]*Extractor - ac *ahoCorasick -} - -// compile builds the Aho-Corasick automaton from loaded patterns. -func (s *Scanner) compile() { - if len(s.patterns) > 0 { - s.ac = newAhoCorasick(s.patterns) - } -} - -// Scan searches data for all known file signatures and returns validated results. -// This implements the same algorithm as the Rust binwalk scanner: -// 1. Check short signatures at file offset 0 -// 2. Run Aho-Corasick scan with restart on high-confidence sized matches -// 3. Sort and de-conflict overlapping results -// 4. Estimate sizes for signatures with unknown size -func (s *Scanner) Scan(data []byte) []SignatureResult { - var fileMap []SignatureResult - nextValidOffset := 0 - availableData := len(data) - - // Phase 1: Check short signatures at offset 0 - for i := range s.shortSignatures { - sig := &s.shortSignatures[i] - for _, magic := range sig.Magic { - magicStart := sig.MagicOffset - magicEnd := magicStart + len(magic) - - if len(data) >= magicEnd && bytesEqual(data[magicStart:magicEnd], magic) { - result, err := sig.Parser(data, magicStart) - if err != nil { - continue - } - autoPopulate(result, sig) - fileMap = append(fileMap, *result) - - if result.Confidence >= ConfidenceHigh { - nextValidOffset = result.Offset + result.Size - } - break - } - } - } - - // Phase 2: Aho-Corasick scan. - // - // The automaton is run exactly once over the whole buffer; we then walk - // the matches in position order. When a high/medium-confidence sized - // signature is accepted, nextValidOffset jumps past it and any later - // matches that start inside that consumed region are skipped. This is the - // same result the binwalk scanner produces, but without re-running the - // search from every new offset — a previous implementation rescanned the - // tail of the file on each accepted match, which is O(n^2) on inputs that - // contain many closely-spaced signatures (e.g. real firmware images) and - // could appear to hang. - if s.ac == nil { - return s.postProcess(data, fileMap) - } - - matches := s.ac.findOverlapping(data, nextValidOffset) - for _, m := range matches { - magicOffset := m.Start - if magicOffset < nextValidOffset { - continue - } - - sig, ok := s.patternSigTable[m.PatternIndex] - if !ok { - continue - } - - result, err := sig.Parser(data, magicOffset) - if err != nil { - continue - } - - sigEnd := result.Offset + result.Size - if sigEnd > availableData { - continue - } - - autoPopulate(result, &sig) - fileMap = append(fileMap, *result) - - if result.Confidence >= ConfidenceMedium && result.Size > 0 { - nextValidOffset = sigEnd - } - } - - return s.postProcess(data, fileMap) -} - -// postProcess sorts results, removes conflicts, and estimates unknown sizes. -func (s *Scanner) postProcess(data []byte, fileMap []SignatureResult) []SignatureResult { - if len(fileMap) == 0 { - return fileMap - } - - // Sort by offset - sort.Slice(fileMap, func(i, j int) bool { - return fileMap[i].Offset < fileMap[j].Offset - }) - - // De-conflict overlapping signatures in a single pass, filtering in place. - // (The previous implementation removed elements with - // append(fileMap[:i], fileMap[i+1:]...) inside the loop; each removal is - // O(n), so a file with many overlapping hits was O(n^2) and could appear - // to hang.) Removed entries are dropped; on a duplicate offset the - // higher-confidence signature wins, matching the original behaviour. - out := fileMap[:0] - nextValidOffset := 0 - for _, thisSig := range fileMap { - // Duplicate offset: keep only the highest-confidence signature. - if len(out) > 0 && thisSig.Offset == out[len(out)-1].Offset { - if thisSig.Confidence > out[len(out)-1].Confidence { - out[len(out)-1] = thisSig - if thisSig.Confidence >= ConfidenceMedium { - nextValidOffset = thisSig.Offset + thisSig.Size - } - } - continue - } - - // Drop signatures that fall inside a previously accepted sized region. - if thisSig.Offset < nextValidOffset { - continue - } - - // Drop signatures whose declared size runs past EOF or wraps. - remainingSize := len(data) - thisSig.Offset - if thisSig.Size > remainingSize || (thisSig.Offset+thisSig.Size) < 0 { - continue - } - - out = append(out, thisSig) - if thisSig.Confidence >= ConfidenceMedium { - nextValidOffset = thisSig.Offset + thisSig.Size - } - } - fileMap = out - - // Estimate sizes for signatures with unknown size (0): extend each to the - // offset of the next medium-or-higher confidence signature, or to EOF. - // Computed in a single right-to-left pass to stay O(n). - nextMediumOffset := len(data) - for i := len(fileMap) - 1; i >= 0; i-- { - if fileMap[i].Size == 0 { - fileMap[i].Size = nextMediumOffset - fileMap[i].Offset - } - if fileMap[i].Confidence >= ConfidenceMedium { - nextMediumOffset = fileMap[i].Offset - } - } - - return fileMap -} - -// ScanDeep validates signatures without suppressing hits inside an already -// identified container. Results are bounded and annotated with containment. -func (s *Scanner) ScanDeep(data []byte, maxResults int) DeepScan { - if maxResults <= 0 || maxResults > 4096 { - maxResults = 4096 - } - result := DeepScan{} - if len(data) == 0 { - return result - } - - results := make([]SignatureResult, 0) - seen := make(map[struct { - offset int - name string - }]int) - add := func(parsed *SignatureResult, sig *Signature) { - if parsed == nil || parsed.Offset < 0 || parsed.Offset > len(data) { - return - } - if parsed.Size < 0 || parsed.Size > len(data)-parsed.Offset { - return - } - autoPopulate(parsed, sig) - key := struct { - offset int - name string - }{parsed.Offset, parsed.Name} - if idx, ok := seen[key]; ok { - if parsed.Confidence > results[idx].Confidence { - results[idx] = *parsed - } - return - } - if len(results) >= maxResults { - result.Truncated = true - return - } - seen[key] = len(results) - results = append(results, *parsed) - } - - for i := range s.shortSignatures { - sig := &s.shortSignatures[i] - for _, magic := range sig.Magic { - start := sig.MagicOffset - end := start + len(magic) - if start >= 0 && end <= len(data) && bytesEqual(data[start:end], magic) { - parsed, err := sig.Parser(data, start) - if err == nil { - add(parsed, sig) - } - break - } - } - } - - if s.ac != nil && len(results) < maxResults { - matchLimit := maxResults * 16 - matches, truncated := s.ac.findOverlappingLimit(data, 0, matchLimit) - result.Truncated = result.Truncated || truncated - for _, match := range matches { - sig, ok := s.patternSigTable[match.PatternIndex] - if !ok { - continue - } - parsed, err := sig.Parser(data, match.Start) - if err != nil { - continue - } - add(parsed, &sig) - if len(results) >= maxResults { - result.Truncated = true - break - } - } - } - - sort.Slice(results, func(i, j int) bool { - if results[i].Offset != results[j].Offset { - return results[i].Offset < results[j].Offset - } - if results[i].Confidence != results[j].Confidence { - return results[i].Confidence > results[j].Confidence - } - return results[i].Size > results[j].Size - }) - unique := results[:0] - for _, hit := range results { - if len(unique) > 0 && unique[len(unique)-1].Offset == hit.Offset { - continue - } - unique = append(unique, hit) - } - results = unique - - nextOffset := len(data) - for i := len(results) - 1; i >= 0; i-- { - if results[i].Size == 0 { - results[i].Size = nextOffset - results[i].Offset - } - nextOffset = results[i].Offset - } - - active := make([]int, 0) - for _, hit := range results { - end := hit.Offset + hit.Size - kept := active[:0] - for _, idx := range active { - if result.Results[idx].End > hit.Offset { - kept = append(kept, idx) - } - } - active = kept - parent := -1 - overlap := false - for i := len(active) - 1; i >= 0; i-- { - candidate := result.Results[active[i]] - if candidate.End >= end && candidate.Offset < hit.Offset { - parent = active[i] - break - } - overlap = true - } - deep := DeepScanResult{SignatureResult: hit, End: end, ParentOffset: -1, Overlap: overlap} - if parent >= 0 { - deep.ParentOffset = result.Results[parent].Offset - deep.Depth = result.Results[parent].Depth + 1 - } - result.Results = append(result.Results, deep) - active = append(active, len(result.Results)-1) - } - return result -} - -// Extract performs extraction on all extractable signatures in the file map. -func (s *Scanner) Extract(data []byte, filePath string, fileMap []SignatureResult) map[string]*ExtractionResult { - results := make(map[string]*ExtractionResult) - - for _, sig := range fileMap { - if sig.ExtractionDeclined { - continue - } - - ext, ok := s.extractorLookup[sig.Name] - if !ok || ext == nil { - continue - } - - result := ExecuteExtraction(data, filePath, &sig, ext) - if !result.Success && sig.Size < len(data)-sig.Offset { - // Retry with all available data - expandedSig := sig - expandedSig.Size = len(data) - sig.Offset - result = ExecuteExtraction(data, filePath, &expandedSig, ext) - } - - results[sig.ID] = result - } - - return results -} - -// bytesEqual compares two byte slices. -func bytesEqual(a, b []byte) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -// autoPopulate fills in auto-generated fields on a SignatureResult. -func autoPopulate(result *SignatureResult, sig *Signature) { - result.ID = newUUID() - result.Name = sig.Name - result.AlwaysDisplay = sig.AlwaysDisplay -} diff --git a/internal/leviathan/binsnoop/sig_archives.go b/internal/leviathan/binsnoop/sig_archives.go deleted file mode 100644 index b010c5a..0000000 --- a/internal/leviathan/binsnoop/sig_archives.go +++ /dev/null @@ -1,1143 +0,0 @@ -package binsnoop - -import ( - "bytes" - "encoding/binary" - "fmt" - "math" - "strconv" - "strings" -) - -// --------------------------------------------------------------------------- -// gzip -// --------------------------------------------------------------------------- - -const gzipDescription = "gzip compressed data" - -func gzipMagic() [][]byte { - return [][]byte{ - {0x1f, 0x8b, 0x08}, - } -} - -func gzipParser(data []byte, offset int) (*SignatureResult, error) { - const ( - flagCRC = 0b0000_0010 - flagExtra = 0b0000_0100 - flagName = 0b0000_1000 - flagComment = 0b0001_0000 - flagReserved = 0b1110_0000 - - deflateCompression = 8 - crcSize = 2 - nullByteSize = 1 - ) - - knownOSIDs := map[int]string{ - 0: "FAT filesystem (MS-DOS, OS/2, NT/Win32", - 1: "Amiga", - 2: "VMS (or OpenVMS)", - 3: "Unix", - 4: "VM/CMS", - 5: "Atari TOS", - 6: "HPFS filesystem (OS/2, NT)", - 7: "Macintosh", - 8: "Z-System", - 9: "CP/M", - 10: "TOPS-20", - 11: "NTFS filesystem (NT)", - 12: "QDOS", - 13: "Acorn RISCOS", - 255: "unknown", - } - - gzipHeaderFields := []StructField{ - {"magic", "u16"}, - {"compression_method", "u8"}, - {"flags", "u8"}, - {"timestamp", "u32"}, - {"extra_flags", "u8"}, - {"osid", "u8"}, - } - - gzipExtraHeaderFields := []StructField{ - {"id", "u16"}, - {"extra_data_len", "u16"}, - } - - if offset >= len(data) { - return nil, fmt.Errorf("gzip: offset out of bounds") - } - - headerData := data[offset:] - hdr, err := Parse(headerData, gzipHeaderFields, "little") - if err != nil { - return nil, fmt.Errorf("gzip: failed to parse header") - } - - if hdr["flags"]&flagReserved != 0 { - return nil, fmt.Errorf("gzip: reserved flags set") - } - if hdr["compression_method"] != deflateCompression { - return nil, fmt.Errorf("gzip: invalid compression method %d", hdr["compression_method"]) - } - osName, ok := knownOSIDs[hdr["osid"]] - if !ok { - return nil, fmt.Errorf("gzip: unknown OS ID %d", hdr["osid"]) - } - - headerSize := StructSize(gzipHeaderFields) - - // Handle optional extra data - if hdr["flags"]&flagExtra != 0 { - extraHeaderSize := StructSize(gzipExtraHeaderFields) - extraStart := headerSize - extraEnd := extraStart + extraHeaderSize - if extraEnd > len(headerData) { - return nil, fmt.Errorf("gzip: not enough data for extra header") - } - extraHdr, err := Parse(headerData[extraStart:], gzipExtraHeaderFields, "little") - if err != nil { - return nil, fmt.Errorf("gzip: failed to parse extra header") - } - headerSize += extraHeaderSize + extraHdr["extra_data_len"] - } - - // Handle optional original file name (NULL-terminated) - originalName := "" - if hdr["flags"]&flagName != 0 { - if headerSize >= len(headerData) { - return nil, fmt.Errorf("gzip: not enough data for original name") - } - originalName = GetCString(headerData[headerSize:]) - headerSize += len(originalName) + nullByteSize - } - - // Handle optional comment (NULL-terminated) - if hdr["flags"]&flagComment != 0 { - if headerSize >= len(headerData) { - return nil, fmt.Errorf("gzip: not enough data for comment") - } - comment := GetCString(headerData[headerSize:]) - headerSize += len(comment) + nullByteSize - } - - // Handle optional header CRC - if hdr["flags"]&flagCRC != 0 { - headerSize += crcSize - } - - if len(headerData) < headerSize { - return nil, fmt.Errorf("gzip: data shorter than header") - } - - originalFileNameText := "" - if originalName != "" { - originalFileNameText = fmt.Sprintf(" original file name: \"%s\",", originalName) - } - - description := fmt.Sprintf("%s,%s operating system: %s, timestamp: %s", - gzipDescription, - originalFileNameText, - osName, - EpochToString(uint32(hdr["timestamp"])), - ) - - return &SignatureResult{ - Offset: offset, - Size: 0, // size unknown without decompression - Description: description, - Confidence: ConfidenceMedium, - }, nil -} - -// --------------------------------------------------------------------------- -// zip -// --------------------------------------------------------------------------- - -const zipDescription = "ZIP archive" - -func zipMagic() [][]byte { - return [][]byte{ - {0x50, 0x4b, 0x03, 0x04}, // PK\x03\x04 - } -} - -func zipParser(data []byte, offset int) (*SignatureResult, error) { - return parseZipLike(data, offset, zipDescription) -} - -// parseZipLike is shared between zip and dahua_zip parsers. -func parseZipLike(data []byte, offset int, descPrefix string) (*SignatureResult, error) { - const ( - unusedFlagsMask = 0b11010111_10000000 - compressionEncrypted = 99 - ) - - zipLocalFileFields := []StructField{ - {"magic", "u32"}, - {"version", "u16"}, - {"flags", "u16"}, - {"compression", "u16"}, - {"modification_time", "u16"}, - {"modification_date", "u16"}, - {"crc", "u32"}, - {"compressed_size", "u32"}, - {"uncompressed_size", "u32"}, - {"file_name_len", "u16"}, - {"extra_field_len", "u16"}, - } - - allowedCompressionMethods := map[int]bool{ - 0: true, 1: true, 2: true, 3: true, 4: true, 5: true, 6: true, - 8: true, 9: true, 10: true, 12: true, 14: true, 18: true, - 19: true, 20: true, 93: true, 94: true, 95: true, 96: true, - 97: true, 98: true, compressionEncrypted: true, - } - - if offset >= len(data) { - return nil, fmt.Errorf("zip: offset out of bounds") - } - - zipData := data[offset:] - hdr, err := Parse(zipData, zipLocalFileFields, "little") - if err != nil { - return nil, fmt.Errorf("zip: failed to parse local file header") - } - - if hdr["flags"]&unusedFlagsMask != 0 { - return nil, fmt.Errorf("zip: unused flags set") - } - if !allowedCompressionMethods[hdr["compression"]] { - return nil, fmt.Errorf("zip: unsupported compression method %d", hdr["compression"]) - } - - versionMajor := hdr["version"] / 10 - versionMinor := hdr["version"] % 10 - - localHeaderSize := StructSize(zipLocalFileFields) + hdr["file_name_len"] + hdr["extra_field_len"] - dataSize := hdr["compressed_size"] - if dataSize == 0 { - dataSize = hdr["uncompressed_size"] - } - firstEntryTotalSize := localHeaderSize + dataSize - - // Try to find the end-of-central-directory header - eocdMagic := []byte("PK\x05\x06\x00\x00\x00\x00") - eocdFields := []StructField{ - {"magic", "u32"}, - {"disk_number", "u16"}, - {"central_directory_disk_number", "u16"}, - {"central_directory_disk_entries", "u16"}, - {"central_directory_total_entries", "u16"}, - {"central_directory_size", "u32"}, - {"central_directory_offset", "u32"}, - {"comment_length", "u16"}, - } - - totalSize := 0 - fileCount := 0 - missingEOCD := false - - // Search for EOCD in data after offset - searchData := data[offset:] - eocdIdx := -1 - for i := 0; i+len(eocdMagic) <= len(searchData); i++ { - if bytes.Equal(searchData[i:i+len(eocdMagic)], eocdMagic) { - eocdData := searchData[i:] - eocdHdr, err := Parse(eocdData, eocdFields, "little") - if err != nil { - continue - } - if eocdHdr["central_directory_disk_entries"] == eocdHdr["central_directory_total_entries"] && - eocdHdr["central_directory_total_entries"] > 0 { - eocdSize := StructSize(eocdFields) + eocdHdr["comment_length"] - eocdIdx = i - totalSize = eocdIdx + eocdSize - fileCount = eocdHdr["central_directory_total_entries"] - break - } - } - } - - if eocdIdx < 0 { - // No EOCD found — walk local file entries - missingEOCD = true - availableData := len(data) - offset - var previousOffset *int - nextOffset := offset + firstEntryTotalSize - - for IsOffsetSafe(availableData, nextOffset, previousOffset) { - entryHdr, err := Parse(data[nextOffset:], zipLocalFileFields, "little") - if err != nil { - totalSize = nextOffset - offset - break - } - if entryHdr["flags"]&unusedFlagsMask != 0 || !allowedCompressionMethods[entryHdr["compression"]] { - totalSize = nextOffset - offset - break - } - entryHeaderSize := StructSize(zipLocalFileFields) + entryHdr["file_name_len"] + entryHdr["extra_field_len"] - entryDataSize := entryHdr["compressed_size"] - if entryDataSize == 0 { - entryDataSize = entryHdr["uncompressed_size"] - } - prev := nextOffset - previousOffset = &prev - nextOffset += entryHeaderSize + entryDataSize - } - if totalSize == 0 { - totalSize = nextOffset - offset - } - } - - if totalSize <= 0 { - return nil, fmt.Errorf("zip: could not determine archive size") - } - - var description string - if missingEOCD { - description = fmt.Sprintf("%s, version: %d.%d, missing end-of-central-directory header, total size: %d bytes", - descPrefix, versionMajor, versionMinor, totalSize) - } else { - description = fmt.Sprintf("%s, version: %d.%d, file count: %d, total size: %d bytes", - descPrefix, versionMajor, versionMinor, fileCount, totalSize) - } - - return &SignatureResult{ - Offset: offset, - Size: totalSize, - Description: description, - Confidence: ConfidenceHigh, - }, nil -} - -// --------------------------------------------------------------------------- -// tarball -// --------------------------------------------------------------------------- - -const tarballDescription = "POSIX tar archive" - -const ( - tarballBlockSize = 512 - tarballMagicOffset = 257 - tarballMagicSize = 5 - tarballSizeOffset = 124 - tarballSizeLen = 11 - tarballMinExpectedHdrs = 10 - tarballChecksumStart = 148 - tarballChecksumEnd = 156 -) - -var tarballUniversalMagic = []byte("ustar") - -func tarballMagic() [][]byte { - return [][]byte{ - {0x75, 0x73, 0x74, 0x61, 0x72, 0x00}, // ustar\x00 - {0x75, 0x73, 0x74, 0x61, 0x72, 0x20, 0x20, 0x00}, // ustar\x20\x20\x00 - } -} - -func tarballParser(data []byte, offset int) (*SignatureResult, error) { - // The magic bytes are at offset 257 within a tar header block - tarballStartOffset := offset - tarballMagicOffset - - if tarballStartOffset < 0 || tarballStartOffset >= len(data) { - return nil, fmt.Errorf("tarball: invalid start offset") - } - - var tarballTotalSize int - var validHeaderCount int - nextHeaderStart := tarballStartOffset - var previousHeaderStart *int - availableData := len(data) - - for IsOffsetSafe(availableData, nextHeaderStart, previousHeaderStart) { - nextHeaderEnd := nextHeaderStart + tarballBlockSize - if nextHeaderEnd > len(data) { - break - } - headerBlock := data[nextHeaderStart:nextHeaderEnd] - - if !tarballHeaderChecksumValid(headerBlock) { - break - } - - validHeaderCount++ - - entrySize, err := tarballEntrySize(headerBlock) - if err != nil { - break - } - - tarballTotalSize += entrySize - prev := nextHeaderStart - previousHeaderStart = &prev - nextHeaderStart += entrySize - } - - if tarballTotalSize < tarballBlockSize { - return nil, fmt.Errorf("tarball: archive too small") - } - - confidence := ConfidenceMedium - if validHeaderCount >= tarballMinExpectedHdrs { - confidence = ConfidenceHigh - } - - return &SignatureResult{ - Offset: tarballStartOffset, - Size: tarballTotalSize, - Description: fmt.Sprintf("%s, file count: %d", tarballDescription, validHeaderCount), - Confidence: confidence, - }, nil -} - -func tarballHeaderChecksumValid(headerBlock []byte) bool { - checksumValueBytes := headerBlock[tarballChecksumStart:tarballChecksumEnd] - reportedChecksum := tarballOctal(checksumValueBytes) - var sum int - for i, b := range headerBlock { - if i >= tarballChecksumStart && i < tarballChecksumEnd { - sum += 0x20 - } else { - sum += int(b) - } - } - return sum == reportedChecksum -} - -func tarballEntrySize(entryData []byte) (int, error) { - entryMagic := entryData[tarballMagicOffset : tarballMagicOffset+tarballMagicSize] - if !bytes.Equal(entryMagic, tarballUniversalMagic) { - return 0, fmt.Errorf("tarball: invalid entry magic") - } - - entrySizeBytes := entryData[tarballSizeOffset : tarballSizeOffset+tarballSizeLen] - reportedEntrySize := tarballOctal(entrySizeBytes) - - blockCount := 1 + int(math.Ceil(float64(reportedEntrySize)/float64(tarballBlockSize))) - return blockCount * tarballBlockSize, nil -} - -func tarballOctal(octalStr []byte) int { - var num int - for _, c := range octalStr { - if c < 0x30 || c > 0x39 { - break - } - num = num*8 + int(c) - 0x30 - } - return num -} - -// --------------------------------------------------------------------------- -// 7-zip (sevenzip) -// --------------------------------------------------------------------------- - -const sevenzipDescription = "7-zip archive data" - -func sevenzipMagic() [][]byte { - return [][]byte{ - {0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c}, // 7z\xbc\xaf\x27\x1c - } -} - -func sevenzipParser(data []byte, offset int) (*SignatureResult, error) { - const ( - sevenzipCRCStart = 12 - sevenzipHeaderSize = 32 - ) - - sevenzipFields := []StructField{ - {"magic_p1", "u16"}, - {"magic_p2", "u32"}, - {"major_version", "u8"}, - {"minor_version", "u8"}, - {"header_crc", "u32"}, - {"next_header_offset", "u64"}, - {"next_header_size", "u64"}, - {"next_header_crc", "u32"}, - } - - if offset >= len(data) { - return nil, fmt.Errorf("7zip: offset out of bounds") - } - - szData := data[offset:] - hdr, err := Parse(szData, sevenzipFields, "little") - if err != nil { - return nil, fmt.Errorf("7zip: failed to parse header") - } - - // Validate the header CRC (over bytes 12..32) - if sevenzipHeaderSize > len(szData) { - return nil, fmt.Errorf("7zip: not enough data for header CRC") - } - crcData := szData[sevenzipCRCStart:sevenzipHeaderSize] - calculatedHeaderCRC := CRC32(crcData) - if calculatedHeaderCRC != uint32(hdr["header_crc"]) { - return nil, fmt.Errorf("7zip: header CRC mismatch") - } - - // Validate the next_header CRC - nextCRCStart := offset + sevenzipHeaderSize + hdr["next_header_offset"] - nextCRCEnd := nextCRCStart + hdr["next_header_size"] - if nextCRCEnd > len(data) { - return nil, fmt.Errorf("7zip: not enough data for next header CRC") - } - nextCRCData := data[nextCRCStart:nextCRCEnd] - calculatedNextCRC := CRC32(nextCRCData) - if int(calculatedNextCRC) != hdr["next_header_crc"] { - return nil, fmt.Errorf("7zip: next header CRC mismatch") - } - - totalSize := sevenzipHeaderSize + hdr["next_header_offset"] + hdr["next_header_size"] - - return &SignatureResult{ - Offset: offset, - Size: totalSize, - Description: fmt.Sprintf("%s, version %d.%d, total size: %d bytes", sevenzipDescription, hdr["major_version"], hdr["minor_version"], totalSize), - Confidence: ConfidenceHigh, - }, nil -} - -// --------------------------------------------------------------------------- -// RAR -// --------------------------------------------------------------------------- - -const rarDescription = "RAR archive" - -func rarMagic() [][]byte { - return [][]byte{ - {0x52, 0x61, 0x72, 0x21, 0x1a, 0x07}, // Rar!\x1A\x07 - } -} - -func rarParser(data []byte, offset int) (*SignatureResult, error) { - archiveHeaderFields := []StructField{ - {"magic_p1", "u32"}, - {"magic_p2", "u16"}, - {"version", "u8"}, - } - - // version field 0 => RARv4, 1 => RARv5 - versionMap := map[int]int{0: 4, 1: 5} - - if offset >= len(data) { - return nil, fmt.Errorf("rar: offset out of bounds") - } - - hdr, err := Parse(data[offset:], archiveHeaderFields, "little") - if err != nil { - return nil, fmt.Errorf("rar: failed to parse header") - } - - version, ok := versionMap[hdr["version"]] - if !ok { - return nil, fmt.Errorf("rar: unknown version %d", hdr["version"]) - } - - // EOF markers for RARv4 and RARv5 - eofMarkers := map[int][]byte{ - 4: {0xc4, 0x3d, 0x7b, 0x00, 0x40, 0x07, 0x00}, - 5: {0x1d, 0x77, 0x56, 0x51, 0x03, 0x05, 0x04, 0x00}, - } - - totalSize := 0 - extraDesc := "" - - if marker, ok := eofMarkers[version]; ok { - searchData := data[offset:] - idx := bytes.Index(searchData, marker) - if idx >= 0 { - totalSize = idx + len(marker) - } else { - extraDesc = " (failed to locate RAR EOF)" - } - } - - return &SignatureResult{ - Offset: offset, - Size: totalSize, - Description: fmt.Sprintf("%s, version: %d, total size: %d bytes%s", rarDescription, version, totalSize, extraDesc), - Confidence: ConfidenceMedium, - }, nil -} - -// --------------------------------------------------------------------------- -// CAB (Microsoft Cabinet) -// --------------------------------------------------------------------------- - -const cabDescription = "Microsoft Cabinet archive" - -func cabMagic() [][]byte { - return [][]byte{ - {0x4d, 0x53, 0x43, 0x46, 0x00, 0x00, 0x00, 0x00}, // MSCF\x00\x00\x00\x00 - } -} - -func cabParser(data []byte, offset int) (*SignatureResult, error) { - const ( - majorVersion = 1 - minorVersion = 3 - cabStructSize = 40 - cabExtraStructSize = 20 - flagExtraDataPresent = 4 - ) - - cabHeaderFields := []StructField{ - {"magic", "u32"}, - {"reserved1", "u32"}, - {"size", "u32"}, - {"reserved2", "u32"}, - {"first_file_offset", "u32"}, - {"reserved3", "u32"}, - {"minor_version", "u8"}, - {"major_version", "u8"}, - {"folder_count", "u16"}, - {"file_count", "u16"}, - {"flags", "u16"}, - {"id", "u16"}, - {"set_number", "u16"}, - {"extra_header_size", "u16"}, - {"cbCFFolder", "u8"}, - {"cbCFData", "u8"}, - } - - cabExtraHeaderFields := []StructField{ - {"unknown1", "u32"}, - {"data_offset", "u32"}, - {"data_size", "u32"}, - {"unknown2", "u32"}, - {"unknown3", "u32"}, - } - - if offset >= len(data) { - return nil, fmt.Errorf("cab: offset out of bounds") - } - - hdr, err := Parse(data[offset:], cabHeaderFields, "little") - if err != nil { - return nil, fmt.Errorf("cab: failed to parse header") - } - - // All reserved fields must be 0 - if hdr["reserved1"] != 0 || hdr["reserved2"] != 0 || hdr["reserved3"] != 0 { - return nil, fmt.Errorf("cab: reserved fields not zero") - } - - // Version must be 1.3 - if hdr["major_version"] != majorVersion || hdr["minor_version"] != minorVersion { - return nil, fmt.Errorf("cab: invalid version %d.%d", hdr["major_version"], hdr["minor_version"]) - } - - totalSize := hdr["size"] - headerSize := cabStructSize - - // Handle extra data flag - if hdr["flags"]&flagExtraDataPresent != 0 && hdr["extra_header_size"] == cabExtraStructSize { - extraStart := cabStructSize - extraEnd := extraStart + cabExtraStructSize - if offset+extraEnd > len(data) { - return nil, fmt.Errorf("cab: not enough data for extra header") - } - extraHdr, err := Parse(data[offset+extraStart:], cabExtraHeaderFields, "little") - if err != nil { - return nil, fmt.Errorf("cab: failed to parse extra header") - } - if extraHdr["data_offset"] != hdr["size"] { - return nil, fmt.Errorf("cab: extra data offset mismatch") - } - totalSize += extraHdr["data_size"] - _ = headerSize - } else if hdr["flags"]&flagExtraDataPresent != 0 { - return nil, fmt.Errorf("cab: extra data flag set but wrong extra header size") - } - - availableData := len(data) - offset - if totalSize > availableData { - return nil, fmt.Errorf("cab: reported size exceeds available data") - } - - return &SignatureResult{ - Offset: offset, - Size: totalSize, - Description: fmt.Sprintf("%s, file count: %d, folder count: %d, header size: %d, total size: %d bytes", cabDescription, hdr["file_count"], hdr["folder_count"], cabStructSize, totalSize), - Confidence: ConfidenceMedium, - }, nil -} - -// --------------------------------------------------------------------------- -// Debian package (deb) -// --------------------------------------------------------------------------- - -const debDescription = "Debian package file" - -func debMagic() [][]byte { - return [][]byte{ - // "!\ndebian-binary " - {0x21, 0x3c, 0x61, 0x72, 0x63, 0x68, 0x3e, 0x0a, - 0x64, 0x65, 0x62, 0x69, 0x61, 0x6e, 0x2d, 0x62, - 0x69, 0x6e, 0x61, 0x72, 0x79, 0x20, 0x20, 0x20}, - } -} - -func debParser(data []byte, offset int) (*SignatureResult, error) { - const ( - endMarkerSize = 2 - dataFileSizeLen = 10 - dataFileSizeOffset = 48 - controlFileSizeEnd = 130 - controlFileSizeStart = 120 - ) - - if offset >= len(data) { - return nil, fmt.Errorf("deb: offset out of bounds") - } - - debData := data[offset:] - - if len(debData) < controlFileSizeEnd { - return nil, fmt.Errorf("deb: not enough data for control file size") - } - - // Read control file size as ASCII decimal - controlFileSizeBytes := debData[controlFileSizeStart:controlFileSizeEnd] - controlFileSizeStr := strings.TrimSpace(string(controlFileSizeBytes)) - controlFileSize, err := strconv.Atoi(controlFileSizeStr) - if err != nil { - return nil, fmt.Errorf("deb: invalid control file size: %s", controlFileSizeStr) - } - - // Calculate data file size location - dataFileSizeStart := controlFileSizeEnd + endMarkerSize + controlFileSize + dataFileSizeOffset - dataFileSizeEnd := dataFileSizeStart + dataFileSizeLen - - if dataFileSizeEnd > len(debData) { - return nil, fmt.Errorf("deb: not enough data for data file size") - } - - dataFileSizeBytes := debData[dataFileSizeStart:dataFileSizeEnd] - dataFileSizeStr := strings.TrimSpace(string(dataFileSizeBytes)) - dataFileSize, err := strconv.Atoi(dataFileSizeStr) - if err != nil { - return nil, fmt.Errorf("deb: invalid data file size: %s", dataFileSizeStr) - } - - totalSize := dataFileSizeEnd + endMarkerSize + dataFileSize - - if totalSize > len(debData) { - return nil, fmt.Errorf("deb: reported size exceeds available data") - } - - return &SignatureResult{ - Offset: offset, - Size: totalSize, - Description: fmt.Sprintf("%s, total size: %d bytes", debDescription, totalSize), - Confidence: ConfidenceHigh, - }, nil -} - -// --------------------------------------------------------------------------- -// CPIO -// --------------------------------------------------------------------------- - -const cpioDescription = "CPIO ASCII archive" - -const cpioHeaderSize = 110 - -func cpioMagic() [][]byte { - return [][]byte{ - {0x30, 0x37, 0x30, 0x37, 0x30, 0x31}, // 070701 - {0x30, 0x37, 0x30, 0x37, 0x30, 0x32}, // 070702 - } -} - -func cpioParser(data []byte, offset int) (*SignatureResult, error) { - const eofMarker = "TRAILER!!!" - - if offset >= len(data) { - return nil, fmt.Errorf("cpio: offset out of bounds") - } - - validMagics := cpioMagic() - - var headerCount int - var totalSize int - nextHeaderOffset := offset - var previousHeaderOffset *int - availableData := len(data) - - for IsOffsetSafe(availableData, nextHeaderOffset, previousHeaderOffset) { - if nextHeaderOffset >= len(data) { - break - } - cpioEntryData := data[nextHeaderOffset:] - - entryHeader, err := parseCPIOEntryHeader(cpioEntryData) - if err != nil { - break - } - - // Validate magic - magicValid := false - for _, m := range validMagics { - if bytes.Equal(entryHeader.magic, m) { - magicValid = true - break - } - } - if !magicValid { - break - } - - headerCount++ - totalSize += entryHeader.headerSize + entryHeader.dataSize - - if entryHeader.fileName == eofMarker { - if headerCount > 1 { - return &SignatureResult{ - Offset: offset, - Size: totalSize, - Description: fmt.Sprintf("%s, file count: %d", cpioDescription, headerCount-1), - Confidence: ConfidenceHigh, - }, nil - } - break - } - - prev := nextHeaderOffset - previousHeaderOffset = &prev - nextHeaderOffset = offset + totalSize - } - - return nil, fmt.Errorf("cpio: no valid archive found") -} - -type cpioEntry struct { - magic []byte - fileName string - headerSize int - dataSize int -} - -func parseCPIOEntryHeader(cpioData []byte) (*cpioEntry, error) { - const ( - nullByteSize = 1 - cpioMagicStart = 0 - cpioMagicEnd = 6 - fileSizeStart = 54 - fileSizeEnd = 62 - fileNameSizeStart = 94 - fileNameSizeEnd = 102 - ) - - if len(cpioData) <= cpioHeaderSize { - return nil, fmt.Errorf("cpio: not enough data") - } - - headerMagic := make([]byte, cpioMagicEnd-cpioMagicStart) - copy(headerMagic, cpioData[cpioMagicStart:cpioMagicEnd]) - - // Get file data size (ASCII hex) - fileSizeStr := string(cpioData[fileSizeStart:fileSizeEnd]) - fileDataSize, err := strconv.ParseInt(fileSizeStr, 16, 64) - if err != nil { - return nil, fmt.Errorf("cpio: invalid file data size") - } - - // Get file name size (ASCII hex) - fileNameSizeStr := string(cpioData[fileNameSizeStart:fileNameSizeEnd]) - fileNameSize, err := strconv.ParseInt(fileNameSizeStr, 16, 64) - if err != nil { - return nil, fmt.Errorf("cpio: invalid file name size") - } - - fileNameStart := cpioHeaderSize - fileNameEnd := fileNameStart + int(fileNameSize) - nullByteSize - - if fileNameEnd > len(cpioData) || fileNameEnd < fileNameStart { - return nil, fmt.Errorf("cpio: file name out of bounds") - } - - fileName := string(cpioData[fileNameStart:fileNameEnd]) - - headerTotalSize := cpioHeaderSize + int(fileNameSize) - - return &cpioEntry{ - magic: headerMagic, - fileName: fileName, - headerSize: headerTotalSize + cpioBytePadding(headerTotalSize), - dataSize: int(fileDataSize) + cpioBytePadding(int(fileDataSize)), - }, nil -} - -func cpioBytePadding(n int) int { - mod := n % 4 - if mod == 0 { - return 0 - } - return 4 - mod -} - -// --------------------------------------------------------------------------- -// ARJ -// --------------------------------------------------------------------------- - -const arjDescription = "ARJ archive data" - -func arjMagic() [][]byte { - return [][]byte{ - {0x60, 0xea}, - } -} - -func arjParser(data []byte, offset int) (*SignatureResult, error) { - arjHeaderFields := []StructField{ - {"magic", "u16"}, - {"basic_header_size", "u16"}, - {"extra_header_size", "u8"}, - {"archiver_version", "u8"}, - {"min_version", "u8"}, - {"host_os", "u8"}, - {"internal_flags", "u8"}, - {"compression_method", "u8"}, - {"file_type", "u8"}, - {"reserved1", "u8"}, - {"datetime_file", "u32"}, - {"compressed_filesize", "u32"}, - {"original_filesize", "u32"}, - } - - if offset >= len(data) { - return nil, fmt.Errorf("arj: offset out of bounds") - } - - hdr, err := Parse(data[offset:], arjHeaderFields, "little") - if err != nil { - return nil, fmt.Errorf("arj: failed to parse header") - } - - // Version validation - if hdr["archiver_version"] < 1 || hdr["archiver_version"] > 16 || - hdr["min_version"] < 1 || hdr["min_version"] > 16 || - hdr["archiver_version"] < hdr["min_version"] { - return nil, fmt.Errorf("arj: invalid version info") - } - - // Flags - flags := "no password" - if hdr["internal_flags"]&0x01 != 0 { - flags = "password" - } - if hdr["internal_flags"]&0x04 != 0 { - flags += "|multi-volume" - } - if hdr["internal_flags"]&0x10 != 0 { - flags += "|slash-switched" - } - if hdr["internal_flags"]&0x20 != 0 { - flags += "|backup" - } - - // Host OS - hostOSMap := map[int]string{ - 0: "MS-DOS", 1: "PRIMOS", 2: "UNIX", 3: "AMIGA", 4: "MAX-OS", - 5: "OS/2", 6: "APPLE GS", 7: "ATARI ST", 8: "NeXT", 9: "VAX VMS", - } - hostOS, ok := hostOSMap[hdr["host_os"]] - if !ok { - return nil, fmt.Errorf("arj: unknown host OS %d", hdr["host_os"]) - } - - // Compression method - compressionMethodMap := map[int]string{ - 0: "stored", 1: "compressed most", 2: "compressed", - 3: "compressed faster", 4: "compressed fastest", - } - compressionMethod, ok := compressionMethodMap[hdr["compression_method"]] - if !ok { - return nil, fmt.Errorf("arj: unknown compression method %d", hdr["compression_method"]) - } - - // File type - fileTypeMap := map[int]string{ - 0: "binary", 1: "7-bit text", 2: "comment header", - 3: "directory", 4: "volume label", - } - fileType, ok := fileTypeMap[hdr["file_type"]] - if !ok { - return nil, fmt.Errorf("arj: unknown file type %d", hdr["file_type"]) - } - - // Compressed/uncompressed sizes — check for negative as signed i32 - compressedFileSize := int32(hdr["compressed_filesize"]) - if compressedFileSize < 0 { - return nil, fmt.Errorf("arj: negative compressed file size") - } - uncompressedFileSize := int32(hdr["original_filesize"]) - if uncompressedFileSize < 0 { - return nil, fmt.Errorf("arj: negative uncompressed file size") - } - - // Original file name - headerSize := hdr["extra_header_size"] - originalName := "" - if headerSize+4 < len(data[offset:]) { - originalName = GetCString(data[offset+headerSize+4:]) - } - - originalFileDate := EpochToString(uint32(hdr["datetime_file"])) - - availableData := len(data) - offset - if headerSize > availableData { - return nil, fmt.Errorf("arj: header size exceeds available data") - } - - extractionDeclined := fileType != "comment header" - - return &SignatureResult{ - Offset: offset, - Size: headerSize, - Description: fmt.Sprintf( - "%s, header size: %d, version %d, minimum version to extract: %d, flags: %s, compression method: %s, file type: %s, original name: %s, original file date: %s, compressed file size: %d, uncompressed file size: %d, os: %s", - arjDescription, - headerSize, - hdr["archiver_version"], - hdr["min_version"], - flags, - compressionMethod, - fileType, - originalName, - originalFileDate, - compressedFileSize, - uncompressedFileSize, - hostOS, - ), - Confidence: ConfidenceMedium, - ExtractionDeclined: extractionDeclined, - }, nil -} - -// --------------------------------------------------------------------------- -// ISO 9660 -// --------------------------------------------------------------------------- - -const iso9660Description = "ISO9660 primary volume" - -func iso9660Magic() [][]byte { - return [][]byte{ - {0x01, 0x43, 0x44, 0x30, 0x30, 0x31, 0x01, 0x00}, // \x01CD001\x01\x00 - } -} - -func iso9660Parser(data []byte, offset int) (*SignatureResult, error) { - const ( - isoMagicOffset = 32768 - isoStructStart = 32840 - ) - - isoFields := []StructField{ - {"unused1", "u64"}, - {"volume_size_lsb", "u32"}, - {"volume_size_msb", "u32"}, - {"unused2", "u64"}, - {"unused3", "u64"}, - {"unused4", "u64"}, - {"unused5", "u64"}, - {"set_size_lsb", "u16"}, - {"set_size_msb", "u16"}, - {"sequence_number_lsb", "u16"}, - {"sequence_number_msb", "u16"}, - {"block_size_lsb", "u16"}, - {"block_size_msb", "u16"}, - {"path_table_size_lsb", "u32"}, - {"path_table_size_msb", "u32"}, - } - - if offset < isoMagicOffset { - return nil, fmt.Errorf("iso9660: not enough data before magic offset") - } - - isoStart := offset - isoMagicOffset - - if isoStart+isoStructStart >= len(data) { - return nil, fmt.Errorf("iso9660: not enough data for header") - } - - isoHeaderData := data[isoStart+isoStructStart:] - hdr, err := Parse(isoHeaderData, isoFields, "little") - if err != nil { - return nil, fmt.Errorf("iso9660: failed to parse header") - } - - // Unused fields must be 0 - if hdr["unused1"] != 0 || hdr["unused2"] != 0 || hdr["unused3"] != 0 || - hdr["unused4"] != 0 || hdr["unused5"] != 0 { - return nil, fmt.Errorf("iso9660: unused fields not zero") - } - - // Validate byte-swapped field pairs - if hdr["set_size_lsb"] != int(swapU16(uint16(hdr["set_size_msb"]))) { - return nil, fmt.Errorf("iso9660: set_size mismatch") - } - if hdr["block_size_lsb"] != int(swapU16(uint16(hdr["block_size_msb"]))) { - return nil, fmt.Errorf("iso9660: block_size mismatch") - } - if hdr["volume_size_lsb"] != int(swapU32(uint32(hdr["volume_size_msb"]))) { - return nil, fmt.Errorf("iso9660: volume_size mismatch") - } - if hdr["sequence_number_lsb"] != int(swapU16(uint16(hdr["sequence_number_msb"]))) { - return nil, fmt.Errorf("iso9660: sequence_number mismatch") - } - if hdr["path_table_size_lsb"] != int(swapU32(uint32(hdr["path_table_size_msb"]))) { - return nil, fmt.Errorf("iso9660: path_table_size mismatch") - } - - imageSize := hdr["volume_size_lsb"] * hdr["block_size_lsb"] - - return &SignatureResult{ - Offset: isoStart, - Size: imageSize, - Description: fmt.Sprintf("%s, total size: %d bytes", iso9660Description, imageSize), - Confidence: ConfidenceHigh, - }, nil -} - -// swapU16 swaps bytes of a uint16 (big-endian to native / vice versa) -func swapU16(v uint16) uint16 { - b := make([]byte, 2) - binary.BigEndian.PutUint16(b, v) - return binary.LittleEndian.Uint16(b) -} - -// swapU32 swaps bytes of a uint32 -func swapU32(v uint32) uint32 { - b := make([]byte, 4) - binary.BigEndian.PutUint32(b, v) - return binary.LittleEndian.Uint32(b) -} - -// --------------------------------------------------------------------------- -// Dahua ZIP -// --------------------------------------------------------------------------- - -const dahuaZipDescription = "Dahua ZIP archive" - -func dahuaZipMagic() [][]byte { - return [][]byte{ - {0x44, 0x48, 0x03, 0x04}, // DH\x03\x04 - } -} - -func dahuaZipParser(data []byte, offset int) (*SignatureResult, error) { - // Parse as a normal ZIP file, then replace the description prefix - result, err := parseZipLike(data, offset, zipDescription) - if err != nil { - return nil, fmt.Errorf("dahua_zip: %v", err) - } - result.Description = strings.Replace(result.Description, zipDescription, dahuaZipDescription, 1) - return result, nil -} diff --git a/internal/leviathan/binsnoop/sig_compression.go b/internal/leviathan/binsnoop/sig_compression.go deleted file mode 100644 index eccfd7a..0000000 --- a/internal/leviathan/binsnoop/sig_compression.go +++ /dev/null @@ -1,1081 +0,0 @@ -package binsnoop - -import ( - "encoding/binary" - "fmt" -) - -// --------------------------------------------------------------------------- -// LZMA -// --------------------------------------------------------------------------- - -const LZMADescription = "LZMA compressed data" - -func LZMAMagic() [][]byte { - // Common LZMA properties - properties := []byte{0x5D, 0x6E, 0x6D, 0x6C} - - dictionarySizes := []uint32{ - 0x10000000, - 0x20000000, - 0x01000000, - 0x02000000, - 0x04000000, - 0x00800000, - 0x00400000, - 0x00200000, - 0x00100000, - 0x00080000, - 0x00020000, - 0x00010000, - } - - // Build magic signatures from property+dictionary_size combinations (little-endian) - var sigs [][]byte - for _, prop := range properties { - for _, dict := range dictionarySizes { - magic := make([]byte, 5) - magic[0] = prop - binary.LittleEndian.PutUint32(magic[1:], dict) - sigs = append(sigs, magic) - } - } - return sigs -} - -func LZMAParser(data []byte, offset int) (*SignatureResult, error) { - // Streamed data has a reported size of -1 (0xFFFFFFFFFFFFFFFF) - const lzmaStreamSize = -1 - - const minSupportedDecompressedSize = 256 - const maxSupportedDecompressedSize = 0xFFFFFFFF - - lzmaStructure := []StructField{ - {"properties", "u8"}, - {"dictionary_size", "u32"}, - {"decompressed_size", "u64"}, - {"null_byte", "u8"}, - } - - lzmaData := data[offset:] - if len(lzmaData) < StructSize(lzmaStructure) { - return nil, fmt.Errorf("LZMA: not enough data for header") - } - - hdr, err := Parse(lzmaData, lzmaStructure, "little") - if err != nil { - return nil, fmt.Errorf("LZMA: header parse error: %w", err) - } - - // Make sure the expected NULL byte is NULL - if hdr["null_byte"] != 0 { - return nil, fmt.Errorf("LZMA: null byte is not zero") - } - - // Sanity check the reported decompressed size. - // In Go int (signed 64-bit), 0xFFFFFFFFFFFFFFFF becomes -1. - decompressedSize := hdr["decompressed_size"] - validSize := decompressedSize == lzmaStreamSize || - (decompressedSize >= minSupportedDecompressedSize && - decompressedSize <= maxSupportedDecompressedSize) - if !validSize { - return nil, fmt.Errorf("LZMA: invalid decompressed size: %d", decompressedSize) - } - - return &SignatureResult{ - Offset: offset, - Description: fmt.Sprintf( - "%s, properties: 0x%02X, dictionary size: %d bytes, uncompressed size: %d bytes", - LZMADescription, - hdr["properties"], - hdr["dictionary_size"], - decompressedSize, - ), - Confidence: ConfidenceHigh, - }, nil -} - -// --------------------------------------------------------------------------- -// bzip2 -// --------------------------------------------------------------------------- - -const Bzip2Description = "bzip2 compressed data" - -func Bzip2Magic() [][]byte { - // Magic bytes include version, block size, and compressed magic signature - return [][]byte{ - []byte("BZh91AY&SY"), - []byte("BZh81AY&SY"), - []byte("BZh71AY&SY"), - []byte("BZh61AY&SY"), - []byte("BZh51AY&SY"), - []byte("BZh41AY&SY"), - []byte("BZh31AY&SY"), - []byte("BZh21AY&SY"), - []byte("BZh11AY&SY"), - } -} - -func Bzip2Parser(data []byte, offset int) (*SignatureResult, error) { - // Validate we have enough data for at least the magic + some compressed data - if offset+10 >= len(data) { - return nil, fmt.Errorf("bzip2: not enough data") - } - - return &SignatureResult{ - Offset: offset, - Description: Bzip2Description, - Confidence: ConfidenceHigh, - }, nil -} - -// --------------------------------------------------------------------------- -// XZ -// --------------------------------------------------------------------------- - -const XZDescription = "XZ compressed data" - -func XZMagic() [][]byte { - return [][]byte{ - {0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00}, - } -} - -func XZParser(data []byte, offset int) (*SignatureResult, error) { - const xzCRCStart = 6 - const xzCRCEnd = 8 - const xzHeaderSize = 12 - - xzStructure := []StructField{ - {"magic_p1", "u32"}, - {"magic_p2", "u16"}, - {"flags", "u16"}, - {"header_crc", "u32"}, - } - - xzData := data[offset:] - if len(xzData) < StructSize(xzStructure) { - return nil, fmt.Errorf("XZ: not enough data for header") - } - - hdr, err := Parse(xzData, xzStructure, "little") - if err != nil { - return nil, fmt.Errorf("XZ: header parse error: %w", err) - } - - // Validate the header CRC over the flags bytes (bytes 6..8) - if xzCRCEnd > len(xzData) { - return nil, fmt.Errorf("XZ: not enough data for CRC") - } - - crcData := xzData[xzCRCStart:xzCRCEnd] - calculatedCRC := CRC32(crcData) - if calculatedCRC != uint32(hdr["header_crc"]) { - return nil, fmt.Errorf("XZ: header CRC mismatch: expected 0x%08X, got 0x%08X", - hdr["header_crc"], calculatedCRC) - } - - return &SignatureResult{ - Offset: offset, - Description: XZDescription, - Confidence: ConfidenceHigh, - Size: xzHeaderSize, - }, nil -} - -// --------------------------------------------------------------------------- -// ZSTD -// --------------------------------------------------------------------------- - -const ZSTDDescription = "ZSTD compressed data" - -func ZSTDMagic() [][]byte { - return [][]byte{ - {0x28, 0xB5, 0x2F, 0xFD}, - } -} - -func ZSTDParser(data []byte, offset int) (*SignatureResult, error) { - const eofChecksumSize = 4 - const minBlockCount = 2 - - availableData := len(data) - - // Parse the ZSTD frame header - zstdHeader, err := parseZSTDHeader(data[offset:]) - if err != nil { - return nil, fmt.Errorf("ZSTD: %w", err) - } - - // Calculate first block header offset accounting for optional fields - nextBlockStart := offset + zstdHeader.fixedHeaderSize - - // If single segment flag is not set, window descriptor byte is present - if !zstdHeader.singleSegmentFlag { - nextBlockStart++ - } - - // Dictionary ID field size - if zstdHeader.dictionaryIDFlag == 1 { - nextBlockStart++ - } else if zstdHeader.dictionaryIDFlag == 2 { - nextBlockStart += 2 - } else if zstdHeader.dictionaryIDFlag == 3 { - nextBlockStart += 4 - } - - // Frame content header field size - if zstdHeader.frameContentFlag == 0 && zstdHeader.singleSegmentFlag { - nextBlockStart++ - } else if zstdHeader.frameContentFlag == 1 { - nextBlockStart += 2 - } else if zstdHeader.frameContentFlag == 2 { - nextBlockStart += 4 - } else if zstdHeader.frameContentFlag == 3 { - nextBlockStart += 8 - } - - var previousBlockStart *int - blockCount := 0 - totalSize := 0 - - // Walk through blocks to determine end of ZSTD data - for IsOffsetSafe(availableData, nextBlockStart, previousBlockStart) { - blockHeader, err := parseZSTDBlockHeader(data[nextBlockStart:]) - if err != nil { - break - } - - blockCount++ - prev := nextBlockStart - previousBlockStart = &prev - nextBlockStart += blockHeader.headerSize + blockHeader.blockSize - - if blockHeader.lastBlock { - totalSize = nextBlockStart - offset - if zstdHeader.contentChecksumPresent { - totalSize += eofChecksumSize - } - if blockCount >= minBlockCount { - return &SignatureResult{ - Offset: offset, - Description: fmt.Sprintf("%s, total size: %d bytes", ZSTDDescription, totalSize), - Confidence: ConfidenceHigh, - Size: totalSize, - }, nil - } - break - } - } - - return nil, fmt.Errorf("ZSTD: failed to validate block structure") -} - -type zstdHeaderInfo struct { - fixedHeaderSize int - dictionaryIDFlag int - contentChecksumPresent bool - singleSegmentFlag bool - frameContentFlag int -} - -func parseZSTDHeader(zstdData []byte) (*zstdHeaderInfo, error) { - const frameUnusedBitsMask = 0b00011000 - const dictionaryIDMask = 0b11 - const contentChecksumMask = 0b100 - const singleSegmentMask = 0b100000 - const frameContentMask = 0b11000000 - const frameContentShift = 6 - - structure := []StructField{ - {"magic", "u32"}, - {"frame_header_descriptor", "u8"}, - } - - hdr, err := Parse(zstdData, structure, "little") - if err != nil { - return nil, fmt.Errorf("header parse error: %w", err) - } - - // Unused bits should be zero - if (hdr["frame_header_descriptor"] & frameUnusedBitsMask) != 0 { - return nil, fmt.Errorf("unused bits are set in frame header descriptor") - } - - return &zstdHeaderInfo{ - fixedHeaderSize: StructSize(structure), - dictionaryIDFlag: hdr["frame_header_descriptor"] & dictionaryIDMask, - contentChecksumPresent: (hdr["frame_header_descriptor"] & contentChecksumMask) != 0, - singleSegmentFlag: (hdr["frame_header_descriptor"] & singleSegmentMask) != 0, - frameContentFlag: (hdr["frame_header_descriptor"] & frameContentMask) >> frameContentShift, - }, nil -} - -type zstdBlockHeaderInfo struct { - headerSize int - blockType int - blockSize int - lastBlock bool -} - -func parseZSTDBlockHeader(blockData []byte) (*zstdBlockHeaderInfo, error) { - const blockTypeMask = 0b110 - const blockTypeShift = 1 - const rleBlockType = 1 - const reservedBlockType = 3 - const lastBlockMask = 0b1 - const blockSizeMask = 0b1111_1111_1111_1111_1111_1000 - const blockSizeShift = 3 - - structure := []StructField{{"info_bits", "u24"}} - - hdr, err := Parse(blockData, structure, "little") - if err != nil { - return nil, fmt.Errorf("block header parse error: %w", err) - } - - info := &zstdBlockHeaderInfo{ - headerSize: StructSize(structure), - lastBlock: (hdr["info_bits"] & lastBlockMask) != 0, - blockType: (hdr["info_bits"] & blockTypeMask) >> blockTypeShift, - blockSize: (hdr["info_bits"] & blockSizeMask) >> blockSizeShift, - } - - // RLE block: single byte of raw data - if info.blockType == rleBlockType { - info.blockSize = 1 - } - - // Reserved block type is invalid - if info.blockType == reservedBlockType { - return nil, fmt.Errorf("reserved block type encountered") - } - - return info, nil -} - -// --------------------------------------------------------------------------- -// LZ4 -// --------------------------------------------------------------------------- - -const LZ4Description = "LZ4 compressed data" - -func LZ4Magic() [][]byte { - return [][]byte{ - {0x04, 0x22, 0x4D, 0x18}, - } -} - -func LZ4Parser(data []byte, offset int) (*SignatureResult, error) { - const contentChecksumLen = 4 - - lz4Header, err := parseLZ4FileHeader(data[offset:]) - if err != nil { - return nil, fmt.Errorf("LZ4: %w", err) - } - - if offset+lz4Header.headerSize >= len(data) { - return nil, fmt.Errorf("LZ4: not enough data after header") - } - - lz4Data := data[offset+lz4Header.headerSize:] - dataSize, err := getLZ4DataSize(lz4Data, lz4Header.blockChecksumPresent) - if err != nil { - return nil, fmt.Errorf("LZ4: %w", err) - } - - totalSize := lz4Header.headerSize + dataSize - if lz4Header.contentChecksumPresent { - totalSize += contentChecksumLen - } - - return &SignatureResult{ - Offset: offset, - Description: fmt.Sprintf("%s, total size: %d bytes", LZ4Description, totalSize), - Confidence: ConfidenceMedium, - Size: totalSize, - }, nil -} - -type lz4FileHeaderInfo struct { - headerSize int - blockChecksumPresent bool - contentChecksumPresent bool -} - -func parseLZ4FileHeader(lz4Data []byte) (*lz4FileHeaderInfo, error) { - const magicSize = 4 - const lz4StructSize = 6 - - const bdReservedMask = 0b10001111 - const flagsReservedMask = 0b00000010 - - const flagDictionaryPresent = 0b00000001 - const flagContentSizePresent = 0b00001000 - const flagBlockChecksumPresent = 0b00010000 - const flagContentChecksumPresent = 0b00000100 - - const dictionaryLen = 4 - const contentSizeLen = 8 - - structure := []StructField{ - {"magic", "u32"}, - {"flags", "u8"}, - {"bd", "u8"}, - } - - hdr, err := Parse(lz4Data, structure, "little") - if err != nil { - return nil, fmt.Errorf("file header parse error: %w", err) - } - - // Reserved bits must not be set - if (hdr["flags"] & flagsReservedMask) != 0 { - return nil, fmt.Errorf("flags reserved bits are set") - } - if (hdr["bd"] & bdReservedMask) != 0 { - return nil, fmt.Errorf("bd reserved bits are set") - } - - // Calculate CRC data boundaries (CRC covers descriptor excluding magic) - crcDataStart := magicSize - crcDataEnd := crcDataStart + (lz4StructSize - magicSize) - - if (hdr["flags"] & flagContentSizePresent) != 0 { - crcDataEnd += contentSizeLen - } - if (hdr["flags"] & flagDictionaryPresent) != 0 { - crcDataEnd += dictionaryLen - } - - if crcDataEnd >= len(lz4Data) { - return nil, fmt.Errorf("not enough data for header CRC") - } - - crcData := lz4Data[crcDataStart:crcDataEnd] - actualCRC := lz4Data[crcDataEnd] - - // LZ4 header CRC is the second byte of xxh32 hash over the descriptor - calculatedCRC := byte((xxh32(crcData, 0) >> 8) & 0xFF) - if actualCRC != calculatedCRC { - return nil, fmt.Errorf("header CRC mismatch: expected 0x%02X, got 0x%02X", - actualCRC, calculatedCRC) - } - - return &lz4FileHeaderInfo{ - headerSize: crcDataEnd + 1, - blockChecksumPresent: (hdr["flags"] & flagBlockChecksumPresent) != 0, - contentChecksumPresent: (hdr["flags"] & flagContentChecksumPresent) != 0, - }, nil -} - -func getLZ4DataSize(lz4Data []byte, checksumPresent bool) (int, error) { - dataSize := 0 - var lastOffset *int - available := len(lz4Data) - - for IsOffsetSafe(available, dataSize, lastOffset) { - if dataSize >= len(lz4Data) { - break - } - - blockHeader, err := parseLZ4BlockHeader(lz4Data[dataSize:], checksumPresent) - if err != nil { - break - } - - prev := dataSize - lastOffset = &prev - dataSize += blockHeader.headerSize + blockHeader.dataSize + blockHeader.checksumSize - - if blockHeader.lastBlock { - return dataSize, nil - } - } - - return 0, fmt.Errorf("failed to find LZ4 end marker") -} - -type lz4BlockHeaderInfo struct { - dataSize int - headerSize int - checksumSize int - lastBlock bool -} - -func parseLZ4BlockHeader(blockData []byte, checksumPresent bool) (*lz4BlockHeaderInfo, error) { - const sizeMask uint32 = 0x7FFFFFFF - const endMarker = 0 - const checksumSize = 4 - const blockStructSize = 4 - - structure := []StructField{{"block_size", "u32"}} - - hdr, err := Parse(blockData, structure, "little") - if err != nil { - return nil, fmt.Errorf("block header parse error: %w", err) - } - - info := &lz4BlockHeaderInfo{ - headerSize: blockStructSize, - lastBlock: hdr["block_size"] == endMarker, - } - - if checksumPresent { - info.checksumSize = checksumSize - } - - // High bit is not part of the actual block size - info.dataSize = int(uint32(hdr["block_size"]) & sizeMask) - - return info, nil -} - -// xxh32 implements the XXHash32 algorithm used by LZ4 for header checksums. -func xxh32(input []byte, seed uint32) uint32 { - const ( - prime1 uint32 = 0x9E3779B1 - prime2 uint32 = 0x85EBCA77 - prime3 uint32 = 0xC2B2AE3D - prime4 uint32 = 0x27D4EB2F - prime5 uint32 = 0x165667B1 - ) - - n := len(input) - p := 0 - var h32 uint32 - - if n >= 16 { - v1 := seed + prime1 + prime2 - v2 := seed + prime2 - v3 := seed - v4 := seed - prime1 - for p+16 <= n { - v1 = xxh32Round(v1, binary.LittleEndian.Uint32(input[p:]), prime1, prime2) - v2 = xxh32Round(v2, binary.LittleEndian.Uint32(input[p+4:]), prime1, prime2) - v3 = xxh32Round(v3, binary.LittleEndian.Uint32(input[p+8:]), prime1, prime2) - v4 = xxh32Round(v4, binary.LittleEndian.Uint32(input[p+12:]), prime1, prime2) - p += 16 - } - h32 = ((v1 << 1) | (v1 >> 31)) + - ((v2 << 7) | (v2 >> 25)) + - ((v3 << 12) | (v3 >> 20)) + - ((v4 << 18) | (v4 >> 14)) - } else { - h32 = seed + prime5 - } - - h32 += uint32(n) - - for p+4 <= n { - h32 += binary.LittleEndian.Uint32(input[p:]) * prime3 - h32 = ((h32 << 17) | (h32 >> 15)) * prime4 - p += 4 - } - - for p < n { - h32 += uint32(input[p]) * prime5 - h32 = ((h32 << 11) | (h32 >> 21)) * prime1 - p++ - } - - h32 ^= h32 >> 15 - h32 *= prime2 - h32 ^= h32 >> 13 - h32 *= prime3 - h32 ^= h32 >> 16 - - return h32 -} - -func xxh32Round(acc, input, prime1, prime2 uint32) uint32 { - acc += input * prime2 - acc = (acc << 13) | (acc >> 19) - acc *= prime1 - return acc -} - -// --------------------------------------------------------------------------- -// LZOP -// --------------------------------------------------------------------------- - -const LZOPDescription = "LZO compressed data" - -func LZOPMagic() [][]byte { - return [][]byte{ - {0x89, 0x4C, 0x5A, 0x4F, 0x00, 0x0D, 0x0A, 0x1A, 0x0A}, - } -} - -func LZOPParser(data []byte, offset int) (*SignatureResult, error) { - lzopHeader, err := parseLZOPFileHeader(data[offset:]) - if err != nil { - return nil, fmt.Errorf("LZOP: %w", err) - } - - if offset+lzopHeader.headerSize >= len(data) { - return nil, fmt.Errorf("LZOP: not enough data after header") - } - - lzopData := data[offset+lzopHeader.headerSize:] - dataSize, err := getLZODataSize(lzopData, lzopHeader.blockChecksumPresent) - if err != nil { - return nil, fmt.Errorf("LZOP: %w", err) - } - - totalSize := lzopHeader.headerSize + dataSize - return &SignatureResult{ - Offset: offset, - Description: fmt.Sprintf("%s, total size: %d bytes", LZOPDescription, totalSize), - Confidence: ConfidenceHigh, - Size: totalSize, - }, nil -} - -type lzopFileHeaderInfo struct { - headerSize int - blockChecksumPresent bool -} - -func parseLZOPFileHeader(lzopData []byte) (*lzopFileHeaderInfo, error) { - const lzoMaxVersion = 0x1040 - const lzoHeaderSizeP1 = 21 - const lzoHeaderSizeP2 = 13 - const filterSize = 4 - const lzoChecksumSize = 4 - - const flagFilter = 0x0000_0800 - const flagCRC32C = 0x0000_0200 - const flagAdler32C = 0x0000_0002 - - structureP1 := []StructField{ - {"magic_p1", "u8"}, - {"magic_p2", "u64"}, - {"version", "u16"}, - {"lib_version", "u16"}, - {"version_needed", "u16"}, - {"method", "u8"}, - {"level", "u8"}, - {"flags", "u32"}, - } - - structureP2 := []StructField{ - {"mode", "u32"}, - {"mtime", "u32"}, - {"gmt_diff", "u32"}, - {"file_name_length", "u8"}, - } - - allowedMethods := []int{1, 2, 3} - - hdrP1, err := Parse(lzopData, structureP1, "big") - if err != nil { - return nil, fmt.Errorf("header part 1 parse error: %w", err) - } - - // Sanity check methods - methodValid := false - for _, m := range allowedMethods { - if hdrP1["method"] == m { - methodValid = true - break - } - } - if !methodValid { - return nil, fmt.Errorf("invalid method: %d", hdrP1["method"]) - } - - // Sanity check version numbers - if hdrP1["version"] > lzoMaxVersion { - return nil, fmt.Errorf("version too high: 0x%04X", hdrP1["version"]) - } - if hdrP1["version"] < hdrP1["version_needed"] { - return nil, fmt.Errorf("version %d < version_needed %d", hdrP1["version"], hdrP1["version_needed"]) - } - - headerP2Start := lzoHeaderSizeP1 - if (hdrP1["flags"] & flagFilter) != 0 { - headerP2Start += filterSize - } - - headerP2End := headerP2Start + lzoHeaderSizeP2 - if headerP2End > len(lzopData) { - return nil, fmt.Errorf("not enough data for header part 2") - } - - hdrP2, err := Parse(lzopData[headerP2Start:headerP2End], structureP2, "big") - if err != nil { - return nil, fmt.Errorf("header part 2 parse error: %w", err) - } - - headerSize := headerP2End + hdrP2["file_name_length"] + lzoChecksumSize - - // Faithfully ported: in the Rust source this is & (AND) which always yields false - blockChecksumPresent := (hdrP1["flags"] & flagAdler32C & flagCRC32C) != 0 - - if headerSize > len(lzopData) { - return nil, fmt.Errorf("calculated header size exceeds available data") - } - - return &lzopFileHeaderInfo{ - headerSize: headerSize, - blockChecksumPresent: blockChecksumPresent, - }, nil -} - -type lzopBlockHeaderInfo struct { - headerSize int - compressedSize int - uncompressedSize int - checksumSize int -} - -func parseLZOPBlockHeader(lzoData []byte, compressedChecksumPresent bool) (*lzopBlockHeaderInfo, error) { - const blockHeaderSize = 12 - const maxUncompressedBlockSize = 64 * 1024 * 1024 - const lzoChecksumSize = 4 - - structure := []StructField{ - {"uncompressed_size", "u32"}, - {"compressed_size", "u32"}, - {"uncompressed_checksum", "u32"}, - } - - hdr, err := Parse(lzoData, structure, "big") - if err != nil { - return nil, fmt.Errorf("block header parse error: %w", err) - } - - if hdr["compressed_size"] == 0 || - hdr["uncompressed_size"] == 0 || - hdr["uncompressed_checksum"] == 0 || - hdr["uncompressed_size"] > maxUncompressedBlockSize { - return nil, fmt.Errorf("invalid block header values") - } - - info := &lzopBlockHeaderInfo{ - headerSize: blockHeaderSize, - compressedSize: hdr["compressed_size"], - uncompressedSize: hdr["uncompressed_size"], - } - - if compressedChecksumPresent { - info.checksumSize = lzoChecksumSize - } - - return info, nil -} - -func parseLZOPEOFMarker(eofData []byte) (int, error) { - const eofMarkerValue = 0 - const eofMarkerSize = 4 - - structure := []StructField{{"marker", "u32"}} - - hdr, err := Parse(eofData, structure, "big") - if err != nil { - return 0, fmt.Errorf("EOF marker parse error: %w", err) - } - - if hdr["marker"] != eofMarkerValue { - return 0, fmt.Errorf("invalid EOF marker: %d", hdr["marker"]) - } - - return eofMarkerSize, nil -} - -func getLZODataSize(lzoData []byte, compressedChecksumPresent bool) (int, error) { - const minBlockCount = 2 - - available := len(lzoData) - var lastOffset *int - dataSize := 0 - blockCount := 0 - - for IsOffsetSafe(available, dataSize, lastOffset) { - blockHeader, err := parseLZOPBlockHeader(lzoData[dataSize:], compressedChecksumPresent) - if err != nil { - break - } - - blockCount++ - prev := dataSize - lastOffset = &prev - dataSize += blockHeader.headerSize + blockHeader.compressedSize + blockHeader.checksumSize - } - - if blockCount >= minBlockCount { - if dataSize < len(lzoData) { - eofSize, err := parseLZOPEOFMarker(lzoData[dataSize:]) - if err != nil { - return 0, fmt.Errorf("EOF marker error: %w", err) - } - dataSize += eofSize - return dataSize, nil - } - } - - return 0, fmt.Errorf("failed to validate LZO block structure") -} - -// --------------------------------------------------------------------------- -// LZFSE -// --------------------------------------------------------------------------- - -const LZFSEDescription = "LZFSE compressed data" - -func LZFSEMagic() [][]byte { - return [][]byte{ - []byte("bvx-"), - []byte("bvx1"), - []byte("bvx2"), - []byte("bvxn"), - } -} - -func LZFSEParser(data []byte, offset int) (*SignatureResult, error) { - available := len(data) - nextBlockOffset := offset - var previousBlockOffset *int - - for IsOffsetSafe(available, nextBlockOffset, previousBlockOffset) { - prev := nextBlockOffset - previousBlockOffset = &prev - - block, err := parseLZFSEBlockHeader(data[nextBlockOffset:]) - if err != nil { - break - } - - nextBlockOffset += block.headerSize + block.dataSize - - if block.eof { - totalSize := nextBlockOffset - offset - return &SignatureResult{ - Offset: offset, - Description: fmt.Sprintf("%s, total size: %d bytes", LZFSEDescription, totalSize), - Confidence: ConfidenceHigh, - Size: totalSize, - }, nil - } - } - - return nil, fmt.Errorf("LZFSE: failed to find end-of-stream block") -} - -type lzfseBlockInfo struct { - eof bool - dataSize int - headerSize int -} - -func parseLZFSEBlockHeader(lzfseData []byte) (*lzfseBlockInfo, error) { - // LZFSE block types (little-endian u32 representations) - const endOfStream = 0x24787662 // "bvx$" - const uncompressed = 0x2D787662 // "bvx-" - const compressedV1 = 0x31787662 // "bvx1" - const compressedV2 = 0x32787662 // "bvx2" - const compressedLZVN = 0x6E787662 // "bvxn" - - blockTypeStructure := []StructField{{"block_type", "u32"}} - - hdr, err := Parse(lzfseData, blockTypeStructure, "little") - if err != nil { - return nil, fmt.Errorf("block header parse error: %w", err) - } - - blockType := hdr["block_type"] - - switch blockType { - case endOfStream: - return &lzfseBlockInfo{eof: true, dataSize: 0, headerSize: 4}, nil - - case uncompressed: - return parseLZFSEUncompressedBlock(lzfseData) - - case compressedV1: - return parseLZFSECompressedV1Block(lzfseData) - - case compressedV2: - return parseLZFSECompressedV2Block(lzfseData) - - case compressedLZVN: - return parseLZFSECompressedLZVNBlock(lzfseData) - } - - return nil, fmt.Errorf("unknown LZFSE block type: 0x%08X", blockType) -} - -func parseLZFSEUncompressedBlock(lzfseData []byte) (*lzfseBlockInfo, error) { - const headerSize = 8 - - structure := []StructField{ - {"magic", "u32"}, - {"n_raw_bytes", "u32"}, - } - - hdr, err := Parse(lzfseData, structure, "little") - if err != nil { - return nil, fmt.Errorf("uncompressed block parse error: %w", err) - } - - return &lzfseBlockInfo{ - eof: false, - dataSize: hdr["n_raw_bytes"], - headerSize: headerSize, - }, nil -} - -func parseLZFSECompressedV1Block(lzfseData []byte) (*lzfseBlockInfo, error) { - const headerSize = 770 - - structure := []StructField{ - {"magic", "u32"}, - {"n_raw_bytes", "u32"}, - {"n_payload_bytes", "u32"}, - {"n_literals", "u32"}, - {"n_matches", "u32"}, - {"n_literal_payload_bytes", "u32"}, - {"n_lmd_payload_bytes", "u32"}, - {"literal_bits", "u32"}, - {"literal_state", "u64"}, - {"lmd_bits", "u32"}, - {"l_state", "u16"}, - {"m_state", "u16"}, - {"d_state", "u16"}, - } - - hdr, err := Parse(lzfseData, structure, "little") - if err != nil { - return nil, fmt.Errorf("compressed v1 block parse error: %w", err) - } - - return &lzfseBlockInfo{ - eof: false, - dataSize: hdr["n_literal_payload_bytes"] + hdr["n_lmd_payload_bytes"], - headerSize: headerSize, - }, nil -} - -func parseLZFSECompressedV2Block(lzfseData []byte) (*lzfseBlockInfo, error) { - const nPayloadShift = 20 - const lmdPayloadShift = 40 - const payloadMask = 0b11111_11111_11111_11111 - - structure := []StructField{ - {"magic", "u32"}, - {"uncompressed_size", "u32"}, - {"packed_field_1", "u64"}, - {"packed_field_2", "u64"}, - {"header_size", "u32"}, - {"state_fields", "u32"}, - } - - hdr, err := Parse(lzfseData, structure, "little") - if err != nil { - return nil, fmt.Errorf("compressed v2 block parse error: %w", err) - } - - nLMDPayloadBytes := (hdr["packed_field_2"] >> lmdPayloadShift) & payloadMask - nLiteralPayloadBytes := (hdr["packed_field_1"] >> nPayloadShift) & payloadMask - - return &lzfseBlockInfo{ - eof: false, - dataSize: nLMDPayloadBytes + nLiteralPayloadBytes, - headerSize: hdr["header_size"], - }, nil -} - -func parseLZFSECompressedLZVNBlock(lzfseData []byte) (*lzfseBlockInfo, error) { - const headerSize = 12 - - structure := []StructField{ - {"magic", "u32"}, - {"n_raw_bytes", "u32"}, - {"n_payload_bytes", "u32"}, - } - - hdr, err := Parse(lzfseData, structure, "little") - if err != nil { - return nil, fmt.Errorf("compressed LZVN block parse error: %w", err) - } - - return &lzfseBlockInfo{ - eof: false, - dataSize: hdr["n_payload_bytes"], - headerSize: headerSize, - }, nil -} - -// --------------------------------------------------------------------------- -// Zlib -// --------------------------------------------------------------------------- - -const ZlibDescription = "Zlib compressed file" - -func ZlibMagic() [][]byte { - return [][]byte{ - {0x78, 0x9C}, - {0x78, 0xDA}, - {0x78, 0x5E}, - } -} - -func ZlibParser(data []byte, offset int) (*SignatureResult, error) { - if offset+2 > len(data) { - return nil, fmt.Errorf("zlib: not enough data") - } - - // Zlib header: CMF (byte 0) and FLG (byte 1) - // The two-byte value (CMF*256 + FLG) must be a multiple of 31 (FCHECK) - cmf := int(data[offset]) - flg := int(data[offset+1]) - if (cmf*256+flg)%31 != 0 { - return nil, fmt.Errorf("zlib: FCHECK validation failed") - } - - // CM (compression method) should be 8 (deflate) - cm := cmf & 0x0F - if cm != 8 { - return nil, fmt.Errorf("zlib: invalid compression method: %d", cm) - } - - // CINFO should be <= 7 - cinfo := (cmf >> 4) & 0x0F - if cinfo > 7 { - return nil, fmt.Errorf("zlib: invalid CINFO: %d", cinfo) - } - - return &SignatureResult{ - Offset: offset, - Description: ZlibDescription, - Confidence: ConfidenceHigh, - }, nil -} - -// --------------------------------------------------------------------------- -// compress'd -// --------------------------------------------------------------------------- - -const CompressdDescription = "compress'd data" - -func CompressdMagic() [][]byte { - return [][]byte{ - {0x1F, 0x9D, 0x90}, - } -} - -func CompressdParser(data []byte, offset int) (*SignatureResult, error) { - result := &SignatureResult{ - Offset: offset, - Description: CompressdDescription, - Confidence: ConfidenceLow, - } - - // Higher confidence when found at the beginning of a file - if offset == 0 { - result.Confidence = ConfidenceMedium - } - - return result, nil -} diff --git a/internal/leviathan/binsnoop/sig_crypto.go b/internal/leviathan/binsnoop/sig_crypto.go deleted file mode 100644 index f1424a1..0000000 --- a/internal/leviathan/binsnoop/sig_crypto.go +++ /dev/null @@ -1,795 +0,0 @@ -package binsnoop - -import ( - "encoding/base64" - "fmt" - "strings" -) - -// ---- AES descriptions ---- - -const ( - DescriptionAESSBox = "AES S-Box" - DescriptionAESFT = "AES Forward Table" - DescriptionAESRT = "AES Reverse Table" - DescriptionAESRCON = "AES RCON" - DescriptionAESAcc = "AES Acceleration Table" -) - -// aesSBoxMagic returns magic bytes for AES S-Box (forward and reverse). -func aesSBoxMagic() [][]byte { - return [][]byte{ - {0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5}, // Forward S-Box - {0x52, 0x09, 0x6A, 0xD5, 0x30, 0x36, 0xA5, 0x38}, // Reverse S-Box - } -} - -// aesSBoxParser validates AES S-Box signatures. -func aesSBoxParser(data []byte, offset int) (*SignatureResult, error) { - return &SignatureResult{ - Offset: offset, - Description: DescriptionAESSBox, - Confidence: ConfidenceLow, - }, nil -} - -// aesForwardTableMagic returns magic bytes for AES Forward Table. -func aesForwardTableMagic() [][]byte { - return [][]byte{ - {0xC6, 0x63, 0x63, 0xA5, 0xF8, 0x7C, 0x7C, 0x84, 0xEE, 0x77, 0x77, 0x99, 0xF6, 0x7B, 0x7B, 0x8D}, - } -} - -// aesForwardTableParser validates AES Forward Table signatures. -func aesForwardTableParser(data []byte, offset int) (*SignatureResult, error) { - return &SignatureResult{ - Offset: offset, - Description: DescriptionAESFT, - Confidence: ConfidenceLow, - }, nil -} - -// aesReverseTableMagic returns magic bytes for AES Reverse Table. -func aesReverseTableMagic() [][]byte { - return [][]byte{ - {0x51, 0xF4, 0xA7, 0x50, 0x7E, 0x41, 0x65, 0x53, 0x1A, 0x17, 0xA4, 0xC3, 0x3A, 0x27, 0x5E, 0x96}, - } -} - -// aesReverseTableParser validates AES Reverse Table signatures. -func aesReverseTableParser(data []byte, offset int) (*SignatureResult, error) { - return &SignatureResult{ - Offset: offset, - Description: DescriptionAESRT, - Confidence: ConfidenceLow, - }, nil -} - -// aesRconMagic returns magic bytes for AES RCON. -func aesRconMagic() [][]byte { - return [][]byte{ - {0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36}, - {0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, - 0x10, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, - 0x1B, 0x00, 0x00, 0x00, 0x36, 0x00, 0x00, 0x00}, - } -} - -// aesRconParser validates AES RCON signatures. -func aesRconParser(data []byte, offset int) (*SignatureResult, error) { - return &SignatureResult{ - Offset: offset, - Description: DescriptionAESRCON, - Confidence: ConfidenceLow, - }, nil -} - -// aesAccelerationTableMagic returns magic bytes for AES Acceleration Table. -func aesAccelerationTableMagic() [][]byte { - return [][]byte{ - // combined sbox x2 - {0xA5, 0x84, 0x99, 0x8D, 0x0D, 0xBD, 0xB1, 0x54, 0x50, 0x03, 0xA9, 0x7D, 0x19, 0x62, 0xE6, 0x9A}, - // combined sbox x3 - {0xC6, 0xF8, 0xEE, 0xF6, 0xFF, 0xD6, 0xDE, 0x91, 0x60, 0x02, 0xCE, 0x56, 0xE7, 0xB5, 0x4D, 0xEC}, - // Galois mult 2 - {0x00, 0x02, 0x04, 0x06, 0x08, 0x0a, 0x0c, 0x0e, 0x10, 0x12, 0x14, 0x16, 0x18, 0x1a, 0x1c, 0x1e, - 0x20, 0x22, 0x24, 0x26, 0x28, 0x2a, 0x2c, 0x2e}, - // Galois mult 3 - {0x00, 0x03, 0x06, 0x05, 0x0c, 0x0f, 0x0a, 0x09, 0x18, 0x1b, 0x1e, 0x1d, 0x14, 0x17, 0x12, 0x11, - 0x30, 0x33, 0x36, 0x35, 0x3c, 0x3f, 0x3a, 0x39}, - // Galois mult 9 - {0x00, 0x09, 0x12, 0x1b, 0x24, 0x2d, 0x36, 0x3f, 0x48, 0x41, 0x5a, 0x53, 0x6c, 0x65, 0x7e, 0x77, - 0x90, 0x99, 0x82, 0x8b, 0xb4, 0xbd, 0xa6, 0xaf}, - // Galois mult 11 - {0x00, 0x0b, 0x16, 0x1d, 0x2c, 0x27, 0x3a, 0x31, 0x58, 0x53, 0x4e, 0x45, 0x74, 0x7f, 0x62, 0x69, - 0xb0, 0xbb, 0xa6, 0xad, 0x9c, 0x97, 0x8a, 0x81}, - // Galois mult 13 - {0x00, 0x0d, 0x1a, 0x17, 0x34, 0x39, 0x2e, 0x23, 0x68, 0x65, 0x72, 0x7f, 0x5c, 0x51, 0x46, 0x4b, - 0xd0, 0xdd, 0xca, 0xc7, 0xe4, 0xe9, 0xfe, 0xf3}, - // Galois mult 14 - {0x00, 0x0e, 0x1c, 0x12, 0x38, 0x36, 0x24, 0x2a, 0x70, 0x7e, 0x6c, 0x62, 0x48, 0x46, 0x54, 0x5a, - 0xe0, 0xee, 0xfc, 0xf2, 0xd8, 0xd6, 0xc4, 0xca}, - } -} - -// aesAccelerationTableParser validates AES Acceleration Table signatures. -func aesAccelerationTableParser(data []byte, offset int) (*SignatureResult, error) { - return &SignatureResult{ - Offset: offset, - Description: DescriptionAESAcc, - Confidence: ConfidenceLow, - }, nil -} - -// ---- RSA ---- - -const DescriptionRSA = "RSA encrypted session key" - -type rsaKeyDefinition struct { - magic []byte - keySize int - usageOffset int - keyIDOffset int - validChecks map[int][][]byte - terminatorOffset int -} - -func rsaKeyDefinitions() []rsaKeyDefinition { - return []rsaKeyDefinition{ - // 1024b RSA key - { - magic: []byte{0x84, 0x8C, 0x03}, - keySize: 1024, - keyIDOffset: 3, - terminatorOffset: 142, - usageOffset: 11, - validChecks: map[int][][]byte{ - 12: { - {0x04, 0x00}, {0x03, 0xff}, {0x03, 0xfe}, {0x03, 0xfd}, - {0x03, 0xfc}, {0x03, 0xfb}, {0x03, 0xfa}, {0x03, 0xf9}, - }, - }, - }, - // 2048b RSA key - { - magic: []byte{0x85, 0x01, 0x0c, 0x03}, - keySize: 2048, - keyIDOffset: 4, - terminatorOffset: 271, - usageOffset: 12, - validChecks: map[int][][]byte{ - 13: { - {0x08, 0x00}, {0x07, 0xff}, {0x07, 0xfe}, {0x07, 0xfd}, - {0x07, 0xfc}, {0x07, 0xfb}, {0x07, 0xfa}, {0x07, 0xf9}, - }, - }, - }, - // 3072b RSA key - { - magic: []byte{0x85, 0x01, 0x8c, 0x03}, - keySize: 3072, - keyIDOffset: 4, - terminatorOffset: 399, - usageOffset: 12, - validChecks: map[int][][]byte{ - 13: { - {0x0c, 0x00}, {0x0b, 0xff}, {0x0b, 0xfe}, {0x0b, 0xfd}, - {0x0b, 0xfc}, {0x0b, 0xfb}, {0x0b, 0xfa}, {0x0b, 0xf9}, - }, - }, - }, - // 4096b RSA key - { - magic: []byte{0x85, 0x02, 0x0c, 0x03}, - keySize: 4096, - keyIDOffset: 4, - terminatorOffset: 527, - usageOffset: 12, - validChecks: map[int][][]byte{ - 13: { - {0x10, 0x00}, {0x0f, 0xff}, {0x0f, 0xfe}, {0x0f, 0xfd}, - {0x0f, 0xfc}, {0x0f, 0xfb}, {0x0f, 0xfa}, {0x0f, 0xf9}, - }, - }, - }, - // 8192b RSA key - { - magic: []byte{0x85, 0x04, 0x0c, 0x03}, - keySize: 8192, - keyIDOffset: 4, - terminatorOffset: 1039, - usageOffset: 12, - validChecks: map[int][][]byte{ - 13: { - {0x20, 0x00}, {0x1f, 0xff}, {0x1f, 0xfe}, {0x1f, 0xfd}, - {0x1f, 0xfc}, {0x1f, 0xfb}, {0x1f, 0xfa}, {0x1f, 0xf9}, - }, - }, - }, - } -} - -// rsaMagic returns magic bytes for all known RSA key sizes. -func rsaMagic() [][]byte { - var magics [][]byte - for _, def := range rsaKeyDefinitions() { - m := make([]byte, len(def.magic)) - copy(m, def.magic) - magics = append(magics, m) - } - return magics -} - -type rsaKeyInfo struct { - bits int - canSign bool - canEncrypt bool - keyID int - dataSize int -} - -func rsaKeyParse(rawData []byte, def *rsaKeyDefinition) (*rsaKeyInfo, error) { - const terminatorByte byte = 0xD2 - const encryptOnly byte = 2 - const signAndEncrypt byte = 1 - const validBytesSize = 2 - - keyIDFields := []StructField{{"id", "u64"}} - - keyDataLen := def.terminatorOffset + 1 - if len(rawData) < keyDataLen { - return nil, fmt.Errorf("insufficient RSA key data") - } - keyData := rawData[:keyDataLen] - - if keyData[def.terminatorOffset] != terminatorByte { - return nil, fmt.Errorf("invalid RSA terminator byte") - } - - if def.keyIDOffset+8 > len(keyData) { - return nil, fmt.Errorf("key ID offset out of bounds") - } - keyIDParsed, err := Parse(keyData[def.keyIDOffset:], keyIDFields, "big") - if err != nil { - return nil, fmt.Errorf("failed to parse RSA key ID") - } - - info := &rsaKeyInfo{keyID: keyIDParsed["id"]} - info.canEncrypt = keyData[def.usageOffset] == encryptOnly - info.canSign = keyData[def.usageOffset] == signAndEncrypt - if info.canSign { - info.canEncrypt = true - } - - if !info.canSign && !info.canEncrypt { - return nil, fmt.Errorf("RSA key cannot sign or encrypt") - } - - for checkOffset, expectedList := range def.validChecks { - if checkOffset+validBytesSize > len(keyData) { - continue - } - actual := keyData[checkOffset : checkOffset+validBytesSize] - for _, expected := range expectedList { - if len(expected) == validBytesSize && actual[0] == expected[0] && actual[1] == expected[1] { - info.bits = def.keySize - info.dataSize = keyDataLen - return info, nil - } - } - } - - return nil, fmt.Errorf("RSA key validation bytes mismatch") -} - -// rsaParser validates RSA encrypted session key signatures. -func rsaParser(data []byte, offset int) (*SignatureResult, error) { - result := &SignatureResult{ - Offset: offset, - Description: DescriptionRSA, - Confidence: ConfidenceMedium, - } - - for _, def := range rsaKeyDefinitions() { - magicEnd := offset + len(def.magic) - if magicEnd > len(data) { - continue - } - match := true - for i, b := range def.magic { - if data[offset+i] != b { - match = false - break - } - } - if !match { - continue - } - - info, err := rsaKeyParse(data[offset:], &def) - if err != nil { - break - } - result.Size = info.dataSize - result.Description = fmt.Sprintf("%s, %d bits, can sign: %t, can encrypt: %t, key ID: %#018X", - result.Description, info.bits, info.canSign, info.canEncrypt, info.keyID) - return result, nil - } - - return nil, fmt.Errorf("invalid RSA signature") -} - -// ---- OpenSSL ---- - -const DescriptionOpenSSL = "OpenSSL encryption" - -// opensslMagic returns magic bytes for OpenSSL encrypted files. -func opensslMagic() [][]byte { - return [][]byte{ - []byte("Salted__"), - } -} - -func isSaltInvalid(salt int) bool { - const saltLen = 8 - badByteCount := 0 - for i := 0; i < saltLen; i++ { - saltByte := byte((salt >> (8 * i)) & 0xFF) - if saltByte == 0 || IsPrintableASCII(saltByte) { - badByteCount++ - } - } - return badByteCount == saltLen -} - -// opensslParser validates OpenSSL encryption signatures. -func opensslParser(data []byte, offset int) (*SignatureResult, error) { - sslFields := []StructField{{"magic", "u64"}, {"salt", "u64"}} - - result := &SignatureResult{ - Offset: offset, - Description: DescriptionOpenSSL, - Confidence: ConfidenceLow, - } - - if offset+StructSize(sslFields) > len(data) { - return nil, fmt.Errorf("insufficient OpenSSL data") - } - - sslHeader, err := Parse(data[offset:], sslFields, "big") - if err != nil { - return nil, fmt.Errorf("failed to parse OpenSSL header") - } - - salt := sslHeader["salt"] - if salt == 0 { - return nil, fmt.Errorf("OpenSSL salt is zero") - } - - if isSaltInvalid(salt) { - return nil, fmt.Errorf("OpenSSL salt is invalid") - } - - if offset == 0 { - result.Confidence = ConfidenceMedium - } - - result.Description = fmt.Sprintf("%s, salt: %#X", result.Description, salt) - return result, nil -} - -// ---- GPG ---- - -const DescriptionGPGSigned = "GPG signed file" - -// gpgSignedMagic returns magic bytes for GPG signed files. -func gpgSignedMagic() [][]byte { - return [][]byte{ - {0xA3, 0x01}, - } -} - -// gpgSignedParser validates GPG signed file signatures. -// The Rust version does a zlib decompression dry run; here we do a simplified validation. -func gpgSignedParser(data []byte, offset int) (*SignatureResult, error) { - result := &SignatureResult{ - Offset: offset, - Confidence: ConfidenceHigh, - Description: DescriptionGPGSigned, - } - - // GPG signed files are zlib compressed with magic bytes replaced. - // Validate there is enough data and the zlib-like stream follows. - if offset+4 > len(data) { - return nil, fmt.Errorf("insufficient GPG data") - } - - // The third byte should be a valid zlib CMF byte (deflate method = 8) - cmf := data[offset+2] - if cmf&0x0F != 8 { - return nil, fmt.Errorf("GPG: invalid deflate method in underlying zlib stream") - } - - result.Description = fmt.Sprintf("%s, compressed data at offset %d", result.Description, offset) - return result, nil -} - -// ---- PEM ---- - -const ( - DescriptionPEMCertificate = "PEM certificate" - DescriptionPEMPublicKey = "PEM public key" - DescriptionPEMPrivateKey = "PEM private key" -) - -// pemCertificateMagic returns magic bytes for PEM certificates. -func pemCertificateMagic() [][]byte { - return [][]byte{ - []byte("-----BEGIN CERTIFICATE-----"), - } -} - -// pemPublicKeyMagic returns magic bytes for PEM public keys. -func pemPublicKeyMagic() [][]byte { - return [][]byte{ - []byte("-----BEGIN PUBLIC KEY-----"), - []byte("-----BEGIN RSA PUBLIC KEY-----"), - []byte("-----BEGIN DSA PUBLIC KEY-----"), - []byte("-----BEGIN ECDSA PUBLIC KEY-----"), - } -} - -// pemPrivateKeyMagic returns magic bytes for PEM private keys. -func pemPrivateKeyMagic() [][]byte { - return [][]byte{ - []byte("-----BEGIN PRIVATE KEY-----"), - []byte("-----BEGIN EC PRIVATE KEY-----"), - []byte("-----BEGIN RSA PRIVATE KEY-----"), - []byte("-----BEGIN DSA PRIVATE KEY-----"), - []byte("-----BEGIN OPENSSH PRIVATE KEY-----"), - []byte("-----BEGIN ANY PRIVATE KEY-----"), - []byte("-----BEGIN ENCRYPTED PRIVATE KEY-----"), - []byte("-----BEGIN TSS2 PRIVATE KEY-----"), - } -} - -func decodePEMData(pemData []byte) (int, error) { - const delim = "--" - - pemStr := string(pemData) - delimCount := 0 - var base64Str strings.Builder - - for _, line := range strings.Split(pemStr, "\n") { - line = strings.TrimRight(line, "\r") - if strings.HasPrefix(line, delim) { - delimCount++ - if delimCount == 2 { - break - } - continue - } - base64Str.WriteString(line) - } - - if base64Str.Len() == 0 { - return 0, fmt.Errorf("no PEM base64 data found") - } - - decoded, err := base64.StdEncoding.DecodeString(base64Str.String()) - if err != nil { - return 0, fmt.Errorf("PEM base64 decode failed: %v", err) - } - - return len(decoded), nil -} - -// pemFindEnd locates the end delimiter and returns the total PEM size. -func pemFindEnd(data []byte, offset int) (int, bool) { - const endPrefix = "-----END " - pemData := string(data[offset:]) - idx := strings.Index(pemData, endPrefix) - if idx < 0 { - return 0, false - } - // Find end of the delimiter line - rest := pemData[idx:] - nlIdx := strings.Index(rest, "\n") - if nlIdx < 0 { - return idx + len(rest), true - } - return idx + nlIdx + 1, true -} - -// pemParser validates PEM certificate and key signatures. -func pemParser(data []byte, offset int) (*SignatureResult, error) { - const minPEMLen = 26 - - result := &SignatureResult{ - Offset: offset, - Confidence: ConfidenceHigh, - } - - if offset+minPEMLen > len(data) { - return nil, fmt.Errorf("insufficient PEM data") - } - - pemMagic := data[offset : offset+minPEMLen] - pemMagicStr := string(pemMagic) - - // Determine type based on first 26 bytes - matched := false - for _, m := range pemPublicKeyMagic() { - if len(m) >= minPEMLen && strings.HasPrefix(string(m), pemMagicStr[:minPEMLen]) { - result.Description = DescriptionPEMPublicKey - matched = true - break - } - } - if !matched { - for _, m := range pemPrivateKeyMagic() { - if len(m) >= minPEMLen && strings.HasPrefix(string(m), pemMagicStr[:minPEMLen]) { - result.Description = DescriptionPEMPrivateKey - matched = true - break - } - } - } - if !matched { - for _, m := range pemCertificateMagic() { - if len(m) >= minPEMLen && strings.HasPrefix(string(m), pemMagicStr[:minPEMLen]) { - result.Description = DescriptionPEMCertificate - matched = true - break - } - } - } - if !matched { - return nil, fmt.Errorf("PEM magic not recognised") - } - - // Find the end of PEM data - pemSize, found := pemFindEnd(data, offset) - if !found || pemSize == 0 { - return nil, fmt.Errorf("PEM end delimiter not found") - } - - // Validate base64 content - if offset+pemSize > len(data) { - pemSize = len(data) - offset - } - if _, err := decodePEMData(data[offset : offset+pemSize]); err != nil { - return nil, fmt.Errorf("PEM validation failed: %v", err) - } - - if offset == 0 && pemSize == len(data) { - result.ExtractionDeclined = true - } - - result.Size = pemSize - return result, nil -} - -// ---- PKCS DER ---- - -const DescriptionDERHash = "PKCS DER hash" - -func derHashLookups() map[string][]byte { - return map[string][]byte{ - "MD5": {0x30, 0x20, 0x30, 0x0c, 0x06, 0x08, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x02, 0x05, 0x05, 0x00, 0x04, 0x10}, - "SHA1": {0x30, 0x21, 0x30, 0x09, 0x06, 0x05, 0x2b, 0x0e, 0x03, 0x02, 0x1a, 0x05, 0x00, 0x04, 0x14}, - "SHA256": {0x30, 0x31, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, 0x05, 0x00, 0x04, 0x20}, - "SHA384": {0x30, 0x41, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x02, 0x05, 0x00, 0x04, 0x30}, - "SHA512": {0x30, 0x51, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x03, 0x05, 0x00, 0x04}, - } -} - -// derHashMagic returns magic bytes for all DER hash types. -func derHashMagic() [][]byte { - var magics [][]byte - for _, m := range derHashLookups() { - mc := make([]byte, len(m)) - copy(mc, m) - magics = append(magics, mc) - } - return magics -} - -// derHashParser validates PKCS DER hash signatures. -func derHashParser(data []byte, offset int) (*SignatureResult, error) { - result := &SignatureResult{ - Offset: offset, - Description: DescriptionDERHash, - Confidence: ConfidenceMedium, - } - - for name, magic := range derHashLookups() { - hashEnd := offset + len(magic) - if hashEnd > len(data) { - continue - } - match := true - for i, b := range magic { - if data[offset+i] != b { - match = false - break - } - } - if match { - result.Size = len(magic) - result.Description = fmt.Sprintf("%s, %s", result.Description, name) - return result, nil - } - } - - return nil, fmt.Errorf("DER hash validation failed") -} - -// ---- LUKS ---- - -const DescriptionLUKS = "LUKS header" - -// luksMagic returns magic bytes for LUKS headers. -func luksMagic() [][]byte { - return [][]byte{ - {0x4C, 0x55, 0x4B, 0x53, 0xBA, 0xBE}, // "LUKS\xBA\xBE" - } -} - -// luksParser validates LUKS header signatures. -func luksParser(data []byte, offset int) (*SignatureResult, error) { - const cipherAlgoStart = 8 - const cipherAlgoEnd = 40 - const cipherModeStart = 40 - const cipherModeEnd = 72 - const hashFnStart = 72 - const hashFnEnd = 104 - const luks2MinHeaderSize = 4032 - - luksBaseFields := []StructField{ - {"magic_1", "u32"}, - {"magic_2", "u16"}, - {"version", "u16"}, - {"header_size", "u64"}, - } - - result := &SignatureResult{ - Offset: offset, - Name: "luks", - Description: DescriptionLUKS, - Confidence: ConfidenceMedium, - } - - if offset+StructSize(luksBaseFields) > len(data) { - return nil, fmt.Errorf("insufficient LUKS data") - } - - luksBase, err := Parse(data[offset:], luksBaseFields, "big") - if err != nil { - return nil, fmt.Errorf("failed to parse LUKS header") - } - - version := luksBase["version"] - - // Hash function string - if offset+hashFnEnd > len(data) { - return nil, fmt.Errorf("LUKS hash function data out of bounds") - } - hashFn := GetCString(data[offset+hashFnStart : offset+hashFnEnd]) - if hashFn == "" { - return nil, fmt.Errorf("LUKS hash function string empty") - } - - if version == 1 { - if offset+cipherAlgoEnd > len(data) { - return nil, fmt.Errorf("LUKS cipher data out of bounds") - } - cipherAlgo := GetCString(data[offset+cipherAlgoStart : offset+cipherAlgoEnd]) - cipherMode := GetCString(data[offset+cipherModeStart : offset+cipherModeEnd]) - if cipherAlgo == "" || cipherMode == "" { - return nil, fmt.Errorf("LUKS v1 cipher strings empty") - } - result.Description = fmt.Sprintf("%s, version: %d, cipher algorithm: %s, cipher mode: %s, hash fn: %s", - result.Description, version, cipherAlgo, cipherMode, hashFn) - return result, nil - } else if version == 2 { - headerSize := luksBase["header_size"] - availableData := len(data) - offset - if headerSize <= luks2MinHeaderSize || headerSize >= availableData { - return nil, fmt.Errorf("LUKS v2 header size invalid") - } - result.Description = fmt.Sprintf("%s, version: %d, header size: %d bytes, hash fn: %s", - result.Description, version, headerSize, hashFn) - return result, nil - } - - return nil, fmt.Errorf("unsupported LUKS version: %d", version) -} - -// ---- Hash constants ---- - -const hashMagicLen = 16 - -const ( - DescriptionCRC32 = "CRC32 polynomial table" - DescriptionSHA256 = "SHA256 hash constants" - DescriptionMD5 = "MD5 hash constants" -) - -// crc32Magic returns magic bytes for CRC32 polynomial tables. -// Order matters: index 0 is big endian, index 1 is little endian. -func crc32ConstMagic() [][]byte { - return [][]byte{ - // Big endian - {0x00, 0x00, 0x00, 0x00, 0x77, 0x07, 0x30, 0x96, 0xEE, 0x0E, 0x61, 0x2C, 0x99, 0x09, 0x51, 0xBA}, - // Little endian - {0x00, 0x00, 0x00, 0x00, 0x96, 0x30, 0x07, 0x77, 0x2C, 0x61, 0x0E, 0xEE, 0xBA, 0x51, 0x09, 0x99}, - } -} - -// sha256Magic returns magic bytes for SHA256 hash constants. -func sha256ConstMagic() [][]byte { - return [][]byte{ - // Big endian - {0x42, 0x8a, 0x2f, 0x98, 0x71, 0x37, 0x44, 0x91, 0xb5, 0xc0, 0xfb, 0xcf, 0xe9, 0xb5, 0xdb, 0xa5}, - // Little endian - {0x98, 0x2f, 0x8a, 0x42, 0x91, 0x44, 0x37, 0x71, 0xcf, 0xfb, 0xc0, 0xb5, 0xa5, 0xdb, 0xb5, 0xe9}, - } -} - -// md5Magic returns magic bytes for MD5 hash constants. -func md5ConstMagic() [][]byte { - return [][]byte{ - // Big endian - {0xd7, 0x6a, 0xa4, 0x78, 0xe8, 0xc7, 0xb7, 0x56, 0x24, 0x20, 0x70, 0xdb, 0xc1, 0xbd, 0xce, 0xee}, - // Little endian - {0x78, 0xa4, 0x6a, 0xd7, 0x56, 0xb7, 0xc7, 0xe8, 0xdb, 0x70, 0x20, 0x24, 0xee, 0xce, 0xbd, 0xc1}, - } -} - -func hashEndianness(data []byte, offset int, magics [][]byte) string { - if offset+hashMagicLen > len(data) { - return "little" - } - thisMagic := data[offset : offset+hashMagicLen] - if len(magics) > 0 && len(magics[0]) == hashMagicLen { - match := true - for i := 0; i < hashMagicLen; i++ { - if thisMagic[i] != magics[0][i] { - match = false - break - } - } - if match { - return "big" - } - } - return "little" -} - -// crc32Parser validates CRC32 polynomial table signatures. -func crc32Parser(data []byte, offset int) (*SignatureResult, error) { - return &SignatureResult{ - Offset: offset, - Size: hashMagicLen, - Description: fmt.Sprintf("%s, %s endian", DescriptionCRC32, hashEndianness(data, offset, crc32ConstMagic())), - }, nil -} - -// sha256Parser validates SHA256 hash constant signatures. -func sha256Parser(data []byte, offset int) (*SignatureResult, error) { - return &SignatureResult{ - Offset: offset, - Size: hashMagicLen, - Description: fmt.Sprintf("%s, %s endian", DescriptionSHA256, hashEndianness(data, offset, sha256ConstMagic())), - }, nil -} - -// md5Parser validates MD5 hash constant signatures. -func md5Parser(data []byte, offset int) (*SignatureResult, error) { - return &SignatureResult{ - Offset: offset, - Size: hashMagicLen, - Description: fmt.Sprintf("%s, %s endian", DescriptionMD5, hashEndianness(data, offset, md5ConstMagic())), - }, nil -} diff --git a/internal/leviathan/binsnoop/sig_executables.go b/internal/leviathan/binsnoop/sig_executables.go deleted file mode 100644 index 20b3429..0000000 --- a/internal/leviathan/binsnoop/sig_executables.go +++ /dev/null @@ -1,485 +0,0 @@ -package binsnoop - -import ( - "fmt" -) - -// --------------------------------------------------------------------------- -// ELF -// --------------------------------------------------------------------------- - -const ELFDescription = "ELF binary" - -func ELFMagic() [][]byte { - return [][]byte{ - {0x7F, 0x45, 0x4C, 0x46}, // "\x7FELF" - } -} - -func ELFParser(data []byte, offset int) (*SignatureResult, error) { - const elfIdentSize = 16 - const elfInfoStructSize = 8 - const expectedVersion = 1 - - elfIdentFields := []StructField{ - {"magic", "u32"}, - {"class", "u8"}, - {"endianness", "u8"}, - {"version", "u8"}, - {"osabi", "u8"}, - {"abiversion", "u8"}, - {"padding_1", "u32"}, - {"padding_2", "u24"}, - } - - elfInfoFields := []StructField{ - {"type", "u16"}, - {"machine", "u16"}, - {"version", "u32"}, - } - - elfClasses := map[int]int{ - 1: 32, - 2: 64, - } - - elfEndianness := map[int]string{ - 1: "little", - 2: "big", - } - - elfOSABI := map[int]string{ - 0: "System-V (Unix)", - 1: "HP-UX", - 2: "NetBSD", - 3: "Linux", - 4: "GNU Hurd", - 5: "86Open", - 6: "Solaris", - 7: "AIX", - 8: "IRIX", - 9: "FreeBSD", - 10: "Tru64", - 11: "Novell Modesto", - 12: "OpenBSD", - 13: "OpenVMS", - 14: "NonStop Kernel", - 15: "AROS", - 16: "FenixOS", - 17: "Nuxi CloudABI", - 18: "OpenVOS", - 97: "ARM ABI", - 102: "Cell LV2", - 202: "Cafe OS", - 255: "embedded", - } - - elfTypes := map[int]string{ - 0: "no file type", - 1: "relocatable", - 2: "executable", - 3: "shared object", - 4: "core file", - } - - elfMachines := map[int]string{ - 0: "no machine", - 1: "AT&T WE 32100", - 2: "SPARC", - 3: "x86", - 4: "Motorola 68k", - 5: "Motorola 88k", - 6: "Intel MCU", - 7: "Intel 80860", - 8: "MIPS", - 9: "IBM System/370", - 10: "MIPS RS3000", - 11: "RS6000", - 15: "HP PA-RISC", - 16: "nCUBE", - 17: "Fujitsu VPP500", - 18: "SPARC32PLUS", - 19: "Intel 80960", - 20: "PowerPC", - 21: "PowerPC 64-bit", - 22: "S390", - 23: "IBM SPU/SPC", - 24: "cisco SVIP", - 25: "cisco 7200", - 36: "NEC V800", - 37: "Fujitsu FR20", - 38: "TRW RH-32", - 39: "Motorola RCE", - 40: "ARM", - 41: "Digital Alpha", - 42: "SuperH", - 43: "SPARCv9", - 44: "Siemens TriCore embedded processor", - 45: "Argonaut RISC Core", - 46: "Hitachi H8/300", - 47: "Hitachi H8/300H", - 48: "Hitachi H8S", - 49: "Hitachi H8/500", - 50: "IA-64", - 51: "Stanford MIPS-X", - 52: "Motorola ColdFire", - 53: "Motorola M68HC12", - 54: "Fujitsu MMA Multimedia Accelerator", - 55: "Siemens PCP", - 56: "Sony nCPU embedded RISC processor", - 57: "Denso NDR1 microprocessor", - 58: "Motorola StarCore", - 59: "Toyota ME16", - 60: "STMicroelectronics ST100", - 61: "Advanced Logic TinyJ embedded processor", - 62: "AMD X86-64", - 63: "Sony DSP processor", - 64: "PDP-10", - 65: "PDP-11", - 66: "Siemens FX66", - 67: "STMicroelectronics ST9+", - 68: "STMicroelectronics ST7", - 69: "Motorola MC68HC16", - 70: "Motorola MC68HC11", - 71: "Motorola MC68HC08", - 72: "Motorola MC68HC05", - 73: "Silicon Graphics SVx", - 74: "STMicroelectonrics ST19", - 75: "Digital VAX", - 76: "Axis Communications 32-bit CPU", - 77: "Infineon Technologies 32-bit CPU", - 78: "Element 14 64-bit DSP", - 79: "LSI Logic 16-bit DSP", - 80: "MMIX", - 81: "Harvard machine-independent", - 82: "SiTera Prism", - 83: "Atmel AVR 8-bit", - 84: "Fujitsu FR30", - 85: "Mitsubishi D10V", - 86: "Mitsubishi D30V", - 87: "NEC v850", - 88: "Renesas M32R", - 89: "Matsushita MN10300", - 90: "Matsushita MN10200", - 91: "picoJava", - 92: "OpenRISC", - 93: "Synopsys ARCompact ARC700 cores", - 94: "Tensilica Xtensa", - 95: "Alphamosaic VideoCore", - 96: "Thompson Multimedia", - 97: "NatSemi 32k", - 98: "Tenor Network TPC", - 99: "Trebia SNP 1000", - 100: "STMicroelectronics ST200", - 101: "Ubicom IP2022", - 102: "MAX Processor", - 103: "NatSemi CompactRISC", - 104: "Fujitsu F2MC16", - 105: "TI msp430", - 106: "Analog Devices Blackfin", - 107: "S1C33 Family of Seiko Epson", - 108: "Sharp embedded", - 109: "Arca RISC", - 110: "PKU-Unity Ltd.", - 111: "eXcess: 16/32/64-bit", - 112: "Icera Deep Execution Processor", - 113: "Altera Nios II", - 114: "NatSemi CRX", - 115: "Motorola XGATE", - 116: "Infineon C16x/XC16x", - 117: "Renesas M16C series", - 118: "Microchip dsPIC30F", - 119: "Freescale RISC core", - 120: "Renesas M32C series", - 131: "Altium TSK3000 core", - 132: "Freescale RS08", - 134: "Cyan Technology eCOG2", - 135: "Sunplus S+core7 RISC", - 136: "New Japan Radio (NJR) 24-bit DSP", - 137: "Broadcom VideoCore III processor", - 138: "LatticeMico32", - 139: "Seiko Epson C17 family", - 140: "TMS320C6000", - 141: "TMS320C2000", - 142: "TMS320C55x", - 144: "TI Programmable Realtime Unit", - 160: "STMicroelectronics 64bit VLIW DSP", - 161: "Cypress M8C", - 162: "Renesas R32C series", - 163: "NXP TriMedia family", - 164: "Qualcomm DSP6", - 165: "Intel 8051 and variants", - 166: "STMicroelectronics STxP7x family", - 167: "Andes embedded RISC", - 168: "Cyan eCOG1X family", - 169: "Dallas MAXQ30", - 170: "New Japan Radio (NJR) 16-bit DSP", - 171: "M2000 Reconfigurable RISC", - 172: "Cray NV2 vector architecture", - 173: "Renesas RX family", - 174: "META", - 175: "MCST Elbrus e2k", - 176: "Cyan Technology eCOG16 family", - 177: "NatSemi CompactRISC", - 178: "Freescale Extended Time Processing Unit", - 179: "Infineon SLE9X", - 180: "Intel L1OM", - 181: "Intel K1OM", - 183: "ARM 64-bit", - 185: "Atmel 32-bit family", - 186: "STMicroeletronics STM8 8-bit", - 187: "Tilera TILE64", - 188: "Tilera TILEPro", - 189: "Xilinx MicroBlaze 32-bit RISC", - 190: "NVIDIA CUDA architecture", - 191: "Tilera TILE-Gx", - 195: "Synopsys ARCv2/HS3x/HS4x cores", - 197: "Renesas RL78 family", - 199: "Renesas 78K0R", - 200: "Freescale 56800EX", - 201: "Beyond BA1", - 202: "Beyond BA2", - 203: "XMOS xCORE", - 204: "Microchip 8-bit PIC(r)", - 210: "KM211 KM32", - 211: "KM211 KMX32", - 212: "KM211 KMX16", - 213: "KM211 KMX8", - 214: "KM211 KVARC", - 215: "Paneve CDP", - 216: "Cognitive Smart Memory", - 217: "iCelero CoolEngine", - 218: "Nanoradio Optimized RISC", - 219: "CSR Kalimba architecture family", - 220: "Zilog Z80", - 221: "Controls and Data Services VISIUMcore processor", - 222: "FTDI Chip FT32 high performance 32-bit RISC architecture", - 223: "Moxie processor family", - 224: "AMD GPU architecture", - 243: "RISC-V", - 244: "Lanai 32-bit processor", - 245: "CEVA Processor Architecture Family", - 246: "CEVA X2 Processor Family", - 247: "Berkeley Packet Filter", - 248: "Graphcore Intelligent Processing Unit", - 249: "Imagination Technologies", - 250: "Netronome Flow Processor", - 251: "NEC Vector Engine", - 252: "C-SKY processor family", - 253: "Synopsys ARCv3 64-bit ISA/HS6x cores", - 254: "MOS Technology MCS 6502 processor", - 255: "Synopsys ARCv3 32-bit", - 256: "Kalray VLIW core of the MPPA family", - 257: "WDC 65C816", - 258: "LoongArch", - 259: "ChipON KungFu32", - } - - elfData := data[offset:] - if len(elfData) < elfIdentSize+elfInfoStructSize { - return nil, fmt.Errorf("ELF: not enough data") - } - - // Parse the e_ident (endianness doesn't matter for single-byte fields) - eIdent, err := Parse(elfData, elfIdentFields, "little") - if err != nil { - return nil, fmt.Errorf("ELF: e_ident parse error: %w", err) - } - - // Sanity check padding fields - if eIdent["padding_1"] != 0 || eIdent["padding_2"] != 0 { - return nil, fmt.Errorf("ELF: non-zero padding in e_ident") - } - if eIdent["version"] != expectedVersion { - return nil, fmt.Errorf("ELF: unexpected e_ident version %d", eIdent["version"]) - } - - classVal, classOK := elfClasses[eIdent["class"]] - if !classOK { - return nil, fmt.Errorf("ELF: unknown class %d", eIdent["class"]) - } - - endianStr, endianOK := elfEndianness[eIdent["endianness"]] - if !endianOK { - return nil, fmt.Errorf("ELF: unknown endianness %d", eIdent["endianness"]) - } - - osabiStr, osabiOK := elfOSABI[eIdent["osabi"]] - if !osabiOK { - return nil, fmt.Errorf("ELF: unknown OS/ABI %d", eIdent["osabi"]) - } - - // Parse the rest of the ELF header with the correct endianness - elfInfoData := elfData[elfIdentSize : elfIdentSize+elfInfoStructSize] - elfInfo, err := Parse(elfInfoData, elfInfoFields, endianStr) - if err != nil { - return nil, fmt.Errorf("ELF: info parse error: %w", err) - } - - if elfInfo["version"] != expectedVersion { - return nil, fmt.Errorf("ELF: unexpected info version %d", elfInfo["version"]) - } - - typeStr, typeOK := elfTypes[elfInfo["type"]] - if !typeOK { - return nil, fmt.Errorf("ELF: unknown type %d", elfInfo["type"]) - } - - machineStr := "Unknown" - if m, ok := elfMachines[elfInfo["machine"]]; ok { - machineStr = m - } - - return &SignatureResult{ - Offset: offset, - Name: "elf", - Description: fmt.Sprintf("%s, %d-bit %s, %s for %s, %s endian", ELFDescription, classVal, typeStr, machineStr, osabiStr, endianStr), - Confidence: ConfidenceMedium, - }, nil -} - -// --------------------------------------------------------------------------- -// PE -// --------------------------------------------------------------------------- - -const PEDescription = "Windows PE binary" - -func PEMagic() [][]byte { - // First 16 bytes of common DOS headers (e_magic through e_ss) - return [][]byte{ - {0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00}, - {0x4D, 0x5A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, - } -} - -func PEParser(data []byte, offset int) (*SignatureResult, error) { - const peMagicValue = 0x00004550 // "PE\x00\x00" - - dosFields := []StructField{ - {"e_magic", "u16"}, - {"e_cblp", "u16"}, - {"e_cp", "u16"}, - {"e_crlc", "u16"}, - {"e_cparhdr", "u16"}, - {"e_minalloc", "u16"}, - {"e_maxalloc", "u16"}, - {"e_ss", "u16"}, - {"e_sp", "u16"}, - {"e_csum", "u16"}, - {"e_ip", "u16"}, - {"e_cs", "u16"}, - {"e_lfarlc", "u16"}, - {"e_ovno", "u16"}, - {"e_res_1", "u16"}, - {"e_res_2", "u16"}, - {"e_res_3", "u16"}, - {"e_res_4", "u16"}, - {"e_oemid", "u16"}, - {"e_oeminfo", "u16"}, - {"e_res_5", "u16"}, - {"e_res_6", "u16"}, - {"e_res_7", "u16"}, - {"e_res_8", "u16"}, - {"e_res_9", "u16"}, - {"e_res_10", "u16"}, - {"e_res_11", "u16"}, - {"e_res_12", "u16"}, - {"e_res_13", "u16"}, - {"e_res_14", "u16"}, - {"e_lfanew", "u32"}, - } - - peFields := []StructField{ - {"magic", "u32"}, - {"machine", "u16"}, - {"number_of_sections", "u16"}, - {"timestamp", "u32"}, - {"symbol_table_ptr", "u32"}, - {"number_of_symbols", "u32"}, - {"optional_header_size", "u16"}, - {"characteristics", "u16"}, - } - - knownMachineTypes := map[int]string{ - 0x0: "Unknown", - 0x184: "Alpha32", - 0x284: "Alpha64", - 0x1D3: "Matsushita AM33", - 0x8664: "Intel x86-64", - 0x1C0: "ARM", - 0xAA64: "ARM-64", - 0x1C4: "ARM Thumb2", - 0xEBC: "EFI", - 0x14C: "Intel x86", - 0x200: "Intel Itanium", - 0x6232: "LoongArch 32-bit", - 0x6264: "LoongArch 64-bit", - 0x9041: "Mitsubishi M32R", - 0x266: "MIPS16", - 0x366: "MIPS with FPU", - 0x466: "MIPS16 with FPU", - 0x1F0: "PowerPC", - 0x1F1: "PowerPC with FPU", - 0x5032: "RISC-V 32-bit", - 0x5064: "RISC-V 64-bit", - 0x5128: "RISC-V 128-bit", - 0x1A2: "Hitachi SH3", - 0x1A3: "Hitachi SH3 DSP", - 0x1A6: "Hitachi SH4", - 0x1A8: "Hitachi SH5", - 0x1C2: "Thumb", - 0x169: "MIPS WCEv2", - } - - peData := data[offset:] - dosStructSize := StructSize(dosFields) - peStructSize := StructSize(peFields) - - if len(peData) < dosStructSize { - return nil, fmt.Errorf("PE: not enough data for DOS header") - } - - dosHeader, err := Parse(peData, dosFields, "little") - if err != nil { - return nil, fmt.Errorf("PE: DOS header parse error: %w", err) - } - - // Sanity check the reserved header fields; they should all be 0 - for _, field := range []string{ - "e_res_1", "e_res_2", "e_res_3", "e_res_4", - "e_res_5", "e_res_6", "e_res_7", "e_res_8", - "e_res_9", "e_res_10", "e_res_11", "e_res_12", - "e_res_13", "e_res_14", - } { - if dosHeader[field] != 0 { - return nil, fmt.Errorf("PE: reserved field %s is non-zero", field) - } - } - - peHeaderStart := dosHeader["e_lfanew"] - peHeaderEnd := peHeaderStart + peStructSize - - if peHeaderEnd > len(peData) { - return nil, fmt.Errorf("PE: e_lfanew points beyond available data") - } - - peHeader, err := Parse(peData[peHeaderStart:peHeaderEnd], peFields, "little") - if err != nil { - return nil, fmt.Errorf("PE: PE header parse error: %w", err) - } - - if peHeader["magic"] != peMagicValue { - return nil, fmt.Errorf("PE: invalid PE magic 0x%08X", peHeader["magic"]) - } - - machineStr, machineOK := knownMachineTypes[peHeader["machine"]] - if !machineOK { - return nil, fmt.Errorf("PE: unknown machine type 0x%X", peHeader["machine"]) - } - - return &SignatureResult{ - Offset: offset, - Description: fmt.Sprintf("%s, machine type: %s", PEDescription, machineStr), - Confidence: ConfidenceMedium, - }, nil -} diff --git a/internal/leviathan/binsnoop/sig_filesystems.go b/internal/leviathan/binsnoop/sig_filesystems.go deleted file mode 100644 index 4f59ffb..0000000 --- a/internal/leviathan/binsnoop/sig_filesystems.go +++ /dev/null @@ -1,1289 +0,0 @@ -package binsnoop - -import ( - "bytes" - "encoding/binary" - "fmt" - "math" -) - -// --------------------------------------------------------------------------- -// SquashFS -// --------------------------------------------------------------------------- - -const SquashFSDescription = "SquashFS file system" - -func squashfs_magic() [][]byte { - return [][]byte{ - []byte("sqsh"), - []byte("hsqs"), - []byte("sqlz"), - []byte("qshs"), - []byte("tqsh"), - []byte("hsqt"), - []byte("shsq"), - } -} - -func squashfs_parser(data []byte, offset int) (*SignatureResult, error) { - const squashfsV4 = 4 - const maxSquashfsVersion uint16 = 4 - const minSquashfsHeaderSize = 120 - const versionStart = 28 - const versionEnd = 30 - - compressionTypes := map[int]string{ - 0: "unknown", 1: "gzip", 2: "lzma", 3: "lzo", 4: "xz", 5: "lz4", 6: "zstd", - } - - available := len(data) - offset - sqshData := data[offset:] - - if len(sqshData) <= minSquashfsHeaderSize { - return nil, fmt.Errorf("not enough data for squashfs header") - } - - // Determine endianness from version field at fixed offset - endianness := "little" - squashfsVersion := int(binary.LittleEndian.Uint16(sqshData[versionStart:versionEnd])) - if squashfsVersion == 0 || squashfsVersion > int(maxSquashfsVersion) { - endianness = "big" - squashfsVersion = int(binary.BigEndian.Uint16(sqshData[versionStart:versionEnd])) - } - if squashfsVersion <= 0 || squashfsVersion > int(maxSquashfsVersion) { - return nil, fmt.Errorf("invalid squashfs version") - } - - // Pick the right structure - var fields []StructField - if squashfsVersion == squashfsV4 { - fields = []StructField{ - {"magic", "u32"}, {"inode_count", "u32"}, {"modification_time", "u32"}, - {"block_size", "u32"}, {"fragment_count", "u32"}, {"compression_id", "u16"}, - {"block_log", "u16"}, {"flags", "u16"}, {"id_count", "u16"}, - {"major_version", "u16"}, {"minor_version", "u16"}, - {"root_inode_ref", "u64"}, {"image_size", "u64"}, {"uid_start", "u64"}, - } - } else { - fields = []StructField{ - {"magic", "u32"}, {"inode_count", "u32"}, - {"bytes_used_2", "u32"}, {"uid_start_2", "u32"}, {"guid_start_2", "u32"}, - {"inode_table_start_2", "u32"}, {"directory_table_start_2", "u32"}, - {"major_version", "u16"}, {"minor_version", "u16"}, - {"block_size_1", "u16"}, {"block_log", "u16"}, - {"flags", "u8"}, {"uid_count", "u8"}, {"guid_count", "u8"}, - {"modification_time", "u32"}, - {"root_inode_ref", "u64"}, {"block_size", "u32"}, - {"fragment_entry_count", "u32"}, {"fragment_table_start_2", "u32"}, - {"image_size", "u64"}, {"uid_start", "u64"}, {"guid_start", "u64"}, - {"inode_table_start", "u64"}, {"directory_table_start", "u64"}, - {"fragment_table_start", "u64"}, {"lookup_table_start", "u64"}, - } - } - - hdr, err := Parse(sqshData, fields, endianness) - if err != nil { - return nil, fmt.Errorf("squashfs header parse failed") - } - - // For v1/v2 images, adjust fields - if squashfsVersion < 3 { - hdr["uid_start"] = hdr["uid_start_2"] - hdr["image_size"] = hdr["bytes_used_2"] - } - - headerSize := StructSize(fields) - imageSize := hdr["image_size"] - - if imageSize <= minSquashfsHeaderSize { - return nil, fmt.Errorf("squashfs image size too small") - } - if imageSize > available { - return nil, fmt.Errorf("squashfs image size exceeds available data") - } - - // Validate block_size vs block_log - blockSize := hdr["block_size"] - blockLog := hdr["block_log"] - if blockSize <= 0 || blockLog != int(math.Log2(float64(blockSize))) { - return nil, fmt.Errorf("squashfs block_size/block_log mismatch") - } - - // Validate UID table - uidTableStart := offset + hdr["uid_start"] - if uidTableStart <= headerSize { - return nil, fmt.Errorf("squashfs uid table offset invalid") - } - if uidTableStart >= len(data) { - return nil, fmt.Errorf("squashfs uid table out of bounds") - } - - // Parse one UID entry - uidData := data[uidTableStart:] - var uidFields []StructField - if squashfsVersion == squashfsV4 { - uidFields = []StructField{{"uid_block_ptr", "u64"}} - } else { - uidFields = []StructField{{"uid_block_ptr", "u32"}} - } - uidEntry, err := Parse(uidData, uidFields, endianness) - if err != nil { - return nil, fmt.Errorf("squashfs uid entry parse failed") - } - uidPtr := uidEntry["uid_block_ptr"] - if uidPtr != 0 && (uidPtr <= headerSize || uidPtr > imageSize) { - return nil, fmt.Errorf("squashfs uid table pointer invalid") - } - - compressionID := hdr["compression_id"] - if squashfsVersion < 4 { - // v3 headers don't have compression_id; use 0 (unknown) - compressionID = 0 - } - compStr, ok := compressionTypes[compressionID] - if !ok { - return nil, fmt.Errorf("squashfs unknown compression type") - } - - createDate := EpochToString(uint32(hdr["modification_time"])) - - return &SignatureResult{ - Offset: offset, - Size: imageSize, - Confidence: ConfidenceHigh, - Description: fmt.Sprintf( - "%s, %s endian, version: %d.%d, compression: %s, inode count: %d, block size: %d, image size: %d bytes, created: %s", - SquashFSDescription, endianness, - hdr["major_version"], hdr["minor_version"], - compStr, hdr["inode_count"], blockSize, imageSize, createDate, - ), - }, nil -} - -// --------------------------------------------------------------------------- -// CramFS -// --------------------------------------------------------------------------- - -const CramFSDescription = "CramFS filesystem" - -func cramfs_magic() [][]byte { - return [][]byte{ - []byte("Compressed ROMFS"), - } -} - -func cramfs_parser(data []byte, offset int) (*SignatureResult, error) { - const signatureOffset = 16 - const crcStartOffset = 32 - const crcEndOffset = 36 - const bigEndianMagic = 0x453DCD28 - const littleEndianMagic = 0x28CD3D45 - - realOffset := offset - signatureOffset - if realOffset < 0 || realOffset >= len(data) { - return nil, fmt.Errorf("cramfs offset out of bounds") - } - - cramfsData := data[realOffset:] - - fields := []StructField{ - {"magic", "u32"}, {"size", "u32"}, {"flags", "u32"}, {"future", "u32"}, - {"signature_p1", "u64"}, {"signature_p2", "u64"}, - {"checksum", "u32"}, {"edition", "u32"}, {"block_count", "u32"}, {"file_count", "u32"}, - } - - endianness := "little" - hdr, err := Parse(cramfsData, fields, endianness) - if err != nil { - return nil, fmt.Errorf("cramfs parse failed") - } - - magic := hdr["magic"] - if magic != bigEndianMagic && magic != littleEndianMagic { - return nil, fmt.Errorf("cramfs invalid magic") - } - if magic == bigEndianMagic { - endianness = "big" - hdr, err = Parse(cramfsData, fields, endianness) - if err != nil { - return nil, fmt.Errorf("cramfs big endian parse failed") - } - } - - structSize := StructSize(fields) - if hdr["size"] <= structSize { - return nil, fmt.Errorf("cramfs reported size too small") - } - - imageSize := hdr["size"] - checksum := uint32(hdr["checksum"]) - fileCount := hdr["file_count"] - - if realOffset+imageSize > len(data) { - return nil, fmt.Errorf("cramfs image exceeds available data") - } - - // Copy image data and null out checksum field for CRC validation - cramfsImage := make([]byte, imageSize) - copy(cramfsImage, cramfsData[:imageSize]) - for i := crcStartOffset; i < crcEndOffset && i < imageSize; i++ { - cramfsImage[i] = 0 - } - - errorMsg := "" - confidence := ConfidenceHigh - if CRC32(cramfsImage) != checksum { - errorMsg = " (checksum error)" - confidence = ConfidenceMedium - } - - return &SignatureResult{ - Offset: realOffset, - Size: imageSize, - Confidence: confidence, - Description: fmt.Sprintf("%s, %s endian, %d files, total size: %d bytes%s", - CramFSDescription, endianness, fileCount, imageSize, errorMsg), - }, nil -} - -// --------------------------------------------------------------------------- -// JFFS2 -// --------------------------------------------------------------------------- - -const JFFS2Description = "JFFS2 filesystem" - -func jffs2_magic() [][]byte { - return [][]byte{ - {0x19, 0x85, 0xE0, 0x01}, - {0x19, 0x85, 0xE0, 0x02}, - {0x19, 0x85, 0x20, 0x03}, - {0x85, 0x19, 0x01, 0xE0}, - {0x85, 0x19, 0x02, 0xE0}, - {0x85, 0x19, 0x03, 0x20}, - } -} - -func jffs2_parser(data []byte, offset int) (*SignatureResult, error) { - const maxPageSize = 0x20000 - const minValidNodeCount = 2 - const jffs2CorrectMagic = 0x1985 - const headerCRCSize = 8 - - jffs2NodeFields := []StructField{ - {"magic", "u16"}, {"type", "u16"}, {"size", "u32"}, {"crc", "u32"}, - } - jffs2NodeStructSize := StructSize(jffs2NodeFields) // 12 - - // Parse first node - firstNode, endianness, err := parseJFFS2NodeHeader(data[offset:], jffs2NodeFields, headerCRCSize, jffs2CorrectMagic) - if err != nil { - return nil, err - } - - jffs2EOF := offset + jffs2Roundup(firstNode["size"]) - if jffs2EOF >= len(data) { - return nil, fmt.Errorf("jffs2 first node exceeds data") - } - - var nodeMagic []byte - if endianness == "big" { - nodeMagic = []byte{0x19, 0x85} - } else { - nodeMagic = []byte{0x85, 0x19} - } - - nodeCount := 1 - - // Scan for subsequent nodes - searchData := data[jffs2EOF:] - for i := 0; i < len(searchData); { - idx := bytes.Index(searchData[i:], nodeMagic) - if idx < 0 { - break - } - absPos := jffs2EOF + i + idx - if absPos < jffs2EOF { - i += idx + 1 - continue - } - if absPos-jffs2EOF > maxPageSize { - break - } - headerEnd := absPos + jffs2NodeStructSize - if headerEnd > len(data) { - break - } - nodeHdr, _, err := parseJFFS2NodeHeader(data[absPos:headerEnd], jffs2NodeFields, headerCRCSize, jffs2CorrectMagic) - if err == nil { - nodeCount++ - jffs2EOF = absPos + jffs2Roundup(nodeHdr["size"]) - } - i += idx + 1 - } - - if nodeCount <= minValidNodeCount { - return nil, fmt.Errorf("jffs2 too few valid nodes: %d", nodeCount) - } - - totalSize := jffs2EOF - offset - return &SignatureResult{ - Offset: offset, - Size: totalSize, - Confidence: ConfidenceHigh, - Description: fmt.Sprintf("%s, %s endian, nodes: %d, total size: %d bytes", - JFFS2Description, endianness, nodeCount, totalSize), - }, nil -} - -func parseJFFS2NodeHeader(nodeData []byte, fields []StructField, headerCRCSize, correctMagic int) (map[string]int, string, error) { - endianness := "little" - hdr, err := Parse(nodeData, fields, endianness) - if err != nil { - return nil, "", fmt.Errorf("jffs2 node parse failed") - } - if hdr["magic"] != correctMagic { - endianness = "big" - hdr, err = Parse(nodeData, fields, endianness) - if err != nil { - return nil, "", fmt.Errorf("jffs2 node big endian parse failed") - } - } - if hdr["magic"] != correctMagic { - return nil, "", fmt.Errorf("jffs2 node invalid magic") - } - - // Validate header CRC - if headerCRCSize > len(nodeData) { - return nil, "", fmt.Errorf("jffs2 not enough data for crc") - } - calcCRC := int(jffs2NodeCRC(nodeData[:headerCRCSize])) - if calcCRC != hdr["crc"] { - return nil, "", fmt.Errorf("jffs2 node header crc mismatch") - } - - return hdr, endianness, nil -} - -func jffs2NodeCRC(data []byte) uint32 { - return CRC32(data) // CRC32 uses IEEE polynomial; JFFS2: crc32(0xFFFFFFFF, data) ^ 0xFFFFFFFF which is standard CRC32 -} - -func jffs2Roundup(num int) int { - base := 4 - return ((num + base - 1) / base) * base -} - -// --------------------------------------------------------------------------- -// YAFFS -// --------------------------------------------------------------------------- - -const YAFFSDescription = "YAFFSv2 filesystem" - -func yaffs_magic() [][]byte { - return [][]byte{ - {0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0xFF, 0xFF}, - {0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0xFF, 0xFF}, - {0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0xFF, 0xFF}, - {0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0xFF, 0xFF}, - } -} - -func yaffs_parser(data []byte, offset int) (*SignatureResult, error) { - const maxObjSize = 16896 - const minNumberOfObjs = 2 - const bigEndianFirstByte byte = 0 - const fileType = 1 - - available := len(data) - requiredMinOffset := offset + (maxObjSize * minNumberOfObjs) - if !IsOffsetSafe(available, requiredMinOffset, nil) { - return nil, fmt.Errorf("yaffs not enough data") - } - - endianness := "little" - if data[offset] == bigEndianFirstByte { - endianness = "big" - } - - yaffsData := data[offset:] - - // Determine page size - pageSize, err := yaffsGetPageSize(yaffsData) - if err != nil { - return nil, err - } - - // Determine spare size - spareSize, err := yaffsGetSpareSize(yaffsData, pageSize, endianness) - if err != nil { - return nil, err - } - - // Get total image size - imageSize, err := yaffsGetImageSize(yaffsData, pageSize, spareSize, endianness) - if err != nil { - return nil, err - } - - return &SignatureResult{ - Offset: offset, - Size: imageSize, - Confidence: ConfidenceMedium, - Description: fmt.Sprintf("%s, %s endian, page size: %d, spare size: %d, image size: %d bytes", - YAFFSDescription, endianness, pageSize, spareSize, imageSize), - }, nil -} - -func yaffsGetPageSize(fileData []byte) (int, error) { - spareMagics := [][]byte{ - {0x00, 0x00, 0x10, 0x00}, - {0x00, 0x10, 0x00, 0x00}, - {0xFF, 0xFF, 0x00, 0x00, 0x10, 0x00}, - {0xFF, 0xFF, 0x00, 0x10, 0x00, 0x00}, - } - pageSizes := []int{512, 1024, 2048, 4096, 8192, 16384} - - for _, ps := range pageSizes { - for _, sm := range spareMagics { - start := ps - end := start + len(sm) - if end <= len(fileData) { - if bytes.Equal(fileData[start:end], sm) { - return ps, nil - } - } - } - } - return 0, fmt.Errorf("yaffs page size not detected") -} - -func yaffsGetSpareSize(fileData []byte, pageSize int, endianness string) (int, error) { - const minNumberOfObjs = 2 - spareSizes := []int{16, 32, 64, 128, 256, 512} - - yaffsObjFields := []StructField{ - {"type", "u32"}, {"parent_id", "u32"}, {"name_checksum", "u16"}, - } - - for _, ss := range spareSizes { - nextObjOffset := (pageSize + ss) * minNumberOfObjs - if nextObjOffset < len(fileData) { - hdr, err := Parse(fileData[nextObjOffset:], yaffsObjFields, endianness) - if err == nil { - allowedTypes := []int{0, 1, 2, 3, 4, 5} - if containsInt(allowedTypes, hdr["type"]) && hdr["parent_id"] > 0 && hdr["name_checksum"] == 0xFFFF { - return ss, nil - } - } - } - } - return 0, fmt.Errorf("yaffs spare size not detected") -} - -func yaffsGetImageSize(fileData []byte, pageSize, spareSize int, endianness string) (int, error) { - const fileType = 1 - const minNumberOfObjs = 2 - const infoStructStart = 268 - - yaffsObjFields := []StructField{ - {"type", "u32"}, {"parent_id", "u32"}, {"name_checksum", "u16"}, - } - yaffsFileInfo := []StructField{ - {"mode", "u32"}, {"uid", "u32"}, {"gid", "u32"}, - {"atime", "u32"}, {"mtime", "u32"}, {"ctime", "u32"}, {"file_size", "u32"}, - } - - imageSize := 0 - nextObjOffset := 0 - var previousObjOffset *int - availableData := len(fileData) - blockSize := pageSize + spareSize - - for IsOffsetSafe(availableData, nextObjOffset, previousObjOffset) { - objData := fileData[nextObjOffset:] - hdr, err := Parse(objData, yaffsObjFields, endianness) - if err != nil { - break - } - allowedTypes := []int{0, 1, 2, 3, 4, 5} - if !containsInt(allowedTypes, hdr["type"]) || hdr["parent_id"] <= 0 || hdr["name_checksum"] != 0xFFFF { - break - } - - dataBlocks := 1 - if hdr["type"] == fileType { - if infoStructStart < len(objData) { - fileInfo, err := Parse(objData[infoStructStart:], yaffsFileInfo, endianness) - if err != nil { - return 0, fmt.Errorf("yaffs file header parse failed") - } - fileBlockCount := int(math.Ceil(float64(fileInfo["file_size"]) / float64(pageSize))) - dataBlocks += fileBlockCount - } else { - return 0, fmt.Errorf("yaffs not enough data for file header") - } - } - - prev := nextObjOffset - previousObjOffset = &prev - imageSize += dataBlocks * blockSize - nextObjOffset = imageSize - } - - if imageSize > blockSize*minNumberOfObjs && imageSize <= availableData { - return imageSize, nil - } - return 0, fmt.Errorf("yaffs image size invalid") -} - -// --------------------------------------------------------------------------- -// RomFS -// --------------------------------------------------------------------------- - -const RomFSDescription = "RomFS filesystem" - -func romfs_magic() [][]byte { - return [][]byte{ - []byte("-rom1fs-"), - } -} - -func romfs_parser(data []byte, offset int) (*SignatureResult, error) { - const maxHeaderCRCDataLen = 512 - - headerFields := []StructField{ - {"magic", "u64"}, {"image_size", "u32"}, {"checksum", "u32"}, - } - - romfsData := data[offset:] - headerSize := StructSize(headerFields) - - hdr, err := Parse(romfsData, headerFields, "big") - if err != nil { - return nil, fmt.Errorf("romfs header parse failed") - } - - if hdr["image_size"] <= headerSize { - return nil, fmt.Errorf("romfs image size too small") - } - - // Get volume name - volumeName := "" - if headerSize < len(romfsData) { - volumeName = GetCString(romfsData[headerSize:]) - } - - // Validate CRC - crcDataLen := maxHeaderCRCDataLen - if hdr["image_size"] < crcDataLen { - crcDataLen = hdr["image_size"] - } - if crcDataLen > len(romfsData) { - return nil, fmt.Errorf("romfs not enough data for crc") - } - if !romfsCRCValid(romfsData[:crcDataLen]) { - return nil, fmt.Errorf("romfs crc invalid") - } - - return &SignatureResult{ - Offset: offset, - Size: hdr["image_size"], - Confidence: ConfidenceHigh, - Description: fmt.Sprintf(`%s, volume name: "%s", total size: %d bytes`, - RomFSDescription, volumeName, hdr["image_size"]), - }, nil -} - -func romfsCRCValid(crcData []byte) bool { - wordSize := 4 - if len(crcData)%wordSize != 0 { - return false - } - var sum uint32 - for i := 0; i < len(crcData); i += wordSize { - sum += binary.BigEndian.Uint32(crcData[i : i+wordSize]) - } - return sum == 0 -} - -// --------------------------------------------------------------------------- -// EXT -// --------------------------------------------------------------------------- - -const EXTDescription = "EXT filesystem" - -func ext_magic() [][]byte { - return [][]byte{ - {0x53, 0xEF, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00}, - {0x53, 0xEF, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00}, - {0x53, 0xEF, 0x01, 0x00, 0x03, 0x00, 0x00, 0x00}, - {0x53, 0xEF, 0x02, 0x00, 0x01, 0x00, 0x00, 0x00}, - {0x53, 0xEF, 0x02, 0x00, 0x02, 0x00, 0x00, 0x00}, - {0x53, 0xEF, 0x02, 0x00, 0x03, 0x00, 0x00, 0x00}, - } -} - -const EXTMagicOffset = 1080 - -func ext_parser(data []byte, offset int) (*SignatureResult, error) { - const magicOffset = 1080 - const superblockOffset = 1024 - const superblockSize = 1024 - const maxBlockLog = 2 - - realOffset := offset - magicOffset - if realOffset < 0 { - return nil, fmt.Errorf("ext offset out of bounds") - } - - extData := data[realOffset:] - if len(extData) < superblockOffset+superblockSize { - return nil, fmt.Errorf("ext not enough data for superblock") - } - - extSBFields := []StructField{ - {"inodes_count", "u32"}, {"blocks_count", "u32"}, {"reserved_blocks_count", "u32"}, - {"free_blocks_count", "u32"}, {"free_inodes_count", "u32"}, {"first_data_block", "u32"}, - {"log_block_size", "u32"}, {"log_frag_size", "u32"}, {"blocks_per_group", "u32"}, - {"frags_per_group", "u32"}, {"inodes_per_group", "u32"}, {"modification_time", "u32"}, - {"write_time", "u32"}, {"mount_count", "u16"}, {"max_mount_count", "u16"}, - {"magic", "u16"}, {"state", "u16"}, {"errors", "u16"}, {"s_minor_rev_level", "u16"}, - {"last_check", "u32"}, {"check_interval", "u32"}, {"creator_os", "u32"}, - {"s_rev_level", "u32"}, {"resuid", "u16"}, {"resgid", "u16"}, - } - - hdr, err := Parse(extData[superblockOffset:], extSBFields, "little") - if err != nil { - return nil, fmt.Errorf("ext superblock parse failed") - } - - supportedOS := map[int]string{0: "Linux", 1: "GNU HURD", 2: "MASIX", 3: "FreeBSD", 4: "Lites"} - osName, osOk := supportedOS[hdr["creator_os"]] - if !osOk { - return nil, fmt.Errorf("ext unknown OS") - } - - allowedRevLevels := []int{0, 1} - if !containsInt(allowedRevLevels, hdr["s_rev_level"]) { - return nil, fmt.Errorf("ext invalid rev level") - } - - allowedFirstDataBlocks := []int{0, 1} - if !containsInt(allowedFirstDataBlocks, hdr["first_data_block"]) { - return nil, fmt.Errorf("ext invalid first_data_block") - } - - if hdr["log_block_size"] > maxBlockLog { - return nil, fmt.Errorf("ext invalid log_block_size") - } - - blockSize := 1024 << hdr["log_block_size"] - imageSize := blockSize * hdr["blocks_count"] - - return &SignatureResult{ - Offset: realOffset, - Size: imageSize, - Confidence: ConfidenceMedium, - Description: fmt.Sprintf( - "%s for %s, inodes: %d, block size: %d, block count: %d, free blocks: %d, reserved blocks: %d, total size: %d bytes", - EXTDescription, osName, hdr["inodes_count"], blockSize, - hdr["free_blocks_count"], hdr["reserved_blocks_count"], hdr["blocks_count"], imageSize), - }, nil -} - -// --------------------------------------------------------------------------- -// FAT -// --------------------------------------------------------------------------- - -const FATDescription = "FAT file system" -const FATMagicOffset = 0x01FE - -func fat_magic() [][]byte { - return [][]byte{ - {0x55, 0xAA}, - } -} - -func fat_parser(data []byte, offset int) (*SignatureResult, error) { - const expectedFATCount = 2 - - validOpcode1 := []int{0xEB, 0xE9} - validSectorSizes := []int{512, 1024, 2048, 4096} - validSectorsPerCluster := []int{1, 2, 4, 8, 16, 32, 64, 128} - validMediaTypes := []int{0xF0, 0xF8, 0xF9, 0xFA, 0xFB, 0xFC, 0xFD, 0xFE} - - if offset < FATMagicOffset { - return nil, fmt.Errorf("fat offset too small") - } - realOffset := offset - FATMagicOffset - - fatData := data[realOffset:] - - fields := []StructField{ - {"opcode1", "u8"}, {"opcode2", "u8"}, {"opcode3", "u8"}, - {"oem_name", "u64"}, {"bytes_per_sector", "u16"}, {"sectors_per_cluster", "u8"}, - {"reserved_sectors", "u16"}, {"fat_count", "u8"}, {"root_entries_count_16", "u16"}, - {"total_sectors_16", "u16"}, {"media_type", "u8"}, {"fat_size_16", "u16"}, - {"sectors_per_track", "u16"}, {"number_of_heads", "u16"}, - {"hidden_sectors", "u32"}, {"total_sectors_32", "u32"}, - } - - hdr, err := Parse(fatData, fields, "little") - if err != nil { - return nil, fmt.Errorf("fat header parse failed") - } - - if !containsInt(validOpcode1, hdr["opcode1"]) { - return nil, fmt.Errorf("fat invalid opcode1") - } - if !containsInt(validSectorSizes, hdr["bytes_per_sector"]) { - return nil, fmt.Errorf("fat invalid sector size") - } - if !containsInt(validSectorsPerCluster, hdr["sectors_per_cluster"]) { - return nil, fmt.Errorf("fat invalid sectors per cluster") - } - if hdr["reserved_sectors"] <= 0 { - return nil, fmt.Errorf("fat invalid reserved sectors") - } - if hdr["fat_count"] != expectedFATCount { - return nil, fmt.Errorf("fat invalid fat count") - } - if !containsInt(validMediaTypes, hdr["media_type"]) { - return nil, fmt.Errorf("fat invalid media type") - } - - isFAT32 := hdr["fat_size_16"] == 0 - - totalSize := 0 - if hdr["total_sectors_16"] != 0 { - totalSize = hdr["total_sectors_16"] * hdr["bytes_per_sector"] - } else { - totalSize = hdr["total_sectors_32"] * hdr["bytes_per_sector"] - } - if totalSize <= 0 { - return nil, fmt.Errorf("fat total size is zero") - } - - fatTypeDesc := "FAT12/16" - if isFAT32 { - fatTypeDesc = "FAT32" - } - - return &SignatureResult{ - Offset: realOffset, - Size: totalSize, - Confidence: ConfidenceMedium, - Description: fmt.Sprintf("%s, type: %s, total size: %d bytes", - FATDescription, fatTypeDesc, totalSize), - }, nil -} - -// --------------------------------------------------------------------------- -// NTFS -// --------------------------------------------------------------------------- - -const NTFSDescription = "NTFS partition" - -func ntfs_magic() [][]byte { - return [][]byte{ - {0xEB, 0x52, 0x90, 0x4E, 0x54, 0x46, 0x53, 0x20, 0x20, 0x20, 0x20}, - } -} - -func ntfs_parser(data []byte, offset int) (*SignatureResult, error) { - ntfsFields := []StructField{ - {"opcodes", "u24"}, {"magic", "u64"}, - {"bytes_per_sector", "u16"}, {"sectors_per_cluster", "u8"}, - {"unused1", "u16"}, {"unused2", "u24"}, {"unused3", "u16"}, - {"media_type", "u8"}, {"unused4", "u16"}, - {"sectors_per_track", "u16"}, {"head_count", "u16"}, - {"hidden_sector_count", "u32"}, {"unused5", "u32"}, - {"unknown", "u32"}, {"sector_count", "u64"}, - } - - ntfsData := data[offset:] - hdr, err := Parse(ntfsData, ntfsFields, "little") - if err != nil { - return nil, fmt.Errorf("ntfs header parse failed") - } - - if hdr["unused1"] != 0 || hdr["unused2"] != 0 || hdr["unused3"] != 0 || - hdr["unused4"] != 0 || hdr["unused5"] != 0 { - return nil, fmt.Errorf("ntfs unused fields are not zero") - } - - sectorSize := hdr["bytes_per_sector"] - sectorCount := hdr["sector_count"] - // The reported sector count does not include the NTFS boot sector itself - totalSize := sectorSize * (sectorCount + 1) - - if totalSize <= sectorSize { - return nil, fmt.Errorf("ntfs total size too small") - } - - return &SignatureResult{ - Offset: offset, - Size: totalSize, - Confidence: ConfidenceMedium, - Description: fmt.Sprintf("%s, number of sectors: %d, bytes per sector: %d, total size: %d bytes", - NTFSDescription, sectorCount, sectorSize, totalSize), - }, nil -} - -// --------------------------------------------------------------------------- -// APFS -// --------------------------------------------------------------------------- - -const APFSDescription = "APple File System" -const APFSMagicOffset = 0x20 - -func apfs_magic() [][]byte { - return [][]byte{ - []byte("NXSB"), - } -} - -func apfs_parser(data []byte, offset int) (*SignatureResult, error) { - const mbrBlockSize = 512 - const maxFSCount = 100 - const fsCountBlockSize = 512 - - if offset < APFSMagicOffset { - return nil, fmt.Errorf("apfs offset too small") - } - realOffset := offset - APFSMagicOffset - available := len(data) - realOffset - - apfsData := data[realOffset:] - apfsStructStart := APFSMagicOffset - - fields := []StructField{ - {"magic", "u32"}, {"block_size", "u32"}, {"block_count", "u64"}, - {"nx_features", "u64"}, {"nx_ro_compat_features", "u64"}, {"nx_incompat_features", "u64"}, - {"nx_uuid_p1", "u64"}, {"nx_uuid_p2", "u64"}, - {"nx_next_oid", "u64"}, {"nx_next_xid", "u64"}, - {"nx_xp_desc_blocks", "u32"}, {"nx_xp_data_blocks", "u32"}, - {"nx_xp_desc_base", "u64"}, {"nx_xp_data_base", "u64"}, - {"nx_xp_desc_next", "u32"}, {"nx_xp_data_next", "u32"}, - {"nx_xp_desc_index", "u32"}, {"nx_xp_desc_len", "u32"}, - {"nx_xp_data_index", "u32"}, {"nx_xp_data_len", "u32"}, - {"nx_spaceman_oid", "u64"}, {"nx_omap_oid", "u64"}, {"nx_reaper_oid", "u64"}, - {"nx_xp_test_type", "u32"}, {"nx_xp_max_file_systems", "u32"}, - } - - structEnd := apfsStructStart + StructSize(fields) - if structEnd > len(apfsData) { - return nil, fmt.Errorf("apfs not enough data for header") - } - - hdr, err := Parse(apfsData[apfsStructStart:structEnd], fields, "little") - if err != nil { - return nil, fmt.Errorf("apfs header parse failed") - } - - if hdr["block_size"] == 0 || hdr["block_count"] == 0 { - return nil, fmt.Errorf("apfs zero block size or count") - } - - allowedFeatureFlags := []int{0, 1, 2, 3} - allowedIncompatFlags := []int{0, 1, 2, 3, 0x100, 0x101, 0x102, 0x103} - allowedROCompatFlags := []int{0} - - if !containsInt(allowedFeatureFlags, hdr["nx_features"]) || - !containsInt(allowedROCompatFlags, hdr["nx_ro_compat_features"]) || - !containsInt(allowedIncompatFlags, hdr["nx_incompat_features"]) { - return nil, fmt.Errorf("apfs invalid feature flags") - } - - if hdr["nx_xp_test_type"] != 0 { - return nil, fmt.Errorf("apfs test_type must be null") - } - - fsCount := int(math.Ceil(float64(hdr["nx_xp_max_file_systems"]) / float64(fsCountBlockSize))) - if fsCount <= 0 || fsCount > maxFSCount { - return nil, fmt.Errorf("apfs invalid filesystem count") - } - - totalSize := hdr["block_count"] * hdr["block_size"] - truncatedMsg := "" - - if totalSize > available { - truncatedSize := totalSize - available - if truncatedSize == mbrBlockSize { - totalSize -= truncatedSize - truncatedMsg = fmt.Sprintf(" (truncated by %d bytes)", truncatedSize) - } - } - - return &SignatureResult{ - Offset: realOffset, - Size: totalSize, - Confidence: ConfidenceMedium, - Description: fmt.Sprintf("%s, block size: %d bytes, block count: %d, total size: %d bytes%s", - APFSDescription, hdr["block_size"], hdr["block_count"], totalSize, truncatedMsg), - }, nil -} - -// --------------------------------------------------------------------------- -// BTRFS -// --------------------------------------------------------------------------- - -const BTRFSDescription = "BTRFS file system" -const BTRFSMagicOffset = 0x10040 - -func btrfs_magic() [][]byte { - return [][]byte{ - []byte("_BHRfS_M"), - } -} - -func btrfs_parser(data []byte, offset int) (*SignatureResult, error) { - const superblockOffset = 0x10000 - const superblockEnd = superblockOffset + 0x1000 - const crcStart = 0x20 - - if offset < BTRFSMagicOffset { - return nil, fmt.Errorf("btrfs offset too small") - } - realOffset := offset - BTRFSMagicOffset - - btrfsData := data[realOffset:] - - if len(btrfsData) < superblockEnd { - return nil, fmt.Errorf("btrfs not enough data") - } - - fields := []StructField{ - {"header_checksum", "u32"}, {"unused1", "u32"}, {"unused2", "u64"}, - {"unused3", "u64"}, {"unused4", "u64"}, - {"uuid_p1", "u64"}, {"uuid_p2", "u64"}, - {"block_phys_addr", "u64"}, {"flags", "u64"}, - {"magic", "u64"}, {"generation", "u64"}, - {"root_tree_address", "u64"}, {"chunk_tree_address", "u64"}, - {"log_tree_address", "u64"}, {"log_root_transid", "u64"}, - {"total_bytes", "u64"}, {"bytes_used", "u64"}, - {"root_dir_objid", "u64"}, {"num_devices", "u64"}, - {"sector_size", "u32"}, {"node_size", "u32"}, - {"leaf_size", "u32"}, {"stripe_size", "u32"}, - } - - headerData := btrfsData[superblockOffset:superblockEnd] - hdr, err := Parse(headerData, fields, "little") - if err != nil { - return nil, fmt.Errorf("btrfs header parse failed") - } - - // Validate CRC32C - calcCRC := crc32c(headerData[crcStart:]) - if hdr["header_checksum"] != int(calcCRC) { - return nil, fmt.Errorf("btrfs crc mismatch") - } - - return &SignatureResult{ - Offset: realOffset, - Size: hdr["total_bytes"], - Confidence: ConfidenceMedium, - Description: fmt.Sprintf( - "%s, node size: %d, sector size: %d, leaf size: %d, stripe size: %d, bytes used: %d, total size: %d bytes", - BTRFSDescription, hdr["node_size"], hdr["sector_size"], - hdr["leaf_size"], hdr["stripe_size"], hdr["bytes_used"], hdr["total_bytes"]), - }, nil -} - -// crc32c computes a CRC-32C (Castagnoli) checksum. -func crc32c(data []byte) uint32 { - // CRC-32C polynomial: 0x1EDC6F41 - const poly = 0x82F63B78 // reversed representation - crc := uint32(0xFFFFFFFF) - for _, b := range data { - crc ^= uint32(b) - for i := 0; i < 8; i++ { - if crc&1 != 0 { - crc = (crc >> 1) ^ poly - } else { - crc >>= 1 - } - } - } - return crc ^ 0xFFFFFFFF -} - -// --------------------------------------------------------------------------- -// LogFS -// --------------------------------------------------------------------------- - -const LogFSDescription = "LogFS file system" -const LogFSMagicOffset = 0x18 - -func logfs_magic() [][]byte { - return [][]byte{ - {0x7A, 0x3A, 0x8E, 0x5C, 0xB9, 0xD5, 0xBF, 0x67}, - } -} - -func logfs_parser(data []byte, offset int) (*SignatureResult, error) { - if offset < LogFSMagicOffset { - return nil, fmt.Errorf("logfs offset too small") - } - realOffset := offset - LogFSMagicOffset - - logfsData := data[realOffset:] - - fields := []StructField{ - {"magic", "u64"}, {"crc32", "u32"}, - {"ifile_levels", "u8"}, {"iblock_levels", "u8"}, - {"data_levels", "u8"}, {"segment_shift", "u8"}, - {"block_shift", "u8"}, {"write_shift", "u8"}, - {"pad0", "u32"}, {"pad1", "u16"}, - {"filesystem_size", "u64"}, {"segment_size", "u32"}, - {"bad_seg_reserved", "u32"}, - {"feature_incompat", "u64"}, {"feature_ro_compat", "u64"}, - {"feature_compat", "u64"}, {"feature_flags", "u64"}, - {"root_reserve", "u64"}, {"speed_reserve", "u64"}, - } - - if LogFSMagicOffset >= len(logfsData) { - return nil, fmt.Errorf("logfs not enough data") - } - - hdr, err := Parse(logfsData[LogFSMagicOffset:], fields, "big") - if err != nil { - return nil, fmt.Errorf("logfs superblock parse failed") - } - - if hdr["pad0"] != 0 || hdr["pad1"] != 0 { - return nil, fmt.Errorf("logfs padding fields not zero") - } - - totalSize := hdr["filesystem_size"] - - return &SignatureResult{ - Offset: realOffset, - Size: totalSize, - Confidence: ConfidenceMedium, - Description: fmt.Sprintf("%s, total size: %d bytes", - LogFSDescription, totalSize), - }, nil -} - -// --------------------------------------------------------------------------- -// UBI -// --------------------------------------------------------------------------- - -const UBIImageDescription = "UBI image" - -func ubi_magic() [][]byte { - return [][]byte{ - {0x55, 0x42, 0x49, 0x23, 0x01}, // "UBI#" + version 1 - } -} - -func ubi_parser(data []byte, offset int) (*SignatureResult, error) { - ubiECFields := []StructField{ - {"magic", "u32"}, {"version", "u8"}, {"padding1", "u24"}, - {"ec", "u64"}, {"volume_id_header_offset", "u32"}, {"data_offset", "u32"}, - {"image_sequence_number", "u32"}, - {"padding2", "u64"}, {"padding3", "u64"}, {"padding4", "u64"}, {"padding5", "u64"}, - {"header_crc", "u32"}, - } - - ecHeaderSize := StructSize(ubiECFields) - crcDataSize := ecHeaderSize - 4 // minus the header_crc field - - ubiData := data[offset:] - ecHdr, err := Parse(ubiData, ubiECFields, "big") - if err != nil { - return nil, fmt.Errorf("ubi ec header parse failed") - } - - if ecHdr["data_offset"] < ecHeaderSize || ecHdr["volume_id_header_offset"] < ecHeaderSize { - return nil, fmt.Errorf("ubi invalid offsets") - } - - // Validate header CRC - if crcDataSize > len(ubiData) { - return nil, fmt.Errorf("ubi not enough data for crc") - } - if ubiCRC(ubiData[:crcDataSize]) != ecHdr["header_crc"] { - return nil, fmt.Errorf("ubi ec header crc mismatch") - } - - dataOffset := offset + ecHdr["data_offset"] - volumeOffset := offset + ecHdr["volume_id_header_offset"] - if dataOffset >= len(data) || volumeOffset >= len(data) { - return nil, fmt.Errorf("ubi offsets out of bounds") - } - - // Get image size by scanning volume headers - imageSize, err := getUBIImageSize(data[offset:]) - if err != nil { - return nil, err - } - - return &SignatureResult{ - Offset: offset, - Size: imageSize, - Confidence: ConfidenceHigh, - Description: fmt.Sprintf("%s, version: %d, image size: %d bytes", - UBIImageDescription, ecHdr["version"], imageSize), - }, nil -} - -func getUBIImageSize(ubiData []byte) (int, error) { - volMagic := []byte{0x55, 0x42, 0x49, 0x21, 0x01} // "UBI!" + version 1 - - ubiVolFields := []StructField{ - {"magic", "u32"}, {"version", "u8"}, {"volume_type", "u8"}, - {"copy_flag", "u8"}, {"compat_type", "u8"}, - {"volume_id", "u32"}, {"logical_erase_block_number", "u32"}, - {"padding1", "u32"}, {"data_size", "u32"}, - {"used_erase_block_count", "u32"}, {"data_padding_size", "u32"}, - {"data_crc", "u32"}, {"padding2", "u32"}, - {"sequence_number", "u64"}, - {"padding3", "u64"}, {"padding4", "u32"}, {"header_crc", "u32"}, - } - - volHeaderSize := StructSize(ubiVolFields) - volCRCDataSize := volHeaderSize - 4 - - blockCount := 0 - previousVolumeOffset := 0 - possibleLEBSizes := make(map[int]int) - - // Scan for volume headers - for i := 0; i <= len(ubiData)-len(volMagic); { - idx := bytes.Index(ubiData[i:], volMagic) - if idx < 0 { - break - } - thisVolumeOffset := i + idx - - if thisVolumeOffset+volHeaderSize > len(ubiData) { - break - } - - volHdr, err := Parse(ubiData[thisVolumeOffset:], ubiVolFields, "big") - if err == nil && - volHdr["padding1"] == 0 && volHdr["padding2"] == 0 && - volHdr["padding3"] == 0 && volHdr["padding4"] == 0 { - // Validate CRC - if thisVolumeOffset+volCRCDataSize <= len(ubiData) { - if ubiCRC(ubiData[thisVolumeOffset:thisVolumeOffset+volCRCDataSize]) == volHdr["header_crc"] { - blockCount++ - if previousVolumeOffset != 0 { - lebSize := thisVolumeOffset - previousVolumeOffset - possibleLEBSizes[lebSize]++ - } - previousVolumeOffset = thisVolumeOffset - } - } - } - i = thisVolumeOffset + 1 - } - - // Pick the most common LEB size - lebSize := 0 - bestCount := 0 - for size, count := range possibleLEBSizes { - if count > bestCount { - lebSize = size - bestCount = count - } - } - - if lebSize != 0 && blockCount != 0 { - return blockCount * lebSize, nil - } - return 0, fmt.Errorf("ubi could not determine image size") -} - -// --------------------------------------------------------------------------- -// UBIFS -// --------------------------------------------------------------------------- - -const UBIFSDescription = "UBIFS image" - -func ubifs_magic() [][]byte { - return [][]byte{ - {0x31, 0x18, 0x10, 0x06}, - } -} - -func ubifs_parser(data []byte, offset int) (*SignatureResult, error) { - const maxGroupType = 2 - const crcStartOffset = 8 - const superblockNodeType = 6 - const superblockStructExtraSize = 3968 - - ubiSBFields := []StructField{ - {"magic", "u32"}, {"header_crc", "u32"}, - {"sequence_number", "u64"}, {"node_len", "u32"}, - {"node_type", "u8"}, {"group_type", "u8"}, - {"padding1", "u32"}, {"key_hash", "u8"}, {"key_format", "u8"}, - {"flags", "u32"}, {"min_io_size", "u32"}, - {"leb_size", "u32"}, {"leb_count", "u32"}, {"max_leb_count", "u32"}, - {"max_bud_bytes", "u64"}, {"log_lebs", "u32"}, {"lpt_lebs", "u32"}, - {"orph_lebs", "u32"}, {"jhead_count", "u32"}, {"fanout", "u32"}, - {"lsave_count", "u32"}, {"fmt_version", "u32"}, - {"default_compression", "u16"}, {"padding2", "u16"}, - {"rp_uid", "u32"}, {"rp_gid", "u32"}, {"rp_size", "u64"}, - {"time_gran", "u32"}, {"uuid_p1", "u64"}, {"uuid_p2", "u64"}, - {"ro_compat_version", "u32"}, - } - - sbStructSize := StructSize(ubiSBFields) + superblockStructExtraSize - - ubisData := data[offset:] - hdr, err := Parse(ubisData, ubiSBFields, "little") - if err != nil { - return nil, fmt.Errorf("ubifs superblock parse failed") - } - - if hdr["padding1"] != 0 || hdr["padding2"] != 0 { - return nil, fmt.Errorf("ubifs padding fields not zero") - } - if hdr["node_type"] != superblockNodeType { - return nil, fmt.Errorf("ubifs not a superblock node") - } - if hdr["group_type"] > maxGroupType { - return nil, fmt.Errorf("ubifs invalid group type") - } - - // Validate header CRC - if crcStartOffset+sbStructSize-crcStartOffset > len(ubisData) { - return nil, fmt.Errorf("ubifs not enough data for crc") - } - crcData := ubisData[crcStartOffset:sbStructSize] - if ubiCRC(crcData) != hdr["header_crc"] { - return nil, fmt.Errorf("ubifs header crc mismatch") - } - - imageSize := hdr["leb_count"] * hdr["leb_size"] - - return &SignatureResult{ - Offset: offset, - Size: imageSize, - Confidence: ConfidenceHigh, - Description: fmt.Sprintf("%s, total size: %d bytes", - UBIFSDescription, imageSize), - }, nil -} - -// ubiCRC computes a UBI checksum: NOT(CRC32(data)) & 0xFFFFFFFF -func ubiCRC(data []byte) int { - const ubiCRCInit = uint32(0xFFFFFFFF) - return int((^CRC32(data)) & ubiCRCInit) -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -func containsInt(slice []int, val int) bool { - for _, s := range slice { - if s == val { - return true - } - } - return false -} diff --git a/internal/leviathan/binsnoop/sig_firmware.go b/internal/leviathan/binsnoop/sig_firmware.go deleted file mode 100644 index e214ed5..0000000 --- a/internal/leviathan/binsnoop/sig_firmware.go +++ /dev/null @@ -1,1333 +0,0 @@ -package binsnoop - -import ( - "encoding/hex" - "fmt" -) - -// ============================================================================ -// uImage -// ============================================================================ - -const uimageDescription = "uImage firmware image" - -func uimageMagic() [][]byte { - return [][]byte{ - {0x27, 0x05, 0x19, 0x56}, - []byte("OKLI"), - } -} - -func uimageParser(data []byte, offset int) (*SignatureResult, error) { - if offset+64 > len(data) { - return nil, fmt.Errorf("insufficient data for uImage header") - } - hdr := data[offset:] - fields := []StructField{ - {"magic", "u32"}, {"header_crc", "u32"}, {"timestamp", "u32"}, - {"data_size", "u32"}, {"load_address", "u32"}, {"entry_point", "u32"}, - {"data_crc", "u32"}, {"os_type", "u8"}, {"cpu_type", "u8"}, - {"image_type", "u8"}, {"compression_type", "u8"}, - } - parsed, err := Parse(hdr, fields, "big") - if err != nil { - return nil, err - } - headerSize := 64 - dataSize := parsed["data_size"] - if dataSize == 0 { - return nil, fmt.Errorf("uImage data size is 0") - } - totalSize := headerSize + dataSize - if offset+totalSize > len(data) { - return nil, fmt.Errorf("uImage extends beyond EOF") - } - - // Validate header CRC: zero out the header_crc field, calc CRC32 over 64 bytes - headerCopy := make([]byte, headerSize) - copy(headerCopy, hdr[:headerSize]) - headerCopy[4] = 0 - headerCopy[5] = 0 - headerCopy[6] = 0 - headerCopy[7] = 0 - calcCRC := CRC32(headerCopy) - headerCRCValid := calcCRC == uint32(parsed["header_crc"]) - - osMap := map[int]string{0: "OpenBSD", 1: "NetBSD", 2: "FreeBSD", 3: "4.4BSD", 4: "Linux", 5: "SVR4", 6: "Esix", 7: "Solaris", 8: "Irix", 9: "SCO", 10: "Dell", 11: "NCR", 12: "LynxOS", 13: "VxWorks", 14: "pSOS", 15: "QNX", 16: "U-Boot", 17: "RTEMS", 18: "ARTOS", 19: "Unity OS", 20: "INTEGRITY", 21: "OSE", 22: "Plan 9", 23: "OpenRTOS", 24: "ARM Trusted Firmware", 25: "Trusted Execution Environment", 26: "RISC-V OpenSBI", 27: "EFI Firmware"} - cpuMap := map[int]string{0: "Invalid", 1: "Alpha", 2: "ARM", 3: "Intel x86", 4: "IA64", 5: "MIPS", 6: "MIPS 64-bit", 7: "PowerPC", 8: "IBM S390", 9: "SuperH", 10: "Sparc", 11: "Sparc 64-bit", 12: "M68K", 13: "Nios-32", 14: "MicroBlaze", 15: "Nios-II", 16: "Blackfin", 17: "AVR32", 18: "ST200", 19: "Sandbox", 20: "NDS32", 21: "OpenRISC 1000", 22: "ARM64", 23: "ARC", 24: "x86_64", 25: "Xtensa", 26: "RISC-V"} - compMap := map[int]string{0: "none", 1: "gzip", 2: "bzip2", 3: "lzma", 4: "lzo", 5: "lz4", 6: "zstd"} - imgMap := map[int]string{0: "Invalid", 1: "Standalone", 2: "Kernel", 3: "RAMDisk", 4: "Multi-File", 5: "Firmware", 6: "Script", 7: "Filesystem", 8: "Flat DT", 9: "Kwb", 10: "IMX", 11: "AIS", 12: "Kernel (no load)", 13: "PBL", 14: "MXS", 15: "GP Header", 16: "ATMEL", 17: "IMX (Silicon Labs)", 18: "TEE", 19: "FW IVT", 20: "PMC", 21: "STM32", 22: "Renesas SPKG", 23: "ST SunXI", 24: "FDT Legacy"} - - osType := lookupMap(osMap, parsed["os_type"], "Unknown") - cpuType := lookupMap(cpuMap, parsed["cpu_type"], "Unknown") - compType := lookupMap(compMap, parsed["compression_type"], "Unknown") - imageType := lookupMap(imgMap, parsed["image_type"], "Unknown") - name := GetCString(hdr[32:64]) - - confidence := ConfidenceHigh - desc := fmt.Sprintf("%s, header size: %d bytes, data size: %d bytes, compression: %s, CPU: %s, OS: %s, image type: %s, load address: 0x%X, entry point: 0x%X, creation time: %s, image name: \"%s\"", - uimageDescription, headerSize, dataSize, compType, cpuType, osType, imageType, - parsed["load_address"], parsed["entry_point"], - EpochToString(uint32(parsed["timestamp"])), name) - - if !headerCRCValid { - if offset == 0 { - confidence = ConfidenceMedium - } else { - confidence = ConfidenceLow - } - desc += ", invalid checksum" - } - - return &SignatureResult{ - Offset: offset, - Size: totalSize, - Description: desc, - Confidence: confidence, - ExtractionDeclined: !headerCRCValid || dataSize == 0, - }, nil -} - -// ============================================================================ -// U-Boot version string -// ============================================================================ - -const ubootDescription = "U-Boot version string" - -func ubootMagic() [][]byte { - return [][]byte{[]byte("U-Boot ")} -} - -func ubootParser(data []byte, offset int) (*SignatureResult, error) { - numOff := offset + 7 - if numOff >= len(data) { - return nil, fmt.Errorf("insufficient data") - } - if !IsASCIINumber(data[numOff]) { - return nil, fmt.Errorf("expected version number") - } - ver := GetCString(data[numOff:]) - if len(ver) == 0 { - return nil, fmt.Errorf("empty version string") - } - if len(ver) > 100 { - ver = ver[:100] - } - return &SignatureResult{ - Offset: offset, - Size: len(ver), - Description: fmt.Sprintf("%s: %s", ubootDescription, ver), - Confidence: ConfidenceMedium, - }, nil -} - -// ============================================================================ -// TRX -// ============================================================================ - -const trxDescription = "TRX firmware image" - -func trxMagic() [][]byte { - return [][]byte{[]byte("HDR0")} -} - -func trxParser(data []byte, offset int) (*SignatureResult, error) { - if offset+28 > len(data) { - return nil, fmt.Errorf("insufficient data for TRX header") - } - hdr := data[offset:] - fields := []StructField{ - {"magic", "u32"}, {"total_size", "u32"}, {"header_crc", "u32"}, - {"version", "u16"}, {"flags", "u16"}, - {"partition1", "u32"}, {"partition2", "u32"}, {"partition3", "u32"}, - } - parsed, err := Parse(hdr, fields, "little") - if err != nil { - return nil, err - } - totalSize := parsed["total_size"] - if totalSize <= 28 || offset+totalSize > len(data) { - return nil, fmt.Errorf("invalid TRX size") - } - // CRC32 validation (over data after the CRC field) - if offset+12 < len(data) { - calcCRC := CRC32(hdr[12:totalSize]) - if int(calcCRC) != parsed["header_crc"] { - return nil, fmt.Errorf("TRX CRC mismatch") - } - } - partCount := 0 - for _, p := range []string{"partition1", "partition2", "partition3"} { - if parsed[p] > 0 { - partCount++ - } - } - return &SignatureResult{ - Offset: offset, - Size: totalSize, - Description: fmt.Sprintf("%s, version %d, partition count: %d, header size: 28 bytes, total size: %d bytes", trxDescription, parsed["version"], partCount, totalSize), - Confidence: ConfidenceHigh, - }, nil -} - -// ============================================================================ -// TP-Link firmware header -// ============================================================================ - -const tplinkDescription = "TP-Link firmware header" - -func tplinkMagic() [][]byte { - return [][]byte{ - {0x01, 0x00, 0x00, 0x00, 0x54, 0x50, 0x2D, 0x4C, 0x49, 0x4E, 0x4B, 0x20, 0x54, 0x65, 0x63, 0x68, 0x6E, 0x6F, 0x6C, 0x6F, 0x67, 0x69, 0x65, 0x73, 0x00, 0x00, 0x00, 0x00, 0x76, 0x65, 0x72, 0x2E, 0x20, 0x31, 0x2E, 0x30}, - } -} - -func tplinkParser(data []byte, offset int) (*SignatureResult, error) { - if offset+0x200 > len(data) { - return nil, fmt.Errorf("insufficient data for TP-Link header") - } - hdr := data[offset:] - fields := []StructField{ - {"version", "u32"}, {"fw_version", "u32"}, {"hw_id", "u32"}, {"hw_rev", "u32"}, - {"unk1", "u32"}, {"kernel_data_offset", "u32"}, {"kernel_load_address", "u32"}, - {"kernel_entry_point", "u32"}, {"kernel_data_length", "u32"}, - } - parsed, err := Parse(hdr[0x40:], fields, "big") - if err != nil { - return nil, err - } - headerSize := 0x200 - return &SignatureResult{ - Offset: offset, - Size: headerSize, - Description: fmt.Sprintf("%s, kernel load address: 0x%X, kernel entry point: 0x%X, header size: %d bytes", tplinkDescription, parsed["kernel_load_address"], parsed["kernel_entry_point"], headerSize), - Confidence: ConfidenceMedium, - }, nil -} - -// ============================================================================ -// TP-Link RTOS firmware -// ============================================================================ - -const tplinkRTOSDescription = "TP-Link RTOS firmware" - -func tplinkRTOSMagic() [][]byte { - return [][]byte{{0x00, 0x14, 0x2F, 0xC0}} -} - -func tplinkRTOSParser(data []byte, offset int) (*SignatureResult, error) { - if offset+0x200 > len(data) { - return nil, fmt.Errorf("insufficient data for TP-Link RTOS header") - } - hdr := data[offset:] - fields := []StructField{ - {"magic", "u32"}, {"header_size", "u32"}, {"total_size", "u32"}, - {"model_number", "u32"}, {"hw_rev_major", "u32"}, {"hw_rev_minor", "u32"}, - } - parsed, err := Parse(hdr, fields, "big") - if err != nil { - return nil, err - } - return &SignatureResult{ - Offset: offset, - Size: parsed["header_size"], - Description: fmt.Sprintf("%s, model number: %X, hardware version: %X.%X, header size: %d bytes, total size: %d bytes", tplinkRTOSDescription, parsed["model_number"], parsed["hw_rev_major"], parsed["hw_rev_minor"], parsed["header_size"], parsed["total_size"]), - Confidence: ConfidenceMedium, - }, nil -} - -// ============================================================================ -// DLOB firmware header -// ============================================================================ - -const dlobDescription = "DLOB firmware header" - -func dlobMagic() [][]byte { - return [][]byte{{0x5e, 0xa3, 0xa4, 0x17}} -} - -func dlobParser(data []byte, offset int) (*SignatureResult, error) { - if offset+16 > len(data) { - return nil, fmt.Errorf("insufficient data for DLOB header") - } - hdr := data[offset:] - fields := []StructField{ - {"magic", "u32"}, {"header_size", "u32"}, {"data_size", "u32"}, - } - parsed, err := Parse(hdr, fields, "big") - if err != nil { - return nil, err - } - availableData := len(data) - offset - headerSize := parsed["header_size"] - dataSize := parsed["data_size"] - if availableData < headerSize+dataSize { - return nil, fmt.Errorf("DLOB size exceeds available data") - } - return &SignatureResult{ - Offset: offset, - Size: headerSize, - Description: fmt.Sprintf("%s, header size: %d bytes, data size: %d", dlobDescription, headerSize, dataSize), - Confidence: ConfidenceMedium, - }, nil -} - -// ============================================================================ -// Arcadyan obfuscated LZMA -// ============================================================================ - -const arcadyanDescription = "Arcadyan obfuscated LZMA" - -func arcadyanMagic() [][]byte { - return [][]byte{{0x00, 0xD5, 0x08, 0x00}} -} - -func arcadyanParser(data []byte, offset int) (*SignatureResult, error) { - const magicOffset = 0x68 - if offset < magicOffset { - return nil, fmt.Errorf("arcadyan magic offset too small") - } - startOffset := offset - magicOffset - return &SignatureResult{ - Offset: startOffset, - Description: arcadyanDescription, - Confidence: ConfidenceMedium, - }, nil -} - -// ============================================================================ -// CHK firmware header (Netgear) -// ============================================================================ - -const chkDescription = "CHK firmware header" - -func chkMagic() [][]byte { - return [][]byte{{0x2A, 0x23, 0x24, 0x5E}} -} - -func chkParser(data []byte, offset int) (*SignatureResult, error) { - if offset+40 > len(data) { - return nil, fmt.Errorf("insufficient data for CHK header") - } - hdr := data[offset:] - fields := []StructField{ - {"magic", "u32"}, {"header_size", "u32"}, {"reserved", "u32"}, - {"kernel_checksum", "u32"}, {"rootfs_checksum", "u32"}, - {"kernel_size", "u32"}, {"rootfs_size", "u32"}, - {"image_checksum", "u32"}, {"header_checksum", "u32"}, - } - parsed, err := Parse(hdr, fields, "big") - if err != nil { - return nil, err - } - headerSize := parsed["header_size"] - kernelSize := parsed["kernel_size"] - rootfsSize := parsed["rootfs_size"] - totalSize := headerSize + kernelSize + rootfsSize - availableData := len(data) - offset - if availableData < totalSize || totalSize <= headerSize { - return nil, fmt.Errorf("invalid CHK sizes") - } - boardID := GetCString(hdr[40:]) - return &SignatureResult{ - Offset: offset, - Size: headerSize, - Description: fmt.Sprintf("%s, board ID: %s, header size: %d bytes, data size: %d bytes", chkDescription, boardID, headerSize, kernelSize+rootfsSize), - Confidence: ConfidenceMedium, - }, nil -} - -// ============================================================================ -// SEAMA firmware header -// ============================================================================ - -const seamaDescription = "SEAMA firmware header" - -func seamaMagic() [][]byte { - return [][]byte{ - {0x5E, 0xA3, 0xA4, 0x17, 0x00, 0x00}, - {0x17, 0xA4, 0xA3, 0x5E, 0x00, 0x00}, - } -} - -func seamaParser(data []byte, offset int) (*SignatureResult, error) { - if offset+12 > len(data) { - return nil, fmt.Errorf("insufficient data for SEAMA header") - } - hdr := data[offset:] - endianness := "big" - if hdr[0] == 0x17 { - endianness = "little" - } - fields := []StructField{ - {"magic", "u32"}, {"meta_size", "u16"}, {"data_size", "u32"}, - } - parsed, err := Parse(hdr, fields, endianness) - if err != nil { - return nil, err - } - headerSize := 12 + parsed["meta_size"] - dataSize := parsed["data_size"] - totalSize := headerSize + dataSize - if offset+totalSize > len(data) { - return nil, fmt.Errorf("SEAMA size exceeds data") - } - return &SignatureResult{ - Offset: offset, - Size: headerSize, - Description: fmt.Sprintf("%s, header size: %d bytes, data size: %d bytes", seamaDescription, headerSize, dataSize), - Confidence: ConfidenceLow, - }, nil -} - -// ============================================================================ -// CFE bootloader -// ============================================================================ - -const cfeDescription = "CFE bootloader" - -func cfeMagic() [][]byte { - return [][]byte{[]byte("CFE1CFE1")} -} - -func cfeParser(_ []byte, offset int) (*SignatureResult, error) { - const cfeMagicOffset = 28 - if offset < cfeMagicOffset { - return nil, fmt.Errorf("CFE magic offset too small") - } - confidence := ConfidenceLow - startOffset := offset - cfeMagicOffset - if startOffset == 0 { - confidence = ConfidenceMedium - } - return &SignatureResult{ - Offset: startOffset, - Description: cfeDescription, - Confidence: confidence, - }, nil -} - -// ============================================================================ -// JBOOT ARM firmware -// ============================================================================ - -const jbootARMDescription = "JBOOT firmware header" - -func jbootARMMagic() [][]byte { - return [][]byte{ - {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x42, 0x48}, - } -} - -func jbootARMParser(data []byte, offset int) (*SignatureResult, error) { - const magicOff = 48 - if offset < magicOff { - return nil, fmt.Errorf("JBOOT ARM magic offset too small") - } - headerStart := offset - magicOff - if headerStart+80 > len(data) { - return nil, fmt.Errorf("insufficient data for JBOOT ARM header") - } - hdr := data[headerStart:] - fields := []StructField{ - {"rom_id_size", "u32"}, {"rom_id", "u32"}, {"product_id", "u32"}, - {"func_id", "u32"}, {"erase_offset", "u32"}, {"erase_size", "u32"}, - {"data_offset", "u32"}, {"data_size", "u32"}, - } - parsed, err := Parse(hdr[20:], fields, "big") - if err != nil { - return nil, err - } - headerSize := 80 - return &SignatureResult{ - Offset: headerStart, - Size: headerSize, - Description: fmt.Sprintf("%s, header size: %d bytes, ROM ID: %d, erase offset: 0x%X, erase size: 0x%X, data flash offset: 0x%X, data size: 0x%X", jbootARMDescription, headerSize, parsed["rom_id"], parsed["erase_offset"], parsed["erase_size"], parsed["data_offset"], parsed["data_size"]), - Confidence: ConfidenceMedium, - }, nil -} - -// ============================================================================ -// JBOOT STAG header -// ============================================================================ - -const jbootSTAGDescription = "JBOOT STAG header" - -func jbootSTAGMagic() [][]byte { - return [][]byte{ - {0x04, 0x04, 0x24, 0x2B}, - {0xFF, 0x04, 0x24, 0x2B}, - } -} - -func jbootSTAGParser(data []byte, offset int) (*SignatureResult, error) { - if offset+16 > len(data) { - return nil, fmt.Errorf("insufficient data for JBOOT STAG header") - } - hdr := data[offset:] - fields := []StructField{ - {"tag", "u32"}, {"header_size", "u16"}, {"flags", "u16"}, {"image_size", "u32"}, - } - parsed, err := Parse(hdr, fields, "big") - if err != nil { - return nil, err - } - headerSize := parsed["header_size"] - imageSize := parsed["image_size"] - if offset+headerSize+imageSize >= len(data) { - return nil, fmt.Errorf("JBOOT STAG size exceeds data") - } - imageType := "factory image" - if parsed["flags"]&0x01 != 0 { - imageType = "system upgrade image" - } - return &SignatureResult{ - Offset: offset, - Size: headerSize, - Description: fmt.Sprintf("%s, %s, header size: %d bytes, kernel data size: %d bytes", jbootSTAGDescription, imageType, headerSize, imageSize), - Confidence: ConfidenceLow, - }, nil -} - -// ============================================================================ -// JBOOT SCH2 header -// ============================================================================ - -const jbootSCH2Description = "JBOOT SCH2 header" - -func jbootSCH2Magic() [][]byte { - return [][]byte{ - {0x24, 0x21, 0x00, 0x02}, - {0x24, 0x21, 0x01, 0x02}, - {0x24, 0x21, 0x02, 0x02}, - {0x24, 0x21, 0x03, 0x02}, - } -} - -func jbootSCH2Parser(data []byte, offset int) (*SignatureResult, error) { - if offset+32 > len(data) { - return nil, fmt.Errorf("insufficient data for JBOOT SCH2 header") - } - hdr := data[offset:] - fields := []StructField{ - {"magic", "u32"}, {"header_size", "u32"}, {"kernel_size", "u32"}, - {"kernel_checksum", "u32"}, {"kernel_entry_point", "u32"}, - {"compression", "u8"}, - } - parsed, err := Parse(hdr, fields, "big") - if err != nil { - return nil, err - } - compTypes := map[int]string{0: "none", 1: "gzip", 2: "bzip2", 3: "lzma"} - comp := lookupMap(compTypes, parsed["compression"], "unknown") - headerSize := parsed["header_size"] - kernelSize := parsed["kernel_size"] - totalSize := headerSize + kernelSize - if offset+totalSize > len(data) { - return nil, fmt.Errorf("JBOOT SCH2 extends beyond EOF") - } - return &SignatureResult{ - Offset: offset, - Size: totalSize, - Description: fmt.Sprintf("%s, header size: %d bytes, kernel size: %d bytes, kernel compression: %s, kernel entry point: 0x%X", jbootSCH2Description, headerSize, kernelSize, comp, parsed["kernel_entry_point"]), - Confidence: ConfidenceHigh, - }, nil -} - -// ============================================================================ -// PackImg firmware header -// ============================================================================ - -const packimgDescription = "PackImg firmware header" - -func packimgMagic() [][]byte { - return [][]byte{[]byte("--PaCkImGs--")} -} - -func packimgParser(data []byte, offset int) (*SignatureResult, error) { - if offset+24 > len(data) { - return nil, fmt.Errorf("insufficient data for PackImg header") - } - hdr := data[offset:] - fields := []StructField{ - {"magic1", "u32"}, {"magic2", "u32"}, {"magic3", "u32"}, - {"data_size", "u32"}, {"header_size", "u32"}, - } - parsed, err := Parse(hdr, fields, "big") - if err != nil { - return nil, err - } - headerSize := parsed["header_size"] - dataSize := parsed["data_size"] - if len(data)-offset < headerSize+dataSize { - return nil, fmt.Errorf("PackImg size exceeds data") - } - return &SignatureResult{ - Offset: offset, - Size: headerSize, - Description: fmt.Sprintf("%s, header size: %d bytes, data size: %d bytes", packimgDescription, headerSize, dataSize), - Confidence: ConfidenceLow, - }, nil -} - -// ============================================================================ -// BIN firmware header -// ============================================================================ - -const binhdrDescription = "BIN firmware header" - -func binhdrMagic() [][]byte { - return [][]byte{[]byte("U2ND")} -} - -func binhdrParser(data []byte, offset int) (*SignatureResult, error) { - const magicOff = 14 - if offset < magicOff { - return nil, fmt.Errorf("BIN header magic offset too small") - } - startOffset := offset - magicOff - if startOffset+24 > len(data) { - return nil, fmt.Errorf("insufficient data for BIN header") - } - hdr := data[startOffset:] - boardID := GetCString(hdr[:14]) - fields := []StructField{ - {"magic", "u32"}, {"hw_rev", "u16"}, {"fw_major", "u8"}, {"fw_minor", "u8"}, - } - parsed, err := Parse(hdr[14:], fields, "big") - if err != nil { - return nil, err - } - return &SignatureResult{ - Offset: startOffset, - Description: fmt.Sprintf("%s, board ID: %s, hardware revision: %d, firmware version: %d.%d", binhdrDescription, boardID, parsed["hw_rev"], parsed["fw_major"], parsed["fw_minor"]), - Confidence: ConfidenceMedium, - }, nil -} - -// ============================================================================ -// Autel obfuscated firmware -// ============================================================================ - -const autelDescription = "Autel obfuscated firmware" - -func autelMagic() [][]byte { - return [][]byte{[]byte("ECC0101\x00")} -} - -func autelParser(data []byte, offset int) (*SignatureResult, error) { - if offset+16 > len(data) { - return nil, fmt.Errorf("insufficient data for Autel header") - } - hdr := data[offset:] - fields := []StructField{ - {"magic", "u64"}, {"data_size", "u32"}, {"header_size", "u32"}, - } - parsed, err := Parse(hdr, fields, "little") - if err != nil { - return nil, err - } - headerSize := parsed["header_size"] - dataSize := parsed["data_size"] - totalSize := headerSize + dataSize - if offset+totalSize > len(data) { - return nil, fmt.Errorf("Autel size exceeds data") - } - return &SignatureResult{ - Offset: offset, - Size: totalSize, - Description: fmt.Sprintf("%s, header size: %d bytes, data size: %d, total size: %d", autelDescription, headerSize, dataSize, totalSize), - Confidence: ConfidenceMedium, - }, nil -} - -// ============================================================================ -// D-Link TLV firmware -// ============================================================================ - -const dlinkTLVDescription = "D-Link TLV firmware" - -func dlinkTLVMagic() [][]byte { - return [][]byte{{0x64, 0x80, 0x19, 0x40}} -} - -func dlinkTLVParser(data []byte, offset int) (*SignatureResult, error) { - if offset+32 > len(data) { - return nil, fmt.Errorf("insufficient data for D-Link TLV header") - } - hdr := data[offset:] - fields := []StructField{ - {"magic", "u32"}, {"header_size", "u32"}, {"data_size", "u32"}, - } - parsed, err := Parse(hdr, fields, "big") - if err != nil { - return nil, err - } - headerSize := parsed["header_size"] - dataSize := parsed["data_size"] - totalSize := headerSize + dataSize - if offset+totalSize > len(data) { - return nil, fmt.Errorf("D-Link TLV size exceeds data") - } - return &SignatureResult{ - Offset: offset, - Size: totalSize, - Description: fmt.Sprintf("%s, header size: %d bytes, data size: %d bytes", dlinkTLVDescription, headerSize, dataSize), - Confidence: ConfidenceHigh, - }, nil -} - -// ============================================================================ -// DLKE encrypted firmware -// ============================================================================ - -const dlkeDescription = "DLK encrypted firmware" - -func dlkeMagic() [][]byte { - return [][]byte{ - []byte("DLK6E8202001"), - []byte("DLK6E6110002"), - } -} - -func dlkeParser(data []byte, offset int) (*SignatureResult, error) { - if offset+80 > len(data) { - return nil, fmt.Errorf("insufficient data for DLKE header") - } - // Parse first JBOOT-like header - hdr1 := data[offset:] - fields := []StructField{ - {"rom_id_size", "u32"}, {"rom_id", "u32"}, {"product_id", "u32"}, - {"func_id", "u32"}, {"erase_offset", "u32"}, {"erase_size", "u32"}, - {"data_offset", "u32"}, {"data_size", "u32"}, - } - parsed1, err := Parse(hdr1[20:], fields, "big") - if err != nil { - return nil, err - } - hdr1Size := 80 - sig1DataSize := parsed1["data_size"] - - nextOffset := offset + hdr1Size + sig1DataSize - if nextOffset+80 > len(data) { - return nil, fmt.Errorf("DLKE second header beyond EOF") - } - hdr2 := data[nextOffset:] - parsed2, err := Parse(hdr2[20:], fields, "big") - if err != nil { - return nil, err - } - hdr2Size := 80 - cryptDataSize := parsed2["data_size"] - - totalSize := hdr1Size + sig1DataSize + hdr2Size + cryptDataSize - return &SignatureResult{ - Offset: offset, - Size: totalSize, - Description: fmt.Sprintf("%s, signature size: %d bytes, encrypted data size: %d bytes", dlkeDescription, sig1DataSize, cryptDataSize), - Confidence: ConfidenceHigh, - }, nil -} - -// ============================================================================ -// SHRS encrypted firmware -// ============================================================================ - -const shrsDescription = "SHRS encrypted firmware" - -func shrsMagic() [][]byte { - return [][]byte{[]byte("SHRS")} -} - -func shrsParser(data []byte, offset int) (*SignatureResult, error) { - if offset+32 > len(data) { - return nil, fmt.Errorf("insufficient data for SHRS header") - } - hdr := data[offset:] - fields := []StructField{ - {"magic", "u32"}, {"header_size", "u32"}, {"data_size", "u32"}, - } - parsed, err := Parse(hdr, fields, "big") - if err != nil { - return nil, err - } - headerSize := parsed["header_size"] - dataSize := parsed["data_size"] - totalSize := headerSize + dataSize - if offset+totalSize > len(data) { - return nil, fmt.Errorf("SHRS size exceeds data") - } - // Extract IV (16 bytes after the fixed header) - ivStr := "" - if headerSize >= 28 && offset+28 <= len(data) { - iv := hdr[12:28] - ivStr = hex.EncodeToString(iv) - } - confidence := ConfidenceLow - if offset == 0 { - confidence = ConfidenceMedium - } - return &SignatureResult{ - Offset: offset, - Size: totalSize, - Description: fmt.Sprintf("%s, header size: %d bytes, encrypted data size: %d bytes, IV: %s", shrsDescription, headerSize, dataSize, ivStr), - Confidence: confidence, - }, nil -} - -// ============================================================================ -// DKBS firmware header -// ============================================================================ - -const dkbsDescription = "DKBS firmware header" - -func dkbsMagic() [][]byte { - return [][]byte{[]byte("_dkbs_")} -} - -func dkbsParser(data []byte, offset int) (*SignatureResult, error) { - const magicOff = 7 - if offset < magicOff { - return nil, fmt.Errorf("DKBS magic offset too small") - } - startOffset := offset - magicOff - if startOffset+64 > len(data) { - return nil, fmt.Errorf("insufficient data for DKBS header") - } - hdr := data[startOffset:] - fields := []StructField{ - {"version", "u32"}, {"magic", "u32"}, {"padding", "u32"}, - {"header_size", "u32"}, {"data_size", "u32"}, - } - parsed, err := Parse(hdr, fields, "big") - if err != nil { - return nil, err - } - headerSize := parsed["header_size"] - dataSize := parsed["data_size"] - availableData := len(data) - startOffset - if availableData < headerSize+dataSize { - return nil, fmt.Errorf("DKBS size exceeds data") - } - confidence := ConfidenceMedium - if startOffset == 0 { - confidence = ConfidenceHigh - } - boardID := GetCString(hdr[24:]) - return &SignatureResult{ - Offset: startOffset, - Size: headerSize, - Description: fmt.Sprintf("%s, board ID: %s, header size: %d bytes, data size: %d", dkbsDescription, boardID, headerSize, dataSize), - Confidence: confidence, - }, nil -} - -// ============================================================================ -// DMS firmware image -// ============================================================================ - -const dmsDescription = "DMS firmware image" - -func dmsMagic() [][]byte { - return [][]byte{[]byte("0><1")} -} - -func dmsParser(data []byte, offset int) (*SignatureResult, error) { - const magicOff = 4 - const minSize = 0x100 - if offset < magicOff { - return nil, fmt.Errorf("DMS magic offset too small") - } - startOffset := offset - magicOff - if startOffset+minSize > len(data) { - return nil, fmt.Errorf("insufficient data for DMS header") - } - - // DMS images have byte-swapped u16 pairs - dmsData := make([]byte, minSize) - copy(dmsData, data[startOffset:startOffset+minSize]) - for i := 0; i+1 < len(dmsData); i += 2 { - dmsData[i], dmsData[i+1] = dmsData[i+1], dmsData[i] - } - - fields := []StructField{ - {"magic", "u32"}, {"unk1", "u32"}, {"image_size", "u32"}, - } - parsed, err := Parse(dmsData, fields, "big") - if err != nil { - return nil, err - } - imageSize := parsed["image_size"] - if imageSize <= 0 || startOffset+imageSize > len(data) { - return nil, fmt.Errorf("invalid DMS image size") - } - return &SignatureResult{ - Offset: startOffset, - Size: imageSize, - Description: fmt.Sprintf("%s, total size: %d bytes", dmsDescription, imageSize), - Confidence: ConfidenceMedium, - }, nil -} - -// ============================================================================ -// Known encrypted firmware (encfw) -// ============================================================================ - -const encfwDescription = "Known encrypted firmware" - -func encfwKnownFirmware() map[string]string { - return map[string]string{ - "\xdf\x8c\x39\x0d": "D-Link DIR-822 rev C", - "\x35\x66\x6f\x68": "D-Link DAP-1665", - "\xf5\x2a\xa0\xb4": "D-Link DIR-842 rev C", - "\xe3\x13\x00\x5b": "D-Link DIR-850 rev A", - "\x0a\x14\xe4\x24": "D-Link DIR-850 rev B", - } -} - -func encfwMagic() [][]byte { - var magics [][]byte - for k := range encfwKnownFirmware() { - magics = append(magics, []byte(k)) - } - return magics -} - -func encfwParser(data []byte, offset int) (*SignatureResult, error) { - if offset+4 > len(data) { - return nil, fmt.Errorf("insufficient data for encfw") - } - key := string(data[offset : offset+4]) - known := encfwKnownFirmware() - model, ok := known[key] - if !ok { - return nil, fmt.Errorf("unknown encfw magic") - } - confidence := ConfidenceMedium - if offset != 0 { - confidence = ConfidenceLow - } - return &SignatureResult{ - Offset: offset, - Description: fmt.Sprintf("%s, %s", encfwDescription, model), - Confidence: confidence, - }, nil -} - -// ============================================================================ -// D-Link encrpted_img -// ============================================================================ - -const encrptedImgDescription = "D-Link Encrpted Image" - -func encrptedImgMagic() [][]byte { - return [][]byte{[]byte("encrpted_img")} -} - -func encrptedImgParser(_ []byte, offset int) (*SignatureResult, error) { - confidence := ConfidenceMedium - if offset != 0 { - confidence = ConfidenceLow - } - return &SignatureResult{ - Offset: offset, - Description: encrptedImgDescription, - Confidence: confidence, - }, nil -} - -// ============================================================================ -// Android boot image -// ============================================================================ - -const androidBootimgDescription = "Android boot image" - -func androidBootimgMagic() [][]byte { - return [][]byte{[]byte("ANDROID!")} -} - -func androidBootimgParser(data []byte, offset int) (*SignatureResult, error) { - if offset+40 > len(data) { - return nil, fmt.Errorf("insufficient data for Android boot image header") - } - hdr := data[offset:] - fields := []StructField{ - {"magic", "u64"}, {"kernel_size", "u32"}, {"kernel_load_addr", "u32"}, - {"ramdisk_size", "u32"}, {"ramdisk_load_addr", "u32"}, - } - parsed, err := Parse(hdr, fields, "little") - if err != nil { - return nil, err - } - confidence := ConfidenceLow - if offset == 0 { - confidence = ConfidenceMedium - } - return &SignatureResult{ - Offset: offset, - Description: fmt.Sprintf("%s, kernel size: %d bytes, kernel load address: 0x%X, ramdisk size: %d bytes, ramdisk load address: 0x%X", androidBootimgDescription, parsed["kernel_size"], parsed["kernel_load_addr"], parsed["ramdisk_size"], parsed["ramdisk_load_addr"]), - Confidence: confidence, - }, nil -} - -// ============================================================================ -// Android sparse image -// ============================================================================ - -const androidSparseDescription = "Android sparse image" - -func androidSparseMagic() [][]byte { - return [][]byte{{0x3A, 0xFF, 0x26, 0xED}} -} - -func androidSparseParser(data []byte, offset int) (*SignatureResult, error) { - if offset+28 > len(data) { - return nil, fmt.Errorf("insufficient data for Android sparse header") - } - hdr := data[offset:] - fields := []StructField{ - {"magic", "u32"}, {"major_version", "u16"}, {"minor_version", "u16"}, - {"header_size", "u16"}, {"chunk_header_size", "u16"}, - {"block_size", "u32"}, {"total_blocks", "u32"}, - {"total_chunks", "u32"}, {"checksum", "u32"}, - } - parsed, err := Parse(hdr, fields, "little") - if err != nil { - return nil, err - } - headerSize := parsed["header_size"] - return &SignatureResult{ - Offset: offset, - Size: headerSize, - Description: fmt.Sprintf("%s, version %d.%d, header size: %d, block size: %d, chunk count: %d", androidSparseDescription, parsed["major_version"], parsed["minor_version"], headerSize, parsed["block_size"], parsed["total_chunks"]), - Confidence: ConfidenceHigh, - }, nil -} - -// ============================================================================ -// Windows CE binary image -// ============================================================================ - -const winceDescription = "Windows CE binary image" - -func winceMagic() [][]byte { - return [][]byte{[]byte("B000FF\n")} -} - -func winceParser(data []byte, offset int) (*SignatureResult, error) { - if offset+16 > len(data) { - return nil, fmt.Errorf("insufficient data for WinCE header") - } - hdr := data[offset:] - fields := []StructField{ - {"magic1", "u32"}, {"magic2", "u32"}, - {"base_address", "u32"}, {"image_size", "u32"}, - } - parsed, err := Parse(hdr, fields, "little") - if err != nil { - return nil, err - } - return &SignatureResult{ - Offset: offset, - Size: parsed["image_size"], - Description: fmt.Sprintf("%s, base address: 0x%X, image size: %d bytes", winceDescription, parsed["base_address"], parsed["image_size"]), - Confidence: ConfidenceHigh, - }, nil -} - -// ============================================================================ -// D-Link MH01 firmware -// ============================================================================ - -const mh01Description = "D-Link MH01 firmware image" - -func mh01Magic() [][]byte { - return [][]byte{[]byte("MH01")} -} - -func mh01Parser(data []byte, offset int) (*SignatureResult, error) { - if offset+32 > len(data) { - return nil, fmt.Errorf("insufficient data for MH01 header") - } - hdr := data[offset:] - fields := []StructField{ - {"magic", "u32"}, {"header_size", "u32"}, {"total_size", "u32"}, - } - parsed, err := Parse(hdr, fields, "big") - if err != nil { - return nil, err - } - totalSize := parsed["total_size"] - if offset+totalSize > len(data) { - return nil, fmt.Errorf("MH01 size exceeds data") - } - return &SignatureResult{ - Offset: offset, - Size: totalSize, - Description: fmt.Sprintf("%s, total size: %d bytes", mh01Description, totalSize), - Confidence: ConfidenceHigh, - }, nil -} - -// ============================================================================ -// CSman DAT -// ============================================================================ - -const csmanDescription = "CSman DAT file" - -func csmanMagic() [][]byte { - return [][]byte{[]byte("SC"), []byte("CS")} -} - -func csmanParser(data []byte, offset int) (*SignatureResult, error) { - if offset+8 > len(data) { - return nil, fmt.Errorf("insufficient data for CSman header") - } - hdr := data[offset:] - endianness := "big" - if hdr[0] == 'C' && hdr[1] == 'S' { - endianness = "little" - } - fields := []StructField{ - {"magic", "u16"}, {"count", "u16"}, {"size", "u32"}, - } - parsed, err := Parse(hdr, fields, endianness) - if err != nil { - return nil, err - } - totalSize := parsed["size"] - if totalSize <= 0 || offset+totalSize > len(data) { - return nil, fmt.Errorf("invalid CSman size") - } - return &SignatureResult{ - Offset: offset, - Size: totalSize, - Description: fmt.Sprintf("%s, total size: %d bytes", csmanDescription, totalSize), - Confidence: ConfidenceHigh, - }, nil -} - -// ============================================================================ -// DXBC (DirectX shader bytecode) -// ============================================================================ - -const dxbcDescription = "DirectX shader bytecode" - -func dxbcMagic() [][]byte { - return [][]byte{[]byte("DXBC")} -} - -func dxbcParser(data []byte, offset int) (*SignatureResult, error) { - if offset+32 > len(data) { - return nil, fmt.Errorf("insufficient data for DXBC header") - } - hdr := data[offset:] - // DXBC header: 4-byte magic, 16-byte hash, 4-byte version, 4-byte total_size, 4-byte chunk_count - fields := []StructField{ - {"magic", "u32"}, {"hash1", "u32"}, {"hash2", "u32"}, - {"hash3", "u32"}, {"hash4", "u32"}, - {"version", "u32"}, {"total_size", "u32"}, {"chunk_count", "u32"}, - } - parsed, err := Parse(hdr, fields, "little") - if err != nil { - return nil, err - } - totalSize := parsed["total_size"] - if totalSize <= 32 || offset+totalSize > len(data) { - return nil, fmt.Errorf("invalid DXBC size") - } - return &SignatureResult{ - Offset: offset, - Size: totalSize, - Description: fmt.Sprintf("%s, total size: %d bytes", dxbcDescription, totalSize), - Confidence: ConfidenceHigh, - }, nil -} - -// ============================================================================ -// DTB (Device Tree Blob) -// ============================================================================ - -const dtbDescription = "Device tree blob (DTB)" - -func dtbMagic() [][]byte { - return [][]byte{{0xD0, 0x0D, 0xFE, 0xED}} -} - -func dtbParser(data []byte, offset int) (*SignatureResult, error) { - if offset+40 > len(data) { - return nil, fmt.Errorf("insufficient data for DTB header") - } - hdr := data[offset:] - fields := []StructField{ - {"magic", "u32"}, {"total_size", "u32"}, {"struct_offset", "u32"}, - {"strings_offset", "u32"}, {"mem_reserve_offset", "u32"}, - {"version", "u32"}, {"last_comp_version", "u32"}, - {"cpu_id", "u32"}, {"strings_size", "u32"}, {"struct_size", "u32"}, - } - parsed, err := Parse(hdr, fields, "big") - if err != nil { - return nil, err - } - totalSize := parsed["total_size"] - structEnd := offset + parsed["struct_offset"] + parsed["struct_size"] - stringsEnd := offset + parsed["strings_offset"] + parsed["strings_size"] - if structEnd > len(data) || stringsEnd > len(data) { - return nil, fmt.Errorf("DTB offsets exceed data") - } - if totalSize <= 0 || offset+totalSize > len(data) { - return nil, fmt.Errorf("invalid DTB total size") - } - return &SignatureResult{ - Offset: offset, - Size: totalSize, - Description: fmt.Sprintf("%s, version: %d, CPU ID: %d, total size: %d bytes", dtbDescription, parsed["version"], parsed["cpu_id"], totalSize), - Confidence: ConfidenceMedium, - }, nil -} - -// ============================================================================ -// Matter OTA firmware -// ============================================================================ - -const matterOTADescription = "Matter OTA firmware" - -func matterOTAMagic() [][]byte { - return [][]byte{{0x1e, 0xf1, 0xee, 0x1b}} -} - -func matterOTAParser(data []byte, offset int) (*SignatureResult, error) { - if offset+32 > len(data) { - return nil, fmt.Errorf("insufficient data for Matter OTA header") - } - hdr := data[offset:] - fields := []StructField{ - {"magic", "u32"}, {"total_size", "u32"}, {"header_size", "u32"}, - {"header_tlv_size", "u32"}, {"vendor_id", "u16"}, {"product_id", "u16"}, - {"version", "u32"}, {"min_version", "u32"}, - {"max_version", "u32"}, {"payload_size", "u32"}, - } - parsed, err := Parse(hdr, fields, "little") - if err != nil { - return nil, err - } - headerSize := parsed["header_size"] - return &SignatureResult{ - Offset: offset, - Size: headerSize, - Description: fmt.Sprintf("%s, total size: %d bytes, header size: %d bytes, vendor id: 0x%x, product id: 0x%x, version: %d, payload size: %d bytes", matterOTADescription, parsed["total_size"], headerSize, parsed["vendor_id"], parsed["product_id"], parsed["version"], parsed["payload_size"]), - Confidence: ConfidenceHigh, - }, nil -} - -// ============================================================================ -// RTK firmware header -// ============================================================================ - -const rtkDescription = "RTK firmware header" - -func rtkMagic() [][]byte { - return [][]byte{[]byte("RTK0")} -} - -func rtkParser(data []byte, offset int) (*SignatureResult, error) { - if offset+16 > len(data) { - return nil, fmt.Errorf("insufficient data for RTK header") - } - hdr := data[offset:] - fields := []StructField{ - {"magic", "u32"}, {"header_size", "u32"}, {"image_size", "u32"}, - } - parsed, err := Parse(hdr, fields, "little") - if err != nil { - return nil, err - } - availableData := len(data) - offset - if parsed["image_size"] != availableData { - return nil, fmt.Errorf("RTK image size mismatch") - } - return &SignatureResult{ - Offset: offset, - Size: parsed["header_size"], - Description: fmt.Sprintf("%s, header size: %d bytes, image size: %d", rtkDescription, parsed["header_size"], parsed["image_size"]), - Confidence: ConfidenceMedium, - }, nil -} - -// ============================================================================ -// DPAPI blob data -// ============================================================================ - -const dpapiDescription = "DPAPI blob data" - -func dpapiMagic() [][]byte { - return [][]byte{ - {0x01, 0x00, 0x00, 0x00, 0xD0, 0x8c, 0x9d, 0xdf, 0x01, 0x15, 0xd1, 0x11, 0x8c, 0x7a, 0x00, 0xc0, 0x4f, 0xc2, 0x97, 0xeb}, - } -} - -func dpapiParser(data []byte, offset int) (*SignatureResult, error) { - if offset+44 > len(data) { - return nil, fmt.Errorf("insufficient data for DPAPI blob header") - } - hdr := data[offset:] - fields := []StructField{ - {"version", "u32"}, {"provider_guid1", "u32"}, {"provider_guid2", "u32"}, - {"provider_guid3", "u32"}, {"provider_guid4", "u32"}, - {"mk_version", "u32"}, {"mk_guid1", "u32"}, {"mk_guid2", "u32"}, - {"mk_guid3", "u32"}, {"mk_guid4", "u32"}, {"flags", "u32"}, - } - parsed, err := Parse(hdr, fields, "little") - if err != nil { - return nil, err - } - return &SignatureResult{ - Offset: offset, - Description: fmt.Sprintf("%s, version: %d, flags: %d", dpapiDescription, parsed["version"], parsed["flags"]), - Confidence: ConfidenceMedium, - }, nil -} - -// ============================================================================ -// QEMU QCOW Image -// ============================================================================ - -const qcowDescription = "QEMU QCOW Image" - -func qcowMagic() [][]byte { - return [][]byte{[]byte("QFI\xFB")} -} - -func qcowParser(data []byte, offset int) (*SignatureResult, error) { - if offset+24 > len(data) { - return nil, fmt.Errorf("insufficient data for QCOW header") - } - hdr := data[offset:] - fields := []StructField{ - {"magic", "u32"}, {"version", "u32"}, {"backing_file_offset", "u64"}, - {"backing_file_size", "u32"}, {"cluster_bits", "u32"}, - {"disk_size", "u64"}, {"encryption_method", "u32"}, - } - parsed, err := Parse(hdr, fields, "big") - if err != nil { - return nil, err - } - encMap := map[int]string{0: "none", 1: "AES", 2: "LUKS"} - enc := lookupMap(encMap, parsed["encryption_method"], "unknown") - return &SignatureResult{ - Offset: offset, - Description: fmt.Sprintf("%s, version: %d, storage media size: 0x%x bytes, cluster block size: 0x%x bytes, encryption method: %s", qcowDescription, parsed["version"], parsed["disk_size"], 1<= len(data) { - return nil, fmt.Errorf("PNG: not enough data for header") - } - - // Walk chunks starting after the 8-byte PNG header - chunkData := data[offset+pngHeaderLen:] - available := len(chunkData) - chunkOffset := 0 - var previousOffset *int - - for IsOffsetSafe(available, chunkOffset, previousOffset) { - if chunkOffset+chunkHeaderSize > available { - break - } - hdr, err := Parse(chunkData[chunkOffset:], chunkFields, "big") - if err != nil { - break - } - isLast := hdr["type"] == iendChunkType - totalChunkSize := chunkHeaderSize + hdr["length"] + crcSize - prev := chunkOffset - previousOffset = &prev - chunkOffset += totalChunkSize - if isLast { - pngSize := pngHeaderLen + chunkOffset - return &SignatureResult{ - Offset: offset, - Description: fmt.Sprintf("%s, total size: %d bytes", PNGDescription, pngSize), - Confidence: ConfidenceHigh, - Size: pngSize, - }, nil - } - } - - return nil, fmt.Errorf("PNG: failed to walk chunks to IEND") -} - -// --------------------------------------------------------------------------- -// JPEG -// --------------------------------------------------------------------------- - -const JPEGDescription = "JPEG image" - -func JPEGMagic() [][]byte { - return [][]byte{ - {0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00}, // JFIF - {0xFF, 0xD8, 0xFF, 0xE1}, // EXIF - {0xFF, 0xD8, 0xFF, 0xDB}, // DQT - } -} - -func JPEGParser(data []byte, offset int) (*SignatureResult, error) { - const markerMagic byte = 0xFF - const sosMarker byte = 0xDA - const eofMarker byte = 0xD9 - - noLengthMarkers := []byte{ - 0x00, 0x01, 0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, eofMarker, - } - sosSkipMarkers := []byte{ - 0x00, 0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, - } - - jpegData := data[offset:] - nextMarkerOffset := 0 - - for { - if nextMarkerOffset >= len(jpegData) { - break - } - magic := jpegData[nextMarkerOffset] - if magic != markerMagic { - break - } - nextMarkerOffset++ - - if nextMarkerOffset >= len(jpegData) { - break - } - markerID := jpegData[nextMarkerOffset] - nextMarkerOffset++ - - // If this marker has a length field - if !byteInSlice(markerID, noLengthMarkers) { - if nextMarkerOffset+2 > len(jpegData) { - break - } - segLen := int(jpegData[nextMarkerOffset])<<8 | int(jpegData[nextMarkerOffset+1]) - nextMarkerOffset += segLen - } - - // SOS marker: scan until next valid marker - if markerID == sosMarker { - for { - if nextMarkerOffset+2 > len(jpegData) { - break - } - if jpegData[nextMarkerOffset] == markerMagic && !byteInSlice(jpegData[nextMarkerOffset+1], sosSkipMarkers) { - break - } - nextMarkerOffset++ - } - } - - // EOF marker - if markerID == eofMarker { - jpegSize := nextMarkerOffset - return &SignatureResult{ - Offset: offset, - Description: fmt.Sprintf("%s, total size: %d bytes", JPEGDescription, jpegSize), - Confidence: ConfidenceMedium, - Size: jpegSize, - }, nil - } - } - - return nil, fmt.Errorf("JPEG: failed to find EOI marker") -} - -// --------------------------------------------------------------------------- -// BMP -// --------------------------------------------------------------------------- - -const BMPDescription = "BMP image (Bitmap)" - -func BMPMagic() [][]byte { - return [][]byte{ - {0x42, 0x4D}, // "BM" - } -} - -func BMPParser(data []byte, offset int) (*SignatureResult, error) { - const bmpFileHeaderSize = 14 - - bmpHeaderFields := []StructField{ - {"bfType", "u16"}, - {"bfSize", "u32"}, - {"bfReserved1", "u16"}, - {"bfReserved2", "u16"}, - {"bfOffBits", "u32"}, - } - - validDIBHeaderSizes := []int{12, 40, 108, 124} - - bmpData := data[offset:] - if len(bmpData) < StructSize(bmpHeaderFields) { - return nil, fmt.Errorf("BMP: not enough data for header") - } - - hdr, err := Parse(bmpData, bmpHeaderFields, "little") - if err != nil { - return nil, fmt.Errorf("BMP: parse error: %w", err) - } - - bmpDataSize := len(bmpData) - - // The BMP file size cannot be bigger than available data - if bmpDataSize < hdr["bfSize"] { - return nil, fmt.Errorf("BMP: reported size %d exceeds available data %d", hdr["bfSize"], bmpDataSize) - } - if hdr["bfSize"] == 0 { - return nil, fmt.Errorf("BMP: file size is 0") - } - if hdr["bfOffBits"] == 0 { - return nil, fmt.Errorf("BMP: bitmap bits offset is 0") - } - if hdr["bfOffBits"] > bmpDataSize { - return nil, fmt.Errorf("BMP: bitmap bits offset exceeds available data") - } - - // Validate DIB header size - if offset+bmpFileHeaderSize+4 > len(data) { - return nil, fmt.Errorf("BMP: not enough data for DIB header size") - } - dibHeaderSize := int(LE32(data, offset+bmpFileHeaderSize)) - validDIB := false - for _, vs := range validDIBHeaderSizes { - if dibHeaderSize == vs { - validDIB = true - break - } - } - if !validDIB { - return nil, fmt.Errorf("BMP: invalid DIB header size %d", dibHeaderSize) - } - - // The offset that points to the image data cannot point into the second header - if hdr["bfOffBits"] < bmpFileHeaderSize+dibHeaderSize { - return nil, fmt.Errorf("BMP: bitmap bits offset points into header") - } - - totalSize := hdr["bfSize"] - return &SignatureResult{ - Offset: offset, - Name: "bmp", - Description: fmt.Sprintf("BMP image, total size: %d", totalSize), - Confidence: ConfidenceMedium, - Size: totalSize, - }, nil -} - -// --------------------------------------------------------------------------- -// GIF -// --------------------------------------------------------------------------- - -const GIFDescription = "GIF image" - -func GIFMagic() [][]byte { - return [][]byte{ - {0x47, 0x49, 0x46, 0x38, 0x37, 0x61}, // GIF87a - {0x47, 0x49, 0x46, 0x38, 0x39, 0x61}, // GIF89a - } -} - -func GIFParser(data []byte, offset int) (*SignatureResult, error) { - gifHeaderFields := []StructField{ - {"magic_p1", "u24"}, - {"magic_p2", "u24"}, - {"image_width", "u16"}, - {"image_height", "u16"}, - {"flags", "u8"}, - {"bg_color_index", "u8"}, - {"aspect_ratio", "u8"}, - } - - gifData := data[offset:] - if len(gifData) < StructSize(gifHeaderFields) { - return nil, fmt.Errorf("GIF: not enough data for header") - } - - hdr, err := Parse(gifData, gifHeaderFields, "little") - if err != nil { - return nil, fmt.Errorf("GIF: parse error: %w", err) - } - - // Calculate header size including optional global color table - headerSize := StructSize(gifHeaderFields) + gifColorTableSize(hdr["flags"]) - - if offset+headerSize > len(data) { - return nil, fmt.Errorf("GIF: not enough data for header + color table") - } - - // Walk GIF blocks to find terminator - blockData := data[offset+headerSize:] - gifDataSize, err := getGIFDataSize(blockData) - if err != nil { - return nil, fmt.Errorf("GIF: %w", err) - } - - totalSize := headerSize + gifDataSize - return &SignatureResult{ - Offset: offset, - Description: fmt.Sprintf("%s, %dx%d pixels, total size: %d bytes", GIFDescription, hdr["image_width"], hdr["image_height"], totalSize), - Confidence: ConfidenceHigh, - Size: totalSize, - }, nil -} - -func gifColorTableSize(flags int) int { - const hasColorTable = 0x80 - const colorTableSizeMask = 0x07 - - if flags&hasColorTable == 0 { - return 0 - } - encodedSize := (flags & colorTableSizeMask) + 1 - size := 3 - for i := 0; i < encodedSize; i++ { - size *= 2 - } - return size -} - -func getGIFDataSize(gifData []byte) (int, error) { - const extensionBlock byte = 0x21 - const terminatorBlock byte = 0x3B - const imageDescriptorBlock byte = 0x2C - - nextOffset := 0 - var previousOffset *int - available := len(gifData) - - for IsOffsetSafe(available, nextOffset, previousOffset) { - if nextOffset >= available { - break - } - blockType := gifData[nextOffset] - - var blockSize int - var err error - - switch blockType { - case imageDescriptorBlock: - blockSize, err = parseGIFImageDescriptor(gifData[nextOffset:]) - case extensionBlock: - blockSize, err = parseGIFExtension(gifData[nextOffset:]) - case terminatorBlock: - return nextOffset + 1, nil - default: - return 0, fmt.Errorf("unknown GIF block type 0x%02X at offset %d", blockType, nextOffset) - } - - if err != nil { - return 0, err - } - prev := nextOffset - previousOffset = &prev - nextOffset += blockSize - } - - return 0, fmt.Errorf("failed to find GIF terminator block") -} - -func parseGIFImageDescriptor(gifData []byte) (int, error) { - const lzwCodeSize = 1 - - imgDescFields := []StructField{ - {"magic", "u8"}, - {"image_left", "u16"}, - {"image_top", "u16"}, - {"image_width", "u16"}, - {"image_height", "u16"}, - {"flags", "u8"}, - } - - if len(gifData) < StructSize(imgDescFields) { - return 0, fmt.Errorf("GIF image descriptor: not enough data") - } - - hdr, err := Parse(gifData, imgDescFields, "little") - if err != nil { - return 0, err - } - - totalSize := StructSize(imgDescFields) + gifColorTableSize(hdr["flags"]) + lzwCodeSize - - if totalSize > len(gifData) { - return 0, fmt.Errorf("GIF image descriptor: not enough data for sub-blocks") - } - - subBlocksSize, err := parseGIFSubBlocks(gifData[totalSize:]) - if err != nil { - return 0, err - } - totalSize += subBlocksSize - return totalSize, nil -} - -func parseGIFExtension(extensionData []byte) (int, error) { - const plainText = 1 - const application = 0xFF - const headerSize = 2 - - extFields := []StructField{ - {"magic", "u8"}, - {"extension_type", "u8"}, - {"sub_block_offset", "u8"}, - } - - if len(extensionData) < StructSize(extFields) { - return 0, fmt.Errorf("GIF extension: not enough data") - } - - hdr, err := Parse(extensionData, extFields, "little") - if err != nil { - return 0, err - } - - subBlocksOffset := headerSize - extType := hdr["extension_type"] - if extType == application || extType == plainText { - subBlocksOffset += hdr["sub_block_offset"] + 1 - } - - if subBlocksOffset > len(extensionData) { - return 0, fmt.Errorf("GIF extension: sub-blocks offset exceeds data") - } - - subBlocksSize, err := parseGIFSubBlocks(extensionData[subBlocksOffset:]) - if err != nil { - return 0, err - } - return subBlocksOffset + subBlocksSize, nil -} - -func parseGIFSubBlocks(subBlockData []byte) (int, error) { - const subBlockTerminator byte = 0 - - available := len(subBlockData) - nextOffset := 0 - var previousOffset *int - - for IsOffsetSafe(available, nextOffset, previousOffset) { - if nextOffset >= available { - break - } - subBlockSize := subBlockData[nextOffset] - if subBlockSize == subBlockTerminator { - return nextOffset + 1, nil - } - prev := nextOffset - previousOffset = &prev - nextOffset += int(subBlockSize) + 1 - } - - return 0, fmt.Errorf("GIF sub-blocks: terminator not found") -} - -// --------------------------------------------------------------------------- -// SVG -// --------------------------------------------------------------------------- - -const SVGDescription = "SVG image" - -const svgOpenTag = "' and declare the SVG xmlns (which the loop below - // requires anyway via headTagCount). Verifying this up front, with a cap - // on how far we look for the closing '>', keeps the parser O(1) per call - // regardless of file size — so a buffer densely packed with " maxRootTagLen { - rootLimit = maxRootTagLen - } - rootEnd := bytes.IndexByte(svgData[:rootLimit], '>') - if rootEnd < 0 { - return nil, fmt.Errorf("SVG: root tag not terminated") - } - if !bytes.Contains(svgData[:rootEnd+1], headMagic) { - return nil, fmt.Errorf("SVG: root tag missing SVG namespace") - } - - headTagCount := 0 - unclosedSVGTags := 0 - - // Search for all occurrences - searchIdx := 0 - for searchIdx < len(svgData) { - // Find next occurrence of either tag - openIdx := bytes.Index(svgData[searchIdx:], openTag) - closeIdx := bytes.Index(svgData[searchIdx:], closeTag) - - var tagStart int - if openIdx == -1 && closeIdx == -1 { - break - } else if openIdx == -1 { - tagStart = searchIdx + closeIdx - } else if closeIdx == -1 { - tagStart = searchIdx + openIdx - } else if openIdx <= closeIdx { - tagStart = searchIdx + openIdx - } else { - tagStart = searchIdx + closeIdx - } - - tagBytes := svgData[tagStart:] - - // Find the end '>' of this tag - endIdx := bytes.IndexByte(tagBytes, '>') - if endIdx == -1 { - break - } - fullTag := tagBytes[:endIdx+1] - - isOpen := bytes.HasPrefix(fullTag, openTag) - isClose := bytes.HasPrefix(fullTag, closeTag) - isHead := bytes.Contains(fullTag, headMagic) - - if isHead { - headTagCount++ - } - if isOpen { - unclosedSVGTags++ - } - if isClose { - unclosedSVGTags-- - } - - // More than one head tag is invalid - if headTagCount > 1 { - break - } - - // If one head tag and all tags closed, that's EOF - if headTagCount == 1 && unclosedSVGTags == 0 { - totalSize := tagStart + len(svgCloseTag) - return &SignatureResult{ - Offset: offset, - Description: fmt.Sprintf("%s, total size: %d bytes", SVGDescription, totalSize), - Confidence: ConfidenceMedium, - Size: totalSize, - }, nil - } - - searchIdx = tagStart + 1 - } - - return nil, fmt.Errorf("SVG: failed to find matching closing tag") -} - -// --------------------------------------------------------------------------- -// RIFF -// --------------------------------------------------------------------------- - -const RIFFDescription = "RIFF image" - -func RIFFMagic() [][]byte { - return [][]byte{ - {0x52, 0x49, 0x46, 0x46}, // "RIFF" - } -} - -func RIFFParser(data []byte, offset int) (*SignatureResult, error) { - const riffMagicValue = 0x46464952 // "RIFF" in LE - const chunkTypeStart = 8 - const chunkTypeEnd = 12 - const fileSizeOffset = 8 - - riffFields := []StructField{ - {"magic", "u32"}, - {"file_size", "u32"}, - {"chunk_type", "u32"}, - } - - riffData := data[offset:] - if len(riffData) < StructSize(riffFields) { - return nil, fmt.Errorf("RIFF: not enough data for header") - } - - hdr, err := Parse(riffData, riffFields, "little") - if err != nil { - return nil, fmt.Errorf("RIFF: parse error: %w", err) - } - - if hdr["magic"] != riffMagicValue { - return nil, fmt.Errorf("RIFF: invalid magic") - } - - if chunkTypeEnd > len(riffData) { - return nil, fmt.Errorf("RIFF: not enough data for chunk type string") - } - - // Get the RIFF type string (e.g., "WAVE", "AVI ") - typeBytes := riffData[chunkTypeStart:chunkTypeEnd] - typeString := strings.TrimSpace(string(typeBytes)) - - // Validate that the type string is valid UTF-8 / printable - for _, b := range typeBytes { - if b < 0x20 || b > 0x7E { - return nil, fmt.Errorf("RIFF: non-printable chunk type") - } - } - - totalSize := hdr["file_size"] + fileSizeOffset - - return &SignatureResult{ - Offset: offset, - Description: fmt.Sprintf("%s, encoding type: %s, total size: %d bytes", RIFFDescription, typeString, totalSize), - Confidence: ConfidenceMedium, - Size: totalSize, - }, nil -} - -// --------------------------------------------------------------------------- -// PDF -// --------------------------------------------------------------------------- - -const PDFDescription = "PDF document" - -func PDFMagic() [][]byte { - return [][]byte{ - {0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E}, // "%PDF-1." - } -} - -func PDFParser(data []byte, offset int) (*SignatureResult, error) { - const minPDFSize = 16 - const newlineOffset = 8 - const minorNumberOffset = 7 - - const asciiZero byte = 0x30 - const asciiNine byte = 0x39 - const asciiNewline byte = 0x0A - const asciiPercent byte = 0x25 - const asciiCarriageReturn byte = 0x0D - - if offset+minPDFSize > len(data) { - return nil, fmt.Errorf("PDF: not enough data for header") - } - - pdfHeader := data[offset : offset+minPDFSize] - - // Get the minor version number - versionMinor := pdfHeader[minorNumberOffset] - if versionMinor < asciiZero || versionMinor > asciiNine { - return nil, fmt.Errorf("PDF: invalid minor version number") - } - - // After the version line, expect newline(s) followed by '%' - for _, b := range pdfHeader[newlineOffset:] { - if b == asciiNewline || b == asciiCarriageReturn { - continue - } else if b == asciiPercent { - return &SignatureResult{ - Offset: offset, - Description: fmt.Sprintf("%s, version 1.%d", PDFDescription, versionMinor-asciiZero), - Confidence: ConfidenceLow, - Size: 0, - }, nil - } else { - break - } - } - - return nil, fmt.Errorf("PDF: invalid header format") -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -func byteInSlice(b byte, s []byte) bool { - return bytes.ContainsRune(s, rune(b)) -} diff --git a/internal/leviathan/binsnoop/sig_misc.go b/internal/leviathan/binsnoop/sig_misc.go deleted file mode 100644 index b353db1..0000000 --- a/internal/leviathan/binsnoop/sig_misc.go +++ /dev/null @@ -1,1399 +0,0 @@ -package binsnoop - -import ( - "bytes" - "fmt" - "strings" -) - -// ---- Copyright ---- - -const DescriptionCopyright = "Copyright text" - -// copyrightMagic returns magic bytes for copyright strings. -func copyrightMagic() [][]byte { - return [][]byte{ - []byte("copyright"), - []byte("Copyright"), - []byte("COPYRIGHT"), - } -} - -// copyrightParser validates copyright text signatures. -func copyrightParser(data []byte, offset int) (*SignatureResult, error) { - const magicSize = 9 - - result := &SignatureResult{ - Offset: offset, - Description: DescriptionCopyright, - Confidence: ConfidenceHigh, - } - - copyrightStr := GetCString(data[offset:]) - if len(copyrightStr) <= magicSize { - return nil, fmt.Errorf("copyright string too short") - } - - result.Size = len(copyrightStr) - truncated := copyrightStr - if len(truncated) > 100 { - truncated = truncated[:100] - } - result.Description = fmt.Sprintf("%s: \"%s\"", result.Description, truncated) - return result, nil -} - -// ---- VxWorks ---- - -const ( - DescriptionVxWorksSymTab = "VxWorks symbol table" - DescriptionWINDKernelVersion = "VxWorks WIND kernel version" -) - -// windKernelMagic returns magic bytes for VxWorks WIND kernel version. -func windKernelMagic() [][]byte { - return [][]byte{ - []byte("WIND version "), - } -} - -// vxworksSymtabMagic returns magic bytes for VxWorks symbol tables. -func vxworksSymtabMagic() [][]byte { - return [][]byte{ - {0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00}, - {0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00}, - {0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00}, - {0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, - {0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, - {0x00, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, - } -} - -// windKernelParser validates VxWorks WIND kernel version signatures. -func windKernelParser(data []byte, offset int) (*SignatureResult, error) { - const magicSize = 13 - - result := &SignatureResult{ - Offset: offset, - Description: DescriptionWINDKernelVersion, - Confidence: ConfidenceLow, - } - - versionOffset := offset + magicSize - if versionOffset >= len(data) { - return nil, fmt.Errorf("WIND kernel version offset out of bounds") - } - - versionStr := GetCString(data[versionOffset:]) - if versionStr == "" { - return nil, fmt.Errorf("WIND kernel version string empty") - } - - result.Size = magicSize + len(versionStr) - result.Description = fmt.Sprintf("%s %s", result.Description, versionStr) - return result, nil -} - -// vxworksSymtabParser validates VxWorks symbol table signatures. -func vxworksSymtabParser(data []byte, offset int) (*SignatureResult, error) { - const symtabMagicOffset = 8 - - result := &SignatureResult{ - Description: DescriptionVxWorksSymTab, - Confidence: ConfidenceHigh, - } - - if offset < symtabMagicOffset { - return nil, fmt.Errorf("VxWorks symbol table: offset too small") - } - - symtabStart := offset - symtabMagicOffset - - // Determine endianness from the type field at offset 9 from symtab start - endianness := "little" - typeFieldOffset := symtabStart + 9 - if typeFieldOffset < len(data) && data[typeFieldOffset] == 0 { - endianness = "big" - } - - // Validate the first symbol table entry - symtabFields := []StructField{ - {"name_ptr", "u32"}, - {"value_ptr", "u32"}, - {"type", "u32"}, - {"group", "u32"}, - } - entrySize := StructSize(symtabFields) - - allowedTypes := map[int]string{ - 0x500: "function", - 0x700: "initialized data", - 0x900: "uninitialized data", - } - - if symtabStart+entrySize > len(data) { - return nil, fmt.Errorf("VxWorks symbol table: insufficient data") - } - - entry, err := Parse(data[symtabStart:], symtabFields, endianness) - if err != nil { - return nil, fmt.Errorf("VxWorks symbol table: parse failed") - } - - if _, ok := allowedTypes[entry["type"]]; !ok { - return nil, fmt.Errorf("VxWorks symbol table: invalid type %#x", entry["type"]) - } - if entry["name_ptr"] == 0 || entry["value_ptr"] == 0 { - return nil, fmt.Errorf("VxWorks symbol table: null pointers") - } - - result.Offset = symtabStart - result.Size = entrySize - result.Description = fmt.Sprintf("%s, total size: %d bytes", result.Description, result.Size) - return result, nil -} - -// ---- eCos ---- - -const DescriptionECosExceptionHandler = "eCos kernel exception handler" - -// ecosExceptionHandlerMagic returns magic bytes for eCos exception handlers (MIPS). -func ecosExceptionHandlerMagic() [][]byte { - return [][]byte{ - {0x00, 0x68, 0x1A, 0x40, 0x00, 0x00, 0x00, 0x00, 0x7F, 0x00, 0x5A, 0x33}, - {0x00, 0x68, 0x1A, 0x40, 0x7F, 0x00, 0x5A, 0x33}, - {0x40, 0x1A, 0x68, 0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x5A, 0x00, 0x7F}, - {0x40, 0x1A, 0x68, 0x00, 0x33, 0x5A, 0x00, 0x7F}, - } -} - -// ecosExceptionHandlerParser validates eCos exception handler signatures. -func ecosExceptionHandlerParser(data []byte, offset int) (*SignatureResult, error) { - result := &SignatureResult{ - Offset: offset, - Description: DescriptionECosExceptionHandler, - Confidence: ConfidenceLow, - } - - endianness := "big" - if offset < len(data) && data[offset] == 0 { - endianness = "little" - } - - result.Description = fmt.Sprintf("%s, MIPS %s endian", result.Description, endianness) - return result, nil -} - -// ---- Linux ---- - -const ( - DescriptionLinuxARMzImage = "Linux ARM boot executable zImage" - DescriptionLinuxBootImage = "Linux kernel boot image" - DescriptionLinuxKernelVersion = "Linux kernel version" - DescriptionLinuxARM64BootImage = "Linux kernel ARM64 boot image" -) - -// linuxBootImageMagic returns magic bytes for Linux boot images. -func linuxBootImageMagic() [][]byte { - return [][]byte{ - {0xb8, 0xc0, 0x07, 0x8e, 0xd8, 0xb8, 0x00, 0x90, 0x8e, 0xc0, 0xb9, 0x00, 0x01, 0x29, 0xf6, 0x29}, - } -} - -// linuxKernelVersionMagic returns magic bytes for Linux kernel version strings. -func linuxKernelVersionMagic() [][]byte { - return [][]byte{ - []byte("Linux version "), - } -} - -// linuxARM64BootImageMagic returns magic bytes for Linux ARM64 boot images. -func linuxARM64BootImageMagic() [][]byte { - return [][]byte{ - {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x41, 0x52, 0x4D, 0x64}, // "\x00\x00\x00\x00\x00\x00\x00\x00ARMd" - } -} - -// linuxARMzImageMagic returns magic bytes for Linux ARM zImage. -func linuxARMzImageMagic() [][]byte { - return [][]byte{ - {0x18, 0x28, 0x6F, 0x01}, - {0x01, 0x6F, 0x28, 0x18}, - } -} - -// linuxARMzImageParser validates Linux ARM zImage signatures. -func linuxARMzImageParser(data []byte, offset int) (*SignatureResult, error) { - const magicOffset = 36 - const nopLE = 0xE1A00000 - const nopBE = 0x0000A0E1 - - result := &SignatureResult{ - Confidence: ConfidenceMedium, - Description: DescriptionLinuxARMzImage, - } - - if offset < magicOffset { - return nil, fmt.Errorf("ARM zImage: offset too small") - } - - result.Offset = offset - magicOffset - zimageData := data[result.Offset:] - - // Parse 8 NOP instructions at the start - nopFields := []StructField{ - {"nop1", "u32"}, {"nop2", "u32"}, {"nop3", "u32"}, {"nop4", "u32"}, - {"nop5", "u32"}, {"nop6", "u32"}, {"nop7", "u32"}, {"nop8", "u32"}, - } - - if len(zimageData) < StructSize(nopFields) { - return nil, fmt.Errorf("ARM zImage: insufficient data") - } - - nops, err := Parse(zimageData, nopFields, "little") - if err != nil { - return nil, fmt.Errorf("ARM zImage: parse failed") - } - - nop1 := nops["nop1"] - if nop1 != nops["nop2"] || nop1 != nops["nop3"] || nop1 != nops["nop4"] || - nop1 != nops["nop5"] || nop1 != nops["nop6"] || nop1 != nops["nop7"] || nop1 != nops["nop8"] { - return nil, fmt.Errorf("ARM zImage: NOP instructions not uniform") - } - - var endianness string - if nop1 == nopLE { - endianness = "little" - } else if nop1 == nopBE { - endianness = "big" - } else { - return nil, fmt.Errorf("ARM zImage: unrecognized NOP value") - } - - result.Description = fmt.Sprintf("%s, %s endian", result.Description, endianness) - return result, nil -} - -// linuxARM64BootImageParser validates Linux ARM64 boot image signatures. -func linuxARM64BootImageParser(data []byte, offset int) (*SignatureResult, error) { - const magicOffset = 0x30 - const flagsEndianMask = 1 - const bigEndianFlag = 1 - - result := &SignatureResult{ - Confidence: ConfidenceMedium, - Description: DescriptionLinuxARM64BootImage, - } - - if offset < magicOffset { - return nil, fmt.Errorf("ARM64 boot image: offset too small") - } - - result.Offset = offset - magicOffset - imgData := data[result.Offset:] - - bootImgFields := []StructField{ - {"code0", "u32"}, - {"code1", "u32"}, - {"image_load_offset", "u64"}, - {"image_size", "u64"}, - {"flags", "u64"}, - {"reserved1", "u64"}, - {"reserved2", "u64"}, - {"reserved3", "u64"}, - {"magic", "u32"}, - {"pe_offset", "u32"}, - } - - structSize := StructSize(bootImgFields) - if len(imgData) < structSize { - return nil, fmt.Errorf("ARM64 boot image: insufficient data") - } - - imgHeader, err := Parse(imgData, bootImgFields, "little") - if err != nil { - return nil, fmt.Errorf("ARM64 boot image: parse failed") - } - - if imgHeader["reserved1"] != 0 || imgHeader["reserved2"] != 0 || imgHeader["reserved3"] != 0 { - return nil, fmt.Errorf("ARM64 boot image: reserved fields not zero") - } - - peOffset := imgHeader["pe_offset"] - peEnd := peOffset + 2 - if peEnd > len(imgData) { - return nil, fmt.Errorf("ARM64 boot image: PE offset out of bounds") - } - if imgData[peOffset] != 'P' || imgData[peOffset+1] != 'E' { - return nil, fmt.Errorf("ARM64 boot image: PE signature not found") - } - - flags := imgHeader["flags"] - // Reserved bits are the upper 60 bits (mask 0xFFFFFFFFFFFFFFF0) - if (uint64(flags) & 0xFFFFFFFFFFFFFFF0) != 0 { - return nil, fmt.Errorf("ARM64 boot image: reserved flag bits set") - } - - endianness := "little" - if (flags & flagsEndianMask) == bigEndianFlag { - endianness = "big" - } - - imageSize := imgHeader["image_size"] - result.Size = structSize - result.Description = fmt.Sprintf("%s, %s endian, effective image size: %d bytes", - result.Description, endianness, imageSize) - return result, nil -} - -// linuxBootImageParser validates Linux boot image signatures. -func linuxBootImageParser(data []byte, offset int) (*SignatureResult, error) { - const hdrsOffset = 514 - const hdrsExpected = "!HdrS" - - result := &SignatureResult{ - Offset: offset, - Description: DescriptionLinuxBootImage, - } - - hdrsStart := offset + hdrsOffset - hdrsEnd := hdrsStart + len(hdrsExpected) - - if hdrsEnd > len(data) { - return nil, fmt.Errorf("Linux boot image: insufficient data for HdrS check") - } - - actual := string(data[hdrsStart:hdrsEnd]) - if actual != hdrsExpected { - return nil, fmt.Errorf("Linux boot image: HdrS signature not found") - } - - return result, nil -} - -// linuxKernelVersionParser validates Linux kernel version string signatures. -func linuxKernelVersionParser(data []byte, offset int) (*SignatureResult, error) { - const periodByte byte = 0x2E - const periodOffset1 = 15 - const periodOffset2 = 17 - const periodOffset3 = 18 - const minFileSize = 100 * 1024 - const minVersionStrLen = 75 - const gccVersionStr = "gcc " - - result := &SignatureResult{ - Offset: offset, - Confidence: ConfidenceLow, - } - - fileSize := len(data) - if fileSize <= minFileSize { - return nil, fmt.Errorf("Linux kernel version: file too small") - } - - kvString := GetCString(data[offset:]) - if len(kvString) <= minVersionStrLen { - return nil, fmt.Errorf("Linux kernel version: string too short") - } - - if !strings.Contains(kvString, gccVersionStr) { - return nil, fmt.Errorf("Linux kernel version: no gcc version string") - } - - if !strings.Contains(kvString, "@") { - return nil, fmt.Errorf("Linux kernel version: no ampersand") - } - - if !strings.HasSuffix(kvString, "\n") { - return nil, fmt.Errorf("Linux kernel version: does not end with newline") - } - - kvBytes := []byte(kvString) - if periodOffset1 >= len(kvBytes) || periodOffset3 >= len(kvBytes) { - return nil, fmt.Errorf("Linux kernel version: string too short for period checks") - } - - if kvBytes[periodOffset1] != periodByte || (kvBytes[periodOffset2] != periodByte && kvBytes[periodOffset3] != periodByte) { - return nil, fmt.Errorf("Linux kernel version: periods not at expected offsets") - } - - // Search for Linux kernel symbol table - symtabMagic := []byte("\x000\x001\x002\x003\x004\x005\x006\x007\x008\x009\x00") - symtabPresent := bytes.Count(data, symtabMagic) == 1 - - if symtabPresent { - result.Offset = 0 - result.Size = len(data) - } else { - result.Size = len(kvString) - result.ExtractionDeclined = true - } - - result.Description = fmt.Sprintf("%s, has symbol table: %t", - strings.TrimSpace(kvString), symtabPresent) - return result, nil -} - -// ---- EFI GPT ---- - -const DescriptionEFIGPT = "EFI Global Partition Table" - -// efigptMagic returns magic bytes for EFI GPT headers. -func efigptMagic() [][]byte { - return [][]byte{ - {0x55, 0xAA, 0x45, 0x46, 0x49, 0x20, 0x50, 0x41, 0x52, 0x54}, // "\x55\xAAEFI PART" - } -} - -func lbaToOffset(lba int) int { - return lba * 512 -} - -// efigptParser validates EFI GPT header signatures. -func efigptParser(data []byte, offset int) (*SignatureResult, error) { - const magicOffset = 0x01FE - const blockSize = 512 - const expectedRevision = 0x00010000 - - result := &SignatureResult{ - Description: DescriptionEFIGPT, - Confidence: ConfidenceHigh, - } - - availableData := len(data) - offset - if offset < magicOffset { - return nil, fmt.Errorf("EFI GPT: offset too small") - } - - result.Offset = offset - magicOffset - efiData := data[result.Offset:] - - if len(efiData) <= blockSize { - return nil, fmt.Errorf("EFI GPT: insufficient data for GPT structure") - } - - gptData := efiData[blockSize:] - - gptFields := []StructField{ - {"magic", "u64"}, - {"revision", "u32"}, - {"header_size", "u32"}, - {"header_crc", "u32"}, - {"reserved", "u32"}, - {"my_lba", "u64"}, - {"alternate_lba", "u64"}, - {"first_usable_lba", "u64"}, - {"last_usable_lba", "u64"}, - {"disk_guid_p1", "u64"}, - {"disk_guid_p2", "u64"}, - {"partition_entry_lba", "u64"}, - {"partition_entry_count", "u32"}, - {"partition_entry_size", "u32"}, - {"partition_entries_crc", "u32"}, - } - - if len(gptData) < StructSize(gptFields) { - return nil, fmt.Errorf("EFI GPT: insufficient GPT header data") - } - - gptHeader, err := Parse(gptData, gptFields, "little") - if err != nil { - return nil, fmt.Errorf("EFI GPT: parse failed") - } - - if gptHeader["reserved"] != 0 { - return nil, fmt.Errorf("EFI GPT: reserved field not zero") - } - if gptHeader["revision"] != expectedRevision { - return nil, fmt.Errorf("EFI GPT: unexpected revision") - } - - partEntriesStart := lbaToOffset(gptHeader["partition_entry_lba"]) - partEntriesEnd := partEntriesStart + (gptHeader["partition_entry_count"] * gptHeader["partition_entry_size"]) - - if partEntriesEnd > len(efiData) || partEntriesStart >= partEntriesEnd { - return nil, fmt.Errorf("EFI GPT: partition entries out of bounds") - } - - partEntriesData := efiData[partEntriesStart:partEntriesEnd] - - // Validate partition entries CRC - if CRC32(partEntriesData) != uint32(gptHeader["partition_entries_crc"]) { - return nil, fmt.Errorf("EFI GPT: partition entries CRC mismatch") - } - - // Parse partition entries to find total size - entryFields := []StructField{ - {"type_guid_p1", "u64"}, - {"type_guid_p2", "u64"}, - {"partition_guid_p1", "u64"}, - {"partition_guid_p2", "u64"}, - {"starting_lba", "u64"}, - {"ending_lba", "u64"}, - {"attributes", "u64"}, - } - - entrySize := gptHeader["partition_entry_size"] - totalSize := 0 - - for i := 0; i+entrySize <= len(partEntriesData); i += entrySize { - entry, err := Parse(partEntriesData[i:], entryFields, "little") - if err != nil { - continue - } - if entry["type_guid_p1"] == 0 && entry["type_guid_p2"] == 0 { - continue - } - startOff := lbaToOffset(entry["starting_lba"]) - endOff := lbaToOffset(entry["ending_lba"]) - if startOff < endOff && endOff > totalSize { - totalSize = endOff - } - } - - if totalSize == 0 { - return nil, fmt.Errorf("EFI GPT: no valid partitions found") - } - - if totalSize > availableData { - result.Size = availableData - } else { - result.Size = totalSize - } - result.Description = fmt.Sprintf("%s, total size: %d", result.Description, result.Size) - return result, nil -} - -// ---- MBR ---- - -const DescriptionMBR = "DOS Master Boot Record" -const MBRMagicOffset = 0x01FE - -// mbrMagic returns magic bytes for MBR signatures. -func mbrMagic() [][]byte { - return [][]byte{ - {0x55, 0xAA}, - } -} - -// mbrParser validates MBR header signatures. -func mbrParser(data []byte, offset int) (*SignatureResult, error) { - const blockSize = 512 - const minImageSize = blockSize * 2 - const partitionCount = 4 - const partitionTableOffset = 446 - - result := &SignatureResult{ - Description: DescriptionMBR, - Confidence: ConfidenceMedium, - } - - if offset != MBRMagicOffset { - return nil, fmt.Errorf("MBR: magic not at expected offset") - } - - result.Offset = offset - MBRMagicOffset - - partEntryFields := []StructField{ - {"status", "u8"}, - {"chs_start", "u24"}, - {"os_type", "u8"}, - {"chs_end", "u24"}, - {"lba_start", "u32"}, - {"lba_size", "u32"}, - } - - knownOSTypes := map[int]string{ - 0x07: "NTFS_IFS_HPFS_exFAT", - 0x0B: "FAT32", - 0x0C: "FAT32", - 0x43: "Linux", - 0x4D: "QNX Primary Volume", - 0x4E: "QNX Secondary Volume", - 0x81: "Minix", - 0x83: "Linux", - 0x8E: "Linux LVM", - 0x96: "ISO-9660", - 0xB1: "QNXv6 File System", - 0xB2: "QNXv6 File System", - 0xB3: "QNXv6 File System", - 0xEE: "EFI GPT Protective", - 0xEF: "EFI System Partition", - } - - allowedStatus := map[int]bool{0: true, 0x80: true} - entrySize := StructSize(partEntryFields) - - partTableStart := partitionTableOffset - partTableEnd := partTableStart + (entrySize * partitionCount) - - if partTableEnd > len(data) { - return nil, fmt.Errorf("MBR: insufficient data for partition table") - } - - partTable := data[partTableStart:partTableEnd] - - type mbrPartition struct { - start int - size int - name string - } - - var partitions []mbrPartition - imageSize := 0 - - for i := 0; i < partitionCount; i++ { - entryStart := i * entrySize - entry, err := Parse(partTable[entryStart:], partEntryFields, "little") - if err != nil { - return nil, fmt.Errorf("MBR: partition entry parse failed") - } - - if entry["os_type"] == 0 && entry["lba_size"] == 0 { - continue - } - - if !allowedStatus[entry["status"]] { - continue - } - - partName := "Unknown" - if name, ok := knownOSTypes[entry["os_type"]]; ok { - partName = name - } - - partStart := entry["lba_start"] * blockSize - partSize := entry["lba_size"] * blockSize - partEnd := partStart + partSize - - if partEnd > len(data) { - continue - } - - if partStart != result.Offset { - partitions = append(partitions, mbrPartition{ - start: partStart, - size: partSize, - name: partName, - }) - } - - if partEnd > imageSize { - imageSize = partEnd - } - } - - if len(partitions) == 0 { - return nil, fmt.Errorf("MBR: no valid partitions") - } - - if imageSize <= minImageSize { - return nil, fmt.Errorf("MBR: image size too small") - } - - result.Size = imageSize - for _, p := range partitions { - if p.start == result.Offset { - result.ExtractionDeclined = true - } - result.Description = fmt.Sprintf("%s, partition: %s", result.Description, p.name) - } - result.Description = fmt.Sprintf("%s, image size: %d bytes", result.Description, result.Size) - return result, nil -} - -// ---- UEFI ---- - -const ( - DescriptionUEFIVolume = "UEFI PI firmware volume" - DescriptionUEFICapsule = "UEFI capsule image" -) - -// uefiVolumeMagic returns magic bytes for UEFI PI firmware volumes. -func uefiVolumeMagic() [][]byte { - return [][]byte{ - []byte("_FVH"), - } -} - -// uefiCapsuleMagic returns magic bytes for UEFI capsule images. -func uefiCapsuleMagic() [][]byte { - return [][]byte{ - // EFI capsule GUID - {0xBD, 0x86, 0x66, 0x3B, 0x76, 0x0D, 0x30, 0x40, 0xB7, 0x0E, 0xB5, 0x51, 0x9E, 0x2F, 0xC5, 0xA0}, - // EFI2 capsule GUID - {0x8B, 0xA6, 0x3C, 0x4A, 0x23, 0x77, 0xFB, 0x48, 0x80, 0x3D, 0x57, 0x8C, 0xC1, 0xFE, 0xC4, 0x4D}, - // UEFI capsule GUID - {0xB9, 0x82, 0x91, 0x53, 0xB5, 0xAB, 0x91, 0x43, 0xB6, 0x9A, 0xE3, 0xA9, 0x43, 0xF7, 0x2F, 0xCC}, - } -} - -// uefiVolumeParser validates UEFI PI firmware volume signatures. -func uefiVolumeParser(data []byte, offset int) (*SignatureResult, error) { - const uefiMagicOffset = 40 - - result := &SignatureResult{ - Description: DescriptionUEFIVolume, - Confidence: ConfidenceMedium, - } - - if offset < uefiMagicOffset { - return nil, fmt.Errorf("UEFI volume: offset too small") - } - - result.Offset = offset - uefiMagicOffset - uefiData := data[result.Offset:] - - validRevisions := map[int]bool{1: true, 2: true} - - volFields := []StructField{ - {"volume_size", "u64"}, - {"magic", "u32"}, - {"attributes", "u32"}, - {"header_size", "u16"}, - {"header_crc", "u16"}, - {"extended_header_offset", "u16"}, - {"reserved", "u8"}, - {"revision", "u8"}, - } - - if len(uefiData) < StructSize(volFields) { - return nil, fmt.Errorf("UEFI volume: insufficient data") - } - - volHeader, err := Parse(uefiData, volFields, "little") - if err != nil { - return nil, fmt.Errorf("UEFI volume: parse failed") - } - - if volHeader["header_size"] >= volHeader["volume_size"] { - return nil, fmt.Errorf("UEFI volume: header size >= volume size") - } - if volHeader["reserved"] != 0 { - return nil, fmt.Errorf("UEFI volume: reserved field not zero") - } - if !validRevisions[volHeader["revision"]] { - return nil, fmt.Errorf("UEFI volume: invalid revision") - } - - volumeSize := volHeader["volume_size"] - if len(data) < result.Offset+volumeSize { - return nil, fmt.Errorf("UEFI volume: volume size exceeds data") - } - - result.Size = volumeSize - result.Description = fmt.Sprintf("%s, header CRC: %#X, header size: %d bytes, total size: %d bytes", - result.Description, uint32(volHeader["header_crc"]), volHeader["header_size"], volumeSize) - return result, nil -} - -// uefiCapsuleParser validates UEFI capsule image signatures. -func uefiCapsuleParser(data []byte, offset int) (*SignatureResult, error) { - result := &SignatureResult{ - Offset: offset, - Description: DescriptionUEFICapsule, - Confidence: ConfidenceMedium, - } - - capsuleFields := []StructField{ - {"guid_p1", "u64"}, - {"guid_p2", "u64"}, - {"header_size", "u32"}, - {"flags", "u32"}, - {"total_size", "u32"}, - } - - if offset+StructSize(capsuleFields) > len(data) { - return nil, fmt.Errorf("UEFI capsule: insufficient data") - } - - capsuleHeader, err := Parse(data[offset:], capsuleFields, "little") - if err != nil { - return nil, fmt.Errorf("UEFI capsule: parse failed") - } - - headerSize := capsuleHeader["header_size"] - totalSize := capsuleHeader["total_size"] - availableData := len(data) - offset - - if headerSize >= totalSize { - return nil, fmt.Errorf("UEFI capsule: header size >= total size") - } - - if totalSize < availableData { - return nil, fmt.Errorf("UEFI capsule: total size smaller than available data") - } - - result.Size = totalSize - result.Description = fmt.Sprintf("%s, header size: %d bytes, total size: %d bytes", - result.Description, headerSize, totalSize) - return result, nil -} - -// ---- PCH ROM ---- - -const DescriptionPCHRom = "Intel serial flash for PCH ROM" - -// pchRomMagic returns magic bytes for PCH ROM signatures. -func pchRomMagic() [][]byte { - return [][]byte{ - {0x5a, 0xa5, 0xf0, 0x0f}, - } -} - -// pchRomParser validates PCH ROM signatures. -func pchRomParser(data []byte, offset int) (*SignatureResult, error) { - const pchMagicOffset = 16 - const headerStructOffset = 16 - const headerStructSize = 8 - const expectedFCBA = 3 - const expectedFRBA = 4 - const flashRegionCount = 5 - const flashRegionEntrySize = 4 - - result := &SignatureResult{ - Description: DescriptionPCHRom, - } - - if offset < pchMagicOffset { - return nil, fmt.Errorf("PCH ROM: offset too small") - } - - result.Offset = offset - pchMagicOffset - pchData := data[result.Offset:] - - structStart := headerStructOffset - structEnd := structStart + headerStructSize - if structEnd > len(pchData) { - return nil, fmt.Errorf("PCH ROM: insufficient data for header") - } - - pchHeaderFields := []StructField{ - {"flmagic", "u32"}, - {"flmap0_fcba", "u8"}, - {"flmap0_nc", "u8"}, - {"flmap0_frba_nr", "u16"}, - } - - pchHeader, err := Parse(pchData[structStart:structEnd], pchHeaderFields, "little") - if err != nil { - return nil, fmt.Errorf("PCH ROM: header parse failed") - } - - if pchHeader["flmap0_fcba"] != expectedFCBA { - return nil, fmt.Errorf("PCH ROM: unexpected FCBA value") - } - if pchHeader["flmap0_frba_nr"] != expectedFRBA { - return nil, fmt.Errorf("PCH ROM: unexpected FRBA value") - } - - nc := pchHeader["flmap0_nc"] - if nc != 0 && nc != 1 { - return nil, fmt.Errorf("PCH ROM: unexpected NC value") - } - - // Parse flash region entries - fcba := pchHeader["flmap0_fcba"] - flashRegionBaseAddr := ((fcba >> 16) & 0xFF) << 4 - - imageSize := 0 - for i := 0; i < flashRegionCount; i++ { - regionEntryStart := flashRegionBaseAddr + (i * flashRegionEntrySize) - regionEntryEnd := regionEntryStart + flashRegionEntrySize - - if regionEntryEnd > len(pchData) { - return nil, fmt.Errorf("PCH ROM: region entry out of bounds") - } - - regionFields := []StructField{{"region_value", "u32"}} - regionEntry, err := Parse(pchData[regionEntryStart:regionEntryEnd], regionFields, "little") - if err != nil { - return nil, fmt.Errorf("PCH ROM: region entry parse failed") - } - - regionValue := regionEntry["region_value"] - regionBase := (regionValue & 0x1FFF) << 12 - regionLimit := (((regionValue & 0x1FFF0000) >> 4) | 0xFFFF) + 1 - regionSize := regionLimit - regionBase - - if regionSize > 0 && regionLimit > imageSize { - imageSize = regionLimit - } - } - - if imageSize == 0 { - return nil, fmt.Errorf("PCH ROM: no valid flash regions") - } - - result.Size = headerStructOffset + imageSize - return result, nil -} - -// ---- PCAP ---- - -const DescriptionPcapNG = "Pcap-NG capture file" - -// pcapngMagic returns magic bytes for Pcap-NG files. -func pcapngMagic() [][]byte { - return [][]byte{ - {0x0A, 0x0D, 0x0D, 0x0A}, - } -} - -// pcapngParser validates Pcap-NG capture file signatures. -func pcapngParser(data []byte, offset int) (*SignatureResult, error) { - const sectionHeaderBlockType = 0x0A0D0D0A - - result := &SignatureResult{ - Offset: offset, - Description: DescriptionPcapNG, - Confidence: ConfidenceHigh, - } - - sectionFields := []StructField{ - {"block_type", "u32"}, - {"block_size", "u32"}, - {"endian_magic", "u32"}, - {"major_version", "u16"}, - {"minor_version", "u16"}, - {"section_length", "u32"}, - } - - if offset+StructSize(sectionFields) > len(data) { - return nil, fmt.Errorf("Pcap-NG: insufficient data") - } - - sectionHeader, err := Parse(data[offset:], sectionFields, "little") - if err != nil { - return nil, fmt.Errorf("Pcap-NG: parse failed") - } - - // Determine endianness - endianMagic := sectionHeader["endian_magic"] - var endianness string - switch endianMagic { - case 0x1A2B3C4D: - endianness = "little" - case 0x4D3C2B1A: - endianness = "big" - default: - return nil, fmt.Errorf("Pcap-NG: invalid endian magic") - } - - // Re-parse with correct endianness - blockFields := []StructField{ - {"block_type", "u32"}, - {"block_size", "u32"}, - } - footerFields := []StructField{{"block_size", "u32"}} - footerSize := StructSize(footerFields) - - blockHeader, err := Parse(data[offset:], blockFields, endianness) - if err != nil { - return nil, fmt.Errorf("Pcap-NG: block header parse failed") - } - - if blockHeader["block_type"] != sectionHeaderBlockType { - return nil, fmt.Errorf("Pcap-NG: unexpected block type") - } - - blockSize := blockHeader["block_size"] - if blockSize < footerSize { - return nil, fmt.Errorf("Pcap-NG: block size too small") - } - - // Validate block size via footer - footerStart := offset + blockSize - footerSize - if footerStart+footerSize > len(data) { - return nil, fmt.Errorf("Pcap-NG: block footer out of bounds") - } - - blockFooter, err := Parse(data[footerStart:], footerFields, endianness) - if err != nil { - return nil, fmt.Errorf("Pcap-NG: block footer parse failed") - } - - if blockFooter["block_size"] != blockSize { - return nil, fmt.Errorf("Pcap-NG: block size mismatch between header and footer") - } - - // Reserved bit check - if (blockHeader["block_type"] & 0x80000000) != 0 { - return nil, fmt.Errorf("Pcap-NG: reserved bit set in block type") - } - - result.Size = blockSize - result.Description = fmt.Sprintf("%s, total size: %d bytes", result.Description, result.Size) - - if offset == 0 && result.Size == len(data) { - result.ExtractionDeclined = true - } - - return result, nil -} - -// ---- PJL ---- - -const DescriptionPJL = "HP Printer Job Language data" - -// pjlMagic returns magic bytes for PJL data. -func pjlMagic() [][]byte { - return [][]byte{ - {0x1B, 0x25, 0x2D, 0x31, 0x32, 0x33, 0x34, 0x35, 0x58, 0x40, 0x50, 0x4A, 0x4C}, // "\x1B%-12345X@PJL" - } -} - -// pjlParser validates PJL data signatures. -func pjlParser(data []byte, offset int) (*SignatureResult, error) { - const pjlCommandsOffset = 9 - - result := &SignatureResult{ - Offset: offset, - Description: DescriptionPJL, - Confidence: ConfidenceLow, - } - - if offset+pjlCommandsOffset >= len(data) { - return nil, fmt.Errorf("PJL: insufficient data") - } - - pjlText := GetCString(data[offset+pjlCommandsOffset:]) - if len(pjlText) == 0 { - return nil, fmt.Errorf("PJL: no command text found") - } - - result.Size = len(pjlText) - displayText := strings.ReplaceAll(pjlText, "\r", " ") - displayText = strings.ReplaceAll(displayText, "\n", "") - result.Description = fmt.Sprintf("%s: \"%s\"", result.Description, displayText) - return result, nil -} - -// ---- S-record ---- - -const ( - DescriptionSRecord = "Motorola S-record" - DescriptionSRecordGeneric = "Motorola S-record (generic)" -) - -// srecordMagic returns magic bytes for Motorola S-records. -func srecordMagic() [][]byte { - return [][]byte{ - []byte("S00600004844521B"), - } -} - -// srecordGenericMagic returns short/generic magic bytes for S-records. -func srecordGenericMagic() [][]byte { - return [][]byte{ - []byte("S0"), - } -} - -// srecordParser validates Motorola S-record signatures. -func srecordParser(data []byte, offset int) (*SignatureResult, error) { - const unixTermChar byte = 0x0A - const windowsTermChar byte = 0x0D - - result := &SignatureResult{ - Offset: offset, - Description: DescriptionSRecord, - Confidence: ConfidenceHigh, - } - - availableData := len(data) - - // Search for srec footer lines - srecFooters := [][]byte{[]byte("\nS9"), []byte("\nS8"), []byte("\nS7")} - footerLen := 3 - - searchData := data[offset:] - - for _, footer := range srecFooters { - idx := bytes.Index(searchData, footer) - if idx < 0 { - continue - } - - osType := "Unix" - srecEOF := offset + idx + footerLen - - for srecEOF < availableData { - b := data[srecEOF] - if b == windowsTermChar || b == unixTermChar { - if b == windowsTermChar { - srecEOF++ - osType = "Windows" - } - if srecEOF >= availableData { - return nil, fmt.Errorf("S-record: unexpected end after footer") - } - if data[srecEOF] != unixTermChar { - return nil, fmt.Errorf("S-record: invalid termination") - } - srecEOF++ - result.Size = srecEOF - offset - result.Description = fmt.Sprintf("%s, origin OS: %s, total size: %d bytes", - result.Description, osType, result.Size) - return result, nil - } - srecEOF++ - } - } - - return nil, fmt.Errorf("S-record: no valid footer found") -} - -// ---- DMG ---- - -const DescriptionDMG = "Apple Disk iMaGe" - -// dmgMagic returns magic bytes for DMG files (footer signature). -func dmgMagic() [][]byte { - return [][]byte{ - {0x6B, 0x6F, 0x6C, 0x79, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x02, 0x00}, // "koly\x00\x00\x00\x04\x00\x00\x02\x00" - } -} - -// dmgParser validates DMG footer signatures. -func dmgParser(data []byte, offset int) (*SignatureResult, error) { - result := &SignatureResult{ - Description: DescriptionDMG, - Confidence: ConfidenceHigh + 1, - } - - dmgFooterFields := []StructField{ - {"magic", "u32"}, - {"version", "u32"}, - {"header_size", "u32"}, - {"flags", "u32"}, - {"running_data_fork_offset", "u64"}, - {"data_fork_offset", "u64"}, - {"data_fork_length", "u64"}, - {"rsrc_fork_offset", "u64"}, - {"rsrc_fork_length", "u64"}, - {"segment_number", "u32"}, - {"segment_count", "u32"}, - {"segment_id_p1", "u64"}, - {"segment_id_p2", "u64"}, - {"data_checksum_type", "u32"}, - {"data_checksum_size", "u32"}, - {"data_checksum_1", "u32"}, - {"data_checksum_2", "u32"}, - {"data_checksum_3", "u32"}, - {"data_checksum_4", "u32"}, - {"data_checksum_5", "u32"}, - {"data_checksum_6", "u32"}, - {"data_checksum_7", "u32"}, - {"data_checksum_8", "u32"}, - {"data_checksum_9", "u32"}, - {"data_checksum_10", "u32"}, - {"data_checksum_11", "u32"}, - {"data_checksum_12", "u32"}, - {"data_checksum_13", "u32"}, - {"data_checksum_14", "u32"}, - {"data_checksum_15", "u32"}, - {"data_checksum_16", "u32"}, - {"data_checksum_17", "u32"}, - {"data_checksum_18", "u32"}, - {"data_checksum_19", "u32"}, - {"data_checksum_20", "u32"}, - {"data_checksum_21", "u32"}, - {"data_checksum_22", "u32"}, - {"data_checksum_23", "u32"}, - {"data_checksum_24", "u32"}, - {"data_checksum_25", "u32"}, - {"data_checksum_26", "u32"}, - {"data_checksum_27", "u32"}, - {"data_checksum_28", "u32"}, - {"data_checksum_29", "u32"}, - {"data_checksum_30", "u32"}, - {"data_checksum_31", "u32"}, - {"data_checksum_32", "u32"}, - {"xml_offset", "u64"}, - {"xml_length", "u64"}, - {"reserved_1", "u64"}, - {"reserved_2", "u64"}, - {"reserved_3", "u64"}, - {"reserved_4", "u64"}, - {"reserved_5", "u64"}, - {"reserved_6", "u64"}, - {"reserved_7", "u64"}, - {"reserved_8", "u64"}, - {"reserved_9", "u64"}, - {"reserved_10", "u64"}, - {"reserved_11", "u64"}, - {"reserved_12", "u64"}, - {"reserved_13", "u64"}, - {"reserved_14", "u64"}, - {"reserved_15", "u64"}, - {"checksum_type", "u32"}, - {"checksum_size", "u32"}, - {"checksum_1", "u32"}, - {"checksum_2", "u32"}, - {"checksum_3", "u32"}, - {"checksum_4", "u32"}, - {"checksum_5", "u32"}, - {"checksum_6", "u32"}, - {"checksum_7", "u32"}, - {"checksum_8", "u32"}, - {"checksum_9", "u32"}, - {"checksum_10", "u32"}, - {"checksum_11", "u32"}, - {"checksum_12", "u32"}, - {"checksum_13", "u32"}, - {"checksum_14", "u32"}, - {"checksum_15", "u32"}, - {"checksum_16", "u32"}, - {"checksum_17", "u32"}, - {"checksum_18", "u32"}, - {"checksum_19", "u32"}, - {"checksum_20", "u32"}, - {"checksum_21", "u32"}, - {"checksum_22", "u32"}, - {"checksum_23", "u32"}, - {"checksum_24", "u32"}, - {"checksum_25", "u32"}, - {"checksum_26", "u32"}, - {"checksum_27", "u32"}, - {"checksum_28", "u32"}, - {"checksum_29", "u32"}, - {"checksum_30", "u32"}, - {"checksum_31", "u32"}, - {"checksum_32", "u32"}, - {"image_variant", "u32"}, - {"sector_count", "u64"}, - {"reserved_16", "u32"}, - {"reserved_17", "u32"}, - {"reserved_18", "u32"}, - } - - footerSize := StructSize(dmgFooterFields) - - if offset+footerSize > len(data) { - return nil, fmt.Errorf("DMG: insufficient data for footer") - } - - dmgFooter, err := Parse(data[offset:], dmgFooterFields, "big") - if err != nil { - return nil, fmt.Errorf("DMG: footer parse failed") - } - - if dmgFooter["header_size"] != footerSize { - return nil, fmt.Errorf("DMG: header size mismatch (got %d, expected %d)", dmgFooter["header_size"], footerSize) - } - - dataLength := dmgFooter["data_fork_length"] - xmlLength := dmgFooter["xml_length"] - - if dataLength+xmlLength > offset { - return nil, fmt.Errorf("DMG: data + xml length exceeds offset") - } - - // Search for XML property list with blkx key - xmlSig := []byte("blkx" - const minXMLLen = 1024 - - xmlOffset := -1 - searchStart := 0 - for { - idx := bytes.Index(data[searchStart:], xmlSig) - if idx < 0 { - break - } - xmlStart := searchStart + idx - xmlEnd := xmlStart + minXMLLen - if xmlEnd > len(data) { - searchStart = xmlStart + len(xmlSig) - continue - } - xmlStr := string(data[xmlStart:xmlEnd]) - if strings.Contains(xmlStr, blkxKey) { - xmlOffset = xmlStart - break - } - searchStart = xmlStart + len(xmlSig) - } - - if xmlOffset < 0 { - return nil, fmt.Errorf("DMG: XML property list not found") - } - - if xmlOffset < dataLength { - return nil, fmt.Errorf("DMG: XML data before image data") - } - - result.Size = offset + footerSize - result.Offset = xmlOffset - dataLength - result.Description = fmt.Sprintf("%s, total size: %d bytes", result.Description, result.Size) - return result, nil -} - -// ---- QNX IFS ---- - -const DescriptionQNXIFS = "QNX IFS image" - -// qnxIFSMagic returns magic bytes for QNX IFS images. -func qnxIFSMagic() [][]byte { - return [][]byte{ - {0xEB, 0x7E, 0xFF, 0x00, 0x01, 0x00}, - } -} - -// qnxIFSParser validates QNX IFS image signatures. -func qnxIFSParser(data []byte, offset int) (*SignatureResult, error) { - result := &SignatureResult{ - Offset: offset, - Description: DescriptionQNXIFS, - } - - ifsFields := []StructField{ - {"magic", "u32"}, - {"version", "u16"}, - {"flags1", "u8"}, - {"flags2", "u8"}, - {"header_size", "u16"}, - {"machine", "u16"}, - {"startup_vaddr", "u32"}, - {"paddr_bias", "u32"}, - {"image_paddr", "u32"}, - {"ram_paddr", "u32"}, - {"ram_size", "u32"}, - {"startup_size", "u32"}, - {"stored_size", "u32"}, - {"imagefs_paddr", "u32"}, - {"imagefs_size", "u32"}, - {"preboot_size", "u16"}, - {"zero_0", "u16"}, - {"zero_1", "u32"}, - {"zero_2", "u32"}, - {"zero_3", "u32"}, - } - - if offset+StructSize(ifsFields) > len(data) { - return nil, fmt.Errorf("QNX IFS: insufficient data") - } - - ifsHeader, err := Parse(data[offset:], ifsFields, "little") - if err != nil { - return nil, fmt.Errorf("QNX IFS: parse failed") - } - - if ifsHeader["flags2"] != 0 { - return nil, fmt.Errorf("QNX IFS: flags2 not zero") - } - if ifsHeader["zero_0"] != 0 || ifsHeader["zero_1"] != 0 || ifsHeader["zero_2"] != 0 || ifsHeader["zero_3"] != 0 { - return nil, fmt.Errorf("QNX IFS: zero fields not zero") - } - - totalSize := ifsHeader["stored_size"] - availableData := len(data) - offset - if totalSize > availableData { - return nil, fmt.Errorf("QNX IFS: total size exceeds available data") - } - - result.Size = totalSize - result.Description = fmt.Sprintf("%s, total size: %d bytes", result.Description, result.Size) - return result, nil -} diff --git a/internal/leviathan/binsnoop/types.go b/internal/leviathan/binsnoop/types.go deleted file mode 100644 index 10d043c..0000000 --- a/internal/leviathan/binsnoop/types.go +++ /dev/null @@ -1,117 +0,0 @@ -package binsnoop - -import ( - "crypto/rand" - "fmt" - "sort" -) - -// Confidence levels for SignatureResult -const ( - ConfidenceLow uint8 = 0 - ConfidenceMedium uint8 = 128 - ConfidenceHigh uint8 = 250 -) - -// SignatureResult describes a validated file signature found during scanning. -type SignatureResult struct { - Offset int `json:"offset"` - ID string `json:"id"` - Size int `json:"size"` - Name string `json:"name"` - Confidence uint8 `json:"confidence"` - Description string `json:"description"` - AlwaysDisplay bool `json:"always_display"` - ExtractionDeclined bool `json:"extraction_declined"` - PreferredExtractor *Extractor `json:"-"` -} - -// DeepScanResult is a validated signature retained by a non-suppressing scan. -// ParentOffset is -1 for a top-level hit. -type DeepScanResult struct { - SignatureResult - End int `json:"end"` - Depth int `json:"depth"` - ParentOffset int `json:"parent_offset"` - Overlap bool `json:"overlap"` -} - -// DeepScan is a bounded nested-signature analysis result. -type DeepScan struct { - Results []DeepScanResult `json:"results"` - Truncated bool `json:"truncated"` -} - -// SignatureParser validates a magic byte match and returns a SignatureResult or error. -type SignatureParser func(data []byte, offset int) (*SignatureResult, error) - -// Signature defines a file signature to search for. -type Signature struct { - Name string - Short bool - Magic [][]byte - MagicOffset int - Description string - AlwaysDisplay bool - Parser SignatureParser - Extractor *Extractor -} - -// ExtractorType defines the type of extractor. -type ExtractorType int - -const ( - ExtractorTypeNone ExtractorType = iota - ExtractorTypeInternal - ExtractorTypeExternal -) - -// SourceFilePlaceholder is replaced at runtime with the actual file path. -const SourceFilePlaceholder = "%e" - -// InternalExtractorFunc is the function signature for built-in extractors. -type InternalExtractorFunc func(data []byte, offset int, outputDir string) *ExtractionResult - -// Extractor describes how to extract a file type. -type Extractor struct { - Type ExtractorType - Command string - Function InternalExtractorFunc - Extension string - Arguments []string - ExitCodes []int - DoNotRecurse bool -} - -// ExtractionResult stores information about a completed extraction. -type ExtractionResult struct { - Size *int `json:"size,omitempty"` - Success bool `json:"success"` - ExtractorName string `json:"extractor"` - DoNotRecurse bool `json:"do_not_recurse"` - OutputDirectory string `json:"output_directory"` -} - -// AnalysisResults contains all results from analysing a file or buffer. -type AnalysisResults struct { - FilePath string `json:"file_path"` - FileMap []SignatureResult `json:"file_map"` - Extractions map[string]*ExtractionResult `json:"extractions"` -} - -// sortSignatureResults sorts by offset for proper file mapping. -func sortSignatureResults(results []SignatureResult) { - sort.Slice(results, func(i, j int) bool { - return results[i].Offset < results[j].Offset - }) -} - -// newUUID generates a v4 UUID string with no external dependencies. -func newUUID() string { - b := make([]byte, 16) - _, _ = rand.Read(b) - b[6] = (b[6] & 0x0f) | 0x40 - b[8] = (b[8] & 0x3f) | 0x80 - return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", - b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) -} diff --git a/internal/leviathan/disasm/aarch64.go b/internal/leviathan/disasm/aarch64.go deleted file mode 100644 index e67495c..0000000 --- a/internal/leviathan/disasm/aarch64.go +++ /dev/null @@ -1,288 +0,0 @@ -package disasm - -import ( - "encoding/binary" - "fmt" -) - -// diAArch64 is a heuristic AArch64 (ARMv8 A64) decoder. It recognises a -// subset of instructions commonly seen in disassembly and falls back to -// ".word 0x…" for everything else. 32-bit fixed-width little-endian. -func diAArch64(buf []byte, base int64, maxInstr int) []map[string]any { - out := []map[string]any{} - i := 0 - for i+4 <= len(buf) && len(out) < maxInstr { - w := binary.LittleEndian.Uint32(buf[i : i+4]) - mn, ops, conf := decodeA64(w, base+int64(i)) - out = append(out, map[string]any{ - "offset": base + int64(i), - "bytes": hexStr(buf[i : i+4]), - "mnemonic": mn, - "operands": ops, - "confident": conf, - }) - i += 4 - } - // Tail bytes (not multiple of 4). - for i < len(buf) && len(out) < maxInstr { - end := i + 1 - out = append(out, map[string]any{ - "offset": base + int64(i), - "bytes": hexStr(buf[i:end]), - "mnemonic": "db", - "operands": hexCommaList(buf[i:end]), - "confident": false, - }) - i++ - } - return out -} - -func decodeA64(w uint32, pc int64) (string, string, bool) { - // NOP - if w == 0xd503201f { - return "nop", "", true - } - // RET Xn (default x30) - if w&0xfffffc1f == 0xd65f0000 { - rn := (w >> 5) & 0x1f - if rn == 30 { - return "ret", "", true - } - return "ret", regX(rn), true - } - // BR Xn - if w&0xfffffc1f == 0xd61f0000 { - return "br", regX((w >> 5) & 0x1f), true - } - // BLR Xn - if w&0xfffffc1f == 0xd63f0000 { - return "blr", regX((w >> 5) & 0x1f), true - } - // B / BL (unconditional branch, imm26 * 4, signed) - if w&0x7c000000 == 0x14000000 { - imm := signExtend64(uint64(w&0x03ffffff)<<2, 28) - target := pc + int64(imm) - if w&0x80000000 != 0 { - return "bl", fmt.Sprintf("0x%x", target), true - } - return "b", fmt.Sprintf("0x%x", target), true - } - // B.cond (conditional branch, imm19*4) - if w&0xff000010 == 0x54000000 { - cond := int(w & 0xf) - imm := signExtend64(uint64((w>>5)&0x7ffff)<<2, 21) - return "b." + condName(cond), fmt.Sprintf("0x%x", pc+int64(imm)), true - } - // CBZ / CBNZ - if w&0x7e000000 == 0x34000000 { - sf := (w >> 31) & 1 - op := (w >> 24) & 1 - rt := w & 0x1f - imm := signExtend64(uint64((w>>5)&0x7ffff)<<2, 21) - mn := "cbz" - if op == 1 { - mn = "cbnz" - } - r := regX(rt) - if sf == 0 { - r = regW(rt) - } - return mn, fmt.Sprintf("%s, 0x%x", r, pc+int64(imm)), true - } - // MOVZ / MOVN / MOVK (wide immediate) - if w&0x1f800000 == 0x12800000 { - sf := (w >> 31) & 1 - opc := (w >> 29) & 0x3 - hw := (w >> 21) & 0x3 - imm16 := (w >> 5) & 0xffff - rd := w & 0x1f - mn := "movn" - switch opc { - case 0b10: - mn = "movz" - case 0b11: - mn = "movk" - } - shift := hw * 16 - r := regX(rd) - if sf == 0 { - r = regW(rd) - } - if shift == 0 { - return mn, fmt.Sprintf("%s, #0x%x", r, imm16), true - } - return mn, fmt.Sprintf("%s, #0x%x, lsl #%d", r, imm16, shift), true - } - // ADR / ADRP - if w&0x1f000000 == 0x10000000 { - page := (w >> 31) & 1 - rd := w & 0x1f - immlo := uint64((w >> 29) & 0x3) - immhi := uint64((w >> 5) & 0x7ffff) - imm := (immhi << 2) | immlo - mn := "adr" - shift := uint(21) - signed := signExtend64(imm, shift) - if page == 1 { - mn = "adrp" - signed = signExtend64(imm<<12, 33) - // adrp target is page-aligned: pc & ~0xFFF - return mn, fmt.Sprintf("%s, 0x%x", regX(rd), (pc & ^int64(0xfff))+int64(signed)), true - } - return mn, fmt.Sprintf("%s, 0x%x", regX(rd), pc+int64(signed)), true - } - // ADD / SUB (immediate) - if w&0x1f000000 == 0x11000000 { - sf := (w >> 31) & 1 - op := (w >> 30) & 1 - sh := (w >> 22) & 1 - imm12 := (w >> 10) & 0xfff - rn := (w >> 5) & 0x1f - rd := w & 0x1f - mn := "add" - if op == 1 { - mn = "sub" - } - lhs := regX(rd) - mid := regX(rn) - if sf == 0 { - lhs = regW(rd) - mid = regW(rn) - } - if sh == 1 { - return mn, fmt.Sprintf("%s, %s, #0x%x, lsl #12", lhs, mid, imm12), true - } - return mn, fmt.Sprintf("%s, %s, #0x%x", lhs, mid, imm12), true - } - // LDR / STR (unsigned offset, 32/64-bit int) - if w&0x3fc00000 == 0x39400000 || w&0x3fc00000 == 0x39000000 { - size := (w >> 30) & 0x3 - l := (w >> 22) & 1 - imm12 := (w >> 10) & 0xfff - rn := (w >> 5) & 0x1f - rt := w & 0x1f - var mn string - if l == 1 { - mn = "ldr" - } else { - mn = "str" - } - scale := int(1 << size) - imm := int(imm12) * scale - var reg string - if size == 3 { - reg = regX(rt) - } else { - reg = regW(rt) - } - base := regXorSP(rn) - if imm == 0 { - return mn, fmt.Sprintf("%s, [%s]", reg, base), true - } - return mn, fmt.Sprintf("%s, [%s, #0x%x]", reg, base, imm), true - } - // LDR (literal / PC-rel) - if w&0xbf000000 == 0x18000000 { - sf := (w >> 30) & 1 - imm := signExtend64(uint64((w>>5)&0x7ffff)<<2, 21) - rt := w & 0x1f - reg := regW(rt) - if sf == 1 { - reg = regX(rt) - } - return "ldr", fmt.Sprintf("%s, 0x%x", reg, pc+int64(imm)), true - } - // ORR / AND / EOR (shifted reg) — simplified detection, no shifts reported. - if w&0x7f200000 == 0x2a000000 { // ORR (32/64) w/ shift=0 - sf := (w >> 31) & 1 - rm := (w >> 16) & 0x1f - rn := (w >> 5) & 0x1f - rd := w & 0x1f - // MOV (register) alias: ORR Xd, XZR, Xm → MOV Xd, Xm (when Rn==31 and shift==0) - if rn == 31 && ((w>>10)&0x3f) == 0 && ((w>>22)&0x3) == 0 { - if sf == 1 { - return "mov", fmt.Sprintf("%s, %s", regX(rd), regX(rm)), true - } - return "mov", fmt.Sprintf("%s, %s", regW(rd), regW(rm)), true - } - if sf == 1 { - return "orr", fmt.Sprintf("%s, %s, %s", regX(rd), regX(rn), regX(rm)), true - } - return "orr", fmt.Sprintf("%s, %s, %s", regW(rd), regW(rn), regW(rm)), true - } - // SVC #imm16 - if w&0xffe0001f == 0xd4000001 { - imm16 := (w >> 5) & 0xffff - return "svc", fmt.Sprintf("#0x%x", imm16), true - } - // BRK #imm16 - if w&0xffe0001f == 0xd4200000 { - imm16 := (w >> 5) & 0xffff - return "brk", fmt.Sprintf("#0x%x", imm16), true - } - // HINT family → treat common ones - if w&0xfffff01f == 0xd503201f { - op := (w >> 5) & 0x7f - switch op { - case 0: - return "nop", "", true - case 1: - return "yield", "", true - case 2: - return "wfe", "", true - case 3: - return "wfi", "", true - case 4: - return "sev", "", true - case 5: - return "sevl", "", true - } - return "hint", fmt.Sprintf("#0x%x", op), true - } - // Fallback - return ".word", fmt.Sprintf("0x%08x", w), false -} - -func regX(r uint32) string { - r &= 0x1f - if r == 31 { - return "xzr" - } - return fmt.Sprintf("x%d", r) -} -func regW(r uint32) string { - r &= 0x1f - if r == 31 { - return "wzr" - } - return fmt.Sprintf("w%d", r) -} - -// regXorSP is used for the "Rn" slot in memory ops where 31 means SP, not XZR. -func regXorSP(r uint32) string { - r &= 0x1f - if r == 31 { - return "sp" - } - return fmt.Sprintf("x%d", r) -} - -func condName(c int) string { - names := []string{ - "eq", "ne", "cs", "cc", "mi", "pl", "vs", "vc", - "hi", "ls", "ge", "lt", "gt", "le", "al", "nv", - } - if c >= 0 && c < 16 { - return names[c] - } - return fmt.Sprintf("c%d", c) -} - -func signExtend64(v uint64, bits uint) int64 { - mask := uint64(1) << (bits - 1) - if v&mask != 0 { - v |= ^uint64(0) << bits - } - return int64(v) -} diff --git a/internal/leviathan/disasm/arm32.go b/internal/leviathan/disasm/arm32.go deleted file mode 100644 index f4518bc..0000000 --- a/internal/leviathan/disasm/arm32.go +++ /dev/null @@ -1,209 +0,0 @@ -package disasm - -import ( - "encoding/binary" - "fmt" - "strings" -) - -// diARM32 is a heuristic ARM state decoder for 32-bit little-endian ARM -// instructions. Thumb/T32 is intentionally not decoded here. -func diARM32(buf []byte, base int64, maxInstr int) []map[string]any { - out := []map[string]any{} - i := 0 - for i+4 <= len(buf) && len(out) < maxInstr { - w := binary.LittleEndian.Uint32(buf[i : i+4]) - mn, ops, conf := decodeARM32(w, base+int64(i)) - out = append(out, map[string]any{ - "offset": base + int64(i), - "bytes": hexStr(buf[i : i+4]), - "mnemonic": mn, - "operands": ops, - "confident": conf, - }) - i += 4 - } - for i < len(buf) && len(out) < maxInstr { - out = append(out, map[string]any{ - "offset": base + int64(i), - "bytes": hexStr(buf[i : i+1]), - "mnemonic": "db", - "operands": hexCommaList(buf[i : i+1]), - "confident": false, - }) - i++ - } - return out -} - -func decodeARM32(w uint32, pc int64) (string, string, bool) { - cond := (w >> 28) & 0xf - suf := armCondSuffix(cond) - if cond == 0xf { - suf = ".nv" - } - // BX Rn - if w&0x0ffffff0 == 0x012fff10 { - return "bx" + suf, armReg(w & 0xf), true - } - // SVC #imm24 - if w&0x0f000000 == 0x0f000000 { - return "svc" + suf, fmt.Sprintf("#0x%x", w&0x00ffffff), true - } - // B / BL, offset is sign-extended imm24 << 2 from PC+8. - if w&0x0e000000 == 0x0a000000 { - imm := signExtend64(uint64(w&0x00ffffff)<<2, 26) - target := pc + 8 + int64(imm) - if w&0x01000000 != 0 { - return "bl" + suf, fmt.Sprintf("0x%x", target), true - } - return "b" + suf, fmt.Sprintf("0x%x", target), true - } - // PUSH/POP common aliases. - if w&0xffff0000 == 0xe92d0000 { - return "push", armRegList(w & 0xffff), true - } - if w&0xffff0000 == 0xe8bd0000 { - return "pop", armRegList(w & 0xffff), true - } - // LDR/STR immediate offset. - if w&0x0c000000 == 0x04000000 { - p := (w >> 24) & 1 - u := (w >> 23) & 1 - b := (w >> 22) & 1 - wb := (w >> 21) & 1 - l := (w >> 20) & 1 - rn := (w >> 16) & 0xf - rd := (w >> 12) & 0xf - imm := w & 0xfff - mn := "str" - if l == 1 { - mn = "ldr" - } - if b == 1 { - mn += "b" - } - mn += suf - sign := "" - if u == 0 { - sign = "-" - } - addr := "" - switch { - case p == 1 && imm == 0 && wb == 0: - addr = fmt.Sprintf("[%s]", armReg(rn)) - case p == 1: - addr = fmt.Sprintf("[%s, #%s0x%x]", armReg(rn), sign, imm) - if wb == 1 { - addr += "!" - } - default: - addr = fmt.Sprintf("[%s], #%s0x%x", armReg(rn), sign, imm) - } - return mn, fmt.Sprintf("%s, %s", armReg(rd), addr), true - } - // Data processing immediate/register subset. - if w&0x0c000000 == 0x00000000 || w&0x0e000000 == 0x02000000 { - immediate := w&0x02000000 != 0 - opcode := (w >> 21) & 0xf - s := (w >> 20) & 1 - rn := (w >> 16) & 0xf - rd := (w >> 12) & 0xf - mn := armDataProcName(opcode) - if mn == "" { - return ".word", fmt.Sprintf("0x%08x", w), false - } - mn += suf - if s == 1 && opcode != 8 && opcode != 9 && opcode != 10 && opcode != 11 { - mn += "s" - } - op2 := "" - if immediate { - imm8 := w & 0xff - rot := ((w >> 8) & 0xf) * 2 - imm := (imm8 >> rot) | (imm8 << ((32 - rot) & 31)) - op2 = fmt.Sprintf("#0x%x", imm) - } else { - rm := w & 0xf - shift := (w >> 4) & 0xff - if shift == 0 { - op2 = armReg(rm) - } else { - op2 = fmt.Sprintf("%s, %s", armReg(rm), armShift(shift)) - } - } - switch opcode { - case 8, 9, 10, 11: // tst/teq/cmp/cmn - return mn, fmt.Sprintf("%s, %s", armReg(rn), op2), true - case 13, 15: // mov/mvn - return mn, fmt.Sprintf("%s, %s", armReg(rd), op2), true - default: - return mn, fmt.Sprintf("%s, %s, %s", armReg(rd), armReg(rn), op2), true - } - } - return ".word", fmt.Sprintf("0x%08x", w), false -} - -func armCondSuffix(cond uint32) string { - return [...]string{".eq", ".ne", ".cs", ".cc", ".mi", ".pl", ".vs", ".vc", ".hi", ".ls", ".ge", ".lt", ".gt", ".le", "", ""}[cond&0xf] -} - -func armDataProcName(op uint32) string { - return [...]string{"and", "eor", "sub", "rsb", "add", "adc", "sbc", "rsc", "tst", "teq", "cmp", "cmn", "orr", "mov", "bic", "mvn"}[op&0xf] -} - -func armReg(r uint32) string { - switch r & 0xf { - case 13: - return "sp" - case 14: - return "lr" - case 15: - return "pc" - default: - return fmt.Sprintf("r%d", r&0xf) - } -} - -func armRegList(mask uint32) string { - regs := []string{} - start := -1 - flush := func(end int) { - if start < 0 { - return - } - if start == end { - regs = append(regs, armReg(uint32(start))) - } else { - regs = append(regs, fmt.Sprintf("%s-%s", armReg(uint32(start)), armReg(uint32(end)))) - } - start = -1 - } - for i := 0; i < 16; i++ { - if mask&(1<> 1) & 0x3 - imm := (v >> 3) & 0x1f - names := [...]string{"lsl", "lsr", "asr", "ror"} - if imm == 0 { - if typ == 0 { - return "lsl #0" - } - if typ == 3 { - return "rrx" - } - imm = 32 - } - return fmt.Sprintf("%s #%d", names[typ], imm) -} diff --git a/internal/leviathan/disasm/cfg.go b/internal/leviathan/disasm/cfg.go deleted file mode 100644 index ec0625d..0000000 --- a/internal/leviathan/disasm/cfg.go +++ /dev/null @@ -1,541 +0,0 @@ -package disasm - -import ( - "sort" - "strconv" - "strings" -) - -// cfg.go builds control-flow graphs and cross-references on top of the -// per-architecture decoders. It performs a recursive-descent sweep from an -// entry point: it follows direct branches and the fall-through after calls -// (calls leave the function, so their targets are recorded as xrefs rather -// than followed), splits the decoded instructions into basic blocks, and -// records every direct code/data reference it can resolve statically. - -// Block is one basic block: a maximal straight-line instruction run. -type Block struct { - Start int64 `json:"start"` - End int64 `json:"end"` // address after the last instruction - Instrs []map[string]any `json:"instrs"` - Succs []int64 `json:"succs"` // successor block start addresses - Kind string `json:"kind"` // ret | jmp | cond | call | fallthrough -} - -// Xref is a single static reference from one address to another. -type Xref struct { - From int64 `json:"from"` - To int64 `json:"to"` - Kind string `json:"kind"` // call | jump | data -} - -// CFG is the analysis result for one entry point. -type CFG struct { - Entry int64 `json:"entry"` - Arch string `json:"arch"` - Blocks []*Block `json:"blocks"` - Calls []int64 `json:"calls"` // distinct call targets (in or out of range) - Xrefs []Xref `json:"xrefs"` // all resolved references - Unknown int `json:"unknown"` // indirect/unresolved branch+call count -} - -// Function is a discovered function and its structural metrics. -type Function struct { - Name string `json:"name"` - Entry int64 `json:"entry"` - End int64 `json:"end"` - Blocks []*Block `json:"blocks"` - Instructions int `json:"instructions"` - Complexity int `json:"complexity"` - Calls []int64 `json:"calls"` - CalledBy []int64 `json:"calledBy"` - Unknown int `json:"unknown"` -} - -// CallEdge is one direct call from a discovered function. -type CallEdge struct { - Caller int64 `json:"caller"` - CallSite int64 `json:"callSite"` - Callee int64 `json:"callee"` - External bool `json:"external"` -} - -// Seed is a known or suspected function entry supplied by executable metadata. -type Seed struct { - Name string `json:"name"` - Entry int64 `json:"entry"` -} - -// Program is a bounded recursive function-discovery result for one byte range. -type Program struct { - Entry int64 `json:"entry"` - Base int64 `json:"base"` - End int64 `json:"end"` - Arch string `json:"arch"` - Functions []*Function `json:"functions"` - Edges []CallEdge `json:"edges"` - ExternalCalls []int64 `json:"externalCalls"` - Instructions int `json:"instructions"` - Unknown int `json:"unknown"` - Truncated bool `json:"truncated"` -} - -type rawInstr struct { - addr int64 - length int - mnem string - class string - target int64 - hasTgt bool - data int64 - hasDat bool - m map[string]any -} - -// BuildCFG decodes from entry (within buf, which starts at address base) and -// returns the control-flow graph plus discovered references. -func BuildCFG(buf []byte, base int64, arch string, entry int64, maxInstr int) *CFG { - if maxInstr <= 0 { - maxInstr = 8192 - } - cfg := &CFG{Entry: entry, Arch: arch} - decoded := map[int64]*rawInstr{} - inRange := func(a int64) bool { return a >= base && a < base+int64(len(buf)) } - - var stack []int64 - push := func(a int64) { - if inRange(a) { - stack = append(stack, a) - } - } - push(entry) - - for len(stack) > 0 && len(decoded) < maxInstr { - addr := stack[len(stack)-1] - stack = stack[:len(stack)-1] - for inRange(addr) && len(decoded) < maxInstr { - if _, seen := decoded[addr]; seen { - break - } - ri := decodeAt(buf, addr, base, arch) - if ri == nil { - break - } - decoded[addr] = ri - next := addr + int64(ri.length) - switch ri.class { - case "ret": - addr = -1 - case "jmp": - if ri.hasTgt { - addr = ri.target - } else { - cfg.Unknown++ - addr = -1 - } - case "cond": - if ri.hasTgt { - push(ri.target) - } else { - cfg.Unknown++ - } - addr = next - case "call": - if !ri.hasTgt { - cfg.Unknown++ - } - addr = next - default: - addr = next - } - if addr < 0 { - break - } - } - } - - // Collect references and call targets. - callSet := map[int64]bool{} - for _, ri := range decoded { - if ri.hasTgt { - switch ri.class { - case "call": - cfg.Xrefs = append(cfg.Xrefs, Xref{From: ri.addr, To: ri.target, Kind: "call"}) - if !callSet[ri.target] { - callSet[ri.target] = true - cfg.Calls = append(cfg.Calls, ri.target) - } - case "jmp", "cond": - cfg.Xrefs = append(cfg.Xrefs, Xref{From: ri.addr, To: ri.target, Kind: "jump"}) - } - } - if ri.hasDat { - cfg.Xrefs = append(cfg.Xrefs, Xref{From: ri.addr, To: ri.data, Kind: "data"}) - } - } - sort.Slice(cfg.Calls, func(i, j int) bool { return cfg.Calls[i] < cfg.Calls[j] }) - sort.Slice(cfg.Xrefs, func(i, j int) bool { - if cfg.Xrefs[i].From != cfg.Xrefs[j].From { - return cfg.Xrefs[i].From < cfg.Xrefs[j].From - } - return cfg.Xrefs[i].To < cfg.Xrefs[j].To - }) - - cfg.Blocks = buildBlocks(decoded, inRange) - return cfg -} - -// DiscoverFunctions recursively follows direct calls from entry, building a -// bounded call graph over buf. Indirect calls are counted but cannot be -// discovered statically. maxInstr is a total budget across all functions. -func DiscoverFunctions(buf []byte, base int64, arch string, entry int64, maxFunctions, maxInstr int) *Program { - return DiscoverFunctionsFromSeeds(buf, base, arch, []Seed{{Entry: entry}}, maxFunctions, maxInstr) -} - -// DiscoverFunctionsFromSeeds analyzes named and unnamed roots plus every -// direct in-range call target reached from them. -func DiscoverFunctionsFromSeeds(buf []byte, base int64, arch string, seeds []Seed, maxFunctions, maxInstr int) *Program { - if maxFunctions <= 0 || maxFunctions > 4096 { - maxFunctions = 256 - } - if maxInstr <= 0 || maxInstr > 1_000_000 { - maxInstr = 65_536 - } - end := base + int64(len(buf)) - entry := base - if len(seeds) > 0 { - entry = seeds[0].Entry - } - program := &Program{Entry: entry, Base: base, End: end, Arch: arch} - inRange := func(addr int64) bool { return addr >= base && addr < end } - queue := make([]int64, 0, len(seeds)) - queued := map[int64]bool{} - names := map[int64]string{} - for _, seed := range seeds { - if !inRange(seed.Entry) { - continue - } - if !queued[seed.Entry] { - queued[seed.Entry] = true - queue = append(queue, seed.Entry) - } - if seed.Name != "" { - names[seed.Entry] = seed.Name - } - } - if len(queue) == 0 { - program.Truncated = true - return program - } - - discovered := map[int64]*Function{} - callerSets := map[int64]map[int64]bool{} - externalSet := map[int64]bool{} - - for len(queue) > 0 && len(discovered) < maxFunctions && program.Instructions < maxInstr { - fnEntry := queue[0] - queue = queue[1:] - if discovered[fnEntry] != nil { - continue - } - remaining := maxInstr - program.Instructions - perFunction := remaining - if perFunction > 8192 { - perFunction = 8192 - } - cfg := BuildCFG(buf, base, arch, fnEntry, perFunction) - fn := summarizeFunction(cfg, names[fnEntry]) - discovered[fnEntry] = fn - program.Functions = append(program.Functions, fn) - program.Instructions += fn.Instructions - program.Unknown += fn.Unknown - - for _, xref := range cfg.Xrefs { - if xref.Kind != "call" { - continue - } - external := !inRange(xref.To) - program.Edges = append(program.Edges, CallEdge{ - Caller: fnEntry, CallSite: xref.From, Callee: xref.To, External: external, - }) - if external { - externalSet[xref.To] = true - continue - } - if callerSets[xref.To] == nil { - callerSets[xref.To] = map[int64]bool{} - } - callerSets[xref.To][fnEntry] = true - if !queued[xref.To] { - queued[xref.To] = true - queue = append(queue, xref.To) - } - } - } - - if len(queue) > 0 || program.Instructions >= maxInstr { - program.Truncated = true - } - for _, fn := range program.Functions { - for caller := range callerSets[fn.Entry] { - fn.CalledBy = append(fn.CalledBy, caller) - } - sort.Slice(fn.CalledBy, func(i, j int) bool { return fn.CalledBy[i] < fn.CalledBy[j] }) - } - for target := range externalSet { - program.ExternalCalls = append(program.ExternalCalls, target) - } - sort.Slice(program.Functions, func(i, j int) bool { return program.Functions[i].Entry < program.Functions[j].Entry }) - sort.Slice(program.Edges, func(i, j int) bool { - if program.Edges[i].Caller != program.Edges[j].Caller { - return program.Edges[i].Caller < program.Edges[j].Caller - } - if program.Edges[i].CallSite != program.Edges[j].CallSite { - return program.Edges[i].CallSite < program.Edges[j].CallSite - } - return program.Edges[i].Callee < program.Edges[j].Callee - }) - sort.Slice(program.ExternalCalls, func(i, j int) bool { return program.ExternalCalls[i] < program.ExternalCalls[j] }) - return program -} - -func summarizeFunction(cfg *CFG, name string) *Function { - if name == "" { - name = unknownFunctionName(cfg.Entry) - } - fn := &Function{ - Name: name, Entry: cfg.Entry, Blocks: cfg.Blocks, - Calls: append([]int64(nil), cfg.Calls...), Unknown: cfg.Unknown, - } - edges := 0 - for _, block := range cfg.Blocks { - if block.End > fn.End { - fn.End = block.End - } - fn.Instructions += len(block.Instrs) - edges += len(block.Succs) - } - fn.Complexity = edges - len(cfg.Blocks) + 2 - if fn.Complexity < 1 { - fn.Complexity = 1 - } - return fn -} - -func unknownFunctionName(entry int64) string { - return "sub_" + strconv.FormatInt(entry, 16) -} - -func buildBlocks(decoded map[int64]*rawInstr, inRange func(int64) bool) []*Block { - if len(decoded) == 0 { - return nil - } - addrs := make([]int64, 0, len(decoded)) - for a := range decoded { - addrs = append(addrs, a) - } - sort.Slice(addrs, func(i, j int) bool { return addrs[i] < addrs[j] }) - - // Leaders: first instr, every branch target, and the instruction after any - // branch/call/ret. - leaders := map[int64]bool{addrs[0]: true} - for _, a := range addrs { - ri := decoded[a] - next := a + int64(ri.length) - switch ri.class { - case "ret": - if _, ok := decoded[next]; ok { - leaders[next] = true - } - case "jmp": - if ri.hasTgt { - if _, ok := decoded[ri.target]; ok { - leaders[ri.target] = true - } - } - if _, ok := decoded[next]; ok { - leaders[next] = true - } - case "cond", "call": - if ri.hasTgt { - if _, ok := decoded[ri.target]; ok { - leaders[ri.target] = true - } - } - if _, ok := decoded[next]; ok { - leaders[next] = true - } - } - } - - var blocks []*Block - var cur *Block - flush := func() { - if cur != nil { - blocks = append(blocks, cur) - cur = nil - } - } - for idx, a := range addrs { - ri := decoded[a] - if leaders[a] { - flush() - cur = &Block{Start: a} - } - if cur == nil { - cur = &Block{Start: a} - } - cur.Instrs = append(cur.Instrs, ri.m) - next := a + int64(ri.length) - cur.End = next - - // Decide whether this instruction terminates the block. - isLast := idx == len(addrs)-1 - nextIsLeader := false - if !isLast { - if leaders[addrs[idx+1]] { - nextIsLeader = true - } - } - terminator := ri.class == "ret" || ri.class == "jmp" || ri.class == "cond" - if terminator || nextIsLeader || isLast { - cur.Kind = blockKind(ri.class) - switch ri.class { - case "ret": - // no successors - case "jmp": - if ri.hasTgt && inRange(ri.target) { - cur.Succs = []int64{ri.target} - } - case "cond": - if ri.hasTgt && inRange(ri.target) { - cur.Succs = append(cur.Succs, ri.target) - } - if _, ok := decoded[next]; ok { - cur.Succs = append(cur.Succs, next) - } - default: - if _, ok := decoded[next]; ok { - cur.Succs = []int64{next} - } - } - flush() - } - } - flush() - return blocks -} - -func blockKind(class string) string { - switch class { - case "ret", "jmp", "cond", "call": - return class - default: - return "fallthrough" - } -} - -// decodeAt decodes a single instruction at address addr (buf starts at base). -func decodeAt(buf []byte, addr, base int64, arch string) *rawInstr { - off := int(addr - base) - if off < 0 || off >= len(buf) { - return nil - } - end := off + 16 // max x86 instruction length; fixed-width arches need <= 4 - if end > len(buf) { - end = len(buf) - } - res := Disassemble(buf[off:end], addr, arch, 1) - if len(res) == 0 { - return nil - } - m := res[0] - ri := &rawInstr{addr: addr, m: m} - ri.length = byteCount(m["bytes"].(string)) - if ri.length <= 0 { - ri.length = 1 - } - ri.mnem, _ = m["mnemonic"].(string) - ops, _ := m["operands"].(string) - ri.class = classify(ri.mnem) - if t, ok := directTarget(ops); ok { - ri.target, ri.hasTgt = t, true - } - if dataReferencing(ri.class) { - if d, ok := dataTarget(ops); ok { - ri.data, ri.hasDat = d, true - } - } - return ri -} - -func byteCount(s string) int { - if s == "" { - return 0 - } - return len(strings.Fields(s)) -} - -func classify(mnem string) string { - switch mnem { - case "ret", "retf", "iret": - return "ret" - case "jmp", "jmpf", "b", "br": - return "jmp" - case "call", "callf", "bl", "blr", "jal", "jalr": - return "call" - case "cbz", "cbnz", "tbz", "tbnz", "loop", "loope", "loopne", "jecxz", "jrcxz": - return "cond" - } - if strings.HasPrefix(mnem, "b.") { // AArch64 conditional branch - return "cond" - } - if strings.HasPrefix(mnem, "j") { // x86 Jcc (jmp already handled above) - return "cond" - } - if strings.HasPrefix(mnem, "beq") || strings.HasPrefix(mnem, "bne") { // MIPS - return "cond" - } - return "normal" -} - -func dataReferencing(class string) bool { - // RIP-relative resolved targets show up as "; 0x.." comments on any - // instruction; only mine them on non-control-flow ops as data xrefs. - return class == "normal" || class == "call" -} - -// directTarget extracts a branch/call target when the final operand is a bare -// absolute address (a direct, statically known target). Register/memory -// indirect operands return false. -func directTarget(operands string) (int64, bool) { - if i := strings.Index(operands, ";"); i >= 0 { - operands = operands[:i] - } - if c := strings.LastIndex(operands, ","); c >= 0 { - operands = operands[c+1:] - } - operands = strings.TrimSpace(operands) - return parseHexAddr(operands) -} - -// dataTarget extracts a resolved address from a "; 0x.." trailing comment -// (e.g. an x86 RIP-relative memory operand). -func dataTarget(operands string) (int64, bool) { - i := strings.Index(operands, ";") - if i < 0 { - return 0, false - } - return parseHexAddr(strings.TrimSpace(operands[i+1:])) -} - -func parseHexAddr(s string) (int64, bool) { - if !strings.HasPrefix(s, "0x") { - return 0, false - } - v, err := strconv.ParseUint(s[2:], 16, 64) - if err != nil { - return 0, false - } - return int64(v), true -} diff --git a/internal/leviathan/disasm/image.go b/internal/leviathan/disasm/image.go deleted file mode 100644 index 9e0f38f..0000000 --- a/internal/leviathan/disasm/image.go +++ /dev/null @@ -1,74 +0,0 @@ -package disasm - -import ( - "github.com/andydixon/link/internal/leviathan/document" - "github.com/andydixon/link/internal/leviathan/parsers" -) - -// ImageAnalysis is a metadata-seeded analysis across file-backed executable -// regions in an ELF or PE image. -type ImageAnalysis struct { - Info *parsers.ExecutableInfo `json:"info"` - Programs []*Program `json:"programs"` - Truncated bool `json:"truncated"` -} - -// DiscoverExecutable analyzes executable regions using header entries, -// symbols and exports as roots. Budgets apply across the whole image. -func DiscoverExecutable(d *document.Document, maxBytes int64, maxFunctions, maxInstr int) (*ImageAnalysis, error) { - info, err := parsers.InspectExecutable(d) - if err != nil { - return nil, err - } - if maxBytes <= 0 || maxBytes > 32<<20 { - maxBytes = 8 << 20 - } - if maxFunctions <= 0 || maxFunctions > 4096 { - maxFunctions = 512 - } - if maxInstr <= 0 || maxInstr > 1_000_000 { - maxInstr = 131_072 - } - analysis := &ImageAnalysis{Info: info} - remainingBytes := maxBytes - remainingFunctions := maxFunctions - remainingInstr := maxInstr - for _, region := range info.Regions { - if remainingBytes <= 0 || remainingFunctions <= 0 || remainingInstr <= 0 { - analysis.Truncated = true - break - } - length := region.Size - if length > 4<<20 { - length = 4 << 20 - analysis.Truncated = true - } - if length > remainingBytes { - length = remainingBytes - analysis.Truncated = true - } - if length <= 0 { - continue - } - buf, err := d.Range(region.Offset, length) - if err != nil { - return nil, err - } - seeds := make([]Seed, 0) - for _, seed := range info.Seeds { - if seed.Offset >= region.Offset && seed.Offset < region.Offset+length { - seeds = append(seeds, Seed{Name: seed.Name, Entry: seed.Offset}) - } - } - if len(seeds) == 0 { - seeds = append(seeds, Seed{Name: region.Name, Entry: region.Offset}) - } - program := DiscoverFunctionsFromSeeds(buf, region.Offset, info.Arch, seeds, remainingFunctions, remainingInstr) - analysis.Programs = append(analysis.Programs, program) - remainingBytes -= length - remainingFunctions -= len(program.Functions) - remainingInstr -= program.Instructions - analysis.Truncated = analysis.Truncated || program.Truncated - } - return analysis, nil -} diff --git a/internal/leviathan/disasm/mips.go b/internal/leviathan/disasm/mips.go deleted file mode 100644 index 6f1f6a9..0000000 --- a/internal/leviathan/disasm/mips.go +++ /dev/null @@ -1,144 +0,0 @@ -package disasm - -import ( - "encoding/binary" - "fmt" -) - -func diMIPS32(buf []byte, base int64, little bool, maxInstr int) []map[string]any { - out := []map[string]any{} - for i := 0; i+4 <= len(buf) && len(out) < maxInstr; i += 4 { - var w uint32 - if little { - w = binary.LittleEndian.Uint32(buf[i : i+4]) - } else { - w = binary.BigEndian.Uint32(buf[i : i+4]) - } - mn, ops, conf := decodeMIPS32(w, base+int64(i)) - out = append(out, map[string]any{ - "offset": base + int64(i), - "bytes": hexStr(buf[i : i+4]), - "mnemonic": mn, - "operands": ops, - "confident": conf, - }) - } - for i := len(buf) - len(buf)%4; i < len(buf) && len(out) < maxInstr; i++ { - out = append(out, map[string]any{ - "offset": base + int64(i), - "bytes": hexStr(buf[i : i+1]), - "mnemonic": "db", - "operands": hexCommaList(buf[i : i+1]), - "confident": false, - }) - } - return out -} - -func decodeMIPS32(w uint32, pc int64) (string, string, bool) { - if w == 0 { - return "nop", "", true - } - op := (w >> 26) & 0x3f - rs := (w >> 21) & 0x1f - rt := (w >> 16) & 0x1f - rd := (w >> 11) & 0x1f - shamt := (w >> 6) & 0x1f - funct := w & 0x3f - imm := uint16(w) - simm := int16(imm) - target26 := w & 0x03ffffff - - switch op { - case 0x00: - switch funct { - case 0x00: - return "sll", fmt.Sprintf("%s, %s, %d", mipsReg(rd), mipsReg(rt), shamt), true - case 0x02: - return "srl", fmt.Sprintf("%s, %s, %d", mipsReg(rd), mipsReg(rt), shamt), true - case 0x03: - return "sra", fmt.Sprintf("%s, %s, %d", mipsReg(rd), mipsReg(rt), shamt), true - case 0x08: - return "jr", mipsReg(rs), true - case 0x09: - return "jalr", fmt.Sprintf("%s, %s", mipsReg(rd), mipsReg(rs)), true - case 0x0c: - return "syscall", fmt.Sprintf("0x%x", (w>>6)&0xfffff), true - case 0x20: - return "add", fmt.Sprintf("%s, %s, %s", mipsReg(rd), mipsReg(rs), mipsReg(rt)), true - case 0x21: - return "addu", fmt.Sprintf("%s, %s, %s", mipsReg(rd), mipsReg(rs), mipsReg(rt)), true - case 0x22: - return "sub", fmt.Sprintf("%s, %s, %s", mipsReg(rd), mipsReg(rs), mipsReg(rt)), true - case 0x23: - return "subu", fmt.Sprintf("%s, %s, %s", mipsReg(rd), mipsReg(rs), mipsReg(rt)), true - case 0x24: - return "and", fmt.Sprintf("%s, %s, %s", mipsReg(rd), mipsReg(rs), mipsReg(rt)), true - case 0x25: - return "or", fmt.Sprintf("%s, %s, %s", mipsReg(rd), mipsReg(rs), mipsReg(rt)), true - case 0x26: - return "xor", fmt.Sprintf("%s, %s, %s", mipsReg(rd), mipsReg(rs), mipsReg(rt)), true - case 0x27: - return "nor", fmt.Sprintf("%s, %s, %s", mipsReg(rd), mipsReg(rs), mipsReg(rt)), true - case 0x2a: - return "slt", fmt.Sprintf("%s, %s, %s", mipsReg(rd), mipsReg(rs), mipsReg(rt)), true - } - case 0x02: - return "j", fmt.Sprintf("0x%x", mipsJumpTarget(pc, target26)), true - case 0x03: - return "jal", fmt.Sprintf("0x%x", mipsJumpTarget(pc, target26)), true - case 0x04: - return "beq", fmt.Sprintf("%s, %s, 0x%x", mipsReg(rs), mipsReg(rt), mipsBranchTarget(pc, simm)), true - case 0x05: - return "bne", fmt.Sprintf("%s, %s, 0x%x", mipsReg(rs), mipsReg(rt), mipsBranchTarget(pc, simm)), true - case 0x08: - return "addi", fmt.Sprintf("%s, %s, %d", mipsReg(rt), mipsReg(rs), simm), true - case 0x09: - return "addiu", fmt.Sprintf("%s, %s, %d", mipsReg(rt), mipsReg(rs), simm), true - case 0x0a: - return "slti", fmt.Sprintf("%s, %s, %d", mipsReg(rt), mipsReg(rs), simm), true - case 0x0c: - return "andi", fmt.Sprintf("%s, %s, 0x%x", mipsReg(rt), mipsReg(rs), imm), true - case 0x0d: - return "ori", fmt.Sprintf("%s, %s, 0x%x", mipsReg(rt), mipsReg(rs), imm), true - case 0x0e: - return "xori", fmt.Sprintf("%s, %s, 0x%x", mipsReg(rt), mipsReg(rs), imm), true - case 0x0f: - return "lui", fmt.Sprintf("%s, 0x%x", mipsReg(rt), imm), true - case 0x20: - return "lb", mipsMem(rt, rs, simm), true - case 0x21: - return "lh", mipsMem(rt, rs, simm), true - case 0x23: - return "lw", mipsMem(rt, rs, simm), true - case 0x28: - return "sb", mipsMem(rt, rs, simm), true - case 0x29: - return "sh", mipsMem(rt, rs, simm), true - case 0x2b: - return "sw", mipsMem(rt, rs, simm), true - } - return ".word", fmt.Sprintf("0x%08x", w), false -} - -func mipsJumpTarget(pc int64, target uint32) int64 { - upper := uint64(pc+4) & 0xfffffffff0000000 - return int64(upper | uint64(target<<2)) -} - -func mipsBranchTarget(pc int64, imm int16) int64 { - return pc + 4 + int64(imm)*4 -} - -func mipsMem(rt, rs uint32, off int16) string { - return fmt.Sprintf("%s, %d(%s)", mipsReg(rt), off, mipsReg(rs)) -} - -func mipsReg(r uint32) string { - return [...]string{ - "$zero", "$at", "$v0", "$v1", "$a0", "$a1", "$a2", "$a3", - "$t0", "$t1", "$t2", "$t3", "$t4", "$t5", "$t6", "$t7", - "$s0", "$s1", "$s2", "$s3", "$s4", "$s5", "$s6", "$s7", - "$t8", "$t9", "$k0", "$k1", "$gp", "$sp", "$fp", "$ra", - }[r&0x1f] -} diff --git a/internal/leviathan/disasm/thumb.go b/internal/leviathan/disasm/thumb.go deleted file mode 100644 index 5fee9e9..0000000 --- a/internal/leviathan/disasm/thumb.go +++ /dev/null @@ -1,117 +0,0 @@ -package disasm - -import ( - "encoding/binary" - "fmt" -) - -func diThumb16(buf []byte, base int64, maxInstr int) []map[string]any { - out := []map[string]any{} - for i := 0; i+2 <= len(buf) && len(out) < maxInstr; i += 2 { - h := binary.LittleEndian.Uint16(buf[i : i+2]) - mn, ops, conf := decodeThumb16(h, base+int64(i)) - out = append(out, map[string]any{ - "offset": base + int64(i), - "bytes": hexStr(buf[i : i+2]), - "mnemonic": mn, - "operands": ops, - "confident": conf, - }) - } - for i := len(buf) - len(buf)%2; i < len(buf) && len(out) < maxInstr; i++ { - out = append(out, map[string]any{ - "offset": base + int64(i), - "bytes": hexStr(buf[i : i+1]), - "mnemonic": "db", - "operands": hexCommaList(buf[i : i+1]), - "confident": false, - }) - } - return out -} - -func decodeThumb16(h uint16, pc int64) (string, string, bool) { - switch { - case h == 0xbf00: - return "nop", "", true - case h&0xff87 == 0x4700: - return "bx", thumbHighReg((h >> 3) & 0xf), true - case h&0xff87 == 0x4780: - return "blx", thumbHighReg((h >> 3) & 0xf), true - case h&0xf800 == 0xe000: - imm := signExtend64(uint64(h&0x07ff)<<1, 12) - return "b", fmt.Sprintf("0x%x", pc+4+imm), true - case h&0xf000 == 0xd000 && h&0x0f00 != 0x0f00: - cond := uint32((h >> 8) & 0xf) - imm := signExtend64(uint64(h&0x00ff)<<1, 9) - return "b" + armCondSuffix(cond), fmt.Sprintf("0x%x", pc+4+imm), true - case h&0xff00 == 0xdf00: - return "svc", fmt.Sprintf("#0x%x", h&0x00ff), true - case h&0xfe00 == 0xb400: - mask := uint32(h & 0x00ff) - if h&0x0100 != 0 { - mask |= 1 << 14 - } - return "push", armRegList(mask), true - case h&0xfe00 == 0xbc00: - mask := uint32(h & 0x00ff) - if h&0x0100 != 0 { - mask |= 1 << 15 - } - return "pop", armRegList(mask), true - case h&0xf800 == 0x2000: - rd := uint32((h >> 8) & 0x7) - return "movs", fmt.Sprintf("%s, #0x%x", armReg(rd), h&0x00ff), true - case h&0xf800 == 0x2800: - rn := uint32((h >> 8) & 0x7) - return "cmp", fmt.Sprintf("%s, #0x%x", armReg(rn), h&0x00ff), true - case h&0xf800 == 0x3000: - rd := uint32((h >> 8) & 0x7) - return "adds", fmt.Sprintf("%s, #0x%x", armReg(rd), h&0x00ff), true - case h&0xf800 == 0x3800: - rd := uint32((h >> 8) & 0x7) - return "subs", fmt.Sprintf("%s, #0x%x", armReg(rd), h&0x00ff), true - case h&0xfc00 == 0x4000: - op := (h >> 6) & 0xf - rs := uint32((h >> 3) & 0x7) - rd := uint32(h & 0x7) - names := [...]string{"ands", "eors", "lsls", "lsrs", "asrs", "adcs", "sbcs", "rors", "tst", "rsbs", "cmp", "cmn", "orrs", "muls", "bics", "mvns"} - mn := names[op] - if op == 8 || op == 10 || op == 11 { - return mn, fmt.Sprintf("%s, %s", armReg(rd), armReg(rs)), true - } - return mn, fmt.Sprintf("%s, %s", armReg(rd), armReg(rs)), true - case h&0xfe00 == 0x1800: - rm := uint32((h >> 6) & 0x7) - rn := uint32((h >> 3) & 0x7) - rd := uint32(h & 0x7) - mn := "adds" - if h&0x0200 != 0 { - mn = "subs" - } - return mn, fmt.Sprintf("%s, %s, %s", armReg(rd), armReg(rn), armReg(rm)), true - case h&0xe000 == 0x6000: - imm := uint32((h>>6)&0x1f) * 4 - rn := uint32((h >> 3) & 0x7) - rd := uint32(h & 0x7) - mn := "str" - if h&0x0800 != 0 { - mn = "ldr" - } - return mn, fmt.Sprintf("%s, [%s, #0x%x]", armReg(rd), armReg(rn), imm), true - } - return ".hword", fmt.Sprintf("0x%04x", h), false -} - -func thumbHighReg(r uint16) string { - if r == 13 { - return "sp" - } - if r == 14 { - return "lr" - } - if r == 15 { - return "pc" - } - return fmt.Sprintf("r%d", r) -} diff --git a/internal/leviathan/disasm/x86.go b/internal/leviathan/disasm/x86.go deleted file mode 100644 index 90fb21d..0000000 --- a/internal/leviathan/disasm/x86.go +++ /dev/null @@ -1,91 +0,0 @@ -// Package disasm provides a honest, limited heuristic disassembler. -// It does NOT aim to be a full x86/ARM decoder. It recognises a -// subset of common opcodes so the UI can show plausible disassembly -// for inspection, and is explicit in the output when instructions -// are unknown. -package disasm - -import "fmt" - -// Instr is one decoded instruction. -type Instr struct { - Offset int64 - Bytes []byte - Mnemonic string - Operands string - Confident bool -} - -// Disassemble returns a slice of Instr for buf starting at `base`. -// arch is one of "x86", "x86_64", "arm", "thumb", "aarch64", "mips". -// For unsupported arches we emit a byte stream with "db" entries. -func Disassemble(buf []byte, base int64, arch string, maxInstr int) []map[string]any { - if maxInstr <= 0 { - maxInstr = 4096 - } - switch arch { - case "x86", "x86_64": - return diX86(buf, base, arch == "x86_64", maxInstr) - case "aarch64", "arm64": - return diAArch64(buf, base, maxInstr) - case "arm", "arm32": - return diARM32(buf, base, maxInstr) - case "thumb", "thumb16", "t16": - return diThumb16(buf, base, maxInstr) - case "mips", "mipsbe", "mips32": - return diMIPS32(buf, base, false, maxInstr) - case "mipsle", "mipsel": - return diMIPS32(buf, base, true, maxInstr) - } - return diRaw(buf, base, arch, maxInstr) -} - -// diRaw emits `db` lines 16 bytes at a time for unsupported arches. -func diRaw(buf []byte, base int64, arch string, maxInstr int) []map[string]any { - out := []map[string]any{} - step := 16 - for i := 0; i < len(buf) && len(out) < maxInstr; i += step { - end := i + step - if end > len(buf) { - end = len(buf) - } - out = append(out, map[string]any{ - "offset": base + int64(i), - "bytes": hexStr(buf[i:end]), - "mnemonic": "db", - "operands": hexCommaList(buf[i:end]), - "confident": false, - "note": arch + " decode not implemented; raw bytes only", - }) - } - return out -} - -// diX86 decodes x86 / x86-64 instructions. The full decoder lives in -// x86_decode.go; this dispatcher only forwards to it. -func diX86(buf []byte, base int64, is64 bool, maxInstr int) []map[string]any { - return decodeX86(buf, base, is64, maxInstr) -} - -func hexStr(b []byte) string { - const h = "0123456789abcdef" - out := make([]byte, 0, len(b)*3) - for i, c := range b { - if i > 0 { - out = append(out, ' ') - } - out = append(out, h[c>>4], h[c&0x0f]) - } - return string(out) -} - -func hexCommaList(b []byte) string { - out := "" - for i, c := range b { - if i > 0 { - out += "," - } - out += fmt.Sprintf("0x%02x", c) - } - return out -} diff --git a/internal/leviathan/disasm/x86_decode.go b/internal/leviathan/disasm/x86_decode.go deleted file mode 100644 index 7fc23c6..0000000 --- a/internal/leviathan/disasm/x86_decode.go +++ /dev/null @@ -1,1172 +0,0 @@ -package disasm - -import "fmt" - -// x86_decode.go is a from-scratch x86 / x86-64 instruction decoder. Unlike -// the previous heuristic, it tracks legacy prefixes, the REX prefix, and -// fully decodes ModR/M + SIB + displacement + immediate operands so that -// instruction *lengths* are correct (which keeps a linear sweep in sync) and -// operands are rendered in Intel syntax. -// -// Coverage: the complete general-purpose integer ISA (ALU groups, MOV/LEA, -// shifts/rotates, the F6/F7/FE/FF groups, string ops, stack ops, all near -// branches), the two-byte 0F map (Jcc/SETcc/CMOVcc, MOVZX/MOVSX, IMUL, bit -// tests, CMPXCHG/XADD, BSWAP, SHLD/SHRD, system ops) and the common SSE/SSE2 -// moves and arithmetic. VEX/EVEX (AVX), x87 detail and a few rare encodings -// fall back to a one-byte `db` resync, exactly like objdump's "(bad)". - -type x86dec struct { - buf []byte - pos int - base int64 - is64 bool - - opSize int // 16/32/64 effective operand size - addrSize int // 16/32/64 effective address size - has66 bool - seg string - rep string // "rep" / "repne" / "" - lock bool - - rexW, rexR, rexX, rexB bool - hasRex bool - - trunc bool // ran past end of buffer mid-instruction - hasRip bool // a RIP-relative memory operand was decoded - ripDisp int64 // its displacement (resolved once length is known) -} - -func decodeX86(buf []byte, base int64, is64 bool, maxInstr int) []map[string]any { - out := []map[string]any{} - i := 0 - for i < len(buf) && len(out) < maxInstr { - d := &x86dec{buf: buf, pos: i, base: base, is64: is64} - mn, ops, conf := d.decode() - var length int - if d.trunc || mn == "" { - length = 1 - mn = "db" - ops = fmt.Sprintf("0x%02x", buf[i]) - conf = false - } else { - length = d.pos - i - if length <= 0 { - length = 1 - } - if i+length > len(buf) { - length = len(buf) - i - } - if d.hasRip { - abs := base + int64(i+length) + d.ripDisp - ops += fmt.Sprintf(" ; 0x%x", abs) - } - } - out = append(out, map[string]any{ - "offset": base + int64(i), - "bytes": hexStr(buf[i : i+length]), - "mnemonic": mn, - "operands": ops, - "confident": conf, - }) - i += length - } - return out -} - -// ---- byte readers (set trunc on overrun, never panic) ---- - -func (d *x86dec) u8() uint8 { - if d.pos >= len(d.buf) { - d.trunc = true - return 0 - } - b := d.buf[d.pos] - d.pos++ - return b -} - -func (d *x86dec) u16() uint16 { return uint16(d.u8()) | uint16(d.u8())<<8 } -func (d *x86dec) u32() uint32 { - return uint32(d.u8()) | uint32(d.u8())<<8 | uint32(d.u8())<<16 | uint32(d.u8())<<24 -} -func (d *x86dec) u64() uint64 { return uint64(d.u32()) | uint64(d.u32())<<32 } - -func (d *x86dec) peek() (uint8, bool) { - if d.pos >= len(d.buf) { - return 0, false - } - return d.buf[d.pos], true -} - -// ---- prefix + opcode dispatch ---- - -func (d *x86dec) decode() (string, string, bool) { - d.opSize = 32 - if d.is64 { - d.addrSize = 64 - } else { - d.addrSize = 32 - } -prefixes: - for { - b, ok := d.peek() - if !ok { - return "", "", false - } - switch b { - case 0x66: - d.has66 = true - d.opSize = 16 - d.pos++ - continue - case 0x67: - if d.is64 { - d.addrSize = 32 - } else { - d.addrSize = 16 - } - d.pos++ - continue - case 0xF0: - d.lock = true - d.pos++ - continue - case 0xF2: - d.rep = "repne" - d.pos++ - continue - case 0xF3: - d.rep = "rep" - d.pos++ - continue - case 0x2E: - d.seg = "cs" - d.pos++ - continue - case 0x36: - d.seg = "ss" - d.pos++ - continue - case 0x3E: - d.seg = "ds" - d.pos++ - continue - case 0x26: - d.seg = "es" - d.pos++ - continue - case 0x64: - d.seg = "fs" - d.pos++ - continue - case 0x65: - d.seg = "gs" - d.pos++ - continue - } - if d.is64 && b >= 0x40 && b <= 0x4f { - d.hasRex = true - d.rexW = b&0x08 != 0 - d.rexR = b&0x04 != 0 - d.rexX = b&0x02 != 0 - d.rexB = b&0x01 != 0 - d.pos++ - } - break prefixes - } - if d.rexW { - d.opSize = 64 - } - - op := d.u8() - if d.trunc { - return "", "", false - } - if op == 0x0f { - return d.decode0F() - } - return d.decode1(op) -} - -// ---- register naming ---- - -var reg8 = [...]string{"al", "cl", "dl", "bl", "ah", "ch", "dh", "bh"} -var reg8rex = [...]string{"al", "cl", "dl", "bl", "spl", "bpl", "sil", "dil", - "r8b", "r9b", "r10b", "r11b", "r12b", "r13b", "r14b", "r15b"} -var reg16 = [...]string{"ax", "cx", "dx", "bx", "sp", "bp", "si", "di", - "r8w", "r9w", "r10w", "r11w", "r12w", "r13w", "r14w", "r15w"} -var reg32 = [...]string{"eax", "ecx", "edx", "ebx", "esp", "ebp", "esi", "edi", - "r8d", "r9d", "r10d", "r11d", "r12d", "r13d", "r14d", "r15d"} -var reg64 = [...]string{"rax", "rcx", "rdx", "rbx", "rsp", "rbp", "rsi", "rdi", - "r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15"} - -func (d *x86dec) regName(size, n int) string { - switch size { - case 8: - if d.hasRex { - return reg8rex[n&15] - } - return reg8[n&7] - case 16: - return reg16[n&15] - case 64: - return reg64[n&15] - case 128: - return fmt.Sprintf("xmm%d", n&15) - default: - return reg32[n&15] - } -} - -func accReg(size int) string { - switch size { - case 8: - return "al" - case 16: - return "ax" - case 64: - return "rax" - default: - return "eax" - } -} - -func ptrSize(size int) string { - switch size { - case 8: - return "byte ptr" - case 16: - return "word ptr" - case 64: - return "qword ptr" - case 128: - return "xmmword ptr" - default: - return "dword ptr" - } -} - -// ---- ModR/M + SIB ---- - -// modrm reads the ModR/M byte (and any SIB/displacement) and returns the -// extended reg field plus the textual r/m operand sized to rmSize. -func (d *x86dec) modrm(rmSize int) (int, string) { - return d.modrmEx(rmSize, true) -} - -func (d *x86dec) modrmEx(rmSize int, ptr bool) (int, string) { - m := d.u8() - mod := int(m >> 6) - reg := int((m >> 3) & 7) - if d.rexR { - reg += 8 - } - rm := int(m & 7) - if mod == 3 { - r := rm - if d.rexB { - r += 8 - } - return reg, d.regName(rmSize, r) - } - if d.addrSize == 16 { - return reg, d.mem16(mod, rm, rmSize, ptr) - } - return reg, d.mem32(mod, rm, rmSize, ptr) -} - -func (d *x86dec) mem32(mod, rm, opsize int, ptr bool) string { - var base, index string - scale := 1 - dispSize := 0 - ripRel := false - - switch { - case rm == 4: - sib := d.u8() - scale = 1 << ((sib >> 6) & 3) - idx := int((sib >> 3) & 7) - baseRaw := int(sib & 7) - if !(idx == 4 && !d.rexX) { - ir := idx - if d.rexX { - ir += 8 - } - index = d.regName(d.addrSize, ir) - } - if baseRaw == 5 && mod == 0 { - dispSize = 32 - } else { - br := baseRaw - if d.rexB { - br += 8 - } - base = d.regName(d.addrSize, br) - } - case rm == 5 && mod == 0: - dispSize = 32 - if d.is64 { - ripRel = true - } - default: - br := rm - if d.rexB { - br += 8 - } - base = d.regName(d.addrSize, br) - } - - switch mod { - case 1: - if dispSize == 0 { - dispSize = 8 - } - case 2: - if dispSize == 0 { - dispSize = 32 - } - } - - var disp int64 - hasDisp := dispSize != 0 - switch dispSize { - case 8: - disp = int64(int8(d.u8())) - case 32: - disp = int64(int32(d.u32())) - } - return d.formatMem(opsize, base, index, scale, disp, hasDisp, ripRel, ptr) -} - -var amode16 = [...]string{"bx+si", "bx+di", "bp+si", "bp+di", "si", "di", "bp", "bx"} - -func (d *x86dec) mem16(mod, rm, opsize int, ptr bool) string { - base := amode16[rm] - dispSize := 0 - if rm == 6 && mod == 0 { - base = "" - dispSize = 16 - } - switch mod { - case 1: - dispSize = 8 - case 2: - dispSize = 16 - } - var disp int64 - hasDisp := dispSize != 0 - switch dispSize { - case 8: - disp = int64(int8(d.u8())) - case 16: - disp = int64(int16(d.u16())) - } - return d.formatMem(opsize, base, "", 1, disp, hasDisp, false, ptr) -} - -func (d *x86dec) formatMem(opsize int, base, index string, scale int, disp int64, hasDisp, ripRel, ptr bool) string { - var inner string - if ripRel { - d.hasRip = true - d.ripDisp = disp - inner = "rip" + signHex(disp) - } else { - first := true - if base != "" { - inner += base - first = false - } - if index != "" { - if !first { - inner += "+" - } - inner += index - if scale != 1 { - inner += fmt.Sprintf("*%d", scale) - } - first = false - } - if hasDisp { - if first { - inner += fmt.Sprintf("0x%x", uint64(disp)&addrMask(d.addrSize)) - } else { - inner += signHex(disp) - } - } else if first { - inner += "0x0" - } - } - seg := "" - if d.seg != "" { - seg = d.seg + ":" - } - if ptr { - return ptrSize(opsize) + " " + seg + "[" + inner + "]" - } - return seg + "[" + inner + "]" -} - -func addrMask(size int) uint64 { - switch size { - case 16: - return 0xffff - case 32: - return 0xffffffff - default: - return ^uint64(0) - } -} - -func signHex(v int64) string { - if v < 0 { - return fmt.Sprintf("-0x%x", -v) - } - return fmt.Sprintf("+0x%x", v) -} - -// ---- immediates as text ---- - -func (d *x86dec) immZ() string { - if d.opSize == 16 { - return fmt.Sprintf("0x%x", d.u16()) - } - return fmt.Sprintf("0x%x", d.u32()) -} - -func (d *x86dec) immB() string { return fmt.Sprintf("0x%x", d.u8()) } -func (d *x86dec) immBs() string { return fmt.Sprintf("0x%x", uint64(int64(int8(d.u8())))) } - -// ---- condition codes ---- - -var ccName = [...]string{"o", "no", "b", "ae", "e", "ne", "be", "a", - "s", "ns", "p", "np", "l", "ge", "le", "g"} - -func (d *x86dec) rel8() string { - rel := int64(int8(d.u8())) - return fmt.Sprintf("0x%x", d.base+int64(d.pos)+rel) -} - -func (d *x86dec) rel32() string { - rel := int64(int32(d.u32())) - return fmt.Sprintf("0x%x", d.base+int64(d.pos)+rel) -} - -// stackSize is the operand size for implicit-stack ops (push/pop/call/jmp), -// which default to 64-bit in long mode. -func (d *x86dec) stackSize() int { - if d.is64 { - if d.has66 { - return 16 - } - return 64 - } - return d.opSize -} - -var aluName = [...]string{"add", "or", "adc", "sbb", "and", "sub", "xor", "cmp"} -var shiftName = [...]string{"rol", "ror", "rcl", "rcr", "shl", "shr", "shl", "sar"} - -// ---- one-byte opcode map ---- - -func (d *x86dec) decode1(op uint8) (string, string, bool) { - // ALU group: 0x00-0x3F in the standard 8-wide pattern. - if op <= 0x3f && (op&7) <= 5 { - name := aluName[(op>>3)&7] - switch op & 7 { - case 0: - reg, rm := d.modrm(8) - return name, rm + ", " + d.regName(8, reg), true - case 1: - reg, rm := d.modrm(d.opSize) - return name, rm + ", " + d.regName(d.opSize, reg), true - case 2: - reg, rm := d.modrm(8) - return name, d.regName(8, reg) + ", " + rm, true - case 3: - reg, rm := d.modrm(d.opSize) - return name, d.regName(d.opSize, reg) + ", " + rm, true - case 4: - return name, "al, " + d.immB(), true - case 5: - return name, accReg(d.opSize) + ", " + d.immZ(), true - } - } - - switch { - case op == 0x0f: - return d.decode0F() - - // INC/DEC reg (32-bit only; REX in 64-bit is handled as a prefix). - case op >= 0x40 && op <= 0x4f && !d.is64: - name := "inc" - if op >= 0x48 { - name = "dec" - } - return name, d.regName(d.opSize, int(op&7)), true - - case op >= 0x50 && op <= 0x57: - r := int(op - 0x50) - if d.rexB { - r += 8 - } - return "push", d.regName(d.stackSize(), r), true - case op >= 0x58 && op <= 0x5f: - r := int(op - 0x58) - if d.rexB { - r += 8 - } - return "pop", d.regName(d.stackSize(), r), true - - case op == 0x63: // MOVSXD (64) / ARPL (32) - if d.is64 { - reg, rm := d.modrm(32) - return "movsxd", d.regName(64, reg) + ", " + rm, true - } - reg, rm := d.modrm(16) - return "arpl", rm + ", " + d.regName(16, reg), true - - case op == 0x68: - return "push", d.immZ(), true - case op == 0x6a: - return "push", d.immBs(), true - case op == 0x69: - reg, rm := d.modrm(d.opSize) - return "imul", d.regName(d.opSize, reg) + ", " + rm + ", " + d.immZ(), true - case op == 0x6b: - reg, rm := d.modrm(d.opSize) - return "imul", d.regName(d.opSize, reg) + ", " + rm + ", " + d.immBs(), true - - case op >= 0x70 && op <= 0x7f: - return "j" + ccName[op-0x70], d.rel8(), true - - case op == 0x80: // GRP1 Eb, ib - reg, rm := d.modrm(8) - return aluName[reg&7], rm + ", " + d.immB(), true - case op == 0x81: // GRP1 Ev, iz - reg, rm := d.modrm(d.opSize) - return aluName[reg&7], rm + ", " + d.immZ(), true - case op == 0x82: // alias of 0x80 - reg, rm := d.modrm(8) - return aluName[reg&7], rm + ", " + d.immB(), true - case op == 0x83: // GRP1 Ev, ib (sign-extended) - reg, rm := d.modrm(d.opSize) - return aluName[reg&7], rm + ", " + d.immBs(), true - - case op == 0x84: - reg, rm := d.modrm(8) - return "test", rm + ", " + d.regName(8, reg), true - case op == 0x85: - reg, rm := d.modrm(d.opSize) - return "test", rm + ", " + d.regName(d.opSize, reg), true - case op == 0x86: - reg, rm := d.modrm(8) - return "xchg", rm + ", " + d.regName(8, reg), true - case op == 0x87: - reg, rm := d.modrm(d.opSize) - return "xchg", rm + ", " + d.regName(d.opSize, reg), true - - case op == 0x88: - reg, rm := d.modrm(8) - return "mov", rm + ", " + d.regName(8, reg), true - case op == 0x89: - reg, rm := d.modrm(d.opSize) - return "mov", rm + ", " + d.regName(d.opSize, reg), true - case op == 0x8a: - reg, rm := d.modrm(8) - return "mov", d.regName(8, reg) + ", " + rm, true - case op == 0x8b: - reg, rm := d.modrm(d.opSize) - return "mov", d.regName(d.opSize, reg) + ", " + rm, true - case op == 0x8c: // mov Ew, Sreg - reg, rm := d.modrm(16) - return "mov", rm + ", " + segReg(reg), true - case op == 0x8d: // lea - reg, rm := d.modrmEx(d.opSize, false) - return "lea", d.regName(d.opSize, reg) + ", " + rm, true - case op == 0x8e: // mov Sreg, Ew - reg, rm := d.modrm(16) - return "mov", segReg(reg) + ", " + rm, true - case op == 0x8f: // GRP1A pop Ev - _, rm := d.modrm(d.stackSize()) - return "pop", rm, true - - case op == 0x90: - if d.rep == "rep" { - return "pause", "", true - } - if !d.rexB { - return "nop", "", true - } - fallthrough - case op >= 0x91 && op <= 0x97: - r := int(op - 0x90) - if d.rexB { - r += 8 - } - return "xchg", accReg(d.opSize) + ", " + d.regName(d.opSize, r), true - case op == 0x98: - return map[int]string{16: "cbw", 32: "cwde", 64: "cdqe"}[d.opSize], "", true - case op == 0x99: - return map[int]string{16: "cwd", 32: "cdq", 64: "cqo"}[d.opSize], "", true - case op == 0x9b: - return "fwait", "", true - case op == 0x9c: - return "pushf", "", true - case op == 0x9d: - return "popf", "", true - case op == 0x9e: - return "sahf", "", true - case op == 0x9f: - return "lahf", "", true - - case op == 0xa0: - return "mov", "al, " + d.moffs(), true - case op == 0xa1: - return "mov", accReg(d.opSize) + ", " + d.moffs(), true - case op == 0xa2: - return "mov", d.moffs() + ", al", true - case op == 0xa3: - return "mov", d.moffs() + ", " + accReg(d.opSize), true - case op == 0xa4: - return d.repPfx() + "movsb", "", true - case op == 0xa5: - return d.repPfx() + "movs" + sizeSuffix(d.opSize), "", true - case op == 0xa6: - return d.repPfx() + "cmpsb", "", true - case op == 0xa7: - return d.repPfx() + "cmps" + sizeSuffix(d.opSize), "", true - case op == 0xa8: - return "test", "al, " + d.immB(), true - case op == 0xa9: - return "test", accReg(d.opSize) + ", " + d.immZ(), true - case op == 0xaa: - return d.repPfx() + "stosb", "", true - case op == 0xab: - return d.repPfx() + "stos" + sizeSuffix(d.opSize), "", true - case op == 0xac: - return d.repPfx() + "lodsb", "", true - case op == 0xad: - return d.repPfx() + "lods" + sizeSuffix(d.opSize), "", true - case op == 0xae: - return d.repPfx() + "scasb", "", true - case op == 0xaf: - return d.repPfx() + "scas" + sizeSuffix(d.opSize), "", true - - case op >= 0xb0 && op <= 0xb7: - r := int(op - 0xb0) - if d.rexB { - r += 8 - } - return "mov", d.regName(8, r) + ", " + d.immB(), true - case op >= 0xb8 && op <= 0xbf: - r := int(op - 0xb8) - if d.rexB { - r += 8 - } - if d.opSize == 64 { - return "movabs", d.regName(64, r) + fmt.Sprintf(", 0x%x", d.u64()), true - } - return "mov", d.regName(d.opSize, r) + ", " + d.immZ(), true - - case op == 0xc0: // GRP2 Eb, ib - reg, rm := d.modrm(8) - return shiftName[reg&7], rm + ", " + d.immB(), true - case op == 0xc1: // GRP2 Ev, ib - reg, rm := d.modrm(d.opSize) - return shiftName[reg&7], rm + ", " + d.immB(), true - case op == 0xc2: - return "ret", fmt.Sprintf("0x%x", d.u16()), true - case op == 0xc3: - return "ret", "", true - case op == 0xc6: // GRP11 mov Eb, ib - _, rm := d.modrm(8) - return "mov", rm + ", " + d.immB(), true - case op == 0xc7: // GRP11 mov Ev, iz - _, rm := d.modrm(d.opSize) - return "mov", rm + ", " + d.immZ(), true - case op == 0xc8: - iw := d.u16() - ib := d.u8() - return "enter", fmt.Sprintf("0x%x, 0x%x", iw, ib), true - case op == 0xc9: - return "leave", "", true - case op == 0xca: - return "retf", fmt.Sprintf("0x%x", d.u16()), true - case op == 0xcb: - return "retf", "", true - case op == 0xcc: - return "int3", "", true - case op == 0xcd: - return "int", d.immB(), true - case op == 0xce: - return "into", "", true - case op == 0xcf: - return "iret", "", true - - case op == 0xd0: // GRP2 Eb, 1 - reg, rm := d.modrm(8) - return shiftName[reg&7], rm + ", 1", true - case op == 0xd1: // GRP2 Ev, 1 - reg, rm := d.modrm(d.opSize) - return shiftName[reg&7], rm + ", 1", true - case op == 0xd2: // GRP2 Eb, cl - reg, rm := d.modrm(8) - return shiftName[reg&7], rm + ", cl", true - case op == 0xd3: // GRP2 Ev, cl - reg, rm := d.modrm(d.opSize) - return shiftName[reg&7], rm + ", cl", true - case op == 0xd7: - return "xlat", "", true - case op >= 0xd8 && op <= 0xdf: // x87 — consume ModR/M for length, name generic - _, rm := d.modrmEx(32, true) - return "fpu", rm, false - - case op == 0xe0: - return "loopne", d.rel8(), true - case op == 0xe1: - return "loope", d.rel8(), true - case op == 0xe2: - return "loop", d.rel8(), true - case op == 0xe3: - if d.addrSize == 32 { - return "jecxz", d.rel8(), true - } - return "jrcxz", d.rel8(), true - case op == 0xe4: - return "in", "al, " + d.immB(), true - case op == 0xe5: - return "in", accReg(d.opSize) + ", " + d.immB(), true - case op == 0xe6: - return "out", d.immB() + ", al", true - case op == 0xe7: - return "out", d.immB() + ", " + accReg(d.opSize), true - case op == 0xe8: - return "call", d.rel32(), true - case op == 0xe9: - return "jmp", d.rel32(), true - case op == 0xeb: - return "jmp", d.rel8(), true - case op == 0xec: - return "in", "al, dx", true - case op == 0xed: - return "in", accReg(d.opSize) + ", dx", true - case op == 0xee: - return "out", "dx, al", true - case op == 0xef: - return "out", "dx, " + accReg(d.opSize), true - - case op == 0xf1: - return "int1", "", true - case op == 0xf4: - return "hlt", "", true - case op == 0xf5: - return "cmc", "", true - case op == 0xf6: - return d.grp3(8) - case op == 0xf7: - return d.grp3(d.opSize) - case op == 0xf8: - return "clc", "", true - case op == 0xf9: - return "stc", "", true - case op == 0xfa: - return "cli", "", true - case op == 0xfb: - return "sti", "", true - case op == 0xfc: - return "cld", "", true - case op == 0xfd: - return "std", "", true - case op == 0xfe: // GRP4 - reg, rm := d.modrm(8) - if reg&7 == 0 { - return "inc", rm, true - } - return "dec", rm, true - case op == 0xff: // GRP5 - return d.grp5() - } - return "", "", false -} - -func (d *x86dec) repPfx() string { - if d.rep == "rep" { - return "rep " - } - if d.rep == "repne" { - return "repne " - } - return "" -} - -func sizeSuffix(size int) string { - switch size { - case 16: - return "w" - case 64: - return "q" - default: - return "d" - } -} - -func segReg(n int) string { - names := [...]string{"es", "cs", "ss", "ds", "fs", "gs", "?", "?"} - return names[n&7] -} - -func (d *x86dec) moffs() string { - var off uint64 - switch d.addrSize { - case 16: - off = uint64(d.u16()) - case 32: - off = uint64(d.u32()) - default: - off = d.u64() - } - seg := "" - if d.seg != "" { - seg = d.seg + ":" - } - return fmt.Sprintf("%s[0x%x]", seg, off) -} - -func (d *x86dec) grp3(size int) (string, string, bool) { - reg, rm := d.modrm(size) - switch reg & 7 { - case 0, 1: - if size == 8 { - return "test", rm + ", " + d.immB(), true - } - return "test", rm + ", " + d.immZ(), true - case 2: - return "not", rm, true - case 3: - return "neg", rm, true - case 4: - return "mul", rm, true - case 5: - return "imul", rm, true - case 6: - return "div", rm, true - default: - return "idiv", rm, true - } -} - -func (d *x86dec) grp5() (string, string, bool) { - // Peek reg field without committing size, then decode appropriately. - m, ok := d.peek() - if !ok { - return "", "", false - } - reg := int((m >> 3) & 7) - switch reg { - case 0: - _, rm := d.modrm(d.opSize) - return "inc", rm, true - case 1: - _, rm := d.modrm(d.opSize) - return "dec", rm, true - case 2: - _, rm := d.modrm(d.stackSize()) - return "call", rm, true - case 3: - _, rm := d.modrm(d.opSize) - return "callf", rm, true - case 4: - _, rm := d.modrm(d.stackSize()) - return "jmp", rm, true - case 5: - _, rm := d.modrm(d.opSize) - return "jmpf", rm, true - case 6: - _, rm := d.modrm(d.stackSize()) - return "push", rm, true - default: - _, rm := d.modrm(d.opSize) - return "(bad)", rm, false - } -} - -// ---- two-byte 0F map ---- - -func (d *x86dec) decode0F() (string, string, bool) { - op := d.u8() - if d.trunc { - return "", "", false - } - switch { - case op == 0x05: - return "syscall", "", true - case op == 0x06: - return "clts", "", true - case op == 0x07: - return "sysret", "", true - case op == 0x08: - return "invd", "", true - case op == 0x09: - return "wbinvd", "", true - case op == 0x0b: - return "ud2", "", true - case op == 0x1f: // multi-byte NOP - _, rm := d.modrm(d.opSize) - return "nop", rm, true - case op == 0x1e && d.rep == "rep": // ENDBR32/ENDBR64 (f3 0f 1e fa/fb) - m := d.u8() - if m == 0xfa { - return "endbr64", "", true - } - if m == 0xfb { - return "endbr32", "", true - } - return "nop", "", true - case op >= 0x18 && op <= 0x1e: // hints / prefetch - _, rm := d.modrmEx(8, true) - return "nop", rm, true - case op == 0x31: - return "rdtsc", "", true - case op == 0x34: - return "sysenter", "", true - case op == 0x35: - return "sysexit", "", true - case op == 0xa2: - return "cpuid", "", true - case op == 0x77: - return "emms", "", true - case op == 0xa0: - return "push", "fs", true - case op == 0xa1: - return "pop", "fs", true - case op == 0xa8: - return "push", "gs", true - case op == 0xa9: - return "pop", "gs", true - - case op >= 0x40 && op <= 0x4f: // CMOVcc - reg, rm := d.modrm(d.opSize) - return "cmov" + ccName[op-0x40], d.regName(d.opSize, reg) + ", " + rm, true - case op >= 0x80 && op <= 0x8f: // Jcc rel32 - return "j" + ccName[op-0x80], d.rel32(), true - case op >= 0x90 && op <= 0x9f: // SETcc Eb - _, rm := d.modrm(8) - return "set" + ccName[op-0x90], rm, true - - case op == 0xa3: - reg, rm := d.modrm(d.opSize) - return "bt", rm + ", " + d.regName(d.opSize, reg), true - case op == 0xab: - reg, rm := d.modrm(d.opSize) - return "bts", rm + ", " + d.regName(d.opSize, reg), true - case op == 0xb3: - reg, rm := d.modrm(d.opSize) - return "btr", rm + ", " + d.regName(d.opSize, reg), true - case op == 0xbb: - reg, rm := d.modrm(d.opSize) - return "btc", rm + ", " + d.regName(d.opSize, reg), true - case op == 0xba: // GRP8 Ev, ib - reg, rm := d.modrm(d.opSize) - names := map[int]string{4: "bt", 5: "bts", 6: "btr", 7: "btc"} - if n, okn := names[reg&7]; okn { - return n, rm + ", " + d.immB(), true - } - return "(bad)", rm, false - - case op == 0xa4: - reg, rm := d.modrm(d.opSize) - return "shld", rm + ", " + d.regName(d.opSize, reg) + ", " + d.immB(), true - case op == 0xa5: - reg, rm := d.modrm(d.opSize) - return "shld", rm + ", " + d.regName(d.opSize, reg) + ", cl", true - case op == 0xac: - reg, rm := d.modrm(d.opSize) - return "shrd", rm + ", " + d.regName(d.opSize, reg) + ", " + d.immB(), true - case op == 0xad: - reg, rm := d.modrm(d.opSize) - return "shrd", rm + ", " + d.regName(d.opSize, reg) + ", cl", true - case op == 0xaf: - reg, rm := d.modrm(d.opSize) - return "imul", d.regName(d.opSize, reg) + ", " + rm, true - - case op == 0xb0: - reg, rm := d.modrm(8) - return "cmpxchg", rm + ", " + d.regName(8, reg), true - case op == 0xb1: - reg, rm := d.modrm(d.opSize) - return "cmpxchg", rm + ", " + d.regName(d.opSize, reg), true - case op == 0xb6: - reg, rm := d.modrm(8) - return "movzx", d.regName(d.opSize, reg) + ", " + rm, true - case op == 0xb7: - reg, rm := d.modrm(16) - return "movzx", d.regName(d.opSize, reg) + ", " + rm, true - case op == 0xbe: - reg, rm := d.modrm(8) - return "movsx", d.regName(d.opSize, reg) + ", " + rm, true - case op == 0xbf: - reg, rm := d.modrm(16) - return "movsx", d.regName(d.opSize, reg) + ", " + rm, true - case op == 0xbc: - reg, rm := d.modrm(d.opSize) - name := "bsf" - if d.rep == "rep" { - name = "tzcnt" - } - return name, d.regName(d.opSize, reg) + ", " + rm, true - case op == 0xbd: - reg, rm := d.modrm(d.opSize) - name := "bsr" - if d.rep == "rep" { - name = "lzcnt" - } - return name, d.regName(d.opSize, reg) + ", " + rm, true - case op == 0xc0: - reg, rm := d.modrm(8) - return "xadd", rm + ", " + d.regName(8, reg), true - case op == 0xc1: - reg, rm := d.modrm(d.opSize) - return "xadd", rm + ", " + d.regName(d.opSize, reg), true - case op >= 0xc8 && op <= 0xcf: - r := int(op - 0xc8) - if d.rexB { - r += 8 - } - return "bswap", d.regName(d.opSize, r), true - } - // Common SSE / SSE2 (XMM) subset. - if mn, ops, ok := d.sse0F(op); ok { - return mn, ops, true - } - return "", "", false -} - -// sse0F decodes the common SSE/SSE2 instructions. Mnemonics depend on the -// mandatory prefix (none / 0x66 / 0xF3 / 0xF2). -func (d *x86dec) sse0F(op uint8) (string, string, bool) { - pfx := "none" - switch { - case d.has66: - pfx = "66" - case d.rep == "rep": - pfx = "f3" - case d.rep == "repne": - pfx = "f2" - } - - xmmRM := func(name string, store bool) (string, string, bool) { - reg, rm := d.modrm(128) - r := d.regName(128, reg) - if store { - return name, rm + ", " + r, true - } - return name, r + ", " + rm, true - } - - switch op { - case 0x10: - return xmmRM(map[string]string{"none": "movups", "66": "movupd", "f3": "movss", "f2": "movsd"}[pfx], false) - case 0x11: - return xmmRM(map[string]string{"none": "movups", "66": "movupd", "f3": "movss", "f2": "movsd"}[pfx], true) - case 0x28: - return xmmRM(map[string]string{"none": "movaps", "66": "movapd"}[pfx2(pfx)], false) - case 0x29: - return xmmRM(map[string]string{"none": "movaps", "66": "movapd"}[pfx2(pfx)], true) - case 0x2e: - return xmmRM(map[string]string{"none": "ucomiss", "66": "ucomisd"}[pfx2(pfx)], false) - case 0x2f: - return xmmRM(map[string]string{"none": "comiss", "66": "comisd"}[pfx2(pfx)], false) - case 0x51: - return xmmRM(sseScalar("sqrt", pfx), false) - case 0x54: - return xmmRM(map[string]string{"none": "andps", "66": "andpd"}[pfx2(pfx)], false) - case 0x57: - return xmmRM(map[string]string{"none": "xorps", "66": "xorpd"}[pfx2(pfx)], false) - case 0x58: - return xmmRM(sseScalar("add", pfx), false) - case 0x59: - return xmmRM(sseScalar("mul", pfx), false) - case 0x5c: - return xmmRM(sseScalar("sub", pfx), false) - case 0x5e: - return xmmRM(sseScalar("div", pfx), false) - case 0x5d: - return xmmRM(sseScalar("min", pfx), false) - case 0x5f: - return xmmRM(sseScalar("max", pfx), false) - case 0x6e: // movd/movq xmm, r/m - sz := 32 - name := "movd" - if d.opSize == 64 { - sz, name = 64, "movq" - } - reg, rm := d.modrm(sz) - return name, d.regName(128, reg) + ", " + rm, true - case 0x7e: - if pfx == "f3" { // movq xmm, xmm/m64 - return xmmRM("movq", false) - } - sz := 32 - name := "movd" - if d.opSize == 64 { - sz, name = 64, "movq" - } - reg, rm := d.modrm(sz) - return name, rm + ", " + d.regName(128, reg), true - case 0x6f: - return xmmRM(map[string]string{"66": "movdqa", "f3": "movdqu"}[pfx], false) - case 0x7f: - return xmmRM(map[string]string{"66": "movdqa", "f3": "movdqu"}[pfx], true) - case 0xd6: - return xmmRM("movq", true) - case 0xef: - return xmmRM("pxor", false) - case 0x76: - return xmmRM("pcmpeqd", false) - case 0x74: - return xmmRM("pcmpeqb", false) - case 0xfc: - return xmmRM("paddb", false) - case 0xfe: - return xmmRM("paddd", false) - case 0x12: - return xmmRM(map[string]string{"none": "movlps", "66": "movlpd"}[pfx2(pfx)], false) - case 0x13: - return xmmRM(map[string]string{"none": "movlps", "66": "movlpd"}[pfx2(pfx)], true) - case 0x16: - return xmmRM(map[string]string{"none": "movhps", "66": "movhpd"}[pfx2(pfx)], false) - case 0x17: - return xmmRM(map[string]string{"none": "movhps", "66": "movhpd"}[pfx2(pfx)], true) - case 0x14: - return xmmRM(map[string]string{"none": "unpcklps", "66": "unpcklpd"}[pfx2(pfx)], false) - case 0x2a: - reg, rm := d.modrm(d.opSize) - return sseScalar("cvtsi2", pfx), d.regName(128, reg) + ", " + rm, true - case 0x2c, 0x2d: - reg, rm := d.modrm(128) - return "cvt", d.regName(d.opSize, reg) + ", " + rm, true - } - return "", "", false -} - -// pfx2 collapses f3/f2 to "none" for instructions that only vary between the -// no-prefix and 0x66 forms. -func pfx2(p string) string { - if p == "66" { - return "66" - } - return "none" -} - -func sseScalar(base, pfx string) string { - switch pfx { - case "f3": - return base + "ss" - case "f2": - return base + "sd" - case "66": - return base + "pd" - default: - return base + "ps" - } -} diff --git a/internal/leviathan/document/document.go b/internal/leviathan/document/document.go deleted file mode 100644 index d43797d..0000000 --- a/internal/leviathan/document/document.go +++ /dev/null @@ -1,439 +0,0 @@ -// Package document implements the chunked binary document model. -// -// The document holds three conceptual layers per slot: -// -// source — immutable original bytes, loaded once. -// patch — sparse overlay of modified bytes keyed by offset. -// working — logical view = source with patch applied on read. -// -// Reads happen through windowed Range() calls so neither the editor -// nor analysis services need to materialise the whole file. -package document - -import ( - "errors" - "fmt" - "io" - "os" - "path/filepath" - "sync" -) - -// ChunkSize is the granularity at which source bytes are exposed to -// analysis services. It is independent of the view row width. -const ChunkSize = 64 * 1024 - -// Document represents one loaded artefact. A Session may hold many. -type Document struct { - mu sync.RWMutex - name string - source []byte // original immutable content - path string // optional immutable file-backed source - size int64 - patch map[int64]byte - history []EditOp - redo []EditOp - cursor int -} - -// EditOp records a single byte-level mutation for undo/redo. -type EditOp struct { - Offset int64 - Old byte - New byte - Kind string // "set", "fill", "xor" -} - -// New creates a Document from raw source bytes. The slice is not -// copied; callers must not mutate it after handoff. -func New(name string, src []byte) *Document { - return &Document{ - name: name, - source: src, - size: int64(len(src)), - patch: make(map[int64]byte), - } -} - -// Open creates a Document backed by an immutable file on disk. Source -// bytes are read on demand through Range rather than loaded eagerly. -func Open(path string) (*Document, error) { - st, err := os.Stat(path) - if err != nil { - return nil, err - } - if st.IsDir() { - return nil, errors.New("document path is a directory") - } - return &Document{ - name: filepath.Base(path), - path: path, - size: st.Size(), - patch: make(map[int64]byte), - }, nil -} - -// Name returns the document's display name. -func (d *Document) Name() string { - d.mu.RLock() - defer d.mu.RUnlock() - return d.name -} - -// Size returns the logical document length in bytes. -func (d *Document) Size() int64 { - d.mu.RLock() - defer d.mu.RUnlock() - return d.size -} - -// At returns the logical byte at offset (patch takes precedence). -func (d *Document) At(offset int64) (byte, error) { - d.mu.RLock() - defer d.mu.RUnlock() - if offset < 0 || offset >= d.size { - return 0, errors.New("offset out of range") - } - if b, ok := d.patch[offset]; ok { - return b, nil - } - return d.sourceByteLocked(offset) -} - -// Range copies [start, start+length) into dst. It applies the patch -// overlay. Callers get a fresh slice they are free to retain. -func (d *Document) Range(start, length int64) ([]byte, error) { - d.mu.RLock() - defer d.mu.RUnlock() - n := d.size - if start < 0 || start > n { - return nil, errors.New("range start out of bounds") - } - if length < 0 { - return nil, errors.New("negative range") - } - if length > n-start { - length = n - start - } - out, err := d.sourceRangeLocked(start, length) - if err != nil { - return nil, err - } - if len(d.patch) > 0 { - end := start + length - for off, b := range d.patch { - if off >= start && off < end { - out[off-start] = b - } - } - } - return out, nil -} - -// IsDirty reports whether any byte has been patched. -func (d *Document) IsDirty() bool { - d.mu.RLock() - defer d.mu.RUnlock() - return len(d.patch) > 0 -} - -// Set patches a single byte. Returns the previous value. -func (d *Document) Set(offset int64, b byte) (byte, error) { - d.mu.Lock() - defer d.mu.Unlock() - if offset < 0 || offset >= d.size { - return 0, errors.New("offset out of range") - } - orig, err := d.sourceByteLocked(offset) - if err != nil { - return 0, err - } - var old byte - if v, ok := d.patch[offset]; ok { - old = v - } else { - old = orig - } - if b == orig { - delete(d.patch, offset) - } else { - d.patch[offset] = b - } - d.history = append(d.history, EditOp{Offset: offset, Old: old, New: b, Kind: "set"}) - d.redo = nil - return old, nil -} - -// Fill patches [start,start+length) with b. -func (d *Document) Fill(start, length int64, b byte) error { - d.mu.Lock() - defer d.mu.Unlock() - if err := d.validateRangeLocked("fill", start, length); err != nil { - return err - } - orig, err := d.sourceRangeLocked(start, length) - if err != nil { - return err - } - for i := int64(0); i < length; i++ { - off := start + i - origB := orig[i] - var old byte - if v, ok := d.patch[off]; ok { - old = v - } else { - old = origB - } - if b == origB { - delete(d.patch, off) - } else { - d.patch[off] = b - } - d.history = append(d.history, EditOp{Offset: off, Old: old, New: b, Kind: "fill"}) - } - d.redo = nil - return nil -} - -// XOR patches [start,start+length) with a repeating key. -func (d *Document) XOR(start, length int64, key []byte) error { - if len(key) == 0 { - return errors.New("empty key") - } - d.mu.Lock() - defer d.mu.Unlock() - if err := d.validateRangeLocked("xor", start, length); err != nil { - return err - } - orig, err := d.sourceRangeLocked(start, length) - if err != nil { - return err - } - for i := int64(0); i < length; i++ { - off := start + i - origB := orig[i] - var cur byte - if v, ok := d.patch[off]; ok { - cur = v - } else { - cur = origB - } - newB := cur ^ key[i%int64(len(key))] - if newB == origB { - delete(d.patch, off) - } else { - d.patch[off] = newB - } - d.history = append(d.history, EditOp{Offset: off, Old: cur, New: newB, Kind: "xor"}) - } - d.redo = nil - return nil -} - -// Copy copies [src,src+length) onto [dst,dst+length) as a patch. -func (d *Document) Copy(src, length, dst int64) error { - d.mu.Lock() - defer d.mu.Unlock() - if err := d.validateRangeLocked("copy source", src, length); err != nil { - return err - } - if err := d.validateRangeLocked("copy destination", dst, length); err != nil { - return err - } - buf, err := d.workingRangeLocked(src, length) - if err != nil { - return err - } - dstOrig, err := d.sourceRangeLocked(dst, length) - if err != nil { - return err - } - for i := int64(0); i < length; i++ { - off := dst + i - origB := dstOrig[i] - var cur byte - if v, ok := d.patch[off]; ok { - cur = v - } else { - cur = origB - } - b := buf[i] - if b == origB { - delete(d.patch, off) - } else { - d.patch[off] = b - } - d.history = append(d.history, EditOp{Offset: off, Old: cur, New: b, Kind: "fill"}) - } - d.redo = nil - return nil -} - -// Undo reverts the last edit. Returns false if there is no history. -func (d *Document) Undo() bool { - d.mu.Lock() - defer d.mu.Unlock() - if len(d.history) == 0 { - return false - } - op := d.history[len(d.history)-1] - d.history = d.history[:len(d.history)-1] - orig, err := d.sourceByteLocked(op.Offset) - if err != nil { - return false - } - if op.Old == orig { - delete(d.patch, op.Offset) - } else { - d.patch[op.Offset] = op.Old - } - d.redo = append(d.redo, op) - return true -} - -// Redo reapplies the last undone edit. Returns false if there is no redo. -func (d *Document) Redo() bool { - d.mu.Lock() - defer d.mu.Unlock() - if len(d.redo) == 0 { - return false - } - op := d.redo[len(d.redo)-1] - d.redo = d.redo[:len(d.redo)-1] - orig, err := d.sourceByteLocked(op.Offset) - if err != nil { - return false - } - if op.New == orig { - delete(d.patch, op.Offset) - } else { - d.patch[op.Offset] = op.New - } - d.history = append(d.history, op) - return true -} - -// Revert drops all patches and history. -func (d *Document) Revert() { - d.mu.Lock() - defer d.mu.Unlock() - d.patch = make(map[int64]byte) - d.history = nil - d.redo = nil -} - -// Patches returns a snapshot of the patch overlay. -func (d *Document) Patches() map[int64]byte { - d.mu.RLock() - defer d.mu.RUnlock() - out := make(map[int64]byte, len(d.patch)) - for k, v := range d.patch { - out[k] = v - } - return out -} - -// HistoryLen returns the number of recorded edits. -func (d *Document) HistoryLen() int { - d.mu.RLock() - defer d.mu.RUnlock() - return len(d.history) -} - -// RedoLen returns the number of redoable edits. -func (d *Document) RedoLen() int { - d.mu.RLock() - defer d.mu.RUnlock() - return len(d.redo) -} - -// ForEachChunk yields chunks covering [start,start+length) with the -// working-copy bytes visible. It is suitable for analysis passes that -// want bounded memory use. -func (d *Document) ForEachChunk(start, length int64, fn func(off int64, chunk []byte) error) error { - d.mu.RLock() - n := d.size - d.mu.RUnlock() - if start < 0 { - start = 0 - } - end := start + length - if length < 0 || end > n { - end = n - } - off := start - for off < end { - chunkEnd := off + ChunkSize - if chunkEnd > end { - chunkEnd = end - } - buf, err := d.Range(off, chunkEnd-off) - if err != nil { - return err - } - if err := fn(off, buf); err != nil { - return err - } - off = chunkEnd - } - return nil -} - -func (d *Document) sourceByteLocked(offset int64) (byte, error) { - buf, err := d.sourceRangeLocked(offset, 1) - if err != nil { - return 0, err - } - if len(buf) == 0 { - return 0, errors.New("offset out of range") - } - return buf[0], nil -} - -func (d *Document) workingRangeLocked(start, length int64) ([]byte, error) { - buf, err := d.sourceRangeLocked(start, length) - if err != nil { - return nil, err - } - for off, b := range d.patch { - if off >= start && off < start+length { - buf[off-start] = b - } - } - return buf, nil -} - -func (d *Document) sourceRangeLocked(start, length int64) ([]byte, error) { - if err := d.validateRangeLocked("range", start, length); err != nil { - return nil, err - } - if length == 0 { - return []byte{}, nil - } - if d.path == "" { - out := make([]byte, length) - copy(out, d.source[start:start+length]) - return out, nil - } - f, err := os.Open(d.path) - if err != nil { - return nil, err - } - defer f.Close() - out := make([]byte, length) - _, err = f.ReadAt(out, start) - if errors.Is(err, io.EOF) { - err = nil - } - if err != nil { - return nil, err - } - return out, nil -} - -func (d *Document) validateRangeLocked(label string, start, length int64) error { - if start < 0 || start > d.size || length < 0 || length > d.size-start { - return fmt.Errorf("%s range invalid", label) - } - return nil -} diff --git a/internal/leviathan/document/session.go b/internal/leviathan/document/session.go deleted file mode 100644 index c0c0a86..0000000 --- a/internal/leviathan/document/session.go +++ /dev/null @@ -1,62 +0,0 @@ -package document - -import ( - "errors" - "sync" -) - -// Slot identifies one of the documents in a session. -type Slot string - -const ( - SlotOriginal Slot = "original" - SlotCompare Slot = "compare" -) - -// Session tracks loaded documents and addresses them by slot. -// The original slot is the active working document; compare is -// used by the diff tooling. -type Session struct { - mu sync.RWMutex - documents map[Slot]*Document -} - -// NewSession returns an empty session. -func NewSession() *Session { - return &Session{documents: make(map[Slot]*Document)} -} - -// Load replaces the document in the given slot. -func (s *Session) Load(slot Slot, name string, src []byte) *Document { - s.mu.Lock() - defer s.mu.Unlock() - d := New(name, src) - s.documents[slot] = d - return d -} - -// Get returns the document for a slot, or an error if not loaded. -func (s *Session) Get(slot Slot) (*Document, error) { - s.mu.RLock() - defer s.mu.RUnlock() - d, ok := s.documents[slot] - if !ok { - return nil, errors.New("slot not loaded: " + string(slot)) - } - return d, nil -} - -// Has reports whether a slot has a document loaded. -func (s *Session) Has(slot Slot) bool { - s.mu.RLock() - defer s.mu.RUnlock() - _, ok := s.documents[slot] - return ok -} - -// Drop removes a slot. -func (s *Session) Drop(slot Slot) { - s.mu.Lock() - defer s.mu.Unlock() - delete(s.documents, slot) -} diff --git a/internal/leviathan/parsers/asn1.go b/internal/leviathan/parsers/asn1.go deleted file mode 100644 index 5baf9aa..0000000 --- a/internal/leviathan/parsers/asn1.go +++ /dev/null @@ -1,321 +0,0 @@ -package parsers - -import ( - "encoding/asn1" - "encoding/pem" - "errors" - "fmt" - "strings" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// ParseASN1 produces a field tree for the DER TLV structure starting -// at `at`, spanning at most `length` bytes. Nested SEQUENCE / SET -// structures are decoded recursively; primitive content is rendered -// as best-effort. -func ParseASN1(d *document.Document, at, length int64) (*Field, error) { - size := d.Size() - if length < 0 || at+length > size { - length = size - at - } - if length <= 0 { - return nil, errors.New("asn1: empty range") - } - buf, err := d.Range(at, length) - if err != nil { - return nil, err - } - // If the buffer looks like PEM, decode first. - if pemBlock, _ := pem.Decode(buf); pemBlock != nil { - root := &Field{Name: "PEM " + pemBlock.Type, Type: "container", Offset: at, Length: length} - inner, err := decodeASN1(pemBlock.Bytes, at) - if err == nil { - root.Children = append(root.Children, inner) - } else { - root.Note = "pem decoded but asn1 failed: " + err.Error() - } - return root, nil - } - return decodeASN1(buf, at) -} - -// decodeASN1 walks buf recursively, treating it as one or more DER TLVs. -func decodeASN1(buf []byte, base int64) (*Field, error) { - root := &Field{Name: "ASN.1", Type: "container", Offset: base, Length: int64(len(buf))} - off := 0 - for off < len(buf) { - raw, consumed, err := readTLV(buf[off:]) - if err != nil { - if off == 0 { - return nil, err - } - break - } - node := asn1Node(raw, base+int64(off)) - root.Children = append(root.Children, node) - off += consumed - } - if len(root.Children) == 0 { - return nil, errors.New("asn1: no TLV decoded") - } - if len(root.Children) == 1 { - return root.Children[0], nil - } - return root, nil -} - -// tlv is one DER TLV we've split out of the byte stream. -type tlv struct { - Class int // 0=universal, 1=application, 2=context, 3=private - Constructed bool - Tag int - Content []byte // the V of the TLV - HeaderLen int // length of T+L bytes -} - -func readTLV(buf []byte) (tlv, int, error) { - if len(buf) < 2 { - return tlv{}, 0, errors.New("short TLV") - } - b0 := buf[0] - t := tlv{ - Class: int(b0 >> 6), - Constructed: b0&0x20 != 0, - } - tagVal := int(b0 & 0x1f) - i := 1 - if tagVal == 0x1f { - // multi-byte tag - tagVal = 0 - for i < len(buf) { - c := buf[i] - i++ - tagVal = (tagVal << 7) | int(c&0x7f) - if c&0x80 == 0 { - break - } - } - } - t.Tag = tagVal - if i >= len(buf) { - return tlv{}, 0, errors.New("truncated length") - } - ln := int(buf[i]) - i++ - if ln >= 0x80 { - n := ln & 0x7f - if n == 0 || n > 8 || i+n > len(buf) { - return tlv{}, 0, errors.New("bad length") - } - ln = 0 - for k := 0; k < n; k++ { - ln = (ln << 8) | int(buf[i+k]) - } - i += n - } - if i+ln > len(buf) { - return tlv{}, 0, errors.New("length exceeds buffer") - } - t.HeaderLen = i - t.Content = buf[i : i+ln] - return t, i + ln, nil -} - -func asn1Node(t tlv, off int64) *Field { - name, kind := asn1TagName(t) - total := int64(t.HeaderLen + len(t.Content)) - node := &Field{Name: name, Type: kind, Offset: off, Length: total} - if t.Class == 2 { - node.Name = fmt.Sprintf("[%d]", t.Tag) - } - if t.Constructed { - // Recurse into children. - inner := t.Content - childOff := off + int64(t.HeaderLen) - i := 0 - for i < len(inner) { - child, consumed, err := readTLV(inner[i:]) - if err != nil { - break - } - node.Children = append(node.Children, asn1Node(child, childOff+int64(i))) - i += consumed - } - } else { - node.Value = renderPrimitive(t) - } - return node -} - -func asn1TagName(t tlv) (string, string) { - if t.Class != 0 { - prefix := []string{"", "APP ", "CTX ", "PRIV "}[t.Class] - return fmt.Sprintf("%s%d", prefix, t.Tag), "tag" - } - names := map[int]string{ - 1: "BOOLEAN", - 2: "INTEGER", - 3: "BIT STRING", - 4: "OCTET STRING", - 5: "NULL", - 6: "OBJECT IDENTIFIER", - 10: "ENUMERATED", - 12: "UTF8String", - 16: "SEQUENCE", - 17: "SET", - 19: "PrintableString", - 20: "T61String", - 22: "IA5String", - 23: "UTCTime", - 24: "GeneralizedTime", - } - if n, ok := names[t.Tag]; ok { - return n, strings.ToLower(n) - } - return fmt.Sprintf("tag %d", t.Tag), "tag" -} - -func renderPrimitive(t tlv) string { - switch t.Tag { - case 1: - if len(t.Content) == 1 { - if t.Content[0] == 0 { - return "false" - } - return "true" - } - case 2: - return fmt.Sprintf("%s (%d bytes)", bigIntString(t.Content), len(t.Content)) - case 3: - if len(t.Content) == 0 { - return "empty" - } - return fmt.Sprintf("unused=%d bits=%x", t.Content[0], t.Content[1:]) - case 4: - if isPrintable(t.Content) { - return fmt.Sprintf("%q (%d bytes)", string(t.Content), len(t.Content)) - } - return fmt.Sprintf("%x (%d bytes)", t.Content, len(t.Content)) - case 5: - return "null" - case 6: - oid, err := parseOID(t.Content) - if err != nil { - return err.Error() - } - return oid + oidNote(oid) - case 12, 19, 20, 22: - return fmt.Sprintf("%q", string(t.Content)) - case 23, 24: - return fmt.Sprintf("%q (time)", string(t.Content)) - } - if isPrintable(t.Content) { - return fmt.Sprintf("%q", string(t.Content)) - } - if len(t.Content) > 32 { - return fmt.Sprintf("%x… (%d bytes)", t.Content[:32], len(t.Content)) - } - return fmt.Sprintf("%x", t.Content) -} - -func bigIntString(b []byte) string { - if len(b) == 0 { - return "0" - } - if len(b) <= 8 { - var v int64 - negative := b[0]&0x80 != 0 - for _, c := range b { - v = (v << 8) | int64(c) - } - if negative { - // sign-extend - v -= 1 << uint(8*len(b)) - } - return fmt.Sprintf("%d", v) - } - // large — hex is more useful than a decimal approximation. - return fmt.Sprintf("0x%x", b) -} - -func parseOID(b []byte) (string, error) { - // Use Go's asn1 parser for correctness. - var oid asn1.ObjectIdentifier - rest, err := asn1.Unmarshal(append([]byte{0x06, byte(len(b))}, b...), &oid) - if err != nil || len(rest) != 0 { - // Fallback manual decode. - return manualOID(b), nil - } - return oid.String(), nil -} - -func manualOID(b []byte) string { - if len(b) == 0 { - return "" - } - parts := []string{} - first := int(b[0]) - parts = append(parts, fmt.Sprintf("%d.%d", first/40, first%40)) - v := 0 - for _, c := range b[1:] { - v = (v << 7) | int(c&0x7f) - if c&0x80 == 0 { - parts = append(parts, fmt.Sprintf("%d", v)) - v = 0 - } - } - return strings.Join(parts, ".") -} - -// Known-OID table for human-readable hints in cert dumps. -var oidNames = map[string]string{ - "1.2.840.113549.1.1.1": " rsaEncryption", - "1.2.840.113549.1.1.11": " sha256WithRSAEncryption", - "1.2.840.113549.1.1.5": " sha1WithRSAEncryption", - "1.2.840.113549.1.1.13": " sha512WithRSAEncryption", - "1.2.840.10045.2.1": " ecPublicKey", - "1.2.840.10045.4.3.2": " ecdsaWithSHA256", - "1.3.101.112": " Ed25519", - "2.5.4.3": " commonName", - "2.5.4.6": " countryName", - "2.5.4.7": " localityName", - "2.5.4.8": " stateOrProvinceName", - "2.5.4.10": " organizationName", - "2.5.4.11": " organizationalUnitName", - "2.5.4.5": " serialNumber", - "1.2.840.113549.1.9.1": " emailAddress", - "2.5.29.14": " subjectKeyIdentifier", - "2.5.29.15": " keyUsage", - "2.5.29.17": " subjectAltName", - "2.5.29.19": " basicConstraints", - "2.5.29.31": " cRLDistributionPoints", - "2.5.29.32": " certificatePolicies", - "2.5.29.35": " authorityKeyIdentifier", - "2.5.29.37": " extKeyUsage", - "1.3.6.1.5.5.7.3.1": " serverAuth", - "1.3.6.1.5.5.7.3.2": " clientAuth", - "1.3.6.1.5.5.7.1.1": " authorityInfoAccess", -} - -func oidNote(oid string) string { - if n, ok := oidNames[oid]; ok { - return n - } - return "" -} - -func isPrintable(b []byte) bool { - if len(b) == 0 { - return false - } - for _, c := range b { - if c == 0x09 || c == 0x0a || c == 0x0d { - continue - } - if c < 0x20 || c > 0x7e { - return false - } - } - return true -} diff --git a/internal/leviathan/parsers/cert.go b/internal/leviathan/parsers/cert.go deleted file mode 100644 index 492eafa..0000000 --- a/internal/leviathan/parsers/cert.go +++ /dev/null @@ -1,196 +0,0 @@ -package parsers - -import ( - "crypto/sha256" - "crypto/x509" - "encoding/pem" - "errors" - "fmt" - "strings" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// ParseCerts scans the whole document for PEM-encoded certificates -// and for DER-encoded X.509 certificates appearing at arbitrary -// offsets (heuristic: SEQUENCE 0x30 0x82 , then tbsCertificate -// SEQUENCE, then version INTEGER). Returns a list of decoded certs. -func ParseCerts(d *document.Document) (*Field, error) { - size := d.Size() - root := &Field{Name: "Certificates", Type: "container", Offset: 0, Length: size} - - // Read whole doc — bounded by caller's judgment. For huge firmware - // blobs this might be expensive but certs are small; most real - // cases are 32*1024*1024 { - return nil, errors.New("certs scan skipped: file > 32 MiB") - } - all, err := d.Range(0, size) - if err != nil { - return nil, err - } - - // PEM pass. - rest := all - pemCount := 0 - var pemOff int64 = 0 - for { - block, next := pem.Decode(rest) - if block == nil { - break - } - // Approximate offset: the header "-----BEGIN" will be somewhere in `rest`. - idx := indexOfHeader(rest) - if idx < 0 { - break - } - start := pemOff + int64(idx) - end := pemOff + int64(len(rest)-len(next)) - pemCount++ - child := &Field{ - Name: fmt.Sprintf("PEM %s #%d", block.Type, pemCount), - Type: "pem", - Offset: start, - Length: end - start, - } - if block.Type == "CERTIFICATE" { - if cert, err := x509.ParseCertificate(block.Bytes); err == nil { - populateCert(child, cert) - } else { - child.Note = "x509 parse failed: " + err.Error() - } - } - root.Children = append(root.Children, child) - pemOff += int64(len(rest) - len(next)) - rest = next - } - - // DER pass — scan for SEQUENCE 0x30 0x82 xx yy (definite long-form, 2 length bytes). - // - // Each candidate runs a full x509.ParseCertificate over a blob up to 64 KiB. - // Without guards a buffer dense with "30 82" byte pairs (random/firmware - // data, or a hostile file) would attempt that parse at nearly every offset - // — O(n) expensive parses, enough to freeze the UI. Two guards keep it - // bounded: a cheap structural pre-filter (a DER certificate body begins - // with the tbsCertificate SEQUENCE, 0x30) rejects almost all false - // candidates without parsing, and a hard cap on parse attempts bounds even - // adversarial input that defeats the pre-filter. - const maxDERAttempts = 1 << 16 - derCount := 0 - derAttempts := 0 - for i := 0; i+4 < len(all); i++ { - if all[i] != 0x30 || all[i+1] != 0x82 { - continue - } - if all[i+4] != 0x30 { - continue - } - totalLen := int(all[i+2])<<8 | int(all[i+3]) - if totalLen < 100 || i+4+totalLen > len(all) { - continue - } - if derAttempts++; derAttempts > maxDERAttempts { - break - } - blob := all[i : i+4+totalLen] - cert, err := x509.ParseCertificate(blob) - if err != nil { - continue - } - derCount++ - child := &Field{ - Name: fmt.Sprintf("DER cert #%d", derCount), - Type: "der", - Offset: int64(i), - Length: int64(4 + totalLen), - } - populateCert(child, cert) - root.Children = append(root.Children, child) - i += 4 + totalLen - 1 - } - - if len(root.Children) == 0 { - return nil, errors.New("no certificates found") - } - root.Value = fmt.Sprintf("PEM=%d DER=%d", pemCount, derCount) - return root, nil -} - -func populateCert(f *Field, c *x509.Certificate) { - f.Value = fmt.Sprintf("subject=%q issuer=%q serial=%x", - c.Subject.String(), c.Issuer.String(), c.SerialNumber) - add := func(name, value string) { - f.Children = append(f.Children, &Field{Name: name, Type: "field", Offset: f.Offset, Length: 0, Value: value}) - } - add("subject", c.Subject.String()) - add("issuer", c.Issuer.String()) - add("serial", fmt.Sprintf("%x", c.SerialNumber)) - add("valid_from", c.NotBefore.UTC().Format("2006-01-02 15:04:05 UTC")) - add("valid_until", c.NotAfter.UTC().Format("2006-01-02 15:04:05 UTC")) - add("pubkey_alg", c.PublicKeyAlgorithm.String()) - add("sig_alg", c.SignatureAlgorithm.String()) - if len(c.DNSNames) > 0 { - add("dns_names", strings.Join(c.DNSNames, ", ")) - } - if len(c.IPAddresses) > 0 { - ips := make([]string, 0, len(c.IPAddresses)) - for _, ip := range c.IPAddresses { - ips = append(ips, ip.String()) - } - add("ip_addresses", strings.Join(ips, ", ")) - } - if len(c.EmailAddresses) > 0 { - add("emails", strings.Join(c.EmailAddresses, ", ")) - } - if c.IsCA { - add("ca", "true") - } - if c.KeyUsage != 0 { - add("key_usage", fmt.Sprintf("0x%x", int(c.KeyUsage))) - } - if len(c.ExtKeyUsage) > 0 { - parts := make([]string, 0, len(c.ExtKeyUsage)) - for _, u := range c.ExtKeyUsage { - parts = append(parts, extKeyUsageName(u)) - } - add("ext_key_usage", strings.Join(parts, ", ")) - } - // SHA-256 fingerprint of DER - add("sha256_fingerprint", sha256Hex(c.Raw)) -} - -func extKeyUsageName(u x509.ExtKeyUsage) string { - switch u { - case x509.ExtKeyUsageAny: - return "any" - case x509.ExtKeyUsageServerAuth: - return "serverAuth" - case x509.ExtKeyUsageClientAuth: - return "clientAuth" - case x509.ExtKeyUsageCodeSigning: - return "codeSigning" - case x509.ExtKeyUsageEmailProtection: - return "emailProtection" - case x509.ExtKeyUsageTimeStamping: - return "timeStamping" - case x509.ExtKeyUsageOCSPSigning: - return "OCSPSigning" - } - return fmt.Sprintf("%d", int(u)) -} - -func sha256Hex(b []byte) string { - h := sha256.Sum256(b) - return fmt.Sprintf("%x", h[:]) -} - -// indexOfHeader returns the byte offset of the next PEM BEGIN line. -func indexOfHeader(b []byte) int { - const tag = "-----BEGIN" - for i := 0; i+len(tag) <= len(b); i++ { - if string(b[i:i+len(tag)]) == tag { - return i - } - } - return -1 -} diff --git a/internal/leviathan/parsers/executable.go b/internal/leviathan/parsers/executable.go deleted file mode 100644 index 246bd02..0000000 --- a/internal/leviathan/parsers/executable.go +++ /dev/null @@ -1,412 +0,0 @@ -package parsers - -import ( - "encoding/binary" - "errors" - "fmt" - "sort" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// ExecutableRegion is a file-backed executable code range. -type ExecutableRegion struct { - Name string `json:"name"` - Offset int64 `json:"offset"` - Size int64 `json:"size"` - Address uint64 `json:"address"` -} - -// FunctionSeed is a named function entry mapped to a file offset. -type FunctionSeed struct { - Name string `json:"name"` - Offset int64 `json:"offset"` - Address uint64 `json:"address"` - Size uint64 `json:"size"` - Source string `json:"source"` -} - -// ExecutableInfo contains disassembly-oriented metadata for an ELF or PE. -type ExecutableInfo struct { - Format string `json:"format"` - Arch string `json:"arch"` - Entry int64 `json:"entry"` - Regions []ExecutableRegion `json:"regions"` - Seeds []FunctionSeed `json:"seeds"` -} - -// InspectExecutable extracts executable ranges, entry point and named function -// seeds without scraping the display-oriented parser tree. -func InspectExecutable(d *document.Document) (*ExecutableInfo, error) { - head, err := d.Range(0, min64(64, d.Size())) - if err != nil { - return nil, err - } - if len(head) >= 4 && string(head[:4]) == "\x7fELF" { - return inspectELFExecutable(d, head) - } - if len(head) >= 2 && string(head[:2]) == "MZ" { - return inspectPEExecutable(d) - } - return nil, errors.New("not a supported ELF or PE executable") -} - -func inspectELFExecutable(d *document.Document, hdr []byte) (*ExecutableInfo, error) { - if len(hdr) < 52 || (hdr[4] != 1 && hdr[4] != 2) || (hdr[5] != 1 && hdr[5] != 2) { - return nil, errors.New("invalid ELF header") - } - class, data := hdr[4], hdr[5] - bo := elfByteOrder(data) - var machine uint16 - var entry, phoff, shoff uint64 - var phentsize, phnum, shentsize, shnum uint16 - if class == 2 { - if len(hdr) < 64 { - return nil, errors.New("ELF64 header too short") - } - machine = bo.Uint16(hdr[18:20]) - entry = bo.Uint64(hdr[24:32]) - phoff = bo.Uint64(hdr[32:40]) - shoff = bo.Uint64(hdr[40:48]) - phentsize, phnum = bo.Uint16(hdr[54:56]), bo.Uint16(hdr[56:58]) - shentsize, shnum = bo.Uint16(hdr[58:60]), bo.Uint16(hdr[60:62]) - } else { - machine = bo.Uint16(hdr[18:20]) - entry = uint64(bo.Uint32(hdr[24:28])) - phoff = uint64(bo.Uint32(hdr[28:32])) - shoff = uint64(bo.Uint32(hdr[32:36])) - phentsize, phnum = bo.Uint16(hdr[42:44]), bo.Uint16(hdr[44:46]) - shentsize, shnum = bo.Uint16(hdr[46:48]), bo.Uint16(hdr[48:50]) - } - sections, _ := executableELFSections(d, bo, class, shoff, shentsize, shnum) - info := &ExecutableInfo{Format: "elf", Arch: elfDisasmArch(machine, data), Entry: -1} - if info.Arch == "" { - return nil, fmt.Errorf("unsupported ELF machine 0x%x", machine) - } - for i, section := range sections { - if section.flags&0x4 != 0 && validFileRange(d, section.fileOff, section.secSize) { - info.Regions = append(info.Regions, ExecutableRegion{ - Name: fmt.Sprintf("section[%d]", i), Offset: int64(section.fileOff), - Size: int64(section.secSize), Address: section.addr, - }) - } - } - if len(info.Regions) == 0 { - info.Regions = executableELFSegments(d, bo, class, phoff, phentsize, phnum) - } - if off, ok := elfAddressToOffset(sections, entry); ok { - info.Entry = off - info.Seeds = append(info.Seeds, FunctionSeed{Name: "entry", Offset: off, Address: entry, Source: "header"}) - } else if off, ok := regionAddressToOffset(info.Regions, entry); ok { - info.Entry = off - info.Seeds = append(info.Seeds, FunctionSeed{Name: "entry", Offset: off, Address: entry, Source: "header"}) - } - for _, symtab := range sections { - if symtab.typ == 2 || symtab.typ == 11 { - info.Seeds = append(info.Seeds, executableELFFunctions(d, bo, class, sections, symtab)...) - } - } - finishExecutableInfo(info) - return info, nil -} - -func executableELFSegments(d *document.Document, bo binary.ByteOrder, class byte, off uint64, entrySize, count uint16) []ExecutableRegion { - if off == 0 || entrySize == 0 || count == 0 || count > 8192 { - return nil - } - regions := make([]ExecutableRegion, 0) - for i := uint16(0); i < count; i++ { - buf, err := d.Range(int64(off)+int64(i)*int64(entrySize), int64(entrySize)) - if err != nil { - break - } - var typ, flags uint32 - var fileOff, address, fileSize uint64 - if class == 2 { - if len(buf) < 56 { - break - } - typ, flags = bo.Uint32(buf[0:4]), bo.Uint32(buf[4:8]) - fileOff, address, fileSize = bo.Uint64(buf[8:16]), bo.Uint64(buf[16:24]), bo.Uint64(buf[32:40]) - } else { - if len(buf) < 32 { - break - } - typ, flags = bo.Uint32(buf[0:4]), bo.Uint32(buf[24:28]) - fileOff, address, fileSize = uint64(bo.Uint32(buf[4:8])), uint64(bo.Uint32(buf[8:12])), uint64(bo.Uint32(buf[16:20])) - } - if typ == 1 && flags&0x1 != 0 && validFileRange(d, fileOff, fileSize) { - regions = append(regions, ExecutableRegion{ - Name: fmt.Sprintf("PT_LOAD[%d]", i), Offset: int64(fileOff), Size: int64(fileSize), Address: address, - }) - } - } - return regions -} - -func executableELFSections(d *document.Document, bo binary.ByteOrder, class byte, off uint64, entrySize, count uint16) ([]elfSectionInfo, error) { - if off == 0 || entrySize == 0 || count == 0 || count > 8192 { - return nil, errors.New("ELF has no usable section table") - } - sections := make([]elfSectionInfo, 0, count) - for i := uint16(0); i < count; i++ { - buf, err := d.Range(int64(off)+int64(i)*int64(entrySize), int64(entrySize)) - if err != nil { - return nil, err - } - var section elfSectionInfo - if class == 2 { - if len(buf) < 64 { - return nil, errors.New("truncated ELF64 section") - } - section.typ, section.flags = bo.Uint32(buf[4:8]), bo.Uint64(buf[8:16]) - section.addr, section.fileOff, section.secSize = bo.Uint64(buf[16:24]), bo.Uint64(buf[24:32]), bo.Uint64(buf[32:40]) - section.link, section.entrySize = bo.Uint32(buf[40:44]), bo.Uint64(buf[56:64]) - } else { - if len(buf) < 40 { - return nil, errors.New("truncated ELF32 section") - } - section.typ, section.flags = bo.Uint32(buf[4:8]), uint64(bo.Uint32(buf[8:12])) - section.addr = uint64(bo.Uint32(buf[12:16])) - section.fileOff, section.secSize = uint64(bo.Uint32(buf[16:20])), uint64(bo.Uint32(buf[20:24])) - section.link, section.entrySize = bo.Uint32(buf[24:28]), uint64(bo.Uint32(buf[36:40])) - } - sections = append(sections, section) - } - return sections, nil -} - -func executableELFFunctions(d *document.Document, bo binary.ByteOrder, class byte, sections []elfSectionInfo, symtab elfSectionInfo) []FunctionSeed { - entrySize := symtab.entrySize - if entrySize == 0 { - entrySize = 16 - if class == 2 { - entrySize = 24 - } - } - if !validFileRange(d, symtab.fileOff, symtab.secSize) || int(symtab.link) >= len(sections) { - return nil - } - strsec := sections[symtab.link] - if !validFileRange(d, strsec.fileOff, strsec.secSize) || strsec.secSize > 8<<20 { - return nil - } - strtab, err := d.Range(int64(strsec.fileOff), int64(strsec.secSize)) - if err != nil { - return nil - } - count := int(symtab.secSize / entrySize) - if count > 16384 { - count = 16384 - } - out := make([]FunctionSeed, 0) - for i := 0; i < count; i++ { - buf, err := d.Range(int64(symtab.fileOff)+int64(i)*int64(entrySize), int64(entrySize)) - if err != nil || len(buf) < int(entrySize) { - break - } - var nameOff uint32 - var info byte - var sectionIndex uint16 - var value, size uint64 - if class == 2 { - nameOff, info, sectionIndex = bo.Uint32(buf[0:4]), buf[4], bo.Uint16(buf[6:8]) - value, size = bo.Uint64(buf[8:16]), bo.Uint64(buf[16:24]) - } else { - nameOff, value, size = bo.Uint32(buf[0:4]), uint64(bo.Uint32(buf[4:8])), uint64(bo.Uint32(buf[8:12])) - info, sectionIndex = buf[12], bo.Uint16(buf[14:16]) - } - if info&0xf != 2 || sectionIndex == 0 || value == 0 { - continue - } - name := elfStringAt(strtab, nameOff) - offset, ok := elfAddressToOffset(sections, value) - if name == "" || !ok { - continue - } - source := "symtab" - if symtab.typ == 11 { - source = "dynsym" - } - out = append(out, FunctionSeed{Name: name, Offset: offset, Address: value, Size: size, Source: source}) - } - return out -} - -func inspectPEExecutable(d *document.Document) (*ExecutableInfo, error) { - sections, dirs, is64, err := peLayout(d) - if err != nil { - return nil, err - } - hdr, _ := d.Range(0, 64) - lfanew := int64(binary.LittleEndian.Uint32(hdr[0x3c:0x40])) - peHdr, _ := d.Range(lfanew, 24) - machine := binary.LittleEndian.Uint16(peHdr[4:6]) - optSize := int64(binary.LittleEndian.Uint16(peHdr[20:22])) - opt, err := d.Range(lfanew+24, optSize) - if err != nil || len(opt) < 20 { - return nil, errors.New("PE optional header too short") - } - entryRVA := binary.LittleEndian.Uint32(opt[16:20]) - info := &ExecutableInfo{Format: "pe", Arch: peDisasmArch(machine, is64), Entry: -1} - if info.Arch == "" { - return nil, fmt.Errorf("unsupported PE machine 0x%x", machine) - } - for _, section := range sections { - if section.Characteristics&0x20000000 != 0 && validFileRange(d, uint64(section.RawOffset), uint64(section.RawSize)) { - info.Regions = append(info.Regions, ExecutableRegion{ - Name: section.Name, Offset: int64(section.RawOffset), Size: int64(section.RawSize), Address: uint64(section.VirtualAddress), - }) - } - } - if off, ok := peRVAToOffset(sections, entryRVA); ok { - info.Entry = int64(off) - info.Seeds = append(info.Seeds, FunctionSeed{Name: "entry", Offset: int64(off), Address: uint64(entryRVA), Source: "header"}) - } - if len(dirs) > 0 && dirs[0].RVA != 0 { - info.Seeds = append(info.Seeds, executablePEExports(d, sections, dirs[0])...) - } - finishExecutableInfo(info) - return info, nil -} - -func executablePEExports(d *document.Document, sections []peSectionInfo, dir peDataDirectory) []FunctionSeed { - off, ok := peRVAToOffset(sections, dir.RVA) - if !ok { - return nil - } - buf, err := d.Range(int64(off), 40) - if err != nil || len(buf) < 40 { - return nil - } - functionCount, nameCount := binary.LittleEndian.Uint32(buf[20:24]), binary.LittleEndian.Uint32(buf[24:28]) - functionsOff, funcsOK := peRVAToOffset(sections, binary.LittleEndian.Uint32(buf[28:32])) - namesOff, namesOK := peRVAToOffset(sections, binary.LittleEndian.Uint32(buf[32:36])) - ordinalsOff, ordOK := peRVAToOffset(sections, binary.LittleEndian.Uint32(buf[36:40])) - if !funcsOK || !namesOK || !ordOK { - return nil - } - if nameCount > 16384 { - nameCount = 16384 - } - out := make([]FunctionSeed, 0, nameCount) - for i := uint32(0); i < nameCount; i++ { - namePtr, nameErr := d.Range(int64(namesOff)+int64(i)*4, 4) - ordBuf, ordErr := d.Range(int64(ordinalsOff)+int64(i)*2, 2) - if nameErr != nil || ordErr != nil || len(namePtr) < 4 || len(ordBuf) < 2 { - break - } - index := uint32(binary.LittleEndian.Uint16(ordBuf)) - if index >= functionCount { - continue - } - funcBuf, err := d.Range(int64(functionsOff)+int64(index)*4, 4) - if err != nil || len(funcBuf) < 4 { - break - } - funcRVA := binary.LittleEndian.Uint32(funcBuf) - if funcRVA >= dir.RVA && funcRVA < dir.RVA+dir.Size { - continue - } - nameOff, nameOK := peRVAToOffset(sections, binary.LittleEndian.Uint32(namePtr)) - funcOff, funcOK := peRVAToOffset(sections, funcRVA) - if !nameOK || !funcOK { - continue - } - name := readCString(d, int64(nameOff), 512) - if name != "" { - out = append(out, FunctionSeed{Name: name, Offset: int64(funcOff), Address: uint64(funcRVA), Source: "export"}) - } - } - return out -} - -func validFileRange(d *document.Document, offset, size uint64) bool { - return size > 0 && offset <= uint64(d.Size()) && size <= uint64(d.Size())-offset -} - -func elfAddressToOffset(sections []elfSectionInfo, address uint64) (int64, bool) { - for _, section := range sections { - if section.secSize > 0 && address >= section.addr && address < section.addr+section.secSize { - return int64(section.fileOff + address - section.addr), true - } - } - return 0, false -} - -func regionAddressToOffset(regions []ExecutableRegion, address uint64) (int64, bool) { - for _, region := range regions { - if address >= region.Address && address < region.Address+uint64(region.Size) { - return region.Offset + int64(address-region.Address), true - } - } - return 0, false -} - -func elfDisasmArch(machine uint16, data byte) string { - switch machine { - case 3: - return "x86" - case 8: - if data == 1 { - return "mipsle" - } - return "mips" - case 40: - return "arm32" - case 62: - return "x86_64" - case 183: - return "aarch64" - } - return "" -} - -func peDisasmArch(machine uint16, _ bool) string { - switch machine { - case 0x14c: - return "x86" - case 0x8664: - return "x86_64" - case 0x1c0, 0x1c4: - return "arm32" - case 0xaa64: - return "aarch64" - case 0x166, 0x266, 0x366, 0x466: - return "mipsle" - } - return "" -} - -func finishExecutableInfo(info *ExecutableInfo) { - sort.Slice(info.Regions, func(i, j int) bool { return info.Regions[i].Offset < info.Regions[j].Offset }) - sort.Slice(info.Seeds, func(i, j int) bool { - if info.Seeds[i].Offset != info.Seeds[j].Offset { - return info.Seeds[i].Offset < info.Seeds[j].Offset - } - return info.Seeds[i].Name < info.Seeds[j].Name - }) - unique := info.Seeds[:0] - for _, seed := range info.Seeds { - if seed.Offset < 0 || !offsetInRegions(info.Regions, seed.Offset) { - continue - } - if len(unique) > 0 && unique[len(unique)-1].Offset == seed.Offset { - if unique[len(unique)-1].Name == "entry" && seed.Name != "entry" { - unique[len(unique)-1] = seed - } - continue - } - unique = append(unique, seed) - } - info.Seeds = unique -} - -func offsetInRegions(regions []ExecutableRegion, offset int64) bool { - for _, region := range regions { - if offset >= region.Offset && offset < region.Offset+region.Size { - return true - } - } - return false -} diff --git a/internal/leviathan/parsers/formats.go b/internal/leviathan/parsers/formats.go deleted file mode 100644 index 7154f06..0000000 --- a/internal/leviathan/parsers/formats.go +++ /dev/null @@ -1,2182 +0,0 @@ -// Package parsers provides lightweight structure parsers for common -// binary formats used in Leviathan's structures / templates panel. -// Each parser returns a tree of Field values anchored at absolute -// offsets into the document. -package parsers - -import ( - "encoding/binary" - "errors" - "fmt" - "strings" - "unicode/utf16" - "unicode/utf8" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// Field is one node of the parsed structure. -type Field struct { - Name string - Type string - Offset int64 - Length int64 - Value string - Note string - Children []*Field -} - -// ToMap serialises a Field tree to JS-friendly maps. -func (f *Field) ToMap() map[string]any { - m := map[string]any{ - "name": f.Name, - "type": f.Type, - "offset": f.Offset, - "length": f.Length, - "value": f.Value, - } - if f.Note != "" { - m["note"] = f.Note - } - if len(f.Children) > 0 { - kids := make([]map[string]any, len(f.Children)) - for i, c := range f.Children { - kids[i] = c.ToMap() - } - m["children"] = kids - } - return m -} - -// DetectAndParse picks the first matching parser for the document. -// It returns the parser name used and the tree. -func DetectAndParse(d *document.Document) (string, *Field, error) { - // Read enough to cover the tar ustar magic at offset 257. - head, err := d.Range(0, min64(265, d.Size())) - if err != nil { - return "", nil, err - } - switch { - case len(head) >= 8 && string(head[:8]) == "\x89PNG\r\n\x1a\n": - f, err := ParsePNG(d) - return "png", f, err - case len(head) >= 3 && head[0] == 0xff && head[1] == 0xd8 && head[2] == 0xff: - f, err := ParseJPEG(d) - return "jpeg", f, err - case len(head) >= 6 && (string(head[:6]) == "GIF87a" || string(head[:6]) == "GIF89a"): - f, err := ParseGIF(d) - return "gif", f, err - case len(head) >= 2 && string(head[:2]) == "BM": - f, err := ParseBMP(d) - return "bmp", f, err - case len(head) >= 4 && string(head[:4]) == "PK\x03\x04": - f, err := ParseZIP(d) - return "zip", f, err - case len(head) >= 4 && string(head[:4]) == "\x7fELF": - f, err := ParseELF(d) - return "elf", f, err - case len(head) >= 2 && string(head[:2]) == "MZ": - f, err := ParsePE(d) - return "pe", f, err - case len(head) >= 4 && string(head[:4]) == "RIFF": - f, err := ParseRIFF(d) - return "riff", f, err - case len(head) >= 16 && string(head[:16]) == "SQLite format 3\x00": - f, err := ParseSQLite(d) - return "sqlite", f, err - case len(head) >= 8 && string(head[:8]) == string(cfbMagic): - f, err := ParseCFB(d) - return "cfb", f, err - case len(head) >= 3 && head[0] == 0x1f && head[1] == 0x8b && head[2] == 8: - f, err := ParseGZIP(d) - return "gzip", f, err - case len(head) >= 4 && string(head[:4]) == "\x0a\x0d\x0d\x0a": - f, err := ParsePCAPNG(d) - return "pcapng", f, err - case len(head) >= 4 && isPCAPMagic(head): - f, err := ParsePCAP(d) - return "pcap", f, err - case len(head) >= 4 && (string(head[:4]) == "II\x2a\x00" || string(head[:4]) == "MM\x00\x2a"): - f, err := ParseTIFF(d) - return "tiff", f, err - case len(head) >= 265 && (string(head[257:262]) == "ustar"): - f, err := ParseTAR(d) - return "tar", f, err - } - // Mach-O (including fat/universal) — detection needs more than 8 bytes. - if IsMachO(head, d.Size()) { - f, err := ParseMachO(d) - return "macho", f, err - } - return "", nil, errors.New("no parser matched") -} - -func isPCAPMagic(head []byte) bool { - m := binary.LittleEndian.Uint32(head[:4]) - return m == 0xa1b2c3d4 || m == 0xd4c3b2a1 || m == 0xa1b23c4d || m == 0x4d3cb2a1 -} - -// ParsePNG walks chunks sequentially and returns a Field tree. -func ParsePNG(d *document.Document) (*Field, error) { - root := &Field{Name: "PNG", Type: "container", Offset: 0} - hdr, err := d.Range(0, 8) - if err != nil { - return nil, err - } - root.Children = append(root.Children, &Field{ - Name: "signature", Type: "bytes", Offset: 0, Length: 8, - Value: fmt.Sprintf("% x", hdr), - }) - off := int64(8) - size := d.Size() - for off+8 <= size { - hdr, err := d.Range(off, 8) - if err != nil { - return root, err - } - ln := int64(binary.BigEndian.Uint32(hdr[0:4])) - typ := string(hdr[4:8]) - chunk := &Field{ - Name: typ + "@" + fmt.Sprintf("0x%x", off), - Type: "chunk", - Offset: off, - Length: 12 + ln, - Value: fmt.Sprintf("length=%d", ln), - } - chunk.Children = []*Field{ - {Name: "length", Type: "u32be", Offset: off, Length: 4, Value: fmt.Sprintf("%d", ln)}, - {Name: "type", Type: "ascii", Offset: off + 4, Length: 4, Value: typ}, - {Name: "data", Type: "bytes", Offset: off + 8, Length: ln, Value: fmt.Sprintf("%d bytes", ln)}, - {Name: "crc", Type: "u32be", Offset: off + 8 + ln, Length: 4, Value: "crc32"}, - } - root.Children = append(root.Children, chunk) - off += 12 + ln - if typ == "IEND" { - break - } - } - root.Length = off - return root, nil -} - -// ParseJPEG walks marker segments. -func ParseJPEG(d *document.Document) (*Field, error) { - root := &Field{Name: "JPEG", Type: "container", Offset: 0} - off := int64(0) - size := d.Size() - for off < size { - b, err := d.Range(off, 2) - if err != nil || len(b) < 2 { - break - } - if b[0] != 0xff { - return root, errors.New("lost sync") - } - marker := b[1] - mark := &Field{ - Name: jpegMarkerName(marker), - Type: "marker", - Offset: off, - Value: fmt.Sprintf("0x%02x", marker), - } - off += 2 - if marker == 0xd8 || marker == 0xd9 || marker == 0x00 || (marker >= 0xd0 && marker <= 0xd7) { - mark.Length = 2 - root.Children = append(root.Children, mark) - if marker == 0xd9 { - break - } - continue - } - lb, err := d.Range(off, 2) - if err != nil || len(lb) < 2 { - break - } - ln := int64(binary.BigEndian.Uint16(lb)) - mark.Length = 2 + ln - mark.Children = []*Field{ - {Name: "length", Type: "u16be", Offset: off, Length: 2, Value: fmt.Sprintf("%d", ln)}, - {Name: "data", Type: "bytes", Offset: off + 2, Length: ln - 2, Value: fmt.Sprintf("%d bytes", ln-2)}, - } - root.Children = append(root.Children, mark) - off += ln - if marker == 0xda { // SOS - // scan until next marker - for off < size { - bb, err := d.Range(off, 2) - if err != nil || len(bb) < 2 { - break - } - if bb[0] == 0xff && bb[1] != 0x00 && !(bb[1] >= 0xd0 && bb[1] <= 0xd7) { - break - } - off++ - } - } - } - root.Length = off - return root, nil -} - -func jpegMarkerName(m byte) string { - switch m { - case 0xd8: - return "SOI" - case 0xd9: - return "EOI" - case 0xda: - return "SOS" - case 0xdb: - return "DQT" - case 0xc4: - return "DHT" - case 0xe0: - return "APP0" - case 0xe1: - return "APP1" - case 0xfe: - return "COM" - } - if m >= 0xc0 && m <= 0xcf { - return fmt.Sprintf("SOF%d", int(m-0xc0)) - } - if m >= 0xe0 && m <= 0xef { - return fmt.Sprintf("APP%d", int(m-0xe0)) - } - return fmt.Sprintf("MRK%02x", m) -} - -// ParseGIF walks the logical screen descriptor, global colour table, -// extension blocks, image descriptors and trailer. -func ParseGIF(d *document.Document) (*Field, error) { - size := d.Size() - if size < 13 { - return nil, errors.New("gif too short") - } - hdr, err := d.Range(0, 13) - if err != nil { - return nil, err - } - version := string(hdr[0:6]) - if version != "GIF87a" && version != "GIF89a" { - return nil, errors.New("bad gif signature") - } - width := binary.LittleEndian.Uint16(hdr[6:8]) - height := binary.LittleEndian.Uint16(hdr[8:10]) - packed := hdr[10] - gctFlag := packed&0x80 != 0 - colorRes := ((packed >> 4) & 0x07) + 1 - sortFlag := packed&0x08 != 0 - gctEntries := 0 - if gctFlag { - gctEntries = 1 << ((packed & 0x07) + 1) - } - root := &Field{ - Name: "GIF", - Type: "container", - Offset: 0, - Value: fmt.Sprintf("%s %dx%d", version, width, height), - Children: []*Field{ - {Name: "signature", Type: "ascii", Offset: 0, Length: 6, Value: version}, - {Name: "width", Type: "u16le", Offset: 6, Length: 2, Value: fmt.Sprintf("%d", width)}, - {Name: "height", Type: "u16le", Offset: 8, Length: 2, Value: fmt.Sprintf("%d", height)}, - {Name: "packed", Type: "u8", Offset: 10, Length: 1, Value: fmt.Sprintf("gct=%v colorRes=%d sort=%v entries=%d", gctFlag, colorRes, sortFlag, gctEntries)}, - {Name: "backgroundColorIndex", Type: "u8", Offset: 11, Length: 1, Value: fmt.Sprintf("%d", hdr[11])}, - {Name: "pixelAspectRatio", Type: "u8", Offset: 12, Length: 1, Value: fmt.Sprintf("%d", hdr[12])}, - }, - } - off := int64(13) - if gctEntries > 0 { - ln := int64(gctEntries * 3) - if off+ln > size { - return root, errors.New("gif global colour table exceeds file") - } - root.Children = append(root.Children, &Field{Name: "globalColorTable", Type: "palette", Offset: off, Length: ln, Value: fmt.Sprintf("%d RGB entries", gctEntries)}) - off += ln - } - for off < size { - tag, err := d.Range(off, 1) - if err != nil || len(tag) < 1 { - break - } - switch tag[0] { - case 0x3b: - root.Children = append(root.Children, &Field{Name: "trailer", Type: "byte", Offset: off, Length: 1, Value: "0x3b"}) - off++ - root.Length = off - return root, nil - case 0x21: - block, next, err := parseGIFExtension(d, off, size) - root.Children = append(root.Children, block) - if err != nil { - root.Length = next - return root, err - } - off = next - case 0x2c: - block, next, err := parseGIFImage(d, off, size) - root.Children = append(root.Children, block) - if err != nil { - root.Length = next - return root, err - } - off = next - default: - root.Children = append(root.Children, &Field{Name: "unknown", Type: "byte", Offset: off, Length: 1, Value: fmt.Sprintf("0x%02x", tag[0])}) - root.Length = off + 1 - return root, fmt.Errorf("unknown gif block 0x%02x at 0x%x", tag[0], off) - } - } - root.Length = off - return root, nil -} - -func parseGIFExtension(d *document.Document, off, size int64) (*Field, int64, error) { - hdr, err := d.Range(off, 2) - if err != nil || len(hdr) < 2 { - return &Field{Name: "extension", Type: "block", Offset: off}, off, errors.New("short gif extension") - } - label := hdr[1] - name := gifExtensionName(label) - next, subBlocks, err := skipGIFSubBlocks(d, off+2, size) - f := &Field{ - Name: name, - Type: "extension", - Offset: off, - Length: next - off, - Value: fmt.Sprintf("label=0x%02x subBlocks=%d", label, subBlocks), - Children: []*Field{ - {Name: "introducer", Type: "u8", Offset: off, Length: 1, Value: "0x21"}, - {Name: "label", Type: "u8", Offset: off + 1, Length: 1, Value: fmt.Sprintf("0x%02x", label)}, - }, - } - return f, next, err -} - -func parseGIFImage(d *document.Document, off, size int64) (*Field, int64, error) { - hdr, err := d.Range(off, 10) - if err != nil || len(hdr) < 10 { - return &Field{Name: "image", Type: "image", Offset: off}, off, errors.New("short gif image descriptor") - } - left := binary.LittleEndian.Uint16(hdr[1:3]) - top := binary.LittleEndian.Uint16(hdr[3:5]) - width := binary.LittleEndian.Uint16(hdr[5:7]) - height := binary.LittleEndian.Uint16(hdr[7:9]) - packed := hdr[9] - lctEntries := 0 - if packed&0x80 != 0 { - lctEntries = 1 << ((packed & 0x07) + 1) - } - next := off + 10 + int64(lctEntries*3) - if next >= size { - return nil, next, errors.New("gif image data missing") - } - lzw, err := d.Range(next, 1) - if err != nil || len(lzw) < 1 { - return nil, next, errors.New("gif lzw minimum code size missing") - } - dataStart := next + 1 - next, subBlocks, err := skipGIFSubBlocks(d, dataStart, size) - f := &Field{ - Name: fmt.Sprintf("image@0x%x", off), - Type: "image", - Offset: off, - Length: next - off, - Value: fmt.Sprintf("%dx%d at %d,%d subBlocks=%d", width, height, left, top, subBlocks), - Children: []*Field{ - {Name: "left", Type: "u16le", Offset: off + 1, Length: 2, Value: fmt.Sprintf("%d", left)}, - {Name: "top", Type: "u16le", Offset: off + 3, Length: 2, Value: fmt.Sprintf("%d", top)}, - {Name: "width", Type: "u16le", Offset: off + 5, Length: 2, Value: fmt.Sprintf("%d", width)}, - {Name: "height", Type: "u16le", Offset: off + 7, Length: 2, Value: fmt.Sprintf("%d", height)}, - {Name: "packed", Type: "u8", Offset: off + 9, Length: 1, Value: fmt.Sprintf("lctEntries=%d interlace=%v", lctEntries, packed&0x40 != 0)}, - {Name: "lzwMinimumCodeSize", Type: "u8", Offset: dataStart - 1, Length: 1, Value: fmt.Sprintf("%d", lzw[0])}, - {Name: "imageData", Type: "subblocks", Offset: dataStart, Length: next - dataStart, Value: fmt.Sprintf("%d sub-blocks", subBlocks)}, - }, - } - if lctEntries > 0 { - f.Children = append(f.Children, &Field{Name: "localColorTable", Type: "palette", Offset: off + 10, Length: int64(lctEntries * 3), Value: fmt.Sprintf("%d RGB entries", lctEntries)}) - } - return f, next, err -} - -func skipGIFSubBlocks(d *document.Document, off, size int64) (int64, int, error) { - count := 0 - for off < size { - b, err := d.Range(off, 1) - if err != nil || len(b) < 1 { - return off, count, errors.New("short gif sub-block") - } - off++ - ln := int64(b[0]) - if ln == 0 { - return off, count, nil - } - if off+ln > size { - return off, count, errors.New("gif sub-block exceeds file") - } - off += ln - count++ - } - return off, count, errors.New("unterminated gif sub-block chain") -} - -func gifExtensionName(label byte) string { - switch label { - case 0xf9: - return "graphicControlExtension" - case 0xfe: - return "commentExtension" - case 0xff: - return "applicationExtension" - case 0x01: - return "plainTextExtension" - } - return fmt.Sprintf("extension%02x", label) -} - -// ParseBMP reads the BMP file header and DIB header metadata. -func ParseBMP(d *document.Document) (*Field, error) { - size := d.Size() - if size < 26 { - return nil, errors.New("bmp too short") - } - hdr, err := d.Range(0, min64(size, 138)) - if err != nil { - return nil, err - } - if len(hdr) < 26 || string(hdr[0:2]) != "BM" { - return nil, errors.New("bad bmp signature") - } - fileSize := binary.LittleEndian.Uint32(hdr[2:6]) - pixelOff := binary.LittleEndian.Uint32(hdr[10:14]) - dibSize := binary.LittleEndian.Uint32(hdr[14:18]) - root := &Field{ - Name: "BMP", - Type: "container", - Offset: 0, - Length: int64(fileSize), - Value: fmt.Sprintf("fileSize=%d pixelOffset=0x%x dib=%s", fileSize, pixelOff, bmpDIBName(dibSize)), - Children: []*Field{ - {Name: "signature", Type: "ascii", Offset: 0, Length: 2, Value: "BM"}, - {Name: "fileSize", Type: "u32le", Offset: 2, Length: 4, Value: fmt.Sprintf("%d", fileSize)}, - {Name: "pixelDataOffset", Type: "u32le", Offset: 10, Length: 4, Value: fmt.Sprintf("0x%x", pixelOff)}, - {Name: "dibHeaderSize", Type: "u32le", Offset: 14, Length: 4, Value: fmt.Sprintf("%d (%s)", dibSize, bmpDIBName(dibSize))}, - }, - } - if dibSize == 12 { - if len(hdr) < 26 { - return root, nil - } - width := binary.LittleEndian.Uint16(hdr[18:20]) - height := binary.LittleEndian.Uint16(hdr[20:22]) - planes := binary.LittleEndian.Uint16(hdr[22:24]) - bpp := binary.LittleEndian.Uint16(hdr[24:26]) - root.Children = append(root.Children, - &Field{Name: "width", Type: "u16le", Offset: 18, Length: 2, Value: fmt.Sprintf("%d", width)}, - &Field{Name: "height", Type: "u16le", Offset: 20, Length: 2, Value: fmt.Sprintf("%d", height)}, - &Field{Name: "planes", Type: "u16le", Offset: 22, Length: 2, Value: fmt.Sprintf("%d", planes)}, - &Field{Name: "bitsPerPixel", Type: "u16le", Offset: 24, Length: 2, Value: fmt.Sprintf("%d", bpp)}, - ) - } else if dibSize >= 40 && len(hdr) >= 54 { - width := int32(binary.LittleEndian.Uint32(hdr[18:22])) - height := int32(binary.LittleEndian.Uint32(hdr[22:26])) - planes := binary.LittleEndian.Uint16(hdr[26:28]) - bpp := binary.LittleEndian.Uint16(hdr[28:30]) - compression := binary.LittleEndian.Uint32(hdr[30:34]) - imageSize := binary.LittleEndian.Uint32(hdr[34:38]) - xppm := int32(binary.LittleEndian.Uint32(hdr[38:42])) - yppm := int32(binary.LittleEndian.Uint32(hdr[42:46])) - coloursUsed := binary.LittleEndian.Uint32(hdr[46:50]) - root.Children = append(root.Children, - &Field{Name: "width", Type: "i32le", Offset: 18, Length: 4, Value: fmt.Sprintf("%d", width)}, - &Field{Name: "height", Type: "i32le", Offset: 22, Length: 4, Value: fmt.Sprintf("%d", height)}, - &Field{Name: "planes", Type: "u16le", Offset: 26, Length: 2, Value: fmt.Sprintf("%d", planes)}, - &Field{Name: "bitsPerPixel", Type: "u16le", Offset: 28, Length: 2, Value: fmt.Sprintf("%d", bpp)}, - &Field{Name: "compression", Type: "u32le", Offset: 30, Length: 4, Value: fmt.Sprintf("%d (%s)", compression, bmpCompressionName(compression))}, - &Field{Name: "imageSize", Type: "u32le", Offset: 34, Length: 4, Value: fmt.Sprintf("%d", imageSize)}, - &Field{Name: "xPixelsPerMeter", Type: "i32le", Offset: 38, Length: 4, Value: fmt.Sprintf("%d", xppm)}, - &Field{Name: "yPixelsPerMeter", Type: "i32le", Offset: 42, Length: 4, Value: fmt.Sprintf("%d", yppm)}, - &Field{Name: "coloursUsed", Type: "u32le", Offset: 46, Length: 4, Value: fmt.Sprintf("%d", coloursUsed)}, - ) - } - if int64(pixelOff) < size { - root.Children = append(root.Children, &Field{Name: "pixelData", Type: "bytes", Offset: int64(pixelOff), Length: size - int64(pixelOff), Value: fmt.Sprintf("%d bytes", size-int64(pixelOff))}) - } - return root, nil -} - -func bmpDIBName(size uint32) string { - switch size { - case 12: - return "BITMAPCOREHEADER" - case 40: - return "BITMAPINFOHEADER" - case 52: - return "BITMAPV2INFOHEADER" - case 56: - return "BITMAPV3INFOHEADER" - case 108: - return "BITMAPV4HEADER" - case 124: - return "BITMAPV5HEADER" - } - return "unknown" -} - -func bmpCompressionName(v uint32) string { - switch v { - case 0: - return "BI_RGB" - case 1: - return "BI_RLE8" - case 2: - return "BI_RLE4" - case 3: - return "BI_BITFIELDS" - case 4: - return "BI_JPEG" - case 5: - return "BI_PNG" - } - return "unknown" -} - -// ParseZIP walks local file headers by seeking forward. -func ParseZIP(d *document.Document) (*Field, error) { - root := &Field{Name: "ZIP", Type: "container", Offset: 0, Length: d.Size()} - size := d.Size() - - // 1) Local file headers, walked from the front. - off := int64(0) - entries := 0 - for off+30 <= size && entries < 5000 { - sig, err := d.Range(off, 4) - if err != nil || string(sig) != "PK\x03\x04" { - break // reached the central directory / EOCD region - } - hdr, err := d.Range(off, 30) - if err != nil { - break - } - flags := binary.LittleEndian.Uint16(hdr[6:8]) - method := binary.LittleEndian.Uint16(hdr[8:10]) - dosTime := binary.LittleEndian.Uint16(hdr[10:12]) - dosDate := binary.LittleEndian.Uint16(hdr[12:14]) - crc := binary.LittleEndian.Uint32(hdr[14:18]) - compSize := int64(binary.LittleEndian.Uint32(hdr[18:22])) - uncompSize := int64(binary.LittleEndian.Uint32(hdr[22:26])) - nameLen := int64(binary.LittleEndian.Uint16(hdr[26:28])) - extraLen := int64(binary.LittleEndian.Uint16(hdr[28:30])) - nameB, _ := d.Range(off+30, nameLen) - e := &Field{ - Name: string(nameB), - Type: "entry", - Offset: off, - Length: 30 + nameLen + extraLen + compSize, - Value: fmt.Sprintf("%s comp=%d uncomp=%d", zipMethod(method), compSize, uncompSize), - Children: []*Field{ - {Name: "signature", Type: "bytes", Offset: off, Length: 4, Value: "PK\\x03\\x04"}, - {Name: "version_needed", Type: "u16le", Offset: off + 4, Length: 2, Value: fmt.Sprintf("%d", binary.LittleEndian.Uint16(hdr[4:6]))}, - {Name: "flags", Type: "u16le", Offset: off + 6, Length: 2, Value: zipFlags(flags)}, - {Name: "method", Type: "u16le", Offset: off + 8, Length: 2, Value: zipMethod(method)}, - {Name: "mod_time", Type: "dostime", Offset: off + 10, Length: 4, Value: zipDOSDateTime(dosDate, dosTime)}, - {Name: "crc32", Type: "u32le", Offset: off + 14, Length: 4, Value: fmt.Sprintf("0x%08x", crc)}, - {Name: "compressed_size", Type: "u32le", Offset: off + 18, Length: 4, Value: fmt.Sprintf("%d", compSize)}, - {Name: "uncompressed_size", Type: "u32le", Offset: off + 22, Length: 4, Value: fmt.Sprintf("%d", uncompSize)}, - {Name: "name", Type: "ascii", Offset: off + 30, Length: nameLen, Value: string(nameB)}, - {Name: "data", Type: "bytes", Offset: off + 30 + nameLen + extraLen, Length: compSize, Value: fmt.Sprintf("%d bytes", compSize)}, - }, - } - root.Children = append(root.Children, e) - off += 30 + nameLen + extraLen + compSize - entries++ - } - - // 2) Central directory + end-of-central-directory, located from the back. - if cd := zipCentralDirectory(d); cd != nil { - root.Children = append(root.Children, cd...) - } - return root, nil -} - -// zipCentralDirectory finds the EOCD record, decodes it, and walks the central -// directory it points at. Returns the EOCD field plus a "central_directory" -// container, or nil if no EOCD is present. -func zipCentralDirectory(d *document.Document) []*Field { - size := d.Size() - // EOCD is at most 22 + 65535 bytes from the end; scan a bounded tail. - scan := int64(22 + 65535) - if scan > size { - scan = size - } - tail, err := d.Range(size-scan, scan) - if err != nil { - return nil - } - eocdRel := int64(-1) - for i := len(tail) - 22; i >= 0; i-- { - if tail[i] == 'P' && tail[i+1] == 'K' && tail[i+2] == 0x05 && tail[i+3] == 0x06 { - eocdRel = int64(i) - break - } - } - if eocdRel < 0 { - return nil - } - eocdOff := size - scan + eocdRel - e, _ := d.Range(eocdOff, 22) - if len(e) < 22 { - return nil - } - total := binary.LittleEndian.Uint16(e[10:12]) - cdSize := int64(binary.LittleEndian.Uint32(e[12:16])) - cdOff := int64(binary.LittleEndian.Uint32(e[16:20])) - commentLen := int64(binary.LittleEndian.Uint16(e[20:22])) - eocd := &Field{ - Name: "end_of_central_directory", Type: "eocd", Offset: eocdOff, Length: 22 + commentLen, - Value: fmt.Sprintf("%d entries, cd@0x%x size=%d", total, cdOff, cdSize), - Children: []*Field{ - {Name: "total_entries", Type: "u16le", Offset: eocdOff + 10, Length: 2, Value: fmt.Sprintf("%d", total)}, - {Name: "cd_size", Type: "u32le", Offset: eocdOff + 12, Length: 4, Value: fmt.Sprintf("%d", cdSize)}, - {Name: "cd_offset", Type: "u32le", Offset: eocdOff + 16, Length: 4, Value: fmt.Sprintf("0x%x", cdOff)}, - {Name: "comment_length", Type: "u16le", Offset: eocdOff + 20, Length: 2, Value: fmt.Sprintf("%d", commentLen)}, - }, - } - out := []*Field{} - - // ZIP64: a 0xffffffff offset or 0xffff count signals a ZIP64 EOCD locator. - if cdOff == 0xffffffff || total == 0xffff { - if eocdOff-20 >= 0 { - loc, _ := d.Range(eocdOff-20, 20) - if len(loc) == 20 && string(loc[0:4]) == "PK\x06\x07" { - z64Off := int64(binary.LittleEndian.Uint64(loc[8:16])) - eocd.Children = append(eocd.Children, &Field{Name: "zip64_locator", Type: "bytes", Offset: eocdOff - 20, Length: 20, Value: fmt.Sprintf("zip64 EOCD @0x%x", z64Off)}) - if z64, _ := d.Range(z64Off, 56); len(z64) == 56 && string(z64[0:4]) == "PK\x06\x06" { - cdOff = int64(binary.LittleEndian.Uint64(z64[48:56])) - cdSize = int64(binary.LittleEndian.Uint64(z64[40:48])) - out = append(out, &Field{Name: "zip64_eocd", Type: "zip64", Offset: z64Off, Length: 56, Value: fmt.Sprintf("entries=%d cd@0x%x", binary.LittleEndian.Uint64(z64[32:40]), cdOff)}) - } - } - } - } - - // Walk central-directory file headers. - if cdOff >= 0 && cdOff < size { - cd := &Field{Name: "central_directory", Type: "directory", Offset: cdOff, Length: cdSize} - off := cdOff - end := size - if cdSize > 0 && cdOff+cdSize <= size { - end = cdOff + cdSize - } - for n := 0; off+46 <= end && n < 5000; n++ { - h, err := d.Range(off, 46) - if err != nil || string(h[0:4]) != "PK\x01\x02" { - break - } - method := binary.LittleEndian.Uint16(h[10:12]) - crc := binary.LittleEndian.Uint32(h[16:20]) - compSize := int64(binary.LittleEndian.Uint32(h[20:24])) - nameLen := int64(binary.LittleEndian.Uint16(h[28:30])) - extraLen := int64(binary.LittleEndian.Uint16(h[30:32])) - commLen := int64(binary.LittleEndian.Uint16(h[32:34])) - lho := binary.LittleEndian.Uint32(h[42:46]) - nameB, _ := d.Range(off+46, nameLen) - cd.Children = append(cd.Children, &Field{ - Name: string(nameB), Type: "cd_entry", Offset: off, Length: 46 + nameLen + extraLen + commLen, - Value: fmt.Sprintf("%s crc=0x%08x comp=%d lfh@0x%x", zipMethod(method), crc, compSize, lho), - }) - off += 46 + nameLen + extraLen + commLen - } - out = append(out, cd) - } - out = append(out, eocd) - return out -} - -func zipMethod(m uint16) string { - names := map[uint16]string{0: "store", 1: "shrink", 6: "implode", 8: "deflate", 9: "deflate64", 12: "bzip2", 14: "lzma", 95: "xz", 96: "jpeg", 97: "wavpack", 98: "ppmd", 99: "aes"} - if n, ok := names[m]; ok { - return fmt.Sprintf("%d (%s)", m, n) - } - return fmt.Sprintf("%d", m) -} - -func zipFlags(f uint16) string { - var on []string - if f&0x0001 != 0 { - on = append(on, "encrypted") - } - if f&0x0008 != 0 { - on = append(on, "data-descriptor") - } - if f&0x0800 != 0 { - on = append(on, "utf8") - } - if len(on) == 0 { - return fmt.Sprintf("0x%04x", f) - } - return fmt.Sprintf("0x%04x (%s)", f, strings.Join(on, "|")) -} - -// zipDOSDateTime decodes the MS-DOS packed date/time used by ZIP entries. -func zipDOSDateTime(date, t uint16) string { - if date == 0 && t == 0 { - return "0" - } - year := 1980 + int(date>>9) - month := int(date>>5) & 0x0f - day := int(date) & 0x1f - hour := int(t >> 11) - minute := int(t>>5) & 0x3f - second := (int(t) & 0x1f) * 2 - return fmt.Sprintf("%04d-%02d-%02d %02d:%02d:%02d", year, month, day, hour, minute, second) -} - -// ParseELF returns header, program header, and section header fields. -func ParseELF(d *document.Document) (*Field, error) { - hdr, err := d.Range(0, 64) - if err != nil || len(hdr) < 52 { - return nil, errors.New("elf too short") - } - class := hdr[4] - data := hdr[5] - kind := "32-bit" - if class == 2 { - kind = "64-bit" - } - endian := "little-endian" - if data == 2 { - endian = "big-endian" - } - root := &Field{ - Name: "ELF", - Type: "container", - Offset: 0, - Length: d.Size(), - Value: kind + " " + endian, - Children: []*Field{ - {Name: "ei_mag", Type: "bytes", Offset: 0, Length: 4, Value: "\\x7fELF"}, - {Name: "ei_class", Type: "u8", Offset: 4, Length: 1, Value: kind}, - {Name: "ei_data", Type: "u8", Offset: 5, Length: 1, Value: endian}, - {Name: "ei_version", Type: "u8", Offset: 6, Length: 1, Value: fmt.Sprintf("%d", hdr[6])}, - {Name: "ei_osabi", Type: "u8", Offset: 7, Length: 1, Value: fmt.Sprintf("%d", hdr[7])}, - }, - } - var e_type, e_machine uint16 - var e_entry uint64 - var e_phoff, e_shoff uint64 - var e_phentsize, e_phnum, e_shentsize, e_shnum, e_shstrndx uint16 - bo := elfByteOrder(data) - if class == 2 { - e_type = bo.Uint16(hdr[16:18]) - e_machine = bo.Uint16(hdr[18:20]) - e_entry = bo.Uint64(hdr[24:32]) - e_phoff = bo.Uint64(hdr[32:40]) - e_shoff = bo.Uint64(hdr[40:48]) - e_phentsize = bo.Uint16(hdr[54:56]) - e_phnum = bo.Uint16(hdr[56:58]) - e_shentsize = bo.Uint16(hdr[58:60]) - e_shnum = bo.Uint16(hdr[60:62]) - e_shstrndx = bo.Uint16(hdr[62:64]) - } else { - e_type = bo.Uint16(hdr[16:18]) - e_machine = bo.Uint16(hdr[18:20]) - e_entry = uint64(bo.Uint32(hdr[24:28])) - e_phoff = uint64(bo.Uint32(hdr[28:32])) - e_shoff = uint64(bo.Uint32(hdr[32:36])) - e_phentsize = bo.Uint16(hdr[42:44]) - e_phnum = bo.Uint16(hdr[44:46]) - e_shentsize = bo.Uint16(hdr[46:48]) - e_shnum = bo.Uint16(hdr[48:50]) - e_shstrndx = bo.Uint16(hdr[50:52]) - } - root.Children = append(root.Children, - &Field{Name: "e_type", Value: fmt.Sprintf("%d (%s)", e_type, elfType(e_type))}, - &Field{Name: "e_machine", Value: fmt.Sprintf("%d (%s)", e_machine, elfMachine(e_machine))}, - &Field{Name: "e_entry", Value: fmt.Sprintf("0x%x", e_entry)}, - &Field{Name: "e_phoff", Value: fmt.Sprintf("0x%x", e_phoff)}, - &Field{Name: "e_shoff", Value: fmt.Sprintf("0x%x", e_shoff)}, - &Field{Name: "e_phentsize", Value: fmt.Sprintf("%d", e_phentsize)}, - &Field{Name: "e_phnum", Value: fmt.Sprintf("%d", e_phnum)}, - &Field{Name: "e_shentsize", Value: fmt.Sprintf("%d", e_shentsize)}, - &Field{Name: "e_shnum", Value: fmt.Sprintf("%d", e_shnum)}, - &Field{Name: "e_shstrndx", Value: fmt.Sprintf("%d", e_shstrndx)}, - ) - root.Children = append(root.Children, elfProgramHeaders(d, bo, class, e_phoff, e_phentsize, e_phnum)...) - root.Children = append(root.Children, elfSectionHeaders(d, bo, class, e_shoff, e_shentsize, e_shnum, e_shstrndx)...) - return root, nil -} - -func elfByteOrder(data byte) binary.ByteOrder { - if data == 2 { - return binary.BigEndian - } - return binary.LittleEndian -} - -func elfProgramHeaders(d *document.Document, bo binary.ByteOrder, class byte, off uint64, entsize, count uint16) []*Field { - if off == 0 || entsize == 0 || count == 0 { - return nil - } - size := d.Size() - out := make([]*Field, 0, count) - for i := uint16(0); i < count; i++ { - at := int64(off) + int64(i)*int64(entsize) - if at < 0 || at+int64(entsize) > size { - break - } - buf, err := d.Range(at, int64(entsize)) - if err != nil { - break - } - var typ, flags uint32 - var pOffset, vaddr, filesz, memsz, align uint64 - if class == 2 { - if len(buf) < 56 { - break - } - typ = bo.Uint32(buf[0:4]) - flags = bo.Uint32(buf[4:8]) - pOffset = bo.Uint64(buf[8:16]) - vaddr = bo.Uint64(buf[16:24]) - filesz = bo.Uint64(buf[32:40]) - memsz = bo.Uint64(buf[40:48]) - align = bo.Uint64(buf[48:56]) - } else { - if len(buf) < 32 { - break - } - typ = bo.Uint32(buf[0:4]) - pOffset = uint64(bo.Uint32(buf[4:8])) - vaddr = uint64(bo.Uint32(buf[8:12])) - filesz = uint64(bo.Uint32(buf[16:20])) - memsz = uint64(bo.Uint32(buf[20:24])) - flags = bo.Uint32(buf[24:28]) - align = uint64(bo.Uint32(buf[28:32])) - } - out = append(out, &Field{ - Name: fmt.Sprintf("program[%d]", i), - Type: "program_header", - Offset: at, - Length: int64(entsize), - Value: fmt.Sprintf("type=%s offset=0x%x vaddr=0x%x filesz=%d memsz=%d flags=%s align=0x%x", elfProgramType(typ), pOffset, vaddr, filesz, memsz, elfProgramFlags(flags), align), - }) - } - return out -} - -type elfSectionInfo struct { - nameOff uint32 - typ uint32 - flags, addr uint64 - fileOff, secSize uint64 - link, info uint32 - align, entrySize uint64 - at int64 - length int64 -} - -func elfSectionHeaders(d *document.Document, bo binary.ByteOrder, class byte, off uint64, entsize, count, strIndex uint16) []*Field { - if off == 0 || entsize == 0 || count == 0 { - return nil - } - size := d.Size() - sections := make([]elfSectionInfo, 0, count) - for i := uint16(0); i < count; i++ { - at := int64(off) + int64(i)*int64(entsize) - if at < 0 || at+int64(entsize) > size { - break - } - buf, err := d.Range(at, int64(entsize)) - if err != nil { - break - } - var s elfSectionInfo - s.at = at - s.length = int64(entsize) - if class == 2 { - if len(buf) < 64 { - break - } - s.nameOff = bo.Uint32(buf[0:4]) - s.typ = bo.Uint32(buf[4:8]) - s.flags = bo.Uint64(buf[8:16]) - s.addr = bo.Uint64(buf[16:24]) - s.fileOff = bo.Uint64(buf[24:32]) - s.secSize = bo.Uint64(buf[32:40]) - s.link = bo.Uint32(buf[40:44]) - s.info = bo.Uint32(buf[44:48]) - s.align = bo.Uint64(buf[48:56]) - s.entrySize = bo.Uint64(buf[56:64]) - } else { - if len(buf) < 40 { - break - } - s.nameOff = bo.Uint32(buf[0:4]) - s.typ = bo.Uint32(buf[4:8]) - s.flags = uint64(bo.Uint32(buf[8:12])) - s.addr = uint64(bo.Uint32(buf[12:16])) - s.fileOff = uint64(bo.Uint32(buf[16:20])) - s.secSize = uint64(bo.Uint32(buf[20:24])) - s.link = bo.Uint32(buf[24:28]) - s.info = bo.Uint32(buf[28:32]) - s.align = uint64(bo.Uint32(buf[32:36])) - s.entrySize = uint64(bo.Uint32(buf[36:40])) - } - sections = append(sections, s) - } - var names []byte - if int(strIndex) < len(sections) { - str := sections[strIndex] - if str.fileOff <= uint64(size) && str.fileOff+str.secSize <= uint64(size) && str.secSize <= 1<<20 { - names, _ = d.Range(int64(str.fileOff), int64(str.secSize)) - } - } - sectionNames := make([]string, len(sections)) - for i, s := range sections { - name := elfStringAt(names, s.nameOff) - if name == "" { - name = fmt.Sprintf("section[%d]", i) - } - sectionNames[i] = name - } - out := make([]*Field, 0, len(sections)) - for i, s := range sections { - name := sectionNames[i] - children := []*Field{} - if s.typ == 6 { - children = elfDynamicEntries(d, bo, class, sections, s) - } else if s.typ == 2 || s.typ == 11 { - children = elfSymbolEntries(d, bo, class, sections, s) - } - out = append(out, &Field{ - Name: name, - Type: "section_header", - Offset: s.at, - Length: s.length, - Value: fmt.Sprintf("type=%s offset=0x%x size=%d addr=0x%x flags=%s link=%d info=%d align=0x%x entsize=%d", elfSectionType(s.typ), s.fileOff, s.secSize, s.addr, elfSectionFlags(s.flags), s.link, s.info, s.align, s.entrySize), - Children: children, - }) - } - return out -} - -func elfSymbolEntries(d *document.Document, bo binary.ByteOrder, class byte, sections []elfSectionInfo, symtab elfSectionInfo) []*Field { - entrySize := symtab.entrySize - if entrySize == 0 { - if class == 2 { - entrySize = 24 - } else { - entrySize = 16 - } - } - if entrySize == 0 || symtab.secSize == 0 || symtab.fileOff+symtab.secSize > uint64(d.Size()) { - return nil - } - var strtab []byte - if int(symtab.link) < len(sections) { - linked := sections[symtab.link] - if linked.fileOff+linked.secSize <= uint64(d.Size()) && linked.secSize <= 8<<20 { - strtab, _ = d.Range(int64(linked.fileOff), int64(linked.secSize)) - } - } - count := int(symtab.secSize / entrySize) - if count > 4096 { - count = 4096 - } - out := []*Field{} - for i := 0; i < count; i++ { - at := int64(symtab.fileOff + uint64(i)*entrySize) - buf, err := d.Range(at, int64(entrySize)) - if err != nil || len(buf) < int(entrySize) { - break - } - var nameOff uint32 - var info, other byte - var shndx uint16 - var value, size uint64 - if class == 2 { - nameOff = bo.Uint32(buf[0:4]) - info = buf[4] - other = buf[5] - shndx = bo.Uint16(buf[6:8]) - value = bo.Uint64(buf[8:16]) - size = bo.Uint64(buf[16:24]) - } else { - nameOff = bo.Uint32(buf[0:4]) - value = uint64(bo.Uint32(buf[4:8])) - size = uint64(bo.Uint32(buf[8:12])) - info = buf[12] - other = buf[13] - shndx = bo.Uint16(buf[14:16]) - } - name := elfStringAt(strtab, nameOff) - if name == "" { - name = fmt.Sprintf("", i) - } - out = append(out, &Field{ - Name: fmt.Sprintf("symbol[%d] %s", i, name), - Type: "symbol", - Offset: at, - Length: int64(entrySize), - Value: fmt.Sprintf("name=%s bind=%s type=%s vis=%s shndx=%s value=0x%x size=%d", - name, elfSymbolBind(info>>4), elfSymbolType(info&0xf), elfSymbolVisibility(other&0x3), elfSymbolSection(shndx), value, size), - }) - } - return out -} - -func elfDynamicEntries(d *document.Document, bo binary.ByteOrder, class byte, sections []elfSectionInfo, dyn elfSectionInfo) []*Field { - entrySize := dyn.entrySize - if entrySize == 0 { - if class == 2 { - entrySize = 16 - } else { - entrySize = 8 - } - } - if entrySize == 0 || dyn.secSize == 0 || dyn.fileOff+dyn.secSize > uint64(d.Size()) { - return nil - } - var strtab []byte - if int(dyn.link) < len(sections) { - linked := sections[dyn.link] - if linked.fileOff+linked.secSize <= uint64(d.Size()) && linked.secSize <= 8<<20 { - strtab, _ = d.Range(int64(linked.fileOff), int64(linked.secSize)) - } - } - out := []*Field{} - count := int(dyn.secSize / entrySize) - if count > 4096 { - count = 4096 - } - for i := 0; i < count; i++ { - at := int64(dyn.fileOff + uint64(i)*entrySize) - buf, err := d.Range(at, int64(entrySize)) - if err != nil || len(buf) < int(entrySize) { - break - } - var tag int64 - var val uint64 - if class == 2 { - tag = int64(bo.Uint64(buf[0:8])) - val = bo.Uint64(buf[8:16]) - } else { - tag = int64(int32(bo.Uint32(buf[0:4]))) - val = uint64(bo.Uint32(buf[4:8])) - } - value := fmt.Sprintf("%s=0x%x", elfDynamicTag(tag), val) - if tag == 1 && len(strtab) > 0 { - if s := elfStringAt(strtab, uint32(val)); s != "" { - value = "NEEDED=" + s - } - } - out = append(out, &Field{ - Name: fmt.Sprintf("dynamic[%d]", i), - Type: "dynamic_entry", - Offset: at, - Length: int64(entrySize), - Value: value, - }) - if tag == 0 { - break - } - } - return out -} - -func elfSymbolBind(bind byte) string { - switch bind { - case 0: - return "LOCAL" - case 1: - return "GLOBAL" - case 2: - return "WEAK" - } - return fmt.Sprintf("0x%x", bind) -} - -func elfSymbolType(typ byte) string { - switch typ { - case 0: - return "NOTYPE" - case 1: - return "OBJECT" - case 2: - return "FUNC" - case 3: - return "SECTION" - case 4: - return "FILE" - case 5: - return "COMMON" - case 6: - return "TLS" - } - return fmt.Sprintf("0x%x", typ) -} - -func elfSymbolVisibility(vis byte) string { - switch vis { - case 0: - return "DEFAULT" - case 1: - return "INTERNAL" - case 2: - return "HIDDEN" - case 3: - return "PROTECTED" - } - return fmt.Sprintf("0x%x", vis) -} - -func elfSymbolSection(idx uint16) string { - switch idx { - case 0: - return "UND" - case 0xfff1: - return "ABS" - case 0xfff2: - return "COMMON" - } - return fmt.Sprintf("%d", idx) -} - -func elfDynamicTag(tag int64) string { - switch tag { - case 0: - return "NULL" - case 1: - return "NEEDED" - case 2: - return "PLTRELSZ" - case 3: - return "PLTGOT" - case 4: - return "HASH" - case 5: - return "STRTAB" - case 6: - return "SYMTAB" - case 7: - return "RELA" - case 8: - return "RELASZ" - case 9: - return "RELAENT" - case 10: - return "STRSZ" - case 11: - return "SYMENT" - case 12: - return "INIT" - case 13: - return "FINI" - case 14: - return "SONAME" - case 15: - return "RPATH" - case 16: - return "SYMBOLIC" - case 17: - return "REL" - case 18: - return "RELSZ" - case 19: - return "RELENT" - case 20: - return "PLTREL" - case 21: - return "DEBUG" - case 22: - return "TEXTREL" - case 23: - return "JMPREL" - case 29: - return "RUNPATH" - } - return fmt.Sprintf("0x%x", tag) -} - -func elfStringAt(buf []byte, off uint32) string { - if int(off) >= len(buf) { - return "" - } - end := int(off) - for end < len(buf) && buf[end] != 0 { - end++ - } - return string(buf[off:end]) -} - -func elfProgramType(t uint32) string { - switch t { - case 0: - return "NULL" - case 1: - return "LOAD" - case 2: - return "DYNAMIC" - case 3: - return "INTERP" - case 4: - return "NOTE" - case 5: - return "SHLIB" - case 6: - return "PHDR" - case 7: - return "TLS" - } - return fmt.Sprintf("0x%x", t) -} - -func elfProgramFlags(flags uint32) string { - var out string - if flags&4 != 0 { - out += "R" - } - if flags&2 != 0 { - out += "W" - } - if flags&1 != 0 { - out += "X" - } - if out == "" { - return "-" - } - return out -} - -func elfSectionType(t uint32) string { - switch t { - case 0: - return "NULL" - case 1: - return "PROGBITS" - case 2: - return "SYMTAB" - case 3: - return "STRTAB" - case 4: - return "RELA" - case 5: - return "HASH" - case 6: - return "DYNAMIC" - case 7: - return "NOTE" - case 8: - return "NOBITS" - case 9: - return "REL" - case 11: - return "DYNSYM" - case 14: - return "INIT_ARRAY" - case 15: - return "FINI_ARRAY" - case 17: - return "GROUP" - case 18: - return "SYMTAB_SHNDX" - } - return fmt.Sprintf("0x%x", t) -} - -func elfSectionFlags(flags uint64) string { - var out string - if flags&0x1 != 0 { - out += "W" - } - if flags&0x2 != 0 { - out += "A" - } - if flags&0x4 != 0 { - out += "X" - } - if flags&0x10 != 0 { - out += "M" - } - if flags&0x20 != 0 { - out += "S" - } - if flags&0x40 != 0 { - out += "I" - } - if flags&0x400 != 0 { - out += "T" - } - if out == "" { - return "-" - } - return out -} - -func elfType(t uint16) string { - switch t { - case 1: - return "REL" - case 2: - return "EXEC" - case 3: - return "DYN" - case 4: - return "CORE" - } - return "UNKNOWN" -} - -func elfMachine(m uint16) string { - switch m { - case 0x03: - return "x86" - case 0x28: - return "ARM" - case 0x3e: - return "x86_64" - case 0xb7: - return "AArch64" - case 0x08: - return "MIPS" - case 0xf3: - return "RISC-V" - } - return "other" -} - -// ParsePE walks the DOS header, PE header, and section headers. -func ParsePE(d *document.Document) (*Field, error) { - hdr, err := d.Range(0, 64) - if err != nil || len(hdr) < 64 { - return nil, errors.New("too short") - } - lfanew := int64(binary.LittleEndian.Uint32(hdr[0x3c:0x40])) - root := &Field{Name: "PE", Type: "container", Offset: 0} - root.Children = append(root.Children, - &Field{Name: "e_magic", Offset: 0, Length: 2, Value: "MZ"}, - &Field{Name: "e_lfanew", Offset: 0x3c, Length: 4, Value: fmt.Sprintf("0x%x", lfanew)}, - ) - peHdr, err := d.Range(lfanew, 24) - if err != nil || len(peHdr) < 24 || string(peHdr[:4]) != "PE\x00\x00" { - return root, errors.New("no PE signature") - } - machine := binary.LittleEndian.Uint16(peHdr[4:6]) - numSections := binary.LittleEndian.Uint16(peHdr[6:8]) - timestamp := binary.LittleEndian.Uint32(peHdr[8:12]) - sizeOptHdr := binary.LittleEndian.Uint16(peHdr[20:22]) - characteristics := binary.LittleEndian.Uint16(peHdr[22:24]) - root.Children = append(root.Children, - &Field{Name: "Signature", Offset: lfanew, Length: 4, Value: "PE\\x00\\x00"}, - &Field{Name: "Machine", Offset: lfanew + 4, Length: 2, Value: fmt.Sprintf("0x%04x (%s)", machine, peMachine(machine))}, - &Field{Name: "NumberOfSections", Offset: lfanew + 6, Length: 2, Value: fmt.Sprintf("%d", numSections)}, - &Field{Name: "TimeDateStamp", Offset: lfanew + 8, Length: 4, Value: fmt.Sprintf("%d", timestamp)}, - &Field{Name: "SizeOfOptionalHeader", Offset: lfanew + 20, Length: 2, Value: fmt.Sprintf("%d", sizeOptHdr)}, - &Field{Name: "Characteristics", Offset: lfanew + 22, Length: 2, Value: fmt.Sprintf("0x%04x", characteristics)}, - ) - sections := make([]peSectionInfo, 0, numSections) - optOff := lfanew + 24 - var dataDirs []peDataDirectory - peIs64 := false - if sizeOptHdr > 0 { - opt, err := d.Range(optOff, int64(sizeOptHdr)) - if err == nil && len(opt) >= 2 { - optional, dirs := parsePEOptionalHeader(opt, optOff) - root.Children = append(root.Children, optional) - dataDirs = dirs - peIs64 = binary.LittleEndian.Uint16(opt[0:2]) == 0x20b - } - } - sectOff := lfanew + 24 + int64(sizeOptHdr) - for i := 0; i < int(numSections); i++ { - sh, err := d.Range(sectOff, 40) - if err != nil || len(sh) < 40 { - break - } - name := string(trimZero(sh[0:8])) - vsize := binary.LittleEndian.Uint32(sh[8:12]) - vaddr := binary.LittleEndian.Uint32(sh[12:16]) - rsize := binary.LittleEndian.Uint32(sh[16:20]) - raddr := binary.LittleEndian.Uint32(sh[20:24]) - flags := binary.LittleEndian.Uint32(sh[36:40]) - sec := &Field{ - Name: name, Type: "section", Offset: sectOff, Length: 40, - Value: fmt.Sprintf("vaddr=0x%x vsize=%d raw=0x%x rsize=%d flags=0x%x", vaddr, vsize, raddr, rsize, flags), - } - root.Children = append(root.Children, sec) - sections = append(sections, peSectionInfo{Name: name, VirtualAddress: vaddr, VirtualSize: vsize, RawOffset: raddr, RawSize: rsize, Characteristics: flags}) - sectOff += 40 - } - if len(dataDirs) > 0 && dataDirs[0].RVA != 0 && dataDirs[0].Size != 0 { - root.Children = append(root.Children, parsePEExports(d, sections, dataDirs[0])...) - } - if len(dataDirs) > 1 && dataDirs[1].RVA != 0 && dataDirs[1].Size != 0 { - root.Children = append(root.Children, parsePEImports(d, sections, dataDirs[1], peIs64)...) - } - if len(dataDirs) > 2 && dataDirs[2].RVA != 0 && dataDirs[2].Size != 0 { - root.Children = append(root.Children, parsePEResources(d, sections, dataDirs[2])...) - } - return root, nil -} - -type peDataDirectory struct { - Name string - RVA uint32 - Size uint32 -} - -type peSectionInfo struct { - Name string - VirtualAddress uint32 - VirtualSize uint32 - RawOffset uint32 - RawSize uint32 - Characteristics uint32 -} - -func parsePEOptionalHeader(opt []byte, off int64) (*Field, []peDataDirectory) { - magic := binary.LittleEndian.Uint16(opt[0:2]) - is64 := magic == 0x20b - name := "PE32" - if is64 { - name = "PE32+" - } - field := &Field{Name: "optional_header", Type: "optional_header", Offset: off, Length: int64(len(opt)), Value: name} - if len(opt) >= 28 { - field.Children = append(field.Children, - &Field{Name: "Magic", Type: "u16le", Offset: off, Length: 2, Value: fmt.Sprintf("0x%04x (%s)", magic, name)}, - &Field{Name: "AddressOfEntryPoint", Type: "u32le", Offset: off + 16, Length: 4, Value: fmt.Sprintf("0x%x", binary.LittleEndian.Uint32(opt[16:20]))}, - &Field{Name: "BaseOfCode", Type: "u32le", Offset: off + 20, Length: 4, Value: fmt.Sprintf("0x%x", binary.LittleEndian.Uint32(opt[20:24]))}, - ) - } - if is64 && len(opt) >= 112 { - field.Children = append(field.Children, - &Field{Name: "ImageBase", Type: "u64le", Offset: off + 24, Length: 8, Value: fmt.Sprintf("0x%x", binary.LittleEndian.Uint64(opt[24:32]))}, - &Field{Name: "SectionAlignment", Type: "u32le", Offset: off + 32, Length: 4, Value: fmt.Sprintf("0x%x", binary.LittleEndian.Uint32(opt[32:36]))}, - &Field{Name: "FileAlignment", Type: "u32le", Offset: off + 36, Length: 4, Value: fmt.Sprintf("0x%x", binary.LittleEndian.Uint32(opt[36:40]))}, - &Field{Name: "SizeOfImage", Type: "u32le", Offset: off + 56, Length: 4, Value: fmt.Sprintf("0x%x", binary.LittleEndian.Uint32(opt[56:60]))}, - &Field{Name: "Subsystem", Type: "u16le", Offset: off + 68, Length: 2, Value: peSubsystem(binary.LittleEndian.Uint16(opt[68:70]))}, - ) - return field, peDataDirectories(opt, off, 112) - } - if !is64 && len(opt) >= 96 { - field.Children = append(field.Children, - &Field{Name: "ImageBase", Type: "u32le", Offset: off + 28, Length: 4, Value: fmt.Sprintf("0x%x", binary.LittleEndian.Uint32(opt[28:32]))}, - &Field{Name: "SectionAlignment", Type: "u32le", Offset: off + 32, Length: 4, Value: fmt.Sprintf("0x%x", binary.LittleEndian.Uint32(opt[32:36]))}, - &Field{Name: "FileAlignment", Type: "u32le", Offset: off + 36, Length: 4, Value: fmt.Sprintf("0x%x", binary.LittleEndian.Uint32(opt[36:40]))}, - &Field{Name: "SizeOfImage", Type: "u32le", Offset: off + 56, Length: 4, Value: fmt.Sprintf("0x%x", binary.LittleEndian.Uint32(opt[56:60]))}, - &Field{Name: "Subsystem", Type: "u16le", Offset: off + 68, Length: 2, Value: peSubsystem(binary.LittleEndian.Uint16(opt[68:70]))}, - ) - return field, peDataDirectories(opt, off, 96) - } - return field, nil -} - -func peDataDirectories(opt []byte, optOff int64, dirOff int) []peDataDirectory { - if len(opt) < dirOff { - return nil - } - names := []string{"export", "import", "resource", "exception", "certificate", "base_reloc", "debug", "architecture", "global_ptr", "tls", "load_config", "bound_import", "iat", "delay_import", "clr_runtime", "reserved"} - count := (len(opt) - dirOff) / 8 - if count > len(names) { - count = len(names) - } - out := make([]peDataDirectory, 0, count) - for i := 0; i < count; i++ { - at := dirOff + i*8 - rva := binary.LittleEndian.Uint32(opt[at : at+4]) - size := binary.LittleEndian.Uint32(opt[at+4 : at+8]) - out = append(out, peDataDirectory{Name: names[i], RVA: rva, Size: size}) - } - return out -} - -func parsePEImports(d *document.Document, sections []peSectionInfo, dir peDataDirectory, is64 bool) []*Field { - off, ok := peRVAToOffset(sections, dir.RVA) - if !ok { - return nil - } - out := []*Field{} - for i := 0; i < 256; i++ { - at := int64(off) + int64(i*20) - buf, err := d.Range(at, 20) - if err != nil || len(buf) < 20 { - break - } - oft := binary.LittleEndian.Uint32(buf[0:4]) - timestamp := binary.LittleEndian.Uint32(buf[4:8]) - forwarder := binary.LittleEndian.Uint32(buf[8:12]) - nameRVA := binary.LittleEndian.Uint32(buf[12:16]) - firstThunk := binary.LittleEndian.Uint32(buf[16:20]) - if oft == 0 && timestamp == 0 && forwarder == 0 && nameRVA == 0 && firstThunk == 0 { - break - } - name := "" - if nameOff, ok := peRVAToOffset(sections, nameRVA); ok { - name = readCString(d, int64(nameOff), 256) - } - if name == "" { - name = fmt.Sprintf("rva_0x%x", nameRVA) - } - out = append(out, &Field{ - Name: fmt.Sprintf("import[%d]", i), - Type: "import_descriptor", - Offset: at, - Length: 20, - Value: fmt.Sprintf("dll=%s oft=0x%x first_thunk=0x%x timestamp=0x%x", name, oft, firstThunk, timestamp), - Children: parsePEImportThunks(d, sections, oft, firstThunk, is64), - }) - } - return out -} - -func parsePEExports(d *document.Document, sections []peSectionInfo, dir peDataDirectory) []*Field { - off, ok := peRVAToOffset(sections, dir.RVA) - if !ok { - return nil - } - buf, err := d.Range(int64(off), 40) - if err != nil || len(buf) < 40 { - return nil - } - nameRVA := binary.LittleEndian.Uint32(buf[12:16]) - ordinalBase := binary.LittleEndian.Uint32(buf[16:20]) - numberOfFunctions := binary.LittleEndian.Uint32(buf[20:24]) - numberOfNames := binary.LittleEndian.Uint32(buf[24:28]) - functionsRVA := binary.LittleEndian.Uint32(buf[28:32]) - namesRVA := binary.LittleEndian.Uint32(buf[32:36]) - ordinalsRVA := binary.LittleEndian.Uint32(buf[36:40]) - module := "" - if nameOff, ok := peRVAToOffset(sections, nameRVA); ok { - module = readCString(d, int64(nameOff), 512) - } - if module == "" { - module = fmt.Sprintf("rva_0x%x", nameRVA) - } - root := &Field{ - Name: "exports", - Type: "export_directory", - Offset: int64(off), - Length: 40, - Value: fmt.Sprintf("module=%s ordinal_base=%d functions=%d names=%d", module, ordinalBase, numberOfFunctions, numberOfNames), - } - if numberOfNames > 4096 { - numberOfNames = 4096 - } - namesOff, namesOK := peRVAToOffset(sections, namesRVA) - ordinalsOff, ordOK := peRVAToOffset(sections, ordinalsRVA) - functionsOff, funcsOK := peRVAToOffset(sections, functionsRVA) - if namesOK && ordOK && funcsOK { - for i := uint32(0); i < numberOfNames; i++ { - namePtrAt := int64(namesOff) + int64(i)*4 - namePtr, err := d.Range(namePtrAt, 4) - if err != nil || len(namePtr) < 4 { - break - } - symNameRVA := binary.LittleEndian.Uint32(namePtr) - symName := "" - if symNameOff, ok := peRVAToOffset(sections, symNameRVA); ok { - symName = readCString(d, int64(symNameOff), 512) - } - if symName == "" { - symName = fmt.Sprintf("rva_0x%x", symNameRVA) - } - ordAt := int64(ordinalsOff) + int64(i)*2 - ordBuf, err := d.Range(ordAt, 2) - if err != nil || len(ordBuf) < 2 { - break - } - index := uint32(binary.LittleEndian.Uint16(ordBuf)) - if index >= numberOfFunctions { - continue - } - funcAt := int64(functionsOff) + int64(index)*4 - funcBuf, err := d.Range(funcAt, 4) - if err != nil || len(funcBuf) < 4 { - break - } - funcRVA := binary.LittleEndian.Uint32(funcBuf) - value := fmt.Sprintf("name=%s ordinal=%d rva=0x%x", symName, ordinalBase+index, funcRVA) - if funcOff, ok := peRVAToOffset(sections, funcRVA); ok { - value += fmt.Sprintf(" file=0x%x", funcOff) - } - root.Children = append(root.Children, &Field{ - Name: fmt.Sprintf("export[%d] %s", i, symName), - Type: "export", - Offset: namePtrAt, - Length: 4, - Value: value, - }) - } - } - return []*Field{root} -} - -func parsePEResources(d *document.Document, sections []peSectionInfo, dir peDataDirectory) []*Field { - baseOff, ok := peRVAToOffset(sections, dir.RVA) - if !ok { - return nil - } - root := &Field{ - Name: "resources", - Type: "resource_directory", - Offset: int64(baseOff), - Length: int64(dir.Size), - Value: fmt.Sprintf("rva=0x%x size=%d", dir.RVA, dir.Size), - } - seen := map[uint32]bool{} - root.Children = parsePEResourceDirectory(d, sections, dir.RVA, baseOff, 0, 0, seen, "") - return []*Field{root} -} - -func parsePEResourceDirectory(d *document.Document, sections []peSectionInfo, baseRVA, baseOff, rel uint32, depth int, seen map[uint32]bool, path string) []*Field { - if depth > 8 || seen[rel] { - return nil - } - seen[rel] = true - defer delete(seen, rel) - - at := int64(baseOff + rel) - buf, err := d.Range(at, 16) - if err != nil || len(buf) < 16 { - return nil - } - named := binary.LittleEndian.Uint16(buf[12:14]) - ids := binary.LittleEndian.Uint16(buf[14:16]) - count := int(named) + int(ids) - if count > 1024 { - count = 1024 - } - header := &Field{ - Name: fmt.Sprintf("directory depth=%d", depth), - Type: "resource_directory_header", - Offset: at, - Length: 16, - Value: fmt.Sprintf("named=%d ids=%d timestamp=0x%x version=%d.%d", named, ids, binary.LittleEndian.Uint32(buf[4:8]), binary.LittleEndian.Uint16(buf[10:12]), binary.LittleEndian.Uint16(buf[8:10])), - } - out := []*Field{header} - for i := 0; i < count; i++ { - entryAt := at + 16 + int64(i)*8 - ent, err := d.Range(entryAt, 8) - if err != nil || len(ent) < 8 { - break - } - nameRaw := binary.LittleEndian.Uint32(ent[0:4]) - targetRaw := binary.LittleEndian.Uint32(ent[4:8]) - name := peResourceName(d, baseOff, nameRaw, depth) - targetRel := targetRaw & 0x7fffffff - nextPath := path - if nextPath == "" { - nextPath = name - } else { - nextPath += "/" + name - } - entry := &Field{ - Name: fmt.Sprintf("entry[%d] %s", i, name), - Type: "resource_entry", - Offset: entryAt, - Length: 8, - Value: fmt.Sprintf("name=0x%x target=0x%x path=%s", nameRaw, targetRaw, nextPath), - } - if targetRaw&0x80000000 != 0 { - entry.Children = parsePEResourceDirectory(d, sections, baseRVA, baseOff, targetRel, depth+1, seen, nextPath) - } else { - entry.Children = parsePEResourceDataEntry(d, sections, baseOff, targetRel, nextPath) - } - out = append(out, entry) - } - return out -} - -func parsePEResourceDataEntry(d *document.Document, sections []peSectionInfo, baseOff, rel uint32, path string) []*Field { - at := int64(baseOff + rel) - buf, err := d.Range(at, 16) - if err != nil || len(buf) < 16 { - return nil - } - dataRVA := binary.LittleEndian.Uint32(buf[0:4]) - size := binary.LittleEndian.Uint32(buf[4:8]) - codePage := binary.LittleEndian.Uint32(buf[8:12]) - dataOff, mapped := peRVAToOffset(sections, dataRVA) - value := fmt.Sprintf("data_rva=0x%x size=%d codepage=%d", dataRVA, size, codePage) - if mapped { - value += fmt.Sprintf(" file=0x%x", dataOff) - } - entry := &Field{Name: "data", Type: "resource_data", Offset: at, Length: 16, Value: value} - if mapped && size > 0 { - entry.Children = append(entry.Children, peResourcePayloadFields(d, int64(dataOff), size, path, codePage)...) - } - return []*Field{entry} -} - -func peResourcePayloadFields(d *document.Document, off int64, size uint32, path string, codePage uint32) []*Field { - if size > 16<<20 { - return []*Field{{Name: "payload", Type: "resource_payload", Offset: off, Length: int64(size), Value: "too large to decode"}} - } - buf, err := d.Range(off, int64(size)) - if err != nil { - return nil - } - out := []*Field{{Name: "payload", Type: "resource_payload", Offset: off, Length: int64(size), Value: fmt.Sprintf("%d bytes", size)}} - parts := strings.Split(path, "/") - typ := "" - if len(parts) > 0 { - typ = parts[0] - } - switch { - case strings.HasPrefix(typ, "RT_VERSION"): - out = append(out, decodePEVersionResource(buf, off)...) - case strings.HasPrefix(typ, "RT_STRING"): - out = append(out, decodePEStringTable(buf, off)...) - case strings.HasPrefix(typ, "RT_MANIFEST"): - out = append(out, &Field{Name: "manifest", Type: "manifest", Offset: off, Length: int64(size), Value: peResourceTextPreview(buf, codePage)}) - default: - if txt := peResourceTextPreview(buf, codePage); txt != "" { - out = append(out, &Field{Name: "text_preview", Type: "text", Offset: off, Length: int64(size), Value: txt}) - } - } - return out -} - -func peResourceName(d *document.Document, baseOff, raw uint32, depth int) string { - if raw&0x80000000 != 0 { - rel := raw & 0x7fffffff - buf, err := d.Range(int64(baseOff+rel), 2) - if err != nil || len(buf) < 2 { - return fmt.Sprintf("name@0x%x", rel) - } - n := int(binary.LittleEndian.Uint16(buf)) - if n <= 0 || n > 512 { - return fmt.Sprintf("name@0x%x", rel) - } - rawName, err := d.Range(int64(baseOff+rel+2), int64(n*2)) - if err != nil || len(rawName) < n*2 { - return fmt.Sprintf("name@0x%x", rel) - } - return decodeUTF16LE(rawName) - } - id := raw & 0xffff - if depth == 0 { - return peResourceTypeName(id) - } - if depth == 2 { - return peLanguageName(id) - } - return fmt.Sprintf("#%d", id) -} - -func peResourceTypeName(id uint32) string { - names := map[uint32]string{ - 1: "RT_CURSOR", 2: "RT_BITMAP", 3: "RT_ICON", 4: "RT_MENU", 5: "RT_DIALOG", 6: "RT_STRING", - 7: "RT_FONTDIR", 8: "RT_FONT", 9: "RT_ACCELERATOR", 10: "RT_RCDATA", 11: "RT_MESSAGETABLE", - 12: "RT_GROUP_CURSOR", 14: "RT_GROUP_ICON", 16: "RT_VERSION", 17: "RT_DLGINCLUDE", - 19: "RT_PLUGPLAY", 20: "RT_VXD", 21: "RT_ANICURSOR", 22: "RT_ANIICON", 23: "RT_HTML", 24: "RT_MANIFEST", - } - if name, ok := names[id]; ok { - return fmt.Sprintf("%s(%d)", name, id) - } - return fmt.Sprintf("#%d", id) -} - -func peLanguageName(id uint32) string { - switch id { - case 0: - return "LANG_NEUTRAL" - case 0x0409: - return "en-US" - case 0x0809: - return "en-GB" - } - return fmt.Sprintf("lang-0x%04x", id) -} - -func decodePEStringTable(buf []byte, off int64) []*Field { - out := []*Field{} - pos := 0 - for i := 0; i < 16 && pos+2 <= len(buf); i++ { - n := int(binary.LittleEndian.Uint16(buf[pos : pos+2])) - pos += 2 - if pos+n*2 > len(buf) { - break - } - if n > 0 { - out = append(out, &Field{Name: fmt.Sprintf("string[%d]", i), Type: "resource_string", Offset: off + int64(pos), Length: int64(n * 2), Value: decodeUTF16LE(buf[pos : pos+n*2])}) - } - pos += n * 2 - } - return out -} - -func decodePEVersionResource(buf []byte, off int64) []*Field { - if len(buf) < 6 { - return nil - } - key, keyEnd := readPEVersionKey(buf, 6) - out := []*Field{{Name: "version_info", Type: "version_info", Offset: off, Length: int64(len(buf)), Value: fmt.Sprintf("key=%s length=%d valueLength=%d type=%d", key, binary.LittleEndian.Uint16(buf[0:2]), binary.LittleEndian.Uint16(buf[2:4]), binary.LittleEndian.Uint16(buf[4:6]))}} - valueOff := align4(keyEnd) - if key == "VS_VERSION_INFO" && valueOff+52 <= len(buf) { - fixed := buf[valueOff : valueOff+52] - if binary.LittleEndian.Uint32(fixed[0:4]) == 0xfeef04bd { - fileMS := binary.LittleEndian.Uint32(fixed[8:12]) - fileLS := binary.LittleEndian.Uint32(fixed[12:16]) - prodMS := binary.LittleEndian.Uint32(fixed[16:20]) - prodLS := binary.LittleEndian.Uint32(fixed[20:24]) - out = append(out, &Field{Name: "fixed_file_info", Type: "version_fixed", Offset: off + int64(valueOff), Length: 52, Value: fmt.Sprintf("file=%d.%d.%d.%d product=%d.%d.%d.%d flags=0x%x type=0x%x subtype=0x%x", - fileMS>>16, fileMS&0xffff, fileLS>>16, fileLS&0xffff, - prodMS>>16, prodMS&0xffff, prodLS>>16, prodLS&0xffff, - binary.LittleEndian.Uint32(fixed[28:32]), binary.LittleEndian.Uint32(fixed[36:40]), binary.LittleEndian.Uint32(fixed[40:44]))}) - } - } - if strings.Contains(string(buf), "StringFileInfo") || strings.Contains(string(buf), "VarFileInfo") { - text := decodeUTF16LEASCII(buf) - for _, label := range []string{"CompanyName", "FileDescription", "FileVersion", "ProductName", "ProductVersion", "OriginalFilename"} { - if strings.Contains(text, label) { - out = append(out, &Field{Name: label, Type: "version_string_hint", Offset: off, Length: int64(len(buf)), Value: label}) - } - } - } - return out -} - -func readPEVersionKey(buf []byte, pos int) (string, int) { - start := pos - for pos+1 < len(buf) { - if buf[pos] == 0 && buf[pos+1] == 0 { - return decodeUTF16LE(buf[start:pos]), pos + 2 - } - pos += 2 - } - return "", pos -} - -func align4(n int) int { - return (n + 3) &^ 3 -} - -func peResourceTextPreview(buf []byte, codePage uint32) string { - if len(buf) == 0 { - return "" - } - if len(buf) >= 2 { - if buf[0] == 0xff && buf[1] == 0xfe { - return cleanResourcePreview(decodeUTF16LE(buf[2:])) - } - if buf[0] == 0xfe && buf[1] == 0xff { - return cleanResourcePreview(decodeUTF16BE(buf[2:])) - } - } - if codePage == 1200 { - return cleanResourcePreview(decodeUTF16LE(buf)) - } - if utf8.Valid(buf) { - return cleanResourcePreview(string(buf)) - } - return "" -} - -func cleanResourcePreview(s string) string { - s = strings.TrimSpace(strings.ReplaceAll(s, "\x00", "")) - if len(s) > 512 { - s = s[:512] + "..." - } - if strings.IndexFunc(s, func(r rune) bool { return r < 0x09 }) >= 0 { - return "" - } - return s -} - -func decodeUTF16LE(buf []byte) string { - u := make([]uint16, 0, len(buf)/2) - for i := 0; i+1 < len(buf); i += 2 { - u = append(u, binary.LittleEndian.Uint16(buf[i:i+2])) - } - return string(utf16.Decode(u)) -} - -func decodeUTF16BE(buf []byte) string { - u := make([]uint16, 0, len(buf)/2) - for i := 0; i+1 < len(buf); i += 2 { - u = append(u, binary.BigEndian.Uint16(buf[i:i+2])) - } - return string(utf16.Decode(u)) -} - -func decodeUTF16LEASCII(buf []byte) string { - var b strings.Builder - for i := 0; i+1 < len(buf); i += 2 { - r := binary.LittleEndian.Uint16(buf[i : i+2]) - if r >= 0x20 && r < 0x7f { - b.WriteByte(byte(r)) - } else { - b.WriteByte(' ') - } - } - return b.String() -} - -func parsePEImportThunks(d *document.Document, sections []peSectionInfo, oft, firstThunk uint32, is64 bool) []*Field { - thunkRVA := oft - if thunkRVA == 0 { - thunkRVA = firstThunk - } - thunkOff, ok := peRVAToOffset(sections, thunkRVA) - if !ok { - return nil - } - width := int64(4) - ordinalMask := uint64(0x80000000) - if is64 { - width = 8 - ordinalMask = 0x8000000000000000 - } - out := []*Field{} - for i := 0; i < 4096; i++ { - at := int64(thunkOff) + int64(i)*width - buf, err := d.Range(at, width) - if err != nil || int64(len(buf)) < width { - break - } - var thunk uint64 - if is64 { - thunk = binary.LittleEndian.Uint64(buf) - } else { - thunk = uint64(binary.LittleEndian.Uint32(buf)) - } - if thunk == 0 { - break - } - value := "" - if thunk&ordinalMask != 0 { - value = fmt.Sprintf("ordinal=%d", thunk&0xffff) - } else if nameOff, ok := peRVAToOffset(sections, uint32(thunk)); ok { - name := readImportByName(d, int64(nameOff)) - if name != "" { - value = "name=" + name - } else { - value = fmt.Sprintf("name_rva=0x%x", thunk) - } - } else { - value = fmt.Sprintf("thunk=0x%x", thunk) - } - out = append(out, &Field{ - Name: fmt.Sprintf("thunk[%d]", i), - Type: "import_thunk", - Offset: at, - Length: width, - Value: value, - }) - } - return out -} - -func readImportByName(d *document.Document, off int64) string { - buf, err := d.Range(off, 258) - if err != nil || len(buf) < 3 { - return "" - } - return readCStringFrom(buf[2:]) -} - -func peRVAToOffset(sections []peSectionInfo, rva uint32) (uint32, bool) { - for _, s := range sections { - span := s.VirtualSize - if s.RawSize > span { - span = s.RawSize - } - if span == 0 { - continue - } - if rva >= s.VirtualAddress && rva < s.VirtualAddress+span { - return s.RawOffset + (rva - s.VirtualAddress), true - } - } - return 0, false -} - -func readCString(d *document.Document, off int64, max int64) string { - buf, err := d.Range(off, max) - if err != nil { - return "" - } - return readCStringFrom(buf) -} - -func readCStringFrom(buf []byte) string { - for i, b := range buf { - if b == 0 { - return string(buf[:i]) - } - } - return string(buf) -} - -func peSubsystem(v uint16) string { - switch v { - case 1: - return "native" - case 2: - return "windows_gui" - case 3: - return "windows_cui" - case 7: - return "posix_cui" - case 9: - return "windows_ce_gui" - case 10: - return "efi_application" - case 11: - return "efi_boot_service_driver" - case 12: - return "efi_runtime_driver" - } - return fmt.Sprintf("0x%x", v) -} - -func peMachine(m uint16) string { - switch m { - case 0x14c: - return "i386" - case 0x8664: - return "x86_64" - case 0x1c0: - return "ARM" - case 0xaa64: - return "ARM64" - case 0x200: - return "IA64" - } - return "?" -} - -func trimZero(b []byte) []byte { - for i, c := range b { - if c == 0 { - return b[:i] - } - } - return b -} - -// ParseRIFF walks RIFF chunks. -func ParseRIFF(d *document.Document) (*Field, error) { - size := d.Size() - if size < 12 { - return nil, errors.New("too short") - } - hdr, err := d.Range(0, 12) - if err != nil { - return nil, err - } - total := int64(binary.LittleEndian.Uint32(hdr[4:8])) - form := string(hdr[8:12]) - end := total + 8 - if end > size { - end = size - } - root := &Field{ - Name: "RIFF", Type: "container", Offset: 0, Length: total + 8, - Value: fmt.Sprintf("form=%s (%s)", form, riffFormName(form)), - } - root.Children = riffChunks(d, 12, end, 0) - return root, nil -} - -// riffChunks walks the chunks in [off,end), recursing into LIST containers and -// decoding the common WAV/AVI chunks. -func riffChunks(d *document.Document, off, end int64, depth int) []*Field { - var out []*Field - for off+8 <= end && depth < 16 { - ch, err := d.Range(off, 8) - if err != nil || len(ch) < 8 { - break - } - id := string(ch[0:4]) - ln := int64(binary.LittleEndian.Uint32(ch[4:8])) - body := off + 8 - chunkEnd := body + ln - if chunkEnd > end { - chunkEnd = end - } - f := &Field{Name: riffChunkLabel(id), Type: "chunk", Offset: off, Length: 8 + ln, Value: fmt.Sprintf("length=%d", ln)} - switch id { - case "LIST", "RIFF": - if ln >= 4 { - lb, _ := d.Range(body, 4) - lt := string(lb) - f.Type = "list" - f.Value = fmt.Sprintf("type=%s (%s)", lt, riffFormName(lt)) - f.Children = riffChunks(d, body+4, chunkEnd, depth+1) - } - case "fmt ": - if fb, e := d.Range(body, min64(16, ln)); e == nil && len(fb) >= 16 { - f.Children = []*Field{ - {Name: "audio_format", Type: "u16le", Offset: body, Length: 2, Value: wavFormat(binary.LittleEndian.Uint16(fb[0:2]))}, - {Name: "channels", Type: "u16le", Offset: body + 2, Length: 2, Value: fmt.Sprintf("%d", binary.LittleEndian.Uint16(fb[2:4]))}, - {Name: "sample_rate", Type: "u32le", Offset: body + 4, Length: 4, Value: fmt.Sprintf("%d Hz", binary.LittleEndian.Uint32(fb[4:8]))}, - {Name: "byte_rate", Type: "u32le", Offset: body + 8, Length: 4, Value: fmt.Sprintf("%d", binary.LittleEndian.Uint32(fb[8:12]))}, - {Name: "block_align", Type: "u16le", Offset: body + 12, Length: 2, Value: fmt.Sprintf("%d", binary.LittleEndian.Uint16(fb[12:14]))}, - {Name: "bits_per_sample", Type: "u16le", Offset: body + 14, Length: 2, Value: fmt.Sprintf("%d", binary.LittleEndian.Uint16(fb[14:16]))}, - } - } - case "avih": - if ab, e := d.Range(body, min64(40, ln)); e == nil && len(ab) >= 16 { - f.Children = []*Field{ - {Name: "microsec_per_frame", Type: "u32le", Offset: body, Length: 4, Value: fmt.Sprintf("%d", binary.LittleEndian.Uint32(ab[0:4]))}, - {Name: "max_bytes_per_sec", Type: "u32le", Offset: body + 4, Length: 4, Value: fmt.Sprintf("%d", binary.LittleEndian.Uint32(ab[4:8]))}, - {Name: "total_frames", Type: "u32le", Offset: body + 16, Length: 4, Value: fmt.Sprintf("%d", binary.LittleEndian.Uint32(ab[16:20]))}, - } - } - } - out = append(out, f) - off = chunkEnd - if ln%2 != 0 { - off++ // chunks are word-aligned - } - } - return out -} - -func riffFormName(f string) string { - switch f { - case "WAVE": - return "audio/wav" - case "AVI ": - return "video/avi" - case "WEBP": - return "image/webp" - case "ANI ": - return "animated cursor" - case "RMID": - return "MIDI" - case "INFO": - return "metadata" - case "movi": - return "movie data" - case "hdrl": - return "header list" - case "strl": - return "stream list" - default: - return "?" - } -} - -func riffChunkLabel(id string) string { - return strings.TrimRight(id, " ") -} - -func wavFormat(f uint16) string { - names := map[uint16]string{1: "PCM", 2: "ADPCM", 3: "IEEE float", 6: "A-law", 7: "mu-law", 0xfffe: "extensible"} - if n, ok := names[f]; ok { - return fmt.Sprintf("%d (%s)", f, n) - } - return fmt.Sprintf("%d", f) -} - -func min64(a, b int64) int64 { - if a < b { - return a - } - return b -} diff --git a/internal/leviathan/parsers/formats_more.go b/internal/leviathan/parsers/formats_more.go deleted file mode 100644 index f15d391..0000000 --- a/internal/leviathan/parsers/formats_more.go +++ /dev/null @@ -1,662 +0,0 @@ -// Additional structure parsers that round out the structures panel into a -// forensic-grade set: gzip, tar, SQLite, TIFF, pcap/pcapng and the OLE/CFB -// compound-file container used by legacy Office documents and MSI installers. -// -// Each parser follows the same contract as the parsers in formats.go: it -// returns a *Field tree anchored at absolute document offsets, never panics on -// truncated input, and bounds every loop so a hostile file cannot wedge the UI. -package parsers - -import ( - "encoding/binary" - "errors" - "fmt" - "strconv" - "strings" - "time" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// leaf builds a terminal Field with no children. -func leaf(name, typ string, off, length int64, value string) *Field { - return &Field{Name: name, Type: typ, Offset: off, Length: length, Value: value} -} - -// ---------- gzip (RFC 1952) ---------- - -// ParseGZIP decodes the gzip member header, optional name/comment/extra fields -// and the trailing CRC-32 / ISIZE footer. -func ParseGZIP(d *document.Document) (*Field, error) { - size := d.Size() - hdr, err := d.Range(0, min64(10, size)) - if err != nil || len(hdr) < 10 { - return nil, errors.New("gzip too short") - } - if hdr[0] != 0x1f || hdr[1] != 0x8b { - return nil, errors.New("not gzip") - } - root := &Field{Name: "GZIP", Type: "container", Offset: 0, Length: size} - method := hdr[2] - methodName := "deflate" - if method != 8 { - methodName = fmt.Sprintf("unknown(%d)", method) - } - flg := hdr[3] - mtime := binary.LittleEndian.Uint32(hdr[4:8]) - root.Children = append(root.Children, - leaf("id", "bytes", 0, 2, "\\x1f\\x8b"), - leaf("method", "u8", 2, 1, methodName), - leaf("flags", "u8", 3, 1, gzipFlags(flg)), - leaf("mtime", "u32le", 4, 4, gzipTime(mtime)), - leaf("extra_flags", "u8", 8, 1, fmt.Sprintf("%d", hdr[8])), - leaf("os", "u8", 9, 1, gzipOS(hdr[9])), - ) - off := int64(10) - // FEXTRA - if flg&0x04 != 0 && off+2 <= size { - xb, _ := d.Range(off, 2) - xlen := int64(binary.LittleEndian.Uint16(xb)) - root.Children = append(root.Children, leaf("extra", "bytes", off, 2+xlen, fmt.Sprintf("%d bytes", xlen))) - off += 2 + xlen - } - // FNAME - if flg&0x08 != 0 { - s, n := readCStringField(d, off, size) - root.Children = append(root.Children, leaf("name", "asciiz", off, n, s)) - off += n - } - // FCOMMENT - if flg&0x10 != 0 { - s, n := readCStringField(d, off, size) - root.Children = append(root.Children, leaf("comment", "asciiz", off, n, s)) - off += n - } - // FHCRC - if flg&0x02 != 0 && off+2 <= size { - hb, _ := d.Range(off, 2) - root.Children = append(root.Children, leaf("header_crc16", "u16le", off, 2, fmt.Sprintf("0x%04x", binary.LittleEndian.Uint16(hb)))) - off += 2 - } - if size >= off+8 { - root.Children = append(root.Children, leaf("compressed_data", "bytes", off, size-off-8, fmt.Sprintf("%d bytes", size-off-8))) - ft, _ := d.Range(size-8, 8) - root.Children = append(root.Children, - leaf("crc32", "u32le", size-8, 4, fmt.Sprintf("0x%08x", binary.LittleEndian.Uint32(ft[0:4]))), - leaf("isize", "u32le", size-4, 4, fmt.Sprintf("%d bytes (mod 2^32)", binary.LittleEndian.Uint32(ft[4:8]))), - ) - } - return root, nil -} - -func gzipFlags(f byte) string { - var on []string - for _, b := range []struct { - mask byte - name string - }{{0x01, "TEXT"}, {0x02, "HCRC"}, {0x04, "EXTRA"}, {0x08, "NAME"}, {0x10, "COMMENT"}} { - if f&b.mask != 0 { - on = append(on, b.name) - } - } - if len(on) == 0 { - return fmt.Sprintf("0x%02x", f) - } - return fmt.Sprintf("0x%02x (%s)", f, strings.Join(on, "|")) -} - -func gzipOS(b byte) string { - names := map[byte]string{0: "FAT", 1: "Amiga", 2: "VMS", 3: "Unix", 6: "HPFS", 7: "Macintosh", 11: "NTFS", 255: "unknown"} - if n, ok := names[b]; ok { - return fmt.Sprintf("%d (%s)", b, n) - } - return fmt.Sprintf("%d", b) -} - -func gzipTime(t uint32) string { - if t == 0 { - return "0 (no timestamp)" - } - return fmt.Sprintf("%d (%s)", t, time.Unix(int64(t), 0).UTC().Format("2006-01-02 15:04:05 UTC")) -} - -// ---------- tar (POSIX ustar) ---------- - -// ParseTAR walks the 512-byte record headers of a ustar/GNU tar archive. -func ParseTAR(d *document.Document) (*Field, error) { - size := d.Size() - root := &Field{Name: "TAR", Type: "container", Offset: 0} - off := int64(0) - entries := 0 - for off+512 <= size && entries < 10000 { - blk, err := d.Range(off, 512) - if err != nil { - break - } - if allZero(blk) { - root.Children = append(root.Children, leaf("end_of_archive", "bytes", off, 512, "zero block")) - off += 512 - break - } - name := trimZeroStr(blk[0:100]) - prefix := trimZeroStr(blk[345:500]) - full := name - if prefix != "" { - full = prefix + "/" + name - } - fsize := octal(blk[124:136]) - mtime := octal(blk[136:148]) - typ := blk[156] - e := &Field{ - Name: full, Type: "entry", Offset: off, - Length: 512 + roundUp(fsize, 512), - Value: fmt.Sprintf("%s size=%d", tarType(typ), fsize), - Children: []*Field{ - leaf("name", "asciiz", off, 100, name), - leaf("mode", "octal", off+100, 8, fmt.Sprintf("0%o", octal(blk[100:108]))), - leaf("uid", "octal", off+108, 8, fmt.Sprintf("%d", octal(blk[108:116]))), - leaf("gid", "octal", off+116, 8, fmt.Sprintf("%d", octal(blk[116:124]))), - leaf("size", "octal", off+124, 12, fmt.Sprintf("%d", fsize)), - leaf("mtime", "octal", off+136, 12, tarTime(mtime)), - leaf("typeflag", "u8", off+156, 1, tarType(typ)), - leaf("magic", "ascii", off+257, 8, trimZeroStr(blk[257:265])), - leaf("uname", "asciiz", off+265, 32, trimZeroStr(blk[265:297])), - leaf("gname", "asciiz", off+297, 32, trimZeroStr(blk[297:329])), - }, - } - if prefix != "" { - e.Children = append(e.Children, leaf("prefix", "asciiz", off+345, 155, prefix)) - } - root.Children = append(root.Children, e) - off += 512 + roundUp(fsize, 512) - entries++ - } - root.Length = off - if len(root.Children) == 0 { - return nil, errors.New("no tar entries") - } - return root, nil -} - -func tarType(t byte) string { - switch t { - case 0, '0': - return "file" - case '1': - return "hardlink" - case '2': - return "symlink" - case '3': - return "char-device" - case '4': - return "block-device" - case '5': - return "directory" - case '6': - return "fifo" - case '7': - return "contiguous" - case 'x': - return "pax-header" - case 'g': - return "pax-global" - case 'L': - return "gnu-longname" - case 'K': - return "gnu-longlink" - default: - return fmt.Sprintf("type-%q", t) - } -} - -func tarTime(t int64) string { - if t == 0 { - return "0" - } - return fmt.Sprintf("%d (%s)", t, time.Unix(t, 0).UTC().Format("2006-01-02 15:04:05 UTC")) -} - -// ---------- SQLite 3 ---------- - -// ParseSQLite decodes the 100-byte SQLite database header. -func ParseSQLite(d *document.Document) (*Field, error) { - hdr, err := d.Range(0, min64(100, d.Size())) - if err != nil || len(hdr) < 100 { - return nil, errors.New("sqlite header too short") - } - if string(hdr[0:16]) != "SQLite format 3\x00" { - return nil, errors.New("not a SQLite database") - } - be := binary.BigEndian - pageSize := int64(be.Uint16(hdr[16:18])) - if pageSize == 1 { - pageSize = 65536 - } - enc := map[uint32]string{1: "UTF-8", 2: "UTF-16le", 3: "UTF-16be"}[be.Uint32(hdr[56:60])] - if enc == "" { - enc = "unknown" - } - root := &Field{ - Name: "SQLite", Type: "container", Offset: 0, Length: d.Size(), - Value: fmt.Sprintf("page=%d pages=%d %s", pageSize, be.Uint32(hdr[28:32]), enc), - Children: []*Field{ - leaf("magic", "ascii", 0, 16, "SQLite format 3"), - leaf("page_size", "u16be", 16, 2, fmt.Sprintf("%d", pageSize)), - leaf("write_version", "u8", 18, 1, sqliteFmtVer(hdr[18])), - leaf("read_version", "u8", 19, 1, sqliteFmtVer(hdr[19])), - leaf("reserved_space", "u8", 20, 1, fmt.Sprintf("%d", hdr[20])), - leaf("change_counter", "u32be", 24, 4, fmt.Sprintf("%d", be.Uint32(hdr[24:28]))), - leaf("page_count", "u32be", 28, 4, fmt.Sprintf("%d", be.Uint32(hdr[28:32]))), - leaf("freelist_head", "u32be", 32, 4, fmt.Sprintf("%d", be.Uint32(hdr[32:36]))), - leaf("freelist_pages", "u32be", 36, 4, fmt.Sprintf("%d", be.Uint32(hdr[36:40]))), - leaf("schema_cookie", "u32be", 40, 4, fmt.Sprintf("%d", be.Uint32(hdr[40:44]))), - leaf("schema_format", "u32be", 44, 4, fmt.Sprintf("%d", be.Uint32(hdr[44:48]))), - leaf("text_encoding", "u32be", 56, 4, enc), - leaf("user_version", "u32be", 60, 4, fmt.Sprintf("%d", be.Uint32(hdr[60:64]))), - leaf("vacuum_mode", "u32be", 64, 4, fmt.Sprintf("%d", be.Uint32(hdr[64:68]))), - leaf("application_id", "u32be", 68, 4, fmt.Sprintf("0x%08x", be.Uint32(hdr[68:72]))), - leaf("version_valid_for", "u32be", 92, 4, fmt.Sprintf("%d", be.Uint32(hdr[92:96]))), - leaf("sqlite_version", "u32be", 96, 4, sqliteVersion(be.Uint32(hdr[96:100]))), - }, - } - return root, nil -} - -func sqliteFmtVer(b byte) string { - switch b { - case 1: - return "1 (legacy/rollback)" - case 2: - return "2 (WAL)" - default: - return fmt.Sprintf("%d", b) - } -} - -func sqliteVersion(v uint32) string { - // SQLITE_VERSION_NUMBER = major*1_000_000 + minor*1000 + patch. - return fmt.Sprintf("%d (%d.%d.%d)", v, v/1000000, (v/1000)%1000, v%1000) -} - -// ---------- TIFF ---------- - -// ParseTIFF decodes the TIFF header and the entries of each image file -// directory (IFD), following the next-IFD chain. -func ParseTIFF(d *document.Document) (*Field, error) { - size := d.Size() - hdr, err := d.Range(0, min64(8, size)) - if err != nil || len(hdr) < 8 { - return nil, errors.New("tiff too short") - } - var bo binary.ByteOrder - switch string(hdr[0:2]) { - case "II": - bo = binary.LittleEndian - case "MM": - bo = binary.BigEndian - default: - return nil, errors.New("not TIFF") - } - if bo.Uint16(hdr[2:4]) != 42 { - return nil, errors.New("bad TIFF magic") - } - order := "little-endian" - if string(hdr[0:2]) == "MM" { - order = "big-endian" - } - root := &Field{ - Name: "TIFF", Type: "container", Offset: 0, Length: size, Value: order, - Children: []*Field{ - leaf("byte_order", "ascii", 0, 2, order), - leaf("magic", "u16", 2, 2, "42"), - leaf("first_ifd", "u32", 4, 4, fmt.Sprintf("0x%x", bo.Uint32(hdr[4:8]))), - }, - } - ifdOff := int64(bo.Uint32(hdr[4:8])) - for n := 0; ifdOff > 0 && ifdOff+2 <= size && n < 64; n++ { - cb, err := d.Range(ifdOff, 2) - if err != nil { - break - } - count := int64(bo.Uint16(cb)) - ifd := &Field{ - Name: fmt.Sprintf("IFD[%d]", n), Type: "directory", Offset: ifdOff, - Length: 2 + count*12 + 4, Value: fmt.Sprintf("%d entries", count), - } - for i := int64(0); i < count && i < 4096; i++ { - eoff := ifdOff + 2 + i*12 - eb, err := d.Range(eoff, 12) - if err != nil || len(eb) < 12 { - break - } - tag := bo.Uint16(eb[0:2]) - typ := bo.Uint16(eb[2:4]) - cnt := bo.Uint32(eb[4:8]) - val := bo.Uint32(eb[8:12]) - ifd.Children = append(ifd.Children, leaf( - fmt.Sprintf("tag 0x%04x %s", tag, tiffTag(tag)), "ifd_entry", eoff, 12, - fmt.Sprintf("type=%s count=%d value=%d", tiffType(typ), cnt, val))) - } - root.Children = append(root.Children, ifd) - nb, err := d.Range(ifdOff+2+count*12, 4) - if err != nil || len(nb) < 4 { - break - } - ifdOff = int64(bo.Uint32(nb)) - } - return root, nil -} - -func tiffType(t uint16) string { - names := map[uint16]string{1: "BYTE", 2: "ASCII", 3: "SHORT", 4: "LONG", 5: "RATIONAL", 6: "SBYTE", 7: "UNDEFINED", 8: "SSHORT", 9: "SLONG", 10: "SRATIONAL", 11: "FLOAT", 12: "DOUBLE"} - if n, ok := names[t]; ok { - return n - } - return fmt.Sprintf("type%d", t) -} - -func tiffTag(t uint16) string { - names := map[uint16]string{ - 256: "ImageWidth", 257: "ImageLength", 258: "BitsPerSample", 259: "Compression", - 262: "PhotometricInterpretation", 273: "StripOffsets", 277: "SamplesPerPixel", - 278: "RowsPerStrip", 279: "StripByteCounts", 282: "XResolution", 283: "YResolution", - 296: "ResolutionUnit", 305: "Software", 306: "DateTime", 315: "Artist", - 271: "Make", 272: "Model", 274: "Orientation", 34665: "ExifIFD", 34853: "GPSIFD", - } - if n, ok := names[t]; ok { - return n - } - return "" -} - -// ---------- pcap / pcapng ---------- - -// ParsePCAP decodes a classic libpcap global header and walks packet records. -func ParsePCAP(d *document.Document) (*Field, error) { - size := d.Size() - hdr, err := d.Range(0, min64(24, size)) - if err != nil || len(hdr) < 24 { - return nil, errors.New("pcap too short") - } - magic := binary.LittleEndian.Uint32(hdr[0:4]) - var bo binary.ByteOrder - nano := false - switch magic { - case 0xa1b2c3d4: - bo = binary.LittleEndian - case 0xd4c3b2a1: - bo = binary.BigEndian - case 0xa1b23c4d: - bo, nano = binary.LittleEndian, true - case 0x4d3cb2a1: - bo, nano = binary.BigEndian, true - default: - return nil, errors.New("not pcap") - } - res := "microsecond" - if nano { - res = "nanosecond" - } - linktype := bo.Uint32(hdr[20:24]) - root := &Field{ - Name: "PCAP", Type: "container", Offset: 0, Length: size, - Value: fmt.Sprintf("%s, %s", res, pcapLinkType(linktype)), - Children: []*Field{ - leaf("magic", "u32", 0, 4, fmt.Sprintf("0x%08x (%s)", magic, res)), - leaf("version", "u16", 4, 4, fmt.Sprintf("%d.%d", bo.Uint16(hdr[4:6]), bo.Uint16(hdr[6:8]))), - leaf("thiszone", "i32", 8, 4, fmt.Sprintf("%d", int32(bo.Uint32(hdr[8:12])))), - leaf("sigfigs", "u32", 12, 4, fmt.Sprintf("%d", bo.Uint32(hdr[12:16]))), - leaf("snaplen", "u32", 16, 4, fmt.Sprintf("%d", bo.Uint32(hdr[16:20]))), - leaf("linktype", "u32", 20, 4, fmt.Sprintf("%d (%s)", linktype, pcapLinkType(linktype))), - }, - } - off := int64(24) - for n := 0; off+16 <= size && n < 100000; n++ { - rb, err := d.Range(off, 16) - if err != nil { - break - } - ts := bo.Uint32(rb[0:4]) - us := bo.Uint32(rb[4:8]) - inclLen := int64(bo.Uint32(rb[8:12])) - origLen := bo.Uint32(rb[12:16]) - root.Children = append(root.Children, leaf( - fmt.Sprintf("packet[%d]", n), "record", off, 16+inclLen, - fmt.Sprintf("ts=%d.%06d incl=%d orig=%d", ts, us, inclLen, origLen))) - off += 16 + inclLen - } - return root, nil -} - -// ParsePCAPNG decodes the pcapng block structure (section header + block walk). -func ParsePCAPNG(d *document.Document) (*Field, error) { - size := d.Size() - hb, err := d.Range(0, min64(12, size)) - if err != nil || len(hb) < 12 { - return nil, errors.New("pcapng too short") - } - if binary.LittleEndian.Uint32(hb[0:4]) != 0x0a0d0d0a { - return nil, errors.New("not pcapng") - } - // Byte-order magic lives in the SHB body at offset 8. - var bo binary.ByteOrder - switch binary.LittleEndian.Uint32(hb[8:12]) { - case 0x1a2b3c4d: - bo = binary.LittleEndian - case 0x4d3c2b1a: - bo = binary.BigEndian - default: - return nil, errors.New("bad pcapng byte-order magic") - } - root := &Field{Name: "PCAPNG", Type: "container", Offset: 0, Length: size} - off := int64(0) - for n := 0; off+12 <= size && n < 100000; n++ { - bh, err := d.Range(off, 12) - if err != nil { - break - } - btype := bo.Uint32(bh[0:4]) - blen := int64(bo.Uint32(bh[4:8])) - if blen < 12 || off+blen > size { - break - } - root.Children = append(root.Children, leaf( - fmt.Sprintf("block[%d] %s", n, pcapngBlock(btype)), "block", off, blen, - fmt.Sprintf("type=0x%08x length=%d", btype, blen))) - off += blen - } - return root, nil -} - -func pcapngBlock(t uint32) string { - switch t { - case 0x0a0d0d0a: - return "SectionHeader" - case 0x00000001: - return "InterfaceDescription" - case 0x00000003: - return "SimplePacket" - case 0x00000004: - return "NameResolution" - case 0x00000005: - return "InterfaceStatistics" - case 0x00000006: - return "EnhancedPacket" - default: - return "block" - } -} - -func pcapLinkType(t uint32) string { - names := map[uint32]string{0: "NULL", 1: "ETHERNET", 6: "TOKEN_RING", 9: "PPP", 101: "RAW", 105: "IEEE802_11", 113: "LINUX_SLL", 127: "IEEE802_11_RADIOTAP", 228: "IPV4", 229: "IPV6", 276: "LINUX_SLL2"} - if n, ok := names[t]; ok { - return n - } - return fmt.Sprintf("linktype%d", t) -} - -// ---------- OLE / Compound File Binary (MS-CFB) ---------- - -var cfbMagic = []byte{0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1} - -// ParseCFB decodes the compound-file header and lists the entries in the first -// directory sector (legacy .doc/.xls/.ppt/.msi container). -func ParseCFB(d *document.Document) (*Field, error) { - size := d.Size() - hdr, err := d.Range(0, min64(512, size)) - if err != nil || len(hdr) < 76 { - return nil, errors.New("cfb too short") - } - if string(hdr[0:8]) != string(cfbMagic) { - return nil, errors.New("not a compound file") - } - le := binary.LittleEndian - major := le.Uint16(hdr[26:28]) - sectorShift := le.Uint16(hdr[30:32]) - sectorSize := int64(1) << sectorShift - firstDir := le.Uint32(hdr[48:52]) - root := &Field{ - Name: "CFB/OLE", Type: "container", Offset: 0, Length: size, - Value: fmt.Sprintf("v%d.%d sector=%d", major, le.Uint16(hdr[24:26]), sectorSize), - Children: []*Field{ - leaf("signature", "bytes", 0, 8, "D0 CF 11 E0 A1 B1 1A E1"), - leaf("minor_version", "u16le", 24, 2, fmt.Sprintf("%d", le.Uint16(hdr[24:26]))), - leaf("major_version", "u16le", 26, 2, fmt.Sprintf("%d", major)), - leaf("byte_order", "u16le", 28, 2, fmt.Sprintf("0x%04x", le.Uint16(hdr[28:30]))), - leaf("sector_shift", "u16le", 30, 2, fmt.Sprintf("%d (%d bytes)", sectorShift, sectorSize)), - leaf("mini_sector_shift", "u16le", 32, 2, fmt.Sprintf("%d (%d bytes)", le.Uint16(hdr[32:34]), int64(1)< size { - return nil - } - dir := &Field{Name: "directory", Type: "directory", Offset: off} - per := sectorSize / 128 - count := int64(0) - for i := int64(0); i < per; i++ { - eoff := off + i*128 - eb, err := d.Range(eoff, 128) - if err != nil || len(eb) < 128 { - break - } - nameLen := int64(binary.LittleEndian.Uint16(eb[64:66])) - objType := eb[66] - if objType == 0 { // unallocated entry - continue - } - name := "" - if nameLen >= 2 && nameLen <= 64 { - name = decodeUTF16LE(eb[0 : nameLen-2]) - } - streamSize := binary.LittleEndian.Uint64(eb[120:128]) - dir.Children = append(dir.Children, leaf( - cfbEntryLabel(name, count), "dir_entry", eoff, 128, - fmt.Sprintf("%s size=%d", cfbObjType(objType), streamSize))) - count++ - } - dir.Length = sectorSize - dir.Value = fmt.Sprintf("%d entries", count) - if count == 0 { - return nil - } - return dir -} - -func cfbEntryLabel(name string, i int64) string { - if name == "" { - return fmt.Sprintf("entry[%d]", i) - } - // Strip the control prefix Office uses on some stream names for display. - clean := strings.Map(func(r rune) rune { - if r < 0x20 { - return -1 - } - return r - }, name) - if clean == "" { - clean = name - } - return fmt.Sprintf("entry[%d] %s", i, clean) -} - -func cfbObjType(t byte) string { - switch t { - case 1: - return "storage" - case 2: - return "stream" - case 5: - return "root" - default: - return fmt.Sprintf("type%d", t) - } -} - -// ---------- shared helpers ---------- - -func readCStringField(d *document.Document, off, size int64) (string, int64) { - var sb strings.Builder - n := int64(0) - for off+n < size && n < 4096 { - b, err := d.At(off + n) - if err != nil { - break - } - n++ - if b == 0 { - break - } - sb.WriteByte(b) - } - return sb.String(), n -} - -func allZero(b []byte) bool { - for _, x := range b { - if x != 0 { - return false - } - } - return true -} - -func trimZeroStr(b []byte) string { - return strings.TrimRight(string(b), "\x00 ") -} - -// octal parses a space/NUL-padded octal ASCII field (tar number encoding). -func octal(b []byte) int64 { - s := strings.TrimSpace(strings.Trim(string(b), "\x00")) - if s == "" { - return 0 - } - v, err := strconv.ParseInt(s, 8, 64) - if err != nil { - return 0 - } - return v -} - -func roundUp(n, to int64) int64 { - if n <= 0 { - return 0 - } - return ((n + to - 1) / to) * to -} diff --git a/internal/leviathan/parsers/macho.go b/internal/leviathan/parsers/macho.go deleted file mode 100644 index db760a1..0000000 --- a/internal/leviathan/parsers/macho.go +++ /dev/null @@ -1,408 +0,0 @@ -package parsers - -import ( - "encoding/binary" - "errors" - "fmt" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// Mach-O magic values (32/64, LE/BE) plus fat (universal) magic. -const ( - machoMagic32 = 0xfeedface - machoCigam32 = 0xcefaedfe - machoMagic64 = 0xfeedfacf - machoCigam64 = 0xcffaedfe - machoFatMagic = 0xcafebabe // also Java .class; disambiguate by position + nfat_arch - machoFatCigam = 0xbebafeca - machoFatMagic64 = 0xcafebabf - machoFatCigam64 = 0xbfbafeca -) - -// IsMachO reports whether the given prefix looks like a Mach-O header. -// Fat magic 0xcafebabe is shared with Java class files; we accept it -// only when nfat_arch is plausible and the first arch's cputype points -// somewhere in-document. -func IsMachO(head []byte, size int64) bool { - if len(head) < 8 { - return false - } - m := binary.LittleEndian.Uint32(head[:4]) - switch m { - case machoMagic32, machoMagic64, machoCigam32, machoCigam64: - return true - } - mBE := binary.BigEndian.Uint32(head[:4]) - switch mBE { - case machoFatMagic, machoFatMagic64: - // nfat_arch is next BE u32; plausibly small. - n := binary.BigEndian.Uint32(head[4:8]) - return n > 0 && n < 64 && size >= 8+int64(n)*20 - } - _ = mBE - return false -} - -// ParseMachO returns a field tree for a Mach-O (fat or thin) file. -func ParseMachO(d *document.Document) (*Field, error) { - head, err := d.Range(0, min64(8, d.Size())) - if err != nil { - return nil, err - } - if len(head) < 8 { - return nil, errors.New("file too small for Mach-O") - } - mLE := binary.LittleEndian.Uint32(head[:4]) - mBE := binary.BigEndian.Uint32(head[:4]) - - switch mBE { - case machoFatMagic, machoFatMagic64: - return parseMachOFat(d, mBE == machoFatMagic64) - } - - // Thin header. - switch mLE { - case machoMagic32: - return parseMachOThin(d, 0, binary.LittleEndian, false) - case machoMagic64: - return parseMachOThin(d, 0, binary.LittleEndian, true) - case machoCigam32: - return parseMachOThin(d, 0, binary.BigEndian, false) - case machoCigam64: - return parseMachOThin(d, 0, binary.BigEndian, true) - } - return nil, errors.New("not a Mach-O file") -} - -func parseMachOFat(d *document.Document, is64 bool) (*Field, error) { - root := &Field{Name: "Mach-O fat", Type: "container", Offset: 0} - hdr, err := d.Range(0, 8) - if err != nil { - return nil, err - } - n := int64(binary.BigEndian.Uint32(hdr[4:8])) - root.Children = append(root.Children, &Field{ - Name: "magic", Type: "u32", Offset: 0, Length: 4, - Value: fmt.Sprintf("0x%08x", binary.BigEndian.Uint32(hdr[:4])), - }) - root.Children = append(root.Children, &Field{ - Name: "nfat_arch", Type: "u32", Offset: 4, Length: 4, - Value: fmt.Sprintf("%d", n), - }) - - archSize := int64(20) - if is64 { - archSize = 32 - } - off := int64(8) - for i := int64(0); i < n && off+archSize <= d.Size(); i++ { - arch, err := d.Range(off, archSize) - if err != nil { - break - } - cpuType := binary.BigEndian.Uint32(arch[0:4]) - cpuSub := binary.BigEndian.Uint32(arch[4:8]) - var archOff, archLen int64 - if is64 { - archOff = int64(binary.BigEndian.Uint64(arch[8:16])) - archLen = int64(binary.BigEndian.Uint64(arch[16:24])) - } else { - archOff = int64(binary.BigEndian.Uint32(arch[8:12])) - archLen = int64(binary.BigEndian.Uint32(arch[12:16])) - } - node := &Field{ - Name: fmt.Sprintf("arch[%d]", i), - Type: "fat_arch", - Offset: off, - Length: archSize, - Value: fmt.Sprintf("cpu=%s sub=0x%x offset=%d size=%d", cpuTypeName(cpuType), cpuSub, archOff, archLen), - } - // Parse the nested Mach-O at archOff if it fits. - if archOff > 0 && archOff+8 <= d.Size() { - if nested, err := parseMachOInline(d, archOff); err == nil { - node.Children = append(node.Children, nested) - } - } - root.Children = append(root.Children, node) - off += archSize - } - return root, nil -} - -// parseMachOInline is like ParseMachO but anchored at a given offset -// (used for each arch inside a fat binary). -func parseMachOInline(d *document.Document, at int64) (*Field, error) { - head, err := d.Range(at, 8) - if err != nil || len(head) < 8 { - return nil, errors.New("short arch payload") - } - mLE := binary.LittleEndian.Uint32(head[:4]) - switch mLE { - case machoMagic32: - return parseMachOThin(d, at, binary.LittleEndian, false) - case machoMagic64: - return parseMachOThin(d, at, binary.LittleEndian, true) - case machoCigam32: - return parseMachOThin(d, at, binary.BigEndian, false) - case machoCigam64: - return parseMachOThin(d, at, binary.BigEndian, true) - } - return nil, errors.New("arch payload is not Mach-O") -} - -func parseMachOThin(d *document.Document, base int64, bo binary.ByteOrder, is64 bool) (*Field, error) { - headerLen := int64(28) - if is64 { - headerLen = 32 - } - if base+headerLen > d.Size() { - return nil, errors.New("truncated Mach-O header") - } - hdr, err := d.Range(base, headerLen) - if err != nil { - return nil, err - } - - magic := bo.Uint32(hdr[0:4]) - cpuType := bo.Uint32(hdr[4:8]) - cpuSub := bo.Uint32(hdr[8:12]) - fileType := bo.Uint32(hdr[12:16]) - ncmds := bo.Uint32(hdr[16:20]) - sizeOfCmds := bo.Uint32(hdr[20:24]) - flags := bo.Uint32(hdr[24:28]) - - name := "Mach-O (32)" - if is64 { - name = "Mach-O (64)" - } - root := &Field{Name: name, Type: "container", Offset: base, Length: headerLen + int64(sizeOfCmds)} - root.Children = append(root.Children, - &Field{Name: "magic", Type: "u32", Offset: base, Length: 4, Value: fmt.Sprintf("0x%08x", magic)}, - &Field{Name: "cputype", Type: "u32", Offset: base + 4, Length: 4, Value: cpuTypeName(cpuType)}, - &Field{Name: "cpusubtype", Type: "u32", Offset: base + 8, Length: 4, Value: fmt.Sprintf("0x%x", cpuSub)}, - &Field{Name: "filetype", Type: "u32", Offset: base + 12, Length: 4, Value: machoFileType(fileType)}, - &Field{Name: "ncmds", Type: "u32", Offset: base + 16, Length: 4, Value: fmt.Sprintf("%d", ncmds)}, - &Field{Name: "sizeofcmds", Type: "u32", Offset: base + 20, Length: 4, Value: fmt.Sprintf("%d", sizeOfCmds)}, - &Field{Name: "flags", Type: "u32", Offset: base + 24, Length: 4, Value: fmt.Sprintf("0x%08x", flags)}, - ) - - // Load commands. - cmdOff := base + headerLen - cmdEnd := cmdOff + int64(sizeOfCmds) - if cmdEnd > d.Size() { - cmdEnd = d.Size() - } - cmds := &Field{Name: "load_commands", Type: "list", Offset: cmdOff, Length: int64(sizeOfCmds)} - for i := uint32(0); i < ncmds && cmdOff+8 <= cmdEnd; i++ { - h, err := d.Range(cmdOff, 8) - if err != nil { - break - } - cmd := bo.Uint32(h[0:4]) - sz := int64(bo.Uint32(h[4:8])) - if sz < 8 || cmdOff+sz > cmdEnd { - break - } - buf, _ := d.Range(cmdOff, sz) - node := decodeLoadCommand(base, cmdOff, cmd, sz, buf, bo, is64) - cmds.Children = append(cmds.Children, node) - cmdOff += sz - } - root.Children = append(root.Children, cmds) - return root, nil -} - -func decodeLoadCommand(base, off int64, cmd uint32, sz int64, buf []byte, bo binary.ByteOrder, is64 bool) *Field { - name, mask := loadCommandName(cmd) - node := &Field{ - Name: name, Type: "load_command", Offset: off, Length: sz, - Value: fmt.Sprintf("cmd=0x%08x size=%d", cmd, sz), - } - if mask != 0 { - node.Note = "required" - } - - switch { - case (cmd&^0x80000000) == 0x19 && is64 && sz >= 72: - // LC_SEGMENT_64 - segName := cstring(buf[8:24]) - vmAddr := bo.Uint64(buf[24:32]) - vmSize := bo.Uint64(buf[32:40]) - fileOff := bo.Uint64(buf[40:48]) - fileSize := bo.Uint64(buf[48:56]) - nsects := bo.Uint32(buf[64:68]) - node.Value = fmt.Sprintf("name=%q vm=%#x+%#x file=%#x+%#x nsects=%d", - segName, vmAddr, vmSize, fileOff, fileSize, nsects) - sectOff := int64(72) - for i := uint32(0); i < nsects && sectOff+80 <= sz; i++ { - s := buf[sectOff:] - sn := cstring(s[0:16]) - seg := cstring(s[16:32]) - addr := bo.Uint64(s[32:40]) - size := bo.Uint64(s[40:48]) - foff := bo.Uint32(s[48:52]) - node.Children = append(node.Children, &Field{ - Name: sn, Type: "section64", Offset: off + sectOff, Length: 80, - Value: fmt.Sprintf("seg=%q addr=%#x size=%#x file=%#x", seg, addr, size, foff), - }) - sectOff += 80 - } - case cmd == 0x01 && sz >= 56: - // LC_SEGMENT (32) - segName := cstring(buf[8:24]) - vmAddr := bo.Uint32(buf[24:28]) - vmSize := bo.Uint32(buf[28:32]) - fileOff := bo.Uint32(buf[32:36]) - fileSize := bo.Uint32(buf[36:40]) - nsects := bo.Uint32(buf[48:52]) - node.Value = fmt.Sprintf("name=%q vm=%#x+%#x file=%#x+%#x nsects=%d", - segName, vmAddr, vmSize, fileOff, fileSize, nsects) - case cmd == 0x1b && sz >= 24: - // LC_UUID - u := buf[8:24] - node.Value = fmt.Sprintf("UUID=%x-%x-%x-%x-%x", u[0:4], u[4:6], u[6:8], u[8:10], u[10:16]) - case (cmd&^0x80000000) == 0x0c && sz > 24: - // LC_LOAD_DYLIB / similar - strOff := bo.Uint32(buf[8:12]) - if int(strOff) < len(buf) { - node.Value = fmt.Sprintf("lib=%q", cstring(buf[strOff:])) - } - case cmd == 0x0e && sz > 12: - // LC_LOAD_DYLINKER - strOff := bo.Uint32(buf[8:12]) - if int(strOff) < len(buf) { - node.Value = fmt.Sprintf("dylinker=%q", cstring(buf[strOff:])) - } - case cmd == 0x21 && sz >= 16: - // LC_ENCRYPTION_INFO / _64 - node.Value = "encryption info" - case (cmd &^ 0x80000000) == 0x22: - node.Value = "LC_DYLD_INFO" - case cmd == 0x25: - node.Value = "LC_VERSION_MIN_MACOSX" - case cmd == 0x28 || cmd == 0x29: - node.Value = "LC_MAIN / entry point" - } - return node -} - -func cstring(b []byte) string { - for i, c := range b { - if c == 0 { - return string(b[:i]) - } - } - return string(b) -} - -func cpuTypeName(c uint32) string { - switch c { - case 0x7: - return "x86" - case 0x1000007: - return "x86_64" - case 0xc: - return "arm" - case 0x100000c: - return "arm64" - case 0x12: - return "ppc" - case 0x1000012: - return "ppc64" - } - return fmt.Sprintf("0x%x", c) -} - -func machoFileType(t uint32) string { - switch t { - case 1: - return "OBJECT" - case 2: - return "EXECUTE" - case 3: - return "FVMLIB" - case 4: - return "CORE" - case 5: - return "PRELOAD" - case 6: - return "DYLIB" - case 7: - return "DYLINKER" - case 8: - return "BUNDLE" - case 9: - return "DYLIB_STUB" - case 10: - return "DSYM" - case 11: - return "KEXT_BUNDLE" - } - return fmt.Sprintf("%d", t) -} - -// loadCommandName returns a human name and whether the required bit is set. -func loadCommandName(cmd uint32) (string, uint32) { - req := cmd & 0x80000000 - v := cmd &^ 0x80000000 - names := map[uint32]string{ - 0x01: "LC_SEGMENT", - 0x02: "LC_SYMTAB", - 0x03: "LC_SYMSEG", - 0x04: "LC_THREAD", - 0x05: "LC_UNIXTHREAD", - 0x06: "LC_LOADFVMLIB", - 0x07: "LC_IDFVMLIB", - 0x08: "LC_IDENT", - 0x09: "LC_FVMFILE", - 0x0a: "LC_PREPAGE", - 0x0b: "LC_DYSYMTAB", - 0x0c: "LC_LOAD_DYLIB", - 0x0d: "LC_ID_DYLIB", - 0x0e: "LC_LOAD_DYLINKER", - 0x0f: "LC_ID_DYLINKER", - 0x10: "LC_PREBOUND_DYLIB", - 0x11: "LC_ROUTINES", - 0x12: "LC_SUB_FRAMEWORK", - 0x13: "LC_SUB_UMBRELLA", - 0x14: "LC_SUB_CLIENT", - 0x15: "LC_SUB_LIBRARY", - 0x16: "LC_TWOLEVEL_HINTS", - 0x17: "LC_PREBIND_CKSUM", - 0x18: "LC_LOAD_WEAK_DYLIB", - 0x19: "LC_SEGMENT_64", - 0x1a: "LC_ROUTINES_64", - 0x1b: "LC_UUID", - 0x1c: "LC_RPATH", - 0x1d: "LC_CODE_SIGNATURE", - 0x1e: "LC_SEGMENT_SPLIT_INFO", - 0x1f: "LC_REEXPORT_DYLIB", - 0x20: "LC_LAZY_LOAD_DYLIB", - 0x21: "LC_ENCRYPTION_INFO", - 0x22: "LC_DYLD_INFO", - 0x23: "LC_LOAD_UPWARD_DYLIB", - 0x24: "LC_VERSION_MIN_MACOSX", - 0x25: "LC_VERSION_MIN_IPHONEOS", - 0x26: "LC_FUNCTION_STARTS", - 0x27: "LC_DYLD_ENVIRONMENT", - 0x28: "LC_MAIN", - 0x29: "LC_DATA_IN_CODE", - 0x2a: "LC_SOURCE_VERSION", - 0x2b: "LC_DYLIB_CODE_SIGN_DRS", - 0x2c: "LC_ENCRYPTION_INFO_64", - 0x2d: "LC_LINKER_OPTION", - 0x2e: "LC_LINKER_OPTIMIZATION_HINT", - 0x2f: "LC_VERSION_MIN_TVOS", - 0x30: "LC_VERSION_MIN_WATCHOS", - 0x31: "LC_NOTE", - 0x32: "LC_BUILD_VERSION", - 0x33: "LC_DYLD_EXPORTS_TRIE", - 0x34: "LC_DYLD_CHAINED_FIXUPS", - 0x35: "LC_FILESET_ENTRY", - } - if n, ok := names[v]; ok { - return n, req - } - return fmt.Sprintf("LC_0x%x", v), req -} diff --git a/internal/leviathan/parsers/peimports.go b/internal/leviathan/parsers/peimports.go deleted file mode 100644 index 2db1f14..0000000 --- a/internal/leviathan/parsers/peimports.go +++ /dev/null @@ -1,150 +0,0 @@ -package parsers - -import ( - "encoding/binary" - "errors" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// ImportSym is one resolved PE import (a function name, or an ordinal). -type ImportSym struct { - DLL string `json:"dll"` - Func string `json:"func"` - Ordinal int `json:"ordinal"` - ByOrdinal bool `json:"byOrdinal"` -} - -// PEImports walks the import directory of a PE file and returns the ordered -// list of imported symbols. It reuses the same RVA mapping and header parsing -// as ParsePE but yields a clean typed result (for imphash, capability -// analysis, etc.) rather than a display tree. -func PEImports(d *document.Document) ([]ImportSym, error) { - sections, dataDirs, is64, err := peLayout(d) - if err != nil { - return nil, err - } - if len(dataDirs) < 2 || dataDirs[1].RVA == 0 { - return nil, errors.New("no import directory") - } - off, ok := peRVAToOffset(sections, dataDirs[1].RVA) - if !ok { - return nil, errors.New("import RVA unmapped") - } - var out []ImportSym - for i := 0; i < 1024; i++ { - at := int64(off) + int64(i*20) - buf, err := d.Range(at, 20) - if err != nil || len(buf) < 20 { - break - } - oft := binary.LittleEndian.Uint32(buf[0:4]) - nameRVA := binary.LittleEndian.Uint32(buf[12:16]) - firstThunk := binary.LittleEndian.Uint32(buf[16:20]) - if oft == 0 && nameRVA == 0 && firstThunk == 0 { - break - } - dll := "" - if nameOff, ok := peRVAToOffset(sections, nameRVA); ok { - dll = readCString(d, int64(nameOff), 256) - } - if dll == "" { - continue - } - out = append(out, peThunkSyms(d, sections, dll, oft, firstThunk, is64)...) - if len(out) > 100000 { - break - } - } - return out, nil -} - -func peThunkSyms(d *document.Document, sections []peSectionInfo, dll string, oft, firstThunk uint32, is64 bool) []ImportSym { - thunkRVA := oft - if thunkRVA == 0 { - thunkRVA = firstThunk - } - thunkOff, ok := peRVAToOffset(sections, thunkRVA) - if !ok { - return nil - } - width := int64(4) - ordinalMask := uint64(0x80000000) - if is64 { - width = 8 - ordinalMask = 0x8000000000000000 - } - var out []ImportSym - for i := 0; i < 8192; i++ { - at := int64(thunkOff) + int64(i)*width - buf, err := d.Range(at, width) - if err != nil || int64(len(buf)) < width { - break - } - var thunk uint64 - if is64 { - thunk = binary.LittleEndian.Uint64(buf) - } else { - thunk = uint64(binary.LittleEndian.Uint32(buf)) - } - if thunk == 0 { - break - } - if thunk&ordinalMask != 0 { - out = append(out, ImportSym{DLL: dll, Ordinal: int(thunk & 0xffff), ByOrdinal: true}) - } else if nameOff, ok := peRVAToOffset(sections, uint32(thunk)); ok { - name := readImportByName(d, int64(nameOff)) - if name != "" { - out = append(out, ImportSym{DLL: dll, Func: name}) - } - } - } - return out -} - -// peLayout extracts the section table, data directories and 32/64-bitness from -// a PE without building the display field tree. -func peLayout(d *document.Document) ([]peSectionInfo, []peDataDirectory, bool, error) { - hdr, err := d.Range(0, 64) - if err != nil || len(hdr) < 64 { - return nil, nil, false, errors.New("too short") - } - lfanew := int64(binary.LittleEndian.Uint32(hdr[0x3c:0x40])) - peHdr, err := d.Range(lfanew, 24) - if err != nil || len(peHdr) < 24 || string(peHdr[:4]) != "PE\x00\x00" { - return nil, nil, false, errors.New("no PE signature") - } - numSections := int(binary.LittleEndian.Uint16(peHdr[6:8])) - sizeOptHdr := int64(binary.LittleEndian.Uint16(peHdr[20:22])) - optOff := lfanew + 24 - - var dataDirs []peDataDirectory - is64 := false - if sizeOptHdr > 0 { - opt, err := d.Range(optOff, sizeOptHdr) - if err == nil && len(opt) >= 2 { - _, dirs := parsePEOptionalHeader(opt, optOff) - dataDirs = dirs - is64 = binary.LittleEndian.Uint16(opt[0:2]) == 0x20b - } - } - - sectOff := lfanew + 24 + sizeOptHdr - sections := make([]peSectionInfo, 0, numSections) - for i := 0; i < numSections; i++ { - sh, err := d.Range(sectOff, 40) - if err != nil || len(sh) < 40 { - break - } - sections = append(sections, peSectionInfo{ - Name: string(trimZero(sh[0:8])), - VirtualSize: binary.LittleEndian.Uint32(sh[8:12]), - VirtualAddress: binary.LittleEndian.Uint32(sh[12:16]), - RawSize: binary.LittleEndian.Uint32(sh[16:20]), - RawOffset: binary.LittleEndian.Uint32(sh[20:24]), - Characteristics: binary.LittleEndian.Uint32(sh[36:40]), - }) - sectOff += 40 - } - return sections, dataDirs, is64, nil -} diff --git a/internal/leviathan/patch/script.go b/internal/leviathan/patch/script.go deleted file mode 100644 index f8ba3fd..0000000 --- a/internal/leviathan/patch/script.go +++ /dev/null @@ -1,232 +0,0 @@ -// Package patch implements the simple Leviathan patch script language. -// -// set OFFSET HEX... — write hex bytes at OFFSET -// fill START LEN BYTE — fill a range with a single byte -// xor START LEN KEY — XOR a range with an ASCII or hex: key -// copy SRC LEN DST — copy bytes from SRC to DST -// # comments and blank lines are allowed. -package patch - -import ( - "encoding/hex" - "errors" - "fmt" - "strconv" - "strings" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// Step is one parsed instruction. -type Step struct { - Op string - Args []string - Line int - Source string -} - -// Parse parses the script into steps. -func Parse(src string) ([]Step, error) { - out := []Step{} - for i, raw := range strings.Split(src, "\n") { - line := strings.TrimSpace(raw) - if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") { - continue - } - fields := strings.Fields(line) - op := strings.ToLower(fields[0]) - out = append(out, Step{ - Op: op, Args: fields[1:], Line: i + 1, Source: raw, - }) - } - return out, nil -} - -// Plan validates all steps against document size without applying. -// Returns per-step info or an error on validation failure. -func Plan(d *document.Document, steps []Step) ([]map[string]any, error) { - size := d.Size() - info := make([]map[string]any, 0, len(steps)) - for _, s := range steps { - switch s.Op { - case "set": - if len(s.Args) < 2 { - return info, fmt.Errorf("line %d: set needs OFFSET and HEX", s.Line) - } - off, err := parseOffset(s.Args[0]) - if err != nil { - return info, fmt.Errorf("line %d: %v", s.Line, err) - } - b, err := parseHexRest(s.Args[1:]) - if err != nil { - return info, fmt.Errorf("line %d: %v", s.Line, err) - } - if !validRange(size, off, int64(len(b))) { - return info, fmt.Errorf("line %d: out of range", s.Line) - } - info = append(info, map[string]any{ - "op": "set", "line": s.Line, "offset": off, "length": len(b), - }) - case "fill": - if len(s.Args) != 3 { - return info, fmt.Errorf("line %d: fill needs START LEN BYTE", s.Line) - } - off, err := parseOffset(s.Args[0]) - if err != nil { - return info, fmt.Errorf("line %d: %v", s.Line, err) - } - n, err := parseOffset(s.Args[1]) - if err != nil { - return info, fmt.Errorf("line %d: %v", s.Line, err) - } - by, err := parseHexByte(s.Args[2]) - if err != nil { - return info, fmt.Errorf("line %d: %v", s.Line, err) - } - if !validRange(size, off, n) { - return info, fmt.Errorf("line %d: out of range", s.Line) - } - info = append(info, map[string]any{ - "op": "fill", "line": s.Line, "offset": off, "length": n, "byte": by, - }) - case "xor": - if len(s.Args) != 3 { - return info, fmt.Errorf("line %d: xor needs START LEN KEY", s.Line) - } - off, err := parseOffset(s.Args[0]) - if err != nil { - return info, fmt.Errorf("line %d: %v", s.Line, err) - } - n, err := parseOffset(s.Args[1]) - if err != nil { - return info, fmt.Errorf("line %d: %v", s.Line, err) - } - key, err := parseKey(s.Args[2]) - if err != nil { - return info, fmt.Errorf("line %d: %v", s.Line, err) - } - if !validRange(size, off, n) { - return info, fmt.Errorf("line %d: out of range", s.Line) - } - info = append(info, map[string]any{ - "op": "xor", "line": s.Line, "offset": off, "length": n, "keyLen": len(key), - }) - case "copy": - if len(s.Args) != 3 { - return info, fmt.Errorf("line %d: copy needs SRC LEN DST", s.Line) - } - src, err := parseOffset(s.Args[0]) - if err != nil { - return info, fmt.Errorf("line %d: %v", s.Line, err) - } - n, err := parseOffset(s.Args[1]) - if err != nil { - return info, fmt.Errorf("line %d: %v", s.Line, err) - } - dst, err := parseOffset(s.Args[2]) - if err != nil { - return info, fmt.Errorf("line %d: %v", s.Line, err) - } - if !validRange(size, src, n) || !validRange(size, dst, n) { - return info, fmt.Errorf("line %d: out of range", s.Line) - } - info = append(info, map[string]any{ - "op": "copy", "line": s.Line, "src": src, "dst": dst, "length": n, - }) - default: - return info, fmt.Errorf("line %d: unknown op %q", s.Line, s.Op) - } - } - return info, nil -} - -// Apply runs the script against the document. Callers may want to -// first snapshot a checkpoint so apply can be reverted. -func Apply(d *document.Document, steps []Step) error { - if _, err := Plan(d, steps); err != nil { - return err - } - for _, s := range steps { - switch s.Op { - case "set": - off, _ := parseOffset(s.Args[0]) - b, _ := parseHexRest(s.Args[1:]) - for i, bb := range b { - if _, err := d.Set(off+int64(i), bb); err != nil { - return err - } - } - case "fill": - off, _ := parseOffset(s.Args[0]) - n, _ := parseOffset(s.Args[1]) - by, _ := parseHexByte(s.Args[2]) - if err := d.Fill(off, n, by); err != nil { - return err - } - case "xor": - off, _ := parseOffset(s.Args[0]) - n, _ := parseOffset(s.Args[1]) - key, _ := parseKey(s.Args[2]) - if err := d.XOR(off, n, key); err != nil { - return err - } - case "copy": - src, _ := parseOffset(s.Args[0]) - n, _ := parseOffset(s.Args[1]) - dst, _ := parseOffset(s.Args[2]) - if err := d.Copy(src, n, dst); err != nil { - return err - } - } - } - return nil -} - -func validRange(size, start, length int64) bool { - return start >= 0 && start <= size && length >= 0 && length <= size-start -} - -func parseOffset(s string) (int64, error) { - s = strings.TrimSpace(s) - if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") { - v, err := strconv.ParseInt(s[2:], 16, 64) - return v, err - } - return strconv.ParseInt(s, 10, 64) -} - -func parseHexRest(args []string) ([]byte, error) { - joined := strings.Join(args, "") - joined = strings.ReplaceAll(joined, " ", "") - joined = strings.ReplaceAll(joined, ",", "") - return hex.DecodeString(joined) -} - -func parseHexByte(s string) (byte, error) { - if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") { - v, err := strconv.ParseUint(s[2:], 16, 8) - return byte(v), err - } - b, err := hex.DecodeString(s) - if err != nil { - v, err := strconv.ParseUint(s, 10, 8) - if err != nil { - return 0, errors.New("not a byte") - } - return byte(v), nil - } - if len(b) != 1 { - return 0, errors.New("expected one byte") - } - return b[0], nil -} - -func parseKey(s string) ([]byte, error) { - if strings.HasPrefix(s, "hex:") { - return hex.DecodeString(s[4:]) - } - if s == "" { - return nil, errors.New("empty key") - } - return []byte(s), nil -} diff --git a/internal/leviathan/pdf/filters.go b/internal/leviathan/pdf/filters.go deleted file mode 100644 index 67aca73..0000000 --- a/internal/leviathan/pdf/filters.go +++ /dev/null @@ -1,411 +0,0 @@ -package pdf - -import ( - "bytes" - "errors" - "fmt" - - "github.com/andydixon/link/internal/leviathan/transforms" -) - -const MaxDecodedStreamOutput = 64 << 20 - -// ApplyFilter decodes one PDF stream filter. When a predictor is in use -// it must be applied separately after this function returns raw filter -// output; see ApplyPredictor. -func ApplyFilter(name string, in []byte) ([]byte, error) { - switch name { - case "FlateDecode", "Fl": - if out, err := transforms.InflateZlib(in); err == nil { - return out, nil - } - return transforms.InflateRaw(in) - case "ASCIIHexDecode", "AHx": - return decodeASCIIHex(in) - case "ASCII85Decode", "A85": - return decodeASCII85(in) - case "RunLengthDecode", "RL": - return decodeRunLength(in) - case "LZWDecode", "LZW": - return decodeLZW(in) - case "CCITTFaxDecode", "CCF": - return nil, errors.New("CCITTFaxDecode not supported (returns raw bytes)") - case "JBIG2Decode": - return nil, errors.New("JBIG2Decode not supported") - case "DCTDecode", "DCT": - // JPEG payload — return as-is, caller can save as .jpg. - return in, nil - case "JPXDecode": - return in, nil - case "Crypt": - return nil, errors.New("Crypt filter not supported") - } - return nil, fmt.Errorf("unknown filter %q", name) -} - -// ApplyPredictor undoes the PNG-family predictor used with Flate/LZW. If -// predictor <= 1 the data is returned unchanged. -func ApplyPredictor(in []byte, predictor, columns, colors, bits int) ([]byte, error) { - if predictor <= 1 { - return in, nil - } - if len(in) > MaxDecodedStreamOutput { - return nil, fmt.Errorf("predictor input exceeds %d byte limit", MaxDecodedStreamOutput) - } - bytesPerPixel, rowLen, err := predictorGeometry(columns, colors, bits) - if err != nil { - return nil, err - } - - switch { - case predictor == 2: - // TIFF predictor — apply left delta per sample. - out := make([]byte, len(in)) - copy(out, in) - step := bytesPerPixel - for i := step; i < len(out); i++ { - if i%rowLen < step { - continue - } - out[i] = out[i] + out[i-step] - } - return out, nil - case predictor >= 10 && predictor <= 15: - // PNG predictors — each row is prefixed with a filter-type byte. - stride := rowLen + 1 - out := make([]byte, 0, len(in)) - var prev []byte - for i := 0; i < len(in); i += stride { - if i+stride > len(in) { - break - } - filterType := in[i] - row := make([]byte, rowLen) - copy(row, in[i+1:i+stride]) - switch filterType { - case 0: // None - case 1: // Sub - for j := bytesPerPixel; j < rowLen; j++ { - row[j] += row[j-bytesPerPixel] - } - case 2: // Up - if prev != nil { - for j := 0; j < rowLen; j++ { - row[j] += prev[j] - } - } - case 3: // Average - for j := 0; j < rowLen; j++ { - left := byte(0) - if j >= bytesPerPixel { - left = row[j-bytesPerPixel] - } - up := byte(0) - if prev != nil { - up = prev[j] - } - row[j] += byte((int(left) + int(up)) / 2) - } - case 4: // Paeth - for j := 0; j < rowLen; j++ { - a := 0 - if j >= bytesPerPixel { - a = int(row[j-bytesPerPixel]) - } - b := 0 - if prev != nil { - b = int(prev[j]) - } - c := 0 - if prev != nil && j >= bytesPerPixel { - c = int(prev[j-bytesPerPixel]) - } - p := a + b - c - pa := abs(p - a) - pb := abs(p - b) - pc := abs(p - c) - pred := 0 - switch { - case pa <= pb && pa <= pc: - pred = a - case pb <= pc: - pred = b - default: - pred = c - } - row[j] += byte(pred) - } - default: - return nil, fmt.Errorf("PNG predictor: invalid filter type %d", filterType) - } - out = append(out, row...) - prev = row - } - return out, nil - } - return nil, fmt.Errorf("predictor %d not supported", predictor) -} - -func predictorGeometry(columns, colors, bits int) (int, int, error) { - if columns <= 0 || colors <= 0 { - return 0, 0, errors.New("predictor columns and colors must be positive") - } - switch bits { - case 1, 2, 4, 8, 16: - default: - return 0, 0, fmt.Errorf("predictor bits per component %d is invalid", bits) - } - pixelBits := int64(colors) * int64(bits) - rowBits := int64(columns) * pixelBits - if pixelBits <= 0 || rowBits <= 0 || rowBits > int64(MaxDecodedStreamOutput)*8 { - return 0, 0, errors.New("predictor dimensions exceed resource limits") - } - return int((pixelBits + 7) / 8), int((rowBits + 7) / 8), nil -} - -func ensureDecodedSize(current, additional int) error { - if additional < 0 || current > MaxDecodedStreamOutput-additional { - return fmt.Errorf("decoded stream exceeds %d byte limit", MaxDecodedStreamOutput) - } - return nil -} - -func abs(x int) int { - if x < 0 { - return -x - } - return x -} - -// --------------------------------------------------------------------------- -// ASCIIHexDecode -// --------------------------------------------------------------------------- - -func decodeASCIIHex(in []byte) ([]byte, error) { - out := make([]byte, 0, len(in)/2) - hi := -1 - for _, b := range in { - switch { - case b >= '0' && b <= '9': - b = b - '0' - case b >= 'a' && b <= 'f': - b = b - 'a' + 10 - case b >= 'A' && b <= 'F': - b = b - 'A' + 10 - case b == '>': - if hi >= 0 { - out = append(out, byte(hi)<<4) - } - return out, nil - case isPDFWhitespace(b): - continue - default: - continue - } - if hi < 0 { - hi = int(b) - } else { - out = append(out, byte(hi)<<4|b) - hi = -1 - } - } - if hi >= 0 { - out = append(out, byte(hi)<<4) - } - return out, nil -} - -func isPDFWhitespace(b byte) bool { - return b == 0 || b == '\t' || b == '\n' || b == '\f' || b == '\r' || b == ' ' -} - -// --------------------------------------------------------------------------- -// ASCII85Decode -// --------------------------------------------------------------------------- - -func decodeASCII85(in []byte) ([]byte, error) { - // Strip optional <~ ... ~> framing and whitespace. - start := 0 - if bytes.HasPrefix(in, []byte("<~")) { - start = 2 - } - out := make([]byte, 0, len(in)) - var group [5]byte - n := 0 - for i := start; i < len(in); i++ { - c := in[i] - if isPDFWhitespace(c) { - continue - } - if c == '~' { - break - } - if c == 'z' && n == 0 { - if err := ensureDecodedSize(len(out), 4); err != nil { - return nil, err - } - out = append(out, 0, 0, 0, 0) - continue - } - if c < '!' || c > 'u' { - return nil, fmt.Errorf("ASCII85: invalid byte 0x%02x", c) - } - group[n] = c - '!' - n++ - if n == 5 { - v := uint32(group[0])*85*85*85*85 + - uint32(group[1])*85*85*85 + - uint32(group[2])*85*85 + - uint32(group[3])*85 + - uint32(group[4]) - if err := ensureDecodedSize(len(out), 4); err != nil { - return nil, err - } - out = append(out, byte(v>>24), byte(v>>16), byte(v>>8), byte(v)) - n = 0 - } - } - if n > 0 { - for i := n; i < 5; i++ { - group[i] = 84 - } - v := uint32(group[0])*85*85*85*85 + - uint32(group[1])*85*85*85 + - uint32(group[2])*85*85 + - uint32(group[3])*85 + - uint32(group[4]) - full := []byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)} - if err := ensureDecodedSize(len(out), n-1); err != nil { - return nil, err - } - out = append(out, full[:n-1]...) - } - return out, nil -} - -// --------------------------------------------------------------------------- -// RunLengthDecode -// --------------------------------------------------------------------------- - -func decodeRunLength(in []byte) ([]byte, error) { - out := make([]byte, 0, len(in)) - i := 0 - for i < len(in) { - b := in[i] - i++ - switch { - case b == 128: - return out, nil - case b < 128: - n := int(b) + 1 - if i+n > len(in) { - return nil, errors.New("RunLength: truncated literal") - } - if err := ensureDecodedSize(len(out), n); err != nil { - return nil, err - } - out = append(out, in[i:i+n]...) - i += n - default: - n := 257 - int(b) - if i >= len(in) { - return nil, errors.New("RunLength: truncated run") - } - if err := ensureDecodedSize(len(out), n); err != nil { - return nil, err - } - for k := 0; k < n; k++ { - out = append(out, in[i]) - } - i++ - } - } - return out, nil -} - -// --------------------------------------------------------------------------- -// LZWDecode (variable-width, early-change = 1) -// --------------------------------------------------------------------------- - -func decodeLZW(in []byte) ([]byte, error) { - const ( - clearCode = 256 - eodCode = 257 - ) - reader := &bitReader{data: in} - table := make([][]byte, 258) - for i := 0; i < 256; i++ { - table[i] = []byte{byte(i)} - } - codeWidth := 9 - out := []byte{} - prev := []byte(nil) - for { - c, err := reader.read(codeWidth) - if err != nil { - return out, nil - } - if c == eodCode { - return out, nil - } - if c == clearCode { - table = table[:258] - codeWidth = 9 - prev = nil - continue - } - var entry []byte - switch { - case c < len(table): - entry = table[c] - case c == len(table) && prev != nil: - entry = append([]byte{}, prev...) - entry = append(entry, prev[0]) - default: - return out, errors.New("LZW: invalid code") - } - if err := ensureDecodedSize(len(out), len(entry)); err != nil { - return nil, err - } - out = append(out, entry...) - if prev != nil { - ne := append([]byte{}, prev...) - ne = append(ne, entry[0]) - table = append(table, ne) - // PDF LZW uses "early change": widen one code before full. - switch len(table) { - case 511, 1023, 2047: - codeWidth++ - case 4096: - // Implicit CLEAR expected; some encoders omit it. - table = table[:258] - codeWidth = 9 - prev = nil - continue - } - } - prev = entry - } -} - -type bitReader struct { - data []byte - bi int // bit offset into data -} - -func (r *bitReader) read(n int) (int, error) { - if n <= 0 || n > 32 { - return 0, errors.New("bitReader: bad width") - } - v := 0 - for i := 0; i < n; i++ { - byteIdx := r.bi / 8 - if byteIdx >= len(r.data) { - return 0, errors.New("bitReader: EOF") - } - bit := (r.data[byteIdx] >> uint(7-(r.bi%8))) & 1 - v = (v << 1) | int(bit) - r.bi++ - } - return v, nil -} diff --git a/internal/leviathan/pdf/pdf.go b/internal/leviathan/pdf/pdf.go deleted file mode 100644 index 76d6860..0000000 --- a/internal/leviathan/pdf/pdf.go +++ /dev/null @@ -1,352 +0,0 @@ -// Package pdf provides PDF surgery: object enumeration, full stream -// filter chain, cross-reference parsing (classical tables and xref -// streams), compressed object-stream decoding, and surfacing of hostile -// or otherwise-interesting constructs (JavaScript, embedded files, URI -// and launch actions, XFA, OpenAction triggers). -// -// It is not a rendering engine — it will not produce pixels. It is a -// structural tool for humans and detection pipelines. -package pdf - -import ( - "bytes" - "errors" - "fmt" - "regexp" - "strconv" - "strings" - - "github.com/andydixon/link/internal/leviathan/document" -) - -const maxPDFParseBytes = 128 << 20 - -// Object is one PDF indirect object. -type Object struct { - Num int64 - Gen int64 - Offset int64 - Dict string - HasStream bool - StreamOff int64 - StreamLen int64 - Filters []string - Type string - Subtype string - Source string // "direct" or "objstm" - Container int64 // when Source == "objstm", the ObjStm number -} - -// Parse returns a summary of the PDF structure, including cross- -// reference entries, an object list (direct + recovered from ObjStms), -// surfaced suspicious constructs, and top-level flags. -func Parse(d *document.Document) (map[string]any, error) { - size := d.Size() - head, _ := d.Range(0, 16) - if len(head) < 5 || string(head[:5]) != "%PDF-" { - return nil, errors.New("not a PDF header") - } - if size > maxPDFParseBytes { - return nil, fmt.Errorf("pdf parse skipped: file > %d MiB", maxPDFParseBytes>>20) - } - version := string(head[5:8]) - - whole, err := d.Range(0, size) - if err != nil { - return nil, err - } - - objs := scanIndirectObjects(whole) - - // Try xref first; fall back gracefully if it isn't parseable. - xref, xrefMode, xrefErr := ParseXRef(d, whole) - xrefNote := "" - if xrefErr != nil { - xrefNote = xrefErr.Error() - } - - // Decode any ObjStm streams we find and append recovered children. - recovered := recoverObjectStreams(whole, d, objs) - objs = append(objs, recovered...) - - // Surface flags. - surfaced := Surface(objs) - encrypted := bytes.Contains(whole, []byte("/Encrypt")) - linearised := HasLinearisation(whole) - - // Locate trailer text (for quick eyeball). - trailerText := "" - if tIdx := bytes.LastIndex(whole, []byte("trailer")); tIdx >= 0 { - end := bytes.Index(whole[tIdx:], []byte("startxref")) - if end >= 0 { - trailerText = string(whole[tIdx : tIdx+end]) - } - } - - return map[string]any{ - "version": version, - "size": size, - "encrypted": encrypted, - "linearised": linearised, - "trailer": trailerText, - "objects": objectsToMaps(objs), - "xref": xrefEntriesToMaps(xref), - "xrefMode": xrefMode, - "xrefNote": xrefNote, - "suspicious": surfaceToMaps(surfaced), - }, nil -} - -func scanIndirectObjects(whole []byte) []Object { - objRE := regexp.MustCompile(`(?m)(\d+)\s+(\d+)\s+obj\b`) - locs := objRE.FindAllSubmatchIndex(whole, -1) - objs := make([]Object, 0, len(locs)) - for _, loc := range locs { - start := int64(loc[0]) - num, _ := strconv.ParseInt(string(whole[loc[2]:loc[3]]), 10, 64) - gen, _ := strconv.ParseInt(string(whole[loc[4]:loc[5]]), 10, 64) - o := Object{Num: num, Gen: gen, Offset: start, Source: "direct"} - endIdx := bytes.Index(whole[loc[1]:], []byte("endobj")) - if endIdx < 0 { - continue - } - body := whole[loc[1] : loc[1]+endIdx] - streamIdx := bytes.Index(body, []byte("stream")) - var dict []byte - if streamIdx >= 0 { - dict = body[:streamIdx] - s := streamIdx + len("stream") - if s < len(body) && body[s] == '\r' { - s++ - } - if s < len(body) && body[s] == '\n' { - s++ - } - o.HasStream = true - o.StreamOff = int64(loc[1]) + int64(s) - endStreamIdx := bytes.LastIndex(body, []byte("endstream")) - if endStreamIdx < 0 { - continue - } - endStreamAbs := int64(loc[1]) + int64(endStreamIdx) - // trim trailing newline/cr - for k := 0; k < 2; k++ { - if endStreamAbs > 0 && endStreamAbs-1 < int64(len(whole)) { - if whole[endStreamAbs-1] == '\n' || whole[endStreamAbs-1] == '\r' { - endStreamAbs-- - } - } - } - o.StreamLen = endStreamAbs - o.StreamOff - } else { - dict = body - } - o.Dict = strings.TrimSpace(string(dict)) - o.Filters = detectFilters(o.Dict) - o.Type = detectType(o.Dict) - o.Subtype = detectSubtype(o.Dict) - objs = append(objs, o) - } - return objs -} - -func recoverObjectStreams(whole []byte, d *document.Document, objs []Object) []Object { - out := []Object{} - for _, o := range objs { - if !o.HasStream { - continue - } - if !strings.Contains(o.Dict, "/Type /ObjStm") && !strings.Contains(o.Dict, "/Type/ObjStm") { - continue - } - raw, err := d.Range(o.StreamOff, o.StreamLen) - if err != nil { - continue - } - // Apply filters in order before handing to ObjStm decoder. - children, err := DecodeObjStm(raw, o.Dict) - if err != nil { - continue - } - for num, body := range children { - out = append(out, Object{ - Num: num, - Gen: 0, - Offset: o.StreamOff, - Dict: body, - HasStream: false, - Type: detectType(body), - Subtype: detectSubtype(body), - Source: "objstm", - Container: o.Num, - }) - } - } - return out -} - -func objectsToMaps(objs []Object) []map[string]any { - out := make([]map[string]any, len(objs)) - for i, o := range objs { - filters := make([]any, len(o.Filters)) - for j, f := range o.Filters { - filters[j] = f - } - out[i] = map[string]any{ - "num": o.Num, - "gen": o.Gen, - "offset": o.Offset, - "dict": o.Dict, - "hasStream": o.HasStream, - "streamOff": o.StreamOff, - "streamLen": o.StreamLen, - "filters": filters, - "type": o.Type, - "subtype": o.Subtype, - "source": o.Source, - "container": o.Container, - } - } - return out -} - -func xrefEntriesToMaps(entries []XRefEntry) []map[string]any { - out := make([]map[string]any, len(entries)) - for i, e := range entries { - out[i] = map[string]any{ - "type": e.Type, - "num": e.ObjNum, - "gen": e.Gen, - "offset": e.Offset, - "objStm": e.ObjStmNum, - "index": e.Index, - } - } - return out -} - -func surfaceToMaps(hits []SuspiciousHit) []map[string]any { - out := make([]map[string]any, len(hits)) - for i, h := range hits { - out[i] = map[string]any{ - "kind": h.Kind, - "num": h.ObjNum, - "gen": h.Gen, - "offset": h.Offset, - "detail": h.Detail, - } - } - return out -} - -var filtersRE = regexp.MustCompile(`/Filter\s*(?:\[([^\]]*)\]|/([A-Za-z0-9]+))`) - -func detectFilters(dict string) []string { - m := filtersRE.FindStringSubmatch(dict) - if m == nil { - return nil - } - if m[2] != "" { - return []string{m[2]} - } - parts := strings.Fields(m[1]) - out := make([]string, 0, len(parts)) - for _, p := range parts { - p = strings.TrimPrefix(p, "/") - if p != "" { - out = append(out, p) - } - } - return out -} - -var ( - typeRE = regexp.MustCompile(`/Type\s*/([A-Za-z0-9]+)`) - subtypeRE = regexp.MustCompile(`/Subtype\s*/([A-Za-z0-9]+)`) -) - -func detectType(dict string) string { - m := typeRE.FindStringSubmatch(dict) - if len(m) > 1 { - return m[1] - } - return "" -} - -func detectSubtype(dict string) string { - m := subtypeRE.FindStringSubmatch(dict) - if len(m) > 1 { - return m[1] - } - return "" -} - -// DecodeStream reads the stream for object objNum and runs it through -// the declared filter chain (with predictor). Works for both direct -// objects and those recovered from ObjStm containers. -func DecodeStream(d *document.Document, objNum int64) (map[string]any, error) { - summary, err := Parse(d) - if err != nil { - return nil, err - } - for _, o := range summary["objects"].([]map[string]any) { - n, _ := o["num"].(int64) - if n != objNum { - continue - } - hasStream, _ := o["hasStream"].(bool) - if !hasStream { - return nil, fmt.Errorf("object %d has no stream", objNum) - } - off, _ := o["streamOff"].(int64) - ln, _ := o["streamLen"].(int64) - raw, err := d.Range(off, ln) - if err != nil { - return nil, err - } - dict, _ := o["dict"].(string) - filters, _ := o["filters"].([]any) - predictor, columns, colors, bits := parseDecodeParms(dict) - - decoded := append([]byte(nil), raw...) - applied := []string{} - var runErr error - for _, f := range filters { - name, _ := f.(string) - decoded, runErr = ApplyFilter(name, decoded) - if runErr != nil { - return map[string]any{ - "objNum": objNum, - "raw": raw, - "decoded": nil, - "error": runErr.Error(), - "applied": applied, - "filters": filters, - }, nil - } - applied = append(applied, name) - } - if predictor > 1 { - decoded, runErr = ApplyPredictor(decoded, predictor, columns, colors, bits) - if runErr != nil { - return map[string]any{ - "objNum": objNum, - "raw": raw, - "decoded": nil, - "error": runErr.Error(), - "applied": applied, - "filters": filters, - }, nil - } - applied = append(applied, fmt.Sprintf("predictor=%d", predictor)) - } - return map[string]any{ - "objNum": objNum, - "raw": raw, - "decoded": decoded, - "applied": applied, - "filters": filters, - }, nil - } - return nil, fmt.Errorf("object %d not found", objNum) -} diff --git a/internal/leviathan/pdf/pdf_test.go b/internal/leviathan/pdf/pdf_test.go deleted file mode 100644 index 9934edd..0000000 --- a/internal/leviathan/pdf/pdf_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package pdf - -import ( - "os" - "strings" - "testing" - - "github.com/andydixon/link/internal/leviathan/document" -) - -func TestParseRejectsOversizedPDFBeforeWholeRange(t *testing.T) { - file, err := os.CreateTemp(t.TempDir(), "huge-*.pdf") - if err != nil { - t.Fatalf("CreateTemp() failed: %v", err) - } - if _, err := file.WriteString("%PDF-1.7\n"); err != nil { - t.Fatalf("WriteString() failed: %v", err) - } - if err := file.Truncate(maxPDFParseBytes + 1); err != nil { - t.Fatalf("Truncate() failed: %v", err) - } - if err := file.Close(); err != nil { - t.Fatalf("Close() failed: %v", err) - } - - doc, err := document.Open(file.Name()) - if err != nil { - t.Fatalf("document.Open() failed: %v", err) - } - _, err = Parse(doc) - if err == nil { - t.Fatal("expected oversized PDF to be rejected") - } - if !strings.Contains(err.Error(), "pdf parse skipped") { - t.Fatalf("unexpected error: %v", err) - } -} diff --git a/internal/leviathan/pdf/surface.go b/internal/leviathan/pdf/surface.go deleted file mode 100644 index 5dc0395..0000000 --- a/internal/leviathan/pdf/surface.go +++ /dev/null @@ -1,128 +0,0 @@ -package pdf - -import ( - "bytes" - "regexp" - "strings" -) - -// SuspiciousHit marks a dictionary fragment worth a closer look. It is -// surfaced to the UI with its containing object number so a user can -// jump straight to it. -type SuspiciousHit struct { - Kind string `json:"kind"` - ObjNum int64 `json:"objNum"` - Gen int64 `json:"gen"` - Offset int64 `json:"offset"` - Detail string `json:"detail,omitempty"` -} - -// Surface walks the enumerated object list and flags anything routinely -// abused by hostile PDFs: embedded files, JavaScript, launch actions, -// URI actions, form-submission targets, OpenAction / AA triggers. -func Surface(objects []Object) []SuspiciousHit { - hits := []SuspiciousHit{} - for _, o := range objects { - dict := o.Dict - push := func(kind, detail string) { - hits = append(hits, SuspiciousHit{ - Kind: kind, ObjNum: o.Num, Gen: o.Gen, - Offset: o.Offset, Detail: detail, - }) - } - // Key markers. Order matters only for readability of the UI. - if containsName(dict, "JavaScript") || containsName(dict, "JS") { - push("javascript", firstLine(dict, 240)) - } - if containsName(dict, "EmbeddedFile") || containsName(dict, "Filespec") { - push("embedded-file", extractName(dict, "/F")) - } - if containsName(dict, "Launch") { - push("launch-action", extractLaunchTarget(dict)) - } - if containsName(dict, "URI") { - if uri := extractURI(dict); uri != "" { - push("uri-action", uri) - } - } - if containsName(dict, "SubmitForm") { - push("submit-form", "") - } - if containsName(dict, "OpenAction") { - push("open-action", "") - } - if strings.Contains(dict, "/AA") { - push("additional-action", "") - } - if strings.Contains(dict, "/Type /XFA") || strings.Contains(dict, "/XFA ") { - push("xfa-form", "") - } - if strings.Contains(dict, "/RichMedia") { - push("rich-media", "") - } - if strings.Contains(dict, "/Type /Metadata") && strings.Contains(dict, "/Subtype /XML") { - push("xmp-metadata", "") - } - } - return hits -} - -func containsName(dict, name string) bool { - // Match `/Name` as a whole token — not inside /Names/ or /FooJavaScript. - pat := regexp.MustCompile(`/` + regexp.QuoteMeta(name) + `\b`) - return pat.MatchString(dict) -} - -// Go's regexp caps bounded repetitions at 1000 — do not raise this. -var uriRE = regexp.MustCompile(`/URI\s*\(([^)]{1,1000})\)`) - -func extractURI(dict string) string { - m := uriRE.FindStringSubmatch(dict) - if m == nil { - return "" - } - return m[1] -} - -var fnameRE = regexp.MustCompile(`/F\s*\(([^)]{1,512})\)`) - -func extractName(dict, key string) string { - _ = key - m := fnameRE.FindStringSubmatch(dict) - if m == nil { - return "" - } - return m[1] -} - -var launchRE = regexp.MustCompile(`/F\s*\(([^)]{1,512})\)`) - -func extractLaunchTarget(dict string) string { - m := launchRE.FindStringSubmatch(dict) - if m == nil { - return "" - } - return m[1] -} - -func firstLine(s string, max int) string { - nl := strings.IndexByte(s, '\n') - if nl > 0 { - s = s[:nl] - } - s = strings.TrimSpace(s) - if len(s) > max { - s = s[:max] + "…" - } - return s -} - -// HasLinearisation reports whether a PDF is linearised (Fast Web View). -// A linearised PDF has an obj dict near the start containing /Linearized. -func HasLinearisation(whole []byte) bool { - n := 4096 - if n > len(whole) { - n = len(whole) - } - return bytes.Contains(whole[:n], []byte("/Linearized")) -} diff --git a/internal/leviathan/pdf/xref.go b/internal/leviathan/pdf/xref.go deleted file mode 100644 index f6efb22..0000000 --- a/internal/leviathan/pdf/xref.go +++ /dev/null @@ -1,422 +0,0 @@ -package pdf - -import ( - "bytes" - "errors" - "fmt" - "regexp" - "strconv" - "strings" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// XRefEntry describes one cross-reference entry after parsing either a -// classical xref table or a modern xref stream. -// -// Type 0 — free object -// Type 1 — in-use object; Offset is the byte offset in the file -// Type 2 — object inside an object stream; ObjStmNum identifies the -// container ObjStm and Index is the 0-based position within it -type XRefEntry struct { - Type int `json:"type"` - ObjNum int64 `json:"objNum"` - Gen int64 `json:"gen"` - Offset int64 `json:"offset"` - ObjStmNum int64 `json:"objStmNum,omitempty"` - Index int64 `json:"index,omitempty"` -} - -const maxObjectStreamEntries = 1 << 20 - -// ParseXRef attempts to reconstruct the cross-reference map. It handles -// both classical "xref\n0 N\n..." tables and xref streams (/Type /XRef). -// Returns a flat slice of entries covering every object it could resolve. -func ParseXRef(d *document.Document, whole []byte) ([]XRefEntry, string, error) { - mode := "" - entries := map[int64]XRefEntry{} - - // Find "startxref" near the end. - idx := bytes.LastIndex(whole, []byte("startxref")) - if idx < 0 { - return nil, "", errors.New("no startxref") - } - after := whole[idx+len("startxref"):] - after = bytes.TrimLeft(after, " \r\n\t") - var xrefPos int64 - fmt.Sscanf(string(after), "%d", &xrefPos) - if xrefPos <= 0 || xrefPos >= int64(len(whole)) { - return nil, "", fmt.Errorf("bad startxref offset %d", xrefPos) - } - - // Walk the xref chain via /Prev pointers. - visited := map[int64]bool{} - pos := xrefPos - for pos > 0 && !visited[pos] { - visited[pos] = true - // Classical table if the first non-whitespace token is "xref". - tail := whole[pos:] - trimmed := bytes.TrimLeft(tail, " \r\n\t") - if bytes.HasPrefix(trimmed, []byte("xref")) { - if mode == "" { - mode = "table" - } else if mode != "table" { - mode = "hybrid" - } - nextPrev, err := parseClassicalXRef(trimmed, entries) - if err != nil { - return mapToSlice(entries), mode, err - } - pos = nextPrev - continue - } - // Otherwise an xref stream: an indirect object at `pos`. - if mode == "" { - mode = "stream" - } else if mode != "stream" { - mode = "hybrid" - } - nextPrev, err := parseXRefStream(d, whole, pos, entries) - if err != nil { - return mapToSlice(entries), mode, err - } - pos = nextPrev - } - return mapToSlice(entries), mode, nil -} - -func mapToSlice(m map[int64]XRefEntry) []XRefEntry { - out := make([]XRefEntry, 0, len(m)) - for _, e := range m { - out = append(out, e) - } - return out -} - -var xrefRangeRE = regexp.MustCompile(`(?m)^\s*(\d+)\s+(\d+)\s*$`) -var xrefRowRE = regexp.MustCompile(`^(\d{10})\s+(\d{5})\s+([nf])`) -var prevRE = regexp.MustCompile(`/Prev\s+(\d+)`) - -func parseClassicalXRef(block []byte, entries map[int64]XRefEntry) (int64, error) { - // Skip "xref" line. - nl := bytes.IndexByte(block, '\n') - if nl < 0 { - return 0, errors.New("xref: no newline after keyword") - } - cursor := block[nl+1:] - for { - m := xrefRangeRE.FindSubmatchIndex(cursor) - if m == nil { - break - } - if m[0] > 0 { - // Subsection must begin at the start of the cursor. - break - } - start, _ := strconv.ParseInt(string(cursor[m[2]:m[3]]), 10, 64) - count, _ := strconv.ParseInt(string(cursor[m[4]:m[5]]), 10, 64) - // Advance past the subsection line. - nl2 := bytes.IndexByte(cursor, '\n') - if nl2 < 0 { - break - } - cursor = cursor[nl2+1:] - for i := int64(0); i < count; i++ { - if len(cursor) < 20 { - return 0, errors.New("xref: truncated subsection") - } - row := cursor[:20] - rm := xrefRowRE.FindSubmatch(row) - if rm != nil { - off, _ := strconv.ParseInt(string(rm[1]), 10, 64) - gen, _ := strconv.ParseInt(string(rm[2]), 10, 64) - typ := 1 - if rm[3][0] == 'f' { - typ = 0 - } - num := start + i - if _, seen := entries[num]; !seen { - entries[num] = XRefEntry{ - Type: typ, ObjNum: num, Gen: gen, Offset: off, - } - } - } - cursor = cursor[20:] - } - } - // Locate trailer and /Prev. - tIdx := bytes.Index(block, []byte("trailer")) - if tIdx < 0 { - return 0, nil - } - trailer := block[tIdx:] - if e := bytes.Index(trailer, []byte("startxref")); e > 0 { - trailer = trailer[:e] - } - if pm := prevRE.FindSubmatch(trailer); pm != nil { - prev, _ := strconv.ParseInt(string(pm[1]), 10, 64) - return prev, nil - } - return 0, nil -} - -func parseXRefStream(d *document.Document, whole []byte, pos int64, entries map[int64]XRefEntry) (int64, error) { - // Locate "obj" after pos, then "stream"/"endstream". - if pos < 0 || int(pos) >= len(whole) { - return 0, errors.New("xref stream: bad offset") - } - body := whole[pos:] - objIdx := bytes.Index(body, []byte("obj")) - if objIdx < 0 { - return 0, errors.New("xref stream: no obj") - } - dictEnd := bytes.Index(body[objIdx:], []byte("stream")) - if dictEnd < 0 { - return 0, errors.New("xref stream: no stream") - } - dict := string(body[objIdx : objIdx+dictEnd]) - streamStart := objIdx + dictEnd + len("stream") - if streamStart < len(body) && body[streamStart] == '\r' { - streamStart++ - } - if streamStart < len(body) && body[streamStart] == '\n' { - streamStart++ - } - endIdx := bytes.Index(body[streamStart:], []byte("endstream")) - if endIdx < 0 { - return 0, errors.New("xref stream: no endstream") - } - raw := body[streamStart : streamStart+endIdx] - // trim trailing newline. - if len(raw) > 0 && raw[len(raw)-1] == '\n' { - raw = raw[:len(raw)-1] - } - if len(raw) > 0 && raw[len(raw)-1] == '\r' { - raw = raw[:len(raw)-1] - } - - // Decode the xref-stream dictionary fields. - filters := parseDictNameList(dict, "/Filter") - w := parseDictIntArray(dict, "/W") - index := parseDictIntArray(dict, "/Index") - sizeN := parseDictInt(dict, "/Size") - predictor, columns, colors, bits := parseDecodeParms(dict) - - // Apply filters. - data := append([]byte(nil), raw...) - var err error - for _, f := range filters { - data, err = ApplyFilter(f, data) - if err != nil { - return 0, fmt.Errorf("xref stream: filter %s: %w", f, err) - } - } - if predictor > 1 { - data, err = ApplyPredictor(data, predictor, columns, colors, bits) - if err != nil { - return 0, err - } - } - if len(w) != 3 { - return 0, fmt.Errorf("xref stream: bad /W array %v", w) - } - entryLen := 0 - for _, width := range w { - if width < 0 || width > 8 { - return 0, fmt.Errorf("xref stream: invalid field width %d", width) - } - entryLen += width - } - if entryLen <= 0 { - return 0, errors.New("xref stream: zero-length entries") - } - if len(index) == 0 { - if sizeN < 0 { - return 0, errors.New("xref stream: negative /Size") - } - index = []int{0, sizeN} - } - if len(index)%2 != 0 { - return 0, errors.New("xref stream: /Index must contain start/count pairs") - } - // Walk subsections. - pi := 0 - for s := 0; s+1 < len(index); s += 2 { - first := int64(index[s]) - count := int64(index[s+1]) - if first < 0 || count < 0 { - return 0, errors.New("xref stream: negative /Index value") - } - for i := int64(0); i < count; i++ { - if pi+entryLen > len(data) { - break - } - row := data[pi : pi+entryLen] - pi += entryLen - t := readBE(row[:w[0]]) - f2 := readBE(row[w[0] : w[0]+w[1]]) - f3 := readBE(row[w[0]+w[1] : w[0]+w[1]+w[2]]) - if w[0] == 0 { - t = 1 // default Type 1 per spec - } - num := first + i - if _, seen := entries[num]; seen { - continue - } - switch t { - case 0: - entries[num] = XRefEntry{Type: 0, ObjNum: num, Gen: f3} - case 1: - entries[num] = XRefEntry{Type: 1, ObjNum: num, Gen: f3, Offset: f2} - case 2: - entries[num] = XRefEntry{ - Type: 2, ObjNum: num, ObjStmNum: f2, Index: f3, - } - } - } - } - if prev := parseDictInt(dict, "/Prev"); prev > 0 { - return int64(prev), nil - } - return 0, nil -} - -func readBE(b []byte) int64 { - v := int64(0) - for _, c := range b { - v = (v << 8) | int64(c) - } - return v -} - -// --------------------------------------------------------------------------- -// ObjStm — compressed object streams -// --------------------------------------------------------------------------- - -// DecodeObjStm decodes an /ObjStm stream and returns a map of -// object-number → raw object body (text, never with "stream"). -func DecodeObjStm(raw []byte, dict string) (map[int64]string, error) { - filters := parseDictNameList(dict, "/Filter") - predictor, columns, colors, bits := parseDecodeParms(dict) - data := append([]byte(nil), raw...) - var err error - for _, f := range filters { - data, err = ApplyFilter(f, data) - if err != nil { - return nil, err - } - } - if predictor > 1 { - data, err = ApplyPredictor(data, predictor, columns, colors, bits) - if err != nil { - return nil, err - } - } - n := parseDictInt(dict, "/N") - first := parseDictInt(dict, "/First") - if n <= 0 || first <= 0 || first >= len(data) { - return nil, fmt.Errorf("ObjStm: bad /N=%d /First=%d", n, first) - } - header := strings.Fields(string(data[:first])) - if n > maxObjectStreamEntries || n > len(header)/2 { - return nil, fmt.Errorf("ObjStm: header has %d tokens, need %d", len(header), n*2) - } - out := map[int64]string{} - offsets := make([]int, n) - nums := make([]int64, n) - for i := 0; i < n; i++ { - num, _ := strconv.ParseInt(header[i*2], 10, 64) - off, _ := strconv.Atoi(header[i*2+1]) - nums[i] = num - offsets[i] = off - } - for i := 0; i < n; i++ { - start := first + offsets[i] - end := len(data) - if i+1 < n { - end = first + offsets[i+1] - } - if start < 0 || end > len(data) || start > end { - continue - } - out[nums[i]] = strings.TrimSpace(string(data[start:end])) - } - return out, nil -} - -// --------------------------------------------------------------------------- -// Small dictionary helpers -// --------------------------------------------------------------------------- - -var decodeParmRE = regexp.MustCompile(`/DecodeParms\s*<<([^>]*)>>`) - -func parseDictInt(dict, key string) int { - pat := regexp.MustCompile(regexp.QuoteMeta(key) + `\s+(-?\d+)\b`) - m := pat.FindStringSubmatch(dict) - if m == nil { - return 0 - } - n, _ := strconv.Atoi(m[1]) - return n -} - -func parseDictIntArray(dict, key string) []int { - pat := regexp.MustCompile(regexp.QuoteMeta(key) + `\s*\[([^\]]*)\]`) - m := pat.FindStringSubmatch(dict) - if m == nil { - return nil - } - parts := strings.Fields(m[1]) - out := make([]int, 0, len(parts)) - for _, p := range parts { - n, err := strconv.Atoi(p) - if err != nil { - continue - } - out = append(out, n) - } - return out -} - -func parseDictNameList(dict, key string) []string { - // Handles both `/Filter /FlateDecode` and `/Filter [/A85 /Flate]`. - pat1 := regexp.MustCompile(regexp.QuoteMeta(key) + `\s*/([A-Za-z0-9]+)`) - if m := pat1.FindStringSubmatch(dict); m != nil { - return []string{m[1]} - } - pat2 := regexp.MustCompile(regexp.QuoteMeta(key) + `\s*\[([^\]]*)\]`) - if m := pat2.FindStringSubmatch(dict); m != nil { - parts := strings.Fields(m[1]) - out := make([]string, 0, len(parts)) - for _, p := range parts { - out = append(out, strings.TrimPrefix(p, "/")) - } - return out - } - return nil -} - -func parseDecodeParms(dict string) (predictor, columns, colors, bits int) { - m := decodeParmRE.FindStringSubmatch(dict) - if m == nil { - return 1, 1, 1, 8 - } - inner := m[1] - predictor = parseDictInt(inner, "/Predictor") - if predictor == 0 { - predictor = 1 - } - columns = parseDictInt(inner, "/Columns") - if columns == 0 { - columns = 1 - } - colors = parseDictInt(inner, "/Colors") - if colors == 0 { - colors = 1 - } - bits = parseDictInt(inner, "/BitsPerComponent") - if bits == 0 { - bits = 8 - } - return -} diff --git a/internal/leviathan/spectrogram/spectrogram.go b/internal/leviathan/spectrogram/spectrogram.go deleted file mode 100644 index de37675..0000000 --- a/internal/leviathan/spectrogram/spectrogram.go +++ /dev/null @@ -1,798 +0,0 @@ -package spectrogram - -import ( - "bytes" - "encoding/binary" - "errors" - "fmt" - "image" - "image/color" - "image/png" - "io" - "math" - "path/filepath" - "strings" - - _ "image/gif" - _ "image/jpeg" - _ "image/png" - - "github.com/hajimehoshi/go-mp3" -) - -const ( - defaultSampleRate = 44100 - defaultDuration = 5.0 - // MaxImageBytes caps compressed image inputs before decode. Pixel count is - // checked separately because small compressed images can still expand huge. - MaxImageBytes = 64 << 20 - // MaxAudioBytes caps encoded WAV/MP3 inputs before full in-memory decode. - MaxAudioBytes = 128 << 20 - maxImagePixels = 24_000_000 - maxDecodedBytes = 64 << 20 - twoPi = 2 * math.Pi -) - -// EncodeOptions controls image-to-audio spectrogram synthesis. -type EncodeOptions struct { - DurationSec float64 - SampleRate int - MinHz float64 - MaxHz float64 - Bins int - Columns int - Invert bool - PreserveAR bool - Gain float64 -} - -// InsertOptions controls embedding synthesized spectrogram audio into a carrier. -type InsertOptions struct { - EncodeOptions - StartSec float64 - MixLevel float64 -} - -// ViewOptions controls spectrogram image rendering from audio. -type ViewOptions struct { - FFTSize int - Height int - MaxWidth int - Aspect float64 - MinHz float64 - MaxHz float64 - Brightness float64 - Contrast float64 -} - -type audioBuffer struct { - SampleRate int - Channels int - Samples []float64 -} - -// ImageToWAV converts an image into a WAV whose spectrogram reveals that image. -func ImageToWAV(imageData []byte, opts EncodeOptions) ([]byte, map[string]any, error) { - grid, meta, opts, err := imageGrid(imageData, opts) - if err != nil { - return nil, nil, err - } - mono := synthesize(grid, opts) - audio := audioBuffer{SampleRate: opts.SampleRate, Channels: 1, Samples: mono} - wav, err := encodeWAV(audio) - if err != nil { - return nil, nil, err - } - summary := map[string]any{ - "mode": "image-to-wav", - "imageFormat": meta["format"], - "imageWidth": meta["width"], - "imageHeight": meta["height"], - "imageAspect": meta["aspect"], - "durationSec": opts.DurationSec, - "sampleRate": opts.SampleRate, - "minHz": opts.MinHz, - "maxHz": opts.MaxHz, - "bins": opts.Bins, - "columns": opts.Columns, - "preserveAR": opts.PreserveAR, - "wavBytes": len(wav), - } - return wav, summary, nil -} - -// InsertImageIntoAudio mixes a synthesized image spectrogram into WAV or MP3 carrier audio. -func InsertImageIntoAudio(imageData, audioData []byte, audioName string, opts InsertOptions) ([]byte, map[string]any, error) { - carrier, err := DecodeAudio(audioData, audioName) - if err != nil { - return nil, nil, err - } - opts.EncodeOptions.SampleRate = carrier.SampleRate - grid, meta, encodeOpts, err := imageGrid(imageData, opts.EncodeOptions) - if err != nil { - return nil, nil, err - } - tone := synthesize(grid, encodeOpts) - if opts.MixLevel <= 0 { - opts.MixLevel = 0.35 - } - if opts.MixLevel > 1 { - opts.MixLevel = 1 - } - if opts.StartSec < 0 { - opts.StartSec = 0 - } - startFrame := int(math.Round(opts.StartSec * float64(carrier.SampleRate))) - requiredFrames := startFrame + len(tone) - currentFrames := len(carrier.Samples) / carrier.Channels - if requiredFrames > currentFrames { - extended := make([]float64, requiredFrames*carrier.Channels) - copy(extended, carrier.Samples) - carrier.Samples = extended - } - for i, sample := range tone { - frame := startFrame + i - for ch := 0; ch < carrier.Channels; ch++ { - idx := frame*carrier.Channels + ch - carrier.Samples[idx] += sample * opts.MixLevel - } - } - limitPeak(carrier.Samples, 0.98) - wav, err := encodeWAV(carrier) - if err != nil { - return nil, nil, err - } - summary := map[string]any{ - "mode": "insert-into-audio", - "imageFormat": meta["format"], - "imageWidth": meta["width"], - "imageHeight": meta["height"], - "imageAspect": meta["aspect"], - "carrierSampleRate": carrier.SampleRate, - "carrierChannels": carrier.Channels, - "startSec": opts.StartSec, - "mixLevel": opts.MixLevel, - "durationSec": encodeOpts.DurationSec, - "minHz": encodeOpts.MinHz, - "maxHz": encodeOpts.MaxHz, - "bins": encodeOpts.Bins, - "columns": encodeOpts.Columns, - "preserveAR": encodeOpts.PreserveAR, - "wavBytes": len(wav), - } - return wav, summary, nil -} - -// AudioSpectrogramPNG renders WAV or MP3 audio as a PNG spectrogram. -func AudioSpectrogramPNG(audioData []byte, audioName string, opts ViewOptions) ([]byte, map[string]any, error) { - audio, err := DecodeAudio(audioData, audioName) - if err != nil { - return nil, nil, err - } - mono := toMono(audio) - if len(mono) == 0 { - return nil, nil, errors.New("audio contains no samples") - } - opts = normalizeViewOptions(opts, audio.SampleRate) - img := renderSpectrogram(mono, audio.SampleRate, opts) - var buf bytes.Buffer - if err := png.Encode(&buf, img); err != nil { - return nil, nil, err - } - duration := float64(len(mono)) / float64(audio.SampleRate) - summary := map[string]any{ - "mode": "audio-to-spectrogram", - "sampleRate": audio.SampleRate, - "channels": audio.Channels, - "durationSec": duration, - "fftSize": opts.FFTSize, - "width": img.Bounds().Dx(), - "height": img.Bounds().Dy(), - "aspectRatio": opts.Aspect, - "dynamicRange": 80, - "minHz": opts.MinHz, - "maxHz": opts.MaxHz, - "pngBytes": buf.Len(), - } - return buf.Bytes(), summary, nil -} - -// DecodeAudio decodes WAV directly and MP3 through go-mp3. -func DecodeAudio(data []byte, name string) (audioBuffer, error) { - if len(data) == 0 { - return audioBuffer{}, errors.New("audio file is empty") - } - if len(data) > MaxAudioBytes { - return audioBuffer{}, fmt.Errorf("audio input is too large: %d bytes exceeds %d bytes", len(data), MaxAudioBytes) - } - if isWAV(data) { - return decodeWAV(data) - } - ext := strings.ToLower(filepath.Ext(name)) - if ext == ".mp3" || isMP3(data) { - return decodeMP3(data) - } - return audioBuffer{}, errors.New("unsupported audio format: load a WAV or MP3 file") -} - -func imageGrid(data []byte, opts EncodeOptions) ([][]float64, map[string]any, EncodeOptions, error) { - if len(data) == 0 { - return nil, nil, opts, errors.New("image file is empty") - } - if len(data) > MaxImageBytes { - return nil, nil, opts, fmt.Errorf("image input is too large: %d bytes exceeds %d bytes", len(data), MaxImageBytes) - } - cfg, format, err := image.DecodeConfig(bytes.NewReader(data)) - if err != nil { - return nil, nil, opts, fmt.Errorf("decode image config: %w", err) - } - if cfg.Width <= 0 || cfg.Height <= 0 { - return nil, nil, opts, errors.New("image has no pixels") - } - if int64(cfg.Width)*int64(cfg.Height) > maxImagePixels { - return nil, nil, opts, fmt.Errorf("image dimensions are too large: %dx%d exceeds %d pixels", cfg.Width, cfg.Height, maxImagePixels) - } - img, format, err := image.Decode(bytes.NewReader(data)) - if err != nil { - return nil, nil, opts, fmt.Errorf("decode image: %w", err) - } - bounds := img.Bounds() - if bounds.Dx() <= 0 || bounds.Dy() <= 0 { - return nil, nil, opts, errors.New("image has no pixels") - } - opts = normalizeEncodeOptions(opts, bounds.Dx(), bounds.Dy()) - grid := make([][]float64, opts.Bins) - for bin := 0; bin < opts.Bins; bin++ { - grid[bin] = make([]float64, opts.Columns) - sourceY := bounds.Min.Y + int((float64(opts.Bins-1-bin)+0.5)*float64(bounds.Dy())/float64(opts.Bins)) - if sourceY >= bounds.Max.Y { - sourceY = bounds.Max.Y - 1 - } - for col := 0; col < opts.Columns; col++ { - sourceX := bounds.Min.X + int((float64(col)+0.5)*float64(bounds.Dx())/float64(opts.Columns)) - if sourceX >= bounds.Max.X { - sourceX = bounds.Max.X - 1 - } - grid[bin][col] = pixelAmplitude(img.At(sourceX, sourceY), opts.Invert) - } - } - meta := map[string]any{"format": format, "width": bounds.Dx(), "height": bounds.Dy(), "aspect": float64(bounds.Dy()) / float64(bounds.Dx())} - return grid, meta, opts, nil -} - -func normalizeEncodeOptions(opts EncodeOptions, imageWidth, imageHeight int) EncodeOptions { - if opts.DurationSec <= 0 { - opts.DurationSec = defaultDuration - } - opts.DurationSec = clampFloat(opts.DurationSec, 0.25, 60) - if opts.SampleRate <= 0 { - opts.SampleRate = defaultSampleRate - } - opts.SampleRate = clampInt(opts.SampleRate, 8000, 96000) - if opts.MinHz <= 0 { - opts.MinHz = 200 - } - if opts.MaxHz <= opts.MinHz { - opts.MaxHz = math.Min(float64(opts.SampleRate)/2-200, 20000) - } - opts.MinHz = clampFloat(opts.MinHz, 20, float64(opts.SampleRate)/2-100) - opts.MaxHz = clampFloat(opts.MaxHz, opts.MinHz+100, float64(opts.SampleRate)/2-50) - if opts.PreserveAR && imageWidth > 0 && imageHeight > 0 { - aspect := float64(imageHeight) / float64(imageWidth) - baseRange := opts.MaxHz - opts.MinHz - desiredRange := baseRange * aspect - opts.MaxHz = clampFloat(opts.MinHz+desiredRange, opts.MinHz+100, float64(opts.SampleRate)/2-50) - } - if opts.Bins <= 0 { - opts.Bins = clampInt(imageHeight, 48, 192) - } - opts.Bins = clampInt(opts.Bins, 16, 384) - if opts.Columns <= 0 { - opts.Columns = clampInt(imageWidth, 64, 1024) - } - opts.Columns = clampInt(opts.Columns, 16, 2048) - if opts.Gain <= 0 { - opts.Gain = 0.85 - } - opts.Gain = clampFloat(opts.Gain, 0.05, 1) - return opts -} - -func normalizeViewOptions(opts ViewOptions, sampleRate int) ViewOptions { - if opts.FFTSize <= 0 { - opts.FFTSize = 2048 - } - opts.FFTSize = nearestPowerOfTwo(clampInt(opts.FFTSize, 256, 8192)) - if opts.Height <= 0 { - opts.Height = 512 - } - opts.Height = clampInt(opts.Height, 64, 1024) - if opts.MaxWidth <= 0 { - opts.MaxWidth = 1400 - } - opts.MaxWidth = clampInt(opts.MaxWidth, 64, 2400) - if opts.Aspect <= 0 { - opts.Aspect = 1 - } - opts.Aspect = clampFloat(opts.Aspect, 0.25, 4) - if opts.MinHz < 0 { - opts.MinHz = 0 - } - if opts.MaxHz <= opts.MinHz { - opts.MaxHz = float64(sampleRate) / 2 - } - opts.MinHz = clampFloat(opts.MinHz, 0, float64(sampleRate)/2-10) - opts.MaxHz = clampFloat(opts.MaxHz, opts.MinHz+10, float64(sampleRate)/2) - if opts.Brightness <= 0 { - opts.Brightness = 1 - } - opts.Brightness = clampFloat(opts.Brightness, 0.1, 4) - if opts.Contrast <= 0 { - opts.Contrast = 1 - } - opts.Contrast = clampFloat(opts.Contrast, 0.1, 4) - return opts -} - -func pixelAmplitude(c color.Color, invert bool) float64 { - r, g, b, a := c.RGBA() - alpha := float64(a) / 65535 - if alpha <= 0.001 { - return 0 - } - luma := (0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b)) / 65535 - amp := luma - if invert { - amp = 1 - luma - } - amp *= alpha - return math.Pow(clampFloat(amp, 0, 1), 1.15) -} - -func synthesize(grid [][]float64, opts EncodeOptions) []float64 { - bins := len(grid) - cols := len(grid[0]) - frames := int(math.Round(opts.DurationSec * float64(opts.SampleRate))) - if frames < 1 { - frames = 1 - } - out := make([]float64, frames) - phase := make([]float64, bins) - inc := make([]float64, bins) - for i := range inc { - var pos float64 - if bins > 1 { - pos = float64(i) / float64(bins-1) - } - freq := opts.MinHz + (opts.MaxHz-opts.MinHz)*pos - inc[i] = twoPi * freq / float64(opts.SampleRate) - } - for i := 0; i < frames; i++ { - var x float64 - if frames > 1 { - x = float64(i) * float64(cols-1) / float64(frames-1) - } - c0 := int(math.Floor(x)) - c1 := c0 + 1 - if c1 >= cols { - c1 = cols - 1 - } - t := x - float64(c0) - var sample, weight float64 - for bin := 0; bin < bins; bin++ { - amp := grid[bin][c0]*(1-t) + grid[bin][c1]*t - if amp < 0.002 { - continue - } - phase[bin] += inc[bin] - if phase[bin] > twoPi { - phase[bin] -= twoPi - } - sample += math.Sin(phase[bin]) * amp - weight += amp - } - if weight > 1 { - sample /= math.Sqrt(weight) - } - out[i] = sample - } - normalizePeak(out, opts.Gain) - fade(out, opts.SampleRate/100) - return out -} - -func renderSpectrogram(samples []float64, sampleRate int, opts ViewOptions) image.Image { - fftSize := opts.FFTSize - hop := fftSize / 4 - if hop < 1 { - hop = 1 - } - frameCount := 1 - if len(samples) > fftSize { - frameCount = 1 + (len(samples)-fftSize)/hop - } - width := frameCount - if width > opts.MaxWidth { - width = opts.MaxWidth - } - if width < 1 { - width = 1 - } - height := clampInt(int(math.Round(float64(opts.Height)*opts.Aspect)), 32, 4096) - img := image.NewRGBA(image.Rect(0, 0, width, height)) - values := make([]float32, width*height) - maxDB := math.Inf(-1) - window := hamming(fftSize) - for x := 0; x < width; x++ { - frameIndex := 0 - if width > 1 && frameCount > 1 { - frameIndex = int(math.Round(float64(x) * float64(frameCount-1) / float64(width-1))) - } - start := frameIndex * hop - buf := make([]complex128, fftSize) - for i := 0; i < fftSize; i++ { - if start+i < len(samples) { - buf[i] = complex(samples[start+i]*window[i], 0) - } - } - fft(buf) - for y := 0; y < height; y++ { - pos := 0.0 - if height > 1 { - pos = float64(height-1-y) / float64(height-1) - } - freq := opts.MinHz + (opts.MaxHz-opts.MinHz)*pos - bin := int(math.Round(freq * float64(fftSize) / float64(sampleRate))) - if bin < 0 { - bin = 0 - } - if bin >= fftSize/2 { - bin = fftSize/2 - 1 - } - mag := cmplxAbs(buf[bin]) / float64(fftSize) - db := 20 * math.Log10(mag+1e-10) - values[y*width+x] = float32(db) - if db > maxDB { - maxDB = db - } - } - } - if math.IsInf(maxDB, -1) { - maxDB = -80 - } - minDB := maxDB - 80 - for y := 0; y < height; y++ { - for x := 0; x < width; x++ { - db := float64(values[y*width+x]) - norm := clampFloat((db-minDB)/(maxDB-minDB), 0, 1) - norm = clampFloat((norm-0.5)*opts.Contrast+0.5, 0, 1) - norm = clampFloat(norm*opts.Brightness, 0, 1) - img.Set(x, y, palette(norm)) - } - } - return img -} - -func decodeWAV(data []byte) (audioBuffer, error) { - if !isWAV(data) { - return audioBuffer{}, errors.New("not a WAV file") - } - var fmtChunk, dataChunk []byte - pos := 12 - for pos+8 <= len(data) { - id := string(data[pos : pos+4]) - size := int(binary.LittleEndian.Uint32(data[pos+4 : pos+8])) - pos += 8 - if size < 0 || pos+size > len(data) { - return audioBuffer{}, errors.New("invalid WAV chunk size") - } - chunk := data[pos : pos+size] - switch id { - case "fmt ": - fmtChunk = chunk - case "data": - dataChunk = chunk - } - pos += size - if size%2 == 1 { - pos++ - } - } - if len(fmtChunk) < 16 || len(dataChunk) == 0 { - return audioBuffer{}, errors.New("WAV is missing fmt or data chunk") - } - audioFormat := binary.LittleEndian.Uint16(fmtChunk[0:2]) - channels := int(binary.LittleEndian.Uint16(fmtChunk[2:4])) - sampleRate := int(binary.LittleEndian.Uint32(fmtChunk[4:8])) - blockAlign := int(binary.LittleEndian.Uint16(fmtChunk[12:14])) - bits := int(binary.LittleEndian.Uint16(fmtChunk[14:16])) - if channels < 1 || channels > 8 || sampleRate < 1 || blockAlign < channels { - return audioBuffer{}, errors.New("unsupported WAV stream parameters") - } - if len(dataChunk) > maxDecodedBytes { - return audioBuffer{}, errors.New("audio data is too large to decode safely") - } - bytesPerSample := bits / 8 - if bytesPerSample < 1 || blockAlign < channels*bytesPerSample { - return audioBuffer{}, errors.New("unsupported WAV bit depth") - } - frames := len(dataChunk) / blockAlign - samples := make([]float64, frames*channels) - for frame := 0; frame < frames; frame++ { - base := frame * blockAlign - for ch := 0; ch < channels; ch++ { - off := base + ch*bytesPerSample - samples[frame*channels+ch] = decodeWAVSample(dataChunk[off:], audioFormat, bits) - } - } - return audioBuffer{SampleRate: sampleRate, Channels: channels, Samples: samples}, nil -} - -func decodeWAVSample(b []byte, audioFormat uint16, bits int) float64 { - switch audioFormat { - case 1: - switch bits { - case 8: - return (float64(b[0]) - 128) / 128 - case 16: - return float64(int16(binary.LittleEndian.Uint16(b[:2]))) / 32768 - case 24: - v := int32(b[0]) | int32(b[1])<<8 | int32(b[2])<<16 - if v&0x800000 != 0 { - v |= ^0xffffff - } - return float64(v) / 8388608 - case 32: - return float64(int32(binary.LittleEndian.Uint32(b[:4]))) / 2147483648 - } - case 3: - switch bits { - case 32: - return float64(math.Float32frombits(binary.LittleEndian.Uint32(b[:4]))) - case 64: - return math.Float64frombits(binary.LittleEndian.Uint64(b[:8])) - } - } - return 0 -} - -func decodeMP3(data []byte) (audioBuffer, error) { - decoder, err := mp3.NewDecoder(bytes.NewReader(data)) - if err != nil { - return audioBuffer{}, err - } - limited := io.LimitReader(decoder, maxDecodedBytes+1) - pcm, err := io.ReadAll(limited) - if err != nil { - return audioBuffer{}, err - } - if len(pcm) > maxDecodedBytes { - return audioBuffer{}, errors.New("decoded MP3 is too large to process safely") - } - if len(pcm) < 2 { - return audioBuffer{}, errors.New("MP3 decoded no samples") - } - sampleCount := len(pcm) / 2 - channels := 2 - if sampleCount%2 != 0 { - channels = 1 - } - samples := make([]float64, sampleCount) - for i := 0; i < sampleCount; i++ { - samples[i] = float64(int16(binary.LittleEndian.Uint16(pcm[i*2:i*2+2]))) / 32768 - } - return audioBuffer{SampleRate: decoder.SampleRate(), Channels: channels, Samples: samples}, nil -} - -func encodeWAV(audio audioBuffer) ([]byte, error) { - if audio.SampleRate <= 0 || audio.Channels <= 0 { - return nil, errors.New("invalid audio parameters") - } - var buf bytes.Buffer - dataSize := int64(len(audio.Samples)) * 2 - if dataSize > int64(^uint32(0))-44 { - return nil, errors.New("audio is too large for a WAV file") - } - buf.WriteString("RIFF") - _ = binary.Write(&buf, binary.LittleEndian, uint32(36+dataSize)) - buf.WriteString("WAVE") - buf.WriteString("fmt ") - _ = binary.Write(&buf, binary.LittleEndian, uint32(16)) - _ = binary.Write(&buf, binary.LittleEndian, uint16(1)) - _ = binary.Write(&buf, binary.LittleEndian, uint16(audio.Channels)) - _ = binary.Write(&buf, binary.LittleEndian, uint32(audio.SampleRate)) - byteRate := audio.SampleRate * audio.Channels * 2 - _ = binary.Write(&buf, binary.LittleEndian, uint32(byteRate)) - _ = binary.Write(&buf, binary.LittleEndian, uint16(audio.Channels*2)) - _ = binary.Write(&buf, binary.LittleEndian, uint16(16)) - buf.WriteString("data") - _ = binary.Write(&buf, binary.LittleEndian, uint32(dataSize)) - for _, sample := range audio.Samples { - sample = clampFloat(sample, -1, 1) - _ = binary.Write(&buf, binary.LittleEndian, int16(math.Round(sample*32767))) - } - return buf.Bytes(), nil -} - -func toMono(audio audioBuffer) []float64 { - if audio.Channels == 1 { - out := make([]float64, len(audio.Samples)) - copy(out, audio.Samples) - return out - } - frames := len(audio.Samples) / audio.Channels - out := make([]float64, frames) - for frame := 0; frame < frames; frame++ { - var sum float64 - for ch := 0; ch < audio.Channels; ch++ { - sum += audio.Samples[frame*audio.Channels+ch] - } - out[frame] = sum / float64(audio.Channels) - } - return out -} - -func isWAV(data []byte) bool { - return len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WAVE" -} - -func isMP3(data []byte) bool { - return len(data) >= 3 && (string(data[:3]) == "ID3" || (data[0] == 0xff && data[1]&0xe0 == 0xe0)) -} - -func normalizePeak(samples []float64, target float64) { - var peak float64 - for _, s := range samples { - if a := math.Abs(s); a > peak { - peak = a - } - } - if peak <= 0 { - return - } - scale := target / peak - for i := range samples { - samples[i] *= scale - } -} - -func limitPeak(samples []float64, target float64) { - var peak float64 - for _, s := range samples { - if a := math.Abs(s); a > peak { - peak = a - } - } - if peak <= target || peak <= 0 { - return - } - scale := target / peak - for i := range samples { - samples[i] *= scale - } -} - -func fade(samples []float64, fadeLen int) { - if fadeLen <= 0 || len(samples) == 0 { - return - } - if fadeLen*2 > len(samples) { - fadeLen = len(samples) / 2 - } - for i := 0; i < fadeLen; i++ { - gain := float64(i) / float64(fadeLen) - samples[i] *= gain - samples[len(samples)-1-i] *= gain - } -} - -func hamming(n int) []float64 { - w := make([]float64, n) - if n <= 1 { - if n == 1 { - w[0] = 1 - } - return w - } - for i := range w { - w[i] = 0.54 - 0.46*math.Cos(twoPi*float64(i)/float64(n-1)) - } - return w -} - -func fft(a []complex128) { - n := len(a) - j := 0 - for i := 1; i < n; i++ { - bit := n >> 1 - for ; j&bit != 0; bit >>= 1 { - j &^= bit - } - j |= bit - if i < j { - a[i], a[j] = a[j], a[i] - } - } - for length := 2; length <= n; length <<= 1 { - angle := -twoPi / float64(length) - wlen := complex(math.Cos(angle), math.Sin(angle)) - for i := 0; i < n; i += length { - w := complex(1, 0) - for k := 0; k < length/2; k++ { - u := a[i+k] - v := a[i+k+length/2] * w - a[i+k] = u + v - a[i+k+length/2] = u - v - w *= wlen - } - } - } -} - -func cmplxAbs(c complex128) float64 { - return math.Hypot(real(c), imag(c)) -} - -func palette(v float64) color.RGBA { - v = clampFloat(v, 0, 1) - colors := []color.RGBA{ - {R: 68, G: 1, B: 84, A: 255}, - {R: 72, G: 40, B: 120, A: 255}, - {R: 62, G: 73, B: 137, A: 255}, - {R: 49, G: 104, B: 142, A: 255}, - {R: 38, G: 130, B: 142, A: 255}, - {R: 31, G: 158, B: 137, A: 255}, - {R: 53, G: 183, B: 121, A: 255}, - {R: 110, G: 206, B: 88, A: 255}, - {R: 181, G: 222, B: 43, A: 255}, - {R: 253, G: 231, B: 37, A: 255}, - } - scaled := v * float64(len(colors)-1) - idx := int(math.Floor(scaled)) - if idx >= len(colors)-1 { - return colors[len(colors)-1] - } - t := scaled - float64(idx) - a, b := colors[idx], colors[idx+1] - return color.RGBA{ - R: uint8(float64(a.R) + (float64(b.R)-float64(a.R))*t), - G: uint8(float64(a.G) + (float64(b.G)-float64(a.G))*t), - B: uint8(float64(a.B) + (float64(b.B)-float64(a.B))*t), - A: 255, - } -} - -func nearestPowerOfTwo(n int) int { - if n <= 1 { - return 1 - } - p := 1 - for p < n { - p <<= 1 - } - prev := p >> 1 - if p-n < n-prev { - return p - } - return prev -} - -func clampInt(v, min, max int) int { - if v < min { - return min - } - if v > max { - return max - } - return v -} - -func clampFloat(v, min, max float64) float64 { - if v < min { - return min - } - if v > max { - return max - } - return v -} diff --git a/internal/leviathan/spectrogram/spectrogram_test.go b/internal/leviathan/spectrogram/spectrogram_test.go deleted file mode 100644 index 0507c64..0000000 --- a/internal/leviathan/spectrogram/spectrogram_test.go +++ /dev/null @@ -1,177 +0,0 @@ -package spectrogram - -import ( - "bytes" - "encoding/binary" - "hash/crc32" - "image" - "image/color" - "image/png" - "testing" -) - -func TestImageToWAVAndRenderSpectrogram(t *testing.T) { - img := testPNG(t) - wav, summary, err := ImageToWAV(img, EncodeOptions{ - DurationSec: 0.25, - SampleRate: 8000, - MinHz: 300, - MaxHz: 3000, - Bins: 32, - Columns: 32, - Invert: true, - }) - if err != nil { - t.Fatalf("ImageToWAV failed: %v", err) - } - if !isWAV(wav) { - t.Fatalf("expected WAV header") - } - if summary["wavBytes"].(int) != len(wav) { - t.Fatalf("summary wavBytes mismatch") - } - - rendered, viewSummary, err := AudioSpectrogramPNG(wav, "test.wav", ViewOptions{ - FFTSize: 512, - Height: 96, - Aspect: 2, - MaxWidth: 128, - }) - if err != nil { - t.Fatalf("AudioSpectrogramPNG failed: %v", err) - } - if _, err := png.Decode(bytes.NewReader(rendered)); err != nil { - t.Fatalf("rendered spectrogram is not a PNG: %v", err) - } - if viewSummary["sampleRate"].(int) != 8000 { - t.Fatalf("unexpected sample rate: %v", viewSummary["sampleRate"]) - } - if viewSummary["height"].(int) != 192 { - t.Fatalf("expected aspect-adjusted height 192, got %v", viewSummary["height"]) - } -} - -func TestPreserveAspectRatioAdjustsFrequencyRange(t *testing.T) { - img := testPNGSize(t, 32, 16) - _, summary, err := ImageToWAV(img, EncodeOptions{ - DurationSec: 0.1, - SampleRate: 8000, - MinHz: 200, - MaxHz: 3000, - Bins: 16, - Columns: 16, - PreserveAR: true, - Invert: true, - }) - if err != nil { - t.Fatalf("ImageToWAV failed: %v", err) - } - if summary["preserveAR"] != true { - t.Fatalf("expected preserveAR summary") - } - if got := summary["maxHz"].(float64); got != 1600 { - t.Fatalf("expected landscape image to halve frequency range to 1600Hz, got %v", got) - } -} - -func TestImageToWAVRejectsOversizedImageDimensionsBeforeDecode(t *testing.T) { - _, _, err := ImageToWAV(hugePNGHeader(t, 100000, 100000), EncodeOptions{}) - if err == nil { - t.Fatal("expected oversized image dimensions to be rejected") - } - if !bytes.Contains([]byte(err.Error()), []byte("image dimensions are too large")) { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestInsertImageIntoAudioExtendsCarrier(t *testing.T) { - img := testPNG(t) - carrier, _, err := ImageToWAV(img, EncodeOptions{ - DurationSec: 0.1, - SampleRate: 8000, - MinHz: 300, - MaxHz: 2500, - Bins: 16, - Columns: 16, - Invert: true, - }) - if err != nil { - t.Fatalf("carrier generation failed: %v", err) - } - out, summary, err := InsertImageIntoAudio(img, carrier, "carrier.wav", InsertOptions{ - EncodeOptions: EncodeOptions{ - DurationSec: 0.2, - MinHz: 300, - MaxHz: 2500, - Bins: 16, - Columns: 16, - Invert: true, - }, - StartSec: 0.05, - MixLevel: 0.25, - }) - if err != nil { - t.Fatalf("InsertImageIntoAudio failed: %v", err) - } - if !isWAV(out) { - t.Fatalf("expected WAV output") - } - if summary["mode"] != "insert-into-audio" { - t.Fatalf("unexpected mode: %v", summary["mode"]) - } - if len(out) <= len(carrier) { - t.Fatalf("expected carrier to be extended") - } -} - -func testPNG(t *testing.T) []byte { - t.Helper() - return testPNGSize(t, 32, 32) -} - -func testPNGSize(t *testing.T, width, height int) []byte { - t.Helper() - img := image.NewRGBA(image.Rect(0, 0, width, height)) - for y := 0; y < height; y++ { - for x := 0; x < width; x++ { - img.Set(x, y, color.RGBA{R: 255, G: 255, B: 255, A: 255}) - } - } - limit := width - if height < limit { - limit = height - } - for i := 0; i < limit; i++ { - img.Set(i, i, color.RGBA{A: 255}) - img.Set(width-1-i, i, color.RGBA{A: 255}) - } - var buf bytes.Buffer - if err := png.Encode(&buf, img); err != nil { - t.Fatalf("encode png: %v", err) - } - return buf.Bytes() -} - -func hugePNGHeader(t *testing.T, width, height uint32) []byte { - t.Helper() - var buf bytes.Buffer - buf.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) - ihdr := make([]byte, 13) - binary.BigEndian.PutUint32(ihdr[0:4], width) - binary.BigEndian.PutUint32(ihdr[4:8], height) - ihdr[8] = 8 - ihdr[9] = 2 - writePNGChunk(&buf, "IHDR", ihdr) - writePNGChunk(&buf, "IEND", nil) - return buf.Bytes() -} - -func writePNGChunk(buf *bytes.Buffer, kind string, data []byte) { - _ = binary.Write(buf, binary.BigEndian, uint32(len(data))) - buf.WriteString(kind) - buf.Write(data) - crc := crc32.NewIEEE() - crc.Write([]byte(kind)) - crc.Write(data) - _ = binary.Write(buf, binary.BigEndian, crc.Sum32()) -} diff --git a/internal/leviathan/squashfs/squashfs.go b/internal/leviathan/squashfs/squashfs.go deleted file mode 100644 index 75a010e..0000000 --- a/internal/leviathan/squashfs/squashfs.go +++ /dev/null @@ -1,782 +0,0 @@ -// Package squashfs implements a read-only SquashFS 4.0 extractor. -// -// Scope is deliberately pragmatic: gzip, xz and zstd-compressed images, -// basic and extended file and directory inodes, symlink inodes. Other -// compressors (lzma/lzo/lz4) return a structured error so the UI can show -// what's missing. -// -// The module never trusts its input: every table offset and length is -// bounded against the image size and a hard cap on uncompressed metadata -// block size. A malformed or truncated image returns an error rather -// than causing a wild read. -package squashfs - -import ( - "bytes" - "compress/zlib" - "encoding/binary" - "errors" - "fmt" - "io" - "path" - "sort" - - "github.com/andydixon/link/internal/leviathan/document" - "github.com/klauspost/compress/zstd" - "github.com/ulikunitz/xz" -) - -const ( - magicLE uint32 = 0x73717368 // "hsqs" - metadataBlockSize = 8192 - dataBlockMax = 1 << 20 // 1 MiB guard for a single data block - maxEntries = 1 << 20 // total files + dirs we're willing to walk -) - -// Compression IDs from the spec. -const ( - compGZIP = 1 - compLZMA = 2 - compLZO = 3 - compXZ = 4 - compLZ4 = 5 - compZSTD = 6 -) - -// Inode type codes. -const ( - itBasicDir = 1 - itBasicFile = 2 - itBasicSymlink = 3 - itBasicBlockDev = 4 - itBasicCharDev = 5 - itBasicFifo = 6 - itBasicSocket = 7 - itExtDir = 8 - itExtFile = 9 - itExtSymlink = 10 - itExtBlockDev = 11 - itExtCharDev = 12 - itExtFifo = 13 - itExtSocket = 14 -) - -// Superblock is the parsed 96-byte header. -type Superblock struct { - Magic uint32 - InodeCount uint32 - ModTime uint32 - BlockSize uint32 - FragCount uint32 - CompressionID uint16 - BlockLog uint16 - Flags uint16 - IDCount uint16 - VersionMajor uint16 - VersionMinor uint16 - RootInodeRef uint64 - BytesUsed uint64 - IDTableStart uint64 - XattrTableStart uint64 - InodeTable uint64 - DirTable uint64 - FragTable uint64 - ExportTable uint64 -} - -// Entry describes a filesystem entry discovered by the walker. -type Entry struct { - Path string - Type string // "file", "dir", "symlink", "special" - Size int64 - Mode uint16 - UID uint16 // id_idx (not resolved) - GID uint16 - Mtime uint32 - InodeNumber uint32 - InodeRef uint64 // 64-bit metadata reference (for targeted extraction) - LinkTarget string // for symlinks - // For file extraction; zero for non-files. - blocksStart uint64 - fileSize int64 - fragIndex uint32 - fragOffset uint32 - blockSizes []uint32 // raw (top bit=uncompressed) -} - -// FS is the opened image handle. -type FS struct { - d *document.Document - sb Superblock -} - -// Open validates the superblock and returns an FS for further queries. -func Open(d *document.Document) (*FS, error) { - head, err := d.Range(0, 96) - if err != nil { - return nil, fmt.Errorf("read superblock: %w", err) - } - sb, err := parseSuperblock(head) - if err != nil { - return nil, err - } - if sb.VersionMajor != 4 { - return nil, fmt.Errorf("unsupported squashfs version %d.%d (only 4.x)", sb.VersionMajor, sb.VersionMinor) - } - if sb.BytesUsed > uint64(d.Size()) { - return nil, fmt.Errorf("superblock bytes_used=%d exceeds document size=%d", sb.BytesUsed, d.Size()) - } - return &FS{d: d, sb: sb}, nil -} - -// Superblock returns the parsed header (copy). -func (f *FS) Superblock() Superblock { return f.sb } - -// CompressionName returns the human name of the image compressor. -func (f *FS) CompressionName() string { - return compressionName(f.sb.CompressionID) -} - -func compressionName(id uint16) string { - switch id { - case compGZIP: - return "gzip" - case compLZMA: - return "lzma" - case compLZO: - return "lzo" - case compXZ: - return "xz" - case compLZ4: - return "lz4" - case compZSTD: - return "zstd" - } - return fmt.Sprintf("unknown(%d)", id) -} - -func parseSuperblock(b []byte) (Superblock, error) { - var s Superblock - if len(b) < 96 { - return s, errors.New("superblock: short read") - } - s.Magic = binary.LittleEndian.Uint32(b[0:4]) - if s.Magic != magicLE { - return s, fmt.Errorf("bad magic 0x%08x (want 0x%08x)", s.Magic, magicLE) - } - s.InodeCount = binary.LittleEndian.Uint32(b[4:8]) - s.ModTime = binary.LittleEndian.Uint32(b[8:12]) - s.BlockSize = binary.LittleEndian.Uint32(b[12:16]) - s.FragCount = binary.LittleEndian.Uint32(b[16:20]) - s.CompressionID = binary.LittleEndian.Uint16(b[20:22]) - s.BlockLog = binary.LittleEndian.Uint16(b[22:24]) - s.Flags = binary.LittleEndian.Uint16(b[24:26]) - s.IDCount = binary.LittleEndian.Uint16(b[26:28]) - s.VersionMajor = binary.LittleEndian.Uint16(b[28:30]) - s.VersionMinor = binary.LittleEndian.Uint16(b[30:32]) - s.RootInodeRef = binary.LittleEndian.Uint64(b[32:40]) - s.BytesUsed = binary.LittleEndian.Uint64(b[40:48]) - s.IDTableStart = binary.LittleEndian.Uint64(b[48:56]) - s.XattrTableStart = binary.LittleEndian.Uint64(b[56:64]) - s.InodeTable = binary.LittleEndian.Uint64(b[64:72]) - s.DirTable = binary.LittleEndian.Uint64(b[72:80]) - s.FragTable = binary.LittleEndian.Uint64(b[80:88]) - s.ExportTable = binary.LittleEndian.Uint64(b[88:96]) - return s, nil -} - -// ---------- metadata block reader ---------- - -func (f *FS) decompress(buf []byte) ([]byte, error) { - return f.decompressWithLimit(buf, metadataBlockSize+16) -} - -func (f *FS) decompressWithLimit(buf []byte, limit int64) ([]byte, error) { - var r io.ReadCloser - switch f.sb.CompressionID { - case compGZIP: - zr, err := zlib.NewReader(bytes.NewReader(buf)) - if err != nil { - return nil, fmt.Errorf("zlib init: %w", err) - } - r = zr - case compXZ: - xr, err := xz.NewReader(bytes.NewReader(buf)) - if err != nil { - return nil, fmt.Errorf("xz init: %w", err) - } - r = io.NopCloser(xr) - case compZSTD: - zr, err := zstd.NewReader(bytes.NewReader(buf)) - if err != nil { - return nil, fmt.Errorf("zstd init: %w", err) - } - r = zr.IOReadCloser() - default: - return nil, fmt.Errorf("compressor %q not implemented in this build", compressionName(f.sb.CompressionID)) - } - defer r.Close() - return readLimited(r, limit) -} - -// readMetadataBlock reads one metadata block starting at absolute offset -// `off` and returns (data, bytesConsumed, error). bytesConsumed includes -// the 2-byte header. -func (f *FS) readMetadataBlock(off int64) ([]byte, int64, error) { - if off < 0 || off+2 > f.d.Size() { - return nil, 0, fmt.Errorf("metadata block offset %d out of range", off) - } - hdrBuf, err := f.d.Range(off, 2) - if err != nil { - return nil, 0, err - } - hdr := binary.LittleEndian.Uint16(hdrBuf) - compressed := hdr&0x8000 == 0 - size := int64(hdr & 0x7fff) - if size == 0 || size > metadataBlockSize { - return nil, 0, fmt.Errorf("metadata block size %d invalid", size) - } - body, err := f.d.Range(off+2, size) - if err != nil { - return nil, 0, err - } - if !compressed { - return append([]byte(nil), body...), size + 2, nil - } - out, err := f.decompress(body) - if err != nil { - return nil, 0, err - } - return out, size + 2, nil -} - -// metaCursor streams uncompressed data out of a chain of metadata blocks. -// table[i] is the absolute file offset of the i-th metadata block, plus -// a running byte cursor within the uncompressed stream. -type metaCursor struct { - f *FS - base int64 // absolute file offset of first metadata block - blkAbs int64 // absolute file offset of *current* metadata block - inBlk int // index within current uncompressed block - buffer []byte // current decompressed block -} - -func (f *FS) metaCursorAt(base int64, blockOffset uint32, inBlk uint16) (*metaCursor, error) { - mc := &metaCursor{f: f, base: base, blkAbs: base + int64(blockOffset)} - if err := mc.load(); err != nil { - return nil, err - } - if int(inBlk) > len(mc.buffer) { - return nil, fmt.Errorf("inode intra-block offset %d > block length %d", inBlk, len(mc.buffer)) - } - mc.inBlk = int(inBlk) - return mc, nil -} - -func (mc *metaCursor) load() error { - data, _, err := mc.f.readMetadataBlock(mc.blkAbs) - if err != nil { - return err - } - mc.buffer = data - mc.inBlk = 0 - return nil -} - -// advance one metadata block. The on-disk size is the consumed length of -// the current compressed block, which we must read from the header again. -func (mc *metaCursor) nextBlock() error { - // Re-read just the 2-byte header of current block to find its length. - hdrBuf, err := mc.f.d.Range(mc.blkAbs, 2) - if err != nil { - return err - } - hdr := binary.LittleEndian.Uint16(hdrBuf) - size := int64(hdr & 0x7fff) - mc.blkAbs += 2 + size - return mc.load() -} - -// readN returns n uncompressed bytes, crossing metadata-block boundaries -// as required. -func (mc *metaCursor) readN(n int) ([]byte, error) { - if n <= 0 { - return nil, nil - } - out := make([]byte, 0, n) - for n > 0 { - if mc.inBlk >= len(mc.buffer) { - if err := mc.nextBlock(); err != nil { - return nil, err - } - } - take := len(mc.buffer) - mc.inBlk - if take > n { - take = n - } - out = append(out, mc.buffer[mc.inBlk:mc.inBlk+take]...) - mc.inBlk += take - n -= take - } - return out, nil -} - -// ---------- inode ---------- - -type inodeHeader struct { - Type uint16 - Mode uint16 - UID uint16 - GID uint16 - Mtime uint32 - Num uint32 -} - -func readInodeHeader(b []byte) (inodeHeader, error) { - var h inodeHeader - if len(b) < 16 { - return h, errors.New("inode header short") - } - h.Type = binary.LittleEndian.Uint16(b[0:2]) - h.Mode = binary.LittleEndian.Uint16(b[2:4]) - h.UID = binary.LittleEndian.Uint16(b[4:6]) - h.GID = binary.LittleEndian.Uint16(b[6:8]) - h.Mtime = binary.LittleEndian.Uint32(b[8:12]) - h.Num = binary.LittleEndian.Uint32(b[12:16]) - return h, nil -} - -// readInodeAt reads the inode described by a 64-bit ref and returns the -// filled Entry plus the metadata location of the directory contents (if -// it's a directory). -type inodeContent struct { - entry Entry - dirStart uint32 // offset into DirTable (bytes from sb.DirTable) - dirBlockOff uint16 // intra-metadata-block offset for first dir header - dirSize uint32 // directory listing size, real value (file_size - 3) - parentInode uint32 -} - -func (f *FS) readInode(ref uint64) (*inodeContent, error) { - blkOff := uint32(ref >> 16) - inBlk := uint16(ref & 0xffff) - mc, err := f.metaCursorAt(int64(f.sb.InodeTable), blkOff, inBlk) - if err != nil { - return nil, err - } - hdrBytes, err := mc.readN(16) - if err != nil { - return nil, err - } - h, err := readInodeHeader(hdrBytes) - if err != nil { - return nil, err - } - ic := &inodeContent{ - entry: Entry{ - Mode: h.Mode, - UID: h.UID, - GID: h.GID, - Mtime: h.Mtime, - InodeNumber: h.Num, - InodeRef: ref, - }, - } - switch h.Type { - case itBasicDir: - body, err := mc.readN(16) - if err != nil { - return nil, err - } - ic.entry.Type = "dir" - ic.dirStart = binary.LittleEndian.Uint32(body[0:4]) - // nlink := binary.LittleEndian.Uint32(body[4:8]) - fileSize := binary.LittleEndian.Uint16(body[8:10]) - ic.dirBlockOff = binary.LittleEndian.Uint16(body[10:12]) - ic.parentInode = binary.LittleEndian.Uint32(body[12:16]) - // file_size in dir inodes is real dir bytes + 3. Zero-size (3) → empty dir. - if fileSize >= 3 { - ic.dirSize = uint32(fileSize) - 3 - } - case itExtDir: - body, err := mc.readN(24) - if err != nil { - return nil, err - } - ic.entry.Type = "dir" - // nlink := binary.LittleEndian.Uint32(body[0:4]) - fileSize := binary.LittleEndian.Uint32(body[4:8]) - ic.dirStart = binary.LittleEndian.Uint32(body[8:12]) - ic.parentInode = binary.LittleEndian.Uint32(body[12:16]) - indexCount := binary.LittleEndian.Uint16(body[16:18]) - ic.dirBlockOff = binary.LittleEndian.Uint16(body[18:20]) - // xattr := binary.LittleEndian.Uint32(body[20:24]) - if fileSize >= 3 { - ic.dirSize = fileSize - 3 - } - // Skip index entries: indexCount × (4 + 4 + 4 + name[...variable]). - for i := uint16(0); i < indexCount; i++ { - hdr, err := mc.readN(12) - if err != nil { - return nil, err - } - nameSize := binary.LittleEndian.Uint32(hdr[8:12]) - if nameSize > 256 { - return nil, fmt.Errorf("dir index name_size too large: %d", nameSize) - } - if _, err := mc.readN(int(nameSize) + 1); err != nil { - return nil, err - } - } - case itBasicFile: - body, err := mc.readN(16) - if err != nil { - return nil, err - } - ic.entry.Type = "file" - ic.entry.blocksStart = uint64(binary.LittleEndian.Uint32(body[0:4])) - ic.entry.fragIndex = binary.LittleEndian.Uint32(body[4:8]) - ic.entry.fragOffset = binary.LittleEndian.Uint32(body[8:12]) - fileSize := binary.LittleEndian.Uint32(body[12:16]) - ic.entry.fileSize = int64(fileSize) - ic.entry.Size = int64(fileSize) - blocks, err := readBlockSizes(mc, fileSize, ic.entry.fragIndex, f.sb.BlockSize) - if err != nil { - return nil, err - } - ic.entry.blockSizes = blocks - case itExtFile: - body, err := mc.readN(40) - if err != nil { - return nil, err - } - ic.entry.Type = "file" - ic.entry.blocksStart = binary.LittleEndian.Uint64(body[0:8]) - fileSize := binary.LittleEndian.Uint64(body[8:16]) - // sparse := binary.LittleEndian.Uint64(body[16:24]) - // nlink := binary.LittleEndian.Uint32(body[24:28]) - ic.entry.fragIndex = binary.LittleEndian.Uint32(body[28:32]) - ic.entry.fragOffset = binary.LittleEndian.Uint32(body[32:36]) - // xattr := binary.LittleEndian.Uint32(body[36:40]) - ic.entry.fileSize = int64(fileSize) - ic.entry.Size = int64(fileSize) - if fileSize > uint64(1)<<48 { - return nil, fmt.Errorf("unreasonable file size %d", fileSize) - } - blocks, err := readBlockSizes(mc, uint32(fileSize), ic.entry.fragIndex, f.sb.BlockSize) - if err != nil { - return nil, err - } - ic.entry.blockSizes = blocks - case itBasicSymlink, itExtSymlink: - body, err := mc.readN(8) - if err != nil { - return nil, err - } - // nlink := binary.LittleEndian.Uint32(body[0:4]) - target := binary.LittleEndian.Uint32(body[4:8]) - if target > 4096 { - return nil, fmt.Errorf("symlink target length %d too large", target) - } - link, err := mc.readN(int(target)) - if err != nil { - return nil, err - } - ic.entry.Type = "symlink" - ic.entry.LinkTarget = string(link) - ic.entry.Size = int64(target) - if h.Type == itExtSymlink { - // trailing xattr u32 - if _, err := mc.readN(4); err != nil { - return nil, err - } - } - default: - ic.entry.Type = "special" - } - return ic, nil -} - -func readBlockSizes(mc *metaCursor, fileSize uint32, fragIdx uint32, blockSize uint32) ([]uint32, error) { - // If there is a fragment (fragIdx != 0xFFFFFFFF), the tail of the file - // (< blockSize) is stored there — do not count it as a full block. - full := fileSize / blockSize - if fragIdx == 0xFFFFFFFF && fileSize%blockSize != 0 { - full++ // final partial block stored on disk as a block - } - if full > (1 << 20) { - return nil, fmt.Errorf("unreasonable block count %d", full) - } - out := make([]uint32, full) - for i := range out { - b, err := mc.readN(4) - if err != nil { - return nil, err - } - out[i] = binary.LittleEndian.Uint32(b) - } - return out, nil -} - -// ---------- directory walking ---------- - -// readDirEntries returns all (name, inode-ref) tuples in a directory. -func (f *FS) readDirEntries(parent *inodeContent) ([]dirEntry, error) { - if parent.dirSize == 0 { - return nil, nil - } - // dirStart is an offset into the dir table (relative to sb.DirTable). - // dirBlockOff is the intra-block start. - mc, err := f.metaCursorAt(int64(f.sb.DirTable), parent.dirStart, parent.dirBlockOff) - if err != nil { - return nil, err - } - remaining := int(parent.dirSize) - var out []dirEntry - for remaining > 0 { - hdr, err := mc.readN(12) - if err != nil { - return nil, err - } - remaining -= 12 - count := binary.LittleEndian.Uint32(hdr[0:4]) + 1 // stored as count-1 - startBlock := binary.LittleEndian.Uint32(hdr[4:8]) - baseInode := binary.LittleEndian.Uint32(hdr[8:12]) - for i := uint32(0); i < count; i++ { - entHdr, err := mc.readN(8) - if err != nil { - return nil, err - } - remaining -= 8 - entOff := binary.LittleEndian.Uint16(entHdr[0:2]) - iDelta := int16(binary.LittleEndian.Uint16(entHdr[2:4])) - entType := binary.LittleEndian.Uint16(entHdr[4:6]) - nameSize := binary.LittleEndian.Uint16(entHdr[6:8]) - if nameSize+1 > 256 { - return nil, fmt.Errorf("directory entry name_size %d too large", nameSize) - } - name, err := mc.readN(int(nameSize) + 1) // stored as name_size-1 - if err != nil { - return nil, err - } - remaining -= int(nameSize) + 1 - ref := (uint64(startBlock) << 16) | uint64(entOff) - out = append(out, dirEntry{ - name: string(name), - ref: ref, - inodeNum: uint32(int64(baseInode) + int64(iDelta)), - entType: entType, - }) - if len(out) > maxEntries { - return nil, fmt.Errorf("directory entry cap %d exceeded", maxEntries) - } - } - } - return out, nil -} - -type dirEntry struct { - name string - ref uint64 - inodeNum uint32 - entType uint16 -} - -// Walk walks the entire filesystem from the root, emitting every Entry. -// The returned list is sorted by path. Walk is bounded by maxEntries -// and by recursion depth. -func (f *FS) Walk() ([]Entry, error) { - root, err := f.readInode(f.sb.RootInodeRef) - if err != nil { - return nil, fmt.Errorf("root inode: %w", err) - } - if root.entry.Type != "dir" { - return nil, fmt.Errorf("root is not a directory (type %q)", root.entry.Type) - } - out := []Entry{} - root.entry.Path = "/" - out = append(out, root.entry) - if err := f.walk(root, "/", &out, 0); err != nil { - return nil, err - } - sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) - return out, nil -} - -func (f *FS) walk(node *inodeContent, prefix string, out *[]Entry, depth int) error { - if depth > 64 { - return errors.New("directory depth exceeds 64 — possible cycle") - } - entries, err := f.readDirEntries(node) - if err != nil { - return err - } - for _, de := range entries { - child, err := f.readInode(de.ref) - if err != nil { - return fmt.Errorf("%s/%s: %w", prefix, de.name, err) - } - full := path.Join(prefix, de.name) - child.entry.Path = full - *out = append(*out, child.entry) - if len(*out) > maxEntries { - return fmt.Errorf("entry cap %d exceeded", maxEntries) - } - if child.entry.Type == "dir" { - if err := f.walk(child, full, out, depth+1); err != nil { - return err - } - } - } - return nil -} - -// ---------- file extraction ---------- - -// fragmentEntry describes one entry in the fragment table. -type fragmentEntry struct { - Start uint64 - Size uint32 // top bit = uncompressed - Compressed bool -} - -// fragmentTable reads the entire fragment lookup table and returns the -// flat list of entries by index. Result is cached on first call. -func (f *FS) fragmentTable() ([]fragmentEntry, error) { - if f.sb.FragCount == 0 { - return nil, nil - } - // The fragment_table_start points at a u64[] of metadata-block offsets, - // one per 512 fragments (ceil). - indirCount := (int(f.sb.FragCount) + 511) / 512 - indirBytes, err := f.d.Range(int64(f.sb.FragTable), int64(indirCount*8)) - if err != nil { - return nil, fmt.Errorf("frag indirection: %w", err) - } - out := make([]fragmentEntry, 0, f.sb.FragCount) - remaining := int(f.sb.FragCount) - for i := 0; i < indirCount && remaining > 0; i++ { - absOff := int64(binary.LittleEndian.Uint64(indirBytes[i*8 : i*8+8])) - block, _, err := f.readMetadataBlock(absOff) - if err != nil { - return nil, err - } - // Each fragment entry is 16 bytes: u64 start, u32 size, u32 unused. - take := remaining - if take > 512 { - take = 512 - } - if len(block) < take*16 { - return nil, fmt.Errorf("fragment block short: have %d need %d", len(block), take*16) - } - for j := 0; j < take; j++ { - off := j * 16 - sz := binary.LittleEndian.Uint32(block[off+8 : off+12]) - out = append(out, fragmentEntry{ - Start: binary.LittleEndian.Uint64(block[off : off+8]), - Size: sz & 0x00ffffff, // documented size field occupies low 24 bits - Compressed: sz&0x01000000 == 0, - }) - } - remaining -= take - } - return out, nil -} - -// ExtractFile returns the full decompressed contents of the file at the -// given InodeRef. Returns an error if the inode is not a file. -func (f *FS) ExtractFile(ref uint64) ([]byte, error) { - ic, err := f.readInode(ref) - if err != nil { - return nil, err - } - if ic.entry.Type != "file" { - return nil, fmt.Errorf("inode %d is not a file (%s)", ic.entry.InodeNumber, ic.entry.Type) - } - out := make([]byte, 0, ic.entry.fileSize) - cursor := int64(ic.entry.blocksStart) - for i, bs := range ic.entry.blockSizes { - size := int64(bs & 0x00ffffff) - compressed := bs&0x01000000 == 0 - if size == 0 { - // sparse hole: emit blockSize zeroes (or trailing partial) - remaining := ic.entry.fileSize - int64(len(out)) - n := int64(f.sb.BlockSize) - if remaining < n { - n = remaining - } - out = append(out, make([]byte, n)...) - continue - } - if size > dataBlockMax { - return nil, fmt.Errorf("data block %d size %d exceeds cap", i, size) - } - body, err := f.d.Range(cursor, size) - if err != nil { - return nil, fmt.Errorf("data block %d: %w", i, err) - } - cursor += size - if !compressed { - out = append(out, body...) - continue - } - dec, err := f.decompressBlock(body) - if err != nil { - return nil, fmt.Errorf("data block %d: %w", i, err) - } - out = append(out, dec...) - } - if ic.entry.fragIndex != 0xFFFFFFFF { - frags, err := f.fragmentTable() - if err != nil { - return nil, err - } - if int(ic.entry.fragIndex) >= len(frags) { - return nil, fmt.Errorf("fragment index %d out of range (%d entries)", ic.entry.fragIndex, len(frags)) - } - fe := frags[ic.entry.fragIndex] - fragBody, err := f.d.Range(int64(fe.Start), int64(fe.Size)) - if err != nil { - return nil, fmt.Errorf("fragment data: %w", err) - } - var payload []byte - if fe.Compressed { - payload, err = f.decompressBlock(fragBody) - if err != nil { - return nil, err - } - } else { - payload = fragBody - } - tailLen := int(ic.entry.fileSize) - len(out) - if int(ic.entry.fragOffset)+tailLen > len(payload) { - return nil, fmt.Errorf("fragment slice out of range: off=%d len=%d payload=%d", - ic.entry.fragOffset, tailLen, len(payload)) - } - out = append(out, payload[ic.entry.fragOffset:int(ic.entry.fragOffset)+tailLen]...) - } - if int64(len(out)) != ic.entry.fileSize { - return nil, fmt.Errorf("extraction length mismatch: got %d, want %d", len(out), ic.entry.fileSize) - } - return out, nil -} - -// decompressBlock is like decompress but bounded by dataBlockMax; used for fragment payloads. -func (f *FS) decompressBlock(buf []byte) ([]byte, error) { - return f.decompressWithLimit(buf, dataBlockMax) -} - -func readLimited(r io.Reader, limit int64) ([]byte, error) { - if limit <= 0 { - return nil, errors.New("invalid decompression limit") - } - lr := &io.LimitedReader{R: r, N: limit + 1} - out, err := io.ReadAll(lr) - if err != nil { - return nil, err - } - if int64(len(out)) > limit { - return nil, fmt.Errorf("decompressed block exceeds cap %d", limit) - } - return out, nil -} diff --git a/internal/leviathan/stego/metadata.go b/internal/leviathan/stego/metadata.go deleted file mode 100644 index 4570438..0000000 --- a/internal/leviathan/stego/metadata.go +++ /dev/null @@ -1,694 +0,0 @@ -package stego - -import ( - "bytes" - "compress/zlib" - "encoding/binary" - "errors" - "fmt" - "io" - "strings" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// MetaEntry is one piece of textual/EXIF metadata recovered from an image. -type MetaEntry struct { - Offset int64 `json:"offset"` - Length int64 `json:"length"` - Container string `json:"container"` - Kind string `json:"kind"` - Keyword string `json:"keyword"` - Value string `json:"value"` - Note string `json:"note,omitempty"` -} - -// ScanMetadata walks PNG chunks or JPEG segments (APPn / COM) and decodes -// human-readable metadata. Output is best-effort — malformed containers -// return whatever has been parsed up to the failure point. -func ScanMetadata(d *document.Document) ([]MetaEntry, error) { - size := d.Size() - if size < 8 { - return nil, errors.New("file too small") - } - head, err := d.Range(0, minInt64(16, size)) - if err != nil { - return nil, err - } - switch { - case len(head) >= 8 && string(head[:8]) == "\x89PNG\r\n\x1a\n": - return scanPNGMeta(d) - case len(head) >= 3 && head[0] == 0xff && head[1] == 0xd8 && head[2] == 0xff: - return scanJPEGMeta(d) - case len(head) >= 6 && (string(head[:6]) == "GIF87a" || string(head[:6]) == "GIF89a"): - return scanGIFMeta(d) - } - return nil, errors.New("unsupported container (PNG, JPEG and GIF handled)") -} - -func scanPNGMeta(d *document.Document) ([]MetaEntry, error) { - size := d.Size() - out := []MetaEntry{} - off := int64(8) - for off+12 <= size { - hdr, err := d.Range(off, 8) - if err != nil { - break - } - ln := int64(binary.BigEndian.Uint32(hdr[0:4])) - typ := string(hdr[4:8]) - if ln < 0 || off+12+ln > size { - break - } - if typ == "tEXt" || typ == "zTXt" || typ == "iTXt" { - data, err := d.Range(off+8, ln) - if err == nil { - entry := decodePNGTextChunk(typ, data) - entry.Offset = off - entry.Length = 12 + ln - entry.Container = "png" - out = append(out, entry) - } - } - off += 12 + ln - if typ == "IEND" { - break - } - } - return out, nil -} - -func decodePNGTextChunk(typ string, data []byte) MetaEntry { - e := MetaEntry{Kind: typ} - i := bytes.IndexByte(data, 0) - if i < 0 { - e.Note = "no keyword separator" - e.Value = safeText(data) - return e - } - e.Keyword = string(data[:i]) - rest := data[i+1:] - switch typ { - case "tEXt": - e.Value = safeText(rest) - case "zTXt": - if len(rest) < 1 { - e.Note = "truncated" - return e - } - // rest[0] = compression method (0 = zlib) - method := rest[0] - raw, err := zlibDecompress(rest[1:]) - if err != nil { - e.Note = fmt.Sprintf("zlib fail (method=%d): %v", method, err) - return e - } - e.Value = safeText(raw) - case "iTXt": - // compression flag, compression method, language tag\0, translated keyword\0, text - if len(rest) < 2 { - e.Note = "truncated" - return e - } - compressed := rest[0] == 1 - rest = rest[2:] - j := bytes.IndexByte(rest, 0) - if j < 0 { - e.Value = safeText(rest) - return e - } - lang := string(rest[:j]) - rest = rest[j+1:] - k := bytes.IndexByte(rest, 0) - if k < 0 { - e.Value = safeText(rest) - return e - } - trk := string(rest[:k]) - rest = rest[k+1:] - if compressed { - if raw, err := zlibDecompress(rest); err == nil { - rest = raw - } else { - e.Note = "zlib fail: " + err.Error() - } - } - v := safeText(rest) - if lang != "" || trk != "" { - v = fmt.Sprintf("[%s/%s] %s", lang, trk, v) - } - e.Value = v - } - return e -} - -func scanJPEGMeta(d *document.Document) ([]MetaEntry, error) { - size := d.Size() - out := []MetaEntry{} - off := int64(2) // skip SOI - for off+4 <= size { - hdr, err := d.Range(off, 2) - if err != nil || hdr[0] != 0xff { - break - } - marker := hdr[1] - // Standalone markers (no payload): SOI/EOI/RSTn - if marker == 0xd9 || marker == 0xd8 || (marker >= 0xd0 && marker <= 0xd7) { - off += 2 - continue - } - // Read segment length (big-endian, includes the length bytes themselves) - lnBytes, err := d.Range(off+2, 2) - if err != nil { - break - } - segLen := int64(binary.BigEndian.Uint16(lnBytes)) - if segLen < 2 || off+2+segLen > size { - break - } - payload, err := d.Range(off+4, segLen-2) - if err != nil { - break - } - switch { - case marker == 0xfe: // COM - out = append(out, MetaEntry{ - Offset: off, Length: 2 + segLen, - Container: "jpeg", Kind: "COM", - Value: safeText(payload), - }) - case marker == 0xe1: // APP1 — EXIF / XMP - entries := decodeJPEGApp1(payload) - for _, e := range entries { - e.Offset = off - e.Length = 2 + segLen - e.Container = "jpeg" - out = append(out, e) - } - case marker == 0xe0: // APP0 — JFIF - out = append(out, MetaEntry{ - Offset: off, Length: 2 + segLen, - Container: "jpeg", Kind: "APP0", - Keyword: sniffMarker(payload), - Value: fmt.Sprintf("%d bytes", len(payload)), - }) - case marker >= 0xe2 && marker <= 0xef: // APP2..APP15 - out = append(out, MetaEntry{ - Offset: off, Length: 2 + segLen, - Container: "jpeg", Kind: fmt.Sprintf("APP%d", marker-0xe0), - Keyword: sniffMarker(payload), - Value: fmt.Sprintf("%d bytes", len(payload)), - }) - } - off += 2 + segLen - // SOS (Start of Scan) — entropy-coded data follows, stop walking. - if marker == 0xda { - break - } - } - return out, nil -} - -func sniffMarker(p []byte) string { - i := bytes.IndexByte(p, 0) - if i < 0 || i > 16 { - if len(p) > 16 { - return string(p[:16]) - } - return "" - } - return string(p[:i]) -} - -func decodeJPEGApp1(p []byte) []MetaEntry { - out := []MetaEntry{} - if len(p) >= 6 && string(p[:6]) == "Exif\x00\x00" { - tiff := p[6:] - entries, err := decodeEXIF(tiff) - if err == nil { - out = append(out, entries...) - } else { - out = append(out, MetaEntry{Kind: "APP1/EXIF", Note: err.Error()}) - } - return out - } - if len(p) >= 28 && string(p[:28]) == "http://ns.adobe.com/xap/1.0/" { - xmp := p[29:] - out = append(out, MetaEntry{ - Kind: "APP1/XMP", Keyword: "xmp", Value: safeText(xmp), - }) - return out - } - out = append(out, MetaEntry{ - Kind: "APP1", Keyword: sniffMarker(p), Value: fmt.Sprintf("%d bytes", len(p)), - }) - return out -} - -// EXIF reader: TIFF header + IFD0 + IFD1 + Exif SubIFD + GPS SubIFD + -// Interoperability IFD. Handles ASCII/BYTE/SHORT/LONG/RATIONAL/SRATIONAL -// values, plus UNDEFINED as hex preview. Detects MakerNote presence. -func decodeEXIF(tiff []byte) ([]MetaEntry, error) { - if len(tiff) < 8 { - return nil, errors.New("short TIFF header") - } - var bo binary.ByteOrder - switch string(tiff[:2]) { - case "II": - bo = binary.LittleEndian - case "MM": - bo = binary.BigEndian - default: - return nil, errors.New("bad TIFF byte-order mark") - } - if bo.Uint16(tiff[2:4]) != 0x002a { - return nil, errors.New("bad TIFF magic") - } - ifd0 := int(bo.Uint32(tiff[4:8])) - out := []MetaEntry{} - ptrs := ifdPointers{} - nextIFD := 0 - out = append(out, readIFD(tiff, ifd0, bo, "IFD0", &ptrs, &nextIFD)...) - // IFD1 (thumbnail) if present. - if nextIFD > 0 { - out = append(out, readIFD(tiff, nextIFD, bo, "IFD1", nil, nil)...) - } - if ptrs.exif > 0 { - out = append(out, readIFD(tiff, ptrs.exif, bo, "ExifIFD", &ptrs, nil)...) - } - if ptrs.gps > 0 { - out = append(out, readIFD(tiff, ptrs.gps, bo, "GPSIFD", nil, nil)...) - } - if ptrs.interop > 0 { - out = append(out, readIFD(tiff, ptrs.interop, bo, "InteropIFD", nil, nil)...) - } - return out, nil -} - -type ifdPointers struct { - exif int - gps int - interop int -} - -func readIFD(tiff []byte, off int, bo binary.ByteOrder, name string, ptrs *ifdPointers, nextPtr *int) []MetaEntry { - out := []MetaEntry{} - if off < 0 || off+2 > len(tiff) { - return out - } - n := int(bo.Uint16(tiff[off : off+2])) - base := off + 2 - // Cap directory count to head off hostile input. - if n > 512 { - n = 512 - } - for i := 0; i < n; i++ { - p := base + i*12 - if p+12 > len(tiff) { - break - } - tag := bo.Uint16(tiff[p : p+2]) - typ := bo.Uint16(tiff[p+2 : p+4]) - count := int(bo.Uint32(tiff[p+4 : p+8])) - valRaw := tiff[p+8 : p+12] - // Sub-IFD pointers never appear as regular entries in the output. - if ptrs != nil { - switch tag { - case 0x8769: // Exif SubIFD pointer - ptrs.exif = int(bo.Uint32(valRaw)) - continue - case 0x8825: // GPS IFD pointer - ptrs.gps = int(bo.Uint32(valRaw)) - continue - case 0xa005: // Interoperability IFD pointer - ptrs.interop = int(bo.Uint32(valRaw)) - continue - } - } - e := MetaEntry{Kind: name, Keyword: exifTagName(tag, name)} - if tag == 0x927c { // MakerNote - e.Value = fmt.Sprintf("%d bytes (vendor-specific, not decoded)", count) - out = append(out, e) - continue - } - e.Value = readEXIFValue(tiff, bo, typ, count, valRaw) - out = append(out, e) - } - // "Next IFD" pointer follows the directory entries (4 bytes). - if nextPtr != nil { - tail := base + n*12 - if tail+4 <= len(tiff) { - *nextPtr = int(bo.Uint32(tiff[tail : tail+4])) - } - } - return out -} - -func readEXIFValue(tiff []byte, bo binary.ByteOrder, typ uint16, count int, valRaw []byte) string { - typeSize := map[uint16]int{1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8} - sz, ok := typeSize[typ] - if !ok { - return fmt.Sprintf("type=%d count=%d", typ, count) - } - total := sz * count - var data []byte - if total <= 4 { - data = valRaw[:total] - } else { - off := int(bo.Uint32(valRaw)) - if off < 0 || off+total > len(tiff) { - return fmt.Sprintf("type=%d count=%d (oob)", typ, count) - } - data = tiff[off : off+total] - } - switch typ { - case 2: // ASCII (null-terminated) - return safeText(bytes.TrimRight(data, "\x00")) - case 1, 7: // BYTE, UNDEFINED — show as hex preview - if len(data) > 32 { - return fmt.Sprintf("%x… (%d bytes)", data[:32], len(data)) - } - return fmt.Sprintf("%x", data) - case 3: // SHORT - parts := []string{} - for i := 0; i < count && i < 8; i++ { - parts = append(parts, fmt.Sprintf("%d", bo.Uint16(data[i*2:i*2+2]))) - } - if count > 8 { - parts = append(parts, "…") - } - return strings.Join(parts, ", ") - case 4: // LONG - parts := []string{} - for i := 0; i < count && i < 6; i++ { - parts = append(parts, fmt.Sprintf("%d", bo.Uint32(data[i*4:i*4+4]))) - } - if count > 6 { - parts = append(parts, "…") - } - return strings.Join(parts, ", ") - case 5, 10: // RATIONAL / SRATIONAL - parts := []string{} - for i := 0; i < count && i < 4; i++ { - n := bo.Uint32(data[i*8 : i*8+4]) - dn := bo.Uint32(data[i*8+4 : i*8+8]) - if dn == 0 { - parts = append(parts, fmt.Sprintf("%d/0", n)) - } else { - parts = append(parts, fmt.Sprintf("%d/%d", n, dn)) - } - } - return strings.Join(parts, ", ") - case 9: // SLONG - parts := []string{} - for i := 0; i < count && i < 6; i++ { - parts = append(parts, fmt.Sprintf("%d", int32(bo.Uint32(data[i*4:i*4+4])))) - } - return strings.Join(parts, ", ") - } - return fmt.Sprintf("type=%d count=%d", typ, count) -} - -func exifTagName(tag uint16, ifd string) string { - if ifd == "GPSIFD" { - if n, ok := gpsTags[tag]; ok { - return n - } - return fmt.Sprintf("GPS tag 0x%04x", tag) - } - if n, ok := exifTags[tag]; ok { - return n - } - return fmt.Sprintf("tag 0x%04x", tag) -} - -// exifTags covers the standard TIFF/Exif tags routinely seen in -// JPEG/TIFF images produced by consumer cameras and phones. -var exifTags = map[uint16]string{ - // TIFF / IFD0 - 0x0100: "ImageWidth", 0x0101: "ImageLength", - 0x0102: "BitsPerSample", 0x0103: "Compression", - 0x0106: "PhotometricInterpretation", 0x010a: "FillOrder", - 0x010d: "DocumentName", 0x010e: "ImageDescription", - 0x010f: "Make", 0x0110: "Model", 0x0111: "StripOffsets", - 0x0112: "Orientation", 0x0115: "SamplesPerPixel", - 0x0116: "RowsPerStrip", 0x0117: "StripByteCounts", - 0x011a: "XResolution", 0x011b: "YResolution", - 0x011c: "PlanarConfiguration", 0x0128: "ResolutionUnit", - 0x012d: "TransferFunction", 0x0131: "Software", 0x0132: "DateTime", - 0x013b: "Artist", 0x013c: "HostComputer", - 0x013e: "WhitePoint", 0x013f: "PrimaryChromaticities", - 0x0211: "YCbCrCoefficients", 0x0212: "YCbCrSubSampling", - 0x0213: "YCbCrPositioning", 0x0214: "ReferenceBlackWhite", - 0x02bc: "XMP", - 0x8298: "Copyright", 0x83bb: "IPTCNAA", - 0x8769: "ExifIFDPointer", 0x8773: "InterColorProfile", - 0x8825: "GPSIFDPointer", - // Exif SubIFD - 0x829a: "ExposureTime", 0x829d: "FNumber", - 0x8822: "ExposureProgram", 0x8824: "SpectralSensitivity", - 0x8827: "ISOSpeedRatings", 0x8828: "OECF", - 0x9000: "ExifVersion", - 0x9003: "DateTimeOriginal", 0x9004: "DateTimeDigitized", - 0x9010: "OffsetTime", 0x9011: "OffsetTimeOriginal", 0x9012: "OffsetTimeDigitized", - 0x9101: "ComponentsConfiguration", 0x9102: "CompressedBitsPerPixel", - 0x9201: "ShutterSpeedValue", 0x9202: "ApertureValue", - 0x9203: "BrightnessValue", 0x9204: "ExposureBiasValue", - 0x9205: "MaxApertureValue", 0x9206: "SubjectDistance", - 0x9207: "MeteringMode", 0x9208: "LightSource", 0x9209: "Flash", - 0x920a: "FocalLength", - 0x9214: "SubjectArea", 0x927c: "MakerNote", 0x9286: "UserComment", - 0x9290: "SubSecTime", 0x9291: "SubSecTimeOriginal", 0x9292: "SubSecTimeDigitized", - 0xa000: "FlashpixVersion", 0xa001: "ColorSpace", - 0xa002: "PixelXDimension", 0xa003: "PixelYDimension", - 0xa004: "RelatedSoundFile", 0xa005: "InteropIFDPointer", - 0xa20b: "FlashEnergy", 0xa20c: "SpatialFrequencyResponse", - 0xa20e: "FocalPlaneXResolution", 0xa20f: "FocalPlaneYResolution", - 0xa210: "FocalPlaneResolutionUnit", - 0xa214: "SubjectLocation", 0xa215: "ExposureIndex", - 0xa217: "SensingMethod", 0xa300: "FileSource", 0xa301: "SceneType", - 0xa302: "CFAPattern", 0xa401: "CustomRendered", - 0xa402: "ExposureMode", 0xa403: "WhiteBalance", - 0xa404: "DigitalZoomRatio", 0xa405: "FocalLengthIn35mmFilm", - 0xa406: "SceneCaptureType", 0xa407: "GainControl", - 0xa408: "Contrast", 0xa409: "Saturation", 0xa40a: "Sharpness", - 0xa40b: "DeviceSettingDescription", 0xa40c: "SubjectDistanceRange", - 0xa420: "ImageUniqueID", 0xa430: "OwnerName", 0xa431: "SerialNumber", - 0xa432: "LensSpecification", 0xa433: "LensMake", 0xa434: "LensModel", - 0xa435: "LensSerialNumber", - // Thumbnail IFD1 re-uses IFD0 tags; additional ones: - 0x0201: "JPEGInterchangeFormat", 0x0202: "JPEGInterchangeFormatLength", -} - -var gpsTags = map[uint16]string{ - 0x0000: "GPSVersionID", 0x0001: "GPSLatitudeRef", 0x0002: "GPSLatitude", - 0x0003: "GPSLongitudeRef", 0x0004: "GPSLongitude", - 0x0005: "GPSAltitudeRef", 0x0006: "GPSAltitude", - 0x0007: "GPSTimeStamp", 0x0008: "GPSSatellites", 0x0009: "GPSStatus", - 0x000a: "GPSMeasureMode", 0x000b: "GPSDOP", 0x000c: "GPSSpeedRef", - 0x000d: "GPSSpeed", 0x000e: "GPSTrackRef", 0x000f: "GPSTrack", - 0x0010: "GPSImgDirectionRef", 0x0011: "GPSImgDirection", - 0x0012: "GPSMapDatum", 0x0013: "GPSDestLatitudeRef", 0x0014: "GPSDestLatitude", - 0x0015: "GPSDestLongitudeRef", 0x0016: "GPSDestLongitude", - 0x0017: "GPSDestBearingRef", 0x0018: "GPSDestBearing", - 0x0019: "GPSDestDistanceRef", 0x001a: "GPSDestDistance", - 0x001b: "GPSProcessingMethod", 0x001c: "GPSAreaInformation", - 0x001d: "GPSDateStamp", 0x001e: "GPSDifferential", 0x001f: "GPSHPositioningError", -} - -func zlibDecompress(b []byte) ([]byte, error) { - r, err := zlib.NewReader(bytes.NewReader(b)) - if err != nil { - return nil, err - } - defer r.Close() - // 2 MiB cap — textual metadata should never be near this. - lim := &io.LimitedReader{R: r, N: 2 * 1024 * 1024} - return io.ReadAll(lim) -} - -func safeText(b []byte) string { - // Preserve text but trim extreme size and strip control chars other than tab/newline. - if len(b) > 4096 { - b = b[:4096] - } - sb := strings.Builder{} - sb.Grow(len(b)) - for _, c := range b { - if c == '\t' || c == '\n' || c == '\r' || (c >= 0x20 && c <= 0x7e) { - sb.WriteByte(c) - continue - } - if c >= 0x80 { - // Pass through; browser will render as UTF-8 if valid. - sb.WriteByte(c) - continue - } - // Drop other controls. - } - return sb.String() -} - -func minInt64(a, b int64) int64 { - if a < b { - return a - } - return b -} - -// scanGIFMeta walks a GIF file and surfaces every extension block that -// can carry human-readable data: Comment (0x21 0xFE), Plain Text -// (0x21 0x01), and Application (0x21 0xFF — XMP, Netscape loop, etc.). -func scanGIFMeta(d *document.Document) ([]MetaEntry, error) { - size := d.Size() - out := []MetaEntry{} - // Skip header (6) + logical screen descriptor (7 fixed) and the - // optional global colour table. - hdr, err := d.Range(0, 13) - if err != nil { - return nil, err - } - off := int64(13) - packed := hdr[10] - if packed&0x80 != 0 { - gctSize := int64(3) * (int64(1) << uint((packed&0x07)+1)) - off += gctSize - } - for off < size { - b, err := d.Range(off, 1) - if err != nil { - break - } - switch b[0] { - case 0x3b: // trailer - return out, nil - case 0x2c: // Image Descriptor: skip descriptor + optional LCT + LZW data - next, ok := skipGIFImage(d, off) - if !ok { - return out, nil - } - off = next - case 0x21: // Extension Introducer - if off+2 > size { - return out, nil - } - lbl, _ := d.Range(off+1, 1) - start := off - payload, next, ok := readGIFSubBlocks(d, off+2) - if !ok { - return out, nil - } - switch lbl[0] { - case 0xfe: - out = append(out, MetaEntry{ - Offset: start, Length: next - start, - Container: "gif", Kind: "Comment", - Value: safeText(payload), - }) - case 0x01: - // Plain Text: first 12 bytes are text-grid parameters. - text := payload - if len(text) > 12 { - text = text[12:] - } else { - text = nil - } - out = append(out, MetaEntry{ - Offset: start, Length: next - start, - Container: "gif", Kind: "PlainText", - Value: safeText(text), - }) - case 0xff: - // Application Extension: first sub-block is an 11-byte - // identifier like "NETSCAPE2.0" or "XMP DataXMP". - if len(payload) >= 11 { - appID := string(bytes.TrimRight(payload[:11], "\x00 ")) - rest := payload[11:] - out = append(out, MetaEntry{ - Offset: start, Length: next - start, - Container: "gif", Kind: "Application", - Keyword: appID, - Value: safeText(rest), - }) - } else { - out = append(out, MetaEntry{ - Offset: start, Length: next - start, - Container: "gif", Kind: "Application", - Value: safeText(payload), - }) - } - default: - // Graphic control (0xf9) and others — skip. - } - off = next - default: - // Unknown byte; bail. - return out, nil - } - } - return out, nil -} - -// readGIFSubBlocks reads a chain of GIF sub-blocks: each starts with a -// length byte (0 terminates). Returns (concatenated payload, offset of -// next byte after terminator, ok). -func readGIFSubBlocks(d *document.Document, off int64) ([]byte, int64, bool) { - size := d.Size() - out := []byte{} - for off < size { - ln, err := d.Range(off, 1) - if err != nil { - return nil, off, false - } - n := int64(ln[0]) - off++ - if n == 0 { - return out, off, true - } - if off+n > size { - return nil, off, false - } - block, err := d.Range(off, n) - if err != nil { - return nil, off, false - } - out = append(out, block...) - off += n - // Hard cap to prevent OOM on malformed input. - if len(out) > 16*1024*1024 { - return out, off, true - } - } - return nil, off, false -} - -// skipGIFImage walks an Image Descriptor block starting at off (the 0x2c -// byte), skipping the descriptor, any local colour table, the LZW -// minimum-code-size byte and the image data sub-blocks. Returns the -// offset of the next block, or (0, false) on truncation. -func skipGIFImage(d *document.Document, off int64) (int64, bool) { - size := d.Size() - if off+10 > size { - return 0, false - } - desc, err := d.Range(off, 10) - if err != nil { - return 0, false - } - packed := desc[9] - off += 10 - if packed&0x80 != 0 { - lctSize := int64(3) * (int64(1) << uint((packed&0x07)+1)) - off += lctSize - } - // LZW minimum code size byte, then sub-blocks. - if off+1 > size { - return 0, false - } - off++ - _, next, ok := readGIFSubBlocks(d, off) - if !ok { - return 0, false - } - return next, true -} diff --git a/internal/leviathan/stego/stego.go b/internal/leviathan/stego/stego.go deleted file mode 100644 index 03a4ca8..0000000 --- a/internal/leviathan/stego/stego.go +++ /dev/null @@ -1,501 +0,0 @@ -// Package stego provides practical steganography helpers: -// -// - trailer detection (bytes after a declared end) for PNG, JPEG, -// GIF, BMP, WAV and RIFF/WebP containers -// - multi-trailer surfacing: identifies any embedded container -// (PNG/JPEG/ZIP/GIF/PDF/RAR/7z) inside the trailer region -// - LSB extract/inject over an arbitrary byte buffer -// - PNG IDAT, BMP pixel-array and WAV sample-data resolvers so -// callers can point LSB at the correct carrier region -// - textual-metadata extraction for PNG, JPEG, GIF (see metadata.go) -package stego - -import ( - "encoding/binary" - "errors" - - "github.com/andydixon/link/internal/leviathan/document" -) - -// --------------------------------------------------------------------------- -// Trailer detection -// --------------------------------------------------------------------------- - -// EmbeddedHit describes a recognised container signature found inside the -// trailer region (the bytes after a file's declared end). -type EmbeddedHit struct { - Offset int64 `json:"offset"` // absolute offset within the outer file - Kind string `json:"kind"` - Note string `json:"note,omitempty"` -} - -// AnalyseAppended inspects the end of the file for plausible trailers: data -// after the declared end of a known container. The result also lists any -// nested container magic bytes discovered inside the trailer region. -func AnalyseAppended(d *document.Document) (map[string]any, error) { - size := d.Size() - head, _ := d.Range(0, 16) - result := map[string]any{ - "fileSize": size, - "declaredEnd": int64(-1), - "trailerSize": int64(0), - "container": "", - "note": "no recognised container", - "embedded": []EmbeddedHit{}, - } - - end := int64(-1) - container := "" - - switch { - case len(head) >= 8 && string(head[:8]) == "\x89PNG\r\n\x1a\n": - container = "png" - end = findPNGEnd(d) - case len(head) >= 3 && head[0] == 0xff && head[1] == 0xd8 && head[2] == 0xff: - container = "jpeg" - end = findJPEGEnd(d) - case len(head) >= 6 && (string(head[:6]) == "GIF87a" || string(head[:6]) == "GIF89a"): - container = "gif" - end = findGIFEnd(d) - case len(head) >= 2 && head[0] == 'B' && head[1] == 'M': - container = "bmp" - end = findBMPEnd(d) - case len(head) >= 12 && string(head[:4]) == "RIFF": - sub := string(head[8:12]) - if sub == "WAVE" { - container = "wav" - } else if sub == "WEBP" { - container = "webp" - } else { - container = "riff" - } - end = findRIFFEnd(d) - } - - if container != "" { - result["container"] = container - } - - if end > 0 && end <= size { - result["declaredEnd"] = end - if end < size { - trailer := size - end - result["trailerSize"] = trailer - result["note"] = "bytes after " + container + " declared end" - if trailer > 0 { - // Cap scanning the trailer at 16 MiB — otherwise we might - // pull a 2-GiB tail into memory on hostile inputs. - scanLen := trailer - if scanLen > 16*1024*1024 { - scanLen = 16 * 1024 * 1024 - } - tail, _ := d.Range(end, scanLen) - result["embedded"] = scanEmbedded(tail, end) - } - } else { - result["note"] = "no trailing bytes" - } - } - - return result, nil -} - -// ExtractTrailer returns the bytes after a container's declared end. -func ExtractTrailer(d *document.Document) ([]byte, error) { - a, err := AnalyseAppended(d) - if err != nil { - return nil, err - } - end, _ := a["declaredEnd"].(int64) - ts, _ := a["trailerSize"].(int64) - if end < 0 || ts <= 0 { - return nil, errors.New("no trailer found") - } - return d.Range(end, ts) -} - -func findPNGEnd(d *document.Document) int64 { - size := d.Size() - off := int64(8) - for off+12 <= size { - hdr, err := d.Range(off, 8) - if err != nil { - break - } - ln := int64(binary.BigEndian.Uint32(hdr[0:4])) - if ln < 0 || off+12+ln > size { - break - } - typ := string(hdr[4:8]) - off += 12 + ln - if typ == "IEND" { - return off - } - } - return -1 -} - -func findJPEGEnd(d *document.Document) int64 { - size := d.Size() - tailLen := int64(128 * 1024) - if tailLen > size { - tailLen = size - } - tail, _ := d.Range(size-tailLen, tailLen) - for i := len(tail) - 2; i >= 0; i-- { - if tail[i] == 0xff && tail[i+1] == 0xd9 { - return size - tailLen + int64(i+2) - } - } - return -1 -} - -func findGIFEnd(d *document.Document) int64 { - size := d.Size() - // The GIF terminator 0x3B is a single byte that follows the final - // data block of the final image. Walking the container correctly is - // overkill; scan from the tail and accept the last 0x3B that is - // preceded by a block terminator (0x00) or a plausible frame end. - tailLen := int64(32 * 1024) - if tailLen > size { - tailLen = size - } - tail, _ := d.Range(size-tailLen, tailLen) - for i := len(tail) - 1; i >= 0; i-- { - if tail[i] == 0x3b { - return size - tailLen + int64(i+1) - } - } - return -1 -} - -func findBMPEnd(d *document.Document) int64 { - size := d.Size() - hdr, err := d.Range(0, 14) - if err != nil { - return -1 - } - bfSize := int64(binary.LittleEndian.Uint32(hdr[2:6])) - if bfSize > 0 && bfSize <= size { - return bfSize - } - return -1 -} - -func findRIFFEnd(d *document.Document) int64 { - size := d.Size() - hdr, err := d.Range(0, 8) - if err != nil { - return -1 - } - // RIFF chunk size (little-endian) is the size of the rest of the - // file *after* the first 8 bytes, so declared end = 8 + riffSize. - riffSize := int64(binary.LittleEndian.Uint32(hdr[4:8])) - end := 8 + riffSize - if end > 0 && end <= size { - // RIFF pads to even alignment. - if end%2 == 1 && end+1 <= size { - end++ - } - return end - } - return -1 -} - -// scanEmbedded walks a trailer buffer for known container magic bytes. -func scanEmbedded(buf []byte, base int64) []EmbeddedHit { - hits := []EmbeddedHit{} - type sig struct { - bytes []byte - kind string - } - sigs := []sig{ - {[]byte("\x89PNG\r\n\x1a\n"), "png"}, - {[]byte{0xff, 0xd8, 0xff}, "jpeg"}, - {[]byte("GIF87a"), "gif"}, - {[]byte("GIF89a"), "gif"}, - {[]byte("PK\x03\x04"), "zip"}, - {[]byte("PK\x05\x06"), "zip-eocd"}, - {[]byte("Rar!\x1a\x07\x00"), "rar"}, - {[]byte("Rar!\x1a\x07\x01\x00"), "rar5"}, - {[]byte("7z\xbc\xaf\x27\x1c"), "7z"}, - {[]byte("%PDF-"), "pdf"}, - {[]byte("\x1f\x8b\x08"), "gzip"}, - {[]byte{0xfd, '7', 'z', 'X', 'Z', 0x00}, "xz"}, - {[]byte("BZh"), "bzip2"}, - {[]byte{0x28, 0xb5, 0x2f, 0xfd}, "zstd"}, - {[]byte{0x7f, 'E', 'L', 'F'}, "elf"}, - {[]byte("MZ"), "mz/pe"}, - {[]byte("ustar"), "tar"}, - {[]byte("OggS"), "ogg"}, - {[]byte("ID3"), "id3/mp3"}, - {[]byte("fLaC"), "flac"}, - {[]byte("\x1aEߣ"), "matroska/webm"}, - {[]byte("RIFF"), "riff"}, - } - for _, s := range sigs { - if len(s.bytes) == 0 { - continue - } - start := 0 - for { - i := indexOf(buf[start:], s.bytes) - if i < 0 { - break - } - hits = append(hits, EmbeddedHit{ - Offset: base + int64(start+i), - Kind: s.kind, - }) - // A plain "MZ" or "RIFF" hit without more context is noisy; - // the caller can filter on kind. Keep walking past this hit. - start += i + len(s.bytes) - // Bound total hits per signature to keep UI manageable. - if len(hits) > 256 { - break - } - } - if len(hits) > 256 { - break - } - } - return hits -} - -func indexOf(hay, needle []byte) int { - if len(needle) == 0 || len(hay) < len(needle) { - return -1 - } - first := needle[0] - last := len(hay) - len(needle) - for i := 0; i <= last; i++ { - if hay[i] != first { - continue - } - match := true - for j := 1; j < len(needle); j++ { - if hay[i+j] != needle[j] { - match = false - break - } - } - if match { - return i - } - } - return -1 -} - -// --------------------------------------------------------------------------- -// Carrier resolvers (PNG IDAT, BMP pixels, WAV samples) -// --------------------------------------------------------------------------- - -// PNGImageData concatenates all IDAT chunks' payload bytes. Callers can then -// feed the result into LSBExtract/LSBInject. The returned buffer is the raw -// (still-deflated) IDAT stream — it is the conventional LSB carrier for -// tools like zsteg, not the decoded pixel array. -func PNGImageData(d *document.Document) ([]byte, error) { - size := d.Size() - off := int64(8) - buf := []byte{} - for off+12 <= size { - hdr, err := d.Range(off, 8) - if err != nil { - return nil, err - } - ln := int64(binary.BigEndian.Uint32(hdr[0:4])) - typ := string(hdr[4:8]) - if typ == "IDAT" { - chunk, err := d.Range(off+8, ln) - if err != nil { - return nil, err - } - buf = append(buf, chunk...) - } - off += 12 + ln - if typ == "IEND" { - break - } - } - if len(buf) == 0 { - return nil, errors.New("no IDAT data") - } - return buf, nil -} - -// BMPCarrier describes a resolved BMP pixel array that can be used as an -// LSB carrier. -type BMPCarrier struct { - Data []byte `json:"data"` - Offset int64 `json:"offset"` // offset of pixel array in the file - Width int32 `json:"width"` - Height int32 `json:"height"` - BitsPerPixel uint16 `json:"bitsPerPixel"` - RowStride int `json:"rowStride"` -} - -// BMPPixels locates and returns the BMP pixel array. Supports 8/24/32 bpp -// uncompressed BI_RGB images, which are by far the most common carriers. -func BMPPixels(d *document.Document) (*BMPCarrier, error) { - size := d.Size() - if size < 54 { - return nil, errors.New("not a BMP (too small)") - } - hdr, err := d.Range(0, 54) - if err != nil { - return nil, err - } - if hdr[0] != 'B' || hdr[1] != 'M' { - return nil, errors.New("not a BMP") - } - bfOffBits := int64(binary.LittleEndian.Uint32(hdr[10:14])) - dibSize := binary.LittleEndian.Uint32(hdr[14:18]) - // BITMAPINFOHEADER (40), BITMAPV4HEADER (108), BITMAPV5HEADER (124), - // OS/2 BITMAPCOREHEADER (12). Only handle the modern family. - if dibSize < 40 { - return nil, errors.New("BMP: unsupported DIB header (BITMAPCOREHEADER)") - } - w := int32(binary.LittleEndian.Uint32(hdr[18:22])) - h := int32(binary.LittleEndian.Uint32(hdr[22:26])) - bpp := binary.LittleEndian.Uint16(hdr[28:30]) - compression := binary.LittleEndian.Uint32(hdr[30:34]) - if compression != 0 && compression != 3 { // BI_RGB / BI_BITFIELDS - return nil, errors.New("BMP: compressed images not supported") - } - if bpp != 8 && bpp != 24 && bpp != 32 { - return nil, errors.New("BMP: only 8/24/32 bpp supported") - } - absH := h - if absH < 0 { - absH = -absH - } - rowStride := ((int(w)*int(bpp) + 31) / 32) * 4 - total := int64(rowStride) * int64(absH) - if bfOffBits < 0 || bfOffBits+total > size { - return nil, errors.New("BMP: pixel array out of range") - } - data, err := d.Range(bfOffBits, total) - if err != nil { - return nil, err - } - return &BMPCarrier{ - Data: data, Offset: bfOffBits, - Width: w, Height: h, BitsPerPixel: bpp, RowStride: rowStride, - }, nil -} - -// WAVCarrier describes a resolved WAV "data" chunk that can be used as an -// LSB carrier. -type WAVCarrier struct { - Data []byte `json:"data"` - Offset int64 `json:"offset"` // offset of sample data - SampleRate uint32 `json:"sampleRate"` - NumChannels uint16 `json:"numChannels"` - BitsPerSample uint16 `json:"bitsPerSample"` - AudioFormat uint16 `json:"audioFormat"` -} - -// WAVSamples locates the "data" sub-chunk of a RIFF/WAVE file and returns -// its payload. Only PCM (format 1) is usable as a straightforward LSB -// carrier; other formats still return bytes but are flagged in the caller. -func WAVSamples(d *document.Document) (*WAVCarrier, error) { - size := d.Size() - if size < 44 { - return nil, errors.New("not a WAV (too small)") - } - hdr, err := d.Range(0, 12) - if err != nil { - return nil, err - } - if string(hdr[:4]) != "RIFF" || string(hdr[8:12]) != "WAVE" { - return nil, errors.New("not a RIFF/WAVE file") - } - out := &WAVCarrier{} - off := int64(12) - for off+8 <= size { - ch, err := d.Range(off, 8) - if err != nil { - return nil, err - } - id := string(ch[:4]) - cs := int64(binary.LittleEndian.Uint32(ch[4:8])) - if cs < 0 || off+8+cs > size { - break - } - switch id { - case "fmt ": - body, err := d.Range(off+8, cs) - if err == nil && len(body) >= 16 { - out.AudioFormat = binary.LittleEndian.Uint16(body[0:2]) - out.NumChannels = binary.LittleEndian.Uint16(body[2:4]) - out.SampleRate = binary.LittleEndian.Uint32(body[4:8]) - out.BitsPerSample = binary.LittleEndian.Uint16(body[14:16]) - } - case "data": - body, err := d.Range(off+8, cs) - if err != nil { - return nil, err - } - out.Data = body - out.Offset = off + 8 - return out, nil - } - // Pad to even. - step := 8 + cs - if step%2 == 1 { - step++ - } - off += step - } - return nil, errors.New("WAV: data chunk not found") -} - -// --------------------------------------------------------------------------- -// LSB codec -// --------------------------------------------------------------------------- - -// LSBExtract returns a bitstring-as-bytes built from the low bit of each -// byte in the selected range. Useful for exploring LSB payloads over raw -// buffers or specific regions. -func LSBExtract(buf []byte, bitsPerByte int) []byte { - if bitsPerByte <= 0 || bitsPerByte > 8 { - bitsPerByte = 1 - } - total := len(buf) * bitsPerByte - out := make([]byte, total/8) - bitIdx := 0 - for _, b := range buf { - for i := 0; i < bitsPerByte; i++ { - bit := (b >> uint(i)) & 1 - if bit == 1 { - out[bitIdx/8] |= 1 << uint(7-(bitIdx%8)) - } - bitIdx++ - } - } - return out -} - -// LSBInject replaces low bits in buf with bits from payload. Returns the -// modified buffer. When capacity is exceeded, injection stops. -func LSBInject(buf, payload []byte, bitsPerByte int) []byte { - if bitsPerByte <= 0 || bitsPerByte > 8 { - bitsPerByte = 1 - } - out := append([]byte(nil), buf...) - mask := byte(1<> uint(7-(bitIdx%8))) & 1 - if bit != 0 { - chunk |= 1 << uint(b) - } - bitIdx++ - } - out[i] = (out[i] &^ mask) | (chunk & mask) - } - return out -} diff --git a/internal/leviathan/transforms/cipher.go b/internal/leviathan/transforms/cipher.go deleted file mode 100644 index f5cfca7..0000000 --- a/internal/leviathan/transforms/cipher.go +++ /dev/null @@ -1,402 +0,0 @@ -package transforms - -import ( - "math" -) - -// Caesar shifts A-Z/a-z forward by n, leaving other bytes alone. -func Caesar(in []byte, n int) []byte { - n = ((n % 26) + 26) % 26 - out := make([]byte, len(in)) - for i, b := range in { - switch { - case b >= 'A' && b <= 'Z': - out[i] = byte('A' + (int(b-'A')+n)%26) - case b >= 'a' && b <= 'z': - out[i] = byte('a' + (int(b-'a')+n)%26) - default: - out[i] = b - } - } - return out -} - -// Atbash maps A..Z -> Z..A (and lowercase equivalently). -func Atbash(in []byte) []byte { - out := make([]byte, len(in)) - for i, b := range in { - switch { - case b >= 'A' && b <= 'Z': - out[i] = 'A' + 'Z' - b - case b >= 'a' && b <= 'z': - out[i] = 'a' + 'z' - b - default: - out[i] = b - } - } - return out -} - -// Vigenere applies the Vigenère cipher with an ASCII key. encrypt=true -// shifts forward, false shifts backward. Non-letters are preserved and -// do not advance the key. -func Vigenere(in, key []byte, encrypt bool) []byte { - if len(key) == 0 { - return append([]byte(nil), in...) - } - normKey := make([]int, 0, len(key)) - for _, k := range key { - switch { - case k >= 'A' && k <= 'Z': - normKey = append(normKey, int(k-'A')) - case k >= 'a' && k <= 'z': - normKey = append(normKey, int(k-'a')) - } - } - if len(normKey) == 0 { - return append([]byte(nil), in...) - } - out := make([]byte, len(in)) - ki := 0 - for i, b := range in { - var base byte - var letter bool - if b >= 'A' && b <= 'Z' { - base = 'A' - letter = true - } else if b >= 'a' && b <= 'z' { - base = 'a' - letter = true - } - if letter { - shift := normKey[ki%len(normKey)] - if !encrypt { - shift = -shift - } - out[i] = byte(int(base) + ((int(b-base)+shift)%26+26)%26) - ki++ - } else { - out[i] = b - } - } - return out -} - -// English letter frequency (A..Z) in percent. -var englishFreq = [26]float64{ - 8.167, 1.492, 2.782, 4.253, 12.702, 2.228, 2.015, 6.094, 6.966, 0.153, - 0.772, 4.025, 2.406, 6.749, 7.507, 1.929, 0.095, 5.987, 6.327, 9.056, - 2.758, 0.978, 2.360, 0.150, 1.974, 0.074, -} - -// ScoreEnglish returns a heuristic fit to English letter frequencies. -// Higher score = closer to English. ASCII-printable bonus added. -func ScoreEnglish(in []byte) float64 { - if len(in) == 0 { - return 0 - } - var counts [26]int - total := 0 - printable := 0 - for _, b := range in { - if b >= 'A' && b <= 'Z' { - counts[b-'A']++ - total++ - } else if b >= 'a' && b <= 'z' { - counts[b-'a']++ - total++ - } - if (b >= 0x20 && b <= 0x7e) || b == '\n' || b == '\r' || b == '\t' { - printable++ - } - } - printRatio := float64(printable) / float64(len(in)) - if total == 0 { - return printRatio * 10 // give credit for printability - } - // Chi-squared inverse-like score - chi := 0.0 - for i := 0; i < 26; i++ { - obs := float64(counts[i]) / float64(total) * 100 - exp := englishFreq[i] - chi += math.Abs(obs - exp) // L1 distance; simpler than chi^2 for small samples - } - // Lower chi -> better. Convert to a bounded score. - score := (200.0 - chi) + printRatio*50 - if score < 0 { - score = 0 - } - return score -} - -// BruteCaesar returns all 26 shifts with scores, best-first. -func BruteCaesar(in []byte) []map[string]any { - out := make([]map[string]any, 26) - for n := 0; n < 26; n++ { - dec := Caesar(in, n) - out[n] = map[string]any{ - "shift": n, - "score": ScoreEnglish(dec), - "sample": previewText(dec, 120), - "text": string(dec), - } - } - // sort by score desc (simple insertion) - for i := 1; i < len(out); i++ { - for j := i; j > 0 && out[j-1]["score"].(float64) < out[j]["score"].(float64); j-- { - out[j-1], out[j] = out[j], out[j-1] - } - } - return out -} - -// BruteXORByte returns scored 1-byte XOR candidates, best-first. -func BruteXORByte(in []byte) []map[string]any { - out := make([]map[string]any, 256) - for k := 0; k < 256; k++ { - buf := make([]byte, len(in)) - for i, b := range in { - buf[i] = b ^ byte(k) - } - out[k] = map[string]any{ - "key": k, - "keyHex": byteHex(byte(k)), - "score": ScoreEnglish(buf), - "sample": previewText(buf, 120), - "text": string(buf), - } - } - for i := 1; i < len(out); i++ { - for j := i; j > 0 && out[j-1]["score"].(float64) < out[j]["score"].(float64); j-- { - out[j-1], out[j] = out[j], out[j-1] - } - } - return out -} - -// RepeatingXORGuess attempts to recover short repeating XOR keys using -// normalized Hamming distance for key-size ranking and per-column -// single-byte XOR scoring. -func RepeatingXORGuess(in []byte, minKey, maxKey, top int) []map[string]any { - if minKey <= 0 { - minKey = 2 - } - if maxKey < minKey { - maxKey = minKey - } - if maxKey > 64 { - maxKey = 64 - } - if top <= 0 { - top = 5 - } - type keySizeScore struct { - size int - score float64 - } - sizes := []keySizeScore{} - for keySize := minKey; keySize <= maxKey; keySize++ { - if len(in) < keySize*4 { - continue - } - dist := 0.0 - pairs := 0 - for block := 0; block < 4; block++ { - a := block * keySize - b := a + keySize - if b+keySize > len(in) { - break - } - dist += float64(hamming(in[a:a+keySize], in[b:b+keySize])) / float64(keySize) - pairs++ - } - if pairs > 0 { - sizes = append(sizes, keySizeScore{size: keySize, score: dist / float64(pairs)}) - } - } - for i := 1; i < len(sizes); i++ { - for j := i; j > 0 && sizes[j-1].score > sizes[j].score; j-- { - sizes[j-1], sizes[j] = sizes[j], sizes[j-1] - } - } - limit := top - if limit > len(sizes) { - limit = len(sizes) - } - out := make([]map[string]any, 0, limit) - for _, ks := range sizes[:limit] { - key := recoverRepeatingXORKey(in, ks.size) - dec := XORRepeating(in, key) - out = append(out, map[string]any{ - "keySize": ks.size, - "distance": ks.score, - "keyHex": bytesHex(key), - "keyText": previewText(key, len(key)), - "score": ScoreEnglish(dec), - "sample": previewText(dec, 120), - "text": string(dec), - "normalizedScore": ScoreEnglish(dec) - ks.score, - }) - } - for i := 1; i < len(out); i++ { - for j := i; j > 0 && out[j-1]["normalizedScore"].(float64) < out[j]["normalizedScore"].(float64); j-- { - out[j-1], out[j] = out[j], out[j-1] - } - } - return out -} - -func recoverRepeatingXORKey(in []byte, keySize int) []byte { - key := make([]byte, keySize) - for pos := 0; pos < keySize; pos++ { - col := make([]byte, 0, len(in)/keySize+1) - for i := pos; i < len(in); i += keySize { - col = append(col, in[i]) - } - bestScore := -1.0 - bestKey := byte(0) - for k := 0; k < 256; k++ { - buf := make([]byte, len(col)) - for i, b := range col { - buf[i] = b ^ byte(k) - } - score := ScoreEnglish(buf) - if score > bestScore { - bestScore = score - bestKey = byte(k) - } - } - key[pos] = bestKey - } - return key -} - -func XORRepeating(in, key []byte) []byte { - if len(key) == 0 { - return append([]byte(nil), in...) - } - out := make([]byte, len(in)) - for i, b := range in { - out[i] = b ^ key[i%len(key)] - } - return out -} - -func hamming(a, b []byte) int { - n := len(a) - if len(b) < n { - n = len(b) - } - out := 0 - for i := 0; i < n; i++ { - x := a[i] ^ b[i] - for x != 0 { - out++ - x &= x - 1 - } - } - return out -} - -// TryEverything tries a handful of simple decodes and scores them. -func TryEverything(in []byte) []map[string]any { - candidates := []map[string]any{ - {"name": "input", "text": string(in), "score": ScoreEnglish(in)}, - {"name": "rot13", "text": string(Caesar(in, 13)), "score": ScoreEnglish(Caesar(in, 13))}, - {"name": "atbash", "text": string(Atbash(in)), "score": ScoreEnglish(Atbash(in))}, - } - if b, err := Base64Decode(in); err == nil && len(b) > 0 { - candidates = append(candidates, map[string]any{"name": "base64.decode", "text": string(b), "score": ScoreEnglish(b)}) - } - if b, err := HexDecode(in); err == nil && len(b) > 0 { - candidates = append(candidates, map[string]any{"name": "hex.decode", "text": string(b), "score": ScoreEnglish(b)}) - } - if b, err := URLDecode(in); err == nil && len(b) > 0 { - candidates = append(candidates, map[string]any{"name": "url.decode", "text": string(b), "score": ScoreEnglish(b)}) - } - // Top 3 XOR 1-byte candidates - xr := BruteXORByte(in) - for i := 0; i < 3 && i < len(xr); i++ { - candidates = append(candidates, map[string]any{ - "name": "xor1:" + xr[i]["keyHex"].(string), - "text": xr[i]["text"].(string), - "score": xr[i]["score"].(float64), - }) - } - // Top repeating-key XOR candidates. - rx := RepeatingXORGuess(in, 2, 40, 3) - for i := 0; i < 3 && i < len(rx); i++ { - candidates = append(candidates, map[string]any{ - "name": "xor-repeating:" + rx[i]["keyHex"].(string), - "keyHex": rx[i]["keyHex"].(string), - "keyText": rx[i]["keyText"].(string), - "text": rx[i]["text"].(string), - "score": rx[i]["score"].(float64), - }) - } - // Top 2 caesar - cz := BruteCaesar(in) - for i := 0; i < 2 && i < len(cz); i++ { - candidates = append(candidates, map[string]any{ - "name": "caesar:" + byteDec(byte(cz[i]["shift"].(int))), - "text": cz[i]["text"].(string), - "score": cz[i]["score"].(float64), - }) - } - // Sort - for i := 1; i < len(candidates); i++ { - for j := i; j > 0 && candidates[j-1]["score"].(float64) < candidates[j]["score"].(float64); j-- { - candidates[j-1], candidates[j] = candidates[j], candidates[j-1] - } - } - // Samples - for _, c := range candidates { - t, _ := c["text"].(string) - c["sample"] = previewText([]byte(t), 120) - } - return candidates -} - -func previewText(buf []byte, n int) string { - if n > len(buf) { - n = len(buf) - } - out := make([]byte, n) - for i := 0; i < n; i++ { - if buf[i] >= 0x20 && buf[i] <= 0x7e { - out[i] = buf[i] - } else if buf[i] == '\n' { - out[i] = ' ' - } else { - out[i] = '.' - } - } - return string(out) -} - -func byteHex(b byte) string { - const h = "0123456789abcdef" - return string([]byte{h[b>>4], h[b&0x0f]}) -} - -func bytesHex(buf []byte) string { - out := make([]byte, 0, len(buf)*2) - for _, b := range buf { - out = append(out, byteHex(b)...) - } - return string(out) -} - -func byteDec(b byte) string { - if b == 0 { - return "0" - } - out := [3]byte{} - i := 3 - for b > 0 { - i-- - out[i] = '0' + b%10 - b /= 10 - } - return string(out[i:]) -} diff --git a/internal/leviathan/transforms/transforms.go b/internal/leviathan/transforms/transforms.go deleted file mode 100644 index f9344ab..0000000 --- a/internal/leviathan/transforms/transforms.go +++ /dev/null @@ -1,237 +0,0 @@ -// Package transforms exposes chainable transform primitives. Each -// function takes an input []byte and options, and returns an output -// or an error. They are pure and do not mutate input. -package transforms - -import ( - "bytes" - "compress/flate" - "compress/gzip" - "compress/zlib" - "encoding/base64" - "encoding/hex" - "errors" - "io" - "net/url" -) - -const MaxInflateOutput = 64 << 20 - -// HexEncode returns ASCII hex of src. -func HexEncode(src []byte) ([]byte, error) { - return []byte(hex.EncodeToString(src)), nil -} - -// HexDecode parses ASCII hex into bytes. Whitespace is ignored. -func HexDecode(src []byte) ([]byte, error) { - stripped := stripHex(src) - out := make([]byte, len(stripped)/2) - _, err := hex.Decode(out, stripped) - return out, err -} - -func stripHex(s []byte) []byte { - out := make([]byte, 0, len(s)) - for _, b := range s { - if b == ' ' || b == '\n' || b == '\r' || b == '\t' || b == ',' || b == ':' { - continue - } - if b == '0' && len(out) == 0 { - continue - } - out = append(out, b) - } - return out -} - -// Base64Encode returns the standard Base64 of src. -func Base64Encode(src []byte) ([]byte, error) { - return []byte(base64.StdEncoding.EncodeToString(src)), nil -} - -// Base64Decode accepts standard or URL-safe Base64, with or without padding. -func Base64Decode(src []byte) ([]byte, error) { - s := string(bytes.TrimSpace(src)) - attempts := []*base64.Encoding{ - base64.StdEncoding, - base64.URLEncoding, - base64.RawStdEncoding, - base64.RawURLEncoding, - } - for _, enc := range attempts { - if b, err := enc.DecodeString(s); err == nil { - return b, nil - } - } - return nil, errors.New("not valid base64") -} - -// URLEncode applies percent-encoding. -func URLEncode(src []byte) ([]byte, error) { - return []byte(url.QueryEscape(string(src))), nil -} - -// URLDecode reverses percent-encoding. -func URLDecode(src []byte) ([]byte, error) { - v, err := url.QueryUnescape(string(src)) - if err != nil { - return nil, err - } - return []byte(v), nil -} - -// XOR returns src^key, cycling key. -func XOR(src, key []byte) ([]byte, error) { - if len(key) == 0 { - return nil, errors.New("empty key") - } - out := make([]byte, len(src)) - for i := range src { - out[i] = src[i] ^ key[i%len(key)] - } - return out, nil -} - -// InflateGzip, InflateZlib, InflateRaw attempt to decompress. -func InflateGzip(src []byte) ([]byte, error) { - r, err := gzip.NewReader(bytes.NewReader(src)) - if err != nil { - return nil, err - } - defer r.Close() - return readLimitedInflate(r) -} - -func InflateZlib(src []byte) ([]byte, error) { - r, err := zlib.NewReader(bytes.NewReader(src)) - if err != nil { - return nil, err - } - defer r.Close() - return readLimitedInflate(r) -} - -func InflateRaw(src []byte) ([]byte, error) { - r := flate.NewReader(bytes.NewReader(src)) - defer r.Close() - return readLimitedInflate(r) -} - -func readLimitedInflate(r io.Reader) ([]byte, error) { - lr := io.LimitReader(r, MaxInflateOutput+1) - out, err := io.ReadAll(lr) - if err != nil { - return nil, err - } - if len(out) > MaxInflateOutput { - return nil, errors.New("decompressed output exceeds 64 MiB limit") - } - return out, nil -} - -// TryInflate attempts gzip, zlib, and raw deflate in order. -func TryInflate(src []byte) (out []byte, kind string, err error) { - if b, e := InflateGzip(src); e == nil { - return b, "gzip", nil - } - if b, e := InflateZlib(src); e == nil { - return b, "zlib", nil - } - if b, e := InflateRaw(src); e == nil { - return b, "raw", nil - } - return nil, "", errors.New("no decompression succeeded") -} - -// Step is one entry in a transform pipeline. -type Step struct { - Op string // hex.encode, hex.decode, base64.encode, base64.decode, - // xor, inflate, url.encode, url.decode, caesar, rot13, atbash, - // vigenere.enc, vigenere.dec - Arg string // ASCII parameter (e.g. key for XOR/Vigenere, shift for Caesar) -} - -// ApplyPipeline runs steps left-to-right. Intermediate results are -// returned for inspection. -func ApplyPipeline(input []byte, steps []Step) ([][]byte, error) { - results := make([][]byte, 0, len(steps)+1) - cur := append([]byte(nil), input...) - results = append(results, cur) - for _, s := range steps { - out, err := Apply(cur, s) - if err != nil { - return results, err - } - cur = out - results = append(results, cur) - } - return results, nil -} - -// Apply one step. -func Apply(in []byte, s Step) ([]byte, error) { - switch s.Op { - case "hex.encode": - return HexEncode(in) - case "hex.decode": - return HexDecode(in) - case "base64.encode": - return Base64Encode(in) - case "base64.decode": - return Base64Decode(in) - case "url.encode": - return URLEncode(in) - case "url.decode": - return URLDecode(in) - case "xor": - key, err := parseKey(s.Arg) - if err != nil { - return nil, err - } - return XOR(in, key) - case "inflate": - switch s.Arg { - case "gzip": - return InflateGzip(in) - case "zlib": - return InflateZlib(in) - case "raw": - return InflateRaw(in) - default: - out, _, err := TryInflate(in) - return out, err - } - case "caesar": - n := 13 - if s.Arg != "" { - var v int - for _, c := range s.Arg { - if c >= '0' && c <= '9' { - v = v*10 + int(c-'0') - } - } - n = v - } - return Caesar(in, n), nil - case "rot13": - return Caesar(in, 13), nil - case "atbash": - return Atbash(in), nil - case "vigenere.enc": - return Vigenere(in, []byte(s.Arg), true), nil - case "vigenere.dec": - return Vigenere(in, []byte(s.Arg), false), nil - } - return nil, errors.New("unknown op: " + s.Op) -} - -// parseKey accepts either literal string (ASCII) or hex-prefixed "hex:..". -func parseKey(arg string) ([]byte, error) { - if len(arg) > 4 && arg[:4] == "hex:" { - return hex.DecodeString(arg[4:]) - } - if arg == "" { - return nil, errors.New("empty key") - } - return []byte(arg), nil -} diff --git a/internal/localshell/localshell.go b/internal/localshell/localshell.go deleted file mode 100644 index 816d2db..0000000 --- a/internal/localshell/localshell.go +++ /dev/null @@ -1,294 +0,0 @@ -// Package localshell provides a local PTY-based terminal session. -// It spawns a shell process and bridges I/O for the frontend terminal. -package localshell - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "os/exec" - "runtime" - "strconv" - "strings" - "sync" - "syscall" - "time" - - "github.com/andydixon/link/internal/terminalenv" - "github.com/creack/pty" -) - -// Client manages a local shell PTY session. -type Client struct { - mu sync.Mutex - cmd *exec.Cmd - ptmx *os.File - shell string - startDir string - onData func([]byte) - onError func(error) - onClose func() - closed bool - cols uint16 - rows uint16 -} - -// Config holds configuration for a local shell session. -type Config struct { - Shell string // Path to the shell binary (e.g. /bin/zsh) - Cols uint16 - Rows uint16 - StartDir string // Initial working directory; defaults to the user's home directory. - OnData func([]byte) - OnError func(error) - OnClose func() -} - -// DefaultShell returns the system default shell. -func DefaultShell() string { - if runtime.GOOS == "windows" { - // Prefer PowerShell if available, else cmd.exe. - if ps, err := exec.LookPath("powershell.exe"); err == nil { - return ps - } - return "cmd.exe" - } - // Unix: use SHELL env var, fallback to /bin/sh. - if sh := os.Getenv("SHELL"); sh != "" { - return sh - } - return "/bin/sh" -} - -// NewClient creates a new local shell client. -func NewClient(cfg Config) *Client { - shell := cfg.Shell - if shell == "" { - shell = DefaultShell() - } - cols := cfg.Cols - if cols == 0 { - cols = 80 - } - rows := cfg.Rows - if rows == 0 { - rows = 24 - } - startDir := cfg.StartDir - if startDir == "" { - if home, err := os.UserHomeDir(); err == nil { - startDir = home - } - } - return &Client{ - shell: shell, - startDir: startDir, - cols: cols, - rows: rows, - onData: cfg.OnData, - onError: cfg.OnError, - onClose: cfg.OnClose, - } -} - -// Start spawns the shell process and begins reading output. -func (c *Client) Start() error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.closed { - return fmt.Errorf("shell already closed") - } - if c.ptmx != nil { - return fmt.Errorf("shell already running") - } - - cmd := exec.Command(c.shell) - cmd.Env = terminalenv.LocalShellEnv(os.Environ()) - cmd.Dir = c.startDir - - ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{ - Cols: c.cols, - Rows: c.rows, - }) - if err != nil { - return fmt.Errorf("failed to start shell %q: %w", c.shell, err) - } - - c.cmd = cmd - c.ptmx = ptmx - - // Read loop — forward PTY output to the frontend. - go c.readLoop(ptmx) - - // Wait for process exit. - go func() { - _ = cmd.Wait() - c.mu.Lock() - wasClosed := c.closed - c.closed = true - if c.ptmx == ptmx { - c.ptmx = nil - } - c.mu.Unlock() - - if !wasClosed && c.onClose != nil { - c.onClose() - } - }() - - return nil -} - -// Write sends data to the shell's stdin (PTY). -func (c *Client) Write(data []byte) error { - // Capture the shell's current directory before it handles a command or - // EOF. This lets a replacement shell resume in the same directory after - // an ordinary `exit`, without injecting shell-specific prompt hooks. - if bytesContainCommandBoundary(data) { - c.captureWorkingDirectory() - } - - c.mu.Lock() - ptmx := c.ptmx - c.mu.Unlock() - - if ptmx == nil { - return fmt.Errorf("shell not running") - } - _, err := ptmx.Write(data) - return err -} - -// Resize updates the PTY window size. -func (c *Client) Resize(cols, rows uint16) error { - c.mu.Lock() - if cols > 0 { - c.cols = cols - } - if rows > 0 { - c.rows = rows - } - ptmx := c.ptmx - cols = c.cols - rows = c.rows - c.mu.Unlock() - - if ptmx == nil { - return nil - } - return pty.Setsize(ptmx, &pty.Winsize{Cols: cols, Rows: rows}) -} - -// Close terminates the shell process and PTY. -func (c *Client) Close() { - c.mu.Lock() - if c.closed { - c.mu.Unlock() - return - } - c.closed = true - ptmx := c.ptmx - cmd := c.cmd - c.ptmx = nil - c.mu.Unlock() - - if ptmx != nil { - _ = ptmx.Close() - } - if cmd != nil && cmd.Process != nil { - _ = cmd.Process.Kill() - } -} - -// WorkingDirectory returns the last directory observed for the shell. -func (c *Client) WorkingDirectory() string { - c.mu.Lock() - defer c.mu.Unlock() - return c.startDir -} - -func (c *Client) readLoop(ptmx *os.File) { - defer ptmx.Close() - buf := make([]byte, 32*1024) - for { - n, err := ptmx.Read(buf) - if n > 0 && c.onData != nil { - c.onData(buf[:n]) - } - if err != nil { - c.mu.Lock() - wasClosed := c.closed - c.mu.Unlock() - if !wasClosed && !isNormalPTYClosure(err) && c.onError != nil { - c.onError(err) - } - return - } - } -} - -func bytesContainCommandBoundary(data []byte) bool { - for _, b := range data { - if b == '\r' || b == '\n' || b == 0x04 { - return true - } - } - return false -} - -func isNormalPTYClosure(err error) bool { - return errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) || errors.Is(err, syscall.EIO) -} - -func (c *Client) captureWorkingDirectory() { - c.mu.Lock() - cmd := c.cmd - c.mu.Unlock() - if cmd == nil || cmd.Process == nil { - return - } - - dir := processWorkingDirectory(cmd.Process.Pid) - if dir == "" || !isDirectory(dir) { - return - } - - c.mu.Lock() - if c.cmd == cmd { - c.startDir = dir - } - c.mu.Unlock() -} - -func processWorkingDirectory(pid int) string { - switch runtime.GOOS { - case "linux": - dir, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/cwd") - if err == nil { - return dir - } - case "darwin": - // macOS does not expose /proc. lsof is part of the base system and its - // field output gives us the cwd without parsing a human-readable table. - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - out, err := exec.CommandContext(ctx, "/usr/sbin/lsof", "-a", "-p", strconv.Itoa(pid), "-d", "cwd", "-Fn").Output() - if err != nil { - return "" - } - for _, line := range strings.Split(string(out), "\n") { - if strings.HasPrefix(line, "n") { - return strings.TrimPrefix(line, "n") - } - } - } - return "" -} - -func isDirectory(path string) bool { - info, err := os.Stat(path) - return err == nil && info.IsDir() -} diff --git a/internal/localshell/localshell_test.go b/internal/localshell/localshell_test.go deleted file mode 100644 index e8c3f5a..0000000 --- a/internal/localshell/localshell_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package localshell - -import ( - "bytes" - "os" - "path/filepath" - "runtime" - "strings" - "sync" - "testing" - "time" -) - -func TestClientUsesConfiguredDirectoryAndPreStartSize(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("local PTY test requires a Unix shell") - } - - dir := t.TempDir() - startDir := filepath.Join(dir, "working") - if err := os.Mkdir(startDir, 0o700); err != nil { - t.Fatal(err) - } - script := filepath.Join(dir, "report-terminal.sh") - if err := os.WriteFile(script, []byte("#!/bin/sh\nstty size\npwd\n"), 0o700); err != nil { - t.Fatal(err) - } - - var output bytes.Buffer - var outputMu sync.Mutex - closed := make(chan struct{}) - errors := make(chan error, 1) - client := NewClient(Config{ - Shell: script, - StartDir: startDir, - Cols: 80, - Rows: 24, - OnData: func(data []byte) { - outputMu.Lock() - _, _ = output.Write(data) - outputMu.Unlock() - }, - OnError: func(err error) { - errors <- err - }, - OnClose: func() { - close(closed) - }, - }) - - // A frontend fit can arrive before the process starts. The stored size - // must become the initial PTY geometry rather than being discarded. - if err := client.Resize(132, 43); err != nil { - t.Fatalf("Resize() before Start() error = %v", err) - } - if err := client.Start(); err != nil { - t.Fatalf("Start() error = %v", err) - } - - select { - case err := <-errors: - t.Fatalf("unexpected PTY read error: %v", err) - case <-closed: - case <-time.After(5 * time.Second): - client.Close() - t.Fatal("timed out waiting for local shell to exit") - } - - outputMu.Lock() - got := strings.ReplaceAll(output.String(), "\r", "") - outputMu.Unlock() - if !strings.Contains(got, "43 132\n") { - t.Fatalf("PTY output %q does not contain configured size 43 132", got) - } - if !strings.Contains(got, startDir+"\n") { - t.Fatalf("PTY output %q does not contain start directory %q", got, startDir) - } -} - -func TestBytesContainCommandBoundary(t *testing.T) { - for _, data := range [][]byte{[]byte("pwd\r"), []byte("pwd\n"), {0x04}} { - if !bytesContainCommandBoundary(data) { - t.Fatalf("bytesContainCommandBoundary(%q) = false, want true", data) - } - } - if bytesContainCommandBoundary([]byte("pwd")) { - t.Fatal("bytesContainCommandBoundary without enter/EOF = true, want false") - } -} - -func TestClientCapturesWorkingDirectoryBeforeExit(t *testing.T) { - if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { - t.Skip("process cwd lookup is implemented on macOS and Linux") - } - - dir := t.TempDir() - startDir := filepath.Join(dir, "start") - nextDir := filepath.Join(startDir, "next") - if err := os.MkdirAll(nextDir, 0o700); err != nil { - t.Fatal(err) - } - expectedDir, err := filepath.EvalSymlinks(nextDir) - if err != nil { - t.Fatal(err) - } - - closed := make(chan struct{}) - client := NewClient(Config{ - Shell: "/bin/sh", - StartDir: startDir, - OnClose: func() { - close(closed) - }, - }) - if err := client.Start(); err != nil { - t.Fatalf("Start() error = %v", err) - } - defer client.Close() - - if err := client.Write([]byte("cd next\r")); err != nil { - t.Fatalf("Write(cd) error = %v", err) - } - deadline := time.Now().Add(5 * time.Second) - for client.WorkingDirectory() != expectedDir && time.Now().Before(deadline) { - time.Sleep(20 * time.Millisecond) - // The empty command boundary snapshots the directory after cd has run. - if err := client.Write([]byte("\r")); err != nil { - t.Fatalf("Write(snapshot) error = %v", err) - } - } - if got := client.WorkingDirectory(); got != expectedDir { - t.Fatalf("WorkingDirectory() = %q, want %q", got, expectedDir) - } - - if err := client.Write([]byte("exit\r")); err != nil { - t.Fatalf("Write(exit) error = %v", err) - } - select { - case <-closed: - case <-time.After(5 * time.Second): - t.Fatal("timed out waiting for local shell to exit") - } - if got := client.WorkingDirectory(); got != expectedDir { - t.Fatalf("WorkingDirectory() after exit = %q, want %q", got, expectedDir) - } -} diff --git a/internal/models/defaults.go b/internal/models/defaults.go deleted file mode 100644 index 2e248a2..0000000 --- a/internal/models/defaults.go +++ /dev/null @@ -1,390 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -// Default port numbers for each protocol. -const ( - DefaultSSHPort = 22 - DefaultTelnetPort = 23 - DefaultRAWPort = 23 - DefaultGitHubPort = 443 - DefaultVNCPort = 5900 - DefaultIRCPort = 6667 - DefaultIRCTLSPort = 6697 -) - -// Default timeout values (in seconds). -const ( - DefaultConnectTimeout = 30 - DefaultReadTimeout = 0 // no timeout -) - -// Default terminal rendering settings. -const ( - DefaultScrollback = 10000 - DefaultFontSize = 14 - DefaultFontFamily = "JetBrains Mono, Menlo, Monaco, Consolas, monospace" - DefaultCursorStyle = "block" - DefaultTerminalType = "xterm-256color" -) - -// Default SSH keepalive settings. -const ( - DefaultKeepaliveInterval = 30 - DefaultKeepaliveCountMax = 3 -) - -// DefaultAppPreferences returns sensible default application preferences. -func DefaultAppPreferences() AppPreferences { - return AppPreferences{ - Theme: "dark", - TerminalTheme: "charcoal", - DefaultProtocol: string(ProtocolSSH), - ConfirmOnClose: true, - ConfirmOnDisconnect: true, - ShowStatusBar: true, - SidebarWidth: 280, - DefaultFontFamily: DefaultFontFamily, - DefaultFontSize: DefaultFontSize, - DefaultScrollback: DefaultScrollback, - DefaultCursorStyle: DefaultCursorStyle, - UseNativePaste: false, - LocalShell: "", // empty means use system default - LeviathanEnabled: false, - } -} - -// NewProfile creates a new Profile with sensible defaults for the given protocol. -func NewProfile(protocol Protocol) Profile { - now := time.Now() - p := Profile{ - ID: uuid.New().String(), - Protocol: protocol, - Tags: []string{}, - ConnectTimeout: DefaultConnectTimeout, - ReadTimeout: DefaultReadTimeout, - TerminalFontFamily: DefaultFontFamily, - TerminalFontSize: DefaultFontSize, - TerminalScrollback: DefaultScrollback, - TerminalCursorStyle: DefaultCursorStyle, - CreatedAt: now, - UpdatedAt: now, - } - - switch protocol { - case ProtocolSSH: - p.Port = DefaultSSHPort - p.SSH = &SSHConfig{ - AuthMethods: []string{"publickey", "password", "keyboard-interactive"}, - SecretMode: SecretModeSaved, - KeySecretMode: SecretModeSaved, - HostKeyPolicy: HostKeyAsk, - TerminalType: DefaultTerminalType, - KeepaliveInterval: DefaultKeepaliveInterval, - KeepaliveCountMax: DefaultKeepaliveCountMax, - JumpHosts: []JumpHost{}, - PortForwards: []PortForward{}, - EnvVars: map[string]string{}, - } - case ProtocolTelnet: - p.Port = DefaultTelnetPort - p.Telnet = &TelnetConfig{ - LocalEcho: false, - LineEnding: LineEndingCRLF, - TerminalType: DefaultTerminalType, - Encoding: "utf-8", - } - case ProtocolRAW: - p.Port = DefaultRAWPort - p.RAW = &RAWConfig{ - LineEnding: LineEndingCRLF, - Encoding: "utf-8", - PayloadPresets: []PayloadPreset{}, - } - case ProtocolGitHub: - p.Port = DefaultGitHubPort - p.Host = "github.com" - p.GitHub = &GitHubConfig{ - BaseURL: "https://api.github.com", - SecretMode: SecretModeSaved, - RefreshIntervalSecs: 300, - } - case ProtocolVNC: - p.Port = DefaultVNCPort - p.VNC = &VNCConfig{ - VNCPort: DefaultVNCPort, - SecretMode: SecretModeSaved, - ScaleToFit: true, - SharedSession: true, - Quality: 6, - SSHPort: DefaultSSHPort, - SSHAuthMethod: "publickey", - SSHSecretMode: SecretModeSaved, - } - case ProtocolIRC: - p.Port = DefaultIRCPort - p.IRC = &IRCConfig{ - Nick: "link", - RealName: "Alchementary Link", - ServerPasswordMode: SecretModeAskEveryTime, - NickServMode: SecretModeSaved, - AutoJoinRooms: []string{}, - SSHPort: DefaultSSHPort, - SSHAuthMethod: "publickey", - SSHSecretMode: SecretModeSaved, - AutoReconnect: true, - } - } - - return p -} - -// NormalizeProfileDefaults fills missing default values on profiles loaded -// from older exports, third-party imports, or sparse frontend payloads. -func NormalizeProfileDefaults(p *Profile) { - if p == nil { - return - } - if p.Protocol == "" { - p.Protocol = ProtocolSSH - } - - defaults := NewProfile(p.Protocol) - if p.ID == "" { - p.ID = defaults.ID - } - if p.Tags == nil { - p.Tags = []string{} - } - if p.ConnectTimeout == 0 { - p.ConnectTimeout = DefaultConnectTimeout - } - if p.TerminalFontFamily == "" { - p.TerminalFontFamily = DefaultFontFamily - } - if p.TerminalFontSize == 0 { - p.TerminalFontSize = DefaultFontSize - } - if p.TerminalScrollback == 0 { - p.TerminalScrollback = DefaultScrollback - } - if p.TerminalCursorStyle == "" { - p.TerminalCursorStyle = DefaultCursorStyle - } - if p.CreatedAt.IsZero() { - p.CreatedAt = defaults.CreatedAt - } - if p.UpdatedAt.IsZero() { - p.UpdatedAt = defaults.UpdatedAt - } - if p.Port == 0 { - p.Port = defaults.Port - } - if p.DisplayName == "" { - p.DisplayName = defaultProfileName(p) - } - - switch p.Protocol { - case ProtocolSSH: - normalizeSSHConfig(p, defaults.SSH) - case ProtocolTelnet: - normalizeTelnetConfig(p, defaults.Telnet) - case ProtocolRAW: - normalizeRAWConfig(p, defaults.RAW) - case ProtocolGitHub: - if p.Host == "" { - p.Host = defaults.Host - } - normalizeGitHubConfig(p, defaults.GitHub) - case ProtocolVNC: - normalizeVNCConfig(p, defaults.VNC) - case ProtocolIRC: - normalizeIRCConfig(p, defaults.IRC) - } -} - -func defaultProfileName(p *Profile) string { - if p.Host != "" { - return p.Host - } - if p.VNC != nil && p.VNC.VNCHost != "" { - return p.VNC.VNCHost - } - switch p.Protocol { - case ProtocolGitHub: - return "GitHub" - case ProtocolVNC: - return "VNC" - case ProtocolIRC: - return "IRC" - case ProtocolRAW: - return "RAW TCP" - case ProtocolTelnet: - return "Telnet" - default: - return "SSH" - } -} - -func normalizeSecretMode(mode SecretMode, fallback SecretMode) SecretMode { - switch mode { - case SecretModeSaved, SecretModeSessionOnly, SecretModeAskEveryTime: - return mode - case "prompt": - return SecretModeAskEveryTime - case "": - return fallback - default: - return mode - } -} - -func normalizeSSHConfig(p *Profile, defaults *SSHConfig) { - if p.SSH == nil { - cfg := *defaults - p.SSH = &cfg - return - } - cfg := p.SSH - if len(cfg.AuthMethods) == 0 { - cfg.AuthMethods = append([]string{}, defaults.AuthMethods...) - } - cfg.SecretMode = normalizeSecretMode(cfg.SecretMode, defaults.SecretMode) - cfg.KeySecretMode = normalizeSecretMode(cfg.KeySecretMode, defaults.KeySecretMode) - if cfg.HostKeyPolicy == "" { - cfg.HostKeyPolicy = defaults.HostKeyPolicy - } - if cfg.TerminalType == "" { - cfg.TerminalType = defaults.TerminalType - } - if cfg.KeepaliveInterval == 0 { - cfg.KeepaliveInterval = defaults.KeepaliveInterval - } - if cfg.KeepaliveCountMax == 0 { - cfg.KeepaliveCountMax = defaults.KeepaliveCountMax - } - if cfg.JumpHosts == nil { - cfg.JumpHosts = []JumpHost{} - } - if cfg.PortForwards == nil { - cfg.PortForwards = []PortForward{} - } - if cfg.EnvVars == nil { - cfg.EnvVars = map[string]string{} - } -} - -func normalizeTelnetConfig(p *Profile, defaults *TelnetConfig) { - if p.Telnet == nil { - cfg := *defaults - p.Telnet = &cfg - return - } - cfg := p.Telnet - if cfg.LineEnding == "" { - cfg.LineEnding = defaults.LineEnding - } - if cfg.TerminalType == "" { - cfg.TerminalType = defaults.TerminalType - } - if cfg.Encoding == "" { - cfg.Encoding = defaults.Encoding - } -} - -func normalizeRAWConfig(p *Profile, defaults *RAWConfig) { - if p.RAW == nil { - cfg := *defaults - p.RAW = &cfg - return - } - cfg := p.RAW - if cfg.LineEnding == "" { - cfg.LineEnding = defaults.LineEnding - } - if cfg.Encoding == "" { - cfg.Encoding = defaults.Encoding - } - if cfg.PayloadPresets == nil { - cfg.PayloadPresets = []PayloadPreset{} - } - for i := range cfg.PayloadPresets { - if cfg.PayloadPresets[i].ID == "" { - cfg.PayloadPresets[i].ID = uuid.New().String() - } - if cfg.PayloadPresets[i].Format == "" { - cfg.PayloadPresets[i].Format = "text" - } - } -} - -func normalizeGitHubConfig(p *Profile, defaults *GitHubConfig) { - if p.GitHub == nil { - cfg := *defaults - p.GitHub = &cfg - return - } - cfg := p.GitHub - if cfg.BaseURL == "" { - cfg.BaseURL = defaults.BaseURL - } - cfg.SecretMode = normalizeSecretMode(cfg.SecretMode, defaults.SecretMode) - if cfg.RefreshIntervalSecs == 0 { - cfg.RefreshIntervalSecs = defaults.RefreshIntervalSecs - } -} - -func normalizeVNCConfig(p *Profile, defaults *VNCConfig) { - if p.VNC == nil { - cfg := *defaults - p.VNC = &cfg - return - } - cfg := p.VNC - if cfg.VNCPort == 0 { - cfg.VNCPort = defaults.VNCPort - } - cfg.SecretMode = normalizeSecretMode(cfg.SecretMode, defaults.SecretMode) - if cfg.Quality < 0 { - cfg.Quality = defaults.Quality - } - if cfg.SSHPort == 0 { - cfg.SSHPort = defaults.SSHPort - } - if cfg.SSHAuthMethod == "" { - cfg.SSHAuthMethod = defaults.SSHAuthMethod - } - cfg.SSHSecretMode = normalizeSecretMode(cfg.SSHSecretMode, defaults.SSHSecretMode) -} - -func normalizeIRCConfig(p *Profile, defaults *IRCConfig) { - if p.IRC == nil { - cfg := *defaults - cfg.AutoJoinRooms = append([]string{}, defaults.AutoJoinRooms...) - p.IRC = &cfg - return - } - cfg := p.IRC - if cfg.Nick == "" { - cfg.Nick = defaults.Nick - } - if cfg.RealName == "" { - cfg.RealName = defaults.RealName - } - cfg.ServerPasswordMode = normalizeSecretMode(cfg.ServerPasswordMode, defaults.ServerPasswordMode) - cfg.NickServMode = normalizeSecretMode(cfg.NickServMode, defaults.NickServMode) - if cfg.AutoJoinRooms == nil { - cfg.AutoJoinRooms = []string{} - } - if cfg.SSHPort == 0 { - cfg.SSHPort = defaults.SSHPort - } - if cfg.SSHAuthMethod == "" { - cfg.SSHAuthMethod = defaults.SSHAuthMethod - } - cfg.SSHSecretMode = normalizeSecretMode(cfg.SSHSecretMode, defaults.SSHSecretMode) -} diff --git a/internal/models/defaults_test.go b/internal/models/defaults_test.go deleted file mode 100644 index aef6ae2..0000000 --- a/internal/models/defaults_test.go +++ /dev/null @@ -1,10 +0,0 @@ -package models - -import "testing" - -func TestDefaultAppPreferencesHideLeviathan(t *testing.T) { - prefs := DefaultAppPreferences() - if prefs.LeviathanEnabled { - t.Fatal("LeviathanEnabled = true, want false by default") - } -} diff --git a/internal/models/models.go b/internal/models/models.go deleted file mode 100644 index a33827f..0000000 --- a/internal/models/models.go +++ /dev/null @@ -1,372 +0,0 @@ -// Package models defines the core data structures used throughout -// the Alchementary Link application. -package models - -import "time" - -// Protocol represents the connection protocol for a session. -type Protocol string - -const ( - ProtocolSSH Protocol = "ssh" - ProtocolTelnet Protocol = "telnet" - ProtocolRAW Protocol = "raw" - ProtocolGitHub Protocol = "github" - ProtocolVNC Protocol = "vnc" - ProtocolIRC Protocol = "irc" - ProtocolLocal Protocol = "local" -) - -// HostKeyPolicy determines how unknown or changed SSH host keys are handled. -type HostKeyPolicy string - -const ( - HostKeyStrict HostKeyPolicy = "strict" - HostKeyAsk HostKeyPolicy = "ask" - HostKeyAcceptNew HostKeyPolicy = "accept-new" - HostKeyInsecure HostKeyPolicy = "insecure" -) - -// SecretMode controls how credentials are persisted. -type SecretMode string - -const ( - SecretModeSaved SecretMode = "saved" - SecretModeSessionOnly SecretMode = "session-only" - SecretModeAskEveryTime SecretMode = "ask-every-time" -) - -// LineEnding specifies the line-ending convention for Telnet and RAW connections. -type LineEnding string - -const ( - LineEndingLF LineEnding = "lf" - LineEndingCR LineEnding = "cr" - LineEndingCRLF LineEnding = "crlf" - LineEndingNone LineEnding = "none" -) - -// Profile holds the full configuration for a connection profile. -type Profile struct { - ID string `json:"id"` - DisplayName string `json:"displayName"` - Description string `json:"description"` - Protocol Protocol `json:"protocol"` - Host string `json:"host"` - Port int `json:"port"` - Username string `json:"username"` - Tags []string `json:"tags"` - Folder string `json:"folder"` - Environment string `json:"environment"` - Notes string `json:"notes"` - Favourite bool `json:"favourite"` - LastConnected *time.Time `json:"lastConnected" ts_type:"string"` - ConnectTimeout int `json:"connectTimeout"` // seconds - ReadTimeout int `json:"readTimeout"` // seconds - CreatedAt time.Time `json:"createdAt" ts_type:"string"` - UpdatedAt time.Time `json:"updatedAt" ts_type:"string"` - - // Terminal preferences - TerminalFontFamily string `json:"terminalFontFamily"` - TerminalFontSize int `json:"terminalFontSize"` - TerminalScrollback int `json:"terminalScrollback"` - TerminalCursorStyle string `json:"terminalCursorStyle"` - - // SSH-specific configuration - SSH *SSHConfig `json:"ssh,omitempty"` - - // Telnet-specific configuration - Telnet *TelnetConfig `json:"telnet,omitempty"` - - // RAW-specific configuration - RAW *RAWConfig `json:"raw,omitempty"` - - // GitHub-specific configuration - GitHub *GitHubConfig `json:"github,omitempty"` - - // VNC-specific configuration - VNC *VNCConfig `json:"vnc,omitempty"` - - // IRC-specific configuration - IRC *IRCConfig `json:"irc,omitempty"` -} - -// SSHConfig holds SSH-specific settings for a connection profile. -type SSHConfig struct { - AuthMethods []string `json:"authMethods"` // password, publickey, keyboard-interactive - SecretMode SecretMode `json:"secretMode"` - PrivateKeyPath string `json:"privateKeyPath"` - KeySecretMode SecretMode `json:"keySecretMode"` - HostKeyPolicy HostKeyPolicy `json:"hostKeyPolicy"` - TerminalType string `json:"terminalType"` - KeepaliveInterval int `json:"keepaliveInterval"` - KeepaliveCountMax int `json:"keepaliveCountMax"` - Compression bool `json:"compression"` - AgentAuth bool `json:"agentAuth"` // use SSH agent for authentication - AgentForwarding bool `json:"agentForwarding"` // forward agent to remote host - AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect - MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5) - LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence - TOTPSecret string `json:"totpSecret"` // TOTP secret key for 2FA auto-fill (stored encrypted) - ProxyType string `json:"proxyType"` // "none", "socks5", "http" - ProxyHost string `json:"proxyHost"` - ProxyPort int `json:"proxyPort"` - ProxyUsername string `json:"proxyUsername"` - ProxyPassword string `json:"proxyPassword"` // stored in vault for production; inline for simplicity - JumpHosts []JumpHost `json:"jumpHosts"` - PortForwards []PortForward `json:"portForwards"` - StartupCommand string `json:"startupCommand"` - EnvVars map[string]string `json:"envVars"` -} - -// LoginScriptStep represents a single step in an expect-style auto-login sequence. -type LoginScriptStep struct { - Type string `json:"type"` // "wait" or "send" - Value string `json:"value"` // text to wait for / text to send - Timeout int `json:"timeout"` // max seconds to wait (for "wait" steps; 0 = use default 10s) -} - -// JumpHost describes a proxy/bastion host used for SSH multi-hop connections. -type JumpHost struct { - Host string `json:"host"` - Port int `json:"port"` - Username string `json:"username"` - AuthMethod string `json:"authMethod"` - SecretMode SecretMode `json:"secretMode"` - PrivateKeyPath string `json:"privateKeyPath"` -} - -// PortForwardDirection indicates the tunnel direction. -type PortForwardDirection string - -const ( - PortForwardLocal PortForwardDirection = "local" - PortForwardRemote PortForwardDirection = "remote" -) - -// PortForward defines a single SSH port-forwarding rule. -type PortForward struct { - ID string `json:"id"` - Direction PortForwardDirection `json:"direction"` - BindHost string `json:"bindHost"` // local address to listen on (local) or remote bind (remote) - BindPort int `json:"bindPort"` - DestHost string `json:"destHost"` // target address to forward to - DestPort int `json:"destPort"` - Enabled bool `json:"enabled"` // whether to activate on connect - Label string `json:"label"` // optional descriptive label -} - -// TelnetConfig holds Telnet-specific settings for a connection profile. -type TelnetConfig struct { - LocalEcho bool `json:"localEcho"` - LineEnding LineEnding `json:"lineEnding"` - TerminalType string `json:"terminalType"` - Encoding string `json:"encoding"` - NegotiationDebug bool `json:"negotiationDebug"` - LineMode bool `json:"lineMode"` - AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect - MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5) - LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence -} - -// RAWConfig holds RAW TCP-specific settings for a connection profile. -type RAWConfig struct { - AutoReconnect bool `json:"autoReconnect"` - LineEnding LineEnding `json:"lineEnding"` - Encoding string `json:"encoding"` - ShowTimestamps bool `json:"showTimestamps"` - ShowDirection bool `json:"showDirection"` - PayloadPresets []PayloadPreset `json:"payloadPresets"` -} - -// PayloadPreset stores a reusable payload for RAW TCP connections. -type PayloadPreset struct { - ID string `json:"id"` - Name string `json:"name"` - Payload string `json:"payload"` // base64-encoded for binary safety - Format string `json:"format"` // text, hex, escaped -} - -// GitHubConfig holds GitHub API-specific settings for a connection profile. -type GitHubConfig struct { - // BaseURL is the GitHub API base (e.g. "https://api.github.com" for - // github.com, or "https://github.example.com/api/v3" for GHE). - BaseURL string `json:"baseURL"` - - // SecretMode controls how the personal access token is stored. - SecretMode SecretMode `json:"secretMode"` - - // RefreshIntervalSecs controls the auto-refresh period in seconds. - // Defaults to 300 (5 minutes). - RefreshIntervalSecs int `json:"refreshIntervalSecs"` -} - -// VNCConfig holds VNC-specific settings for a connection profile. -type VNCConfig struct { - // VNCHost is the VNC server address (defaults to profile.Host if empty). - VNCHost string `json:"vncHost"` - - // VNCPort is the VNC server port (5900 + display number, default 5900). - VNCPort int `json:"vncPort"` - - // SecretMode controls how the VNC password is stored. - SecretMode SecretMode `json:"secretMode"` - - // ViewOnly disables sending keyboard and mouse events to the server. - ViewOnly bool `json:"viewOnly"` - - // ScaleToFit scales the remote framebuffer to fit the panel. - ScaleToFit bool `json:"scaleToFit"` - - // Quality controls JPEG compression quality (1-9, 0 = default). - Quality int `json:"quality"` - - // SharedSession allows other VNC viewers to remain connected. - SharedSession bool `json:"sharedSession"` - - // SSH tunnel settings — if enabled, VNC traffic is tunnelled through an SSH connection. - SSHTunnel bool `json:"sshTunnel"` - SSHHost string `json:"sshHost"` - SSHPort int `json:"sshPort"` - SSHUsername string `json:"sshUsername"` - SSHAuthMethod string `json:"sshAuthMethod"` // password, publickey - SSHSecretMode SecretMode `json:"sshSecretMode"` - SSHPrivateKey string `json:"sshPrivateKeyPath"` - SSHAgentAuth bool `json:"sshAgentAuth"` -} - -// IRCConfig holds IRC-specific settings for a connection profile. -type IRCConfig struct { - // Nick is the primary nickname used when connecting to the IRC network. - Nick string `json:"nick"` - - // RealName is the user-facing realname/gecos sent during registration. - RealName string `json:"realName"` - - // UseTLS enables TLS for the IRC server connection. - UseTLS bool `json:"useTLS"` - - // ServerPasswordRequired controls whether to prompt for an IRC server PASS secret. - ServerPasswordRequired bool `json:"serverPasswordRequired"` - ServerPasswordMode SecretMode `json:"serverPasswordMode"` - - // NickServ settings. The password is resolved through the secret store. - NickServIdentify bool `json:"nickServIdentify"` - NickServAccount string `json:"nickServAccount"` - NickServMode SecretMode `json:"nickServMode"` - - // AutoJoinRooms are joined automatically after registration. - AutoJoinRooms []string `json:"autoJoinRooms"` - - // SSH tunnel settings — if enabled, IRC traffic is tunnelled through SSH. - SSHTunnel bool `json:"sshTunnel"` - SSHHost string `json:"sshHost"` - SSHPort int `json:"sshPort"` - SSHUsername string `json:"sshUsername"` - SSHAuthMethod string `json:"sshAuthMethod"` // password, publickey - SSHSecretMode SecretMode `json:"sshSecretMode"` - SSHPrivateKey string `json:"sshPrivateKeyPath"` - SSHAgentAuth bool `json:"sshAgentAuth"` - - // Reconnect policy for unexpected disconnects. - AutoReconnect bool `json:"autoReconnect"` - MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5) -} - -// GitHubRepo is a single repository returned to the frontend for display. -type GitHubRepo struct { - FullName string `json:"fullName"` - Name string `json:"name"` - Owner string `json:"owner"` - Description string `json:"description"` - HTMLURL string `json:"htmlURL"` - DefaultBranch string `json:"defaultBranch"` - LatestCommitter string `json:"latestCommitter"` - LatestCommitAt string `json:"latestCommitAt"` - Private bool `json:"private"` - Fork bool `json:"fork"` - Archived bool `json:"archived"` - Language string `json:"language"` - Stars int `json:"stars"` - PushedAt string `json:"pushedAt"` // ISO 8601 - UpdatedAt string `json:"updatedAt"` -} - -// SessionState represents the lifecycle state of a connection session. -type SessionState string - -const ( - SessionConnecting SessionState = "connecting" - SessionConnected SessionState = "connected" - SessionDisconnected SessionState = "disconnected" - SessionReconnecting SessionState = "reconnecting" - SessionError SessionState = "error" -) - -// SessionInfo provides metadata about an active or recent session. -type SessionInfo struct { - ID string `json:"id"` - ProfileID string `json:"profileId"` - ProfileName string `json:"profileName"` - Protocol Protocol `json:"protocol"` - Host string `json:"host"` - Port int `json:"port"` - State SessionState `json:"state"` - TabName string `json:"tabName"` - Pinned bool `json:"pinned"` - ConnectedAt time.Time `json:"connectedAt" ts_type:"string"` - Error string `json:"error,omitempty"` -} - -// KnownHost records a previously-seen SSH host key fingerprint. -type KnownHost struct { - ID string `json:"id"` - Host string `json:"host"` - Port int `json:"port"` - KeyType string `json:"keyType"` - Fingerprint string `json:"fingerprint"` - PublicKey string `json:"publicKey"` - FirstSeen time.Time `json:"firstSeen" ts_type:"string"` - LastSeen time.Time `json:"lastSeen" ts_type:"string"` - Trusted bool `json:"trusted"` -} - -// AppPreferences stores user-level application settings. -type AppPreferences struct { - Theme string `json:"theme"` - TerminalTheme string `json:"terminalTheme"` // colour scheme name (e.g. "charcoal", "solarized-dark") - DefaultProtocol string `json:"defaultProtocol"` - ConfirmOnClose bool `json:"confirmOnClose"` - ConfirmOnDisconnect bool `json:"confirmOnDisconnect"` - ShowStatusBar bool `json:"showStatusBar"` - SidebarWidth int `json:"sidebarWidth"` - DefaultFontFamily string `json:"defaultFontFamily"` - DefaultFontSize int `json:"defaultFontSize"` - DefaultScrollback int `json:"defaultScrollback"` - DefaultCursorStyle string `json:"defaultCursorStyle"` - UseNativePaste bool `json:"useNativePaste"` - LocalShell string `json:"localShell"` // path to shell binary for local terminal - LeviathanEnabled bool `json:"leviathanEnabled"` -} - -// AuditEntry records an auditable action within the application. -type AuditEntry struct { - ID string `json:"id"` - Timestamp time.Time `json:"timestamp" ts_type:"string"` - Action string `json:"action"` - ProfileID string `json:"profileId,omitempty"` - SessionID string `json:"sessionId,omitempty"` - Details string `json:"details"` -} - -// Snippet stores a reusable command for the snippet library. -type Snippet struct { - ID string `json:"id"` - Name string `json:"name"` - Command string `json:"command"` - Description string `json:"description"` - Folder string `json:"folder"` - Tags []string `json:"tags"` - CreatedAt time.Time `json:"createdAt" ts_type:"string"` - UpdatedAt time.Time `json:"updatedAt" ts_type:"string"` -} diff --git a/internal/preferences/preferences.go b/internal/preferences/preferences.go deleted file mode 100644 index 0ead2f2..0000000 --- a/internal/preferences/preferences.go +++ /dev/null @@ -1,107 +0,0 @@ -// Package preferences manages application preferences with in-memory caching -// and persistence to a backing store. -package preferences - -import ( - "fmt" - "sync" - - "github.com/andydixon/link/internal/models" -) - -// PreferenceStore is the persistence interface used by the Manager. -type PreferenceStore interface { - GetPreferences() (*models.AppPreferences, error) - SavePreferences(prefs *models.AppPreferences) error -} - -// Manager handles application preferences with defaults and caching. -// It is safe for concurrent use. -type Manager struct { - store PreferenceStore - cache *models.AppPreferences - mu sync.RWMutex -} - -// NewManager creates a preferences Manager backed by the given store. -// Call Load to populate the cache from disc. -func NewManager(store PreferenceStore) *Manager { - return &Manager{store: store} -} - -// Load reads preferences from the backing store into memory. If no -// preferences have been saved yet, sensible defaults are applied. -func (m *Manager) Load() error { - m.mu.Lock() - defer m.mu.Unlock() - - prefs, err := m.store.GetPreferences() - if err != nil { - return fmt.Errorf("preferences: failed to load: %w", err) - } - - if prefs == nil { - defaults := models.DefaultAppPreferences() - m.cache = &defaults - } else { - m.cache = prefs - m.applyDefaults() - } - return nil -} - -// Get returns the current preferences. If Load has not been called, defaults -// are returned. -func (m *Manager) Get() *models.AppPreferences { - m.mu.RLock() - defer m.mu.RUnlock() - - if m.cache == nil { - defaults := models.DefaultAppPreferences() - return &defaults - } - // Return a copy to prevent callers mutating the cache. - cp := *m.cache - return &cp -} - -// Save persists the given preferences to the backing store and updates the -// in-memory cache. -func (m *Manager) Save(prefs *models.AppPreferences) error { - m.mu.Lock() - defer m.mu.Unlock() - - if err := m.store.SavePreferences(prefs); err != nil { - return fmt.Errorf("preferences: failed to save: %w", err) - } - cp := *prefs - m.cache = &cp - return nil -} - -// applyDefaults fills in zero-valued fields from the default preferences. -// Caller must hold m.mu. -func (m *Manager) applyDefaults() { - defaults := models.DefaultAppPreferences() - if m.cache.Theme == "" { - m.cache.Theme = defaults.Theme - } - if m.cache.DefaultProtocol == "" { - m.cache.DefaultProtocol = defaults.DefaultProtocol - } - if m.cache.SidebarWidth == 0 { - m.cache.SidebarWidth = defaults.SidebarWidth - } - if m.cache.DefaultFontFamily == "" { - m.cache.DefaultFontFamily = defaults.DefaultFontFamily - } - if m.cache.DefaultFontSize == 0 { - m.cache.DefaultFontSize = defaults.DefaultFontSize - } - if m.cache.DefaultScrollback == 0 { - m.cache.DefaultScrollback = defaults.DefaultScrollback - } - if m.cache.DefaultCursorStyle == "" { - m.cache.DefaultCursorStyle = defaults.DefaultCursorStyle - } -} diff --git a/internal/rawclient/encoding.go b/internal/rawclient/encoding.go deleted file mode 100644 index a7acc2b..0000000 --- a/internal/rawclient/encoding.go +++ /dev/null @@ -1,229 +0,0 @@ -package rawclient - -import ( - "encoding/hex" - "fmt" - "strconv" - "strings" - "unicode" - "unicode/utf8" - - "golang.org/x/text/encoding/charmap" -) - -// ParseHexString converts a hex string (with optional spaces and 0x prefixes) -// to raw bytes. -// -// Accepted formats: -// -// "48 65 6c 6c 6f" -// "48656c6c6f" -// "0x48 0x65 0x6c 0x6c 0x6f" -func ParseHexString(s string) ([]byte, error) { - s = strings.TrimSpace(s) - if s == "" { - return nil, fmt.Errorf("empty hex string") - } - - // Strip 0x/0X prefixes. - s = strings.ReplaceAll(s, "0x", "") - s = strings.ReplaceAll(s, "0X", "") - - // Collect only hex digit characters. - var cleaned strings.Builder - for _, r := range s { - if unicode.IsSpace(r) { - continue - } - if !isHexDigit(r) { - return nil, fmt.Errorf("invalid character in hex string: %q", r) - } - cleaned.WriteRune(r) - } - - if cleaned.Len()%2 != 0 { - return nil, fmt.Errorf("hex string has odd number of digits") - } - - return hex.DecodeString(cleaned.String()) -} - -// FormatHex formats bytes as a space-separated hex string. -// Example output: "48 65 6c 6c 6f" -func FormatHex(data []byte) string { - if len(data) == 0 { - return "" - } - parts := make([]string, len(data)) - for i, b := range data { - parts[i] = fmt.Sprintf("%02x", b) - } - return strings.Join(parts, " ") -} - -// FormatHexDump formats bytes as a traditional hex dump with offset, hex, -// and ASCII columns. Each line shows 16 bytes. -// -// Example output: -// -// 00000000 48 65 6c 6c 6f 20 57 6f 72 6c 64 21 |Hello World! | -func FormatHexDump(data []byte) string { - if len(data) == 0 { - return "" - } - - var sb strings.Builder - for offset := 0; offset < len(data); offset += 16 { - fmt.Fprintf(&sb, "%08x ", offset) - - end := offset + 16 - if end > len(data) { - end = len(data) - } - chunk := data[offset:end] - - // Hex columns (two groups of eight bytes). - for i := 0; i < 16; i++ { - if i == 8 { - sb.WriteByte(' ') - } - if i < len(chunk) { - fmt.Fprintf(&sb, "%02x ", chunk[i]) - } else { - sb.WriteString(" ") - } - } - - // ASCII column. - sb.WriteString(" |") - for _, b := range chunk { - if b >= 0x20 && b <= 0x7e { - sb.WriteByte(b) - } else { - sb.WriteByte('.') - } - } - for i := len(chunk); i < 16; i++ { - sb.WriteByte(' ') - } - sb.WriteString("|\n") - } - return sb.String() -} - -// ParseEscaped processes C-style escape sequences in a string. -// Supported sequences: \r \n \t \\ \0 \xNN -func ParseEscaped(s string) ([]byte, error) { - var result []byte - i := 0 - for i < len(s) { - if s[i] != '\\' { - r, size := utf8.DecodeRuneInString(s[i:]) - if r == utf8.RuneError && size <= 1 { - result = append(result, s[i]) - i++ - } else { - result = append(result, s[i:i+size]...) - i += size - } - continue - } - - if i+1 >= len(s) { - return nil, fmt.Errorf("trailing backslash at position %d", i) - } - - next := s[i+1] - switch next { - case 'r': - result = append(result, '\r') - i += 2 - case 'n': - result = append(result, '\n') - i += 2 - case 't': - result = append(result, '\t') - i += 2 - case '\\': - result = append(result, '\\') - i += 2 - case '0': - result = append(result, 0) - i += 2 - case 'x', 'X': - if i+3 >= len(s) { - return nil, fmt.Errorf("incomplete \\x escape at position %d", i) - } - hexStr := s[i+2 : i+4] - b, err := strconv.ParseUint(hexStr, 16, 8) - if err != nil { - return nil, fmt.Errorf("invalid \\x escape at position %d: %s", i, hexStr) - } - result = append(result, byte(b)) - i += 4 - default: - return nil, fmt.Errorf("unknown escape sequence \\%c at position %d", next, i) - } - } - return result, nil -} - -// ToDisplayEncoding converts raw bytes to a display string using the -// specified encoding. Supported encodings: "utf-8" (default), "ascii", -// "iso-8859-1" (also "latin1"). -func ToDisplayEncoding(data []byte, encoding string) string { - switch strings.ToLower(strings.TrimSpace(encoding)) { - case "ascii": - return toASCII(data) - case "iso-8859-1", "latin1", "latin-1": - return toLatin1(data) - default: - return toUTF8(data) - } -} - -// toASCII replaces non-ASCII bytes with '.', preserving common control chars. -func toASCII(data []byte) string { - var sb strings.Builder - sb.Grow(len(data)) - for _, b := range data { - if b >= 0x20 && b <= 0x7e { - sb.WriteByte(b) - } else if b == '\n' || b == '\r' || b == '\t' { - sb.WriteByte(b) - } else { - sb.WriteByte('.') - } - } - return sb.String() -} - -// toLatin1 decodes bytes as ISO 8859-1. -func toLatin1(data []byte) string { - decoded, err := charmap.ISO8859_1.NewDecoder().Bytes(data) - if err != nil { - return string(data) - } - return string(decoded) -} - -// toUTF8 ensures the output is valid UTF-8, replacing invalid bytes with -// the Unicode replacement character. -func toUTF8(data []byte) string { - if utf8.Valid(data) { - return string(data) - } - var sb strings.Builder - sb.Grow(len(data)) - for len(data) > 0 { - r, size := utf8.DecodeRune(data) - sb.WriteRune(r) - data = data[size:] - } - return sb.String() -} - -// isHexDigit reports whether r is a valid hexadecimal digit. -func isHexDigit(r rune) bool { - return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') -} diff --git a/internal/rawclient/encoding_test.go b/internal/rawclient/encoding_test.go deleted file mode 100644 index 949ef20..0000000 --- a/internal/rawclient/encoding_test.go +++ /dev/null @@ -1,226 +0,0 @@ -package rawclient - -import ( - "bytes" - "strings" - "testing" -) - -// --------------------------------------------------------------------------- -// ParseHexString -// --------------------------------------------------------------------------- - -func TestParseHexString(t *testing.T) { - tests := []struct { - name string - input string - want []byte - wantErr bool - }{ - { - name: "contiguous hex for Hello", - input: "48656c6c6f", - want: []byte("Hello"), - }, - { - name: "space-separated hex", - input: "48 65 6c 6c 6f", - want: []byte("Hello"), - }, - { - name: "0x prefix", - input: "0x48 0x65 0x6c 0x6c 0x6f", - want: []byte("Hello"), - }, - { - name: "empty string", - input: "", - wantErr: true, - }, - { - name: "invalid characters", - input: "ZZZZ", - wantErr: true, - }, - { - name: "null bytes", - input: "0000", - want: []byte{0x00, 0x00}, - }, - { - name: "0xff bytes", - input: "ff ff", - want: []byte{0xff, 0xff}, - }, - { - name: "odd number of digits", - input: "abc", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ParseHexString(tt.input) - if (err != nil) != tt.wantErr { - t.Errorf("ParseHexString(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) - return - } - if !tt.wantErr && !bytes.Equal(got, tt.want) { - t.Errorf("ParseHexString(%q) = %v, want %v", tt.input, got, tt.want) - } - }) - } -} - -// --------------------------------------------------------------------------- -// ParseEscaped -// --------------------------------------------------------------------------- - -func TestParseEscaped(t *testing.T) { - tests := []struct { - name string - input string - want []byte - wantErr bool - }{ - {name: "plain text", input: "hello", want: []byte("hello")}, - {name: "newline escape", input: `\n`, want: []byte{'\n'}}, - {name: "carriage return + newline", input: `\r\n`, want: []byte{'\r', '\n'}}, - {name: "tab escape", input: `\t`, want: []byte{'\t'}}, - {name: "null byte", input: `\0`, want: []byte{0x00}}, - {name: "hex escape 0xff", input: `\xff`, want: []byte{0xff}}, - {name: "escaped backslash", input: `\\`, want: []byte{'\\'}}, - {name: "mixed AT command", input: `AT\r\n`, want: []byte{'A', 'T', '\r', '\n'}}, - {name: "hex escape 0x00", input: `\x00`, want: []byte{0x00}}, - {name: "trailing backslash", input: `hello\`, wantErr: true}, - {name: "unknown escape", input: `\q`, wantErr: true}, - {name: "incomplete hex escape", input: `\x0`, wantErr: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ParseEscaped(tt.input) - if (err != nil) != tt.wantErr { - t.Errorf("ParseEscaped(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) - return - } - if !tt.wantErr && !bytes.Equal(got, tt.want) { - t.Errorf("ParseEscaped(%q) = %v, want %v", tt.input, got, tt.want) - } - }) - } -} - -// --------------------------------------------------------------------------- -// FormatHexDump -// --------------------------------------------------------------------------- - -func TestFormatHexDump(t *testing.T) { - t.Run("empty data", func(t *testing.T) { - got := FormatHexDump(nil) - if got != "" { - t.Errorf("FormatHexDump(nil) = %q, want empty", got) - } - }) - - t.Run("small data", func(t *testing.T) { - got := FormatHexDump([]byte("Hi")) - if !strings.Contains(got, "48 69") { - t.Errorf("expected hex for 'Hi', got:\n%s", got) - } - // Should contain the ASCII display column. - if !strings.Contains(got, "|Hi") { - t.Errorf("expected ASCII column with 'Hi', got:\n%s", got) - } - }) - - t.Run("16+ bytes spans multiple lines", func(t *testing.T) { - data := []byte("ABCDEFGHIJKLMNOPQRST") // 20 bytes - got := FormatHexDump(data) - lines := strings.Split(strings.TrimRight(got, "\n"), "\n") - if len(lines) < 2 { - t.Errorf("expected at least 2 lines for 20 bytes, got %d", len(lines)) - } - // First line should start with offset 00000000. - if !strings.HasPrefix(lines[0], "00000000") { - t.Errorf("first line should start with 00000000, got %q", lines[0]) - } - // Second line should start with offset 00000010. - if !strings.HasPrefix(lines[1], "00000010") { - t.Errorf("second line should start with 00000010, got %q", lines[1]) - } - }) -} - -// --------------------------------------------------------------------------- -// FormatHex -// --------------------------------------------------------------------------- - -func TestFormatHex(t *testing.T) { - tests := []struct { - name string - input []byte - want string - }{ - {name: "empty", input: nil, want: ""}, - {name: "single byte", input: []byte{0x42}, want: "42"}, - {name: "Hello", input: []byte("Hello"), want: "48 65 6c 6c 6f"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := FormatHex(tt.input) - if got != tt.want { - t.Errorf("FormatHex() = %q, want %q", got, tt.want) - } - }) - } -} - -// --------------------------------------------------------------------------- -// ToDisplayEncoding -// --------------------------------------------------------------------------- - -func TestToDisplayEncoding(t *testing.T) { - tests := []struct { - name string - data []byte - encoding string - contains string - }{ - { - name: "utf-8 passthrough", - data: []byte("Hello, World!"), - encoding: "utf-8", - contains: "Hello, World!", - }, - { - name: "ascii replaces non-printable", - data: []byte{0x80, 0x41}, - encoding: "ascii", - contains: ".A", - }, - { - name: "latin-1 decodes high bytes", - data: []byte{0xe9}, // é in ISO 8859-1 - encoding: "latin-1", - contains: "é", - }, - { - name: "iso-8859-1 alias", - data: []byte{0xe9}, - encoding: "iso-8859-1", - contains: "é", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ToDisplayEncoding(tt.data, tt.encoding) - if !strings.Contains(got, tt.contains) { - t.Errorf("ToDisplayEncoding() = %q, expected to contain %q", got, tt.contains) - } - }) - } -} diff --git a/internal/rawclient/history_test.go b/internal/rawclient/history_test.go deleted file mode 100644 index 533d685..0000000 --- a/internal/rawclient/history_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package rawclient - -import ( - "bytes" - "testing" - - "github.com/andydixon/link/internal/models" -) - -func TestHistoryIsCappedByEntryCount(t *testing.T) { - client := NewClient(&models.Profile{}) - - for i := 0; i < maxHistoryEntries+25; i++ { - client.appendHistory(HistoryEntry{Direction: "recv", Data: []byte{byte(i)}}) - } - - history := client.GetHistory() - if len(history) != maxHistoryEntries { - t.Fatalf("history length = %d, want %d", len(history), maxHistoryEntries) - } -} - -func TestHistoryIsCappedByBytes(t *testing.T) { - client := NewClient(&models.Profile{}) - chunk := bytes.Repeat([]byte("a"), 1024) - - for i := 0; i < (maxHistoryBytes/len(chunk))+10; i++ { - client.appendHistory(HistoryEntry{Direction: "recv", Data: chunk}) - } - - total := 0 - for _, entry := range client.GetHistory() { - total += len(entry.Data) - } - if total > maxHistoryBytes { - t.Fatalf("history bytes = %d, want <= %d", total, maxHistoryBytes) - } -} - -func TestGetHistoryReturnsDeepCopy(t *testing.T) { - client := NewClient(&models.Profile{}) - client.appendHistory(HistoryEntry{Direction: "recv", Data: []byte("secret")}) - - history := client.GetHistory() - history[0].Data[0] = 'X' - - again := client.GetHistory() - if string(again[0].Data) != "secret" { - t.Fatalf("history was mutated through returned slice: %q", string(again[0].Data)) - } -} diff --git a/internal/rawclient/rawclient.go b/internal/rawclient/rawclient.go deleted file mode 100644 index 0c1427c..0000000 --- a/internal/rawclient/rawclient.go +++ /dev/null @@ -1,332 +0,0 @@ -package rawclient - -import ( - "context" - "encoding/json" - "fmt" - "net" - "os" - "strings" - "sync" - "time" - - "github.com/andydixon/link/internal/models" -) - -const ( - maxHistoryEntries = 10000 - maxHistoryBytes = 10 * 1024 * 1024 -) - -// Client manages a raw TCP connection. -// It provides binary-safe data handling with support for text and hex views, -// timestamps, direction markers, and payload presets. -type Client struct { - mu sync.Mutex - config *models.Profile - conn net.Conn - connected bool - onData func(data []byte, direction string, timestamp time.Time) - onError func(err error) - onClose func() - ctx context.Context - cancel context.CancelFunc - history []HistoryEntry - historyBytes int - historyMu sync.RWMutex -} - -// HistoryEntry records a single sent or received data frame with its timestamp. -type HistoryEntry struct { - Timestamp time.Time `json:"timestamp"` - Direction string `json:"direction"` // "send" or "recv" - Data []byte `json:"data"` -} - -// NewClient creates a new raw TCP client bound to the given profile. -func NewClient(profile *models.Profile) *Client { - ctx, cancel := context.WithCancel(context.Background()) - return &Client{ - config: profile, - ctx: ctx, - cancel: cancel, - } -} - -// SetCallbacks sets the data, error, and close callbacks. Must be called -// before Connect. -func (c *Client) SetCallbacks(onData func([]byte, string, time.Time), onError func(error), onClose func()) { - c.mu.Lock() - defer c.mu.Unlock() - c.onData = onData - c.onError = onError - c.onClose = onClose -} - -// rawConf returns the RAW-specific configuration, or a zero value if nil. -func (c *Client) rawConf() models.RAWConfig { - if c.config.RAW != nil { - return *c.config.RAW - } - return models.RAWConfig{} -} - -// Connect establishes the raw TCP connection and starts the read loop. -func (c *Client) Connect() error { - c.mu.Lock() - if c.connected { - c.mu.Unlock() - return fmt.Errorf("already connected") - } - c.mu.Unlock() - - timeout := time.Duration(c.config.ConnectTimeout) * time.Second - if timeout == 0 { - timeout = 30 * time.Second - } - - port := c.config.Port - if port == 0 { - return fmt.Errorf("port must be specified for raw TCP connections") - } - addr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", port)) - - conn, err := net.DialTimeout("tcp", addr, timeout) - if err != nil { - return fmt.Errorf("raw TCP connection to %s failed: %w", addr, err) - } - - c.mu.Lock() - c.conn = conn - c.connected = true - c.mu.Unlock() - - go c.readLoop() - return nil -} - -// Write sends raw bytes to the connection and records the frame in history. -func (c *Client) Write(data []byte) error { - c.mu.Lock() - if !c.connected || c.conn == nil { - c.mu.Unlock() - return fmt.Errorf("not connected") - } - conn := c.conn - c.mu.Unlock() - - _, err := conn.Write(data) - if err != nil { - return err - } - - now := time.Now() - c.appendHistory(HistoryEntry{ - Timestamp: now, - Direction: "send", - Data: copyBytes(data), - }) - - c.mu.Lock() - cb := c.onData - c.mu.Unlock() - if cb != nil { - cb(copyBytes(data), "send", now) - } - - return nil -} - -// WriteText sends text with the configured line ending appended. -func (c *Client) WriteText(text string) error { - data := c.applyLineEnding(text) - return c.Write(data) -} - -// WriteHex sends hex-encoded bytes (e.g. "48656c6c6f" or "48 65 6c 6c 6f"). -func (c *Client) WriteHex(hexStr string) error { - data, err := ParseHexString(hexStr) - if err != nil { - return fmt.Errorf("invalid hex input: %w", err) - } - return c.Write(data) -} - -// WriteEscaped sends text with C-style escape sequences processed -// (\r, \n, \t, \x00, etc.). -func (c *Client) WriteEscaped(text string) error { - data, err := ParseEscaped(text) - if err != nil { - return fmt.Errorf("escape processing failed: %w", err) - } - return c.Write(data) -} - -// Close shuts down the raw TCP connection and invokes the onClose callback. -func (c *Client) Close() error { - c.mu.Lock() - if !c.connected { - c.mu.Unlock() - return nil - } - c.connected = false - conn := c.conn - onClose := c.onClose - c.mu.Unlock() - - c.cancel() - - var err error - if conn != nil { - err = conn.Close() - } - if onClose != nil { - onClose() - } - return err -} - -// IsConnected returns the current connection state. -func (c *Client) IsConnected() bool { - c.mu.Lock() - defer c.mu.Unlock() - return c.connected -} - -// GetHistory returns a copy of the session data history. -func (c *Client) GetHistory() []HistoryEntry { - c.historyMu.RLock() - defer c.historyMu.RUnlock() - - out := make([]HistoryEntry, len(c.history)) - for i, entry := range c.history { - out[i] = entry - out[i].Data = copyBytes(entry.Data) - } - return out -} - -// ClearHistory clears the session history buffer. -func (c *Client) ClearHistory() { - c.historyMu.Lock() - defer c.historyMu.Unlock() - c.history = nil - c.historyBytes = 0 -} - -// SaveCapture writes the session history to a file in JSON format. -func (c *Client) SaveCapture(path string) error { - entries := c.GetHistory() - data, err := json.MarshalIndent(entries, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal capture data: %w", err) - } - return os.WriteFile(path, data, 0600) -} - -// readLoop reads from the connection and dispatches received data to the -// onData callback. -func (c *Client) readLoop() { - buf := make([]byte, 4096) - - for { - select { - case <-c.ctx.Done(): - return - default: - } - - c.mu.Lock() - conn := c.conn - connected := c.connected - c.mu.Unlock() - - if !connected || conn == nil { - return - } - - n, err := conn.Read(buf) - if n > 0 { - now := time.Now() - data := copyBytes(buf[:n]) - - c.appendHistory(HistoryEntry{ - Timestamp: now, - Direction: "recv", - Data: data, - }) - - c.mu.Lock() - cb := c.onData - c.mu.Unlock() - if cb != nil { - cb(data, "recv", now) - } - } - - if err != nil { - c.mu.Lock() - cb := c.onError - c.mu.Unlock() - if cb != nil { - cb(fmt.Errorf("read error: %w", err)) - } - c.Close() - return - } - } -} - -// appendHistory records a history entry in a thread-safe manner. -func (c *Client) appendHistory(entry HistoryEntry) { - c.historyMu.Lock() - defer c.historyMu.Unlock() - - if len(entry.Data) > maxHistoryBytes { - entry.Data = copyBytes(entry.Data[len(entry.Data)-maxHistoryBytes:]) - } - c.history = append(c.history, entry) - c.historyBytes += len(entry.Data) - for len(c.history) > maxHistoryEntries || c.historyBytes > maxHistoryBytes { - if len(c.history) == 0 { - c.historyBytes = 0 - return - } - c.historyBytes -= len(c.history[0].Data) - c.history[0].Data = nil - c.history = c.history[1:] - } -} - -// applyLineEnding appends the configured line ending to text. -func (c *Client) applyLineEnding(text string) []byte { - rc := c.rawConf() - le := rc.LineEnding - if le == "" { - le = models.LineEndingCRLF - } - - var suffix string - switch le { - case models.LineEndingCR: - suffix = "\r" - case models.LineEndingLF: - suffix = "\n" - case models.LineEndingCRLF: - suffix = "\r\n" - case models.LineEndingNone: - // No suffix. - } - - if suffix != "" && !strings.HasSuffix(text, suffix) { - text += suffix - } - return []byte(text) -} - -// copyBytes returns an independent copy of a byte slice. -func copyBytes(data []byte) []byte { - out := make([]byte, len(data)) - copy(out, data) - return out -} diff --git a/internal/recording/recorder.go b/internal/recording/recorder.go deleted file mode 100644 index b54a2cf..0000000 --- a/internal/recording/recorder.go +++ /dev/null @@ -1,538 +0,0 @@ -// Package recording provides session recording in asciicast v2 format. -// Recordings capture terminal I/O with timestamps, enabling playback of -// complete sessions for auditing, training, or troubleshooting. -// -// File format: https://docs.asciinema.org/manual/asciicast/v2/ -// -// The header line is a JSON object with metadata (version, dimensions, etc.) -// followed by one JSON array per event: [elapsed_secs, event_type, data]. -package recording - -import ( - "bufio" - "bytes" - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "time" -) - -// EventType distinguishes input from output events. -type EventType string - -const ( - // EventOutput represents data sent from the remote host to the terminal. - EventOutput EventType = "o" - // EventInput represents data sent from the user to the remote host. - EventInput EventType = "i" - - // MaxRecordingBytes bounds a single active recording file. - MaxRecordingBytes = int64(250 * 1024 * 1024) - // MaxPlaybackBytes bounds the file size loaded for playback. - MaxPlaybackBytes = int64(100 * 1024 * 1024) - // MaxPlaybackLineBytes bounds one asciicast line during streaming playback - // parsing. Terminal events should stay well below this in normal use. - MaxPlaybackLineBytes = 8 * 1024 * 1024 -) - -// Header is the asciicast v2 file header (first line). -type Header struct { - Version int `json:"version"` - Width int `json:"width"` - Height int `json:"height"` - Timestamp int64 `json:"timestamp"` - Title string `json:"title,omitempty"` - Env map[string]string `json:"env,omitempty"` -} - -// Event is a single recorded I/O event: [elapsed_seconds, type, data]. -type Event struct { - Elapsed float64 `json:"-"` - Type EventType `json:"-"` - Data string `json:"-"` -} - -// MarshalJSON serialises an event as the asciicast v2 array format. -func (e Event) MarshalJSON() ([]byte, error) { - return json.Marshal([]interface{}{e.Elapsed, string(e.Type), e.Data}) -} - -// RecordingMeta describes a saved recording without loading the full data. -type RecordingMeta struct { - ID string `json:"id"` - SessionID string `json:"sessionId"` - Title string `json:"title"` - Width int `json:"width"` - Height int `json:"height"` - Duration float64 `json:"duration"` // seconds - Size int64 `json:"size"` // file size in bytes - CreatedAt time.Time `json:"createdAt"` - FilePath string `json:"filePath"` -} - -// Recorder captures terminal I/O for a single session. -type Recorder struct { - mu sync.Mutex - file *os.File - startTime time.Time - header Header - eventCount int - closed bool - filePath string - bytesWritten int64 - maxBytes int64 -} - -// NewRecorder creates a new recording file and writes the header. -func NewRecorder(dir, id, title string, cols, rows int) (*Recorder, error) { - return newRecorderWithLimit(dir, id, title, cols, rows, MaxRecordingBytes) -} - -func newRecorderWithLimit(dir, id, title string, cols, rows int, maxBytes int64) (*Recorder, error) { - if err := os.MkdirAll(dir, 0700); err != nil { - return nil, fmt.Errorf("recording: failed to create directory: %w", err) - } - - filename := fmt.Sprintf("%s.cast", id) - path := filepath.Join(dir, filename) - - f, err := os.Create(path) - if err != nil { - return nil, fmt.Errorf("recording: failed to create file: %w", err) - } - - now := time.Now() - hdr := Header{ - Version: 2, - Width: cols, - Height: rows, - Timestamp: now.Unix(), - Title: title, - Env: map[string]string{ - "TERM": "xterm-256color", - }, - } - - headerLine, err := marshalJSONLine(hdr) - if err != nil { - f.Close() - os.Remove(path) - return nil, fmt.Errorf("recording: failed to encode header: %w", err) - } - n, err := f.Write(headerLine) - if err != nil { - f.Close() - os.Remove(path) - return nil, fmt.Errorf("recording: failed to write header: %w", err) - } - - return &Recorder{ - file: f, - startTime: now, - header: hdr, - filePath: path, - bytesWritten: int64(n), - maxBytes: maxBytes, - }, nil -} - -// WriteOutput records terminal output (data from remote host). -func (r *Recorder) WriteOutput(data []byte) { - r.writeEvent(EventOutput, data) -} - -// WriteInput records terminal input (data from user). -func (r *Recorder) WriteInput(data []byte) { - r.writeEvent(EventInput, data) -} - -// writeEvent appends a timestamped event to the recording file. -func (r *Recorder) writeEvent(eventType EventType, data []byte) { - r.mu.Lock() - defer r.mu.Unlock() - - if r.closed || r.file == nil { - return - } - - elapsed := time.Since(r.startTime).Seconds() - evt := Event{ - Elapsed: elapsed, - Type: eventType, - Data: string(data), - } - - line, err := marshalJSONLine(evt) - if err != nil { - return - } - if r.maxBytes > 0 && r.bytesWritten+int64(len(line)) > r.maxBytes { - r.closed = true - _ = r.file.Sync() - _ = r.file.Close() - r.file = nil - return - } - - n, err := r.file.Write(line) - if err != nil { - return - } - r.bytesWritten += int64(n) - r.eventCount++ -} - -// UpdateSize records a terminal resize (not part of asciicast v2 events, -// but useful for future playback fidelity). Currently a no-op placeholder. -func (r *Recorder) UpdateSize(cols, rows int) { - // asciicast v2 does not have a standard resize event. Future extension - // could use a custom event type like "r" with JSON dimensions. -} - -// Close flushes and closes the recording file. -func (r *Recorder) Close() error { - r.mu.Lock() - defer r.mu.Unlock() - - if r.closed { - return nil - } - r.closed = true - - if r.file != nil { - if err := r.file.Sync(); err != nil { - r.file.Close() - return fmt.Errorf("recording: sync failed: %w", err) - } - err := r.file.Close() - r.file = nil - return err - } - return nil -} - -// Duration returns the elapsed time since recording started. -func (r *Recorder) Duration() float64 { - r.mu.Lock() - defer r.mu.Unlock() - return time.Since(r.startTime).Seconds() -} - -// FilePath returns the absolute path to the recording file. -func (r *Recorder) FilePath() string { - return r.filePath -} - -// EventCount returns the number of events recorded so far. -func (r *Recorder) EventCount() int { - r.mu.Lock() - defer r.mu.Unlock() - return r.eventCount -} - -// --------------------------------------------------------------------------- -// Recording store — manages listing, reading, and deleting recordings. -// --------------------------------------------------------------------------- - -// Store manages the recording directory. -type Store struct { - dir string -} - -// NewStore creates a recording store at the given directory. -func NewStore(dir string) *Store { - return &Store{dir: dir} -} - -// Dir returns the recordings directory path. -func (s *Store) Dir() string { - return s.dir -} - -// List returns metadata for all recordings in the store. -func (s *Store) List() ([]RecordingMeta, error) { - if err := os.MkdirAll(s.dir, 0700); err != nil { - return nil, err - } - - entries, err := os.ReadDir(s.dir) - if err != nil { - return nil, fmt.Errorf("recording: failed to read directory: %w", err) - } - - var results []RecordingMeta - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".cast") { - continue - } - - meta, err := s.readMeta(entry.Name()) - if err != nil { - continue // skip unreadable files - } - results = append(results, *meta) - } - - // Sort by creation time, newest first. - sort.Slice(results, func(i, j int) bool { - return results[i].CreatedAt.After(results[j].CreatedAt) - }) - - return results, nil -} - -// Get reads the full recording (header + events) from a file. -func (s *Store) Get(id string) (*Header, []Event, error) { - return s.getWithLimit(id, MaxPlaybackBytes) -} - -func (s *Store) getWithLimit(id string, maxBytes int64) (*Header, []Event, error) { - path, err := s.recordingPath(id) - if err != nil { - return nil, nil, err - } - info, err := os.Stat(path) - if err != nil { - return nil, nil, fmt.Errorf("recording: failed to stat file: %w", err) - } - if maxBytes > 0 && info.Size() > maxBytes { - return nil, nil, fmt.Errorf("recording: file is too large for playback (max %d bytes)", maxBytes) - } - file, err := os.Open(path) - if err != nil { - return nil, nil, fmt.Errorf("recording: failed to open file: %w", err) - } - defer file.Close() - - // Parse header (first line). - scanner := bufio.NewScanner(file) - scanner.Buffer(make([]byte, 0, 64*1024), MaxPlaybackLineBytes) - if !scanner.Scan() { - if err := scanner.Err(); err != nil { - return nil, nil, fmt.Errorf("recording: failed to scan header: %w", err) - } - return nil, nil, fmt.Errorf("recording: empty file") - } - var hdr Header - if err := json.Unmarshal([]byte(strings.TrimSpace(scanner.Text())), &hdr); err != nil { - return nil, nil, fmt.Errorf("recording: invalid header: %w", err) - } - - // Parse events (remaining lines). - events := make([]Event, 0) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - var raw []json.RawMessage - if err := json.Unmarshal([]byte(line), &raw); err != nil { - continue - } - if len(raw) != 3 { - continue - } - - var elapsed float64 - var evtType, evtData string - if err := json.Unmarshal(raw[0], &elapsed); err != nil { - continue - } - if err := json.Unmarshal(raw[1], &evtType); err != nil { - continue - } - if err := json.Unmarshal(raw[2], &evtData); err != nil { - continue - } - - events = append(events, Event{ - Elapsed: elapsed, - Type: EventType(evtType), - Data: evtData, - }) - } - if err := scanner.Err(); err != nil { - return nil, nil, fmt.Errorf("recording: failed to scan events: %w", err) - } - - return &hdr, events, nil -} - -// Delete removes a recording file. -func (s *Store) Delete(id string) error { - path, err := s.recordingPath(id) - if err != nil { - return err - } - if err := os.Remove(path); err != nil { - return fmt.Errorf("recording: failed to delete: %w", err) - } - return nil -} - -// StorageInfo summarizes recording storage usage. -type StorageInfo struct { - Count int `json:"count"` - TotalBytes int64 `json:"totalBytes"` - OldestCreatedAt *time.Time `json:"oldestCreatedAt,omitempty"` - NewestCreatedAt *time.Time `json:"newestCreatedAt,omitempty"` - MaxRecordingBytes int64 `json:"maxRecordingBytes"` - MaxPlaybackBytes int64 `json:"maxPlaybackBytes"` -} - -// CleanupResult describes files removed by retention cleanup. -type CleanupResult struct { - Deleted int `json:"deleted"` - FreedBytes int64 `json:"freedBytes"` -} - -// StorageInfo returns aggregate metadata for recordings. -func (s *Store) StorageInfo() (StorageInfo, error) { - metas, err := s.List() - if err != nil { - return StorageInfo{}, err - } - info := StorageInfo{ - Count: len(metas), - MaxRecordingBytes: MaxRecordingBytes, - MaxPlaybackBytes: MaxPlaybackBytes, - } - for i := range metas { - meta := metas[i] - info.TotalBytes += meta.Size - if info.OldestCreatedAt == nil || meta.CreatedAt.Before(*info.OldestCreatedAt) { - t := meta.CreatedAt - info.OldestCreatedAt = &t - } - if info.NewestCreatedAt == nil || meta.CreatedAt.After(*info.NewestCreatedAt) { - t := meta.CreatedAt - info.NewestCreatedAt = &t - } - } - return info, nil -} - -// Cleanup removes recordings older than maxAgeDays and/or enough oldest files -// to bring total storage below maxTotalBytes. Non-positive limits are ignored. -func (s *Store) Cleanup(maxAgeDays int, maxTotalBytes int64) (CleanupResult, error) { - metas, err := s.List() - if err != nil { - return CleanupResult{}, err - } - - result := CleanupResult{} - deleted := map[string]bool{} - total := int64(0) - for _, meta := range metas { - total += meta.Size - } - - deleteMeta := func(meta RecordingMeta) { - if deleted[meta.ID] { - return - } - if err := s.Delete(meta.ID); err == nil { - deleted[meta.ID] = true - result.Deleted++ - result.FreedBytes += meta.Size - total -= meta.Size - } - } - - if maxAgeDays > 0 { - cutoff := time.Now().Add(-time.Duration(maxAgeDays) * 24 * time.Hour) - for _, meta := range metas { - if meta.CreatedAt.Before(cutoff) { - deleteMeta(meta) - } - } - } - - if maxTotalBytes > 0 && total > maxTotalBytes { - sort.Slice(metas, func(i, j int) bool { - return metas[i].CreatedAt.Before(metas[j].CreatedAt) - }) - for _, meta := range metas { - if total <= maxTotalBytes { - break - } - deleteMeta(meta) - } - } - - return result, nil -} - -func (s *Store) recordingPath(id string) (string, error) { - id = strings.TrimSpace(id) - if id == "" || id == "." || id == ".." || strings.ContainsAny(id, `/\`) { - return "", fmt.Errorf("recording: invalid recording ID") - } - return filepath.Join(s.dir, id+".cast"), nil -} - -func marshalJSONLine(v interface{}) ([]byte, error) { - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - enc.SetEscapeHTML(false) - if err := enc.Encode(v); err != nil { - return nil, err - } - return buf.Bytes(), nil -} - -// readMeta reads just the header from a .cast file to produce metadata. -func (s *Store) readMeta(filename string) (*RecordingMeta, error) { - path := filepath.Join(s.dir, filename) - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - - info, err := f.Stat() - if err != nil { - return nil, err - } - - var hdr Header - dec := json.NewDecoder(f) - if err := dec.Decode(&hdr); err != nil { - return nil, err - } - - // Calculate duration by scanning for last event timestamp. - var duration float64 - for dec.More() { - var raw json.RawMessage - if err := dec.Decode(&raw); err != nil { - break - } - var arr []json.RawMessage - if err := json.Unmarshal(raw, &arr); err != nil || len(arr) < 1 { - continue - } - var elapsed float64 - if err := json.Unmarshal(arr[0], &elapsed); err == nil { - duration = elapsed - } - } - - id := strings.TrimSuffix(filename, ".cast") - - return &RecordingMeta{ - ID: id, - SessionID: id, // recording ID matches session ID by default - Title: hdr.Title, - Width: hdr.Width, - Height: hdr.Height, - Duration: duration, - Size: info.Size(), - CreatedAt: time.Unix(hdr.Timestamp, 0), - FilePath: path, - }, nil -} diff --git a/internal/recording/recorder_test.go b/internal/recording/recorder_test.go deleted file mode 100644 index ee95798..0000000 --- a/internal/recording/recorder_test.go +++ /dev/null @@ -1,283 +0,0 @@ -package recording - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestRecorderWriteAndClose(t *testing.T) { - dir := t.TempDir() - id := "test-session-001" - - rec, err := NewRecorder(dir, id, "Test Recording", 80, 24) - if err != nil { - t.Fatalf("NewRecorder failed: %v", err) - } - - // Write some output events. - rec.WriteOutput([]byte("Welcome to the server\r\n")) - rec.WriteOutput([]byte("$ ")) - - // Write some input events. - rec.WriteInput([]byte("ls -la\r\n")) - - // Write more output. - rec.WriteOutput([]byte("total 42\r\n")) - - if rec.EventCount() != 4 { - t.Errorf("expected 4 events, got %d", rec.EventCount()) - } - - if err := rec.Close(); err != nil { - t.Fatalf("Close failed: %v", err) - } - - // Verify the file exists and has correct format. - path := filepath.Join(dir, id+".cast") - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("failed to read recording: %v", err) - } - - lines := strings.Split(strings.TrimSpace(string(data)), "\n") - if len(lines) < 5 { // 1 header + 4 events - t.Fatalf("expected at least 5 lines, got %d", len(lines)) - } - - // Verify header. - var hdr Header - if err := json.Unmarshal([]byte(lines[0]), &hdr); err != nil { - t.Fatalf("failed to parse header: %v", err) - } - if hdr.Version != 2 { - t.Errorf("expected version 2, got %d", hdr.Version) - } - if hdr.Width != 80 || hdr.Height != 24 { - t.Errorf("expected 80x24, got %dx%d", hdr.Width, hdr.Height) - } - - // Verify first event is output. - var evt []json.RawMessage - if err := json.Unmarshal([]byte(lines[1]), &evt); err != nil { - t.Fatalf("failed to parse first event: %v", err) - } - if len(evt) != 3 { - t.Fatalf("expected 3-element array, got %d", len(evt)) - } - var evtType string - json.Unmarshal(evt[1], &evtType) - if evtType != "o" { - t.Errorf("expected event type 'o', got %q", evtType) - } -} - -func TestStoreListAndGet(t *testing.T) { - dir := t.TempDir() - - // Create a recording. - rec, err := NewRecorder(dir, "session-abc", "My Session", 120, 40) - if err != nil { - t.Fatalf("NewRecorder failed: %v", err) - } - rec.WriteOutput([]byte("hello world\r\n")) - time.Sleep(10 * time.Millisecond) - rec.WriteOutput([]byte("done\r\n")) - rec.Close() - - // Use the store to list. - store := NewStore(dir) - metas, err := store.List() - if err != nil { - t.Fatalf("List failed: %v", err) - } - if len(metas) != 1 { - t.Fatalf("expected 1 recording, got %d", len(metas)) - } - if metas[0].ID != "session-abc" { - t.Errorf("expected ID 'session-abc', got %q", metas[0].ID) - } - if metas[0].Width != 120 || metas[0].Height != 40 { - t.Errorf("unexpected dimensions: %dx%d", metas[0].Width, metas[0].Height) - } - - // Read full recording. - hdr, events, err := store.Get("session-abc") - if err != nil { - t.Fatalf("Get failed: %v", err) - } - if hdr.Title != "My Session" { - t.Errorf("expected title 'My Session', got %q", hdr.Title) - } - if len(events) != 2 { - t.Errorf("expected 2 events, got %d", len(events)) - } -} - -func TestStoreDelete(t *testing.T) { - dir := t.TempDir() - - rec, err := NewRecorder(dir, "to-delete", "Delete Me", 80, 24) - if err != nil { - t.Fatalf("NewRecorder failed: %v", err) - } - rec.WriteOutput([]byte("test\n")) - rec.Close() - - store := NewStore(dir) - - // Verify it exists. - metas, _ := store.List() - if len(metas) != 1 { - t.Fatalf("expected 1 recording, got %d", len(metas)) - } - - // Delete. - if err := store.Delete("to-delete"); err != nil { - t.Fatalf("Delete failed: %v", err) - } - - // Verify it's gone. - metas, _ = store.List() - if len(metas) != 0 { - t.Errorf("expected 0 recordings, got %d", len(metas)) - } -} - -func TestDoubleClose(t *testing.T) { - dir := t.TempDir() - rec, err := NewRecorder(dir, "double-close", "Test", 80, 24) - if err != nil { - t.Fatalf("NewRecorder failed: %v", err) - } - rec.WriteOutput([]byte("test")) - - if err := rec.Close(); err != nil { - t.Fatalf("first Close failed: %v", err) - } - // Second close should be a no-op. - if err := rec.Close(); err != nil { - t.Fatalf("second Close failed: %v", err) - } -} - -func TestRecorderStopsAtSizeLimit(t *testing.T) { - dir := t.TempDir() - rec, err := newRecorderWithLimit(dir, "limited", "Limited", 80, 24, 512) - if err != nil { - t.Fatalf("newRecorderWithLimit failed: %v", err) - } - - for i := 0; i < 50; i++ { - rec.WriteOutput([]byte(strings.Repeat("x", 80))) - } - if rec.EventCount() >= 50 { - t.Fatalf("expected recording to stop before all events were written") - } - if err := rec.Close(); err != nil { - t.Fatalf("Close failed: %v", err) - } - info, err := os.Stat(filepath.Join(dir, "limited.cast")) - if err != nil { - t.Fatalf("stat failed: %v", err) - } - if info.Size() > 512 { - t.Fatalf("recording size = %d, want <= 512", info.Size()) - } -} - -func TestStoreGetRejectsOversizedPlayback(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "huge.cast") - header := `{"version":2,"width":80,"height":24,"timestamp":1}` + "\n" - if err := os.WriteFile(path, []byte(header+strings.Repeat("x", 256)), 0600); err != nil { - t.Fatalf("write failed: %v", err) - } - - store := NewStore(dir) - _, _, err := store.getWithLimit("huge", 128) - if err == nil { - t.Fatal("expected oversized playback to be rejected") - } - if !strings.Contains(err.Error(), "too large") { - t.Fatalf("error = %q, want too large", err.Error()) - } -} - -func TestStoreCleanupByAgeAndSize(t *testing.T) { - dir := t.TempDir() - makeRecording := func(id string, created time.Time, payload string) { - rec, err := NewRecorder(dir, id, id, 80, 24) - if err != nil { - t.Fatalf("NewRecorder(%s) failed: %v", id, err) - } - rec.WriteOutput([]byte(payload)) - if err := rec.Close(); err != nil { - t.Fatalf("Close(%s) failed: %v", id, err) - } - path := filepath.Join(dir, id+".cast") - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("ReadFile(%s) failed: %v", id, err) - } - parts := strings.SplitN(string(data), "\n", 2) - var hdr Header - if err := json.Unmarshal([]byte(parts[0]), &hdr); err != nil { - t.Fatalf("header parse(%s) failed: %v", id, err) - } - hdr.Timestamp = created.Unix() - line, err := marshalJSONLine(hdr) - if err != nil { - t.Fatalf("header encode(%s) failed: %v", id, err) - } - rest := "" - if len(parts) == 2 { - rest = parts[1] - } - if err := os.WriteFile(path, append(line, []byte(rest)...), 0600); err != nil { - t.Fatalf("WriteFile(%s) failed: %v", id, err) - } - if err := os.Chtimes(path, created, created); err != nil { - t.Fatalf("Chtimes(%s) failed: %v", id, err) - } - } - - old := time.Now().Add(-45 * 24 * time.Hour) - recent := time.Now().Add(-2 * time.Hour) - makeRecording("old", old, "old") - makeRecording("recent", recent, strings.Repeat("recent", 50)) - - store := NewStore(dir) - result, err := store.Cleanup(30, 0) - if err != nil { - t.Fatalf("Cleanup by age failed: %v", err) - } - if result.Deleted != 1 { - t.Fatalf("deleted = %d, want 1", result.Deleted) - } - if _, err := os.Stat(filepath.Join(dir, "old.cast")); !os.IsNotExist(err) { - t.Fatalf("old recording still exists, stat err=%v", err) - } - - info, err := store.StorageInfo() - if err != nil { - t.Fatalf("StorageInfo failed: %v", err) - } - result, err = store.Cleanup(0, info.TotalBytes-1) - if err != nil { - t.Fatalf("Cleanup by size failed: %v", err) - } - if result.Deleted != 1 { - t.Fatalf("deleted = %d, want 1", result.Deleted) - } -} - -func TestStoreRejectsTraversalRecordingID(t *testing.T) { - store := NewStore(t.TempDir()) - if err := store.Delete("../outside"); err == nil { - t.Fatal("expected traversal recording ID to be rejected") - } -} diff --git a/internal/secrets/keychain.go b/internal/secrets/keychain.go deleted file mode 100644 index eb94c18..0000000 --- a/internal/secrets/keychain.go +++ /dev/null @@ -1,66 +0,0 @@ -package secrets - -import ( - "fmt" - - "github.com/zalando/go-keyring" -) - -// KeychainStore attempts to use the OS keychain for secret storage. -// On macOS this uses the Keychain, on Linux it uses the Secret Service API, -// and on Windows it uses the Credential Manager. -// -// This is the preferred storage method when available. Falls back to -// the encrypted file-based SecretStore when the keychain is not accessible. -type KeychainStore struct { - serviceName string -} - -// NewKeychainStore creates a KeychainStore that stores credentials under the -// given service name in the operating system's native keychain. -func NewKeychainStore(serviceName string) *KeychainStore { - return &KeychainStore{serviceName: serviceName} -} - -// Available checks whether the OS keychain is accessible by performing a -// harmless write-then-delete probe. Returns true if the keychain responded -// without error. -func (k *KeychainStore) Available() bool { - // Attempt a round-trip to verify the keychain is usable. - probeKey := "__link_keychain_probe__" - probeVal := "probe" - - if err := keyring.Set(k.serviceName, probeKey, probeVal); err != nil { - return false - } - _ = keyring.Delete(k.serviceName, probeKey) - return true -} - -// SetSecret stores a secret in the OS keychain under the configured service -// name with the given key. -func (k *KeychainStore) SetSecret(key, value string) error { - if err := keyring.Set(k.serviceName, key, value); err != nil { - return fmt.Errorf("keychain: failed to store secret %q: %w", key, err) - } - return nil -} - -// GetSecret retrieves a secret from the OS keychain. Returns an error if the -// key does not exist or the keychain is inaccessible. -func (k *KeychainStore) GetSecret(key string) (string, error) { - val, err := keyring.Get(k.serviceName, key) - if err != nil { - return "", fmt.Errorf("keychain: failed to retrieve secret %q: %w", key, err) - } - return val, nil -} - -// DeleteSecret removes a secret from the OS keychain. Returns an error if the -// key does not exist or the keychain is inaccessible. -func (k *KeychainStore) DeleteSecret(key string) error { - if err := keyring.Delete(k.serviceName, key); err != nil { - return fmt.Errorf("keychain: failed to delete secret %q: %w", key, err) - } - return nil -} diff --git a/internal/secrets/redact.go b/internal/secrets/redact.go deleted file mode 100644 index 6de96ee..0000000 --- a/internal/secrets/redact.go +++ /dev/null @@ -1,37 +0,0 @@ -// Package secrets provides encrypted credential storage and redaction utilities. -package secrets - -import "strings" - -// RedactSecret replaces a secret string with a redacted placeholder. -// Used for logging and UI display to prevent accidental secret exposure. -func RedactSecret(s string) string { - if len(s) == 0 { - return "" - } - return "••••••••" -} - -// RedactInString replaces all occurrences of a secret within a larger string -// with a redacted placeholder. This is useful for sanitising log output that -// may inadvertently contain sensitive material. -func RedactInString(text, secret string) string { - if secret == "" { - return text - } - return strings.ReplaceAll(text, secret, "••••••••") -} - -// RedactMap returns a shallow copy of the map with every value replaced by -// a redacted placeholder. The original map is not modified. -func RedactMap(m map[string]string) map[string]string { - redacted := make(map[string]string, len(m)) - for k, v := range m { - if v == "" { - redacted[k] = "" - } else { - redacted[k] = "••••••••" - } - } - return redacted -} diff --git a/internal/secrets/secrets.go b/internal/secrets/secrets.go deleted file mode 100644 index 73ae149..0000000 --- a/internal/secrets/secrets.go +++ /dev/null @@ -1,357 +0,0 @@ -package secrets - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "sync" - - "golang.org/x/crypto/argon2" -) - -const ( - vaultFileName = "vault.json" - vaultVersion = 1 - - // Argon2id key derivation parameters. - argonTime = 3 - argonMemory = 128 * 1024 // 128 MiB — deliberately strong for a desktop app - argonThreads = 4 - argonKeyLen = 32 - - saltLen = 32 - - maxVaultFileBytes = 10 * 1024 * 1024 -) - -// SecretStore manages encrypted credential storage. -// It uses AES-256-GCM with Argon2id key derivation from a user-controlled -// master password. No hard-coded secrets are used. -// -// Threat model: -// - Protects secrets at rest on the local filesystem -// - Requires the master password to decrypt -// - Does NOT protect against a compromised running process -// - Does NOT protect against memory forensics on a compromised machine -// - The master password is held in memory only for the duration of the session -type SecretStore struct { - mu sync.RWMutex - storePath string - key []byte // derived from master password, held in memory - vault *secretVault - unlocked bool -} - -type secretVault struct { - Version int `json:"version"` - Salt string `json:"salt"` // base64-encoded - Secrets map[string]string `json:"secrets"` // key -> base64(nonce + ciphertext) -} - -// NewSecretStore creates a new SecretStore that persists its vault file in the -// given directory path. The vault is not unlocked until Unlock is called. -func NewSecretStore(storePath string) *SecretStore { - return &SecretStore{ - storePath: storePath, - } -} - -// IsInitialised returns true if a vault file already exists on disc. -func (s *SecretStore) IsInitialised() bool { - _, err := os.Stat(s.vaultPath()) - return err == nil -} - -// Initialise creates a new vault with the given master password. A random -// 32-byte salt is generated and the key is derived using Argon2id. Returns an -// error if a vault already exists. -func (s *SecretStore) Initialise(masterPassword string) error { - s.mu.Lock() - defer s.mu.Unlock() - - if s.IsInitialised() { - return errors.New("secrets: vault already initialised") - } - - salt := make([]byte, saltLen) - if _, err := io.ReadFull(rand.Reader, salt); err != nil { - return fmt.Errorf("secrets: failed to generate salt: %w", err) - } - - s.vault = &secretVault{ - Version: vaultVersion, - Salt: base64.StdEncoding.EncodeToString(salt), - Secrets: make(map[string]string), - } - s.key = deriveKey(masterPassword, salt) - s.unlocked = true - - if err := os.MkdirAll(s.storePath, 0700); err != nil { - return fmt.Errorf("secrets: failed to create store directory: %w", err) - } - - if err := s.save(); err != nil { - return fmt.Errorf("secrets: failed to save initial vault: %w", err) - } - - return nil -} - -// Unlock decrypts the vault using the provided master password. The derived -// key is verified by attempting to decrypt the first stored secret; if no -// secrets exist the password is accepted optimistically. -func (s *SecretStore) Unlock(masterPassword string) error { - s.mu.Lock() - defer s.mu.Unlock() - - vault, err := s.load() - if err != nil { - return fmt.Errorf("secrets: failed to load vault: %w", err) - } - - salt, err := base64.StdEncoding.DecodeString(vault.Salt) - if err != nil { - return fmt.Errorf("secrets: corrupt vault salt: %w", err) - } - - s.vault = vault - s.key = deriveKey(masterPassword, salt) - s.unlocked = true - - // Attempt to decrypt any existing secret to verify the password. - for _, encVal := range vault.Secrets { - if _, err := s.decrypt(encVal); err != nil { - s.lockInternal() - return fmt.Errorf("secrets: incorrect master password or corrupt vault") - } - break - } - - return nil -} - -// Lock zeroes the derived key material and locks the vault. After calling Lock -// no secret operations are possible until Unlock is called again. -func (s *SecretStore) Lock() { - s.mu.Lock() - defer s.mu.Unlock() - s.lockInternal() -} - -// lockInternal performs the actual lock without acquiring the mutex. The caller -// must already hold s.mu. -func (s *SecretStore) lockInternal() { - for i := range s.key { - s.key[i] = 0 - } - s.key = nil - s.vault = nil - s.unlocked = false -} - -// IsUnlocked returns whether the vault is currently unlocked and ready for use. -func (s *SecretStore) IsUnlocked() bool { - s.mu.RLock() - defer s.mu.RUnlock() - return s.unlocked -} - -// SetSecret encrypts and stores a secret under the given key. The key format -// should follow "profile:{id}:password" or "profile:{id}:passphrase". -func (s *SecretStore) SetSecret(key string, value string) error { - s.mu.Lock() - defer s.mu.Unlock() - - if !s.unlocked { - return errors.New("secrets: vault is locked") - } - - encrypted, err := s.encrypt(value) - if err != nil { - return fmt.Errorf("secrets: encryption failed: %w", err) - } - - s.vault.Secrets[key] = encrypted - return s.save() -} - -// GetSecret retrieves and decrypts a secret by key. Returns an error if the -// key does not exist or decryption fails. -func (s *SecretStore) GetSecret(key string) (string, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if !s.unlocked { - return "", errors.New("secrets: vault is locked") - } - - encVal, ok := s.vault.Secrets[key] - if !ok { - return "", fmt.Errorf("secrets: key %q not found", key) - } - - return s.decrypt(encVal) -} - -// DeleteSecret removes a secret by key. Returns an error if the vault is -// locked or the save fails. -func (s *SecretStore) DeleteSecret(key string) error { - s.mu.Lock() - defer s.mu.Unlock() - - if !s.unlocked { - return errors.New("secrets: vault is locked") - } - - delete(s.vault.Secrets, key) - return s.save() -} - -// DeleteProfileSecrets removes all secrets whose key begins with -// "profile:{profileID}:". -func (s *SecretStore) DeleteProfileSecrets(profileID string) error { - s.mu.Lock() - defer s.mu.Unlock() - - if !s.unlocked { - return errors.New("secrets: vault is locked") - } - - prefix := "profile:" + profileID + ":" - for k := range s.vault.Secrets { - if strings.HasPrefix(k, prefix) { - delete(s.vault.Secrets, k) - } - } - - return s.save() -} - -// ListKeys returns all secret keys stored in the vault. Secret values are -// never returned by this method. -func (s *SecretStore) ListKeys() []string { - s.mu.RLock() - defer s.mu.RUnlock() - - if !s.unlocked || s.vault == nil { - return nil - } - - keys := make([]string, 0, len(s.vault.Secrets)) - for k := range s.vault.Secrets { - keys = append(keys, k) - } - return keys -} - -// --- internal helpers -------------------------------------------------------- - -// vaultPath returns the full filesystem path to the vault JSON file. -func (s *SecretStore) vaultPath() string { - return filepath.Join(s.storePath, vaultFileName) -} - -// deriveKey uses Argon2id to derive a 256-bit key from the master password and -// salt. The parameters are intentionally hard-coded to ensure consistency -// across vault open/close cycles. -func deriveKey(password string, salt []byte) []byte { - return argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) -} - -// encrypt encrypts plaintext using AES-256-GCM. A random nonce is generated -// for each call and prepended to the ciphertext. The result is returned as a -// base64-encoded string. -func (s *SecretStore) encrypt(plaintext string) (string, error) { - block, err := aes.NewCipher(s.key) - if err != nil { - return "", fmt.Errorf("failed to create cipher: %w", err) - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return "", fmt.Errorf("failed to create GCM: %w", err) - } - - nonce := make([]byte, gcm.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return "", fmt.Errorf("failed to generate nonce: %w", err) - } - - ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) - return base64.StdEncoding.EncodeToString(ciphertext), nil -} - -// decrypt decodes a base64 string and decrypts it using AES-256-GCM. The -// nonce is expected to be prepended to the ciphertext. -func (s *SecretStore) decrypt(encoded string) (string, error) { - data, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return "", fmt.Errorf("failed to decode secret: %w", err) - } - - block, err := aes.NewCipher(s.key) - if err != nil { - return "", fmt.Errorf("failed to create cipher: %w", err) - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return "", fmt.Errorf("failed to create GCM: %w", err) - } - - nonceSize := gcm.NonceSize() - if len(data) < nonceSize { - return "", errors.New("ciphertext too short") - } - - nonce, ciphertext := data[:nonceSize], data[nonceSize:] - plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return "", fmt.Errorf("decryption failed: %w", err) - } - - return string(plaintext), nil -} - -// save writes the vault to disc as a JSON file with restricted permissions. -func (s *SecretStore) save() error { - data, err := json.MarshalIndent(s.vault, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal vault: %w", err) - } - - return os.WriteFile(s.vaultPath(), data, 0600) -} - -// load reads and unmarshals the vault from disc. -func (s *SecretStore) load() (*secretVault, error) { - info, err := os.Stat(s.vaultPath()) - if err != nil { - return nil, fmt.Errorf("failed to stat vault file: %w", err) - } - if info.IsDir() { - return nil, errors.New("vault path is a directory") - } - if info.Size() > maxVaultFileBytes { - return nil, fmt.Errorf("vault file is too large: %d bytes exceeds %d bytes", info.Size(), maxVaultFileBytes) - } - data, err := os.ReadFile(s.vaultPath()) - if err != nil { - return nil, fmt.Errorf("failed to read vault file: %w", err) - } - - var vault secretVault - if err := json.Unmarshal(data, &vault); err != nil { - return nil, fmt.Errorf("failed to parse vault file: %w", err) - } - - return &vault, nil -} diff --git a/internal/secrets/secrets_test.go b/internal/secrets/secrets_test.go deleted file mode 100644 index 8ea956f..0000000 --- a/internal/secrets/secrets_test.go +++ /dev/null @@ -1,537 +0,0 @@ -package secrets - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// --------------------------------------------------------------------------- -// Full lifecycle: init → set → get → lock → fail → unlock → get -// --------------------------------------------------------------------------- - -func TestSecretStoreLifecycle(t *testing.T) { - dir := t.TempDir() - store := NewSecretStore(dir) - - if store.IsInitialised() { - t.Fatal("expected vault to be uninitialised on a fresh directory") - } - - // Initialise with a master password. - if err := store.Initialise("correct-horse-battery-staple"); err != nil { - t.Fatalf("Initialise() error = %v", err) - } - if !store.IsInitialised() { - t.Fatal("expected vault to be initialised after Initialise()") - } - if !store.IsUnlocked() { - t.Fatal("expected vault to be unlocked after Initialise()") - } - - // Store a secret and retrieve it. - if err := store.SetSecret("profile:abc:password", "s3cret"); err != nil { - t.Fatalf("SetSecret() error = %v", err) - } - val, err := store.GetSecret("profile:abc:password") - if err != nil { - t.Fatalf("GetSecret() error = %v", err) - } - if val != "s3cret" { - t.Errorf("GetSecret() = %q, want %q", val, "s3cret") - } - - // Lock the vault — should prevent reads. - store.Lock() - if store.IsUnlocked() { - t.Fatal("expected vault to be locked after Lock()") - } - if _, err := store.GetSecret("profile:abc:password"); err == nil { - t.Fatal("expected error when getting secret from locked vault") - } - - // Unlock and read again. - if err := store.Unlock("correct-horse-battery-staple"); err != nil { - t.Fatalf("Unlock() error = %v", err) - } - val, err = store.GetSecret("profile:abc:password") - if err != nil { - t.Fatalf("GetSecret() after unlock error = %v", err) - } - if val != "s3cret" { - t.Errorf("GetSecret() after unlock = %q, want %q", val, "s3cret") - } -} - -// --------------------------------------------------------------------------- -// Wrong password should fail to unlock -// --------------------------------------------------------------------------- - -func TestSecretStoreWrongPassword(t *testing.T) { - dir := t.TempDir() - store := NewSecretStore(dir) - - if err := store.Initialise("passwordA"); err != nil { - t.Fatalf("Initialise() error = %v", err) - } - // Store a secret so the password verification has something to decrypt. - if err := store.SetSecret("test:key", "value"); err != nil { - t.Fatalf("SetSecret() error = %v", err) - } - store.Lock() - - if err := store.Unlock("passwordB"); err == nil { - t.Fatal("expected error when unlocking with wrong password") - } -} - -// --------------------------------------------------------------------------- -// Multiple secrets — store, retrieve, delete selectively -// --------------------------------------------------------------------------- - -func TestSecretStoreMultipleSecrets(t *testing.T) { - dir := t.TempDir() - store := NewSecretStore(dir) - - if err := store.Initialise("master"); err != nil { - t.Fatalf("Initialise() error = %v", err) - } - - secrets := map[string]string{ - "profile:a:password": "alpha", - "profile:b:password": "bravo", - "profile:c:passphrase": "charlie", - } - for k, v := range secrets { - if err := store.SetSecret(k, v); err != nil { - t.Fatalf("SetSecret(%q) error = %v", k, err) - } - } - - // Verify each secret. - for k, want := range secrets { - got, err := store.GetSecret(k) - if err != nil { - t.Fatalf("GetSecret(%q) error = %v", k, err) - } - if got != want { - t.Errorf("GetSecret(%q) = %q, want %q", k, got, want) - } - } - - // Delete one secret and verify the others remain. - if err := store.DeleteSecret("profile:b:password"); err != nil { - t.Fatalf("DeleteSecret() error = %v", err) - } - if _, err := store.GetSecret("profile:b:password"); err == nil { - t.Error("expected error when getting deleted secret") - } - for _, key := range []string{"profile:a:password", "profile:c:passphrase"} { - if _, err := store.GetSecret(key); err != nil { - t.Errorf("GetSecret(%q) should still work after unrelated delete, got %v", key, err) - } - } -} - -// --------------------------------------------------------------------------- -// DeleteProfileSecrets -// --------------------------------------------------------------------------- - -func TestDeleteProfileSecrets(t *testing.T) { - dir := t.TempDir() - store := NewSecretStore(dir) - - if err := store.Initialise("master"); err != nil { - t.Fatalf("Initialise() error = %v", err) - } - - // Store secrets for two profiles. - if err := store.SetSecret("profile:p1:password", "pw1"); err != nil { - t.Fatalf("SetSecret() error = %v", err) - } - if err := store.SetSecret("profile:p1:passphrase", "pp1"); err != nil { - t.Fatalf("SetSecret() error = %v", err) - } - if err := store.SetSecret("profile:p2:password", "pw2"); err != nil { - t.Fatalf("SetSecret() error = %v", err) - } - - // Delete profile p1's secrets. - if err := store.DeleteProfileSecrets("p1"); err != nil { - t.Fatalf("DeleteProfileSecrets() error = %v", err) - } - - // p1 secrets should be gone. - if _, err := store.GetSecret("profile:p1:password"); err == nil { - t.Error("expected p1 password to be deleted") - } - if _, err := store.GetSecret("profile:p1:passphrase"); err == nil { - t.Error("expected p1 passphrase to be deleted") - } - - // p2 secret should remain. - val, err := store.GetSecret("profile:p2:password") - if err != nil { - t.Fatalf("GetSecret(p2) error = %v", err) - } - if val != "pw2" { - t.Errorf("GetSecret(p2) = %q, want %q", val, "pw2") - } -} - -// --------------------------------------------------------------------------- -// Reinitialise an existing vault should fail -// --------------------------------------------------------------------------- - -func TestSecretStoreDoubleInitialise(t *testing.T) { - dir := t.TempDir() - store := NewSecretStore(dir) - - if err := store.Initialise("pw"); err != nil { - t.Fatalf("first Initialise() error = %v", err) - } - if err := store.Initialise("pw"); err == nil { - t.Fatal("expected error on second Initialise()") - } -} - -// --------------------------------------------------------------------------- -// Persistence across store instances -// --------------------------------------------------------------------------- - -func TestSecretStorePersistence(t *testing.T) { - dir := t.TempDir() - - // Instance 1: create and store. - s1 := NewSecretStore(dir) - if err := s1.Initialise("master"); err != nil { - t.Fatalf("Initialise() error = %v", err) - } - if err := s1.SetSecret("k", "v"); err != nil { - t.Fatalf("SetSecret() error = %v", err) - } - s1.Lock() - - // Instance 2: open the same directory and unlock. - s2 := NewSecretStore(dir) - if !s2.IsInitialised() { - t.Fatal("expected vault to be initialised from disc") - } - if err := s2.Unlock("master"); err != nil { - t.Fatalf("Unlock() on second instance error = %v", err) - } - val, err := s2.GetSecret("k") - if err != nil { - t.Fatalf("GetSecret() on second instance error = %v", err) - } - if val != "v" { - t.Errorf("got %q, want %q", val, "v") - } -} - -// --------------------------------------------------------------------------- -// ListKeys -// --------------------------------------------------------------------------- - -func TestListKeys(t *testing.T) { - dir := t.TempDir() - store := NewSecretStore(dir) - - if err := store.Initialise("master"); err != nil { - t.Fatalf("Initialise() error = %v", err) - } - _ = store.SetSecret("a", "1") - _ = store.SetSecret("b", "2") - - keys := store.ListKeys() - if len(keys) != 2 { - t.Fatalf("ListKeys() returned %d keys, want 2", len(keys)) - } -} - -// --------------------------------------------------------------------------- -// Vault path uses the store directory -// --------------------------------------------------------------------------- - -func TestVaultPath(t *testing.T) { - dir := t.TempDir() - store := NewSecretStore(dir) - want := filepath.Join(dir, "vault.json") - - // Access vaultPath via reflection-free approach: just initialise and check the file. - if err := store.Initialise("pw"); err != nil { - t.Fatalf("Initialise() error = %v", err) - } - // The file should exist at the expected path. - if !store.IsInitialised() { - t.Fatalf("expected vault file at %s", want) - } -} - -func TestSecretStoreLoadRejectsOversizedVault(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, vaultFileName) - file, err := os.Create(path) - if err != nil { - t.Fatalf("Create() failed: %v", err) - } - if err := file.Truncate(maxVaultFileBytes + 1); err != nil { - t.Fatalf("Truncate() failed: %v", err) - } - if err := file.Close(); err != nil { - t.Fatalf("Close() failed: %v", err) - } - - store := NewSecretStore(dir) - _, err = store.load() - if err == nil { - t.Fatal("expected oversized vault to be rejected") - } - if !strings.Contains(err.Error(), "vault file is too large") { - t.Fatalf("unexpected error: %v", err) - } -} - -// --------------------------------------------------------------------------- -// Redaction helpers -// --------------------------------------------------------------------------- - -func TestRedactSecret(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - {name: "empty string returns empty", input: "", want: ""}, - {name: "non-empty returns bullets", input: "super-secret", want: "••••••••"}, - {name: "single char returns bullets", input: "x", want: "••••••••"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := RedactSecret(tt.input) - if got != tt.want { - t.Errorf("RedactSecret(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestRedactInString(t *testing.T) { - tests := []struct { - name string - text string - secret string - want string - }{ - { - name: "replaces secret in text", - text: "connecting with password=hunter2 now", - secret: "hunter2", - want: "connecting with password=•••••••• now", - }, - { - name: "empty secret returns text unchanged", - text: "no redaction here", - secret: "", - want: "no redaction here", - }, - { - name: "secret not present returns text unchanged", - text: "nothing to see", - secret: "absent", - want: "nothing to see", - }, - { - name: "multiple occurrences all redacted", - text: "pw=abc and again abc", - secret: "abc", - want: "pw=•••••••• and again ••••••••", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := RedactInString(tt.text, tt.secret) - if got != tt.want { - t.Errorf("RedactInString() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestRedactMap(t *testing.T) { - input := map[string]string{ - "password": "hunter2", - "token": "abc123", - "empty": "", - } - - result := RedactMap(input) - - if result["password"] != "••••••••" { - t.Errorf("expected password to be redacted, got %q", result["password"]) - } - if result["token"] != "••••••••" { - t.Errorf("expected token to be redacted, got %q", result["token"]) - } - if result["empty"] != "" { - t.Errorf("expected empty value to remain empty, got %q", result["empty"]) - } - - // Verify the original map is not modified. - if input["password"] != "hunter2" { - t.Error("original map was modified") - } -} - -// --------------------------------------------------------------------------- -// Initialise standalone — verifies vault file creation and unlocked state -// --------------------------------------------------------------------------- - -func TestSecretStoreInitialise(t *testing.T) { - dir := t.TempDir() - store := NewSecretStore(dir) - - if store.IsInitialised() { - t.Fatal("expected uninitialised state before Initialise()") - } - - if err := store.Initialise("test-master-pw"); err != nil { - t.Fatalf("Initialise() error = %v", err) - } - - // Vault file must exist on disc. - if !store.IsInitialised() { - t.Fatal("expected initialised state after Initialise()") - } - - // Store should be unlocked immediately after initialisation. - if !store.IsUnlocked() { - t.Fatal("expected unlocked state after Initialise()") - } - - // Should be able to set and retrieve a secret straight away. - if err := store.SetSecret("init-test", "value"); err != nil { - t.Fatalf("SetSecret() after Initialise error = %v", err) - } - got, err := store.GetSecret("init-test") - if err != nil { - t.Fatalf("GetSecret() after Initialise error = %v", err) - } - if got != "value" { - t.Errorf("GetSecret() = %q, want %q", got, "value") - } -} - -// --------------------------------------------------------------------------- -// Encrypt / Decrypt round-trip (same-package access to unexported methods) -// --------------------------------------------------------------------------- - -func TestEncryptDecrypt(t *testing.T) { - dir := t.TempDir() - store := NewSecretStore(dir) - if err := store.Initialise("roundtrip-pw"); err != nil { - t.Fatalf("Initialise() error = %v", err) - } - - tests := []struct { - name string - plaintext string - }{ - {name: "short string", plaintext: "hello"}, - {name: "empty string", plaintext: ""}, - {name: "long string", plaintext: "The quick brown fox jumps over the lazy dog. 0123456789!@#$%^&*()"}, - {name: "unicode text", plaintext: "café résumé naïve über 日本語"}, - {name: "binary-ish content", plaintext: "\x00\x01\x02\xff"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - encrypted, err := store.encrypt(tt.plaintext) - if err != nil { - t.Fatalf("encrypt() error = %v", err) - } - - // Encrypted text must differ from plaintext (unless empty — GCM - // still produces a tag). - if encrypted == tt.plaintext && tt.plaintext != "" { - t.Error("encrypted output should not equal plaintext") - } - - decrypted, err := store.decrypt(encrypted) - if err != nil { - t.Fatalf("decrypt() error = %v", err) - } - if decrypted != tt.plaintext { - t.Errorf("decrypt() = %q, want %q", decrypted, tt.plaintext) - } - }) - } -} - -func TestEncryptDecryptWrongKey(t *testing.T) { - dir := t.TempDir() - store := NewSecretStore(dir) - if err := store.Initialise("key-A"); err != nil { - t.Fatalf("Initialise() error = %v", err) - } - - encrypted, err := store.encrypt("secret data") - if err != nil { - t.Fatalf("encrypt() error = %v", err) - } - - // Create a second store with a different key. - dir2 := t.TempDir() - store2 := NewSecretStore(dir2) - if err := store2.Initialise("key-B"); err != nil { - t.Fatalf("Initialise(key-B) error = %v", err) - } - - // Attempting to decrypt with the wrong key should fail. - _, err = store2.decrypt(encrypted) - if err == nil { - t.Error("expected error when decrypting with wrong key") - } -} - -func TestEncryptProducesDifferentCiphertexts(t *testing.T) { - // Each call to encrypt should use a fresh random nonce, so encrypting - // the same plaintext twice must produce different ciphertexts. - dir := t.TempDir() - store := NewSecretStore(dir) - if err := store.Initialise("nonce-test"); err != nil { - t.Fatalf("Initialise() error = %v", err) - } - - a, err := store.encrypt("same input") - if err != nil { - t.Fatalf("first encrypt() error = %v", err) - } - b, err := store.encrypt("same input") - if err != nil { - t.Fatalf("second encrypt() error = %v", err) - } - if a == b { - t.Error("two encryptions of identical plaintext produced identical ciphertext — nonce reuse?") - } -} - -// --------------------------------------------------------------------------- -// SetSecret and GetSecret when vault is locked -// --------------------------------------------------------------------------- - -func TestSetSecretWhenLocked(t *testing.T) { - dir := t.TempDir() - store := NewSecretStore(dir) - if err := store.Initialise("lock-test"); err != nil { - t.Fatalf("Initialise() error = %v", err) - } - store.Lock() - - if err := store.SetSecret("k", "v"); err == nil { - t.Error("expected error when calling SetSecret on a locked vault") - } -} diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go deleted file mode 100644 index 3fe9561..0000000 --- a/internal/sessions/manager.go +++ /dev/null @@ -1,2753 +0,0 @@ -// Package sessions orchestrates multiple simultaneous SSH, Telnet, RAW TCP, -// and GitHub sessions. It coordinates between protocol clients, the storage -// layer, the secret store, and the Wails runtime for event emission. -package sessions - -import ( - "context" - "encoding/base64" - "fmt" - "io" - "net" - "os" - "strings" - "sync" - "time" - - "github.com/google/uuid" - wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" - "golang.org/x/crypto/ssh" - - "github.com/andydixon/link/internal/audit" - "github.com/andydixon/link/internal/githubclient" - "github.com/andydixon/link/internal/ircclient" - "github.com/andydixon/link/internal/localshell" - "github.com/andydixon/link/internal/models" - "github.com/andydixon/link/internal/rawclient" - "github.com/andydixon/link/internal/recording" - "github.com/andydixon/link/internal/secrets" - "github.com/andydixon/link/internal/sftpclient" - "github.com/andydixon/link/internal/sshclient" - "github.com/andydixon/link/internal/storage" - "github.com/andydixon/link/internal/telnetclient" - "github.com/andydixon/link/internal/vncclient" -) - -const maxTunnelPrivateKeyBytes = 1 << 20 - -// Session wraps a protocol client with metadata and state. -type Session struct { - Info models.SessionInfo - closed bool // explicitly removed from the manager - sshClient *sshclient.Client - telnetClient *telnetclient.Client - rawClient *rawclient.Client - githubClient *githubclient.Client - githubCancel context.CancelFunc // stops the auto-refresh goroutine - vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions - ircClient *ircclient.Client // IRC client, nil for non-IRC sessions - localClient *localshell.Client // local shell PTY, nil for non-local sessions - localShell string // configured shell path, retained for restart - localCwd string // last known local working directory - sftpClient *sftpclient.Client // SFTP subsystem, nil until requested - profile *models.Profile - recorder *recording.Recorder // active recording, nil if not recording - reconnectCount int // number of reconnection attempts so far - cancelReconn context.CancelFunc // cancels an in-progress reconnect backoff - - // Latest terminal geometry received before a PTY-capable client is attached. - // The frontend can know the correct size before SSH/Telnet/local setup finishes. - pendingResizeCols int - pendingResizeRows int - - // Login script support — temporary buffer for expect-style matching. - loginBuf []byte - loginBufMu sync.Mutex - loginDone chan struct{} // closed when login script completes or is abandoned -} - -// Manager handles the lifecycle of all active sessions. -// It coordinates between protocol clients, the storage layer, -// the secret store, and the Wails runtime for event emission. -type Manager struct { - mu sync.RWMutex - sessions map[string]*Session - store *storage.Store - secretStore *secrets.SecretStore - keychainStore *secrets.KeychainStore - auditLog *audit.Logger - recordStore *recording.Store - ctx context.Context // Wails app context for event emission - eventSink func(string, interface{}) - hostKeyMu sync.Mutex - hostKeyChans map[string]chan bool - hostKeyListenerRegistered bool - hostKeyListenerCancel func() - secretMu sync.Mutex - secretChans map[string]chan secretPromptResponse - secretListenerRegistered bool - secretListenerCancel func() -} - -type secretPromptResponse struct { - value string - cancelled bool -} - -// NewManager creates a Manager wired to the given backing services. -func NewManager(store *storage.Store, secretStore *secrets.SecretStore, keychainStore *secrets.KeychainStore, auditLog *audit.Logger, recordStore *recording.Store) *Manager { - return &Manager{ - sessions: make(map[string]*Session), - store: store, - secretStore: secretStore, - keychainStore: keychainStore, - auditLog: auditLog, - recordStore: recordStore, - } -} - -// SetContext stores the Wails runtime context for event emission. -func (m *Manager) SetContext(ctx context.Context) { - m.ctx = ctx - m.registerHostKeyResponseListener() - m.registerSecretResponseListener() -} - -// SetEventSink registers an alternate UI event sink for non-Wails hosts. -// Wails remains supported through SetContext; this hook is used by the VS Code -// bridge so existing protocol/session code can be reused without a webview -// runtime context. -func (m *Manager) SetEventSink(sink func(string, interface{})) { - m.mu.Lock() - m.eventSink = sink - m.mu.Unlock() -} - -// Connect creates a new session from a profile and connects it. -// The getSecret callback is used to prompt the user for credentials when -// required. Returns the session ID on success. -func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string) (string, error)) (string, error) { - sessionID := uuid.New().String() - now := time.Now() - - info := models.SessionInfo{ - ID: sessionID, - ProfileID: profile.ID, - ProfileName: profile.DisplayName, - Protocol: profile.Protocol, - Host: profile.Host, - Port: profile.Port, - State: models.SessionConnecting, - TabName: m.createTabName(profile), - ConnectedAt: now, - } - - sess := &Session{ - Info: info, - profile: profile, - } - - m.mu.Lock() - m.sessions[sessionID] = sess - m.mu.Unlock() - - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sessionID, - "state": string(models.SessionConnecting), - }) - - // Perform the actual protocol handshake in a goroutine so we do not - // block the Wails event loop — secret prompts require the UI thread. - go func() { - var err error - switch profile.Protocol { - case models.ProtocolSSH: - err = m.connectSSH(sess, getSecret) - case models.ProtocolTelnet: - err = m.connectTelnet(sess) - case models.ProtocolRAW: - err = m.connectRAW(sess) - case models.ProtocolGitHub: - err = m.connectGitHub(sess, getSecret) - case models.ProtocolVNC: - err = m.connectVNC(sess, getSecret) - case models.ProtocolIRC: - err = m.connectIRC(sess, getSecret) - default: - err = fmt.Errorf("unsupported protocol %q", profile.Protocol) - } - - if err != nil { - m.mu.Lock() - if current, ok := m.sessions[sessionID]; !ok || current != sess || sess.closed { - m.mu.Unlock() - m.closeSession(sess) - return - } - sess.Info.State = models.SessionError - sess.Info.Error = err.Error() - m.mu.Unlock() - - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sessionID, - "state": string(models.SessionError), - "error": err.Error(), - }) - return - } - - m.mu.Lock() - if current, ok := m.sessions[sessionID]; !ok || current != sess || sess.closed { - m.mu.Unlock() - m.closeSession(sess) - return - } - sess.Info.State = models.SessionConnected - sess.Info.ConnectedAt = time.Now() - m.mu.Unlock() - - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sessionID, - "state": string(models.SessionConnected), - }) - - if m.auditLog != nil { - m.auditLog.LogConnect(profile.ID, sessionID, string(profile.Protocol), profile.Host) - } - }() - - return sessionID, nil -} - -// ConnectLocal creates a local shell session without a saved profile. -// The shell path is resolved from the given parameter or falls back to the system default. -func (m *Manager) ConnectLocal(shell string) (string, error) { - sessionID := uuid.New().String() - now := time.Now() - - info := models.SessionInfo{ - ID: sessionID, - ProfileID: "", - ProfileName: "Local Machine", - Protocol: models.ProtocolLocal, - Host: "localhost", - Port: 0, - State: models.SessionConnecting, - TabName: "Local", - ConnectedAt: now, - } - - sess := &Session{ - Info: info, - localShell: shell, - } - - m.mu.Lock() - m.sessions[sessionID] = sess - m.mu.Unlock() - - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sessionID, - "state": string(models.SessionConnecting), - }) - - go m.startLocalSession(sess, 80, 24) - - return sessionID, nil -} - -// RestartLocal starts a replacement shell inside an exited local session. -// Keeping the session ID lets the frontend retain the tab and terminal buffer. -func (m *Manager) RestartLocal(sessionID string, cols, rows int) error { - m.mu.Lock() - sess, ok := m.sessions[sessionID] - if !ok { - m.mu.Unlock() - return fmt.Errorf("sessions: session %q not found", sessionID) - } - if sess.Info.Protocol != models.ProtocolLocal { - m.mu.Unlock() - return fmt.Errorf("sessions: session %q is not local", sessionID) - } - if sess.localClient != nil || sess.Info.State == models.SessionConnecting || sess.Info.State == models.SessionConnected { - m.mu.Unlock() - return fmt.Errorf("sessions: local session %q is still running", sessionID) - } - if cols <= 0 { - cols = 80 - } - if rows <= 0 { - rows = 24 - } - sess.Info.State = models.SessionConnecting - sess.Info.Error = "" - m.mu.Unlock() - - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sessionID, - "state": string(models.SessionConnecting), - }) - go m.startLocalSession(sess, cols, rows) - return nil -} - -func (m *Manager) startLocalSession(sess *Session, cols, rows int) { - m.mu.RLock() - sessionID := sess.Info.ID - shell := sess.localShell - startDir := sess.localCwd - m.mu.RUnlock() - var client *localshell.Client - client = localshell.NewClient(localshell.Config{ - Shell: shell, - StartDir: startDir, - Cols: terminalDimension(cols, 80), - Rows: terminalDimension(rows, 24), - OnData: func(data []byte) { - m.emitEvent("session:data", map[string]interface{}{ - "sessionID": sessionID, - "data": base64.StdEncoding.EncodeToString(data), - }) - m.mu.RLock() - rec := sess.recorder - m.mu.RUnlock() - if rec != nil { - rec.WriteOutput(data) - } - }, - OnError: func(err error) { - m.mu.Lock() - if sess.localClient != client { - m.mu.Unlock() - return - } - sess.Info.State = models.SessionError - sess.Info.Error = err.Error() - m.mu.Unlock() - - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sessionID, - "state": string(models.SessionError), - "error": err.Error(), - }) - }, - OnClose: func() { - m.handleLocalSessionClosed(sessionID, client) - }, - }) - - // Publish the client before Start so a resize arriving during process - // creation is retained by Client.Resize and used for the initial PTY size. - m.mu.Lock() - if sess.closed || sess.Info.State != models.SessionConnecting { - m.mu.Unlock() - return - } - sess.localClient = client - pendingCols, pendingRows := sess.consumePendingResizeLocked() - m.mu.Unlock() - applyLocalResize(client, pendingCols, pendingRows) - - if err := client.Start(); err != nil { - m.mu.Lock() - emitError := false - if sess.localClient == client { - sess.localClient = nil - sess.Info.State = models.SessionError - sess.Info.Error = err.Error() - emitError = true - } - m.mu.Unlock() - - if emitError { - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sessionID, - "state": string(models.SessionError), - "error": err.Error(), - }) - } - return - } - - m.mu.Lock() - if sess.closed || sess.localClient != client || sess.Info.State != models.SessionConnecting { - m.mu.Unlock() - client.Close() - return - } - sess.Info.State = models.SessionConnected - sess.Info.ConnectedAt = time.Now() - m.mu.Unlock() - - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sessionID, - "state": string(models.SessionConnected), - }) - if m.auditLog != nil { - m.auditLog.LogConnect("", sessionID, "local", "localhost") - } -} - -func (m *Manager) handleLocalSessionClosed(sessionID string, client *localshell.Client) { - m.mu.Lock() - sess, ok := m.sessions[sessionID] - if !ok || sess.localClient != client { - m.mu.Unlock() - return - } - sess.localClient = nil - if cwd := client.WorkingDirectory(); cwd != "" { - sess.localCwd = cwd - } - m.mu.Unlock() - - m.handleSessionClosed(sessionID) -} - -func terminalDimension(value int, fallback uint16) uint16 { - if value <= 0 { - return fallback - } - if value > int(^uint16(0)) { - return ^uint16(0) - } - return uint16(value) -} - -// Disconnect closes a specific session. -func (m *Manager) Disconnect(sessionID string) error { - m.mu.Lock() - sess, ok := m.sessions[sessionID] - if !ok { - m.mu.Unlock() - return fmt.Errorf("sessions: session %q not found", sessionID) - } - // Explicit disconnect means the tab/session is gone, not merely offline. - // Detach it before closing clients so their onClose callbacks cannot start - // auto-reconnect work or retain the session in the manager indefinitely. - delete(m.sessions, sessionID) - sess.closed = true - m.mu.Unlock() - - m.closeSession(sess) - if m.auditLog != nil { - m.auditLog.LogDisconnect(sessionID) - } - return nil -} - -// Reconnect closes and re-establishes a session using its original profile. -func (m *Manager) Reconnect(sessionID string, getSecret func(prompt string) (string, error)) error { - m.mu.RLock() - sess, ok := m.sessions[sessionID] - if !ok { - m.mu.RUnlock() - return fmt.Errorf("sessions: session %q not found", sessionID) - } - profile := sess.profile - tabName := sess.Info.TabName - pinned := sess.Info.Pinned - m.mu.RUnlock() - - // Close the existing session. - m.closeSession(sess) - - // Remove the old entry. - m.mu.Lock() - delete(m.sessions, sessionID) - m.mu.Unlock() - - // Reconnect with the same profile. - newID, err := m.Connect(profile, getSecret) - if err != nil { - return err - } - - // Preserve tab name and pin state. - m.mu.Lock() - if newSess, ok := m.sessions[newID]; ok { - newSess.Info.TabName = tabName - newSess.Info.Pinned = pinned - } - m.mu.Unlock() - - return nil -} - -// WriteToSession sends data to a session's input. -func (m *Manager) WriteToSession(sessionID string, data []byte) error { - m.mu.RLock() - sess, ok := m.sessions[sessionID] - if !ok { - m.mu.RUnlock() - return fmt.Errorf("sessions: session %q not found", sessionID) - } - rec := sess.recorder - sshClient := sess.sshClient - telnetClient := sess.telnetClient - rawClient := sess.rawClient - localClient := sess.localClient - m.mu.RUnlock() - - // Record input if recording is active. - if rec != nil { - rec.WriteInput(data) - } - - switch { - case sshClient != nil: - return sshClient.Write(data) - case telnetClient != nil: - return telnetClient.Write(data) - case rawClient != nil: - return rawClient.Write(data) - case localClient != nil: - return localClient.Write(data) - } - return fmt.Errorf("sessions: no active client for session %q", sessionID) -} - -// ResizeSession sends a terminal resize to a session. -func (m *Manager) ResizeSession(sessionID string, cols, rows int) error { - m.mu.Lock() - sess, ok := m.sessions[sessionID] - if !ok { - m.mu.Unlock() - return fmt.Errorf("sessions: session %q not found", sessionID) - } - - sshClient := sess.sshClient - telnetClient := sess.telnetClient - localClient := sess.localClient - isRaw := sess.rawClient != nil || (sess.profile != nil && sess.profile.Protocol == models.ProtocolRAW) - if sshClient == nil && telnetClient == nil && localClient == nil && !isRaw { - sess.pendingResizeCols = cols - sess.pendingResizeRows = rows - } - m.mu.Unlock() - - switch { - case sshClient != nil: - return sshClient.Resize(cols, rows) - case telnetClient != nil: - return telnetClient.Resize(cols, rows) - case localClient != nil: - return localClient.Resize(terminalDimension(cols, 80), terminalDimension(rows, 24)) - } - // RAW sessions have no concept of terminal size. - return nil -} - -// consumePendingResizeLocked returns and clears the latest pre-connect geometry. -// m.mu must be held by the caller. -func (sess *Session) consumePendingResizeLocked() (int, int) { - cols := sess.pendingResizeCols - rows := sess.pendingResizeRows - sess.pendingResizeCols = 0 - sess.pendingResizeRows = 0 - return cols, rows -} - -func applySSHResize(client *sshclient.Client, cols, rows int) { - if cols <= 0 || rows <= 0 { - return - } - _ = client.Resize(cols, rows) -} - -func applyTelnetResize(client *telnetclient.Client, cols, rows int) { - if cols <= 0 || rows <= 0 { - return - } - _ = client.Resize(cols, rows) -} - -func applyLocalResize(client *localshell.Client, cols, rows int) { - if cols <= 0 || rows <= 0 { - return - } - _ = client.Resize(terminalDimension(cols, 80), terminalDimension(rows, 24)) -} - -// GetSessionCwd returns a best-effort working directory for SSH sessions. -func (m *Manager) GetSessionCwd(sessionID string) (string, error) { - m.mu.RLock() - sess, ok := m.sessions[sessionID] - m.mu.RUnlock() - - if !ok { - return "", fmt.Errorf("sessions: session %q not found", sessionID) - } - if sess.sshClient == nil { - return "", fmt.Errorf("sessions: session %q is not an SSH connection", sessionID) - } - - cwd, err := sess.sshClient.RunCommand("pwd") - if err != nil { - return "", err - } - return strings.TrimSpace(cwd), nil -} - -// GetSession returns session info by ID. -func (m *Manager) GetSession(sessionID string) (*models.SessionInfo, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - sess, ok := m.sessions[sessionID] - if !ok { - return nil, fmt.Errorf("sessions: session %q not found", sessionID) - } - info := sess.Info - return &info, nil -} - -// GetSessionInfo returns session metadata or nil if the session does not exist. -func (m *Manager) GetSessionInfo(sessionID string) *models.SessionInfo { - m.mu.RLock() - defer m.mu.RUnlock() - - sess, ok := m.sessions[sessionID] - if !ok { - return nil - } - info := sess.Info - return &info -} - -// ListSessions returns all active session info. -func (m *Manager) ListSessions() []models.SessionInfo { - m.mu.RLock() - defer m.mu.RUnlock() - - result := make([]models.SessionInfo, 0, len(m.sessions)) - for _, sess := range m.sessions { - result = append(result, sess.Info) - } - return result -} - -// RenameSession changes a session's tab name. -func (m *Manager) RenameSession(sessionID string, name string) error { - m.mu.Lock() - defer m.mu.Unlock() - - sess, ok := m.sessions[sessionID] - if !ok { - return fmt.Errorf("sessions: session %q not found", sessionID) - } - sess.Info.TabName = name - return nil -} - -// PinSession toggles pin status. -func (m *Manager) PinSession(sessionID string, pinned bool) error { - m.mu.Lock() - defer m.mu.Unlock() - - sess, ok := m.sessions[sessionID] - if !ok { - return fmt.Errorf("sessions: session %q not found", sessionID) - } - sess.Info.Pinned = pinned - return nil -} - -// CloseAll closes every active session. -func (m *Manager) CloseAll() { - m.mu.Lock() - sessions := make([]*Session, 0, len(m.sessions)) - for _, sess := range m.sessions { - sess.closed = true - sessions = append(sessions, sess) - } - // Detach all sessions before closing protocol clients. Close callbacks can - // otherwise schedule reconnects while the application is shutting down. - m.sessions = make(map[string]*Session) - m.mu.Unlock() - - for _, sess := range sessions { - m.closeSession(sess) - } -} - -// Shutdown closes all sessions, unregisters app-wide Wails listeners, and -// releases any prompts still waiting for frontend input. -func (m *Manager) Shutdown() { - m.CloseAll() - - m.hostKeyMu.Lock() - hostKeyCancel := m.hostKeyListenerCancel - m.hostKeyListenerCancel = nil - m.hostKeyListenerRegistered = false - hostKeyChans := m.hostKeyChans - m.hostKeyChans = make(map[string]chan bool) - m.hostKeyMu.Unlock() - if hostKeyCancel != nil { - hostKeyCancel() - } - for _, ch := range hostKeyChans { - select { - case ch <- false: - default: - } - } - - m.secretMu.Lock() - secretCancel := m.secretListenerCancel - m.secretListenerCancel = nil - m.secretListenerRegistered = false - secretChans := m.secretChans - m.secretChans = make(map[string]chan secretPromptResponse) - m.secretMu.Unlock() - if secretCancel != nil { - secretCancel() - } - for _, ch := range secretChans { - select { - case ch <- secretPromptResponse{cancelled: true}: - default: - } - } -} - -// GetSessionHistory returns the RAW session history (RAW protocol only). -func (m *Manager) GetSessionHistory(sessionID string) ([]rawclient.HistoryEntry, error) { - m.mu.RLock() - sess, ok := m.sessions[sessionID] - m.mu.RUnlock() - - if !ok { - return nil, fmt.Errorf("sessions: session %q not found", sessionID) - } - if sess.rawClient == nil { - return nil, fmt.Errorf("sessions: session %q is not a RAW connection", sessionID) - } - return sess.rawClient.GetHistory(), nil -} - -// ClearSessionHistory clears RAW session history. -func (m *Manager) ClearSessionHistory(sessionID string) error { - m.mu.RLock() - sess, ok := m.sessions[sessionID] - m.mu.RUnlock() - - if !ok { - return fmt.Errorf("sessions: session %q not found", sessionID) - } - if sess.rawClient == nil { - return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID) - } - sess.rawClient.ClearHistory() - return nil -} - -// SaveSessionCapture saves RAW session capture to file. -func (m *Manager) SaveSessionCapture(sessionID string, path string) error { - m.mu.RLock() - sess, ok := m.sessions[sessionID] - m.mu.RUnlock() - - if !ok { - return fmt.Errorf("sessions: session %q not found", sessionID) - } - if sess.rawClient == nil { - return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID) - } - return sess.rawClient.SaveCapture(path) -} - -// SendBreak sends a Telnet break signal (Telnet only). -func (m *Manager) SendBreak(sessionID string) error { - m.mu.RLock() - sess, ok := m.sessions[sessionID] - m.mu.RUnlock() - - if !ok { - return fmt.Errorf("sessions: session %q not found", sessionID) - } - if sess.telnetClient == nil { - return fmt.Errorf("sessions: session %q is not a Telnet connection", sessionID) - } - return sess.telnetClient.SendBreak() -} - -// IRCHandleInput sends a user-entered composer line to an IRC session. -func (m *Manager) IRCHandleInput(sessionID string, target string, input string) error { - client, err := m.getIRC(sessionID) - if err != nil { - return err - } - return client.HandleInput(target, input) -} - -// IRCJoin joins an IRC room. -func (m *Manager) IRCJoin(sessionID string, room string) error { - client, err := m.getIRC(sessionID) - if err != nil { - return err - } - return client.Join(room) -} - -// IRCPart leaves an IRC room. -func (m *Manager) IRCPart(sessionID string, room string) error { - client, err := m.getIRC(sessionID) - if err != nil { - return err - } - return client.Part(room) -} - -// IRCListRooms requests a server-side IRC room list. -func (m *Manager) IRCListRooms(sessionID string) error { - client, err := m.getIRC(sessionID) - if err != nil { - return err - } - return client.ListRooms() -} - -// IRCRooms returns currently joined IRC rooms. -func (m *Manager) IRCRooms(sessionID string) ([]ircclient.RoomInfo, error) { - client, err := m.getIRC(sessionID) - if err != nil { - return nil, err - } - return client.Rooms(), nil -} - -// IRCUsers returns known users in an IRC room. -func (m *Manager) IRCUsers(sessionID string, room string) ([]string, error) { - client, err := m.getIRC(sessionID) - if err != nil { - return nil, err - } - return client.Users(room), nil -} - -func (m *Manager) getIRC(sessionID string) (*ircclient.Client, error) { - m.mu.RLock() - sess, ok := m.sessions[sessionID] - m.mu.RUnlock() - - if !ok { - return nil, fmt.Errorf("sessions: session %q not found", sessionID) - } - if sess.ircClient == nil { - return nil, fmt.Errorf("sessions: session %q is not an IRC connection", sessionID) - } - return sess.ircClient, nil -} - -// WriteSessionText sends text with line ending via the RAW client. -func (m *Manager) WriteSessionText(sessionID string, text string) error { - m.mu.RLock() - sess, ok := m.sessions[sessionID] - m.mu.RUnlock() - - if !ok { - return fmt.Errorf("sessions: session %q not found", sessionID) - } - if sess.rawClient == nil { - return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID) - } - return sess.rawClient.WriteText(text) -} - -// WriteSessionHex sends hex-encoded data via the RAW client. -func (m *Manager) WriteSessionHex(sessionID string, hexStr string) error { - m.mu.RLock() - sess, ok := m.sessions[sessionID] - m.mu.RUnlock() - - if !ok { - return fmt.Errorf("sessions: session %q not found", sessionID) - } - if sess.rawClient == nil { - return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID) - } - return sess.rawClient.WriteHex(hexStr) -} - -// WriteSessionEscaped sends text with C-style escapes via the RAW client. -func (m *Manager) WriteSessionEscaped(sessionID string, text string) error { - m.mu.RLock() - sess, ok := m.sessions[sessionID] - m.mu.RUnlock() - - if !ok { - return fmt.Errorf("sessions: session %q not found", sessionID) - } - if sess.rawClient == nil { - return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID) - } - return sess.rawClient.WriteEscaped(text) -} - -// --------------------------------------------------------------------------- -// Internal: protocol-specific connection logic -// --------------------------------------------------------------------------- - -// connectSSH creates an SSH client, sets callbacks, and connects. -func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (string, error)) error { - profile := sess.profile - - client := sshclient.NewClient(profile) - client.SetCallbacks( - // onData — forward terminal output to the frontend. - func(data []byte) { - m.emitEvent("session:data", map[string]interface{}{ - "sessionID": sess.Info.ID, - "data": base64.StdEncoding.EncodeToString(data), - }) - // Write to active recording if present. - m.mu.RLock() - rec := sess.recorder - m.mu.RUnlock() - if rec != nil { - rec.WriteOutput(data) - } - // Feed data to the login script buffer if active. - sess.loginBufMu.Lock() - if sess.loginDone != nil { - select { - case <-sess.loginDone: - // Login script finished — stop buffering. - default: - sess.loginBuf = append(sess.loginBuf, data...) - if len(sess.loginBuf) > 16384 { - sess.loginBuf = sess.loginBuf[len(sess.loginBuf)-16384:] - } - } - } - sess.loginBufMu.Unlock() - }, - // onError — report connection errors to the frontend. - func(err error) { - m.mu.Lock() - sess.Info.State = models.SessionError - sess.Info.Error = err.Error() - m.mu.Unlock() - - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sess.Info.ID, - "state": string(models.SessionError), - "error": err.Error(), - }) - }, - // onClose — handle remote session closure. - func() { - m.handleSessionClosed(sess.Info.ID) - }, - ) - - // Build credential callbacks that respect the profile's secret mode. - sshConf := profile.SSH - getPassword := m.buildSecretCallback(profile.ID, "password", sshConf.SecretMode, getSecret) - getPassphrase := m.buildSecretCallback(profile.ID, "passphrase", sshConf.KeySecretMode, getSecret) - - // Resolve TOTP secret from vault if not already inline. - if sshConf.TOTPSecret == "" { - totpKey := fmt.Sprintf("profile:%s:totp", profile.ID) - if m.keychainStore != nil && m.keychainStore.Available() { - if val, err := m.keychainStore.GetSecret(totpKey); err == nil && val != "" { - sshConf.TOTPSecret = val - } - } - if sshConf.TOTPSecret == "" && m.secretStore != nil && m.secretStore.IsUnlocked() { - if val, err := m.secretStore.GetSecret(totpKey); err == nil && val != "" { - sshConf.TOTPSecret = val - } - } - } - - if err := client.ConnectWithHostKeyCallback(m.hostKeyCallback(sess.Info.ID, sshConf.HostKeyPolicy), getPassword, getPassphrase); err != nil { - return err - } - - m.mu.Lock() - if current, ok := m.sessions[sess.Info.ID]; !ok || current != sess || sess.closed { - m.mu.Unlock() - _ = client.Close() - return fmt.Errorf("session %q was closed while connecting", sess.Info.ID) - } - sess.sshClient = client - cols, rows := sess.consumePendingResizeLocked() - m.mu.Unlock() - applySSHResize(client, cols, rows) - - // Start any port-forwarding rules configured in the profile. - if sshConf != nil && len(sshConf.PortForwards) > 0 { - fwdMgr := client.Forwards() - if fwdMgr != nil { - for _, pf := range sshConf.PortForwards { - if !pf.Enabled { - continue - } - if err := fwdMgr.StartForward(pf); err != nil { - // Non-fatal — log and notify but don't abort the session. - m.emitEvent("session:forward:error", map[string]interface{}{ - "sessionID": sess.Info.ID, - "forwardID": pf.ID, - "label": pf.Label, - "error": err.Error(), - }) - } else { - m.emitEvent("session:forward:started", map[string]interface{}{ - "sessionID": sess.Info.ID, - "forward": pf, - }) - } - } - } - } - - // Execute login scripts if configured. - if sshConf != nil && len(sshConf.LoginScripts) > 0 { - go m.executeLoginScripts(sess, sshConf.LoginScripts) - } - - return nil -} - -// connectTelnet creates a Telnet client, sets callbacks, and connects. -func (m *Manager) connectTelnet(sess *Session) error { - profile := sess.profile - - client := telnetclient.NewClient(profile) - client.SetCallbacks( - // onData - func(data []byte) { - m.emitEvent("session:data", map[string]interface{}{ - "sessionID": sess.Info.ID, - "data": base64.StdEncoding.EncodeToString(data), - }) - // Write to active recording if present. - m.mu.RLock() - rec := sess.recorder - m.mu.RUnlock() - if rec != nil { - rec.WriteOutput(data) - } - // Feed data to the login script buffer if active. - sess.loginBufMu.Lock() - if sess.loginDone != nil { - select { - case <-sess.loginDone: - default: - sess.loginBuf = append(sess.loginBuf, data...) - if len(sess.loginBuf) > 16384 { - sess.loginBuf = sess.loginBuf[len(sess.loginBuf)-16384:] - } - } - } - sess.loginBufMu.Unlock() - }, - // onError - func(err error) { - m.mu.Lock() - sess.Info.State = models.SessionError - sess.Info.Error = err.Error() - m.mu.Unlock() - - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sess.Info.ID, - "state": string(models.SessionError), - "error": err.Error(), - }) - }, - // onClose - func() { - m.handleSessionClosed(sess.Info.ID) - }, - // onDebug - func(msg string) { - m.emitEvent("session:debug", map[string]interface{}{ - "sessionID": sess.Info.ID, - "message": msg, - }) - }, - ) - - if err := client.Connect(); err != nil { - return err - } - - m.mu.Lock() - if current, ok := m.sessions[sess.Info.ID]; !ok || current != sess || sess.closed { - m.mu.Unlock() - _ = client.Close() - return fmt.Errorf("session %q was closed while connecting", sess.Info.ID) - } - sess.telnetClient = client - cols, rows := sess.consumePendingResizeLocked() - m.mu.Unlock() - applyTelnetResize(client, cols, rows) - - // Execute login scripts if configured. - telConf := profile.Telnet - if telConf != nil && len(telConf.LoginScripts) > 0 { - go m.executeLoginScripts(sess, telConf.LoginScripts) - } - - return nil -} - -// connectRAW creates a RAW TCP client, sets callbacks, and connects. -func (m *Manager) connectRAW(sess *Session) error { - profile := sess.profile - - client := rawclient.NewClient(profile) - client.SetCallbacks( - // onData — includes direction and timestamp for RAW protocol. - func(data []byte, direction string, timestamp time.Time) { - m.emitEvent("session:raw:data", map[string]interface{}{ - "sessionID": sess.Info.ID, - "data": base64.StdEncoding.EncodeToString(data), - "direction": direction, - "timestamp": timestamp.UnixMilli(), - }) - }, - // onError - func(err error) { - m.mu.Lock() - sess.Info.State = models.SessionError - sess.Info.Error = err.Error() - m.mu.Unlock() - - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sess.Info.ID, - "state": string(models.SessionError), - "error": err.Error(), - }) - }, - // onClose - func() { - m.handleSessionClosed(sess.Info.ID) - }, - ) - - if err := client.Connect(); err != nil { - return err - } - - m.mu.Lock() - if current, ok := m.sessions[sess.Info.ID]; !ok || current != sess || sess.closed { - m.mu.Unlock() - _ = client.Close() - return fmt.Errorf("session %q was closed while connecting", sess.Info.ID) - } - sess.rawClient = client - m.mu.Unlock() - return nil -} - -// connectGitHub validates the token and performs the initial repository fetch. -// It also starts a background goroutine for periodic refresh. -func (m *Manager) connectGitHub(sess *Session, getSecret func(prompt string) (string, error)) error { - profile := sess.profile - ghConf := profile.GitHub - if ghConf == nil { - return fmt.Errorf("GitHub configuration missing on profile %q", profile.ID) - } - - // Resolve the access token using the standard secret flow. - getToken := m.buildSecretCallback(profile.ID, "github-token", ghConf.SecretMode, getSecret) - token, err := getToken() - if err != nil { - return fmt.Errorf("obtaining GitHub token: %w", err) - } - if token == "" { - return fmt.Errorf("GitHub token cannot be empty") - } - - client := githubclient.New(ghConf.BaseURL, token) - - // Validate the token. - login, err := client.ValidateToken(context.Background()) - if err != nil { - if m.isGitHubAuthError(err) && getSecret != nil { - retryToken, retryErr := m.promptAndStoreSecret( - profile.ID, - "github-token", - ghConf.SecretMode, - getSecret, - "GitHub token did not work. Please enter a valid token:", - ) - if retryErr == nil && retryToken != "" { - client = githubclient.New(ghConf.BaseURL, retryToken) - login, err = client.ValidateToken(context.Background()) - } - } - if err != nil { - return fmt.Errorf("GitHub authentication failed: %w", err) - } - } - - m.mu.Lock() - if current, ok := m.sessions[sess.Info.ID]; !ok || current != sess || sess.closed { - m.mu.Unlock() - return fmt.Errorf("session %q was closed while connecting", sess.Info.ID) - } - sess.githubClient = client - m.mu.Unlock() - - // Perform the initial fetch. - m.emitEvent("session:github:status", map[string]interface{}{ - "sessionID": sess.Info.ID, - "status": "Loading repositories...", - }) - repos, err := client.FetchReposWithProgress(context.Background(), func(done int, total int) { - if total <= 0 { - return - } - m.emitEvent("session:github:status", map[string]interface{}{ - "sessionID": sess.Info.ID, - "status": fmt.Sprintf("Loading commit info (%d/%d)", done, total), - }) - }) - if err != nil { - return fmt.Errorf("fetching repositories: %w", err) - } - - // Emit the initial repo list. - m.emitEvent("session:github:repos", map[string]interface{}{ - "sessionID": sess.Info.ID, - "login": login, - "repos": repos, - }) - - // Start the periodic refresh goroutine. - interval := time.Duration(ghConf.RefreshIntervalSecs) * time.Second - if interval < 60*time.Second { - interval = 5 * time.Minute - } - - ctx, cancel := context.WithCancel(context.Background()) - m.mu.Lock() - if current, ok := m.sessions[sess.Info.ID]; !ok || current != sess || sess.closed { - m.mu.Unlock() - cancel() - return fmt.Errorf("session %q was closed while connecting", sess.Info.ID) - } - sess.githubCancel = cancel - m.mu.Unlock() - - go func() { - ticker := time.NewTicker(interval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - m.emitEvent("session:github:status", map[string]interface{}{ - "sessionID": sess.Info.ID, - "status": "Refreshing repositories...", - }) - updated, fetchErr := client.FetchReposWithProgress(ctx, func(done int, total int) { - if total <= 0 { - return - } - m.emitEvent("session:github:status", map[string]interface{}{ - "sessionID": sess.Info.ID, - "status": fmt.Sprintf("Loading commit info (%d/%d)", done, total), - }) - }) - if fetchErr != nil { - m.emitEvent("session:github:error", map[string]interface{}{ - "sessionID": sess.Info.ID, - "error": fetchErr.Error(), - }) - continue - } - m.emitEvent("session:github:repos", map[string]interface{}{ - "sessionID": sess.Info.ID, - "login": login, - "repos": updated, - }) - } - } - }() - - return nil -} - -// FetchGitHubRepos triggers an on-demand refresh of GitHub repos for a session. -func (m *Manager) FetchGitHubRepos(sessionID string) ([]models.GitHubRepo, error) { - m.mu.RLock() - sess, ok := m.sessions[sessionID] - m.mu.RUnlock() - - if !ok { - return nil, fmt.Errorf("sessions: session %q not found", sessionID) - } - if sess.githubClient == nil { - return nil, fmt.Errorf("sessions: session %q is not a GitHub connection", sessionID) - } - - repos, err := sess.githubClient.FetchRepos(context.Background()) - if err != nil { - return nil, fmt.Errorf("fetching repos: %w", err) - } - - m.emitEvent("session:github:repos", map[string]interface{}{ - "sessionID": sessionID, - "repos": repos, - }) - - return repos, nil -} - -// DownloadGitHubArchive downloads a repository zip archive for a GitHub session. -func (m *Manager) DownloadGitHubArchive(sessionID string, fullName string, ref string, destPath string) error { - m.mu.RLock() - sess, ok := m.sessions[sessionID] - m.mu.RUnlock() - - if !ok { - return fmt.Errorf("sessions: session %q not found", sessionID) - } - if sess.githubClient == nil { - return fmt.Errorf("sessions: session %q is not a GitHub connection", sessionID) - } - - if ref == "" { - ref = "HEAD" - } - - if err := sess.githubClient.DownloadRepoArchive(context.Background(), fullName, ref, destPath); err != nil { - return fmt.Errorf("downloading archive: %w", err) - } - return nil -} - -// connectVNC establishes a VNC connection (with optional SSH tunnel) and -// emits the local WebSocket proxy URL to the frontend. -func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (string, error)) error { - profile := sess.profile - vncConf := profile.VNC - if vncConf == nil { - return fmt.Errorf("VNC configuration missing") - } - - // Fall back to the profile-level host if VNC-specific host is empty. - vncHost := vncConf.VNCHost - if vncHost == "" { - vncHost = profile.Host - } - - cfg := vncclient.Config{ - VNCHost: vncHost, - VNCPort: vncConf.VNCPort, - OnError: func(err error) { - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sess.Info.ID, - "state": string(models.SessionError), - "error": err.Error(), - }) - }, - } - - // SSH tunnel configuration - if vncConf.SSHTunnel { - cfg.SSHTunnel = true - cfg.SSHHost = vncConf.SSHHost - cfg.SSHPort = vncConf.SSHPort - cfg.SSHUsername = vncConf.SSHUsername - cfg.SSHAuthMethod = vncConf.SSHAuthMethod - cfg.SSHAgentAuth = vncConf.SSHAgentAuth - cfg.SSHHostKeyCallback = m.hostKeyCallback(sess.Info.ID, models.HostKeyAsk) - - // Resolve SSH credentials - switch vncConf.SSHAuthMethod { - case "password": - getPass := m.buildSecretCallback(profile.ID, "vnc-ssh-password", vncConf.SSHSecretMode, getSecret) - password, err := getPass() - if err != nil { - return fmt.Errorf("SSH password: %w", err) - } - cfg.SSHPassword = password - case "publickey": - if vncConf.SSHPrivateKey != "" { - info, err := os.Stat(vncConf.SSHPrivateKey) - if err != nil { - return fmt.Errorf("reading SSH key metadata: %w", err) - } - if info.IsDir() { - return fmt.Errorf("SSH key path is a directory") - } - if info.Size() > maxTunnelPrivateKeyBytes { - return fmt.Errorf("SSH key is too large: %d bytes exceeds %d bytes", info.Size(), maxTunnelPrivateKeyBytes) - } - keyData, err := os.ReadFile(vncConf.SSHPrivateKey) - if err != nil { - return fmt.Errorf("reading SSH key: %w", err) - } - cfg.SSHPrivateKey = keyData - // Get passphrase if needed - getPassphrase := m.buildSecretCallback(profile.ID, "vnc-ssh-passphrase", vncConf.SSHSecretMode, getSecret) - passphrase, _ := getPassphrase() - cfg.SSHPassphrase = passphrase - } - } - - // SSH agent support - if vncConf.SSHAgentAuth { - socketPath := os.Getenv("SSH_AUTH_SOCK") - if socketPath != "" { - agentConn, err := net.Dial("unix", socketPath) - if err == nil { - cfg.SSHAgentConn = agentConn - } - } - } - } else { - // Direct VNC — may need a VNC password - getVNCPass := m.buildSecretCallback(profile.ID, "vnc-password", vncConf.SecretMode, getSecret) - password, _ := getVNCPass() - // VNC auth is handled by the RFB protocol in the frontend (noVNC), - // but we store it in case we need server-side auth negotiation later. - _ = password - } - - client := vncclient.NewClient(cfg) - wsURL, err := client.Connect(cfg) - if err != nil { - return err - } - - m.mu.Lock() - if current, ok := m.sessions[sess.Info.ID]; !ok || current != sess || sess.closed { - m.mu.Unlock() - client.Close() - return fmt.Errorf("session %q was closed while connecting", sess.Info.ID) - } - sess.vncClient = client - m.mu.Unlock() - - // Emit the WebSocket URL so the frontend can connect noVNC. - m.emitEvent("session:vnc:ready", map[string]interface{}{ - "sessionID": sess.Info.ID, - "wsURL": wsURL, - }) - - return nil -} - -// connectIRC establishes an IRC connection, optionally through an SSH tunnel, -// and forwards typed IRC events to the frontend. -func (m *Manager) connectIRC(sess *Session, getSecret func(prompt string) (string, error)) error { - profile := sess.profile - ircConf := profile.IRC - if ircConf == nil { - return fmt.Errorf("IRC configuration missing") - } - - serverPass := "" - if ircConf.ServerPasswordRequired { - getPass := m.buildSecretCallback(profile.ID, "irc-server-password", ircConf.ServerPasswordMode, getSecret) - password, err := getPass() - if err != nil { - return fmt.Errorf("IRC server password: %w", err) - } - serverPass = password - } - - nickServPass := "" - if ircConf.NickServIdentify { - getPass := m.buildSecretCallback(profile.ID, "irc-nickserv-password", ircConf.NickServMode, getSecret) - password, err := getPass() - if err != nil { - return fmt.Errorf("NickServ password: %w", err) - } - nickServPass = password - } - - autoJoinRooms := append([]string{}, ircConf.AutoJoinRooms...) - if m.store != nil && profile.ID != "" { - lastRooms, err := m.store.GetIRCLastRooms(profile.ID) - if err != nil { - return fmt.Errorf("loading remembered IRC rooms: %w", err) - } - autoJoinRooms = mergeIRCRooms(autoJoinRooms, lastRooms) - } - - cfg := ircclient.Config{ - Server: profile.Host, - Port: profile.Port, - Nick: ircConf.Nick, - User: profile.Username, - RealName: ircConf.RealName, - UseTLS: ircConf.UseTLS, - ServerPass: serverPass, - AutoJoinRooms: autoJoinRooms, - NickServIdentify: ircConf.NickServIdentify, - NickServAccount: ircConf.NickServAccount, - NickServPassword: nickServPass, - ConnectTimeout: time.Duration(profile.ConnectTimeout) * time.Second, - OnEvent: func(event ircclient.Event) { m.emitIRCEvent(sess.Info.ID, event) }, - OnError: func(err error) { m.markSessionError(sess, err) }, - OnClose: func() { m.handleSessionClosed(sess.Info.ID) }, - SSHTunnel: ircConf.SSHTunnel, - SSHHost: ircConf.SSHHost, - SSHPort: ircConf.SSHPort, - SSHUsername: ircConf.SSHUsername, - SSHAuthMethod: ircConf.SSHAuthMethod, - SSHAgentAuth: ircConf.SSHAgentAuth, - SSHHostKeyCallback: m.hostKeyCallback(sess.Info.ID, models.HostKeyAsk), - } - if m.store != nil && profile.ID != "" { - profileID := profile.ID - cfg.OnRoomsChanged = func(rooms []string) { - if err := m.store.SaveIRCLastRooms(profileID, rooms); err != nil { - m.emitIRCEvent(sess.Info.ID, ircclient.Event{ - Type: "system", - Target: ircclient.ServerTarget, - Text: "Could not remember open IRC rooms: " + err.Error(), - Timestamp: time.Now().UnixMilli(), - }) - } - } - } - - if cfg.User == "" { - cfg.User = ircConf.Nick - } - - if ircConf.SSHTunnel { - switch ircConf.SSHAuthMethod { - case "password": - getPass := m.buildSecretCallback(profile.ID, "irc-ssh-password", ircConf.SSHSecretMode, getSecret) - password, err := getPass() - if err != nil { - return fmt.Errorf("IRC SSH password: %w", err) - } - cfg.SSHPassword = password - case "publickey": - if ircConf.SSHPrivateKey != "" { - info, err := os.Stat(ircConf.SSHPrivateKey) - if err != nil { - return fmt.Errorf("reading IRC SSH key metadata: %w", err) - } - if info.IsDir() { - return fmt.Errorf("IRC SSH key path is a directory") - } - if info.Size() > maxTunnelPrivateKeyBytes { - return fmt.Errorf("IRC SSH key is too large: %d bytes exceeds %d bytes", info.Size(), maxTunnelPrivateKeyBytes) - } - keyData, err := os.ReadFile(ircConf.SSHPrivateKey) - if err != nil { - return fmt.Errorf("reading IRC SSH key: %w", err) - } - cfg.SSHPrivateKey = keyData - getPassphrase := m.buildSecretCallback(profile.ID, "irc-ssh-passphrase", ircConf.SSHSecretMode, getSecret) - passphrase, _ := getPassphrase() - cfg.SSHPassphrase = passphrase - } - } - - if ircConf.SSHAgentAuth { - socketPath := os.Getenv("SSH_AUTH_SOCK") - if socketPath != "" { - agentConn, err := net.Dial("unix", socketPath) - if err == nil { - cfg.SSHAgentConn = agentConn - } - } - } - } - - client := ircclient.New(cfg) - if err := client.Connect(); err != nil { - return err - } - - m.mu.Lock() - if current, ok := m.sessions[sess.Info.ID]; !ok || current != sess || sess.closed { - m.mu.Unlock() - client.Close() - return fmt.Errorf("session %q was closed while connecting", sess.Info.ID) - } - sess.ircClient = client - m.mu.Unlock() - return nil -} - -func mergeIRCRooms(groups ...[]string) []string { - var rooms []string - seen := make(map[string]struct{}) - for _, group := range groups { - for _, room := range group { - room = strings.TrimSpace(room) - if room == "" { - continue - } - key := strings.ToLower(room) - if _, exists := seen[key]; exists { - continue - } - seen[key] = struct{}{} - rooms = append(rooms, room) - } - } - return rooms -} - -func (m *Manager) emitIRCEvent(sessionID string, event ircclient.Event) { - m.emitEvent("session:irc:event", map[string]interface{}{ - "sessionID": sessionID, - "event": event, - }) -} - -func (m *Manager) markSessionError(sess *Session, err error) { - if err == nil { - return - } - m.mu.Lock() - sess.Info.State = models.SessionError - sess.Info.Error = err.Error() - m.mu.Unlock() - - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sess.Info.ID, - "state": string(models.SessionError), - "error": err.Error(), - }) -} - -// GetVNCWebSocketURL returns the local WebSocket proxy URL for a VNC session. -func (m *Manager) GetVNCWebSocketURL(sessionID string) (string, error) { - m.mu.RLock() - sess, ok := m.sessions[sessionID] - m.mu.RUnlock() - - if !ok { - return "", fmt.Errorf("sessions: session %q not found", sessionID) - } - if sess.vncClient == nil { - return "", fmt.Errorf("sessions: session %q is not a VNC connection", sessionID) - } - return sess.vncClient.WSURL(), nil -} - -// --------------------------------------------------------------------------- -// Session recording -// --------------------------------------------------------------------------- - -// StartRecording begins recording terminal I/O for a session. -// Returns the recording ID (same as session ID) on success. -func (m *Manager) StartRecording(sessionID string, cols, rows int) (string, error) { - m.mu.Lock() - sess, ok := m.sessions[sessionID] - if !ok { - m.mu.Unlock() - return "", fmt.Errorf("sessions: session %q not found", sessionID) - } - if sess.recorder != nil { - m.mu.Unlock() - return "", fmt.Errorf("sessions: session %q is already being recorded", sessionID) - } - m.mu.Unlock() - - if m.recordStore == nil { - return "", fmt.Errorf("sessions: recording store not configured") - } - - title := fmt.Sprintf("%s — %s", sess.Info.ProfileName, sess.Info.Host) - rec, err := recording.NewRecorder(m.recordStore.Dir(), sessionID, title, cols, rows) - if err != nil { - return "", fmt.Errorf("sessions: failed to start recording: %w", err) - } - - m.mu.Lock() - sess.recorder = rec - m.mu.Unlock() - - m.emitEvent("session:recording:started", map[string]interface{}{ - "sessionID": sessionID, - }) - - return sessionID, nil -} - -// StopRecording stops an active recording for a session. -func (m *Manager) StopRecording(sessionID string) error { - m.mu.Lock() - sess, ok := m.sessions[sessionID] - if !ok { - m.mu.Unlock() - return fmt.Errorf("sessions: session %q not found", sessionID) - } - rec := sess.recorder - sess.recorder = nil - m.mu.Unlock() - - if rec == nil { - return fmt.Errorf("sessions: session %q is not being recorded", sessionID) - } - - if err := rec.Close(); err != nil { - return fmt.Errorf("sessions: failed to stop recording: %w", err) - } - - m.emitEvent("session:recording:stopped", map[string]interface{}{ - "sessionID": sessionID, - "duration": rec.Duration(), - "events": rec.EventCount(), - }) - - return nil -} - -// IsRecording reports whether a session is currently being recorded. -func (m *Manager) IsRecording(sessionID string) bool { - m.mu.RLock() - defer m.mu.RUnlock() - - sess, ok := m.sessions[sessionID] - if !ok { - return false - } - return sess.recorder != nil -} - -// ListRecordings returns metadata for all saved recordings. -func (m *Manager) ListRecordings() ([]recording.RecordingMeta, error) { - if m.recordStore == nil { - return nil, fmt.Errorf("sessions: recording store not configured") - } - return m.recordStore.List() -} - -// GetRecording returns the full recording data (header + events) for playback. -func (m *Manager) GetRecording(id string) (*recording.Header, []recording.Event, error) { - if m.recordStore == nil { - return nil, nil, fmt.Errorf("sessions: recording store not configured") - } - return m.recordStore.Get(id) -} - -// DeleteRecording removes a saved recording. -func (m *Manager) DeleteRecording(id string) error { - if m.recordStore == nil { - return fmt.Errorf("sessions: recording store not configured") - } - return m.recordStore.Delete(id) -} - -// RecordingStorageInfo returns aggregate recording storage usage. -func (m *Manager) RecordingStorageInfo() (recording.StorageInfo, error) { - if m.recordStore == nil { - return recording.StorageInfo{}, fmt.Errorf("sessions: recording store not configured") - } - return m.recordStore.StorageInfo() -} - -// CleanupRecordings applies age and total-size retention limits. -func (m *Manager) CleanupRecordings(maxAgeDays int, maxTotalBytes int64) (recording.CleanupResult, error) { - if m.recordStore == nil { - return recording.CleanupResult{}, fmt.Errorf("sessions: recording store not configured") - } - return m.recordStore.Cleanup(maxAgeDays, maxTotalBytes) -} - -// --------------------------------------------------------------------------- -// SFTP file browser -// --------------------------------------------------------------------------- - -// getSFTP returns or lazily creates the SFTP client for an SSH session. -func (m *Manager) getSFTP(sessionID string) (*sftpclient.Client, error) { - m.mu.Lock() - sess, ok := m.sessions[sessionID] - if !ok { - m.mu.Unlock() - return nil, fmt.Errorf("sessions: session %q not found", sessionID) - } - if sess.sshClient == nil { - m.mu.Unlock() - return nil, fmt.Errorf("sessions: session %q is not an SSH connection", sessionID) - } - - // Return existing SFTP client if already open. - if sess.sftpClient != nil { - c := sess.sftpClient - m.mu.Unlock() - return c, nil - } - m.mu.Unlock() - - // Create a new SFTP subsystem. - sshConn := sess.sshClient.SSHConn() - if sshConn == nil { - return nil, fmt.Errorf("sessions: SSH connection is not established") - } - - sc, err := sftpclient.NewClient(sshConn) - if err != nil { - return nil, err - } - - m.mu.Lock() - sess.sftpClient = sc - m.mu.Unlock() - - return sc, nil -} - -// SFTPList lists the contents of a remote directory. -func (m *Manager) SFTPList(sessionID, remotePath string) ([]sftpclient.FileEntry, error) { - sc, err := m.getSFTP(sessionID) - if err != nil { - return nil, err - } - return sc.List(remotePath) -} - -// SFTPStat returns information about a remote path. -func (m *Manager) SFTPStat(sessionID, remotePath string) (*sftpclient.FileEntry, error) { - sc, err := m.getSFTP(sessionID) - if err != nil { - return nil, err - } - return sc.Stat(remotePath) -} - -// SFTPMkdir creates a remote directory. -func (m *Manager) SFTPMkdir(sessionID, remotePath string) error { - sc, err := m.getSFTP(sessionID) - if err != nil { - return err - } - return sc.MkdirAll(remotePath) -} - -// SFTPRemove deletes a remote file or empty directory. -func (m *Manager) SFTPRemove(sessionID, remotePath string) error { - sc, err := m.getSFTP(sessionID) - if err != nil { - return err - } - return sc.Remove(remotePath) -} - -// SFTPRename moves or renames a remote file/directory. -func (m *Manager) SFTPRename(sessionID, oldPath, newPath string) error { - sc, err := m.getSFTP(sessionID) - if err != nil { - return err - } - return sc.Rename(oldPath, newPath) -} - -// SFTPDownload downloads a remote file to a local path. -func (m *Manager) SFTPDownload(sessionID, remotePath, localPath string) error { - sc, err := m.getSFTP(sessionID) - if err != nil { - return err - } - return sc.Download(remotePath, localPath, func(p sftpclient.TransferProgress) { - m.emitEvent("session:sftp:progress", map[string]interface{}{ - "sessionID": sessionID, - "direction": "download", - "transferred": p.Transferred, - "total": p.Total, - "path": p.Path, - "done": p.Done, - }) - }) -} - -// SFTPUpload uploads a local file to a remote path. -func (m *Manager) SFTPUpload(sessionID, localPath, remotePath string) error { - sc, err := m.getSFTP(sessionID) - if err != nil { - return err - } - return sc.Upload(localPath, remotePath, func(p sftpclient.TransferProgress) { - m.emitEvent("session:sftp:progress", map[string]interface{}{ - "sessionID": sessionID, - "direction": "upload", - "transferred": p.Transferred, - "total": p.Total, - "path": p.Path, - "done": p.Done, - }) - }) -} - -// SFTPUploadBytes uploads in-memory data to a remote path. -func (m *Manager) SFTPUploadBytes(sessionID, remotePath string, data []byte) error { - sc, err := m.getSFTP(sessionID) - if err != nil { - return err - } - return sc.UploadBytes(remotePath, data, func(p sftpclient.TransferProgress) { - m.emitEvent("session:sftp:progress", map[string]interface{}{ - "sessionID": sessionID, - "direction": "upload", - "transferred": p.Transferred, - "total": p.Total, - "path": p.Path, - "done": p.Done, - }) - }) -} - -// SFTPOpenUpload opens a remote file for a caller-managed streaming upload. -func (m *Manager) SFTPOpenUpload(sessionID, remotePath string) (io.WriteCloser, error) { - sc, err := m.getSFTP(sessionID) - if err != nil { - return nil, err - } - return sc.CreateWriter(remotePath) -} - -// SFTPPwd returns the current working directory on the remote host. -func (m *Manager) SFTPPwd(sessionID string) (string, error) { - sc, err := m.getSFTP(sessionID) - if err != nil { - return "", err - } - return sc.Pwd() -} - -// --------------------------------------------------------------------------- -// Port-forwarding management -// --------------------------------------------------------------------------- - -// ListPortForwards returns the active port-forwarding rules for a session. -func (m *Manager) ListPortForwards(sessionID string) ([]models.PortForward, error) { - m.mu.RLock() - sess, ok := m.sessions[sessionID] - m.mu.RUnlock() - - if !ok { - return nil, fmt.Errorf("sessions: session %q not found", sessionID) - } - if sess.sshClient == nil { - return nil, fmt.Errorf("sessions: session %q is not an SSH connection", sessionID) - } - - fwdMgr := sess.sshClient.Forwards() - if fwdMgr == nil { - return []models.PortForward{}, nil - } - return fwdMgr.ActiveForwards(), nil -} - -// StartPortForward activates a new forwarding tunnel on a live SSH session. -func (m *Manager) StartPortForward(sessionID string, rule models.PortForward) error { - m.mu.RLock() - sess, ok := m.sessions[sessionID] - m.mu.RUnlock() - - if !ok { - return fmt.Errorf("sessions: session %q not found", sessionID) - } - if sess.sshClient == nil { - return fmt.Errorf("sessions: session %q is not an SSH connection", sessionID) - } - - fwdMgr := sess.sshClient.Forwards() - if fwdMgr == nil { - return fmt.Errorf("sessions: forward manager not initialised") - } - - if err := fwdMgr.StartForward(rule); err != nil { - return err - } - - m.emitEvent("session:forward:started", map[string]interface{}{ - "sessionID": sessionID, - "forward": rule, - }) - return nil -} - -// StopPortForward terminates an active forwarding tunnel. -func (m *Manager) StopPortForward(sessionID string, forwardID string) error { - m.mu.RLock() - sess, ok := m.sessions[sessionID] - m.mu.RUnlock() - - if !ok { - return fmt.Errorf("sessions: session %q not found", sessionID) - } - if sess.sshClient == nil { - return fmt.Errorf("sessions: session %q is not an SSH connection", sessionID) - } - - fwdMgr := sess.sshClient.Forwards() - if fwdMgr == nil { - return fmt.Errorf("sessions: forward manager not initialised") - } - - if err := fwdMgr.StopForward(forwardID); err != nil { - return err - } - - m.emitEvent("session:forward:stopped", map[string]interface{}{ - "sessionID": sessionID, - "forwardID": forwardID, - }) - return nil -} - -// PingSession sends a keepalive request to the server and returns the RTT in milliseconds. -func (m *Manager) PingSession(sessionID string) (int64, error) { - m.mu.RLock() - sess, ok := m.sessions[sessionID] - m.mu.RUnlock() - - if !ok { - return 0, fmt.Errorf("sessions: session %q not found", sessionID) - } - if sess.sshClient == nil { - return 0, fmt.Errorf("sessions: session %q is not an SSH connection", sessionID) - } - - return sess.sshClient.Ping() -} - -// --------------------------------------------------------------------------- -// Internal: helpers -// --------------------------------------------------------------------------- - -func (m *Manager) hostKeyCallback(sessionID string, policy models.HostKeyPolicy) ssh.HostKeyCallback { - if policy == "" { - policy = models.HostKeyAsk - } - verifier := sshclient.NewHostKeyVerifier(m.store, policy) - verifier.SetPromptHandler(func(host string, keyType string, fingerprint string) (bool, error) { - return m.promptHostKey(sessionID, host, keyType, fingerprint) - }) - return verifier.HostKeyCallback() -} - -func (m *Manager) promptHostKey(sessionID string, host string, keyType string, fingerprint string) (bool, error) { - if m.ctx == nil && m.eventSink == nil { - return false, fmt.Errorf("host key prompt unavailable") - } - - requestID := uuid.New().String() - ch := make(chan bool, 1) - - m.hostKeyMu.Lock() - if m.hostKeyChans == nil { - m.hostKeyChans = make(map[string]chan bool) - } - m.hostKeyChans[requestID] = ch - m.hostKeyMu.Unlock() - defer func() { - m.hostKeyMu.Lock() - delete(m.hostKeyChans, requestID) - m.hostKeyMu.Unlock() - }() - - m.emitEvent("session:hostkey:verify", map[string]interface{}{ - "requestID": requestID, - "sessionID": sessionID, - "host": host, - "keyType": keyType, - "fingerprint": fingerprint, - }) - - select { - case accepted := <-ch: - return accepted, nil - case <-time.After(60 * time.Second): - return false, fmt.Errorf("host key prompt timed out waiting for user response") - } -} - -func (m *Manager) registerHostKeyResponseListener() { - ctx := m.ctx - if ctx == nil { - return - } - - m.hostKeyMu.Lock() - if m.hostKeyChans == nil { - m.hostKeyChans = make(map[string]chan bool) - } - if m.hostKeyListenerRegistered { - m.hostKeyMu.Unlock() - return - } - m.hostKeyListenerRegistered = true - m.hostKeyMu.Unlock() - - cancel := wailsRuntime.EventsOn(ctx, "session:hostkey:response", m.handleHostKeyResponse) - m.hostKeyMu.Lock() - m.hostKeyListenerCancel = cancel - m.hostKeyMu.Unlock() -} - -func (m *Manager) handleHostKeyResponse(optionalData ...interface{}) { - if len(optionalData) == 0 { - return - } - data, ok := optionalData[0].(map[string]interface{}) - if !ok { - return - } - requestID, _ := data["requestID"].(string) - accepted, _ := data["accepted"].(bool) - - m.hostKeyMu.Lock() - ch, exists := m.hostKeyChans[requestID] - m.hostKeyMu.Unlock() - - if exists { - select { - case ch <- accepted: - default: - } - } -} - -// RespondHostKey supplies a host-key prompt response for non-Wails hosts. -func (m *Manager) RespondHostKey(requestID string, accepted bool) { - m.hostKeyMu.Lock() - ch, exists := m.hostKeyChans[requestID] - m.hostKeyMu.Unlock() - - if exists { - select { - case ch <- accepted: - default: - } - } -} - -// buildSecretCallback returns a func() (string, error) that resolves a -// credential according to the profile's SecretMode: -// - "saved": check keychain/vault first, prompt if missing, then persist -// - "session-only": check an in-memory session cache, prompt once per session -// - "ask-every-time" (or any other value): always prompt the user -func (m *Manager) buildSecretCallback(profileID string, secretType string, mode models.SecretMode, getSecret func(prompt string) (string, error)) func() (string, error) { - secretKey := fmt.Sprintf("profile:%s:%s", profileID, secretType) - - // Session-local cache for "session-only" mode. The closure captures this - // variable so it survives across multiple calls within the same connection. - var sessionCached string - var sessionCachedSet bool - - return func() (string, error) { - // ── "saved" mode: try persistent stores first ──────────────────── - if mode == models.SecretModeSaved { - // Try the OS keychain first. - if m.keychainStore != nil { - if val, err := m.keychainStore.GetSecret(secretKey); err == nil && val != "" { - m.auditLog.LogSecretAccess(profileID, secretType) - return val, nil - } - } - - // Try the encrypted file store. - if m.secretStore != nil && m.secretStore.IsUnlocked() { - if val, err := m.secretStore.GetSecret(secretKey); err == nil && val != "" { - m.auditLog.LogSecretAccess(profileID, secretType) - return val, nil - } - } - } - - // ── "session-only" mode: return cached value if we already asked ─ - if mode == models.SecretModeSessionOnly && sessionCachedSet { - return sessionCached, nil - } - - // ── Prompt the user ────────────────────────────────────────────── - if getSecret == nil { - return "", fmt.Errorf("no %s available and no prompt callback provided", secretType) - } - - prompt := fmt.Sprintf("Enter %s:", secretType) - val, err := getSecret(prompt) - if err != nil { - return "", err - } - - // ── Persist according to mode ──────────────────────────────────── - switch mode { - case models.SecretModeSaved: - // Prefer OS keychain, fall back to encrypted vault. - stored := false - if m.keychainStore != nil { - if sErr := m.keychainStore.SetSecret(secretKey, val); sErr == nil { - stored = true - } - } - if !stored && m.secretStore != nil && m.secretStore.IsUnlocked() { - _ = m.secretStore.SetSecret(secretKey, val) - } - - case models.SecretModeSessionOnly: - sessionCached = val - sessionCachedSet = true - } - - return val, nil - } -} - -// promptAndStoreSecret asks the user for a secret and stores it based on the mode. -func (m *Manager) promptAndStoreSecret(profileID string, secretType string, mode models.SecretMode, getSecret func(prompt string) (string, error), prompt string) (string, error) { - if getSecret == nil { - return "", fmt.Errorf("no %s available and no prompt callback provided", secretType) - } - val, err := getSecret(prompt) - if err != nil { - return "", err - } - if val == "" { - return "", fmt.Errorf("%s cannot be empty", secretType) - } - - if mode == models.SecretModeSaved { - stored := false - secretKey := fmt.Sprintf("profile:%s:%s", profileID, secretType) - if m.keychainStore != nil { - if sErr := m.keychainStore.SetSecret(secretKey, val); sErr == nil { - stored = true - } - } - if !stored && m.secretStore != nil && m.secretStore.IsUnlocked() { - _ = m.secretStore.SetSecret(secretKey, val) - } - } - - return val, nil -} - -// isGitHubAuthError checks whether an error indicates authentication failure. -func (m *Manager) isGitHubAuthError(err error) bool { - if err == nil { - return false - } - msg := strings.ToLower(err.Error()) - return strings.Contains(msg, "token validation failed") || - strings.Contains(msg, "authentication failed") || - strings.Contains(msg, "http 401") || - strings.Contains(msg, "http 403") -} - -// closeSession shuts down the protocol client for a session. -func (m *Manager) closeSession(sess *Session) { - m.mu.Lock() - sess.closed = true - cancelReconn := sess.cancelReconn - recorder := sess.recorder - sftpClient := sess.sftpClient - sshClient := sess.sshClient - telnetClient := sess.telnetClient - rawClient := sess.rawClient - githubCancel := sess.githubCancel - vncClient := sess.vncClient - ircClient := sess.ircClient - localClient := sess.localClient - - // Break all retained client/callback references while holding the lifecycle - // lock. This makes repeated close calls harmless and lets closed sessions be - // collected even if a short-lived callback still holds the Session object. - sess.cancelReconn = nil - sess.recorder = nil - sess.sftpClient = nil - sess.sshClient = nil - sess.telnetClient = nil - sess.rawClient = nil - sess.githubClient = nil - sess.githubCancel = nil - sess.vncClient = nil - sess.ircClient = nil - sess.localClient = nil - sess.profile = nil - sess.localShell = "" - sess.localCwd = "" - sess.reconnectCount = 0 - sess.pendingResizeCols = 0 - sess.pendingResizeRows = 0 - sess.Info.State = models.SessionDisconnected - m.mu.Unlock() - - if cancelReconn != nil { - cancelReconn() - } - if recorder != nil { - _ = recorder.Close() - } - if sftpClient != nil { - _ = sftpClient.Close() - } - if sshClient != nil { - _ = sshClient.Close() - } - if telnetClient != nil { - _ = telnetClient.Close() - } - if rawClient != nil { - _ = rawClient.Close() - } - if githubCancel != nil { - githubCancel() - } - if vncClient != nil { - vncClient.Close() - } - if ircClient != nil { - ircClient.Close() - } - if localClient != nil { - localClient.Close() - } -} - -// --------------------------------------------------------------------------- -// Login script execution (expect-style send/wait) -// --------------------------------------------------------------------------- - -// executeLoginScripts processes an expect-style login script sequence against -// a connected session. "wait" steps block until the specified text appears in -// the session output (with a timeout). "send" steps write text to the session -// input. The session's loginBuf/loginDone fields are used for pattern matching. -func (m *Manager) executeLoginScripts(sess *Session, steps []models.LoginScriptStep) { - // Initialise the login buffer so the onData hook starts accumulating. - sess.loginBufMu.Lock() - sess.loginBuf = nil - sess.loginDone = make(chan struct{}) - sess.loginBufMu.Unlock() - - defer func() { - sess.loginBufMu.Lock() - close(sess.loginDone) - sess.loginBuf = nil - sess.loginBufMu.Unlock() - }() - - // Short delay to allow the shell to start and produce its initial prompt. - time.Sleep(500 * time.Millisecond) - - for i, step := range steps { - // Check session is still alive. - m.mu.RLock() - state := sess.Info.State - m.mu.RUnlock() - if state != models.SessionConnected { - return - } - - switch step.Type { - case "wait": - timeout := time.Duration(step.Timeout) * time.Second - if timeout <= 0 { - timeout = 10 * time.Second - } - if err := m.waitForOutput(sess, step.Value, timeout); err != nil { - m.emitEvent("session:debug", map[string]interface{}{ - "sessionID": sess.Info.ID, - "message": fmt.Sprintf("login script step %d timed out waiting for %q", i+1, step.Value), - }) - return - } - - case "send": - data := []byte(step.Value + "\n") - if err := m.WriteToSession(sess.Info.ID, data); err != nil { - m.emitEvent("session:debug", map[string]interface{}{ - "sessionID": sess.Info.ID, - "message": fmt.Sprintf("login script step %d failed to send: %v", i+1, err), - }) - return - } - // Small inter-step delay for the remote to process. - time.Sleep(200 * time.Millisecond) - - default: - // Unknown step type — skip. - } - } -} - -// waitForOutput polls the session's login buffer until the specified text -// appears or the timeout expires. -func (m *Manager) waitForOutput(sess *Session, pattern string, timeout time.Duration) error { - if pattern == "" { - return nil - } - - deadline := time.After(timeout) - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-deadline: - return fmt.Errorf("timeout waiting for %q", pattern) - case <-ticker.C: - sess.loginBufMu.Lock() - found := strings.Contains(string(sess.loginBuf), pattern) - if found { - // Clear the buffer up to the match so subsequent waits start fresh. - idx := strings.Index(string(sess.loginBuf), pattern) - sess.loginBuf = sess.loginBuf[idx+len(pattern):] - } - sess.loginBufMu.Unlock() - if found { - return nil - } - } - } -} - -// handleSessionClosed is called when a protocol client reports closure. -func (m *Manager) handleSessionClosed(sessionID string) { - m.mu.Lock() - sess, ok := m.sessions[sessionID] - if !ok || sess.closed { - m.mu.Unlock() - return - } - sess.Info.State = models.SessionDisconnected - m.mu.Unlock() - - if m.auditLog != nil { - m.auditLog.LogDisconnect(sessionID) - } - - // Check whether auto-reconnect is enabled for this session. - if m.shouldAutoReconnect(sess) { - m.scheduleReconnect(sess, sessionID) - return - } - - m.emitEvent("session:closed", map[string]interface{}{ - "sessionID": sessionID, - }) - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sessionID, - "state": string(models.SessionDisconnected), - }) -} - -// shouldAutoReconnect checks whether a session is configured for automatic reconnection. -func (m *Manager) shouldAutoReconnect(sess *Session) bool { - if sess.profile == nil { - return false - } - switch sess.profile.Protocol { - case models.ProtocolSSH: - if sess.profile.SSH != nil { - return sess.profile.SSH.AutoReconnect - } - case models.ProtocolTelnet: - if sess.profile.Telnet != nil { - return sess.profile.Telnet.AutoReconnect - } - case models.ProtocolRAW: - if sess.profile.RAW != nil { - return sess.profile.RAW.AutoReconnect - } - case models.ProtocolIRC: - if sess.profile.IRC != nil { - return sess.profile.IRC.AutoReconnect - } - } - return false -} - -// maxReconnectAttempts returns the configured (or default) max retry count. -func (m *Manager) maxReconnectAttempts(sess *Session) int { - maxTries := 5 - switch sess.profile.Protocol { - case models.ProtocolSSH: - if sess.profile.SSH != nil && sess.profile.SSH.MaxReconnectTries > 0 { - maxTries = sess.profile.SSH.MaxReconnectTries - } - case models.ProtocolTelnet: - if sess.profile.Telnet != nil && sess.profile.Telnet.MaxReconnectTries > 0 { - maxTries = sess.profile.Telnet.MaxReconnectTries - } - case models.ProtocolRAW: - if sess.profile.RAW != nil { - maxTries = 5 - } - case models.ProtocolIRC: - if sess.profile.IRC != nil && sess.profile.IRC.MaxReconnectTries > 0 { - maxTries = sess.profile.IRC.MaxReconnectTries - } - } - return maxTries -} - -// scheduleReconnect initiates an exponential-backoff reconnection loop for a session. -func (m *Manager) scheduleReconnect(sess *Session, sessionID string) { - maxTries := m.maxReconnectAttempts(sess) - - m.mu.Lock() - sess.reconnectCount++ - attempt := sess.reconnectCount - if attempt > maxTries { - // Give up — too many attempts. - sess.Info.State = models.SessionDisconnected - m.mu.Unlock() - - m.emitEvent("session:closed", map[string]interface{}{ - "sessionID": sessionID, - }) - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sessionID, - "state": string(models.SessionDisconnected), - "error": "auto-reconnect failed after maximum attempts", - }) - return - } - - sess.Info.State = models.SessionReconnecting - sess.Info.Error = "" - - // Cancel any previous reconnect timer. - if sess.cancelReconn != nil { - sess.cancelReconn() - } - ctx, cancel := context.WithCancel(context.Background()) - sess.cancelReconn = cancel - m.mu.Unlock() - - // Exponential backoff: 1s, 2s, 4s, 8s, 16s, capped at 30s. - delaySec := 1 << (attempt - 1) // 2^(attempt-1) - if delaySec > 30 { - delaySec = 30 - } - - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sessionID, - "state": string(models.SessionReconnecting), - "attempt": attempt, - "maxRetries": maxTries, - "delaySec": delaySec, - }) - - go func() { - select { - case <-ctx.Done(): - return - case <-time.After(time.Duration(delaySec) * time.Second): - } - - // Clear old clients before reconnecting. - m.mu.Lock() - if current, ok := m.sessions[sessionID]; !ok || current != sess || sess.closed { - m.mu.Unlock() - return - } - sess.sshClient = nil - sess.telnetClient = nil - sess.rawClient = nil - sess.ircClient = nil - sess.sftpClient = nil - sess.Info.State = models.SessionConnecting - m.mu.Unlock() - - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sessionID, - "state": string(models.SessionConnecting), - }) - - var err error - switch sess.profile.Protocol { - case models.ProtocolSSH: - err = m.connectSSH(sess, m.buildInteractiveSecretCallback(ctx, sessionID)) - case models.ProtocolTelnet: - err = m.connectTelnet(sess) - case models.ProtocolRAW: - err = m.connectRAW(sess) - case models.ProtocolIRC: - err = m.connectIRC(sess, m.buildInteractiveSecretCallback(ctx, sessionID)) - default: - err = fmt.Errorf("unsupported protocol for reconnect: %s", sess.profile.Protocol) - } - - if err != nil { - m.mu.Lock() - if current, ok := m.sessions[sessionID]; !ok || current != sess || sess.closed { - m.mu.Unlock() - m.closeSession(sess) - return - } - sess.Info.State = models.SessionError - sess.Info.Error = err.Error() - m.mu.Unlock() - - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sessionID, - "state": string(models.SessionError), - "error": err.Error(), - }) - - // Schedule the next attempt. - m.scheduleReconnect(sess, sessionID) - return - } - - // Success — reset the counter. - m.mu.Lock() - if current, ok := m.sessions[sessionID]; !ok || current != sess || sess.closed { - m.mu.Unlock() - m.closeSession(sess) - return - } - sess.reconnectCount = 0 - sess.Info.State = models.SessionConnected - sess.Info.ConnectedAt = time.Now() - m.mu.Unlock() - - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sessionID, - "state": string(models.SessionConnected), - }) - }() -} - -// buildInteractiveSecretCallback creates a getSecret callback that prompts -// the user via Wails events. Used during auto-reconnect flows where the -// original callback is no longer available. -func (m *Manager) buildInteractiveSecretCallback(ctx context.Context, sessionID string) func(prompt string) (string, error) { - return func(prompt string) (string, error) { - requestID := uuid.New().String() - - ch := make(chan secretPromptResponse, 1) - m.secretMu.Lock() - if m.secretChans == nil { - m.secretChans = make(map[string]chan secretPromptResponse) - } - m.secretChans[requestID] = ch - m.secretMu.Unlock() - defer func() { - m.secretMu.Lock() - delete(m.secretChans, requestID) - m.secretMu.Unlock() - }() - - m.emitEvent("session:secret:prompt", map[string]interface{}{ - "requestID": requestID, - "sessionID": sessionID, - "title": "Reconnection Credentials", - "message": prompt, - }) - - select { - case response := <-ch: - if response.cancelled { - return "", fmt.Errorf("credential prompt cancelled during reconnect") - } - return response.value, nil - case <-ctx.Done(): - return "", fmt.Errorf("credential prompt cancelled during reconnect: %w", ctx.Err()) - case <-time.After(60 * time.Second): - return "", fmt.Errorf("credential prompt timed out during reconnect") - } - } -} - -func (m *Manager) registerSecretResponseListener() { - ctx := m.ctx - if ctx == nil { - return - } - - m.secretMu.Lock() - if m.secretChans == nil { - m.secretChans = make(map[string]chan secretPromptResponse) - } - if m.secretListenerRegistered { - m.secretMu.Unlock() - return - } - m.secretListenerRegistered = true - m.secretMu.Unlock() - - cancel := wailsRuntime.EventsOn(ctx, "session:secret:response", m.handleSecretResponse) - m.secretMu.Lock() - m.secretListenerCancel = cancel - m.secretMu.Unlock() -} - -func (m *Manager) handleSecretResponse(optionalData ...interface{}) { - if len(optionalData) == 0 { - return - } - data, ok := optionalData[0].(map[string]interface{}) - if !ok { - return - } - requestID, _ := data["requestID"].(string) - value, _ := data["value"].(string) - cancelled, _ := data["cancelled"].(bool) - m.RespondSecret(requestID, value, cancelled) -} - -// RespondSecret supplies a credential prompt response for reconnect prompts in -// non-Wails hosts. -func (m *Manager) RespondSecret(requestID string, value string, cancelled bool) { - m.secretMu.Lock() - ch, exists := m.secretChans[requestID] - m.secretMu.Unlock() - - if exists { - select { - case ch <- secretPromptResponse{value: value, cancelled: cancelled}: - default: - } - } -} - -// CancelReconnect cancels any in-progress auto-reconnect for a session. -func (m *Manager) CancelReconnect(sessionID string) { - m.mu.Lock() - sess, ok := m.sessions[sessionID] - if ok && sess.cancelReconn != nil { - sess.cancelReconn() - sess.cancelReconn = nil - sess.reconnectCount = 0 - sess.Info.State = models.SessionDisconnected - } - m.mu.Unlock() - - if ok { - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sessionID, - "state": string(models.SessionDisconnected), - }) - } -} - -// createTabName generates a default tab name from the profile's display name -// and host. -func (m *Manager) createTabName(profile *models.Profile) string { - if profile.DisplayName != "" { - return profile.DisplayName - } - if profile.Username != "" { - return fmt.Sprintf("%s@%s", profile.Username, profile.Host) - } - return profile.Host -} - -// emitEvent sends an event to the Wails frontend via the runtime context. -func (m *Manager) emitEvent(eventName string, data interface{}) { - m.mu.RLock() - sink := m.eventSink - m.mu.RUnlock() - if sink != nil { - sink(eventName, data) - } - if m.ctx == nil { - return - } - wailsRuntime.EventsEmit(m.ctx, eventName, data) -} diff --git a/internal/sessions/manager_test.go b/internal/sessions/manager_test.go deleted file mode 100644 index 8f025ee..0000000 --- a/internal/sessions/manager_test.go +++ /dev/null @@ -1,507 +0,0 @@ -package sessions - -import ( - "context" - "crypto/ed25519" - "crypto/rand" - "encoding/base64" - "fmt" - "os" - "path/filepath" - "runtime" - "strings" - "sync" - "testing" - "time" - - "github.com/andydixon/link/internal/audit" - "github.com/andydixon/link/internal/models" - "github.com/andydixon/link/internal/storage" - "golang.org/x/crypto/ssh" -) - -// --------------------------------------------------------------------------- -// NewManager creates with empty sessions map -// --------------------------------------------------------------------------- - -func TestNewManager(t *testing.T) { - // NewManager requires backing services; pass nil for lightweight tests - // where no actual connections are made. - mgr := NewManager(nil, nil, nil, nil, nil) - if mgr == nil { - t.Fatal("NewManager() returned nil") - } - if mgr.sessions == nil { - t.Fatal("expected sessions map to be initialised") - } - if len(mgr.sessions) != 0 { - t.Errorf("expected empty sessions map, got %d entries", len(mgr.sessions)) - } -} - -// --------------------------------------------------------------------------- -// ListSessions on an empty manager -// --------------------------------------------------------------------------- - -func TestListSessionsEmpty(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, nil) - sessions := mgr.ListSessions() - if len(sessions) != 0 { - t.Errorf("expected 0 sessions, got %d", len(sessions)) - } -} - -// --------------------------------------------------------------------------- -// createTabName logic -// --------------------------------------------------------------------------- - -func TestCreateTabName(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, nil) - - tests := []struct { - name string - profile *models.Profile - want string - }{ - { - name: "uses display name when set", - profile: &models.Profile{ - DisplayName: "Production DB", - Host: "db.prod.com", - Username: "admin", - }, - want: "Production DB", - }, - { - name: "falls back to user@host when no display name", - profile: &models.Profile{ - DisplayName: "", - Host: "server.local", - Username: "root", - }, - want: "root@server.local", - }, - { - name: "falls back to host only when no display name or username", - profile: &models.Profile{ - DisplayName: "", - Host: "switch.local", - Username: "", - }, - want: "switch.local", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := mgr.createTabName(tt.profile) - if got != tt.want { - t.Errorf("createTabName() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestMergeIRCRoomsPreservesConfiguredAndRememberedOrder(t *testing.T) { - got := mergeIRCRooms( - []string{" #configured ", "#Shared"}, - []string{"#shared", "#last-session", "", "#CONFIGURED"}, - ) - if joined := strings.Join(got, ","); joined != "#configured,#Shared,#last-session" { - t.Fatalf("mergeIRCRooms() = %q, want configured rooms followed by new remembered rooms", joined) - } -} - -// --------------------------------------------------------------------------- -// RenameSession on non-existent session -// --------------------------------------------------------------------------- - -func TestRenameSessionNotFound(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, nil) - err := mgr.RenameSession("nonexistent-id", "New Name") - if err == nil { - t.Error("expected error when renaming non-existent session") - } -} - -// --------------------------------------------------------------------------- -// PinSession on non-existent session -// --------------------------------------------------------------------------- - -func TestPinSessionNotFound(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, nil) - err := mgr.PinSession("nonexistent-id", true) - if err == nil { - t.Error("expected error when pinning non-existent session") - } -} - -// --------------------------------------------------------------------------- -// GetSession on non-existent session -// --------------------------------------------------------------------------- - -func TestGetSessionNotFound(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, nil) - _, err := mgr.GetSession("nonexistent-id") - if err == nil { - t.Error("expected error when getting non-existent session") - } -} - -// --------------------------------------------------------------------------- -// Disconnect on non-existent session -// --------------------------------------------------------------------------- - -func TestDisconnectNotFound(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, nil) - err := mgr.Disconnect("nonexistent-id") - if err == nil { - t.Error("expected error when disconnecting non-existent session") - } -} - -func TestDisconnectRemovesSessionAndReleasesReferences(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, nil) - sess := &Session{ - Info: models.SessionInfo{ - ID: "session-1", - State: models.SessionConnected, - }, - profile: &models.Profile{ID: "profile-1"}, - } - mgr.sessions[sess.Info.ID] = sess - - if err := mgr.Disconnect(sess.Info.ID); err != nil { - t.Fatalf("Disconnect() error = %v", err) - } - if got := len(mgr.ListSessions()); got != 0 { - t.Fatalf("ListSessions() returned %d sessions after disconnect, want 0", got) - } - if !sess.closed { - t.Fatal("disconnected session was not marked closed") - } - if sess.profile != nil { - t.Fatal("disconnected session retained its profile") - } -} - -func TestRepeatedDisconnectDoesNotRetainSessions(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, nil) - - for i := 0; i < 1000; i++ { - id := fmt.Sprintf("session-%d", i) - mgr.sessions[id] = &Session{ - Info: models.SessionInfo{ - ID: id, - State: models.SessionConnected, - }, - } - if err := mgr.Disconnect(id); err != nil { - t.Fatalf("Disconnect(%q) error = %v", id, err) - } - } - - if got := len(mgr.ListSessions()); got != 0 { - t.Fatalf("ListSessions() retained %d explicitly closed sessions, want 0", got) - } -} - -// --------------------------------------------------------------------------- -// CloseAll on empty manager does not panic -// --------------------------------------------------------------------------- - -func TestCloseAllEmpty(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, nil) - // Should not panic. - mgr.CloseAll() - if len(mgr.ListSessions()) != 0 { - t.Error("expected 0 sessions after CloseAll") - } -} - -func TestTerminalDimension(t *testing.T) { - if got := terminalDimension(0, 80); got != 80 { - t.Fatalf("terminalDimension(0, 80) = %d, want 80", got) - } - if got := terminalDimension(132, 80); got != 132 { - t.Fatalf("terminalDimension(132, 80) = %d, want 132", got) - } - if got := terminalDimension(100000, 80); got != ^uint16(0) { - t.Fatalf("terminalDimension(100000, 80) = %d, want %d", got, ^uint16(0)) - } -} - -func TestRestartLocalReusesSessionAndAppliesRequestedSize(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("local PTY test requires a Unix shell") - } - dir := t.TempDir() - script := filepath.Join(dir, "local-shell.sh") - if err := os.WriteFile(script, []byte("#!/bin/sh\nstty size\n"), 0o700); err != nil { - t.Fatal(err) - } - - auditStore := &sessionAuditStore{} - mgr := NewManager(nil, nil, nil, audit.NewLogger(auditStore), nil) - var outputMu sync.Mutex - var output strings.Builder - disconnected := make(chan string, 2) - mgr.SetEventSink(func(name string, payload interface{}) { - values, ok := payload.(map[string]interface{}) - if !ok { - return - } - if name == "session:data" { - encoded, _ := values["data"].(string) - decoded, _ := base64.StdEncoding.DecodeString(encoded) - outputMu.Lock() - _, _ = output.Write(decoded) - outputMu.Unlock() - } - if name == "session:state" && values["state"] == string(models.SessionDisconnected) { - disconnected <- values["sessionID"].(string) - } - }) - - sessionID, err := mgr.ConnectLocal(script) - if err != nil { - t.Fatalf("ConnectLocal() error = %v", err) - } - waitForDisconnectedSession(t, disconnected, sessionID) - - if err := mgr.RestartLocal(sessionID, 132, 43); err != nil { - t.Fatalf("RestartLocal() error = %v", err) - } - waitForDisconnectedSession(t, disconnected, sessionID) - - info := mgr.GetSessionInfo(sessionID) - if info == nil || info.ID != sessionID || info.State != models.SessionDisconnected { - t.Fatalf("session after restart = %#v, want same disconnected session", info) - } - outputMu.Lock() - got := strings.ReplaceAll(output.String(), "\r", "") - outputMu.Unlock() - if !strings.Contains(got, "43 132\n") { - t.Fatalf("local output %q does not contain restarted size 43 132", got) - } -} - -func waitForDisconnectedSession(t *testing.T, states <-chan string, want string) { - t.Helper() - select { - case got := <-states: - if got != want { - t.Fatalf("disconnected session = %q, want %q", got, want) - } - case <-time.After(5 * time.Second): - t.Fatalf("timed out waiting for session %q to disconnect", want) - } -} - -type sessionAuditStore struct { - mu sync.Mutex - entries []*models.AuditEntry -} - -func (s *sessionAuditStore) AddAuditEntry(entry *models.AuditEntry) error { - s.mu.Lock() - s.entries = append(s.entries, entry) - s.mu.Unlock() - return nil -} - -func (s *sessionAuditStore) ListAuditEntries(limit int) ([]*models.AuditEntry, error) { - s.mu.Lock() - defer s.mu.Unlock() - return append([]*models.AuditEntry(nil), s.entries...), nil -} - -func TestHostKeyCallbackAcceptNewStoresHost(t *testing.T) { - store := newSessionTestStore(t) - mgr := NewManager(store, nil, nil, nil, nil) - callback := mgr.hostKeyCallback("session-1", models.HostKeyAcceptNew) - - key := testPublicKey(t) - if err := callback("jump.example:2222", nil, key); err != nil { - t.Fatalf("HostKeyCallback() error = %v", err) - } - - saved, err := store.GetKnownHost("jump.example", 2222) - if err != nil { - t.Fatalf("GetKnownHost() error = %v", err) - } - if saved == nil { - t.Fatal("expected host key to be saved") - } - if saved.Host != "jump.example" { - t.Fatalf("saved host = %q, want jump.example", saved.Host) - } - if saved.Port != 2222 { - t.Fatalf("saved port = %d, want 2222", saved.Port) - } -} - -func TestHostKeyCallbackStrictRejectsUnknownHost(t *testing.T) { - mgr := NewManager(newSessionTestStore(t), nil, nil, nil, nil) - callback := mgr.hostKeyCallback("session-1", models.HostKeyStrict) - - err := callback("unknown.example:22", nil, testPublicKey(t)) - if err == nil { - t.Fatal("expected strict policy to reject unknown host") - } -} - -func TestHandleHostKeyResponseRoutesByRequestID(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, nil) - ch := make(chan bool, 1) - mgr.hostKeyChans = map[string]chan bool{"request-1": ch} - - mgr.handleHostKeyResponse(map[string]interface{}{ - "requestID": "request-1", - "accepted": true, - }) - - select { - case accepted := <-ch: - if !accepted { - t.Fatal("accepted = false, want true") - } - default: - t.Fatal("expected host key response to be routed") - } -} - -func TestHandleHostKeyResponseIgnoresOtherRequestIDs(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, nil) - ch := make(chan bool, 1) - mgr.hostKeyChans = map[string]chan bool{"request-1": ch} - - mgr.handleHostKeyResponse(map[string]interface{}{ - "requestID": "request-2", - "accepted": true, - }) - - select { - case <-ch: - t.Fatal("unexpected host key response routed") - default: - } -} - -func TestHandleSecretResponseRoutesByRequestID(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, nil) - ch := make(chan secretPromptResponse, 1) - mgr.secretChans = map[string]chan secretPromptResponse{"request-1": ch} - - mgr.handleSecretResponse(map[string]interface{}{ - "requestID": "request-1", - "value": "secret", - "cancelled": false, - }) - - select { - case response := <-ch: - if response.value != "secret" || response.cancelled { - t.Fatalf("response = %#v, want non-cancelled secret", response) - } - default: - t.Fatal("expected secret response to be routed") - } -} - -func TestInteractiveSecretPromptIsReleasedByReconnectCancellation(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, nil) - prompts := make(chan map[string]interface{}, 1) - mgr.SetEventSink(func(name string, data interface{}) { - if name == "session:secret:prompt" { - prompts <- data.(map[string]interface{}) - } - }) - - ctx, cancel := context.WithCancel(context.Background()) - result := make(chan error, 1) - go func() { - _, err := mgr.buildInteractiveSecretCallback(ctx, "session-1")("Password") - result <- err - }() - - select { - case <-prompts: - case <-time.After(time.Second): - t.Fatal("timed out waiting for secret prompt") - } - cancel() - - select { - case err := <-result: - if err == nil || !strings.Contains(err.Error(), "cancelled") { - t.Fatalf("callback error = %v, want cancellation", err) - } - case <-time.After(time.Second): - t.Fatal("secret prompt did not stop after reconnect cancellation") - } - - mgr.secretMu.Lock() - pending := len(mgr.secretChans) - mgr.secretMu.Unlock() - if pending != 0 { - t.Fatalf("pending secret prompts = %d, want 0", pending) - } -} - -func TestInteractiveSecretPromptHonoursFrontendCancellation(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil, nil) - prompts := make(chan map[string]interface{}, 1) - mgr.SetEventSink(func(name string, data interface{}) { - if name == "session:secret:prompt" { - prompts <- data.(map[string]interface{}) - } - }) - - result := make(chan error, 1) - go func() { - _, err := mgr.buildInteractiveSecretCallback(context.Background(), "session-1")("Password") - result <- err - }() - - var requestID string - select { - case prompt := <-prompts: - requestID, _ = prompt["requestID"].(string) - case <-time.After(time.Second): - t.Fatal("timed out waiting for secret prompt") - } - mgr.RespondSecret(requestID, "", true) - - select { - case err := <-result: - if err == nil || !strings.Contains(err.Error(), "cancelled") { - t.Fatalf("callback error = %v, want frontend cancellation", err) - } - case <-time.After(time.Second): - t.Fatal("secret prompt did not stop after frontend cancellation") - } -} - -func testPublicKey(t *testing.T) ssh.PublicKey { - t.Helper() - pub, _, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatalf("GenerateKey() error = %v", err) - } - key, err := ssh.NewPublicKey(pub) - if err != nil { - t.Fatalf("NewPublicKey() error = %v", err) - } - return key -} - -func newSessionTestStore(t *testing.T) *storage.Store { - t.Helper() - store, err := storage.NewStore(filepath.Join(t.TempDir(), "test.db")) - if err != nil { - t.Fatalf("NewStore() error = %v", err) - } - t.Cleanup(func() { store.Close() }) - return store -} diff --git a/internal/sftpclient/sftpclient.go b/internal/sftpclient/sftpclient.go deleted file mode 100644 index b4f637d..0000000 --- a/internal/sftpclient/sftpclient.go +++ /dev/null @@ -1,341 +0,0 @@ -// Package sftpclient provides an SFTP file browser and transfer capability -// over an established SSH connection. It wraps github.com/pkg/sftp to provide -// directory listing, upload, download, rename, delete, and mkdir operations -// that the frontend can call through the Wails API. -// -// The client is created per-session — each SSH session can have at most one -// SFTP subsystem active. -package sftpclient - -import ( - "bytes" - "fmt" - "io" - "os" - "path" - "sort" - "strings" - "sync" - "time" - - "github.com/pkg/sftp" - "golang.org/x/crypto/ssh" -) - -// FileEntry represents a single file or directory in a remote listing. -type FileEntry struct { - Name string `json:"name"` - Path string `json:"path"` // full remote path - Size int64 `json:"size"` - IsDir bool `json:"isDir"` - Mode string `json:"mode"` // Unix permission string, e.g. "-rwxr-xr-x" - ModifiedAt int64 `json:"modifiedAt"` // Unix epoch milliseconds - Owner string `json:"owner"` // best-effort UID -} - -// TransferProgress reports the state of a file transfer. -type TransferProgress struct { - Transferred int64 `json:"transferred"` // bytes transferred so far - Total int64 `json:"total"` // total bytes (0 if unknown) - Path string `json:"path"` // file being transferred - Done bool `json:"done"` - Error string `json:"error,omitempty"` -} - -// Client wraps an SFTP session. -type Client struct { - mu sync.Mutex - sftp *sftp.Client - closed bool -} - -// NewClient creates an SFTP subsystem over an existing SSH connection. -func NewClient(sshConn *ssh.Client) (*Client, error) { - sc, err := sftp.NewClient(sshConn) - if err != nil { - return nil, fmt.Errorf("sftpclient: failed to start SFTP subsystem: %w", err) - } - return &Client{sftp: sc}, nil -} - -// Close shuts down the SFTP subsystem. -func (c *Client) Close() error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.closed { - return nil - } - c.closed = true - if c.sftp != nil { - return c.sftp.Close() - } - return nil -} - -// List returns the contents of a remote directory. -func (c *Client) List(remotePath string) ([]FileEntry, error) { - c.mu.Lock() - defer c.mu.Unlock() - - if c.closed || c.sftp == nil { - return nil, fmt.Errorf("sftpclient: client is closed") - } - - entries, err := c.sftp.ReadDir(remotePath) - if err != nil { - return nil, fmt.Errorf("sftpclient: failed to list %s: %w", remotePath, err) - } - - result := make([]FileEntry, 0, len(entries)) - for _, e := range entries { - fe := FileEntry{ - Name: e.Name(), - Path: path.Join(remotePath, e.Name()), - Size: e.Size(), - IsDir: e.IsDir(), - Mode: e.Mode().String(), - ModifiedAt: e.ModTime().UnixMilli(), - } - result = append(result, fe) - } - - // Sort: directories first, then alphabetically. - sort.Slice(result, func(i, j int) bool { - if result[i].IsDir != result[j].IsDir { - return result[i].IsDir - } - return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name) - }) - - return result, nil -} - -// Stat returns information about a single remote path. -func (c *Client) Stat(remotePath string) (*FileEntry, error) { - c.mu.Lock() - defer c.mu.Unlock() - - if c.closed || c.sftp == nil { - return nil, fmt.Errorf("sftpclient: client is closed") - } - - info, err := c.sftp.Stat(remotePath) - if err != nil { - return nil, fmt.Errorf("sftpclient: stat failed for %s: %w", remotePath, err) - } - - return &FileEntry{ - Name: info.Name(), - Path: remotePath, - Size: info.Size(), - IsDir: info.IsDir(), - Mode: info.Mode().String(), - ModifiedAt: info.ModTime().UnixMilli(), - }, nil -} - -// Mkdir creates a remote directory. -func (c *Client) Mkdir(remotePath string) error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.closed || c.sftp == nil { - return fmt.Errorf("sftpclient: client is closed") - } - - return c.sftp.Mkdir(remotePath) -} - -// MkdirAll creates a remote directory and all parents. -func (c *Client) MkdirAll(remotePath string) error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.closed || c.sftp == nil { - return fmt.Errorf("sftpclient: client is closed") - } - - return c.sftp.MkdirAll(remotePath) -} - -// Remove deletes a remote file or empty directory. -func (c *Client) Remove(remotePath string) error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.closed || c.sftp == nil { - return fmt.Errorf("sftpclient: client is closed") - } - - return c.sftp.Remove(remotePath) -} - -// Rename moves or renames a remote file/directory. -func (c *Client) Rename(oldPath, newPath string) error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.closed || c.sftp == nil { - return fmt.Errorf("sftpclient: client is closed") - } - - return c.sftp.Rename(oldPath, newPath) -} - -// Download copies a remote file to a local path. The onProgress callback -// is invoked periodically with transfer progress (may be nil). -func (c *Client) Download(remotePath, localPath string, onProgress func(TransferProgress)) error { - c.mu.Lock() - if c.closed || c.sftp == nil { - c.mu.Unlock() - return fmt.Errorf("sftpclient: client is closed") - } - - // Get file size for progress reporting. - info, err := c.sftp.Stat(remotePath) - if err != nil { - c.mu.Unlock() - return fmt.Errorf("sftpclient: stat failed: %w", err) - } - totalSize := info.Size() - - remote, err := c.sftp.Open(remotePath) - if err != nil { - c.mu.Unlock() - return fmt.Errorf("sftpclient: failed to open remote file: %w", err) - } - c.mu.Unlock() - defer remote.Close() - - local, err := os.Create(localPath) - if err != nil { - return fmt.Errorf("sftpclient: failed to create local file: %w", err) - } - defer local.Close() - - return c.copyWithProgress(remote, local, totalSize, remotePath, onProgress) -} - -// Upload copies a local file to a remote path. The onProgress callback -// is invoked periodically with transfer progress (may be nil). -func (c *Client) Upload(localPath, remotePath string, onProgress func(TransferProgress)) error { - local, err := os.Open(localPath) - if err != nil { - return fmt.Errorf("sftpclient: failed to open local file: %w", err) - } - defer local.Close() - - info, err := local.Stat() - if err != nil { - return fmt.Errorf("sftpclient: failed to stat local file: %w", err) - } - totalSize := info.Size() - - c.mu.Lock() - if c.closed || c.sftp == nil { - c.mu.Unlock() - return fmt.Errorf("sftpclient: client is closed") - } - remote, err := c.sftp.Create(remotePath) - if err != nil { - c.mu.Unlock() - return fmt.Errorf("sftpclient: failed to create remote file: %w", err) - } - c.mu.Unlock() - defer remote.Close() - - return c.copyWithProgress(local, remote, totalSize, remotePath, onProgress) -} - -// UploadBytes writes in-memory data to a remote file path. -func (c *Client) UploadBytes(remotePath string, data []byte, onProgress func(TransferProgress)) error { - c.mu.Lock() - if c.closed || c.sftp == nil { - c.mu.Unlock() - return fmt.Errorf("sftpclient: client is closed") - } - remote, err := c.sftp.Create(remotePath) - if err != nil { - c.mu.Unlock() - return fmt.Errorf("sftpclient: failed to create remote file: %w", err) - } - c.mu.Unlock() - defer remote.Close() - - reader := bytes.NewReader(data) - return c.copyWithProgress(reader, remote, int64(len(data)), remotePath, onProgress) -} - -// CreateWriter creates or truncates a remote file and returns a streaming -// writer for callers that already manage progress and chunking. -func (c *Client) CreateWriter(remotePath string) (io.WriteCloser, error) { - c.mu.Lock() - defer c.mu.Unlock() - if c.closed || c.sftp == nil { - return nil, fmt.Errorf("sftpclient: client is closed") - } - remote, err := c.sftp.Create(remotePath) - if err != nil { - return nil, fmt.Errorf("sftpclient: failed to create remote file: %w", err) - } - return remote, nil -} - -// Pwd returns the current working directory on the remote host. -func (c *Client) Pwd() (string, error) { - c.mu.Lock() - defer c.mu.Unlock() - - if c.closed || c.sftp == nil { - return "", fmt.Errorf("sftpclient: client is closed") - } - - return c.sftp.Getwd() -} - -// copyWithProgress copies from src to dst, reporting progress periodically. -func (c *Client) copyWithProgress(src io.Reader, dst io.Writer, totalSize int64, filePath string, onProgress func(TransferProgress)) error { - buf := make([]byte, 32*1024) // 32 KB buffer - var transferred int64 - lastReport := time.Now() - - for { - n, err := src.Read(buf) - if n > 0 { - nw, wErr := dst.Write(buf[:n]) - if wErr != nil { - return fmt.Errorf("sftpclient: write failed: %w", wErr) - } - transferred += int64(nw) - - // Report progress at most every 100ms. - if onProgress != nil && time.Since(lastReport) > 100*time.Millisecond { - onProgress(TransferProgress{ - Transferred: transferred, - Total: totalSize, - Path: filePath, - }) - lastReport = time.Now() - } - } - if err == io.EOF { - break - } - if err != nil { - return fmt.Errorf("sftpclient: read failed: %w", err) - } - } - - // Final progress report. - if onProgress != nil { - onProgress(TransferProgress{ - Transferred: transferred, - Total: totalSize, - Path: filePath, - Done: true, - }) - } - - return nil -} diff --git a/internal/sshclient/agent.go b/internal/sshclient/agent.go deleted file mode 100644 index 8de7d5c..0000000 --- a/internal/sshclient/agent.go +++ /dev/null @@ -1,80 +0,0 @@ -// Package sshclient — SSH agent integration. -// -// This file provides SSH agent authentication and agent forwarding. -// It connects to the local SSH agent via the SSH_AUTH_SOCK environment -// variable and can both use agent keys for authentication and forward -// the agent channel to the remote host (allowing the remote session -// to authenticate further hops using local keys). -package sshclient - -import ( - "fmt" - "net" - "os" - - "golang.org/x/crypto/ssh" - "golang.org/x/crypto/ssh/agent" -) - -// connectAgent connects to the local SSH agent via SSH_AUTH_SOCK. -// Returns the agent and a cleanup function, or an error if unavailable. -func connectAgent() (agent.Agent, func(), error) { - sock := os.Getenv("SSH_AUTH_SOCK") - if sock == "" { - return nil, nil, fmt.Errorf("SSH_AUTH_SOCK not set — SSH agent unavailable") - } - - conn, err := net.Dial("unix", sock) - if err != nil { - return nil, nil, fmt.Errorf("unable to connect to SSH agent at %s: %w", sock, err) - } - - ag := agent.NewClient(conn) - cleanup := func() { conn.Close() } - return ag, cleanup, nil -} - -// agentAuthMethod returns an ssh.AuthMethod that delegates authentication -// to the running SSH agent. Returns nil if the agent is unavailable. -func agentAuthMethod() (ssh.AuthMethod, func(), error) { - ag, cleanup, err := connectAgent() - if err != nil { - return nil, nil, err - } - - signers, err := ag.Signers() - if err != nil { - cleanup() - return nil, nil, fmt.Errorf("SSH agent: failed to list keys: %w", err) - } - - if len(signers) == 0 { - cleanup() - return nil, nil, fmt.Errorf("SSH agent: no keys loaded") - } - - return ssh.PublicKeysCallback(ag.Signers), cleanup, nil -} - -// requestAgentForwarding sets up agent forwarding on the SSH session so that -// the remote host can use local agent keys for further authentication. -// This is equivalent to `ssh -A`. -func requestAgentForwarding(sshClient *ssh.Client, session *ssh.Session) error { - if err := agent.RequestAgentForwarding(session); err != nil { - return fmt.Errorf("requesting agent forwarding: %w", err) - } - - ag, cleanup, err := connectAgent() - if err != nil { - return err - } - - // ForwardToAgent runs in a background goroutine and serves agent - // requests from the remote side. It stops when the SSH connection closes. - go func() { - defer cleanup() - agent.ForwardToAgent(sshClient, ag) - }() - - return nil -} diff --git a/internal/sshclient/hostkey.go b/internal/sshclient/hostkey.go deleted file mode 100644 index 57a0b29..0000000 --- a/internal/sshclient/hostkey.go +++ /dev/null @@ -1,162 +0,0 @@ -package sshclient - -import ( - "crypto/sha256" - "encoding/base64" - "fmt" - "net" - "time" - - "github.com/andydixon/link/internal/models" - "golang.org/x/crypto/ssh" -) - -// HostKeyStore is the interface for known-host persistence. -// Implementations handle the underlying storage mechanism (e.g. SQLite, file). -type HostKeyStore interface { - GetKnownHost(host string, port int) (*models.KnownHost, error) - SaveKnownHost(kh *models.KnownHost) error -} - -// HostKeyVerifier handles SSH host key verification against the known-hosts -// store. It supports four policies: -// - strict: reject unknown or changed keys -// - ask: prompt the user for unknown keys (via callback) -// - accept-new: automatically accept new keys, reject changed keys -// - insecure: accept all keys (for testing only — clearly warned) -type HostKeyVerifier struct { - store HostKeyStore - policy models.HostKeyPolicy - onPrompt func(host string, keyType string, fingerprint string) (bool, error) -} - -// NewHostKeyVerifier creates a verifier with the given store and policy. -func NewHostKeyVerifier(store HostKeyStore, policy models.HostKeyPolicy) *HostKeyVerifier { - return &HostKeyVerifier{ - store: store, - policy: policy, - } -} - -// SetPromptHandler sets the callback used in "ask" mode to prompt the user -// for acceptance of an unknown host key. -func (v *HostKeyVerifier) SetPromptHandler(fn func(string, string, string) (bool, error)) { - v.onPrompt = fn -} - -// HostKeyCallback returns an ssh.HostKeyCallback suitable for use with -// ssh.ClientConfig. The behaviour depends on the configured policy. -func (v *HostKeyVerifier) HostKeyCallback() ssh.HostKeyCallback { - return func(hostname string, remote net.Addr, key ssh.PublicKey) error { - host, port := extractHostPort(hostname, remote) - keyType := key.Type() - fingerprint := computeFingerprint(key) - - stored, err := v.store.GetKnownHost(host, port) - if err != nil { - return fmt.Errorf("failed to query known hosts: %w", err) - } - - switch v.policy { - case models.HostKeyInsecure: - return v.saveIfNew(stored, host, port, keyType, fingerprint, key) - - case models.HostKeyAcceptNew: - if stored == nil { - return v.saveHost(host, port, keyType, fingerprint, key) - } - return v.verifyStored(stored, fingerprint, host) - - case models.HostKeyAsk: - if stored == nil { - return v.promptAndSave(host, port, keyType, fingerprint, key) - } - return v.verifyStored(stored, fingerprint, host) - - case models.HostKeyStrict: - if stored == nil { - return fmt.Errorf("host key for %s:%d is not known (policy: strict)", host, port) - } - return v.verifyStored(stored, fingerprint, host) - - default: - return fmt.Errorf("unknown host key policy: %s", v.policy) - } - } -} - -// verifyStored checks that the presented fingerprint matches the stored one. -func (v *HostKeyVerifier) verifyStored(stored *models.KnownHost, fingerprint, host string) error { - if stored.Fingerprint != fingerprint { - return fmt.Errorf( - "host key for %s has changed! stored fingerprint: %s, received: %s — possible MITM attack", - host, stored.Fingerprint, fingerprint, - ) - } - stored.LastSeen = time.Now() - _ = v.store.SaveKnownHost(stored) - return nil -} - -// promptAndSave asks the user whether to accept an unknown host key. -func (v *HostKeyVerifier) promptAndSave(host string, port int, keyType, fingerprint string, key ssh.PublicKey) error { - if v.onPrompt == nil { - return fmt.Errorf("host key for %s:%d is unknown and no prompt handler is set (policy: ask)", host, port) - } - - accepted, err := v.onPrompt(host, keyType, fingerprint) - if err != nil { - return fmt.Errorf("host key prompt failed: %w", err) - } - if !accepted { - return fmt.Errorf("host key for %s:%d rejected by user", host, port) - } - - return v.saveHost(host, port, keyType, fingerprint, key) -} - -// saveIfNew stores the host key only when no prior record exists. -func (v *HostKeyVerifier) saveIfNew(stored *models.KnownHost, host string, port int, keyType, fingerprint string, key ssh.PublicKey) error { - if stored == nil { - return v.saveHost(host, port, keyType, fingerprint, key) - } - stored.LastSeen = time.Now() - _ = v.store.SaveKnownHost(stored) - return nil -} - -// saveHost persists a new known-host record. -func (v *HostKeyVerifier) saveHost(host string, port int, keyType, fingerprint string, key ssh.PublicKey) error { - now := time.Now() - kh := &models.KnownHost{ - Host: host, - Port: port, - KeyType: keyType, - Fingerprint: fingerprint, - PublicKey: base64.StdEncoding.EncodeToString(key.Marshal()), - FirstSeen: now, - LastSeen: now, - Trusted: true, - } - return v.store.SaveKnownHost(kh) -} - -// computeFingerprint returns the SHA-256 fingerprint of a public key in -// the standard "SHA256:" format. -func computeFingerprint(key ssh.PublicKey) string { - hash := sha256.Sum256(key.Marshal()) - return "SHA256:" + base64.RawStdEncoding.EncodeToString(hash[:]) -} - -// extractHostPort splits the hostname and port from the SSH callback arguments. -func extractHostPort(hostname string, remote net.Addr) (string, int) { - host, portStr, err := net.SplitHostPort(hostname) - if err != nil { - host, portStr, _ = net.SplitHostPort(remote.String()) - } - port := 22 - if portStr != "" { - fmt.Sscanf(portStr, "%d", &port) - } - return host, port -} diff --git a/internal/sshclient/portforward.go b/internal/sshclient/portforward.go deleted file mode 100644 index 3f79833..0000000 --- a/internal/sshclient/portforward.go +++ /dev/null @@ -1,269 +0,0 @@ -// Package sshclient — port forwarding support. -// -// This file implements local and remote SSH port forwarding (tunnelling). -// Local forwarding listens on a local address and pipes connections through -// the SSH channel to a remote destination. Remote forwarding requests the -// SSH server to listen on a remote address and pipes accepted connections -// back to a local destination. -package sshclient - -import ( - "fmt" - "io" - "net" - "sync" - - "github.com/andydixon/link/internal/models" - "golang.org/x/crypto/ssh" -) - -// ActiveForward tracks a running port-forwarding tunnel. -type ActiveForward struct { - Rule models.PortForward `json:"rule"` - Listener net.Listener `json:"-"` - done chan struct{} - mu sync.Mutex - conns []net.Conn // tracked connections for clean shutdown -} - -// ForwardManager manages active port-forwarding tunnels for a single SSH connection. -type ForwardManager struct { - mu sync.Mutex - forwards map[string]*ActiveForward - client *ssh.Client -} - -// NewForwardManager creates a ForwardManager bound to the given SSH client. -func NewForwardManager(client *ssh.Client) *ForwardManager { - return &ForwardManager{ - forwards: make(map[string]*ActiveForward), - client: client, - } -} - -// StartForward activates a port-forwarding rule. Returns an error if the -// rule's ID is already active or the listener cannot be established. -func (fm *ForwardManager) StartForward(rule models.PortForward) error { - fm.mu.Lock() - if _, exists := fm.forwards[rule.ID]; exists { - fm.mu.Unlock() - return fmt.Errorf("forward %q is already active", rule.ID) - } - fm.mu.Unlock() - - switch rule.Direction { - case models.PortForwardLocal: - return fm.startLocalForward(rule) - case models.PortForwardRemote: - return fm.startRemoteForward(rule) - default: - return fmt.Errorf("unsupported forward direction %q", rule.Direction) - } -} - -// StopForward terminates a specific active forward. -func (fm *ForwardManager) StopForward(forwardID string) error { - fm.mu.Lock() - af, ok := fm.forwards[forwardID] - if !ok { - fm.mu.Unlock() - return fmt.Errorf("forward %q not found", forwardID) - } - delete(fm.forwards, forwardID) - fm.mu.Unlock() - - return af.shutdown() -} - -// StopAll terminates every active forward. Called on session close. -func (fm *ForwardManager) StopAll() { - fm.mu.Lock() - all := make([]*ActiveForward, 0, len(fm.forwards)) - for id, af := range fm.forwards { - all = append(all, af) - delete(fm.forwards, id) - } - fm.mu.Unlock() - - for _, af := range all { - _ = af.shutdown() - } -} - -// ActiveForwards returns the list of currently active forwarding rules. -func (fm *ForwardManager) ActiveForwards() []models.PortForward { - fm.mu.Lock() - defer fm.mu.Unlock() - - result := make([]models.PortForward, 0, len(fm.forwards)) - for _, af := range fm.forwards { - result = append(result, af.Rule) - } - return result -} - -// --------------------------------------------------------------------------- -// Local forwarding: listen locally → tunnel to remote via SSH -// --------------------------------------------------------------------------- - -func (fm *ForwardManager) startLocalForward(rule models.PortForward) error { - bindAddr := rule.BindHost - if bindAddr == "" { - bindAddr = "127.0.0.1" - } - listenAddr := net.JoinHostPort(bindAddr, fmt.Sprintf("%d", rule.BindPort)) - - listener, err := net.Listen("tcp", listenAddr) - if err != nil { - return fmt.Errorf("local forward: listen on %s failed: %w", listenAddr, err) - } - - af := &ActiveForward{ - Rule: rule, - Listener: listener, - done: make(chan struct{}), - } - - fm.mu.Lock() - fm.forwards[rule.ID] = af - fm.mu.Unlock() - - destAddr := net.JoinHostPort(rule.DestHost, fmt.Sprintf("%d", rule.DestPort)) - - go func() { - defer close(af.done) - for { - localConn, acceptErr := listener.Accept() - if acceptErr != nil { - // Listener closed — normal shutdown path. - return - } - - af.mu.Lock() - af.conns = append(af.conns, localConn) - af.mu.Unlock() - - go func(lc net.Conn) { - defer lc.Close() - - remoteConn, dialErr := fm.client.Dial("tcp", destAddr) - if dialErr != nil { - return - } - defer remoteConn.Close() - - af.mu.Lock() - af.conns = append(af.conns, remoteConn) - af.mu.Unlock() - - pipeBidirectional(lc, remoteConn) - }(localConn) - } - }() - - return nil -} - -// --------------------------------------------------------------------------- -// Remote forwarding: ask server to listen → tunnel back to local destination -// --------------------------------------------------------------------------- - -func (fm *ForwardManager) startRemoteForward(rule models.PortForward) error { - bindAddr := rule.BindHost - if bindAddr == "" { - bindAddr = "0.0.0.0" - } - listenAddr := net.JoinHostPort(bindAddr, fmt.Sprintf("%d", rule.BindPort)) - - listener, err := fm.client.Listen("tcp", listenAddr) - if err != nil { - return fmt.Errorf("remote forward: server listen on %s failed: %w", listenAddr, err) - } - - af := &ActiveForward{ - Rule: rule, - Listener: listener, - done: make(chan struct{}), - } - - fm.mu.Lock() - fm.forwards[rule.ID] = af - fm.mu.Unlock() - - destAddr := net.JoinHostPort(rule.DestHost, fmt.Sprintf("%d", rule.DestPort)) - - go func() { - defer close(af.done) - for { - remoteConn, acceptErr := listener.Accept() - if acceptErr != nil { - return - } - - af.mu.Lock() - af.conns = append(af.conns, remoteConn) - af.mu.Unlock() - - go func(rc net.Conn) { - defer rc.Close() - - localConn, dialErr := net.Dial("tcp", destAddr) - if dialErr != nil { - return - } - defer localConn.Close() - - af.mu.Lock() - af.conns = append(af.conns, localConn) - af.mu.Unlock() - - pipeBidirectional(rc, localConn) - }(remoteConn) - } - }() - - return nil -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -// shutdown closes the listener and all tracked connections. -func (af *ActiveForward) shutdown() error { - var firstErr error - if af.Listener != nil { - if err := af.Listener.Close(); err != nil { - firstErr = err - } - } - - af.mu.Lock() - conns := af.conns - af.conns = nil - af.mu.Unlock() - - for _, c := range conns { - _ = c.Close() - } - return firstErr -} - -// pipeBidirectional copies data between two connections until either side closes. -func pipeBidirectional(a, b net.Conn) { - var wg sync.WaitGroup - wg.Add(2) - - copy := func(dst, src net.Conn) { - defer wg.Done() - _, _ = io.Copy(dst, src) - // Signal the other direction to stop by closing the write-half. - if tc, ok := dst.(*net.TCPConn); ok { - _ = tc.CloseWrite() - } - } - - go copy(a, b) - go copy(b, a) - wg.Wait() -} diff --git a/internal/sshclient/sshclient.go b/internal/sshclient/sshclient.go deleted file mode 100644 index b74d236..0000000 --- a/internal/sshclient/sshclient.go +++ /dev/null @@ -1,816 +0,0 @@ -package sshclient - -import ( - "bufio" - "context" - "encoding/base64" - "fmt" - "io" - "net" - "net/http" - "os" - "strings" - "sync" - "time" - - "github.com/andydixon/link/internal/models" - "github.com/andydixon/link/internal/terminalenv" - "github.com/pquerna/otp/totp" - "golang.org/x/crypto/ssh" - "golang.org/x/net/proxy" -) - -const maxSSHPrivateKeyBytes = 1 << 20 - -// Client manages a single SSH session lifecycle. -// It handles connection establishment, authentication, PTY allocation, -// terminal resize propagation, port forwarding, and clean shutdown. -type Client struct { - mu sync.Mutex - config *models.Profile - sshClient *ssh.Client - session *ssh.Session - stdin io.WriteCloser - stdout io.Reader - stderr io.Reader - connected bool - onData func(data []byte) - onError func(err error) - onClose func() - ctx context.Context - cancel context.CancelFunc - forwards *ForwardManager // port forwarding tunnels - agentCleanup func() // closes agent socket connection -} - -// NewClient creates a new SSH client bound to the given profile. -func NewClient(profile *models.Profile) *Client { - ctx, cancel := context.WithCancel(context.Background()) - return &Client{ - config: profile, - ctx: ctx, - cancel: cancel, - } -} - -// SetCallbacks sets the data, error, and close callbacks. Must be called -// before Connect. -func (c *Client) SetCallbacks(onData func([]byte), onError func(error), onClose func()) { - c.mu.Lock() - defer c.mu.Unlock() - c.onData = onData - c.onError = onError - c.onClose = onClose -} - -// sshConf returns the SSH-specific configuration, or a zero value if nil. -func (c *Client) sshConf() models.SSHConfig { - if c.config.SSH != nil { - return *c.config.SSH - } - return models.SSHConfig{} -} - -// Connect refuses to connect without an explicit host-key callback. Use -// ConnectWithHostKeyCallback so all callers make host-key policy visible. -func (c *Client) Connect(getPassword func() (string, error), getPassphrase func() (string, error)) error { - return fmt.Errorf("sshclient: host key callback is required; use ConnectWithHostKeyCallback") -} - -// ConnectWithHostKeyCallback applies a custom HostKeyCallback (e.g. from -// HostKeyVerifier) and then establishes the connection. -func (c *Client) ConnectWithHostKeyCallback( - hkCallback ssh.HostKeyCallback, - getPassword func() (string, error), - getPassphrase func() (string, error), -) error { - return c.connectInternal(hkCallback, getPassword, getPassphrase) -} - -// connectInternal is the shared implementation for both Connect variants. -func (c *Client) connectInternal( - hkCallback ssh.HostKeyCallback, - getPassword func() (string, error), - getPassphrase func() (string, error), -) error { - c.mu.Lock() - if c.connected { - c.mu.Unlock() - return fmt.Errorf("already connected") - } - c.mu.Unlock() - - sc := c.sshConf() - - authMethods, err := c.buildAuthMethods(sc, getPassword, getPassphrase) - if err != nil { - return fmt.Errorf("failed to build auth methods: %w", err) - } - - termType := sc.TerminalType - if termType == "" { - termType = "xterm-256color" - } - - timeout := time.Duration(c.config.ConnectTimeout) * time.Second - if timeout == 0 { - timeout = 30 * time.Second - } - - sshConfig := &ssh.ClientConfig{ - User: c.config.Username, - Auth: authMethods, - HostKeyCallback: hkCallback, - Timeout: timeout, - } - - var sshConn *ssh.Client - - if len(sc.JumpHosts) > 0 { - sshConn, err = c.connectViaJumpHosts(sc, sshConfig, getPassphrase) - } else { - port := c.config.Port - if port == 0 { - port = 22 - } - addr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", port)) - - // Dial through proxy if configured, otherwise use direct TCP. - conn, dialErr := c.dialTarget(sc, addr, timeout) - if dialErr != nil { - return fmt.Errorf("SSH connection failed: %w", dialErr) - } - ncc, chans, reqs, sshErr := ssh.NewClientConn(conn, addr, sshConfig) - if sshErr != nil { - conn.Close() - return fmt.Errorf("SSH handshake failed: %w", sshErr) - } - sshConn = ssh.NewClient(ncc, chans, reqs) - } - if err != nil { - return fmt.Errorf("SSH connection failed: %w", err) - } - - session, err := sshConn.NewSession() - if err != nil { - sshConn.Close() - return fmt.Errorf("failed to create SSH session: %w", err) - } - - // Allocate a PTY with sensible defaults and terminal modes. - cols := 80 - rows := 24 - modes := ssh.TerminalModes{ - ssh.ECHO: 1, - ssh.TTY_OP_ISPEED: 115200, - ssh.TTY_OP_OSPEED: 115200, - ssh.IUTF8: 1, - } - - sessionEnv := terminalenv.SSHEnvVars(sc.EnvVars) - for _, key := range terminalenv.SortedEnvKeys(sessionEnv) { - // Some SSH servers never reply to env requests. Send them without - // requiring a reply so optional locale/profile env cannot stall login. - sendSessionEnv(session, key, sessionEnv[key]) - } - - if err := session.RequestPty(termType, rows, cols, modes); err != nil { - session.Close() - sshConn.Close() - return fmt.Errorf("PTY request failed: %w", err) - } - - // Set up SSH agent forwarding if requested (must happen before shell start). - if sc.AgentForwarding { - if fwdErr := requestAgentForwarding(sshConn, session); fwdErr != nil { - // Non-fatal — log but don't abort the connection. - if c.onError != nil { - c.onError(fmt.Errorf("agent forwarding: %w", fwdErr)) - } - } - } - - stdinPipe, err := session.StdinPipe() - if err != nil { - session.Close() - sshConn.Close() - return fmt.Errorf("failed to get stdin pipe: %w", err) - } - - stdoutPipe, err := session.StdoutPipe() - if err != nil { - session.Close() - sshConn.Close() - return fmt.Errorf("failed to get stdout pipe: %w", err) - } - - stderrPipe, err := session.StderrPipe() - if err != nil { - session.Close() - sshConn.Close() - return fmt.Errorf("failed to get stderr pipe: %w", err) - } - - // Start shell or explicit startup command. - if sc.StartupCommand != "" { - if err := session.Start(sc.StartupCommand); err != nil { - session.Close() - sshConn.Close() - return fmt.Errorf("failed to start command: %w", err) - } - } else { - if err := session.Shell(); err != nil { - session.Close() - sshConn.Close() - return fmt.Errorf("failed to start shell: %w", err) - } - } - - c.mu.Lock() - c.sshClient = sshConn - c.session = session - c.stdin = stdinPipe - c.stdout = stdoutPipe - c.stderr = stderrPipe - c.connected = true - c.forwards = NewForwardManager(sshConn) - c.mu.Unlock() - - go c.readLoop() - - if sc.KeepaliveInterval > 0 { - go c.startKeepalive(sc.KeepaliveInterval) - } - - return nil -} - -func sendSessionEnv(session *ssh.Session, name, value string) { - _, _ = session.SendRequest("env", false, ssh.Marshal(struct { - Name string - Value string - }{ - Name: name, - Value: value, - })) -} - -// Write sends data to the SSH session's stdin. -func (c *Client) Write(data []byte) error { - c.mu.Lock() - if !c.connected || c.stdin == nil { - c.mu.Unlock() - return fmt.Errorf("not connected") - } - stdin := c.stdin - c.mu.Unlock() - - _, err := stdin.Write(data) - return err -} - -// Resize sends a terminal resize request to the remote PTY. -func (c *Client) Resize(cols, rows int) error { - c.mu.Lock() - if !c.connected || c.session == nil { - c.mu.Unlock() - return fmt.Errorf("not connected") - } - session := c.session - c.mu.Unlock() - - return session.WindowChange(rows, cols) -} - -// Ping sends a keepalive request and returns the round-trip time in milliseconds. -func (c *Client) Ping() (int64, error) { - c.mu.Lock() - if !c.connected || c.sshClient == nil { - c.mu.Unlock() - return 0, fmt.Errorf("not connected") - } - client := c.sshClient - c.mu.Unlock() - - start := time.Now() - _, _, err := client.SendRequest("keepalive@openssh.com", true, nil) - if err != nil { - return 0, err - } - return time.Since(start).Milliseconds(), nil -} - -// RunCommand executes a single command using a new SSH session. -func (c *Client) RunCommand(command string) (string, error) { - c.mu.Lock() - if !c.connected || c.sshClient == nil { - c.mu.Unlock() - return "", fmt.Errorf("not connected") - } - client := c.sshClient - c.mu.Unlock() - - session, err := client.NewSession() - if err != nil { - return "", fmt.Errorf("failed to open SSH session: %w", err) - } - defer session.Close() - - out, err := session.Output(command) - if err != nil { - return "", fmt.Errorf("command failed: %w", err) - } - return strings.TrimSpace(string(out)), nil -} - -// Close cleanly shuts down the SSH session and connection, cancels the -// background context, and invokes the onClose callback. -func (c *Client) Close() error { - c.mu.Lock() - if !c.connected { - c.mu.Unlock() - return nil - } - c.connected = false - session := c.session - sshClient := c.sshClient - fwdMgr := c.forwards - agCleanup := c.agentCleanup - onClose := c.onClose - c.mu.Unlock() - - c.cancel() - - // Shut down any active port forwards first. - if fwdMgr != nil { - fwdMgr.StopAll() - } - - // Close the SSH agent socket connection. - if agCleanup != nil { - agCleanup() - } - - var firstErr error - if session != nil { - if err := session.Close(); err != nil && firstErr == nil { - firstErr = err - } - } - if sshClient != nil { - if err := sshClient.Close(); err != nil && firstErr == nil { - firstErr = err - } - } - - if onClose != nil { - onClose() - } - return firstErr -} - -// IsConnected returns the current connection state. -func (c *Client) IsConnected() bool { - c.mu.Lock() - defer c.mu.Unlock() - return c.connected -} - -// Forwards returns the ForwardManager for this client, or nil if not connected. -func (c *Client) Forwards() *ForwardManager { - c.mu.Lock() - defer c.mu.Unlock() - return c.forwards -} - -// SSHConn returns the underlying ssh.Client for use by subsystems such as -// SFTP. Returns nil if not connected. -func (c *Client) SSHConn() *ssh.Client { - c.mu.Lock() - defer c.mu.Unlock() - return c.sshClient -} - -// buildAuthMethods constructs the SSH authentication method list from the -// profile's SSHConfig and the provided credential callbacks. -func (c *Client) buildAuthMethods(sc models.SSHConfig, getPassword func() (string, error), getPassphrase func() (string, error)) ([]ssh.AuthMethod, error) { - var methods []ssh.AuthMethod - - // If agent auth is enabled, try to use the local SSH agent first. - if sc.AgentAuth { - am, cleanup, err := agentAuthMethod() - if err == nil { - methods = append(methods, am) - c.mu.Lock() - c.agentCleanup = cleanup - c.mu.Unlock() - } - // Non-fatal — fall through to other auth methods if agent unavailable. - } - - authList := sc.AuthMethods - if len(authList) == 0 { - authList = []string{"publickey", "password", "keyboard-interactive"} - } - - for _, m := range authList { - switch m { - case "password": - if getPassword != nil { - methods = append(methods, ssh.PasswordCallback(func() (string, error) { - return getPassword() - })) - } - - case "publickey": - if sc.PrivateKeyPath != "" { - signer, err := c.parsePrivateKey(sc.PrivateKeyPath, getPassphrase) - if err != nil { - return nil, fmt.Errorf("failed to parse private key %s: %w", sc.PrivateKeyPath, err) - } - methods = append(methods, ssh.PublicKeys(signer)) - } - - case "keyboard-interactive": - if getPassword != nil { - pwdCallback := getPassword - totpSecret := "" - if sc.TOTPSecret != "" { - totpSecret = sc.TOTPSecret - } - methods = append(methods, ssh.KeyboardInteractive( - func(name, instruction string, questions []string, echos []bool) ([]string, error) { - answers := make([]string, len(questions)) - for i, question := range questions { - q := strings.ToLower(question) - // Detect TOTP/2FA prompts and auto-fill when secret is configured. - if totpSecret != "" && isTOTPPrompt(q) { - code, err := totp.GenerateCode(totpSecret, time.Now()) - if err != nil { - return nil, fmt.Errorf("failed to generate TOTP code: %w", err) - } - answers[i] = code - } else { - ans, err := pwdCallback() - if err != nil { - return nil, err - } - answers[i] = ans - } - } - return answers, nil - }, - )) - } - } - } - - if len(methods) == 0 { - return nil, fmt.Errorf("no authentication methods available") - } - return methods, nil -} - -// isTOTPPrompt checks whether a keyboard-interactive question looks like a -// TOTP/2FA prompt. Common patterns include "verification code", "otp", -// "two-factor", "2fa", "authenticator", etc. -func isTOTPPrompt(question string) bool { - totpKeywords := []string{ - "verification code", - "otp", - "one-time", - "one time", - "two-factor", - "two factor", - "2fa", - "totp", - "authenticator", - "security code", - "6-digit", - "6 digit", - } - for _, kw := range totpKeywords { - if strings.Contains(question, kw) { - return true - } - } - return false -} - -// dialTarget dials the target address, optionally through a SOCKS5 or HTTP -// CONNECT proxy as configured in the profile's SSH settings. -func (c *Client) dialTarget(sc models.SSHConfig, addr string, timeout time.Duration) (net.Conn, error) { - switch strings.ToLower(sc.ProxyType) { - case "socks5": - return c.dialSOCKS5(sc, addr, timeout) - case "http": - return c.dialHTTPProxy(sc, addr, timeout) - default: - // No proxy — direct TCP dial. - return net.DialTimeout("tcp", addr, timeout) - } -} - -// dialSOCKS5 connects to the target via a SOCKS5 proxy. -func (c *Client) dialSOCKS5(sc models.SSHConfig, addr string, timeout time.Duration) (net.Conn, error) { - proxyPort := sc.ProxyPort - if proxyPort == 0 { - proxyPort = 1080 - } - proxyAddr := net.JoinHostPort(sc.ProxyHost, fmt.Sprintf("%d", proxyPort)) - - var auth *proxy.Auth - if sc.ProxyUsername != "" { - auth = &proxy.Auth{ - User: sc.ProxyUsername, - Password: sc.ProxyPassword, - } - } - - dialer, err := proxy.SOCKS5("tcp", proxyAddr, auth, &net.Dialer{Timeout: timeout}) - if err != nil { - return nil, fmt.Errorf("SOCKS5 proxy setup failed: %w", err) - } - - conn, err := dialer.Dial("tcp", addr) - if err != nil { - return nil, fmt.Errorf("SOCKS5 proxy connection to %s failed: %w", addr, err) - } - return conn, nil -} - -// dialHTTPProxy connects to the target via an HTTP CONNECT proxy. -func (c *Client) dialHTTPProxy(sc models.SSHConfig, addr string, timeout time.Duration) (net.Conn, error) { - proxyPort := sc.ProxyPort - if proxyPort == 0 { - proxyPort = 8080 - } - proxyAddr := net.JoinHostPort(sc.ProxyHost, fmt.Sprintf("%d", proxyPort)) - - conn, err := net.DialTimeout("tcp", proxyAddr, timeout) - if err != nil { - return nil, fmt.Errorf("HTTP proxy connection failed: %w", err) - } - - // Send CONNECT request. - connectReq := fmt.Sprintf("CONNECT %s HTTP/1.1\r\nHost: %s\r\n", addr, addr) - if sc.ProxyUsername != "" { - cred := base64.StdEncoding.EncodeToString([]byte(sc.ProxyUsername + ":" + sc.ProxyPassword)) - connectReq += fmt.Sprintf("Proxy-Authorization: Basic %s\r\n", cred) - } - connectReq += "\r\n" - - if _, err := conn.Write([]byte(connectReq)); err != nil { - conn.Close() - return nil, fmt.Errorf("HTTP CONNECT write failed: %w", err) - } - - // Read response. - br := bufio.NewReader(conn) - resp, err := http.ReadResponse(br, &http.Request{Method: "CONNECT"}) - if err != nil { - conn.Close() - return nil, fmt.Errorf("HTTP CONNECT response read failed: %w", err) - } - resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - conn.Close() - return nil, fmt.Errorf("HTTP CONNECT proxy returned %d %s", resp.StatusCode, resp.Status) - } - - return conn, nil -} - -// connectViaJumpHosts establishes the SSH connection through a chain of -// jump hosts. Each hop opens a new net.Conn through the previous hop's -// SSH client. -func (c *Client) connectViaJumpHosts(sc models.SSHConfig, finalConfig *ssh.ClientConfig, getPassphrase func() (string, error)) (*ssh.Client, error) { - var currentClient *ssh.Client - - for i, jump := range sc.JumpHosts { - jumpPort := jump.Port - if jumpPort == 0 { - jumpPort = 22 - } - jumpAddr := net.JoinHostPort(jump.Host, fmt.Sprintf("%d", jumpPort)) - - var jumpAuth []ssh.AuthMethod - if jump.PrivateKeyPath != "" { - signer, err := c.parsePrivateKey(jump.PrivateKeyPath, getPassphrase) - if err != nil { - if currentClient != nil { - currentClient.Close() - } - return nil, fmt.Errorf("jump host %d private key error: %w", i, err) - } - jumpAuth = append(jumpAuth, ssh.PublicKeys(signer)) - } - - jumpConfig := jumpHostClientConfig(jump, jumpAuth, finalConfig) - - if currentClient == nil { - client, err := ssh.Dial("tcp", jumpAddr, jumpConfig) - if err != nil { - return nil, fmt.Errorf("failed to connect to jump host %d (%s): %w", i, jumpAddr, err) - } - currentClient = client - } else { - conn, err := currentClient.Dial("tcp", jumpAddr) - if err != nil { - currentClient.Close() - return nil, fmt.Errorf("failed to dial through jump host %d (%s): %w", i, jumpAddr, err) - } - ncc, chans, reqs, err := ssh.NewClientConn(conn, jumpAddr, jumpConfig) - if err != nil { - conn.Close() - currentClient.Close() - return nil, fmt.Errorf("failed to establish SSH through jump host %d: %w", i, err) - } - currentClient = ssh.NewClient(ncc, chans, reqs) - } - } - - // Final hop to the target host. - targetPort := c.config.Port - if targetPort == 0 { - targetPort = 22 - } - targetAddr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", targetPort)) - - conn, err := currentClient.Dial("tcp", targetAddr) - if err != nil { - currentClient.Close() - return nil, fmt.Errorf("failed to dial target %s through jump hosts: %w", targetAddr, err) - } - - ncc, chans, reqs, err := ssh.NewClientConn(conn, targetAddr, finalConfig) - if err != nil { - conn.Close() - currentClient.Close() - return nil, fmt.Errorf("failed to establish SSH to target %s: %w", targetAddr, err) - } - - return ssh.NewClient(ncc, chans, reqs), nil -} - -func jumpHostClientConfig(jump models.JumpHost, auth []ssh.AuthMethod, finalConfig *ssh.ClientConfig) *ssh.ClientConfig { - return &ssh.ClientConfig{ - User: jump.Username, - Auth: auth, - HostKeyCallback: finalConfig.HostKeyCallback, - Timeout: finalConfig.Timeout, - } -} - -// parsePrivateKey loads and parses an SSH private key file. If the key is -// encrypted and getPassphrase is provided, it will attempt decryption. -func (c *Client) parsePrivateKey(path string, getPassphrase func() (string, error)) (ssh.Signer, error) { - info, err := os.Stat(path) - if err != nil { - return nil, fmt.Errorf("unable to stat key file: %w", err) - } - if info.IsDir() { - return nil, fmt.Errorf("key path is a directory") - } - if info.Size() > maxSSHPrivateKeyBytes { - return nil, fmt.Errorf("key file is too large: %d bytes exceeds %d bytes", info.Size(), maxSSHPrivateKeyBytes) - } - keyData, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("unable to read key file: %w", err) - } - - signer, err := ssh.ParsePrivateKey(keyData) - if err == nil { - return signer, nil - } - - if getPassphrase == nil { - return nil, fmt.Errorf("key is encrypted and no passphrase callback provided: %w", err) - } - - passphrase, err := getPassphrase() - if err != nil { - return nil, fmt.Errorf("failed to get passphrase: %w", err) - } - - signer, err = ssh.ParsePrivateKeyWithPassphrase(keyData, []byte(passphrase)) - if err != nil { - return nil, fmt.Errorf("failed to parse encrypted key: %w", err) - } - return signer, nil -} - -// startKeepalive periodically sends keepalive requests to the server. -// It exits when the context is cancelled or the connection closes. -func (c *Client) startKeepalive(intervalSecs int) { - interval := time.Duration(intervalSecs) * time.Second - ticker := time.NewTicker(interval) - defer ticker.Stop() - - for { - select { - case <-c.ctx.Done(): - return - case <-ticker.C: - c.mu.Lock() - client := c.sshClient - connected := c.connected - c.mu.Unlock() - - if !connected || client == nil { - return - } - - _, _, err := client.SendRequest("keepalive@openssh.com", true, nil) - if err != nil { - c.mu.Lock() - onError := c.onError - c.mu.Unlock() - if onError != nil { - onError(fmt.Errorf("keepalive failed: %w", err)) - } - return - } - } - } -} - -// readLoop reads from stdout and stderr, dispatching received data to the -// onData callback. It runs until the context is cancelled or the streams close. -func (c *Client) readLoop() { - done := make(chan struct{}, 2) - - go func() { - defer func() { done <- struct{}{} }() - buf := make([]byte, 32*1024) - for { - select { - case <-c.ctx.Done(): - return - default: - } - - c.mu.Lock() - stdout := c.stdout - c.mu.Unlock() - if stdout == nil { - return - } - - n, err := stdout.Read(buf) - if n > 0 { - data := make([]byte, n) - copy(data, buf[:n]) - c.mu.Lock() - cb := c.onData - c.mu.Unlock() - if cb != nil { - cb(data) - } - } - if err != nil { - return - } - } - }() - - go func() { - defer func() { done <- struct{}{} }() - buf := make([]byte, 32*1024) - for { - select { - case <-c.ctx.Done(): - return - default: - } - - c.mu.Lock() - stderr := c.stderr - c.mu.Unlock() - if stderr == nil { - return - } - - n, err := stderr.Read(buf) - if n > 0 { - data := make([]byte, n) - copy(data, buf[:n]) - c.mu.Lock() - cb := c.onData - c.mu.Unlock() - if cb != nil { - cb(data) - } - } - if err != nil { - return - } - } - }() - - <-done - <-done - c.Close() -} diff --git a/internal/sshclient/sshclient_test.go b/internal/sshclient/sshclient_test.go deleted file mode 100644 index 2ecbcd9..0000000 --- a/internal/sshclient/sshclient_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package sshclient - -import ( - "crypto/ed25519" - "crypto/rand" - "net" - "testing" - "time" - - "github.com/andydixon/link/internal/models" - "golang.org/x/crypto/ssh" -) - -func TestConnectRequiresHostKeyCallback(t *testing.T) { - profile := models.NewProfile(models.ProtocolSSH) - client := NewClient(&profile) - - err := client.Connect(nil, nil) - if err == nil { - t.Fatal("expected Connect to reject missing host key callback") - } -} - -func TestJumpHostClientConfigUsesFinalHostKeyCallback(t *testing.T) { - called := false - hostKeyCallback := func(hostname string, _ net.Addr, _ ssh.PublicKey) error { - called = true - if hostname != "jump.example:2222" { - t.Fatalf("hostname = %q, want jump.example:2222", hostname) - } - return nil - } - - finalConfig := &ssh.ClientConfig{ - HostKeyCallback: hostKeyCallback, - Timeout: 12 * time.Second, - } - jump := models.JumpHost{ - Host: "jump.example", - Port: 2222, - Username: "alice", - } - - got := jumpHostClientConfig(jump, nil, finalConfig) - if got.User != jump.Username { - t.Fatalf("User = %q, want %q", got.User, jump.Username) - } - if got.Timeout != finalConfig.Timeout { - t.Fatalf("Timeout = %s, want %s", got.Timeout, finalConfig.Timeout) - } - if got.HostKeyCallback == nil { - t.Fatal("HostKeyCallback is nil") - } - - if err := got.HostKeyCallback("jump.example:2222", nil, nil); err != nil { - t.Fatalf("HostKeyCallback() error = %v", err) - } - if !called { - t.Fatal("final host key callback was not used") - } -} - -func TestPingReturnsAfterSSHTransportCloses(t *testing.T) { - _, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatalf("GenerateKey() error = %v", err) - } - signer, err := ssh.NewSignerFromKey(privateKey) - if err != nil { - t.Fatalf("NewSignerFromKey() error = %v", err) - } - - serverConfig := &ssh.ServerConfig{NoClientAuth: true} - serverConfig.AddHostKey(signer) - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("Listen() error = %v", err) - } - t.Cleanup(func() { _ = listener.Close() }) - - serverReady := make(chan *ssh.ServerConn, 1) - serverErrors := make(chan error, 1) - go func() { - conn, acceptErr := listener.Accept() - if acceptErr != nil { - serverErrors <- acceptErr - return - } - serverConn, _, _, handshakeErr := ssh.NewServerConn(conn, serverConfig) - if handshakeErr != nil { - _ = conn.Close() - serverErrors <- handshakeErr - return - } - serverReady <- serverConn - }() - - conn, err := net.DialTimeout("tcp", listener.Addr().String(), 2*time.Second) - if err != nil { - t.Fatalf("DialTimeout() error = %v", err) - } - clientConn, channels, requests, err := ssh.NewClientConn(conn, listener.Addr().String(), &ssh.ClientConfig{ - User: "test", - HostKeyCallback: ssh.InsecureIgnoreHostKey(), // Test server uses an ephemeral key. - }) - if err != nil { - _ = conn.Close() - t.Fatalf("NewClientConn() error = %v", err) - } - rawClient := ssh.NewClient(clientConn, channels, requests) - - var serverConn *ssh.ServerConn - select { - case serverConn = <-serverReady: - case serverErr := <-serverErrors: - t.Fatalf("SSH server handshake error = %v", serverErr) - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for SSH server handshake") - } - - client := NewClient(&models.Profile{}) - client.mu.Lock() - client.sshClient = rawClient - client.connected = true - client.mu.Unlock() - t.Cleanup(func() { _ = client.Close() }) - - if err := serverConn.Close(); err != nil { - t.Fatalf("server Close() error = %v", err) - } - waitDone := make(chan error, 1) - go func() { waitDone <- rawClient.Wait() }() - select { - case <-waitDone: - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for SSH transport to close") - } - - pingDone := make(chan error, 1) - go func() { - _, pingErr := client.Ping() - pingDone <- pingErr - }() - select { - case pingErr := <-pingDone: - if pingErr == nil { - t.Fatal("Ping() succeeded after the SSH transport closed") - } - case <-time.After(2 * time.Second): - t.Fatal("Ping() did not return after the SSH transport closed") - } -} diff --git a/internal/sshconfig/sshconfig.go b/internal/sshconfig/sshconfig.go deleted file mode 100644 index 1a3ae1e..0000000 --- a/internal/sshconfig/sshconfig.go +++ /dev/null @@ -1,302 +0,0 @@ -// Package sshconfig parses OpenSSH ~/.ssh/config files and converts -// Host blocks into Alchementary Link connection profiles. Supports a useful -// subset of SSH config directives: HostName, Port, User, IdentityFile, -// ProxyJump, ForwardAgent, and Compression. -// -// Limitations (future work): -// - Match blocks are ignored -// - Wildcards in Host patterns are skipped -// - Include directives are not followed -// - Token expansion (%h, %p, etc.) is not performed -package sshconfig - -import ( - "bufio" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/andydixon/link/internal/models" - "github.com/google/uuid" -) - -const ( - maxConfigFileBytes = 5 * 1024 * 1024 - maxConfigLineBytes = 256 * 1024 - maxHostBlocks = 5000 -) - -// HostBlock represents a parsed SSH config Host section. -type HostBlock struct { - Aliases []string - HostName string - Port int - User string - IdentityFile string - ProxyJump string - ForwardAgent bool - Compression bool -} - -// ParseFile reads and parses an OpenSSH config file. -func ParseFile(path string) ([]HostBlock, error) { - f, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("sshconfig: failed to open %s: %w", path, err) - } - defer f.Close() - info, err := f.Stat() - if err != nil { - return nil, fmt.Errorf("sshconfig: failed to stat %s: %w", path, err) - } - if info.IsDir() { - return nil, fmt.Errorf("sshconfig: %s is a directory", path) - } - if info.Size() > maxConfigFileBytes { - return nil, fmt.Errorf("sshconfig: config file is too large: %d bytes exceeds %d bytes", info.Size(), maxConfigFileBytes) - } - - var blocks []HostBlock - var current *HostBlock - commitCurrent := func() error { - if current == nil { - return nil - } - if len(blocks) >= maxHostBlocks { - return fmt.Errorf("sshconfig: too many Host blocks (max %d)", maxHostBlocks) - } - blocks = append(blocks, *current) - current = nil - return nil - } - - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 0, 64*1024), maxConfigLineBytes) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - - // Skip empty lines and comments. - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - // Split into directive and value. - key, value := splitDirective(line) - key = strings.ToLower(key) - - switch key { - case "host": - // Commit previous block. - if err := commitCurrent(); err != nil { - return nil, err - } - aliases := strings.Fields(value) - // Skip wildcard-only hosts (e.g. "Host *"). - hasWildcard := false - for _, a := range aliases { - if strings.Contains(a, "*") || strings.Contains(a, "?") { - hasWildcard = true - } - } - if hasWildcard && len(aliases) == 1 { - current = nil - continue - } - current = &HostBlock{ - Aliases: aliases, - Port: 22, - } - - case "match": - // Commit previous block and skip Match blocks. - if err := commitCurrent(); err != nil { - return nil, err - } - current = nil - - case "hostname": - if current != nil { - current.HostName = value - } - case "port": - if current != nil { - if p, err := strconv.Atoi(value); err == nil { - current.Port = p - } - } - case "user": - if current != nil { - current.User = value - } - case "identityfile": - if current != nil { - current.IdentityFile = expandTilde(value) - } - case "proxyjump": - if current != nil { - current.ProxyJump = value - } - case "forwardagent": - if current != nil { - current.ForwardAgent = isTrueish(value) - } - case "compression": - if current != nil { - current.Compression = isTrueish(value) - } - } - } - - // Commit final block. - if err := commitCurrent(); err != nil { - return nil, err - } - - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("sshconfig: read error: %w", err) - } - - return blocks, nil -} - -// DefaultConfigPath returns the standard OpenSSH config file path. -func DefaultConfigPath() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".ssh", "config") -} - -// ToProfiles converts parsed Host blocks into Alchementary Link profiles. -// Each block produces one profile; the first alias becomes the display name. -func ToProfiles(blocks []HostBlock, folder string) []models.Profile { - profiles := make([]models.Profile, 0, len(blocks)) - - for _, b := range blocks { - if len(b.Aliases) == 0 { - continue - } - - host := b.HostName - if host == "" { - host = b.Aliases[0] - } - - displayName := b.Aliases[0] - username := b.User - - sshConf := &models.SSHConfig{ - AuthMethods: []string{"publickey", "password"}, - SecretMode: models.SecretModeAskEveryTime, - HostKeyPolicy: models.HostKeyAsk, - TerminalType: "xterm-256color", - AgentAuth: b.ForwardAgent, - AgentForwarding: b.ForwardAgent, - Compression: b.Compression, - } - - if b.IdentityFile != "" { - sshConf.PrivateKeyPath = b.IdentityFile - } - - // Parse ProxyJump into a jump host. - if b.ProxyJump != "" && b.ProxyJump != "none" { - jh := parseProxyJump(b.ProxyJump) - if jh != nil { - sshConf.JumpHosts = []models.JumpHost{*jh} - } - } - - profile := models.NewProfile(models.ProtocolSSH) - profile.ID = uuid.New().String() - profile.DisplayName = displayName - profile.Host = host - profile.Port = b.Port - profile.Username = username - profile.Folder = folder - profile.SSH = sshConf - profile.Tags = []string{"ssh-config-import"} - - profiles = append(profiles, profile) - } - - return profiles -} - -// parseProxyJump parses a simple ProxyJump value (user@host:port format). -// Only the first hop is parsed; chained jumps are future work. -func parseProxyJump(value string) *models.JumpHost { - // Take only the first hop if comma-separated. - if idx := strings.Index(value, ","); idx >= 0 { - value = value[:idx] - } - value = strings.TrimSpace(value) - if value == "" || value == "none" { - return nil - } - - jh := &models.JumpHost{ - Port: 22, - AuthMethod: "publickey", - SecretMode: models.SecretModeAskEveryTime, - } - - // Extract user@ prefix. - if at := strings.Index(value, "@"); at >= 0 { - jh.Username = value[:at] - value = value[at+1:] - } - - // Extract :port suffix. - if colon := strings.LastIndex(value, ":"); colon >= 0 { - if p, err := strconv.Atoi(value[colon+1:]); err == nil { - jh.Port = p - value = value[:colon] - } - } - - jh.Host = value - return jh -} - -// splitDirective splits a line into key and value, handling both -// "Key Value" and "Key=Value" formats. -func splitDirective(line string) (string, string) { - // Try = separator first. - if idx := strings.Index(line, "="); idx >= 0 { - return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:]) - } - // Fall back to whitespace separator. - parts := strings.SplitN(line, " ", 2) - if len(parts) == 1 { - parts = strings.SplitN(line, "\t", 2) - } - if len(parts) < 2 { - return parts[0], "" - } - return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) -} - -// expandTilde replaces a leading ~ with the user's home directory. -func expandTilde(path string) string { - if !strings.HasPrefix(path, "~/") { - return path - } - home, err := os.UserHomeDir() - if err != nil { - return path - } - return filepath.Join(home, path[2:]) -} - -// isTrueish returns true for "yes", "true", "on", and "1". -func isTrueish(val string) bool { - switch strings.ToLower(val) { - case "yes", "true", "on", "1": - return true - } - return false -} diff --git a/internal/sshconfig/sshconfig_test.go b/internal/sshconfig/sshconfig_test.go deleted file mode 100644 index 564e003..0000000 --- a/internal/sshconfig/sshconfig_test.go +++ /dev/null @@ -1,218 +0,0 @@ -package sshconfig - -import ( - "os" - "path/filepath" - "strconv" - "strings" - "testing" -) - -const testConfig = `# Test SSH config - -Host * - ServerAliveInterval 60 - -Host webserver - HostName web.example.com - Port 2222 - User deploy - IdentityFile ~/.ssh/deploy_key - ForwardAgent yes - Compression yes - -Host dbserver - HostName db.internal.example.com - User postgres - ProxyJump bastion@jump.example.com:2200 - -Host simple - HostName 10.0.0.5 - -Host wildcard-* - User admin -` - -func TestParseFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config") - if err := os.WriteFile(path, []byte(testConfig), 0600); err != nil { - t.Fatal(err) - } - - blocks, err := ParseFile(path) - if err != nil { - t.Fatalf("ParseFile failed: %v", err) - } - - // Should have 3 blocks: webserver, dbserver, simple. - // "Host *" and "Host wildcard-*" should be skipped. - if len(blocks) != 3 { - t.Fatalf("expected 3 blocks, got %d", len(blocks)) - } - - // Verify webserver block. - wb := blocks[0] - if wb.Aliases[0] != "webserver" { - t.Errorf("expected alias 'webserver', got %q", wb.Aliases[0]) - } - if wb.HostName != "web.example.com" { - t.Errorf("expected HostName 'web.example.com', got %q", wb.HostName) - } - if wb.Port != 2222 { - t.Errorf("expected port 2222, got %d", wb.Port) - } - if wb.User != "deploy" { - t.Errorf("expected user 'deploy', got %q", wb.User) - } - if !wb.ForwardAgent { - t.Error("expected ForwardAgent to be true") - } - if !wb.Compression { - t.Error("expected Compression to be true") - } - - // Verify dbserver block with ProxyJump. - db := blocks[1] - if db.Aliases[0] != "dbserver" { - t.Errorf("expected alias 'dbserver', got %q", db.Aliases[0]) - } - if db.ProxyJump != "bastion@jump.example.com:2200" { - t.Errorf("unexpected ProxyJump: %q", db.ProxyJump) - } - - // Verify simple block. - sb := blocks[2] - if sb.HostName != "10.0.0.5" { - t.Errorf("expected HostName '10.0.0.5', got %q", sb.HostName) - } - if sb.Port != 22 { - t.Errorf("expected default port 22, got %d", sb.Port) - } -} - -func TestParseFileRejectsOversizedFile(t *testing.T) { - path := filepath.Join(t.TempDir(), "config") - f, err := os.Create(path) - if err != nil { - t.Fatal(err) - } - if err := f.Truncate(maxConfigFileBytes + 1); err != nil { - f.Close() - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - - _, err = ParseFile(path) - if err == nil { - t.Fatal("expected oversized config file to be rejected") - } - if !strings.Contains(err.Error(), "too large") { - t.Fatalf("error = %q, want too large", err.Error()) - } -} - -func TestParseFileRejectsTooManyHostBlocks(t *testing.T) { - path := filepath.Join(t.TempDir(), "config") - var b strings.Builder - for i := 0; i < maxHostBlocks+1; i++ { - b.WriteString("Host host") - b.WriteString(strconv.Itoa(i)) - b.WriteString("\n HostName example.com\n") - } - if err := os.WriteFile(path, []byte(b.String()), 0600); err != nil { - t.Fatal(err) - } - - _, err := ParseFile(path) - if err == nil { - t.Fatal("expected excessive Host blocks to be rejected") - } - if !strings.Contains(err.Error(), "too many Host blocks") { - t.Fatalf("error = %q, want too many Host blocks", err.Error()) - } -} - -func TestToProfiles(t *testing.T) { - blocks := []HostBlock{ - { - Aliases: []string{"myserver"}, - HostName: "srv.example.com", - Port: 22, - User: "admin", - ForwardAgent: true, - }, - { - Aliases: []string{"jumpbox"}, - HostName: "jump.example.com", - Port: 2200, - User: "jumper", - ProxyJump: "gateway@gw.example.com", - }, - } - - profiles := ToProfiles(blocks, "Imported") - if len(profiles) != 2 { - t.Fatalf("expected 2 profiles, got %d", len(profiles)) - } - - p := profiles[0] - if p.DisplayName != "myserver" { - t.Errorf("expected DisplayName 'myserver', got %q", p.DisplayName) - } - if p.Host != "srv.example.com" { - t.Errorf("expected host 'srv.example.com', got %q", p.Host) - } - if p.SSH == nil { - t.Fatal("expected SSH config to be set") - } - if !p.SSH.AgentAuth { - t.Error("expected AgentAuth to be true for ForwardAgent host") - } - - jp := profiles[1] - if jp.SSH == nil || len(jp.SSH.JumpHosts) == 0 { - t.Fatal("expected jump host from ProxyJump") - } - jh := jp.SSH.JumpHosts[0] - if jh.Host != "gw.example.com" { - t.Errorf("expected jump host 'gw.example.com', got %q", jh.Host) - } - if jh.Username != "gateway" { - t.Errorf("expected jump user 'gateway', got %q", jh.Username) - } -} - -func TestParseProxyJump(t *testing.T) { - tests := []struct { - input string - wantHost string - wantUser string - wantPort int - }{ - {"bastion.example.com", "bastion.example.com", "", 22}, - {"user@bastion.example.com", "bastion.example.com", "user", 22}, - {"user@bastion.example.com:2200", "bastion.example.com", "user", 2200}, - {"host:443", "host", "", 443}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - jh := parseProxyJump(tt.input) - if jh == nil { - t.Fatal("expected non-nil result") - } - if jh.Host != tt.wantHost { - t.Errorf("host: got %q, want %q", jh.Host, tt.wantHost) - } - if jh.Username != tt.wantUser { - t.Errorf("user: got %q, want %q", jh.Username, tt.wantUser) - } - if jh.Port != tt.wantPort { - t.Errorf("port: got %d, want %d", jh.Port, tt.wantPort) - } - }) - } -} diff --git a/internal/storage/migrations/migrations.go b/internal/storage/migrations/migrations.go deleted file mode 100644 index 32e1ec3..0000000 --- a/internal/storage/migrations/migrations.go +++ /dev/null @@ -1,10 +0,0 @@ -// Package migrations provides embedded SQL migration files for the storage layer. -package migrations - -import "embed" - -// MigrationFS contains all embedded SQL migration files. -// Each migration is numbered sequentially (e.g. 001_initial.sql). -// -//go:embed *.sql -var MigrationFS embed.FS diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go deleted file mode 100644 index 6b7a686..0000000 --- a/internal/storage/storage_test.go +++ /dev/null @@ -1,695 +0,0 @@ -package storage - -import ( - "encoding/json" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/andydixon/link/internal/models" - "github.com/google/uuid" -) - -// setupTestStore creates an isolated Store backed by a temporary SQLite database. -func setupTestStore(t *testing.T) *Store { - t.Helper() - dir := t.TempDir() - dbPath := filepath.Join(dir, "test.db") - store, err := NewStore(dbPath) - if err != nil { - t.Fatalf("NewStore() error = %v", err) - } - t.Cleanup(func() { store.Close() }) - return store -} - -// makeSSHProfile returns a minimal valid SSH profile for testing. -func makeSSHProfile(name, host string) *models.Profile { - p := models.NewProfile(models.ProtocolSSH) - p.DisplayName = name - p.Host = host - p.Username = "testuser" - return &p -} - -func makeIRCProfile(name, host string) *models.Profile { - p := models.NewProfile(models.ProtocolIRC) - p.DisplayName = name - p.Host = host - p.Username = "link" - p.IRC.Nick = "link" - return &p -} - -// --------------------------------------------------------------------------- -// Profile CRUD -// --------------------------------------------------------------------------- - -func TestCreateAndGetProfile(t *testing.T) { - store := setupTestStore(t) - p := makeSSHProfile("My Server", "10.0.0.1") - - if err := store.CreateProfile(p); err != nil { - t.Fatalf("CreateProfile() error = %v", err) - } - - got, err := store.GetProfile(p.ID) - if err != nil { - t.Fatalf("GetProfile() error = %v", err) - } - if got.DisplayName != "My Server" { - t.Errorf("DisplayName = %q, want %q", got.DisplayName, "My Server") - } - if got.Host != "10.0.0.1" { - t.Errorf("Host = %q, want %q", got.Host, "10.0.0.1") - } - if got.Protocol != models.ProtocolSSH { - t.Errorf("Protocol = %q, want %q", got.Protocol, models.ProtocolSSH) - } -} - -func TestUpdateProfile(t *testing.T) { - store := setupTestStore(t) - p := makeSSHProfile("Original", "host.example.com") - _ = store.CreateProfile(p) - - p.DisplayName = "Updated" - p.Host = "new-host.example.com" - p.UpdatedAt = time.Now() - if err := store.UpdateProfile(p); err != nil { - t.Fatalf("UpdateProfile() error = %v", err) - } - - got, err := store.GetProfile(p.ID) - if err != nil { - t.Fatalf("GetProfile() error = %v", err) - } - if got.DisplayName != "Updated" { - t.Errorf("DisplayName = %q, want %q", got.DisplayName, "Updated") - } - if got.Host != "new-host.example.com" { - t.Errorf("Host = %q, want %q", got.Host, "new-host.example.com") - } -} - -func TestDeleteProfile(t *testing.T) { - store := setupTestStore(t) - p := makeSSHProfile("ToDelete", "host.local") - _ = store.CreateProfile(p) - - if err := store.DeleteProfile(p.ID); err != nil { - t.Fatalf("DeleteProfile() error = %v", err) - } - if _, err := store.GetProfile(p.ID); err == nil { - t.Error("expected error when getting deleted profile") - } -} - -func TestDeleteProfileNotFound(t *testing.T) { - store := setupTestStore(t) - if err := store.DeleteProfile("nonexistent"); err == nil { - t.Error("expected error when deleting non-existent profile") - } -} - -func TestIRCLastRoomsRoundTripReplaceAndDelete(t *testing.T) { - store := setupTestStore(t) - p := makeIRCProfile("IRC Network", "irc.example.com") - if err := store.CreateProfile(p); err != nil { - t.Fatalf("CreateProfile() error = %v", err) - } - - if err := store.SaveIRCLastRooms(p.ID, []string{" #Go-Nuts ", "#link", "#go-nuts", ""}); err != nil { - t.Fatalf("SaveIRCLastRooms() error = %v", err) - } - rooms, err := store.GetIRCLastRooms(p.ID) - if err != nil { - t.Fatalf("GetIRCLastRooms() error = %v", err) - } - if got, want := strings.Join(rooms, ","), "#Go-Nuts,#link"; got != want { - t.Fatalf("GetIRCLastRooms() = %q, want %q", got, want) - } - - if err := store.SaveIRCLastRooms(p.ID, []string{"#new-room"}); err != nil { - t.Fatalf("SaveIRCLastRooms(replace) error = %v", err) - } - rooms, err = store.GetIRCLastRooms(p.ID) - if err != nil { - t.Fatalf("GetIRCLastRooms(replaced) error = %v", err) - } - if got, want := strings.Join(rooms, ","), "#new-room"; got != want { - t.Fatalf("GetIRCLastRooms(replaced) = %q, want %q", got, want) - } - - if err := store.DeleteProfile(p.ID); err != nil { - t.Fatalf("DeleteProfile() error = %v", err) - } - rooms, err = store.GetIRCLastRooms(p.ID) - if err != nil { - t.Fatalf("GetIRCLastRooms(after delete) error = %v", err) - } - if len(rooms) != 0 { - t.Fatalf("GetIRCLastRooms(after delete) = %v, want empty", rooms) - } -} - -func TestSaveIRCLastRoomsRejectsEmptyProfileID(t *testing.T) { - store := setupTestStore(t) - if err := store.SaveIRCLastRooms(" ", []string{"#room"}); err == nil { - t.Fatal("SaveIRCLastRooms() accepted an empty profile ID") - } -} - -// --------------------------------------------------------------------------- -// List and search -// --------------------------------------------------------------------------- - -func TestListProfiles(t *testing.T) { - store := setupTestStore(t) - _ = store.CreateProfile(makeSSHProfile("Alpha", "a.example.com")) - _ = store.CreateProfile(makeSSHProfile("Beta", "b.example.com")) - - profiles, err := store.ListProfiles() - if err != nil { - t.Fatalf("ListProfiles() error = %v", err) - } - if len(profiles) != 2 { - t.Errorf("ListProfiles() returned %d profiles, want 2", len(profiles)) - } -} - -func TestSearchProfiles(t *testing.T) { - store := setupTestStore(t) - _ = store.CreateProfile(makeSSHProfile("Production Web", "web.prod.com")) - _ = store.CreateProfile(makeSSHProfile("Staging DB", "db.staging.com")) - - results, err := store.SearchProfiles("prod") - if err != nil { - t.Fatalf("SearchProfiles() error = %v", err) - } - if len(results) != 1 { - t.Fatalf("SearchProfiles(\"prod\") returned %d results, want 1", len(results)) - } - if results[0].DisplayName != "Production Web" { - t.Errorf("DisplayName = %q, want %q", results[0].DisplayName, "Production Web") - } -} - -func TestSearchProfilesNoResults(t *testing.T) { - store := setupTestStore(t) - _ = store.CreateProfile(makeSSHProfile("Foo", "foo.local")) - - results, err := store.SearchProfiles("zzz-no-match") - if err != nil { - t.Fatalf("SearchProfiles() error = %v", err) - } - if len(results) != 0 { - t.Errorf("expected 0 results, got %d", len(results)) - } -} - -func TestListProfilesByProtocol(t *testing.T) { - store := setupTestStore(t) - - ssh := makeSSHProfile("SSH Server", "ssh.local") - _ = store.CreateProfile(ssh) - - telnet := models.NewProfile(models.ProtocolTelnet) - telnet.DisplayName = "Telnet Switch" - telnet.Host = "switch.local" - _ = store.CreateProfile(&telnet) - - sshResults, err := store.ListProfilesByProtocol("ssh") - if err != nil { - t.Fatalf("ListProfilesByProtocol(ssh) error = %v", err) - } - if len(sshResults) != 1 { - t.Errorf("expected 1 SSH profile, got %d", len(sshResults)) - } - - telnetResults, err := store.ListProfilesByProtocol("telnet") - if err != nil { - t.Fatalf("ListProfilesByProtocol(telnet) error = %v", err) - } - if len(telnetResults) != 1 { - t.Errorf("expected 1 Telnet profile, got %d", len(telnetResults)) - } -} - -// --------------------------------------------------------------------------- -// Favourites -// --------------------------------------------------------------------------- - -func TestFavourites(t *testing.T) { - store := setupTestStore(t) - p := makeSSHProfile("Fav Server", "fav.local") - _ = store.CreateProfile(p) - - // Initially no favourites. - favs, err := store.ListFavourites() - if err != nil { - t.Fatalf("ListFavourites() error = %v", err) - } - if len(favs) != 0 { - t.Fatalf("expected 0 favourites, got %d", len(favs)) - } - - // Mark as favourite. - if err := store.SetFavourite(p.ID, true); err != nil { - t.Fatalf("SetFavourite(true) error = %v", err) - } - favs, _ = store.ListFavourites() - if len(favs) != 1 { - t.Fatalf("expected 1 favourite, got %d", len(favs)) - } - - // Unmark. - if err := store.SetFavourite(p.ID, false); err != nil { - t.Fatalf("SetFavourite(false) error = %v", err) - } - favs, _ = store.ListFavourites() - if len(favs) != 0 { - t.Errorf("expected 0 favourites after unmark, got %d", len(favs)) - } -} - -// --------------------------------------------------------------------------- -// Known hosts -// --------------------------------------------------------------------------- - -func TestKnownHostCRUD(t *testing.T) { - store := setupTestStore(t) - now := time.Now() - - kh := &models.KnownHost{ - ID: uuid.New().String(), - Host: "server.example.com", - Port: 22, - KeyType: "ssh-ed25519", - Fingerprint: "SHA256:abcdef1234567890", - PublicKey: "AAAAC3NzaC1lZDI1NTE5...", - FirstSeen: now, - LastSeen: now, - Trusted: true, - } - - // Save and retrieve. - if err := store.SaveKnownHost(kh); err != nil { - t.Fatalf("SaveKnownHost() error = %v", err) - } - got, err := store.GetKnownHost("server.example.com", 22) - if err != nil { - t.Fatalf("GetKnownHost() error = %v", err) - } - if got == nil { - t.Fatal("GetKnownHost() returned nil") - } - if got.Fingerprint != kh.Fingerprint { - t.Errorf("Fingerprint = %q, want %q", got.Fingerprint, kh.Fingerprint) - } - - // List should contain it. - hosts, err := store.ListKnownHosts() - if err != nil { - t.Fatalf("ListKnownHosts() error = %v", err) - } - if len(hosts) != 1 { - t.Fatalf("expected 1 known host, got %d", len(hosts)) - } - - // Delete. - if err := store.DeleteKnownHost(kh.ID); err != nil { - t.Fatalf("DeleteKnownHost() error = %v", err) - } - hosts, _ = store.ListKnownHosts() - if len(hosts) != 0 { - t.Errorf("expected 0 known hosts after delete, got %d", len(hosts)) - } -} - -func TestGetKnownHostNotFound(t *testing.T) { - store := setupTestStore(t) - got, err := store.GetKnownHost("no-such-host", 22) - if err != nil { - t.Fatalf("GetKnownHost() error = %v", err) - } - if got != nil { - t.Error("expected nil for unknown host") - } -} - -// --------------------------------------------------------------------------- -// Preferences -// --------------------------------------------------------------------------- - -func TestPreferencesDefaults(t *testing.T) { - store := setupTestStore(t) - - // No preferences stored yet — should return nil. - prefs, err := store.GetPreferences() - if err != nil { - t.Fatalf("GetPreferences() error = %v", err) - } - if prefs != nil { - t.Error("expected nil preferences on empty database") - } -} - -func TestSavePreferencesDoesNotError(t *testing.T) { - store := setupTestStore(t) - - prefs := models.DefaultAppPreferences() - prefs.ConfirmOnClose = true - prefs.DefaultFontSize = 16 - prefs.Theme = "custom-dark" - prefs.LeviathanEnabled = true - - if err := store.SavePreferences(&prefs); err != nil { - t.Fatalf("SavePreferences() error = %v", err) - } - - got, err := store.GetPreferences() - if err != nil { - t.Fatalf("GetPreferences() error = %v", err) - } - if got == nil { - t.Fatal("GetPreferences() returned nil after save") - } - if got.ConfirmOnClose != true { - t.Errorf("ConfirmOnClose = %v, want true", got.ConfirmOnClose) - } - if got.DefaultFontSize != 16 { - t.Errorf("DefaultFontSize = %d, want 16", got.DefaultFontSize) - } - if got.Theme != "custom-dark" { - t.Errorf("Theme = %q, want %q", got.Theme, "custom-dark") - } - if !got.LeviathanEnabled { - t.Error("LeviathanEnabled = false, want true") - } -} - -// --------------------------------------------------------------------------- -// Audit log -// --------------------------------------------------------------------------- - -func TestAuditEntries(t *testing.T) { - store := setupTestStore(t) - - entry := &models.AuditEntry{ - ID: uuid.New().String(), - Timestamp: time.Now(), - Action: "connect", - ProfileID: "profile-1", - SessionID: "session-1", - Details: "Connected via SSH to 10.0.0.1", - } - - if err := store.AddAuditEntry(entry); err != nil { - t.Fatalf("AddAuditEntry() error = %v", err) - } - - entries, err := store.ListAuditEntries(10) - if err != nil { - t.Fatalf("ListAuditEntries() error = %v", err) - } - if len(entries) != 1 { - t.Fatalf("expected 1 audit entry, got %d", len(entries)) - } - if entries[0].Action != "connect" { - t.Errorf("Action = %q, want %q", entries[0].Action, "connect") - } - if entries[0].Details != "Connected via SSH to 10.0.0.1" { - t.Errorf("Details = %q, want %q", entries[0].Details, "Connected via SSH to 10.0.0.1") - } -} - -func TestAuditEntriesLimit(t *testing.T) { - store := setupTestStore(t) - - for i := 0; i < 5; i++ { - _ = store.AddAuditEntry(&models.AuditEntry{ - ID: uuid.New().String(), - Timestamp: time.Now(), - Action: "test", - Details: "entry", - }) - } - - entries, err := store.ListAuditEntries(3) - if err != nil { - t.Fatalf("ListAuditEntries() error = %v", err) - } - if len(entries) != 3 { - t.Errorf("expected 3 audit entries with limit, got %d", len(entries)) - } -} - -// --------------------------------------------------------------------------- -// UpdateLastConnected -// --------------------------------------------------------------------------- - -func TestUpdateLastConnected(t *testing.T) { - store := setupTestStore(t) - p := makeSSHProfile("Connectable", "conn.local") - _ = store.CreateProfile(p) - - // Initially last_connected should be nil. - got, err := store.GetProfile(p.ID) - if err != nil { - t.Fatalf("GetProfile() error = %v", err) - } - if got.LastConnected != nil { - t.Error("expected LastConnected to be nil initially") - } - - // Update last connected. - if err := store.UpdateLastConnected(p.ID); err != nil { - t.Fatalf("UpdateLastConnected() error = %v", err) - } - - got, err = store.GetProfile(p.ID) - if err != nil { - t.Fatalf("GetProfile() after update error = %v", err) - } - if got.LastConnected == nil { - t.Fatal("expected LastConnected to be set after update") - } - // Should be within the last few seconds. - elapsed := time.Since(*got.LastConnected) - if elapsed > 5*time.Second { - t.Errorf("LastConnected is too old: %v ago", elapsed) - } -} - -func TestUpdateLastConnectedNotFound(t *testing.T) { - store := setupTestStore(t) - if err := store.UpdateLastConnected("nonexistent"); err == nil { - t.Error("expected error when updating non-existent profile") - } -} - -// --------------------------------------------------------------------------- -// Export / Import -// --------------------------------------------------------------------------- - -func TestExportProfiles(t *testing.T) { - store := setupTestStore(t) - _ = store.CreateProfile(makeSSHProfile("Export A", "a.example.com")) - _ = store.CreateProfile(makeSSHProfile("Export B", "b.example.com")) - - data, err := store.ExportProfiles(false) - if err != nil { - t.Fatalf("ExportProfiles() error = %v", err) - } - if len(data) == 0 { - t.Fatal("ExportProfiles() returned empty data") - } - - // The exported JSON should be parseable as a slice of profiles. - var profiles []*models.Profile - if err := json.Unmarshal(data, &profiles); err != nil { - t.Fatalf("failed to unmarshal exported data: %v", err) - } - if len(profiles) != 2 { - t.Errorf("expected 2 exported profiles, got %d", len(profiles)) - } -} - -func TestExportProfilesRedactsInlineSSHSecrets(t *testing.T) { - store := setupTestStore(t) - profile := makeSSHProfile("Export Secret", "secret.example.com") - profile.SSH.ProxyPassword = "proxy-secret" - profile.SSH.TOTPSecret = "totp-secret" - if err := store.CreateProfile(profile); err != nil { - t.Fatalf("CreateProfile() error = %v", err) - } - - data, err := store.ExportProfiles(false) - if err != nil { - t.Fatalf("ExportProfiles(false) error = %v", err) - } - if strings.Contains(string(data), "proxy-secret") || strings.Contains(string(data), "totp-secret") { - t.Fatalf("ExportProfiles(false) leaked inline SSH secrets: %s", string(data)) - } - - data, err = store.ExportProfiles(true) - if err != nil { - t.Fatalf("ExportProfiles(true) error = %v", err) - } - if !strings.Contains(string(data), "proxy-secret") || !strings.Contains(string(data), "totp-secret") { - t.Fatalf("ExportProfiles(true) did not include inline SSH secrets: %s", string(data)) - } -} - -func TestImportProfiles(t *testing.T) { - store := setupTestStore(t) - p := makeSSHProfile("Import Me", "import.local") - _ = store.CreateProfile(p) - - // Export from source store. - data, err := store.ExportProfiles(false) - if err != nil { - t.Fatalf("ExportProfiles() error = %v", err) - } - - // Import into a fresh store. - store2 := setupTestStore(t) - n, err := store2.ImportProfiles(data, "skip") - if err != nil { - t.Fatalf("ImportProfiles() error = %v", err) - } - if n != 1 { - t.Errorf("expected 1 imported profile, got %d", n) - } - - profiles, _ := store2.ListProfiles() - if len(profiles) != 1 { - t.Fatalf("expected 1 profile in target store, got %d", len(profiles)) - } - if profiles[0].DisplayName != "Import Me" { - t.Errorf("DisplayName = %q, want %q", profiles[0].DisplayName, "Import Me") - } -} - -func TestImportDuplicateHandling(t *testing.T) { - store := setupTestStore(t) - p := makeSSHProfile("Original", "orig.local") - _ = store.CreateProfile(p) - - data, err := store.ExportProfiles(false) - if err != nil { - t.Fatalf("ExportProfiles() error = %v", err) - } - - // "skip" strategy — importing same profile again should import 0. - n, err := store.ImportProfiles(data, "skip") - if err != nil { - t.Fatalf("ImportProfiles(skip) error = %v", err) - } - if n != 0 { - t.Errorf("skip strategy: expected 0 imports, got %d", n) - } - - // "overwrite" strategy — should overwrite the existing profile. - n, err = store.ImportProfiles(data, "overwrite") - if err != nil { - t.Fatalf("ImportProfiles(overwrite) error = %v", err) - } - if n != 1 { - t.Errorf("overwrite strategy: expected 1 import, got %d", n) - } - // Total profiles should still be 1. - all, _ := store.ListProfiles() - if len(all) != 1 { - t.Errorf("after overwrite: expected 1 profile, got %d", len(all)) - } - - // "duplicate" strategy — should create a new entry with a fresh ID. - n, err = store.ImportProfiles(data, "duplicate") - if err != nil { - t.Fatalf("ImportProfiles(duplicate) error = %v", err) - } - if n != 1 { - t.Errorf("duplicate strategy: expected 1 import, got %d", n) - } - all, _ = store.ListProfiles() - if len(all) != 2 { - t.Errorf("after duplicate: expected 2 profiles, got %d", len(all)) - } -} - -func TestImportInvalidStrategy(t *testing.T) { - store := setupTestStore(t) - data := []byte(`[]`) - _, err := store.ImportProfiles(data, "badstrategy") - if err == nil { - t.Error("expected error for invalid import strategy") - } -} - -func TestImportInvalidJSON(t *testing.T) { - store := setupTestStore(t) - _, err := store.ImportProfiles([]byte("not json"), "skip") - if err == nil { - t.Error("expected error for invalid JSON import data") - } -} - -func TestImportRejectsNullProfileEntry(t *testing.T) { - store := setupTestStore(t) - _, err := store.ImportProfiles([]byte(`[null]`), "skip") - if err == nil { - t.Fatal("expected error for null profile entry") - } - if !strings.Contains(err.Error(), "entry 0 is null") { - t.Fatalf("error = %q, want null entry detail", err.Error()) - } -} - -// --------------------------------------------------------------------------- -// Migration idempotency -// --------------------------------------------------------------------------- - -func TestMigrateIdempotent(t *testing.T) { - // NewStore already runs migrate() internally. Creating a second Store - // against the same database verifies that migrations are idempotent. - dir := t.TempDir() - dbPath := filepath.Join(dir, "migrate-test.db") - - s1, err := NewStore(dbPath) - if err != nil { - t.Fatalf("first NewStore() error = %v", err) - } - s1.Close() - - s2, err := NewStore(dbPath) - if err != nil { - t.Fatalf("second NewStore() error = %v (migration not idempotent?)", err) - } - s2.Close() -} - -func TestMigrateCreatesTablesForCRUD(t *testing.T) { - // Verify that a fresh store can perform basic CRUD — proving that - // migration created the required tables. - store := setupTestStore(t) - - p := makeSSHProfile("Migration Test", "mig.local") - if err := store.CreateProfile(p); err != nil { - t.Fatalf("CreateProfile after migration failed: %v", err) - } - if _, err := store.GetProfile(p.ID); err != nil { - t.Fatalf("GetProfile after migration failed: %v", err) - } - if _, err := store.ListKnownHosts(); err != nil { - t.Fatalf("ListKnownHosts after migration failed: %v", err) - } - if _, err := store.ListAuditEntries(10); err != nil { - t.Fatalf("ListAuditEntries after migration failed: %v", err) - } - if _, err := store.GetPreferences(); err != nil { - t.Fatalf("GetPreferences after migration failed: %v", err) - } -} diff --git a/internal/storage/store.go b/internal/storage/store.go deleted file mode 100644 index 54f7097..0000000 --- a/internal/storage/store.go +++ /dev/null @@ -1,939 +0,0 @@ -// Package storage provides a SQLite-backed persistence layer for profiles, -// known hosts, audit entries, and application preferences. -package storage - -import ( - "database/sql" - "encoding/json" - "fmt" - "strings" - "time" - - _ "modernc.org/sqlite" - - "github.com/andydixon/link/internal/models" - "github.com/andydixon/link/internal/storage/migrations" - "github.com/google/uuid" -) - -// Store wraps a SQLite database and exposes typed CRUD methods for -// all application entities. -type Store struct { - db *sql.DB -} - -// NewStore opens (or creates) the SQLite database at dbPath, applies any -// pending migrations, and returns an initialised Store. -func NewStore(dbPath string) (*Store, error) { - db, err := sql.Open("sqlite", dbPath) - if err != nil { - return nil, fmt.Errorf("storage: failed to open database: %w", err) - } - if err := db.Ping(); err != nil { - return nil, fmt.Errorf("storage: database not reachable: %w", err) - } - if err := configureSQLite(db); err != nil { - _ = db.Close() - return nil, err - } - - s := &Store{db: db} - if err := s.migrate(); err != nil { - return nil, fmt.Errorf("storage: migration failed: %w", err) - } - return s, nil -} - -func configureSQLite(db *sql.DB) error { - db.SetMaxOpenConns(1) - - if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil { - return fmt.Errorf("storage: failed to set SQLite busy timeout: %w", err) - } - if _, err := db.Exec("PRAGMA journal_mode = WAL"); err != nil { - return fmt.Errorf("storage: failed to enable SQLite WAL mode: %w", err) - } - return nil -} - -// Close shuts down the database connection. -func (s *Store) Close() error { - return s.db.Close() -} - -// --------------------------------------------------------------------------- -// Migrations -// --------------------------------------------------------------------------- - -// migrate reads embedded SQL files and applies them in order. -func (s *Store) migrate() error { - // Ensure the schema_version table exists before querying it. - _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version ( - version INTEGER PRIMARY KEY, - applied_at TEXT NOT NULL - )`) - if err != nil { - return fmt.Errorf("failed to create schema_version table: %w", err) - } - - var current int - row := s.db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_version") - if err := row.Scan(¤t); err != nil { - return fmt.Errorf("failed to read schema version: %w", err) - } - - entries, err := migrations.MigrationFS.ReadDir(".") - if err != nil { - return fmt.Errorf("failed to read migration files: %w", err) - } - - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - if !strings.HasSuffix(name, ".sql") { - continue - } - // Extract version number from filename prefix (e.g. "001_initial.sql" → 1). - var ver int - if _, err := fmt.Sscanf(name, "%03d_", &ver); err != nil { - continue - } - if ver <= current { - continue - } - - data, err := migrations.MigrationFS.ReadFile(name) - if err != nil { - return fmt.Errorf("failed to read migration %s: %w", name, err) - } - if _, err := s.db.Exec(string(data)); err != nil { - return fmt.Errorf("failed to apply migration %s: %w", name, err) - } - } - return nil -} - -// --------------------------------------------------------------------------- -// Profile operations -// --------------------------------------------------------------------------- - -// CreateProfile inserts a new profile into the database. -func (s *Store) CreateProfile(p *models.Profile) error { - tagsJSON, _ := json.Marshal(p.Tags) - sshJSON, _ := json.Marshal(p.SSH) - telnetJSON, _ := json.Marshal(p.Telnet) - rawJSON, _ := json.Marshal(p.RAW) - githubJSON, _ := json.Marshal(p.GitHub) - vncJSON, _ := json.Marshal(p.VNC) - ircJSON, _ := json.Marshal(p.IRC) - - _, err := s.db.Exec(`INSERT INTO profiles ( - id, display_name, description, protocol, host, port, username, - tags, folder, environment, notes, favourite, last_connected, - connect_timeout, read_timeout, - terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, - ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config, - created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - p.ID, p.DisplayName, p.Description, string(p.Protocol), - p.Host, p.Port, p.Username, - string(tagsJSON), p.Folder, p.Environment, p.Notes, - boolToInt(p.Favourite), timeToStr(p.LastConnected), - p.ConnectTimeout, p.ReadTimeout, - p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle, - nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON), - nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON), nullableJSON(p.IRC, ircJSON), - p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339), - ) - if err != nil { - return fmt.Errorf("storage: failed to create profile: %w", err) - } - return nil -} - -// GetProfile retrieves a single profile by ID. -func (s *Store) GetProfile(id string) (*models.Profile, error) { - row := s.db.QueryRow(`SELECT - id, display_name, description, protocol, host, port, username, - tags, folder, environment, notes, favourite, last_connected, - connect_timeout, read_timeout, - terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, - ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config, - created_at, updated_at - FROM profiles WHERE id = ?`, id) - - p, err := scanProfile(row) - if err == sql.ErrNoRows { - return nil, fmt.Errorf("storage: profile %q not found", id) - } - if err != nil { - return nil, fmt.Errorf("storage: failed to get profile: %w", err) - } - return p, nil -} - -// UpdateProfile overwrites an existing profile. -func (s *Store) UpdateProfile(p *models.Profile) error { - tagsJSON, _ := json.Marshal(p.Tags) - sshJSON, _ := json.Marshal(p.SSH) - telnetJSON, _ := json.Marshal(p.Telnet) - rawJSON, _ := json.Marshal(p.RAW) - githubJSON, _ := json.Marshal(p.GitHub) - vncJSON, _ := json.Marshal(p.VNC) - ircJSON, _ := json.Marshal(p.IRC) - - res, err := s.db.Exec(`UPDATE profiles SET - display_name=?, description=?, protocol=?, host=?, port=?, username=?, - tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?, - connect_timeout=?, read_timeout=?, - terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?, - ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?, irc_config=?, - updated_at=? - WHERE id=?`, - p.DisplayName, p.Description, string(p.Protocol), - p.Host, p.Port, p.Username, - string(tagsJSON), p.Folder, p.Environment, p.Notes, - boolToInt(p.Favourite), timeToStr(p.LastConnected), - p.ConnectTimeout, p.ReadTimeout, - p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle, - nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON), - nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON), nullableJSON(p.IRC, ircJSON), - p.UpdatedAt.Format(time.RFC3339), - p.ID, - ) - if err != nil { - return fmt.Errorf("storage: failed to update profile: %w", err) - } - n, _ := res.RowsAffected() - if n == 0 { - return fmt.Errorf("storage: profile %q not found", p.ID) - } - return nil -} - -// DeleteProfile removes a profile by ID. -func (s *Store) DeleteProfile(id string) error { - tx, err := s.db.Begin() - if err != nil { - return fmt.Errorf("storage: failed to begin profile deletion: %w", err) - } - defer func() { _ = tx.Rollback() }() - - if _, err := tx.Exec("DELETE FROM irc_channel_state WHERE profile_id = ?", id); err != nil { - return fmt.Errorf("storage: failed to delete IRC channel state: %w", err) - } - res, err := tx.Exec("DELETE FROM profiles WHERE id = ?", id) - if err != nil { - return fmt.Errorf("storage: failed to delete profile: %w", err) - } - n, _ := res.RowsAffected() - if n == 0 { - return fmt.Errorf("storage: profile %q not found", id) - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("storage: failed to commit profile deletion: %w", err) - } - return nil -} - -// ListProfiles returns all profiles ordered by display name. -func (s *Store) ListProfiles() ([]*models.Profile, error) { - rows, err := s.db.Query(`SELECT - id, display_name, description, protocol, host, port, username, - tags, folder, environment, notes, favourite, last_connected, - connect_timeout, read_timeout, - terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, - ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config, - created_at, updated_at - FROM profiles ORDER BY display_name`) - if err != nil { - return nil, fmt.Errorf("storage: failed to list profiles: %w", err) - } - defer rows.Close() - return scanProfiles(rows) -} - -// SearchProfiles returns profiles whose display name, host, or tags contain -// the query string (case-insensitive). -func (s *Store) SearchProfiles(query string) ([]*models.Profile, error) { - q := "%" + query + "%" - rows, err := s.db.Query(`SELECT - id, display_name, description, protocol, host, port, username, - tags, folder, environment, notes, favourite, last_connected, - connect_timeout, read_timeout, - terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, - ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config, - created_at, updated_at - FROM profiles - WHERE display_name LIKE ? OR host LIKE ? OR tags LIKE ? - ORDER BY display_name`, q, q, q) - if err != nil { - return nil, fmt.Errorf("storage: failed to search profiles: %w", err) - } - defer rows.Close() - return scanProfiles(rows) -} - -// ListProfilesByProtocol returns profiles filtered by protocol. -func (s *Store) ListProfilesByProtocol(protocol string) ([]*models.Profile, error) { - rows, err := s.db.Query(`SELECT - id, display_name, description, protocol, host, port, username, - tags, folder, environment, notes, favourite, last_connected, - connect_timeout, read_timeout, - terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, - ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config, - created_at, updated_at - FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol) - if err != nil { - return nil, fmt.Errorf("storage: failed to list profiles by protocol: %w", err) - } - defer rows.Close() - return scanProfiles(rows) -} - -// ListProfilesByTag returns profiles that contain the specified tag. -func (s *Store) ListProfilesByTag(tag string) ([]*models.Profile, error) { - q := `%"` + tag + `"%` - rows, err := s.db.Query(`SELECT - id, display_name, description, protocol, host, port, username, - tags, folder, environment, notes, favourite, last_connected, - connect_timeout, read_timeout, - terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, - ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config, - created_at, updated_at - FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q) - if err != nil { - return nil, fmt.Errorf("storage: failed to list profiles by tag: %w", err) - } - defer rows.Close() - return scanProfiles(rows) -} - -// ListProfilesByFolder returns profiles in the specified folder. -func (s *Store) ListProfilesByFolder(folder string) ([]*models.Profile, error) { - rows, err := s.db.Query(`SELECT - id, display_name, description, protocol, host, port, username, - tags, folder, environment, notes, favourite, last_connected, - connect_timeout, read_timeout, - terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, - ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config, - created_at, updated_at - FROM profiles WHERE folder = ? ORDER BY display_name`, folder) - if err != nil { - return nil, fmt.Errorf("storage: failed to list profiles by folder: %w", err) - } - defer rows.Close() - return scanProfiles(rows) -} - -// ListFavourites returns all profiles marked as favourites. -func (s *Store) ListFavourites() ([]*models.Profile, error) { - rows, err := s.db.Query(`SELECT - id, display_name, description, protocol, host, port, username, - tags, folder, environment, notes, favourite, last_connected, - connect_timeout, read_timeout, - terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, - ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config, - created_at, updated_at - FROM profiles WHERE favourite = 1 ORDER BY display_name`) - if err != nil { - return nil, fmt.Errorf("storage: failed to list favourites: %w", err) - } - defer rows.Close() - return scanProfiles(rows) -} - -// SetFavourite updates the favourite flag on a profile. -func (s *Store) SetFavourite(id string, favourite bool) error { - res, err := s.db.Exec("UPDATE profiles SET favourite = ?, updated_at = ? WHERE id = ?", - boolToInt(favourite), time.Now().Format(time.RFC3339), id) - if err != nil { - return fmt.Errorf("storage: failed to update favourite: %w", err) - } - n, _ := res.RowsAffected() - if n == 0 { - return fmt.Errorf("storage: profile %q not found", id) - } - return nil -} - -// UpdateLastConnected sets the last_connected timestamp for the given profile -// to the current time. -func (s *Store) UpdateLastConnected(id string) error { - now := time.Now().Format(time.RFC3339) - res, err := s.db.Exec("UPDATE profiles SET last_connected = ?, updated_at = ? WHERE id = ?", - now, now, id) - if err != nil { - return fmt.Errorf("storage: failed to update last_connected: %w", err) - } - n, _ := res.RowsAffected() - if n == 0 { - return fmt.Errorf("storage: profile %q not found", id) - } - return nil -} - -// ExportProfiles serialises all profiles as JSON. If includeSecrets is false -// the export omits inline credential-related fields. -func (s *Store) ExportProfiles(includeSecrets bool) ([]byte, error) { - profiles, err := s.ListProfiles() - if err != nil { - return nil, fmt.Errorf("storage: export failed: %w", err) - } - if !includeSecrets { - profiles = redactProfileSecrets(profiles) - } - return json.MarshalIndent(profiles, "", " ") -} - -// ImportProfiles deserialises profiles from JSON and inserts them according to -// the given strategy: "skip" ignores duplicates, "overwrite" replaces them, -// and "duplicate" creates new entries with fresh IDs. Returns the number of -// profiles imported. -func (s *Store) ImportProfiles(data []byte, strategy string) (int, error) { - switch strategy { - case "skip", "overwrite", "duplicate": - default: - return 0, fmt.Errorf("storage: unknown import strategy %q", strategy) - } - - var profiles []*models.Profile - if err := json.Unmarshal(data, &profiles); err != nil { - return 0, fmt.Errorf("storage: invalid import data: %w", err) - } - - imported := 0 - for i, p := range profiles { - if p == nil { - return imported, fmt.Errorf("storage: import profile entry %d is null", i) - } - - existing, _ := s.GetProfile(p.ID) - - switch strategy { - case "skip": - if existing != nil { - continue - } - case "overwrite": - if existing != nil { - if err := s.UpdateProfile(p); err != nil { - return imported, err - } - imported++ - continue - } - case "duplicate": - if existing != nil { - p.ID = uuid.New().String() - p.CreatedAt = time.Now() - p.UpdatedAt = time.Now() - } - } - - if err := s.CreateProfile(p); err != nil { - return imported, err - } - imported++ - } - return imported, nil -} - -// --------------------------------------------------------------------------- -// Known hosts -// --------------------------------------------------------------------------- - -// GetKnownHost retrieves a known host entry by host, port, and key type. -func (s *Store) GetKnownHost(host string, port int) (*models.KnownHost, error) { - row := s.db.QueryRow(`SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted - FROM known_hosts WHERE host = ? AND port = ?`, host, port) - - kh := &models.KnownHost{} - var firstSeen, lastSeen string - var trusted int - if err := row.Scan(&kh.ID, &kh.Host, &kh.Port, &kh.KeyType, &kh.Fingerprint, - &kh.PublicKey, &firstSeen, &lastSeen, &trusted); err != nil { - if err == sql.ErrNoRows { - return nil, nil - } - return nil, fmt.Errorf("storage: failed to get known host: %w", err) - } - kh.FirstSeen, _ = time.Parse(time.RFC3339, firstSeen) - kh.LastSeen, _ = time.Parse(time.RFC3339, lastSeen) - kh.Trusted = trusted == 1 - return kh, nil -} - -// SaveKnownHost inserts or updates a known host entry. -func (s *Store) SaveKnownHost(kh *models.KnownHost) error { - if kh.ID == "" { - kh.ID = uuid.New().String() - } - _, err := s.db.Exec(`INSERT INTO known_hosts (id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(host, port, key_type) DO UPDATE SET - fingerprint=excluded.fingerprint, public_key=excluded.public_key, - last_seen=excluded.last_seen, trusted=excluded.trusted`, - kh.ID, kh.Host, kh.Port, kh.KeyType, kh.Fingerprint, kh.PublicKey, - kh.FirstSeen.Format(time.RFC3339), kh.LastSeen.Format(time.RFC3339), - boolToInt(kh.Trusted)) - if err != nil { - return fmt.Errorf("storage: failed to save known host: %w", err) - } - return nil -} - -// ListKnownHosts returns all stored known host entries. -func (s *Store) ListKnownHosts() ([]*models.KnownHost, error) { - rows, err := s.db.Query(`SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted - FROM known_hosts ORDER BY host, port`) - if err != nil { - return nil, fmt.Errorf("storage: failed to list known hosts: %w", err) - } - defer rows.Close() - - var hosts []*models.KnownHost - for rows.Next() { - kh := &models.KnownHost{} - var firstSeen, lastSeen string - var trusted int - if err := rows.Scan(&kh.ID, &kh.Host, &kh.Port, &kh.KeyType, &kh.Fingerprint, - &kh.PublicKey, &firstSeen, &lastSeen, &trusted); err != nil { - return nil, fmt.Errorf("storage: failed to scan known host: %w", err) - } - kh.FirstSeen, _ = time.Parse(time.RFC3339, firstSeen) - kh.LastSeen, _ = time.Parse(time.RFC3339, lastSeen) - kh.Trusted = trusted == 1 - hosts = append(hosts, kh) - } - return hosts, rows.Err() -} - -// DeleteKnownHost removes a known host entry by ID. -func (s *Store) DeleteKnownHost(id string) error { - res, err := s.db.Exec("DELETE FROM known_hosts WHERE id = ?", id) - if err != nil { - return fmt.Errorf("storage: failed to delete known host: %w", err) - } - n, _ := res.RowsAffected() - if n == 0 { - return fmt.Errorf("storage: known host %q not found", id) - } - return nil -} - -// --------------------------------------------------------------------------- -// Audit log -// --------------------------------------------------------------------------- - -// AddAuditEntry inserts an audit log entry. -func (s *Store) AddAuditEntry(entry *models.AuditEntry) error { - _, err := s.db.Exec(`INSERT INTO audit_log (id, timestamp, action, profile_id, session_id, details) - VALUES (?, ?, ?, ?, ?, ?)`, - entry.ID, entry.Timestamp.Format(time.RFC3339), entry.Action, - entry.ProfileID, entry.SessionID, entry.Details) - if err != nil { - return fmt.Errorf("storage: failed to add audit entry: %w", err) - } - return nil -} - -// ListAuditEntries returns the most recent audit entries, limited to count. -func (s *Store) ListAuditEntries(limit int) ([]*models.AuditEntry, error) { - rows, err := s.db.Query(`SELECT id, timestamp, action, profile_id, session_id, details - FROM audit_log ORDER BY timestamp DESC LIMIT ?`, limit) - if err != nil { - return nil, fmt.Errorf("storage: failed to list audit entries: %w", err) - } - defer rows.Close() - - var entries []*models.AuditEntry - for rows.Next() { - e := &models.AuditEntry{} - var ts string - if err := rows.Scan(&e.ID, &ts, &e.Action, &e.ProfileID, &e.SessionID, &e.Details); err != nil { - return nil, fmt.Errorf("storage: failed to scan audit entry: %w", err) - } - e.Timestamp, _ = time.Parse(time.RFC3339, ts) - entries = append(entries, e) - } - return entries, rows.Err() -} - -// --------------------------------------------------------------------------- -// IRC channel state -// --------------------------------------------------------------------------- - -// GetIRCLastRooms returns the channels that were open the last time the given -// saved IRC profile was connected. -func (s *Store) GetIRCLastRooms(profileID string) ([]string, error) { - rows, err := s.db.Query(`SELECT room_name FROM irc_channel_state - WHERE profile_id = ? ORDER BY position, room_name`, profileID) - if err != nil { - return nil, fmt.Errorf("storage: failed to get IRC channel state: %w", err) - } - defer rows.Close() - - rooms := make([]string, 0) - for rows.Next() { - var room string - if err := rows.Scan(&room); err != nil { - return nil, fmt.Errorf("storage: failed to scan IRC channel state: %w", err) - } - rooms = append(rooms, room) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("storage: failed to read IRC channel state: %w", err) - } - return rooms, nil -} - -// SaveIRCLastRooms atomically replaces the remembered channel set for a saved -// IRC profile. Empty names and case-insensitive duplicates are discarded. -func (s *Store) SaveIRCLastRooms(profileID string, rooms []string) error { - profileID = strings.TrimSpace(profileID) - if profileID == "" { - return fmt.Errorf("storage: IRC channel state requires a profile ID") - } - - normalized := make([]string, 0, len(rooms)) - seen := make(map[string]struct{}, len(rooms)) - for _, room := range rooms { - room = strings.TrimSpace(room) - if room == "" { - continue - } - key := strings.ToLower(room) - if _, exists := seen[key]; exists { - continue - } - seen[key] = struct{}{} - normalized = append(normalized, room) - } - - tx, err := s.db.Begin() - if err != nil { - return fmt.Errorf("storage: failed to begin IRC channel state update: %w", err) - } - defer func() { _ = tx.Rollback() }() - - if _, err := tx.Exec("DELETE FROM irc_channel_state WHERE profile_id = ?", profileID); err != nil { - return fmt.Errorf("storage: failed to clear IRC channel state: %w", err) - } - now := time.Now().Format(time.RFC3339) - for position, room := range normalized { - if _, err := tx.Exec(`INSERT INTO irc_channel_state - (profile_id, room_name, position, updated_at) VALUES (?, ?, ?, ?)`, - profileID, room, position, now); err != nil { - return fmt.Errorf("storage: failed to save IRC channel state: %w", err) - } - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("storage: failed to commit IRC channel state: %w", err) - } - return nil -} - -// --------------------------------------------------------------------------- -// Preferences (key-value) -// --------------------------------------------------------------------------- - -// GetPreferences reads the stored preferences JSON blob and deserialises it -// into an AppPreferences struct. Returns nil if no preferences have been saved. -func (s *Store) GetPreferences() (*models.AppPreferences, error) { - var value string - err := s.db.QueryRow("SELECT value FROM preferences WHERE key = ?", "_all").Scan(&value) - if err != nil { - // No preferences saved yet — return nil to signal defaults should be used. - return nil, nil - } - - prefs := &models.AppPreferences{} - if err := json.Unmarshal([]byte(value), prefs); err != nil { - return nil, fmt.Errorf("storage: failed to unmarshal preferences: %w", err) - } - return prefs, nil -} - -// SavePreferences serialises the AppPreferences as a JSON blob and persists -// it to the preferences table under a single key. -func (s *Store) SavePreferences(prefs *models.AppPreferences) error { - data, err := json.Marshal(prefs) - if err != nil { - return fmt.Errorf("storage: failed to marshal preferences: %w", err) - } - - _, err = s.db.Exec(`INSERT INTO preferences (key, value) VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value`, "_all", string(data)) - if err != nil { - return fmt.Errorf("storage: failed to save preferences: %w", err) - } - return nil -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -// scanProfile reads a single profile row from the given scanner. -func scanProfile(row *sql.Row) (*models.Profile, error) { - p := &models.Profile{} - var ( - protocol, tagsJSON string - sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString - ircJSON sql.NullString - lastConn sql.NullString - favourite int - createdAt, updatedAt string - ) - - err := row.Scan( - &p.ID, &p.DisplayName, &p.Description, &protocol, - &p.Host, &p.Port, &p.Username, - &tagsJSON, &p.Folder, &p.Environment, &p.Notes, - &favourite, &lastConn, - &p.ConnectTimeout, &p.ReadTimeout, - &p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle, - &sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON, &ircJSON, - &createdAt, &updatedAt, - ) - if err != nil { - return nil, err - } - - p.Protocol = models.Protocol(protocol) - p.Favourite = favourite == 1 - p.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) - p.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) - if lastConn.Valid && lastConn.String != "" { - t, err := time.Parse(time.RFC3339, lastConn.String) - if err == nil { - p.LastConnected = &t - } - } - - _ = json.Unmarshal([]byte(tagsJSON), &p.Tags) - if p.Tags == nil { - p.Tags = []string{} - } - - if sshJSON.Valid && sshJSON.String != "" { - p.SSH = &models.SSHConfig{} - _ = json.Unmarshal([]byte(sshJSON.String), p.SSH) - } - if telnetJSON.Valid && telnetJSON.String != "" { - p.Telnet = &models.TelnetConfig{} - _ = json.Unmarshal([]byte(telnetJSON.String), p.Telnet) - } - if rawJSON.Valid && rawJSON.String != "" { - p.RAW = &models.RAWConfig{} - _ = json.Unmarshal([]byte(rawJSON.String), p.RAW) - } - if githubJSON.Valid && githubJSON.String != "" { - p.GitHub = &models.GitHubConfig{} - _ = json.Unmarshal([]byte(githubJSON.String), p.GitHub) - } - if vncJSON.Valid && vncJSON.String != "" { - p.VNC = &models.VNCConfig{} - _ = json.Unmarshal([]byte(vncJSON.String), p.VNC) - } - if ircJSON.Valid && ircJSON.String != "" { - p.IRC = &models.IRCConfig{} - _ = json.Unmarshal([]byte(ircJSON.String), p.IRC) - } - - return p, nil -} - -// scanProfiles iterates over rows and returns a slice of profiles. -func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) { - var result []*models.Profile - for rows.Next() { - p := &models.Profile{} - var ( - protocol, tagsJSON string - sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString - ircJSON sql.NullString - lastConn sql.NullString - favourite int - createdAt, updatedAt string - ) - - err := rows.Scan( - &p.ID, &p.DisplayName, &p.Description, &protocol, - &p.Host, &p.Port, &p.Username, - &tagsJSON, &p.Folder, &p.Environment, &p.Notes, - &favourite, &lastConn, - &p.ConnectTimeout, &p.ReadTimeout, - &p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle, - &sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON, &ircJSON, - &createdAt, &updatedAt, - ) - if err != nil { - return nil, fmt.Errorf("storage: failed to scan profile row: %w", err) - } - - p.Protocol = models.Protocol(protocol) - p.Favourite = favourite == 1 - p.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) - p.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) - if lastConn.Valid && lastConn.String != "" { - t, err := time.Parse(time.RFC3339, lastConn.String) - if err == nil { - p.LastConnected = &t - } - } - - _ = json.Unmarshal([]byte(tagsJSON), &p.Tags) - if p.Tags == nil { - p.Tags = []string{} - } - - if sshJSON.Valid && sshJSON.String != "" { - p.SSH = &models.SSHConfig{} - _ = json.Unmarshal([]byte(sshJSON.String), p.SSH) - } - if telnetJSON.Valid && telnetJSON.String != "" { - p.Telnet = &models.TelnetConfig{} - _ = json.Unmarshal([]byte(telnetJSON.String), p.Telnet) - } - if rawJSON.Valid && rawJSON.String != "" { - p.RAW = &models.RAWConfig{} - _ = json.Unmarshal([]byte(rawJSON.String), p.RAW) - } - if githubJSON.Valid && githubJSON.String != "" { - p.GitHub = &models.GitHubConfig{} - _ = json.Unmarshal([]byte(githubJSON.String), p.GitHub) - } - if vncJSON.Valid && vncJSON.String != "" { - p.VNC = &models.VNCConfig{} - _ = json.Unmarshal([]byte(vncJSON.String), p.VNC) - } - if ircJSON.Valid && ircJSON.String != "" { - p.IRC = &models.IRCConfig{} - _ = json.Unmarshal([]byte(ircJSON.String), p.IRC) - } - - result = append(result, p) - } - return result, rows.Err() -} - -func boolToInt(b bool) int { - if b { - return 1 - } - return 0 -} - -func timeToStr(t *time.Time) interface{} { - if t == nil { - return nil - } - return t.Format(time.RFC3339) -} - -// nullableJSON returns nil when the source object pointer is nil, -// otherwise the marshalled JSON bytes as a string. -func nullableJSON(ptr interface{}, data []byte) interface{} { - if ptr == nil || string(data) == "null" { - return nil - } - return string(data) -} - -func redactProfileSecrets(profiles []*models.Profile) []*models.Profile { - redacted := make([]*models.Profile, len(profiles)) - for i, profile := range profiles { - cp := *profile - if cp.SSH != nil { - sshCfg := *cp.SSH - sshCfg.ProxyPassword = "" - sshCfg.TOTPSecret = "" - cp.SSH = &sshCfg - } - redacted[i] = &cp - } - return redacted -} - -// --------------------------------------------------------------------------- -// Snippet CRUD -// --------------------------------------------------------------------------- - -// CreateSnippet inserts a new snippet into the database. -func (s *Store) CreateSnippet(snippet *models.Snippet) error { - tagsJSON, _ := json.Marshal(snippet.Tags) - _, err := s.db.Exec(`INSERT INTO snippets (id, name, command, description, folder, tags, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - snippet.ID, snippet.Name, snippet.Command, snippet.Description, - snippet.Folder, string(tagsJSON), - snippet.CreatedAt.Format(time.RFC3339), - snippet.UpdatedAt.Format(time.RFC3339), - ) - return err -} - -// UpdateSnippet updates an existing snippet. -func (s *Store) UpdateSnippet(snippet *models.Snippet) error { - tagsJSON, _ := json.Marshal(snippet.Tags) - _, err := s.db.Exec(`UPDATE snippets SET name=?, command=?, description=?, folder=?, tags=?, updated_at=? WHERE id=?`, - snippet.Name, snippet.Command, snippet.Description, - snippet.Folder, string(tagsJSON), - snippet.UpdatedAt.Format(time.RFC3339), - snippet.ID, - ) - return err -} - -// DeleteSnippet removes a snippet by ID. -func (s *Store) DeleteSnippet(id string) error { - _, err := s.db.Exec(`DELETE FROM snippets WHERE id = ?`, id) - return err -} - -// ListSnippets returns all snippets ordered by name. -func (s *Store) ListSnippets() ([]*models.Snippet, error) { - rows, err := s.db.Query(`SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets ORDER BY name ASC`) - if err != nil { - return nil, err - } - defer rows.Close() - - var snippets []*models.Snippet - for rows.Next() { - sn := &models.Snippet{} - var tagsStr, createdAt, updatedAt string - if err := rows.Scan(&sn.ID, &sn.Name, &sn.Command, &sn.Description, &sn.Folder, &tagsStr, &createdAt, &updatedAt); err != nil { - return nil, err - } - _ = json.Unmarshal([]byte(tagsStr), &sn.Tags) - if sn.Tags == nil { - sn.Tags = []string{} - } - sn.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) - sn.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) - snippets = append(snippets, sn) - } - return snippets, rows.Err() -} - -// GetSnippet returns a single snippet by ID. -func (s *Store) GetSnippet(id string) (*models.Snippet, error) { - sn := &models.Snippet{} - var tagsStr, createdAt, updatedAt string - err := s.db.QueryRow(`SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets WHERE id = ?`, id). - Scan(&sn.ID, &sn.Name, &sn.Command, &sn.Description, &sn.Folder, &tagsStr, &createdAt, &updatedAt) - if err != nil { - return nil, err - } - _ = json.Unmarshal([]byte(tagsStr), &sn.Tags) - if sn.Tags == nil { - sn.Tags = []string{} - } - sn.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) - sn.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) - return sn, nil -} diff --git a/internal/telnetclient/telnetclient.go b/internal/telnetclient/telnetclient.go deleted file mode 100644 index 546313e..0000000 --- a/internal/telnetclient/telnetclient.go +++ /dev/null @@ -1,603 +0,0 @@ -package telnetclient - -import ( - "context" - "encoding/binary" - "fmt" - "net" - "strings" - "sync" - "time" - - "github.com/andydixon/link/internal/models" - "golang.org/x/text/encoding/charmap" -) - -// Telnet command bytes (RFC 854/855). -const ( - IAC byte = 255 // Interpret As Command - DONT byte = 254 - DO byte = 253 - WONT byte = 252 - WILL byte = 251 - SB byte = 250 // Sub-negotiation Begin - SE byte = 240 // Sub-negotiation End - - // Common options. - OptEcho byte = 1 - OptSGA byte = 3 // Suppress Go Ahead - OptTermType byte = 24 // Terminal Type - OptNAWS byte = 31 // Negotiate About Window Size - OptLineMode byte = 34 -) - -// Sub-negotiation constants. -const ( - subIS byte = 0 // IS (used in terminal-type sub-negotiation) - subSEND byte = 1 // SEND (server requests terminal type) -) - -// Client manages a single Telnet session. -// It handles Telnet option negotiation (RFC 854/855), terminal-type -// sub-negotiation, window-size negotiation (NAWS), and data framing. -type Client struct { - mu sync.Mutex - config *models.Profile - conn net.Conn - connected bool - onData func(data []byte) - onError func(err error) - onClose func() - onDebug func(msg string) - ctx context.Context - cancel context.CancelFunc - localEcho bool - encoding string - cols int - rows int - - // Track which options we have agreed to support. - willOptions map[byte]bool - doOptions map[byte]bool -} - -// NewClient creates a new Telnet client bound to the given profile. -func NewClient(profile *models.Profile) *Client { - ctx, cancel := context.WithCancel(context.Background()) - - localEcho := false - enc := "utf-8" - if profile.Telnet != nil { - localEcho = profile.Telnet.LocalEcho - if profile.Telnet.Encoding != "" { - enc = profile.Telnet.Encoding - } - } - - return &Client{ - config: profile, - ctx: ctx, - cancel: cancel, - localEcho: localEcho, - encoding: enc, - cols: 80, - rows: 24, - willOptions: make(map[byte]bool), - doOptions: make(map[byte]bool), - } -} - -// SetCallbacks sets the data, error, close, and debug callbacks. -// Must be called before Connect. -func (c *Client) SetCallbacks(onData func([]byte), onError func(error), onClose func(), onDebug func(string)) { - c.mu.Lock() - defer c.mu.Unlock() - c.onData = onData - c.onError = onError - c.onClose = onClose - c.onDebug = onDebug -} - -// telnetConf returns the Telnet-specific configuration, or a zero value -// if nil. -func (c *Client) telnetConf() models.TelnetConfig { - if c.config.Telnet != nil { - return *c.config.Telnet - } - return models.TelnetConfig{} -} - -// Connect establishes the Telnet connection and starts the read loop. -func (c *Client) Connect() error { - c.mu.Lock() - if c.connected { - c.mu.Unlock() - return fmt.Errorf("already connected") - } - c.mu.Unlock() - - timeout := time.Duration(c.config.ConnectTimeout) * time.Second - if timeout == 0 { - timeout = 30 * time.Second - } - - port := c.config.Port - if port == 0 { - port = 23 - } - addr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", port)) - - conn, err := net.DialTimeout("tcp", addr, timeout) - if err != nil { - return fmt.Errorf("Telnet connection to %s failed: %w", addr, err) - } - - c.mu.Lock() - c.conn = conn - c.connected = true - c.mu.Unlock() - - go c.readLoop() - return nil -} - -// Write sends data to the Telnet session, applying line-ending conversion -// and escaping any literal IAC bytes in the payload. -func (c *Client) Write(data []byte) error { - c.mu.Lock() - if !c.connected || c.conn == nil { - c.mu.Unlock() - return fmt.Errorf("not connected") - } - conn := c.conn - c.mu.Unlock() - - converted := c.convertLineEnding(data) - escaped := c.escapeIAC(converted) - encoded := c.encodeOutgoing(escaped) - - _, err := conn.Write(encoded) - return err -} - -// Resize sends a NAWS (Negotiate About Window Size) update to the remote host. -func (c *Client) Resize(cols, rows int) error { - c.mu.Lock() - if !c.connected || c.conn == nil { - c.mu.Unlock() - return fmt.Errorf("not connected") - } - c.cols = cols - c.rows = rows - conn := c.conn - c.mu.Unlock() - - return c.sendNAWS(conn, cols, rows) -} - -// Close shuts down the Telnet connection and invokes the onClose callback. -func (c *Client) Close() error { - c.mu.Lock() - if !c.connected { - c.mu.Unlock() - return nil - } - c.connected = false - conn := c.conn - onClose := c.onClose - c.mu.Unlock() - - c.cancel() - - var err error - if conn != nil { - err = conn.Close() - } - if onClose != nil { - onClose() - } - return err -} - -// IsConnected returns the connection state. -func (c *Client) IsConnected() bool { - c.mu.Lock() - defer c.mu.Unlock() - return c.connected -} - -// SendBreak transmits a Telnet break signal (IAC BRK). -func (c *Client) SendBreak() error { - c.mu.Lock() - if !c.connected || c.conn == nil { - c.mu.Unlock() - return fmt.Errorf("not connected") - } - conn := c.conn - c.mu.Unlock() - - _, err := conn.Write([]byte{IAC, 243}) // 243 = BRK - return err -} - -// readLoop reads from the connection, processes IAC sequences, and dispatches -// plain data to the onData callback. -func (c *Client) readLoop() { - buf := make([]byte, 4096) - var dataBuf []byte - - for { - select { - case <-c.ctx.Done(): - return - default: - } - - c.mu.Lock() - conn := c.conn - connected := c.connected - c.mu.Unlock() - - if !connected || conn == nil { - return - } - - n, err := conn.Read(buf) - if n > 0 { - raw := buf[:n] - dataBuf = dataBuf[:0] - - i := 0 - for i < len(raw) { - if raw[i] == IAC { - if len(dataBuf) > 0 { - c.dispatchData(dataBuf) - dataBuf = dataBuf[:0] - } - consumed := c.handleIAC(raw[i:]) - if consumed == 0 { - break - } - i += consumed - } else { - dataBuf = append(dataBuf, raw[i]) - i++ - } - } - - if len(dataBuf) > 0 { - c.dispatchData(dataBuf) - } - } - - if err != nil { - c.mu.Lock() - onError := c.onError - c.mu.Unlock() - if onError != nil { - onError(fmt.Errorf("read error: %w", err)) - } - c.Close() - return - } - } -} - -// handleIAC processes a Telnet IAC sequence starting at data[0]. -// Returns the number of bytes consumed. -func (c *Client) handleIAC(data []byte) int { - if len(data) < 2 { - return 0 - } - - // IAC IAC = escaped 0xFF byte. - if data[1] == IAC { - c.dispatchData([]byte{IAC}) - return 2 - } - - switch data[1] { - case DO: - if len(data) < 3 { - return 0 - } - c.handleDO(data[2]) - return 3 - - case DONT: - if len(data) < 3 { - return 0 - } - c.handleDONT(data[2]) - return 3 - - case WILL: - if len(data) < 3 { - return 0 - } - c.handleWILL(data[2]) - return 3 - - case WONT: - if len(data) < 3 { - return 0 - } - c.handleWONT(data[2]) - return 3 - - case SB: - return c.handleSubnegotiation(data) - - default: - c.debug("received IAC command: %d", data[1]) - return 2 - } -} - -// handleDO processes a DO request from the server. -func (c *Client) handleDO(option byte) { - c.debug("received DO %d", option) - - switch option { - case OptTermType: - c.negotiate(WILL, option) - c.mu.Lock() - c.willOptions[option] = true - c.mu.Unlock() - - case OptNAWS: - c.negotiate(WILL, option) - c.mu.Lock() - c.willOptions[option] = true - cols := c.cols - rows := c.rows - conn := c.conn - c.mu.Unlock() - if conn != nil { - _ = c.sendNAWS(conn, cols, rows) - } - - case OptSGA: - c.negotiate(WILL, option) - c.mu.Lock() - c.willOptions[option] = true - c.mu.Unlock() - - default: - c.negotiate(WONT, option) - } -} - -// handleDONT processes a DONT request from the server. -func (c *Client) handleDONT(option byte) { - c.debug("received DONT %d", option) - c.mu.Lock() - delete(c.willOptions, option) - c.mu.Unlock() - c.negotiate(WONT, option) -} - -// handleWILL processes a WILL offer from the server. -func (c *Client) handleWILL(option byte) { - c.debug("received WILL %d", option) - - switch option { - case OptEcho: - c.negotiate(DO, option) - c.mu.Lock() - c.doOptions[option] = true - c.localEcho = false - c.mu.Unlock() - - case OptSGA: - c.negotiate(DO, option) - c.mu.Lock() - c.doOptions[option] = true - c.mu.Unlock() - - default: - c.negotiate(DONT, option) - } -} - -// handleWONT processes a WONT refusal from the server. -func (c *Client) handleWONT(option byte) { - c.debug("received WONT %d", option) - - if option == OptEcho { - c.mu.Lock() - c.localEcho = true - c.mu.Unlock() - } - - c.mu.Lock() - delete(c.doOptions, option) - c.mu.Unlock() - c.negotiate(DONT, option) -} - -// handleSubnegotiation processes an SB...SE sub-negotiation block. -// Returns the total number of bytes consumed (including IAC SB and IAC SE). -func (c *Client) handleSubnegotiation(data []byte) int { - for i := 2; i < len(data)-1; i++ { - if data[i] == IAC && data[i+1] == SE { - payload := data[2:i] - c.processSubnegotiation(payload) - return i + 2 - } - } - return 0 -} - -// processSubnegotiation handles the payload between SB and SE. -func (c *Client) processSubnegotiation(payload []byte) { - if len(payload) < 1 { - return - } - - option := payload[0] - c.debug("sub-negotiation for option %d", option) - - switch option { - case OptTermType: - if len(payload) >= 2 && payload[1] == subSEND { - c.sendTermType() - } - } -} - -// sendTermType responds to a terminal-type SEND request with an IS response. -func (c *Client) sendTermType() { - tc := c.telnetConf() - termType := tc.TerminalType - if termType == "" { - termType = "xterm-256color" - } - - c.debug("sending terminal type: %s", termType) - - resp := []byte{IAC, SB, OptTermType, subIS} - resp = append(resp, []byte(strings.ToUpper(termType))...) - resp = append(resp, IAC, SE) - - c.mu.Lock() - conn := c.conn - c.mu.Unlock() - if conn != nil { - _, _ = conn.Write(resp) - } -} - -// negotiate sends a WILL/WONT/DO/DONT response for the given option. -func (c *Client) negotiate(command, option byte) { - c.debug("sending %s %d", commandName(command), option) - - c.mu.Lock() - conn := c.conn - c.mu.Unlock() - if conn != nil { - _, _ = conn.Write([]byte{IAC, command, option}) - } -} - -// sendNAWS sends the current window size via NAWS sub-negotiation. -// Columns and rows are each sent as big-endian uint16 values. -func (c *Client) sendNAWS(conn net.Conn, cols, rows int) error { - c.debug("sending NAWS: %dx%d", cols, rows) - - var buf [9]byte - buf[0] = IAC - buf[1] = SB - buf[2] = OptNAWS - binary.BigEndian.PutUint16(buf[3:5], uint16(cols)) - binary.BigEndian.PutUint16(buf[5:7], uint16(rows)) - buf[7] = IAC - buf[8] = SE - - _, err := conn.Write(buf[:]) - return err -} - -// convertLineEnding applies the configured line-ending conversion on -// outgoing data. -func (c *Client) convertLineEnding(data []byte) []byte { - tc := c.telnetConf() - le := tc.LineEnding - if le == "" { - le = models.LineEndingCRLF - } - if le == models.LineEndingNone { - return data - } - - s := string(data) - - // Normalise all line endings to LF first. - s = strings.ReplaceAll(s, "\r\n", "\n") - s = strings.ReplaceAll(s, "\r", "\n") - - switch le { - case models.LineEndingCR: - s = strings.ReplaceAll(s, "\n", "\r") - case models.LineEndingCRLF: - s = strings.ReplaceAll(s, "\n", "\r\n") - case models.LineEndingLF: - // Already LF. - } - return []byte(s) -} - -// escapeIAC doubles any literal 0xFF bytes in the data so they are not -// misinterpreted as IAC commands. -func (c *Client) escapeIAC(data []byte) []byte { - count := 0 - for _, b := range data { - if b == IAC { - count++ - } - } - if count == 0 { - return data - } - escaped := make([]byte, 0, len(data)+count) - for _, b := range data { - escaped = append(escaped, b) - if b == IAC { - escaped = append(escaped, IAC) - } - } - return escaped -} - -// encodeOutgoing converts outgoing text from UTF-8 to the configured encoding. -func (c *Client) encodeOutgoing(data []byte) []byte { - switch strings.ToLower(c.encoding) { - case "iso-8859-1", "latin1", "latin-1": - encoded, err := charmap.ISO8859_1.NewEncoder().Bytes(data) - if err != nil { - return data - } - return encoded - default: - return data - } -} - -// dispatchData sends received data to the onData callback. -func (c *Client) dispatchData(data []byte) { - out := make([]byte, len(data)) - copy(out, data) - - c.mu.Lock() - cb := c.onData - c.mu.Unlock() - if cb != nil { - cb(out) - } -} - -// debug emits a formatted debug message if a debug callback is registered. -func (c *Client) debug(format string, args ...interface{}) { - c.mu.Lock() - cb := c.onDebug - c.mu.Unlock() - if cb != nil { - cb(fmt.Sprintf(format, args...)) - } -} - -// commandName returns a human-readable name for a Telnet negotiation command. -func commandName(cmd byte) string { - switch cmd { - case WILL: - return "WILL" - case WONT: - return "WONT" - case DO: - return "DO" - case DONT: - return "DONT" - default: - return fmt.Sprintf("CMD(%d)", cmd) - } -} diff --git a/internal/terminalenv/terminalenv.go b/internal/terminalenv/terminalenv.go deleted file mode 100644 index 8a1f2b2..0000000 --- a/internal/terminalenv/terminalenv.go +++ /dev/null @@ -1,137 +0,0 @@ -// Package terminalenv prepares environment variables for terminal sessions. -package terminalenv - -import ( - "runtime" - "sort" - "strings" -) - -const ( - envLANG = "LANG" - envLCAll = "LC_ALL" - envLCType = "LC_CTYPE" - envTerm = "TERM" - defaultTerm = "xterm-256color" -) - -// LocalShellEnv returns an environment suitable for a local interactive PTY. -func LocalShellEnv(env []string) []string { - env = setEnv(env, envTerm, defaultTerm) - return ensureUTF8CharacterLocale(env, runtime.GOOS) -} - -// SSHEnvVars returns environment variables to request for an SSH session. -// Profile locale values are preserved; otherwise a UTF-8 character locale is -// requested by default. -func SSHEnvVars(profileEnv map[string]string) map[string]string { - result := map[string]string{} - for key, value := range profileEnv { - key = strings.TrimSpace(key) - if key == "" { - continue - } - result[key] = value - } - - if hasAnyLocaleMap(result) { - return result - } - result[envLCType] = DefaultUTF8Locale(runtime.GOOS) - return result -} - -// SortedEnvKeys returns stable keys for deterministic environment requests. -func SortedEnvKeys(env map[string]string) []string { - keys := make([]string, 0, len(env)) - for key := range env { - keys = append(keys, key) - } - sort.Strings(keys) - return keys -} - -// DefaultUTF8Locale returns a locale name broadly available on the target OS. -func DefaultUTF8Locale(goos string) string { - if goos == "darwin" { - return "en_US.UTF-8" - } - return "C.UTF-8" -} - -func ensureUTF8CharacterLocale(env []string, goos string) []string { - if hasEffectiveUTF8Locale(env) { - return env - } - - locale := DefaultUTF8Locale(goos) - if hasEnv(env, envLCAll) { - return setEnv(env, envLCAll, locale) - } - if hasEnv(env, envLCType) { - return setEnv(env, envLCType, locale) - } - if hasEnv(env, envLANG) { - return setEnv(env, envLCType, locale) - } - return setEnv(env, envLANG, locale) -} - -func hasEffectiveUTF8Locale(env []string) bool { - values := envToMap(env) - if value, ok := values[envLCAll]; ok { - return isUTF8Locale(value) - } - if value, ok := values[envLCType]; ok { - return isUTF8Locale(value) - } - if value, ok := values[envLANG]; ok { - return isUTF8Locale(value) - } - return false -} - -func hasAnyLocaleMap(env map[string]string) bool { - _, hasLCAll := env[envLCAll] - _, hasLCType := env[envLCType] - _, hasLANG := env[envLANG] - return hasLCAll || hasLCType || hasLANG -} - -func isUTF8Locale(value string) bool { - normalized := strings.ToLower(strings.ReplaceAll(value, "-", "")) - return strings.Contains(normalized, "utf8") -} - -func setEnv(env []string, key, value string) []string { - prefix := key + "=" - result := make([]string, 0, len(env)+1) - for _, item := range env { - if strings.HasPrefix(item, prefix) { - continue - } - result = append(result, item) - } - return append(result, prefix+value) -} - -func hasEnv(env []string, key string) bool { - prefix := key + "=" - for _, item := range env { - if strings.HasPrefix(item, prefix) { - return true - } - } - return false -} - -func envToMap(env []string) map[string]string { - result := map[string]string{} - for _, item := range env { - key, value, ok := strings.Cut(item, "=") - if ok { - result[key] = value - } - } - return result -} diff --git a/internal/terminalenv/terminalenv_test.go b/internal/terminalenv/terminalenv_test.go deleted file mode 100644 index 1c3b90c..0000000 --- a/internal/terminalenv/terminalenv_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package terminalenv - -import "testing" - -func TestLocalShellEnvSetsTermAndUTF8WhenMissing(t *testing.T) { - got := LocalShellEnv([]string{"SHELL=/bin/zsh"}) - - if value := envValue(got, envTerm); value != defaultTerm { - t.Fatalf("TERM = %q, want %q", value, defaultTerm) - } - if value := envValue(got, envLANG); value == "" || !isUTF8Locale(value) { - t.Fatalf("LANG = %q, want UTF-8 locale", value) - } -} - -func TestLocalShellEnvUsesLCTypeWhenLangIsNonUTF8(t *testing.T) { - got := ensureUTF8CharacterLocale([]string{"LANG=C"}, "linux") - - if value := envValue(got, envLANG); value != "C" { - t.Fatalf("LANG = %q, want C", value) - } - if value := envValue(got, envLCType); value != "C.UTF-8" { - t.Fatalf("LC_CTYPE = %q, want C.UTF-8", value) - } -} - -func TestLocalShellEnvOverridesNonUTF8LCAll(t *testing.T) { - got := ensureUTF8CharacterLocale([]string{"LC_ALL=C", "LANG=C"}, "darwin") - - if value := envValue(got, envLCAll); value != "en_US.UTF-8" { - t.Fatalf("LC_ALL = %q, want en_US.UTF-8", value) - } -} - -func TestSSHEnvVarsPreservesProfileUTF8Locale(t *testing.T) { - got := SSHEnvVars(map[string]string{ - "LANG": "de_DE.UTF-8", - "FOO": "bar", - }) - - if got["LANG"] != "de_DE.UTF-8" { - t.Fatalf("LANG = %q, want profile value", got["LANG"]) - } - if got["FOO"] != "bar" { - t.Fatalf("FOO = %q, want bar", got["FOO"]) - } - if _, ok := got[envLCType]; ok { - t.Fatalf("unexpected LC_CTYPE default when LANG is already UTF-8") - } -} - -func TestSSHEnvVarsPreservesProfileNonUTF8Locale(t *testing.T) { - got := SSHEnvVars(map[string]string{ - envLANG: "C", - "FOO": "bar", - }) - - if got[envLANG] != "C" { - t.Fatalf("LANG = %q, want C", got[envLANG]) - } - if _, ok := got[envLCType]; ok { - t.Fatalf("unexpected LC_CTYPE default when profile sets locale") - } -} - -func TestSSHEnvVarsAddsUTF8LCTypeByDefault(t *testing.T) { - got := SSHEnvVars(map[string]string{"FOO": "bar"}) - - if got["FOO"] != "bar" { - t.Fatalf("FOO = %q, want bar", got["FOO"]) - } - if value := got[envLCType]; value == "" || !isUTF8Locale(value) { - t.Fatalf("LC_CTYPE = %q, want UTF-8 locale", value) - } -} - -func envValue(env []string, key string) string { - prefix := key + "=" - for _, item := range env { - if len(item) >= len(prefix) && item[:len(prefix)] == prefix { - return item[len(prefix):] - } - } - return "" -} diff --git a/internal/updater/updater.go b/internal/updater/updater.go deleted file mode 100644 index 3b3af18..0000000 --- a/internal/updater/updater.go +++ /dev/null @@ -1,303 +0,0 @@ -// Package updater checks for new application releases by querying the -// GitHub Releases API. It compares the latest published tag against the -// current build version and reports whether an update is available. -// -// The check is non-blocking and opt-in — it runs in the background and -// does not download or install anything automatically. -package updater - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -const ( - // defaultOwner and defaultRepo identify the GitHub repository to check. - // These should be overridden via SetRepository if the project is hosted - // elsewhere. - defaultOwner = "alchementary-link" - defaultRepo = "alchementary-link" - - // requestTimeout limits the HTTP request duration. - requestTimeout = 10 * time.Second - - // maxUpdateResponseBytes bounds release and manifest responses before - // parsing them into memory. - maxUpdateResponseBytes = 1 << 20 -) - -// Release describes a GitHub release with the fields we care about. -type Release struct { - TagName string `json:"tag_name"` - Name string `json:"name"` - HTMLURL string `json:"html_url"` - PublishedAt string `json:"published_at"` - Body string `json:"body"` - Prerelease bool `json:"prerelease"` - Draft bool `json:"draft"` -} - -// UpdateInfo carries the result of an update check. -type UpdateInfo struct { - Available bool `json:"available"` - CurrentVersion string `json:"currentVersion"` - LatestVersion string `json:"latestVersion"` - ReleaseURL string `json:"releaseURL"` - ReleaseName string `json:"releaseName"` - ReleaseNotes string `json:"releaseNotes"` - PublishedAt string `json:"publishedAt"` -} - -// Checker performs version checks against GitHub Releases. -type Checker struct { - owner string - repo string - current string - client *http.Client -} - -// Manifest describes a self-hosted update manifest. -type Manifest struct { - LatestVersion string `json:"latestVersion"` - Version string `json:"version"` - ReleaseURL string `json:"releaseURL"` - ReleaseName string `json:"releaseName"` - ReleaseNotes string `json:"releaseNotes"` - PublishedAt string `json:"publishedAt"` -} - -// URLChecker performs version checks against a self-hosted update manifest. -type URLChecker struct { - url string - current string - client *http.Client -} - -// NewChecker creates an update checker for the given current version. -// The version string should be a semver tag (e.g. "v1.2.3" or "1.2.3"). -func NewChecker(currentVersion string) *Checker { - return &Checker{ - owner: defaultOwner, - repo: defaultRepo, - current: normaliseVersion(currentVersion), - client: &http.Client{ - Timeout: requestTimeout, - }, - } -} - -// NewURLChecker creates a checker for a self-hosted update manifest URL. -func NewURLChecker(url string, currentVersion string) *URLChecker { - return &URLChecker{ - url: url, - current: normaliseVersion(currentVersion), - client: &http.Client{ - Timeout: requestTimeout, - }, - } -} - -// SetRepository overrides the GitHub repository to check. -func (c *Checker) SetRepository(owner, repo string) { - c.owner = owner - c.repo = repo -} - -// Check queries the GitHub API for the latest release and compares it to -// the current version. Returns UpdateInfo describing the result. -func (c *Checker) Check(ctx context.Context) (*UpdateInfo, error) { - url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", c.owner, c.repo) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("updater: failed to create request: %w", err) - } - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("User-Agent", "AlchementaryLink-UpdateChecker") - - resp, err := c.client.Do(req) - if err != nil { - return nil, fmt.Errorf("updater: request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - // No releases exist yet — not an error. - return &UpdateInfo{ - Available: false, - CurrentVersion: c.current, - LatestVersion: c.current, - }, nil - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("updater: GitHub API returned %d", resp.StatusCode) - } - - body, err := readLimitedUpdateBody(resp.Body) - if err != nil { - return nil, fmt.Errorf("updater: failed to read response: %w", err) - } - - var release Release - if err := json.Unmarshal(body, &release); err != nil { - return nil, fmt.Errorf("updater: failed to decode response: %w", err) - } - - // Skip drafts and pre-releases. - if release.Draft || release.Prerelease { - return &UpdateInfo{ - Available: false, - CurrentVersion: c.current, - LatestVersion: normaliseVersion(release.TagName), - }, nil - } - - latest := normaliseVersion(release.TagName) - available := isNewer(latest, c.current) - - return &UpdateInfo{ - Available: available, - CurrentVersion: c.current, - LatestVersion: latest, - ReleaseURL: release.HTMLURL, - ReleaseName: release.Name, - ReleaseNotes: truncateNotes(release.Body, 500), - PublishedAt: release.PublishedAt, - }, nil -} - -// Check queries the update manifest URL and compares it to the current version. -func (c *URLChecker) Check(ctx context.Context) (*UpdateInfo, error) { - if c.url == "" { - return nil, fmt.Errorf("updater: update manifest URL is empty") - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil) - if err != nil { - return nil, fmt.Errorf("updater: failed to create request: %w", err) - } - req.Header.Set("Accept", "application/json, text/plain") - req.Header.Set("User-Agent", "AlchementaryLink-UpdateChecker") - - resp, err := c.client.Do(req) - if err != nil { - return nil, fmt.Errorf("updater: request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("updater: update server returned %d", resp.StatusCode) - } - - body, err := readLimitedUpdateBody(resp.Body) - if err != nil { - return nil, fmt.Errorf("updater: failed to read response: %w", err) - } - - info, ok := parseManifest(body, c.current) - if ok { - return info, nil - } - - latest := normaliseVersion(strings.TrimSpace(string(body))) - if latest == "" { - return nil, fmt.Errorf("updater: update manifest is empty") - } - - return &UpdateInfo{ - Available: isNewer(latest, c.current), - CurrentVersion: c.current, - LatestVersion: latest, - }, nil -} - -// normaliseVersion strips a leading "v" and trims whitespace. -func normaliseVersion(v string) string { - v = strings.TrimSpace(v) - v = strings.TrimPrefix(v, "v") - return v -} - -func readLimitedUpdateBody(r io.Reader) ([]byte, error) { - body, err := io.ReadAll(io.LimitReader(r, maxUpdateResponseBytes+1)) - if err != nil { - return nil, err - } - if len(body) > maxUpdateResponseBytes { - return nil, fmt.Errorf("response exceeds %d bytes", maxUpdateResponseBytes) - } - return body, nil -} - -func parseManifest(body []byte, current string) (*UpdateInfo, bool) { - var manifest Manifest - if err := json.Unmarshal(body, &manifest); err != nil { - return nil, false - } - - latest := manifest.LatestVersion - if latest == "" { - latest = manifest.Version - } - latest = normaliseVersion(latest) - if latest == "" { - return nil, false - } - - return &UpdateInfo{ - Available: isNewer(latest, current), - CurrentVersion: current, - LatestVersion: latest, - ReleaseURL: manifest.ReleaseURL, - ReleaseName: manifest.ReleaseName, - ReleaseNotes: manifest.ReleaseNotes, - PublishedAt: manifest.PublishedAt, - }, true -} - -// isNewer performs a simple string comparison of dot-separated version parts. -// Returns true if latest > current (basic semver, no pre-release handling). -func isNewer(latest, current string) bool { - if current == "" || current == "dev" { - return false // dev builds never trigger update prompts - } - - lParts := strings.Split(latest, ".") - cParts := strings.Split(current, ".") - - maxLen := len(lParts) - if len(cParts) > maxLen { - maxLen = len(cParts) - } - - for i := 0; i < maxLen; i++ { - var lv, cv int - if i < len(lParts) { - fmt.Sscanf(lParts[i], "%d", &lv) - } - if i < len(cParts) { - fmt.Sscanf(cParts[i], "%d", &cv) - } - if lv > cv { - return true - } - if lv < cv { - return false - } - } - return false -} - -// truncateNotes trims release notes to a maximum length. -func truncateNotes(body string, maxLen int) string { - if len(body) <= maxLen { - return body - } - return body[:maxLen] + "…" -} diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go deleted file mode 100644 index f26df66..0000000 --- a/internal/updater/updater_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package updater - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestNormaliseVersion(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"v1.2.3", "1.2.3"}, - {"1.2.3", "1.2.3"}, - {" v0.5.0 ", "0.5.0"}, - {"dev", "dev"}, - } - for _, tt := range tests { - got := normaliseVersion(tt.input) - if got != tt.want { - t.Errorf("normaliseVersion(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} - -func TestIsNewer(t *testing.T) { - tests := []struct { - latest string - current string - want bool - }{ - {"1.1.0", "1.0.0", true}, - {"2.0.0", "1.9.9", true}, - {"1.0.1", "1.0.0", true}, - {"1.0.0", "1.0.0", false}, - {"0.9.0", "1.0.0", false}, - {"1.0.0", "dev", false}, // dev builds ignored - {"1.0.0", "", false}, // empty current ignored - {"1.2", "1.1.0", true}, // fewer parts - {"1.0.0", "1.0.0.1", false}, - } - for _, tt := range tests { - t.Run(tt.latest+" vs "+tt.current, func(t *testing.T) { - got := isNewer(tt.latest, tt.current) - if got != tt.want { - t.Errorf("isNewer(%q, %q) = %v, want %v", tt.latest, tt.current, got, tt.want) - } - }) - } -} - -func TestTruncateNotes(t *testing.T) { - short := "Short note" - if got := truncateNotes(short, 100); got != short { - t.Errorf("expected %q, got %q", short, got) - } - - long := strings.Repeat("x", 600) - got := truncateNotes(long, 500) - if !strings.HasSuffix(got, "…") { - t.Error("expected truncated notes to end with ellipsis") - } - if len([]rune(got)) != 501 { // 500 runes + "…" - t.Errorf("expected 501 runes, got %d", len([]rune(got))) - } -} - -func TestURLCheckerManifestJSON(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"latestVersion":"1.2.3","releaseURL":"https://example.com/release"}`)) - })) - defer server.Close() - - checker := NewURLChecker(server.URL, "1.2.0") - info, err := checker.Check(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !info.Available { - t.Fatalf("expected update to be available") - } - if info.LatestVersion != "1.2.3" { - t.Fatalf("unexpected latest version: %s", info.LatestVersion) - } -} - -func TestURLCheckerPlainText(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - _, _ = w.Write([]byte("2.0.0")) - })) - defer server.Close() - - checker := NewURLChecker(server.URL, "1.9.0") - info, err := checker.Check(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !info.Available { - t.Fatalf("expected update to be available") - } -} - -func TestURLCheckerRejectsOversizedManifest(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - _, _ = w.Write([]byte(strings.Repeat("x", maxUpdateResponseBytes+1))) - })) - defer server.Close() - - checker := NewURLChecker(server.URL, "1.9.0") - _, err := checker.Check(context.Background()) - if err == nil { - t.Fatal("expected oversized manifest to be rejected") - } - if !strings.Contains(err.Error(), "response exceeds") { - t.Fatalf("unexpected error: %v", err) - } -} diff --git a/internal/validation/validation.go b/internal/validation/validation.go deleted file mode 100644 index 97d6046..0000000 --- a/internal/validation/validation.go +++ /dev/null @@ -1,518 +0,0 @@ -// Package validation provides thorough validation for connection profiles -// and their protocol-specific configurations. -package validation - -import ( - "fmt" - "net" - "net/url" - "strings" - - "github.com/andydixon/link/internal/models" - "github.com/lrstanley/girc" -) - -// ValidationError describes a single field-level validation failure. -type ValidationError struct { - Field string `json:"field"` - Message string `json:"message"` -} - -// Error satisfies the error interface. -func (e *ValidationError) Error() string { - return fmt.Sprintf("%s: %s", e.Field, e.Message) -} - -// ValidationErrors collects multiple validation failures into one error. -type ValidationErrors struct { - Errors []ValidationError `json:"errors"` -} - -// Error satisfies the error interface, returning a summary of all failures. -func (e *ValidationErrors) Error() string { - msgs := make([]string, len(e.Errors)) - for i, err := range e.Errors { - msgs[i] = err.Error() - } - return fmt.Sprintf("validation failed: %s", strings.Join(msgs, "; ")) -} - -// HasErrors reports whether any validation errors have been collected. -func (e *ValidationErrors) HasErrors() bool { - return len(e.Errors) > 0 -} - -// Add appends a validation error to the collection. -func (e *ValidationErrors) Add(field, message string) { - e.Errors = append(e.Errors, ValidationError{Field: field, Message: message}) -} - -// ValidateProfile validates all fields of a connection profile, including -// protocol-specific configuration blocks. -func ValidateProfile(p *models.Profile) error { - errs := &ValidationErrors{} - - if p == nil { - return &ValidationError{Field: "profile", Message: "profile must not be nil"} - } - - if strings.TrimSpace(p.ID) == "" { - errs.Add("id", "profile ID must not be empty") - } - if strings.TrimSpace(p.DisplayName) == "" { - errs.Add("displayName", "display name must not be empty") - } - - switch p.Protocol { - case models.ProtocolSSH: - validateProfileHostPort(errs, p.Host, p.Port) - if strings.TrimSpace(p.Username) == "" { - errs.Add("username", "username is required for SSH connections") - } else if err := ValidateUsername(p.Username); err != nil { - errs.Add("username", err.(*ValidationError).Message) - } - if p.SSH == nil { - errs.Add("ssh", "SSH configuration is required for SSH protocol") - } else if err := ValidateSSHConfig(p.SSH); err != nil { - if ve, ok := err.(*ValidationErrors); ok { - errs.Errors = append(errs.Errors, ve.Errors...) - } - } - case models.ProtocolTelnet: - validateProfileHostPort(errs, p.Host, p.Port) - if p.Telnet == nil { - errs.Add("telnet", "Telnet configuration is required for Telnet protocol") - } else if err := ValidateTelnetConfig(p.Telnet); err != nil { - if ve, ok := err.(*ValidationErrors); ok { - errs.Errors = append(errs.Errors, ve.Errors...) - } - } - case models.ProtocolRAW: - validateProfileHostPort(errs, p.Host, p.Port) - if p.RAW == nil { - errs.Add("raw", "RAW configuration is required for RAW protocol") - } else if err := ValidateRAWConfig(p.RAW); err != nil { - if ve, ok := err.(*ValidationErrors); ok { - errs.Errors = append(errs.Errors, ve.Errors...) - } - } - case models.ProtocolGitHub: - if p.GitHub == nil { - errs.Add("github", "GitHub configuration is required for GitHub protocol") - } else if err := ValidateGitHubConfig(p.GitHub); err != nil { - if ve, ok := err.(*ValidationErrors); ok { - errs.Errors = append(errs.Errors, ve.Errors...) - } - } - case models.ProtocolVNC: - if p.VNC == nil { - errs.Add("vnc", "VNC configuration is required for VNC protocol") - } else if err := ValidateVNCConfig(p.Host, p.VNC); err != nil { - if ve, ok := err.(*ValidationErrors); ok { - errs.Errors = append(errs.Errors, ve.Errors...) - } - } - case models.ProtocolIRC: - validateProfileHostPort(errs, p.Host, p.Port) - if p.IRC == nil { - errs.Add("irc", "IRC configuration is required for IRC protocol") - } else if err := ValidateIRCConfig(p.IRC); err != nil { - if ve, ok := err.(*ValidationErrors); ok { - errs.Errors = append(errs.Errors, ve.Errors...) - } - } - default: - errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol)) - } - - if p.ConnectTimeout < 0 { - errs.Add("connectTimeout", "connect timeout must not be negative") - } - if p.ReadTimeout < 0 { - errs.Add("readTimeout", "read timeout must not be negative") - } - if p.TerminalFontSize < 1 || p.TerminalFontSize > 72 { - errs.Add("terminalFontSize", "font size must be between 1 and 72") - } - if p.TerminalScrollback < 0 { - errs.Add("terminalScrollback", "scrollback must not be negative") - } - - if errs.HasErrors() { - return errs - } - return nil -} - -func validateProfileHostPort(errs *ValidationErrors, host string, port int) { - if err := ValidateHost(host); err != nil { - errs.Add("host", err.(*ValidationError).Message) - } - if err := ValidatePort(port); err != nil { - errs.Add("port", err.(*ValidationError).Message) - } -} - -// ValidateHost ensures the host string is non-empty and syntactically valid. -func ValidateHost(host string) error { - h := strings.TrimSpace(host) - if h == "" { - return &ValidationError{Field: "host", Message: "host must not be empty"} - } - // Reject hosts that contain whitespace. - if strings.ContainsAny(h, " \t\n\r") { - return &ValidationError{Field: "host", Message: "host must not contain whitespace"} - } - // Reject hosts that contain a scheme (e.g. "ssh://"). - if strings.Contains(h, "://") { - return &ValidationError{Field: "host", Message: "host must not include a URI scheme"} - } - // If it looks like an IP address, parse it properly. - if ip := net.ParseIP(h); ip == nil { - // Not a plain IP — verify it is a plausible hostname. - if strings.HasPrefix(h, "-") { - return &ValidationError{Field: "host", Message: "host must not begin with a hyphen"} - } - } - return nil -} - -// ValidatePort checks that the port number is within the valid TCP range. -func ValidatePort(port int) error { - if port < 1 || port > 65535 { - return &ValidationError{Field: "port", Message: "port must be between 1 and 65535"} - } - return nil -} - -// ValidateUsername checks that the username is non-empty and free of control characters. -func ValidateUsername(username string) error { - u := strings.TrimSpace(username) - if u == "" { - return &ValidationError{Field: "username", Message: "username must not be empty"} - } - for _, r := range u { - if r < 0x20 || r == 0x7f { - return &ValidationError{Field: "username", Message: "username must not contain control characters"} - } - } - return nil -} - -// ValidateSSHConfig validates the SSH-specific configuration block. -func ValidateSSHConfig(cfg *models.SSHConfig) error { - errs := &ValidationErrors{} - - if len(cfg.AuthMethods) == 0 { - errs.Add("ssh.authMethods", "at least one authentication method is required") - } - validAuth := map[string]bool{ - "password": true, "publickey": true, "keyboard-interactive": true, - } - for _, m := range cfg.AuthMethods { - if !validAuth[m] { - errs.Add("ssh.authMethods", fmt.Sprintf("unsupported authentication method %q", m)) - } - } - - validSecret := map[models.SecretMode]bool{ - models.SecretModeSaved: true, models.SecretModeSessionOnly: true, models.SecretModeAskEveryTime: true, - } - if !validSecret[cfg.SecretMode] { - errs.Add("ssh.secretMode", fmt.Sprintf("unsupported secret mode %q", cfg.SecretMode)) - } - if !validSecret[cfg.KeySecretMode] { - errs.Add("ssh.keySecretMode", fmt.Sprintf("unsupported key secret mode %q", cfg.KeySecretMode)) - } - - validPolicy := map[models.HostKeyPolicy]bool{ - models.HostKeyStrict: true, models.HostKeyAsk: true, - models.HostKeyAcceptNew: true, models.HostKeyInsecure: true, - } - if !validPolicy[cfg.HostKeyPolicy] { - errs.Add("ssh.hostKeyPolicy", fmt.Sprintf("unsupported host key policy %q", cfg.HostKeyPolicy)) - } - - if cfg.KeepaliveInterval < 0 { - errs.Add("ssh.keepaliveInterval", "keepalive interval must not be negative") - } - if cfg.KeepaliveCountMax < 0 { - errs.Add("ssh.keepaliveCountMax", "keepalive count max must not be negative") - } - - // Validate each jump host entry. - for i, jh := range cfg.JumpHosts { - prefix := fmt.Sprintf("ssh.jumpHosts[%d]", i) - if strings.TrimSpace(jh.Host) == "" { - errs.Add(prefix+".host", "jump host address must not be empty") - } - if jh.Port < 1 || jh.Port > 65535 { - errs.Add(prefix+".port", "jump host port must be between 1 and 65535") - } - if strings.TrimSpace(jh.Username) == "" { - errs.Add(prefix+".username", "jump host username must not be empty") - } - } - - // Validate each port-forwarding rule. - for i, pf := range cfg.PortForwards { - prefix := fmt.Sprintf("ssh.portForwards[%d]", i) - if pf.Direction != models.PortForwardLocal && pf.Direction != models.PortForwardRemote { - errs.Add(prefix+".direction", fmt.Sprintf("unsupported forwarding direction %q", pf.Direction)) - } - if pf.BindPort < 1 || pf.BindPort > 65535 { - errs.Add(prefix+".bindPort", "bind port must be between 1 and 65535") - } - if pf.DestPort < 1 || pf.DestPort > 65535 { - errs.Add(prefix+".destPort", "destination port must be between 1 and 65535") - } - if strings.TrimSpace(pf.DestHost) == "" { - errs.Add(prefix+".destHost", "destination host must not be empty") - } - } - - if errs.HasErrors() { - return errs - } - return nil -} - -// ValidateTelnetConfig validates the Telnet-specific configuration block. -func ValidateTelnetConfig(cfg *models.TelnetConfig) error { - errs := &ValidationErrors{} - - validEnding := map[models.LineEnding]bool{ - models.LineEndingLF: true, models.LineEndingCR: true, - models.LineEndingCRLF: true, models.LineEndingNone: true, - } - if !validEnding[cfg.LineEnding] { - errs.Add("telnet.lineEnding", fmt.Sprintf("unsupported line ending %q", cfg.LineEnding)) - } - - if strings.TrimSpace(cfg.Encoding) == "" { - errs.Add("telnet.encoding", "encoding must not be empty") - } - - if errs.HasErrors() { - return errs - } - return nil -} - -// ValidateRAWConfig validates the RAW TCP-specific configuration block. -func ValidateRAWConfig(cfg *models.RAWConfig) error { - errs := &ValidationErrors{} - - validEnding := map[models.LineEnding]bool{ - models.LineEndingLF: true, models.LineEndingCR: true, - models.LineEndingCRLF: true, models.LineEndingNone: true, - } - if !validEnding[cfg.LineEnding] { - errs.Add("raw.lineEnding", fmt.Sprintf("unsupported line ending %q", cfg.LineEnding)) - } - - if strings.TrimSpace(cfg.Encoding) == "" { - errs.Add("raw.encoding", "encoding must not be empty") - } - - validFormat := map[string]bool{"text": true, "hex": true, "escaped": true} - for i, preset := range cfg.PayloadPresets { - prefix := fmt.Sprintf("raw.payloadPresets[%d]", i) - if strings.TrimSpace(preset.Name) == "" { - errs.Add(prefix+".name", "payload preset name must not be empty") - } - if !validFormat[preset.Format] { - errs.Add(prefix+".format", fmt.Sprintf("unsupported payload format %q", preset.Format)) - } - } - - if errs.HasErrors() { - return errs - } - return nil -} - -// ValidateGitHubConfig validates GitHub API settings before tokens are sent. -func ValidateGitHubConfig(cfg *models.GitHubConfig) error { - errs := &ValidationErrors{} - - if cfg == nil { - return &ValidationError{Field: "github", Message: "GitHub configuration must not be nil"} - } - - u, err := url.Parse(strings.TrimSpace(cfg.BaseURL)) - if err != nil || u.Scheme == "" || u.Host == "" { - errs.Add("github.baseURL", "API base URL must be an absolute HTTPS URL") - } else { - if u.User != nil { - errs.Add("github.baseURL", "API base URL must not include credentials") - } - if u.Scheme != "https" && !(u.Scheme == "http" && isLoopbackHost(u.Hostname())) { - errs.Add("github.baseURL", "API base URL must use HTTPS unless it points to loopback") - } - } - - validSecret := map[models.SecretMode]bool{ - models.SecretModeSaved: true, models.SecretModeSessionOnly: true, models.SecretModeAskEveryTime: true, - } - if !validSecret[cfg.SecretMode] { - errs.Add("github.secretMode", fmt.Sprintf("unsupported secret mode %q", cfg.SecretMode)) - } - if cfg.RefreshIntervalSecs != 0 && cfg.RefreshIntervalSecs < 60 { - errs.Add("github.refreshIntervalSecs", "refresh interval must be at least 60 seconds") - } - - if errs.HasErrors() { - return errs - } - return nil -} - -// ValidateVNCConfig validates VNC connection settings. -func ValidateVNCConfig(profileHost string, cfg *models.VNCConfig) error { - errs := &ValidationErrors{} - - if cfg == nil { - return &ValidationError{Field: "vnc", Message: "VNC configuration must not be nil"} - } - - vncHost := strings.TrimSpace(cfg.VNCHost) - if vncHost == "" { - vncHost = strings.TrimSpace(profileHost) - } - if err := ValidateHost(vncHost); err != nil { - errs.Add("vnc.vncHost", err.(*ValidationError).Message) - } - if err := ValidatePort(cfg.VNCPort); err != nil { - errs.Add("vnc.vncPort", err.(*ValidationError).Message) - } - if cfg.Quality < 0 || cfg.Quality > 9 { - errs.Add("vnc.quality", "quality must be between 0 and 9") - } - - if cfg.SSHTunnel { - if err := ValidateHost(cfg.SSHHost); err != nil { - errs.Add("vnc.sshHost", err.(*ValidationError).Message) - } - if err := ValidatePort(cfg.SSHPort); err != nil { - errs.Add("vnc.sshPort", err.(*ValidationError).Message) - } - if strings.TrimSpace(cfg.SSHUsername) == "" { - errs.Add("vnc.sshUsername", "SSH username is required when tunnelling") - } else if err := ValidateUsername(cfg.SSHUsername); err != nil { - errs.Add("vnc.sshUsername", err.(*ValidationError).Message) - } - if cfg.SSHAuthMethod != "password" && cfg.SSHAuthMethod != "publickey" { - errs.Add("vnc.sshAuthMethod", fmt.Sprintf("unsupported SSH auth method %q", cfg.SSHAuthMethod)) - } - } - - if errs.HasErrors() { - return errs - } - return nil -} - -// ValidateIRCConfig validates IRC connection settings. -func ValidateIRCConfig(cfg *models.IRCConfig) error { - errs := &ValidationErrors{} - - if cfg == nil { - return &ValidationError{Field: "irc", Message: "IRC configuration must not be nil"} - } - - nick := strings.TrimSpace(cfg.Nick) - if nick == "" { - errs.Add("irc.nick", "nickname is required") - } else if !girc.IsValidNick(nick) { - errs.Add("irc.nick", "nickname is not valid for IRC") - } - - if strings.ContainsAny(cfg.RealName, "\r\n\x00") { - errs.Add("irc.realName", "real name must not contain control characters") - } - - if !isValidSecretMode(cfg.ServerPasswordMode) { - errs.Add("irc.serverPasswordMode", fmt.Sprintf("unsupported secret mode %q", cfg.ServerPasswordMode)) - } - if !isValidSecretMode(cfg.NickServMode) { - errs.Add("irc.nickServMode", fmt.Sprintf("unsupported secret mode %q", cfg.NickServMode)) - } - if cfg.NickServIdentify && !isValidNickServAccount(cfg.NickServAccount) { - errs.Add("irc.nickServAccount", "NickServ account must not contain control characters") - } - - seenRooms := map[string]bool{} - for i, room := range cfg.AutoJoinRooms { - field := fmt.Sprintf("irc.autoJoinRooms[%d]", i) - name := strings.TrimSpace(room) - if name == "" { - errs.Add(field, "room name must not be empty") - continue - } - if !girc.IsValidChannel(name) { - errs.Add(field, "room name must start with a valid IRC channel prefix") - } - key := strings.ToLower(name) - if seenRooms[key] { - errs.Add(field, "room is listed more than once") - } - seenRooms[key] = true - } - - if cfg.SSHTunnel { - if err := ValidateHost(cfg.SSHHost); err != nil { - errs.Add("irc.sshHost", err.(*ValidationError).Message) - } - if err := ValidatePort(cfg.SSHPort); err != nil { - errs.Add("irc.sshPort", err.(*ValidationError).Message) - } - if strings.TrimSpace(cfg.SSHUsername) == "" { - errs.Add("irc.sshUsername", "SSH username is required when tunnelling") - } else if err := ValidateUsername(cfg.SSHUsername); err != nil { - errs.Add("irc.sshUsername", err.(*ValidationError).Message) - } - if cfg.SSHAuthMethod != "password" && cfg.SSHAuthMethod != "publickey" { - errs.Add("irc.sshAuthMethod", fmt.Sprintf("unsupported SSH auth method %q", cfg.SSHAuthMethod)) - } - if !isValidSecretMode(cfg.SSHSecretMode) { - errs.Add("irc.sshSecretMode", fmt.Sprintf("unsupported secret mode %q", cfg.SSHSecretMode)) - } - } - - if cfg.MaxReconnectTries < 0 { - errs.Add("irc.maxReconnectTries", "max reconnect attempts must not be negative") - } - - if errs.HasErrors() { - return errs - } - return nil -} - -func isValidSecretMode(mode models.SecretMode) bool { - switch mode { - case models.SecretModeSaved, models.SecretModeSessionOnly, models.SecretModeAskEveryTime: - return true - default: - return false - } -} - -func isValidNickServAccount(account string) bool { - for _, r := range account { - if r < 0x20 || r == 0x7f { - return false - } - } - return true -} - -func isLoopbackHost(host string) bool { - h := strings.ToLower(strings.TrimSpace(host)) - if h == "localhost" { - return true - } - ip := net.ParseIP(h) - return ip != nil && ip.IsLoopback() -} diff --git a/internal/validation/validation_test.go b/internal/validation/validation_test.go deleted file mode 100644 index f115cd6..0000000 --- a/internal/validation/validation_test.go +++ /dev/null @@ -1,539 +0,0 @@ -package validation - -import ( - "testing" - - "github.com/andydixon/link/internal/models" -) - -// --------------------------------------------------------------------------- -// ValidateHost -// --------------------------------------------------------------------------- - -func TestValidateHost(t *testing.T) { - tests := []struct { - name string - host string - wantErr bool - }{ - {name: "valid hostname", host: "example.com", wantErr: false}, - {name: "valid IP address", host: "192.168.1.1", wantErr: false}, - {name: "valid IPv6 address", host: "::1", wantErr: false}, - {name: "empty string", host: "", wantErr: true}, - {name: "only spaces", host: " ", wantErr: true}, - {name: "contains spaces", host: "host name", wantErr: true}, - {name: "contains tab", host: "host\tname", wantErr: true}, - {name: "URI scheme present", host: "ssh://example.com", wantErr: true}, - {name: "leading hyphen", host: "-bad-host", wantErr: true}, - {name: "subdomain hostname", host: "server.internal.corp", wantErr: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateHost(tt.host) - if (err != nil) != tt.wantErr { - t.Errorf("ValidateHost(%q) error = %v, wantErr %v", tt.host, err, tt.wantErr) - } - }) - } -} - -// --------------------------------------------------------------------------- -// ValidatePort -// --------------------------------------------------------------------------- - -func TestValidatePort(t *testing.T) { - tests := []struct { - name string - port int - wantErr bool - }{ - {name: "port 1 is valid", port: 1, wantErr: false}, - {name: "port 22 is valid", port: 22, wantErr: false}, - {name: "port 443 is valid", port: 443, wantErr: false}, - {name: "port 65535 is valid", port: 65535, wantErr: false}, - {name: "port 0 is invalid", port: 0, wantErr: true}, - {name: "negative port is invalid", port: -1, wantErr: true}, - {name: "port 65536 is invalid", port: 65536, wantErr: true}, - {name: "very large port is invalid", port: 99999, wantErr: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidatePort(tt.port) - if (err != nil) != tt.wantErr { - t.Errorf("ValidatePort(%d) error = %v, wantErr %v", tt.port, err, tt.wantErr) - } - }) - } -} - -// --------------------------------------------------------------------------- -// ValidateUsername -// --------------------------------------------------------------------------- - -func TestValidateUsername(t *testing.T) { - tests := []struct { - name string - username string - wantErr bool - }{ - {name: "valid username", username: "admin", wantErr: false}, - {name: "empty string is invalid", username: "", wantErr: true}, - {name: "only whitespace is invalid", username: " ", wantErr: true}, - {name: "username with spaces is allowed", username: "my user", wantErr: false}, - {name: "very long username is valid", username: "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789", wantErr: false}, - {name: "contains control character", username: "bad\x01user", wantErr: true}, - {name: "contains DEL character", username: "bad\x7fuser", wantErr: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateUsername(tt.username) - if (err != nil) != tt.wantErr { - t.Errorf("ValidateUsername(%q) error = %v, wantErr %v", tt.username, err, tt.wantErr) - } - }) - } -} - -// --------------------------------------------------------------------------- -// ValidateProfile -// --------------------------------------------------------------------------- - -func TestValidateProfile(t *testing.T) { - // Helper to build a valid SSH profile. - validSSH := func() *models.Profile { - p := models.NewProfile(models.ProtocolSSH) - p.DisplayName = "Test SSH" - p.Host = "example.com" - p.Username = "root" - return &p - } - - // Helper to build a valid Telnet profile. - validTelnet := func() *models.Profile { - p := models.NewProfile(models.ProtocolTelnet) - p.DisplayName = "Test Telnet" - p.Host = "switch.local" - return &p - } - - // Helper to build a valid RAW profile. - validRAW := func() *models.Profile { - p := models.NewProfile(models.ProtocolRAW) - p.DisplayName = "Test RAW" - p.Host = "device.local" - return &p - } - - validVNC := func() *models.Profile { - p := models.NewProfile(models.ProtocolVNC) - p.DisplayName = "Test VNC" - p.Host = "" - p.VNC.VNCHost = "desktop.local" - return &p - } - - tests := []struct { - name string - profile *models.Profile - wantErr bool - }{ - {name: "full valid SSH profile", profile: validSSH(), wantErr: false}, - {name: "full valid Telnet profile", profile: validTelnet(), wantErr: false}, - {name: "full valid RAW profile", profile: validRAW(), wantErr: false}, - {name: "valid VNC profile with VNC-specific host", profile: validVNC(), wantErr: false}, - {name: "nil profile", profile: nil, wantErr: true}, - { - name: "missing host", - profile: func() *models.Profile { - p := validSSH() - p.Host = "" - return p - }(), - wantErr: true, - }, - { - name: "missing port (zero)", - profile: func() *models.Profile { - p := validSSH() - p.Port = 0 - return p - }(), - wantErr: true, - }, - { - name: "invalid protocol", - profile: func() *models.Profile { - p := validSSH() - p.Protocol = "ftp" - return p - }(), - wantErr: true, - }, - { - name: "SSH missing username", - profile: func() *models.Profile { - p := validSSH() - p.Username = "" - return p - }(), - wantErr: true, - }, - { - name: "SSH nil config", - profile: func() *models.Profile { - p := validSSH() - p.SSH = nil - return p - }(), - wantErr: true, - }, - { - name: "negative connect timeout", - profile: func() *models.Profile { - p := validSSH() - p.ConnectTimeout = -1 - return p - }(), - wantErr: true, - }, - { - name: "font size out of range", - profile: func() *models.Profile { - p := validSSH() - p.TerminalFontSize = 0 - return p - }(), - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateProfile(tt.profile) - if (err != nil) != tt.wantErr { - t.Errorf("ValidateProfile() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -// --------------------------------------------------------------------------- -// ValidateGitHubConfig -// --------------------------------------------------------------------------- - -func TestValidateGitHubConfig(t *testing.T) { - tests := []struct { - name string - cfg *models.GitHubConfig - wantErr bool - }{ - { - name: "valid github.com API URL", - cfg: &models.GitHubConfig{ - BaseURL: "https://api.github.com", - SecretMode: models.SecretModeSaved, - RefreshIntervalSecs: 300, - }, - wantErr: false, - }, - { - name: "valid loopback HTTP URL", - cfg: &models.GitHubConfig{ - BaseURL: "http://localhost:3000/api/v3", - SecretMode: models.SecretModeAskEveryTime, - RefreshIntervalSecs: 60, - }, - wantErr: false, - }, - { - name: "rejects non-HTTPS remote URL", - cfg: &models.GitHubConfig{ - BaseURL: "http://evil.example/api/v3", - SecretMode: models.SecretModeSaved, - RefreshIntervalSecs: 300, - }, - wantErr: true, - }, - { - name: "rejects URL credentials", - cfg: &models.GitHubConfig{ - BaseURL: "https://token@example.com/api/v3", - SecretMode: models.SecretModeSaved, - RefreshIntervalSecs: 300, - }, - wantErr: true, - }, - { - name: "rejects aggressive refresh interval", - cfg: &models.GitHubConfig{ - BaseURL: "https://api.github.com", - SecretMode: models.SecretModeSaved, - RefreshIntervalSecs: 1, - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateGitHubConfig(tt.cfg) - if (err != nil) != tt.wantErr { - t.Errorf("ValidateGitHubConfig() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -// --------------------------------------------------------------------------- -// ValidateVNCConfig -// --------------------------------------------------------------------------- - -func TestValidateVNCConfig(t *testing.T) { - tests := []struct { - name string - profileHost string - cfg *models.VNCConfig - wantErr bool - }{ - { - name: "uses VNC-specific host", - profileHost: "", - cfg: &models.VNCConfig{ - VNCHost: "desktop.local", - VNCPort: 5900, - Quality: 6, - }, - wantErr: false, - }, - { - name: "uses profile host fallback", - profileHost: "desktop.local", - cfg: &models.VNCConfig{ - VNCPort: 5900, - Quality: 6, - }, - wantErr: false, - }, - { - name: "rejects missing host", - profileHost: "", - cfg: &models.VNCConfig{ - VNCPort: 5900, - Quality: 6, - }, - wantErr: true, - }, - { - name: "rejects invalid SSH tunnel auth method", - profileHost: "desktop.local", - cfg: &models.VNCConfig{ - VNCPort: 5900, - Quality: 6, - SSHTunnel: true, - SSHHost: "jump.local", - SSHPort: 22, - SSHUsername: "admin", - SSHAuthMethod: "token", - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateVNCConfig(tt.profileHost, tt.cfg) - if (err != nil) != tt.wantErr { - t.Errorf("ValidateVNCConfig() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -// --------------------------------------------------------------------------- -// ValidateSSHConfig -// --------------------------------------------------------------------------- - -func TestValidateSSHConfig(t *testing.T) { - validCfg := func() *models.SSHConfig { - return &models.SSHConfig{ - AuthMethods: []string{"password"}, - SecretMode: models.SecretModeSaved, - KeySecretMode: models.SecretModeAskEveryTime, - HostKeyPolicy: models.HostKeyAsk, - KeepaliveInterval: 30, - KeepaliveCountMax: 3, - JumpHosts: []models.JumpHost{}, - } - } - - tests := []struct { - name string - cfg *models.SSHConfig - wantErr bool - }{ - {name: "valid config", cfg: validCfg(), wantErr: false}, - { - name: "empty auth methods", - cfg: func() *models.SSHConfig { - c := validCfg() - c.AuthMethods = nil - return c - }(), - wantErr: true, - }, - { - name: "invalid auth method", - cfg: func() *models.SSHConfig { - c := validCfg() - c.AuthMethods = []string{"oauth"} - return c - }(), - wantErr: true, - }, - { - name: "invalid secret mode", - cfg: func() *models.SSHConfig { - c := validCfg() - c.SecretMode = "wrong" - return c - }(), - wantErr: true, - }, - { - name: "invalid host key policy", - cfg: func() *models.SSHConfig { - c := validCfg() - c.HostKeyPolicy = "yolo" - return c - }(), - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateSSHConfig(tt.cfg) - if (err != nil) != tt.wantErr { - t.Errorf("ValidateSSHConfig() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -// --------------------------------------------------------------------------- -// ValidateTelnetConfig -// --------------------------------------------------------------------------- - -func TestValidateTelnetConfig(t *testing.T) { - tests := []struct { - name string - cfg *models.TelnetConfig - wantErr bool - }{ - { - name: "valid config", - cfg: &models.TelnetConfig{ - LineEnding: models.LineEndingCRLF, - Encoding: "utf-8", - }, - wantErr: false, - }, - { - name: "invalid line ending", - cfg: &models.TelnetConfig{ - LineEnding: "bad", - Encoding: "utf-8", - }, - wantErr: true, - }, - { - name: "empty encoding", - cfg: &models.TelnetConfig{ - LineEnding: models.LineEndingLF, - Encoding: "", - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateTelnetConfig(tt.cfg) - if (err != nil) != tt.wantErr { - t.Errorf("ValidateTelnetConfig() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -// --------------------------------------------------------------------------- -// ValidateRAWConfig -// --------------------------------------------------------------------------- - -func TestValidateRAWConfig(t *testing.T) { - tests := []struct { - name string - cfg *models.RAWConfig - wantErr bool - }{ - { - name: "valid config", - cfg: &models.RAWConfig{ - LineEnding: models.LineEndingCRLF, - Encoding: "utf-8", - PayloadPresets: []models.PayloadPreset{}, - }, - wantErr: false, - }, - { - name: "invalid line ending", - cfg: &models.RAWConfig{ - LineEnding: "bad", - Encoding: "utf-8", - }, - wantErr: true, - }, - { - name: "empty encoding", - cfg: &models.RAWConfig{ - LineEnding: models.LineEndingLF, - Encoding: "", - }, - wantErr: true, - }, - { - name: "preset with invalid format", - cfg: &models.RAWConfig{ - LineEnding: models.LineEndingCRLF, - Encoding: "utf-8", - PayloadPresets: []models.PayloadPreset{ - {Name: "ping", Format: "binary"}, - }, - }, - wantErr: true, - }, - { - name: "preset with empty name", - cfg: &models.RAWConfig{ - LineEnding: models.LineEndingCRLF, - Encoding: "utf-8", - PayloadPresets: []models.PayloadPreset{ - {Name: "", Format: "text"}, - }, - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateRAWConfig(tt.cfg) - if (err != nil) != tt.wantErr { - t.Errorf("ValidateRAWConfig() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} diff --git a/internal/vncclient/vncclient.go b/internal/vncclient/vncclient.go deleted file mode 100644 index ce54347..0000000 --- a/internal/vncclient/vncclient.go +++ /dev/null @@ -1,340 +0,0 @@ -// Package vncclient provides a WebSocket-to-TCP proxy for VNC connections, -// with optional SSH tunnel support. -package vncclient - -import ( - "context" - "crypto/rand" - "encoding/hex" - "fmt" - "net" - "net/http" - "net/url" - "strconv" - "strings" - "sync" - "time" - - "github.com/gorilla/websocket" - "golang.org/x/crypto/ssh" - "golang.org/x/crypto/ssh/agent" -) - -// Client manages a VNC session, optionally tunnelled through SSH. -type Client struct { - mu sync.Mutex - ctx context.Context - cancel context.CancelFunc - listener net.Listener - sshClient *ssh.Client - wsURL string - connected bool - onError func(error) -} - -// Config holds the configuration for a VNC connection. -type Config struct { - // VNC server address - VNCHost string - VNCPort int - - // SSH tunnel settings - SSHTunnel bool - SSHHost string - SSHPort int - SSHUsername string - SSHAuthMethod string // "password", "publickey" - SSHPassword string - SSHPrivateKey []byte - SSHPassphrase string - SSHAgentAuth bool - SSHAgentConn net.Conn - SSHHostKeyCallback ssh.HostKeyCallback - - // Callbacks - OnError func(error) -} - -var upgrader = websocket.Upgrader{ - CheckOrigin: allowProxyOrigin, - ReadBufferSize: 65536, - WriteBufferSize: 65536, -} - -const maxWebSocketMessageBytes = 8 << 20 - -// NewClient creates a new VNC proxy client. -func NewClient(cfg Config) *Client { - ctx, cancel := context.WithCancel(context.Background()) - return &Client{ - ctx: ctx, - cancel: cancel, - onError: cfg.OnError, - } -} - -// Connect establishes the VNC connection (with optional SSH tunnel) and starts -// a local WebSocket proxy. Returns the WebSocket URL for the frontend. -func (c *Client) Connect(cfg Config) (string, error) { - c.mu.Lock() - defer c.mu.Unlock() - - if c.connected { - return c.wsURL, nil - } - - var vncConn net.Conn - var err error - - vncAddr := net.JoinHostPort(cfg.VNCHost, strconv.Itoa(cfg.VNCPort)) - - if cfg.SSHTunnel { - // Establish SSH connection first - sshAddr := net.JoinHostPort(cfg.SSHHost, strconv.Itoa(cfg.SSHPort)) - if cfg.SSHHostKeyCallback == nil { - return "", fmt.Errorf("SSH host key callback is required") - } - sshConfig := &ssh.ClientConfig{ - User: cfg.SSHUsername, - HostKeyCallback: cfg.SSHHostKeyCallback, - Timeout: 15 * time.Second, - } - - // Configure auth methods - var authMethods []ssh.AuthMethod - if cfg.SSHAgentAuth && cfg.SSHAgentConn != nil { - agent := newSSHAgent(cfg.SSHAgentConn) - if agent != nil { - authMethods = append(authMethods, agent) - } - } - if len(cfg.SSHPrivateKey) > 0 { - var signer ssh.Signer - if cfg.SSHPassphrase != "" { - signer, err = ssh.ParsePrivateKeyWithPassphrase(cfg.SSHPrivateKey, []byte(cfg.SSHPassphrase)) - } else { - signer, err = ssh.ParsePrivateKey(cfg.SSHPrivateKey) - } - if err == nil { - authMethods = append(authMethods, ssh.PublicKeys(signer)) - } - } - if cfg.SSHPassword != "" { - authMethods = append(authMethods, ssh.Password(cfg.SSHPassword)) - } - sshConfig.Auth = authMethods - - c.sshClient, err = ssh.Dial("tcp", sshAddr, sshConfig) - if err != nil { - return "", fmt.Errorf("SSH connection failed: %w", err) - } - - // Dial VNC through the SSH tunnel - // When tunnelling, VNC address is relative to the SSH server - tunnelAddr := vncAddr - if cfg.VNCHost == "" || cfg.VNCHost == cfg.SSHHost { - tunnelAddr = fmt.Sprintf("127.0.0.1:%d", cfg.VNCPort) - } - vncConn, err = c.sshClient.Dial("tcp", tunnelAddr) - if err != nil { - c.sshClient.Close() - c.sshClient = nil - return "", fmt.Errorf("VNC tunnel dial failed: %w", err) - } - } else { - // Direct TCP connection to VNC server - vncConn, err = net.DialTimeout("tcp", vncAddr, 15*time.Second) - if err != nil { - return "", fmt.Errorf("VNC connection failed: %w", err) - } - } - - // Start local WebSocket proxy - wsURL, err := c.startProxy(vncConn) - if err != nil { - vncConn.Close() - if c.sshClient != nil { - c.sshClient.Close() - c.sshClient = nil - } - return "", fmt.Errorf("failed to start WebSocket proxy: %w", err) - } - - c.wsURL = wsURL - c.connected = true - return wsURL, nil -} - -// startProxy creates a local HTTP server that upgrades to WebSocket and bridges -// traffic to the VNC TCP connection. -func (c *Client) startProxy(vncConn net.Conn) (string, error) { - // Listen on a random available port on localhost - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return "", err - } - token, err := newProxyToken() - if err != nil { - listener.Close() - return "", err - } - c.listener = listener - - port := listener.Addr().(*net.TCPAddr).Port - proxyPath := "/" + token - wsURL := fmt.Sprintf("ws://127.0.0.1:%d%s", port, proxyPath) - - mux := http.NewServeMux() - mux.HandleFunc(proxyPath, func(w http.ResponseWriter, r *http.Request) { - ws, err := upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - defer ws.Close() - ws.SetReadLimit(maxWebSocketMessageBytes) - - // Bridge WebSocket ↔ VNC TCP in both directions - done := make(chan struct{}, 2) - - // VNC → WebSocket - go func() { - defer func() { done <- struct{}{} }() - buf := make([]byte, 65536) - for { - n, err := vncConn.Read(buf) - if n > 0 { - if writeErr := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); writeErr != nil { - return - } - } - if err != nil { - return - } - } - }() - - // WebSocket → VNC - go func() { - defer func() { done <- struct{}{} }() - for { - _, msg, err := ws.ReadMessage() - if err != nil { - return - } - if _, err := vncConn.Write(msg); err != nil { - return - } - } - }() - - // Wait for either direction to close - select { - case <-done: - case <-c.ctx.Done(): - } - }) - - server := &http.Server{ - Handler: mux, - ReadTimeout: 0, // VNC is long-lived - WriteTimeout: 0, - } - - go func() { - if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { - if c.onError != nil { - c.onError(fmt.Errorf("VNC proxy error: %w", err)) - } - } - }() - - // Close server when context is cancelled - go func() { - <-c.ctx.Done() - server.Close() - vncConn.Close() - }() - - return wsURL, nil -} - -func allowProxyOrigin(r *http.Request) bool { - origin := r.Header.Get("Origin") - if origin == "" { - return true - } - u, err := url.Parse(origin) - if err != nil { - return false - } - switch strings.ToLower(u.Scheme) { - case "wails": - return true - case "http", "https": - return isLoopbackHost(u.Hostname()) - default: - return false - } -} - -func isLoopbackHost(host string) bool { - h := strings.ToLower(strings.TrimSpace(host)) - if h == "localhost" { - return true - } - ip := net.ParseIP(h) - return ip != nil && ip.IsLoopback() -} - -func newProxyToken() (string, error) { - token := make([]byte, 16) - if _, err := rand.Read(token); err != nil { - return "", fmt.Errorf("generate proxy token: %w", err) - } - return hex.EncodeToString(token), nil -} - -// Close shuts down the VNC proxy and any SSH tunnel. -func (c *Client) Close() { - c.mu.Lock() - defer c.mu.Unlock() - - c.cancel() - c.connected = false - - if c.listener != nil { - c.listener.Close() - c.listener = nil - } - if c.sshClient != nil { - c.sshClient.Close() - c.sshClient = nil - } -} - -// IsConnected returns whether the VNC session is active. -func (c *Client) IsConnected() bool { - c.mu.Lock() - defer c.mu.Unlock() - return c.connected -} - -// WSURL returns the WebSocket URL for the frontend to connect to. -func (c *Client) WSURL() string { - c.mu.Lock() - defer c.mu.Unlock() - return c.wsURL -} - -// newSSHAgent creates an SSH agent auth method from the given connection. -func newSSHAgent(conn net.Conn) ssh.AuthMethod { - if conn == nil { - return nil - } - ag := agent.NewClient(conn) - signers, err := ag.Signers() - if err != nil || len(signers) == 0 { - return nil - } - return ssh.PublicKeys(signers...) -} diff --git a/internal/vncclient/vncclient_test.go b/internal/vncclient/vncclient_test.go deleted file mode 100644 index 11a6f28..0000000 --- a/internal/vncclient/vncclient_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package vncclient - -import ( - "net/http" - "testing" -) - -func TestNewProxyTokenIsRandomHex(t *testing.T) { - first, err := newProxyToken() - if err != nil { - t.Fatalf("newProxyToken() error = %v", err) - } - second, err := newProxyToken() - if err != nil { - t.Fatalf("newProxyToken() second error = %v", err) - } - - if len(first) != 32 { - t.Fatalf("token length = %d, want 32", len(first)) - } - if first == second { - t.Fatal("expected separate tokens to differ") - } - for _, ch := range first { - if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f')) { - t.Fatalf("token contains non-hex character %q", ch) - } - } -} - -func TestAllowProxyOrigin(t *testing.T) { - tests := []struct { - name string - origin string - want bool - }{ - {name: "missing origin", origin: "", want: true}, - {name: "wails origin", origin: "wails://wails", want: true}, - {name: "localhost http", origin: "http://localhost:34115", want: true}, - {name: "loopback ipv4", origin: "http://127.0.0.1:34115", want: true}, - {name: "loopback ipv6", origin: "http://[::1]:34115", want: true}, - {name: "remote http", origin: "http://example.com", want: false}, - {name: "lookalike host", origin: "http://127.0.0.1.example.com", want: false}, - {name: "bad origin", origin: "://bad", want: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/ws", nil) - if err != nil { - t.Fatal(err) - } - if tt.origin != "" { - req.Header.Set("Origin", tt.origin) - } - if got := allowProxyOrigin(req); got != tt.want { - t.Fatalf("allowProxyOrigin(%q) = %v, want %v", tt.origin, got, tt.want) - } - }) - } -} diff --git a/main.go b/main.go deleted file mode 100644 index f1a0906..0000000 --- a/main.go +++ /dev/null @@ -1,79 +0,0 @@ -// Alchementary Link – a desktop SSH, Telnet and RAW TCP client built with Wails v2. -// This is the main entry point for the application. -package main - -import ( - "log" - - "github.com/wailsapp/wails/v2" - "github.com/wailsapp/wails/v2/pkg/menu" - "github.com/wailsapp/wails/v2/pkg/menu/keys" - "github.com/wailsapp/wails/v2/pkg/options" - "github.com/wailsapp/wails/v2/pkg/options/assetserver" - "github.com/wailsapp/wails/v2/pkg/options/linux" - "github.com/wailsapp/wails/v2/pkg/options/mac" - "github.com/wailsapp/wails/v2/pkg/options/windows" - - "github.com/andydixon/link/frontend" - application "github.com/andydixon/link/internal/app" -) - -func buildAppMenu(app *application.App) *menu.Menu { - mainMenu := menu.NewMenu() - - appMenu := menu.NewMenu() - appMenu.AddText("About Alchementary Link", nil, func(_ *menu.CallbackData) { - app.EmitOpenAbout() - }) - appMenu.AddSeparator() - appMenu.AddText("Quit Alchementary Link", keys.CmdOrCtrl("q"), func(_ *menu.CallbackData) { - app.RequestQuit() - }) - - helpMenu := menu.NewMenu() - helpMenu.AddText("About Alchementary Link", nil, func(_ *menu.CallbackData) { - app.EmitOpenAbout() - }) - - mainMenu.Append(menu.SubMenu("Alchementary Link", appMenu)) - mainMenu.Append(menu.EditMenu()) - mainMenu.Append(menu.WindowMenu()) - mainMenu.Append(menu.SubMenu("Help", helpMenu)) - - return mainMenu -} - -func main() { - link := application.NewApp() - - // The API service is created during Startup once storage is initialised. - // We bind the App itself so Wails can call lifecycle hooks, and use - // OnStartup to wire everything, then bind the API service separately. - err := wails.Run(&options.App{ - Title: "Alchementary Link", - Width: 1400, - Height: 900, - MinWidth: 1024, - MinHeight: 600, - AssetServer: &assetserver.Options{ - Assets: frontend.Assets, - }, - BackgroundColour: &options.RGBA{R: 26, G: 26, B: 46, A: 255}, // #1a1a2e - OnStartup: link.Startup, - OnShutdown: link.Shutdown, - OnDomReady: link.DomReady, - Menu: buildAppMenu(link), - // macOS: enable the green fullscreen traffic-light button. - Mac: &mac.Options{}, - // Windows: default decorations include maximise; fullscreen via F11. - Windows: &windows.Options{}, - // Linux: use the default window manager decorations (fullscreen via WM). - Linux: &linux.Options{}, - Bind: []interface{}{ - link.APIService(), - }, - }) - if err != nil { - log.Fatalf("failed to start Alchementary Link: %v", err) - } -} diff --git a/release.sh b/release.sh index 1c3330c..7ed7888 100755 --- a/release.sh +++ b/release.sh @@ -1,458 +1,3 @@ #!/usr/bin/env bash -# release.sh — Build and package release artefacts for Alchementary Link. -# Produces per-platform zips, manifests, and version files for every supported -# 64-bit desktop OS/architecture combination. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$SCRIPT_DIR" -source "$SCRIPT_DIR/scripts/build-common.sh" - -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' - -DEFAULT_UPDATE_URL="https://alchementary.co.uk/download/link/manifest-{platform}-{arch}.json" -DEFAULT_RELEASE_BASE_URL="https://alchementary.co.uk/download/link" -WAILS_VERSION="$(link_wails_version go.mod)" -GO_TOOLCHAIN_VERSION="$( - awk ' - $1 == "go" { go_version = $2 } - $1 == "toolchain" { - sub(/^go/, "", $2) - toolchain_version = $2 - } - END { - if (toolchain_version != "") { - print toolchain_version - } else { - print go_version - } - } - ' go.mod -)" -DEFAULT_LINUX_BUILD_IMAGE="docker.io/library/golang:${GO_TOOLCHAIN_VERSION:-1.26}-bookworm" -DEFAULT_NODE_VERSION="20.19.4" - -log() { echo -e "${GREEN}[link]${NC} $1"; } -warn() { echo -e "${YELLOW}[link]${NC} $1"; } -err() { echo -e "${RED}[link]${NC} $1" >&2; } - -host_os() { - case "$(uname -s)" in - Darwin) echo "darwin" ;; - Linux) echo "linux" ;; - MINGW*|MSYS*|CYGWIN*) echo "windows" ;; - *) echo "unknown" ;; - esac -} - -build_tags() { - link_build_tags -} - -default_targets() { - printf '%s\n' \ - "darwin/amd64" \ - "darwin/arm64" \ - "linux/amd64" \ - "linux/arm64" \ - "windows/amd64" \ - "windows/arm64" -} - -release_targets() { - if [ -n "${LINK_RELEASE_TARGETS:-}" ]; then - echo "$LINK_RELEASE_TARGETS" | tr ',' '\n' | sed '/^[[:space:]]*$/d' - return - fi - - default_targets -} - -check_deps() { - command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; } - export PATH="$PATH:$(go env GOPATH)/bin" - command -v node >/dev/null 2>&1 || { err "Node.js is not installed"; exit 1; } - command -v npm >/dev/null 2>&1 || { err "npm is not installed"; exit 1; } - command -v zip >/dev/null 2>&1 || { err "zip is not installed"; exit 1; } - command -v shasum >/dev/null 2>&1 || { err "shasum is not installed"; exit 1; } - - if ! command -v wails >/dev/null 2>&1; then - if [ -f "$HOME/go/bin/wails" ]; then - export PATH="$PATH:$HOME/go/bin" - else - if [ -z "$WAILS_VERSION" ]; then - err "Wails is not installed and no pinned Wails version was found in go.mod" - exit 1 - fi - log "Installing pinned Wails CLI ${WAILS_VERSION}..." - go install "github.com/wailsapp/wails/v2/cmd/wails@${WAILS_VERSION}" - export PATH="$PATH:$(go env GOPATH)/bin" - fi - fi - - ensure_upx -} - -install_deps() { - log "Installing Go dependencies..." - go mod download - - log "Installing frontend dependencies..." - if [ -f frontend/package-lock.json ]; then - (cd frontend && npm ci) - else - (cd frontend && npm install) - fi -} - -build_version() { - link_build_version -} - -build_ldflags() { - link_build_ldflags "$1" -} - -container_engine() { - local candidate - for candidate in podman docker; do - if command -v "$candidate" >/dev/null 2>&1 \ - && "$candidate" info >/dev/null 2>&1; then - echo "$candidate" - return 0 - fi - done - return 1 -} - -ensure_upx() { - if ! link_bool_enabled "${LINK_COMPRESS:-0}"; then - return - fi - local requires_upx=0 - local target - while IFS= read -r target; do - [ -n "$target" ] || continue - local target_os="${target%%/*}" - if link_use_upx_for_target "$target_os"; then - requires_upx=1 - break - fi - done < <(release_targets) - if [ "$requires_upx" -eq 0 ]; then - return - fi - if command -v upx >/dev/null 2>&1; then - return - fi - err "LINK_COMPRESS=1 requires UPX. Install it first, for example: brew install upx" - exit 1 -} - -build_linux_target_in_container() { - local target="$1" - local version="$2" - local out_dir="$3" - local target_os="${target%%/*}" - local target_arch="${target##*/}" - - local engine - if ! engine="$(container_engine)"; then - warn "Linux targets require a running Podman or Docker engine on $(host_os)." - return 1 - fi - - local node_arch - case "$target_arch" in - amd64) node_arch="x64" ;; - arm64) node_arch="arm64" ;; - *) - warn "No Node.js Linux archive mapping for ${target_arch}." - return 1 - ;; - esac - - mkdir -p "$SCRIPT_DIR/build" - local container_release_dir - container_release_dir="$(mktemp -d "$SCRIPT_DIR/build/.release-linux.XXXXXX")" - local container_release_root="build/$(basename "$container_release_dir")" - local image="${LINK_LINUX_BUILD_IMAGE:-$DEFAULT_LINUX_BUILD_IMAGE}" - local node_version="${LINK_NODE_VERSION:-$DEFAULT_NODE_VERSION}" - local builder_image="link-linux-release:${GO_TOOLCHAIN_VERSION:-1.26}-${node_version}-${target_arch}" - - log "Preparing the ${engine} Linux/${target_arch} release builder..." - if ! "$engine" build \ - --platform "linux/${target_arch}" \ - --build-arg "GO_IMAGE=${image}" \ - --build-arg "NODE_ARCH=${node_arch}" \ - --build-arg "NODE_VERSION=${node_version}" \ - --build-arg "WAILS_VERSION=${WAILS_VERSION}" \ - -t "$builder_image" \ - -f "$SCRIPT_DIR/scripts/linux-release.Dockerfile" \ - "$SCRIPT_DIR/scripts"; then - warn "Could not prepare the Linux/${target_arch} release builder." - rm -rf -- "$container_release_dir" - return 1 - fi - - log "Building ${target} in the ${engine} container..." - if ! "$engine" run --rm \ - --platform "linux/${target_arch}" \ - -v "${SCRIPT_DIR}:/src" \ - --tmpfs /src/frontend/node_modules \ - -w /src \ - -e "LINK_RELEASE_TARGETS=${target}" \ - -e "LINK_RELEASE_ROOT=${container_release_root}" \ - -e "LINK_UPDATE_URL=${LINK_UPDATE_URL}" \ - -e "LINK_RELEASE_BASE_URL=${LINK_RELEASE_BASE_URL}" \ - -e "GIT_CONFIG_COUNT=1" \ - -e "GIT_CONFIG_KEY_0=safe.directory" \ - -e "GIT_CONFIG_VALUE_0=/src" \ - "$builder_image" \ - ./release.sh; then - warn "Container build failed for ${target}." - rm -rf -- "$container_release_dir" - return 1 - fi - - local output_base="AlchementaryLink-${target_os}-${target_arch}" - local container_out="${container_release_dir}/${version}" - if [ ! -f "${container_out}/${output_base}" ] \ - || [ ! -f "${container_out}/${output_base}.zip" ]; then - warn "Container did not produce the expected artefacts for ${target}." - rm -rf -- "$container_release_dir" - return 1 - fi - - cp "${container_out}/${output_base}" "${out_dir}/${output_base}" - cp "${container_out}/${output_base}.zip" "${out_dir}/${output_base}.zip" - echo "${target_os}|${target_arch}|${output_base}.zip" >> "${out_dir}/.release-files" - rm -rf -- "$container_release_dir" -} - -build_target() { - local target_os="$1" - local target_arch="$2" - local version="$3" - local out_dir="$4" - - local platform="${target_os}/${target_arch}" - - local output_base="AlchementaryLink-${target_os}-${target_arch}" - local output_path - if [ "$target_os" = "darwin" ]; then - output_path="${out_dir}/${output_base}.app" - elif [ "$target_os" = "windows" ]; then - output_path="${out_dir}/${output_base}.exe" - else - output_path="${out_dir}/${output_base}" - fi - - log "Building ${platform}..." - link_configure_pure_go_build - local ldflags - ldflags="$(build_ldflags "$version")" - local tags - tags="$(build_tags)" - local build_args=("-platform" "$platform" "-trimpath") - if link_use_upx_for_target "$target_os"; then - build_args+=("-upx") - if [ -n "${LINK_UPX_FLAGS:-}" ]; then - build_args+=("-upxflags" "$LINK_UPX_FLAGS") - fi - elif link_skips_upx_for_target "$target_os"; then - warn "Skipping UPX compression for ${platform}; distribute the zipped .app instead, or set LINK_FORCE_MACOS_UPX=1 with suitable LINK_UPX_FLAGS." - fi - if [ -n "$tags" ]; then - build_args+=("-tags" "$tags") - fi - build_args+=("-ldflags" "$ldflags" "-o" "$output_path") - if ! CGO_ENABLED=0 wails build "${build_args[@]}"; then - warn "Build failed for ${platform}" - return 1 - fi - - local expected_path="" - local build_bin="build/bin" - if [ "$target_os" = "darwin" ]; then - expected_path="${out_dir}/${output_base}.app" - if [ ! -d "$expected_path" ]; then - local source_app - source_app="$(find "$build_bin" -type d -name "*.app" -print0 | xargs -0 ls -td 2>/dev/null | head -1)" - if [ -z "$source_app" ]; then - warn "Built .app not found for ${platform}" - return 1 - fi - log "Using app bundle: ${source_app}" - cp -R "$source_app" "$expected_path" - fi - if ! (cd "$out_dir" && zip -r "${output_base}.zip" "${output_base}.app" >/dev/null); then - warn "Zip failed for ${platform}" - return 1 - fi - elif [ "$target_os" = "windows" ]; then - expected_path="${out_dir}/${output_base}.exe" - if [ ! -f "$expected_path" ]; then - local source_exe - source_exe="$(find "$build_bin" -type f -name "${output_base}.exe" -print0 | xargs -0 ls -t 2>/dev/null | head -1)" - if [ -z "$source_exe" ]; then - warn "Built .exe not found for ${platform}" - return 1 - fi - log "Using executable: ${source_exe}" - cp "$source_exe" "$expected_path" - fi - if ! (cd "$out_dir" && zip -j "${output_base}.zip" "${output_base}.exe" >/dev/null); then - warn "Zip failed for ${platform}" - return 1 - fi - else - expected_path="${out_dir}/${output_base}" - if [ ! -f "$expected_path" ]; then - local source_bin - source_bin="$(find "$build_bin" -type f -name "${output_base}" -print0 | xargs -0 ls -t 2>/dev/null | head -1)" - if [ -z "$source_bin" ] || [ ! -f "$source_bin" ]; then - warn "Built binary not found for ${platform}" - return 1 - fi - log "Using binary: ${source_bin}" - cp "$source_bin" "$expected_path" - fi - if ! (cd "$out_dir" && zip -j "${output_base}.zip" "${output_base}" >/dev/null); then - warn "Zip failed for ${platform}" - return 1 - fi - fi - - echo "$target_os|$target_arch|$output_base.zip" >> "${out_dir}/.release-files" -} - -write_manifest() { - local version="$1" - local out_dir="$2" - local release_root="$3" - local base_url="${LINK_RELEASE_BASE_URL:-}" - - local published_at - published_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" - - while IFS='|' read -r os arch filename; do - [ -z "$os" ] && continue - local manifest_path="${release_root}/manifest-${os}-${arch}.json" - local version_path="${release_root}/version-${os}-${arch}.txt" - local file_path="${out_dir}/${filename}" - local sha - sha="$(shasum -a 256 "$file_path" | awk '{print $1}')" - local size - size="$(wc -c < "$file_path" | tr -d ' ')" - local url="" - if [ -n "$base_url" ]; then - url="${base_url}/${filename}" - fi - - echo "$version" > "$version_path" - - { - echo "{" - echo " \"latestVersion\": \"${version}\"," - echo " \"publishedAt\": \"${published_at}\"," - if [ -n "$base_url" ]; then - echo " \"releaseURL\": \"${base_url}/\"," - else - echo " \"releaseURL\": \"\"," - fi - echo " \"files\": [" - printf " {\"platform\":\"%s\",\"arch\":\"%s\",\"filename\":\"%s\",\"url\":\"%s\",\"sha256\":\"%s\",\"size\":%s}" "$os" "$arch" "$filename" "$url" "$sha" "$size" - echo - echo " ]" - echo "}" - } > "$manifest_path" - done < "${out_dir}/.release-files" - - rm -f "${out_dir}/.release-files" -} - -main() { - check_deps - install_deps - - local version - version="$(build_version)" - - if [ -z "${LINK_UPDATE_URL:-}" ]; then - LINK_UPDATE_URL="${DEFAULT_UPDATE_URL}" - fi - if [ -z "${LINK_RELEASE_BASE_URL:-}" ]; then - LINK_RELEASE_BASE_URL="${DEFAULT_RELEASE_BASE_URL}" - fi - - local release_root="${LINK_RELEASE_ROOT:-build/releases}" - local out_dir="${release_root}/${version}" - mkdir -p "$out_dir" - - log "Building release ${version}..." - : > "${out_dir}/.release-files" - - local targets=() - local selected_target - while IFS= read -r selected_target; do - [ -n "$selected_target" ] && targets+=("$selected_target") - done < <(release_targets) - if [ "${#targets[@]}" -eq 0 ]; then - err "No release targets selected." - exit 1 - fi - - local errors=0 - local host - host="$(host_os)" - local target - local container_linux_targets=() - if [ "$host" != "linux" ]; then - for target in "${targets[@]}"; do - if [[ "$target" == linux/* ]]; then - container_linux_targets+=("$target") - fi - done - fi - - if [ "${#container_linux_targets[@]}" -gt 0 ]; then - for target in "${container_linux_targets[@]}"; do - if ! build_linux_target_in_container "$target" "$version" "$out_dir"; then - errors=1 - fi - done - fi - - for target in "${targets[@]}"; do - local target_os="${target%%/*}" - local target_arch="${target##*/}" - if [ "$target_os" = "$target_arch" ] || [ -z "$target_os" ] || [ -z "$target_arch" ]; then - err "Invalid release target: ${target}" - errors=1 - continue - fi - if [ "$target_os" = "linux" ] && [ "$host" != "linux" ]; then - continue - fi - if ! build_target "$target_os" "$target_arch" "$version" "$out_dir"; then - errors=1 - fi - done - - write_manifest "$version" "$out_dir" "$release_root" - - log "Release artefacts written to ${out_dir}" - log "Manifests: ${release_root}/manifest--.json" - log "Versions: ${release_root}/version--.txt" - - if [ $errors -ne 0 ]; then - err "One or more targets failed. Manifest and version were still generated." - exit 12 - fi -} - -main "$@" +# Release builds live with the Rust workspace; see docs/build-and-release.md. +exec "$(dirname "${BASH_SOURCE[0]}")/rust/release.sh" "$@" diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..be8aab0 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,6429 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "enumn", + "serde", + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf", + "serde", + "zvariant 5.15.0", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus 5.19.0", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array 0.14.7", +] + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", + "zeroize", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", + "subtle", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead 0.6.1", + "aes 0.9.3", + "cipher 0.5.2", + "ctr 0.10.1", + "ctutils", + "ghash 0.6.0", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alacritty_terminal" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" +dependencies = [ + "base64 0.22.1", + "bitflags 2.13.2", + "home", + "libc", + "log", + "miow", + "parking_lot", + "piper", + "polling", + "regex-automata", + "rustix 1.1.5", + "rustix-openpty", + "serde", + "signal-hook", + "unicode-width", + "vte", + "windows-sys 0.59.0", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2 0.10.6", + "cpufeatures 0.2.17", + "password-hash 0.5.0", +] + +[[package]] +name = "argon2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +dependencies = [ + "base64ct", + "blake2 0.11.0", + "cpufeatures 0.3.1", + "password-hash 0.6.1", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.5", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.5", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.5", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus 5.19.0", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names 4.3.4", + "zvariant 5.15.0", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus 5.19.0", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt-pbkdf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2 0.11.0", +] + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "blowfish" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher 0.5.2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cipher 0.5.2", + "cpufeatures 0.3.1", + "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", + "zeroize", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint", + "rand_core 0.10.1", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", + "subtle", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rand_core 0.10.1", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes 0.8.4", + "block-padding 0.3.3", + "cbc 0.1.2", + "dbus", + "fastrand", + "hkdf 0.12.4", + "num", + "once_cell", + "sha2 0.10.9", + "zeroize", +] + +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "des" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "libc", + "objc2 0.6.5", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "digest 0.11.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "ecolor" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +dependencies = [ + "bytemuck", + "emath", + "serde", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "eframe" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "profiling", + "raw-window-handle", + "ron", + "serde", + "static_assertions", + "wasm-bindgen", + "web-sys", + "web-time", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "ron", + "serde", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui-phosphor" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4299551488dddaa8f757727e1eb4ffdaf277ffe6e30b413403468f5ecf4634" +dependencies = [ + "egui", +] + +[[package]] +name = "egui-wgpu" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.21", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.5", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "serde", + "smithay-clipboard", + "web-time", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "profiling", + "winit", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hkdf 0.13.0", + "hybrid-array", + "pem-rfc7468", + "pkcs8", + "rand_core 0.10.1", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "emath" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +dependencies = [ + "bytemuck", + "serde", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "epaint" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "serde", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +dependencies = [ + "bytemuck", + "serde", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "generic-array" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" +dependencies = [ + "generic-array 0.14.7", + "rustversion", + "typenum", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.5", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.3", + "zeroize", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glifo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] + +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases 0.2.2", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases 0.2.2", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "ctutils", + "subtle", + "typenum", + "zeroize", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding 0.3.3", + "generic-array 0.14.7", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding 0.4.2", + "hybrid-array", +] + +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zbus 4.4.0", + "zeroize", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "miow" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "ml-kem" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8", + "rand_core 0.10.1", + "sha3 0.11.0", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.2.2", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nexus" +version = "0.1.0" +dependencies = [ + "alacritty_terminal", + "arboard", + "chrono", + "eframe", + "egui", + "egui-phosphor", + "image", + "nexus-core", + "regex", + "rfd", + "serde", + "serde_json", + "winresource", +] + +[[package]] +name = "nexus-core" +version = "0.1.0" +dependencies = [ + "aes-gcm 0.10.3", + "argon2 0.5.3", + "base64 0.22.1", + "chrono", + "des", + "flate2", + "hmac 0.13.0", + "image", + "keyring", + "libc", + "portable-pty", + "rand 0.8.8", + "rusqlite", + "russh", + "russh-sftp", + "rustls", + "serde", + "serde_json", + "serialport", + "sha1 0.11.0", + "sha2 0.11.0", + "tiny_http", + "tokio", + "ureq", + "uuid", + "webpki-roots", + "zeroize", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.2.2", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.2.2", + "libc", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint 0.4.8", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.10.3", + "rand_core 0.10.1", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint 0.4.8", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.3", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "p384" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" +dependencies = [ + "ecdsa", + "elliptic-curve", + "fiat-crypto", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "p521" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "pageant" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0786409b5aaa884655c9b4065681db0835db0cc185a6fb19fb17a5d38656866" +dependencies = [ + "base16ct", + "byteorder", + "bytes", + "delegate", + "futures", + "log", + "rand 0.10.3", + "sha2 0.11.0", + "thiserror 2.0.21", + "tokio", + "windows", + "windows-strings", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "phc", +] + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkcs5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" +dependencies = [ + "aes 0.9.3", + "aes-gcm 0.11.1", + "cbc 0.2.1", + "der", + "pbkdf2", + "rand_core 0.10.1", + "scrypt", + "sha2 0.11.0", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "pkcs5", + "rand_core 0.10.1", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.1", + "universal-hash 0.6.1", + "zeroize", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash 0.6.1", + "zeroize", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac 0.13.0", +] + +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.2", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rsa" +version = "0.10.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" +dependencies = [ + "const-oid", + "crypto-bigint", + "crypto-primes", + "digest 0.11.3", + "pkcs1", + "pkcs8", + "rand_core 0.10.1", + "sha2 0.11.0", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "russh" +version = "0.64.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba61e87b9ec9a39a59a6bbed4c0b8ff7fb07073405b24a767a1f2c8560ac8f58" +dependencies = [ + "aes 0.9.3", + "bitflags 2.13.2", + "block-padding 0.4.2", + "byteorder", + "bytes", + "cbc 0.2.1", + "cipher 0.5.2", + "crypto-bigint", + "ctr 0.10.1", + "curve25519-dalek", + "data-encoding", + "delegate", + "der", + "digest 0.11.3", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "enum_dispatch", + "flate2", + "futures", + "generic-array 1.4.5", + "getrandom 0.4.3", + "ghash 0.6.0", + "hex-literal", + "hmac 0.13.0", + "inout 0.2.2", + "keccak", + "log", + "md5", + "ml-kem", + "module-lattice", + "num-bigint 0.5.1", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2", + "pkcs1", + "pkcs5", + "pkcs8", + "polyval 0.7.3", + "rand 0.10.3", + "rand_core 0.10.1", + "ring", + "rsa", + "russh-cryptovec", + "russh-util", + "salsa20", + "scrypt", + "sec1", + "sha1 0.11.0", + "sha2 0.11.0", + "sha3 0.12.0", + "signature", + "spki", + "ssh-encoding", + "ssh-key", + "subtle", + "thiserror 2.0.21", + "tokio", + "typenum", + "universal-hash 0.6.1", + "zeroize", +] + +[[package]] +name = "russh-cryptovec" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aec6cb630dbe85d72ffd7bcd95f07e1bd69f9f270ee8adfa1afe443a6331438" +dependencies = [ + "log", + "nix 0.31.3", + "ssh-encoding", + "windows-sys 0.61.2", +] + +[[package]] +name = "russh-sftp" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb17f72aba4314656b359ffba72c68dcbd70ed9e97497898b75498fe7198042" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "chrono", + "dashmap", + "gloo-timers", + "log", + "serde", + "serde_bytes", + "thiserror 2.0.21", + "tokio", + "tokio-util", + "wasm-bindgen-futures", +] + +[[package]] +name = "russh-util" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-openpty" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393" +dependencies = [ + "errno", + "libc", + "rustix 1.1.5", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher 0.5.2", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "pbkdf2", + "salsa20", + "sha2 0.11.0", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes 0.8.4", + "cbc 0.1.2", + "futures-util", + "generic-array 0.14.7", + "hkdf 0.12.4", + "num", + "once_cell", + "rand 0.8.8", + "serde", + "sha2 0.10.9", + "zbus 4.4.0", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "serde_json" +version = "1.0.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa62079d232416ecf7b2f938d389e28bbcd3677bcdb67b42e2f29c2433552176" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "serialport" +version = "4.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba5f8f29aa20853c4e3e85a33ec580eb66be1f057142e77a333834a318bacf2" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "core-foundation 0.10.1", + "core-foundation-sys", + "io-kit-sys", + "mach2", + "nix 0.26.4", + "scopeguard", + "unescaper", + "windows-sys 0.52.0", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", +] + +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b29ffa27d3d59a049bb328258de100f8012848d1b08d444e08bbc02ac468435e" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +dependencies = [ + "serde", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + +[[package]] +name = "ssh-cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597" +dependencies = [ + "aead 0.6.1", + "aes 0.9.3", + "aes-gcm 0.11.1", + "chacha20", + "cipher 0.5.2", + "ctutils", + "des", + "poly1305", + "ssh-encoding", + "zeroize", +] + +[[package]] +name = "ssh-encoding" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e" +dependencies = [ + "base64ct", + "bytes", + "crypto-bigint", + "ctutils", + "digest 0.11.3", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "ssh-key" +version = "0.7.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3" +dependencies = [ + "argon2 0.6.0", + "bcrypt-pbkdf", + "ctutils", + "ed25519-dalek", + "hex", + "hmac 0.13.0", + "p256", + "p384", + "p521", + "rand_core 0.10.1", + "rsa", + "sec1", + "sha1 0.11.0", + "sha2 0.11.0", + "signature", + "ssh-cipher", + "ssh-encoding", + "zeroize", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unescaper" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vello_common" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "cursor-icon", + "log", + "memchr", + "serde", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.7", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.5", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.5", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases 0.2.2", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases 0.2.2", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.2.2", + "hashbrown 0.17.1", + "libloading", + "log", + "naga", + "naga-types", + "portable-atomic", + "portable-atomic-util", + "raw-window-handle", + "renderdoc-sys", + "static_assertions", + "thiserror 2.0.21", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases 0.2.2", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml", + "version_check", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.5", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.8", + "serde", + "serde_repr", + "sha1 0.10.7", + "static_assertions", + "tokio", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.5", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros 5.19.0", + "zbus_names 4.3.4", + "zvariant 5.15.0", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant 5.15.0", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant 5.15.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.7", + "zbus_names 4.3.4", + "zvariant 5.15.0", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant 5.15.0", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names 4.3.4", + "zvariant 5.15.0", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive 5.15.0", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.7", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.7", + "winnow", +] diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..983f2a5 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,17 @@ +[workspace] +resolver = "3" +members = ["core", "app"] + +[workspace.package] +version = "0.1.0" +edition = "2024" + +[profile.dev.package."*"] +opt-level = 2 + +# The terminal parser is generic and gets instantiated in the app crate; unoptimised it is ~50x slower. +[profile.dev.package.nexus] +opt-level = 2 + +[profile.release] +lto = "thin" diff --git a/rust/app/Cargo.toml b/rust/app/Cargo.toml new file mode 100644 index 0000000..e1fe582 --- /dev/null +++ b/rust/app/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "nexus" +version.workspace = true +edition.workspace = true +build = "build.rs" + +[[bin]] +name = "nexus-terminal" +path = "src/main.rs" + +[dependencies] +nexus-core = { path = "../core" } +eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "wayland", "x11", "accesskit", "persistence"] } +egui = "0.36" +egui-phosphor = "0.14" +rfd = "0.17" +image = { version = "0.25", default-features = false, features = ["png"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +regex = "1" +alacritty_terminal = "0.26.0" +chrono = "0.4.45" +arboard = "3.6.1" + +[build-dependencies] +winresource = "0.1" diff --git a/rust/app/assets/fonts/Inter-Regular.ttf b/rust/app/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..ec7fdfe Binary files /dev/null and b/rust/app/assets/fonts/Inter-Regular.ttf differ diff --git a/rust/app/assets/fonts/Inter-SemiBold.ttf b/rust/app/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000..254cc56 Binary files /dev/null and b/rust/app/assets/fonts/Inter-SemiBold.ttf differ diff --git a/rust/app/assets/fonts/JetBrainsMono-Regular.ttf b/rust/app/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..f94e715 Binary files /dev/null and b/rust/app/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/rust/app/assets/fonts/OFL-Inter.txt b/rust/app/assets/fonts/OFL-Inter.txt new file mode 100644 index 0000000..9dbb993 --- /dev/null +++ b/rust/app/assets/fonts/OFL-Inter.txt @@ -0,0 +1,95 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +Note: the bundled TTFs have their private-use-area (U+E000–U+F8FF) cmap entries removed so +they do not shadow the icon font. Glyph outlines are unchanged. diff --git a/rust/app/assets/fonts/OFL-JetBrainsMono.txt b/rust/app/assets/fonts/OFL-JetBrainsMono.txt new file mode 100644 index 0000000..bcb8863 --- /dev/null +++ b/rust/app/assets/fonts/OFL-JetBrainsMono.txt @@ -0,0 +1,96 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +Note: the bundled TTFs have their private-use-area (U+E000–U+F8FF) cmap entries removed so +they do not shadow the icon font. Glyph outlines are unchanged. diff --git a/build/linux/icons/alchementary-link-256.png b/rust/app/assets/icon/icon-256.png similarity index 100% rename from build/linux/icons/alchementary-link-256.png rename to rust/app/assets/icon/icon-256.png diff --git a/build/linux/icons/alchementary-link-512.png b/rust/app/assets/icon/icon-512.png similarity index 100% rename from build/linux/icons/alchementary-link-512.png rename to rust/app/assets/icon/icon-512.png diff --git a/rust/app/build.rs b/rust/app/build.rs new file mode 100644 index 0000000..04f2ece --- /dev/null +++ b/rust/app/build.rs @@ -0,0 +1,14 @@ +//! Embeds the icon and version info into the Windows executable. +fn main() { + println!("cargo:rerun-if-changed=../packaging/windows/nexus.ico"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + let mut res = winresource::WindowsResource::new(); + if std::path::Path::new("../packaging/windows/nexus.ico").exists() { + res.set_icon("../packaging/windows/nexus.ico"); + } + res.set("ProductName", "Nexus Terminal").set("FileDescription", "Nexus Terminal"); + if let Err(e) = res.compile() { + println!("cargo:warning=Windows icon not embedded: {e}"); + } + } +} diff --git a/rust/app/src/app.rs b/rust/app/src/app.rs new file mode 100644 index 0000000..a5fea03 --- /dev/null +++ b/rust/app/src/app.rs @@ -0,0 +1,960 @@ +//! The main window: title bar, connection sidebar, tab strip and workspace (terminals, split +//! panes, protocol viewers), right dock (SFTP or the profile editor), status bar, command +//! palette, prompts and toasts. Session events from nexus-core are drained here every frame. + +use crate::state::{Pane, Request, SettingsTab, Shared, Tab, ToastKind}; +use crate::terminal::{CursorStyle, MenuAction, TermView}; +use crate::theme::{self, ThemeKind, Tokens}; +use crate::views::View; +use crate::{chrome, forwards, profile_editor, quick_connect, recordings, settings, sftp, sidebar, snippets, ssh_keys, tabs, transfer, triggers}; +use egui::{Key, KeyboardShortcut, Modifiers, Ui}; +use nexus_core::models::{Profile, Protocol, SessionInfo, SessionState}; +use nexus_core::service::Service; +use nexus_core::sessions::Event; +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, mpsc}; + +pub struct Options { + pub screenshot: Option, + pub theme: Option, + /// Open a local terminal at startup and type this command once connected (testing aid). + pub run_local: Option, + /// Open a window at startup: editor, quick, settings[:tab], keys, snippets, triggers, + /// recordings, palette, export, import (screenshot checks). + pub open: Option, + /// Connect saved profiles (by name or id) at startup. + pub connect: Vec, + /// Command-palette actions (by label) to run once sessions have had time to connect. + pub actions: Vec, + /// Replies to the first credential prompts, in order (screenshots of token-protected sessions). + pub answers: Vec, + /// How long to wait before taking the screenshot. + pub shot_delay: std::time::Duration, +} + +/// Errors from worker threads, drained into toasts each frame. +static POSTED: Mutex> = Mutex::new(Vec::new()); + +pub fn post_error(e: String) { + POSTED.lock().unwrap().push(e); +} + +/// A credential or host-key question from a connecting session. +pub enum Prompt { + Secret { session: String, title: String, message: String, value: String, reply: mpsc::Sender }, + HostKey { host: String, key_type: String, fingerprint: String, reply: mpsc::Sender }, +} + +/// Modal dialogs owned by the main window. +pub enum Dialog { + /// A profile already has a live session. + Reuse { + profile: String, + existing: String, + }, + /// Unencrypted VNC without an SSH tunnel. + DirectVnc { + profile: String, + }, + ConfirmClose { + session: String, + }, + ConfirmDisconnectMany { + sessions: Vec, + }, + Rename { + session: String, + name: String, + }, + Broadcast { + picks: Vec<(String, String, bool)>, + }, + /// Guided help for an auth or host-key failure. + Guided { + session: String, + title: String, + body: String, + known_hosts: bool, + }, + Delete { + profiles: Vec, + }, + Input { + title: String, + label: String, + value: String, + then: InputThen, + }, +} + +pub enum InputThen { + Tags { profiles: Vec, replace: bool }, + MoveGroup { profiles: Vec }, +} + +pub struct App { + pub s: Shared, + rx: mpsc::Receiver, + pub theme: ThemeKind, + pub editor: profile_editor::ProfileEditor, + pub quick: quick_connect::QuickConnect, + pub settings: settings::Settings, + pub keys: ssh_keys::SshKeys, + pub transfer: transfer::Transfer, + pub sftp: sftp::Sftp, + pub forwards: forwards::Forwards, + pub snippets: snippets::Snippets, + pub triggers: triggers::Triggers, + pub recordings: recordings::Recordings, + pub sidebar: sidebar::Sidebar, + pub palette: Option, + pub prompts: VecDeque, + pub dialog: Option, + pub tabs_ui: tabs::TabsUi, + pub latency: Option<(String, Result)>, + pub update: Option, + screenshot: Option<(PathBuf, std::time::Instant, std::time::Duration)>, + shot_settle: u32, + actions: Vec, + answers: Vec, + run_local: Option<(String, Option)>, + /// Apply `prefs.sidebar_width` to the panel on the next frame (after Settings changed it). + resize_sidebar: bool, +} + +const fn sc(m: Modifiers, k: Key) -> KeyboardShortcut { + KeyboardShortcut::new(m, k) +} +const CMD: Modifiers = Modifiers::COMMAND; +const CMD_SHIFT: Modifiers = Modifiers::COMMAND.plus(Modifiers::SHIFT); + +impl App { + pub fn new(cc: &eframe::CreationContext, svc: Arc, opts: Options) -> App { + theme::install_fonts(&cc.egui_ctx); + let (tx, rx) = mpsc::channel(); + let ctx = cc.egui_ctx.clone(); + let tx = Mutex::new(tx); + svc.sessions.set_sink(Arc::new(move |e| { + let _ = tx.lock().unwrap().send(e); + ctx.request_repaint(); + })); + let s = Shared::new(svc.clone(), cc.egui_ctx.clone()); + let theme = opts.theme.as_deref().and_then(ThemeKind::from_id).unwrap_or_else(|| theme_for(&s.prefs.theme)); + theme::apply(&cc.egui_ctx, theme); + let mut app = App { + s, + rx, + theme, + editor: Default::default(), + quick: Default::default(), + settings: Default::default(), + keys: Default::default(), + transfer: Default::default(), + sftp: Default::default(), + forwards: Default::default(), + snippets: Default::default(), + triggers: Default::default(), + recordings: Default::default(), + sidebar: Default::default(), + palette: None, + prompts: VecDeque::new(), + dialog: None, + tabs_ui: Default::default(), + latency: None, + update: None, + screenshot: opts.screenshot.map(|p| (p, std::time::Instant::now(), opts.shot_delay)), + shot_settle: 0, + actions: opts.actions, + answers: opts.answers, + run_local: opts.run_local.map(|c| (c, None)), + resize_sidebar: false, + }; + for c in &opts.connect { + match app.s.profiles.iter().find(|p| &p.id == c || &p.display_name == c).map(|p| p.id.clone()) { + Some(id) => app.connect_profile(&id, true), + None => eprintln!("--connect: no profile named {c:?}"), + } + } + if let Some(w) = opts.open.as_deref() { + app.open_window(w); + } + if let Some((_, sid)) = &mut app.run_local { + let id = app.s.svc.connect_local(); + *sid = Some(id.clone()); + app.add_session(&id, true); + } + if let Some(from) = &svc.config.migrated_from { + app.s.success(format!("Imported your Alchementary Link data from {}", from.display())); + } + // Background update check, once per launch. + if app.screenshot.is_none() { + let svc2 = svc.clone(); + let ctx = cc.egui_ctx.clone(); + std::thread::spawn(move || { + if let Ok(info) = svc2.check_for_updates() { + ctx.data_mut(|d| d.insert_temp(egui::Id::new("nexus-update"), Arc::new(info))); + ctx.request_repaint(); + } + }); + } + app + } + + fn open_window(&mut self, w: &str) { + let (w, arg) = w.split_once(':').unwrap_or((w, "")); + match w { + "editor" => match self.s.profiles.iter().find(|p| p.protocol.id() == arg || p.id == arg).map(|p| p.id.clone()) { + Some(id) => self.editor.open_edit(&self.s, &id), + None => self.editor.open_new(&self.s, Protocol::from_id(arg), ""), + }, + "quick" => self.quick.open(), + "settings" => self.settings.open(match arg { + "terminal" => SettingsTab::Terminal, + "security" => SettingsTab::Security, + "knownhosts" => SettingsTab::KnownHosts, + "help" => SettingsTab::Help, + "about" => SettingsTab::About, + _ => SettingsTab::General, + }), + "keys" => self.keys.open(), + "snippets" => self.snippets.open(), + "triggers" => self.triggers.open(), + "recordings" => self.recordings.open(), + "palette" => self.palette = Some(Default::default()), + "export" => self.transfer.export_encrypted(vec![]), + "import" => self.transfer.import_encrypted(), + _ => eprintln!("--open: unknown window {w:?}"), + } + } + + // --- sessions ------------------------------------------------------------------------- + + fn view_for(&self, info: &SessionInfo, profile: Option<&Profile>) -> View { + let prefs = &self.s.prefs; + match (info.protocol, profile) { + (Protocol::Raw, Some(p)) => View::Raw(Box::new(crate::raw_view::RawView::new(&info.id, p))), + (Protocol::Irc, Some(p)) => View::Irc(Box::new(crate::irc_view::IrcView::new(&info.id, p))), + (Protocol::Github, Some(p)) => View::Github(Box::new(crate::github_view::GithubView::new(&info.id, &p.display_name))), + (Protocol::Vnc, Some(p)) => View::Vnc(Box::new(crate::vnc_view::VncView::new(&info.id, p))), + (_, p) => { + let (scroll, size, cursor) = match p { + Some(p) => (p.terminal_scrollback, p.terminal_font_size, p.terminal_cursor_style.clone()), + None => (prefs.default_scrollback, prefs.default_font_size, prefs.default_cursor_style.clone()), + }; + View::Terminal(Box::new(TermView::new(scroll.max(0) as usize, size as f32, CursorStyle::from_id(&cursor)))) + } + } + } + + /// Registers a just-opened session as a pane (and a tab unless it's a split child). + pub fn add_session(&mut self, id: &str, tab: bool) { + let Some(info) = self.s.svc.sessions.info(id) else { + return; + }; + let profile = self.s.svc.sessions.profile(id); + let view = self.view_for(&info, profile.as_ref()); + let lines = profile.as_ref().and_then(|p| p.serial.as_ref()).map_or((true, true), |c| (c.dtr_on_connect, c.rts_on_connect)); + self.s.panes.insert(id.into(), Pane { info, view, recording: false, local_exited: false, connected_since: None, highlight_until: 0.0, lines }); + if tab { + self.s.tabs.push(Tab { id: id.into(), activity: false, split: None }); + self.s.active = Some(id.into()); + self.s.focused = Some(id.into()); + } + } + + /// Connect a saved profile, asking first if it already has a live session. + pub fn connect_profile(&mut self, id: &str, skip_checks: bool) { + let Some(p) = self.s.profile(id).cloned() else { + self.s.error("Profile not found"); + return; + }; + if !skip_checks { + let live = self.s.panes.values().find(|pn| { + pn.info.profile_id == id + && matches!(self.s.session_state(&pn.info.id), Some(SessionState::Connected | SessionState::Connecting | SessionState::Reconnecting)) + }); + if let Some(pn) = live { + self.dialog = Some(Dialog::Reuse { profile: id.into(), existing: pn.info.id.clone() }); + return; + } + if p.protocol == Protocol::Vnc && !p.vnc.as_ref().is_some_and(|v| v.ssh_tunnel) { + self.dialog = Some(Dialog::DirectVnc { profile: id.into() }); + return; + } + } + match self.s.svc.connect_profile(id) { + Ok(sid) => { + self.add_session(&sid, true); + self.s.reload_profiles(); + self.s.info(format!("Connecting to {}…", if p.display_name.is_empty() { &p.host } else { &p.display_name })); + } + Err(e) => self.s.error(format!("Connection failed: {e}")), + } + } + + pub fn connect_local(&mut self) { + let sid = self.s.svc.connect_local(); + self.add_session(&sid, true); + self.s.info("Opening local terminal…"); + } + + /// Closes a session's UI and backend. Tabs close their split children too. + pub fn close_session(&mut self, id: &str) { + let children: Vec = self.s.tab(id).and_then(|t| t.split.as_ref()).map(|sp| sp.sessions()).unwrap_or_default(); + for c in children.iter().filter(|c| *c != id) { + self.close_one(c); + } + if let Some(pos) = self.s.tabs.iter().position(|t| t.id == id) { + self.s.tabs.remove(pos); + if self.s.active.as_deref() == Some(id) { + // The nearest neighbour becomes active. + let next = self.s.tabs.get(pos).or_else(|| self.s.tabs.get(pos.wrapping_sub(1))).map(|t| t.id.clone()); + self.s.active = next.clone(); + self.s.focused = next; + } + } else { + crate::splits::remove_session(&mut self.s, id); + } + self.close_one(id); + } + + fn close_one(&mut self, id: &str) { + self.sftp.close(id); + self.triggers.cleanup(id); + self.s.panes.remove(id); + if self.s.focused.as_deref() == Some(id) { + self.s.focused = self.s.active.clone(); + } + let svc = self.s.svc.clone(); + let id = id.to_string(); + std::thread::spawn(move || { + let _ = svc.sessions.disconnect(&id); + }); + } + + /// Close with the "confirm on close" preference (not for GitHub tabs). + pub fn request_close(&mut self, id: &str) { + let proto = self.s.pane(id).map(|p| p.info.protocol); + let live = matches!(self.s.session_state(id), Some(SessionState::Connected)); + if self.s.prefs.confirm_on_close && live && proto != Some(Protocol::Github) { + self.dialog = Some(Dialog::ConfirmClose { session: id.into() }); + } else { + self.close_session(id); + } + } + + pub fn reconnect(&mut self, id: &str) { + let old = self.s.pane(id).map(|p| p.info.tab_name.clone()); + match self.s.svc.reconnect(id) { + Ok(new) => { + // Keep the tab in place; swap in the new session id. + let info = self.s.svc.sessions.info(&new); + if let (Some(mut pane), Some(info)) = (self.s.panes.remove(id), info) { + pane.info = info; + pane.local_exited = false; + if let View::Terminal(t) = &mut pane.view { + t.feed(b"\r\n\x1b[2m--- reconnecting ---\x1b[0m\r\n"); + } + self.s.panes.insert(new.clone(), pane); + } + for t in &mut self.s.tabs { + if t.id == id { + t.id = new.clone(); + } + if let Some(sp) = &mut t.split { + sp.rename(id, &new); + } + } + if self.s.active.as_deref() == Some(id) { + self.s.active = Some(new.clone()); + } + if self.s.focused.as_deref() == Some(id) { + self.s.focused = Some(new.clone()); + } + self.s.info(format!("Reconnecting {}…", old.unwrap_or_default())); + } + Err(e) => self.s.error(format!("Reconnect failed: {e}")), + } + } + + /// Sends user input to a session and, in broadcast mode, to the other targets. + pub fn send_input(&mut self, from: &str, data: &[u8]) { + if data.is_empty() { + return; + } + let svc = self.s.svc.clone(); + let _ = svc.sessions.write(from, data); + if self.s.broadcast { + for id in self.s.all_sessions() { + if id == from || !self.s.broadcast_targets.as_ref().is_none_or(|t| t.contains(&id)) { + continue; + } + if self.s.session_state(&id) == Some(SessionState::Connected) && self.s.pane(&id).is_some_and(|p| matches!(p.view, View::Terminal(_))) { + let _ = svc.sessions.write(&id, data); + } + } + } + } + + // --- events --------------------------------------------------------------------------- + + fn drain_events(&mut self) { + for e in POSTED.lock().unwrap().drain(..) { + self.s.error(e); + } + while let Ok(e) = self.rx.try_recv() { + self.on_event(e); + } + if let Some(info) = self.s.ctx.data_mut(|d| d.remove_temp::>(egui::Id::new("nexus-update"))) { + self.update = Some((*info).clone()); + } + } + + fn mark_activity(&mut self, session: &str) { + let active = self.s.active.clone(); + if let Some(t) = self.s.tabs.iter_mut().find(|t| t.id == session || t.split.as_ref().is_some_and(|sp| sp.sessions().iter().any(|x| x == session))) + && active.as_deref() != Some(&t.id) + { + t.activity = true; + } + } + + fn on_event(&mut self, e: Event) { + match e { + Event::Data { session, data } => { + if let Some(View::Terminal(t)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) { + t.feed(&data); + } + self.mark_activity(&session); + let now = self.s.now; + if self.triggers.check(&mut self.s, &session, &data) + && let Some(p) = self.s.pane_mut(&session) + { + p.highlight_until = now + 1.5; + } + } + Event::State { session, state, error, retry } => self.on_state(&session, state, &error, retry), + Event::Closed { session } => { + self.sftp.close(&session); + self.triggers.cleanup(&session); + } + Event::RawData { session, data, direction, timestamp } => { + if let Some(View::Raw(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) { + v.on_data(&data, &direction, timestamp); + } + self.mark_activity(&session); + } + Event::Debug { .. } => {} + Event::GithubStatus { session, status } => { + if let Some(View::Github(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) { + v.on_status(status); + } + } + Event::GithubRepos { session, login, repos } => { + if let Some(View::Github(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) { + v.on_repos(login, repos); + } + } + Event::GithubError { session, error } => { + if let Some(View::Github(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) { + v.on_error(error); + } + } + Event::Irc { session, event } => { + if let Some(View::Irc(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) { + v.on_event(event); + } + self.mark_activity(&session); + } + Event::ForwardStarted { forward, .. } => { + let label = if forward.label.is_empty() { + format!("{}:{} → {}:{}", forward.bind_host, forward.bind_port, forward.dest_host, forward.dest_port) + } else { + forward.label.clone() + }; + self.s.success(format!("Forward started: {label}")); + } + Event::ForwardStopped { forward_id, .. } => self.s.info(format!("Forward stopped: {forward_id}")), + Event::ForwardError { error, .. } => self.s.error(format!("Forward error: {error}")), + Event::RecordingStarted { session } => { + if let Some(p) = self.s.pane_mut(&session) { + p.recording = true; + } + } + Event::RecordingStopped { session, duration, events } => { + if let Some(p) = self.s.pane_mut(&session) { + p.recording = false; + } + self.s.success(format!("Recording saved ({:.0}s, {events} events)", duration)); + } + Event::SftpProgress { session, progress } => self.sftp.on_progress(&session, progress), + Event::SecretPrompt { session, title, message, reply } if !self.answers.is_empty() => { + let _ = (session, title, message, reply.send(self.answers.remove(0))); + } + Event::SecretPrompt { session, title, message, reply } => { + self.prompts.push_back(Prompt::Secret { session, title, message, value: String::new(), reply }); + } + Event::HostKeyVerify { host, key_type, fingerprint, reply, .. } => { + self.prompts.push_back(Prompt::HostKey { host, key_type, fingerprint, reply }); + } + Event::VncReady { session } => { + if let Some(View::Vnc(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) { + v.on_ready(); + } + } + Event::VncFrame { .. } => {} + Event::VncClipboard { text, .. } => self.s.ctx.copy_text(text), + } + } + + fn on_state(&mut self, session: &str, state: SessionState, error: &str, retry: Option) { + // A reconnect replaces the info wholesale. + if let Some(info) = self.s.svc.sessions.info(session) + && let Some(p) = self.s.pane_mut(session) + { + p.info = info; + p.info.state = state; + if !error.is_empty() { + p.info.error = error.into(); + } + } + let Some(p) = self.s.pane_mut(session) else { + return; + }; + let local = p.info.protocol == Protocol::Local; + match state { + SessionState::Connected => { + if let Some((cmd, Some(sid))) = &self.run_local + && sid == session + { + let _ = self.s.svc.sessions.write(session, format!("{cmd}\n").as_bytes()); + self.run_local = None; + } + let Some(p) = self.s.pane_mut(session) else { + return; + }; + p.local_exited = false; + p.connected_since = Some(chrono::Local::now()); + // The PTY was created before the view knew its size: send it now. + if let View::Terminal(t) = &mut p.view { + let (c, r) = t.size(); + let _ = self.s.svc.sessions.resize(session, c, r); + } + } + SessionState::Disconnected if local => { + p.local_exited = true; + if let View::Terminal(t) = &mut p.view { + t.feed(b"\r\n\x1b[2m[Exited - press enter to restart]\x1b[0m\r\n"); + } + } + SessionState::Reconnecting => { + if let Some(r) = retry { + self.s.info(format!("Reconnecting (attempt {}/{}) in {}s…", r.attempt, r.max, r.delay_secs)); + } + } + SessionState::Error if !error.is_empty() => { + if let Some((title, body, kh)) = classify_error(error) { + self.dialog = Some(Dialog::Guided { session: session.into(), title: title.into(), body: format!("{body}\n\n{error}"), known_hosts: kh }); + } + } + _ => {} + } + } + + // --- requests, shortcuts, commands ---------------------------------------------------- + + fn handle_requests(&mut self) { + for r in std::mem::take(&mut self.s.requests) { + match r { + Request::NewProfile { protocol, folder } => self.editor.open_new(&self.s, protocol, &folder), + Request::EditProfile(id) => self.editor.open_edit(&self.s, &id), + Request::Connect(id) => self.connect_profile(&id, false), + Request::ConnectQuick(p) => { + let name = if p.display_name.is_empty() { p.host.clone() } else { p.display_name.clone() }; + let sid = self.s.svc.connect_quick(p); + self.add_session(&sid, true); + self.s.info(format!("Connecting to {name}…")); + } + Request::ReloadProfiles => self.s.reload_profiles(), + Request::ReloadPreferences => { + self.s.prefs = self.s.svc.preferences(); + self.resize_sidebar = true; + self.theme = theme_for(&self.s.prefs.theme); + theme::apply(&self.s.ctx, self.theme); + } + Request::ExportEncrypted(ids) => self.transfer.export_encrypted(ids), + Request::ImportEncrypted => self.transfer.import_encrypted(), + Request::Sftp(id) => self.sftp.toggle(&id), + Request::SendToActive(text) => match self.s.focused_session() { + Some(id) if self.s.session_state(&id) == Some(SessionState::Connected) => self.send_input(&id, text.as_bytes()), + _ => self.s.error("No active connected session"), + }, + Request::Write(id, data) => self.send_input(&id, &data), + } + } + } + + fn shortcuts(&mut self, ctx: &egui::Context) { + let pressed = |s: KeyboardShortcut| ctx.input_mut(|i| i.consume_shortcut(&s)); + if pressed(sc(CMD, Key::K)) { + self.palette = if self.palette.is_some() { None } else { Some(chrome::Palette::default()) }; + } + if pressed(sc(CMD, Key::N)) { + self.s.request(Request::NewProfile { protocol: None, folder: String::new() }); + } + if pressed(sc(CMD, Key::W)) + && let Some(id) = self.s.focused_session() + { + self.request_close(&id); + } + if pressed(sc(CMD_SHIFT, Key::Tab)) { + self.cycle_tab(-1); + } + if pressed(sc(CMD, Key::Tab)) { + self.cycle_tab(1); + } + for (i, k) in [Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9].into_iter().enumerate() { + if pressed(sc(CMD, k)) + && let Some(t) = tabs::ordered(&self.s).get(i) + { + let id = t.clone(); + self.activate(&id); + } + } + if pressed(sc(CMD, Key::Comma)) { + self.settings.open(SettingsTab::General); + } + if pressed(sc(CMD, Key::F)) { + self.open_find(); + } + if pressed(sc(CMD_SHIFT, Key::D)) { + crate::splits::split_focused(self, crate::splits::Dir::Right); + } + if pressed(sc(CMD_SHIFT, Key::E)) { + crate::splits::split_focused(self, crate::splits::Dir::Down); + } + if pressed(sc(CMD_SHIFT, Key::R)) + && let Some(id) = self.s.focused_session() + { + self.toggle_recording(&id); + } + if pressed(sc(CMD_SHIFT, Key::B)) { + self.toggle_broadcast(); + } + if pressed(sc(CMD_SHIFT, Key::S)) { + self.snippets.open(); + } + if pressed(sc(CMD_SHIFT, Key::Q)) { + self.quick.open(); + } + if pressed(sc(Modifiers::NONE, Key::F11)) { + let full = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!full)); + } + } + + pub fn activate(&mut self, id: &str) { + self.s.active = Some(id.into()); + self.s.focused = Some(id.into()); + if let Some(t) = self.s.tabs.iter_mut().find(|t| t.id == id) { + t.activity = false; + } + if let Some(View::Terminal(t)) = self.s.panes.get_mut(id).map(|p| &mut p.view) { + t.request_focus(); + } + } + + fn cycle_tab(&mut self, dir: i32) { + let order = tabs::ordered(&self.s); + if order.is_empty() { + return; + } + let cur = self.s.active.as_ref().and_then(|a| order.iter().position(|x| x == a)).unwrap_or(0) as i32; + let next = (cur + dir).rem_euclid(order.len() as i32) as usize; + let id = order[next].clone(); + self.activate(&id); + } + + pub fn open_find(&mut self) { + if let Some(id) = self.s.focused_session() + && let Some(View::Terminal(t)) = self.s.panes.get_mut(&id).map(|p| &mut p.view) + { + if t.find.is_none() { + t.find = Some(Default::default()); + } + self.tabs_ui.find_focus = true; + } + } + + pub fn toggle_recording(&mut self, id: &str) { + let svc = self.s.svc.clone(); + if svc.sessions.is_recording(id) { + if let Err(e) = svc.sessions.stop_recording(id) { + self.s.error(e.to_string()); + } + } else { + let (c, r) = match self.s.pane(id).map(|p| &p.view) { + Some(View::Terminal(t)) => t.size(), + _ => (80, 24), + }; + match svc.sessions.start_recording(id, c, r) { + Ok(()) => self.s.info("Recording started"), + Err(e) => self.s.error(e.to_string()), + } + } + } + + /// Telnet or serial break. + pub fn send_break(&mut self, id: &str) { + match self.s.svc.sessions.send_break(id) { + Ok(()) => self.s.info("Break sent"), + Err(e) => self.s.error(e.to_string()), + } + } + + /// Toggles a serial modem line: `dtr` true for DTR, false for RTS. + pub fn toggle_line(&mut self, id: &str, dtr: bool) { + let Some((d, r)) = self.s.pane(id).map(|p| p.lines) else { return }; + let (d, r) = if dtr { (!d, r) } else { (d, !r) }; + match self.s.svc.sessions.set_serial_lines(id, d, r) { + Ok(()) => { + if let Some(p) = self.s.pane_mut(id) { + p.lines = (d, r); + } + self.s.info(format!("DTR {} · RTS {}", if d { "on" } else { "off" }, if r { "on" } else { "off" })); + } + Err(e) => self.s.error(e.to_string()), + } + } + + pub fn toggle_broadcast(&mut self) { + self.s.broadcast = !self.s.broadcast; + if !self.s.broadcast { + self.s.broadcast_targets = None; + } + self.s.info(if self.s.broadcast { "Broadcast mode enabled — input sent to all sessions" } else { "Broadcast mode disabled" }); + } + + pub fn pick_broadcast_targets(&mut self) { + let focused = self.s.focused_session(); + let picks: Vec<(String, String, bool)> = self + .s + .panes + .values() + .filter(|p| Some(&p.info.id) != focused.as_ref() && self.s.session_state(&p.info.id) == Some(SessionState::Connected)) + .map(|p| (p.info.id.clone(), p.info.tab_name.clone(), self.s.broadcast_targets.as_ref().is_none_or(|t| t.contains(&p.info.id)))) + .collect(); + if picks.is_empty() { + self.s.error("No other connected sessions to broadcast to"); + return; + } + self.dialog = Some(Dialog::Broadcast { picks }); + } + + /// A terminal's context-menu action. + pub fn terminal_menu(&mut self, id: &str, a: MenuAction) { + match a { + MenuAction::Paste => { + if let Ok(text) = arboard::Clipboard::new().and_then(|mut c| c.get_text()) + && let Some(View::Terminal(t)) = self.s.panes.get_mut(id).map(|p| &mut p.view) + { + let mut out = crate::terminal::TermResponse::default(); + t.paste(&text, &mut out); + let input = out.input; + self.send_input(id, &input); + } + } + MenuAction::Find => { + self.s.focused = Some(id.into()); + self.open_find(); + } + MenuAction::SendSnippet => self.snippets.open(), + MenuAction::PortForwards => { + if self.s.pane(id).is_some_and(|p| p.info.protocol == Protocol::Ssh) { + self.forwards.open(id); + } else { + self.s.error("Port forwards are only available on SSH sessions"); + } + } + MenuAction::SplitRight | MenuAction::SplitDown => { + self.s.focused = Some(id.into()); + crate::splits::split_focused(self, if a == MenuAction::SplitRight { crate::splits::Dir::Right } else { crate::splits::Dir::Down }); + } + MenuAction::Export => { + if let Some(View::Terminal(t)) = self.s.panes.get(id).map(|p| &p.view) { + let text = t.buffer_text(); + if let Some(path) = rfd::FileDialog::new().set_file_name("terminal-output.txt").save_file() { + match std::fs::write(&path, text) { + Ok(()) => self.s.success(format!("Saved {}", path.display())), + Err(e) => self.s.error(format!("Export failed: {e}")), + } + } + } + } + MenuAction::Clear => { + if let Some(View::Terminal(t)) = self.s.panes.get_mut(id).map(|p| &mut p.view) { + t.clear(); + } + } + MenuAction::Reset => { + if let Some(View::Terminal(t)) = self.s.panes.get_mut(id).map(|p| &mut p.view) { + t.reset(); + } + } + MenuAction::Copy | MenuAction::SelectAll => {} + } + } + + fn file_drops(&mut self, ctx: &egui::Context) { + let dropped: Vec = ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_path_buf()).filter(|p| !p.as_os_str().is_empty()).collect()); + if dropped.is_empty() { + return; + } + match self.s.focused_session() { + Some(id) if self.s.pane(&id).is_some_and(|p| matches!(p.view, View::Terminal(_))) => self.sftp.drop_files(&mut self.s, &id, dropped), + _ => self.s.error("Drop files on a terminal session"), + } + } + + // --- screenshot automation --------------------------------------------------------- + + fn screenshot(&mut self, ctx: &egui::Context) { + let Some((path, started, delay)) = &mut self.screenshot else { + return; + }; + let (elapsed, delay) = (started.elapsed(), *delay); + if !self.actions.is_empty() && elapsed >= delay / 2 { + for a in std::mem::take(&mut self.actions) { + if !chrome::run_labelled(self, ctx, &a) { + eprintln!("--do: no palette command {a:?}"); + } + } + ctx.request_repaint(); + return; + } + let path = path.clone(); + if elapsed < delay { + std::thread::sleep(std::time::Duration::from_millis(15)); + ctx.request_repaint(); + return; + } + if self.shot_settle == 0 { + self.shot_settle = 1; + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); + } + let shot = ctx.input(|i| i.events.iter().find_map(|e| if let egui::Event::Screenshot { image, .. } = e { Some(image.clone()) } else { None })); + if let Some(img) = shot { + let [w, h] = img.size; + let raw: Vec = img.pixels.iter().flat_map(|p| p.to_array()).collect(); + if let Err(e) = image::save_buffer(&path, &raw, w as u32, h as u32, image::ExtendedColorType::Rgba8) { + eprintln!("screenshot: {e}"); + } + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + ctx.request_repaint(); + } +} + +/// App chrome theme from the "theme" preference (the web build used dark/light). +pub fn theme_for(pref: &str) -> ThemeKind { + ThemeKind::from_id(pref).unwrap_or(if pref == "light" { ThemeKind::Light } else { ThemeKind::Pro }) +} + +/// Recognises auth and host-key failures for the guided error dialog. +pub fn classify_error(msg: &str) -> Option<(&'static str, &'static str, bool)> { + let m = msg.to_lowercase(); + if ["unable to authenticate", "authentication failed", "permission denied", "no supported methods"].iter().any(|p| m.contains(p)) { + return Some(("Authentication Failed", "The server rejected the provided credentials. Check username, password, keys, and auth methods.", false)); + } + if ["host key", "fingerprint"].iter().any(|p| m.contains(p)) { + return Some(("Host Key Verification Failed", "The server's host key could not be verified. Manage known hosts or update the profile's policy.", true)); + } + None +} + +impl eframe::App for App { + fn persist_egui_memory(&self) -> bool { + self.screenshot.is_none() + } + + fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { + let ctx = ui.ctx().clone(); + let t = Tokens::get(&ctx); + self.s.now = ctx.input(|i| i.time); + self.drain_events(); + self.handle_requests(); + // Dialogs and the palette take the keyboard while open. + if self.palette.is_none() && self.dialog.is_none() && self.prompts.is_empty() { + self.shortcuts(&ctx); + } + if ctx.input(|i| i.key_pressed(Key::Escape)) && ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)) && self.palette.is_none() { + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false)); + } + self.file_drops(&ctx); + + egui::Panel::top("titlebar").exact_size(38.0).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| chrome::title_bar(self, ui)); + if self.s.prefs.show_status_bar { + egui::Panel::bottom("status").exact_size(26.0).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| chrome::status_bar(self, ui)); + } + let width = self.s.prefs.sidebar_width.clamp(180, 500) as f32; + let mut panel = egui::Panel::left("sidebar").default_size(width); + if std::mem::take(&mut self.resize_sidebar) { + panel = panel.exact_size(width); + } + let side = panel.min_size(180.0).max_size(500.0).resizable(true).frame(egui::Frame::NONE.fill(t.dock)).show(ui, |ui| sidebar::ui(self, ui)); + let w = side.response.rect.width().round() as i64; + if (w - self.s.prefs.sidebar_width).abs() > 2 && !ctx.input(|i| i.pointer.any_down()) { + self.s.prefs.sidebar_width = w; + let _ = self.s.svc.save_preferences(&self.s.prefs); + } + if self.editor.is_open() { + egui::Panel::right("editor") + .default_size(460.0) + .min_size(380.0) + .max_size(800.0) + .resizable(true) + .frame(egui::Frame::NONE.fill(t.dock).inner_margin(6)) + .show(ui, |ui| { + self.editor.ui(ui, &mut self.s); + }); + } else if let Some(id) = self.sftp.open_for().map(String::from) { + if self.s.pane(&id).is_some() { + egui::Panel::right("sftp") + .default_size(380.0) + .min_size(280.0) + .max_size(800.0) + .resizable(true) + .frame(egui::Frame::NONE.fill(t.dock).inner_margin(6)) + .show(ui, |ui| { + self.sftp.ui(ui, &mut self.s); + }); + } else { + self.sftp.close(&id); + } + } + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.dock)).show(ui, |ui| tabs::workspace(self, ui)); + + // Floating windows and modals. + self.quick.ui(&ctx, &mut self.s); + self.settings.ui(&ctx, &mut self.s); + self.keys.ui(&ctx, &mut self.s); + self.transfer.ui(&ctx, &mut self.s); + self.sftp.dialogs(&ctx, &mut self.s); + self.forwards.ui(&ctx, &mut self.s); + self.snippets.ui(&ctx, &mut self.s); + self.triggers.ui(&ctx, &mut self.s); + self.recordings.ui(&ctx, &mut self.s); + chrome::dialogs(self, &ctx); + chrome::prompts(self, &ctx); + chrome::palette(self, &ctx); + chrome::toasts(self, &ctx); + self.screenshot(&ctx); + chrome::resize_edges(&ctx); + // Requests raised while drawing are handled on the next frame. + if !self.s.requests.is_empty() { + ctx.request_repaint(); + } + let _ = ToastKind::Info; + } +} diff --git a/rust/app/src/chrome.rs b/rust/app/src/chrome.rs new file mode 100644 index 0000000..7d38bb3 --- /dev/null +++ b/rust/app/src/chrome.rs @@ -0,0 +1,1026 @@ +//! Window chrome: custom title bar, status bar, command palette, credential and host-key +//! prompts, the main window's modal dialogs, toasts and resize edges. + +use crate::app::{App, Dialog, InputThen, Prompt}; +use crate::state::{Request, SettingsTab, ToastKind, keys}; +use crate::theme::{self, ThemeKind, Tokens}; +use crate::views::View; +use crate::widgets; +use egui::{Align2, Color32, Key, Rect, RichText, Sense, Stroke, Ui, ViewportCommand, pos2, vec2}; +use egui_phosphor::regular as icon; +use nexus_core::models::{Protocol, SessionState}; + +pub const LOGO_PNG: &[u8] = include_bytes!("../assets/icon/icon-256.png"); + +/// The Nexus mark, uploaded once. +pub fn logo(ctx: &egui::Context) -> egui::TextureHandle { + let id = egui::Id::new("nexus-logo"); + if let Some(t) = ctx.data(|d| d.get_temp::(id)) { + return t; + } + let img = image::load_from_memory(LOGO_PNG).map(|i| i.to_rgba8()).unwrap_or_default(); + let color = egui::ColorImage::from_rgba_unmultiplied([img.width() as usize, img.height() as usize], img.as_raw()); + let opts = egui::TextureOptions { mipmap_mode: Some(egui::TextureFilter::Linear), ..egui::TextureOptions::LINEAR }; + let tex = ctx.load_texture("nexus-logo", color, opts); + ctx.data_mut(|d| d.insert_temp(id, tex.clone())); + tex +} + +fn brand(ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.add(egui::Image::new(&logo(ui.ctx())).fit_to_exact_size(vec2(22.0, 22.0))); + ui.label(RichText::new("Nexus").font(theme::semibold(13.5)).color(t.accent)); + ui.add_space(-6.0); + ui.label(RichText::new("Terminal").font(theme::semibold(13.5))); +} + +fn item(ui: &mut Ui, glyph: &str, label: &str, shortcut: &str) -> bool { + let clicked = ui.add(egui::Button::new(format!("{glyph} {label}")).shortcut_text(keys(shortcut)).min_size(vec2(230.0, 0.0))).clicked(); + if clicked { + ui.close(); + } + clicked +} + +pub fn title_bar(app: &mut App, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let bar = ui.max_rect(); + let drag = ui.interact(bar, ui.id().with("titlebar"), Sense::click_and_drag()); + if drag.drag_started() { + ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag); + } + if drag.double_clicked() { + let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); + ui.ctx().send_viewport_cmd(ViewportCommand::Maximized(!max)); + } + let mut menus_right = bar.left(); + ui.horizontal_centered(|ui| { + ui.add_space(10.0); + brand(ui); + ui.add_space(8.0); + egui::MenuBar::new().ui(ui, |ui| { + ui.menu_button("File", |ui| { + if item(ui, icon::PLUS_CIRCLE, "New connection…", "Mod+N") { + app.s.request(Request::NewProfile { protocol: None, folder: String::new() }); + } + if item(ui, icon::LIGHTNING, "Quick connect…", "Mod+Shift+Q") { + app.quick.open(); + } + if item(ui, icon::TERMINAL_WINDOW, "Local terminal", "") { + app.connect_local(); + } + ui.separator(); + if item(ui, icon::DOWNLOAD_SIMPLE, "Import profiles…", "") { + app.s.request(Request::ImportEncrypted); + } + if item(ui, icon::UPLOAD_SIMPLE, "Export profiles…", "") { + app.s.request(Request::ExportEncrypted(vec![])); + } + if item(ui, icon::FILE_ARROW_DOWN, "Import ~/.ssh/config", "") { + match app.s.svc.import_ssh_config(None, "SSH Config") { + Ok(n) => { + app.s.success(format!("Imported {n} host(s) from ~/.ssh/config")); + app.s.reload_profiles(); + } + Err(e) => app.s.error(format!("Import failed: {e}")), + } + } + ui.separator(); + if item(ui, icon::GEAR, "Settings", "Mod+,") { + app.settings.open(SettingsTab::General); + } + if item(ui, icon::SIGN_OUT, "Quit", "") { + ui.ctx().send_viewport_cmd(ViewportCommand::Close); + } + }); + ui.menu_button("Session", |ui| { + let focused = app.s.focused_session(); + let ssh = focused.as_deref().and_then(|f| app.s.pane(f)).is_some_and(|p| p.info.protocol == Protocol::Ssh); + if item(ui, icon::MAGNIFYING_GLASS, "Find in terminal", "Mod+F") { + app.open_find(); + } + if item(ui, icon::SQUARE_SPLIT_HORIZONTAL, "Split right", "Mod+Shift+D") { + crate::splits::split_focused(app, crate::splits::Dir::Right); + } + if item(ui, icon::SQUARE_SPLIT_VERTICAL, "Split down", "Mod+Shift+E") { + crate::splits::split_focused(app, crate::splits::Dir::Down); + } + ui.separator(); + if item(ui, icon::BROADCAST, if app.s.broadcast { "Stop broadcast" } else { "Broadcast input" }, "Mod+Shift+B") { + app.toggle_broadcast(); + } + if item(ui, icon::USERS_THREE, "Broadcast targets…", "") { + app.pick_broadcast_targets(); + } + if item(ui, icon::RECORD, "Toggle recording", "Mod+Shift+R") + && let Some(f) = focused.clone() + { + app.toggle_recording(&f); + } + if item(ui, icon::CODE, "Snippets", "Mod+Shift+S") { + app.snippets.open(); + } + if item(ui, icon::LIGHTNING_A, "Trigger rules", "") { + app.triggers.open(); + } + if item(ui, icon::FILM_STRIP, "Recordings", "") { + app.recordings.open(); + } + ui.separator(); + ui.add_enabled_ui(ssh, |ui| { + if item(ui, icon::FOLDER_OPEN, "SFTP browser", "") + && let Some(f) = focused.clone() + { + app.sftp.toggle(&f); + } + if item(ui, icon::ARROWS_LEFT_RIGHT, "Port forwards", "") + && let Some(f) = focused.clone() + { + app.forwards.open(&f); + } + }); + if item(ui, icon::KEY, "SSH key manager", "") { + app.keys.open(); + } + }); + ui.menu_button("View", |ui| { + if item(ui, icon::COMMAND, "Command palette", "Mod+K") { + app.palette = Some(Palette::default()); + } + if item(ui, icon::CORNERS_OUT, "Full screen", "F11") { + let full = ui.ctx().input(|i| i.viewport().fullscreen.unwrap_or(false)); + ui.ctx().send_viewport_cmd(ViewportCommand::Fullscreen(!full)); + } + ui.separator(); + for k in ThemeKind::ALL { + let mark = if k == app.theme { icon::CHECK } else { " " }; + if item(ui, mark, k.label(), "") { + set_theme(app, k); + } + } + ui.menu_button(format!("{} Terminal colours", icon::PALETTE), |ui| { + for th in crate::terminal_themes::THEMES { + let mark = if th.id == app.s.prefs.terminal_theme { icon::CHECK } else { " " }; + if item(ui, mark, th.label, "") { + app.s.prefs.terminal_theme = th.id.into(); + let _ = app.s.svc.save_preferences(&app.s.prefs); + } + } + }); + }); + ui.menu_button("Help", |ui| { + if item(ui, icon::BOOK_OPEN, "Help", "") { + app.settings.open(SettingsTab::Help); + } + if item(ui, icon::INFO, "About Nexus Terminal", "") { + app.settings.open(SettingsTab::About); + } + }); + }); + menus_right = ui.min_rect().right(); + }); + + let mut x = bar.right() - 3.0 * 44.0; + window_controls(ui, Rect::from_min_max(pos2(x, bar.top()), bar.max)); + x -= 34.0; + let (next, glyph) = if app.theme == ThemeKind::Light { (ThemeKind::Pro, icon::MOON) } else { (ThemeKind::Light, icon::SUN) }; + let tr = Rect::from_center_size(pos2(x + 14.0, bar.center().y), vec2(24.0, 24.0)); + if widgets::icon_button(&mut ui.new_child(egui::UiBuilder::new().max_rect(tr)), glyph, "Toggle light / dark").clicked() { + set_theme(app, next); + } + let w = (bar.width() * 0.22).clamp(140.0, 300.0); + x -= w + 8.0; + let r = Rect::from_min_size(pos2(x, bar.center().y - 12.0), vec2(w, 24.0)); + let resp = ui.interact(r, ui.id().with("palette-launch"), Sense::click()); + ui.painter().rect(r, 6.0, if resp.hovered() { t.hover } else { t.field }, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + ui.painter().text(r.left_center() + vec2(9.0, 0.0), Align2::LEFT_CENTER, icon::MAGNIFYING_GLASS, egui::FontId::proportional(13.0), t.text_faint); + let show_keys = w > 250.0; + let clip = Rect::from_min_max(r.min, pos2(r.right() - if show_keys { 52.0 } else { 4.0 }, r.bottom())); + let hint = if w > 290.0 { "Search connections and commands" } else { "Search" }; + ui.painter().with_clip_rect(clip).text(r.left_center() + vec2(28.0, 0.0), Align2::LEFT_CENTER, hint, egui::FontId::proportional(12.0), t.text_faint); + if show_keys { + ui.painter().text(r.right_center() - vec2(8.0, 0.0), Align2::RIGHT_CENTER, keys("Mod+K").replace('+', " "), theme::semibold(10.5), t.text_faint); + } + if resp.clicked() { + app.palette = Some(Palette::default()); + } + if let Some(p) = app.s.active.as_deref().and_then(|a| app.s.pane(a)) { + let title = format!("{} — {}", p.info.tab_name, p.info.host); + let gap = Rect::from_min_max(pos2(menus_right + 16.0, bar.top()), pos2(x - 16.0, bar.bottom())); + if gap.width() > 60.0 { + ui.painter().with_clip_rect(gap).text(gap.center(), Align2::CENTER_CENTER, title, egui::FontId::proportional(12.0), t.text_dim); + } + } +} + +pub fn set_theme(app: &mut App, k: ThemeKind) { + app.theme = k; + theme::apply(&app.s.ctx, k); + app.s.prefs.theme = k.id().into(); + let _ = app.s.svc.save_preferences(&app.s.prefs); +} + +fn window_controls(ui: &mut Ui, area: Rect) { + let t = Tokens::get(ui.ctx()); + let ctx = ui.ctx().clone(); + let max = ctx.input(|i| i.viewport().maximized.unwrap_or(false)); + for (k, (glyph, close)) in [(icon::MINUS, false), (if max { icon::CORNERS_IN } else { icon::SQUARE }, false), (icon::X, true)].into_iter().enumerate() { + let r = Rect::from_min_size(pos2(area.left() + k as f32 * 44.0, area.top()), vec2(44.0, area.height())); + let resp = ui.interact(r, ui.id().with(("winctl", k)), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 0.0, if close { Color32::from_rgb(196, 43, 28) } else { t.hover }); + } + let c = if close && resp.hovered() { Color32::WHITE } else { t.text_dim }; + ui.painter().text(r.center(), Align2::CENTER_CENTER, glyph, egui::FontId::proportional(14.0), c); + if resp.clicked() { + match k { + 0 => ctx.send_viewport_cmd(ViewportCommand::Minimized(true)), + 1 => ctx.send_viewport_cmd(ViewportCommand::Maximized(!max)), + _ => ctx.send_viewport_cmd(ViewportCommand::Close), + } + } + } +} + +/// Invisible window edges that resize the undecorated window. +pub fn resize_edges(ctx: &egui::Context) { + use egui::{CursorIcon, ResizeDirection as D}; + if ctx.input(|i| i.viewport().maximized.unwrap_or(false) || i.viewport().fullscreen.unwrap_or(false)) { + return; + } + let r = ctx.content_rect(); + let e = 5.0; + let zones = [ + (Rect::from_min_max(r.min, r.min + vec2(12.0, 12.0)), D::NorthWest, CursorIcon::ResizeNorthWest), + (Rect::from_min_max(pos2(r.right() - 12.0, r.top()), pos2(r.right(), r.top() + 12.0)), D::NorthEast, CursorIcon::ResizeNorthEast), + (Rect::from_min_max(pos2(r.left(), r.bottom() - 12.0), pos2(r.left() + 12.0, r.bottom())), D::SouthWest, CursorIcon::ResizeSouthWest), + (Rect::from_min_max(r.max - vec2(12.0, 12.0), r.max), D::SouthEast, CursorIcon::ResizeSouthEast), + (Rect::from_min_max(r.min, pos2(r.right(), r.top() + e)), D::North, CursorIcon::ResizeVertical), + (Rect::from_min_max(pos2(r.left(), r.bottom() - e), r.max), D::South, CursorIcon::ResizeVertical), + (Rect::from_min_max(r.min, pos2(r.left() + e, r.bottom())), D::West, CursorIcon::ResizeHorizontal), + (Rect::from_min_max(pos2(r.right() - e, r.top()), r.max), D::East, CursorIcon::ResizeHorizontal), + ]; + let Some(p) = ctx.input(|i| i.pointer.hover_pos()) else { + return; + }; + for (zone, dir, cursor) in zones { + if zone.contains(p) { + ctx.set_cursor_icon(cursor); + if ctx.input(|i| i.pointer.primary_pressed()) { + ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir)); + } + return; + } + } +} + +// --- status bar ----------------------------------------------------------------------------- + +pub fn state_color(t: &Tokens, s: SessionState) -> Color32 { + match s { + SessionState::Connected => t.success, + SessionState::Connecting | SessionState::Reconnecting => t.warning, + SessionState::Error => t.danger, + SessionState::Disconnected => t.text_faint, + } +} + +pub fn state_label(s: SessionState) -> &'static str { + match s { + SessionState::Connecting => "Connecting", + SessionState::Connected => "Connected", + SessionState::Disconnected => "Disconnected", + SessionState::Reconnecting => "Reconnecting", + SessionState::Error => "Error", + } +} + +pub fn status_bar(app: &mut App, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + latency_tick(app); + ui.horizontal_centered(|ui| { + ui.spacing_mut().item_spacing.x = 14.0; + ui.add_space(8.0); + let dim = |s: String| RichText::new(s).size(11.5).color(t.text_dim); + let active = app.s.active.clone(); + match active.as_deref().and_then(|a| app.s.pane(a)) { + Some(p) => { + let st = app.s.session_state(&p.info.id).unwrap_or(p.info.state); + ui.label(RichText::new("●").color(state_color(&t, st)).size(10.0)); + ui.add_space(-10.0); + ui.label(dim(state_label(st).into())); + ui.label(RichText::new(crate::views::protocol_badge(p.info.protocol)).size(11.0).color(crate::views::protocol_color(p.info.protocol))); + if p.recording { + ui.label(RichText::new(format!("{} REC", icon::RECORD)).size(11.0).color(t.danger)); + } + } + None => { + ui.label(dim("No active session".into())); + } + } + if let Some((sid, r)) = &app.latency + && active.as_deref() == Some(sid) + && let Ok(ms) = r + { + let c = if *ms < 100 { + t.success + } else if *ms < 250 { + t.warning + } else { + t.danger + }; + ui.label(RichText::new(format!("{ms} ms")).size(11.5).color(c)); + } + if app.s.broadcast { + ui.label(RichText::new(format!("{} BROADCAST", icon::BROADCAST)).size(11.0).color(t.warning)); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(8.0); + let vault = app.s.svc.secrets.vault.is_unlocked(); + let keychain = app.s.svc.secrets.keychain_ok; + let label = if vault { format!("{} Vault unlocked", icon::LOCK_OPEN) } else { format!("{} Vault locked", icon::LOCK) }; + if ui + .add(egui::Label::new(dim(label)).sense(Sense::click())) + .on_hover_text(if keychain { + "Saved credentials use the OS keychain; the vault is the fallback." + } else { + "No OS keychain available: saved credentials use the encrypted vault." + }) + .clicked() + { + app.settings.open(SettingsTab::Security); + } + let total = app.s.panes.len(); + let connected = app.s.panes.keys().filter(|id| app.s.session_state(id) == Some(SessionState::Connected)).count(); + ui.label(dim(format!("{connected}/{total} sessions"))); + if let Some(u) = &app.update + && u.available + { + let txt = format!( + "{} Update available{}", + icon::ARROW_CIRCLE_UP, + if u.latest_version.is_empty() { String::new() } else { format!(" ({})", u.latest_version) } + ); + if ui.add(egui::Label::new(RichText::new(txt).size(11.5).color(t.accent)).sense(Sense::click())).clicked() { + app.settings.open(SettingsTab::About); + } + } + }); + }); +} + +static LATENCY: std::sync::Mutex)>> = std::sync::Mutex::new(None); + +/// Pings the active SSH session every 10 s for the status-bar latency. +fn latency_tick(app: &mut App) { + let id = app.s.active.clone(); + let ssh = id.as_deref().and_then(|a| app.s.pane(a)).is_some_and(|p| p.info.protocol == Protocol::Ssh); + if let Some(r) = LATENCY.lock().unwrap().take() { + app.latency = Some(r); + } + let Some(id) = id.filter(|_| ssh) else { return }; + if app.s.session_state(&id) != Some(SessionState::Connected) { + return; + } + let last = app.s.ctx.data(|d| d.get_temp::<(String, f64)>(egui::Id::new("nexus-latency-at"))); + let due = last.as_ref().is_none_or(|(sid, at)| sid != &id || app.s.now - at > 10.0); + // A failed ping stops pinging that session, as before. + let failed = matches!(&app.latency, Some((sid, Err(_))) if sid == &id); + if !due || failed { + app.s.ctx.request_repaint_after(std::time::Duration::from_secs(10)); + return; + } + app.s.ctx.data_mut(|d| d.insert_temp(egui::Id::new("nexus-latency-at"), (id.clone(), app.s.now))); + let (svc, ctx) = (app.s.svc.clone(), app.s.ctx.clone()); + std::thread::spawn(move || { + let r = svc.sessions.ping(&id).map_err(|e| e.to_string()); + *LATENCY.lock().unwrap() = Some((id, r)); + ctx.request_repaint(); + }); +} + +// --- palette -------------------------------------------------------------------------------- + +#[derive(Default)] +pub struct Palette { + pub query: String, + pub sel: usize, +} + +#[derive(Clone)] +enum Cmd { + Connect(String), + NewProfile(Protocol), + Quick, + Local, + CloseTab, + NextTab, + PrevTab, + Tab(String), + Settings, + Import, + Export, + ExportJson, + ImportJson, + Lock, + Unlock, + Keys, + Triggers, + Snippets, + Recordings, + SplitRight, + SplitDown, + Clear, + Reset, + Broadcast, + Theme(ThemeKind), + About, + Fullscreen, + Sftp(String), +} + +/// Fuzzy subsequence match with bonuses for consecutive and leading hits (utils.js fuzzyMatch). +pub fn fuzzy_score(text: &str, query: &str) -> Option { + if query.is_empty() { + return Some(1); + } + let text: Vec = text.to_lowercase().chars().collect(); + let (mut idx, mut score, mut bonus) = (0usize, 0i32, 0i32); + for qc in query.to_lowercase().chars() { + let found = (idx..text.len()).find(|&i| text[i] == qc)?; + bonus = if found == idx { bonus + 1 } else { 0 }; + score += 1 + bonus + if found == 0 { 2 } else { 0 }; + idx = found + 1; + } + Some(score) +} + +fn commands(app: &App) -> Vec<(Cmd, String, &'static str, &'static str, String)> { + let mut v: Vec<(Cmd, String, &'static str, &'static str, String)> = vec![]; + for p in &app.s.profiles { + let name = if p.display_name.is_empty() { &p.host } else { &p.display_name }; + v.push((Cmd::Connect(p.id.clone()), format!("Connect to {name}"), "Connections", icon::PLUGS_CONNECTED, String::new())); + } + for (pr, label) in [ + (Protocol::Ssh, "New SSH Connection"), + (Protocol::Telnet, "New Telnet Connection"), + (Protocol::Raw, "New RAW Connection"), + (Protocol::Serial, "New Serial Connection"), + ] { + v.push((Cmd::NewProfile(pr), label.into(), "Actions", icon::PLUS_CIRCLE, if pr == Protocol::Ssh { keys("Mod+N") } else { String::new() })); + } + v.push((Cmd::Quick, "Quick Connect…".into(), "Actions", icon::LIGHTNING, keys("Mod+Shift+Q"))); + v.push((Cmd::Local, "Open Local Terminal".into(), "Actions", icon::TERMINAL_WINDOW, String::new())); + v.push((Cmd::Broadcast, "Toggle Broadcast Mode".into(), "Actions", icon::BROADCAST, keys("Mod+Shift+B"))); + v.push((Cmd::Snippets, "Snippet Library".into(), "Actions", icon::CODE, keys("Mod+Shift+S"))); + v.push((Cmd::Recordings, "Recordings".into(), "Actions", icon::FILM_STRIP, String::new())); + if !app.s.tabs.is_empty() { + v.push((Cmd::CloseTab, "Close Tab".into(), "Tabs", icon::X, keys("Mod+W"))); + v.push((Cmd::NextTab, "Next Tab".into(), "Tabs", icon::ARROW_RIGHT, keys("Mod+Tab"))); + v.push((Cmd::PrevTab, "Previous Tab".into(), "Tabs", icon::ARROW_LEFT, keys("Mod+Shift+Tab"))); + for t in &app.s.tabs { + if let Some(p) = app.s.pane(&t.id) { + v.push((Cmd::Tab(t.id.clone()), format!("Switch to {}", p.info.tab_name), "Tabs", icon::BROWSER, String::new())); + } + } + } + v.push((Cmd::Settings, "Settings".into(), "Application", icon::GEAR, keys("Mod+,"))); + v.push((Cmd::Import, "Import Profiles".into(), "Application", icon::DOWNLOAD_SIMPLE, String::new())); + v.push((Cmd::Export, "Export Profiles".into(), "Application", icon::UPLOAD_SIMPLE, String::new())); + v.push((Cmd::ImportJson, "Import Profiles (JSON)".into(), "Application", icon::FILE_ARROW_DOWN, String::new())); + v.push((Cmd::ExportJson, "Export Profiles (JSON)".into(), "Application", icon::FILE_ARROW_UP, String::new())); + for k in ThemeKind::ALL { + v.push((Cmd::Theme(k), format!("Theme: {}", k.label()), "Application", icon::PALETTE, String::new())); + } + v.push((Cmd::Fullscreen, "Toggle Full Screen".into(), "Application", icon::CORNERS_OUT, "F11".into())); + v.push((Cmd::About, "About Nexus Terminal".into(), "Application", icon::INFO, String::new())); + if app.s.svc.secrets.vault.is_unlocked() { + v.push((Cmd::Lock, "Lock Secret Store".into(), "Security", icon::LOCK, String::new())); + } else { + v.push((Cmd::Unlock, "Unlock Secret Store".into(), "Security", icon::LOCK_OPEN, String::new())); + } + v.push((Cmd::Keys, "SSH Key Manager".into(), "Security", icon::KEY, String::new())); + v.push((Cmd::Triggers, "Trigger Rules".into(), "Terminal", icon::LIGHTNING_A, String::new())); + if app.s.focused_session().and_then(|f| app.s.pane(&f)).is_some_and(|p| matches!(p.view, View::Terminal(_))) { + v.push((Cmd::SplitRight, "Split Pane Right".into(), "Terminal", icon::SQUARE_SPLIT_HORIZONTAL, keys("Mod+Shift+D"))); + v.push((Cmd::SplitDown, "Split Pane Down".into(), "Terminal", icon::SQUARE_SPLIT_VERTICAL, keys("Mod+Shift+E"))); + if let Some(id) = app.s.focused_session().filter(|f| app.s.pane(f).is_some_and(|p| p.info.protocol == Protocol::Ssh)) { + v.push((Cmd::Sftp(id), "SFTP Browser".into(), "Terminal", icon::FOLDER_OPEN, String::new())); + } + v.push((Cmd::Clear, "Clear Terminal".into(), "Terminal", icon::ERASER, String::new())); + v.push((Cmd::Reset, "Reset Terminal".into(), "Terminal", icon::ARROW_COUNTER_CLOCKWISE, String::new())); + } + v +} + +/// Runs the palette command with this label (case-insensitive); false when there is none. +pub fn run_labelled(app: &mut App, ctx: &egui::Context, label: &str) -> bool { + match commands(app).into_iter().find(|c| c.1.eq_ignore_ascii_case(label)) { + Some(c) => { + run(app, ctx, c.0); + true + } + None => false, + } +} + +fn run(app: &mut App, ctx: &egui::Context, c: Cmd) { + match c { + Cmd::Sftp(id) => app.s.request(Request::Sftp(id)), + Cmd::Connect(id) => app.connect_profile(&id, false), + Cmd::NewProfile(p) => app.s.request(Request::NewProfile { protocol: Some(p), folder: String::new() }), + Cmd::Quick => app.quick.open(), + Cmd::Local => app.connect_local(), + Cmd::CloseTab => { + if let Some(id) = app.s.focused_session() { + app.request_close(&id); + } + } + Cmd::NextTab | Cmd::PrevTab => { + let order = crate::tabs::ordered(&app.s); + if let Some(cur) = app.s.active.as_ref().and_then(|a| order.iter().position(|x| x == a)) { + let n = order.len() as i32; + let next = (cur as i32 + if matches!(c, Cmd::NextTab) { 1 } else { -1 }).rem_euclid(n) as usize; + let id = order[next].clone(); + app.activate(&id); + } + } + Cmd::Tab(id) => app.activate(&id), + Cmd::Settings => app.settings.open(SettingsTab::General), + Cmd::Import => app.s.request(Request::ImportEncrypted), + Cmd::Export => app.s.request(Request::ExportEncrypted(vec![])), + Cmd::ExportJson => app.transfer.export_json(), + Cmd::ImportJson => app.transfer.import_json(), + Cmd::Lock => { + app.s.svc.secrets.vault.lock(); + app.s.info("Secret store locked"); + } + Cmd::Unlock => app.settings.open(SettingsTab::Security), + Cmd::Keys => app.keys.open(), + Cmd::Triggers => app.triggers.open(), + Cmd::Snippets => app.snippets.open(), + Cmd::Recordings => app.recordings.open(), + Cmd::SplitRight => crate::splits::split_focused(app, crate::splits::Dir::Right), + Cmd::SplitDown => crate::splits::split_focused(app, crate::splits::Dir::Down), + Cmd::Clear | Cmd::Reset => { + if let Some(id) = app.s.focused_session() { + app.terminal_menu(&id, if matches!(c, Cmd::Clear) { crate::terminal::MenuAction::Clear } else { crate::terminal::MenuAction::Reset }); + } + } + Cmd::Broadcast => app.toggle_broadcast(), + Cmd::Theme(k) => set_theme(app, k), + Cmd::About => app.settings.open(SettingsTab::About), + Cmd::Fullscreen => { + let full = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)); + ctx.send_viewport_cmd(ViewportCommand::Fullscreen(!full)); + } + } +} + +pub fn modal_backdrop(ctx: &egui::Context) { + egui::Area::new("backdrop".into()).order(egui::Order::Middle).fixed_pos(pos2(0.0, 0.0)).show(ctx, |ui| { + let r = ctx.content_rect(); + ui.painter().rect_filled(r, 0.0, Color32::from_black_alpha(90)); + ui.allocate_rect(r, Sense::click()); + }); +} + +pub fn palette(app: &mut App, ctx: &egui::Context) { + let Some(mut pal) = app.palette.take() else { + return; + }; + let t = Tokens::get(ctx); + let mut matches: Vec<_> = commands(app).into_iter().filter_map(|c| fuzzy_score(&format!("{} {}", c.1, c.2), pal.query.trim()).map(|s| (s, c))).collect(); + if !pal.query.trim().is_empty() { + matches.sort_by_key(|m| std::cmp::Reverse(m.0)); + } + let matches: Vec<_> = matches.into_iter().map(|(_, c)| c).collect(); + pal.sel = pal.sel.min(matches.len().saturating_sub(1)); + let mut chosen = None; + let mut close = ctx.input(|i| i.key_pressed(Key::Escape)); + if ctx.input(|i| i.key_pressed(Key::ArrowDown)) { + pal.sel = (pal.sel + 1).min(matches.len().saturating_sub(1)); + } + if ctx.input(|i| i.key_pressed(Key::ArrowUp)) { + pal.sel = pal.sel.saturating_sub(1); + } + if ctx.input(|i| i.key_pressed(Key::Enter)) { + chosen = matches.get(pal.sel).map(|m| m.0.clone()); + } + modal_backdrop(ctx); + egui::Area::new("palette".into()).order(egui::Order::Foreground).anchor(Align2::CENTER_TOP, vec2(0.0, 80.0)).show(ctx, |ui| { + egui::Frame::popup(ui.style()).corner_radius(10).inner_margin(8).show(ui, |ui| { + ui.set_width(580.0); + ui.horizontal(|ui| { + ui.label(RichText::new(icon::MAGNIFYING_GLASS).size(16.0).color(t.text_faint)); + let r = ui.add( + egui::TextEdit::singleline(&mut pal.query) + .hint_text("Connect, switch tab, or run a command…") + .frame(egui::Frame::NONE) + .desired_width(f32::INFINITY) + .font(egui::FontId::proportional(15.0)), + ); + r.request_focus(); + if r.changed() { + pal.sel = 0; + } + }); + ui.separator(); + egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| { + let mut last = ""; + for (i, (cmd, label, section, glyph, keys)) in matches.iter().enumerate() { + if *section != last { + last = section; + ui.label(RichText::new(section.to_uppercase()).font(theme::semibold(10.0)).color(t.text_faint)); + } + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click()); + if i == pal.sel { + ui.painter().rect_filled(r, 6.0, t.accent_soft); + ui.scroll_to_rect(r, None); + } else if resp.hovered() { + ui.painter().rect_filled(r, 6.0, t.hover); + } + ui.painter().text(r.left_center() + vec2(12.0, 0.0), Align2::LEFT_CENTER, *glyph, egui::FontId::proportional(15.0), t.accent); + ui.painter().text(r.left_center() + vec2(36.0, 0.0), Align2::LEFT_CENTER, label, egui::FontId::proportional(13.0), t.text); + if !keys.is_empty() { + ui.painter().text(r.right_center() - vec2(10.0, 0.0), Align2::RIGHT_CENTER, keys, theme::semibold(10.5), t.text_faint); + } + if resp.clicked() { + chosen = Some(cmd.clone()); + } + } + if matches.is_empty() { + widgets::hint(ui, "No matching command."); + } + }); + }); + }); + if let Some(c) = chosen { + close = true; + run(app, ctx, c); + } + if !close { + app.palette = Some(pal); + } +} + +// --- prompts -------------------------------------------------------------------------------- + +pub fn modal(ctx: &egui::Context, id: &str, width: f32, body: impl FnOnce(&mut Ui)) { + modal_backdrop(ctx); + egui::Area::new(egui::Id::new(id)).order(egui::Order::Foreground).anchor(Align2::CENTER_CENTER, vec2(0.0, -40.0)).show(ctx, |ui| { + egui::Frame::popup(ui.style()).corner_radius(10).inner_margin(16).show(ui, |ui| { + ui.set_width(width); + body(ui); + }); + }); +} + +pub fn prompts(app: &mut App, ctx: &egui::Context) { + let Some(p) = app.prompts.front_mut() else { + return; + }; + let t = Tokens::get(ctx); + let esc = ctx.input(|i| i.key_pressed(Key::Escape)); + let enter = ctx.input(|i| i.key_pressed(Key::Enter)); + let mut done = false; + match p { + Prompt::Secret { session, title, message, value, reply } => { + let who = app.s.pane(session).map(|p| format!("{} ({})", p.info.tab_name, p.info.host)); + modal(ctx, "secret-prompt", 380.0, |ui| { + ui.label(RichText::new(format!("{} {title}", icon::KEY)).font(theme::semibold(15.0))); + if let Some(w) = &who { + ui.label(RichText::new(w).color(t.text_dim)); + } + ui.add_space(4.0); + ui.label(message.as_str()); + let r = ui.add(egui::TextEdit::singleline(value).password(true).desired_width(f32::INFINITY)); + r.request_focus(); + ui.add_space(6.0); + ui.horizontal(|ui| { + if widgets::primary(ui, icon::CHECK, "OK").clicked() || enter { + let _ = reply.send(std::mem::take(value)); + done = true; + } + if ui.button("Cancel").clicked() || esc { + done = true; // dropping the sender cancels + } + }); + }); + } + Prompt::HostKey { host, key_type, fingerprint, reply } => { + modal(ctx, "hostkey-prompt", 460.0, |ui| { + ui.label(RichText::new(format!("{} Verify host key", icon::SHIELD_WARNING)).font(theme::semibold(15.0))); + ui.label(format!("The authenticity of {host} can't be established.")); + ui.add_space(4.0); + widgets::kv(ui, "hk", &[("Key type", key_type.clone()), ("Fingerprint", fingerprint.clone())]); + widgets::hint(ui, "Accept only if this matches the fingerprint the server's administrator gave you."); + ui.add_space(6.0); + ui.horizontal(|ui| { + if widgets::primary(ui, icon::CHECK, "Accept & Save").clicked() { + let _ = reply.send(true); + done = true; + } + if ui.button("Reject").clicked() || esc { + let _ = reply.send(false); + done = true; + } + }); + }); + } + } + if done { + app.prompts.pop_front(); + } +} + +// --- dialogs -------------------------------------------------------------------------------- + +pub fn dialogs(app: &mut App, ctx: &egui::Context) { + let Some(mut d) = app.dialog.take() else { + return; + }; + let t = Tokens::get(ctx); + let esc = ctx.input(|i| i.key_pressed(Key::Escape)); + let mut keep = !esc; + match &mut d { + Dialog::Reuse { profile, existing } => { + let name = app.s.profile(profile).map(|p| p.display_name.clone()).unwrap_or_default(); + modal(ctx, "reuse", 400.0, |ui| { + ui.label(RichText::new("Connection Already Open").font(theme::semibold(15.0))); + ui.label(format!("{name} already has an open session.")); + ui.add_space(8.0); + ui.horizontal(|ui| { + if widgets::primary(ui, icon::BROWSER, "Use Existing").clicked() { + let id = existing.clone(); + app.activate(&id); + keep = false; + } + if ui.button("New Connection").clicked() { + let id = profile.clone(); + app.connect_profile(&id, true); + keep = false; + } + if ui.button("Cancel").clicked() { + keep = false; + } + }); + }); + } + Dialog::DirectVnc { profile } => { + modal(ctx, "direct-vnc", 420.0, |ui| { + ui.label(RichText::new(format!("{} Unencrypted VNC", icon::WARNING)).font(theme::semibold(15.0)).color(t.warning)); + ui.label("This VNC connection isn't tunnelled over SSH. VNC traffic (including keystrokes and screen contents) is sent unencrypted. Enable the SSH tunnel in the profile to protect it."); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Connect Anyway").clicked() { + let id = profile.clone(); + app.connect_profile(&id, true); + keep = false; + } + if ui.button("Edit Profile").clicked() { + app.s.request(Request::EditProfile(profile.clone())); + keep = false; + } + if ui.button("Cancel").clicked() { + keep = false; + } + }); + }); + } + Dialog::ConfirmClose { session } => { + let name = app.s.pane(session).map(|p| p.info.tab_name.clone()).unwrap_or_default(); + modal(ctx, "confirm-close", 360.0, |ui| { + ui.label(RichText::new("Close session?").font(theme::semibold(15.0))); + ui.label(format!("{name} is still connected.")); + ui.add_space(8.0); + ui.horizontal(|ui| { + if widgets::primary(ui, icon::X, "Close").clicked() || ctx.input(|i| i.key_pressed(Key::Enter)) { + let id = session.clone(); + app.close_session(&id); + keep = false; + } + if ui.button("Cancel").clicked() { + keep = false; + } + }); + }); + } + Dialog::ConfirmDisconnectMany { sessions } => { + modal(ctx, "confirm-many", 360.0, |ui| { + ui.label(RichText::new("Disconnect sessions?").font(theme::semibold(15.0))); + ui.label(format!("{} open session(s) will be closed.", sessions.len())); + ui.horizontal(|ui| { + if widgets::primary(ui, icon::PLUGS, "Disconnect").clicked() { + for id in sessions.clone() { + app.close_session(&id); + } + keep = false; + } + if ui.button("Cancel").clicked() { + keep = false; + } + }); + }); + } + Dialog::Rename { session, name } => { + modal(ctx, "rename", 340.0, |ui| { + ui.label(RichText::new("Rename tab").font(theme::semibold(15.0))); + let r = ui.add(egui::TextEdit::singleline(name).desired_width(f32::INFINITY)); + r.request_focus(); + ui.horizontal(|ui| { + if widgets::primary(ui, icon::CHECK, "Rename").clicked() || ctx.input(|i| i.key_pressed(Key::Enter)) { + let n = name.trim().to_string(); + if !n.is_empty() { + let _ = app.s.svc.sessions.rename(session, &n); + if let Some(p) = app.s.pane_mut(session) { + p.info.tab_name = n; + } + } + keep = false; + } + if ui.button("Cancel").clicked() { + keep = false; + } + }); + }); + } + Dialog::Broadcast { picks } => { + modal(ctx, "broadcast", 360.0, |ui| { + ui.label(RichText::new("Broadcast Targets").font(theme::semibold(15.0))); + widgets::hint(ui, "Select which sessions should receive broadcast input:"); + for (_, label, on) in picks.iter_mut() { + ui.checkbox(on, label.as_str()); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + if widgets::primary(ui, icon::BROADCAST, "Apply & Enable").clicked() { + let sel: std::collections::HashSet = picks.iter().filter(|p| p.2).map(|p| p.0.clone()).collect(); + if sel.is_empty() { + app.s.error("Select at least one target session"); + } else { + let all = sel.len() == picks.len(); + app.s.broadcast_targets = if all { None } else { Some(sel.clone()) }; + if !app.s.broadcast { + app.toggle_broadcast(); + } else { + app.s.info(if all { "Broadcasting to all sessions".to_string() } else { format!("Broadcasting to {} sessions", sel.len()) }); + } + keep = false; + } + } + if ui.button("Select All").clicked() { + for p in picks.iter_mut() { + p.2 = true; + } + } + if ui.button("Cancel").clicked() { + keep = false; + } + }); + }); + } + Dialog::Guided { session, title, body, known_hosts } => { + let label = app.s.pane(session).map(|p| p.info.tab_name.clone()).unwrap_or_else(|| "Session".into()); + let profile_id = app.s.pane(session).map(|p| p.info.profile_id.clone()).unwrap_or_default(); + modal(ctx, "guided", 440.0, |ui| { + ui.label(RichText::new(title.as_str()).font(theme::semibold(15.0)).color(t.danger)); + ui.label(RichText::new(label).strong()); + ui.add(egui::Label::new(body.as_str()).wrap()); + ui.add_space(8.0); + ui.horizontal(|ui| { + if widgets::primary(ui, icon::ARROW_CLOCKWISE, "Retry").clicked() { + let id = session.clone(); + app.reconnect(&id); + keep = false; + } + if !profile_id.is_empty() && ui.button("Edit Profile").clicked() { + app.s.request(crate::state::Request::EditProfile(profile_id.clone())); + keep = false; + } + if *known_hosts && ui.button("Known Hosts").clicked() { + app.settings.open(SettingsTab::KnownHosts); + keep = false; + } + if ui.button("Close").clicked() { + keep = false; + } + }); + }); + } + Dialog::Delete { profiles } => { + let names: Vec = profiles.iter().filter_map(|id| app.s.profile(id)).map(|p| p.display_name.clone()).collect(); + modal(ctx, "delete", 380.0, |ui| { + ui.label(RichText::new("Delete connection?").font(theme::semibold(15.0))); + ui.label(if names.len() == 1 { + format!("\"{}\" will be deleted, with its saved credentials.", names[0]) + } else { + format!("{} connections will be deleted.", names.len()) + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.add(egui::Button::new(RichText::new(format!("{} Delete", icon::TRASH)).color(Color32::WHITE)).fill(t.danger)).clicked() { + for id in profiles.clone() { + if let Err(e) = app.s.svc.delete_profile(&id) { + app.s.error(e.to_string()); + } + } + app.s.reload_profiles(); + app.sidebar.selected.clear(); + keep = false; + } + if ui.button("Cancel").clicked() { + keep = false; + } + }); + }); + } + Dialog::Input { title, label, value, then } => { + let mut submit = false; + modal(ctx, "input", 360.0, |ui| { + ui.label(RichText::new(title.as_str()).font(theme::semibold(15.0))); + ui.label(label.as_str()); + let r = ui.add(egui::TextEdit::singleline(value).desired_width(f32::INFINITY)); + r.request_focus(); + ui.horizontal(|ui| { + submit = widgets::primary(ui, icon::CHECK, "OK").clicked() || ctx.input(|i| i.key_pressed(Key::Enter)); + if ui.button("Cancel").clicked() { + keep = false; + } + }); + }); + if submit { + keep = false; + let v = value.trim().to_string(); + match then { + InputThen::Tags { profiles, replace } => { + let tags: Vec = v.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(); + for id in profiles.iter() { + if let Some(mut p) = app.s.profile(id).cloned() { + if *replace { + p.tags = tags.clone(); + } else { + for tg in &tags { + if !p.tags.contains(tg) { + p.tags.push(tg.clone()); + } + } + } + let _ = app.s.svc.update_profile(p); + } + } + } + InputThen::MoveGroup { profiles } => { + for id in profiles.iter() { + if let Some(mut p) = app.s.profile(id).cloned() { + p.folder = v.clone(); + let _ = app.s.svc.update_profile(p); + } + } + } + } + app.s.reload_profiles(); + } + } + } + if keep && app.dialog.is_none() { + app.dialog = Some(d); + } +} + +// --- toasts --------------------------------------------------------------------------------- + +pub fn toasts(app: &mut App, ctx: &egui::Context) { + let t = Tokens::get(ctx); + let now = app.s.now; + app.s.toasts.retain(|x| x.until > now); + if app.s.toasts.is_empty() { + return; + } + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + let mut dismiss = None; + egui::Area::new("toasts".into()).order(egui::Order::Tooltip).anchor(Align2::RIGHT_TOP, vec2(-16.0, 48.0)).show(ctx, |ui| { + for (i, toast) in app.s.toasts.iter().enumerate() { + egui::Frame::popup(ui.style()).corner_radius(8).inner_margin(egui::Margin::symmetric(12, 8)).show(ui, |ui| { + ui.set_max_width(420.0); + ui.horizontal_top(|ui| { + let (g, c) = match toast.kind { + ToastKind::Error => (icon::WARNING_CIRCLE, t.danger), + ToastKind::Warning => (icon::WARNING, t.warning), + ToastKind::Success => (icon::CHECK_CIRCLE, t.success), + ToastKind::Info => (icon::INFO, t.accent), + }; + ui.label(RichText::new(g).color(c).size(15.0)); + ui.add(egui::Label::new(&toast.text).wrap()); + if ui.add(egui::Button::new(RichText::new(icon::X).size(11.0)).frame(false)).clicked() { + dismiss = Some(i); + } + }); + }); + ui.add_space(6.0); + } + }); + if let Some(i) = dismiss { + app.s.toasts.remove(i); + } +} diff --git a/rust/app/src/forwards.rs b/rust/app/src/forwards.rs new file mode 100644 index 0000000..0554554 --- /dev/null +++ b/rust/app/src/forwards.rs @@ -0,0 +1,166 @@ +//! Port forwards dialog for a live SSH session: list, stop, add. + +use crate::chrome::modal; +use crate::state::Shared; +use crate::theme::{self, Tokens}; +use crate::widgets; +use egui::{Align, Key, Layout, RichText}; +use egui_phosphor::regular as icon; +use nexus_core::models::{ForwardDirection, PortForward, new_id}; +use std::sync::{Arc, Mutex}; + +struct Form { + remote: bool, + bind_host: String, + bind_port: String, + dest_host: String, + dest_port: String, + label: String, +} + +impl Default for Form { + fn default() -> Form { + Form { + remote: false, + bind_host: "127.0.0.1".into(), + bind_port: String::new(), + dest_host: "localhost".into(), + dest_port: String::new(), + label: String::new(), + } + } +} + +#[derive(Default)] +pub struct Forwards { + session: Option, + form: Form, + /// Worker results: true = a forward started, false = one stopped. + done: Arc>>, +} + +fn port(s: &str) -> Option { + s.trim().parse::().ok().filter(|&p| p > 0).map(i64::from) +} + +impl Forwards { + pub fn open(&mut self, session: &str) { + self.session = Some(session.into()); + } + + pub fn ui(&mut self, ctx: &egui::Context, s: &mut Shared) { + for started in std::mem::take(&mut *self.done.lock().unwrap()) { + if started { + s.success("Forward started"); + self.form.bind_port.clear(); + self.form.dest_port.clear(); + self.form.label.clear(); + } else { + s.info("Forward stopped"); + } + } + let Some(sid) = self.session.clone() else { + return; + }; + // A lock and a clone in the client; no network. + let list = match s.svc.sessions.forwards(&sid) { + Ok(l) => l, + Err(_) => { + s.error("Port forwarding is only available for SSH sessions"); + self.session = None; + return; + } + }; + let t = Tokens::get(ctx); + let mut keep = !ctx.input(|i| i.key_pressed(Key::Escape)); + let mut stop = None; + let mut start = false; + let f = &mut self.form; + modal(ctx, "port-forwards", 600.0, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(format!("{} Port Forwards", icon::ARROWS_LEFT_RIGHT)).font(theme::semibold(15.0))); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::icon_button(ui, icon::X, "Close").clicked() { + keep = false; + } + }); + }); + ui.add_space(4.0); + if list.is_empty() { + widgets::hint(ui, "No active port forwards"); + } + for fwd in &list { + ui.horizontal(|ui| { + let local = fwd.direction == ForwardDirection::Local; + widgets::chip(ui, if local { "L" } else { "R" }, if local { t.accent } else { t.warning }); + let or = |s: &str, d: &str| { + if s.is_empty() { d.to_string() } else { s.to_string() } + }; + let text = format!("{}:{} → {}:{}", or(&fwd.bind_host, "127.0.0.1"), fwd.bind_port, or(&fwd.dest_host, "localhost"), fwd.dest_port); + ui.label(RichText::new(text).font(theme::mono(12.0))); + if !fwd.label.is_empty() { + ui.label(RichText::new(&fwd.label).size(11.5).color(t.text_faint)); + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add(egui::Button::new(RichText::new(format!("{} Stop", icon::STOP)).color(t.accent_text)).fill(t.danger)).clicked() { + stop = Some(fwd.id.clone()); + } + }); + }); + } + ui.separator(); + widgets::section(ui, "Add new forward"); + ui.horizontal(|ui| { + egui::ComboBox::from_id_salt("pf-dir").width(96.0).selected_text(if f.remote { "Remote ←" } else { "Local →" }).show_ui(ui, |ui| { + ui.selectable_value(&mut f.remote, false, "Local →"); + ui.selectable_value(&mut f.remote, true, "Remote ←"); + }); + ui.add(egui::TextEdit::singleline(&mut f.bind_host).hint_text("127.0.0.1").desired_width(110.0)); + ui.add(egui::TextEdit::singleline(&mut f.bind_port).hint_text("Port").desired_width(52.0)); + ui.label(RichText::new(icon::ARROW_RIGHT).color(t.text_faint)); + ui.add(egui::TextEdit::singleline(&mut f.dest_host).hint_text("localhost").desired_width(110.0)); + ui.add(egui::TextEdit::singleline(&mut f.dest_port).hint_text("Port").desired_width(52.0)); + }); + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + start = widgets::primary(ui, icon::PLUS, "Start Forward").clicked(); + ui.add(egui::TextEdit::singleline(&mut f.label).hint_text("Label (optional)").desired_width(f32::INFINITY)); + }); + }); + }); + + if let Some(id) = stop { + let (svc, sid, done) = (s.svc.clone(), sid.clone(), self.done.clone()); + s.spawn(move || svc.sessions.stop_forward(&sid, &id).map_err(|e| format!("Failed to stop: {e}").into()), move |_| done.lock().unwrap().push(false)); + } + if start { + let f = &self.form; + match (port(&f.bind_port), port(&f.dest_port)) { + (Some(bind_port), Some(dest_port)) => { + let or = |s: &str, d: &str| { + if s.trim().is_empty() { d.to_string() } else { s.trim().to_string() } + }; + let rule = PortForward { + id: new_id(), + direction: if f.remote { ForwardDirection::Remote } else { ForwardDirection::Local }, + bind_host: or(&f.bind_host, "127.0.0.1"), + bind_port, + dest_host: or(&f.dest_host, "localhost"), + dest_port, + enabled: true, + label: f.label.trim().into(), + }; + let (svc, done) = (s.svc.clone(), self.done.clone()); + s.spawn( + move || svc.sessions.start_forward(&sid, rule).map_err(|e| format!("Failed to start forward: {e}").into()), + move |_| done.lock().unwrap().push(true), + ); + } + _ => s.error("Bind port and destination port are required"), + } + } + if !keep { + self.session = None; + } + } +} diff --git a/rust/app/src/github_view.rs b/rust/app/src/github_view.rs new file mode 100644 index 0000000..c14e843 --- /dev/null +++ b/rust/app/src/github_view.rs @@ -0,0 +1,387 @@ +//! GitHub repository browser: owner groups of repo cards with ZIP download and Visit. Port of +//! `github.js`. + +use crate::state::Shared; +use crate::theme::{self, Tokens}; +use crate::widgets; +use chrono::{DateTime, Utc}; +use egui::{Color32, RichText, Sense, Ui, vec2}; +use egui_phosphor::regular as icon; +use nexus_core::models::GitHubRepo; +use std::sync::{Arc, Mutex}; + +enum Msg { + Repos(Vec), + RefreshFailed(String), + Downloaded(String, String), + DownloadFailed(String), +} + +pub struct GithubView { + pub session: String, + title: String, + login: String, + repos: Vec, + filter: String, + status: String, + status_error: bool, + inbox: Arc>>, +} + +fn parse_time(s: &str) -> Option> { + DateTime::parse_from_rfc3339(s).ok().map(|d| d.with_timezone(&Utc)) +} + +/// Latest commit, else push, else update time, in unix ms (0 when unknown). +fn commit_ts(r: &GitHubRepo) -> i64 { + [&r.latest_commit_at, &r.pushed_at, &r.updated_at].into_iter().find(|s| !s.is_empty()).and_then(|s| parse_time(s)).map_or(0, |d| d.timestamp_millis()) +} + +/// Filters, groups by owner (repos newest first), and orders groups by their newest repo with +/// the user's own login first on ties, then by name. +fn grouped<'a>(repos: &'a [GitHubRepo], login: &str, filter: &str) -> Vec<(String, Vec<&'a GitHubRepo>)> { + let f = filter.to_lowercase(); + let mut groups: Vec<(String, Vec<&GitHubRepo>)> = Vec::new(); + for r in repos.iter().filter(|r| { + f.is_empty() || r.full_name.to_lowercase().contains(&f) || r.description.to_lowercase().contains(&f) || r.language.to_lowercase().contains(&f) + }) { + let owner = if r.owner.is_empty() { "unknown" } else { &r.owner }; + match groups.iter_mut().find(|(o, _)| o == owner) { + Some((_, v)) => v.push(r), + None => groups.push((owner.to_string(), vec![r])), + } + } + for (_, v) in &mut groups { + v.sort_by_key(|r| std::cmp::Reverse(commit_ts(r))); + } + let newest = |v: &[&GitHubRepo]| v.first().map_or(0, |r| commit_ts(r)); + groups.sort_by(|(a, av), (b, bv)| newest(bv).cmp(&newest(av)).then_with(|| (b == login).cmp(&(a == login))).then_with(|| a.cmp(b))); + groups +} + +/// "just now", "3 minutes ago", "1 day ago"… (the web build's relativeTime). +fn relative_time(iso: &str, now: DateTime) -> String { + let Some(d) = parse_time(iso) else { + return String::new(); + }; + let secs = (now - d).num_seconds(); + if secs < 60 { + return "just now".into(); + } + let plural = |n: i64, unit: &str| { + if n == 1 { format!("1 {unit} ago") } else { format!("{n} {unit}s ago") } + }; + let mins = secs / 60; + let hrs = mins / 60; + let days = hrs / 24; + let months = days / 30; + if mins < 60 { + plural(mins, "minute") + } else if hrs < 24 { + plural(hrs, "hour") + } else if days < 30 { + plural(days, "day") + } else if months < 12 { + plural(months, "month") + } else { + plural(months / 12, "year") + } +} + +fn local_time(iso: &str) -> String { + parse_time(iso).map(|d| d.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M").to_string()).unwrap_or_else(|| iso.to_string()) +} + +/// A subset of GitHub's linguist colours. +fn lang_colour(lang: &str) -> Color32 { + let hex = match lang { + "JavaScript" => 0xf1e05a, + "TypeScript" => 0x3178c6, + "Python" => 0x3572a5, + "Go" => 0x00add8, + "Rust" => 0xdea584, + "Java" => 0xb07219, + "C" => 0x555555, + "C++" => 0xf34b7d, + "C#" => 0x178600, + "Ruby" => 0x701516, + "PHP" => 0x4f5d95, + "Swift" => 0xf05138, + "Kotlin" => 0xa97bff, + "Shell" => 0x89e051, + "HTML" => 0xe34c26, + "CSS" => 0x563d7c, + "Dart" => 0x00b4ab, + "Lua" => 0x000080, + "Scala" => 0xc22d40, + "Elixir" => 0x6e4a7e, + "Haskell" => 0x5e5086, + "Zig" => 0xec915c, + "Vue" => 0x41b883, + "SCSS" => 0xc6538c, + _ => 0x8b949e, + }; + Color32::from_rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8) +} + +#[allow(clippy::large_enum_variant)] // short-lived, one per click +enum Action { + Zip(GitHubRepo), + Visit(String), + Copy(String), +} + +impl GithubView { + pub fn new(session: &str, title: &str) -> GithubView { + GithubView { + session: session.into(), + title: if title.is_empty() { "GitHub".into() } else { title.into() }, + login: String::new(), + repos: vec![], + filter: String::new(), + status: "Connecting…".into(), + status_error: false, + inbox: Default::default(), + } + } + + pub fn on_status(&mut self, status: String) { + self.status = status; + self.status_error = false; + } + + pub fn on_repos(&mut self, login: String, repos: Vec) { + self.login = login; + self.repos = repos; + self.update_status(); + } + + pub fn on_error(&mut self, error: String) { + self.status = format!("{} {error}", icon::WARNING); + self.status_error = true; + } + + fn update_status(&mut self) { + let n = self.repos.len(); + let now = chrono::Local::now().format("%H:%M:%S"); + self.on_status(format!("{} · {n} repositor{} · updated {now}", self.login, if n == 1 { "y" } else { "ies" })); + } + + fn refresh(&mut self, s: &Shared) { + self.on_status("Refreshing…".into()); + let (svc, sid, inbox, ctx) = (s.svc.clone(), self.session.clone(), self.inbox.clone(), s.ctx.clone()); + std::thread::spawn(move || { + let msg = match svc.sessions.github(&sid).and_then(|c| c.fetch_repos()) { + Ok(r) => Msg::Repos(r), + Err(e) => Msg::RefreshFailed(e.to_string()), + }; + inbox.lock().unwrap().push(msg); + ctx.request_repaint(); + }); + } + + fn download(&mut self, s: &Shared, repo: &GitHubRepo) { + let reference = repo.default_branch.clone(); + let name = if reference.is_empty() { format!("{}.zip", repo.name) } else { format!("{}-{reference}.zip", repo.name) }; + let Some(path) = rfd::FileDialog::new().set_file_name(name).add_filter("ZIP archive", &["zip"]).save_file() else { + return; + }; + self.on_status(format!("Downloading {}...", repo.full_name)); + let (svc, sid, inbox, ctx, full) = (s.svc.clone(), self.session.clone(), self.inbox.clone(), s.ctx.clone(), repo.full_name.clone()); + std::thread::spawn(move || { + let msg = match svc.sessions.github(&sid).and_then(|c| c.download_repo_archive(&full, &reference, &path)) { + Ok(()) => Msg::Downloaded(full, path.display().to_string()), + Err(e) => Msg::DownloadFailed(e.to_string()), + }; + inbox.lock().unwrap().push(msg); + ctx.request_repaint(); + }); + } + + pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) { + let msgs = std::mem::take(&mut *self.inbox.lock().unwrap()); + for m in msgs { + match m { + Msg::Repos(r) => { + let login = self.login.clone(); + self.on_repos(login, r); + } + Msg::RefreshFailed(e) => self.on_error(e), + Msg::Downloaded(name, path) => { + self.update_status(); + s.success(format!("Downloaded {name} to {path}")); + } + Msg::DownloadFailed(e) => { + self.on_error(format!("Download failed: {e}")); + s.error(format!("Download failed: {e}")); + } + } + } + + let t = Tokens::get(ui.ctx()); + egui::Frame::new().fill(t.chrome).inner_margin(egui::Margin::symmetric(12, 8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label(RichText::new(icon::GITHUB_LOGO).size(18.0)); + ui.label(RichText::new(&self.title).font(theme::semibold(14.0))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::icon_button(ui, icon::ARROW_CLOCKWISE, "Refresh now").clicked() { + self.refresh(s); + } + ui.add( + egui::TextEdit::singleline(&mut self.filter) + .hint_text(format!("{} Filter repositories…", icon::MAGNIFYING_GLASS)) + .desired_width(240.0), + ); + }); + }); + let c = if self.status_error { t.danger } else { t.text_dim }; + ui.label(RichText::new(&self.status).color(c).size(11.5)); + }); + + let mut action = None; + egui::ScrollArea::vertical().id_salt(("gh", &self.session)).auto_shrink([false, false]).show(ui, |ui| { + ui.add_space(8.0); + let groups = grouped(&self.repos, &self.login, &self.filter); + if groups.is_empty() { + let msg = if self.filter.is_empty() { "No repositories found." } else { "No matching repositories." }; + widgets::empty(ui, icon::GITHUB_LOGO, msg, ""); + return; + } + let now = Utc::now(); + egui::Frame::new().inner_margin(egui::Margin::symmetric(14, 0)).show(ui, |ui| { + for (owner, repos) in groups { + ui.horizontal(|ui| { + ui.label(RichText::new(&owner).font(theme::semibold(13.0))); + widgets::chip(ui, &repos.len().to_string(), t.text_dim); + }); + ui.add_space(2.0); + for r in repos { + if let Some(a) = card(ui, r, now) { + action = Some(a); + } + ui.add_space(6.0); + } + ui.add_space(8.0); + } + }); + }); + match action { + Some(Action::Zip(r)) => self.download(s, &r), + Some(Action::Visit(url)) if !url.is_empty() => { + if !widgets::open_web_url(ui.ctx(), &url) { + s.error("The repository link is not a web address"); + } + } + Some(Action::Copy(url)) => { + ui.ctx().copy_text(url); + s.info("URL copied"); + } + _ => {} + } + } +} + +fn card(ui: &mut Ui, r: &GitHubRepo, now: DateTime) -> Option { + let t = Tokens::get(ui.ctx()); + let mut action = None; + let resp = egui::Frame::new() + .fill(t.card) + .stroke(egui::Stroke::new(1.0, t.card_border)) + .corner_radius(6) + .inner_margin(egui::Margin::symmetric(12, 9)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label(RichText::new(&r.name).font(theme::semibold(13.5)).color(t.accent)); + if r.private { + widgets::chip(ui, "Private", t.warning); + } + if r.fork { + widgets::chip(ui, "Fork", t.text_dim); + } + if r.archived { + widgets::chip(ui, "Archived", t.danger); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(format!("{} Visit", icon::ARROW_SQUARE_OUT)).on_hover_text("Open repository in browser").clicked() { + action = Some(Action::Visit(r.html_url.clone())); + } + if ui.button(format!("{} ZIP", icon::FILE_ZIP)).on_hover_text("Download repository as zip").clicked() { + action = Some(Action::Zip(r.clone())); + } + }); + }); + if !r.latest_committer.is_empty() && !r.latest_commit_at.is_empty() { + ui.label(RichText::new(format!("Last commit: {} by {}", local_time(&r.latest_commit_at), r.latest_committer)).color(t.text_faint).size(11.5)); + } + if !r.description.is_empty() { + ui.label(RichText::new(&r.description).color(t.text_dim)); + } + ui.horizontal(|ui| { + if !r.language.is_empty() { + let (dot, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover()); + ui.painter().circle_filled(dot.center(), 5.0, lang_colour(&r.language)); + ui.label(RichText::new(&r.language).color(t.text_dim).size(11.5)); + ui.add_space(8.0); + } + if r.stars > 0 { + ui.label(RichText::new(format!("{} {}", icon::STAR, widgets::group(r.stars as u64))).color(t.text_dim).size(11.5)); + ui.add_space(8.0); + } + if !r.pushed_at.is_empty() { + ui.label(RichText::new(relative_time(&r.pushed_at, now)).color(t.text_faint).size(11.5)).on_hover_text(local_time(&r.pushed_at)); + } + }); + }); + let resp = resp.response.interact(Sense::click()); + if resp.double_clicked() { + action = Some(Action::Visit(r.html_url.clone())); + } + resp.context_menu(|ui| { + if ui.button(format!("{} Visit", icon::ARROW_SQUARE_OUT)).clicked() { + action = Some(Action::Visit(r.html_url.clone())); + } + if ui.button(format!("{} Download ZIP…", icon::FILE_ZIP)).clicked() { + action = Some(Action::Zip(r.clone())); + } + if ui.button(format!("{} Copy URL", icon::COPY)).clicked() { + action = Some(Action::Copy(r.html_url.clone())); + } + }); + action +} + +#[cfg(test)] +mod tests { + use super::*; + + fn repo(owner: &str, name: &str, at: &str) -> GitHubRepo { + GitHubRepo { owner: owner.into(), name: name.into(), full_name: format!("{owner}/{name}"), pushed_at: at.into(), ..Default::default() } + } + + #[test] + fn groups_by_newest_with_login_first_on_ties() { + let repos = vec![ + repo("org", "a", "2024-01-01T00:00:00Z"), + repo("me", "old", "2023-01-01T00:00:00Z"), + repo("me", "new", "2024-01-01T00:00:00Z"), + repo("zed", "z", "2025-01-01T00:00:00Z"), + ]; + let g = grouped(&repos, "me", ""); + let names: Vec<(&str, Vec<&str>)> = g.iter().map(|(o, v)| (o.as_str(), v.iter().map(|r| r.name.as_str()).collect())).collect(); + assert_eq!(names, [("zed", vec!["z"]), ("me", vec!["new", "old"]), ("org", vec!["a"])]); + assert_eq!(grouped(&repos, "me", "OLD").len(), 1); + } + + #[test] + fn relative_times() { + let now = parse_time("2025-06-10T12:00:00Z").unwrap(); + assert_eq!(relative_time("2025-06-10T11:59:30Z", now), "just now"); + assert_eq!(relative_time("2025-06-10T11:59:00Z", now), "1 minute ago"); + assert_eq!(relative_time("2025-06-10T09:00:00Z", now), "3 hours ago"); + assert_eq!(relative_time("2025-06-01T12:00:00Z", now), "9 days ago"); + assert_eq!(relative_time("2023-06-01T12:00:00Z", now), "2 years ago"); + assert_eq!(relative_time("bad", now), ""); + } +} diff --git a/rust/app/src/irc_view.rs b/rust/app/src/irc_view.rs new file mode 100644 index 0000000..70107ff --- /dev/null +++ b/rust/app/src/irc_view.rs @@ -0,0 +1,543 @@ +//! IRC client view: room tabs, message log, composer and room/user lists. Port of `irc.js`; +//! lines are `ircformat::format_event` spans drawn in the terminal theme's colours. + +use crate::state::Shared; +use crate::terminal_themes::TermTheme; +use crate::theme::Tokens; +use crate::widgets; +use egui::text::LayoutJob; +use egui::{Color32, FontId, Galley, Rect, RichText, Sense, Stroke, TextFormat, Ui, pos2, vec2}; +use egui_phosphor::regular as icon; +use nexus_core::clients::irc::{self, RoomInfo}; +use nexus_core::ircformat::{self, Span}; +use nexus_core::models::Profile; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; + +const SERVER: &str = "server"; +const MAX_LINES: usize = 2000; +const SIDEBAR_W: f32 = 230.0; + +struct Line { + spans: Vec, + /// Laid-out line for the wrap width and theme it was built with. + galley: Option<(f32, &'static str, Arc)>, +} + +struct Target { + name: String, + lines: VecDeque, +} + +/// Where an event goes (irc.js `handleEvent`). +#[derive(Debug, PartialEq)] +enum Route { + Rooms, + Users(String), + RoomList, + Line(String), +} + +fn route(e: &irc::Event) -> Route { + match e.kind.as_str() { + "rooms" => Route::Rooms, + "users" => Route::Users(e.target.clone()), + "room-list" => Route::RoomList, + _ => Route::Line(if e.target.is_empty() { SERVER.into() } else { e.target.clone() }), + } +} + +fn is_room(t: &str) -> bool { + t.starts_with(['#', '&', '+', '!']) +} + +fn display_nick(u: &str) -> &str { + u.trim_start_matches(['~', '&', '@', '%', '+']) +} + +/// Tab completion of the last word: `@pre` → `@nick `, `pre` → `nick ` (first match, case-insensitive). +fn complete(input: &str, users: &[String]) -> Option { + let start = input.rfind(char::is_whitespace).map_or(0, |i| i + 1); + let word = &input[start..]; + let (at, prefix) = match word.strip_prefix('@') { + Some(p) => ("@", p), + None => ("", word), + }; + if prefix.contains('@') || (at.is_empty() && prefix.is_empty()) { + return None; + } + let lower = prefix.to_lowercase(); + let nick = users.iter().map(|u| display_nick(u)).find(|n| n.to_lowercase().starts_with(&lower))?; + Some(format!("{}{at}{nick} ", &input[..start])) +} + +fn time(ms: i64) -> String { + use chrono::{Local, TimeZone}; + let d = if ms > 0 { Local.timestamp_millis_opt(ms).single() } else { None }.unwrap_or_else(Local::now); + d.format("%H:%M:%S").to_string() +} + +/// xterm-256 palette: the theme's 16 colours, the 6×6×6 cube, then the grey ramp. +fn xterm(i: u8, theme: &TermTheme) -> Color32 { + let i = i as usize; + if i < 16 { + let [r, g, b, _] = theme.ansi[i]; + return Color32::from_rgb(r, g, b); + } + if i < 232 { + let i = i - 16; + let v = |x: usize| if x == 0 { 0 } else { (55 + x * 40) as u8 }; + return Color32::from_rgb(v(i / 36), v((i / 6) % 6), v(i % 6)); + } + let g = (8 + (i - 232) * 10) as u8; + Color32::from_rgb(g, g, g) +} + +fn rgb4(c: [u8; 4]) -> Color32 { + Color32::from_rgb(c[0], c[1], c[2]) +} + +fn layout(spans: &[Span], theme: &TermTheme, font: &FontId, wrap: f32) -> LayoutJob { + let mut job = LayoutJob { wrap: egui::text::TextWrapping { max_width: wrap, ..Default::default() }, ..Default::default() }; + let (dfg, dbg) = (rgb4(theme.foreground), Color32::TRANSPARENT); + for s in spans { + let st = s.style; + let mut fg = st.fg_rgb.map(|[r, g, b]| Color32::from_rgb(r, g, b)).or(st.fg.map(|i| xterm(i, theme))).unwrap_or(dfg); + let mut bg = st.bg_rgb.map(|[r, g, b]| Color32::from_rgb(r, g, b)).or(st.bg.map(|i| xterm(i, theme))).unwrap_or(dbg); + if st.reverse { + (fg, bg) = (if bg == Color32::TRANSPARENT { rgb4(theme.background) } else { bg }, fg); + } + if st.dim { + fg = fg.gamma_multiply(0.65); + } + let line = Stroke::new(1.0, fg); + job.append( + &s.text, + 0.0, + TextFormat { + font_id: font.clone(), + color: fg, + background: bg, + italics: st.italic, + underline: if st.underline { line } else { Stroke::NONE }, + strikethrough: if st.strike { line } else { Stroke::NONE }, + ..Default::default() + }, + ); + } + job +} + +pub struct IrcView { + pub session: String, + targets: Vec, + active: String, + unread: HashSet, + users: HashMap>, + joined: Vec, + room_list: Vec, + listing: bool, + room_filter: String, + join_input: String, + input: String, + refreshed: bool, + focus_composer: bool, + font_size: f32, +} + +impl IrcView { + pub fn new(session: &str, profile: &Profile) -> IrcView { + let mut v = IrcView { + session: session.into(), + targets: vec![], + active: SERVER.into(), + unread: HashSet::new(), + users: HashMap::new(), + joined: vec![], + room_list: vec![], + listing: false, + room_filter: String::new(), + join_input: String::new(), + input: String::new(), + refreshed: false, + focus_composer: true, + font_size: if profile.terminal_font_size > 0 { profile.terminal_font_size as f32 } else { 13.0 }, + }; + v.append(SERVER, &irc::Event { kind: "system".into(), text: "Connecting...".into(), ..Default::default() }); + v + } + + fn target(&mut self, name: &str) -> &mut Target { + let name = if name.is_empty() { SERVER } else { name }; + let i = match self.targets.iter().position(|t| t.name == name) { + Some(i) => i, + None => { + self.targets.push(Target { name: name.into(), lines: VecDeque::new() }); + self.targets.len() - 1 + } + }; + &mut self.targets[i] + } + + fn append(&mut self, target: &str, e: &irc::Event) { + let spans = ircformat::format_event(e, &time(e.timestamp)); + let t = self.target(target); + t.lines.push_back(Line { spans, galley: None }); + while t.lines.len() > MAX_LINES { + t.lines.pop_front(); + } + } + + pub fn on_event(&mut self, event: irc::Event) { + match route(&event) { + Route::Rooms => { + // Show the first room once auto-join lands, rather than leaving the server log up. + let first = self.joined.is_empty() && self.active == SERVER; + self.joined = event.rooms; + if first && let Some(r) = self.joined.first().map(|r| r.name.clone()) { + self.set_active(&r); + } + for r in self.joined.clone() { + self.target(&r.name); + } + } + Route::Users(target) => { + if !target.is_empty() { + self.users.insert(target, event.users); + } + } + Route::RoomList => { + self.room_list = event.rooms; + self.listing = false; + } + Route::Line(target) => { + self.append(&target, &event); + if target != self.active { + self.unread.insert(target); + } + } + } + } + + fn set_active(&mut self, target: &str) { + self.target(target); + self.active = target.into(); + self.unread.remove(target); + self.focus_composer = true; + } + + fn send(&mut self, s: &mut Shared) { + let text = std::mem::take(&mut self.input); + if text.trim().is_empty() { + return; + } + let r = s.svc.sessions.irc(&self.session).and_then(|c| c.handle_input(&self.active, &text)); + if let Err(e) = r { + s.error(e.to_string()); + let active = self.active.clone(); + self.append(&active, &irc::Event { kind: "error".into(), text: e.to_string(), ..Default::default() }); + } + } + + fn join(&mut self, s: &mut Shared, room: &str) { + if let Err(e) = s.svc.sessions.irc(&self.session).and_then(|c| c.join(room)) { + s.error(format!("Join failed: {e}")); + } + } + + fn insert_mention(&mut self, nick: &str) { + if !self.input.is_empty() && !self.input.ends_with(char::is_whitespace) { + self.input.push(' '); + } + self.input.push_str(&format!("@{nick} ")); + self.focus_composer = true; + } + + pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) { + if !self.refreshed { + self.refreshed = true; + if let Ok(c) = s.svc.sessions.irc(&self.session) { + self.on_event(irc::Event { kind: "rooms".into(), rooms: c.rooms(), ..Default::default() }); + } + } + let t = Tokens::get(ui.ctx()); + let full = ui.available_rect_before_wrap(); + let side_w = SIDEBAR_W.min(full.width() * 0.4); + let main = Rect::from_min_max(full.min, pos2(full.right() - side_w, full.bottom())); + let side = Rect::from_min_max(pos2(main.right(), full.top()), full.max); + ui.allocate_rect(full, Sense::hover()); + + let tabs_h = 32.0; + let comp_h = 40.0; + let tabs = Rect::from_min_max(main.min, pos2(main.right(), main.top() + tabs_h)); + let log = Rect::from_min_max(pos2(main.left(), tabs.bottom()), pos2(main.right(), main.bottom() - comp_h)); + let comp = Rect::from_min_max(pos2(main.left(), log.bottom()), main.max); + + ui.painter().rect_filled(tabs, 0.0, t.tab_strip); + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(tabs.shrink2(vec2(6.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + self.tabs(&mut c, s); + + let theme = crate::terminal_themes::get(&s.prefs.terminal_theme); + ui.painter().rect_filled(log, 0.0, rgb4(theme.background)); + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(log.shrink2(vec2(10.0, 6.0)))); + self.log(&mut c, theme); + + ui.painter().rect_filled(comp, 0.0, t.chrome); + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(comp.shrink2(vec2(8.0, 6.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + self.composer(&mut c, s); + + ui.painter().rect_filled(side, 0.0, t.dock); + ui.painter().line_segment([side.left_top(), side.left_bottom()], Stroke::new(1.0, t.separator)); + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(side.shrink2(vec2(10.0, 8.0)))); + self.sidebar(&mut c, s); + } + + fn tabs(&mut self, ui: &mut Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + egui::ScrollArea::horizontal().id_salt("irc-tabs").show(ui, |ui| { + ui.horizontal(|ui| { + let names: Vec = self.targets.iter().map(|t| t.name.clone()).collect(); + for name in names { + let label = if name == SERVER { format!("{} Server", icon::HARD_DRIVES) } else { name.clone() }; + let unread = self.unread.contains(&name); + let text = if unread { RichText::new(format!("{label} •")).color(t.accent) } else { RichText::new(label) }; + let r = ui.selectable_label(self.active == name, text); + if r.clicked() { + self.set_active(&name); + } + if is_room(&name) { + r.context_menu(|ui| { + if ui.button(format!("{} Leave {name}", icon::SIGN_OUT)).clicked() + && let Err(e) = s.svc.sessions.irc(&self.session).and_then(|c| c.part(&name)) + { + s.error(format!("Part failed: {e}")); + } + }); + } + } + }); + }); + } + + fn log(&mut self, ui: &mut Ui, theme: &'static TermTheme) { + let font = FontId::monospace(self.font_size); + let wrap = ui.available_width().max(100.0); + let Some(target) = self.targets.iter_mut().find(|t| t.name == self.active) else { + return; + }; + // Lay out lines once per width/theme; the galley cache keeps scrolling cheap. + let mut tops = Vec::with_capacity(target.lines.len()); + let mut y = 0.0; + for line in target.lines.iter_mut() { + let stale = line.galley.as_ref().is_none_or(|(w, id, _)| *w != wrap || *id != theme.id); + if stale { + let g = ui.fonts_mut(|f| f.layout_job(layout(&line.spans, theme, &font, wrap))); + line.galley = Some((wrap, theme.id, g)); + } + tops.push(y); + y += line.galley.as_ref().map_or(0.0, |(_, _, g)| g.size().y) + 1.0; + } + let total = y; + egui::ScrollArea::vertical().id_salt(("irc-log", &self.active)).auto_shrink([false, false]).stick_to_bottom(true).show_viewport(ui, |ui, vp| { + let (rect, _) = ui.allocate_exact_size(vec2(wrap, total), Sense::hover()); + let first = tops.partition_point(|&top| top < vp.min.y).saturating_sub(1); + for (i, line) in target.lines.iter().enumerate().skip(first) { + if tops[i] > vp.max.y { + break; + } + if let Some((_, _, g)) = &line.galley { + ui.painter().galley(pos2(rect.left(), rect.top() + tops[i]), g.clone(), rgb4(theme.foreground)); + } + } + }); + } + + fn composer(&mut self, ui: &mut Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + let id = egui::Id::new(("irc-composer", &self.session)); + // Tab completes nicks instead of moving focus. + if ui.memory(|m| m.has_focus(id)) && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)) && is_room(&self.active) { + let users = self.users.get(&self.active).cloned().unwrap_or_default(); + if let Some(done) = complete(&self.input, &users) { + self.input = done; + self.move_cursor_to_end(ui, id); + } + } + let mut send = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + send |= ui.add(egui::Button::new(RichText::new(format!("{} Send", icon::PAPER_PLANE_RIGHT)).color(t.accent_text)).fill(t.accent)).clicked(); + let hint = if self.active == SERVER { "/command or /msg ".to_string() } else { format!("Message {} or /command", self.active) }; + let r = ui.add(egui::TextEdit::singleline(&mut self.input).id(id).hint_text(hint).desired_width(ui.available_width())); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + send = true; + self.focus_composer = true; + } + }); + if send { + self.send(s); + } + if std::mem::take(&mut self.focus_composer) { + ui.memory_mut(|m| m.request_focus(id)); + self.move_cursor_to_end(ui, id); + } + } + + fn move_cursor_to_end(&self, ui: &Ui, id: egui::Id) { + if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), id) { + let end = egui::text::CCursor::new(self.input.chars().count()); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(end))); + st.store(ui.ctx(), id); + } + } + + fn sidebar(&mut self, ui: &mut Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + let total_h = ui.available_height(); + + widgets::section(ui, "Rooms"); + let mut join = false; + ui.horizontal(|ui| { + let r = ui.add(egui::TextEdit::singleline(&mut self.join_input).hint_text("#room").desired_width(ui.available_width() - 52.0)); + join |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + join |= ui.button("Join").clicked(); + }); + if join && !self.join_input.trim().is_empty() { + let room = self.join_input.trim().to_string(); + self.join(s, &room); + self.join_input.clear(); + } + let rows_h = 22.0; + if self.joined.is_empty() { + widgets::hint(ui, "No joined rooms"); + } else { + let joined = self.joined.clone(); + let h = (joined.len() as f32 * (rows_h + 2.0)).min(total_h * 0.22); + ui.allocate_ui(vec2(ui.available_width(), h), |ui| { + let sel = joined.iter().position(|r| r.name == self.active); + let clicked = widgets::rows(ui, "irc-joined", joined.len(), rows_h, sel, |ui, i| { + let r = &joined[i]; + ui.label(&r.name); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(r.users.to_string()).color(t.text_faint).size(11.0)); + }); + let tip = ircformat::plain_text(if r.topic.is_empty() { &r.name } else { &r.topic }); + ui.interact(ui.max_rect(), ui.id().with("tip"), Sense::hover()).on_hover_text(tip); + }); + if let Some(i) = clicked { + self.set_active(&joined[i].name); + } + }); + } + + ui.add_space(6.0); + ui.horizontal(|ui| { + widgets::section(ui, "Room List"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button(format!("{} List", icon::LIST_BULLETS)).clicked() { + self.room_list.clear(); + self.listing = true; + if let Err(e) = s.svc.sessions.irc(&self.session).and_then(|c| c.list_rooms()) { + self.listing = false; + s.error(format!("Room list failed: {e}")); + } + } + }); + }); + ui.add(egui::TextEdit::singleline(&mut self.room_filter).hint_text("Filter rooms").desired_width(ui.available_width())); + let filter = self.room_filter.to_lowercase(); + let listed: Vec<&RoomInfo> = + self.room_list.iter().filter(|r| filter.is_empty() || format!("{} {}", r.name, r.topic).to_lowercase().contains(&filter)).collect(); + let list_h = total_h * 0.3; + let mut join_room = None; + if listed.is_empty() { + widgets::hint(ui, if self.listing { "Loading rooms..." } else { "No rooms loaded" }); + } else { + ui.allocate_ui(vec2(ui.available_width(), list_h), |ui| { + let clicked = widgets::rows(ui, "irc-room-list", listed.len(), rows_h, None, |ui, i| { + let r = listed[i]; + ui.label(&r.name); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(r.users.to_string()).color(t.text_faint).size(11.0)); + ui.label(RichText::new(ircformat::plain_text(&r.topic)).color(t.text_faint).size(11.0)); + }); + ui.interact(ui.max_rect(), ui.id().with("tip"), Sense::hover()) + .on_hover_text(format!("{}\nClick to join", ircformat::plain_text(if r.topic.is_empty() { &r.name } else { &r.topic }))); + }); + join_room = clicked.map(|i| listed[i].name.clone()); + }); + } + if let Some(room) = join_room { + self.join(s, &room); + } + + ui.add_space(6.0); + let users = if is_room(&self.active) { self.users.get(&self.active).cloned() } else { None }; + widgets::section(ui, &format!("Users{}", users.as_ref().map(|u| format!(" ({})", u.len())).unwrap_or_default())); + match users { + _ if !is_room(&self.active) => widgets::hint(ui, "No room selected"), + Some(u) if !u.is_empty() => { + let clicked = widgets::rows(ui, "irc-users", u.len(), rows_h, None, |ui, i| { + let nick = display_nick(&u[i]); + ui.label(RichText::new(nick).color(xterm(ircformat::nick_colour(nick), crate::terminal_themes::get(&s.prefs.terminal_theme)))); + ui.interact(ui.max_rect(), ui.id().with("tip"), Sense::hover()).on_hover_text(format!("{}\nClick to mention", u[i])); + }); + if let Some(i) = clicked { + self.insert_mention(display_nick(&u[i])); + } + } + _ => widgets::hint(ui, "No users yet"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ev(kind: &str, target: &str) -> irc::Event { + irc::Event { kind: kind.into(), target: target.into(), text: "hi".into(), ..Default::default() } + } + + #[test] + fn routes_like_irc_js() { + assert_eq!(route(&ev("rooms", "")), Route::Rooms); + assert_eq!(route(&ev("users", "#a")), Route::Users("#a".into())); + assert_eq!(route(&ev("room-list", "")), Route::RoomList); + assert_eq!(route(&ev("message", "#a")), Route::Line("#a".into())); + assert_eq!(route(&ev("quit", "")), Route::Line(SERVER.into())); + } + + #[test] + fn events_fill_targets_and_unread() { + let mut v = IrcView::new("s", &Profile::new(nexus_core::models::Protocol::Irc)); + v.on_event(ev("message", "#rust")); + v.on_event(ev("nick", "")); + assert_eq!(v.targets.iter().map(|t| t.name.as_str()).collect::>(), [SERVER, "#rust"]); + assert!(v.unread.contains("#rust") && !v.unread.contains(SERVER)); + assert_eq!(v.targets[0].lines.len(), 2); // "Connecting..." + nick + v.set_active("#rust"); + assert!(v.unread.is_empty()); + for _ in 0..MAX_LINES + 5 { + v.on_event(ev("message", "#rust")); + } + assert_eq!(v.targets[1].lines.len(), MAX_LINES); + } + + #[test] + fn nick_completion() { + let users = vec!["@Alice".to_string(), "+bob".to_string()]; + assert_eq!(complete("hi @al", &users).as_deref(), Some("hi @Alice ")); + assert_eq!(complete("bo", &users).as_deref(), Some("bob ")); + assert_eq!(complete("hi ", &users), None); + assert_eq!(complete("x @zz", &users), None); + assert_eq!(display_nick("~&@%+nick"), "nick"); + } + + #[test] + fn palette() { + let th = crate::terminal_themes::get("charcoal"); + assert_eq!(xterm(16, th), Color32::from_rgb(0, 0, 0)); + assert_eq!(xterm(231, th), Color32::from_rgb(255, 255, 255)); + assert_eq!(xterm(244, th), Color32::from_rgb(128, 128, 128)); + } +} diff --git a/rust/app/src/main.rs b/rust/app/src/main.rs new file mode 100644 index 0000000..d6faa54 --- /dev/null +++ b/rust/app/src/main.rs @@ -0,0 +1,121 @@ +//! Nexus Terminal: SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local terminals in one +//! native window. `nexus-terminal [--theme pro|midnight|light] [--screenshot out.png] [--size WxH]`. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod app; +mod chrome; +mod forwards; +mod github_view; +mod irc_view; +mod profile_editor; +mod quick_connect; +mod raw_view; +mod recordings; +mod settings; +mod sftp; +mod sidebar; +mod snippets; +mod splits; +mod ssh_keys; +mod state; +mod tabs; +mod terminal; +mod terminal_themes; +mod theme; +mod transfer; +mod triggers; +mod views; +mod vnc_view; +mod widgets; + +use nexus_core::config::AppConfig; +use nexus_core::service::Service; +use std::sync::Arc; + +fn main() -> eframe::Result { + let mut opts = app::Options { + screenshot: None, + theme: None, + run_local: None, + open: None, + connect: vec![], + actions: vec![], + answers: vec![], + shot_delay: std::time::Duration::from_millis(600), + }; + let mut size = [1360.0, 860.0]; + let mut data_dir = None; + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + match a.as_str() { + "--theme" => opts.theme = args.next(), + "--run-local" => opts.run_local = args.next(), + "--open" => opts.open = args.next(), + "--connect" => opts.connect.extend(args.next()), + "--do" => opts.actions.extend(args.next()), + "--answer" => opts.answers.extend(args.next()), + "--shot-delay" => { + if let Some(ms) = args.next().and_then(|s| s.parse().ok()) { + opts.shot_delay = std::time::Duration::from_millis(ms); + } + } + "--screenshot" => opts.screenshot = args.next().map(Into::into), + // Use another data directory (testing, screenshots, portable installs). + "--data-dir" => data_dir = args.next(), + "--size" => { + if let Some((w, h)) = args.next().as_deref().and_then(|s| s.split_once('x')) { + size = [w.parse().unwrap_or(size[0]), h.parse().unwrap_or(size[1])]; + } + } + "-V" | "--version" => { + println!("Nexus Terminal {}", nexus_core::service::VERSION); + return Ok(()); + } + _ => { + println!( + "usage: nexus-terminal [--theme pro|midnight|light] [--data-dir DIR] [--connect NAME]... [--open WINDOW] [--do PALETTE-COMMAND]... [--answer SECRET]... [--shot-delay MS] [--run-local CMD] [--screenshot out.png] [--size WxH] [--version]" + ); + return Ok(()); + } + } + } + let config = match data_dir { + Some(d) => { + let c = AppConfig::in_dir(d.into()); + nexus_core::config::create_private_dir(&c.data_dir).and_then(|_| nexus_core::config::create_private_dir(&c.vault_dir)).map(|_| c) + } + None => AppConfig::load(), + }; + let svc = match config.map_err(|e| e.to_string()).and_then(|c| Service::open(c).map_err(|e| e.to_string())) { + Ok(s) => Arc::new(s), + Err(e) => { + eprintln!("Nexus Terminal couldn't open its data: {e}"); + rfd::MessageDialog::new().set_title("Nexus Terminal").set_description(format!("Couldn't open the application data:\n{e}")).show(); + std::process::exit(1); + } + }; + let headless = opts.screenshot.is_some(); + let native = eframe::NativeOptions { + persist_window: !headless, + viewport: egui::ViewportBuilder::default() + .with_title("Nexus Terminal") + .with_app_id("nexus-terminal") + .with_icon(window_icon()) + .with_inner_size(size) + .with_min_inner_size([820.0, 520.0]) + .with_decorations(false) + .with_transparent(false) + .with_drag_and_drop(true), + ..Default::default() + }; + let svc2 = svc.clone(); + let r = eframe::run_native("nexus-terminal", native, Box::new(move |cc| Ok(Box::new(app::App::new(cc, svc2, opts))))); + svc.sessions.close_all(); + svc.secrets.vault.lock(); + r +} + +fn window_icon() -> egui::IconData { + let img = image::load_from_memory(chrome::LOGO_PNG).map(|i| i.to_rgba8()).unwrap_or_default(); + egui::IconData { width: img.width(), height: img.height(), rgba: img.into_raw() } +} diff --git a/rust/app/src/profile_editor.rs b/rust/app/src/profile_editor.rs new file mode 100644 index 0000000..c9025a5 --- /dev/null +++ b/rust/app/src/profile_editor.rs @@ -0,0 +1,1216 @@ +//! Profile editor (right dock): create and edit every protocol's settings. A port of the +//! `profiles.js` drawer plus the Serial protocol. Switching protocol keeps the common fields +//! (including the group), and list editors keep unsaved edits. + +use crate::state::{Request, Shared}; +use crate::theme::{self, Tokens}; +use crate::widgets; +use egui::{Align, Button, ComboBox, DragValue, Layout, Response, RichText, Stroke, TextEdit, Ui, vec2}; +use egui_phosphor::regular as icon; +use nexus_core::clients::serial; +use nexus_core::keyfiles; +use nexus_core::models::*; +use nexus_core::validation::{ValidationError, validate_profile}; +use std::collections::{BTreeMap, HashMap}; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +type KeyChecks = HashMap>; + +pub struct ProfileEditor { + open: bool, + is_new: bool, + p: Profile, + // Text buffers parsed on save. + tags: String, + env: String, + rooms: String, + /// Secrets typed into the editor, by session-manager kind ("password", "github-token", …); + /// stored when the profile is saved. + secrets: BTreeMap<&'static str, String>, + totp: String, + totp_show: bool, + totp_saved: bool, + /// Text shown on the TOTP Test button until the given time. + totp_flash: Option<(String, f64)>, + keys: Vec, + folders: Vec, + ports: Vec, + key_checks: KeyChecks, + /// Inline validation shows after the first Save/Test attempt. + show_errors: bool, + confirm_delete: bool, + testing: bool, + test: Arc>>>, +} + +impl Default for ProfileEditor { + fn default() -> Self { + ProfileEditor { + open: false, + is_new: true, + p: Profile::new(Protocol::Ssh), + tags: String::new(), + env: String::new(), + rooms: String::new(), + secrets: BTreeMap::new(), + totp: String::new(), + totp_show: false, + totp_saved: false, + totp_flash: None, + keys: vec![], + folders: vec![], + ports: vec![], + key_checks: HashMap::new(), + show_errors: false, + confirm_delete: false, + testing: false, + test: Default::default(), + } + } +} + +impl ProfileEditor { + pub fn open_new(&mut self, s: &Shared, protocol: Option, folder: &str) { + let proto = protocol.or_else(|| Protocol::from_id(&s.prefs.default_protocol)).filter(|p| *p != Protocol::Local).unwrap_or_default(); + let mut p = Profile::new(proto); + p.folder = folder.to_string(); + self.load(s, p, true); + } + + pub fn open_edit(&mut self, s: &Shared, id: &str) { + if let Some(p) = s.profile(id) { + self.load(s, p.clone(), false); + } + } + + pub fn is_open(&self) -> bool { + self.open + } + + fn load(&mut self, s: &Shared, mut p: Profile, is_new: bool) { + prep(&mut p); + self.tags = p.tags.join(", "); + self.env = p.ssh.as_ref().map(|c| c.env_vars.iter().map(|(k, v)| format!("{k}={v}")).collect::>().join("\n")).unwrap_or_default(); + self.rooms = p.irc.as_ref().map(|c| c.auto_join_rooms.join("\n")).unwrap_or_default(); + self.totp_saved = !is_new && s.svc.has_totp(&p.id); + self.p = p; + self.is_new = is_new; + self.secrets.clear(); + self.totp.clear(); + self.totp_show = false; + self.totp_flash = None; + self.keys = keyfiles::list_ssh_keys().unwrap_or_default().iter().map(|k| k.display().to_string()).collect(); + let mut folders: Vec = s.profiles.iter().map(|p| p.folder.trim().to_string()).filter(|f| !f.is_empty()).collect(); + folders.sort_by_key(|f| f.to_lowercase()); + folders.dedup(); + self.folders = folders; + self.ports = serial::list_ports(); + self.key_checks.clear(); + self.show_errors = false; + self.confirm_delete = false; + self.open = true; + } + + /// Keeps the common fields (folder included) and resets the port to the new protocol's + /// default. Protocol configs edited earlier in this session are kept until save. + fn switch(&mut self, proto: Protocol) { + if self.p.protocol == proto { + return; + } + let d = Profile::new(proto); + let p = &mut self.p; + p.protocol = proto; + p.port = d.port; + if proto == Protocol::Github && p.host.trim().is_empty() { + p.host = d.host; + } + p.ssh = p.ssh.take().or(d.ssh); + p.telnet = p.telnet.take().or(d.telnet); + p.raw = p.raw.take().or(d.raw); + p.github = p.github.take().or(d.github); + p.vnc = p.vnc.take().or(d.vnc); + p.irc = p.irc.take().or(d.irc); + p.serial = p.serial.take().or(d.serial); + prep(p); + } + + /// The profile as it would be saved: buffers parsed, other protocols' configs dropped. + fn collect(&self) -> Profile { + let mut p = self.p.clone(); + let proto = p.protocol; + if proto != Protocol::Ssh { + p.ssh = None; + } + if proto != Protocol::Telnet { + p.telnet = None; + } + if proto != Protocol::Raw { + p.raw = None; + } + if proto != Protocol::Github { + p.github = None; + } + if proto != Protocol::Vnc { + p.vnc = None; + } + if proto != Protocol::Irc { + p.irc = None; + } + if proto != Protocol::Serial { + p.serial = None; + } + p.folder = p.folder.trim().to_string(); + p.host = p.host.trim().to_string(); + p.tags = self.tags.split(',').map(str::trim).filter(|t| !t.is_empty()).map(String::from).collect(); + if let Some(c) = &mut p.ssh { + c.env_vars = parse_env(&self.env); + if c.proxy_type == "none" { + c.proxy_type.clear(); + } + } + if let Some(c) = &mut p.irc { + c.auto_join_rooms = self.rooms.split(['\n', ',']).map(str::trim).filter(|r| !r.is_empty()).map(String::from).collect(); + } + p + } + + fn validate(&self) -> Vec { + let mut p = self.collect(); + p.normalize(); + validate_profile(&p).err().map(|e| e.errors).unwrap_or_default() + } + + /// Drawn inside the right dock while open. + pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + if let Some(r) = self.test.lock().unwrap().take() { + self.testing = false; + match r { + Ok(ms) => s.success(format!("Reachable — {ms} ms")), + Err(e) => s.error(format!("Test failed: {e}")), + } + } + ui.horizontal(|ui| { + ui.label(RichText::new(if self.is_new { "New Connection" } else { "Edit Connection" }).font(theme::semibold(15.0))); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::icon_button(ui, icon::X, "Close").clicked() { + self.open = false; + } + }); + }); + ui.horizontal_wrapped(|ui| { + for proto in Protocol::SAVED { + if ui.selectable_label(self.p.protocol == proto, proto.label()).clicked() { + self.switch(proto); + } + } + }); + ui.add_space(2.0); + let errs = if self.show_errors { self.validate() } else { vec![] }; + egui::Panel::bottom("profile-editor-footer") + .resizable(false) + .frame(egui::Frame::NONE.fill(t.dock).inner_margin(egui::Margin::symmetric(0, 6))) + .show(ui, |ui| self.footer(ui, s, &errs)); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + ui.add_space(4.0); + self.general(ui, &errs); + match self.p.protocol { + Protocol::Ssh => self.ssh(ui, s, &errs), + Protocol::Telnet => self.telnet(ui), + Protocol::Raw => self.raw(ui, &errs), + Protocol::Github => self.github(ui, &errs), + Protocol::Vnc => self.vnc(ui, &errs), + Protocol::Irc => self.irc(ui, &errs), + Protocol::Serial => self.serial(ui, &errs), + Protocol::Local => {} + } + if matches!(self.p.protocol, Protocol::Ssh | Protocol::Telnet | Protocol::Irc | Protocol::Serial) { + self.terminal(ui, &errs); + } + let (p, tags) = (&mut self.p, &mut self.tags); + section(ui, "Notes & Tags", |ui| { + text(ui, "Tags", tags, "production, london, vpn"); + widgets::hint(ui, "Comma-separated tags for filtering"); + label(ui, "Notes"); + ui.add(TextEdit::multiline(&mut p.notes).hint_text("Free-form notes…").desired_rows(4).desired_width(f32::INFINITY)); + }); + }); + } + + fn footer(&mut self, ui: &mut Ui, s: &mut Shared, errs: &[ValidationError]) { + let t = Tokens::get(ui.ctx()); + if !errs.is_empty() { + let all = errs.iter().map(|e| e.message.clone()).collect::>().join("\n"); + let n = errs.len(); + ui.label( + RichText::new(format!("{} {n} problem{} — see the highlighted fields", icon::WARNING_CIRCLE, if n == 1 { "" } else { "s" })) + .color(t.danger) + .size(12.0), + ) + .on_hover_text(all); + } + if self.confirm_delete { + ui.horizontal(|ui| { + ui.label(RichText::new(format!("Delete \"{}\"? This can't be undone.", self.p.display_name)).color(t.danger)); + }); + ui.horizontal(|ui| { + if ui.add(Button::new(RichText::new(format!("{} Delete", icon::TRASH)).color(t.accent_text)).fill(t.danger)).clicked() { + match s.svc.delete_profile(&self.p.id) { + Ok(()) => { + s.success("Profile deleted"); + s.request(Request::ReloadProfiles); + self.open = false; + } + Err(e) => s.error(format!("Failed to delete profile: {e}")), + } + self.confirm_delete = false; + } + if ui.button("Keep").clicked() { + self.confirm_delete = false; + } + }); + return; + } + ui.horizontal(|ui| { + if !self.is_new && ui.add(Button::new(RichText::new(icon::TRASH).color(t.danger))).on_hover_text("Delete profile").clicked() { + self.confirm_delete = true; + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let connect = if self.is_new { "Create & Connect" } else { "Save & Connect" }; + if widgets::primary(ui, icon::PLUGS_CONNECTED, connect).clicked() { + self.save(s, true); + } + if widgets::button(ui, icon::FLOPPY_DISK, if self.is_new { "Create" } else { "Save" }).clicked() { + self.save(s, false); + } + let can_test = !self.testing && self.p.protocol != Protocol::Serial; + if ui.add_enabled(can_test, Button::new(format!("{} {}", icon::PULSE, if self.testing { "Testing…" } else { "Test" }))).clicked() { + self.test_connection(s); + } + if ui.button("Cancel").clicked() { + self.open = false; + } + }); + }); + } + + fn save(&mut self, s: &mut Shared, and_connect: bool) { + self.show_errors = true; + let errs = self.validate(); + if let Some(e) = errs.first() { + s.warn(format!("Please fix the highlighted fields ({}).", e.message)); + return; + } + let p = self.collect(); + let r = if self.is_new { s.svc.create_profile(p) } else { s.svc.update_profile(p) }; + match r { + Err(e) => s.error(format!("Save failed: {e}")), + Ok(saved) => { + for (kind, v) in &self.secrets { + if !v.is_empty() + && let Err(e) = s.svc.set_profile_secret(&saved.id, kind, v) + { + s.error(format!("Couldn't store the {kind} secret: {e}")); + } + } + s.success(if self.is_new { "Profile created" } else { "Profile updated" }); + s.request(Request::ReloadProfiles); + self.open = false; + if and_connect { + s.request(Request::Connect(saved.id)); + } + } + } + } + + fn test_connection(&mut self, s: &mut Shared) { + self.show_errors = true; + let p = self.collect(); + let (host, port) = match &p.vnc { + Some(c) if p.protocol == Protocol::Vnc => (if c.vnc_host.trim().is_empty() { p.host.clone() } else { c.vnc_host.trim().to_string() }, c.vnc_port), + _ => (p.host.clone(), p.port), + }; + s.info(format!("Testing connection to {host}:{port}…")); + self.testing = true; + let (slot, svc, ctx) = (self.test.clone(), s.svc.clone(), s.ctx.clone()); + std::thread::spawn(move || { + let r = svc.test_connection(&host, port).map_err(|e| e.to_string()); + *slot.lock().unwrap() = Some(r); + ctx.request_repaint(); + }); + } + + // --- sections --------------------------------------------------------------------------- + + fn general(&mut self, ui: &mut Ui, errs: &[ValidationError]) { + let Self { p, folders, ports, .. } = self; + section(ui, "General", |ui| { + text(ui, "Display Name", &mut p.display_name, "My Server"); + if p.protocol == Protocol::Serial { + label(ui, "Device"); + let mut refresh = false; + suggest(ui, &mut p.host, "/dev/ttyUSB0, /dev/cu.usbserial…, COM3", ports, |ui, _| { + refresh = widgets::icon_button(ui, icon::ARROWS_CLOCKWISE, "Refresh the device list").clicked(); + }); + if refresh { + *ports = serial::list_ports(); + } + if ports.is_empty() { + widgets::hint(ui, "No serial devices found. Type a device path or plug one in and refresh."); + } + err(ui, errs, "host"); + } else { + let hint = if p.protocol == Protocol::Github { "github.com" } else { "192.168.1.1 or example.com" }; + text(ui, "Host", &mut p.host, hint); + err(ui, errs, "host"); + if p.protocol != Protocol::Github { + num(ui, "Port", &mut p.port, 1..=65535); + err(ui, errs, "port"); + text(ui, "Username", &mut p.username, "root"); + err(ui, errs, "username"); + } + } + label(ui, "Group"); + suggest(ui, &mut p.folder, "e.g. Production, Staging, Home Lab", folders, |_, _| {}); + widgets::hint(ui, "Choose an existing group or type a new name to create one"); + text(ui, "Description", &mut p.description, "Optional description"); + err(ui, errs, "displayName"); + }); + } + + fn ssh(&mut self, ui: &mut Ui, s: &mut Shared, errs: &[ValidationError]) { + let Self { p, keys, key_checks, secrets, env, totp, totp_show, totp_saved, totp_flash, is_new, .. } = self; + let id = p.id.clone(); + let c = p.ssh.get_or_insert_with(SshConfig::defaults); + section(ui, "Authentication", |ui| { + mode(ui, "Password Mode", &mut c.secret_mode); + if c.secret_mode == SecretMode::Saved { + secret(ui, "Password", secrets, "password", "Leave blank to keep the saved password"); + } + label(ui, "Auth Methods"); + ui.horizontal_wrapped(|ui| { + for (m, l) in [("publickey", "Public key"), ("password", "Password"), ("keyboard-interactive", "Keyboard-interactive")] { + let mut on = c.auth_methods.iter().any(|x| x == m); + if ui.checkbox(&mut on, l).changed() { + if on { + c.auth_methods.push(m.into()); + } else { + c.auth_methods.retain(|x| x != m); + } + } + } + }); + err(ui, errs, "ssh.authMethods"); + }); + section(ui, "Key & Passphrase", |ui| { + label(ui, "Private Key Path"); + key_path(ui, &mut c.private_key_path, keys, key_checks, "/Users/username/.ssh/id_ed25519"); + widgets::hint(ui, "Select a key from your .ssh folder or type a full path"); + mode(ui, "Passphrase Mode", &mut c.key_secret_mode); + if c.key_secret_mode == SecretMode::Saved { + secret(ui, "Passphrase", secrets, "passphrase", "Leave blank to keep the saved passphrase"); + } + }); + section(ui, "Host Verification", |ui| { + let opts: Vec<_> = HostKeyPolicy::ALL.iter().map(|k| (*k, k.label())).collect(); + combo(ui, "Host Key Policy", &mut c.host_key_policy, &opts); + }); + section(ui, "Jump Hosts", |ui| { + let n = c.jump_hosts.len(); + let mut act = None; + for (i, jh) in c.jump_hosts.iter_mut().enumerate() { + item(ui, |ui| { + rtl(ui, |ui| { + act = row_buttons(ui, i, n).or(act.take()); + ui.add(TextEdit::singleline(&mut jh.username).hint_text("User").desired_width(ui.available_width() * 0.35)); + ui.add(DragValue::new(&mut jh.port).range(1..=65535)); + ui.add(TextEdit::singleline(&mut jh.host).hint_text("Host").desired_width(ui.available_width())); + }); + key_path(ui, &mut jh.private_key_path, keys, key_checks, "Full key path (optional)"); + }); + } + apply(&mut c.jump_hosts, act); + list_errs(ui, errs, "ssh.jumpHosts"); + if ui.button(format!("{} Add Jump Host", icon::PLUS)).clicked() { + c.jump_hosts.push(JumpHost { port: 22, auth_method: "publickey".into(), secret_mode: SecretMode::AskEveryTime, ..Default::default() }); + } + }); + section(ui, "Port Forwarding", |ui| { + let n = c.port_forwards.len(); + let mut act = None; + for (i, pf) in c.port_forwards.iter_mut().enumerate() { + item(ui, |ui| { + ui.horizontal(|ui| { + let opts = [(ForwardDirection::Local, "Local →"), (ForwardDirection::Remote, "← Remote")]; + ComboBox::from_id_salt(("pf-dir", i)) + .width(84.0) + .selected_text(if pf.direction == ForwardDirection::Local { opts[0].1 } else { opts[1].1 }) + .show_ui(ui, |ui| { + for (v, l) in opts { + ui.selectable_value(&mut pf.direction, v, l); + } + }); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + act = row_buttons(ui, i, n).or(act.take()); + ui.checkbox(&mut pf.enabled, "Active").on_hover_text("Start on connect"); + ui.add(TextEdit::singleline(&mut pf.label).hint_text("Label (optional)").desired_width(ui.available_width())); + }); + }); + rtl(ui, |ui| { + // Two host fields share what the ports and arrow leave. + let w = ((ui.available_width() - 150.0) / 2.0).max(40.0); + ui.add(DragValue::new(&mut pf.dest_port).range(1..=65535)); + ui.add(TextEdit::singleline(&mut pf.dest_host).hint_text("Dest host").desired_width(w)); + ui.label("⟶"); + ui.add(DragValue::new(&mut pf.bind_port).range(1..=65535)); + ui.add(TextEdit::singleline(&mut pf.bind_host).hint_text("Bind host").desired_width(ui.available_width())); + }); + }); + } + apply(&mut c.port_forwards, act); + list_errs(ui, errs, "ssh.portForwards"); + if ui.button(format!("{} Add Port Forward", icon::PLUS)).clicked() { + c.port_forwards.push(PortForward { + id: new_id(), + bind_host: "127.0.0.1".into(), + bind_port: 1, + dest_port: 1, + enabled: true, + ..Default::default() + }); + } + }); + section(ui, "Startup", |ui| { + text(ui, "Startup Command", &mut c.startup_command, "Optional command to run after connecting"); + label(ui, "Environment Variables"); + ui.add( + TextEdit::multiline(env).hint_text("KEY=VALUE (one per line)").desired_rows(3).desired_width(f32::INFINITY).font(egui::TextStyle::Monospace), + ); + }); + section(ui, "Login Script", |ui| login_script(ui, "ssh-login", &mut c.login_scripts)); + section(ui, "SSH Agent", |ui| { + check(ui, "Use SSH Agent for Authentication", &mut c.agent_auth, "Authenticate using keys from the local SSH agent (ssh-agent)"); + check(ui, "Forward Agent to Remote", &mut c.agent_forwarding, "Allow the remote host to use your local SSH agent keys (equivalent to ssh -A)"); + }); + section(ui, "TOTP / 2FA", |ui| { + widgets::hint(ui, "Configure a TOTP secret to auto-fill 2FA codes during SSH keyboard-interactive authentication. The secret is stored encrypted."); + label(ui, "TOTP Secret"); + let hint = if *totp_saved { "••••••••• (secret saved)" } else { "Base32-encoded secret (e.g. from authenticator app setup)" }; + rtl(ui, |ui| { + if widgets::icon_button(ui, if *totp_show { icon::EYE_SLASH } else { icon::EYE }, "Show/hide secret").clicked() { + *totp_show = !*totp_show; + } + ui.add(TextEdit::singleline(totp).password(!*totp_show).hint_text(hint).desired_width(ui.available_width())); + }); + ui.horizontal(|ui| { + let flash = totp_flash.as_ref().filter(|(_, until)| *until > s.now).map(|(t, _)| t.clone()); + if let Some((_, until)) = totp_flash { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((*until - s.now).max(0.0))); + } + let test = ui + .add(Button::new(RichText::new(flash.as_deref().unwrap_or("Test")).font(theme::mono(12.5)))) + .on_hover_text("Generate a code with the saved secret"); + if test.clicked() { + *totp_flash = Some(match (*is_new, s.svc.totp_code(&id)) { + (false, Ok(code)) => (code, s.now + 5.0), + _ => ("Save first".into(), s.now + 2.0), + }); + } + if ui.button("Save").on_hover_text("Encrypt and save the TOTP secret").clicked() { + let v = totp.trim().to_string(); + if *is_new { + s.warn("Save the profile first, then set the TOTP secret."); + } else if v.is_empty() { + s.svc.delete_totp(&id); + *totp_saved = false; + s.info("TOTP secret removed."); + } else { + match s.svc.set_profile_secret(&id, "totp", &v) { + Ok(()) => { + totp.clear(); + *totp_saved = true; + s.success("TOTP secret saved securely."); + } + Err(e) => s.error(format!("Failed to save TOTP secret: {e}")), + } + } + } + if ui.add_enabled(!*is_new, Button::new("Delete")).on_hover_text("Remove the TOTP secret").clicked() { + s.svc.delete_totp(&id); + *totp_saved = false; + s.info("TOTP secret removed."); + } + }); + }); + section(ui, "Proxy", |ui| { + combo_s(ui, "Proxy Type", &mut c.proxy_type, &[("none", "None (direct connection)"), ("socks5", "SOCKS5"), ("http", "HTTP CONNECT")]); + if c.proxy_type != "none" { + text(ui, "Proxy Host", &mut c.proxy_host, "proxy.example.com"); + num(ui, "Proxy Port", &mut c.proxy_port, 1..=65535); + text(ui, "Proxy Username", &mut c.proxy_username, "Optional"); + label(ui, "Proxy Password"); + ui.add(TextEdit::singleline(&mut c.proxy_password).password(true).hint_text("Optional").desired_width(f32::INFINITY)); + } + }); + section(ui, "Advanced", |ui| { + text(ui, "Terminal Type", &mut c.terminal_type, DEFAULT_TERMINAL_TYPE); + num(ui, "Keepalive Interval (s)", &mut c.keepalive_interval, 0..=86400); + num(ui, "Max Missed Keepalives", &mut c.keepalive_count_max, 0..=1000); + check(ui, "Compression", &mut c.compression, ""); + reconnect(ui, &mut c.auto_reconnect, &mut c.max_reconnect_tries); + }); + } + + fn telnet(&mut self, ui: &mut Ui) { + let c = self.p.telnet.get_or_insert_with(TelnetConfig::defaults); + section(ui, "Login", |ui| { + check(ui, "Local Echo", &mut c.local_echo, ""); + check(ui, "Line Mode", &mut c.line_mode, ""); + }); + section(ui, "Terminal & Encoding", |ui| { + text(ui, "Terminal Type", &mut c.terminal_type, DEFAULT_TERMINAL_TYPE); + combo_s(ui, "Encoding", &mut c.encoding, ENCODINGS); + }); + section(ui, "Echo & Line Endings", |ui| line_ending(ui, &mut c.line_ending)); + section(ui, "Negotiation", |ui| check(ui, "Debug Negotiation", &mut c.negotiation_debug, "")); + section(ui, "Login Script", |ui| login_script(ui, "telnet-login", &mut c.login_scripts)); + section(ui, "Reconnection", |ui| reconnect(ui, &mut c.auto_reconnect, &mut c.max_reconnect_tries)); + } + + fn raw(&mut self, ui: &mut Ui, errs: &[ValidationError]) { + let p = &mut self.p; + let c = p.raw.get_or_insert_with(RawConfig::defaults); + section(ui, "Encoding & Framing", |ui| { + combo_s(ui, "Encoding", &mut c.encoding, &[("utf-8", "UTF-8"), ("ascii", "ASCII"), ("hex", "Hex")]); + line_ending(ui, &mut c.line_ending); + }); + section(ui, "Display", |ui| { + check(ui, "Show Timestamps", &mut c.show_timestamps, ""); + check(ui, "Show Direction", &mut c.show_direction, ""); + }); + section(ui, "Payload Presets", |ui| { + let n = c.payload_presets.len(); + let mut act = None; + for (i, ps) in c.payload_presets.iter_mut().enumerate() { + rtl(ui, |ui| { + act = row_buttons(ui, i, n).or(act.take()); + ComboBox::from_id_salt(("preset-fmt", i)).width(76.0).selected_text(fmt_label(&ps.format)).show_ui(ui, |ui| { + for f in ["text", "hex", "escaped"] { + ui.selectable_value(&mut ps.format, f.to_string(), fmt_label(f)); + } + }); + let w = (ui.available_width() - ui.spacing().item_spacing.x) * 0.6; + ui.add(TextEdit::singleline(&mut ps.payload).hint_text("Payload").font(egui::TextStyle::Monospace).desired_width(w)); + ui.add(TextEdit::singleline(&mut ps.name).hint_text("Name").desired_width(ui.available_width())); + }); + } + apply(&mut c.payload_presets, act); + list_errs(ui, errs, "raw.payloadPresets"); + if ui.button(format!("{} Add Preset", icon::PLUS)).clicked() { + c.payload_presets.push(PayloadPreset { id: new_id(), format: "text".into(), ..Default::default() }); + } + }); + section(ui, "Timeouts & Reconnect", |ui| { + num(ui, "Connect Timeout (s)", &mut p.connect_timeout, 0..=3600); + num(ui, "Read Timeout (s)", &mut p.read_timeout, 0..=3600); + check(ui, "Auto-reconnect", &mut c.auto_reconnect, ""); + }); + } + + fn github(&mut self, ui: &mut Ui, errs: &[ValidationError]) { + let Self { p, secrets, .. } = self; + let c = p.github.get_or_insert_with(GitHubConfig::defaults); + section(ui, "API", |ui| { + text(ui, "API Base URL", &mut c.base_url, "https://api.github.com"); + err(ui, errs, "github.baseURL"); + mode(ui, "Token Storage", &mut c.secret_mode); + if c.secret_mode == SecretMode::Saved { + secret(ui, "Personal Access Token", secrets, "github-token", "Leave blank to keep the saved token"); + } + }); + section(ui, "Refresh", |ui| { + num(ui, "Refresh Interval (seconds)", &mut c.refresh_interval_secs, 60..=3600); + err(ui, errs, "github.refreshIntervalSecs"); + }); + } + + fn vnc(&mut self, ui: &mut Ui, errs: &[ValidationError]) { + let Self { p, keys, key_checks, secrets, .. } = self; + let c = p.vnc.get_or_insert_with(VncConfig::defaults); + section(ui, "VNC Server", |ui| { + text(ui, "VNC Host", &mut c.vnc_host, "192.168.1.100 (defaults to Host)"); + err(ui, errs, "vnc.vncHost"); + num(ui, "VNC Port", &mut c.vnc_port, 1..=65535); + err(ui, errs, "vnc.vncPort"); + mode(ui, "Password Storage", &mut c.secret_mode); + if c.secret_mode == SecretMode::Saved { + secret(ui, "VNC Password", secrets, "vnc-password", "Leave blank to keep the saved password"); + } + }); + section(ui, "Display", |ui| { + check(ui, "View Only", &mut c.view_only, ""); + check(ui, "Scale to Fit", &mut c.scale_to_fit, ""); + check(ui, "Shared Session", &mut c.shared_session, ""); + num(ui, "Quality (0-9)", &mut c.quality, 0..=9); + err(ui, errs, "vnc.quality"); + }); + section(ui, "SSH Tunnel", |ui| { + tunnel( + ui, + "vnc", + Tunnel { + on: &mut c.ssh_tunnel, + host: &mut c.ssh_host, + port: &mut c.ssh_port, + user: &mut c.ssh_username, + auth: &mut c.ssh_auth_method, + key: &mut c.ssh_private_key, + agent: &mut c.ssh_agent_auth, + mode: &mut c.ssh_secret_mode, + }, + keys, + key_checks, + secrets, + errs, + ); + }); + } + + fn irc(&mut self, ui: &mut Ui, errs: &[ValidationError]) { + let Self { p, keys, key_checks, secrets, rooms, .. } = self; + let port = &mut p.port; + let c = p.irc.get_or_insert_with(IrcConfig::defaults); + section(ui, "IRC", |ui| { + text(ui, "Nick", &mut c.nick, "yourNick"); + err(ui, errs, "irc.nick"); + text(ui, "Real Name", &mut c.real_name, "Shown in WHOIS"); + err(ui, errs, "irc.realName"); + if ui.checkbox(&mut c.use_tls, "Use TLS").changed() { + // Follow the protocol default unless the port was customised. + match (c.use_tls, *port) { + (true, p) if p == i64::from(DEFAULT_IRC_PORT) => *port = DEFAULT_IRC_TLS_PORT.into(), + (false, p) if p == i64::from(DEFAULT_IRC_TLS_PORT) => *port = DEFAULT_IRC_PORT.into(), + _ => {} + } + } + widgets::hint(ui, "Use port 6697 for most TLS IRC networks."); + check(ui, "Server Password", &mut c.server_password_required, "Prompt for an IRC PASS secret before registration."); + if c.server_password_required { + mode(ui, "Server Password Storage", &mut c.server_password_mode); + if c.server_password_mode == SecretMode::Saved { + secret(ui, "Server Password", secrets, "irc-server-password", "Leave blank to keep the saved password"); + } + } + }); + section(ui, "NickServ", |ui| { + check(ui, "Identify on Connect", &mut c.nick_serv_identify, ""); + if c.nick_serv_identify { + text(ui, "Account", &mut c.nick_serv_account, "Optional if it matches your nick"); + err(ui, errs, "irc.nickServAccount"); + mode(ui, "Password Storage", &mut c.nick_serv_mode); + if c.nick_serv_mode == SecretMode::Saved { + secret(ui, "NickServ Password", secrets, "irc-nickserv-password", "Leave blank to keep the saved password"); + } + } + }); + section(ui, "Rooms", |ui| { + label(ui, "Auto-Join Rooms"); + ui.add(TextEdit::multiline(rooms).hint_text("#general\n#ops").desired_rows(4).desired_width(f32::INFINITY)); + widgets::hint(ui, "One room per line or comma-separated."); + let names: Vec<&str> = rooms.split(['\n', ',']).map(str::trim).filter(|r| !r.is_empty()).collect(); + for e in errs.iter().filter(|e| e.field.starts_with("irc.autoJoinRooms")) { + let room = index_of(&e.field).and_then(|i| names.get(i)).copied().unwrap_or(""); + error_line(ui, &format!("{room}: {}", e.message)); + } + }); + section(ui, "SSH Tunnel", |ui| { + tunnel( + ui, + "irc", + Tunnel { + on: &mut c.ssh_tunnel, + host: &mut c.ssh_host, + port: &mut c.ssh_port, + user: &mut c.ssh_username, + auth: &mut c.ssh_auth_method, + key: &mut c.ssh_private_key, + agent: &mut c.ssh_agent_auth, + mode: &mut c.ssh_secret_mode, + }, + keys, + key_checks, + secrets, + errs, + ); + }); + section(ui, "Reconnection", |ui| reconnect(ui, &mut c.auto_reconnect, &mut c.max_reconnect_tries)); + } + + fn serial(&mut self, ui: &mut Ui, errs: &[ValidationError]) { + let c = self.p.serial.get_or_insert_with(SerialConfig::default); + section(ui, "Line Settings", |ui| { + label(ui, "Baud Rate"); + ui.horizontal(|ui| { + ComboBox::from_id_salt("serial-baud") + .selected_text(if BAUDS.contains(&c.baud_rate) { c.baud_rate.to_string() } else { "Custom".into() }) + .show_ui(ui, |ui| { + for b in BAUDS { + ui.selectable_value(&mut c.baud_rate, b, b.to_string()); + } + }); + ui.add(DragValue::new(&mut c.baud_rate).range(1..=20_000_000)).on_hover_text("Any rate the adapter supports"); + }); + err(ui, errs, "serial.baudRate"); + combo(ui, "Data Bits", &mut c.data_bits, &[(5, "5"), (6, "6"), (7, "7"), (8, "8")]); + err(ui, errs, "serial.dataBits"); + combo(ui, "Parity", &mut c.parity, &[(Parity::None, "None"), (Parity::Odd, "Odd"), (Parity::Even, "Even")]); + combo(ui, "Stop Bits", &mut c.stop_bits, &[(1, "1"), (2, "2")]); + err(ui, errs, "serial.stopBits"); + combo( + ui, + "Flow Control", + &mut c.flow_control, + &[(FlowControl::None, "None"), (FlowControl::Xonxoff, "XON/XOFF (software)"), (FlowControl::Rtscts, "RTS/CTS (hardware)")], + ); + }); + section(ui, "Behaviour", |ui| { + line_ending(ui, &mut c.line_ending); + combo_s(ui, "Encoding", &mut c.encoding, ENCODINGS); + check(ui, "Local Echo", &mut c.local_echo, "Show typed characters locally (for devices that don't echo)"); + check(ui, "Assert DTR on connect", &mut c.dtr_on_connect, ""); + check(ui, "Assert RTS on connect", &mut c.rts_on_connect, ""); + reconnect(ui, &mut c.auto_reconnect, &mut c.max_reconnect_tries); + widgets::hint(ui, "Reconnects when the device is plugged back in."); + }); + } + + fn terminal(&mut self, ui: &mut Ui, errs: &[ValidationError]) { + let p = &mut self.p; + section(ui, "Terminal", |ui| { + text(ui, "Font Family", &mut p.terminal_font_family, DEFAULT_FONT_FAMILY); + num(ui, "Font Size", &mut p.terminal_font_size, 8..=36); + err(ui, errs, "terminalFontSize"); + num(ui, "Scrollback Lines", &mut p.terminal_scrollback, 0..=100_000); + err(ui, errs, "terminalScrollback"); + combo_s(ui, "Cursor Style", &mut p.terminal_cursor_style, &[("block", "Block"), ("underline", "Underline"), ("bar", "Bar")]); + }); + } +} + +const ENCODINGS: &[(&str, &str)] = &[("utf-8", "UTF-8"), ("ascii", "ASCII"), ("iso-8859-1", "ISO 8859-1")]; +const BAUDS: [u32; 14] = [300, 1200, 2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 230400, 460800, 921600, 1_000_000]; + +/// Fills the zero values the JS form defaulted (`|| 5`, `|| 1080`…), so range-clamped inputs +/// start from the same numbers. +fn prep(p: &mut Profile) { + fn tries(n: &mut i64) { + if *n <= 0 { + *n = 5; + } + } + fn port(n: &mut i64, d: i64) { + if *n <= 0 { + *n = d; + } + } + fn steps(v: &mut [LoginScriptStep]) { + for s in v.iter_mut().filter(|s| s.kind == "wait" && s.timeout <= 0) { + s.timeout = 10; + } + } + if p.terminal_font_size == 0 { + p.terminal_font_size = DEFAULT_FONT_SIZE; + } + if p.terminal_font_family.is_empty() { + p.terminal_font_family = DEFAULT_FONT_FAMILY.into(); + } + if p.terminal_cursor_style.is_empty() { + p.terminal_cursor_style = DEFAULT_CURSOR_STYLE.into(); + } + if let Some(c) = &mut p.ssh { + tries(&mut c.max_reconnect_tries); + port(&mut c.proxy_port, 1080); + if c.proxy_type.is_empty() { + c.proxy_type = "none".into(); + } + for j in &mut c.jump_hosts { + port(&mut j.port, 22); + } + steps(&mut c.login_scripts); + } + if let Some(c) = &mut p.telnet { + tries(&mut c.max_reconnect_tries); + steps(&mut c.login_scripts); + } + if let Some(c) = &mut p.irc { + tries(&mut c.max_reconnect_tries); + port(&mut c.ssh_port, 22); + } + if let Some(c) = &mut p.vnc { + port(&mut c.vnc_port, DEFAULT_VNC_PORT.into()); + port(&mut c.ssh_port, 22); + } + if let Some(c) = &mut p.github + && c.refresh_interval_secs == 0 + { + c.refresh_interval_secs = 300; + } + if let Some(c) = &mut p.serial { + tries(&mut c.max_reconnect_tries); + } +} + +fn parse_env(text: &str) -> BTreeMap { + text.lines().filter_map(|l| l.split_once('=')).map(|(k, v)| (k.trim().to_string(), v.trim().to_string())).filter(|(k, _)| !k.is_empty()).collect() +} + +/// "ssh.jumpHosts[2].host" → 2 +fn index_of(field: &str) -> Option { + field.split_once('[')?.1.split_once(']')?.0.parse().ok() +} + +// --- form widgets ----------------------------------------------------------------------------- + +/// A collapsible card with a section header. +fn section(ui: &mut Ui, title: &str, body: impl FnOnce(&mut Ui)) { + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.fill(t.card).stroke(Stroke::new(1.0, t.card_border)).corner_radius(6).inner_margin(10).show(ui, |ui| { + ui.set_width(ui.available_width()); + egui::CollapsingHeader::new(RichText::new(title.to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6)) + .id_salt(("pe-section", title)) + .default_open(true) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 4.0; + body(ui) + }); + }); + ui.add_space(6.0); +} + +/// A list entry (jump host, forward) on a faint background. +fn item(ui: &mut Ui, body: impl FnOnce(&mut Ui)) { + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.fill(t.text.gamma_multiply(0.04)).corner_radius(4).inner_margin(6).show(ui, |ui| { + ui.set_width(ui.available_width()); + body(ui) + }); +} + +fn label(ui: &mut Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.add_space(2.0); + ui.label(RichText::new(text).color(t.text_dim).size(12.0)); +} + +fn error_line(ui: &mut Ui, msg: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(RichText::new(format!("{} {msg}", icon::WARNING_CIRCLE)).color(t.danger).size(11.5)); +} + +fn err(ui: &mut Ui, errs: &[ValidationError], field: &str) { + for e in errs.iter().filter(|e| e.field == field) { + error_line(ui, &e.message); + } +} + +/// Errors for a list's rows ("ssh.jumpHosts[1].port" → "Row 2: …"). +fn list_errs(ui: &mut Ui, errs: &[ValidationError], prefix: &str) { + for e in errs.iter().filter(|e| e.field.starts_with(prefix)) { + match index_of(&e.field) { + Some(i) => error_line(ui, &format!("Row {}: {}", i + 1, e.message)), + None => error_line(ui, &e.message), + } + } +} + +fn text(ui: &mut Ui, l: &str, v: &mut String, hint: &str) -> Response { + label(ui, l); + ui.add(TextEdit::singleline(v).hint_text(hint).desired_width(f32::INFINITY)) +} + +fn num(ui: &mut Ui, l: &str, v: &mut i64, range: std::ops::RangeInclusive) { + label(ui, l); + ui.add(DragValue::new(v).range(range).speed(0.25)); +} + +fn check(ui: &mut Ui, l: &str, v: &mut bool, help: &str) { + ui.checkbox(v, l); + if !help.is_empty() { + widgets::hint(ui, help); + } +} + +fn combo(ui: &mut Ui, l: &str, v: &mut T, opts: &[(T, &str)]) { + label(ui, l); + let cur = opts.iter().find(|o| o.0 == *v).map_or("", |o| o.1); + ComboBox::from_id_salt(("pe-combo", l)).selected_text(cur).width(ui.available_width().min(320.0)).show_ui(ui, |ui| { + for (o, text) in opts { + ui.selectable_value(v, *o, *text); + } + }); +} + +fn combo_s(ui: &mut Ui, l: &str, v: &mut String, opts: &[(&str, &str)]) { + label(ui, l); + let cur = opts.iter().find(|o| o.0 == v).map_or(v.clone(), |o| o.1.to_string()); + ComboBox::from_id_salt(("pe-combo", l)).selected_text(cur).width(ui.available_width().min(320.0)).show_ui(ui, |ui| { + for (o, text) in opts { + ui.selectable_value(v, o.to_string(), *text); + } + }); +} + +fn mode(ui: &mut Ui, l: &str, v: &mut SecretMode) { + let opts: Vec<_> = SecretMode::ALL.iter().map(|m| (*m, m.label())).collect(); + combo(ui, l, v, &opts); +} + +fn line_ending(ui: &mut Ui, v: &mut LineEnding) { + let opts: Vec<_> = LineEnding::ALL.iter().map(|e| (*e, e.label())).collect(); + combo(ui, "Line Ending", v, &opts); +} + +fn reconnect(ui: &mut Ui, on: &mut bool, tries: &mut i64) { + check(ui, "Auto-Reconnect", on, "Automatically reconnect with exponential backoff when the connection drops"); + if *on { + num(ui, "Max Reconnect Attempts", tries, 1..=20); + } +} + +/// A password field whose value is stored in the vault/keychain under `kind` on save. +fn secret(ui: &mut Ui, l: &str, secrets: &mut BTreeMap<&'static str, String>, kind: &'static str, hint: &str) { + label(ui, l); + ui.add(TextEdit::singleline(secrets.entry(kind).or_default()).password(true).hint_text(hint).desired_width(f32::INFINITY)); +} + +/// A text field with a drop-down of suggestions (an HTML datalist). `extra` draws trailing +/// buttons, right to left, so the field takes exactly the width they leave. +fn suggest(ui: &mut Ui, v: &mut String, hint: &str, opts: &[String], extra: impl FnOnce(&mut Ui, &mut String)) -> Response { + rtl(ui, |ui| { + extra(ui, v); + ui.add_enabled_ui(!opts.is_empty(), |ui| { + ui.menu_button(icon::CARET_DOWN, |ui| { + egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| { + for o in opts { + if ui.selectable_label(v == o, o).clicked() { + *v = o.clone(); + ui.close(); + } + } + }); + }) + .response + .on_hover_text("Suggestions") + }); + ui.add(TextEdit::singleline(v).hint_text(hint).desired_width(ui.available_width())) + }) +} + +/// A row laid out right to left: add fixed-size widgets first, then one that fills the rest. +fn rtl(ui: &mut Ui, body: impl FnOnce(&mut Ui) -> R) -> R { + ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), body).inner).inner +} + +/// Key path with `~/.ssh` suggestions, Browse, and the JS checks once the field loses focus. +fn key_path(ui: &mut Ui, v: &mut String, keys: &[String], checks: &mut KeyChecks, hint: &str) { + let r = suggest(ui, v, hint, keys, |ui, v| { + if ui.button("Browse").clicked() + && let Some(p) = rfd::FileDialog::new().set_title("Select SSH Private Key").pick_file() + { + *v = p.display().to_string(); + } + }); + let path = v.trim(); + if path.is_empty() || r.has_focus() { + return; + } + let t = Tokens::get(ui.ctx()); + match checks.entry(path.to_string()).or_insert_with(|| check_key(path)) { + Ok(m) => ui.label(RichText::new(format!("{} {m}", icon::CHECK_CIRCLE)).color(t.success).size(11.5)), + Err(m) => ui.label(RichText::new(format!("{} {m}", icon::WARNING_CIRCLE)).color(t.danger).size(11.5)), + }; +} + +fn check_key(path: &str) -> Result<&'static str, &'static str> { + if path.starts_with('~') { + Err("Please use a full path (no ~).") + } else if !Path::new(path).exists() { + Err("Key file not found.") + } else if !keyfiles::is_likely_private_key(Path::new(path)) { + Err("File is not a recognised private key.") + } else { + Ok("Key verified.") + } +} + +enum Act { + Up(usize), + Down(usize), + Remove(usize), +} + +fn row_buttons(ui: &mut Ui, i: usize, n: usize) -> Option { + let mut a = None; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let mut order = [0, 1, 2]; + // Always ▲ ▼ ✕ on screen, also inside right-to-left rows. + if ui.layout().prefer_right_to_left() { + order.reverse(); + } + for k in order { + let (glyph, tip, enabled, act) = match k { + 0 => (icon::ARROW_UP, "Move up", i > 0, Act::Up(i)), + 1 => (icon::ARROW_DOWN, "Move down", i + 1 < n, Act::Down(i)), + _ => (icon::X, "Remove", true, Act::Remove(i)), + }; + if ui.add_enabled(enabled, Button::new(glyph).small()).on_hover_text(tip).clicked() { + a = Some(act); + } + } + }); + a +} + +fn apply(v: &mut Vec, a: Option) { + match a { + Some(Act::Up(i)) if i > 0 && i < v.len() => v.swap(i - 1, i), + Some(Act::Down(i)) if i + 1 < v.len() => v.swap(i, i + 1), + Some(Act::Remove(i)) if i < v.len() => { + v.remove(i); + } + _ => {} + } +} + +fn fmt_label(f: &str) -> &'static str { + match f { + "hex" => "Hex", + "escaped" => "Escaped", + _ => "Text", + } +} + +/// Expect-style steps: wait for text (timeout 1–120 s) or send text. +fn login_script(ui: &mut Ui, id: &str, steps: &mut Vec) { + widgets::hint(ui, "Automate login sequences: wait for text from the server, then send a response. Steps execute in order after connection."); + if steps.is_empty() { + widgets::hint(ui, "No login script steps configured."); + } + let n = steps.len(); + let mut act = None; + // Rows are fixed-width columns around a value field that fills the rest, so they line up. + for (i, st) in steps.iter_mut().enumerate() { + ui.horizontal(|ui| { + let wait = st.kind == "wait"; + ComboBox::from_id_salt((id, i)).width(84.0).selected_text(if wait { "Wait for" } else { "Send" }).show_ui(ui, |ui| { + ui.selectable_value(&mut st.kind, "wait".into(), "Wait for"); + ui.selectable_value(&mut st.kind, "send".into(), "Send"); + }); + if st.kind == "wait" && st.timeout <= 0 { + st.timeout = 10; + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + act = row_buttons(ui, i, n).or(act.take()); + if wait { + ui.add_sized(vec2(56.0, 22.0), DragValue::new(&mut st.timeout).range(1..=120).suffix(" s")).on_hover_text("Timeout (seconds)"); + } else { + ui.add_space(56.0 + ui.spacing().item_spacing.x); + } + ui.add( + TextEdit::singleline(&mut st.value).hint_text(if wait { "Text to match…" } else { "Text to send…" }).desired_width(ui.available_width()), + ); + }); + }); + } + apply(steps, act); + ui.horizontal(|ui| { + if ui.button(format!("{} Wait", icon::PLUS)).clicked() { + steps.push(LoginScriptStep { kind: "wait".into(), value: String::new(), timeout: 10 }); + } + if ui.button(format!("{} Send", icon::PLUS)).clicked() { + steps.push(LoginScriptStep { kind: "send".into(), value: String::new(), timeout: 0 }); + } + }); +} + +/// The SSH-tunnel fields VNC and IRC share. +struct Tunnel<'a> { + on: &'a mut bool, + host: &'a mut String, + port: &'a mut i64, + user: &'a mut String, + auth: &'a mut String, + key: &'a mut String, + agent: &'a mut bool, + mode: &'a mut SecretMode, +} + +fn tunnel(ui: &mut Ui, kind: &str, c: Tunnel, keys: &[String], checks: &mut KeyChecks, secrets: &mut BTreeMap<&'static str, String>, errs: &[ValidationError]) { + check(ui, "Enable SSH Tunnel", c.on, ""); + if !*c.on { + return; + } + text(ui, "SSH Host", c.host, "jump.example.com"); + err(ui, errs, &format!("{kind}.sshHost")); + num(ui, "SSH Port", c.port, 1..=65535); + err(ui, errs, &format!("{kind}.sshPort")); + text(ui, "SSH Username", c.user, "admin"); + err(ui, errs, &format!("{kind}.sshUsername")); + combo_s(ui, "SSH Auth Method", c.auth, &[("publickey", "Public Key"), ("password", "Password")]); + err(ui, errs, &format!("{kind}.sshAuthMethod")); + if c.auth != "password" { + label(ui, "SSH Private Key Path"); + key_path(ui, c.key, keys, checks, "/Users/username/.ssh/id_ed25519"); + check(ui, "Use SSH Agent", c.agent, ""); + } + mode(ui, "SSH Secret Storage", c.mode); + if *c.mode == SecretMode::Saved { + // Kinds match the session manager's tunnel secrets ("vnc-ssh-password", "irc-ssh-passphrase"). + let (l, k): (&str, &'static str) = match (kind, c.auth.as_str()) { + ("vnc", "password") => ("SSH Password", "vnc-ssh-password"), + ("vnc", _) => ("SSH Key Passphrase", "vnc-ssh-passphrase"), + (_, "password") => ("SSH Password", "irc-ssh-password"), + _ => ("SSH Key Passphrase", "irc-ssh-passphrase"), + }; + secret(ui, l, secrets, k, "Leave blank to keep the saved secret"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_moves_and_parsing() { + let mut v = vec![1, 2, 3]; + apply(&mut v, Some(Act::Up(2))); + assert_eq!(v, [1, 3, 2]); + apply(&mut v, Some(Act::Down(0))); + assert_eq!(v, [3, 1, 2]); + apply(&mut v, Some(Act::Remove(1))); + assert_eq!(v, [3, 2]); + apply(&mut v, Some(Act::Down(1))); // last row: no-op + assert_eq!(v, [3, 2]); + assert_eq!(index_of("ssh.jumpHosts[12].host"), Some(12)); + let env = parse_env("A=1\n =x\nB = two=2\nnoeq"); + assert_eq!(env.len(), 2); + assert_eq!(env["B"], "two=2"); + } + + #[test] + fn switching_protocol_keeps_common_fields() { + let mut e = ProfileEditor::default(); + e.p.folder = "Prod".into(); + e.p.host = "box".into(); + e.p.display_name = "Box".into(); + e.switch(Protocol::Serial); + assert_eq!((e.p.folder.as_str(), e.p.host.as_str(), e.p.port), ("Prod", "box", 0)); + assert!(e.p.serial.is_some()); + e.switch(Protocol::Irc); + assert_eq!(e.p.port, 6667); + let saved = e.collect(); + assert!(saved.ssh.is_none() && saved.serial.is_none() && saved.irc.is_some()); + } +} diff --git a/rust/app/src/quick_connect.rs b/rust/app/src/quick_connect.rs new file mode 100644 index 0000000..39140a3 --- /dev/null +++ b/rust/app/src/quick_connect.rs @@ -0,0 +1,187 @@ +//! Quick Connect dialog: an ad-hoc session without saving a profile. + +use crate::chrome::modal; +use crate::state::{Request, Shared}; +use crate::theme::{self, Tokens}; +use crate::widgets; +use egui::{ComboBox, DragValue, Key, RichText, TextEdit}; +use egui_phosphor::regular as icon; +use nexus_core::clients::serial; +use nexus_core::models::{Profile, Protocol}; + +const PROTOCOLS: [Protocol; 5] = [Protocol::Ssh, Protocol::Telnet, Protocol::Raw, Protocol::Irc, Protocol::Serial]; + +pub struct QuickConnect { + open: bool, + focus: bool, + protocol: Protocol, + host: String, + port: i64, + username: String, + nick: String, + ports: Vec, +} + +impl Default for QuickConnect { + fn default() -> Self { + QuickConnect { + open: false, + focus: false, + protocol: Protocol::Ssh, + host: String::new(), + port: 22, + username: String::new(), + nick: "nexus".into(), + ports: vec![], + } + } +} + +impl QuickConnect { + pub fn open(&mut self) { + self.open = true; + self.focus = true; + self.host.clear(); + if self.protocol == Protocol::Serial { + self.ports = serial::list_ports(); + } + } + + fn set_protocol(&mut self, p: Protocol) { + self.protocol = p; + self.port = Profile::new(p).port; + if p == Protocol::Serial { + self.ports = serial::list_ports(); + } + } + + /// The profile to connect, or None when the host is empty. + fn profile(&self) -> Option { + let host = self.host.trim(); + if host.is_empty() { + return None; + } + let mut p = Profile::new(self.protocol); + p.host = host.into(); + p.display_name = host.into(); + if self.protocol != Protocol::Serial { + p.port = self.port; + } + if matches!(self.protocol, Protocol::Ssh | Protocol::Irc) { + p.username = self.username.trim().into(); + } + if let Some(c) = &mut p.irc + && !self.nick.trim().is_empty() + { + c.nick = self.nick.trim().into(); + } + Some(p) + } + + /// Called every frame; draws its window(s) while open. + pub fn ui(&mut self, ctx: &egui::Context, s: &mut Shared) { + if !self.open { + return; + } + let t = Tokens::get(ctx); + let (enter, esc) = ctx.input(|i| (i.key_pressed(Key::Enter), i.key_pressed(Key::Escape))); + let mut connect = enter; + let mut close = esc; + let label = |ui: &mut egui::Ui, l: &str| ui.label(RichText::new(l).color(t.text_dim).size(12.0)); + modal(ctx, "quick-connect", 360.0, |ui| { + ui.label(RichText::new(format!("{} Quick Connect", icon::LIGHTNING)).font(theme::semibold(15.0))); + ui.add_space(4.0); + label(ui, "Protocol"); + let mut proto = self.protocol; + ComboBox::from_id_salt("qc-protocol").width(ui.available_width()).selected_text(proto.label()).show_ui(ui, |ui| { + for p in PROTOCOLS { + ui.selectable_value(&mut proto, p, p.label()); + } + }); + if proto != self.protocol { + self.set_protocol(proto); + } + let serial = self.protocol == Protocol::Serial; + label(ui, if serial { "Device" } else { "Host" }); + let r = ui + .horizontal(|ui| { + let w = if serial { ui.available_width() - 34.0 } else { ui.available_width() }; + let r = ui + .add(TextEdit::singleline(&mut self.host).hint_text(if serial { "/dev/ttyUSB0 or COM3" } else { "e.g. 192.168.1.1" }).desired_width(w)); + if serial { + ui.menu_button(icon::CARET_DOWN, |ui| { + if self.ports.is_empty() { + widgets::hint(ui, "No serial devices found."); + } + for p in &self.ports { + if ui.selectable_label(self.host == *p, p).clicked() { + self.host = p.clone(); + ui.close(); + } + } + if ui.button(format!("{} Refresh", icon::ARROWS_CLOCKWISE)).clicked() { + self.ports = serial::list_ports(); + } + }); + } + r + }) + .inner; + if std::mem::take(&mut self.focus) { + r.request_focus(); + } + if !serial { + label(ui, "Port"); + ui.add(DragValue::new(&mut self.port).range(1..=65535).speed(0.25)); + } + if matches!(self.protocol, Protocol::Ssh | Protocol::Irc) { + label(ui, "Username"); + ui.add(TextEdit::singleline(&mut self.username).hint_text("root").desired_width(f32::INFINITY)); + } + if self.protocol == Protocol::Irc { + label(ui, "Nick"); + ui.add(TextEdit::singleline(&mut self.nick).desired_width(f32::INFINITY)); + } + ui.add_space(8.0); + ui.horizontal(|ui| { + if widgets::primary(ui, icon::PLUGS_CONNECTED, "Connect").clicked() { + connect = true; + } + if ui.button("Cancel").clicked() { + close = true; + } + }); + }); + if close { + self.open = false; + } else if connect { + match self.profile() { + Some(p) => { + self.open = false; + s.request(Request::ConnectQuick(p)); + } + None => self.focus = true, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_profiles() { + let mut q = QuickConnect::default(); + assert!(q.profile().is_none()); + q.host = " box ".into(); + q.username = "me".into(); + q.set_protocol(Protocol::Irc); + q.nick = "andy".into(); + let p = q.profile().unwrap(); + assert_eq!((p.host.as_str(), p.port, p.username.as_str(), p.irc.unwrap().nick.as_str()), ("box", 6667, "me", "andy")); + q.set_protocol(Protocol::Serial); + let p = q.profile().unwrap(); + assert_eq!((p.port, p.username.as_str()), (0, "")); + } +} diff --git a/rust/app/src/raw_view.rs b/rust/app/src/raw_view.rs new file mode 100644 index 0000000..facdb83 --- /dev/null +++ b/rust/app/src/raw_view.rs @@ -0,0 +1,523 @@ +//! RAW TCP viewer: text / hex / split views of the capture, pause, clear, save, and a send bar. +//! Port of the web build's `raw.js`. Sent data comes back through `Event::RawData` ("send"), so +//! the echo of a sent entry needs no local bookkeeping. + +use crate::state::Shared; +use crate::theme::{self, Tokens}; +use crate::widgets; +use egui::text::LayoutJob; +use egui::{Color32, FontId, RichText, Sense, TextFormat, Ui, pos2, vec2}; +use egui_phosphor::regular as icon; +use nexus_core::clients::raw::format_hex_dump; +use nexus_core::clients::raw::to_display_encoding; +use nexus_core::models::{PayloadPreset, Profile}; +use std::collections::VecDeque; + +/// Entries kept in the viewer (the core keeps its own, larger capture history). +const MAX_ENTRIES: usize = 5000; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mode { + Text, + Hex, + Split, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Format { + Text, + Hex, + Escaped, +} + +impl Format { + fn label(self) -> &'static str { + match self { + Format::Text => "Text", + Format::Hex => "Hex", + Format::Escaped => "Escaped", + } + } + fn from_id(s: &str) -> Format { + match s { + "hex" => Format::Hex, + "escaped" => Format::Escaped, + _ => Format::Text, + } + } +} + +struct Entry { + seq: u64, + send: bool, + timestamp: i64, + data: Vec, +} + +/// One display row. `header` rows carry the direction/time line of a hex or split entry. +#[derive(Debug, PartialEq)] +struct Row { + seq: u64, + send: bool, + header: bool, + left: String, + /// The hex dump column in split mode. + right: String, +} + +pub struct RawView { + pub session: String, + show_timestamps: bool, + show_direction: bool, + encoding: String, + presets: Vec, + mode: Mode, + paused: bool, + entries: VecDeque, + next_seq: u64, + rows: VecDeque, + /// Widest row in characters (for horizontal scrolling). + widest: usize, + tx_bytes: u64, + rx_bytes: u64, + format: Format, + input: String, +} + +impl RawView { + pub fn new(session: &str, profile: &Profile) -> RawView { + let raw = profile.raw.clone().unwrap_or_default(); + RawView { + session: session.into(), + show_timestamps: raw.show_timestamps, + show_direction: raw.show_direction, + encoding: raw.encoding, + presets: raw.payload_presets, + mode: Mode::Text, + paused: false, + entries: VecDeque::new(), + next_seq: 0, + rows: VecDeque::new(), + widest: 0, + tx_bytes: 0, + rx_bytes: 0, + format: Format::Text, + input: String::new(), + } + } + + /// Data from `Event::RawData` (direction "send"/"recv", unix ms timestamp). + pub fn on_data(&mut self, data: &[u8], direction: &str, timestamp: i64) { + let send = direction == "send"; + if send { + self.tx_bytes += data.len() as u64; + } else { + self.rx_bytes += data.len() as u64; + } + let entry = Entry { seq: self.next_seq, send, timestamp, data: data.to_vec() }; + self.next_seq += 1; + if !self.paused { + let rows = self.rows_for(&entry); + self.push_rows(rows); + } + self.entries.push_back(entry); + while self.entries.len() > MAX_ENTRIES { + self.entries.pop_front(); + } + // A paused display stays frozen; it is rebuilt on resume. + if !self.paused + && let Some(first) = self.entries.front().map(|e| e.seq) + { + while self.rows.front().is_some_and(|r| r.seq < first) { + self.rows.pop_front(); + } + } + } + + fn push_rows(&mut self, rows: Vec) { + for r in rows { + self.widest = self.widest.max(r.left.chars().count() + r.right.chars().count()); + self.rows.push_back(r); + } + } + + fn rebuild(&mut self) { + self.rows.clear(); + self.widest = 0; + let rows: Vec = self.entries.iter().flat_map(|e| self.rows_for(e)).collect(); + self.push_rows(rows); + } + + fn prefix(&self, e: &Entry) -> String { + let mut p = String::new(); + if self.show_timestamps { + p.push_str(&format!("[{}] ", format_time(e.timestamp))); + } + if self.show_direction { + p.push_str(if e.send { "[TX] " } else { "[RX] " }); + } + p + } + + fn rows_for(&self, e: &Entry) -> Vec { + let row = |header: bool, left: String, right: String| Row { seq: e.seq, send: e.send, header, left, right }; + match self.mode { + Mode::Text => { + let prefix = self.prefix(e); + let pad = " ".repeat(prefix.chars().count()); + text_lines(&to_display_encoding(&e.data, &self.encoding)) + .into_iter() + .enumerate() + .map(|(i, l)| row(false, format!("{}{l}", if i == 0 { &prefix } else { &pad }), String::new())) + .collect() + } + Mode::Hex => { + let mut out = Vec::new(); + let prefix = self.prefix(e); + if !prefix.is_empty() { + out.push(row(true, format!("{}· {} bytes", prefix, e.data.len()), String::new())); + } + out.extend(format_hex_dump(&e.data).lines().map(|l| row(false, l.to_string(), String::new()))); + out + } + Mode::Split => { + let mut head = String::new(); + if self.show_timestamps { + head.push_str(&format_time(e.timestamp)); + head.push(' '); + } + head.push_str(if e.send { "▲ TX" } else { "▼ RX" }); + let mut out = vec![row(true, format!("{head} · {} bytes", e.data.len()), String::new())]; + let text = text_lines(&to_display_encoding(&e.data, &self.encoding)); + let dump = format_hex_dump(&e.data); + let hex: Vec<&str> = dump.lines().collect(); + for i in 0..text.len().max(hex.len()) { + out.push(row(false, text.get(i).cloned().unwrap_or_default(), hex.get(i).map(|s| s.to_string()).unwrap_or_default())); + } + out + } + } + } + + fn set_mode(&mut self, mode: Mode) { + if self.mode != mode { + self.mode = mode; + self.rebuild(); + } + } + + fn send(&mut self, s: &mut Shared) { + if self.input.is_empty() { + return; + } + let m = &s.svc.sessions; + let r = match self.format { + Format::Text => m.raw_write_text(&self.session, &self.input), + Format::Hex => m.raw_write_hex(&self.session, &self.input), + Format::Escaped => m.raw_write_escaped(&self.session, &self.input), + }; + match r { + Ok(()) => self.input.clear(), + Err(e) => s.error(format!("Send failed: {e}")), + } + } + + fn save(&mut self, s: &mut Shared) { + if self.entries.is_empty() { + s.warn("Nothing to save"); + return; + } + let Some(path) = rfd::FileDialog::new().set_file_name("capture.json").add_filter("JSON", &["json"]).save_file() else { + return; + }; + match s.svc.sessions.raw_save_capture(&self.session, &path) { + Ok(()) => s.success("Capture saved"), + Err(e) => s.error(format!("Save failed: {e}")), + } + } + + fn clear(&mut self, s: &mut Shared) { + self.entries.clear(); + self.rows.clear(); + self.widest = 0; + self.tx_bytes = 0; + self.rx_bytes = 0; + let _ = s.svc.sessions.raw_clear_history(&self.session); + } + + pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + egui::Frame::new().fill(t.chrome).inner_margin(egui::Margin::symmetric(8, 5)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| self.toolbar(ui, s)); + }); + let send_h = 40.0; + let h = (ui.available_height() - send_h).max(40.0); + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::hover()); + ui.painter().rect_filled(rect, 0.0, t.canvas); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(8.0, 4.0)))); + self.display(&mut child); + egui::Frame::new().fill(t.chrome).inner_margin(egui::Margin::symmetric(8, 6)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| self.send_bar(ui, s)); + }); + } + + fn toolbar(&mut self, ui: &mut Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + for (m, label, glyph) in [(Mode::Text, "Text", icon::TEXT_AA), (Mode::Hex, "Hex", icon::HASH), (Mode::Split, "Split", icon::COLUMNS)] { + if ui.selectable_label(self.mode == m, format!("{glyph} {label}")).clicked() { + self.set_mode(m); + } + } + ui.separator(); + let (g, label) = if self.paused { (icon::PLAY, "Resume") } else { (icon::PAUSE, "Pause") }; + if widgets::button(ui, g, label).clicked() { + self.paused = !self.paused; + if !self.paused { + self.rebuild(); + } + } + if widgets::button(ui, icon::TRASH, "Clear").clicked() { + self.clear(s); + } + if widgets::button(ui, icon::FLOPPY_DISK, "Save").clicked() { + self.save(s); + } + if self.paused { + widgets::chip(ui, "PAUSED", t.warning); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let total = self.tx_bytes + self.rx_bytes; + ui.label(RichText::new(widgets::human_size(total)).font(theme::semibold(12.0))); + ui.label(RichText::new(format!("{} RX {}", icon::ARROW_DOWN, widgets::human_size(self.rx_bytes))).color(t.text_dim).size(11.5)); + ui.label(RichText::new(format!("{} TX {}", icon::ARROW_UP, widgets::human_size(self.tx_bytes))).color(t.accent).size(11.5)); + ui.label(RichText::new(format!("{} entries", widgets::group(self.entries.len() as u64))).color(t.text_faint).size(11.5)); + }); + } + + fn display(&mut self, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + if self.rows.is_empty() { + widgets::empty(ui, icon::BROADCAST, if self.paused { "Paused" } else { "No data yet" }, "Received and sent bytes appear here"); + return; + } + let font = theme::mono(12.5); + let (cw, rh) = ui.fonts_mut(|f| (f.glyph_width(&font, '0'), f.row_height(&font))); + let split = self.mode == Mode::Split; + let avail = ui.available_width(); + // Split mode: the text column takes what the hex dump (77 chars) leaves, at least 30 chars. + let hex_w = 79.0 * cw; + let left_w = if split { (avail - hex_w - 12.0).max(30.0 * cw) } else { 0.0 }; + let width = if split { left_w + 12.0 + hex_w } else { self.widest as f32 * cw + 16.0 }; + ui.spacing_mut().item_spacing.y = 0.0; + egui::ScrollArea::both().auto_shrink([false, false]).stick_to_bottom(true).show_rows(ui, rh, self.rows.len(), |ui, range| { + for i in range { + let row = &self.rows[i]; + let (rect, _) = ui.allocate_exact_size(vec2(width.max(avail), rh), Sense::hover()); + let fg = if row.send { t.accent } else { t.byte_print }; + let p = ui.painter(); + if row.header { + p.rect_filled(rect, 0.0, t.text.gamma_multiply(0.04)); + p.text(rect.left_center(), egui::Align2::LEFT_CENTER, &row.left, font.clone(), if row.send { t.accent } else { t.text_dim }); + continue; + } + match self.mode { + Mode::Text => { + p.text(rect.left_center(), egui::Align2::LEFT_CENTER, &row.left, font.clone(), fg); + } + Mode::Hex => { + let g = ui.fonts_mut(|f| f.layout_job(hex_job(&row.left, &font, &t, fg))); + p.galley(pos2(rect.left(), rect.top()), g, fg); + } + Mode::Split => { + let clip = egui::Rect::from_min_size(rect.min, vec2(left_w, rh)).intersect(ui.clip_rect()); + p.with_clip_rect(clip).text(rect.left_center(), egui::Align2::LEFT_CENTER, &row.left, font.clone(), fg); + let x = rect.left() + left_w + 6.0; + p.line_segment([pos2(x, rect.top()), pos2(x, rect.bottom())], egui::Stroke::new(1.0, t.separator)); + if !row.right.is_empty() { + let g = ui.fonts_mut(|f| f.layout_job(hex_job(&row.right, &font, &t, fg))); + p.galley(pos2(x + 6.0, rect.top()), g, fg); + } + } + } + } + }); + } + + fn send_bar(&mut self, ui: &mut Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + egui::ComboBox::from_id_salt(("raw-format", &self.session)).width(84.0).selected_text(self.format.label()).show_ui(ui, |ui| { + for f in [Format::Text, Format::Hex, Format::Escaped] { + ui.selectable_value(&mut self.format, f, f.label()); + } + }); + // Right-hand controls are laid out first so the input takes the remaining width. + let mut send = false; + let mut insert: Option<&str> = None; + let mut focus = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + send |= ui.add(egui::Button::new(RichText::new(format!("{} Send", icon::PAPER_PLANE_RIGHT)).color(t.accent_text)).fill(t.accent)).clicked(); + if !self.presets.is_empty() { + egui::ComboBox::from_id_salt(("raw-presets", &self.session)).width(110.0).selected_text("Presets…").show_ui(ui, |ui| { + for p in &self.presets { + if ui.selectable_label(false, &p.name).clicked() { + self.input = p.payload.clone(); + self.format = Format::from_id(&p.format); + focus = true; + } + } + }); + } + for (label, esc) in [("EOT", "\\x04"), ("ETX", "\\x03"), ("LF", "\\n"), ("CR", "\\r"), ("NUL", "\\x00")] { + if ui.small_button(label).on_hover_text(esc).clicked() { + insert = Some(esc); + } + } + let r = ui.add( + egui::TextEdit::singleline(&mut self.input) + .id_salt(("raw-input", &self.session)) + .font(theme::mono(12.5)) + .hint_text("Type data to send…") + .desired_width(ui.available_width()), + ); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + send = true; + focus = true; + } + if let Some(esc) = insert { + self.insert_byte(esc); + focus = true; + } + if focus { + r.request_focus(); + } + }); + if send { + self.send(s); + } + } + + /// Appends a quick byte in the current format (Text switches to Escaped so it isn't sent literally). + fn insert_byte(&mut self, esc: &str) { + match self.format { + Format::Hex => { + if !self.input.is_empty() && !self.input.ends_with(' ') { + self.input.push(' '); + } + self.input.push_str(&esc_to_hex(esc)); + } + Format::Text | Format::Escaped => { + self.format = Format::Escaped; + self.input.push_str(esc); + } + } + } +} + +/// "\\x04" / "\\n" → "04" / "0a". +fn esc_to_hex(esc: &str) -> String { + nexus_core::clients::raw::parse_escaped(esc).map(|b| nexus_core::clients::raw::format_hex(&b)).unwrap_or_default() +} + +/// Splits decoded text into display lines: CRLF/LF break lines, other control characters show as '·'. +fn text_lines(s: &str) -> Vec { + let s = s.strip_suffix('\n').unwrap_or(s); + s.split('\n') + .map(|l| { + l.trim_end_matches('\r') + .chars() + .map(|c| match c { + '\t' => " ".to_string(), + c if c.is_control() => "·".to_string(), + c => c.to_string(), + }) + .collect() + }) + .collect() +} + +/// A hex dump line coloured by column: faint offset, bytes, dim ASCII. +fn hex_job(line: &str, font: &FontId, t: &Tokens, fg: Color32) -> LayoutJob { + let mut job = LayoutJob::default(); + let fmt = |c: Color32| TextFormat { font_id: font.clone(), color: c, ..Default::default() }; + let (offset, rest) = line.split_at(line.len().min(10)); + let (bytes, ascii) = rest.find('|').map(|i| rest.split_at(i)).unwrap_or((rest, "")); + job.append(offset, 0.0, fmt(t.text_faint)); + job.append(bytes, 0.0, fmt(fg)); + job.append(ascii, 0.0, fmt(t.text_dim)); + job +} + +/// HH:MM:SS for today, otherwise "Mon DD HH:MM:SS" (the web build's formatTimestamp). +fn format_time(ms: i64) -> String { + use chrono::{Local, TimeZone}; + let Some(d) = Local.timestamp_millis_opt(ms).single() else { + return String::new(); + }; + if d.date_naive() == Local::now().date_naive() { d.format("%H:%M:%S").to_string() } else { d.format("%b %d %H:%M:%S").to_string() } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn view() -> RawView { + let mut p = Profile::new(nexus_core::models::Protocol::Raw); + p.raw = Some(nexus_core::models::RawConfig { show_direction: true, ..Default::default() }); + RawView::new("s", &p) + } + + #[test] + fn text_rows_decode_utf8_and_prefix_direction() { + let mut v = view(); + v.on_data("héllo\r\nwörld\n".as_bytes(), "recv", 0); + v.on_data(b"ping\x01", "send", 0); + let rows: Vec<&str> = v.rows.iter().map(|r| r.left.as_str()).collect(); + assert_eq!(rows, ["[RX] héllo", " wörld", "[TX] ping·"]); + assert_eq!((v.rx_bytes, v.tx_bytes), (15, 5)); + } + + #[test] + fn hex_and_split_rows() { + let mut v = view(); + v.on_data(&[0x41; 20], "recv", 0); + v.set_mode(Mode::Hex); + assert_eq!(v.rows.len(), 3); // header + 2 dump lines + assert!(v.rows[1].left.starts_with("00000000 41 41")); + v.set_mode(Mode::Split); + assert!(v.rows[0].header && v.rows[0].left.contains("▼ RX")); + assert_eq!(v.rows[1].left, "A".repeat(20)); + assert!(v.rows[2].right.starts_with("00000010")); + } + + #[test] + fn pause_freezes_and_buffer_caps() { + let mut v = view(); + v.paused = true; + for _ in 0..MAX_ENTRIES + 10 { + v.on_data(b"x", "recv", 0); + } + assert!(v.rows.is_empty()); + assert_eq!(v.entries.len(), MAX_ENTRIES); + v.paused = false; + v.rebuild(); + assert_eq!(v.rows.len(), MAX_ENTRIES); + v.on_data(b"y", "recv", 0); + assert_eq!(v.rows.len(), MAX_ENTRIES); + assert_eq!(v.rows.back().unwrap().left, "[RX] y"); + } + + #[test] + fn quick_bytes() { + assert_eq!(esc_to_hex("\\x04"), "04"); + assert_eq!(esc_to_hex("\\n"), "0a"); + let mut v = view(); + v.insert_byte("\\r"); + assert!(v.format == Format::Escaped && v.input == "\\r"); + v.format = Format::Hex; + v.input = "41".into(); + v.insert_byte("\\x00"); + assert_eq!(v.input, "41 00"); + } +} diff --git a/rust/app/src/recordings.rs b/rust/app/src/recordings.rs new file mode 100644 index 0000000..55bdb4c --- /dev/null +++ b/rust/app/src/recordings.rs @@ -0,0 +1,358 @@ +//! Recordings browser and playback window (port of the RecordingManager in recordings.js). + +use crate::state::Shared; +use crate::terminal::{CursorStyle, TermView}; +use crate::theme::{self, Tokens}; +use crate::widgets; +use egui::{Key, RichText}; +use egui_phosphor::regular as icon; +use nexus_core::recording::{CleanupResult, EVENT_OUTPUT, Event, Header, RecordingMeta, StorageInfo}; +use std::sync::mpsc::{Receiver, channel}; + +const AGES: [(u32, &str); 4] = [(30, "Older than 30 days"), (7, "Older than 7 days"), (90, "Older than 90 days"), (0, "Ignore age")]; +const SIZES: [(u64, &str); 3] = [(1 << 30, "Keep under 1 GB"), (5 << 30, "Keep under 5 GB"), (0, "Ignore size")]; +const SPEEDS: [f64; 5] = [0.5, 1.0, 2.0, 5.0, 10.0]; + +/// m:ss, as the JS `_formatSeconds`. +pub fn mmss(secs: f64) -> String { + if secs.is_nan() || secs <= 0.0 { + return "0:00".into(); + } + let s = secs.floor() as u64; + format!("{}:{:02}", s / 60, s % 60) +} + +enum Loaded { + List(Vec, StorageInfo), + Cleaned(CleanupResult), + Recording(String, Header, Vec), +} + +struct Playback { + title: String, + term: TermView, + events: Vec, + total: f64, + /// Virtual time played so far, and the next event to feed. + pos: f64, + next: usize, + playing: bool, + speed: f64, +} + +impl Playback { + fn feed_until(&mut self, t: f64) { + while let Some(e) = self.events.get(self.next).filter(|e| e.elapsed <= t) { + if e.kind == EVENT_OUTPUT { + self.term.feed(e.data.as_bytes()); + } + self.next += 1; + } + } + + /// Replays from the start up to `t`. + fn seek(&mut self, t: f64) { + self.term.reset(); + self.next = 0; + self.pos = t.clamp(0.0, self.total); + self.feed_until(self.pos); + } + + fn play(&mut self) { + if self.next >= self.events.len() { + self.seek(0.0); + } + self.playing = true; + } + + fn tick(&mut self, ctx: &egui::Context) { + if !self.playing { + return; + } + let dt = ctx.input(|i| i.stable_dt).min(0.25) as f64; + self.pos = (self.pos + dt * self.speed).min(self.total); + self.feed_until(self.pos); + if self.next >= self.events.len() { + self.playing = false; + self.pos = self.total; + } else { + let wait = (self.events[self.next].elapsed - self.pos) / self.speed; + ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait.clamp(0.0, 0.25))); + } + } +} + +pub struct Recordings { + open: bool, + list: Vec, + info: Option, + loading: bool, + rx: Option>, + age: u32, + size: u64, + status: String, + confirm_delete: Option, + playback: Option, +} + +impl Default for Recordings { + fn default() -> Self { + Recordings { + open: false, + list: vec![], + info: None, + loading: false, + rx: None, + age: 30, + size: 1 << 30, + status: String::new(), + confirm_delete: None, + playback: None, + } + } +} + +impl Recordings { + pub fn open(&mut self) { + self.open = true; + self.status.clear(); + self.info = None; + } + + /// Runs `f` on a worker thread; its result is picked up by `poll`. + fn spawn(&mut self, s: &Shared, f: impl FnOnce(&nexus_core::recording::Store) -> nexus_core::Result + Send + 'static) { + let (tx, rx) = channel(); + let store = s.svc.sessions.recordings().clone(); + self.rx = Some(rx); + self.loading = true; + s.spawn(move || f(&store), move |v| drop(tx.send(v))); + } + + fn reload(&mut self, s: &Shared) { + self.spawn(s, |st| Ok(Loaded::List(st.list()?, st.storage_info()?))); + } + + fn poll(&mut self, s: &mut Shared) { + let Some(rx) = &self.rx else { return }; + let got = match rx.try_recv() { + Ok(v) => v, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(_) => { + // Worker failed (error already toasted). + self.rx = None; + self.loading = false; + return; + } + }; + self.rx = None; + self.loading = false; + match got { + Loaded::List(list, info) => { + self.list = list; + self.info = Some(info); + } + Loaded::Cleaned(r) => { + self.status = format!("Deleted {}; freed {}.", r.deleted, widgets::human_size(r.freed_bytes)); + self.reload(s); + } + Loaded::Recording(title, header, events) => { + if events.is_empty() { + return s.info("Recording is empty — nothing to play back."); + } + let total = events.last().map_or(1.0, |e| e.elapsed).max(f64::EPSILON); + let title = if header.title.is_empty() { title } else { header.title }; + let mut p = + Playback { title, term: TermView::new(10_000, 13.0, CursorStyle::Block), events, total, pos: 0.0, next: 0, playing: false, speed: 1.0 }; + p.play(); // autoplay + self.playback = Some(p); + } + } + } + + /// Called every frame; draws its window(s) while open. + pub fn ui(&mut self, ctx: &egui::Context, s: &mut Shared) { + self.poll(s); + self.playback_ui(ctx, s); + self.delete_dialog(ctx, s); + if !self.open { + return; + } + if self.info.is_none() && !self.loading { + self.reload(s); + } + let mut open = self.open; + egui::Window::new(format!("{} Session Recordings", icon::RECORD)) + .id(egui::Id::new("recordings")) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) + .open(&mut open) + .collapsible(false) + .default_size([640.0, 440.0]) + .show(ctx, |ui| self.browser(ui, s)); + self.open = open; + } + + fn browser(&mut self, ui: &mut egui::Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + let (count, total) = self.info.as_ref().map_or((self.list.len(), 0), |i| (i.count, i.total_bytes)); + ui.label(RichText::new(format!("{count} recording{}", if count == 1 { "" } else { "s" })).font(theme::semibold(13.0))); + ui.label(RichText::new(widgets::human_size(total)).color(t.text_dim)); + if self.loading { + ui.spinner(); + } + }); + ui.horizontal(|ui| { + egui::ComboBox::from_id_salt("rec-age").selected_text(AGES.iter().find(|a| a.0 == self.age).map_or("", |a| a.1)).show_ui(ui, |ui| { + for (v, l) in AGES { + ui.selectable_value(&mut self.age, v, l); + } + }); + egui::ComboBox::from_id_salt("rec-size").selected_text(SIZES.iter().find(|a| a.0 == self.size).map_or("", |a| a.1)).show_ui(ui, |ui| { + for (v, l) in SIZES { + ui.selectable_value(&mut self.size, v, l); + } + }); + if ui.add_enabled(!self.loading, egui::Button::new(format!("{} Clean up", icon::BROOM))).clicked() { + let (age, size) = (self.age, self.size); + self.status.clear(); + self.spawn(s, move |st| Ok(Loaded::Cleaned(st.cleanup(age, size)?))); + } + }); + if !self.status.is_empty() { + widgets::hint(ui, &self.status); + } + ui.separator(); + if self.list.is_empty() { + if !self.loading { + widgets::empty(ui, icon::RECORD, "No recordings yet", "Press Ctrl/Cmd+Shift+R in a terminal, or use Session → Toggle recording."); + } + return; + } + let (mut play, mut delete) = (None, None); + widgets::rows(ui, "rec-list", self.list.len(), 44.0, None, |ui, i| { + let r = &self.list[i]; + ui.vertical(|ui| { + ui.set_width(ui.available_width() - 64.0); + ui.label(RichText::new(if r.title.is_empty() { &r.id } else { &r.title }).font(theme::semibold(13.0))); + let date = r.created_at.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M"); + ui.label( + RichText::new(format!("{date} · {} · {} · {}×{}", mmss(r.duration), widgets::human_size(r.size), r.width, r.height)) + .size(11.5) + .color(t.text_dim), + ); + }); + if widgets::icon_button(ui, icon::PLAY, "Play").clicked() { + play = Some(i); + } + if widgets::icon_button(ui, icon::TRASH, "Delete").clicked() { + delete = Some(i); + } + }); + if let Some(i) = play { + let (id, title) = (self.list[i].id.clone(), self.list[i].title.clone()); + self.open = false; + self.spawn(s, move |st| { + let (h, ev) = st.get(&id)?; + Ok(Loaded::Recording(title, h, ev)) + }); + } + if let Some(i) = delete { + self.confirm_delete = Some(self.list[i].clone()); + } + } + + fn delete_dialog(&mut self, ctx: &egui::Context, s: &mut Shared) { + let Some(r) = &self.confirm_delete else { + return; + }; + let (mut yes, mut no) = (false, ctx.input(|i| i.key_pressed(Key::Escape))); + let name = if r.title.is_empty() { r.id.clone() } else { r.title.clone() }; + crate::chrome::modal(ctx, "rec-delete", 380.0, |ui| { + ui.label(RichText::new("Delete recording?").font(theme::semibold(15.0))); + ui.label(format!("\u{201c}{name}\u{201d} will be permanently deleted.")); + ui.add_space(8.0); + ui.horizontal(|ui| { + yes = widgets::primary(ui, icon::TRASH, "Delete").clicked(); + no |= ui.button("Cancel").clicked(); + }); + }); + if yes { + let id = self.confirm_delete.take().unwrap().id; + match s.svc.sessions.recordings().delete(&id) { + Ok(()) => { + s.success("Recording deleted"); + self.list.retain(|m| m.id != id); + self.reload(s); + } + Err(e) => s.error(format!("Failed to delete recording: {e}")), + } + } else if no { + self.confirm_delete = None; + } + } + + fn playback_ui(&mut self, ctx: &egui::Context, s: &mut Shared) { + let Some(p) = &mut self.playback else { return }; + p.tick(ctx); + let t = Tokens::get(ctx); + let theme = crate::terminal_themes::get(&s.prefs.terminal_theme); + let mut open = true; + egui::Window::new(format!("{} {}", icon::PLAY_CIRCLE, p.title)) + .id(egui::Id::new("rec-playback")) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) + .open(&mut open) + .collapsible(false) + .default_size([820.0, 520.0]) + .show(ctx, |ui| { + egui::Panel::bottom("rec-controls").frame(egui::Frame::NONE.inner_margin(egui::Margin::symmetric(0, 6))).show(ui, |ui| { + ui.horizontal(|ui| { + let (glyph, tip) = if p.playing { (icon::PAUSE, "Pause") } else { (icon::PLAY, "Play") }; + if widgets::icon_button(ui, glyph, tip).clicked() { + if p.playing { p.playing = false } else { p.play() } + } + if widgets::icon_button(ui, icon::ARROW_COUNTER_CLOCKWISE, "Restart").clicked() { + p.playing = false; + p.seek(0.0); + } + let label = format!("{} / {}", mmss(p.pos), mmss(p.total)); + let lw = 120.0; + let mut pos = p.pos; + ui.spacing_mut().slider_width = (ui.available_width() - lw - 90.0).max(80.0); + if ui.add(egui::Slider::new(&mut pos, 0.0..=p.total).show_value(false)).changed() { + p.seek(pos); + } + ui.add_sized([lw, 20.0], egui::Label::new(RichText::new(label).font(theme::mono(12.0)).color(t.text_dim))); + egui::ComboBox::from_id_salt("rec-speed").width(60.0).selected_text(format!("{}×", p.speed)).show_ui(ui, |ui| { + for v in SPEEDS { + ui.selectable_value(&mut p.speed, v, format!("{v}×")); + } + }); + }); + }); + egui::Frame::NONE + .fill(egui::Color32::from_rgba_unmultiplied(theme.background[0], theme.background[1], theme.background[2], 255)) + .corner_radius(t.radius) + .show(ui, |ui| { + ui.set_min_size(ui.available_size()); + // Read-only: keyboard input and terminal replies are dropped. + let _ = p.term.ui(ui, theme, false); + }); + }); + if !open { + self.playback = None; + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn mmss() { + assert_eq!(super::mmss(0.0), "0:00"); + assert_eq!(super::mmss(f64::NAN), "0:00"); + assert_eq!(super::mmss(59.9), "0:59"); + assert_eq!(super::mmss(125.0), "2:05"); + } +} diff --git a/rust/app/src/settings.rs b/rust/app/src/settings.rs new file mode 100644 index 0000000..ab98921 --- /dev/null +++ b/rust/app/src/settings.rs @@ -0,0 +1,948 @@ +//! Settings window: General, Terminal, Security, Known Hosts, Help, About. Also the +//! master-password dialog (strength meter + confirm) used to create or unlock the vault. + +use crate::state::{Request, SettingsTab, Shared, keys}; +use crate::theme::{self, ThemeKind, Tokens}; +use crate::widgets; +use egui::text::{LayoutJob, TextFormat}; +use egui::{RichText, Ui, vec2}; +use egui_phosphor::regular as icon; +use nexus_core::models::{AppPreferences, KnownHost, Protocol}; +use nexus_core::updater::UpdateInfo; +use std::sync::{Arc, Mutex}; + +/// A result from a worker thread, polled each frame (`Some` while the work is in flight). +pub struct Job(Arc>>); + +impl Job { + pub fn spawn(ctx: &egui::Context, f: impl FnOnce() -> T + Send + 'static) -> Self { + let slot = Arc::new(Mutex::new(None)); + let (out, ctx) = (slot.clone(), ctx.clone()); + std::thread::spawn(move || { + let v = f(); + *out.lock().unwrap() = Some(v); + ctx.request_repaint(); + }); + Job(slot) + } + + pub fn take(&self) -> Option { + self.0.lock().unwrap().take() + } +} + +/// Polls `job`; when it has finished, clears it and returns the result. +pub fn poll(job: &mut Option>) -> Option { + let v = job.as_ref()?.take()?; + *job = None; + Some(v) +} + +/// Password strength 0–100 and its label, the same formula as the web build's `_measureStrength`. +pub fn strength(pw: &str) -> (u32, &'static str) { + if pw.is_empty() { + return (0, ""); + } + let len = pw.chars().count() as u32; + let mut score = (len * 3).min(40); + let classes = [ + pw.chars().any(|c| c.is_ascii_lowercase()), + pw.chars().any(|c| c.is_ascii_uppercase()), + pw.chars().any(|c| c.is_ascii_digit()), + pw.chars().any(|c| !c.is_ascii_alphanumeric()), + ]; + score += classes.iter().filter(|b| **b).count() as u32 * 10; + let unique = pw.chars().collect::>().len() as u32; + score += (unique * 20 / len).min(20); + let score = score.min(100); + let label = match score { + 0..25 => "Weak", + 25..50 => "Fair", + 50..75 => "Good", + _ => "Strong", + }; + (score, label) +} + +/// Strength bar + label under a new-password field. +pub fn strength_meter(ui: &mut Ui, pw: &str) { + let t = Tokens::get(ui.ctx()); + let (score, label) = strength(pw); + let color = match label { + "Weak" => t.danger, + "Fair" => t.warning, + "Good" => t.accent, + _ => t.success, + }; + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width() - 60.0, 6.0), egui::Sense::hover()); + ui.painter().rect_filled(r, 3.0, t.field); + let mut fill = r; + fill.set_width(r.width() * score as f32 / 100.0); + ui.painter().rect_filled(fill, 3.0, color); + ui.label(RichText::new(label).color(color).size(11.5)); + }); +} + +/// Create or unlock the vault. +struct MasterPrompt { + new: bool, + pw: String, + confirm: String, + error: String, +} + +#[derive(Default)] +pub struct Settings { + open: bool, + tab: SettingsTab, + /// Load the working copy from `Shared` on the next frame. + fresh: bool, + prefs: AppPreferences, + theme: ThemeKind, + known: Vec, + default_shell: String, + help_topic: usize, + master: Option, + vault_job: Option>>, + update_job: Option>>, + update: Option>, +} + +impl Settings { + pub fn open(&mut self, tab: SettingsTab) { + self.open = true; + self.fresh = true; + self.tab = tab; + } + + /// Called every frame; draws its window(s) while open. + pub fn ui(&mut self, ctx: &egui::Context, s: &mut Shared) { + self.poll(s); + if !self.open { + return; + } + if std::mem::take(&mut self.fresh) { + self.prefs = s.prefs.clone(); + self.theme = crate::app::theme_for(&self.prefs.theme); + self.known = s.svc.known_hosts(); + self.default_shell = nexus_core::clients::local::default_shell(); + } + let mut open = true; + let mut close = false; + egui::Window::new(RichText::new(format!("{} Settings", icon::GEAR)).font(theme::semibold(14.0))) + .id(egui::Id::new("settings-window")) + .open(&mut open) + .collapsible(false) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) + .resizable(true) + .default_size([760.0, 540.0]) + .min_size([560.0, 380.0]) + .show(ctx, |ui| { + self.tab_bar(ui); + ui.separator(); + let footer = 40.0; + let h = (ui.available_height() - footer).max(200.0); + ui.allocate_ui(vec2(ui.available_width(), h), |ui| { + ui.set_min_height(h); + match self.tab { + SettingsTab::General => self.general(ui), + SettingsTab::Terminal => self.terminal(ui), + SettingsTab::Security => self.security(ui, s), + SettingsTab::KnownHosts => self.known_hosts(ui, s), + SettingsTab::Help => self.help(ui), + SettingsTab::About => self.about(ui, s), + } + }); + ui.separator(); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::primary(ui, icon::CHECK, "Save").clicked() { + self.save(s); + close = true; + } + if ui.button("Cancel").clicked() { + close = true; + } + }); + }); + self.open = open && !close; + self.master_dialog(ctx, s); + } + + fn poll(&mut self, s: &mut Shared) { + match poll(&mut self.vault_job) { + Some(Ok(msg)) => s.success(msg), + Some(Err(e)) => s.error(e), + None => {} + } + if let Some(r) = poll(&mut self.update_job) { + self.update = Some(r); + } + } + + fn save(&mut self, s: &mut Shared) { + let mut p = self.prefs.clone(); + p.theme = self.theme.id().into(); + p.sidebar_width = p.sidebar_width.clamp(180, 500); + p.default_font_size = p.default_font_size.clamp(8, 36); + p.default_scrollback = p.default_scrollback.clamp(0, 100_000); + p.local_shell = p.local_shell.trim().into(); + match s.svc.save_preferences(&p) { + Ok(()) => { + s.prefs = p; + s.request(Request::ReloadPreferences); + s.success("Settings saved"); + } + Err(e) => s.error(format!("Failed to save settings: {e}")), + } + } + + fn tab_bar(&mut self, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + for (tab, glyph, label) in [ + (SettingsTab::General, icon::SLIDERS_HORIZONTAL, "General"), + (SettingsTab::Terminal, icon::TERMINAL_WINDOW, "Terminal"), + (SettingsTab::Security, icon::SHIELD_CHECK, "Security"), + (SettingsTab::KnownHosts, icon::FINGERPRINT, "Known Hosts"), + (SettingsTab::Help, icon::BOOK_OPEN, "Help"), + (SettingsTab::About, icon::INFO, "About"), + ] { + let on = self.tab == tab; + let text = RichText::new(format!("{glyph} {label}")).color(if on { t.text } else { t.text_dim }); + if ui.selectable_label(on, text).clicked() { + self.tab = tab; + } + } + }); + } + + // --- General / Terminal --------------------------------------------------------------- + + fn general(&mut self, ui: &mut Ui) { + let p = &mut self.prefs; + form(ui, "general-form", |ui| { + field(ui, "App theme"); + egui::ComboBox::from_id_salt("app-theme").selected_text(self.theme.label()).width(220.0).show_ui(ui, |ui| { + for k in ThemeKind::ALL { + ui.selectable_value(&mut self.theme, k, k.label()); + } + }); + ui.end_row(); + + field(ui, "Default protocol"); + let cur = Protocol::from_id(&p.default_protocol).map_or("SSH", |x| x.label()); + egui::ComboBox::from_id_salt("default-proto").selected_text(cur).width(220.0).show_ui(ui, |ui| { + for proto in Protocol::SAVED { + if ui.selectable_label(p.default_protocol == proto.id(), proto.label()).clicked() { + p.default_protocol = proto.id().into(); + } + } + }); + ui.end_row(); + + field(ui, "Confirm on close"); + ui.checkbox(&mut p.confirm_on_close, "Ask before closing a connected tab"); + ui.end_row(); + field(ui, "Confirm on disconnect"); + ui.checkbox(&mut p.confirm_on_disconnect, "Ask before disconnecting sessions"); + ui.end_row(); + field(ui, "Status bar"); + ui.checkbox(&mut p.show_status_bar, "Show status bar"); + ui.end_row(); + + field(ui, "Sidebar width"); + ui.add(egui::DragValue::new(&mut p.sidebar_width).range(180..=500).suffix(" px")); + ui.end_row(); + + field(ui, "Local shell"); + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut p.local_shell).hint_text(&self.default_shell).desired_width(300.0)); + if widgets::button(ui, icon::FOLDER_OPEN, "Browse…").clicked() + && let Some(path) = rfd::FileDialog::new().set_title("Choose a shell").pick_file() + { + p.local_shell = path.to_string_lossy().into(); + } + }); + widgets::hint(ui, "Leave empty to use the system default"); + }); + ui.end_row(); + }); + } + + fn terminal(&mut self, ui: &mut Ui) { + let p = &mut self.prefs; + form(ui, "terminal-form", |ui| { + field(ui, "Colour scheme"); + let cur = crate::terminal_themes::get(&p.terminal_theme).label; + egui::ComboBox::from_id_salt("term-theme").selected_text(cur).width(220.0).show_ui(ui, |ui| { + for th in crate::terminal_themes::THEMES { + if ui.selectable_label(p.terminal_theme == th.id, th.label).clicked() { + p.terminal_theme = th.id.into(); + } + } + }); + ui.end_row(); + + field(ui, "Font family"); + ui.add(egui::TextEdit::singleline(&mut p.default_font_family).hint_text("JetBrains Mono, Menlo, Monaco, Consolas, monospace").desired_width(300.0)); + ui.end_row(); + field(ui, "Font size"); + ui.add(egui::DragValue::new(&mut p.default_font_size).range(8..=36).suffix(" pt")); + ui.end_row(); + field(ui, "Scrollback lines"); + ui.add(egui::DragValue::new(&mut p.default_scrollback).range(0..=100_000).speed(100)); + ui.end_row(); + + field(ui, "Cursor style"); + let label = |c: &str| match c { + "underline" => "Underline", + "bar" => "Bar", + _ => "Block", + }; + egui::ComboBox::from_id_salt("cursor").selected_text(label(&p.default_cursor_style)).width(220.0).show_ui(ui, |ui| { + for c in ["block", "underline", "bar"] { + if ui.selectable_label(p.default_cursor_style == c, label(c)).clicked() { + p.default_cursor_style = c.into(); + } + } + }); + ui.end_row(); + }); + ui.add_space(6.0); + widgets::hint(ui, "Font, size, scrollback and cursor are the defaults for new profiles; profiles can override them."); + } + + // --- Security ------------------------------------------------------------------------- + + fn security(&mut self, ui: &mut Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + let sec = &s.svc.secrets; + let (init, unlocked) = (sec.vault.is_initialised(), sec.vault.is_unlocked()); + widgets::section(ui, "Credential storage"); + form(ui, "security-form", |ui| { + field(ui, "OS keychain"); + if sec.keychain_ok { + ui.label(RichText::new(format!("{} Available", icon::CHECK_CIRCLE)).color(t.success)); + } else { + ui.label(RichText::new(format!("{} Unavailable", icon::WARNING_CIRCLE)).color(t.warning)); + } + ui.end_row(); + field(ui, "Encrypted vault"); + let (text, color) = match (init, unlocked) { + (false, _) => ("Not initialised", t.text_faint), + (true, false) => ("Locked", t.text_dim), + (true, true) => ("Unlocked", t.success), + }; + ui.label(RichText::new(text).color(color)); + ui.end_row(); + }); + ui.add_space(4.0); + widgets::hint( + ui, + if sec.keychain_ok { + "Saved credentials go to the OS keychain first. The encrypted vault (AES-256-GCM, Argon2id key) is the fallback when the keychain refuses a write." + } else { + "The OS keychain isn't available, so saved credentials go to the encrypted vault (AES-256-GCM, Argon2id key). Unlock it to save or use credentials." + }, + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + if self.vault_job.is_some() { + ui.spinner(); + ui.label("Working…"); + } else if !init { + if widgets::primary(ui, icon::SHIELD_PLUS, "Initialise Secret Store").clicked() { + self.master = Some(MasterPrompt { new: true, pw: String::new(), confirm: String::new(), error: String::new() }); + } + } else if !unlocked { + if widgets::primary(ui, icon::LOCK_OPEN, "Unlock").clicked() { + self.master = Some(MasterPrompt { new: false, pw: String::new(), confirm: String::new(), error: String::new() }); + } + } else if widgets::button(ui, icon::LOCK, "Lock").clicked() { + s.svc.secrets.vault.lock(); + s.info("Secret store locked"); + } + }); + } + + fn master_dialog(&mut self, ctx: &egui::Context, s: &mut Shared) { + let Some(m) = &mut self.master else { return }; + let mut submit = false; + let mut cancel = false; + let r = egui::Modal::new(egui::Id::new("master-password")).show(ctx, |ui| { + ui.set_width(380.0); + let title = if m.new { "Create Master Password" } else { "Unlock Secret Store" }; + ui.label(RichText::new(format!("{} {title}", icon::KEY)).font(theme::semibold(15.0))); + ui.label(if m.new { + "Choose a strong master password. This protects all saved credentials." + } else { + "Enter your master password to unlock the secret store." + }); + ui.add_space(4.0); + ui.label("Master password"); + let r = ui.add(egui::TextEdit::singleline(&mut m.pw).password(true).hint_text("Password…").desired_width(f32::INFINITY)); + if !r.has_focus() && m.pw.is_empty() && m.confirm.is_empty() { + r.request_focus(); + } + let mut enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if m.new { + strength_meter(ui, &m.pw); + ui.label("Confirm password"); + let r = ui.add(egui::TextEdit::singleline(&mut m.confirm).password(true).hint_text("Confirm…").desired_width(f32::INFINITY)); + enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + } + if !m.error.is_empty() { + ui.label(RichText::new(&m.error).color(Tokens::get(ui.ctx()).danger).size(12.0)); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + submit = widgets::primary(ui, icon::CHECK, if m.new { "Create" } else { "Unlock" }).clicked() || enter; + cancel = ui.button("Cancel").clicked(); + }); + }); + if submit { + if m.pw.is_empty() { + m.error = "Enter a password.".into(); + } else if m.new && m.pw != m.confirm { + m.error = "Passwords do not match.".into(); + } else { + let (new, pw) = (m.new, std::mem::take(&mut m.pw)); + let svc = s.svc.clone(); + self.vault_job = Some(Job::spawn(ctx, move || { + let v = &svc.secrets.vault; + if new { + v.initialise(&pw).map(|_| "Secret store created").map_err(|e| format!("Failed to initialise: {e}")) + } else { + v.unlock(&pw).map(|_| "Secret store unlocked").map_err(|e| format!("Unlock failed: {e}")) + } + })); + cancel = true; + } + } + if cancel || r.should_close() { + self.master = None; + } + } + + // --- Known hosts ---------------------------------------------------------------------- + + fn known_hosts(&mut self, ui: &mut Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + if self.known.is_empty() { + widgets::empty(ui, icon::FINGERPRINT, "No known hosts recorded", "Host keys are recorded the first time you connect over SSH."); + return; + } + widgets::count(ui, self.known.len(), "known host(s)", false); + let when = |d: &nexus_core::models::Time| { + use chrono::Datelike; + if d.year() <= 1 { "—".to_string() } else { d.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M").to_string() } + }; + let mut remove = None; + egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| { + egui::Grid::new("known-hosts").num_columns(7).striped(true).spacing(vec2(14.0, 6.0)).show(ui, |ui| { + for h in ["Host", "Port", "Key Type", "Fingerprint", "First Seen", "Last Seen", ""] { + ui.label(RichText::new(h).font(theme::semibold(11.5)).color(t.text_faint)); + } + ui.end_row(); + for kh in &self.known { + ui.label(&kh.host); + ui.label(kh.port.to_string()); + ui.label(RichText::new(&kh.key_type).font(theme::mono(11.5))); + let short: String = kh.fingerprint.chars().take(24).collect(); + let fp = ui.add(egui::Label::new(RichText::new(format!("{short}…")).font(theme::mono(11.5))).sense(egui::Sense::click())); + if fp.on_hover_text(format!("{}\nClick to copy", kh.fingerprint)).clicked() { + ui.ctx().copy_text(kh.fingerprint.clone()); + } + ui.label(RichText::new(when(&kh.first_seen)).color(t.text_dim)); + ui.label(RichText::new(when(&kh.last_seen)).color(t.text_dim)); + if ui.button(RichText::new(format!("{} Remove", icon::TRASH)).color(t.danger)).clicked() { + remove = Some(kh.id.clone()); + } + ui.end_row(); + } + }); + }); + if let Some(id) = remove { + match s.svc.delete_known_host(&id) { + Ok(()) => { + self.known.retain(|h| h.id != id); + s.success("Known host removed"); + } + Err(e) => s.error(format!("Failed to remove: {e}")), + } + } + } + + // --- Help ----------------------------------------------------------------------------- + + fn help(&mut self, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.horizontal_top(|ui| { + ui.allocate_ui_with_layout(vec2(170.0, ui.available_height()), egui::Layout::top_down_justified(egui::Align::Min), |ui| { + ui.set_width(170.0); + for (i, (title, _)) in HELP.iter().enumerate() { + let on = self.help_topic == i; + if ui.selectable_label(on, RichText::new(*title).color(if on { t.text } else { t.text_dim })).clicked() { + self.help_topic = i; + } + } + }); + ui.separator(); + let (title, body) = HELP[self.help_topic.min(HELP.len() - 1)]; + ui.vertical(|ui| { + egui::ScrollArea::vertical().id_salt(("help", self.help_topic)).auto_shrink([false, false]).show(ui, |ui| { + ui.label(RichText::new(title).font(theme::semibold(17.0))); + ui.add_space(4.0); + render_help(ui, body); + }); + }); + }); + } + + // --- About ---------------------------------------------------------------------------- + + fn about(&mut self, ui: &mut Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + ui.add_space(8.0); + ui.label(RichText::new("Nexus Terminal").font(theme::semibold(22.0))); + ui.label(RichText::new(format!("Version {}", nexus_core::service::VERSION)).color(t.text_dim)); + ui.label(RichText::new("Developed by Andy Dixon - alchementary.co.uk").color(t.text_faint)); + ui.add_space(8.0); + for f in [ + "SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local terminals", + "AES-256-GCM encrypted credential vault", + "Platform-native keychain integration", + "SFTP file browser and transfer", + "Session recording and playback", + ] { + ui.label(RichText::new(format!("{} {f}", icon::CHECK)).color(t.text_dim)); + } + ui.add_space(12.0); + ui.horizontal(|ui| { + if self.update_job.is_some() { + ui.spinner(); + ui.label("Checking…"); + } else if widgets::primary(ui, icon::ARROW_CLOCKWISE, "Check for Updates").clicked() { + let svc = s.svc.clone(); + self.update = None; + self.update_job = Some(Job::spawn(ui.ctx(), move || svc.check_for_updates().map_err(|e| e.to_string()))); + } + if widgets::button(ui, icon::DOWNLOAD_SIMPLE, "Import ~/.ssh/config").clicked() { + match s.svc.import_ssh_config(None, "SSH Config") { + Ok(n) => { + s.success(format!("Imported {n} host(s) from ~/.ssh/config")); + s.request(Request::ReloadProfiles); + } + Err(e) => s.error(format!("SSH config import failed: {e}")), + } + } + }); + ui.add_space(8.0); + match &self.update { + None => {} + Some(Err(e)) => { + ui.label(RichText::new(format!("Update check failed: {e}")).color(t.danger)); + } + Some(Ok(info)) => { + widgets::kv(ui, "update-kv", &[("Current", info.current_version.clone()), ("Latest", info.latest_version.clone())]); + if info.available { + let name = if info.release_name.is_empty() { &info.latest_version } else { &info.release_name }; + ui.label(RichText::new(format!("Update available: {name}")).color(t.accent)); + if !info.release_notes.trim().is_empty() { + egui::Frame::new().fill(t.field).corner_radius(t.radius).inner_margin(8).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(150.0).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label(RichText::new(info.release_notes.trim()).color(t.text_dim)); + }); + }); + } + if widgets::is_web_url(&info.release_url) + && widgets::button(ui, icon::ARROW_SQUARE_OUT, "View release").clicked() + && !widgets::open_web_url(ui.ctx(), &info.release_url) + { + s.error("The release link is not a web address"); + } + } else { + ui.label(RichText::new("You are running the latest version.").color(t.success)); + } + } + } + } +} + +/// Two-column label/control grid. +fn form(ui: &mut Ui, id: &str, body: impl FnOnce(&mut Ui)) { + egui::Grid::new(id).num_columns(2).spacing(vec2(18.0, 10.0)).min_col_width(150.0).show(ui, body); +} + +fn field(ui: &mut Ui, label: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(RichText::new(label).color(t.text_dim)); +} + +/// Draws help text: `## heading`, `- bullet`, `1. step`, `> key :: description` (shortcut row), +/// blank-line separated paragraphs, inline `**bold**` and `` `code` ``. `{Mod+K}` becomes the +/// platform's shortcut ("Ctrl+K" or "⌘K"). +fn render_help(ui: &mut Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + for line in text.lines().map(str::trim) { + if line.is_empty() { + ui.add_space(4.0); + } else if let Some(h) = line.strip_prefix("## ") { + ui.add_space(6.0); + ui.label(RichText::new(h).font(theme::semibold(13.5)).color(t.text)); + } else if let Some(row) = line.strip_prefix("> ") { + let (k, d) = row.split_once(" :: ").unwrap_or((row, "")); + ui.horizontal(|ui| { + ui.add_sized([150.0, 18.0], egui::Label::new(inline(&format!("`{}`", expand_keys(k)), &t))); + ui.label(inline(d, &t)); + }); + } else if let Some(b) = line.strip_prefix("- ") { + ui.horizontal_top(|ui| { + ui.add_space(8.0); + ui.label(RichText::new("•").color(t.text_faint)); + ui.add(egui::Label::new(inline(b, &t)).wrap()); + }); + } else if line.len() > 2 && line.as_bytes()[0].is_ascii_digit() && line[1..].starts_with(". ") { + ui.horizontal_top(|ui| { + ui.add_space(8.0); + ui.label(RichText::new(&line[..2]).color(t.text_faint)); + ui.add(egui::Label::new(inline(&line[3..], &t)).wrap()); + }); + } else { + ui.add(egui::Label::new(inline(line, &t)).wrap()); + } + } +} + +fn expand_keys(s: &str) -> String { + let mut out = String::new(); + let mut rest = s; + while let Some(i) = rest.find('{') { + let Some(j) = rest[i..].find('}') else { break }; + out.push_str(&rest[..i]); + out.push_str(&keys(&rest[i + 1..i + j])); + rest = &rest[i + j + 1..]; + } + out + rest +} + +/// `**bold**` and `` `code` `` spans as a wrapped layout job; `{Mod+X}` keys render as code. +fn inline(s: &str, t: &Tokens) -> LayoutJob { + let s = s.replace('{', "`{").replace('}', "}`"); + let s = expand_keys(&s); + let mut job = LayoutJob::default(); + let (mut bold, mut code) = (false, false); + let mut buf = String::new(); + let flush = |job: &mut LayoutJob, buf: &mut String, bold: bool, code: bool| { + if buf.is_empty() { + return; + } + let fmt = if code { + TextFormat { font_id: theme::mono(12.0), color: t.text, background: t.field, ..Default::default() } + } else if bold { + TextFormat { font_id: theme::semibold(13.0), color: t.text, ..Default::default() } + } else { + TextFormat { font_id: egui::FontId::proportional(13.0), color: t.text_dim, ..Default::default() } + }; + job.append(&std::mem::take(buf), 0.0, fmt); + }; + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '`' { + flush(&mut job, &mut buf, bold, code); + code = !code; + } else if c == '*' && !code && chars.peek() == Some(&'*') { + chars.next(); + flush(&mut job, &mut buf, bold, code); + bold = !bold; + } else { + buf.push(c); + } + } + flush(&mut job, &mut buf, bold, code); + job +} + +/// Help topics (the web build's, minus Leviathan, plus Serial). +const HELP: &[(&str, &str)] = &[ + ( + "Getting Started", + "Welcome to **Nexus Terminal** — a desktop application for SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local terminal sessions. +## Creating Your First Connection +1. Click the **+** button at the top of the sidebar, or press {Mod+N}. +2. Choose a protocol (SSH, Telnet, RAW, Serial, VNC, IRC or GitHub). +3. Fill in the hostname, port, and any credentials required. +4. Optionally assign the connection to a **group** to keep your sidebar organised. +5. Click **Save**, then click the entry in the sidebar to connect. +## Quick Connect +Need to connect quickly without saving a profile? Press {Mod+Shift+Q} to open the quick connect dialog. Choose a protocol, enter the host and port, and you're connected immediately. +## Managing Connections +Right-click any connection in the sidebar to **edit**, **duplicate**, **favourite** or **delete** it. To move connections to another group, Ctrl/Cmd-click to select them and choose **Move Group** in the bulk-action bar. Use the search box to filter by name, host, tag or protocol.", + ), + ( + "SSH Connections", + "SSH (Secure Shell) is the most common way to connect to remote servers. Nexus Terminal supports: +- **Password authentication** — enter your password when prompted, or save it in the OS keychain or encrypted vault. +- **Public key authentication** — point to your private key file (e.g. `~/.ssh/id_ed25519`). If the key has a passphrase, you will be prompted. +- **Keyboard-interactive** — for servers that use challenge-response (e.g. two-factor prompts). +- **SSH agent** — tick \"Use SSH Agent\" to authenticate via your system's SSH agent (e.g. `ssh-agent` or 1Password). +## TOTP / 2FA Auto-Fill +If your server requires a time-based one-time password (TOTP) during login, store the TOTP secret in the profile editor under the **TOTP / 2FA** section. When connecting, Nexus Terminal detects 2FA prompts (such as \"Verification code\" or \"OTP\") and automatically generates and submits a 6-digit code. Use the **Test** button to verify your secret before connecting. +## Jump Hosts (Bastions) +If you need to reach a server through an intermediate host, add one or more jump hosts in the connection editor. Nexus Terminal tunnels through each hop automatically. +## Port Forwarding +You can set up **local** and **remote** port forwards in the connection editor. These start automatically when the session connects. +## Proxy Support +Open the **Proxy** section in the profile editor to connect through a **SOCKS5** or **HTTP CONNECT** proxy, with optional username/password authentication. The proxy is used to reach the SSH server (or the first jump host if jump hosts are configured). +## SFTP File Browser +Once connected to an SSH server, click the folder icon on the tab to open the SFTP file browser. You can browse directories, upload, download, rename, and delete files from a side panel. +## SSH Agent Forwarding +Tick \"Agent Forwarding\" in the SSH settings to forward your local SSH agent to the remote server, so you can use your local keys when connecting onward from that server. +## Auto-Login Scripts +Define expect-style login scripts to automate interactions after connecting. In the **Login Script** section of the profile editor, add a sequence of **Wait for** (match text from the server) and **Send** (send text + newline) steps. For example: +1. Wait for: `login:` +2. Send: `admin` +3. Wait for: `Password:` +4. Send: `mysecretpassword` + +Each wait step has a configurable timeout (default 10 seconds). Steps execute in order after the connection is established. +## Auto-Reconnect +Enable **Auto-Reconnect** in the Advanced section to reconnect automatically when the connection drops unexpectedly. Reconnection uses exponential backoff (1s, 2s, 4s, 8s… up to 30s). You can configure the maximum number of attempts (default 5).", + ), + ( + "Telnet Connections", + "Telnet is an older, unencrypted protocol still used for network equipment, legacy systems, and some fun internet services. +- **Option negotiation** — Nexus Terminal handles standard Telnet options (echo, terminal type, window size) automatically. +- **Character encoding** — choose UTF-8, ASCII, or Latin-1 for the connection. +- **Local echo** — enable this if the remote host does not echo back what you type. +- **Line mode** — send data line-by-line (press Enter to send) rather than character-by-character. +- **Auto-login scripts** — the same expect-style send/wait sequences as SSH. +- **Auto-reconnect** — reconnect automatically with exponential backoff when the connection drops. + +**Note:** Telnet sends everything in plain text. Avoid using it for anything sensitive over an untrusted network.", + ), + ( + "RAW TCP Connections", + "RAW TCP lets you connect to any TCP port and exchange data directly — useful for debugging network services, testing APIs, or working with custom protocols. +- **Text view** — shows incoming data as readable text. +- **Hex view** — shows raw bytes in a hex dump format. +- **Split view** — text and hex side by side. +- **Payload presets** — save frequently-used commands or data packets for quick sending. +- **Line endings** — choose CR, LF, or CRLF to match the remote service. +- **Auto-reconnect** — reconnect automatically if the connection drops.", + ), + ( + "Serial Connections", + "Serial profiles open a terminal on a local serial port — USB-serial adapters, console cables, development boards and network gear. +- **Device** — pick the port from the list (refresh after plugging in an adapter), e.g. `/dev/ttyUSB0`, `/dev/cu.usbserial-…` or `COM3`. +- **Line settings** — baud rate (default 115200), data bits, parity, stop bits and flow control (none, XON/XOFF, RTS/CTS). +- **Behaviour** — line ending, local echo, encoding, and whether DTR/RTS are raised on connect. +- **Tab controls** — right-click the tab to send a break or toggle DTR/RTS. +- **Auto-reconnect** — unplugging the adapter disconnects the session; it reopens the port when it comes back. + +On Linux your user usually needs to be in the `dialout` (or `uucp`) group. On macOS use the `/dev/cu.*` device. A \"busy\" error means another program has the port open.", + ), + ( + "VNC Connections", + "VNC profiles open a remote desktop viewer inside the Nexus Terminal workspace. +- **Password handling** — ask each time, use the session only, or save the password in the secret store. +- **View-only mode** — observe a desktop without sending mouse or keyboard input. +- **Scale-to-fit** — resize the remote desktop to fit the available workspace. +- **Shared session** — request a shared VNC session where the server supports it. +- **Quality** — tune image quality for slower links. +- **SSH tunnel** — carry VNC traffic through an SSH connection using password, public key, or SSH agent authentication. + +Prefer **SSH tunnel** for VNC outside trusted networks. Direct VNC is supported for isolated or trusted networks, but many classic VNC deployments do not provide strong transport encryption.", + ), + ( + "GitHub Connections", + "The GitHub connection type lets you browse your repositories without leaving Nexus Terminal. +## Setting Up +1. Create a new connection and choose **GitHub** as the protocol. +2. Enter your **Personal Access Token** (create one at GitHub → Settings → Developer settings → Personal access tokens). The token needs the `repo` scope. +3. Save and connect. +## What You'll See +Your repositories are listed grouped by organisation or owner, sorted by last commit date (newest first). The list refreshes automatically every few minutes.", + ), + ( + "Sessions & Tabs", + "## Tab Management +- **Rename** — right-click a tab and choose \"Rename\". +- **Reorder** — drag tabs left or right to rearrange. +- **Pin** — right-click and pin a tab so it stays at the left of the bar. +- **Scroll** — use the mouse wheel or trackpad over the tab strip when many tabs are open. +- **Hover hints** — hover a tab to see its full name and connected-since time where available. +- **Close** — click the **×** on the tab, or press {Mod+W}. +## Split Panes +You can split the terminal into multiple panes: +- {Mod+Shift+D} — split vertically (side by side). +- {Mod+Shift+E} — split horizontally (top and bottom). + +Drag the divider between panes to resize them. +## Broadcast Input +Press {Mod+Shift+B} to toggle **broadcast mode**. When active, everything you type is sent to **all connected sessions** simultaneously — perfect for running the same command across multiple servers. +## Session Recording +Press {Mod+Shift+R} to start recording a session. A red dot appears on the tab. Press the same shortcut again to stop. Browse and replay recordings from the Recordings window. +## Right-Click Context Menu +Right-click in the terminal for: +- **Copy** — copy selected text to the clipboard +- **Paste** — paste from the clipboard into the terminal +- **Select All** — select all terminal content +- **Find** — open the terminal search overlay +- **Send Snippet** — quickly send a saved command from the snippet library +- **Clear** / **Reset** — clear the terminal buffer", + ), + ( + "Power Features", + "## Snippet Library +Press {Mod+Shift+S} to open the **snippet library**. Save frequently-used commands with a name, description, and optional folder. Click **Send to Session** to inject a command into the active terminal. You can also reach snippets from the right-click context menu. +## Terminal Search +Press {Mod+F} to open the search overlay on the active terminal: +- `Aa` — toggle case-sensitive matching +- `.*` — toggle regular expression mode +- A match counter showing current/total matches +- Move between matches with the arrows or Enter / Shift+Enter +## Quick Connect +Press {Mod+Shift+Q} to open a lightweight connect dialog and connect without creating a saved profile. +## Auto-Login Scripts +For SSH and Telnet connections you can define expect-style login scripts — a sequence of \"wait for text\" and \"send text\" steps that run automatically after connecting. Configure them in the **Login Script** section of the profile editor. +## Auto-Reconnect +Enable auto-reconnect in a profile's settings to reconnect automatically when a connection drops, using exponential backoff with a configurable maximum number of attempts.", + ), + ( + "Security & Credentials", + "## How Your Passwords Are Stored +Nexus Terminal uses a **two-tier** approach to keep your credentials safe: +1. **OS keychain** (preferred) — your operating system's credential store (macOS Keychain, Windows Credential Manager, or Linux Secret Service). If available, it is used automatically. +2. **Encrypted vault** (fallback) — if the OS keychain is not available, Nexus Terminal keeps a local vault encrypted with AES-256-GCM. You set a master password to unlock it. +## Encryption Details +The vault uses **Argon2id** to derive the encryption key from your master password (128 MiB memory, making brute-force attacks very expensive). Each secret is individually encrypted and never stored in plain text. +## TOTP / 2FA Secrets +TOTP secrets for SSH two-factor authentication are stored alongside your other credentials — in the OS keychain or the encrypted vault. They are never saved in the profile database in plain text. +## Known Hosts +When you connect to an SSH server for the first time, Nexus Terminal records the server's fingerprint. If the fingerprint changes on a later connection, you'll be warned — this protects against man-in-the-middle attacks. + +You can review and remove known hosts in **Settings → Known Hosts**. +## Encrypted Export +When exporting profiles, encrypted export (a `.nexus` file) protects the file with a password, using the same AES-256-GCM encryption as the vault.", + ), + ( + "Keyboard Shortcuts", + "On macOS, use ⌘ (Cmd). On Windows and Linux, use Ctrl. +> {Mod+K} :: Open the command palette +> {Mod+N} :: New connection +> {Mod+W} :: Close the active tab +> {Mod+Tab} :: Next tab +> {Mod+Shift+Tab} :: Previous tab +> {Mod+1} – {Mod+9} :: Switch to tab by number +> {Mod+,} :: Open Settings +> {Mod+F} :: Search in terminal +> {Mod+Shift+B} :: Toggle broadcast input mode +> {Mod+Shift+D} :: Split pane vertically +> {Mod+Shift+E} :: Split pane horizontally +> {Mod+Shift+Q} :: Quick connect (no saved profile) +> {Mod+Shift+R} :: Toggle session recording +> {Mod+Shift+S} :: Open snippet library +> F11 :: Toggle fullscreen", + ), + ( + "Import & Export", + "## Exporting Profiles +Use **Export (Encrypted)** from the sidebar menu or command palette. Choose which profiles to include and set a password to encrypt the file. Exported files use the `.nexus` extension. Plain **JSON** export is also available, optionally including secrets. +## Importing Profiles +Use **Import (Encrypted)**, select a `.nexus` file (older `.link` exports from Alchementary Link work too) and enter the password used when exporting. Choose whether existing profiles are skipped, overwritten or duplicated. Imported profiles appear in your sidebar immediately. +## Importing from OpenSSH +If you have an existing `~/.ssh/config` file, go to **Settings → About** and click **Import ~/.ssh/config**. Nexus Terminal creates profiles from your SSH Host blocks, including hostnames, usernames, ports, identity files, and jump hosts.", + ), + ( + "Troubleshooting", + "## I can't connect to my server +- Double-check the **hostname** and **port** — a common mistake is using port 22 for Telnet (which is usually 23) or vice versa. +- Make sure the server is reachable from your machine. Try `ping hostname` from another terminal. +- If using SSH keys, check that the key file path is correct and that the file permissions are not too open (should be `600` or `400`). +- If you're behind a firewall or VPN, ensure the relevant port is allowed through. +## SSH connection fails through a proxy +- Check the proxy host, port, and type (SOCKS5 or HTTP CONNECT) in the profile's **Proxy** section. +- If the proxy requires authentication, make sure the username and password are filled in. +- SOCKS5 defaults to port 1080; HTTP CONNECT defaults to port 8080. +- Some proxies block SSH traffic — check with your network administrator. +## TOTP/2FA code is rejected +- Ensure the TOTP secret is correct (base32-encoded, as shown during authenticator app setup). +- Check that your system clock is accurate — TOTP codes are only valid for 30-second windows. +- Use the **Test** button in the profile's TOTP section to verify the secret generates valid codes. +## Login script times out +- Each \"wait\" step has a configurable timeout (default 10 seconds). Increase it if the server takes longer. +- Check that the wait pattern matches exactly — it's a plain text substring match, not a regular expression. +- Make sure the server actually sends the expected text; prompts can differ by locale or configuration. +## Auto-reconnect keeps failing +- Auto-reconnect only works after a successful initial connection. +- If the server is genuinely unreachable, attempts stop after the configured maximum (default 5). +- Check the reconnection settings in the profile's Advanced section. +## Serial port won't open +- Check the device path and that the adapter is plugged in; refresh the device list in the editor. +- On Linux, add your user to the `dialout` (or `uucp`) group and log in again. +- Close any other program (screen, minicom, an IDE monitor) that has the port open. +## VNC will not connect +- Check the VNC host and port. The common port is `5900` plus the display number. +- If a password is required, check whether the profile is set to saved, session-only, or ask-every-time mode. +- If using an SSH tunnel, verify the tunnel host, username, authentication method, private key, and host-key policy. +- If using direct VNC, test from a trusted network and prefer SSH tunnelling on untrusted networks. +## The terminal looks garbled or the wrong size +- Try resizing the window — the terminal re-fits automatically. +- If you're using split panes, drag the divider to give the terminal more space. +- Some older servers don't support modern terminal features. Try a different terminal type or colour scheme. +## \"Host key verification failed\" +This means the server's fingerprint has changed since your last connection. This can happen if the server was reinstalled, but it could also indicate a security threat. If you're certain it's safe, go to **Settings → Known Hosts**, find the entry, and remove it. You'll be asked to accept the new key on your next connection. +## I forgot my vault master password +The vault is designed so that without the master password the stored credentials cannot be recovered. You would need to delete the vault and set up a new one. Your connection profiles (without saved passwords) remain intact. +## SFTP panel doesn't open +SFTP is only available for **SSH** connections. Make sure you are connected to an SSH session first, then click the folder icon on the tab. +## Export/import isn't working +Use the same password to import that was used to export. Encrypted `.nexus` files cannot be opened with the wrong password.", + ), + ( + "Fun Things to Try", + "Nexus Terminal isn't just for work! Here are some entertaining things you can connect to: +## Star Wars in ASCII Art +Create a **Telnet** connection to `towel.blinkenlights.nl` on port **23**. Sit back and enjoy the original Star Wars film retold entirely in ASCII art. +## MapSCII — World Map in Your Terminal +Connect via **SSH** to `mapscii.me` on port **22**. Use the arrow keys to pan and `a`/`z` to zoom in and out of a fully interactive world map rendered in text. +## Telnet BBS Systems +Connect via **Telnet** to `particlesbbs.dyndns.org` on port **23** for a retro bulletin board system, complete with door games, message boards, and ASCII art. +## Fortune Cookies +Connect via **Telnet** to `djxmmx.net` on port **17** (the Quote of the Day protocol). You'll receive a random quote each time you connect — great for testing RAW TCP connections too. +## Test Your SSH Setup +Connect via **SSH** to `ssh.github.com` on port **443** (or `github.com` on port **22**) with your GitHub username. GitHub greets you by name if your key is set up correctly. +## RAW TCP Experiments +Create a **RAW TCP** connection to any HTTP server on port **80**, then type `GET / HTTP/1.0` followed by two blank lines to see the raw HTTP response. +## Broadcast Mode Fun +Open two or more sessions and press {Mod+Shift+B} to turn on broadcast mode. Everything you type goes to all sessions at once — try running `hostname` and watch them all respond!", + ), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strength_matches_web_formula() { + assert_eq!(strength(""), (0, "")); + // "abc": 9 + 10 + 20 = 39 + assert_eq!(strength("abc"), (39, "Fair")); + // "aaaa": 12 + 10 + floor(1/4*20)=5 → 27 + assert_eq!(strength("aaaa"), (27, "Fair")); + assert_eq!(strength("a"), (3 + 10 + 20, "Fair")); + assert_eq!(strength("Tr0ub4dor&3xyz!"), (98, "Strong")); + assert_eq!(strength("password1").1, "Good"); + } + + #[test] + fn keys_expand() { + assert_eq!(expand_keys("x {Mod+N} y"), format!("x {} y", keys("Mod+N"))); + assert!(HELP.iter().all(|(t, b)| !t.contains("Leviathan") && !b.contains("Leviathan") && !b.contains("drag connections"))); + } +} diff --git a/rust/app/src/sftp.rs b/rust/app/src/sftp.rs new file mode 100644 index 0000000..5afef0f --- /dev/null +++ b/rust/app/src/sftp.rs @@ -0,0 +1,1144 @@ +//! SFTP browser (right dock), the file-drop dialog and the remote directory picker. +//! Every SFTP call blocks on the network, so it runs on a worker thread; results come back +//! through `inbox` and are applied on the UI thread at the start of the next frame. + +use crate::chrome::modal; +use crate::state::{Request, Shared}; +use crate::theme::{self, Tokens}; +use crate::views::View; +use crate::widgets; +use egui::{Align, Key, Layout, Modifiers, PointerButton, RichText, Sense, Ui, vec2}; +use egui_phosphor::regular as icon; +use nexus_core::clients::sftp::{self as rsftp, FileEntry, Progress}; +use nexus_core::keyfiles::safe_sftp_upload_name; +use nexus_core::models::Protocol; +use nexus_core::service::Service; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +/// Worker results, applied on the UI thread. +enum Done { + Listed { + session: String, + path: String, + files: Result, String>, + }, + Picker { + session: String, + result: Result<(String, Vec, bool), String>, + }, + Refresh(String), + TransferEnded { + session: String, + path: String, + }, + /// Always shown. + Toast(String), + /// Shown only while the panel for `session` is open (otherwise per-file toasts cover it). + Summary { + session: String, + text: String, + }, +} + +#[derive(Clone)] +struct Inbox(Arc>>, egui::Context); + +impl Inbox { + fn push(&self, d: Done) { + self.0.lock().unwrap().push(d); + self.1.request_repaint(); + } +} + +#[derive(Default)] +struct Panel { + cwd: String, + files: Vec, + loading: bool, + error: Option, + /// Selected paths. + selected: HashSet, + /// Index into `files` for Shift-range selection. + anchor: Option, +} + +struct Transfer { + session: String, + path: String, + upload: bool, + transferred: u64, + total: u64, +} + +enum InputKind { + Mkdir, + Rename(FileEntry), + GoTo, +} + +struct Picker { + session: String, + files: Vec, + cwd: String, + entries: Vec, + cwd_writable: bool, + filter: String, + /// Index into the visible (filtered) entries. + cursor: Option, + loading: bool, + error: Option, + focused: bool, +} + +enum Dialog { + Input { session: String, kind: InputKind, value: String }, + Delete { session: String, entries: Vec }, + Drop { session: String, paths: Vec, ssh: bool }, + Picker(Picker), +} + +#[derive(Default)] +pub struct Sftp { + open_for: Option, + panels: HashMap, + transfers: Vec, + dialog: Option, + /// Profile id (or host:port for quick connects) → bookmarked paths. Loaded on first use. + bookmarks: Option>>, + inbox: Arc>>, +} + +// --- path helpers --------------------------------------------------------------------------- + +fn parent(p: &str) -> String { + let t = p.trim_end_matches('/'); + match t.rfind('/') { + Some(i) if i > 0 => t[..i].to_string(), + _ => "/".into(), + } +} + +/// "/a/b" → [("/", "/"), ("a", "/a"), ("b", "/a/b")] +fn segments(p: &str) -> Vec<(String, String)> { + let mut out = vec![("/".to_string(), "/".to_string())]; + let mut acc = String::new(); + for part in p.split('/').filter(|s| !s.is_empty()) { + acc = format!("{acc}/{part}"); + out.push((part.to_string(), acc.clone())); + } + out +} + +/// Permission checks from the mode string, as the web build did (any r/w bit counts). +fn can_read(mode: &str) -> bool { + mode.is_empty() || mode.contains('r') +} + +fn can_write(mode: &str) -> bool { + mode.is_empty() || mode.contains('w') +} + +fn file_icon(e: &FileEntry) -> &'static str { + if e.is_dir { + return icon::FOLDER; + } + let ext = e.name.rsplit('.').next().unwrap_or("").to_lowercase(); + match ext.as_str() { + "jpg" | "jpeg" | "png" | "gif" | "svg" | "webp" => icon::FILE_IMAGE, + "mp3" | "wav" | "ogg" | "flac" => icon::FILE_AUDIO, + "mp4" | "avi" | "mkv" | "mov" => icon::FILE_VIDEO, + "zip" | "tar" | "gz" | "bz2" | "xz" | "7z" => icon::FILE_ZIP, + "sh" | "bash" | "zsh" => icon::TERMINAL, + "py" | "js" | "ts" | "go" | "rs" | "c" | "h" | "cpp" | "java" => icon::FILE_CODE, + "md" | "txt" | "log" | "csv" => icon::FILE_TEXT, + "json" | "yaml" | "yml" | "toml" | "xml" => icon::GEAR, + "key" | "pem" | "crt" | "pub" => icon::KEY, + _ => icon::FILE, + } +} + +fn base_name(p: &str) -> &str { + p.rsplit('/').next().filter(|s| !s.is_empty()).unwrap_or(p) +} + +fn local_name(p: &Path) -> String { + p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default() +} + +fn modified(ms: i64) -> String { + use chrono::TimeZone; + chrono::Local.timestamp_millis_opt(ms).single().map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()).unwrap_or_default() +} + +fn danger(ui: &mut Ui, label: &str) -> egui::Response { + let t = Tokens::get(ui.ctx()); + ui.add(egui::Button::new(RichText::new(label).color(t.accent_text)).fill(t.danger)) +} + +fn breadcrumb(ui: &mut Ui, path: &str) -> Option { + let t = Tokens::get(ui.ctx()); + let mut go = None; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for (i, (label, p)) in segments(path).into_iter().enumerate() { + if i > 1 { + ui.label(RichText::new(icon::CARET_RIGHT).size(10.0).color(t.text_faint)); + } + let last = p == path || (path.is_empty() && i == 0); + let text = RichText::new(label).size(12.0).color(if last { t.text } else { t.text_dim }); + if ui.add(egui::Button::new(text).frame(false)).on_hover_text(&p).clicked() { + go = Some(p); + } + } + }); + go +} + +fn state_line(ui: &mut Ui, loading: bool, error: Option<&str>) -> bool { + let t = Tokens::get(ui.ctx()); + let mut retry = false; + ui.vertical_centered(|ui| { + ui.add_space(28.0); + if loading { + ui.add(egui::Spinner::new()); + ui.label(RichText::new("Loading…").color(t.text_dim)); + } else if let Some(e) = error { + ui.label(RichText::new(icon::WARNING).size(28.0).color(t.warning)); + ui.label(RichText::new(e).color(t.text_dim).size(12.0)); + retry = ui.button(format!("{} Retry", icon::ARROWS_CLOCKWISE)).clicked(); + } + }); + retry +} + +impl Sftp { + pub fn toggle(&mut self, session: &str) { + if self.open_for.as_deref() == Some(session) { + self.close(session); + } else { + self.open_for = Some(session.into()); + } + } + + /// The session whose browser is showing in the dock, if any. + pub fn open_for(&self) -> Option<&str> { + self.open_for.as_deref() + } + + pub fn close(&mut self, session: &str) { + if self.open_for.as_deref() == Some(session) { + self.open_for = None; + } + self.panels.remove(session); + } + + pub fn on_progress(&mut self, session: &str, p: Progress) { + let i = match self.transfers.iter().position(|t| t.session == session && t.path == p.path) { + Some(i) => i, + None if p.done => return, + None => { + self.transfers.push(Transfer { session: session.into(), path: p.path.clone(), upload: false, transferred: 0, total: 0 }); + self.transfers.len() - 1 + } + }; + let tr = &mut self.transfers[i]; + tr.transferred = p.transferred; + tr.total = p.total; + if p.done { + let tr = self.transfers.remove(i); + if self.open_for.as_deref() != Some(session) { + let verb = if tr.upload { "↑ Uploaded" } else { "↓ Downloaded" }; + self.inbox.lock().unwrap().push(Done::Toast(format!("{verb} {}", base_name(&tr.path)))); + } + } + } + + /// Files dropped on a terminal: offer "paste contents" or (SSH) "upload to a directory". + pub fn drop_files(&mut self, s: &mut Shared, session: &str, paths: Vec) { + let ssh = s.pane(session).is_some_and(|p| p.info.protocol == Protocol::Ssh); + self.dialog = Some(Dialog::Drop { session: session.into(), paths, ssh }); + } + + // --- workers -------------------------------------------------------------------------- + + fn work(&self, s: &Shared, f: impl FnOnce(&Service, &Inbox) + Send + 'static) { + let (svc, inbox) = (s.svc.clone(), Inbox(self.inbox.clone(), s.ctx.clone())); + std::thread::spawn(move || f(&svc, &inbox)); + } + + fn drain(&mut self, s: &mut Shared) { + let items = std::mem::take(&mut *self.inbox.lock().unwrap()); + for d in items { + match d { + Done::Listed { session, path, files } => { + if let Some(p) = self.panels.get_mut(&session) { + p.loading = false; + match files { + Ok(f) => { + p.cwd = path; + p.files = f; + p.error = None; + } + Err(e) => p.error = Some(e), + } + } + } + Done::Picker { session, result } => { + if let Some(Dialog::Picker(pk)) = &mut self.dialog + && pk.session == session + { + pk.loading = false; + match result { + Ok((cwd, files, writable)) => { + pk.cwd = cwd; + pk.entries = files.into_iter().filter(|e| e.is_dir).collect(); + pk.cwd_writable = writable; + pk.cursor = None; + pk.error = None; + } + Err(e) => pk.error = Some(e), + } + } + } + Done::Refresh(session) => { + if self.open_for.as_deref() == Some(session.as_str()) + && let Some(cwd) = self.panels.get(&session).map(|p| p.cwd.clone()) + { + self.navigate(s, &session, Some(cwd)); + } + } + Done::TransferEnded { session, path } => self.transfers.retain(|t| !(t.session == session && t.path == path)), + Done::Toast(text) => s.success(text), + Done::Summary { session, text } => { + if self.open_for.as_deref() == Some(session.as_str()) { + s.success(text); + } + } + } + } + } + + /// Lists `path` (None: the session's cwd, falling back to the SFTP home). + fn navigate(&mut self, s: &Shared, session: &str, path: Option) { + let p = self.panels.entry(session.into()).or_default(); + if p.loading { + return; + } + p.loading = true; + p.selected.clear(); + p.anchor = None; + let sid = session.to_string(); + self.work(s, move |svc, out| { + let path = path.filter(|p| !p.is_empty()).map(Ok).unwrap_or_else(|| start_dir(svc, &sid)); + let (path, files) = match path { + Ok(p) => { + let f = svc.sftp_list(&sid, &p).map_err(|e| e.to_string()); + (p, f) + } + Err(e) => (String::new(), Err(e)), + }; + out.push(Done::Listed { session: sid, path, files }); + }); + } + + fn refresh(&mut self, s: &Shared, session: &str) { + let cwd = self.panels.get(session).map(|p| p.cwd.clone()).filter(|c| !c.is_empty()); + self.navigate(s, session, cwd); + } + + /// Uploads local files into `dir`, one after another; stops at the first failure. + fn upload(&mut self, s: &Shared, session: &str, dir: &str, files: Vec) { + if files.is_empty() { + return; + } + for f in &files { + if let Ok(name) = safe_sftp_upload_name(&local_name(f)) { + self.transfers.push(Transfer { session: session.into(), path: rsftp::join(dir, &name), upload: true, transferred: 0, total: 0 }); + } + } + let (sid, dir) = (session.to_string(), dir.to_string()); + self.work(s, move |svc, out| { + let n = files.len(); + for f in &files { + let name = local_name(f); + let remote = safe_sftp_upload_name(&name).map(|n| rsftp::join(&dir, &n)).unwrap_or_default(); + let r = svc.sftp_upload_path(&sid, &dir, f); + out.push(Done::TransferEnded { session: sid.clone(), path: remote }); + out.push(Done::Refresh(sid.clone())); + if let Err(e) = r { + // Drop the queued entries that will never start. + for g in &files { + if let Ok(n) = safe_sftp_upload_name(&local_name(g)) { + out.push(Done::TransferEnded { session: sid.clone(), path: rsftp::join(&dir, &n) }); + } + } + crate::app::post_error(format!("Upload failed for {name}: {e}")); + return; + } + } + let text = if n == 1 { format!("Uploaded {}", local_name(&files[0])) } else { format!("Uploaded {n} files") }; + out.push(Done::Summary { session: sid, text }); + }); + } + + fn download(&mut self, s: &Shared, session: &str, files: Vec) { + let targets: Vec<(FileEntry, PathBuf)> = if files.len() == 1 { + let Some(p) = rfd::FileDialog::new().set_file_name(&files[0].name).save_file() else { + return; + }; + vec![(files[0].clone(), p)] + } else { + let Some(dir) = rfd::FileDialog::new().set_title("Download to folder").pick_folder() else { + return; + }; + files.into_iter().filter_map(|e| safe_sftp_upload_name(&e.name).ok().map(|n| (e, dir.join(n)))).collect() + }; + for (e, _) in &targets { + self.transfers.push(Transfer { session: session.into(), path: e.path.clone(), upload: false, transferred: 0, total: e.size }); + } + let sid = session.to_string(); + self.work(s, move |svc, out| { + let n = targets.len(); + let mut failed = 0; + for (e, local) in &targets { + let r = svc.sessions.sftp_download(&sid, &e.path, local); + out.push(Done::TransferEnded { session: sid.clone(), path: e.path.clone() }); + if let Err(err) = r { + failed += 1; + crate::app::post_error(format!("Download failed: {} — {err}", e.name)); + } + } + if failed == 0 { + let text = if n == 1 { format!("Downloaded {}", targets[0].0.name) } else { format!("All {n} files downloaded") }; + out.push(Done::Summary { session: sid, text }); + } + }); + } + + fn delete(&mut self, s: &Shared, session: &str, entries: Vec) { + let sid = session.to_string(); + self.work(s, move |svc, out| { + let mut failed = 0; + for e in &entries { + if let Err(err) = svc.sftp_remove(&sid, &e.path) { + failed += 1; + crate::app::post_error(format!("Delete failed: {} — {err}", e.name)); + } + } + if failed == 0 { + let text = match entries.as_slice() { + [e] => format!("{} deleted", if e.is_dir { "Directory" } else { "File" }), + _ => format!("Deleted {} items", entries.len()), + }; + out.push(Done::Summary { session: sid.clone(), text }); + } + out.push(Done::Refresh(sid)); + }); + } + + /// Runs a single SFTP operation, then toasts `ok` and refreshes the listing. + fn op( + &mut self, + s: &Shared, + session: &str, + ok: &'static str, + what: &'static str, + f: impl FnOnce(&Service, &str) -> nexus_core::Result<()> + Send + 'static, + ) { + let sid = session.to_string(); + self.work(s, move |svc, out| { + match f(svc, &sid) { + Ok(()) => out.push(Done::Summary { session: sid.clone(), text: ok.into() }), + Err(e) => crate::app::post_error(format!("{what} failed: {e}")), + } + out.push(Done::Refresh(sid)); + }); + } + + // --- bookmarks ------------------------------------------------------------------------ + + fn bookmarks_path(s: &Shared) -> PathBuf { + s.svc.config.data_dir.join("sftp-bookmarks.json") + } + + fn bookmarks(&mut self, s: &Shared) -> &mut BTreeMap> { + self.bookmarks.get_or_insert_with(|| std::fs::read(Self::bookmarks_path(s)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()) + } + + fn save_bookmarks(&mut self, s: &mut Shared) { + let json = serde_json::to_vec_pretty(self.bookmarks(s)).unwrap_or_default(); + if let Err(e) = std::fs::write(Self::bookmarks_path(s), json) { + s.error(format!("Couldn't save bookmarks: {e}")); + } + } + + fn bookmark_key(s: &Shared, session: &str) -> Option { + let i = &s.pane(session)?.info; + Some(if i.profile_id.is_empty() { format!("{}:{}", i.host, i.port) } else { i.profile_id.clone() }) + } + + // --- dock panel ----------------------------------------------------------------------- + + /// The dock panel. + pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) { + self.drain(s); + let Some(session) = self.open_for.clone() else { + return; + }; + if !self.panels.contains_key(&session) { + self.navigate(s, &session, None); + } + let t = Tokens::get(ui.ctx()); + ui.style_mut().interaction.selectable_labels = false; + let title = s.pane(&session).map(|p| p.info.tab_name.clone()).unwrap_or_default(); + let bm_key = Self::bookmark_key(s, &session); + let marks = bm_key.as_ref().and_then(|k| self.bookmarks(s).get(k).cloned()).unwrap_or_default(); + + enum Act { + Nav(String), + Refresh, + Close, + Upload, + Mkdir, + GoTo, + AddMark, + DelMark(usize), + Download(Vec), + Rename(FileEntry), + Delete(Vec), + } + let mut act = None; + + // Header: title + toolbar. + ui.horizontal(|ui| { + ui.label(RichText::new(icon::FOLDER_OPEN).size(15.0).color(t.accent)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 2.0; + if widgets::icon_button(ui, icon::X, "Close").clicked() { + act = Some(Act::Close); + } + if widgets::icon_button(ui, icon::ARROWS_CLOCKWISE, "Refresh").clicked() { + act = Some(Act::Refresh); + } + if widgets::icon_button(ui, icon::UPLOAD_SIMPLE, "Upload files").clicked() { + act = Some(Act::Upload); + } + if widgets::icon_button(ui, icon::FOLDER_PLUS, "New folder").clicked() { + act = Some(Act::Mkdir); + } + let star = widgets::icon_button(ui, icon::STAR, "Bookmarks"); + egui::Popup::menu(&star).show(|ui| { + ui.set_min_width(220.0); + widgets::section(ui, "Bookmarks"); + if marks.is_empty() { + widgets::hint(ui, "No bookmarks yet."); + } + for (i, m) in marks.iter().enumerate() { + ui.horizontal(|ui| { + if widgets::icon_button(ui, icon::X, "Remove bookmark").clicked() { + act = Some(Act::DelMark(i)); + } + if ui.add(egui::Button::new(RichText::new(m).font(theme::mono(12.0))).frame(false)).clicked() { + act = Some(Act::Nav(m.clone())); + ui.close(); + } + }); + } + ui.separator(); + if ui.button(format!("{} Bookmark current", icon::STAR)).clicked() { + act = Some(Act::AddMark); + ui.close(); + } + }); + if widgets::icon_button(ui, icon::NAVIGATION_ARROW, "Go to path").clicked() { + act = Some(Act::GoTo); + } + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + ui.add(egui::Label::new(RichText::new(&title).font(theme::semibold(13.0))).truncate()); + }); + }); + }); + + let Some(p) = self.panels.get(&session) else { + return; + }; + if let Some(go) = breadcrumb(ui, &p.cwd) { + act = Some(Act::Nav(go)); + } + ui.separator(); + + // Transfer queue. + let mine: Vec<&Transfer> = self.transfers.iter().filter(|x| x.session == session).collect(); + if !mine.is_empty() { + widgets::section(ui, &format!("Transfers ({})", mine.len())); + egui::ScrollArea::vertical().id_salt("sftp-transfers").max_height(120.0).show(ui, |ui| { + for x in mine { + let frac = if x.total > 0 { (x.transferred as f32 / x.total as f32).min(1.0) } else { 0.0 }; + ui.horizontal(|ui| { + let arrow = if x.upload { icon::ARROW_UP } else { icon::DOWNLOAD_SIMPLE }; + ui.label(RichText::new(arrow).color(t.accent)); + ui.vertical(|ui| { + ui.add(egui::Label::new(base_name(&x.path)).truncate()).on_hover_text(&x.path); + ui.add(egui::ProgressBar::new(frac).desired_height(4.0).fill(t.accent)); + let total = if x.total > 0 { widgets::human_size(x.total) } else { "?".into() }; + ui.label( + RichText::new(format!("{} / {total} ({:.0}%)", widgets::human_size(x.transferred), frac * 100.0)) + .size(11.0) + .color(t.text_faint), + ); + }); + }); + } + }); + ui.separator(); + } + + // File list. + let sel: Vec = p.files.iter().filter(|f| p.selected.contains(&f.path)).cloned().collect(); + let bar_h = if sel.is_empty() { 0.0 } else { 34.0 }; + let list_h = (ui.available_height() - bar_h).max(60.0); + let has_parent = !p.cwd.is_empty() && p.cwd != "/"; + let off = has_parent as usize; + let mut pick = None; + ui.allocate_ui(vec2(ui.available_width(), list_h), |ui| { + if p.loading || p.error.is_some() { + if state_line(ui, p.loading, p.error.as_deref()) { + act = Some(Act::Refresh); + } + return; + } + if p.files.is_empty() { + if has_parent && ui.add(egui::Button::new(format!("{} ..", icon::ARROW_BEND_UP_LEFT)).frame(false)).double_clicked() { + act = Some(Act::Nav(parent(&p.cwd))); + } + widgets::empty(ui, icon::FOLDER_OPEN, "Empty directory", ""); + return; + } + let clicked = widgets::rows(ui, &format!("sftp-{session}"), p.files.len() + off, 24.0, None, |ui, i| { + let r = ui.max_rect(); + if i < off { + ui.label(RichText::new(icon::ARROW_BEND_UP_LEFT).size(14.0).color(t.text_dim)); + ui.label(RichText::new("..").color(t.text_dim)); + return; + } + let e = &p.files[i - off]; + if p.selected.contains(&e.path) { + ui.painter().rect_filled(r.expand2(vec2(6.0, 0.0)), 3.0, t.row_selected); + } + ui.painter().text( + r.left_center() + vec2(8.0, 0.0), + egui::Align2::CENTER_CENTER, + file_icon(e), + egui::FontId::proportional(14.0), + if e.is_dir { t.accent } else { t.text_dim }, + ); + let meta_w = if e.is_dir { 0.0 } else { 72.0 }; + let nr = egui::Rect::from_min_max(egui::pos2(r.min.x + 22.0, r.min.y), egui::pos2(r.max.x - meta_w - 4.0, r.max.y)); + ui.scope_builder(egui::UiBuilder::new().max_rect(nr).layout(Layout::left_to_right(Align::Center)), |ui| { + ui.add(egui::Label::new(&e.name).truncate()); + }); + if !e.is_dir { + let mr = egui::Rect::from_min_max(egui::pos2(r.max.x - meta_w, r.min.y), r.max); + ui.painter().text( + mr.right_center(), + egui::Align2::RIGHT_CENTER, + widgets::human_size(e.size), + egui::FontId::proportional(11.5), + t.text_faint, + ); + if e.modified_at > 0 { + ui.interact(mr, ui.id().with(("meta", i)), Sense::hover()).on_hover_text(modified(e.modified_at)); + } + } + }); + if let Some(i) = clicked { + let dbl = ui.input(|inp| inp.pointer.button_double_clicked(PointerButton::Primary)); + if i < off { + if dbl { + act = Some(Act::Nav(parent(&p.cwd))); + } + } else if dbl && p.files[i - off].is_dir { + act = Some(Act::Nav(p.files[i - off].path.clone())); + } else if !dbl { + pick = Some((i - off, ui.input(|inp| inp.modifiers))); + } + } + }); + if let Some((fi, m)) = pick + && let Some(p) = self.panels.get_mut(&session) + { + let path = p.files[fi].path.clone(); + if m.command { + if !p.selected.remove(&path) { + p.selected.insert(path); + } + p.anchor = Some(fi); + } else if m.shift + && let Some(a) = p.anchor + { + let (lo, hi) = (a.min(fi), a.max(fi)); + p.selected = p.files[lo..=hi].iter().map(|f| f.path.clone()).collect(); + } else { + let only = p.selected.len() == 1 && p.selected.contains(&path); + p.selected.clear(); + if !only { + p.selected.insert(path); + } + p.anchor = Some(fi); + } + } + + // Selection actions. + if !sel.is_empty() { + ui.separator(); + ui.horizontal(|ui| { + let files: Vec = sel.iter().filter(|e| !e.is_dir).cloned().collect(); + let label = if sel.len() == 1 { sel[0].name.clone() } else { format!("{} items selected", sel.len()) }; + let tip = sel.iter().map(|e| e.path.as_str()).collect::>().join("\n"); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let n = if sel.len() == 1 { String::new() } else { format!(" ({})", sel.len()) }; + if danger(ui, &format!("{} Delete{n}", icon::TRASH)).clicked() { + act = Some(Act::Delete(sel.clone())); + } + if sel.len() == 1 && widgets::button(ui, icon::PENCIL_SIMPLE, "Rename").clicked() { + act = Some(Act::Rename(sel[0].clone())); + } + if !files.is_empty() { + let n = if files.len() == 1 { String::new() } else { format!(" ({})", files.len()) }; + if widgets::primary(ui, icon::DOWNLOAD_SIMPLE, &format!("Download{n}")).clicked() { + act = Some(Act::Download(files)); + } + } + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + ui.add(egui::Label::new(RichText::new(label).color(t.text_dim)).truncate()).on_hover_text(tip); + }); + }); + }); + } + + let cwd = self.panels.get(&session).map(|p| p.cwd.clone()).unwrap_or_default(); + match act { + None => {} + Some(Act::Nav(path)) => self.navigate(s, &session, Some(path)), + Some(Act::Refresh) => self.refresh(s, &session), + Some(Act::Close) => self.close(&session), + Some(Act::Upload) => { + if let Some(files) = rfd::FileDialog::new().set_title("Upload files").pick_files() { + self.upload(s, &session, &cwd, files); + } + } + Some(Act::Mkdir) => self.dialog = Some(Dialog::Input { session, kind: InputKind::Mkdir, value: String::new() }), + Some(Act::GoTo) => self.dialog = Some(Dialog::Input { session, kind: InputKind::GoTo, value: cwd }), + Some(Act::Rename(e)) => self.dialog = Some(Dialog::Input { session, value: e.name.clone(), kind: InputKind::Rename(e) }), + Some(Act::Delete(entries)) => self.dialog = Some(Dialog::Delete { session, entries }), + Some(Act::Download(files)) => self.download(s, &session, files), + Some(Act::AddMark) => { + if let Some(k) = bm_key + && !cwd.is_empty() + { + let list = self.bookmarks(s).entry(k).or_default(); + if list.contains(&cwd) { + s.info("Already bookmarked"); + } else { + list.push(cwd); + self.save_bookmarks(s); + } + } + } + Some(Act::DelMark(i)) => { + if let Some(k) = bm_key + && let Some(list) = self.bookmarks(s).get_mut(&k) + && i < list.len() + { + list.remove(i); + if list.is_empty() { + self.bookmarks(s).remove(&k); + } + self.save_bookmarks(s); + } + } + } + } + + // --- dialogs -------------------------------------------------------------------------- + + /// Modal dialogs (file drop, directory picker), drawn every frame. + pub fn dialogs(&mut self, ctx: &egui::Context, s: &mut Shared) { + self.drain(s); + let Some(mut d) = self.dialog.take() else { + return; + }; + let t = Tokens::get(ctx); + let esc = ctx.input(|i| i.key_pressed(Key::Escape)); + let enter = ctx.input(|i| i.key_pressed(Key::Enter)); + let mut keep = !esc; + match &mut d { + Dialog::Input { session, kind, value } => { + let (title, hint, ok) = match kind { + InputKind::Mkdir => ("New Folder", "Folder name", "Create"), + InputKind::Rename(_) => ("Rename", "New name", "Rename"), + InputKind::GoTo => ("Go to Path", "/path/to/directory", "Navigate"), + }; + let mut go = enter; + modal(ctx, "sftp-input", 360.0, |ui| { + ui.label(RichText::new(title).font(theme::semibold(15.0))); + ui.add(egui::TextEdit::singleline(value).hint_text(hint).desired_width(f32::INFINITY)).request_focus(); + ui.add_space(6.0); + ui.horizontal(|ui| { + go |= widgets::primary(ui, icon::CHECK, ok).clicked(); + if ui.button("Cancel").clicked() { + keep = false; + } + }); + }); + let v = value.trim().to_string(); + if go && keep { + keep = false; + let cwd = self.panels.get(session.as_str()).map(|p| p.cwd.clone()).unwrap_or_default(); + match kind { + _ if v.is_empty() => {} + InputKind::GoTo => self.navigate(s, session, Some(v)), + InputKind::Mkdir => { + let path = rsftp::join(&cwd, &v); + self.op(s, session, "Directory created", "mkdir", move |svc, sid| svc.sftp_mkdir(sid, &path)); + } + InputKind::Rename(e) if v != e.name => { + let (from, to) = (e.path.clone(), rsftp::join(&parent(&e.path), &v)); + self.op(s, session, "Renamed", "Rename", move |svc, sid| svc.sftp_rename(sid, &from, &to)); + } + InputKind::Rename(_) => {} + } + } + } + Dialog::Delete { session, entries } => { + let (title, what, ok) = match entries.as_slice() { + [e] => (format!("Delete {}", if e.is_dir { "Folder" } else { "File" }), e.name.clone(), "Delete"), + _ => (format!("Delete {} Items", entries.len()), format!("{} selected items", entries.len()), "Delete All"), + }; + let mut go = false; + modal(ctx, "sftp-delete", 380.0, |ui| { + ui.label(RichText::new(format!("{} {title}", icon::TRASH)).font(theme::semibold(15.0))); + ui.label(format!("Permanently delete {what}?")); + ui.add_space(6.0); + ui.horizontal(|ui| { + go = danger(ui, ok).clicked(); + if ui.button("Cancel").clicked() { + keep = false; + } + }); + }); + if go { + keep = false; + self.delete(s, session, std::mem::take(entries)); + } + } + Dialog::Drop { session, paths, ssh } => { + let title = match paths.as_slice() { + [p] => { + let size = std::fs::metadata(p).ok().filter(|m| m.is_file()).map(|m| format!(" ({})", widgets::human_size(m.len()))); + format!("{}{}", local_name(p), size.unwrap_or_default()) + } + _ => format!("{} files", paths.len()), + }; + let (mut paste, mut upload) = (false, false); + modal(ctx, "sftp-drop", 420.0, |ui| { + ui.label(RichText::new(format!("{} File Drop", icon::FILE_ARROW_UP)).font(theme::semibold(15.0))); + ui.label(RichText::new(title).strong()); + ui.label( + RichText::new(format!("Choose what to do with the dropped {}.", if paths.len() == 1 { "file" } else { "files" })).color(t.text_dim), + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + upload = ui + .add_enabled_ui(*ssh, |ui| widgets::primary(ui, icon::UPLOAD_SIMPLE, "Upload to current directory")) + .inner + .on_disabled_hover_text("Upload is only available for SSH sessions.") + .clicked(); + paste = widgets::button(ui, icon::CLIPBOARD_TEXT, "Paste contents").clicked(); + if ui.button("Cancel").clicked() { + keep = false; + } + }); + }); + if paste { + keep = false; + paste_files(s, session, paths); + } else if upload { + let start = self.panels.get(session.as_str()).map(|p| p.cwd.clone()).filter(|c| !c.is_empty()); + let pk = Picker { + session: session.clone(), + files: std::mem::take(paths), + cwd: start.clone().unwrap_or_default(), + entries: vec![], + cwd_writable: true, + filter: String::new(), + cursor: None, + loading: false, + error: None, + focused: false, + }; + d = Dialog::Picker(pk); + if let Dialog::Picker(pk) = &mut d { + self.picker_load(s, pk, start); + } + } + } + Dialog::Picker(pk) => { + if let Some(dir) = self.picker_ui(ctx, s, pk, &mut keep) { + keep = false; + let files = std::mem::take(&mut pk.files); + if files.len() > 1 { + s.info(format!("Uploading {} files…", files.len())); + } + let session = pk.session.clone(); + self.upload(s, &session, &dir, files); + } + } + } + if keep && self.dialog.is_none() { + self.dialog = Some(d); + } + } + + fn picker_load(&self, s: &Shared, pk: &mut Picker, path: Option) { + if pk.loading { + return; + } + pk.loading = true; + let sid = pk.session.clone(); + self.work(s, move |svc, out| { + let result = (|| -> Result<_, String> { + let path = match path.filter(|p| !p.is_empty()) { + Some(p) => p, + None => start_dir(svc, &sid)?, + }; + let files = svc.sftp_list(&sid, &path).map_err(|e| e.to_string())?; + let sftp = svc.sessions.ssh(&sid).and_then(|c| c.sftp()).map_err(|e| e.to_string())?; + let st = rsftp::stat(&sftp, &path).map_err(|e| e.to_string())?; + Ok((path, files, can_write(&st.mode))) + })(); + out.push(Done::Picker { session: sid, result }); + }); + } + + /// Draws the directory picker; returns the chosen directory. + fn picker_ui(&self, ctx: &egui::Context, s: &Shared, pk: &mut Picker, keep: &mut bool) -> Option { + let t = Tokens::get(ctx); + let filter = pk.filter.trim().to_lowercase(); + let visible: Vec = (0..pk.entries.len()).filter(|&i| pk.entries[i].name.to_lowercase().contains(&filter)).collect(); + pk.cursor = pk.cursor.filter(|&c| c < visible.len()); + let sel = pk.cursor.map(|c| &pk.entries[visible[c]]); + let (target, writable) = match sel { + Some(e) => (e.path.clone(), can_write(&e.mode)), + None => (pk.cwd.clone(), pk.cwd_writable), + }; + let ready = !pk.loading && pk.error.is_none(); + let mut chosen = None; + let mut load: Option> = None; + + // Keyboard: ↑↓ move (skipping unreadable folders), Enter uses, Backspace goes up. + if ready { + let (down, up, back) = ctx + .input_mut(|i| (i.consume_key(Modifiers::NONE, Key::ArrowDown), i.consume_key(Modifiers::NONE, Key::ArrowUp), i.key_pressed(Key::Backspace))); + let delta: isize = if down { + 1 + } else if up { + -1 + } else { + 0 + }; + if delta != 0 && !visible.is_empty() { + let n = visible.len() as isize; + let mut idx = pk.cursor.map_or(if delta > 0 { -1 } else { n }, |c| c as isize); + for _ in 0..n { + idx = (idx + delta).rem_euclid(n); + if can_read(&pk.entries[visible[idx as usize]].mode) { + pk.cursor = Some(idx as usize); + break; + } + } + } + if ctx.input(|i| i.key_pressed(Key::Enter)) && writable && !target.is_empty() { + chosen = Some(target.clone()); + } + if back && pk.filter.is_empty() && !pk.cwd.is_empty() && pk.cwd != "/" { + load = Some(Some(parent(&pk.cwd))); + } + } + + modal(ctx, "sftp-picker", 580.0, |ui| { + ui.style_mut().interaction.selectable_labels = false; + ui.horizontal(|ui| { + ui.label(RichText::new(format!("{} Select Upload Directory", icon::FOLDER_OPEN)).font(theme::semibold(15.0))); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::icon_button(ui, icon::X, "Close").clicked() { + *keep = false; + } + }); + }); + if let Some(p) = breadcrumb(ui, &pk.cwd) { + load = Some(Some(p)); + } + ui.horizontal(|ui| { + let r = ui.add(egui::TextEdit::singleline(&mut pk.filter).hint_text("Filter folders").desired_width(260.0)); + if !pk.focused { + r.request_focus(); + pk.focused = true; + } + if r.changed() { + pk.cursor = None; + } + if widgets::button(ui, icon::ARROW_UP, "Up").clicked() && !pk.cwd.is_empty() { + load = Some(Some(parent(&pk.cwd))); + } + if widgets::button(ui, icon::ARROWS_CLOCKWISE, "Refresh").clicked() { + load = Some(Some(pk.cwd.clone())); + } + }); + ui.separator(); + let has_parent = !pk.cwd.is_empty() && pk.cwd != "/"; + let off = has_parent as usize; + ui.allocate_ui(vec2(ui.available_width(), 300.0), |ui| { + ui.set_min_height(300.0); + if !ready { + if state_line(ui, pk.loading, pk.error.as_deref()) { + load = Some(Some(pk.cwd.clone())); + } + return; + } + if visible.is_empty() && !has_parent { + widgets::empty(ui, icon::FOLDER_DASHED, "No folders found", ""); + return; + } + let n = visible.len() + off; + let clicked = widgets::rows(ui, "sftp-picker-rows", n.max(1), 24.0, pk.cursor.map(|c| c + off), |ui, i| { + if i < off { + ui.label(RichText::new(icon::ARROW_BEND_UP_LEFT).size(14.0).color(t.text_dim)); + ui.label(RichText::new("..").color(t.text_dim)); + return; + } + let Some(&ei) = visible.get(i - off) else { + widgets::hint(ui, "No folders found"); + return; + }; + let e = &pk.entries[ei]; + let (r, w) = (can_read(&e.mode), can_write(&e.mode)); + let dim = if r { t.text } else { t.text_faint }; + ui.label(RichText::new(icon::FOLDER).size(14.0).color(if r { t.accent } else { t.text_faint })); + ui.label(RichText::new(&e.name).color(dim)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if !r { + widgets::chip(ui, "NO READ", t.danger); + } else if !w { + widgets::chip(ui, "READ ONLY", t.warning); + } + }); + }); + if let Some(i) = clicked { + let dbl = ui.input(|inp| inp.pointer.button_double_clicked(PointerButton::Primary)); + if i < off { + if dbl { + load = Some(Some(parent(&pk.cwd))); + } + } else if let Some(&ei) = visible.get(i - off) + && can_read(&pk.entries[ei].mode) + { + if dbl { + load = Some(Some(pk.entries[ei].path.clone())); + } else { + pk.cursor = Some(i - off); + } + } + } + }); + ui.separator(); + ui.horizontal(|ui| { + let suffix = if sel.is_none() && !pk.cwd_writable { " (read only)" } else { "" }; + ui.add(egui::Label::new(RichText::new(format!("{target}{suffix}")).font(theme::mono(11.5)).color(t.text_dim)).truncate()) + .on_hover_text(&target); + let status = match (sel.is_some(), writable) { + (_, true) => "", + (true, false) => "No write permission", + (false, false) => "Current folder is read only", + }; + if !status.is_empty() { + ui.label(RichText::new(status).size(11.5).color(t.warning)); + } + }); + ui.horizontal(|ui| { + widgets::chip(ui, "READ ONLY", t.warning); + widgets::chip(ui, "NO READ", t.danger); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let ok = ui + .add_enabled_ui(ready && writable && !target.is_empty(), |ui| widgets::primary(ui, icon::CHECK, "Use this folder")) + .inner + .on_disabled_hover_text("No write permission"); + if ok.clicked() { + chosen = Some(target.clone()); + } + if ui.button("Cancel").clicked() { + *keep = false; + } + }); + }); + }); + if let Some(path) = load { + self.picker_load(s, pk, path); + } + chosen + } +} + +/// The directory a browser starts in: the shell's cwd, else the SFTP home. +fn start_dir(svc: &Service, sid: &str) -> Result { + match svc.sessions.cwd(sid) { + Ok(c) if c.trim().starts_with('/') => Ok(c.trim().to_string()), + _ => svc.sftp_pwd(sid).map_err(|e| e.to_string()), + } +} + +/// "Paste contents": the dropped text files, joined by newlines, as a terminal paste. +fn paste_files(s: &mut Shared, session: &str, paths: &[PathBuf]) { + let mut combined = String::new(); + for p in paths { + match std::fs::read_to_string(p) { + Ok(text) if !text.is_empty() => { + if !combined.is_empty() { + combined.push('\n'); + } + combined.push_str(&text); + } + Ok(_) => {} + Err(_) => { + s.error("Failed to read file contents."); + return; + } + } + } + if combined.is_empty() { + return; + } + if let Some(View::Terminal(term)) = s.panes.get_mut(session).map(|p| &mut p.view) { + let mut out = crate::terminal::TermResponse::default(); + term.paste(&combined, &mut out); + s.request(Request::Write(session.into(), out.input)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths() { + assert_eq!(parent("/a/b/"), "/a"); + assert_eq!(parent("/a"), "/"); + assert_eq!(parent("/"), "/"); + let segs: Vec = segments("/home/me").into_iter().map(|s| s.1).collect(); + assert_eq!(segs, ["/", "/home", "/home/me"]); + assert!(can_read("dr-x------") && !can_write("dr-x------")); + assert!(!can_read("d-wx--x--x") && can_write("")); + } +} diff --git a/rust/app/src/sidebar.rs b/rust/app/src/sidebar.rs new file mode 100644 index 0000000..2997b17 --- /dev/null +++ b/rust/app/src/sidebar.rs @@ -0,0 +1,331 @@ +//! The connection sidebar: Local Machine, Favourites, Recent, folders, Ungrouped; fuzzy search; +//! click to connect, Ctrl/Cmd-click to multi-select, double-click to edit; bulk actions. + +use crate::app::{App, Dialog, InputThen}; +use crate::chrome::fuzzy_score; +use crate::state::Request; +use crate::theme::{self, Tokens}; +use crate::views::{protocol_badge, protocol_color}; +use crate::widgets; +use egui::{Align2, Rect, RichText, Sense, Ui, vec2}; +use egui_phosphor::regular as icon; +use nexus_core::models::Profile; +use std::collections::HashSet; + +#[derive(Default)] +pub struct Sidebar { + pub search: String, + pub collapsed: HashSet, + pub selected: HashSet, +} + +const RECENT: usize = 5; + +pub fn ui(app: &mut App, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.add_space(8.0); + ui.label(RichText::new(icon::MAGNIFYING_GLASS).color(t.text_faint)); + ui.add(egui::TextEdit::singleline(&mut app.sidebar.search).hint_text("Search connections").desired_width(ui.available_width() - 10.0)); + }); + ui.add_space(4.0); + + // Footer actions, pinned to the bottom of the sidebar. + egui::Panel::bottom("sidebar-footer").exact_size(36.0).frame(egui::Frame::NONE.fill(t.dock)).show(ui, |ui| { + ui.horizontal_centered(|ui| { + ui.add_space(6.0); + if widgets::icon_button(ui, icon::PLUS, "New connection (Ctrl/Cmd+N)").clicked() { + app.s.request(Request::NewProfile { protocol: None, folder: String::new() }); + } + if widgets::icon_button(ui, icon::COMMAND, "Command palette (Ctrl/Cmd+K)").clicked() { + app.palette = Some(Default::default()); + } + if widgets::icon_button(ui, icon::DOWNLOAD_SIMPLE, "Import profiles").clicked() { + app.s.request(Request::ImportEncrypted); + } + if widgets::icon_button(ui, icon::UPLOAD_SIMPLE, "Export profiles").clicked() { + app.s.request(Request::ExportEncrypted(vec![])); + } + if widgets::icon_button(ui, icon::FILM_SLATE, "Recordings").clicked() { + app.recordings.open(); + } + if widgets::icon_button(ui, icon::GEAR, "Settings (Ctrl/Cmd+,)").clicked() { + app.settings.open(crate::state::SettingsTab::General); + } + }); + }); + if !app.sidebar.selected.is_empty() { + egui::Panel::bottom("sidebar-bulk").frame(egui::Frame::NONE.fill(t.dock).inner_margin(6)).show(ui, |ui| bulk_bar(app, ui)); + } + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| list(app, ui)); +} + +fn list(app: &mut App, ui: &mut Ui) { + let q = app.sidebar.search.trim().to_string(); + // Search: one ranked list. + if !q.is_empty() { + let mut hits: Vec<(i32, Profile)> = app + .s + .profiles + .iter() + .filter_map(|p| { + let fields = format!("{} {} {} {}", p.display_name, p.host, p.tags.join(" "), p.protocol.id()); + fuzzy_score(&fields, &q).map(|s| (s, p.clone())) + }) + .collect(); + hits.sort_by_key(|h| std::cmp::Reverse(h.0)); + if hits.is_empty() { + widgets::empty(ui, icon::MAGNIFYING_GLASS, "No matches", "Try a host, name, tag or protocol."); + } + for (_, p) in hits { + row(app, ui, &p, "search"); + } + return; + } + + local_row(app, ui); + let profiles = app.s.profiles.clone(); + let favs: Vec<&Profile> = profiles.iter().filter(|p| p.favourite).collect(); + if !favs.is_empty() { + section(app, ui, "★ Favourites", "favourites", &favs); + } + let mut recent: Vec<&Profile> = profiles.iter().filter(|p| p.last_connected.is_some()).collect(); + recent.sort_by_key(|p| std::cmp::Reverse(p.last_connected)); + recent.truncate(RECENT); + if !recent.is_empty() { + section(app, ui, "Recent", "recent", &recent); + } + let mut folders: std::collections::BTreeMap> = Default::default(); + let mut ungrouped = vec![]; + for p in &profiles { + let f = p.folder.trim(); + if f.is_empty() { + ungrouped.push(p); + } else { + folders.entry(f.to_string()).or_default().push(p); + } + } + let mut names: Vec<&String> = folders.keys().collect(); + names.sort_by_key(|a| a.to_lowercase()); + let has_groups = !names.is_empty(); + for f in names { + section(app, ui, f, &format!("group:{f}"), &folders[f]); + } + if !ungrouped.is_empty() { + section(app, ui, if has_groups { "Ungrouped" } else { "All Connections" }, "ungrouped", &ungrouped); + } + if profiles.is_empty() { + widgets::empty(ui, icon::PLUGS, "No connections yet", "Create one with the + button below."); + ui.vertical_centered(|ui| { + if widgets::primary(ui, icon::PLUS, "New connection").clicked() { + app.s.request(Request::NewProfile { protocol: None, folder: String::new() }); + } + }); + } +} + +fn header(app: &mut App, ui: &mut Ui, title: &str, key: &str, count: usize) -> bool { + let t = Tokens::get(ui.ctx()); + let folded = app.sidebar.collapsed.contains(key); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + let c = if resp.hovered() { t.text_dim } else { t.text_faint }; + let caret = if folded { icon::CARET_RIGHT } else { icon::CARET_DOWN }; + ui.painter().text(r.left_center() + vec2(10.0, 0.0), Align2::LEFT_CENTER, caret, egui::FontId::proportional(10.0), c); + ui.painter().text(r.left_center() + vec2(24.0, 0.0), Align2::LEFT_CENTER, title.to_uppercase(), theme::semibold(10.5), c); + ui.painter().text(r.right_center() - vec2(10.0, 0.0), Align2::RIGHT_CENTER, count.to_string(), egui::FontId::proportional(10.5), t.text_faint); + if resp.clicked() { + if folded { + app.sidebar.collapsed.remove(key); + } else { + app.sidebar.collapsed.insert(key.into()); + } + } + // Right-click a folder to add a connection to it. + if let Some(folder) = key.strip_prefix("group:") { + resp.context_menu(|ui| { + if ui.button(format!("{} New connection in {folder}", icon::PLUS)).clicked() { + app.s.request(Request::NewProfile { protocol: None, folder: folder.into() }); + ui.close(); + } + }); + } + !folded +} + +fn section(app: &mut App, ui: &mut Ui, title: &str, key: &str, list: &[&Profile]) { + ui.add_space(4.0); + if header(app, ui, title, key, list.len()) { + for p in list { + row(app, ui, p, key); + } + } +} + +fn local_row(app: &mut App, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); + let r = r.shrink2(vec2(6.0, 1.0)); + if resp.hovered() { + ui.painter().rect_filled(r, 6.0, t.hover); + } + ui.painter().text( + r.left_center() + vec2(10.0, 0.0), + Align2::LEFT_CENTER, + icon::TERMINAL_WINDOW, + egui::FontId::proportional(15.0), + crate::views::protocol_color(nexus_core::models::Protocol::Local), + ); + ui.painter().text(r.left_center() + vec2(32.0, 0.0), Align2::LEFT_CENTER, "Local Machine", egui::FontId::proportional(13.0), t.text); + if resp.on_hover_text("Open a terminal on this computer").clicked() { + app.connect_local(); + } +} + +fn row(app: &mut App, ui: &mut Ui, p: &Profile, section: &str) { + let t = Tokens::get(ui.ctx()); + let selected = app.sidebar.selected.contains(&p.id); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); + let r = r.shrink2(vec2(6.0, 1.0)); + if selected { + ui.painter().rect_filled(r, 6.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 6.0, t.hover); + } + let live = app.s.panes.values().any(|pn| pn.info.profile_id == p.id); + let dot = Rect::from_center_size(r.left_center() + vec2(12.0, 0.0), vec2(8.0, 8.0)); + let col = protocol_color(p.protocol); + if live { + ui.painter().circle_filled(dot.center(), 4.0, col); + } else { + ui.painter().circle_stroke(dot.center(), 3.5, egui::Stroke::new(1.5, col)); + } + let name = if p.display_name.is_empty() { &p.host } else { &p.display_name }; + let text_r = Rect::from_min_max(r.min + vec2(24.0, 0.0), r.max - vec2(46.0, 0.0)); + let painter = ui.painter().with_clip_rect(text_r); + painter.text(text_r.left_top() + vec2(0.0, 3.0), Align2::LEFT_TOP, name, egui::FontId::proportional(13.0), t.text); + let sub = if p.username.is_empty() || p.protocol == nexus_core::models::Protocol::Serial { p.host.clone() } else { format!("{}@{}", p.username, p.host) }; + painter.text(text_r.left_bottom() - vec2(0.0, 3.0), Align2::LEFT_BOTTOM, sub, egui::FontId::proportional(11.0), t.text_faint); + ui.painter().text(r.right_center() - vec2(8.0, 0.0), Align2::RIGHT_CENTER, protocol_badge(p.protocol), theme::semibold(9.0), col); + if p.favourite { + ui.painter().text(r.right_center() - vec2(36.0, 0.0), Align2::RIGHT_CENTER, "★", egui::FontId::proportional(11.0), t.warning); + } + let tip = format!( + "{}\n{} {}:{}{}", + name, + protocol_badge(p.protocol), + p.host, + p.port, + if p.description.is_empty() { String::new() } else { format!("\n{}", p.description) } + ); + let resp = resp.on_hover_text(tip); + let multi = ui.input(|i| i.modifiers.command); + if resp.double_clicked() { + app.s.request(Request::EditProfile(p.id.clone())); + } else if resp.clicked() { + if multi { + if !app.sidebar.selected.remove(&p.id) { + app.sidebar.selected.insert(p.id.clone()); + } + } else { + app.sidebar.selected.clear(); + app.connect_profile(&p.id, false); + } + } + resp.context_menu(|ui| { + let _ = section; + if ui.button(format!("{} Connect", icon::PLUGS_CONNECTED)).clicked() { + app.connect_profile(&p.id, false); + ui.close(); + } + if ui.button(format!("{} Edit", icon::PENCIL_SIMPLE)).clicked() { + app.s.request(Request::EditProfile(p.id.clone())); + ui.close(); + } + if ui.button(format!("{} Duplicate", icon::COPY)).clicked() { + match app.s.svc.duplicate_profile(&p.id) { + Ok(d) => app.s.success(format!("Created {}", d.display_name)), + Err(e) => app.s.error(e.to_string()), + } + app.s.reload_profiles(); + ui.close(); + } + let fav = if p.favourite { "Remove from Favourites" } else { "Add to Favourites" }; + if ui.button(format!("{} {fav}", icon::STAR)).clicked() { + let _ = app.s.svc.set_favourite(&p.id, !p.favourite); + app.s.reload_profiles(); + ui.close(); + } + if ui.button(format!("{} Export…", icon::UPLOAD_SIMPLE)).clicked() { + app.s.request(Request::ExportEncrypted(vec![p.id.clone()])); + ui.close(); + } + ui.separator(); + if ui.button(RichText::new(format!("{} Delete", icon::TRASH)).color(t.danger)).clicked() { + app.dialog = Some(Dialog::Delete { profiles: vec![p.id.clone()] }); + ui.close(); + } + }); +} + +fn bulk_bar(app: &mut App, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let ids: Vec = app.sidebar.selected.iter().cloned().collect(); + egui::Frame::NONE.fill(t.card).corner_radius(6).inner_margin(6).show(ui, |ui| { + ui.label(RichText::new(format!("{} selected", ids.len())).size(11.5).color(t.text_dim)); + ui.horizontal_wrapped(|ui| { + if ui.small_button(format!("{} Connect all", icon::PLUGS_CONNECTED)).clicked() { + for id in &ids { + app.connect_profile(id, true); + } + app.sidebar.selected.clear(); + } + if ui.small_button(format!("{} Disconnect", icon::PLUGS)).clicked() { + let sessions: Vec = app.s.panes.values().filter(|p| ids.contains(&p.info.profile_id)).map(|p| p.info.id.clone()).collect(); + if sessions.is_empty() { + app.s.info("None of the selected connections are open"); + } else if app.s.prefs.confirm_on_disconnect { + app.dialog = Some(Dialog::ConfirmDisconnectMany { sessions }); + } else { + for s in sessions { + app.close_session(&s); + } + } + } + ui.menu_button(format!("{} Tags", icon::TAG), |ui| { + if ui.button("Add tags…").clicked() { + app.dialog = Some(Dialog::Input { + title: "Add tags".into(), + label: "Comma-separated tags to add".into(), + value: String::new(), + then: InputThen::Tags { profiles: ids.clone(), replace: false }, + }); + ui.close(); + } + if ui.button("Replace tags…").clicked() { + app.dialog = Some(Dialog::Input { + title: "Replace tags".into(), + label: "Comma-separated tags".into(), + value: String::new(), + then: InputThen::Tags { profiles: ids.clone(), replace: true }, + }); + ui.close(); + } + }); + if ui.small_button(format!("{} Move group", icon::FOLDER)).clicked() { + app.dialog = Some(Dialog::Input { + title: "Move to group".into(), + label: "Group name (empty for Ungrouped)".into(), + value: String::new(), + then: InputThen::MoveGroup { profiles: ids.clone() }, + }); + } + if ui.small_button(format!("{} Delete", icon::TRASH)).clicked() { + app.dialog = Some(Dialog::Delete { profiles: ids.clone() }); + } + if ui.small_button("Clear").clicked() { + app.sidebar.selected.clear(); + } + }); + }); +} diff --git a/rust/app/src/snippets.rs b/rust/app/src/snippets.rs new file mode 100644 index 0000000..e118817 --- /dev/null +++ b/rust/app/src/snippets.rs @@ -0,0 +1,387 @@ +//! Snippet library window and the {{variable}} prompt (port of snippets.js). + +use crate::state::{Request, Shared}; +use crate::theme::{self, Tokens}; +use crate::widgets; +use egui::{Key, RichText}; +use egui_phosphor::regular as icon; +use nexus_core::models::{SessionState, Snippet}; +use regex::Regex; +use std::collections::BTreeMap; +use std::sync::LazyLock; + +static VAR_RE: LazyLock = LazyLock::new(|| Regex::new(r"\{\{([^}]+)\}\}").unwrap()); + +/// Replaces built-ins (`hostname`, `date`, `time`, `timestamp`, case-insensitive) and returns the +/// remaining user variable names, de-duplicated in order of appearance. +pub fn expand_builtins(cmd: &str, hostname: &str) -> (String, Vec) { + let now = chrono::Local::now(); + let mut vars: Vec = vec![]; + let out = VAR_RE.replace_all(cmd, |c: ®ex::Captures| { + let name = c[1].trim(); + let v = match name.to_lowercase().as_str() { + "hostname" => Some(if hostname.is_empty() { "unknown".to_owned() } else { hostname.to_owned() }), + // Same formats as JS: toISOString() date (UTC), toTimeString() HH:MM:SS (local), epoch secs. + "date" => Some(now.with_timezone(&chrono::Utc).format("%Y-%m-%d").to_string()), + "time" => Some(now.format("%H:%M:%S").to_string()), + "timestamp" => Some(now.timestamp().to_string()), + _ => None, + }; + v.unwrap_or_else(|| { + if !vars.iter().any(|x| x == name) { + vars.push(name.to_owned()); + } + c[0].to_owned() + }) + }); + (out.into_owned(), vars) +} + +/// Fills user variables (`vars[i]` ← `values[i]`) left by `expand_builtins`. +pub fn fill(resolved: &str, vars: &[String], values: &[String]) -> String { + VAR_RE + .replace_all(resolved, |c: ®ex::Captures| match vars.iter().position(|v| v == c[1].trim()) { + Some(i) => values.get(i).cloned().unwrap_or_default(), + None => c[0].to_owned(), + }) + .into_owned() +} + +enum Pane { + Empty(&'static str), + Detail(String), + Edit(Snippet), +} + +struct VarForm { + resolved: String, + vars: Vec, + values: Vec, +} + +pub struct Snippets { + open: bool, + list: Vec, + search: String, + pane: Pane, + confirm_delete: Option, + vars: Option, + loaded: bool, +} + +impl Default for Snippets { + fn default() -> Self { + Snippets { + open: false, + list: vec![], + search: String::new(), + pane: Pane::Empty("Select a snippet or create a new one"), + confirm_delete: None, + vars: None, + loaded: false, + } + } +} + +impl Snippets { + pub fn open(&mut self) { + self.open = true; + self.loaded = false; + self.pane = Pane::Empty("Select a snippet or create a new one"); + } + + /// Called every frame; draws its window(s) while open. + pub fn ui(&mut self, ctx: &egui::Context, s: &mut Shared) { + self.var_form(ctx, s); + self.delete_dialog(ctx, s); + if !self.open { + return; + } + if !self.loaded { + self.list = s.svc.snippets(); + self.loaded = true; + } + let mut open = self.open; + egui::Window::new(format!("{} Snippets", icon::CODE)) + .id(egui::Id::new("snippets")) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) + .open(&mut open) + .collapsible(false) + .default_size([720.0, 440.0]) + .show(ctx, |ui| { + ui.horizontal_top(|ui| { + let h = ui.available_height().max(400.0); + ui.allocate_ui(egui::vec2(300.0, h), |ui| { + ui.set_width(300.0); + ui.vertical(|ui| self.list_ui(ui)); + }); + ui.separator(); + ui.vertical(|ui| { + ui.set_min_height(h); + self.pane_ui(ui, s); + }); + }); + }); + self.open = open; + } + + fn list_ui(&mut self, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut self.search).hint_text("Search snippets…").desired_width(200.0)); + if widgets::primary(ui, icon::PLUS, "New").clicked() { + self.pane = Pane::Edit(blank()); + } + }); + let q = self.search.trim().to_lowercase(); + let mut groups: BTreeMap<&str, Vec<&Snippet>> = BTreeMap::new(); + for sn in &self.list { + if q.is_empty() || [&sn.name, &sn.command, &sn.description].iter().any(|f| f.to_lowercase().contains(&q)) { + groups.entry(sn.folder.as_str()).or_default().push(sn); + } + } + if groups.is_empty() { + widgets::empty(ui, icon::CODE, "No snippets found", if q.is_empty() { "Create one with New" } else { "Try another search" }); + return; + } + let selected = match &self.pane { + Pane::Detail(id) => Some(id.clone()), + _ => None, + }; + let mut pick = None; + egui::ScrollArea::vertical().id_salt("snippet-list").auto_shrink([false, false]).show(ui, |ui| { + // Ungrouped ("") sorts first, folders alphabetically after. + for (folder, items) in groups { + if !folder.is_empty() { + widgets::section(ui, &format!("{} {folder}", icon::FOLDER)); + } + for sn in items { + let sel = selected.as_deref() == Some(sn.id.as_str()); + let r = egui::Frame::NONE + .fill(if sel { t.row_selected } else { egui::Color32::TRANSPARENT }) + .corner_radius(t.radius) + .inner_margin(egui::Margin::symmetric(6, 4)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate); + ui.label(RichText::new(&sn.name).font(theme::semibold(13.0))); + ui.label(RichText::new(&sn.command).font(theme::mono(11.5)).color(t.text_dim)); + }) + .response + .interact(egui::Sense::click()); + if r.hovered() && !sel { + ui.painter().rect_filled(r.rect, t.radius, t.hover.gamma_multiply(0.4)); + } + if r.clicked() { + pick = Some(sn.id.clone()); + } + } + } + }); + if let Some(id) = pick { + self.pane = Pane::Detail(id); + } + } + + fn pane_ui(&mut self, ui: &mut egui::Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + match &mut self.pane { + Pane::Empty(msg) => { + widgets::empty(ui, icon::TERMINAL_WINDOW, msg, "Snippets are sent to the active session"); + } + Pane::Detail(id) => { + let Some(sn) = self.list.iter().find(|x| &x.id == id).cloned() else { + self.pane = Pane::Empty("Select a snippet or create a new one"); + return; + }; + ui.label(RichText::new(&sn.name).font(theme::semibold(15.0))); + if !sn.description.is_empty() { + ui.label(RichText::new(&sn.description).color(t.text_dim)); + } + if !sn.folder.is_empty() { + widgets::chip(ui, &sn.folder, t.accent); + } + egui::Frame::NONE.fill(t.field).corner_radius(t.radius).inner_margin(10).show(ui, |ui| { + ui.set_width(ui.available_width()); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + ui.add(egui::Label::new(RichText::new(&sn.command).font(theme::mono(12.5))).wrap()); + }); + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + if widgets::primary(ui, icon::PAPER_PLANE_RIGHT, "Send to Session").clicked() { + self.send(s, &sn.command); + } + if widgets::button(ui, icon::PENCIL_SIMPLE, "Edit").clicked() { + self.pane = Pane::Edit(sn.clone()); + } + if widgets::button(ui, icon::TRASH, "Delete").clicked() { + self.confirm_delete = Some(sn.id.clone()); + } + }); + } + Pane::Edit(ed) => { + let is_new = ed.id.is_empty(); + ui.label(RichText::new(if is_new { "New Snippet" } else { "Edit Snippet" }).font(theme::semibold(14.0))); + egui::Grid::new("snippet-editor").num_columns(2).spacing([10.0, 6.0]).show(ui, |ui| { + ui.label("Name"); + ui.add(egui::TextEdit::singleline(&mut ed.name).desired_width(f32::INFINITY)); + ui.end_row(); + ui.label("Description"); + ui.add(egui::TextEdit::singleline(&mut ed.description).desired_width(f32::INFINITY)); + ui.end_row(); + ui.label("Command"); + ui.add(egui::TextEdit::multiline(&mut ed.command).font(theme::mono(12.5)).desired_rows(8).desired_width(f32::INFINITY)); + ui.end_row(); + ui.label("Folder"); + ui.add(egui::TextEdit::singleline(&mut ed.folder).hint_text("e.g. Networking, Debugging").desired_width(f32::INFINITY)); + ui.end_row(); + }); + widgets::hint(ui, "Variables: {{hostname}} {{date}} {{time}} {{timestamp}}, or {{anything}} to be asked when sending."); + ui.add_space(6.0); + let (mut save, mut cancel) = (false, false); + ui.horizontal(|ui| { + save = widgets::primary(ui, icon::FLOPPY_DISK, "Save").clicked(); + cancel = ui.button("Cancel").clicked(); + }); + if cancel { + self.pane = if is_new { Pane::Empty("Select a snippet or create a new one") } else { Pane::Detail(ed.id.clone()) }; + } else if save { + let mut sn = ed.clone(); + sn.name = sn.name.trim().to_owned(); + sn.description = sn.description.trim().to_owned(); + sn.folder = sn.folder.trim().to_owned(); + if sn.name.is_empty() { + s.error("Name is required"); + } else if sn.command.is_empty() { + s.error("Command is required"); + } else { + match s.svc.save_snippet(sn) { + Ok(saved) => { + s.info(if is_new { "Snippet created" } else { "Snippet updated" }); + self.list = s.svc.snippets(); + self.pane = Pane::Detail(saved.id); + } + Err(e) => s.error(format!("Failed to save snippet: {e}")), + } + } + } + } + } + } + + /// Resolves variables then types the command (plus newline) into the focused session. + fn send(&mut self, s: &mut Shared, command: &str) { + let Some(id) = s.focused_session() else { + return s.error("No active session"); + }; + if s.session_state(&id) != Some(SessionState::Connected) { + return s.error("Session is not connected"); + } + let host = s.pane(&id).map(|p| p.info.host.clone()).unwrap_or_default(); + let (resolved, vars) = expand_builtins(command, &host); + if vars.is_empty() { + send_text(s, resolved); + } else { + let values = vec![String::new(); vars.len()]; + self.vars = Some(VarForm { resolved, vars, values }); + } + } + + fn var_form(&mut self, ctx: &egui::Context, s: &mut Shared) { + let Some(f) = &mut self.vars else { return }; + let (mut send, mut cancel) = (false, ctx.input(|i| i.key_pressed(Key::Escape))); + crate::chrome::modal(ctx, "snippet-vars", 360.0, |ui| { + ui.label(RichText::new("Snippet Variables").font(theme::semibold(15.0))); + ui.add_space(4.0); + for (i, (name, value)) in f.vars.iter().zip(f.values.iter_mut()).enumerate() { + ui.label(name); + let r = ui.add(egui::TextEdit::singleline(value).hint_text(format!("Enter value for {name}")).desired_width(f32::INFINITY)); + if i == 0 && !ui.ctx().memory(|m| m.focused().is_some()) { + r.request_focus(); + } + } + ui.add_space(8.0); + ui.horizontal(|ui| { + send = widgets::primary(ui, icon::PAPER_PLANE_RIGHT, "Send").clicked() || ui.input(|i| i.key_pressed(Key::Enter)); + cancel |= ui.button("Cancel").clicked(); + }); + }); + if send { + let text = fill(&f.resolved, &f.vars, &f.values); + self.vars = None; + send_text(s, text); + } else if cancel { + self.vars = None; + } + } + + fn delete_dialog(&mut self, ctx: &egui::Context, s: &mut Shared) { + let Some(id) = self.confirm_delete.clone() else { + return; + }; + let name = self.list.iter().find(|x| x.id == id).map(|x| x.name.clone()).unwrap_or_default(); + let (mut yes, mut no) = (false, ctx.input(|i| i.key_pressed(Key::Escape))); + crate::chrome::modal(ctx, "snippet-delete", 360.0, |ui| { + ui.label(RichText::new("Delete snippet?").font(theme::semibold(15.0))); + ui.label(format!("\u{201c}{name}\u{201d} will be permanently deleted.")); + ui.add_space(8.0); + ui.horizontal(|ui| { + yes = widgets::primary(ui, icon::TRASH, "Delete").clicked(); + no |= ui.button("Cancel").clicked(); + }); + }); + if yes { + self.confirm_delete = None; + match s.svc.delete_snippet(&id) { + Ok(()) => { + self.list.retain(|x| x.id != id); + self.pane = Pane::Empty("Snippet deleted"); + } + Err(e) => s.error(format!("Failed to delete: {e}")), + } + } else if no { + self.confirm_delete = None; + } + } +} + +fn blank() -> Snippet { + let now = nexus_core::models::now(); + Snippet { + id: String::new(), + name: String::new(), + command: String::new(), + description: String::new(), + folder: String::new(), + tags: vec![], + created_at: now, + updated_at: now, + } +} + +fn send_text(s: &mut Shared, text: String) { + s.request(Request::SendToActive(text + "\n")); + s.info("Snippet sent"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtins_and_prompted_vars() { + let (out, vars) = expand_builtins("ssh {{ HOSTNAME }} {{user}} {{user}} {{port}} at {{timestamp}}", "box"); + assert!(out.starts_with("ssh box {{user}} {{user}} {{port}} at ")); + assert!(out.rsplit(' ').next().unwrap().parse::().is_ok()); + assert_eq!(vars, ["user", "port"]); + assert_eq!(fill(&out, &vars, &["root".into(), "22".into()]).split(" at ").next().unwrap(), "ssh box root root 22"); + let (out, _) = expand_builtins("{{hostname}} {{date}} {{time}}", ""); + let parts: Vec<&str> = out.split(' ').collect(); + assert_eq!(parts[0], "unknown"); + assert!(chrono::NaiveDate::parse_from_str(parts[1], "%Y-%m-%d").is_ok()); + assert!(chrono::NaiveTime::parse_from_str(parts[2], "%H:%M:%S").is_ok()); + assert_eq!(expand_builtins("echo {not} {{}}", "h"), ("echo {not} {{}}".into(), vec![])); + } +} diff --git a/rust/app/src/splits.rs b/rust/app/src/splits.rs new file mode 100644 index 0000000..a8d3766 --- /dev/null +++ b/rust/app/src/splits.rs @@ -0,0 +1,350 @@ +//! Split panes: a binary tree of sessions inside one tab. A new split starts as a placeholder +//! pane offering Local Machine and the saved connections; ✕ on a pane promotes its sibling. + +use crate::app::App; +use crate::state::Shared; +use crate::theme::Tokens; +use crate::views::protocol_color; +use crate::widgets; +use egui::{Align2, Rect, RichText, Sense, Ui, pos2, vec2}; +use egui_phosphor::regular as icon; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Dir { + /// Side by side (Split Right). + Right, + /// Stacked (Split Down). + Down, +} + +/// A tab's layout. Leaf `None` is the placeholder pane showing the connection picker. +#[derive(Debug)] +pub enum Split { + Leaf(Option), + Node { dir: Dir, ratio: f32, a: Box, b: Box }, +} + +const MIN_PANE: f32 = 100.0; + +impl Split { + pub fn sessions(&self) -> Vec { + match self { + Split::Leaf(Some(s)) => vec![s.clone()], + Split::Leaf(None) => vec![], + Split::Node { a, b, .. } => { + let mut v = a.sessions(); + v.extend(b.sessions()); + v + } + } + } + + pub fn rename(&mut self, from: &str, to: &str) { + match self { + Split::Leaf(Some(s)) if s == from => *s = to.into(), + Split::Node { a, b, .. } => { + a.rename(from, to); + b.rename(from, to); + } + _ => {} + } + } + + fn at(&mut self, path: &[bool]) -> &mut Split { + match (self, path.split_first()) { + (Split::Node { a, b, .. }, Some((side, rest))) => { + if *side { + b.at(rest) + } else { + a.at(rest) + } + } + (s, _) => s, + } + } + + /// Splits the leaf holding `session` (or the first placeholder when `session` is None). + fn split_leaf(&mut self, session: Option<&str>, dir: Dir) -> bool { + match self { + Split::Leaf(s) if s.as_deref() == session => { + let old = std::mem::replace(self, Split::Leaf(None)); + *self = Split::Node { dir, ratio: 0.5, a: Box::new(old), b: Box::new(Split::Leaf(None)) }; + true + } + Split::Node { a, b, .. } => a.split_leaf(session, dir) || b.split_leaf(session, dir), + _ => false, + } + } + + /// Removes the leaf at `path`, replacing its parent with the sibling. + fn remove_at(&mut self, path: &[bool]) { + let Some((last, parent)) = path.split_last() else { + *self = Split::Leaf(None); + return; + }; + let node = self.at(parent); + if let Split::Node { a, b, .. } = std::mem::replace(node, Split::Leaf(None)) { + *node = if *last { *a } else { *b }; + } + } + + fn find(&self, session: &str, path: &mut Vec) -> bool { + match self { + Split::Leaf(Some(s)) => s == session, + Split::Leaf(None) => false, + Split::Node { a, b, .. } => { + path.push(false); + if a.find(session, path) { + return true; + } + path.pop(); + path.push(true); + if b.find(session, path) { + return true; + } + path.pop(); + false + } + } + } + + fn leaves(&self, rect: Rect, path: &mut Vec, out: &mut Vec<(Vec, Option, Rect)>, dividers: &mut Vec<(Vec, Dir, Rect, Rect)>) { + match self { + Split::Leaf(s) => out.push((path.clone(), s.clone(), rect)), + Split::Node { dir, ratio, a, b } => { + let (ra, rb, div) = divide(rect, *dir, *ratio); + dividers.push((path.clone(), *dir, div, rect)); + path.push(false); + a.leaves(ra, path, out, dividers); + path.pop(); + path.push(true); + b.leaves(rb, path, out, dividers); + path.pop(); + } + } + } +} + +fn divide(r: Rect, dir: Dir, ratio: f32) -> (Rect, Rect, Rect) { + const GAP: f32 = 4.0; + match dir { + Dir::Right => { + let x = r.left() + (r.width() - GAP) * ratio; + ( + Rect::from_min_max(r.min, pos2(x, r.bottom())), + Rect::from_min_max(pos2(x + GAP, r.top()), r.max), + Rect::from_min_max(pos2(x, r.top()), pos2(x + GAP, r.bottom())), + ) + } + Dir::Down => { + let y = r.top() + (r.height() - GAP) * ratio; + ( + Rect::from_min_max(r.min, pos2(r.right(), y)), + Rect::from_min_max(pos2(r.left(), y + GAP), r.max), + Rect::from_min_max(pos2(r.left(), y), pos2(r.right(), y + GAP)), + ) + } + } +} + +/// Splits the focused pane of the active tab. +pub fn split_focused(app: &mut App, dir: Dir) { + let Some(tab_id) = app.s.active.clone() else { + return; + }; + let focused = app.s.focused_session().unwrap_or(tab_id.clone()); + let Some(tab) = app.s.tabs.iter_mut().find(|t| t.id == tab_id) else { + return; + }; + match &mut tab.split { + None => tab.split = Some(Split::Node { dir, ratio: 0.5, a: Box::new(Split::Leaf(Some(tab_id.clone()))), b: Box::new(Split::Leaf(None)) }), + Some(sp) => { + if !sp.split_leaf(Some(&focused), dir) { + sp.split_leaf(Some(&tab_id), dir); + } + } + } +} + +/// Takes a split child out of its tab (after its session closed). The tab's own session can be +/// removed too: the next pane takes over the tab. +pub fn remove_session(s: &mut Shared, session: &str) { + for i in 0..s.tabs.len() { + let Some(sp) = &mut s.tabs[i].split else { + continue; + }; + let mut path = vec![]; + if !sp.find(session, &mut path) { + continue; + } + sp.remove_at(&path); + let rest = sp.sessions(); + if let Split::Leaf(Some(only)) = sp { + let only = only.clone(); + s.tabs[i].split = None; + s.tabs[i].id = only; + } else if s.tabs[i].id == session { + match rest.first() { + Some(next) => s.tabs[i].id = next.clone(), + None => { + s.tabs.remove(i); + } + } + } + if s.active.as_deref() == Some(session) { + s.active = s.tabs.get(i.min(s.tabs.len().saturating_sub(1))).map(|t| t.id.clone()); + } + return; + } +} + +pub fn render(app: &mut App, ui: &mut Ui, tab_id: &str) { + let t = Tokens::get(ui.ctx()); + let area = ui.available_rect_before_wrap(); + let (mut leaves, mut dividers) = (vec![], vec![]); + if let Some(sp) = app.s.tab(tab_id).and_then(|tb| tb.split.as_ref()) { + sp.leaves(area, &mut vec![], &mut leaves, &mut dividers); + } + // Dividers: drag to resize, never squeezing a pane under MIN_PANE. + for (path, dir, div, total) in dividers { + let resp = ui.interact(div.expand(2.0), ui.id().with(("divider", &path)), Sense::drag()); + let c = if resp.hovered() || resp.dragged() { t.accent } else { t.separator }; + ui.painter().rect_filled(div, 0.0, c); + if resp.hovered() { + ui.ctx().set_cursor_icon(if dir == Dir::Right { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); + } + if resp.dragged() + && let Some(p) = resp.interact_pointer_pos() + { + let (pos, start, len) = match dir { + Dir::Right => (p.x, total.left(), total.width()), + Dir::Down => (p.y, total.top(), total.height()), + }; + let min = (MIN_PANE / len).min(0.45); + let ratio = ((pos - start) / len).clamp(min, 1.0 - min); + if let Some(Split::Node { ratio: r, .. }) = app.s.tabs.iter_mut().find(|tb| tb.id == tab_id).and_then(|tb| tb.split.as_mut()).map(|sp| sp.at(&path)) + { + *r = ratio; + } + } + } + let mut close: Option<(Vec, Option)> = None; + for (path, session, rect) in leaves { + let head = Rect::from_min_size(rect.min, vec2(rect.width(), 22.0)); + let body = Rect::from_min_max(pos2(rect.left(), head.bottom()), rect.max); + let focused = session.is_some() && app.s.focused == session; + ui.painter().rect_filled(head, 0.0, if focused { t.card } else { t.tab_strip }); + let title = session + .as_deref() + .and_then(|s| app.s.pane(s)) + .map(|p| (p.info.tab_name.clone(), protocol_color(p.info.protocol))) + .unwrap_or(("New pane".into(), t.text_faint)); + ui.painter().circle_filled(head.left_center() + vec2(10.0, 0.0), 3.5, title.1); + ui.painter().with_clip_rect(head.shrink2(vec2(24.0, 0.0))).text( + head.left_center() + vec2(20.0, 0.0), + Align2::LEFT_CENTER, + title.0, + egui::FontId::proportional(11.5), + if focused { t.text } else { t.text_dim }, + ); + let xr = Rect::from_center_size(head.right_center() - vec2(12.0, 0.0), vec2(16.0, 16.0)); + if widgets::icon_button(&mut ui.new_child(egui::UiBuilder::new().max_rect(xr)), icon::X, "Close pane").clicked() { + close = Some((path.clone(), session.clone())); + } + match session { + Some(id) => { + if ui.interact(head, ui.id().with(("pane-head", &id)), Sense::click()).clicked() { + app.s.focused = Some(id.clone()); + } + crate::tabs::pane(app, ui, &id, body); + } + None => picker(app, ui, tab_id, &path, body), + } + } + if let Some((path, session)) = close { + match session { + Some(id) => app.close_session(&id), + None => { + if let Some(tb) = app.s.tabs.iter_mut().find(|tb| tb.id == tab_id) + && let Some(sp) = &mut tb.split + { + sp.remove_at(&path); + if let Split::Leaf(Some(_)) = sp { + tb.split = None; + } + } + } + } + } +} + +/// The placeholder pane: pick what to open here. +fn picker(app: &mut App, ui: &mut Ui, tab_id: &str, path: &[bool], rect: Rect) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_filled(rect, 0.0, t.canvas); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(16.0))); + let mut chosen: Option> = None; // Some(None) = local + child.vertical_centered(|ui| { + ui.label(RichText::new("Open in this pane").font(crate::theme::semibold(14.0))); + ui.add_space(6.0); + egui::ScrollArea::vertical().max_height(rect.height() - 60.0).show(ui, |ui| { + if ui.button(format!("{} Local Machine", icon::TERMINAL_WINDOW)).clicked() { + chosen = Some(None); + } + for p in &app.s.profiles { + if !p.protocol.is_terminal() { + continue; + } + let name = if p.display_name.is_empty() { &p.host } else { &p.display_name }; + if ui.button(RichText::new(format!("● {name}")).color(t.text)).clicked() { + chosen = Some(Some(p.id.clone())); + } + } + }); + }); + let Some(choice) = chosen else { return }; + let sid = match choice { + None => app.s.svc.connect_local(), + Some(pid) => match app.s.svc.connect_profile(&pid) { + Ok(s) => s, + Err(e) => { + app.s.error(format!("Connection failed: {e}")); + return; + } + }, + }; + // The pane may have been closed meanwhile; then drop the new session. + let placed = app.s.tabs.iter_mut().find(|tb| tb.id == tab_id).and_then(|tb| tb.split.as_mut()).map(|sp| { + let leaf = sp.at(path); + if matches!(leaf, Split::Leaf(None)) { + *leaf = Split::Leaf(Some(sid.clone())); + true + } else { + false + } + }); + if placed == Some(true) { + app.add_session(&sid, false); + app.s.focused = Some(sid); + } else { + let _ = app.s.svc.sessions.disconnect(&sid); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_and_collapse() { + let mut sp = Split::Node { dir: Dir::Right, ratio: 0.5, a: Box::new(Split::Leaf(Some("a".into()))), b: Box::new(Split::Leaf(None)) }; + assert!(sp.split_leaf(Some("a"), Dir::Down)); + assert_eq!(sp.sessions(), vec!["a".to_string()]); + let mut path = vec![]; + assert!(sp.find("a", &mut path)); + assert_eq!(path, vec![false, false]); + sp.remove_at(&path); + assert!(sp.sessions().is_empty()); + sp.rename("a", "b"); + } +} diff --git a/rust/app/src/ssh_keys.rs b/rust/app/src/ssh_keys.rs new file mode 100644 index 0000000..5be11c8 --- /dev/null +++ b/rust/app/src/ssh_keys.rs @@ -0,0 +1,205 @@ +//! SSH Key Manager window: key pairs in ~/.ssh — copy public key, delete, generate (in-process, OpenSSH format). + +use crate::settings::{Job, poll}; +use crate::state::Shared; +use crate::theme::{self, Tokens}; +use crate::widgets; +use egui::{RichText, vec2}; +use egui_phosphor::regular as icon; +use nexus_core::service::SshKeyInfo; + +struct Generate { + name: String, + kind: &'static str, + bits: u32, + pass: String, + confirm: String, +} + +#[derive(Default)] +pub struct SshKeys { + open: bool, + /// Reload the list on the next frame. + stale: bool, + keys: Vec, + load_error: Option, + confirm_delete: Option, + form: Option, + job: Option>>, +} + +impl SshKeys { + pub fn open(&mut self) { + self.open = true; + self.stale = true; + } + + /// Called every frame; draws its window(s) while open. + pub fn ui(&mut self, ctx: &egui::Context, s: &mut Shared) { + match poll(&mut self.job) { + Some(Ok(name)) => { + s.success(format!("Key \"{name}\" generated successfully")); + self.form = None; + self.stale = true; + } + Some(Err(e)) => s.error(format!("Generation failed: {e}")), + None => {} + } + if !self.open { + return; + } + if std::mem::take(&mut self.stale) { + match s.svc.ssh_key_pairs() { + Ok(k) => (self.keys, self.load_error) = (k, None), + Err(e) => self.load_error = Some(e.to_string()), + } + } + let t = Tokens::get(ctx); + let mut open = true; + egui::Window::new(RichText::new(format!("{} SSH Key Manager", icon::KEY)).font(theme::semibold(14.0))) + .id(egui::Id::new("ssh-keys-window")) + .open(&mut open) + .collapsible(false) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) + .resizable(true) + .default_size([620.0, 460.0]) + .min_size([480.0, 300.0]) + .show(ctx, |ui| { + widgets::section(ui, "Key pairs in ~/.ssh"); + if let Some(e) = &self.load_error { + ui.label(RichText::new(format!("Failed to load keys: {e}")).color(t.danger)); + } else if self.keys.is_empty() { + widgets::empty(ui, icon::KEY, "No SSH keys found", "Generate a key pair below."); + } + let list_h = if self.form.is_some() { 150.0 } else { (ui.available_height() - 40.0).max(120.0) }; + egui::ScrollArea::vertical().id_salt("keys").max_height(list_h).auto_shrink([false, true]).show(ui, |ui| { + for k in &self.keys { + egui::Frame::new().fill(t.field).corner_radius(t.radius).inner_margin(8).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label(RichText::new(&k.name).font(theme::semibold(13.0))); + widgets::chip(ui, &k.kind.to_uppercase(), t.accent); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::icon_button(ui, icon::TRASH, "Delete").clicked() { + self.confirm_delete = Some(k.name.clone()); + } + if widgets::icon_button(ui, icon::COPY, "Copy public key").clicked() { + ui.ctx().copy_text(k.public_key.clone()); + s.success("Public key copied to clipboard"); + } + }); + }); + ui.add(egui::Label::new(RichText::new(&k.public_key).font(theme::mono(11.0)).color(t.text_dim)).truncate()) + .on_hover_text(&k.public_key); + }); + ui.add_space(2.0); + } + }); + ui.add_space(6.0); + match &mut self.form { + None => { + if widgets::primary(ui, icon::PLUS, "Generate New Key").clicked() { + self.form = Some(Generate { name: String::new(), kind: "ed25519", bits: 4096, pass: String::new(), confirm: String::new() }); + } + } + Some(f) => { + if generate_form(ui, f, self.job.is_some()) { + let name = f.name.trim().to_string(); + if name.is_empty() { + s.error("Key name is required"); + } else if f.pass != f.confirm { + s.error("Passphrases do not match"); + } else { + let (svc, kind, bits, pass) = (s.svc.clone(), f.kind, f.bits, f.pass.clone()); + self.job = + Some(Job::spawn(ctx, move || svc.generate_ssh_key(&name, kind, bits, &pass).map(|_| name).map_err(|e| e.to_string()))); + } + } + if self.job.is_none() && ui.button("Cancel").clicked() { + self.form = None; + } + } + } + }); + self.open = open; + self.delete_dialog(ctx, s); + } + + fn delete_dialog(&mut self, ctx: &egui::Context, s: &mut Shared) { + let Some(name) = self.confirm_delete.clone() else { + return; + }; + let t = Tokens::get(ctx); + let mut done = false; + let r = egui::Modal::new(egui::Id::new("delete-key")).show(ctx, |ui| { + ui.set_width(360.0); + ui.label(RichText::new("Delete Key").font(theme::semibold(15.0))); + ui.label(format!("Delete key pair \"{name}\"? This cannot be undone.")); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.add(egui::Button::new(RichText::new(format!("{} Delete", icon::TRASH)).color(t.accent_text)).fill(t.danger)).clicked() { + match s.svc.delete_ssh_key(&name) { + Ok(()) => s.success(format!("Key \"{name}\" deleted")), + Err(e) => s.error(format!("Delete failed: {e}")), + } + self.stale = true; + done = true; + } + if ui.button("Cancel").clicked() { + done = true; + } + }); + }); + if done || r.should_close() { + self.confirm_delete = None; + } + } +} + +/// The generate form; true when Generate is clicked. +fn generate_form(ui: &mut egui::Ui, f: &mut Generate, busy: bool) -> bool { + widgets::section(ui, "Generate new key"); + let mut go = false; + egui::Grid::new("gen-key").num_columns(2).spacing(vec2(14.0, 8.0)).show(ui, |ui| { + ui.label("Key name"); + ui.add(egui::TextEdit::singleline(&mut f.name).hint_text(format!("id_{}_nexus", f.kind)).desired_width(260.0)); + ui.end_row(); + ui.label("Key type"); + let label = |k: &str| match k { + "rsa" => "RSA", + "ecdsa" => "ECDSA", + _ => "Ed25519 (recommended)", + }; + egui::ComboBox::from_id_salt("key-type").selected_text(label(f.kind)).width(260.0).show_ui(ui, |ui| { + for k in ["ed25519", "rsa", "ecdsa"] { + ui.selectable_value(&mut f.kind, k, label(k)); + } + }); + ui.end_row(); + if f.kind == "rsa" { + ui.label("Key size"); + ui.horizontal(|ui| { + ui.selectable_value(&mut f.bits, 4096, "4096"); + ui.selectable_value(&mut f.bits, 2048, "2048"); + }); + ui.end_row(); + } + ui.label("Passphrase"); + ui.add(egui::TextEdit::singleline(&mut f.pass).password(true).hint_text("Leave blank for no passphrase").desired_width(260.0)); + ui.end_row(); + ui.label("Confirm"); + ui.add(egui::TextEdit::singleline(&mut f.confirm).password(true).desired_width(260.0)); + ui.end_row(); + }); + ui.add_space(4.0); + ui.horizontal(|ui| { + if busy { + ui.spinner(); + ui.label("Generating…"); + } else { + go = widgets::primary(ui, icon::KEY, "Generate").clicked(); + } + }); + go +} diff --git a/rust/app/src/state.rs b/rust/app/src/state.rs new file mode 100644 index 0000000..ca153e7 --- /dev/null +++ b/rust/app/src/state.rs @@ -0,0 +1,200 @@ +//! State shared by the main window and every feature module: the service, cached profiles and +//! preferences, open tabs, toasts, and a request queue features use to ask the app to act. + +use crate::views::View; +use nexus_core::models::{AppPreferences, Profile, Protocol, SessionInfo, SessionState}; +use nexus_core::service::Service; +use std::collections::HashSet; +use std::sync::Arc; + +/// Things a feature asks the main window to do (handled once per frame, in order). +#[allow(clippy::large_enum_variant)] // a per-frame queue; boxing buys nothing +pub enum Request { + /// Open the profile editor for a new profile (optionally pre-set protocol / folder). + NewProfile { + protocol: Option, + folder: String, + }, + EditProfile(String), + Connect(String), + ConnectQuick(Profile), + /// Reload `Shared::profiles` from the database. + ReloadProfiles, + /// Re-read and apply preferences (theme, terminal theme, fonts…). + ReloadPreferences, + ExportEncrypted(Vec), + ImportEncrypted, + Sftp(String), + /// Type text into the active session (snippets). + SendToActive(String), + /// Bytes to a specific session. + Write(String, Vec), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum SettingsTab { + #[default] + General, + Terminal, + Security, + KnownHosts, + Help, + About, +} + +pub struct Toast { + pub text: String, + pub kind: ToastKind, + pub until: f64, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ToastKind { + Info, + Success, + Warning, + Error, +} + +/// A session on screen (a tab's own session or a split pane) and how it's drawn. +pub struct Pane { + pub info: SessionInfo, + pub view: View, + pub recording: bool, + /// Local shell exited: Enter restarts it. + pub local_exited: bool, + /// Wall-clock time the session last connected. + pub connected_since: Option>, + /// Bumped to make trigger highlights flash. + pub highlight_until: f64, + /// Serial DTR / RTS as last set (starts from the profile's on-connect settings). + pub lines: (bool, bool), +} + +/// A tab. Its session is `id`; `split` lays out extra panes (which have no tab of their own). +pub struct Tab { + pub id: String, + /// Output arrived while the tab wasn't active. + pub activity: bool, + pub split: Option, +} + +pub struct Shared { + pub svc: Arc, + pub profiles: Vec, + pub prefs: AppPreferences, + pub tabs: Vec, + /// Every on-screen session by id (tabs and split panes). + pub panes: std::collections::HashMap, + /// Session id of the active tab. + pub active: Option, + /// Session id of the focused pane inside the active tab (the tab itself or a split child). + pub focused: Option, + pub toasts: Vec, + pub requests: Vec, + pub broadcast: bool, + /// None = every connected session. + pub broadcast_targets: Option>, + pub now: f64, + pub ctx: egui::Context, +} + +impl Shared { + pub fn new(svc: Arc, ctx: egui::Context) -> Shared { + let prefs = svc.preferences(); + let profiles = svc.profiles(); + Shared { + svc, + profiles, + prefs, + tabs: vec![], + panes: Default::default(), + active: None, + focused: None, + toasts: vec![], + requests: vec![], + broadcast: false, + broadcast_targets: None, + now: 0.0, + ctx, + } + } + + pub fn request(&mut self, r: Request) { + self.requests.push(r); + self.ctx.request_repaint(); + } + + fn push(&mut self, text: impl Into, kind: ToastKind) { + let secs = if kind == ToastKind::Error { 6.0 } else { 4.0 }; + self.toasts.push(Toast { text: text.into(), kind, until: self.now + secs }); + self.ctx.request_repaint(); + } + + pub fn info(&mut self, text: impl Into) { + self.push(text, ToastKind::Info); + } + + pub fn success(&mut self, text: impl Into) { + self.push(text, ToastKind::Success); + } + + pub fn warn(&mut self, text: impl Into) { + self.push(text, ToastKind::Warning); + } + + pub fn error(&mut self, text: impl Into) { + self.push(text, ToastKind::Error); + } + + pub fn profile(&self, id: &str) -> Option<&Profile> { + self.profiles.iter().find(|p| p.id == id) + } + + pub fn reload_profiles(&mut self) { + self.profiles = self.svc.profiles(); + } + + pub fn tab(&self, id: &str) -> Option<&Tab> { + self.tabs.iter().find(|t| t.id == id) + } + + pub fn pane(&self, id: &str) -> Option<&Pane> { + self.panes.get(id) + } + + pub fn pane_mut(&mut self, id: &str) -> Option<&mut Pane> { + self.panes.get_mut(id) + } + + /// The session keyboard actions apply to: the focused pane, else the active tab. + pub fn focused_session(&self) -> Option { + self.focused.clone().or_else(|| self.active.clone()) + } + + /// Every session id on screen (tabs and split panes). + pub fn all_sessions(&self) -> Vec { + self.panes.keys().cloned().collect() + } + + pub fn session_state(&self, id: &str) -> Option { + self.svc.sessions.info(id).map(|i| i.state) + } + + /// Runs `f` on a worker thread and toasts its error, if any (for slow service calls). + pub fn spawn(&self, f: impl FnOnce() -> nexus_core::Result + Send + 'static, done: impl FnOnce(T) + Send + 'static) { + let ctx = self.ctx.clone(); + std::thread::spawn(move || { + match f() { + Ok(v) => done(v), + Err(e) => crate::app::post_error(e.to_string()), + } + ctx.request_repaint(); + }); + } +} + +/// Formats a command-key shortcut for menus: "⌘K" on macOS, "Ctrl+K" elsewhere. +pub fn keys(s: &str) -> String { + if cfg!(target_os = "macos") { s.replace("Mod+Shift+", "⇧⌘").replace("Mod+", "⌘") } else { s.replace("Mod+", "Ctrl+") } +} diff --git a/rust/app/src/tabs.rs b/rust/app/src/tabs.rs new file mode 100644 index 0000000..6bfea48 --- /dev/null +++ b/rust/app/src/tabs.rs @@ -0,0 +1,481 @@ +//! The tab strip and the workspace under it: the active tab's session (or its split panes), +//! terminal find bar and connection overlays. + +use crate::app::{App, Dialog}; +use crate::chrome::{state_color, state_label}; +use crate::state::{Request, Shared}; +use crate::terminal::TermResponse; +use crate::theme::{self, Tokens}; +use crate::views::{View, protocol_badge, protocol_color}; +use crate::widgets; +use egui::{Align2, CornerRadius, Key, Rect, RichText, Sense, Stroke, Ui, pos2, vec2}; +use egui_phosphor::regular as icon; +use nexus_core::models::{Protocol, SessionState}; + +#[derive(Default)] +pub struct TabsUi { + pub find_focus: bool, + drag: Option, +} + +/// Tab ids in display order: pinned first, then the rest, each in insertion order. +pub fn ordered(s: &Shared) -> Vec { + let pinned = |id: &str| s.pane(id).is_some_and(|p| p.info.pinned); + let mut v: Vec = s.tabs.iter().filter(|t| pinned(&t.id)).map(|t| t.id.clone()).collect(); + v.extend(s.tabs.iter().filter(|t| !pinned(&t.id)).map(|t| t.id.clone())); + v +} + +pub fn workspace(app: &mut App, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + egui::Panel::top("tabstrip").exact_size(32.0).frame(egui::Frame::NONE.fill(t.dock)).show(ui, |ui| strip(app, ui)); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| { + let Some(active) = app.s.active.clone().filter(|a| app.s.tab(a).is_some()) else { + empty_state(app, ui); + return; + }; + let has_split = app.s.tab(&active).is_some_and(|tb| tb.split.is_some()); + if has_split { + crate::splits::render(app, ui, &active); + } else { + let rect = ui.available_rect_before_wrap(); + pane(app, ui, &active, rect); + } + }); +} + +fn empty_state(app: &mut App, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let r = ui.max_rect(); + ui.scope_builder(egui::UiBuilder::new().max_rect(Rect::from_center_size(r.center(), vec2(420.0, 420.0))), |ui| { + ui.vertical_centered(|ui| { + ui.add(egui::Image::new(&crate::chrome::logo(ui.ctx())).fit_to_exact_size(vec2(84.0, 84.0))); + ui.label(RichText::new("Nexus Terminal").font(theme::semibold(28.0))); + ui.label(RichText::new("SSH · Telnet · RAW · Serial · VNC · IRC · GitHub").color(t.text_dim)); + ui.add_space(18.0); + ui.horizontal(|ui| { + ui.add_space((ui.available_width() - 330.0).max(0.0) / 2.0); + if widgets::primary(ui, icon::PLUS, "New Connection").clicked() { + app.s.request(Request::NewProfile { protocol: None, folder: String::new() }); + } + if widgets::button(ui, icon::LIGHTNING, "Quick Connect").clicked() { + app.quick.open(); + } + if widgets::button(ui, icon::TERMINAL_WINDOW, "Local").clicked() { + app.connect_local(); + } + }); + ui.add_space(16.0); + let keys = [("Mod+K", "command palette"), ("Mod+N", "new connection"), ("Mod+Shift+Q", "quick connect"), ("Mod+,", "settings")]; + egui::Grid::new("welcome-keys").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + for (k, v) in keys { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| widgets::chip(ui, &crate::state::keys(k), t.text_dim)); + ui.label(RichText::new(v).color(t.text_faint)); + ui.end_row(); + } + }); + }); + }); +} + +fn strip(app: &mut App, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let order = ordered(&app.s); + let mut close = None; + let mut activate = None; + let mut menu_action: Option<(String, &'static str)> = None; + let full = ui.max_rect(); + let right_w = 64.0; + let strip_rect = Rect::from_min_max(full.min, pos2(full.right() - right_w, full.bottom())); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(strip_rect)); + egui::ScrollArea::horizontal().id_salt("tabs-scroll").scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden).show(&mut child, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 1.0; + ui.add_space(4.0); + for id in &order { + let Some(p) = app.s.pane(id) else { continue }; + let p_info = p.info.clone(); + let (p_recording, p_since, lines) = (p.recording, p.connected_since, p.lines); + let p = PaneInfo { info: p_info, recording: p_recording, connected_since: p_since }; + let tab_activity = app.s.tab(id).is_some_and(|tb| tb.activity); + let active = app.s.active.as_deref() == Some(id.as_str()); + let state = app.s.session_state(id).unwrap_or(p.info.state); + let label = p.info.tab_name.clone(); + let font = egui::FontId::proportional(12.5); + let galley = ui.painter().layout_no_wrap(label.clone(), font, if active { t.text } else { t.text_dim }); + let badge = ui.painter().layout_no_wrap(protocol_badge(p.info.protocol).into(), theme::semibold(9.0), protocol_color(p.info.protocol)); + let sftp = p.info.protocol == Protocol::Ssh; + let extra = if p.info.pinned { 16.0 } else { 0.0 } + if sftp { 20.0 } else { 0.0 }; + let w = (galley.size().x + badge.size().x + 66.0 + extra).min(280.0); + let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag()); + let body = Rect::from_min_max(r.min + vec2(0.0, 3.0), r.max); + if active { + ui.painter().rect_filled(body, CornerRadius { nw: 6, ne: 6, sw: 0, se: 0 }, t.canvas); + ui.painter().hline(body.x_range().shrink(6.0), body.top() + 0.5, Stroke::new(2.0, protocol_color(p.info.protocol))); + if active && resp.rect.min.x < strip_rect.min.x || resp.rect.max.x > strip_rect.max.x { + ui.scroll_to_rect(r, None); + } + } else if resp.hovered() { + ui.painter().rect_filled(body, CornerRadius { nw: 6, ne: 6, sw: 0, se: 0 }, t.hover); + } + let mut x = body.left() + 9.0; + ui.painter().circle_filled(pos2(x + 3.0, body.center().y), 3.5, state_color(&t, state)); + x += 12.0; + let br = Rect::from_min_size(pos2(x, body.center().y - badge.size().y / 2.0 - 2.0), badge.size() + vec2(8.0, 4.0)); + ui.painter().rect_filled(br, 4.0, protocol_color(p.info.protocol).gamma_multiply(0.16)); + ui.painter().galley(br.min + vec2(4.0, 2.0), badge, t.text); + x = br.right() + 7.0; + if p.info.pinned { + ui.painter().text(pos2(x, body.center().y), Align2::LEFT_CENTER, icon::PUSH_PIN, egui::FontId::proportional(11.0), t.text_faint); + x += 16.0; + } + let right_controls = 24.0 + if sftp { 20.0 } else { 0.0 }; + let gr = Rect::from_min_max(pos2(x, body.top()), pos2(body.right() - right_controls, body.bottom())); + ui.painter().with_clip_rect(gr).galley(pos2(x, body.center().y - galley.size().y / 2.0), galley, t.text); + if tab_activity && !active { + ui.painter().circle_filled(pos2(body.right() - right_controls - 2.0, body.top() + 7.0), 3.0, t.accent); + } + if p.recording { + ui.painter().circle_filled(pos2(body.left() + 4.0, body.top() + 6.0), 3.0, t.danger); + } + if sftp { + let fr = Rect::from_center_size(pos2(body.right() - 33.0, body.center().y), vec2(16.0, 16.0)); + let fresp = ui.interact(fr, ui.id().with(("sftp", id)), Sense::click()); + let c = if app.sftp.open_for() == Some(id.as_str()) { + t.accent + } else if fresp.hovered() { + t.text + } else { + t.text_faint + }; + ui.painter().text(fr.center(), Align2::CENTER_CENTER, icon::FOLDER_SIMPLE, egui::FontId::proportional(12.0), c); + if fresp.on_hover_text("SFTP browser").clicked() { + app.s.request(Request::Sftp(id.clone())); + } + } + let xr = Rect::from_center_size(pos2(body.right() - 13.0, body.center().y), vec2(16.0, 16.0)); + let xresp = ui.interact(xr, ui.id().with(("close", id)), Sense::click()); + if active || resp.hovered() || xresp.hovered() { + if xresp.hovered() { + ui.painter().rect_filled(xr, 3.0, t.hover); + } + ui.painter().text(xr.center(), Align2::CENTER_CENTER, icon::X, egui::FontId::proportional(11.0), t.text_dim); + } + // Drag to reorder. + if resp.drag_started() { + app.tabs_ui.drag = Some(id.clone()); + } + if let Some(d) = app.tabs_ui.drag.clone() + && d != *id + && resp.contains_pointer() + && ui.input(|i| i.pointer.primary_down()) + { + let from = app.s.tabs.iter().position(|x| x.id == d); + let to = app.s.tabs.iter().position(|x| x.id == *id); + if let (Some(f), Some(to)) = (from, to) { + let tb = app.s.tabs.remove(f); + app.s.tabs.insert(to, tb); + } + } + if xresp.clicked() { + close = Some(id.clone()); + } else if resp.clicked() { + activate = Some(id.clone()); + } + let since = p.connected_since.map(|t| format!("\nConnected since {}", t.format("%H:%M:%S"))).unwrap_or_default(); + let tip = format!("{} — {}\n{}{since}", p.info.tab_name, p.info.host, state_label(state)); + let resp = resp.on_hover_text(tip); + resp.context_menu(|ui| { + for (label, a, glyph) in [ + ("Rename…", "rename", icon::PENCIL_SIMPLE), + ("Duplicate", "duplicate", icon::COPY), + (if p.info.pinned { "Unpin" } else { "Pin" }, "pin", icon::PUSH_PIN), + ("Move Left", "left", icon::ARROW_LEFT), + ("Move Right", "right", icon::ARROW_RIGHT), + ("Reconnect", "reconnect", icon::ARROW_CLOCKWISE), + ("Close", "close", icon::X), + ] { + if matches!(a, "left" | "reconnect" | "close") { + ui.separator(); + } + if ui.button(format!("{glyph} {label}")).clicked() { + menu_action = Some((id.clone(), a)); + ui.close(); + } + } + if matches!(p.info.protocol, Protocol::Telnet | Protocol::Serial) { + ui.separator(); + if ui.button(format!("{} Send Break", icon::LIGHTNING)).clicked() { + menu_action = Some((id.clone(), "break")); + ui.close(); + } + } + if p.info.protocol == Protocol::Serial { + let (d, r) = lines; + if ui.button(format!("{} DTR", if d { icon::CHECK_SQUARE } else { icon::SQUARE })).clicked() { + menu_action = Some((id.clone(), "dtr")); + ui.close(); + } + if ui.button(format!("{} RTS", if r { icon::CHECK_SQUARE } else { icon::SQUARE })).clicked() { + menu_action = Some((id.clone(), "rts")); + ui.close(); + } + } + }); + } + if !ui.input(|i| i.pointer.primary_down()) { + app.tabs_ui.drag = None; + } + }); + }); + // Right cluster: new tab and broadcast. + let rr = Rect::from_min_max(pos2(full.right() - right_w, full.top()), full.max); + let mut right = ui.new_child(egui::UiBuilder::new().max_rect(rr).layout(egui::Layout::left_to_right(egui::Align::Center))); + right.add_space(6.0); + if widgets::icon_button(&mut right, icon::PLUS, "New connection").clicked() { + app.s.request(Request::NewProfile { protocol: None, folder: String::new() }); + } + let b = widgets::icon_button( + &mut right, + icon::BROADCAST, + if app.s.broadcast { "Broadcast ON — click to stop, right-click for targets" } else { "Broadcast input (right-click to pick targets)" }, + ); + if app.s.broadcast { + right.painter().rect_stroke(b.rect, 4.0, Stroke::new(1.5, t.warning), egui::StrokeKind::Inside); + } + if b.clicked() { + app.toggle_broadcast(); + } + if b.secondary_clicked() { + app.pick_broadcast_targets(); + } + + if let Some(id) = activate { + app.activate(&id); + } + if let Some(id) = close { + app.request_close(&id); + } + if let Some((id, a)) = menu_action { + tab_action(app, &id, a); + } +} + +struct PaneInfo { + info: nexus_core::models::SessionInfo, + recording: bool, + connected_since: Option>, +} + +fn tab_action(app: &mut App, id: &str, a: &str) { + match a { + "rename" => { + let name = app.s.pane(id).map(|p| p.info.tab_name.clone()).unwrap_or_default(); + app.dialog = Some(Dialog::Rename { session: id.into(), name }); + } + "duplicate" => { + let Some(p) = app.s.pane(id) else { return }; + if p.info.protocol == Protocol::Local { + app.connect_local(); + } else if let Some(prof) = app.s.svc.sessions.profile(id) { + if app.s.profile(&prof.id).is_some() { + let pid = prof.id.clone(); + app.connect_profile(&pid, true); + } else { + app.s.request(Request::ConnectQuick(prof)); + } + } + } + "pin" => { + if let Some(p) = app.s.pane_mut(id) { + p.info.pinned = !p.info.pinned; + let pinned = p.info.pinned; + let _ = app.s.svc.sessions.pin(id, pinned); + } + } + "left" | "right" => { + if let Some(i) = app.s.tabs.iter().position(|t| t.id == id) { + let j = if a == "left" { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < app.s.tabs.len()) }; + if let Some(j) = j { + app.s.tabs.swap(i, j); + } + } + } + "reconnect" => app.reconnect(id), + "break" => app.send_break(id), + "dtr" => app.toggle_line(id, true), + "rts" => app.toggle_line(id, false), + "close" => app.request_close(id), + _ => {} + } +} + +/// Draws one session into `rect`. +pub fn pane(app: &mut App, ui: &mut Ui, id: &str, rect: Rect) { + let Some(mut pane) = app.s.panes.remove(id) else { + return; + }; + let t = Tokens::get(ui.ctx()); + let theme_id = app.s.prefs.terminal_theme.clone(); + let focused_tab = app.s.focused.as_deref() == Some(id) || (app.s.focused.is_none() && app.s.active.as_deref() == Some(id)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).id_salt(("pane", id))); + child.set_clip_rect(rect.intersect(ui.clip_rect())); + let ui = &mut child; + let mut response = None; + match &mut pane.view { + View::Terminal(term) => { + if term.find.is_some() { + find_bar(app, ui, term); + } + let theme = crate::terminal_themes::get(&theme_id); + let r = term.ui(ui, theme, focused_tab); + if pane.highlight_until > app.s.now { + ui.painter().rect_stroke(rect.shrink(1.0), 0.0, Stroke::new(2.0, t.warning), egui::StrokeKind::Inside); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + } + response = Some(r); + } + View::Raw(v) => v.ui(ui, &mut app.s), + View::Irc(v) => v.ui(ui, &mut app.s), + View::Github(v) => v.ui(ui, &mut app.s), + View::Vnc(v) => v.ui(ui, &mut app.s), + } + let state = app.s.session_state(id).unwrap_or(pane.info.state); + let local_exited = pane.local_exited; + let error = pane.info.error.clone(); + let proto = pane.info.protocol; + app.s.panes.insert(id.into(), pane); + if let Some(r) = response { + terminal_response(app, id, r, local_exited); + } + overlay(app, ui, id, rect, state, &error, local_exited, proto); +} + +fn terminal_response(app: &mut App, id: &str, r: TermResponse, local_exited: bool) { + if r.focused && app.s.focused.as_deref() != Some(id) { + app.s.focused = Some(id.into()); + } + if let Some((c, rows)) = r.resized { + let _ = app.s.svc.sessions.resize(id, c, rows); + } + if !r.reply.is_empty() { + let _ = app.s.svc.sessions.write(id, &r.reply); + } + if local_exited { + if r.input.iter().any(|b| *b == b'\r' || *b == b'\n') { + let size = match app.s.pane(id).map(|p| &p.view) { + Some(View::Terminal(t)) => t.size(), + _ => (80, 24), + }; + if let Err(e) = app.s.svc.sessions.restart_local(id, size.0, size.1) { + app.s.error(e.to_string()); + } else if let Some(p) = app.s.pane_mut(id) { + p.local_exited = false; + } + } + } else if !r.input.is_empty() { + app.send_input(id, &r.input); + } + if r.bell { + app.s.ctx.request_repaint(); + } + if let Some(a) = r.menu { + app.terminal_menu(id, a); + } +} + +#[allow(clippy::too_many_arguments)] +fn overlay(app: &mut App, ui: &mut Ui, id: &str, rect: Rect, state: SessionState, error: &str, local_exited: bool, proto: Protocol) { + let t = Tokens::get(ui.ctx()); + let show = match state { + SessionState::Disconnected => !local_exited && proto != Protocol::Local, + SessionState::Error => true, + SessionState::Reconnecting => true, + SessionState::Connecting => proto != Protocol::Local && !matches!(app.s.pane(id).map(|p| &p.view), Some(View::Terminal(_))), + SessionState::Connected => false, + }; + if !show { + return; + } + let card = Rect::from_center_size(rect.center(), vec2(360.0_f32.min(rect.width() - 20.0), 150.0)); + ui.painter().rect_filled(rect, 0.0, t.canvas.gamma_multiply(0.55)); + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(card)); + egui::Frame::popup(c.style()).corner_radius(10).inner_margin(14).show(&mut c, |ui| { + ui.set_width(card.width() - 28.0); + ui.vertical_centered(|ui| { + let (g, title, col) = match state { + SessionState::Reconnecting => (icon::ARROW_CLOCKWISE, "Reconnecting…", t.warning), + SessionState::Connecting => (icon::PLUGS, "Connecting…", t.warning), + SessionState::Error => (icon::WARNING_CIRCLE, "Connection error", t.danger), + _ => (icon::PLUGS, "Disconnected", t.text_dim), + }; + ui.label(RichText::new(format!("{g} {title}")).font(theme::semibold(15.0)).color(col)); + if !error.is_empty() && state != SessionState::Reconnecting { + ui.add(egui::Label::new(RichText::new(error).color(t.text_dim).size(12.0)).wrap()); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.add_space((ui.available_width() - 200.0).max(0.0) / 2.0); + if state == SessionState::Reconnecting { + if ui.button("Cancel").clicked() { + app.s.svc.sessions.cancel_reconnect(id); + } + } else if state != SessionState::Connecting { + if widgets::primary(ui, icon::ARROW_CLOCKWISE, "Reconnect").clicked() { + app.reconnect(id); + } + if ui.button("Close").clicked() { + app.close_session(id); + } + } + }); + }); + }); +} + +fn find_bar(app: &mut App, ui: &mut Ui, term: &mut crate::terminal::TermView) { + let t = Tokens::get(ui.ctx()); + let (cur, total) = term.find_counts(); + let mut close = false; + let mut step = None; + egui::Frame::NONE.fill(t.card).inner_margin(egui::Margin::symmetric(8, 4)).show(ui, |ui| { + ui.horizontal(|ui| { + let f = term.find.as_mut().unwrap(); + ui.label(RichText::new(icon::MAGNIFYING_GLASS).color(t.text_faint)); + let r = ui.add(egui::TextEdit::singleline(&mut f.query).hint_text("Find").desired_width(220.0)); + if app.tabs_ui.find_focus { + r.request_focus(); + app.tabs_ui.find_focus = false; + } + if r.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) { + step = Some(!ui.input(|i| i.modifiers.shift)); + r.request_focus(); + } + if r.has_focus() && ui.input(|i| i.key_pressed(Key::Escape)) { + close = true; + } + ui.toggle_value(&mut f.case_sensitive, "Aa").on_hover_text("Match case"); + ui.toggle_value(&mut f.regex, ".*").on_hover_text("Regular expression"); + match &f.error { + Some(e) => ui.label(RichText::new(e.lines().last().unwrap_or("bad regex")).color(t.danger).size(11.0)), + None => ui.label(RichText::new(if f.query.is_empty() { String::new() } else { format!("{cur} of {total}") }).size(11.5).color(t.text_dim)), + }; + if widgets::icon_button(ui, icon::ARROW_UP, "Previous (Shift+Enter)").clicked() { + step = Some(false); + } + if widgets::icon_button(ui, icon::ARROW_DOWN, "Next (Enter)").clicked() { + step = Some(true); + } + if widgets::icon_button(ui, icon::X, "Close (Esc)").clicked() { + close = true; + } + }); + }); + if let Some(fwd) = step { + term.find_step(fwd); + } + if close { + term.find = None; + term.request_focus(); + } +} diff --git a/rust/app/src/terminal.rs b/rust/app/src/terminal.rs new file mode 100644 index 0000000..e9fee00 --- /dev/null +++ b/rust/app/src/terminal.rs @@ -0,0 +1,906 @@ +//! TermView: an alacritty_terminal grid drawn with egui's painter. Bytes from a session go +//! straight into the VT parser; keystrokes, paste, mouse reports and terminal replies come back +//! out through `TermResponse` for the app to send to the session (and broadcast targets). + +use crate::terminal_themes::TermTheme; +use alacritty_terminal::event::{Event as TermEvent, EventListener}; +use alacritty_terminal::grid::{Dimensions, Scroll}; +use alacritty_terminal::index::{Column, Line, Point, Side}; +use alacritty_terminal::selection::{Selection, SelectionType}; +use alacritty_terminal::term::cell::Flags; +use alacritty_terminal::term::test::TermSize; +use alacritty_terminal::term::{Config, Term, TermMode}; +use alacritty_terminal::vte::ansi::{self, Color, CursorShape, NamedColor, Processor, StdSyncHandler}; +use egui::{Color32, Event, FontId, Key, Modifiers, PointerButton, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2}; +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Default)] +struct Listener(Arc>>); + +impl EventListener for Listener { + fn send_event(&self, e: TermEvent) { + if matches!(e, TermEvent::PtyWrite(_) | TermEvent::Title(_) | TermEvent::Bell | TermEvent::ClipboardStore(..)) { + self.0.lock().unwrap().push(e); + } + } +} + +/// What the context menu asked for; the app carries these out. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MenuAction { + Copy, + Paste, + SelectAll, + Find, + SendSnippet, + PortForwards, + SplitRight, + SplitDown, + Export, + Clear, + Reset, +} + +#[derive(Default)] +pub struct TermResponse { + /// User input (keys, paste, mouse reports): goes to the session and broadcast targets. + pub input: Vec, + /// Terminal replies (DA, DSR, …): go to this session only. + pub reply: Vec, + pub resized: Option<(u16, u16)>, + pub menu: Option, + pub title: Option, + pub bell: bool, + pub focused: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum CursorStyle { + #[default] + Block, + Underline, + Bar, +} + +impl CursorStyle { + pub fn from_id(s: &str) -> Self { + match s { + "underline" => Self::Underline, + "bar" => Self::Bar, + _ => Self::Block, + } + } +} + +#[derive(Default)] +pub struct Find { + pub query: String, + pub case_sensitive: bool, + pub regex: bool, + /// (grid line, first column, last column inclusive) + pub matches: Vec<(i32, usize, usize)>, + pub current: usize, + pub error: Option, + computed_for: Option<(String, bool, bool, u64)>, +} + +pub struct TermView { + term: Term, + parser: Processor, + events: Listener, + pub font_size: f32, + pub cursor: CursorStyle, + cols: u16, + rows: u16, + drag: Option<(Point, SelectionType)>, + last_click: (f64, u32, Pos2), + wheel_acc: f32, + mouse_down: Option, + /// Bumped on every feed, so find results know when to recompute. + generation: u64, + pub find: Option, + focus_requested: bool, +} + +const URL_RE: &str = r#"(?:https?|ftp)://[^\s<>"'`]+[^\s<>"'`.,;:!?)\]]"#; + +fn rgb(c: [u8; 4]) -> Color32 { + Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]) +} + +impl TermView { + pub fn new(scrollback: usize, font_size: f32, cursor: CursorStyle) -> TermView { + let events = Listener::default(); + let config = Config { scrolling_history: scrollback.min(100_000), ..Config::default() }; + TermView { + term: Term::new(config, &TermSize::new(80, 24), events.clone()), + parser: Processor::new(), + events, + font_size: font_size.clamp(6.0, 48.0), + cursor, + cols: 80, + rows: 24, + drag: None, + last_click: (0.0, 0, Pos2::ZERO), + wheel_acc: 0.0, + mouse_down: None, + generation: 0, + find: None, + focus_requested: true, + } + } + + pub fn feed(&mut self, bytes: &[u8]) { + self.parser.advance(&mut self.term, bytes); + self.generation += 1; + } + + pub fn size(&self) -> (u16, u16) { + (self.cols, self.rows) + } + + pub fn request_focus(&mut self) { + self.focus_requested = true; + } + + pub fn selection_text(&self) -> Option { + self.term.selection_to_string().filter(|s| !s.is_empty()) + } + + pub fn select_all(&mut self) { + let top = self.term.grid().topmost_line(); + let bottom = self.term.grid().bottommost_line(); + let mut s = Selection::new(SelectionType::Simple, Point::new(top, Column(0)), Side::Left); + s.update(Point::new(bottom, Column(self.cols.saturating_sub(1) as usize)), Side::Right); + self.term.selection = Some(s); + } + + /// Clears the scrollback and the screen (the shell redraws its prompt on its own). + pub fn clear(&mut self) { + use ansi::Handler; + self.term.clear_screen(ansi::ClearMode::Saved); + self.feed(b"\x1b[H\x1b[2J"); + } + + pub fn reset(&mut self) { + use ansi::Handler; + self.term.reset_state(); + self.generation += 1; + } + + /// Every line of scrollback and screen as text, for Export Output. + pub fn buffer_text(&self) -> String { + let g = self.term.grid(); + let mut out = String::new(); + for l in g.topmost_line().0..=g.bottommost_line().0 { + out.push_str(self.line_text(Line(l)).0.trim_end()); + out.push('\n'); + } + out + } + + /// Text of a grid line plus, for each char, the column it came from. + fn line_text(&self, line: Line) -> (String, Vec) { + let row = &self.term.grid()[line]; + let (mut s, mut cols) = (String::new(), vec![]); + for c in 0..self.term.columns() { + let cell = &row[Column(c)]; + if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { + continue; + } + s.push(cell.c); + cols.push(c); + } + (s, cols) + } + + fn color(&self, c: Color, theme: &TermTheme, bold: bool) -> Color32 { + let pal = |i: usize| -> Color32 { + if let Some(rgb) = self.term.colors()[i] { + return Color32::from_rgb(rgb.r, rgb.g, rgb.b); + } + if i < 16 { + return crate::terminal::rgb(theme.ansi[i]); + } + if i < 232 { + let i = i - 16; + let v = |x: usize| if x == 0 { 0 } else { (55 + x * 40) as u8 }; + return Color32::from_rgb(v(i / 36), v((i / 6) % 6), v(i % 6)); + } + let g = (8 + (i - 232) * 10) as u8; + Color32::from_rgb(g, g, g) + }; + match c { + Color::Spec(rgb) => Color32::from_rgb(rgb.r, rgb.g, rgb.b), + Color::Indexed(i) => { + let i = i as usize; + pal(if bold && i < 8 { i + 8 } else { i }) + } + Color::Named(n) => match n { + NamedColor::Foreground | NamedColor::BrightForeground => rgb(theme.foreground), + NamedColor::Background => rgb(theme.background), + NamedColor::Cursor => rgb(theme.cursor), + NamedColor::DimForeground => rgb(theme.foreground).gamma_multiply(0.7), + n => { + let i = n as usize; + if i < 16 { + pal(if bold && i < 8 { i + 8 } else { i }) + } else if (NamedColor::DimBlack as usize..=NamedColor::DimWhite as usize).contains(&i) { + pal(i - NamedColor::DimBlack as usize).gamma_multiply(0.7) + } else { + rgb(theme.foreground) + } + } + }, + } + } + + fn cell_metrics(&self, ui: &Ui) -> (FontId, f32, f32) { + let font = FontId::monospace(self.font_size); + let (w, h) = ui.fonts_mut(|f| (f.glyph_width(&font, 'M'), f.row_height(&font))); + (font, w.max(1.0), h.ceil().max(1.0)) + } + + /// Grid point under a screen position. + fn point_at(&self, origin: Pos2, cw: f32, ch: f32, p: Pos2) -> (Point, Side) { + let col = ((p.x - origin.x) / cw).floor().clamp(0.0, self.cols.saturating_sub(1) as f32); + let row = ((p.y - origin.y) / ch).floor().clamp(0.0, self.rows.saturating_sub(1) as f32); + let side = if (p.x - origin.x) / cw - col > 0.5 { Side::Right } else { Side::Left }; + let line = Line(row as i32 - self.term.grid().display_offset() as i32); + (Point::new(line, Column(col as usize)), side) + } + + pub fn ui(&mut self, ui: &mut Ui, theme: &TermTheme, active: bool) -> TermResponse { + let mut out = TermResponse::default(); + let (font, cw, ch) = self.cell_metrics(ui); + let pad = vec2(6.0, 4.0); + let rect = ui.available_rect_before_wrap(); + let resp = ui.allocate_rect(rect, Sense::click_and_drag()); + let id = resp.id; + let origin = rect.min + pad; + + // Fit the grid to the widget. + let cols = (((rect.width() - 2.0 * pad.x) / cw).floor() as u16).max(2); + let rows = (((rect.height() - 2.0 * pad.y) / ch).floor() as u16).max(1); + if (cols, rows) != (self.cols, self.rows) { + self.cols = cols; + self.rows = rows; + self.term.resize(TermSize::new(cols as usize, rows as usize)); + out.resized = Some((cols, rows)); + } + + if resp.clicked() || resp.drag_started() || (active && self.focus_requested) { + ui.memory_mut(|m| m.request_focus(id)); + self.focus_requested = false; + } + let focused = ui.memory(|m| m.has_focus(id)); + out.focused = focused; + if focused { + ui.memory_mut(|m| m.set_focus_lock_filter(id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })); + self.keyboard(ui, &mut out); + let cur = self.term.grid().cursor.point; + let cursor_rect = Rect::from_min_size(origin + vec2(cur.column.0 as f32 * cw, cur.line.0 as f32 * ch), vec2(cw, ch)); + ui.output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Terminal, rect, cursor_rect, should_interrupt_composition: false }) + }); + } + self.mouse(ui, &resp, origin, cw, ch, &mut out); + + // Terminal-originated events. + for e in std::mem::take(&mut *self.events.0.lock().unwrap()) { + match e { + TermEvent::PtyWrite(s) => out.reply.extend_from_slice(s.as_bytes()), + TermEvent::Title(t) => out.title = Some(t), + TermEvent::Bell => out.bell = true, + TermEvent::ClipboardStore(_, s) => ui.ctx().copy_text(s), + _ => {} + } + } + + self.paint(ui, rect, origin, &font, cw, ch, theme, focused); + + resp.context_menu(|ui| { + let m = if cfg!(target_os = "macos") { "⌘" } else { "Ctrl+" }; + let items: [(MenuAction, &str, String); 11] = [ + (MenuAction::Copy, "Copy", format!("{m}C")), + (MenuAction::Paste, "Paste", format!("{m}V")), + (MenuAction::SelectAll, "Select All", String::new()), + (MenuAction::Find, "Find", format!("{m}F")), + (MenuAction::SendSnippet, "Send Snippet", format!("{m}Shift+S")), + (MenuAction::PortForwards, "Port Forwards", String::new()), + (MenuAction::SplitRight, "Split Right", format!("{m}Shift+D")), + (MenuAction::SplitDown, "Split Down", format!("{m}Shift+E")), + (MenuAction::Export, "Export Output", String::new()), + (MenuAction::Clear, "Clear", String::new()), + (MenuAction::Reset, "Reset", String::new()), + ]; + for (i, (a, label, keys)) in items.into_iter().enumerate() { + if matches!(i, 3 | 5 | 6 | 8) { + ui.separator(); + } + if ui.add(egui::Button::new(label).shortcut_text(keys).min_size(vec2(200.0, 0.0))).clicked() { + out.menu = Some(a); + ui.close(); + } + } + }); + if let Some(MenuAction::Copy) = out.menu + && let Some(s) = self.selection_text() + { + ui.ctx().copy_text(s); + out.menu = None; + } + if out.menu == Some(MenuAction::SelectAll) { + self.select_all(); + out.menu = None; + } + out + } + + fn keyboard(&mut self, ui: &mut Ui, out: &mut TermResponse) { + let events = ui.input(|i| i.events.clone()); + let mode = *self.term.mode(); + let alt_meta = !cfg!(target_os = "macos"); + let mut typed = false; + for e in events { + match e { + Event::Copy => match self.selection_text() { + Some(s) => ui.ctx().copy_text(s), + None => out.input.push(3), + }, + Event::Cut => out.input.push(0x18), + Event::Paste(text) => { + self.paste(&text, out); + typed = true; + } + Event::Text(t) => { + if t.chars().any(|c| c.is_control()) { + continue; + } + let alt = ui.input(|i| i.modifiers.alt); + if alt && alt_meta { + out.input.push(0x1b); + } + out.input.extend_from_slice(t.as_bytes()); + typed = true; + } + Event::Ime(egui::ImeEvent::Commit(t)) => { + out.input.extend_from_slice(t.as_bytes()); + typed = true; + } + Event::Key { key, pressed: true, modifiers, .. } => { + // Shift+PageUp/PageDown/Home/End scroll the local scrollback. + if modifiers.shift && !modifiers.ctrl && !mode.contains(TermMode::ALT_SCREEN) { + let page = self.rows as i32 - 1; + let scroll = match key { + Key::PageUp => Some(Scroll::Delta(page)), + Key::PageDown => Some(Scroll::Delta(-page)), + Key::Home => Some(Scroll::Top), + Key::End => Some(Scroll::Bottom), + _ => None, + }; + if let Some(s) = scroll { + self.term.scroll_display(s); + continue; + } + } + if let Some(bytes) = key_bytes(key, modifiers, mode) { + out.input.extend_from_slice(&bytes); + typed = true; + } + } + _ => {} + } + } + if typed { + self.term.selection = None; + self.term.scroll_display(Scroll::Bottom); + } + } + + pub fn paste(&mut self, text: &str, out: &mut TermResponse) { + let text = text.replace("\r\n", "\r").replace('\n', "\r"); + if self.term.mode().contains(TermMode::BRACKETED_PASTE) { + out.input.extend_from_slice(b"\x1b[200~"); + out.input.extend_from_slice(text.replace('\x1b', "").as_bytes()); + out.input.extend_from_slice(b"\x1b[201~"); + } else { + out.input.extend_from_slice(text.as_bytes()); + } + } + + fn mouse(&mut self, ui: &mut Ui, resp: &egui::Response, origin: Pos2, cw: f32, ch: f32, out: &mut TermResponse) { + let mode = *self.term.mode(); + let reporting = mode.intersects(TermMode::MOUSE_MODE) && !ui.input(|i| i.modifiers.shift); + let hover = resp.hover_pos(); + let events = ui.input(|i| i.events.clone()); + let to_cell = |p: Pos2| { + let c = ((p.x - origin.x) / cw).floor().max(0.0) as u32; + let r = ((p.y - origin.y) / ch).floor().max(0.0) as u32; + (c.min(self.cols as u32 - 1), r.min(self.rows as u32 - 1)) + }; + for e in &events { + match e { + Event::MouseWheel { delta, unit, modifiers, .. } if hover.is_some() => { + if modifiers.command { + continue; // left for zoom + } + let lines = match unit { + egui::MouseWheelUnit::Point => delta.y / ch, + egui::MouseWheelUnit::Line => delta.y, + egui::MouseWheelUnit::Page => delta.y * self.rows as f32, + }; + self.wheel_acc += lines; + let n = self.wheel_acc.trunc() as i32; + if n == 0 { + continue; + } + self.wheel_acc -= n as f32; + if reporting { + let (c, r) = to_cell(hover.unwrap()); + for _ in 0..n.unsigned_abs() { + out.input.extend(mouse_report(if n > 0 { 64 } else { 65 }, c, r, true, mode)); + } + } else if mode.contains(TermMode::ALT_SCREEN) { + let seq: &[u8] = if n > 0 { b"\x1b[A" } else { b"\x1b[B" }; + for _ in 0..n.unsigned_abs() { + out.input.extend_from_slice(seq); + } + } else { + self.term.scroll_display(Scroll::Delta(n)); + } + } + Event::PointerButton { pos, button, pressed, modifiers } if resp.rect.contains(*pos) => { + let b = match button { + PointerButton::Primary => 0, + PointerButton::Middle => 1, + PointerButton::Secondary => 2, + _ => continue, + }; + if reporting && b != 2 || reporting && mode.contains(TermMode::MOUSE_MODE) && b == 2 && !*pressed { + let (c, r) = to_cell(*pos); + out.input.extend(mouse_report(b, c, r, *pressed, mode)); + self.mouse_down = pressed.then_some(b); + continue; + } + if b != 0 { + continue; + } + if *pressed { + if modifiers.command + && let Some(url) = self.url_at(origin, cw, ch, *pos) + { + ui.ctx().open_url(egui::OpenUrl::new_tab(url)); + continue; + } + let now = ui.input(|i| i.time); + let (t, n, p) = self.last_click; + let count = if now - t < 0.4 && p.distance(*pos) < 4.0 { n % 3 + 1 } else { 1 }; + self.last_click = (now, count, *pos); + let (point, side) = self.point_at(origin, cw, ch, *pos); + let ty = match count { + 2 => SelectionType::Semantic, + 3 => SelectionType::Lines, + _ => SelectionType::Simple, + }; + self.term.selection = (count > 1 || modifiers.shift).then(|| Selection::new(ty, point, side)); + if modifiers.shift + && let Some(s) = &mut self.term.selection + { + s.update(point, side); + } + self.drag = Some((point, ty)); + } else { + self.drag = None; + } + } + Event::PointerMoved(p) => { + if reporting { + if let Some(b) = self.mouse_down + && (mode.contains(TermMode::MOUSE_DRAG) || mode.contains(TermMode::MOUSE_MOTION)) + && resp.rect.contains(*p) + { + let (c, r) = to_cell(*p); + out.input.extend(mouse_report(b + 32, c, r, true, mode)); + } + continue; + } + if let Some((start, ty)) = self.drag { + let (point, side) = self.point_at(origin, cw, ch, *p); + if self.term.selection.is_none() && point != start { + self.term.selection = Some(Selection::new(ty, start, Side::Left)); + } + if let Some(s) = &mut self.term.selection { + s.update(point, side); + } + // Auto-scroll while dragging past the edges. + if p.y < resp.rect.top() { + self.term.scroll_display(Scroll::Delta(1)); + } else if p.y > resp.rect.bottom() { + self.term.scroll_display(Scroll::Delta(-1)); + } + } + } + _ => {} + } + } + if let Some(p) = hover + && ui.input(|i| i.modifiers.command) + && self.url_at(origin, cw, ch, p).is_some() + { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } else if hover.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + } + } + + fn url_at(&self, origin: Pos2, cw: f32, ch: f32, p: Pos2) -> Option { + let (point, _) = self.point_at(origin, cw, ch, p); + let (text, cols) = self.line_text(point.line); + let re = regex::Regex::new(URL_RE).ok()?; + re.find_iter(&text).find_map(|m| { + let start = text[..m.start()].chars().count(); + let end = start + m.as_str().chars().count() - 1; + (cols.get(start)? <= &point.column.0 && cols.get(end)? >= &point.column.0).then(|| m.as_str().to_string()) + }) + } + + #[allow(clippy::too_many_arguments)] + fn paint(&mut self, ui: &mut Ui, rect: Rect, origin: Pos2, font: &FontId, cw: f32, ch: f32, theme: &TermTheme, focused: bool) { + let painter = ui.painter_at(rect); + let bg_default = rgb(theme.background); + painter.rect_filled(rect, 0.0, bg_default); + let offset = self.term.grid().display_offset() as i32; + let selection = self.term.selection.as_ref().and_then(|s| s.to_range(&self.term)); + let sel_bg = rgb(theme.selection); + self.refresh_find(); + let finds: Vec<(i32, usize, usize, bool)> = + self.find.as_ref().map(|f| f.matches.iter().enumerate().map(|(i, &(l, a, b))| (l, a, b, i == f.current)).collect()).unwrap_or_default(); + let ncols = self.cols as usize; + for vr in 0..self.rows as i32 { + let line = Line(vr - offset); + if line.0 > self.term.grid().bottommost_line().0 { + break; + } + let y = origin.y + vr as f32 * ch; + let row = &self.term.grid()[line]; + let mut run = String::new(); + let mut run_x = 0.0; + let mut run_color = Color32::TRANSPARENT; + let flush = |run: &mut String, x: f32, color: Color32| { + if !run.trim().is_empty() { + painter.text(pos2(x, y), egui::Align2::LEFT_TOP, run.as_str(), font.clone(), color); + } + run.clear(); + }; + for c in 0..ncols.min(self.term.columns()) { + let cell = &row[Column(c)]; + if cell.flags.contains(Flags::WIDE_CHAR_SPACER) { + continue; + } + let bold = cell.flags.contains(Flags::BOLD); + let mut fg = self.color(cell.fg, theme, bold); + let mut bg = self.color(cell.bg, theme, false); + if cell.flags.contains(Flags::INVERSE) { + std::mem::swap(&mut fg, &mut bg); + } + if cell.flags.contains(Flags::DIM) { + fg = fg.gamma_multiply(0.66); + } + let point = Point::new(line, Column(c)); + let width = if cell.flags.contains(Flags::WIDE_CHAR) { 2.0 } else { 1.0 }; + let x = origin.x + c as f32 * cw; + let cell_rect = Rect::from_min_size(pos2(x, y), vec2(cw * width, ch)); + if selection.as_ref().is_some_and(|s| s.contains(point)) { + bg = sel_bg; + } else if let Some(&(_, _, _, cur)) = finds.iter().find(|(l, a, b, _)| *l == line.0 && (*a..=*b).contains(&c)) { + bg = if cur { Color32::from_rgb(255, 160, 40) } else { Color32::from_rgba_unmultiplied(255, 200, 60, 110) }; + if cur { + fg = Color32::BLACK; + } + } + if bg != bg_default { + painter.rect_filled(cell_rect, 0.0, bg); + } + if cell.flags.intersects(Flags::ALL_UNDERLINES) { + painter.hline(cell_rect.x_range(), y + ch - 1.5, Stroke::new(1.0, fg)); + } + if cell.flags.contains(Flags::STRIKEOUT) { + painter.hline(cell_rect.x_range(), y + ch * 0.55, Stroke::new(1.0, fg)); + } + let hidden = cell.flags.contains(Flags::HIDDEN); + let chr = if hidden { ' ' } else { cell.c }; + // ASCII runs share one text call; anything else is placed on its own cell so a + // fallback font's advance can't push later glyphs off the grid. + if chr.is_ascii() && width == 1.0 && fg == run_color && !run.is_empty() { + run.push(chr); + continue; + } + flush(&mut run, run_x, run_color); + if chr.is_ascii() && width == 1.0 { + run.push(chr); + run_x = x; + run_color = fg; + } else if chr != ' ' { + let mut s = String::from(chr); + if let Some(z) = cell.zerowidth() { + s.extend(z); + } + painter.text(cell_rect.center(), egui::Align2::CENTER_CENTER, s, font.clone(), fg); + } + } + flush(&mut run, run_x, run_color); + } + + // Cursor. + let mode = *self.term.mode(); + let cur = self.term.grid().cursor.point; + let vr = cur.line.0 + offset; + if mode.contains(TermMode::SHOW_CURSOR) && (0..self.rows as i32).contains(&vr) { + let blink_on = !focused || (ui.input(|i| i.time) * 1.9) as i64 % 2 == 0; + if focused { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(530)); + } + let r = Rect::from_min_size(pos2(origin.x + cur.column.0 as f32 * cw, origin.y + vr as f32 * ch), vec2(cw, ch)); + let c = rgb(theme.cursor); + let shape = match self.term.cursor_style().shape { + CursorShape::Underline => CursorStyle::Underline, + CursorShape::Beam => CursorStyle::Bar, + CursorShape::Block if self.term.cursor_style() == alacritty_terminal::vte::ansi::CursorStyle::default() => self.cursor, + _ => CursorStyle::Block, + }; + if !focused { + painter.rect_stroke(r, 0.0, Stroke::new(1.0, c), egui::StrokeKind::Inside); + } else if blink_on { + match shape { + CursorStyle::Block => { + painter.rect_filled(r, 0.0, c); + let cell = &self.term.grid()[cur.line][cur.column]; + if cell.c != ' ' { + painter.text(r.left_top(), egui::Align2::LEFT_TOP, cell.c, font.clone(), rgb(theme.cursor_accent)); + } + } + CursorStyle::Underline => { + painter.rect_filled(Rect::from_min_max(pos2(r.left(), r.bottom() - 2.0), r.max), 0.0, c); + } + CursorStyle::Bar => { + painter.rect_filled(Rect::from_min_size(r.min, vec2(2.0, ch)), 0.0, c); + } + } + } + } + // Scroll position indicator when looking at history. + if offset > 0 { + let total = (self.term.grid().history_size() + self.rows as usize) as f32; + let h = (rect.height() * self.rows as f32 / total).max(16.0); + let top = rect.top() + (rect.height() - h) * (1.0 - offset as f32 / self.term.grid().history_size().max(1) as f32); + painter.rect_filled(Rect::from_min_size(pos2(rect.right() - 5.0, top), vec2(3.0, h)), 2.0, rgb(theme.foreground).gamma_multiply(0.35)); + } + } + + // --- find --------------------------------------------------------------------------- + + fn refresh_find(&mut self) { + let generation = self.generation; + let Some(f) = &self.find else { return }; + let key = (f.query.clone(), f.case_sensitive, f.regex, generation); + if f.computed_for.as_ref() == Some(&key) { + return; + } + let pattern = if f.regex { f.query.clone() } else { regex::escape(&f.query) }; + let pattern = if f.case_sensitive { pattern } else { format!("(?i){pattern}") }; + let mut matches = vec![]; + let mut error = None; + if !f.query.is_empty() { + match regex::Regex::new(&pattern) { + Ok(re) => { + let g = self.term.grid(); + for l in g.topmost_line().0..=g.bottommost_line().0 { + let (text, cols) = self.line_text(Line(l)); + for m in re.find_iter(&text) { + if m.as_str().is_empty() { + continue; + } + let a = text[..m.start()].chars().count(); + let b = a + m.as_str().chars().count() - 1; + if let (Some(&ca), Some(&cb)) = (cols.get(a), cols.get(b)) { + matches.push((l, ca, cb)); + } + } + } + } + Err(e) => error = Some(e.to_string()), + } + } + let f = self.find.as_mut().unwrap(); + let had = f.matches.len(); + f.matches = matches; + f.error = error; + if had == 0 || f.current >= f.matches.len() { + f.current = f.matches.len().saturating_sub(1); + } + f.computed_for = Some(key); + } + + /// Moves to the next (or previous) match and scrolls it into view. + pub fn find_step(&mut self, forward: bool) { + self.refresh_find(); + let Some(f) = &mut self.find else { return }; + if f.matches.is_empty() { + return; + } + let n = f.matches.len(); + f.current = if forward { (f.current + 1) % n } else { (f.current + n - 1) % n }; + let line = f.matches[f.current].0; + self.reveal(line); + } + + fn reveal(&mut self, line: i32) { + let offset = self.term.grid().display_offset() as i32; + let rows = self.rows as i32; + let vr = line + offset; + if !(0..rows).contains(&vr) { + let target = (rows / 2 - line).clamp(0, self.term.grid().history_size() as i32); + self.term.scroll_display(Scroll::Delta(target - offset)); + } + } + + pub fn find_counts(&self) -> (usize, usize) { + self.find.as_ref().map_or((0, 0), |f| (if f.matches.is_empty() { 0 } else { f.current + 1 }, f.matches.len())) + } +} + +/// xterm encoding of a mouse event at 0-based cell (c, r). +fn mouse_report(button: u8, c: u32, r: u32, pressed: bool, mode: TermMode) -> Vec { + if mode.contains(TermMode::SGR_MOUSE) { + format!("\x1b[<{button};{};{}{}", c + 1, r + 1, if pressed { 'M' } else { 'm' }).into_bytes() + } else { + let b = if pressed { button } else { 3 }; + let enc = |v: u32| (32 + v + 1).min(255) as u8; + vec![0x1b, b'[', b'M', 32 + b, enc(c), enc(r)] + } +} + +/// Bytes an xterm sends for a non-text key. +pub fn key_bytes(key: Key, m: Modifiers, mode: TermMode) -> Option> { + let app_cursor = mode.contains(TermMode::APP_CURSOR); + let mods = 1 + (m.shift as u8) + 2 * (m.alt as u8) + 4 * (m.ctrl as u8); + let csi = |final_: char| -> Vec { + if mods > 1 { + format!("\x1b[1;{mods}{final_}").into_bytes() + } else if app_cursor { + format!("\x1bO{final_}").into_bytes() + } else { + format!("\x1b[{final_}").into_bytes() + } + }; + let tilde = |n: u8| -> Vec { if mods > 1 { format!("\x1b[{n};{mods}~") } else { format!("\x1b[{n}~") }.into_bytes() }; + let ss3 = |final_: char| -> Vec { if mods > 1 { format!("\x1b[1;{mods}{final_}").into_bytes() } else { format!("\x1bO{final_}").into_bytes() } }; + let esc = |b: &[u8]| -> Vec { + let mut v = if m.alt { vec![0x1b] } else { vec![] }; + v.extend_from_slice(b); + v + }; + Some(match key { + Key::Enter => esc(b"\r"), + Key::Backspace => esc(if m.ctrl { b"\x08" } else { b"\x7f" }), + Key::Tab if m.shift => b"\x1b[Z".to_vec(), + Key::Tab => b"\t".to_vec(), + Key::Escape => b"\x1b".to_vec(), + Key::ArrowUp => csi('A'), + Key::ArrowDown => csi('B'), + Key::ArrowRight => csi('C'), + Key::ArrowLeft => csi('D'), + Key::Home => csi('H'), + Key::End => csi('F'), + Key::Insert => tilde(2), + Key::Delete => tilde(3), + Key::PageUp => tilde(5), + Key::PageDown => tilde(6), + Key::F1 => ss3('P'), + Key::F2 => ss3('Q'), + Key::F3 => ss3('R'), + Key::F4 => ss3('S'), + Key::F5 => tilde(15), + Key::F6 => tilde(17), + Key::F7 => tilde(18), + Key::F8 => tilde(19), + Key::F9 => tilde(20), + Key::F10 => tilde(21), + Key::F11 => tilde(23), + Key::F12 => tilde(24), + _ if m.ctrl && !mac_cmd_only(m) => { + let b = match key { + Key::Space | Key::Num2 => 0, + Key::OpenBracket | Key::Num3 => 0x1b, + Key::Backslash | Key::Num4 => 0x1c, + Key::CloseBracket | Key::Num5 => 0x1d, + Key::Num6 => 0x1e, + Key::Slash | Key::Minus | Key::Num7 => 0x1f, + Key::Num8 => 0x7f, + k => { + let name = k.name(); + let c = name.chars().next().filter(|c| name.len() == 1 && c.is_ascii_alphabetic())?; + (c.to_ascii_uppercase() as u8) & 0x1f + } + }; + esc(&[b]) + } + _ if m.alt && !m.ctrl && !cfg!(target_os = "macos") => return None, // arrives as Text with ESC prefix + _ => return None, + }) +} + +/// On macOS Cmd is the app's modifier while Ctrl still produces control codes. +fn mac_cmd_only(m: Modifiers) -> bool { + cfg!(target_os = "macos") && m.mac_cmd && !m.ctrl +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_encode_like_xterm() { + let none = Modifiers::NONE; + assert_eq!(key_bytes(Key::ArrowUp, none, TermMode::empty()).unwrap(), b"\x1b[A"); + assert_eq!(key_bytes(Key::ArrowUp, none, TermMode::APP_CURSOR).unwrap(), b"\x1bOA"); + assert_eq!(key_bytes(Key::ArrowLeft, Modifiers::CTRL, TermMode::empty()).unwrap(), b"\x1b[1;5D"); + assert_eq!(key_bytes(Key::C, Modifiers::CTRL, TermMode::empty()).unwrap(), [3]); + // egui reports Ctrl as ctrl + command on Linux/Windows; it must still send a control code. + let ctrl_cmd = Modifiers { ctrl: true, command: true, ..Default::default() }; + assert_eq!(key_bytes(Key::D, ctrl_cmd, TermMode::empty()).unwrap(), [4]); + assert_eq!(key_bytes(Key::F5, none, TermMode::empty()).unwrap(), b"\x1b[15~"); + assert_eq!(key_bytes(Key::Backspace, none, TermMode::empty()).unwrap(), [0x7f]); + assert!(key_bytes(Key::A, none, TermMode::empty()).is_none()); + } + + #[test] + fn feeds_text_and_finds_it() { + let mut t = TermView::new(1000, 14.0, CursorStyle::Block); + t.feed(b"hello \x1b[31mworld\x1b[0m\r\nsecond world\r\n"); + assert!(t.buffer_text().starts_with("hello world\nsecond world")); + t.find = Some(Find { query: "WORLD".into(), ..Default::default() }); + t.refresh_find(); + assert_eq!(t.find_counts().1, 2); + t.find.as_mut().unwrap().case_sensitive = true; + t.refresh_find(); + assert_eq!(t.find_counts().1, 0); + } + + #[test] + fn replies_to_device_attributes() { + let mut t = TermView::new(10, 14.0, CursorStyle::Block); + t.feed(b"\x1b[6n"); + let ev = t.events.0.lock().unwrap(); + assert!(ev.iter().any(|e| matches!(e, TermEvent::PtyWrite(s) if s == "\x1b[1;1R"))); + } + + #[test] + fn mouse_reports() { + assert_eq!(mouse_report(0, 0, 0, true, TermMode::SGR_MOUSE), b"\x1b[<0;1;1M"); + assert_eq!(mouse_report(0, 2, 3, true, TermMode::empty()), vec![0x1b, b'[', b'M', 32, 35, 36]); + } +} + +#[cfg(test)] +mod perf { + /// `cargo test --release -p nexus perf -- --ignored --nocapture` + #[test] + #[ignore] + fn feed_throughput() { + let mut t = super::TermView::new(10_000, 14.0, super::CursorStyle::Block); + let line = b"drwxr-xr-x 22 root root 4096 Oct 1 00:01 \x1b[01;34msome-directory-name\x1b[0m and more text\r\n"; + let chunk: Vec = line.iter().copied().cycle().take(64 * 1024).collect(); + let total = 50 * 1024 * 1024; + let start = std::time::Instant::now(); + let mut n = 0; + while n < total { + t.feed(&chunk); + n += chunk.len(); + } + let secs = start.elapsed().as_secs_f64(); + eprintln!("fed {} MiB in {secs:.2}s = {:.0} MiB/s", total >> 20, (total >> 20) as f64 / secs); + assert!(secs < 30.0, "too slow even allowing for a loaded machine"); + } +} diff --git a/rust/app/src/terminal_themes.rs b/rust/app/src/terminal_themes.rs new file mode 100644 index 0000000..1c9d274 --- /dev/null +++ b/rust/app/src/terminal_themes.rs @@ -0,0 +1,317 @@ +//! The 11 terminal colour schemes, generated from the web build's themes.js. + +pub struct TermTheme { + pub id: &'static str, + pub label: &'static str, + pub background: [u8; 4], + pub foreground: [u8; 4], + pub cursor: [u8; 4], + pub cursor_accent: [u8; 4], + pub selection: [u8; 4], + /// black, red, green, yellow, blue, magenta, cyan, white, then the bright eight. + pub ansi: [[u8; 4]; 16], +} + +pub const THEMES: &[TermTheme] = &[ + TermTheme { + id: "charcoal", + label: "Charcoal (Default)", + background: [28, 28, 28, 255], + foreground: [228, 228, 228, 255], + cursor: [78, 205, 196, 255], + cursor_accent: [28, 28, 28, 255], + selection: [78, 205, 196, 76], + ansi: [ + [28, 28, 28, 255], + [239, 83, 80, 255], + [102, 187, 106, 255], + [255, 167, 38, 255], + [66, 165, 245, 255], + [171, 71, 188, 255], + [78, 205, 196, 255], + [228, 228, 228, 255], + [85, 85, 85, 255], + [255, 112, 67, 255], + [129, 199, 132, 255], + [255, 204, 2, 255], + [100, 181, 246, 255], + [206, 147, 216, 255], + [128, 222, 234, 255], + [255, 255, 255, 255], + ], + }, + TermTheme { + id: "solarized-dark", + label: "Solarized Dark", + background: [0, 43, 54, 255], + foreground: [131, 148, 150, 255], + cursor: [147, 161, 161, 255], + cursor_accent: [0, 43, 54, 255], + selection: [147, 161, 161, 76], + ansi: [ + [7, 54, 66, 255], + [220, 50, 47, 255], + [133, 153, 0, 255], + [181, 137, 0, 255], + [38, 139, 210, 255], + [211, 54, 130, 255], + [42, 161, 152, 255], + [238, 232, 213, 255], + [88, 110, 117, 255], + [203, 75, 22, 255], + [88, 110, 117, 255], + [101, 123, 131, 255], + [131, 148, 150, 255], + [108, 113, 196, 255], + [147, 161, 161, 255], + [253, 246, 227, 255], + ], + }, + TermTheme { + id: "monokai", + label: "Monokai", + background: [39, 40, 34, 255], + foreground: [248, 248, 242, 255], + cursor: [248, 248, 240, 255], + cursor_accent: [39, 40, 34, 255], + selection: [73, 72, 62, 153], + ansi: [ + [39, 40, 34, 255], + [249, 38, 114, 255], + [166, 226, 46, 255], + [244, 191, 117, 255], + [102, 217, 239, 255], + [174, 129, 255, 255], + [161, 239, 228, 255], + [248, 248, 242, 255], + [117, 113, 94, 255], + [249, 38, 114, 255], + [166, 226, 46, 255], + [244, 191, 117, 255], + [102, 217, 239, 255], + [174, 129, 255, 255], + [161, 239, 228, 255], + [249, 248, 245, 255], + ], + }, + TermTheme { + id: "nord", + label: "Nord", + background: [46, 52, 64, 255], + foreground: [216, 222, 233, 255], + cursor: [216, 222, 233, 255], + cursor_accent: [46, 52, 64, 255], + selection: [136, 192, 208, 76], + ansi: [ + [59, 66, 82, 255], + [191, 97, 106, 255], + [163, 190, 140, 255], + [235, 203, 139, 255], + [129, 161, 193, 255], + [180, 142, 173, 255], + [136, 192, 208, 255], + [229, 233, 240, 255], + [76, 86, 106, 255], + [191, 97, 106, 255], + [163, 190, 140, 255], + [235, 203, 139, 255], + [129, 161, 193, 255], + [180, 142, 173, 255], + [143, 188, 187, 255], + [236, 239, 244, 255], + ], + }, + TermTheme { + id: "dracula", + label: "Dracula", + background: [40, 42, 54, 255], + foreground: [248, 248, 242, 255], + cursor: [248, 248, 242, 255], + cursor_accent: [40, 42, 54, 255], + selection: [68, 71, 90, 153], + ansi: [ + [33, 34, 44, 255], + [255, 85, 85, 255], + [80, 250, 123, 255], + [241, 250, 140, 255], + [189, 147, 249, 255], + [255, 121, 198, 255], + [139, 233, 253, 255], + [248, 248, 242, 255], + [98, 114, 164, 255], + [255, 110, 110, 255], + [105, 255, 148, 255], + [255, 255, 165, 255], + [214, 172, 255, 255], + [255, 146, 223, 255], + [164, 255, 255, 255], + [255, 255, 255, 255], + ], + }, + TermTheme { + id: "one-dark", + label: "One Dark", + background: [40, 44, 52, 255], + foreground: [171, 178, 191, 255], + cursor: [82, 139, 255, 255], + cursor_accent: [40, 44, 52, 255], + selection: [82, 139, 255, 76], + ansi: [ + [40, 44, 52, 255], + [224, 108, 117, 255], + [152, 195, 121, 255], + [229, 192, 123, 255], + [97, 175, 239, 255], + [198, 120, 221, 255], + [86, 182, 194, 255], + [171, 178, 191, 255], + [92, 99, 112, 255], + [224, 108, 117, 255], + [152, 195, 121, 255], + [229, 192, 123, 255], + [97, 175, 239, 255], + [198, 120, 221, 255], + [86, 182, 194, 255], + [255, 255, 255, 255], + ], + }, + TermTheme { + id: "gruvbox-dark", + label: "Gruvbox Dark", + background: [40, 40, 40, 255], + foreground: [235, 219, 178, 255], + cursor: [235, 219, 178, 255], + cursor_accent: [40, 40, 40, 255], + selection: [214, 93, 14, 76], + ansi: [ + [40, 40, 40, 255], + [204, 36, 29, 255], + [152, 151, 26, 255], + [215, 153, 33, 255], + [69, 133, 136, 255], + [177, 98, 134, 255], + [104, 157, 106, 255], + [168, 153, 132, 255], + [146, 131, 116, 255], + [251, 73, 52, 255], + [184, 187, 38, 255], + [250, 189, 47, 255], + [131, 165, 152, 255], + [211, 134, 155, 255], + [142, 192, 124, 255], + [235, 219, 178, 255], + ], + }, + TermTheme { + id: "tokyo-night", + label: "Tokyo Night", + background: [26, 27, 38, 255], + foreground: [169, 177, 214, 255], + cursor: [192, 202, 245, 255], + cursor_accent: [26, 27, 38, 255], + selection: [41, 46, 66, 230], + ansi: [ + [21, 22, 30, 255], + [247, 118, 142, 255], + [158, 206, 106, 255], + [224, 175, 104, 255], + [122, 162, 247, 255], + [187, 154, 247, 255], + [125, 207, 255, 255], + [169, 177, 214, 255], + [65, 72, 104, 255], + [247, 118, 142, 255], + [158, 206, 106, 255], + [224, 175, 104, 255], + [122, 162, 247, 255], + [187, 154, 247, 255], + [125, 207, 255, 255], + [192, 202, 245, 255], + ], + }, + TermTheme { + id: "catppuccin-mocha", + label: "Catppuccin Mocha", + background: [30, 30, 46, 255], + foreground: [205, 214, 244, 255], + cursor: [245, 224, 220, 255], + cursor_accent: [30, 30, 46, 255], + selection: [88, 91, 112, 128], + ansi: [ + [69, 71, 90, 255], + [243, 139, 168, 255], + [166, 227, 161, 255], + [249, 226, 175, 255], + [137, 180, 250, 255], + [245, 194, 231, 255], + [148, 226, 213, 255], + [186, 194, 222, 255], + [88, 91, 112, 255], + [243, 139, 168, 255], + [166, 227, 161, 255], + [249, 226, 175, 255], + [137, 180, 250, 255], + [245, 194, 231, 255], + [148, 226, 213, 255], + [166, 173, 200, 255], + ], + }, + TermTheme { + id: "rose-pine", + label: "Rosé Pine", + background: [25, 23, 36, 255], + foreground: [224, 222, 244, 255], + cursor: [82, 79, 103, 255], + cursor_accent: [224, 222, 244, 255], + selection: [110, 106, 134, 102], + ansi: [ + [38, 35, 58, 255], + [235, 111, 146, 255], + [49, 116, 143, 255], + [246, 193, 119, 255], + [156, 207, 216, 255], + [196, 167, 231, 255], + [235, 188, 186, 255], + [224, 222, 244, 255], + [110, 106, 134, 255], + [235, 111, 146, 255], + [49, 116, 143, 255], + [246, 193, 119, 255], + [156, 207, 216, 255], + [196, 167, 231, 255], + [235, 188, 186, 255], + [224, 222, 244, 255], + ], + }, + TermTheme { + id: "solarized-light", + label: "Solarized Light", + background: [253, 246, 227, 255], + foreground: [101, 123, 131, 255], + cursor: [88, 110, 117, 255], + cursor_accent: [253, 246, 227, 255], + selection: [7, 54, 66, 51], + ansi: [ + [7, 54, 66, 255], + [220, 50, 47, 255], + [133, 153, 0, 255], + [181, 137, 0, 255], + [38, 139, 210, 255], + [211, 54, 130, 255], + [42, 161, 152, 255], + [238, 232, 213, 255], + [0, 43, 54, 255], + [203, 75, 22, 255], + [88, 110, 117, 255], + [101, 123, 131, 255], + [131, 148, 150, 255], + [108, 113, 196, 255], + [147, 161, 161, 255], + [253, 246, 227, 255], + ], + }, +]; + +pub fn get(id: &str) -> &'static TermTheme { + THEMES.iter().find(|t| t.id == id).unwrap_or(&THEMES[0]) +} diff --git a/rust/app/src/theme.rs b/rust/app/src/theme.rs new file mode 100644 index 0000000..761cb23 --- /dev/null +++ b/rust/app/src/theme.rs @@ -0,0 +1,297 @@ +//! Design system: colour tokens, radii and typography (Inter for UI, JetBrains Mono for bytes). +//! Widgets read `Tokens::get(ctx)` instead of hard-coding colours, so every theme applies +//! everywhere. Copied from Leviathan Desktop so both apps share one look. + +use egui::{Color32, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals}; +use std::sync::Arc; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ThemeKind { + /// Charcoal panels, blue accent: a pro-tool dark. + #[default] + Pro, + /// Near-black surfaces, violet accent. + Midnight, + Light, +} + +impl ThemeKind { + pub const ALL: [ThemeKind; 3] = [ThemeKind::Pro, ThemeKind::Midnight, ThemeKind::Light]; + pub fn label(self) -> &'static str { + match self { + ThemeKind::Pro => "Pro (dark)", + ThemeKind::Midnight => "Midnight", + ThemeKind::Light => "Light", + } + } + pub fn id(self) -> &'static str { + match self { + ThemeKind::Pro => "pro", + ThemeKind::Midnight => "midnight", + ThemeKind::Light => "light", + } + } + pub fn from_id(s: &str) -> Option { + Self::ALL.into_iter().find(|k| k.id() == s) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Tokens { + pub kind: ThemeKind, + pub chrome: Color32, + pub dock: Color32, + pub card: Color32, + pub card_border: Color32, + pub tab_strip: Color32, + pub field: Color32, + pub field_border: Color32, + pub hover: Color32, + pub row_selected: Color32, + pub text: Color32, + pub text_dim: Color32, + pub text_faint: Color32, + pub accent: Color32, + pub accent_soft: Color32, + pub accent_text: Color32, + pub danger: Color32, + pub warning: Color32, + pub success: Color32, + pub separator: Color32, + /// Hex view background and byte-class colours. + pub canvas: Color32, + pub byte_null: Color32, + pub byte_print: Color32, + pub byte_space: Color32, + pub byte_ctrl: Color32, + pub byte_high: Color32, + pub byte_ff: Color32, + pub hit: Color32, + pub bookmark: Color32, + pub patched: Color32, + pub radius: f32, +} + +impl Tokens { + pub fn for_kind(kind: ThemeKind) -> Self { + let rgb = Color32::from_rgb; + match kind { + ThemeKind::Pro => Tokens { + kind, + chrome: rgb(38, 38, 40), + dock: rgb(30, 30, 32), + card: rgb(44, 44, 47), + card_border: rgb(28, 28, 30), + tab_strip: rgb(34, 34, 36), + field: rgb(30, 30, 32), + field_border: rgb(72, 72, 76), + hover: rgb(60, 60, 64), + row_selected: rgb(40, 66, 102), + text: rgb(226, 226, 230), + text_dim: rgb(170, 170, 178), + text_faint: rgb(118, 118, 126), + accent: rgb(55, 142, 240), + accent_soft: Color32::from_rgba_unmultiplied(55, 142, 240, 56), + accent_text: Color32::WHITE, + danger: rgb(236, 91, 98), + warning: rgb(232, 176, 70), + success: rgb(80, 196, 128), + separator: rgb(24, 24, 26), + canvas: rgb(26, 26, 28), + byte_null: rgb(84, 84, 92), + byte_print: rgb(214, 222, 235), + byte_space: rgb(120, 190, 160), + byte_ctrl: rgb(232, 176, 70), + byte_high: rgb(196, 140, 236), + byte_ff: rgb(236, 110, 110), + hit: Color32::from_rgba_unmultiplied(232, 176, 70, 90), + bookmark: rgb(80, 196, 128), + patched: rgb(236, 91, 98), + radius: 4.0, + }, + ThemeKind::Midnight => Tokens { + kind, + chrome: rgb(18, 18, 21), + dock: rgb(14, 14, 16), + card: rgb(24, 24, 28), + card_border: rgb(40, 40, 46), + tab_strip: rgb(18, 18, 21), + field: rgb(32, 32, 37), + field_border: rgb(52, 52, 60), + hover: rgb(42, 42, 50), + row_selected: rgb(52, 44, 96), + text: rgb(236, 236, 242), + text_dim: rgb(150, 150, 162), + text_faint: rgb(96, 96, 108), + accent: rgb(139, 124, 246), + accent_soft: Color32::from_rgba_unmultiplied(139, 124, 246, 50), + accent_text: rgb(240, 238, 255), + separator: rgb(34, 34, 40), + canvas: rgb(12, 12, 14), + byte_null: rgb(70, 70, 82), + byte_print: rgb(220, 224, 240), + byte_space: rgb(110, 200, 180), + byte_ctrl: rgb(240, 190, 90), + byte_high: rgb(170, 150, 255), + byte_ff: rgb(255, 120, 140), + hit: Color32::from_rgba_unmultiplied(240, 190, 90, 80), + bookmark: rgb(110, 220, 150), + patched: rgb(255, 110, 120), + ..Tokens::for_kind(ThemeKind::Pro) + }, + ThemeKind::Light => Tokens { + kind, + chrome: rgb(236, 236, 240), + dock: rgb(226, 226, 231), + card: rgb(250, 250, 252), + card_border: rgb(214, 214, 220), + tab_strip: rgb(232, 232, 236), + field: rgb(255, 255, 255), + field_border: rgb(200, 200, 208), + hover: rgb(222, 222, 230), + row_selected: rgb(204, 222, 250), + text: rgb(28, 28, 32), + text_dim: rgb(84, 84, 96), + text_faint: rgb(140, 140, 150), + accent: rgb(30, 110, 220), + accent_soft: Color32::from_rgba_unmultiplied(30, 110, 220, 40), + accent_text: Color32::WHITE, + danger: rgb(200, 50, 56), + warning: rgb(176, 120, 10), + success: rgb(30, 140, 80), + separator: rgb(214, 214, 220), + canvas: rgb(252, 252, 253), + byte_null: rgb(180, 180, 190), + byte_print: rgb(30, 34, 44), + byte_space: rgb(20, 130, 100), + byte_ctrl: rgb(176, 110, 0), + byte_high: rgb(120, 70, 200), + byte_ff: rgb(200, 50, 56), + hit: Color32::from_rgba_unmultiplied(240, 180, 40, 110), + bookmark: rgb(30, 150, 80), + patched: rgb(210, 40, 50), + radius: 4.0, + }, + } + } + + pub fn get(ctx: &egui::Context) -> Tokens { + ctx.data(|d| d.get_temp::(egui::Id::new("nexus-theme"))).unwrap_or_else(|| Tokens::for_kind(ThemeKind::Pro)) + } + + pub fn dark(&self) -> bool { + self.kind != ThemeKind::Light + } +} + +pub fn install_fonts(ctx: &egui::Context) { + let mut fonts = FontDefinitions::default(); + let mut add = |name: &str, bytes: &'static [u8]| { + fonts.font_data.insert(name.into(), Arc::new(FontData::from_static(bytes))); + }; + add("Inter", include_bytes!("../assets/fonts/Inter-Regular.ttf")); + add("Inter-SemiBold", include_bytes!("../assets/fonts/Inter-SemiBold.ttf")); + add("JetBrainsMono", include_bytes!("../assets/fonts/JetBrainsMono-Regular.ttf")); + egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular); + // The bundled Inter has its private-use-area cmap stripped, so phosphor's icons win there. + fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "Inter".into()); + fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "JetBrainsMono".into()); + // CJK and other scripts the bundled fonts lack: borrow a system font if one is installed. + if let Some(bytes) = system_fallback_font() { + fonts.font_data.insert("system-fallback".into(), Arc::new(FontData::from_owned(bytes))); + for fam in [FontFamily::Proportional, FontFamily::Monospace] { + fonts.families.entry(fam).or_default().push("system-fallback".into()); + } + } + let mut semibold = vec!["Inter-SemiBold".to_owned()]; + semibold.extend(fonts.families[&FontFamily::Proportional].iter().cloned()); + fonts.families.insert(FontFamily::Name("semibold".into()), semibold); + ctx.set_fonts(fonts); +} + +/// The first wide-coverage (CJK) system font found on this machine. +fn system_fallback_font() -> Option> { + let candidates: &[&str] = if cfg!(target_os = "macos") { + &[ + "/System/Library/Fonts/PingFang.ttc", + "/System/Library/Fonts/Hiragino Sans GB.ttc", + "/Library/Fonts/Arial Unicode.ttf", + "/System/Library/Fonts/Supplemental/Arial Unicode.ttf", + ] + } else if cfg!(windows) { + &["C:\\Windows\\Fonts\\msyh.ttc", "C:\\Windows\\Fonts\\YuGothM.ttc", "C:\\Windows\\Fonts\\msgothic.ttc", "C:\\Windows\\Fonts\\malgun.ttf"] + } else { + &[ + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", + "/usr/share/fonts/wenquanyi/wqy-microhei/wqy-microhei.ttc", + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", + ] + }; + candidates.iter().find_map(|p| std::fs::read(p).ok()) +} + +pub fn semibold(size: f32) -> FontId { + FontId::new(size, FontFamily::Name("semibold".into())) +} + +pub fn mono(size: f32) -> FontId { + FontId::monospace(size) +} + +pub fn apply(ctx: &egui::Context, kind: ThemeKind) { + let t = Tokens::for_kind(kind); + ctx.data_mut(|d| d.insert_temp(egui::Id::new("nexus-theme"), t)); + let mut v = if t.dark() { Visuals::dark() } else { Visuals::light() }; + v.panel_fill = t.card; + v.window_fill = t.card; + v.window_stroke = Stroke::new(1.0, t.card_border); + v.extreme_bg_color = t.field; + v.faint_bg_color = t.tab_strip; + v.code_bg_color = t.field; + v.hyperlink_color = t.accent; + v.selection.bg_fill = t.accent_soft; + v.selection.stroke = Stroke::new(1.0, t.accent); + v.override_text_color = Some(t.text); + v.warn_fg_color = t.warning; + v.error_fg_color = t.danger; + let r = egui::CornerRadius::same(t.radius as u8); + v.window_corner_radius = egui::CornerRadius::same(8); + v.menu_corner_radius = egui::CornerRadius::same(6); + for (w, fill) in [ + (&mut v.widgets.noninteractive, t.card), + (&mut v.widgets.inactive, t.field), + (&mut v.widgets.hovered, t.hover), + (&mut v.widgets.active, t.hover), + (&mut v.widgets.open, t.hover), + ] { + w.bg_fill = fill; + w.weak_bg_fill = fill; + w.corner_radius = r; + w.fg_stroke.color = t.text; + } + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.separator); + v.widgets.noninteractive.fg_stroke.color = t.text_dim; + v.widgets.inactive.bg_stroke = Stroke::new(1.0, t.field_border); + v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.text_faint); + v.widgets.active.bg_stroke = Stroke::new(1.0, t.accent); + v.popup_shadow = egui::epaint::Shadow { offset: [0, 6], blur: 18, spread: 0, color: Color32::from_black_alpha(if t.dark() { 120 } else { 40 }) }; + v.window_shadow = v.popup_shadow; + ctx.set_visuals(v); + ctx.all_styles_mut(|s| { + s.text_styles = [ + (TextStyle::Small, FontId::proportional(11.0)), + (TextStyle::Body, FontId::proportional(13.0)), + (TextStyle::Button, FontId::proportional(13.0)), + (TextStyle::Heading, semibold(16.0)), + (TextStyle::Monospace, FontId::monospace(12.5)), + ] + .into(); + s.spacing.item_spacing = egui::vec2(8.0, 6.0); + s.spacing.button_padding = egui::vec2(10.0, 4.0); + s.spacing.interact_size.y = 24.0; + s.spacing.scroll = egui::style::ScrollStyle::thin(); + }); +} diff --git a/rust/app/src/transfer.rs b/rust/app/src/transfer.rs new file mode 100644 index 0000000..12d89cd --- /dev/null +++ b/rust/app/src/transfer.rs @@ -0,0 +1,295 @@ +//! Profile export/import: encrypted `.nexus` (also reads `.link`) and plain JSON. + +use crate::settings::{Job, poll}; +use crate::state::{Request, Shared}; +use crate::theme::{self, Tokens}; +use crate::widgets; +use egui::{RichText, Ui, vec2}; +use egui_phosphor::regular as icon; +use nexus_core::exportimport::EXTENSION; +use std::collections::HashSet; +use std::path::PathBuf; + +const STRATEGIES: [(&str, &str); 3] = [("skip", "Skip existing"), ("overwrite", "Overwrite existing"), ("duplicate", "Create duplicates")]; + +enum Mode { + /// `selected` is filled from `preselect` (or every profile) on the first frame. + Export { + preselect: Option>, + selected: HashSet, + pw: String, + confirm: String, + }, + Import { + path: PathBuf, + pw: String, + strategy: &'static str, + status: String, + }, + ExportJson { + include_secrets: bool, + }, + ImportJson { + path: PathBuf, + strategy: &'static str, + }, +} + +enum Done { + Exported(usize, PathBuf), + Imported(usize), +} + +#[derive(Default)] +pub struct Transfer { + mode: Option, + job: Option>>, +} + +impl Transfer { + /// Encrypted export of these profiles (all when empty): pick profiles, password, file. + pub fn export_encrypted(&mut self, ids: Vec) { + self.mode = Some(Mode::Export { preselect: Some(ids), selected: HashSet::new(), pw: String::new(), confirm: String::new() }); + } + + pub fn import_encrypted(&mut self) { + if let Some(path) = + rfd::FileDialog::new().set_title("Import Profiles").add_filter("Nexus Export", &[EXTENSION, "link"]).add_filter("All files", &["*"]).pick_file() + { + self.mode = Some(Mode::Import { path, pw: String::new(), strategy: "skip", status: String::new() }); + } + } + + pub fn export_json(&mut self) { + self.mode = Some(Mode::ExportJson { include_secrets: false }); + } + + pub fn import_json(&mut self) { + if let Some(path) = rfd::FileDialog::new().set_title("Import Profiles (JSON)").add_filter("JSON", &["json"]).pick_file() { + self.mode = Some(Mode::ImportJson { path, strategy: "skip" }); + } + } + + pub fn ui(&mut self, ctx: &egui::Context, s: &mut Shared) { + if let Some(r) = poll(&mut self.job) { + match r { + Ok(Done::Exported(n, path)) => { + s.success(format!("Exported {n} profile(s) to {}", path.display())); + self.mode = None; + } + Ok(Done::Imported(n)) => { + s.success(format!("Imported {n} profile(s)")); + s.request(Request::ReloadProfiles); + self.mode = None; + } + Err(e) => { + if let Some(Mode::Import { status, .. }) = &mut self.mode { + *status = e.split_whitespace().collect::>().join(" "); + s.error("Import failed"); + } else { + s.error(e); + } + } + } + } + let Some(mode) = &mut self.mode else { return }; + if let Mode::Export { preselect: pre @ Some(_), selected, .. } = mode { + if s.profiles.is_empty() { + s.info("No profiles to export."); + self.mode = None; + return; + } + let ids = pre.take().unwrap_or_default(); + *selected = if ids.is_empty() { s.profiles.iter().map(|p| p.id.clone()).collect() } else { ids.into_iter().collect() }; + } + let busy = self.job.is_some(); + let mut close = false; + let mut start: Option Result + Send>> = None; + let r = egui::Modal::new(egui::Id::new("transfer")).show(ctx, |ui| { + let t = Tokens::get(ui.ctx()); + match mode { + Mode::Export { selected, pw, confirm, .. } => { + ui.set_width(440.0); + title(ui, icon::EXPORT, "Export Profiles (Encrypted)"); + ui.horizontal(|ui| { + ui.label(RichText::new("Select profiles to export").color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("None").clicked() { + selected.clear(); + } + if ui.small_button("All").clicked() { + *selected = s.profiles.iter().map(|p| p.id.clone()).collect(); + } + }); + }); + egui::Frame::new().fill(t.field).corner_radius(t.radius).inner_margin(6).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(200.0).auto_shrink([false, true]).show(ui, |ui| { + for p in &s.profiles { + let mut on = selected.contains(&p.id); + let name = if p.display_name.is_empty() { &p.host } else { &p.display_name }; + let label = format!("{name} ({})", p.protocol.id()); + if ui.checkbox(&mut on, label).changed() { + if on { + selected.insert(p.id.clone()) + } else { + selected.remove(&p.id) + }; + } + } + }); + }); + ui.add_space(4.0); + ui.label("Export password"); + ui.add(egui::TextEdit::singleline(pw).password(true).hint_text("Enter a strong password").desired_width(f32::INFINITY)); + crate::settings::strength_meter(ui, pw); + widgets::hint(ui, "This password will be required to import the file."); + ui.label("Confirm password"); + ui.add(egui::TextEdit::singleline(confirm).password(true).hint_text("Confirm password").desired_width(f32::INFINITY)); + if buttons(ui, busy, icon::EXPORT, "Export", &mut close) { + if pw.is_empty() { + s.error("Please enter a password."); + } else if pw != confirm { + s.error("Passwords do not match."); + } else if selected.is_empty() { + s.error("Please select at least one profile."); + } else if let Some(path) = rfd::FileDialog::new() + .set_title("Export Profiles") + .set_file_name(format!("nexus-profiles.{EXTENSION}")) + .add_filter("Nexus Export", &[EXTENSION]) + .save_file() + { + // Keep the sidebar's order. + let ids: Vec = s.profiles.iter().filter(|p| selected.contains(&p.id)).map(|p| p.id.clone()).collect(); + let (svc, pw) = (s.svc.clone(), pw.clone()); + start = Some(Box::new(move || { + svc.export_encrypted(&ids, &pw, &path).map(|p| Done::Exported(ids.len(), p)).map_err(|e| format!("Export failed: {e}")) + })); + } + } + } + Mode::Import { path, pw, strategy, status } => { + ui.set_width(420.0); + title(ui, icon::DOWNLOAD_SIMPLE, "Import Profiles (Encrypted)"); + file_line(ui, path); + ui.label("Decryption password"); + let r = ui.add(egui::TextEdit::singleline(pw).password(true).hint_text("Enter the export password").desired_width(f32::INFINITY)); + if pw.is_empty() && !busy { + r.request_focus(); + } + let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + strategy_combo(ui, strategy); + if !status.is_empty() { + ui.label(RichText::new(status.as_str()).color(t.danger).size(12.0)); + } + if buttons(ui, busy, icon::DOWNLOAD_SIMPLE, "Import", &mut close) || (enter && !busy) { + if pw.is_empty() { + *status = "Please enter the decryption password.".into(); + } else { + status.clear(); + let (svc, pw, path, strategy) = (s.svc.clone(), pw.clone(), path.clone(), *strategy); + start = Some(Box::new(move || svc.import_encrypted(&path, &pw, strategy).map(Done::Imported).map_err(|e| e.to_string()))); + } + } + } + Mode::ExportJson { include_secrets } => { + ui.set_width(400.0); + title(ui, icon::FILE_ARROW_UP, "Export Profiles (JSON)"); + ui.label(format!("Exports all {} profile(s) as plain JSON.", s.profiles.len())); + ui.checkbox(include_secrets, "Include secrets (proxy passwords, TOTP secrets)"); + if *include_secrets { + ui.label( + RichText::new(format!("{} The file will contain secrets in plain text. Prefer the encrypted export.", icon::WARNING)) + .color(t.warning) + .size(12.0), + ); + } + if buttons(ui, false, icon::FILE_ARROW_UP, "Export", &mut close) + && let Some(path) = rfd::FileDialog::new() + .set_title("Export Profiles (JSON)") + .set_file_name("nexus-profiles.json") + .add_filter("JSON", &["json"]) + .save_file() + { + match s + .svc + .export_json(*include_secrets) + .map_err(|e| e.to_string()) + .and_then(|j| nexus_core::secrets::write_private(&path, j.as_bytes()).map_err(|e| e.to_string())) + { + Ok(()) => { + s.success(format!("Exported {} profile(s) to {}", s.profiles.len(), path.display())); + close = true; + } + Err(e) => s.error(format!("Export failed: {e}")), + } + } + } + Mode::ImportJson { path, strategy } => { + ui.set_width(400.0); + title(ui, icon::FILE_ARROW_DOWN, "Import Profiles (JSON)"); + file_line(ui, path); + strategy_combo(ui, strategy); + if buttons(ui, false, icon::FILE_ARROW_DOWN, "Import", &mut close) { + match std::fs::read_to_string(&path).map_err(|e| e.to_string()).and_then(|j| s.svc.import_json(&j, strategy).map_err(|e| e.to_string())) + { + Ok(n) => { + s.success(format!("Imported {n} profile(s)")); + s.request(Request::ReloadProfiles); + close = true; + } + Err(e) => s.error(format!("Import failed: {e}")), + } + } + } + } + }); + if let Some(f) = start { + self.job = Some(Job::spawn(ctx, f)); + } + if (close || r.should_close()) && self.job.is_none() { + self.mode = None; + } + } +} + +fn title(ui: &mut Ui, glyph: &str, text: &str) { + ui.label(RichText::new(format!("{glyph} {text}")).font(theme::semibold(15.0))); + ui.add_space(4.0); +} + +fn file_line(ui: &mut Ui, path: &std::path::Path) { + let t = Tokens::get(ui.ctx()); + let name = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into()); + ui.label(RichText::new(format!("{} {name}", icon::FILE)).color(t.text_dim)).on_hover_text(path.display().to_string()); +} + +fn strategy_combo(ui: &mut Ui, strategy: &mut &'static str) { + ui.horizontal(|ui| { + ui.label("If a profile already exists"); + let cur = STRATEGIES.iter().find(|s| s.0 == *strategy).map_or("", |s| s.1); + egui::ComboBox::from_id_salt("import-strategy").selected_text(cur).show_ui(ui, |ui| { + for (id, label) in STRATEGIES { + ui.selectable_value(strategy, id, label); + } + }); + }); +} + +/// Primary + Cancel row; true when the primary button is clicked. +fn buttons(ui: &mut Ui, busy: bool, glyph: &str, label: &str, close: &mut bool) -> bool { + ui.add_space(8.0); + let mut go = false; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(8.0, 0.0); + if busy { + ui.spinner(); + ui.label("Working…"); + } else { + go = widgets::primary(ui, glyph, label).clicked(); + if ui.button("Cancel").clicked() { + *close = true; + } + } + }); + go +} diff --git a/rust/app/src/triggers.rs b/rust/app/src/triggers.rs new file mode 100644 index 0000000..831bbb4 --- /dev/null +++ b/rust/app/src/triggers.rs @@ -0,0 +1,328 @@ +//! Trigger rules: match terminal output lines and notify / highlight / beep (port of triggers.js). +//! Rules live in `triggers.json` in the data directory (the web build's localStorage rules are +//! not migrated). + +use crate::state::Shared; +use crate::theme::{self, Tokens}; +use crate::widgets; +use egui::RichText; +use egui_phosphor::regular as icon; +use regex::{Regex, RegexBuilder}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::LazyLock; + +const ACTIONS: [(&str, &str); 4] = [("notify", "Notify"), ("highlight", "Highlight tab"), ("both", "Notify + Highlight"), ("sound", "Sound")]; + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Rule { + pub name: String, + pub pattern: String, + pub is_regex: bool, + pub action: String, + pub case_sensitive: bool, + pub enabled: bool, + #[serde(skip)] + re: Option, +} + +impl Rule { + fn compile(&mut self) -> Result<(), regex::Error> { + self.re = None; + if self.is_regex { + self.re = Some(RegexBuilder::new(&self.pattern).case_insensitive(!self.case_sensitive).build()?); + } + Ok(()) + } + + /// Whether `line` (already ANSI-stripped) matches. An invalid regex never matches. + pub fn matches(&self, line: &str) -> bool { + if self.is_regex { + self.re.as_ref().is_some_and(|r| r.is_match(line)) + } else if self.case_sensitive { + line.contains(&self.pattern) + } else { + line.to_lowercase().contains(&self.pattern.to_lowercase()) + } + } +} + +/// Strips CSI sequences exactly as the JS did (`/\x1b\[[0-9;]*[a-zA-Z]/g`). +pub fn strip_ansi(line: &str) -> std::borrow::Cow<'_, str> { + static RE: LazyLock = LazyLock::new(|| Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").unwrap()); + RE.replace_all(line, "") +} + +/// Appends `data` to the session buffer and returns every completed line; the trailing partial +/// line stays buffered. +pub fn complete_lines(buf: &mut Vec, data: &[u8]) -> Vec { + buf.extend_from_slice(data); + let Some(last) = buf.iter().rposition(|&b| b == b'\n') else { + // ponytail: a line with no newline is capped at 64 KiB (keeps the tail) so a stream + // without newlines can't grow forever. + if buf.len() > 64 * 1024 { + buf.drain(..buf.len() - 64 * 1024); + } + return vec![]; + }; + let rest = buf.split_off(last + 1); + let done = std::mem::replace(buf, rest); + done[..done.len() - 1].split(|&b| b == b'\n').map(|l| String::from_utf8_lossy(l).into_owned()).collect() +} + +enum Form { + New(Rule), + Edit(usize, Rule), +} + +#[derive(Default)] +pub struct Triggers { + open: bool, + rules: Option>, + buffers: HashMap>, + form: Option
, +} + +fn path(s: &Shared) -> PathBuf { + s.svc.config.data_dir.join("triggers.json") +} + +impl Triggers { + pub fn open(&mut self) { + self.open = true; + } + + fn rules(&mut self, s: &Shared) -> &mut Vec { + self.rules.get_or_insert_with(|| { + let mut v: Vec = std::fs::read(path(s)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); + v.iter_mut().for_each(|r| drop(r.compile())); + v + }) + } + + fn save(&mut self, s: &mut Shared) { + let json = serde_json::to_vec_pretty(self.rules(s)).unwrap_or_default(); + if let Err(e) = std::fs::write(path(s), json) { + s.error(format!("Failed to save trigger rules: {e}")); + } + } + + /// Feeds session output; fires matching rules (toasts, highlight request, bell). + /// Returns true if the tab should be highlighted. + pub fn check(&mut self, s: &mut Shared, session: &str, data: &[u8]) -> bool { + if self.rules(s).is_empty() { + return false; + } + let lines = complete_lines(self.buffers.entry(session.to_owned()).or_default(), data); + let mut highlight = false; + let label = s + .pane(session) + .map(|p| if p.info.tab_name.is_empty() { p.info.profile_name.clone() } else { p.info.tab_name.clone() }) + .unwrap_or_else(|| session.to_owned()); + let mut fired: Vec<(String, String)> = vec![]; + for line in &lines { + let clean = strip_ansi(line); + for r in self.rules(s).iter().filter(|r| r.enabled && r.matches(&clean)) { + let name = if r.name.is_empty() { r.pattern.clone() } else { r.name.clone() }; + fired.push((r.action.clone(), name)); + } + } + for (action, name) in fired { + let msg = format!("[{label}] Trigger: {name}"); + match action.as_str() { + "notify" => s.info(msg), + "highlight" => highlight = true, + "both" => { + s.info(msg); + highlight = true; + } + "sound" => { + s.ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational)); + s.info(format!("{} {msg}", icon::BELL_RINGING)); + } + _ => {} + } + } + highlight + } + + pub fn cleanup(&mut self, session: &str) { + self.buffers.remove(session); + } + + pub fn ui(&mut self, ctx: &egui::Context, s: &mut Shared) { + if !self.open { + return; + } + let mut open = self.open; + egui::Window::new(format!("{} Trigger Rules", icon::LIGHTNING)) + .id(egui::Id::new("triggers")) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) + .open(&mut open) + .collapsible(false) + .default_size([620.0, 380.0]) + .show(ctx, |ui| { + widgets::hint(ui, "Rules match against terminal output and fire actions when a pattern is detected."); + ui.add_space(4.0); + if self.form.is_some() { + self.form_ui(ui, s); + } else { + self.list_ui(ui, s); + } + }); + self.open = open; + } + + fn list_ui(&mut self, ui: &mut egui::Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + if widgets::primary(ui, icon::PLUS, "Add Rule").clicked() { + self.form = Some(Form::New(Rule { action: "notify".into(), enabled: true, ..Default::default() })); + } + ui.add_space(4.0); + let (mut changed, mut edit, mut delete) = (false, None, None); + let rules = self.rules(s); + if rules.is_empty() { + widgets::empty(ui, icon::LIGHTNING, "No trigger rules defined", "Add a rule to watch terminal output"); + return; + } + egui::ScrollArea::vertical().auto_shrink([false, true]).show(ui, |ui| { + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); + egui::Grid::new("trigger-rules").num_columns(4).striped(true).spacing([12.0, 6.0]).show(ui, |ui| { + for (i, r) in rules.iter_mut().enumerate() { + changed |= ui.checkbox(&mut r.enabled, "").on_hover_text("Enabled").changed(); + ui.vertical(|ui| { + ui.label(RichText::new(if r.name.is_empty() { &r.pattern } else { &r.name }).font(theme::semibold(13.0))); + let action = ACTIONS.iter().find(|a| a.0 == r.action).map_or(r.action.as_str(), |a| a.1); + let meta = format!( + "{} · {action} · {}", + if r.is_regex { "Regex" } else { "Text" }, + if r.case_sensitive { "Case-sensitive" } else { "Case-insensitive" } + ); + ui.label(RichText::new(meta).size(11.5).color(t.text_dim)); + }); + ui.label(RichText::new(&r.pattern).font(theme::mono(12.0)).color(if r.is_regex && r.re.is_none() { t.danger } else { t.text })); + ui.horizontal(|ui| { + if widgets::icon_button(ui, icon::PENCIL_SIMPLE, "Edit").clicked() { + edit = Some(i); + } + if widgets::icon_button(ui, icon::TRASH, "Delete").clicked() { + delete = Some(i); + } + }); + ui.end_row(); + } + }); + }); + if let Some(i) = delete { + rules.remove(i); + changed = true; + } else if let Some(i) = edit { + let rule = rules[i].clone(); + self.form = Some(Form::Edit(i, rule)); + } + if changed { + self.save(s); + } + } + + fn form_ui(&mut self, ui: &mut egui::Ui, s: &mut Shared) { + let Some(form) = &mut self.form else { return }; + let (is_new, r) = match form { + Form::New(r) => (true, r), + Form::Edit(_, r) => (false, r), + }; + egui::Grid::new("trigger-form").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + ui.label("Name (optional)"); + ui.add(egui::TextEdit::singleline(&mut r.name).hint_text("e.g. Error alert").desired_width(320.0)); + ui.end_row(); + ui.label("Pattern"); + ui.add(egui::TextEdit::singleline(&mut r.pattern).hint_text("e.g. ERROR|FATAL|panic").font(theme::mono(12.5)).desired_width(320.0)); + ui.end_row(); + ui.label("Match type"); + egui::ComboBox::from_id_salt("trigger-type").selected_text(if r.is_regex { "Regex" } else { "Plain text" }).show_ui(ui, |ui| { + ui.selectable_value(&mut r.is_regex, false, "Plain text"); + ui.selectable_value(&mut r.is_regex, true, "Regex"); + }); + ui.end_row(); + ui.label("Action"); + let cur = ACTIONS.iter().find(|a| a.0 == r.action).map_or("Notify", |a| a.1); + egui::ComboBox::from_id_salt("trigger-action").selected_text(cur).show_ui(ui, |ui| { + for (id, label) in ACTIONS { + if ui.selectable_label(r.action == id, label).clicked() { + r.action = id.into(); + } + } + }); + ui.end_row(); + ui.label(""); + ui.checkbox(&mut r.case_sensitive, "Case-sensitive"); + ui.end_row(); + }); + ui.add_space(8.0); + let (mut save, mut cancel) = (false, false); + ui.horizontal(|ui| { + save = widgets::primary(ui, icon::CHECK, if is_new { "Add" } else { "Save" }).clicked(); + cancel = ui.button("Cancel").clicked(); + }); + if cancel { + self.form = None; + } else if save { + let mut rule = r.clone(); + rule.name = rule.name.trim().to_owned(); + rule.pattern = rule.pattern.trim().to_owned(); + if rule.pattern.is_empty() { + return s.error("Pattern is required"); + } + if let Err(e) = rule.compile() { + return s.error(format!("Invalid regex: {e}")); + } + let form = self.form.take(); + let rules = self.rules(s); + match form { + Some(Form::Edit(i, _)) if i < rules.len() => rules[i] = rule, + _ => rules.push(rule), + } + self.save(s); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn line_buffering() { + let mut b = vec![]; + assert!(complete_lines(&mut b, b"par").is_empty()); + assert_eq!(complete_lines(&mut b, b"tial\nnext\n\nlast"), ["partial", "next", ""]); + assert_eq!(b, b"last"); + assert_eq!(complete_lines(&mut b, b"\n"), ["last"]); + assert!(b.is_empty()); + // A UTF-8 char split across chunks survives. + let e = "é".as_bytes(); + complete_lines(&mut b, &e[..1]); + assert_eq!(complete_lines(&mut b, &[e[1], b'\n']), ["é"]); + } + + #[test] + fn ansi_strip_and_match() { + assert_eq!(strip_ansi("\x1b[1;31mERROR\x1b[0m: disk\x1b[K"), "ERROR: disk"); + // Only CSI with digits/semicolons is stripped, as in the JS. + assert_eq!(strip_ansi("\x1b[?25lx\x1b]0;t\x07"), "\x1b[?25lx\x1b]0;t\x07"); + let mut r = Rule { pattern: "error|fatal".into(), is_regex: true, enabled: true, ..Default::default() }; + r.compile().unwrap(); + assert!(r.matches(&strip_ansi("\x1b[31mFATAL\x1b[0m"))); + r.case_sensitive = true; + r.compile().unwrap(); + assert!(!r.matches("FATAL")); + let r = Rule { pattern: "Disk Full".into(), ..Default::default() }; + assert!(r.matches("warning: disk full")); + let mut bad = Rule { pattern: "(".into(), is_regex: true, ..Default::default() }; + assert!(bad.compile().is_err()); + assert!(!bad.matches("(")); + } +} diff --git a/rust/app/src/views.rs b/rust/app/src/views.rs new file mode 100644 index 0000000..5b718c2 --- /dev/null +++ b/rust/app/src/views.rs @@ -0,0 +1,46 @@ +//! How a session is drawn. Terminal-style protocols share `TermView`; the rest have their own. + +use crate::github_view::GithubView; +use crate::irc_view::IrcView; +use crate::raw_view::RawView; +use crate::terminal::TermView; +use crate::vnc_view::VncView; + +pub enum View { + Terminal(Box), + Raw(Box), + Irc(Box), + Github(Box), + Vnc(Box), +} + +/// Protocol badge colour (the web build's --protocol-* tokens). +pub fn protocol_color(p: nexus_core::models::Protocol) -> egui::Color32 { + use nexus_core::models::Protocol::*; + let rgb = egui::Color32::from_rgb; + match p { + Ssh => rgb(0x4e, 0xcd, 0xc4), + Telnet => rgb(0xff, 0xa7, 0x26), + Raw => rgb(0xab, 0x47, 0xbc), + Github => rgb(0x7c, 0x8a, 0xff), + Vnc => rgb(0xe8, 0x5d, 0x4a), + Irc => rgb(0xd8, 0xb2, 0x4c), + Local => rgb(0x8b, 0xc3, 0x4a), + Serial => rgb(0x4f, 0xa3, 0xe0), + } +} + +/// Short badge text ("SSH", "TELNET"…). +pub fn protocol_badge(p: nexus_core::models::Protocol) -> &'static str { + use nexus_core::models::Protocol::*; + match p { + Ssh => "SSH", + Telnet => "TELNET", + Raw => "RAW", + Github => "GitHub", + Vnc => "VNC", + Irc => "IRC", + Local => "LOCAL", + Serial => "SERIAL", + } +} diff --git a/rust/app/src/vnc_view.rs b/rust/app/src/vnc_view.rs new file mode 100644 index 0000000..e8a911f --- /dev/null +++ b/rust/app/src/vnc_view.rs @@ -0,0 +1,405 @@ +//! VNC viewer: the remote framebuffer as a texture, with mouse, keyboard and clipboard input. +//! Toolbar after the web build's `vnc.js` (status, Ctrl+Alt+Del, clipboard), plus refresh and a +//! scale toggle. + +use crate::state::Shared; +use crate::theme::{self, Tokens}; +use crate::widgets; +use egui::{Event, Key, Modifiers, Pos2, Rect, RichText, Sense, TextureHandle, Ui, vec2}; +use egui_phosphor::regular as icon; +use nexus_core::clients::vnc::{self, keysym_for}; +use nexus_core::models::Profile; +use std::sync::Arc; + +const XK_SHIFT: u32 = 0xffe1; +const XK_CONTROL: u32 = 0xffe3; +const XK_ALT: u32 = 0xffe9; +const XK_SUPER: u32 = 0xffeb; +const XK_DELETE: u32 = 0xffff; +/// Wheel distance (points) per remote scroll notch for pixel-precise wheels. +const NOTCH: f32 = 40.0; + +pub struct VncView { + pub session: String, + view_only: bool, + scale_to_fit: bool, + ready: bool, + client: Option>, + texture: Option, + fb_size: [usize; 2], + name: String, + /// Modifier keysyms currently held on the remote side. + held: Vec, + buttons: u8, + last_pos: (u16, u16), + wheel: f32, + had_focus: bool, +} + +/// Keysym for a non-printing key (arrows, F-keys, Enter…); printable keys arrive as `Event::Text`. +fn named_keysym(key: Key) -> Option { + let name = key.name(); + if name.chars().count() == 1 || key == Key::Space { + return None; + } + keysym_for(name, None) +} + +/// Keysym for a printable key pressed with Ctrl/Alt/Cmd (egui sends no text for those). +fn chord_keysym(key: Key, shift: bool) -> Option { + if key == Key::Space { + return Some(0x20); + } + let mut chars = key.symbol_or_name().chars(); + let c = chars.next()?; + if chars.next().is_some() { + return None; + } + keysym_for("", Some(if shift { c.to_ascii_uppercase() } else { c.to_ascii_lowercase() })) +} + +/// Modifier keysyms that should be held for `m`. +fn modifier_syms(m: Modifiers) -> Vec { + let mut v = vec![]; + if m.shift { + v.push(XK_SHIFT); + } + if m.ctrl { + v.push(XK_CONTROL); + } + if m.alt { + v.push(XK_ALT); + } + if m.mac_cmd { + v.push(XK_SUPER); + } + v +} + +/// Vertical wheel movement this frame in notches (positive = up). +fn wheel_notches(events: &[Event]) -> f32 { + events + .iter() + .map(|e| match e { + Event::MouseWheel { unit, delta, .. } => match unit { + egui::MouseWheelUnit::Point => delta.y / NOTCH, + egui::MouseWheelUnit::Line => delta.y, + egui::MouseWheelUnit::Page => delta.y * 5.0, + }, + _ => 0.0, + }) + .sum() +} + +/// Pointer button mask: bit 0 left, 1 middle, 2 right. +fn button_mask(left: bool, middle: bool, right: bool) -> u8 { + left as u8 | (middle as u8) << 1 | (right as u8) << 2 +} + +/// A screen position over the drawn image → framebuffer coordinates (clamped). +fn to_remote(p: Pos2, img: Rect, fb: [usize; 2]) -> (u16, u16) { + let sx = fb[0] as f32 / img.width().max(1.0); + let sy = fb[1] as f32 / img.height().max(1.0); + let x = ((p.x - img.left()) * sx).clamp(0.0, fb[0].saturating_sub(1) as f32); + let y = ((p.y - img.top()) * sy).clamp(0.0, fb[1].saturating_sub(1) as f32); + (x as u16, y as u16) +} + +impl VncView { + pub fn new(session: &str, profile: &Profile) -> VncView { + let cfg = profile.vnc.clone().unwrap_or_default(); + VncView { + session: session.into(), + view_only: cfg.view_only, + scale_to_fit: cfg.scale_to_fit, + ready: false, + client: None, + texture: None, + fb_size: [0, 0], + name: String::new(), + held: vec![], + buttons: 0, + last_pos: (u16::MAX, u16::MAX), + wheel: 0.0, + had_focus: false, + } + } + + /// Called on `Event::VncReady` (the client is connected; fetch it via `s.svc.sessions.vnc(id)`). + pub fn on_ready(&mut self) { + self.ready = true; + self.client = None; + } + + fn key(&self, sym: u32, down: bool) { + if let Some(c) = &self.client { + let _ = c.key(sym, down); + } + } + + fn tap(&self, syms: &[u32]) { + syms.iter().for_each(|&k| self.key(k, true)); + syms.iter().rev().for_each(|&k| self.key(k, false)); + } + + fn sync_modifiers(&mut self, want: Vec) { + for k in self.held.clone() { + if !want.contains(&k) { + self.key(k, false); + } + } + for &k in &want { + if !self.held.contains(&k) { + self.key(k, true); + } + } + self.held = want; + } + + fn release_all(&mut self) { + self.sync_modifiers(vec![]); + if self.buttons != 0 + && let Some(c) = &self.client + { + let _ = c.pointer(self.last_pos.0, self.last_pos.1, 0); + } + self.buttons = 0; + } + + fn paste(&self, s: &mut Shared, text: &str) { + if let Some(c) = &self.client + && let Err(e) = c.send_clipboard(text) + { + s.error(format!("Clipboard: {e}")); + } + } + + pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) { + if self.ready && self.client.is_none() { + self.client = s.svc.sessions.vnc(&self.session).ok(); + if let Some(c) = &self.client { + self.name = c.desktop_name(); + } + } + self.upload(ui); + self.toolbar(ui, s); + + let t = Tokens::get(ui.ctx()); + let area = ui.available_rect_before_wrap(); + ui.painter().rect_filled(area, 0.0, t.canvas); + let Some(tex) = self.texture.clone().filter(|_| self.ready) else { + ui.allocate_rect(area, Sense::hover()); + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(area)); + c.vertical_centered(|ui| { + ui.add_space(area.height() / 2.0 - 30.0); + ui.add(egui::Spinner::new().size(22.0)); + ui.label(RichText::new(if self.ready { "Waiting for the first frame…" } else { "Connecting…" }).color(t.text_dim)); + }); + return; + }; + let fb = self.fb_size; + let size = vec2(fb[0] as f32, fb[1] as f32); + if self.scale_to_fit { + ui.allocate_rect(area, Sense::hover()); + let scale = (area.width() / size.x).min(area.height() / size.y); + let img = Rect::from_center_size(area.center(), size * scale); + self.draw(ui, s, &tex, img); + } else { + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(area)); + egui::ScrollArea::both().id_salt(("vnc", &self.session)).auto_shrink([false, false]).show(&mut c, |ui| { + let (img, _) = ui.allocate_exact_size(size, Sense::hover()); + self.draw(ui, s, &tex, img); + }); + ui.allocate_rect(area, Sense::hover()); + } + } + + /// Uploads the framebuffer when the client marked it dirty. + fn upload(&mut self, ui: &Ui) { + let Some(c) = &self.client else { return }; + let fb = c.framebuffer(); + let mut fb = fb.lock().unwrap_or_else(|e| e.into_inner()); + if !fb.dirty || fb.width == 0 || fb.height == 0 { + return; + } + let size = [fb.width as usize, fb.height as usize]; + // Alpha is always 255, so the buffer is already premultiplied. + let img = egui::ColorImage::from_rgba_premultiplied(size, &fb.rgba); + fb.dirty = false; + drop(fb); + self.fb_size = size; + match &mut self.texture { + Some(t) => t.set(img, egui::TextureOptions::LINEAR), + None => self.texture = Some(ui.ctx().load_texture(format!("vnc-{}", self.session), img, egui::TextureOptions::LINEAR)), + } + } + + fn toolbar(&mut self, ui: &mut Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + egui::Frame::new().fill(t.chrome).inner_margin(egui::Margin::symmetric(8, 5)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let (dot, status) = match (self.ready, self.name.is_empty()) { + (false, _) => (t.warning, "Connecting…".to_string()), + (true, true) => (t.success, "Connected".to_string()), + (true, false) => (t.success, format!("Connected — {}", self.name)), + }; + ui.label(RichText::new("●").color(dot).size(10.0)); + ui.label(RichText::new(status).font(theme::semibold(12.5))); + if self.fb_size[0] > 0 { + ui.label(RichText::new(format!("{}×{}", self.fb_size[0], self.fb_size[1])).color(t.text_faint).size(11.5)); + } + if self.view_only { + widgets::chip(ui, "VIEW ONLY", t.warning).on_hover_text("Input is not sent to the remote desktop"); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_enabled_ui(self.ready, |ui| { + let (g, tip) = if self.scale_to_fit { + (icon::CORNERS_IN, "Scaled to fit (click for 1:1)") + } else { + (icon::CORNERS_OUT, "1:1 (click to scale to fit)") + }; + if widgets::icon_button(ui, g, tip).clicked() { + self.scale_to_fit = !self.scale_to_fit; + } + if widgets::icon_button(ui, icon::ARROW_CLOCKWISE, "Refresh the screen").clicked() + && let Some(c) = &self.client + { + let _ = c.request_full_update(); + } + if !self.view_only { + if widgets::button(ui, icon::CLIPBOARD_TEXT, "Paste Clipboard") + .on_hover_text("Send the local clipboard to the remote desktop") + .clicked() + { + match arboard::Clipboard::new().and_then(|mut c| c.get_text()) { + Ok(text) if !text.is_empty() => { + self.paste(s, &text); + s.info("Clipboard sent"); + } + _ => s.warn("The clipboard has no text"), + } + } + if widgets::button(ui, icon::KEYBOARD, "Ctrl+Alt+Del").clicked() { + self.tap(&[XK_CONTROL, XK_ALT, XK_DELETE]); + } + } + }); + }); + }); + }); + } + + fn draw(&mut self, ui: &mut Ui, s: &mut Shared, tex: &TextureHandle, img: Rect) { + ui.painter().image(tex.id(), img, Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), egui::Color32::WHITE); + let id = egui::Id::new(("vnc-input", &self.session)); + let resp = ui.interact(img, id, Sense::click_and_drag()); + if resp.clicked() || resp.drag_started() || resp.secondary_clicked() || resp.middle_clicked() { + resp.request_focus(); + } + let focused = resp.has_focus(); + if focused { + ui.painter().rect_stroke(img, 0.0, egui::Stroke::new(1.0, Tokens::get(ui.ctx()).accent.gamma_multiply(0.6)), egui::StrokeKind::Outside); + ui.memory_mut(|m| m.set_focus_lock_filter(id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })); + } else if self.had_focus { + self.release_all(); + } + self.had_focus = focused; + if self.view_only || self.client.is_none() { + return; + } + self.pointer(ui, &resp, img); + if focused { + self.keyboard(ui, s); + } + } + + fn pointer(&mut self, ui: &Ui, resp: &egui::Response, img: Rect) { + let Some(c) = self.client.clone() else { return }; + let (pos, left, middle, right, scroll) = ui.input(|i| { + let p = &i.pointer; + (p.latest_pos(), p.primary_down(), p.middle_down(), p.secondary_down(), wheel_notches(&i.events)) + }); + let Some(pos) = pos else { return }; + // Only while hovering the image, or while a drag that began on it continues. + if !resp.hovered() && !resp.dragged() && self.buttons == 0 { + return; + } + let buttons = if resp.hovered() || resp.dragged() || self.buttons != 0 { button_mask(left, middle, right) } else { 0 }; + let at = to_remote(pos, img, self.fb_size); + if at != self.last_pos || buttons != self.buttons { + let _ = c.pointer(at.0, at.1, buttons); + self.last_pos = at; + self.buttons = buttons; + } + if resp.hovered() && scroll != 0.0 { + self.wheel += scroll; + while self.wheel.abs() >= 1.0 { + let bit = if self.wheel > 0.0 { 1 << 3 } else { 1 << 4 }; + let _ = c.pointer(at.0, at.1, buttons | bit); + let _ = c.pointer(at.0, at.1, buttons); + self.wheel -= self.wheel.signum(); + } + } + } + + fn keyboard(&mut self, ui: &Ui, s: &mut Shared) { + let (events, mods) = ui.input(|i| (i.events.clone(), i.modifiers)); + self.sync_modifiers(modifier_syms(mods)); + for e in events { + match e { + Event::Key { key, pressed, modifiers, .. } => { + let sym = named_keysym(key) + .or_else(|| (modifiers.ctrl || modifiers.alt || modifiers.mac_cmd).then(|| chord_keysym(key, modifiers.shift)).flatten()); + if let Some(sym) = sym { + self.key(sym, pressed); + } + } + Event::Text(text) => { + for ch in text.chars() { + if let Some(sym) = keysym_for("", Some(ch)) { + self.tap(&[sym]); + } + } + } + // egui turns Ctrl+C/X/V into these; forward the keystroke (and the text for paste). + Event::Copy => self.tap(&[u32::from(b'c')]), + Event::Cut => self.tap(&[u32::from(b'x')]), + Event::Paste(text) => { + self.paste(s, &text); + self.tap(&[u32::from(b'v')]); + } + _ => {} + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_map_to_keysyms() { + assert_eq!(named_keysym(Key::Enter), Some(0xff0d)); + assert_eq!(named_keysym(Key::ArrowLeft), Some(0xff51)); + assert_eq!(named_keysym(Key::F5), Some(0xffc2)); + assert_eq!(named_keysym(Key::A), None); + assert_eq!(named_keysym(Key::Space), None); + assert_eq!(named_keysym(Key::Comma), None); + assert_eq!(chord_keysym(Key::A, false), Some(u32::from(b'a'))); + assert_eq!(chord_keysym(Key::A, true), Some(u32::from(b'A'))); + assert_eq!(chord_keysym(Key::Num1, false), Some(u32::from(b'1'))); + assert_eq!(chord_keysym(Key::Space, false), Some(0x20)); + assert_eq!(modifier_syms(Modifiers::CTRL | Modifiers::SHIFT), vec![XK_SHIFT, XK_CONTROL]); + } + + #[test] + fn pointer_mapping() { + assert_eq!(button_mask(true, false, true), 0b101); + let img = Rect::from_min_size(Pos2::new(100.0, 50.0), vec2(400.0, 300.0)); + assert_eq!(to_remote(Pos2::new(100.0, 50.0), img, [800, 600]), (0, 0)); + assert_eq!(to_remote(Pos2::new(300.0, 200.0), img, [800, 600]), (400, 300)); + assert_eq!(to_remote(Pos2::new(900.0, 900.0), img, [800, 600]), (799, 599)); + } +} diff --git a/rust/app/src/widgets.rs b/rust/app/src/widgets.rs new file mode 100644 index 0000000..d2d2d20 --- /dev/null +++ b/rust/app/src/widgets.rs @@ -0,0 +1,173 @@ +//! Shared widgets: section headers, buttons, chips, key/value grids, virtual lists, empty states. +//! Colours always come from `Tokens`. + +use crate::theme::{self, Tokens}; +use egui::{Align, Color32, Layout, Response, RichText, Sense, Ui, vec2}; + +/// Small uppercase section header with a hairline. +pub fn section(ui: &mut Ui, title: &str) { + let t = Tokens::get(ui.ctx()); + ui.add_space(4.0); + ui.label(RichText::new(title.to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6)); + ui.add_space(1.0); +} + +/// An icon + label button in the secondary style. +pub fn button(ui: &mut Ui, glyph: &str, label: &str) -> Response { + ui.add(egui::Button::new(format!("{glyph} {label}"))) +} + +/// Filled accent button for a panel's primary action. +pub fn primary(ui: &mut Ui, glyph: &str, label: &str) -> Response { + let t = Tokens::get(ui.ctx()); + ui.add(egui::Button::new(RichText::new(format!("{glyph} {label}")).color(t.accent_text)).fill(t.accent)) +} + +/// A square icon-only button with a tooltip. +pub fn icon_button(ui: &mut Ui, glyph: &str, tip: &str) -> Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, t.radius, t.hover); + } + let c = if resp.hovered() { t.text } else { t.text_dim }; + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, glyph, egui::FontId::proportional(15.0), c); + resp.on_hover_text(tip) +} + +pub fn chip(ui: &mut Ui, text: &str, color: Color32) -> Response { + let font = theme::semibold(10.5); + let galley = ui.painter().layout_no_wrap(text.to_owned(), font, color); + let (rect, resp) = ui.allocate_exact_size(galley.size() + vec2(12.0, 4.0), Sense::hover()); + ui.painter().rect_filled(rect, 9.0, color.gamma_multiply(0.18)); + ui.painter().galley(rect.center() - galley.size() / 2.0, galley, color); + resp +} + +/// Two-column key/value grid; clicking a value copies it. +pub fn kv(ui: &mut Ui, id: &str, rows: &[(&str, String)]) { + let t = Tokens::get(ui.ctx()); + egui::Grid::new(id).num_columns(2).spacing(vec2(12.0, 4.0)).striped(false).show(ui, |ui| { + for (k, v) in rows { + ui.label(RichText::new(*k).color(t.text_dim)); + let r = ui.add(egui::Label::new(RichText::new(v).font(theme::mono(12.0))).sense(Sense::click()).wrap()); + if r.on_hover_text("Click to copy").clicked() { + ui.ctx().copy_text(v.clone()); + } + ui.end_row(); + } + }); +} + +/// A virtualised list of `n` rows of height `h`; `row` draws row `i` and returns whether it +/// was clicked. Hover and selection are painted for you. +pub fn rows(ui: &mut Ui, id: &str, n: usize, h: f32, selected: Option, mut row: impl FnMut(&mut Ui, usize)) -> Option { + let t = Tokens::get(ui.ctx()); + let mut clicked = None; + egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, false]).show_rows(ui, h, n, |ui, range| { + for i in range { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click()); + if selected == Some(i) { + ui.painter().rect_filled(rect, 3.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(rect, 3.0, t.hover.gamma_multiply(0.6)); + } else if i % 2 == 1 { + ui.painter().rect_filled(rect, 0.0, t.text.gamma_multiply(0.025)); + } + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(6.0, 0.0))).layout(Layout::left_to_right(Align::Center))); + child.set_clip_rect(rect.intersect(ui.clip_rect())); + // A row never grows: long text truncates (hover shows it in full where the panel adds it). + child.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate); + row(&mut child, i); + if resp.clicked() { + clicked = Some(i); + } + } + }); + clicked +} + +/// Centered empty state. +pub fn empty(ui: &mut Ui, glyph: &str, title: &str, hint: &str) { + let t = Tokens::get(ui.ctx()); + ui.vertical_centered(|ui| { + ui.add_space(28.0); + ui.label(RichText::new(glyph).size(30.0).color(t.text_faint)); + ui.add_space(4.0); + ui.label(RichText::new(title).font(theme::semibold(13.5)).color(t.text_dim)); + ui.label(RichText::new(hint).color(t.text_faint).size(12.0)); + }); +} + +/// Dim helper text. +pub fn hint(ui: &mut Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(RichText::new(text).color(t.text_faint).size(11.5)); +} + +/// A small counter line ("1,234 results"), with an optional truncation note. +pub fn count(ui: &mut Ui, n: usize, noun: &str, capped: bool) { + let t = Tokens::get(ui.ctx()); + let s = format!("{} {noun}{}", group(n as u64), if capped { " (capped)" } else { "" }); + ui.label(RichText::new(s).color(if capped { t.warning } else { t.text_dim }).size(11.5)); +} + +/// 1234567 → "1,234,567" +pub fn group(n: u64) -> String { + let s = n.to_string(); + let mut out = String::new(); + for (i, c) in s.chars().enumerate() { + if i > 0 && (s.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(c); + } + out +} + +/// Whether `url` is a plain web link. Links that arrive from the network (update manifests, +/// GitHub API responses) are only handed to the system opener when they are http(s), so a +/// hostile or spoofed server can't launch `file:`, `ssh:` or custom-scheme handlers. +pub fn is_web_url(url: &str) -> bool { + let u = url.trim(); + (u.len() > 8 && u[..8].eq_ignore_ascii_case("https://")) || (u.len() > 7 && u[..7].eq_ignore_ascii_case("http://")) +} + +/// Opens `url` in the browser if it is a web link; returns false (and opens nothing) otherwise. +pub fn open_web_url(ctx: &egui::Context, url: &str) -> bool { + if !is_web_url(url) { + eprintln!("refusing to open non-web URL {url:?}"); + return false; + } + ctx.open_url(egui::OpenUrl::new_tab(url.trim())); + true +} + +pub fn human_size(n: u64) -> String { + const U: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut v = n as f64; + let mut i = 0; + while v >= 1024.0 && i < 4 { + v /= 1024.0; + i += 1; + } + if i == 0 { format!("{n} B") } else { format!("{v:.1} {}", U[i]) } +} + +#[cfg(test)] +mod tests { + #[test] + fn helpers() { + assert_eq!(super::group(1234567), "1,234,567"); + } + + #[test] + fn only_web_urls_are_opened() { + for ok in ["https://dixon.cx/downloads/nexus/", "http://localhost:3000/x", " HTTPS://Example.com "] { + assert!(super::is_web_url(ok), "{ok}"); + } + for bad in ["", "https://", "file:///etc/passwd", "ssh://evil", "javascript:alert(1)", "ms-msdt:/id x", "ftp://host/", "//host/path"] { + assert!(!super::is_web_url(bad), "{bad}"); + } + } +} diff --git a/rust/core/Cargo.toml b/rust/core/Cargo.toml new file mode 100644 index 0000000..e5bfeab --- /dev/null +++ b/rust/core/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "nexus-core" +version.workspace = true +edition.workspace = true + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +rusqlite = { version = "0.37", features = ["bundled"] } +uuid = { version = "1", features = ["v4"] } +chrono = { version = "0.4", features = ["serde"] } +argon2 = "0.5" +aes-gcm = "0.10" +rand = "0.8" +base64 = "0.22" +ureq = "3" +keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "tokio", "crypto-rust"] } +russh = { version = "0.64.1", default-features = false, features = ["flate2", "ring", "rsa"] } +russh-sftp = "3.0.1" +tokio = { version = "1.53.2", features = ["full"] } +sha2 = "0.11.0" +hmac = "0.13.0" +sha1 = "0.11.0" +zeroize = "1" +portable-pty = "0.9.0" +serialport = { version = "4", default-features = false } +des = "0.9" +flate2 = "1" +image = { version = "0.25", default-features = false, features = ["jpeg"] } + +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +webpki-roots = "1" + +[dev-dependencies] +tiny_http = "0.12" + +[target.'cfg(target_os = "linux")'.dev-dependencies] +libc = "0.2" diff --git a/rust/core/examples/demo_data.rs b/rust/core/examples/demo_data.rs new file mode 100644 index 0000000..806b881 --- /dev/null +++ b/rust/core/examples/demo_data.rs @@ -0,0 +1,181 @@ +//! Fills a data directory with demo profiles, snippets, known hosts, trigger rules and recordings +//! for the user-guide screenshots (docs/guide/screenshots.sh). +//! +//! cargo run -p nexus-core --example demo_data -- DATA_DIR SSH_KEY + +use nexus_core::config::AppConfig; +use nexus_core::models::*; +use nexus_core::service::Service; + +fn main() { + let mut args = std::env::args().skip(1); + let (Some(dir), Some(key)) = (args.next(), args.next()) else { + eprintln!("usage: demo_data DATA_DIR SSH_KEY"); + std::process::exit(2); + }; + let config = AppConfig::in_dir(dir.into()); + std::fs::create_dir_all(&config.vault_dir).unwrap(); + std::fs::create_dir_all(&config.recordings_dir).unwrap(); + let svc = Service::open(config).unwrap(); + let user = std::env::var("USER").unwrap_or_else(|_| "andy".into()); + + let mk = |proto, name: &str, host: &str, port: i64, folder: &str, fav: bool| { + let mut p = Profile::new(proto); + p.display_name = name.into(); + p.host = host.into(); + if port != 0 { + p.port = port; + } + p.folder = folder.into(); + p.favourite = fav; + p + }; + let ssh = |name: &str, folder: &str, fav: bool, tags: &[&str]| { + let mut p = mk(Protocol::Ssh, name, "127.0.0.1", 42022, folder, fav); + p.username = user.clone(); + p.tags = tags.iter().map(|t| t.to_string()).collect(); + let s = p.ssh.as_mut().unwrap(); + s.private_key_path = key.clone(); + s.auth_methods = vec!["publickey".into()]; + s.host_key_policy = HostKeyPolicy::AcceptNew; + p + }; + + let mut profiles = vec![]; + let mut atlas = ssh("atlas", "Production", true, &["web", "eu-west"]); + atlas.description = "Primary web node".into(); + atlas.notes = "Deploys go out from here. Be nice to it.".into(); + profiles.push(atlas); + let mut db = ssh("db-primary", "Production", false, &["db"]); + db.description = "Postgres primary, via the bastion".into(); + db.ssh.as_mut().unwrap().jump_hosts = vec![JumpHost { + host: "bastion.example.net".into(), + port: 22, + username: user.clone(), + auth_method: "publickey".into(), + secret_mode: SecretMode::Saved, + private_key_path: key.clone(), + }]; + db.ssh.as_mut().unwrap().port_forwards = vec![PortForward { + id: new_id(), + direction: ForwardDirection::Local, + bind_host: "127.0.0.1".into(), + bind_port: 15432, + dest_host: "127.0.0.1".into(), + dest_port: 5432, + enabled: true, + label: "Postgres".into(), + }]; + profiles.push(db); + let mut hello = ssh("hello-ssh", "Production", false, &["demo"]); + hello.ssh.as_mut().unwrap().host_key_policy = HostKeyPolicy::Ask; + hello.description = "Same box, but asks about its host key".into(); + profiles.push(hello); + profiles.push(ssh("build-runner", "Lab", false, &["ci"])); + + let mut router = mk(Protocol::Telnet, "Core router", "192.0.2.1", 0, "Lab", false); + router.description = "Out-of-band console".into(); + profiles.push(router); + let mut console = mk(Protocol::Serial, "Switch console", "/dev/ttyUSB0", 0, "Lab", false); + console.serial.as_mut().unwrap().baud_rate = 9600; + profiles.push(console); + let mut sensor = mk(Protocol::Raw, "Sensor feed", "127.0.0.1", 42023, "Lab", false); + let raw = sensor.raw.as_mut().unwrap(); + raw.show_timestamps = true; + raw.show_direction = true; + raw.line_ending = LineEnding::Lf; + raw.payload_presets = vec![ + PayloadPreset { id: new_id(), name: "Status".into(), payload: "U1RBVFVT".into(), format: "text".into() }, + PayloadPreset { id: new_id(), name: "Reset".into(), payload: "UkVTRVQ=".into(), format: "text".into() }, + ]; + profiles.push(sensor); + + let mut irc = mk(Protocol::Irc, "Libera Chat", "127.0.0.1", 42667, "Home", true); + let ic = irc.irc.as_mut().unwrap(); + ic.nick = "andy".into(); + ic.real_name = "Andy Dixon".into(); + ic.use_tls = false; + ic.auto_join_rooms = vec!["#nexus".into(), "#rust".into()]; + profiles.push(irc); + let mut gh = mk(Protocol::Github, "GitHub", "github.com", 0, "Home", false); + gh.github.as_mut().unwrap().base_url = "http://127.0.0.1:42080".into(); + profiles.push(gh); + let mut vnc = mk(Protocol::Vnc, "Studio desktop", "127.0.0.1", 42900, "Home", false); + vnc.vnc.as_mut().unwrap().vnc_host = "127.0.0.1".into(); + vnc.vnc.as_mut().unwrap().vnc_port = 42900; + vnc.vnc.as_mut().unwrap().scale_to_fit = true; + profiles.push(vnc); + + for p in profiles { + svc.create_profile(p).unwrap(); + } + + for (name, cmd, desc, folder) in [ + ("Disk usage", "df -h | sort -k5 -r | head", "Fullest filesystems first", "Diagnostics"), + ("Who's hogging CPU", "ps aux --sort=-%cpu | head -n 8", "Top processes by CPU", "Diagnostics"), + ("Tail a service", "journalctl -fu {{service}}", "Asks which service", "Logs"), + ("Stamp the log", "echo '--- {{hostname}} {{date}} {{time}} ---'", "Drop a marker in the scrollback", "Logs"), + ("Restart nginx", "sudo systemctl restart nginx && systemctl status nginx --no-pager", "", "Deploy"), + ] { + svc.save_snippet(Snippet { + id: String::new(), + name: name.into(), + command: cmd.into(), + description: desc.into(), + folder: folder.into(), + tags: vec![], + created_at: now(), + updated_at: now(), + }) + .unwrap(); + } + + for (host, port, kind, fp) in [ + ("bastion.example.net", 22, "ssh-ed25519", "SHA256:x1rT0pV6c1m9QbHq4nWJcF5gk1Yt3uZ3w8sLrM2dE0Q"), + ("192.0.2.40", 22, "ecdsa-sha2-nistp256", "SHA256:9Hq0b2VtZr6y1oQwA7mXJ4sEo5kP8fN3cLdT6uB2iRg"), + ("git.example.org", 2222, "ssh-rsa", "SHA256:Qm7cV2pK9zR1tY5uW3xN8bL0aE6sD4fG2hJ1kM7nP5o"), + ] { + let n = now(); + svc.store + .save_known_host(&KnownHost { + id: String::new(), + host: host.into(), + port, + key_type: kind.into(), + fingerprint: fp.into(), + public_key: String::new(), + first_seen: n, + last_seen: n, + trusted: true, + }) + .unwrap(); + } + + std::fs::write( + svc.config.data_dir.join("triggers.json"), + r#"[{"name":"Errors","pattern":"(?i)\\b(error|fatal|panic)\\b","isRegex":true,"action":"both","caseSensitive":false,"enabled":true}, +{"name":"Build finished","pattern":"BUILD SUCCESSFUL","isRegex":false,"action":"sound","caseSensitive":true,"enabled":true}, +{"name":"Disk nearly full","pattern":"9[0-9]%","isRegex":true,"action":"highlight","caseSensitive":false,"enabled":false}]"#, + ) + .unwrap(); + + let start = 1_791_633_486u64; + for (i, (title, lines)) in [ + ("atlas — deploy", &["$ ./deploy.sh\r\n", "\x1b[32m✔\x1b[0m built in 41s\r\n", "\x1b[32m✔\x1b[0m rolled out to 4/4 nodes\r\n"][..]), + ("Switch console", &["Switch> enable\r\n", "Switch# show version\r\n", "Uptime is 212 days\r\n"][..]), + ("Local Machine", &["$ cargo test\r\n", "test result: \x1b[32mok\x1b[0m. 135 passed\r\n"][..]), + ] + .iter() + .enumerate() + { + let mut body = format!( + "{{\"version\":2,\"width\":100,\"height\":30,\"timestamp\":{},\"title\":\"{title}\",\"env\":{{\"TERM\":\"xterm-256color\"}}}}\n", + start - i as u64 * 86_400 * 3 + ); + for (j, l) in lines.iter().enumerate() { + body += &format!("[{}.5,\"o\",{}]\n", j * 2, serde_json::to_string(l).unwrap()); + } + std::fs::write(svc.config.recordings_dir.join(format!("demo-{i}.cast")), body).unwrap(); + } + println!("demo data written to {}", svc.config.data_dir.display()); +} diff --git a/internal/storage/migrations/001_initial.sql b/rust/core/migrations/001_initial.sql similarity index 100% rename from internal/storage/migrations/001_initial.sql rename to rust/core/migrations/001_initial.sql diff --git a/internal/storage/migrations/002_github_protocol.sql b/rust/core/migrations/002_github_protocol.sql similarity index 100% rename from internal/storage/migrations/002_github_protocol.sql rename to rust/core/migrations/002_github_protocol.sql diff --git a/internal/storage/migrations/003_snippets.sql b/rust/core/migrations/003_snippets.sql similarity index 100% rename from internal/storage/migrations/003_snippets.sql rename to rust/core/migrations/003_snippets.sql diff --git a/internal/storage/migrations/004_vnc_protocol.sql b/rust/core/migrations/004_vnc_protocol.sql similarity index 100% rename from internal/storage/migrations/004_vnc_protocol.sql rename to rust/core/migrations/004_vnc_protocol.sql diff --git a/internal/storage/migrations/005_irc_protocol.sql b/rust/core/migrations/005_irc_protocol.sql similarity index 100% rename from internal/storage/migrations/005_irc_protocol.sql rename to rust/core/migrations/005_irc_protocol.sql diff --git a/internal/storage/migrations/006_irc_channel_state.sql b/rust/core/migrations/006_irc_channel_state.sql similarity index 100% rename from internal/storage/migrations/006_irc_channel_state.sql rename to rust/core/migrations/006_irc_channel_state.sql diff --git a/rust/core/migrations/007_serial_protocol.sql b/rust/core/migrations/007_serial_protocol.sql new file mode 100644 index 0000000..e52ae88 --- /dev/null +++ b/rust/core/migrations/007_serial_protocol.sql @@ -0,0 +1,108 @@ +-- Migration 007: Add Serial protocol support +-- Adds the 'serial' value to the protocol CHECK constraint and a serial_config column. + +CREATE TABLE IF NOT EXISTS profiles_new ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT DEFAULT '', + protocol TEXT NOT NULL CHECK( + protocol IN ('ssh', 'telnet', 'raw', 'github', 'vnc', 'irc', 'serial') + ), + host TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT DEFAULT '', + tags TEXT DEFAULT '[]', + folder TEXT DEFAULT '', + environment TEXT DEFAULT '', + notes TEXT DEFAULT '', + favourite INTEGER DEFAULT 0, + last_connected TEXT, + connect_timeout INTEGER DEFAULT 30, + read_timeout INTEGER DEFAULT 0, + terminal_font_family TEXT DEFAULT '', + terminal_font_size INTEGER DEFAULT 14, + terminal_scrollback INTEGER DEFAULT 10000, + terminal_cursor_style TEXT DEFAULT 'block', + ssh_config TEXT, + telnet_config TEXT, + raw_config TEXT, + github_config TEXT, + vnc_config TEXT, + irc_config TEXT, + serial_config TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT + OR IGNORE INTO profiles_new ( + id, + display_name, + description, + protocol, + host, + port, + username, + tags, + folder, + environment, + notes, + favourite, + last_connected, + connect_timeout, + read_timeout, + terminal_font_family, + terminal_font_size, + terminal_scrollback, + terminal_cursor_style, + ssh_config, + telnet_config, + raw_config, + github_config, + vnc_config, + irc_config, + serial_config, + created_at, + updated_at + ) +SELECT + id, + display_name, + description, + protocol, + host, + port, + username, + tags, + folder, + environment, + notes, + favourite, + last_connected, + connect_timeout, + read_timeout, + terminal_font_family, + terminal_font_size, + terminal_scrollback, + terminal_cursor_style, + ssh_config, + telnet_config, + raw_config, + github_config, + vnc_config, + irc_config, + NULL, + created_at, + updated_at +FROM + profiles; + +DROP TABLE profiles; + +ALTER TABLE + profiles_new RENAME TO profiles; + +INSERT INTO + schema_version (version, applied_at) +VALUES + (7, datetime('now')); diff --git a/rust/core/src/clients/github.rs b/rust/core/src/clients/github.rs new file mode 100644 index 0000000..4c41275 --- /dev/null +++ b/rust/core/src/clients/github.rs @@ -0,0 +1,512 @@ +//! Lightweight GitHub REST client for repository metadata and archive downloads. Works with +//! github.com and GitHub Enterprise via a configurable API base URL. + +use std::io::{Read, Write}; +use std::path::Path; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use chrono::{DateTime, FixedOffset, SecondsFormat}; +use serde::Deserialize; + +use crate::models::GitHubRepo; + +const MAX_JSON_BODY_BYTES: u64 = 4 << 20; +const MAX_ERROR_BODY_BYTES: u64 = 64 << 10; +const MAX_ARCHIVE_BYTES: u64 = 2 << 30; +const USER_AGENT: &str = "NexusTerminal/1.0"; +const COMMIT_LOOKUP_WORKERS: usize = 5; + +pub struct Client { + base_url: String, + token: String, + agent: ureq::Agent, +} + +#[derive(Deserialize)] +struct ApiRepo { + #[serde(default)] + full_name: Option, + #[serde(default)] + name: Option, + #[serde(default)] + description: Option, + #[serde(default)] + html_url: Option, + #[serde(default)] + default_branch: Option, + #[serde(default)] + private: Option, + #[serde(default)] + fork: Option, + #[serde(default)] + archived: Option, + #[serde(default)] + language: Option, + #[serde(default)] + stargazers_count: Option, + #[serde(default)] + pushed_at: Option, + #[serde(default)] + updated_at: Option, + #[serde(default)] + owner: Option, +} + +#[derive(Deserialize)] +struct ApiOwner { + #[serde(default)] + login: Option, +} + +#[derive(Deserialize, Default)] +struct ApiPerson { + #[serde(default)] + name: Option, + #[serde(default)] + date: Option, +} + +impl Client { + /// `base_url` is the API root, e.g. `https://api.github.com` or `https://ghe.example.com/api/v3`. + pub fn new(base_url: &str, token: &str) -> Client { + let agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(30))).http_status_as_error(false).build().into(); + Client { base_url: base_url.trim_end_matches('/').to_string(), token: token.to_string(), agent } + } + + fn get(&self, url: &str) -> Result, ureq::Error> { + self.agent + .get(url) + .header("Authorization", &format!("Bearer {}", self.token)) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", USER_AGENT) + .call() + } + + /// Confirms the token works and returns the authenticated login. + pub fn validate_token(&self) -> crate::Result { + let mut resp = self.get(&format!("{}/user", self.base_url)).map_err(|e| format!("contacting GitHub API: {e}"))?; + let status = resp.status().as_u16(); + if status != 200 { + return Err(format!("token validation failed (HTTP {status})").into()); + } + let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES).map_err(|e| format!("reading user response: {e}"))?; + #[derive(Deserialize)] + struct User { + #[serde(default)] + login: Option, + } + let user: User = serde_json::from_slice(&body).map_err(|e| format!("decoding user: {e}"))?; + Ok(user.login.unwrap_or_default()) + } + + /// All repositories visible to the token, newest push first, enriched with latest-commit info. + pub fn fetch_repos(&self) -> crate::Result> { + self.fetch_repos_with_progress(|_, _| {}) + } + + /// Like `fetch_repos`, reporting commit-enrichment progress as `(done, total)`; it is called + /// from worker threads. + pub fn fetch_repos_with_progress(&self, progress: impl Fn(usize, usize) + Sync) -> crate::Result> { + let mut all: Vec = Vec::new(); + let mut page = 1; + loop { + let url = format!("{}/user/repos?per_page=100&page={page}&sort=pushed&direction=desc", self.base_url); + let mut resp = self.get(&url).map_err(|e| format!("fetching repos page {page}: {e}"))?; + let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES).map_err(|e| format!("reading response body: {e}"))?; + let status = resp.status().as_u16(); + if status == 401 || status == 403 { + return Err(format!("authentication failed (HTTP {status}) — check your access token").into()); + } + if status != 200 { + return Err(format!("GitHub API returned HTTP {status}: {}", String::from_utf8_lossy(&body)).into()); + } + let batch: Vec = serde_json::from_slice(&body).map_err(|e| format!("decoding repos: {e}"))?; + if batch.is_empty() { + break; + } + let short = batch.len() < 100; + all.extend(batch); + // Safety cap — 5000 repos should be more than enough. + if all.len() >= 5000 || short { + break; + } + page += 1; + } + + let mut parsed = Vec::with_capacity(all.len()); + for r in all { + let pushed = parse_time(r.pushed_at.as_deref()).map_err(|e| format!("decoding repos: {e}"))?; + let updated = parse_time(r.updated_at.as_deref()).map_err(|e| format!("decoding repos: {e}"))?; + parsed.push((r, pushed, updated)); + } + parsed.sort_by_key(|p| std::cmp::Reverse(p.1)); + + let mut result: Vec = parsed + .into_iter() + .map(|(r, pushed, updated)| GitHubRepo { + full_name: r.full_name.unwrap_or_default(), + name: r.name.unwrap_or_default(), + owner: r.owner.and_then(|o| o.login).unwrap_or_default(), + description: r.description.unwrap_or_default(), + html_url: r.html_url.unwrap_or_default(), + default_branch: r.default_branch.unwrap_or_default(), + private: r.private.unwrap_or_default(), + fork: r.fork.unwrap_or_default(), + archived: r.archived.unwrap_or_default(), + language: r.language.unwrap_or_default(), + stars: r.stargazers_count.unwrap_or_default(), + pushed_at: format_time(pushed), + updated_at: format_time(updated), + ..Default::default() + }) + .collect(); + + let total = result.len(); + progress(0, total); + + // Enrich with latest commit metadata (best-effort), five lookups at a time. + let next = AtomicUsize::new(0); + let done = AtomicUsize::new(0); + let lookups: Vec<(usize, String, String)> = result + .iter() + .enumerate() + .map(|(i, r)| { + let reference = if r.default_branch.is_empty() { "HEAD" } else { &r.default_branch }; + (i, r.full_name.clone(), reference.to_string()) + }) + .collect(); + let found: Vec<(usize, String, String)> = std::thread::scope(|s| { + let workers: Vec<_> = (0..COMMIT_LOOKUP_WORKERS.min(total)) + .map(|_| { + s.spawn(|| { + let mut out = Vec::new(); + loop { + let i = next.fetch_add(1, Ordering::SeqCst); + let Some((idx, name, reference)) = lookups.get(i) else { break }; + if let Ok((who, when)) = self.fetch_latest_commit(name, reference) { + out.push((*idx, who, when)); + } + progress(done.fetch_add(1, Ordering::SeqCst) + 1, total); + } + out + }) + }) + .collect(); + workers.into_iter().flat_map(|w| w.join().unwrap_or_default()).collect() + }); + for (idx, who, when) in found { + result[idx].latest_committer = who; + result[idx].latest_commit_at = when; + } + Ok(result) + } + + /// Committer name and RFC 3339 commit time for a repo and ref (`HEAD` when empty). + pub fn fetch_latest_commit(&self, full_name: &str, reference: &str) -> crate::Result<(String, String)> { + let reference = if reference.is_empty() { "HEAD" } else { reference }; + let (owner, repo) = split_repo_full_name(full_name)?; + let url = self.endpoint_url(&["repos", &owner, &repo, "commits", reference])?; + let mut resp = self.get(&url).map_err(|e| format!("fetching commit: {e}"))?; + let status = resp.status().as_u16(); + if status == 401 || status == 403 { + return Err(format!("authentication failed (HTTP {status})").into()); + } + if status != 200 { + return Err(format!("GitHub API returned HTTP {status}: {}", read_error_body(resp.body_mut().as_reader())).into()); + } + let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES).map_err(|e| format!("reading commit response: {e}"))?; + + #[derive(Deserialize)] + struct Payload { + #[serde(default)] + commit: Commit, + } + #[derive(Deserialize, Default)] + struct Commit { + #[serde(default)] + committer: Option, + #[serde(default)] + author: Option, + } + let payload: Payload = serde_json::from_slice(&body).map_err(|e| format!("decoding commit: {e}"))?; + let committer = payload.commit.committer.unwrap_or_default(); + let author = payload.commit.author.unwrap_or_default(); + let (mut name, mut date) = (committer.name.unwrap_or_default(), committer.date); + if name.is_empty() { + name = author.name.unwrap_or_default(); + date = author.date; + } + let date = parse_time(date.as_deref()).map_err(|e| format!("decoding commit: {e}"))?; + match date { + Some(d) if !name.is_empty() => Ok((name, format_time(Some(d)))), + _ => Err("commit metadata missing".into()), + } + } + + /// Downloads the repository zipball (default branch when `reference` is empty) to `dest_path`. + pub fn download_repo_archive(&self, full_name: &str, reference: &str, dest_path: &Path) -> crate::Result<()> { + let (owner, repo) = split_repo_full_name(full_name)?; + let mut segments = vec!["repos", &owner, &repo, "zipball"]; + if !reference.is_empty() { + segments.push(reference); + } + let url = self.endpoint_url(&segments)?; + let mut resp = self.get(&url).map_err(|e| format!("downloading archive: {e}"))?; + let status = resp.status().as_u16(); + if status == 401 || status == 403 { + return Err(format!("authentication failed (HTTP {status}) — check your access token").into()); + } + if status != 200 { + return Err(format!("GitHub API returned HTTP {status}: {}", read_error_body(resp.body_mut().as_reader())).into()); + } + let length = resp.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.trim().parse::().ok()); + if let Some(length) = length.filter(|&l| l > MAX_ARCHIVE_BYTES) { + return Err(format!("archive is too large: {length} bytes exceeds {MAX_ARCHIVE_BYTES} bytes").into()); + } + let mut file = std::fs::File::create(dest_path).map_err(|e| format!("creating file: {e}"))?; + copy_limited_archive(&mut file, resp.body_mut().as_reader(), MAX_ARCHIVE_BYTES).map_err(|e| format!("writing archive: {e}"))?; + Ok(()) + } + + /// Base URL plus path-escaped segments. + fn endpoint_url(&self, segments: &[&str]) -> crate::Result { + let mut url = self.base_url.clone(); + for segment in segments { + if segment.is_empty() { + return Err("empty GitHub API path segment".into()); + } + url.push('/'); + url.push_str(&path_escape(segment)); + } + Ok(url) + } +} + +/// Go's `url.PathEscape`. +fn path_escape(segment: &str) -> String { + let mut out = String::new(); + for b in segment.bytes() { + if b.is_ascii_alphanumeric() || b"-_.~$&+:=@".contains(&b) { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +fn parse_time(value: Option<&str>) -> Result>, String> { + match value { + None => Ok(None), + Some(v) => { + let t = DateTime::parse_from_rfc3339(v).map_err(|e| format!("parsing time {v:?}: {e}"))?; + // Go's zero time means "unset". + Ok(Some(t).filter(|t| t.timestamp() != -62135596800)) + } + } +} + +/// Go's `time.Time.Format(time.RFC3339)`, with the zero time for a missing value. +fn format_time(value: Option>) -> String { + match value { + Some(t) => t.to_rfc3339_opts(SecondsFormat::Secs, true), + None => "0001-01-01T00:00:00Z".to_string(), + } +} + +fn read_limited(r: impl Read, max_bytes: u64) -> crate::Result> { + let mut body = Vec::new(); + r.take(max_bytes + 1).read_to_end(&mut body)?; + if body.len() as u64 > max_bytes { + return Err(format!("response body exceeds {max_bytes} bytes").into()); + } + Ok(body) +} + +fn read_error_body(r: impl Read) -> String { + match read_limited(r, MAX_ERROR_BODY_BYTES) { + Ok(body) => String::from_utf8_lossy(&body).trim().to_string(), + Err(e) => format!(""), + } +} + +fn split_repo_full_name(full_name: &str) -> crate::Result<(String, String)> { + let parts: Vec<&str> = full_name.trim().split('/').collect(); + let [owner, repo] = parts.as_slice() else { + return Err("repository name must be in owner/repo format".into()); + }; + let (owner, repo) = (owner.trim(), repo.trim()); + if owner.is_empty() || repo.is_empty() { + return Err("repository name must be in owner/repo format".into()); + } + Ok((owner.to_string(), repo.to_string())) +} + +/// Copies at most `max_bytes`; returns the number copied, or an error once the source exceeds it. +fn copy_limited_archive(dst: &mut impl Write, src: impl Read, max_bytes: u64) -> crate::Result { + let n = std::io::copy(&mut src.take(max_bytes + 1), dst)?; + if n > max_bytes { + return Err(format!("archive exceeds {max_bytes} bytes").into()); + } + Ok(n) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + #[test] + fn read_limited_rejects_oversized_response() { + let err = read_limited(&b"abcd"[..], 3).unwrap_err(); + assert!(err.to_string().contains("response body exceeds"), "{err}"); + } + + #[test] + fn read_limited_allows_response_at_limit() { + assert_eq!(read_limited(&b"abc"[..], 3).unwrap(), b"abc"); + } + + #[test] + fn endpoint_url_escapes_repo_path_segments() { + let client = Client::new("https://ghe.example.com/api/v3", ""); + let got = client.endpoint_url(&["repos", "owner", "repo", "commits", "feature/a b"]).unwrap(); + assert_eq!(got, "https://ghe.example.com/api/v3/repos/owner/repo/commits/feature%2Fa%20b"); + assert!(client.endpoint_url(&["repos", ""]).is_err()); + } + + #[test] + fn split_repo_full_name_rejects_extra_segments() { + assert!(split_repo_full_name("owner/repo/../../user").is_err()); + assert!(split_repo_full_name("owner/").is_err()); + assert_eq!(split_repo_full_name(" a / b ").unwrap(), ("a".into(), "b".into())); + } + + #[test] + fn copy_limited_archive_rejects_oversized_stream() { + let mut dst = Vec::new(); + let err = copy_limited_archive(&mut dst, &b"abcd"[..], 3).unwrap_err(); + assert!(err.to_string().contains("archive exceeds"), "{err}"); + assert_eq!(dst.len(), 4); + } + + #[test] + fn copy_limited_archive_allows_stream_at_limit() { + let mut dst = Vec::new(); + assert_eq!(copy_limited_archive(&mut dst, &b"abc"[..], 3).unwrap(), 3); + assert_eq!(dst, b"abc"); + } + + type Seen = Arc, Option)>>>; + + /// A local HTTP server answering with `handler(url) -> (status, body)`; records + /// (url, authorization, user-agent) for every request. + fn serve(handler: impl Fn(&str) -> (u16, String) + Send + 'static) -> (String, Seen) { + let server = tiny_http::Server::http("127.0.0.1:0").unwrap(); + let base = format!("http://{}", server.server_addr().to_ip().unwrap()); + let seen: Seen = Arc::default(); + let log = seen.clone(); + std::thread::spawn(move || { + for req in server.incoming_requests() { + let header = |name: &'static str| req.headers().iter().find(|h| h.field.equiv(name)).map(|h| h.value.to_string()); + log.lock().unwrap().push((req.url().to_string(), header("Authorization"), header("User-Agent"))); + let (status, body) = handler(req.url()); + let _ = req.respond(tiny_http::Response::from_string(body).with_status_code(status)); + } + }); + (base, seen) + } + + #[test] + fn validate_token_returns_login_and_reports_status() { + let (base, seen) = serve(|_| (200, r#"{"login":"octocat"}"#.into())); + assert_eq!(Client::new(&base, "tok").validate_token().unwrap(), "octocat"); + let (url, auth, ua) = seen.lock().unwrap()[0].clone(); + assert_eq!(url, "/user"); + assert_eq!(auth.as_deref(), Some("Bearer tok")); + assert_eq!(ua.as_deref(), Some(USER_AGENT)); + + let (base, _) = serve(|_| (401, "{}".into())); + let err = Client::new(&base, "bad").validate_token().unwrap_err(); + assert_eq!(err.to_string(), "token validation failed (HTTP 401)"); + } + + fn repo_json(n: usize, pushed: &str) -> serde_json::Value { + serde_json::json!({ + "full_name": format!("me/r{n}"), "name": format!("r{n}"), "description": null, + "html_url": format!("https://example/me/r{n}"), "default_branch": if n == 0 { "" } else { "main" }, + "private": n.is_multiple_of(2), "fork": false, "archived": false, "language": null, + "stargazers_count": n, "pushed_at": pushed, "updated_at": "2024-01-01T00:00:00Z", + "owner": {"login": "me"} + }) + } + + #[test] + fn fetch_repos_paginates_sorts_and_enriches() { + let (base, seen) = serve(|url| { + if url.starts_with("/user/repos") && url.contains("page=1&") { + let repos: Vec<_> = (0..100).map(|n| repo_json(n, &format!("2024-02-{:02}T00:00:00Z", 1 + n % 28))).collect(); + (200, serde_json::to_string(&repos).unwrap()) + } else if url.starts_with("/user/repos") { + (200, serde_json::to_string(&[repo_json(100, "2025-06-01T12:00:00Z")]).unwrap()) + } else if url == "/repos/me/r100/commits/main" { + (200, r#"{"commit":{"committer":{"name":"","date":"0001-01-01T00:00:00Z"},"author":{"name":"Ann","date":"2025-06-01T11:00:00Z"}}}"#.into()) + } else if url.starts_with("/repos/me/r0/commits/HEAD") { + (200, r#"{"commit":{"committer":{"name":"Bob","date":"2024-01-05T10:00:00+00:00"}}}"#.into()) + } else { + (404, "nope".into()) + } + }); + let calls = Mutex::new(Vec::new()); + let repos = Client::new(&base, "t").fetch_repos_with_progress(|done, total| calls.lock().unwrap().push((done, total))).unwrap(); + assert_eq!(repos.len(), 101); + assert_eq!(repos[0].full_name, "me/r100"); + assert_eq!(repos[0].pushed_at, "2025-06-01T12:00:00Z"); + assert_eq!(repos[0].latest_committer, "Ann"); + assert_eq!(repos[0].latest_commit_at, "2025-06-01T11:00:00Z"); + assert!(repos.windows(2).all(|w| w[0].pushed_at >= w[1].pushed_at)); + let r0 = repos.iter().find(|r| r.name == "r0").unwrap(); + assert_eq!((r0.latest_committer.as_str(), r0.latest_commit_at.as_str()), ("Bob", "2024-01-05T10:00:00Z")); + assert_eq!(r0.owner, "me"); + assert!(r0.private && r0.description.is_empty() && r0.language.is_empty()); + assert!(repos.iter().filter(|r| r.latest_committer.is_empty()).count() == 99); + + let mut calls = calls.into_inner().unwrap(); + assert_eq!(calls[0], (0, 101)); + calls.sort(); + assert_eq!(calls.last(), Some(&(101, 101))); + assert_eq!(calls.len(), 102); + assert_eq!(seen.lock().unwrap().iter().filter(|s| s.0.starts_with("/user/repos")).count(), 2); + } + + #[test] + fn fetch_repos_reports_auth_and_api_errors() { + let (base, _) = serve(|_| (403, "{}".into())); + let err = Client::new(&base, "t").fetch_repos().unwrap_err().to_string(); + assert_eq!(err, "authentication failed (HTTP 403) — check your access token"); + + let (base, _) = serve(|_| (500, "boom".into())); + let err = Client::new(&base, "t").fetch_repos().unwrap_err().to_string(); + assert_eq!(err, "GitHub API returned HTTP 500: boom"); + } + + #[test] + fn download_repo_archive_writes_file() { + let (base, seen) = serve(|url| match url { + "/repos/me/r/zipball/v1%2F2" => (200, "ZIPDATA".into()), + _ => (404, " missing \n".into()), + }); + let dir = std::env::temp_dir().join(format!("nexus-gh-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let dest = dir.join("a.zip"); + let client = Client::new(&base, "t"); + client.download_repo_archive("me/r", "v1/2", &dest).unwrap(); + assert_eq!(std::fs::read(&dest).unwrap(), b"ZIPDATA"); + let err = client.download_repo_archive("me/r", "", &dest).unwrap_err().to_string(); + assert_eq!(err, "GitHub API returned HTTP 404: missing"); + assert_eq!(seen.lock().unwrap()[1].0, "/repos/me/r/zipball"); + assert!(client.download_repo_archive("bad", "", &dest).is_err()); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/rust/core/src/clients/irc.rs b/rust/core/src/clients/irc.rs new file mode 100644 index 0000000..833ac4b --- /dev/null +++ b/rust/core/src/clients/irc.rs @@ -0,0 +1,1301 @@ +//! IRC client: registration, PING/PONG, channel/user tracking, CTCP ACTION, NickServ identify, +//! auto-join and the composer's /commands, reporting through typed `Event`s. +//! +//! One I/O thread owns the transport (TCP, or a caller-supplied stream such as an SSH tunnel, +//! optionally wrapped in TLS). It alternates between short reads and draining the outgoing +//! queue, so the transport never has to be split. + +use std::collections::HashMap; +use std::io::{ErrorKind, Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; + +use super::{CloseFn, ErrorFn}; + +pub const SERVER_TARGET: &str = "server"; +const MAX_ROOM_LIST_ENTRIES: usize = 2000; +const QUIT_MESSAGE: &str = "Nexus Terminal disconnected"; +const READ_POLL: Duration = Duration::from_millis(50); +const PING_DELAY: Duration = Duration::from_secs(20); +const PING_TIMEOUT: Duration = Duration::from_secs(60); +const PING_GRACE: Duration = Duration::from_secs(30); +const MAX_LINE_BYTES: usize = 1 << 20; + +/// A bidirectional byte stream. Reads must not block indefinitely: they should return +/// `WouldBlock` or `TimedOut` after a short interval (tens of milliseconds) so the client's +/// I/O thread can interleave writes. +pub trait ReadWrite: Read + Write + Send {} +impl ReadWrite for T {} + +pub type Connector = Arc crate::Result> + Send + Sync>; +pub type EventFn = Arc; +pub type RoomsChangedFn = Arc) + Send + Sync>; + +#[derive(Clone)] +pub struct Config { + pub server: String, + pub port: u16, + pub nick: String, + pub user: String, + pub real_name: String, + pub use_tls: bool, + pub server_pass: String, + pub auto_join_rooms: Vec, + + pub nickserv_identify: bool, + pub nickserv_account: String, + pub nickserv_password: String, + + /// Provides the transport instead of a direct TCP connect (e.g. an SSH tunnel). TLS, when + /// enabled, is layered on top of it. + pub connector: Option, + + pub connect_timeout: Duration, + pub on_event: EventFn, + pub on_error: ErrorFn, + pub on_close: CloseFn, + pub on_rooms_changed: RoomsChangedFn, +} + +impl Default for Config { + fn default() -> Config { + Config { + server: String::new(), + port: 6667, + nick: String::new(), + user: String::new(), + real_name: String::new(), + use_tls: false, + server_pass: String::new(), + auto_join_rooms: Vec::new(), + nickserv_identify: false, + nickserv_account: String::new(), + nickserv_password: String::new(), + connector: None, + connect_timeout: Duration::ZERO, + on_event: Arc::new(|_| {}), + on_error: Arc::new(|_| {}), + on_close: Arc::new(|| {}), + on_rooms_changed: Arc::new(|_| {}), + } + } +} + +/// A typed IRC event, serialised for the UI exactly as Go's `ircclient.Event`. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Event { + #[serde(rename = "type")] + pub kind: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub target: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub from: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub text: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub command: String, + pub timestamp: i64, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub rooms: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub users: Vec, + #[serde(skip_serializing_if = "String::is_empty")] + pub topic: String, +} + +/// A joined or listed IRC room. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct RoomInfo { + pub name: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub topic: String, + #[serde(skip_serializing_if = "is_zero")] + pub users: i64, +} + +fn is_zero(n: &i64) -> bool { + *n == 0 +} + +enum Out { + Line(String), + Quit(String), +} + +struct Channel { + name: String, + topic: String, + users: Vec, +} + +struct State { + nick: String, + connected: bool, + closing: bool, + list_rooms: Vec, + room_names: Vec, + /// Keyed by RFC 1459 casefolded name. + channels: HashMap, + ready: Option>>, + last_pong: Instant, +} + +pub struct Client { + cfg: Config, + state: Mutex, + out: Mutex>>, +} + +/// A parsed server line. +struct Message { + source: String, + command: String, + params: Vec, +} + +impl Message { + fn last(&self) -> &str { + self.params.last().map(String::as_str).unwrap_or("") + } + fn param(&self, i: usize) -> &str { + self.params.get(i).map(String::as_str).unwrap_or("") + } +} + +impl Client { + pub fn new(mut cfg: Config) -> Arc { + if cfg.connect_timeout.is_zero() { + cfg.connect_timeout = Duration::from_secs(30); + } + if cfg.user.is_empty() { + cfg.user = cfg.nick.clone(); + } + if cfg.real_name.is_empty() { + cfg.real_name = cfg.nick.clone(); + } + let nick = cfg.nick.clone(); + Arc::new(Client { + cfg, + state: Mutex::new(State { + nick, + connected: false, + closing: false, + list_rooms: Vec::new(), + room_names: Vec::new(), + channels: HashMap::new(), + ready: None, + last_pong: Instant::now(), + }), + out: Mutex::new(None), + }) + } + + fn st(&self) -> MutexGuard<'_, State> { + self.state.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Connects and blocks until registration succeeds (RPL_WELCOME), fails, or times out. + pub fn connect(self: &Arc) -> crate::Result<()> { + let (ready_tx, ready_rx) = mpsc::channel(); + let (out_tx, out_rx) = mpsc::channel(); + self.st().ready = Some(ready_tx); + *self.out.lock().unwrap_or_else(|e| e.into_inner()) = Some(out_tx); + + let client = self.clone(); + std::thread::spawn(move || client.io_thread(out_rx)); + + let timeout = self.cfg.connect_timeout; + match ready_rx.recv_timeout(timeout) { + Ok(Ok(())) => Ok(()), + Ok(Err(e)) => Err(e.into()), + Err(_) => { + self.close(); + Err(format!("IRC connection timed out after {timeout:?}").into()) + } + } + } + + /// Sends QUIT and disconnects. Idempotent; `on_close` fires from the I/O thread. + pub fn close(&self) { + { + let mut st = self.st(); + if st.closing { + return; + } + st.closing = true; + } + self.queue(Out::Quit(QUIT_MESSAGE.to_string())); + } + + fn queue(&self, out: Out) -> bool { + let guard = self.out.lock().unwrap_or_else(|e| e.into_inner()); + guard.as_ref().is_some_and(|tx| tx.send(out).is_ok()) + } + + /// Sends one IRC line (CR/LF are stripped). + fn send_raw(&self, line: &str) -> crate::Result<()> { + let line: String = line.chars().filter(|c| *c != '\r' && *c != '\n').collect(); + if self.queue(Out::Line(line)) { Ok(()) } else { Err("not connected".into()) } + } + + fn send(&self, command: &str, params: &[&str]) -> crate::Result<()> { + self.send_raw(&encode(command, params)) + } + + /// PRIVMSG/NOTICE, split so each line fits the 512-byte limit. + fn send_text(&self, command: &str, target: &str, text: &str) -> crate::Result<()> { + // ponytail: fixed prefix allowance (nick!user@host) like girc's default; no ISUPPORT tuning. + let max = 510usize.saturating_sub(115 + command.len() + target.len() + 3).max(64); + let mut rest = text; + while !rest.is_empty() { + let mut cut = rest.len().min(max); + while !rest.is_char_boundary(cut) { + cut -= 1; + } + self.send(command, &[target, &rest[..cut]])?; + rest = &rest[cut..]; + } + Ok(()) + } + + fn nick(&self) -> String { + self.st().nick.clone() + } + + /// Parses a composer line: plain text goes to `target`, `/commands` are interpreted. + pub fn handle_input(&self, target: &str, input: &str) -> crate::Result<()> { + let text = input.trim(); + if text.is_empty() { + return Ok(()); + } + let target = if target.is_empty() { SERVER_TARGET } else { target }; + + if !text.starts_with('/') || text == "/" { + if target == SERVER_TARGET { + return Err("select a room or use /msg ".into()); + } + self.send_text("PRIVMSG", target, text)?; + self.emit_local_message("message", target, text); + return Ok(()); + } + + let command_line = text[1..].trim(); + let Some(first) = command_line.split_whitespace().next() else { return Ok(()) }; + let cmd = first.to_lowercase(); + let args = command_line[first.len()..].trim(); + + match cmd.as_str() { + "me" | "action" => { + if target == SERVER_TARGET { + return Err("/me requires an active room or private tab".into()); + } + if args.is_empty() { + return Err("/me requires an action".into()); + } + self.send("PRIVMSG", &[target, &format!("\x01ACTION {args}\x01")])?; + self.emit_local_message("action", target, args); + } + "msg" | "query" => { + let (recipient, body) = split_first_arg(args); + if recipient.is_empty() || body.is_empty() { + return Err("/msg requires a target and message".into()); + } + self.send_text("PRIVMSG", recipient, body)?; + self.emit_local_message("message", recipient, body); + } + "notice" => { + let (recipient, body) = split_first_arg(args); + if recipient.is_empty() || body.is_empty() { + return Err("/notice requires a target and message".into()); + } + self.send_text("NOTICE", recipient, body)?; + self.emit_local_message("notice", recipient, body); + } + "join" | "j" => { + let (room, key) = split_first_arg(args); + if room.is_empty() { + return Err("/join requires a room".into()); + } + if key.is_empty() { + self.send("JOIN", &[room])?; + } else { + self.send("JOIN", &[room, key])?; + } + } + "part" | "leave" => { + let (room, msg) = split_first_arg(args); + let room = if room.is_empty() { target } else { room }; + if room == SERVER_TARGET { + return Err("/part requires a room".into()); + } + if msg.is_empty() { + self.send("PART", &[room])?; + } else { + self.send("PART", &[room, msg])?; + } + } + "quit" => { + let msg = if args.is_empty() { QUIT_MESSAGE } else { args }; + self.queue(Out::Quit(msg.to_string())); + } + "nick" => { + let Some(nick) = args.split_whitespace().next() else { + return Err("/nick requires a nickname".into()); + }; + self.send("NICK", &[nick])?; + } + "topic" => { + let (room, topic) = split_first_arg(args); + let room = if room.is_empty() { target } else { room }; + if room == SERVER_TARGET { + return Err("/topic requires a room".into()); + } + if topic.is_empty() { + self.send("TOPIC", &[room])?; + } else { + self.send("TOPIC", &[room, topic])?; + } + } + "list" => { + if args.is_empty() { + self.send("LIST", &[])?; + } else { + self.send("LIST", &[&args.split_whitespace().collect::>().join(",")])?; + } + } + "names" => { + let room = if args.is_empty() && target != SERVER_TARGET { target } else { args }; + if room.is_empty() { + return self.send_raw("NAMES"); + } + return self.send_raw(&format!("NAMES {room}")); + } + "whois" => { + if args.is_empty() { + return Err("/whois requires a nickname".into()); + } + for nick in args.split_whitespace() { + self.send("WHOIS", &[nick])?; + } + } + "who" => { + let args = if args.is_empty() { + if target == SERVER_TARGET { + return Err("/who requires a target".into()); + } + target + } else { + args + }; + for who in args.split_whitespace() { + self.send("WHO", &[who, "%tcuhnr,2"])?; + } + } + "raw" | "quote" => { + if args.is_empty() { + return Err("/raw requires an IRC command".into()); + } + return self.send_raw(args); + } + "help" => self.emit(Event { + kind: "system".into(), + target: target.into(), + text: "Commands: /join, /part, /msg, /notice, /me, /nick, /topic, /list, /names, /whois, /who, /raw, /quit".into(), + ..Default::default() + }), + _ => { + let mut raw = first.to_uppercase(); + if !args.is_empty() { + raw.push(' '); + raw.push_str(args); + } + return self.send_raw(&raw); + } + } + Ok(()) + } + + pub fn join(&self, room: &str) -> crate::Result<()> { + let room = room.trim(); + if room.is_empty() { + return Err("room is required".into()); + } + self.send("JOIN", &[room]) + } + + pub fn part(&self, room: &str) -> crate::Result<()> { + let room = room.trim(); + if room.is_empty() { + return Err("room is required".into()); + } + self.send("PART", &[room]) + } + + /// Requests the server's room list; results arrive as a `room-list` event. + pub fn list_rooms(&self) -> crate::Result<()> { + self.st().list_rooms.clear(); + self.send("LIST", &[]) + } + + /// Currently joined rooms, sorted case-insensitively. + pub fn rooms(&self) -> Vec { + let st = self.st(); + let mut rooms: Vec = st + .room_names + .iter() + .map(|name| match st.channels.get(&fold(name)) { + Some(ch) => RoomInfo { name: ch.name.clone(), topic: ch.topic.clone(), users: ch.users.len() as i64 }, + None => RoomInfo { name: name.clone(), ..Default::default() }, + }) + .collect(); + rooms.sort_by_key(|r| r.name.to_lowercase()); + rooms + } + + /// Known users in a room, sorted ignoring mode prefixes. + pub fn users(&self, room: &str) -> Vec { + let st = self.st(); + let Some(ch) = st.channels.get(&fold(room)) else { return Vec::new() }; + let mut users = ch.users.clone(); + users.sort_by_key(|u| u.trim_start_matches(['~', '&', '@', '%', '+']).to_lowercase()); + users + } + + // ---- I/O thread ---- + + fn io_thread(self: Arc, out_rx: Receiver) { + let result = self.open_transport().and_then(|stream| self.run(stream, &out_rx)); + *self.out.lock().unwrap_or_else(|e| e.into_inner()) = None; + let (was_closing, was_connected, ready) = { + let mut st = self.st(); + let r = (st.closing, st.connected, st.ready.take()); + st.connected = false; + r + }; + if let Err(e) = result + && !was_closing + { + let msg = e.to_string(); + self.emit(Event { kind: "error".into(), text: msg.clone(), ..Default::default() }); + (self.cfg.on_error)(msg.clone()); + if let Some(ready) = ready { + let _ = ready.send(Err(msg)); + } + } + if was_connected { + (self.cfg.on_close)(); + } + } + + fn open_transport(&self) -> crate::Result> { + let stream: Box = match &self.cfg.connector { + Some(connector) => connector()?, + None => { + let addr = format!("{}:{}", self.cfg.server, self.cfg.port); + let tcp = dial(&self.cfg.server, self.cfg.port, self.cfg.connect_timeout).map_err(|e| format!("dial tcp {addr}: {e}"))?; + tcp.set_read_timeout(Some(READ_POLL))?; + tcp.set_write_timeout(Some(self.cfg.connect_timeout))?; + let _ = tcp.set_nodelay(true); + Box::new(tcp) + } + }; + if !self.cfg.use_tls { + return Ok(stream); + } + self.tls_wrap(stream).map_err(|e| format!("IRC TLS handshake failed: {e}").into()) + } + + fn tls_wrap(&self, sock: Box) -> crate::Result> { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions()? + .with_root_certificates(roots) + .with_no_client_auth(); + let name = rustls::pki_types::ServerName::try_from(self.cfg.server.clone())?; + let conn = rustls::ClientConnection::new(Arc::new(config), name)?; + let mut tls = rustls::StreamOwned::new(conn, sock); + let deadline = Instant::now() + self.cfg.connect_timeout; + while tls.conn.is_handshaking() { + match tls.conn.complete_io(&mut tls.sock) { + Ok(_) => {} + Err(e) if is_poll_timeout(&e) && Instant::now() < deadline => {} + Err(e) => return Err(e.into()), + } + } + Ok(Box::new(tls)) + } + + /// Registers, then pumps reads, the outgoing queue and keepalive pings until the connection + /// ends. `Ok` means a deliberate QUIT. + fn run(&self, mut stream: Box, out_rx: &Receiver) -> crate::Result<()> { + if self.st().closing { + return Ok(()); + } + let write = |stream: &mut Box, line: &str| -> crate::Result<()> { + stream.write_all(line.as_bytes())?; + stream.write_all(b"\r\n")?; + Ok(stream.flush()?) + }; + if !self.cfg.server_pass.is_empty() { + write(&mut stream, &encode("PASS", &[&self.cfg.server_pass]))?; + } + write(&mut stream, &encode("NICK", &[&self.cfg.nick]))?; + write(&mut stream, &encode("USER", &[&self.cfg.user, "*", "*", &self.cfg.real_name]))?; + + let started = Instant::now(); + let mut last_ping = Instant::now(); + let mut ping_sent = false; + self.st().last_pong = Instant::now(); + let mut buf = Vec::new(); + let mut chunk = [0u8; 8192]; + loop { + while let Ok(out) = out_rx.try_recv() { + match out { + Out::Line(line) => write(&mut stream, &line)?, + Out::Quit(msg) => { + let _ = write(&mut stream, &encode("QUIT", &[&msg])); + return Ok(()); + } + } + } + + if started.elapsed() >= PING_GRACE && last_ping.elapsed() >= PING_DELAY { + if ping_sent && self.st().last_pong.elapsed() > PING_DELAY + PING_TIMEOUT { + return Err("timed out waiting for a requested PING response".into()); + } + let id = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos(); + write(&mut stream, &encode("PING", &[&id.to_string()]))?; + last_ping = Instant::now(); + ping_sent = true; + } + + match stream.read(&mut chunk) { + Ok(0) => return Err("connection closed by server".into()), + Ok(n) => buf.extend_from_slice(&chunk[..n]), + Err(e) if is_poll_timeout(&e) || e.kind() == ErrorKind::Interrupted => continue, + Err(e) => return Err(e.into()), + } + while let Some(pos) = buf.iter().position(|&b| b == b'\n') { + let raw: Vec = buf.drain(..=pos).collect(); + let line = String::from_utf8_lossy(&raw); + if let Some(msg) = parse_line(line.trim_end_matches(['\r', '\n'])) { + self.handle_message(&msg); + } + } + if buf.len() > MAX_LINE_BYTES { + return Err("server line too long".into()); + } + } + } + + // ---- server message handling (girc's state tracking, then Link's handlers) ---- + + fn is_me(&self, nick: &str) -> bool { + fold(nick) == fold(&self.st().nick) + } + + fn handle_message(&self, m: &Message) { + match m.command.as_str() { + "001" => self.handle_welcome(m), + "433" | "436" | "437" => { + let mut st = self.st(); + if !st.connected { + st.nick.push('_'); + let nick = st.nick.clone(); + drop(st); + let _ = self.send("NICK", &[&nick]); + } + } + "PING" => { + let _ = self.send("PONG", &[m.last()]); + } + "PONG" => self.st().last_pong = Instant::now(), + "PRIVMSG" => self.handle_privmsg(m), + "NOTICE" => self.handle_notice(m), + "JOIN" => self.handle_join(m), + "PART" => self.handle_part(m), + "KICK" => self.handle_kick(m), + "QUIT" => self.handle_quit(m), + "NICK" => self.handle_nick(m), + "TOPIC" | "332" => self.handle_topic(m), + "353" => self.handle_names(m), + "366" => { + if m.params.len() >= 2 { + self.emit_users(m.param(1)); + } + } + "322" => { + if m.params.len() >= 4 { + let room = RoomInfo { name: m.param(1).into(), users: m.param(2).parse().unwrap_or(0), topic: m.param(3).into() }; + let mut st = self.st(); + if st.list_rooms.len() < MAX_ROOM_LIST_ENTRIES { + st.list_rooms.push(room); + } + } + } + "323" => { + let mut rooms = std::mem::take(&mut self.st().list_rooms); + rooms.sort_by(|a, b| b.users.cmp(&a.users).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + self.emit(Event { kind: "room-list".into(), rooms, ..Default::default() }); + } + "ERROR" => self.emit(Event { kind: "error".into(), text: m.last().into(), ..Default::default() }), + _ => {} + } + } + + fn handle_welcome(&self, m: &Message) { + let ready = { + let mut st = self.st(); + if !m.params.is_empty() { + st.nick = m.param(0).to_string(); + } + st.connected = true; + st.ready.take() + }; + let host = if self.cfg.server.contains(':') { format!("[{}]", self.cfg.server) } else { self.cfg.server.clone() }; + self.emit(Event { kind: "system".into(), text: format!("Connected to {host}:{}", self.cfg.port), ..Default::default() }); + self.identify_with_nickserv(); + if !self.cfg.auto_join_rooms.is_empty() { + let _ = self.send("JOIN", &[&self.cfg.auto_join_rooms.join(",")]); + } + self.emit_rooms(); + if let Some(ready) = ready { + let _ = ready.send(Ok(())); + } + } + + fn identify_with_nickserv(&self) { + if !self.cfg.nickserv_identify || self.cfg.nickserv_password.is_empty() { + return; + } + let body = if self.cfg.nickserv_account.is_empty() { + format!("IDENTIFY {}", self.cfg.nickserv_password) + } else { + format!("IDENTIFY {} {}", self.cfg.nickserv_account, self.cfg.nickserv_password) + }; + let _ = self.send("PRIVMSG", &["NickServ", &body]); + self.emit(Event { kind: "system".into(), text: "Sent NickServ identify request".into(), ..Default::default() }); + } + + fn handle_privmsg(&self, m: &Message) { + if m.params.len() < 2 || m.source.is_empty() { + return; + } + let mut target = m.param(0).to_string(); + if self.is_me(&target) { + target = m.source.clone(); + } + let mut kind = "message"; + let mut text = m.last().to_string(); + if let Some((ctcp, args)) = decode_ctcp(m) { + match ctcp.as_str() { + "ACTION" => { + kind = "action"; + text = args; + } + "VERSION" => { + let _ = self.send("NOTICE", &[&m.source, "\x01VERSION Nexus Terminal\x01"]); + } + "PING" => { + let body = if args.is_empty() { "\x01PING\x01".to_string() } else { format!("\x01PING {args}\x01") }; + let _ = self.send("NOTICE", &[&m.source, &body]); + } + _ => {} + } + } + self.emit(Event { kind: kind.into(), target, from: m.source.clone(), text, ..Default::default() }); + } + + fn handle_notice(&self, m: &Message) { + let mut target = SERVER_TARGET.to_string(); + if !m.params.is_empty() { + target = m.param(0).to_string(); + if target == "*" { + target = SERVER_TARGET.into(); + } + if self.is_me(&target) && !m.source.is_empty() { + target = m.source.clone(); + } + } + self.emit(Event { kind: "notice".into(), target, from: m.source.clone(), text: m.last().into(), ..Default::default() }); + } + + fn handle_join(&self, m: &Message) { + let room = m.param(0).to_string(); + if room.is_empty() { + return; + } + let from = m.source.clone(); + if !from.is_empty() { + let mut st = self.st(); + let ch = st.channels.entry(fold(&room)).or_insert_with(|| Channel { name: room.clone(), topic: String::new(), users: Vec::new() }); + add_user(ch, &from); + } + if self.is_me(&from) { + self.remember_room_membership(&room, true); + } + self.emit(Event { kind: "join".into(), target: room.clone(), from: from.clone(), text: format!("{from} joined {room}"), ..Default::default() }); + self.emit_rooms(); + self.emit_users(&room); + } + + fn handle_part(&self, m: &Message) { + let room = m.param(0).to_string(); + if room.is_empty() { + return; + } + let from = m.source.clone(); + let me = self.is_me(&from); + if !from.is_empty() { + let mut st = self.st(); + if me { + st.channels.remove(&fold(&room)); + } else if let Some(ch) = st.channels.get_mut(&fold(&room)) { + remove_user(ch, &from); + } + } + let message = if m.params.len() > 1 { m.last().trim().to_string() } else { String::new() }; + if me { + self.remember_room_membership(&room, false); + } + self.emit(Event { kind: "part".into(), target: room.clone(), from, text: message, ..Default::default() }); + self.emit_rooms(); + self.emit_users(&room); + } + + fn handle_kick(&self, m: &Message) { + if m.params.len() < 2 { + return; + } + let (room, kicked) = (m.param(0).to_string(), m.param(1).to_string()); + let me = self.is_me(&kicked); + { + let mut st = self.st(); + if me { + st.channels.remove(&fold(&room)); + } else if let Some(ch) = st.channels.get_mut(&fold(&room)) { + remove_user(ch, &kicked); + } + } + if me { + self.remember_room_membership(&room, false); + } + self.emit(Event { + kind: "kick".into(), + target: room.clone(), + from: m.source.clone(), + command: kicked, + text: m.last().trim().into(), + ..Default::default() + }); + self.emit_rooms(); + self.emit_users(&room); + } + + fn handle_quit(&self, m: &Message) { + if !m.source.is_empty() && !self.is_me(&m.source) { + for ch in self.st().channels.values_mut() { + remove_user(ch, &m.source); + } + } + self.emit(Event { kind: "quit".into(), from: m.source.clone(), text: m.last().trim().into(), ..Default::default() }); + self.emit_users_for_all(); + } + + fn handle_nick(&self, m: &Message) { + let new_nick = m.last().to_string(); + if !m.source.is_empty() && !m.params.is_empty() { + let mut st = self.st(); + if fold(&m.source) == fold(&st.nick) { + st.nick = new_nick.clone(); + } + for ch in st.channels.values_mut() { + if remove_user(ch, &m.source) { + add_user(ch, &new_nick); + } + } + } + self.emit(Event { kind: "nick".into(), from: m.source.clone(), text: new_nick, ..Default::default() }); + self.emit_users_for_all(); + } + + fn handle_topic(&self, m: &Message) { + let (room, topic) = if m.command == "332" && m.params.len() >= 3 { + (m.param(1), m.param(2)) + } else if m.params.len() >= 2 { + (m.param(0), m.param(1)) + } else { + return; + }; + if room.is_empty() { + return; + } + if let Some(ch) = self.st().channels.get_mut(&fold(room)) { + ch.topic = topic.to_string(); + } + self.emit(Event { kind: "topic".into(), target: room.into(), from: m.source.clone(), text: topic.into(), topic: topic.into(), ..Default::default() }); + self.emit_rooms(); + } + + fn handle_names(&self, m: &Message) { + if m.params.len() < 4 { + return; + } + { + let mut st = self.st(); + if let Some(ch) = st.channels.get_mut(&fold(m.param(2))) { + for part in m.last().split(' ') { + let nick = part.trim_start_matches(['~', '&', '@', '%', '+']); + let nick = if nick.contains('@') { source_name(nick) } else { nick }; + if is_valid_nick(nick) { + add_user(ch, nick); + } + } + } + } + self.emit(Event { + kind: "users".into(), + target: m.param(2).into(), + users: m.param(3).split_whitespace().map(String::from).collect(), + ..Default::default() + }); + } + + // ---- emitting ---- + + fn remember_room_membership(&self, room: &str, joined: bool) { + let room = room.trim(); + if room.is_empty() { + return; + } + let names = { + let mut st = self.st(); + let position = st.room_names.iter().position(|r| r.eq_ignore_ascii_case(room)); + match (joined, position) { + (true, None) => st.room_names.push(room.to_string()), + (false, Some(i)) => { + st.room_names.remove(i); + } + _ => return, + } + st.room_names.clone() + }; + (self.cfg.on_rooms_changed)(names); + } + + fn emit_rooms(&self) { + self.emit(Event { kind: "rooms".into(), rooms: self.rooms(), ..Default::default() }); + } + + fn emit_users(&self, room: &str) { + if !room.is_empty() { + self.emit(Event { kind: "users".into(), target: room.into(), users: self.users(room), ..Default::default() }); + } + } + + fn emit_users_for_all(&self) { + for room in self.rooms() { + self.emit_users(&room.name); + } + } + + fn emit_local_message(&self, kind: &str, target: &str, text: &str) { + self.emit(Event { kind: kind.into(), target: target.into(), from: self.nick(), text: text.into(), ..Default::default() }); + } + + fn emit(&self, mut event: Event) { + if event.timestamp == 0 { + event.timestamp = chrono::Utc::now().timestamp_millis(); + } + if event.target.is_empty() { + event.target = SERVER_TARGET.into(); + } + (self.cfg.on_event)(event); + } +} + +fn dial(host: &str, port: u16, timeout: Duration) -> std::io::Result { + let mut last = std::io::Error::new(ErrorKind::NotFound, "no addresses found"); + for addr in (host, port).to_socket_addrs()? { + match TcpStream::connect_timeout(&addr, timeout) { + Ok(s) => return Ok(s), + Err(e) => last = e, + } + } + Err(last) +} + +fn is_poll_timeout(e: &std::io::Error) -> bool { + matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) +} + +/// girc's `ToRFC1459`: ASCII A-Z and `[\]^` fold to a-z and `{|}~`. +fn fold(s: &str) -> String { + s.chars().map(|c| if ('A'..='^').contains(&c) { (c as u8 + 32) as char } else { c }).collect() +} + +fn add_user(ch: &mut Channel, nick: &str) { + if !ch.users.iter().any(|u| fold(u) == fold(nick)) { + ch.users.push(nick.to_string()); + } +} + +fn remove_user(ch: &mut Channel, nick: &str) -> bool { + let before = ch.users.len(); + ch.users.retain(|u| fold(u) != fold(nick)); + ch.users.len() != before +} + +/// girc's `IsValidNick`. +fn is_valid_nick(nick: &str) -> bool { + let b = nick.as_bytes(); + let Some(&first) = b.first() else { return false }; + if !(b'A'..=b'}').contains(&first) && first != b'?' { + return false; + } + b[1..].iter().all(|&c| (b'A'..=b'}').contains(&c) || c.is_ascii_digit() || c == b'-') +} + +/// The nick part of `nick!user@host`. +fn source_name(prefix: &str) -> &str { + prefix.split(['!', '@']).next().unwrap_or("") +} + +/// Encodes a command line the way girc does: the last parameter gets a `:` when it is empty, +/// contains a space or starts with `:`; CR/LF are stripped. +fn encode(command: &str, params: &[&str]) -> String { + let mut line = command.to_string(); + for (i, p) in params.iter().enumerate() { + line.push(' '); + if i == params.len() - 1 && (p.is_empty() || p.contains(' ') || p.starts_with(':')) { + line.push(':'); + } + line.push_str(p); + } + line.retain(|c| c != '\r' && c != '\n'); + line +} + +fn parse_line(line: &str) -> Option { + let mut rest = line.trim_start(); + if rest.starts_with('@') { + rest = rest.split_once(' ')?.1.trim_start(); + } + let mut source = String::new(); + if let Some(prefixed) = rest.strip_prefix(':') { + let (prefix, tail) = prefixed.split_once(' ')?; + source = source_name(prefix).to_string(); + rest = tail.trim_start(); + } + let (head, trailing) = match rest.split_once(" :") { + Some((h, t)) => (h, Some(t)), + None => (rest, None), + }; + let mut words = head.split(' ').filter(|w| !w.is_empty()); + let command = words.next()?.to_uppercase(); + let mut params: Vec = words.map(String::from).collect(); + params.extend(trailing.map(String::from)); + Some(Message { source, command, params }) +} + +/// A CTCP request in a PRIVMSG: `(TAG, args)`, as girc's `DecodeCTCP`. +fn decode_ctcp(m: &Message) -> Option<(String, String)> { + if m.params.len() != 2 { + return None; + } + let text = m.param(1).strip_prefix('\x01')?.strip_suffix('\x01')?; + let (tag, args) = text.split_once(' ').unwrap_or((text, "")); + if tag.is_empty() || !tag.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit()) { + return None; + } + Some((tag.to_string(), args.to_string())) +} + +/// The first whitespace-separated word, and the trimmed remainder. +fn split_first_arg(input: &str) -> (&str, &str) { + let input = input.trim_start(); + match input.split_whitespace().next() { + Some(first) => (first, input[first.len()..].trim()), + None => ("", ""), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{BufRead, BufReader}; + use std::net::TcpListener; + + #[test] + fn remember_room_membership_tracks_confirmed_channels() { + let updates = Arc::new(Mutex::new(Vec::new())); + let log = updates.clone(); + let client = Client::new(Config { on_rooms_changed: Arc::new(move |rooms| log.lock().unwrap().push(rooms.join(","))), ..Default::default() }); + client.remember_room_membership("#One", true); + client.remember_room_membership("#two", true); + client.remember_room_membership("#one", true); + client.remember_room_membership("#missing", false); + client.remember_room_membership("#ONE", false); + assert_eq!(updates.lock().unwrap().join("|"), "#One|#One,#two|#two"); + assert_eq!(client.st().room_names, vec!["#two"]); + } + + #[test] + fn parses_and_encodes_lines() { + let m = parse_line("@time=x :nick!u@h PRIVMSG #chan :hello there").unwrap(); + assert_eq!((m.source.as_str(), m.command.as_str()), ("nick", "PRIVMSG")); + assert_eq!(m.params, vec!["#chan", "hello there"]); + let m = parse_line("PING :abc").unwrap(); + assert_eq!((m.source.as_str(), m.last()), ("", "abc")); + let m = parse_line(":srv 353 me = #c :@op +v plain").unwrap(); + assert_eq!(m.params.len(), 4); + assert_eq!(encode("PRIVMSG", &["#c", "hi"]), "PRIVMSG #c hi"); + assert_eq!(encode("PRIVMSG", &["#c", "hi there"]), "PRIVMSG #c :hi there"); + assert_eq!(encode("TOPIC", &["#c", ""]), "TOPIC #c :"); + assert_eq!(encode("QUIT", &[":x\r\nJOIN #evil"]), "QUIT ::xJOIN #evil"); + assert_eq!(split_first_arg(" #room key here "), ("#room", "key here")); + assert_eq!(split_first_arg(" "), ("", "")); + assert_eq!(fold("Nick[A]^"), "nick{a}~"); + } + + #[test] + fn event_serialises_like_go() { + let e = Event { kind: "users".into(), target: "#a".into(), timestamp: 5, ..Default::default() }; + assert_eq!(serde_json::to_string(&e).unwrap(), r##"{"type":"users","target":"#a","timestamp":5}"##); + let e = Event { kind: "rooms".into(), rooms: vec![RoomInfo { name: "#a".into(), topic: String::new(), users: 0 }], ..Default::default() }; + assert_eq!(serde_json::to_string(&e).unwrap(), r##"{"type":"rooms","timestamp":0,"rooms":[{"name":"#a"}]}"##); + } + + struct Fake { + reader: BufReader, + writer: TcpStream, + } + + impl Fake { + fn line(&mut self) -> String { + let mut s = String::new(); + self.reader.read_line(&mut s).unwrap(); + s.trim_end().to_string() + } + fn expect(&mut self, want: &str) { + for _ in 0..20 { + if self.line() == want { + return; + } + } + panic!("never received {want:?}"); + } + fn send(&mut self, line: &str) { + self.writer.write_all(format!("{line}\r\n").as_bytes()).unwrap(); + } + } + + fn harness(cfg: Config) -> (Arc, TcpListener, Receiver, Arc>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let (tx, rx) = mpsc::channel(); + let tx = Mutex::new(tx); + let closes = Arc::new(Mutex::new(0)); + let c = closes.clone(); + let client = Client::new(Config { + server: "127.0.0.1".into(), + port: listener.local_addr().unwrap().port(), + nick: "me".into(), + connect_timeout: Duration::from_secs(5), + on_event: Arc::new(move |e| { + let _ = tx.lock().unwrap().send(e); + }), + on_close: Arc::new(move || *c.lock().unwrap() += 1), + ..cfg + }); + (client, listener, rx, closes) + } + + fn accept(listener: &TcpListener) -> Fake { + let (s, _) = listener.accept().unwrap(); + s.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + Fake { reader: BufReader::new(s.try_clone().unwrap()), writer: s } + } + + fn wait_for(rx: &Receiver, pred: impl Fn(&Event) -> bool) -> Event { + loop { + let e = rx.recv_timeout(Duration::from_secs(5)).expect("event"); + if pred(&e) { + return e; + } + } + } + + #[test] + fn registers_tracks_rooms_and_handles_input() { + let (client, listener, rx, closes) = harness(Config { + server_pass: "pw".into(), + auto_join_rooms: vec!["#a".into(), "#b".into()], + nickserv_identify: true, + nickserv_account: "acct".into(), + nickserv_password: "secret".into(), + ..Default::default() + }); + let c = client.clone(); + let connecting = std::thread::spawn(move || c.connect()); + let mut srv = accept(&listener); + assert_eq!(srv.line(), "PASS pw"); + assert_eq!(srv.line(), "NICK me"); + assert_eq!(srv.line(), "USER me * * me"); + srv.send(":srv 433 * me :Nickname is already in use"); + assert_eq!(srv.line(), "NICK me_"); + srv.send(":srv 001 me_ :Welcome"); + connecting.join().unwrap().unwrap(); + let e = wait_for(&rx, |e| e.kind == "system"); + assert_eq!(e.text, format!("Connected to 127.0.0.1:{}", client.cfg.port)); + assert_eq!(e.target, SERVER_TARGET); + assert_eq!(srv.line(), "PRIVMSG NickServ :IDENTIFY acct secret"); + assert_eq!(srv.line(), "JOIN #a,#b"); + assert_eq!(wait_for(&rx, |e| e.kind == "system").text, "Sent NickServ identify request"); + + srv.send("PING :xyz"); + srv.expect("PONG xyz"); + + srv.send(":me_!u@h JOIN #a"); + let e = wait_for(&rx, |e| e.kind == "join"); + assert_eq!((e.target.as_str(), e.from.as_str(), e.text.as_str()), ("#a", "me_", "me_ joined #a")); + srv.send(":srv 332 me_ #a :the topic"); + assert_eq!(wait_for(&rx, |e| e.kind == "topic").topic, "the topic"); + srv.send(":srv 353 me_ = #a :@me_ +Bob carol"); + assert_eq!(wait_for(&rx, |e| e.kind == "users").users, vec!["@me_", "+Bob", "carol"]); + srv.send(":srv 366 me_ #a :End of NAMES"); + assert_eq!(wait_for(&rx, |e| e.kind == "users").users, vec!["Bob", "carol", "me_"]); + assert_eq!(client.rooms(), vec![RoomInfo { name: "#a".into(), topic: "the topic".into(), users: 3 }]); + + srv.send(":Bob!u@h PRIVMSG #a :hi all"); + let e = wait_for(&rx, |e| e.kind == "message"); + assert_eq!((e.target.as_str(), e.from.as_str(), e.text.as_str()), ("#a", "Bob", "hi all")); + srv.send(":Bob!u@h PRIVMSG ME_ :\x01ACTION waves\x01"); + let e = wait_for(&rx, |e| e.kind == "action"); + assert_eq!((e.target.as_str(), e.text.as_str()), ("Bob", "waves")); + srv.send(":Bob!u@h NICK :Robert"); + assert_eq!(wait_for(&rx, |e| e.kind == "nick").text, "Robert"); + assert_eq!(client.users("#A"), vec!["carol", "me_", "Robert"]); + srv.send(":carol!u@h QUIT :bye"); + assert_eq!(wait_for(&rx, |e| e.kind == "quit").text, "bye"); + assert_eq!(client.users("#a"), vec!["me_", "Robert"]); + srv.send(":Robert!u@h KICK #a me_ :out"); + let e = wait_for(&rx, |e| e.kind == "kick"); + assert_eq!((e.from.as_str(), e.command.as_str(), e.text.as_str()), ("Robert", "me_", "out")); + assert!(client.rooms().is_empty()); + + client.handle_input("#a", "hello there").unwrap(); + srv.expect("PRIVMSG #a :hello there"); + let e = wait_for(&rx, |e| e.kind == "message"); + assert_eq!((e.from.as_str(), e.text.as_str()), ("me_", "hello there")); + client.handle_input("#a", "/me dances").unwrap(); + srv.expect("PRIVMSG #a :\x01ACTION dances\x01"); + client.handle_input("#a", "/join #c key").unwrap(); + srv.expect("JOIN #c key"); + client.handle_input("#a", "/part").unwrap(); + srv.expect("PART #a"); + client.handle_input("#a", "/topic").unwrap(); + srv.expect("TOPIC #a"); + client.handle_input("#a", "/names").unwrap(); + srv.expect("NAMES #a"); + client.handle_input("#a", "/mode #a +i").unwrap(); + srv.expect("MODE #a +i"); + assert_eq!(client.handle_input(SERVER_TARGET, "hi").unwrap_err().to_string(), "select a room or use /msg "); + assert_eq!(client.handle_input("#a", "/msg bob").unwrap_err().to_string(), "/msg requires a target and message"); + client.handle_input("", "/help").unwrap(); + assert!(wait_for(&rx, |e| e.kind == "system").text.starts_with("Commands: /join")); + + client.list_rooms().unwrap(); + srv.expect("LIST"); + srv.send(":srv 322 me_ #small 2 :s"); + srv.send(":srv 322 me_ #Big 9 :b"); + srv.send(":srv 322 me_ #also 2 :a"); + srv.send(":srv 323 me_ :End"); + let names: Vec<_> = wait_for(&rx, |e| e.kind == "room-list").rooms.into_iter().map(|r| r.name).collect(); + assert_eq!(names, vec!["#Big", "#also", "#small"]); + + client.close(); + client.close(); + srv.expect("QUIT :Nexus Terminal disconnected"); + let deadline = Instant::now() + Duration::from_secs(5); + while *closes.lock().unwrap() == 0 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + std::thread::sleep(Duration::from_millis(100)); + assert_eq!(*closes.lock().unwrap(), 1); + assert!(client.handle_input("#a", "late").is_err()); + } + + #[test] + fn remote_close_reports_error_and_close() { + let errors = Arc::new(Mutex::new(Vec::new())); + let log = errors.clone(); + let (client, listener, rx, closes) = harness(Config { on_error: Arc::new(move |e| log.lock().unwrap().push(e)), ..Default::default() }); + let c = client.clone(); + let connecting = std::thread::spawn(move || c.connect()); + let mut srv = accept(&listener); + srv.expect("USER me * * me"); + srv.send(":srv 001 me :Welcome"); + connecting.join().unwrap().unwrap(); + srv.send("ERROR :Closing link"); + assert_eq!(wait_for(&rx, |e| e.kind == "error").text, "Closing link"); + drop(srv); + assert_eq!(wait_for(&rx, |e| e.kind == "error").text, "connection closed by server"); + std::thread::sleep(Duration::from_millis(100)); + assert_eq!(*errors.lock().unwrap(), vec!["connection closed by server"]); + assert_eq!(*closes.lock().unwrap(), 1); + } + + #[test] + fn connect_times_out_without_welcome() { + let (client, listener, _rx, closes) = harness(Config::default()); + let client = Client::new(Config { connect_timeout: Duration::from_millis(300), ..client.cfg.clone() }); + let err = client.connect().unwrap_err().to_string(); + assert_eq!(err, "IRC connection timed out after 300ms"); + drop(listener); + assert_eq!(*closes.lock().unwrap(), 0); + } + + #[test] + fn connector_supplies_the_transport() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let used = Arc::new(Mutex::new(false)); + let flag = used.clone(); + let client = Client::new(Config { + server: "unresolvable.invalid".into(), + nick: "me".into(), + connect_timeout: Duration::from_secs(5), + connector: Some(Arc::new(move || { + *flag.lock().unwrap() = true; + let s = TcpStream::connect(addr)?; + s.set_read_timeout(Some(Duration::from_millis(20)))?; + Ok(Box::new(s) as Box) + })), + ..Default::default() + }); + let c = client.clone(); + let connecting = std::thread::spawn(move || c.connect()); + let mut srv = accept(&listener); + srv.expect("USER me * * me"); + srv.send(":srv 001 me :Welcome"); + connecting.join().unwrap().unwrap(); + assert!(*used.lock().unwrap()); + client.handle_input("", "/quit gone").unwrap(); + srv.expect("QUIT gone"); + } + + #[test] + fn dial_failure_is_reported() { + let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); + let client = Client::new(Config { server: "127.0.0.1".into(), port, nick: "me".into(), ..Default::default() }); + let err = client.connect().unwrap_err().to_string(); + assert!(err.starts_with(&format!("dial tcp 127.0.0.1:{port}:")), "{err}"); + } +} diff --git a/rust/core/src/clients/local.rs b/rust/core/src/clients/local.rs new file mode 100644 index 0000000..74ae572 --- /dev/null +++ b/rust/core/src/clients/local.rs @@ -0,0 +1,189 @@ +//! Local shell in a PTY (forkpty on Unix, ConPTY on Windows), a port of internal/localshell. + +use super::Callbacks; +use crate::Result; +use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system}; +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +pub fn default_shell() -> String { + if cfg!(windows) { + let ps = std::env::var_os("SystemRoot").map(|r| PathBuf::from(r).join(r"System32\WindowsPowerShell\v1.0\powershell.exe")).filter(|p| p.exists()); + return ps.map_or_else(|| "cmd.exe".into(), |p| p.to_string_lossy().into()); + } + std::env::var("SHELL").ok().filter(|s| !s.is_empty()).unwrap_or_else(|| "/bin/sh".into()) +} + +struct Running { + master: Box, + writer: Box, + killer: Box, + pid: Option, +} + +pub struct LocalShell { + shell: String, + cb: Callbacks, + state: Mutex<(Option, PathBuf, u16, u16, bool)>, // running, cwd, cols, rows, closed +} + +impl LocalShell { + pub fn new(shell: &str, start_dir: Option, cols: u16, rows: u16, cb: Callbacks) -> Arc { + let shell = if shell.is_empty() { default_shell() } else { shell.to_string() }; + let dir = start_dir.filter(|d| d.is_dir()).or_else(home_dir).unwrap_or_else(|| PathBuf::from("/")); + Arc::new(LocalShell { shell, cb, state: Mutex::new((None, dir, cols.max(1), rows.max(1), false)) }) + } + + pub fn start(self: &Arc) -> Result<()> { + let mut st = self.state.lock().unwrap(); + if st.4 { + return Err("shell already closed".into()); + } + if st.0.is_some() { + return Err("shell already running".into()); + } + let pair = native_pty_system() + .openpty(PtySize { rows: st.3, cols: st.2, pixel_width: 0, pixel_height: 0 }) + .map_err(|e| format!("failed to start shell {:?}: {e}", self.shell))?; + let mut cmd = CommandBuilder::new(&self.shell); + cmd.env_clear(); + let env = crate::terminalenv::local_shell_env(std::env::vars().map(|(k, v)| format!("{k}={v}")).collect()); + for kv in env { + if let Some((k, v)) = kv.split_once('=') { + cmd.env(k, v); + } + } + cmd.cwd(&st.1); + let mut child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start shell {:?}: {e}", self.shell))?; + drop(pair.slave); + let mut reader = pair.master.try_clone_reader()?; + let writer = pair.master.take_writer()?; + let pid = child.process_id(); + st.0 = Some(Running { master: pair.master, writer, killer: child.clone_killer(), pid }); + drop(st); + + let me = self.clone(); + std::thread::Builder::new().name("nexus-local-read".into()).spawn(move || { + let mut buf = vec![0u8; 32 * 1024]; + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => (me.cb.on_data)(&buf[..n]), + } + } + })?; + let me = self.clone(); + std::thread::Builder::new().name("nexus-local-wait".into()).spawn(move || { + let _ = child.wait(); + let was_closed = { + let mut st = me.state.lock().unwrap(); + let was = st.4; + st.4 = true; + st.0 = None; + was + }; + if !was_closed { + (me.cb.on_close)(); + } + })?; + Ok(()) + } + + pub fn write(&self, data: &[u8]) -> Result<()> { + if data.iter().any(|b| matches!(b, b'\r' | b'\n' | 4)) { + self.capture_cwd(); + } + let mut st = self.state.lock().unwrap(); + let r = st.0.as_mut().ok_or("shell not running")?; + r.writer.write_all(data)?; + Ok(()) + } + + pub fn resize(&self, cols: u16, rows: u16) -> Result<()> { + let mut st = self.state.lock().unwrap(); + if cols > 0 { + st.2 = cols; + } + if rows > 0 { + st.3 = rows; + } + let size = PtySize { rows: st.3, cols: st.2, pixel_width: 0, pixel_height: 0 }; + if let Some(r) = &st.0 { + r.master.resize(size)?; + } + Ok(()) + } + + /// Idempotent; does not fire on_close (matches Go: an explicit close is not an exit). + pub fn close(&self) { + let mut st = self.state.lock().unwrap(); + if st.4 { + return; + } + st.4 = true; + if let Some(mut r) = st.0.take() { + let _ = r.killer.kill(); + } + } + + /// The last directory the shell was seen in (used to restart in the same place). + pub fn working_directory(&self) -> PathBuf { + self.state.lock().unwrap().1.clone() + } + + fn capture_cwd(&self) { + let pid = self.state.lock().unwrap().0.as_ref().and_then(|r| r.pid); + if let Some(dir) = pid.and_then(process_cwd).filter(|d| d.is_dir()) { + self.state.lock().unwrap().1 = dir; + } + } +} + +fn home_dir() -> Option { + std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from) +} + +fn process_cwd(pid: u32) -> Option { + if cfg!(target_os = "linux") { + return std::fs::read_link(format!("/proc/{pid}/cwd")).ok(); + } + if cfg!(target_os = "macos") { + let out = std::process::Command::new("/usr/sbin/lsof").args(["-a", "-p", &pid.to_string(), "-d", "cwd", "-Fn"]).output().ok()?; + return String::from_utf8_lossy(&out.stdout).lines().find_map(|l| l.strip_prefix('n').map(PathBuf::from)); + } + None +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::time::{Duration, Instant}; + + #[test] + fn runs_echoes_and_reports_exit() { + let seen: Arc>> = Arc::default(); + let closed = Arc::new(Mutex::new(0)); + let (s2, c2) = (seen.clone(), closed.clone()); + let cb = Callbacks { + on_data: Arc::new(move |d| s2.lock().unwrap().extend_from_slice(d)), + on_error: Arc::new(|_| {}), + on_close: Arc::new(move || *c2.lock().unwrap() += 1), + }; + let sh = LocalShell::new("/bin/sh", Some("/tmp".into()), 80, 24, cb); + sh.start().unwrap(); + sh.write(b"cd / && echo local-$((2*21))\n").unwrap(); + let t = Instant::now(); + while !String::from_utf8_lossy(&seen.lock().unwrap()).contains("local-42") { + assert!(t.elapsed() < Duration::from_secs(5)); + std::thread::sleep(Duration::from_millis(20)); + } + sh.resize(100, 30).unwrap(); + sh.write(b"exit\n").unwrap(); + while *closed.lock().unwrap() == 0 { + assert!(t.elapsed() < Duration::from_secs(5)); + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!(sh.working_directory(), PathBuf::from("/")); + } +} diff --git a/rust/core/src/clients/mod.rs b/rust/core/src/clients/mod.rs new file mode 100644 index 0000000..837e41f --- /dev/null +++ b/rust/core/src/clients/mod.rs @@ -0,0 +1,151 @@ +//! Protocol clients. Conventions, shared by every client so the session manager treats them alike: +//! - `connect()` blocks the calling thread (the manager calls it from a worker thread, as Go +//! called it from a goroutine) and returns once the connection is usable. +//! - After connecting, the client owns its own reader thread(s) and reports through callbacks: +//! data, a fatal error, and close (exactly once, whether remote or local). +//! - `write`/`resize`/`close` are callable from any thread; `close` is idempotent and must not +//! fire `on_close` re-entrantly while holding locks. + +pub mod github; +pub mod irc; +pub mod local; +pub mod sftp; +pub mod ssh; + +use std::sync::{Arc, OnceLock}; + +/// The shared tokio runtime for async protocol stacks (russh). Blocking clients use threads. +pub fn rt() -> &'static tokio::runtime::Runtime { + static RT: OnceLock = OnceLock::new(); + RT.get_or_init(|| tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().thread_name("nexus-net").build().expect("tokio runtime")) +} + +pub type DataFn = Arc; +pub type ErrorFn = Arc; +pub type CloseFn = Arc; + +#[derive(Clone)] +pub struct Callbacks { + pub on_data: DataFn, + pub on_error: ErrorFn, + pub on_close: CloseFn, +} + +impl Callbacks { + pub fn noop() -> Callbacks { + Callbacks { on_data: Arc::new(|_| {}), on_error: Arc::new(|_| {}), on_close: Arc::new(|| {}) } + } +} + +pub mod raw; +pub mod serial; +pub mod telnet; +pub mod vnc; + +/// Called with protocol negotiation debug messages (Go's `onDebug`). +pub type DebugFn = Arc; + +/// TCP dial shared by the Telnet and RAW clients: Go's `net.DialTimeout` with a 30 s default. +/// Returns the stream and the `host:port` string used in error messages. +pub(crate) fn dial(host: &str, port: i64, timeout_secs: i64) -> (String, std::io::Result) { + use std::net::{TcpStream, ToSocketAddrs}; + let timeout = std::time::Duration::from_secs(if timeout_secs > 0 { timeout_secs as u64 } else { 30 }); + // Go's net.JoinHostPort brackets IPv6 literals. + let addr = if host.contains(':') { format!("[{host}]:{port}") } else { format!("{host}:{port}") }; + let result = (|| { + let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, "no addresses found"); + for sa in addr.to_socket_addrs()? { + match TcpStream::connect_timeout(&sa, timeout) { + Ok(s) => return Ok(s), + Err(e) => last = e, + } + } + Err(last) + })(); + (addr, result) +} + +/// The TCP connection lifecycle shared by the Telnet and RAW clients: one writer-side stream behind +/// a lock, a reader thread that feeds `on_read`, and Go's error/close semantics (a read error or EOF +/// reports `read error: …` then closes; `close` is idempotent and fires `on_close` once). +pub(crate) struct TcpConn { + stream: std::sync::Mutex>, + on_error: ErrorFn, + on_close: CloseFn, +} + +impl TcpConn { + pub(crate) fn new(on_error: ErrorFn, on_close: CloseFn) -> TcpConn { + TcpConn { stream: std::sync::Mutex::new(None), on_error, on_close } + } + + pub(crate) fn connect(self: &Arc, stream: std::net::TcpStream, mut on_read: impl FnMut(&[u8]) + Send + 'static) -> crate::Result<()> { + let mut reader = stream.try_clone()?; + { + let mut guard = self.stream.lock().unwrap(); + if guard.is_some() { + return Err("already connected".into()); + } + *guard = Some(stream); + } + let me = self.clone(); + std::thread::spawn(move || { + use std::io::Read; + let mut buf = [0u8; 4096]; + loop { + let err = match reader.read(&mut buf) { + Ok(0) => "EOF".to_string(), + Ok(n) => { + on_read(&buf[..n]); + continue; + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => e.to_string(), + }; + // A local close also ends the read; Go reported that as an error too, we don't. + if me.is_connected() { + (me.on_error)(format!("read error: {err}")); + me.close(); + } + return; + } + }); + Ok(()) + } + + pub(crate) fn write(&self, data: &[u8]) -> crate::Result<()> { + use std::io::Write; + let guard = self.stream.lock().unwrap(); + let mut s = guard.as_ref().ok_or("not connected")?; + s.write_all(data)?; + Ok(()) + } + + pub(crate) fn is_connected(&self) -> bool { + self.stream.lock().unwrap().is_some() + } + + pub(crate) fn close(&self) { + let taken = self.stream.lock().unwrap().take(); + if let Some(s) = taken { + let _ = s.shutdown(std::net::Shutdown::Both); + (self.on_close)(); + } + } +} + +/// True for the encoding names Go mapped to ISO 8859-1. +pub(crate) fn is_latin1(encoding: &str) -> bool { + matches!(encoding.trim().to_ascii_lowercase().as_str(), "iso-8859-1" | "latin1" | "latin-1") +} + +/// UTF-8 → ISO 8859-1. Like Go's charmap encoder, input that isn't valid UTF-8 or holds a +/// character above U+00FF is returned unchanged. +pub(crate) fn utf8_to_latin1(data: &[u8]) -> Vec { + std::str::from_utf8(data).ok().and_then(|s| s.chars().map(|c| u8::try_from(c).ok()).collect()).unwrap_or_else(|| data.to_vec()) +} + +/// ISO 8859-1 → UTF-8 (every byte maps to the code point of the same value). +pub(crate) fn latin1_to_utf8(data: &[u8]) -> String { + data.iter().map(|&b| b as char).collect() +} diff --git a/rust/core/src/clients/raw.rs b/rust/core/src/clients/raw.rs new file mode 100644 index 0000000..f289c5d --- /dev/null +++ b/rust/core/src/clients/raw.rs @@ -0,0 +1,423 @@ +//! RAW TCP client (port of Go `internal/rawclient`): binary-safe send/receive, a capped history +//! of sent ("send") and received ("recv") frames, JSON capture, and the hex/escape encoders the +//! RAW view reuses. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use base64::Engine; +use serde::Serialize; + +use super::{CloseFn, ErrorFn, TcpConn}; +use crate::models::{LineEnding, Profile, Time}; + +pub const MAX_HISTORY_ENTRIES: usize = 10_000; +pub const MAX_HISTORY_BYTES: usize = 10 * 1024 * 1024; + +pub const SEND: &str = "send"; +pub const RECV: &str = "recv"; + +/// `(data, direction, timestamp in unix ms)`; direction is [`SEND`] or [`RECV`]. +pub type RawDataFn = Arc; + +/// One sent or received frame. Serialises like Go's (RFC 3339 timestamp, base64 data). +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct HistoryEntry { + pub timestamp: Time, + pub direction: String, + #[serde(serialize_with = "ser_b64")] + pub data: Vec, +} + +fn ser_b64(d: &[u8], s: S) -> Result { + s.serialize_str(&base64::engine::general_purpose::STANDARD.encode(d)) +} + +pub struct RawClient { + inner: Arc, +} + +struct Inner { + conn: Arc, + host: String, + port: i64, + connect_timeout: i64, + line_ending: LineEnding, + on_data: RawDataFn, + history: Mutex, +} + +#[derive(Default)] +struct History { + entries: VecDeque, + bytes: usize, +} + +impl RawClient { + pub fn new(profile: &Profile, on_data: RawDataFn, on_error: ErrorFn, on_close: CloseFn) -> RawClient { + RawClient { + inner: Arc::new(Inner { + conn: Arc::new(TcpConn::new(on_error, on_close)), + host: profile.host.clone(), + port: profile.port, + connect_timeout: profile.connect_timeout, + line_ending: profile.raw.as_ref().map(|r| r.line_ending).unwrap_or_default(), + on_data, + history: Mutex::default(), + }), + } + } + + pub fn connect(&self) -> crate::Result<()> { + let inner = &self.inner; + if inner.conn.is_connected() { + return Err("already connected".into()); + } + if inner.port == 0 { + return Err("port must be specified for raw TCP connections".into()); + } + let (addr, res) = super::dial(&inner.host, inner.port, inner.connect_timeout); + let stream = res.map_err(|e| format!("raw TCP connection to {addr} failed: {e}"))?; + let me = inner.clone(); + inner.conn.connect(stream, move |d| me.record(RECV, d)) + } + + /// Sends bytes as-is, then records them and reports them through `on_data` as "send". + pub fn write(&self, data: &[u8]) -> crate::Result<()> { + self.inner.conn.write(data)?; + self.inner.record(SEND, data); + Ok(()) + } + + /// Sends text with the configured line ending appended (unless it already ends with it). + pub fn write_text(&self, text: &str) -> crate::Result<()> { + self.write(&apply_line_ending(text, self.inner.line_ending)) + } + + /// Sends hex such as "48656c6c6f", "48 65 6c" or "0x48 0x65". + pub fn write_hex(&self, hex: &str) -> crate::Result<()> { + let data = parse_hex_string(hex).map_err(|e| format!("invalid hex input: {e}"))?; + self.write(&data) + } + + /// Sends text after processing C-style escapes (see [`parse_escaped`]). + pub fn write_escaped(&self, text: &str) -> crate::Result<()> { + let data = parse_escaped(text).map_err(|e| format!("escape processing failed: {e}"))?; + self.write(&data) + } + + pub fn close(&self) { + self.inner.conn.close(); + } + + pub fn is_connected(&self) -> bool { + self.inner.conn.is_connected() + } + + pub fn history(&self) -> Vec { + self.inner.history.lock().unwrap().entries.iter().cloned().collect() + } + + pub fn clear_history(&self) { + *self.inner.history.lock().unwrap() = History::default(); + } + + /// Writes the history to `path` as indented JSON (mode 0600 on Unix), as Go did. + pub fn save_capture(&self, path: &std::path::Path) -> crate::Result<()> { + let json = serde_json::to_vec_pretty(&self.history()).map_err(|e| format!("failed to marshal capture data: {e}"))?; + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600); + std::io::Write::write_all(&mut opts.open(path)?, &json)?; + Ok(()) + } +} + +impl Inner { + fn record(&self, direction: &str, data: &[u8]) { + let timestamp = crate::models::now(); + let ms = timestamp.timestamp_millis(); + self.append_history(HistoryEntry { timestamp, direction: direction.into(), data: data.to_vec() }); + (self.on_data)(data, direction, ms); + } + + fn append_history(&self, mut entry: HistoryEntry) { + if entry.data.len() > MAX_HISTORY_BYTES { + entry.data.drain(..entry.data.len() - MAX_HISTORY_BYTES); + } + let mut h = self.history.lock().unwrap(); + h.bytes += entry.data.len(); + h.entries.push_back(entry); + while h.entries.len() > MAX_HISTORY_ENTRIES || h.bytes > MAX_HISTORY_BYTES { + let Some(old) = h.entries.pop_front() else { break }; + h.bytes -= old.data.len(); + } + } +} + +fn apply_line_ending(text: &str, le: LineEnding) -> Vec { + let suffix = le.bytes(); + let mut out = text.as_bytes().to_vec(); + if !suffix.is_empty() && !out.ends_with(suffix) { + out.extend_from_slice(suffix); + } + out +} + +/// Parses hex with optional whitespace and 0x/0X prefixes into bytes. +pub fn parse_hex_string(s: &str) -> crate::Result> { + let s = s.trim(); + if s.is_empty() { + return Err("empty hex string".into()); + } + let s = s.replace("0x", "").replace("0X", ""); + let mut digits = Vec::new(); + for c in s.chars().filter(|c| !c.is_whitespace()) { + let d = c.to_digit(16).ok_or_else(|| format!("invalid character in hex string: {c:?}"))?; + digits.push(d as u8); + } + if digits.len() % 2 != 0 { + return Err("hex string has odd number of digits".into()); + } + Ok(digits.chunks(2).map(|p| (p[0] << 4) | p[1]).collect()) +} + +/// Space-separated lowercase hex, e.g. "48 65 6c 6c 6f". +pub fn format_hex(data: &[u8]) -> String { + data.iter().map(|b| format!("{b:02x}")).collect::>().join(" ") +} + +/// Classic 16-bytes-per-line hex dump with offset, hex and ASCII columns. +pub fn format_hex_dump(data: &[u8]) -> String { + use std::fmt::Write; + let mut sb = String::new(); + for (line, chunk) in data.chunks(16).enumerate() { + let _ = write!(sb, "{:08x} ", line * 16); + for i in 0..16 { + if i == 8 { + sb.push(' '); + } + match chunk.get(i) { + Some(b) => { + let _ = write!(sb, "{b:02x} "); + } + None => sb.push_str(" "), + } + } + sb.push_str(" |"); + sb.extend(chunk.iter().map(|&b| if (0x20..=0x7e).contains(&b) { b as char } else { '.' })); + sb.extend(std::iter::repeat_n(' ', 16 - chunk.len())); + sb.push_str("|\n"); + } + sb +} + +/// Processes C-style escapes: `\r \n \t \\ \0 \xNN` (and `\XNN`); anything else is an error. +pub fn parse_escaped(s: &str) -> crate::Result> { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + if b[i] != b'\\' { + out.push(b[i]); + i += 1; + continue; + } + let Some(&next) = b.get(i + 1) else { + return Err(format!("trailing backslash at position {i}").into()); + }; + let simple = match next { + b'r' => Some(b'\r'), + b'n' => Some(b'\n'), + b't' => Some(b'\t'), + b'\\' => Some(b'\\'), + b'0' => Some(0), + _ => None, + }; + if let Some(v) = simple { + out.push(v); + i += 2; + } else if next == b'x' || next == b'X' { + if i + 3 >= b.len() { + return Err(format!("incomplete \\x escape at position {i}").into()); + } + let hex = &b[i + 2..i + 4]; + let v = std::str::from_utf8(hex) + .ok() + .filter(|h| h.bytes().all(|c| c.is_ascii_hexdigit())) + .and_then(|h| u8::from_str_radix(h, 16).ok()) + .ok_or_else(|| format!("invalid \\x escape at position {i}: {}", String::from_utf8_lossy(hex)))?; + out.push(v); + i += 4; + } else { + let c = s[i + 1..].chars().next().unwrap_or('?'); + return Err(format!("unknown escape sequence \\{c} at position {i}").into()); + } + } + Ok(out) +} + +/// Bytes → display text: "ascii" (non-printables except \n \r \t become '.'), "iso-8859-1" / +/// "latin1" / "latin-1", otherwise UTF-8 with invalid bytes replaced by U+FFFD. +pub fn to_display_encoding(data: &[u8], encoding: &str) -> String { + match encoding.trim().to_ascii_lowercase().as_str() { + "ascii" => data.iter().map(|&b| if (0x20..=0x7e).contains(&b) || matches!(b, b'\n' | b'\r' | b'\t') { b as char } else { '.' }).collect(), + e if super::is_latin1(e) => super::latin1_to_utf8(data), + _ => String::from_utf8_lossy(data).into_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + use std::time::Duration; + + fn client() -> RawClient { + RawClient::new(&Profile::new(crate::models::Protocol::Raw), Arc::new(|_, _, _| {}), Arc::new(|_| {}), Arc::new(|| {})) + } + + #[test] + fn parse_hex_string_cases() { + let ok: [(&str, &[u8]); 5] = + [("48656c6c6f", b"Hello"), ("48 65 6c 6c 6f", b"Hello"), ("0x48 0x65 0x6c 0x6c 0x6f", b"Hello"), ("0000", &[0, 0]), ("ff ff", &[0xff, 0xff])]; + for (input, want) in ok { + assert_eq!(parse_hex_string(input).unwrap(), want, "{input:?}"); + } + for input in ["", "ZZZZ", "abc"] { + assert!(parse_hex_string(input).is_err(), "{input:?}"); + } + } + + #[test] + fn parse_escaped_cases() { + let ok: [(&str, &[u8]); 9] = [ + ("hello", b"hello"), + (r"\n", b"\n"), + (r"\r\n", b"\r\n"), + (r"\t", b"\t"), + (r"\0", &[0]), + (r"\xff", &[0xff]), + (r"\\", b"\\"), + (r"AT\r\n", b"AT\r\n"), + (r"\x00", &[0]), + ]; + for (input, want) in ok { + assert_eq!(parse_escaped(input).unwrap(), want, "{input:?}"); + } + for input in [r"hello\", r"\q", r"\x0", r"\x+f"] { + assert!(parse_escaped(input).is_err(), "{input:?}"); + } + } + + #[test] + fn format_hex_dump_cases() { + assert_eq!(format_hex_dump(&[]), ""); + let got = format_hex_dump(b"Hi"); + assert!(got.contains("48 69") && got.contains("|Hi"), "{got}"); + let got = format_hex_dump(b"ABCDEFGHIJKLMNOPQRST"); + let lines: Vec<_> = got.trim_end_matches('\n').split('\n').collect(); + assert_eq!(lines.len(), 2); + assert!(lines[0].starts_with("00000000") && lines[1].starts_with("00000010")); + assert_eq!(lines[0], "00000000 41 42 43 44 45 46 47 48 49 4a 4b 4c 4d 4e 4f 50 |ABCDEFGHIJKLMNOP|"); + assert_eq!(lines[1].len(), lines[0].len()); + } + + #[test] + fn format_hex_cases() { + assert_eq!(format_hex(&[]), ""); + assert_eq!(format_hex(&[0x42]), "42"); + assert_eq!(format_hex(b"Hello"), "48 65 6c 6c 6f"); + } + + #[test] + fn to_display_encoding_cases() { + assert_eq!(to_display_encoding(b"Hello, World!", "utf-8"), "Hello, World!"); + assert_eq!(to_display_encoding(&[0x80, 0x41], "ascii"), ".A"); + assert_eq!(to_display_encoding(&[0xe9], "latin-1"), "é"); + assert_eq!(to_display_encoding(&[0xe9], "iso-8859-1"), "é"); + } + + #[test] + fn history_is_capped_by_entry_count() { + let c = client(); + for i in 0..MAX_HISTORY_ENTRIES + 25 { + c.inner.record(RECV, &[i as u8]); + } + assert_eq!(c.history().len(), MAX_HISTORY_ENTRIES); + } + + #[test] + fn history_is_capped_by_bytes() { + let c = client(); + let chunk = vec![b'a'; 1024]; + for _ in 0..MAX_HISTORY_BYTES / chunk.len() + 10 { + c.inner.record(RECV, &chunk); + } + let total: usize = c.history().iter().map(|e| e.data.len()).sum(); + assert!(total <= MAX_HISTORY_BYTES); + c.clear_history(); + assert!(c.history().is_empty()); + } + + #[test] + fn line_ending_is_appended_once() { + assert_eq!(apply_line_ending("AT", LineEnding::Crlf), b"AT\r\n"); + assert_eq!(apply_line_ending("AT\r\n", LineEnding::Crlf), b"AT\r\n"); + assert_eq!(apply_line_ending("AT", LineEnding::Cr), b"AT\r"); + assert_eq!(apply_line_ending("AT", LineEnding::None), b"AT"); + } + + #[test] + fn send_receive_history_and_capture() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let profile = Profile { host: "127.0.0.1".into(), port: listener.local_addr().unwrap().port() as i64, ..Profile::new(crate::models::Protocol::Raw) }; + let (tx, rx) = mpsc::channel::<(Vec, String, i64)>(); + let (ctx, crx) = mpsc::channel::<()>(); + let c = RawClient::new( + &profile, + Arc::new(move |d, dir, ts| tx.send((d.to_vec(), dir.to_string(), ts)).unwrap()), + Arc::new(|_| {}), + Arc::new(move || ctx.send(()).unwrap()), + ); + assert!(c.write(b"x").is_err()); + c.connect().unwrap(); + let (mut srv, _) = listener.accept().unwrap(); + srv.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + + c.write_text("AT").unwrap(); + c.write_hex("01 ff").unwrap(); + c.write_escaped(r"\x00z").unwrap(); + assert!(c.write_hex("zz").unwrap_err().to_string().starts_with("invalid hex input")); + let mut buf = [0u8; 8]; + srv.read_exact(&mut buf).unwrap(); + assert_eq!(&buf, b"AT\r\n\x01\xff\x00z"); + + srv.write_all(b"OK").unwrap(); + let mut events = Vec::new(); + while events.iter().map(|(d, dir, _): &(Vec, String, i64)| if dir == RECV { d.len() } else { 0 }).sum::() < 2 { + events.push(rx.recv_timeout(Duration::from_secs(5)).unwrap()); + } + assert_eq!(events[0].1, SEND); + assert!(events[0].2 > 1_600_000_000_000); + let hist = c.history(); + assert_eq!(hist.iter().filter(|e| e.direction == SEND).count(), 3); + assert_eq!(hist.last().unwrap().direction, RECV); + + let path = std::env::temp_dir().join(format!("nexus-raw-capture-{}.json", std::process::id())); + c.save_capture(&path).unwrap(); + let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + std::fs::remove_file(&path).unwrap(); + assert_eq!(v[0]["direction"], "send"); + assert_eq!(v[0]["data"], "QVQNCg=="); // "AT\r\n" + assert!(v[0]["timestamp"].as_str().unwrap().contains('T')); + + c.close(); + c.close(); + crx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(crx.recv_timeout(Duration::from_millis(100)).is_err()); + } +} diff --git a/rust/core/src/clients/serial.rs b/rust/core/src/clients/serial.rs new file mode 100644 index 0000000..bee042f --- /dev/null +++ b/rust/core/src/clients/serial.rs @@ -0,0 +1,369 @@ +//! Serial client (new protocol, no Go original) on the `serialport` crate. The device path is +//! `Profile::host`, line settings come from `Profile::serial`. Same conventions as the other +//! clients: blocking `connect`, an own reader thread, `on_close` exactly once (unplug included). + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serialport::{DataBits, FlowControl, Parity, SerialPort, StopBits}; + +use super::Callbacks; +use crate::models::{self, LineEnding, Profile, SerialConfig}; + +/// The reader polls this often, so `close` stops it within this long. +const READ_POLL: Duration = Duration::from_millis(100); +const WRITE_TIMEOUT: Duration = Duration::from_secs(5); +const BREAK_DURATION: Duration = Duration::from_millis(250); + +type OpenPort = (Box, Arc); + +pub struct SerialClient { + inner: Arc, +} + +struct Inner { + path: String, + cfg: SerialConfig, + cb: Callbacks, + /// The writer handle plus this connection's stop flag (the reader owns a clone of the port). + port: Mutex>, +} + +/// Device paths of the serial ports on this machine (empty if enumeration fails). +pub fn list_ports() -> Vec { + serialport::available_ports().map(|v| v.into_iter().map(|p| p.port_name).collect()).unwrap_or_default() +} + +impl SerialClient { + pub fn new(profile: &Profile, cb: Callbacks) -> SerialClient { + SerialClient { + inner: Arc::new(Inner { path: profile.host.trim().to_string(), cfg: profile.serial.clone().unwrap_or_default(), cb, port: Mutex::new(None) }), + } + } + + /// Opens and configures the port, applies DTR/RTS, and starts the reader thread. + pub fn connect(&self) -> crate::Result<()> { + let inner = &self.inner; + if inner.port.lock().unwrap().is_some() { + return Err("already connected".into()); + } + if inner.path.is_empty() { + return Err("no serial device specified".into()); + } + let c = &inner.cfg; + let mut port = serialport::new(&inner.path, c.baud_rate) + .data_bits(match c.data_bits { + 5 => DataBits::Five, + 6 => DataBits::Six, + 7 => DataBits::Seven, + _ => DataBits::Eight, + }) + .parity(match c.parity { + models::Parity::None => Parity::None, + models::Parity::Odd => Parity::Odd, + models::Parity::Even => Parity::Even, + }) + .stop_bits(if c.stop_bits == 2 { StopBits::Two } else { StopBits::One }) + .flow_control(match c.flow_control { + models::FlowControl::None => FlowControl::None, + models::FlowControl::Xonxoff => FlowControl::Software, + models::FlowControl::Rtscts => FlowControl::Hardware, + }) + .timeout(WRITE_TIMEOUT) + .open() + .map_err(|e| open_error(&inner.path, &e))?; + // Opening asserts both lines on most OSes, so set them either way. Ptys and some adapters + // have no modem lines; that's not fatal. + let _ = port.write_data_terminal_ready(c.dtr_on_connect); + let _ = port.write_request_to_send(c.rts_on_connect); + + let mut reader = port.try_clone()?; + reader.set_timeout(READ_POLL)?; + let stop = Arc::new(AtomicBool::new(false)); + { + let mut guard = inner.port.lock().unwrap(); + if guard.is_some() { + return Err("already connected".into()); + } + *guard = Some((port, stop.clone())); + } + let me = inner.clone(); + std::thread::spawn(move || me.read_loop(reader, stop)); + Ok(()) + } + + /// Sends user input: the Enter key's `\r` becomes the configured line ending, text is encoded, + /// and with local echo on the input is fed back through `on_data` (Enter shown as CRLF). + pub fn write(&self, data: &[u8]) -> crate::Result<()> { + let out = translate_enter(data, self.inner.cfg.line_ending); + let out = if super::is_latin1(&self.inner.cfg.encoding) { super::utf8_to_latin1(&out) } else { out }; + self.with_port(|p| Ok(p.write_all(&out)?))?; + if self.inner.cfg.local_echo { + (self.inner.cb.on_data)(&translate_enter(data, LineEnding::Crlf)); + } + Ok(()) + } + + pub fn set_lines(&self, dtr: bool, rts: bool) -> crate::Result<()> { + self.with_port(|p| { + p.write_data_terminal_ready(dtr)?; + p.write_request_to_send(rts)?; + Ok(()) + }) + } + + /// Holds the line in break for ~250 ms (blocks the caller, and other writes, meanwhile). + pub fn send_break(&self) -> crate::Result<()> { + self.with_port(|p| { + p.set_break()?; + std::thread::sleep(BREAK_DURATION); + p.clear_break()?; + Ok(()) + }) + } + + pub fn close(&self) { + self.inner.close(); + } + + pub fn is_connected(&self) -> bool { + self.inner.port.lock().unwrap().is_some() + } + + fn with_port(&self, f: impl FnOnce(&mut Box) -> crate::Result<()>) -> crate::Result<()> { + let mut guard = self.inner.port.lock().unwrap(); + let (port, _) = guard.as_mut().ok_or("not connected")?; + f(port) + } +} + +impl Inner { + fn read_loop(&self, mut reader: Box, stop: Arc) { + let mut buf = [0u8; 4096]; + let latin1 = super::is_latin1(&self.cfg.encoding); + while !stop.load(Ordering::SeqCst) { + let err = match reader.read(&mut buf) { + Ok(0) => "device disconnected".to_string(), + Ok(n) if latin1 => { + (self.cb.on_data)(super::latin1_to_utf8(&buf[..n]).as_bytes()); + continue; + } + Ok(n) => { + (self.cb.on_data)(&buf[..n]); + continue; + } + Err(e) if matches!(e.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::Interrupted) => continue, + Err(e) => format!("device disconnected: {e}"), + }; + if !stop.load(Ordering::SeqCst) { + (self.cb.on_error)(format!("read error: {err}")); + self.close(); + } + return; + } + } + + fn close(&self) { + let taken = self.port.lock().unwrap().take(); + if let Some((port, stop)) = taken { + stop.store(true, Ordering::SeqCst); + drop(port); + (self.cb.on_close)(); + } + } +} + +/// Replaces each `\r` (the Enter key) with `le`; `None` leaves it alone. +fn translate_enter(data: &[u8], le: LineEnding) -> Vec { + if le == LineEnding::None { + return data.to_vec(); + } + let mut out = Vec::with_capacity(data.len() + 4); + for &b in data { + if b == b'\r' { out.extend_from_slice(le.bytes()) } else { out.push(b) } + } + out +} + +fn open_error(path: &str, e: &serialport::Error) -> crate::Error { + let desc = e.description.to_lowercase(); + let busy = ["busy", "lock", "access is denied", "in use"].iter().any(|s| desc.contains(s)); + let hint = match e.kind { + serialport::ErrorKind::NoDevice if busy => " (the port is in use by another program)", + serialport::ErrorKind::Io(std::io::ErrorKind::PermissionDenied) => { + " (permission denied: add your user to the dialout (Linux) / uucp group, then log in again)" + } + _ => "", + }; + format!("cannot open serial port {path}: {}{hint}", e.description).into() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + + #[test] + fn enter_translation() { + assert_eq!(translate_enter(b"AT\r", LineEnding::Crlf), b"AT\r\n"); + assert_eq!(translate_enter(b"AT\r", LineEnding::Lf), b"AT\n"); + assert_eq!(translate_enter(b"AT\r", LineEnding::Cr), b"AT\r"); + assert_eq!(translate_enter(b"AT\r", LineEnding::None), b"AT\r"); + } + + #[test] + fn error_hints() { + let perm = serialport::Error::new(serialport::ErrorKind::Io(std::io::ErrorKind::PermissionDenied), "Permission denied"); + assert!(open_error("/dev/ttyUSB0", &perm).to_string().contains("dialout")); + let busy = serialport::Error::new(serialport::ErrorKind::NoDevice, "Device or resource busy"); + assert!(open_error("/dev/ttyUSB0", &busy).to_string().contains("in use by another program")); + let missing = serialport::Error::new(serialport::ErrorKind::Io(std::io::ErrorKind::NotFound), "No such file or directory"); + assert_eq!(open_error("/dev/x", &missing).to_string(), "cannot open serial port /dev/x: No such file or directory"); + } + + #[test] + fn not_connected() { + let c = SerialClient::new(&Profile::new(models::Protocol::Serial), Callbacks::noop()); + assert!(c.write(b"x").is_err()); + assert_eq!(c.connect().unwrap_err().to_string(), "no serial device specified"); + let _ = list_ports(); + } + + /// A pty pair: the client opens the slave as its "device", the test plays the far end on the master. + #[cfg(target_os = "linux")] + mod pty { + use super::*; + use std::fs::File; + use std::io::Write; + use std::os::fd::{AsRawFd, FromRawFd}; + + struct Pty { + master: File, + slave: File, + path: String, + } + + fn open_pty() -> Pty { + let (mut m, mut s) = (0, 0); + let mut name = [0 as libc::c_char; 128]; + let rc = unsafe { libc::openpty(&mut m, &mut s, name.as_mut_ptr(), std::ptr::null(), std::ptr::null()) }; + assert_eq!(rc, 0); + let path = unsafe { std::ffi::CStr::from_ptr(name.as_ptr()) }.to_string_lossy().into_owned(); + Pty { master: unsafe { File::from_raw_fd(m) }, slave: unsafe { File::from_raw_fd(s) }, path } + } + + /// Reads from the master until something arrives or `timeout` passes. + fn read_master(master: &File, timeout: Duration) -> Vec { + let mut pfd = libc::pollfd { fd: master.as_raw_fd(), events: libc::POLLIN, revents: 0 }; + if unsafe { libc::poll(&mut pfd, 1, timeout.as_millis() as i32) } != 1 { + return Vec::new(); + } + let mut buf = [0u8; 256]; + let n = std::io::Read::read(&mut &*master, &mut buf).unwrap(); + buf[..n].to_vec() + } + + fn master_n(master: &File, n: usize) -> Vec { + let mut got = Vec::new(); + while got.len() < n { + let chunk = read_master(master, Duration::from_secs(5)); + assert!(!chunk.is_empty(), "timed out"); + got.extend(chunk); + } + got + } + + fn recv_n(rx: &mpsc::Receiver>, n: usize) -> Vec { + let mut got = Vec::new(); + while got.len() < n { + got.extend(rx.recv_timeout(Duration::from_secs(5)).expect("timed out")); + } + got + } + + fn client(pty: &Pty, cfg: SerialConfig) -> (SerialClient, mpsc::Receiver>, mpsc::Receiver, mpsc::Receiver<()>) { + let (dtx, drx) = mpsc::channel(); + let (etx, erx) = mpsc::channel(); + let (ctx, crx) = mpsc::channel(); + let cb = Callbacks { + on_data: Arc::new(move |d| { + let _ = dtx.send(d.to_vec()); + }), + on_error: Arc::new(move |e| { + let _ = etx.send(e); + }), + on_close: Arc::new(move || { + let _ = ctx.send(()); + }), + }; + let profile = Profile { host: pty.path.clone(), serial: Some(cfg), ..Profile::new(models::Protocol::Serial) }; + (SerialClient::new(&profile, cb), drx, erx, crx) + } + + #[test] + fn data_echo_line_endings_and_close() { + let mut pty = open_pty(); + let cfg = SerialConfig { local_echo: true, line_ending: LineEnding::Crlf, ..Default::default() }; + let (c, data, errors, closed) = client(&pty, cfg); + c.connect().unwrap(); + assert!(c.connect().is_err()); + + c.write(b"AT\r").unwrap(); + assert_eq!(master_n(&pty.master, 4), b"AT\r\n"); + assert_eq!(recv_n(&data, 4), b"AT\r\n"); // local echo + + pty.master.write_all(b"OK\r\n").unwrap(); + assert_eq!(recv_n(&data, 4), b"OK\r\n"); + + c.close(); + c.close(); + closed.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(closed.recv_timeout(Duration::from_millis(300)).is_err()); + assert!(errors.try_recv().is_err()); + assert!(c.write(b"x").is_err()); + } + + #[test] + fn latin1_encoding() { + let mut pty = open_pty(); + let cfg = SerialConfig { encoding: "latin1".into(), line_ending: LineEnding::Lf, ..Default::default() }; + let (c, data, _e, _c) = client(&pty, cfg); + c.connect().unwrap(); + c.write("é\r".as_bytes()).unwrap(); + assert_eq!(master_n(&pty.master, 2), [0xe9, b'\n']); + pty.master.write_all(&[0xe9]).unwrap(); + assert_eq!(recv_n(&data, 2), "é".as_bytes()); + } + + #[test] + fn xon_xoff_flow_control() { + let mut pty = open_pty(); + let cfg = SerialConfig { flow_control: models::FlowControl::Xonxoff, line_ending: LineEnding::None, ..Default::default() }; + let (c, _d, _e, _c) = client(&pty, cfg); + c.connect().unwrap(); + pty.master.write_all(&[0x13]).unwrap(); // XOFF + std::thread::sleep(Duration::from_millis(100)); + let c = Arc::new(c); + let w = c.clone(); + let writer = std::thread::spawn(move || w.write(b"x")); + assert!(read_master(&pty.master, Duration::from_millis(300)).is_empty(), "output not paused by XOFF"); + pty.master.write_all(&[0x11]).unwrap(); // XON + assert_eq!(master_n(&pty.master, 1), b"x"); + writer.join().unwrap().unwrap(); + } + + #[test] + fn unplug_reports_error_and_closes() { + let pty = open_pty(); + let (c, _d, errors, closed) = client(&pty, SerialConfig::default()); + c.connect().unwrap(); + drop(pty.master); + drop(pty.slave); + let err = errors.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(err.starts_with("read error: device disconnected"), "{err}"); + closed.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(!c.is_connected()); + } + } +} diff --git a/rust/core/src/clients/sftp.rs b/rust/core/src/clients/sftp.rs new file mode 100644 index 0000000..886313a --- /dev/null +++ b/rust/core/src/clients/sftp.rs @@ -0,0 +1,149 @@ +//! SFTP operations over an SSH session's subsystem (internal/sftpclient). Every call blocks the +//! caller, so the UI runs them on worker threads. + +use super::rt; +use crate::Result; +use russh_sftp::client::SftpSession; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[derive(Clone, Debug, PartialEq)] +pub struct FileEntry { + pub name: String, + /// Full remote path. + pub path: String, + pub size: u64, + pub is_dir: bool, + /// Unix permission string, e.g. "-rwxr-xr-x". + pub mode: String, + /// Unix epoch milliseconds. + pub modified_at: i64, +} + +#[derive(Clone, Debug, Default)] +pub struct Progress { + pub transferred: u64, + pub total: u64, + pub path: String, + pub done: bool, + pub error: String, +} + +fn mode_string(m: &russh_sftp::protocol::FileAttributes) -> String { + let kind = if m.is_dir() { + 'd' + } else if m.is_symlink() { + 'L' + } else { + '-' + }; + format!("{kind}{}", m.permissions()) +} + +fn entry(name: String, path: String, m: &russh_sftp::protocol::FileAttributes) -> FileEntry { + FileEntry { name, path, size: m.size.unwrap_or(0), is_dir: m.is_dir(), mode: mode_string(m), modified_at: m.mtime.map_or(0, |t| t as i64 * 1000) } +} + +/// `path.Join` for remote (always '/') paths. +pub fn join(dir: &str, name: &str) -> String { + if dir.is_empty() { + name.to_string() + } else if dir.ends_with('/') { + format!("{dir}{name}") + } else { + format!("{dir}/{name}") + } +} + +/// Directories first, then case-insensitive name order. +pub fn list(s: &Arc, path: &str) -> Result> { + let rd = rt().block_on(s.read_dir(path)).map_err(|e| format!("sftpclient: failed to list {path}: {e}"))?; + let mut out: Vec = + rd.filter(|e| e.file_name() != "." && e.file_name() != "..").map(|e| entry(e.file_name(), join(path, &e.file_name()), &e.metadata())).collect(); + out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + Ok(out) +} + +pub fn stat(s: &Arc, path: &str) -> Result { + let m = rt().block_on(s.metadata(path)).map_err(|e| format!("sftpclient: stat failed for {path}: {e}"))?; + let name = path.rsplit('/').next().unwrap_or(path).to_string(); + Ok(entry(name, path.to_string(), &m)) +} + +pub fn mkdir(s: &Arc, path: &str) -> Result<()> { + Ok(rt().block_on(s.create_dir(path))?) +} + +/// Removes a file, or an empty directory. +pub fn remove(s: &Arc, path: &str) -> Result<()> { + rt().block_on(async { + match s.remove_file(path).await { + Ok(()) => Ok(()), + Err(e) => s.remove_dir(path).await.map_err(|_| e), + } + })?; + Ok(()) +} + +pub fn rename(s: &Arc, from: &str, to: &str) -> Result<()> { + Ok(rt().block_on(s.rename(from, to))?) +} + +pub fn pwd(s: &Arc) -> Result { + Ok(rt().block_on(s.canonicalize("."))?) +} + +async fn pump( + mut src: impl tokio::io::AsyncRead + Unpin, + mut dst: impl tokio::io::AsyncWrite + Unpin, + total: u64, + path: &str, + on: &dyn Fn(Progress), +) -> Result<()> { + let mut buf = vec![0u8; 32 * 1024]; + let (mut done, mut last) = (0u64, Instant::now()); + loop { + let n = src.read(&mut buf).await.map_err(|e| format!("sftpclient: read failed: {e}"))?; + if n == 0 { + break; + } + dst.write_all(&buf[..n]).await.map_err(|e| format!("sftpclient: write failed: {e}"))?; + done += n as u64; + if last.elapsed() > Duration::from_millis(100) { + on(Progress { transferred: done, total, path: path.into(), ..Default::default() }); + last = Instant::now(); + } + } + dst.flush().await?; + dst.shutdown().await?; + on(Progress { transferred: done, total, path: path.into(), done: true, ..Default::default() }); + Ok(()) +} + +pub fn download(s: &Arc, remote: &str, local: &std::path::Path, on: &dyn Fn(Progress)) -> Result<()> { + rt().block_on(async { + let total = s.metadata(remote).await.map_err(|e| format!("sftpclient: stat failed: {e}"))?.size.unwrap_or(0); + let src = s.open(remote).await.map_err(|e| format!("sftpclient: failed to open remote file: {e}"))?; + let dst = tokio::fs::File::create(local).await.map_err(|e| format!("sftpclient: failed to create local file: {e}"))?; + pump(src, dst, total, remote, on).await + }) +} + +pub fn upload(s: &Arc, local: &std::path::Path, remote: &str, on: &dyn Fn(Progress)) -> Result<()> { + rt().block_on(async { + let src = tokio::fs::File::open(local).await.map_err(|e| format!("sftpclient: failed to open local file: {e}"))?; + let total = src.metadata().await?.len(); + let dst = s.create(remote).await.map_err(|e| format!("sftpclient: failed to create remote file: {e}"))?; + pump(src, dst, total, remote, on).await + }) +} + +#[cfg(test)] +mod tests { + #[test] + fn join_paths() { + assert_eq!(super::join("/", "a"), "/a"); + assert_eq!(super::join("/home/x", "a"), "/home/x/a"); + } +} diff --git a/rust/core/src/clients/ssh.rs b/rust/core/src/clients/ssh.rs new file mode 100644 index 0000000..29b434b --- /dev/null +++ b/rust/core/src/clients/ssh.rs @@ -0,0 +1,948 @@ +//! SSH client on russh: interactive PTY shell, the profile's auth chain (agent, public key, +//! password, keyboard-interactive with TOTP), jump hosts, SOCKS5/HTTP proxies, local and remote +//! port forwards, agent forwarding and SFTP. A port of internal/sshclient + sftpclient. + +use super::{Callbacks, rt}; +use crate::Result; +use crate::models::{ForwardDirection, JumpHost, PortForward, Profile, SshConfig}; +use base64::Engine; +use russh::client::{self, Handle, Msg}; +use russh::keys::{HashAlg, PrivateKeyWithHashAlg, PublicKey}; +use russh::{Channel, ChannelMsg, Pty}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::sync::mpsc; + +const MAX_PRIVATE_KEY_BYTES: u64 = 1 << 20; +/// Bounds what `run_command` buffers, so a hostile server can't stream unlimited output into memory. +const MAX_COMMAND_OUTPUT_BYTES: usize = 1 << 20; + +/// What a host-key decision needs. The manager's checker consults the known-hosts table and, +/// per policy, prompts the user; it returns Err with the reason to reject. +#[derive(Clone, Debug)] +pub struct HostKeyInfo { + pub host: String, + pub port: u16, + pub key_type: String, + pub fingerprint: String, + pub public_key_b64: String, +} + +pub type HostKeyCheck = Arc std::result::Result<(), String> + Send + Sync>; +/// Blocking credential source (may prompt the user). +pub type SecretFn = Arc Result + Send + Sync>; + +pub fn host_key_info(host: &str, port: u16, key: &PublicKey) -> HostKeyInfo { + HostKeyInfo { + host: host.into(), + port, + key_type: key.algorithm().as_str().into(), + fingerprint: key.fingerprint(HashAlg::Sha256).to_string(), + public_key_b64: base64::engine::general_purpose::STANDARD.encode(key.to_bytes().unwrap_or_default()), + } +} + +/// State the russh handler shares with the client: remote-forward targets and the last +/// host-key rejection reason (russh only reports "unknown key"). +#[derive(Default)] +struct Shared { + remote_targets: Mutex>, + rejection: Mutex>, + agent_forwarding: std::sync::atomic::AtomicBool, +} + +struct Handler { + host: String, + port: u16, + check: HostKeyCheck, + shared: Arc, +} + +impl client::Handler for Handler { + type Error = russh::Error; + + async fn check_server_key(&mut self, key: &russh::keys::PublicKeyOrCertificate) -> std::result::Result { + let key = match key { + russh::keys::PublicKeyOrCertificate::PublicKey { key, .. } => key.clone(), + russh::keys::PublicKeyOrCertificate::Certificate(c) => c.public_key().clone().into(), + }; + let info = host_key_info(&self.host, self.port, &key); + let check = self.check.clone(); + // The check may block on a UI prompt. + let res = tokio::task::spawn_blocking(move || check(&info)).await.unwrap_or(Err("host key check failed".into())); + match res { + Ok(()) => Ok(true), + Err(why) => { + *self.shared.rejection.lock().unwrap() = Some(why); + Ok(false) + } + } + } + + async fn server_channel_open_forwarded_tcpip( + &mut self, + channel: Channel, + connected_address: &str, + connected_port: u32, + _originator_address: &str, + _originator_port: u32, + reply: client::ChannelOpenHandle, + _session: &mut client::Session, + ) -> std::result::Result<(), Self::Error> { + let dest = self.shared.remote_targets.lock().unwrap().get(&(connected_address.to_string(), connected_port)).cloned(); + let Some((host, port)) = dest else { + reply.reject(russh::ChannelOpenFailure::AdministrativelyProhibited).await; + return Ok(()); + }; + reply.accept().await; + tokio::spawn(async move { + if let Ok(mut local) = tokio::net::TcpStream::connect((host.as_str(), port)).await { + let mut remote = channel.into_stream(); + let _ = tokio::io::copy_bidirectional(&mut remote, &mut local).await; + } + }); + Ok(()) + } + + async fn server_channel_open_agent_forward( + &mut self, + channel: Channel, + reply: client::ChannelOpenHandle, + _session: &mut client::Session, + ) -> std::result::Result<(), Self::Error> { + if !self.shared.agent_forwarding.load(std::sync::atomic::Ordering::Relaxed) { + reply.reject(russh::ChannelOpenFailure::AdministrativelyProhibited).await; + return Ok(()); + } + reply.accept().await; + tokio::spawn(async move { + if let Ok(mut agent) = connect_agent_socket().await { + let mut remote = channel.into_stream(); + let _ = tokio::io::copy_bidirectional(&mut remote, &mut agent).await; + } + }); + Ok(()) + } +} + +trait Stream: AsyncRead + AsyncWrite + Unpin + Send {} +impl Stream for T {} + +/// The local agent's socket: `SSH_AUTH_SOCK` on Unix, the OpenSSH named pipe on Windows. +async fn connect_agent_socket() -> std::io::Result> { + #[cfg(unix)] + { + let p = std::env::var("SSH_AUTH_SOCK").map_err(|_| std::io::Error::other("SSH_AUTH_SOCK not set — SSH agent unavailable"))?; + Ok(Box::new(tokio::net::UnixStream::connect(p).await?)) + } + #[cfg(windows)] + { + let p = std::env::var("SSH_AUTH_SOCK").unwrap_or_else(|_| r"\\.\pipe\openssh-ssh-agent".into()); + Ok(Box::new(tokio::net::windows::named_pipe::ClientOptions::new().open(p)?)) + } +} + +type Agent = russh::keys::agent::client::AgentClient>; + +async fn connect_agent() -> Result { + #[cfg(unix)] + { + Ok(russh::keys::agent::client::AgentClient::connect_env().await.map_err(|e| format!("SSH agent unavailable: {e}"))?.dynamic()) + } + #[cfg(windows)] + { + if let Ok(a) = russh::keys::agent::client::AgentClient::connect_named_pipe(r"\\.\pipe\openssh-ssh-agent").await { + return Ok(a.dynamic()); + } + Ok(russh::keys::agent::client::AgentClient::connect_pageant().await.map_err(|e| format!("SSH agent unavailable: {e}"))?.dynamic()) + } +} + +fn is_totp_prompt(q: &str) -> bool { + let q = q.to_lowercase(); + ["verification code", "otp", "one-time", "one time", "two-factor", "two factor", "2fa", "totp", "authenticator", "security code", "6-digit", "6 digit"] + .iter() + .any(|k| q.contains(k)) +} + +/// RFC 6238 code (SHA-1, 6 digits, 30 s) for a base32 secret, normalised as pquerna/otp does. +pub fn totp_code(secret: &str) -> Result { + totp_at(secret, std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()) +} + +fn totp_at(secret: &str, unix: u64) -> Result { + use hmac::{Hmac, KeyInit, Mac}; + let key = base32_decode(secret).ok_or("invalid TOTP secret (not base32)")?; + let mut mac = Hmac::::new_from_slice(&key).map_err(|e| e.to_string())?; + mac.update(&(unix / 30).to_be_bytes()); + let h = mac.finalize().into_bytes(); + let o = (h[19] & 0xf) as usize; + let bin = u32::from_be_bytes([h[o] & 0x7f, h[o + 1], h[o + 2], h[o + 3]]); + Ok(format!("{:06}", bin % 1_000_000)) +} + +fn base32_decode(s: &str) -> Option> { + let (mut out, mut buf, mut bits) = (vec![], 0u64, 0u32); + for c in s.chars().filter(|c| !c.is_whitespace() && *c != '=') { + let v = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".find(c.to_ascii_uppercase())? as u64; + buf = (buf << 5) | v; + bits += 5; + if bits >= 8 { + bits -= 8; + out.push((buf >> bits) as u8); + } + } + (!out.is_empty()).then_some(out) +} + +/// Reads an OpenSSH/PEM private key, asking for a passphrase only if it's encrypted. +pub fn load_private_key(path: &str, passphrase: Option<&SecretFn>) -> Result { + let meta = std::fs::metadata(path).map_err(|e| format!("unable to stat key file: {e}"))?; + if meta.is_dir() { + return Err("key path is a directory".into()); + } + if meta.len() > MAX_PRIVATE_KEY_BYTES { + return Err(format!("key file is too large: {} bytes exceeds {MAX_PRIVATE_KEY_BYTES} bytes", meta.len()).into()); + } + let data = std::fs::read_to_string(path).map_err(|e| format!("unable to read key file: {e}"))?; + match russh::keys::decode_secret_key(&data, None) { + Ok(k) => Ok(k), + Err(first) => { + let Some(get) = passphrase else { + return Err(format!("key is encrypted and no passphrase callback provided: {first}").into()); + }; + let pass = get().map_err(|e| format!("failed to get passphrase: {e}"))?; + russh::keys::decode_secret_key(&data, Some(&pass)).map_err(|e| format!("failed to parse encrypted key: {e}").into()) + } + } +} + +async fn blocking(f: impl FnOnce() -> T + Send + 'static) -> T { + tokio::task::spawn_blocking(f).await.expect("blocking task panicked") +} + +async fn dial(sc: &SshConfig, host: &str, port: u16, timeout: Duration) -> Result { + let fut = async { + match sc.proxy_type.to_lowercase().as_str() { + "socks5" => socks5(sc, host, port).await, + "http" => http_connect(sc, host, port).await, + _ => Ok(tokio::net::TcpStream::connect((host, port)).await?), + } + }; + tokio::time::timeout(timeout, fut).await.map_err(|_| format!("dial tcp {host}:{port}: i/o timeout"))? +} + +async fn socks5(sc: &SshConfig, host: &str, port: u16) -> Result { + let pport = if sc.proxy_port == 0 { 1080 } else { sc.proxy_port as u16 }; + let mut s = tokio::net::TcpStream::connect((sc.proxy_host.as_str(), pport)).await.map_err(|e| format!("SOCKS5 proxy setup failed: {e}"))?; + let auth = !sc.proxy_username.is_empty(); + s.write_all(if auth { &[5, 2, 0, 2] } else { &[5, 1, 0] }).await?; + let mut r = [0u8; 2]; + s.read_exact(&mut r).await?; + match r[1] { + 0 => {} + 2 if auth => { + s.write_all(&socks5_auth_request(&sc.proxy_username, &sc.proxy_password)?).await?; + s.read_exact(&mut r).await?; + if r[1] != 0 { + return Err("SOCKS5 proxy: authentication failed".into()); + } + } + _ => return Err("SOCKS5 proxy: no acceptable authentication method".into()), + } + s.write_all(&socks5_connect_request(host, port)?).await?; + let mut head = [0u8; 4]; + s.read_exact(&mut head).await?; + if head[1] != 0 { + return Err(format!("SOCKS5 proxy connection to {host}:{port} failed: reply code {}", head[1]).into()); + } + let skip = match head[3] { + 1 => 4, + 4 => 16, + 3 => s.read_u8().await? as usize, + _ => 0, + } + 2; + let mut rest = vec![0u8; skip]; + s.read_exact(&mut rest).await?; + Ok(s) +} + +/// RFC 1929 username/password sub-negotiation. Both fields carry a one-byte length, so longer +/// values are rejected rather than silently truncated into a malformed request. +fn socks5_auth_request(user: &str, pass: &str) -> Result> { + let ulen = u8::try_from(user.len()).map_err(|_| "SOCKS5 proxy: username longer than 255 bytes")?; + let plen = u8::try_from(pass.len()).map_err(|_| "SOCKS5 proxy: password longer than 255 bytes")?; + let mut m = vec![1, ulen]; + m.extend(user.as_bytes()); + m.push(plen); + m.extend(pass.as_bytes()); + Ok(m) +} + +/// RFC 1928 CONNECT with a domain-name address (one-byte length). +fn socks5_connect_request(host: &str, port: u16) -> Result> { + let hlen = u8::try_from(host.len()).map_err(|_| "SOCKS5 proxy: host name longer than 255 bytes")?; + let mut req = vec![5, 1, 0, 3, hlen]; + req.extend(host.as_bytes()); + req.extend(port.to_be_bytes()); + Ok(req) +} + +async fn http_connect(sc: &SshConfig, host: &str, port: u16) -> Result { + let pport = if sc.proxy_port == 0 { 8080 } else { sc.proxy_port as u16 }; + let mut s = tokio::net::TcpStream::connect((sc.proxy_host.as_str(), pport)).await.map_err(|e| format!("HTTP proxy connection failed: {e}"))?; + let mut req = format!("CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n"); + if !sc.proxy_username.is_empty() { + let cred = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", sc.proxy_username, sc.proxy_password)); + req += &format!("Proxy-Authorization: Basic {cred}\r\n"); + } + req += "\r\n"; + s.write_all(req.as_bytes()).await?; + // Read the response head byte by byte so no tunnelled bytes are consumed. + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + head.push(s.read_u8().await.map_err(|e| format!("HTTP CONNECT response read failed: {e}"))?); + if head.len() > 16 * 1024 { + return Err("HTTP CONNECT response too large".into()); + } + } + let status = String::from_utf8_lossy(&head).lines().next().unwrap_or("").to_string(); + if status.split_whitespace().nth(1) != Some("200") { + return Err(format!("HTTP CONNECT proxy returned {status}").into()); + } + Ok(s) +} + +fn config(sc: &SshConfig) -> Arc { + let mut c = client::Config::default(); + if sc.keepalive_interval > 0 { + c.keepalive_interval = Some(Duration::from_secs(sc.keepalive_interval as u64)); + c.keepalive_max = sc.keepalive_count_max.max(1) as usize; + } + c.preferred.compression = if sc.compression { + vec![russh::compression::ZLIB_LEGACY, russh::compression::ZLIB, russh::compression::NONE].into() + } else { + vec![russh::compression::NONE].into() + }; + c.nodelay = true; + Arc::new(c) +} + +struct Auth<'a> { + user: &'a str, + methods: Vec, + agent: bool, + key: Option, + password: Option, + totp_secret: String, +} + +async fn authenticate(h: &mut Handle, a: Auth<'_>) -> Result<()> { + if a.agent + && let Ok(mut agent) = connect_agent().await + && let Ok(ids) = agent.request_identities().await + { + for id in ids { + let key = id.public_key().into_owned(); + let hash = if key.algorithm().is_rsa() { h.best_supported_rsa_hash().await.ok().flatten().flatten() } else { None }; + if let Ok(r) = h.authenticate_publickey_with(a.user, key, hash, &mut agent).await + && r.success() + { + return Ok(()); + } + } + } + for m in &a.methods { + match m.as_str() { + "publickey" => { + if let Some(k) = &a.key { + let hash = if k.algorithm().is_rsa() { h.best_supported_rsa_hash().await?.flatten() } else { None }; + if h.authenticate_publickey(a.user, PrivateKeyWithHashAlg::new(Arc::new(k.clone()), hash)).await?.success() { + return Ok(()); + } + } + } + "password" => { + if let Some(get) = a.password.clone() { + let pw = blocking(move || get()).await?; + if h.authenticate_password(a.user, pw).await?.success() { + return Ok(()); + } + } + } + "keyboard-interactive" => { + let Some(get) = a.password.clone() else { continue }; + let mut resp = h.authenticate_keyboard_interactive_start(a.user, None).await?; + loop { + match resp { + client::KeyboardInteractiveAuthResponse::Success => return Ok(()), + client::KeyboardInteractiveAuthResponse::Failure { .. } => break, + client::KeyboardInteractiveAuthResponse::InfoRequest { prompts, .. } => { + let mut answers = vec![]; + for p in prompts { + if !a.totp_secret.is_empty() && is_totp_prompt(&p.prompt) { + answers.push(totp_code(&a.totp_secret).map_err(|e| format!("failed to generate TOTP code: {e}"))?); + } else { + let get = get.clone(); + answers.push(blocking(move || get()).await?); + } + } + resp = h.authenticate_keyboard_interactive_respond(answers).await?; + } + } + } + } + _ => {} + } + } + Err("ssh: handshake failed: ssh: unable to authenticate, no supported methods remain".into()) +} + +enum Cmd { + Data(Vec), + Resize(u32, u32), +} + +struct Forward { + rule: PortForward, + tasks: Arc>>, +} + +pub struct SshClient { + profile: Profile, + cb: Callbacks, + shared: Arc, + handle: Mutex>>>, + /// Earlier hops of a jump chain; kept alive for the session's lifetime. + hops: Mutex>>>, + tx: Mutex>>, + forwards: Mutex>, + sftp: tokio::sync::Mutex>>, + closed: std::sync::atomic::AtomicBool, +} + +impl SshClient { + pub fn new(profile: &Profile, cb: Callbacks) -> Arc { + Arc::new(SshClient { + profile: profile.clone(), + cb, + shared: Arc::default(), + handle: Mutex::new(None), + hops: Mutex::new(vec![]), + tx: Mutex::new(None), + forwards: Mutex::new(HashMap::new()), + sftp: tokio::sync::Mutex::new(None), + closed: false.into(), + }) + } + + fn sc(&self) -> SshConfig { + self.profile.ssh.clone().unwrap_or_else(SshConfig::defaults) + } + + /// Connects, authenticates and starts the shell. Blocks the calling thread. + pub fn connect(self: &Arc, check: HostKeyCheck, password: Option, passphrase: Option) -> Result<()> { + let me = self.clone(); + rt().block_on(async move { me.connect_async(check, password, passphrase, true).await }) + } + + /// Connects and authenticates without opening a shell, for tunnelling another protocol. + pub fn connect_tunnel_only(self: &Arc, check: HostKeyCheck, password: Option, passphrase: Option) -> Result<()> { + let me = self.clone(); + rt().block_on(async move { me.connect_async(check, password, passphrase, false).await }) + } + + /// A `direct-tcpip` channel to `host:port` presented as an ordinary loopback TCP stream, + /// so blocking clients (IRC, VNC) can use it with read timeouts. + pub fn open_tunnel(&self, host: &str, port: u16) -> Result { + let h = self.handle()?; + let host = host.to_string(); + let ch = rt() + .block_on(h.channel_open_direct_tcpip(host.clone(), port as u32, "127.0.0.1", 0)) + .map_err(|e| format!("SSH tunnel to {host}:{port} failed: {e}"))?; + let (client, server) = loopback_pair()?; + server.set_nonblocking(true)?; + rt().spawn(async move { + if let Ok(mut local) = tokio::net::TcpStream::from_std(server) { + let mut remote = ch.into_stream(); + let _ = tokio::io::copy_bidirectional(&mut local, &mut remote).await; + } + }); + Ok(client) + } + + async fn handshake( + &self, + stream: impl AsyncRead + AsyncWrite + Unpin + Send + 'static, + host: &str, + port: u16, + check: &HostKeyCheck, + ) -> Result> { + let handler = Handler { host: host.into(), port, check: check.clone(), shared: self.shared.clone() }; + let timeout = Duration::from_secs(self.profile.connect_timeout.max(1) as u64); + match tokio::time::timeout(timeout, client::connect_stream(config(&self.sc()), stream, handler)).await { + Err(_) => Err(format!("SSH handshake with {host}:{port} timed out").into()), + Ok(Ok(h)) => Ok(h), + Ok(Err(e)) => match self.shared.rejection.lock().unwrap().take() { + Some(why) => Err(why.into()), + None => Err(format!("SSH handshake failed: {e}").into()), + }, + } + } + + async fn connect_async(self: &Arc, check: HostKeyCheck, password: Option, passphrase: Option, shell: bool) -> Result<()> { + let sc = self.sc(); + let p = &self.profile; + let port = if p.port == 0 { 22 } else { p.port as u16 }; + let timeout = Duration::from_secs(if p.connect_timeout == 0 { 30 } else { p.connect_timeout as u64 }); + let methods = if sc.auth_methods.is_empty() { SshConfig::defaults().auth_methods } else { sc.auth_methods.clone() }; + let key = if methods.iter().any(|m| m == "publickey") && !sc.private_key_path.is_empty() { + let (path, pp) = (sc.private_key_path.clone(), passphrase.clone()); + Some( + blocking(move || load_private_key(&path, pp.as_ref())) + .await + .map_err(|e| format!("failed to build auth methods: failed to parse private key {}: {e}", sc.private_key_path))?, + ) + } else { + None + }; + + let mut handle = if sc.jump_hosts.is_empty() { + let tcp = dial(&sc, &p.host, port, timeout).await.map_err(|e| format!("SSH connection failed: {e}"))?; + self.handshake(tcp, &p.host, port, &check).await? + } else { + let (prev, h) = self.via_jumps(&sc, &check, passphrase.clone(), timeout).await?; + let ch = prev + .channel_open_direct_tcpip(p.host.clone(), port as u32, "127.0.0.1", 0) + .await + .map_err(|e| format!("failed to dial target {}:{port} through jump hosts: {e}", p.host))?; + self.hops.lock().unwrap().extend(h); + self.hops.lock().unwrap().push(prev); + self.handshake(ch.into_stream(), &p.host, port, &check).await? + }; + + authenticate(&mut handle, Auth { user: &p.username, methods, agent: sc.agent_auth, key, password, totp_secret: sc.totp_secret.clone() }).await?; + let handle = Arc::new(handle); + if !shell { + *self.handle.lock().unwrap() = Some(handle); + return Ok(()); + } + + let ch = handle.channel_open_session().await.map_err(|e| format!("failed to create SSH session: {e}"))?; + for (k, v) in crate::terminalenv::ssh_env_vars(&sc.env_vars) { + let _ = ch.set_env(false, k, v).await; + } + let term = if sc.terminal_type.is_empty() { "xterm-256color" } else { &sc.terminal_type }; + let modes = [(Pty::ECHO, 1), (Pty::TTY_OP_ISPEED, 115200), (Pty::TTY_OP_OSPEED, 115200), (Pty::IUTF8, 1)]; + ch.request_pty(true, term, 80, 24, 0, 0, &modes).await.map_err(|e| format!("PTY request failed: {e}"))?; + if sc.agent_forwarding { + self.shared.agent_forwarding.store(true, std::sync::atomic::Ordering::Relaxed); + if let Err(e) = ch.agent_forward(false).await { + (self.cb.on_error)(format!("agent forwarding: {e}")); + } + } + if sc.startup_command.is_empty() { + ch.request_shell(true).await.map_err(|e| format!("failed to start shell: {e}"))?; + } else { + ch.exec(true, sc.startup_command.as_bytes()).await.map_err(|e| format!("failed to start command: {e}"))?; + } + + let (mut read, write) = ch.split(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + *self.tx.lock().unwrap() = Some(tx); + *self.handle.lock().unwrap() = Some(handle); + tokio::spawn(async move { + while let Some(cmd) = rx.recv().await { + let r = match cmd { + Cmd::Data(d) => write.data_bytes(d).await, + Cmd::Resize(c, r) => write.window_change(c, r, 0, 0).await, + }; + if r.is_err() { + break; + } + } + }); + let me = self.clone(); + tokio::spawn(async move { + while let Some(msg) = read.wait().await { + match msg { + ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, .. } => (me.cb.on_data)(&data), + ChannelMsg::Eof | ChannelMsg::Close => break, + _ => {} + } + } + let me2 = me.clone(); + let _ = tokio::task::spawn_blocking(move || me2.close()).await; + }); + Ok(()) + } + + /// Authenticates through each jump host in turn; returns the last hop and the ones before it. + async fn via_jumps( + &self, + sc: &SshConfig, + check: &HostKeyCheck, + passphrase: Option, + timeout: Duration, + ) -> Result<(Arc>, Vec>>)> { + let mut chain: Vec>> = vec![]; + for (i, j) in sc.jump_hosts.iter().enumerate() { + let JumpHost { host, port, username, private_key_path, .. } = j; + let jport = if *port == 0 { 22 } else { *port as u16 }; + let key = if private_key_path.is_empty() { + None + } else { + let (path, pp) = (private_key_path.clone(), passphrase.clone()); + Some(blocking(move || load_private_key(&path, pp.as_ref())).await.map_err(|e| format!("jump host {i} private key error: {e}"))?) + }; + let mut h = match chain.last() { + None => { + let tcp = tokio::time::timeout(timeout, tokio::net::TcpStream::connect((host.as_str(), jport))) + .await + .map_err(|_| format!("failed to connect to jump host {i} ({host}:{jport}): timeout"))? + .map_err(|e| format!("failed to connect to jump host {i} ({host}:{jport}): {e}"))?; + self.handshake(tcp, host, jport, check).await? + } + Some(prev) => { + let ch = prev + .channel_open_direct_tcpip(host.clone(), jport as u32, "127.0.0.1", 0) + .await + .map_err(|e| format!("failed to dial through jump host {i} ({host}:{jport}): {e}"))?; + self.handshake(ch.into_stream(), host, jport, check).await? + } + }; + authenticate( + &mut h, + Auth { + user: username, + methods: vec!["publickey".into()], + agent: key.is_none() || sc.agent_auth, + key, + password: None, + totp_secret: String::new(), + }, + ) + .await + .map_err(|e| format!("failed to establish SSH through jump host {i}: {e}"))?; + chain.push(Arc::new(h)); + } + let last = chain.pop().expect("at least one jump host"); + Ok((last, chain)) + } + + fn handle(&self) -> Result>> { + self.handle.lock().unwrap().clone().ok_or_else(|| "not connected".into()) + } + + pub fn write(&self, data: &[u8]) -> Result<()> { + let tx = self.tx.lock().unwrap(); + tx.as_ref().ok_or("not connected")?.send(Cmd::Data(data.to_vec())).map_err(|_| "not connected".into()) + } + + pub fn resize(&self, cols: u32, rows: u32) -> Result<()> { + let tx = self.tx.lock().unwrap(); + tx.as_ref().ok_or("not connected")?.send(Cmd::Resize(cols, rows)).map_err(|_| "not connected".into()) + } + + /// Round trip of a keepalive, in milliseconds. + pub fn ping(&self) -> Result { + let h = self.handle()?; + let start = Instant::now(); + rt().block_on(h.send_ping())?; + Ok(start.elapsed().as_millis() as i64) + } + + /// Runs a command on a separate channel and returns trimmed stdout. + pub fn run_command(&self, command: &str) -> Result { + let h = self.handle()?; + let command = command.to_string(); + rt().block_on(async move { + let mut ch = h.channel_open_session().await.map_err(|e| format!("failed to open SSH session: {e}"))?; + ch.exec(true, command.as_bytes()).await?; + let mut out = vec![]; + while let Some(m) = ch.wait().await { + match m { + ChannelMsg::Data { data } => { + if out.len() + data.len() > MAX_COMMAND_OUTPUT_BYTES { + return Err(format!("command output exceeds {MAX_COMMAND_OUTPUT_BYTES} bytes").into()); + } + out.extend_from_slice(&data); + } + ChannelMsg::Eof | ChannelMsg::Close => break, + _ => {} + } + } + Ok(String::from_utf8_lossy(&out).trim().to_string()) + }) + } + + pub fn is_connected(&self) -> bool { + self.handle.lock().unwrap().as_ref().is_some_and(|h| !h.is_closed()) + } + + /// Idempotent; fires on_close once. + pub fn close(&self) { + if self.closed.swap(true, std::sync::atomic::Ordering::SeqCst) { + return; + } + self.stop_all_forwards(); + *self.tx.lock().unwrap() = None; + let h = self.handle.lock().unwrap().take(); + let hops = std::mem::take(&mut *self.hops.lock().unwrap()); + rt().spawn(async move { + if let Some(h) = h { + let _ = h.disconnect(russh::Disconnect::ByApplication, "", "en").await; + } + for h in hops.into_iter().rev() { + let _ = h.disconnect(russh::Disconnect::ByApplication, "", "en").await; + } + }); + (self.cb.on_close)(); + } + + // --- port forwarding ------------------------------------------------------------------- + + pub fn start_forward(&self, rule: PortForward) -> Result<()> { + if self.forwards.lock().unwrap().contains_key(&rule.id) { + return Err(format!("forward {:?} is already active", rule.id).into()); + } + let h = self.handle()?; + let tasks: Arc>> = Arc::default(); + match rule.direction { + ForwardDirection::Local => { + let bind = if rule.bind_host.is_empty() { "127.0.0.1".to_string() } else { rule.bind_host.clone() }; + let addr = format!("{bind}:{}", rule.bind_port); + let listener = + rt().block_on(tokio::net::TcpListener::bind(addr.clone())).map_err(|e| format!("local forward: listen on {addr} failed: {e}"))?; + let (dhost, dport) = (rule.dest_host.clone(), rule.dest_port as u32); + let t2 = tasks.clone(); + let task = rt().spawn(async move { + while let Ok((mut sock, peer)) = listener.accept().await { + let (h, dhost) = (h.clone(), dhost.clone()); + let conn = tokio::spawn(async move { + if let Ok(ch) = h.channel_open_direct_tcpip(dhost, dport, peer.ip().to_string(), peer.port() as u32).await { + let mut remote = ch.into_stream(); + let _ = tokio::io::copy_bidirectional(&mut sock, &mut remote).await; + } + }); + let mut handles = t2.lock().unwrap(); + handles.retain(|h| !h.is_finished()); + handles.push(conn.abort_handle()); + } + }); + tasks.lock().unwrap().push(task.abort_handle()); + } + ForwardDirection::Remote => { + let bind = if rule.bind_host.is_empty() { "0.0.0.0".to_string() } else { rule.bind_host.clone() }; + let port = rt() + .block_on(h.tcpip_forward(bind.clone(), rule.bind_port as u32)) + .map_err(|e| format!("remote forward: server listen on {bind}:{} failed: {e}", rule.bind_port))?; + let port = if rule.bind_port == 0 { port } else { rule.bind_port as u32 }; + self.shared.remote_targets.lock().unwrap().insert((bind, port), (rule.dest_host.clone(), rule.dest_port as u16)); + } + } + self.forwards.lock().unwrap().insert(rule.id.clone(), Forward { rule, tasks }); + Ok(()) + } + + pub fn stop_forward(&self, id: &str) -> Result<()> { + let f = self.forwards.lock().unwrap().remove(id).ok_or_else(|| format!("forward {id:?} not found"))?; + self.shutdown_forward(f); + Ok(()) + } + + fn shutdown_forward(&self, f: Forward) { + for t in f.tasks.lock().unwrap().drain(..) { + t.abort(); + } + if f.rule.direction == ForwardDirection::Remote { + let bind = if f.rule.bind_host.is_empty() { "0.0.0.0".to_string() } else { f.rule.bind_host.clone() }; + self.shared.remote_targets.lock().unwrap().retain(|(a, p), _| !(a == &bind && *p == f.rule.bind_port as u32)); + if let Ok(h) = self.handle() { + let port = f.rule.bind_port as u32; + rt().spawn(async move { + let _ = h.cancel_tcpip_forward(bind, port).await; + }); + } + } + } + + fn stop_all_forwards(&self) { + let all: Vec = self.forwards.lock().unwrap().drain().map(|(_, f)| f).collect(); + for f in all { + self.shutdown_forward(f); + } + } + + pub fn active_forwards(&self) -> Vec { + self.forwards.lock().unwrap().values().map(|f| f.rule.clone()).collect() + } + + // --- SFTP ------------------------------------------------------------------------------- + + /// The SFTP subsystem, opened on first use. + pub fn sftp(&self) -> Result> { + rt().block_on(async { + let mut slot = self.sftp.lock().await; + if let Some(s) = slot.as_ref() { + return Ok(s.clone()); + } + let h = self.handle()?; + let ch = h.channel_open_session().await?; + ch.request_subsystem(true, "sftp").await?; + let s = + Arc::new(russh_sftp::client::SftpSession::new(ch.into_stream()).await.map_err(|e| format!("sftpclient: failed to start SFTP subsystem: {e}"))?); + *slot = Some(s.clone()); + Ok(s) + }) + } +} + +/// A connected pair of loopback sockets. The accepted end is checked against the connecting +/// end's address, so another local process racing for the ephemeral port can't be bridged +/// into the tunnel instead of our own client. +pub(crate) fn loopback_pair() -> std::io::Result<(std::net::TcpStream, std::net::TcpStream)> { + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + let client = std::net::TcpStream::connect(listener.local_addr()?)?; + let ours = client.local_addr()?; + loop { + let (server, peer) = listener.accept()?; + if peer == ours { + return Ok((client, server)); + } + // A stranger connected first: drop it and keep waiting for our own connection. + drop(server); + } +} + +/// Generates `/` and `/.pub` in OpenSSH format, in-process, so a +/// passphrase never appears on a command line. `kind` is ed25519 (default), ecdsa (P-256) or rsa +/// (`bits` 2048/3072/4096, default 4096). Returns the private key path. +pub fn generate_key_pair(dir: &std::path::Path, name: &str, kind: &str, bits: u32, passphrase: &str) -> Result { + use russh::keys::ssh_key::private::{EcdsaKeypair, Ed25519Keypair, KeypairData, RsaKeypair}; + use russh::keys::ssh_key::{EcdsaCurve, LineEnding}; + let name = crate::keyfiles::validate_ssh_key_name(name)?; + let kind = if kind.trim().is_empty() { "ed25519".to_string() } else { kind.trim().to_lowercase() }; + let mut rng = OsCryptoRng; + let data = match kind.as_str() { + "ed25519" => KeypairData::from(Ed25519Keypair::random(&mut rng)), + "ecdsa" => KeypairData::from(EcdsaKeypair::random(&mut rng, EcdsaCurve::NistP256).map_err(|e| e.to_string())?), + "rsa" => { + let bits = match bits { + 0 => 4096, + 2048 | 3072 | 4096 => bits, + b => return Err(format!("unsupported RSA key size {b} (use 2048, 3072, or 4096)").into()), + }; + KeypairData::from(RsaKeypair::random(&mut rng, bits as usize).map_err(|e| e.to_string())?) + } + k => return Err(format!("unsupported SSH key type {k:?}").into()), + }; + let comment = format!("{name}@nexus-terminal"); + let key = russh::keys::PrivateKey::new(data, comment.as_str()).map_err(|e| e.to_string())?; + let key = if passphrase.is_empty() { key } else { key.encrypt(&mut rng, passphrase.as_bytes()).map_err(|e| e.to_string())? }; + std::fs::create_dir_all(dir)?; + let path = dir.join(&name); + if path.exists() || path.with_extension("pub").exists() { + return Err(format!("key {name:?} already exists").into()); + } + key.write_openssh_file(&path, LineEnding::LF).map_err(|e| format!("failed to write private key: {e}"))?; + let mut public = key.public_key().clone(); + public.set_comment(comment.as_str()); + public.write_openssh_file(path.with_extension("pub")).map_err(|e| format!("failed to write public key: {e}"))?; + Ok(path) +} + +/// The OS CSPRNG as the `rand_core` 0.10 generator `ssh-key` expects (our `rand` is 0.8). +struct OsCryptoRng; + +impl russh::keys::ssh_key::rand_core::TryRng for OsCryptoRng { + type Error = std::convert::Infallible; + fn try_next_u32(&mut self) -> std::result::Result { + Ok(rand::RngCore::next_u32(&mut rand::rngs::OsRng)) + } + fn try_next_u64(&mut self) -> std::result::Result { + Ok(rand::RngCore::next_u64(&mut rand::rngs::OsRng)) + } + fn try_fill_bytes(&mut self, dst: &mut [u8]) -> std::result::Result<(), Self::Error> { + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, dst); + Ok(()) + } +} + +impl russh::keys::ssh_key::rand_core::TryCryptoRng for OsCryptoRng {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn loopback_pair_ignores_a_stranger_that_connects_first() { + // Bind, then let a "stranger" race us for the port before our own connect: the pair must + // still bridge our client to our server end. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let _stranger = std::net::TcpStream::connect(addr).unwrap(); + let client = std::net::TcpStream::connect(addr).unwrap(); + let ours = client.local_addr().unwrap(); + let server = loop { + let (s, peer) = listener.accept().unwrap(); + if peer == ours { + break s; + } + }; + assert_eq!(server.peer_addr().unwrap(), ours); + let (c, s) = loopback_pair().unwrap(); + assert_eq!(c.local_addr().unwrap(), s.peer_addr().unwrap()); + } + + #[test] + fn socks5_requests_reject_overlong_fields() { + assert_eq!(socks5_connect_request("example.com", 22).unwrap(), [&[5u8, 1, 0, 3, 11][..], b"example.com", &[0, 22]].concat()); + assert!(socks5_connect_request(&"h".repeat(256), 22).is_err()); + assert_eq!(socks5_auth_request("u", "pw").unwrap(), vec![1, 1, b'u', 2, b'p', b'w']); + assert!(socks5_auth_request("u", &"p".repeat(256)).is_err()); + assert!(socks5_auth_request(&"u".repeat(256), "p").is_err()); + } + + #[test] + fn generates_encrypted_key_pairs_in_process() { + let dir = std::env::temp_dir().join(format!("nexus-keygen-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = generate_key_pair(&dir, "id_test", "ed25519", 0, "pass phrase").unwrap(); + assert_eq!(path, dir.join("id_test")); + let public = std::fs::read_to_string(dir.join("id_test.pub")).unwrap(); + assert!(public.starts_with("ssh-ed25519 ") && public.trim_end().ends_with("id_test@nexus-terminal"), "{public}"); + #[cfg(unix)] + assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&path).unwrap().permissions()) & 0o777, 0o600); + assert!(crate::keyfiles::is_likely_private_key(&path)); + // Encrypted: unreadable without the passphrase, readable with it. + let p = path.to_string_lossy().into_owned(); + assert!(load_private_key(&p, None).is_err()); + let pp: SecretFn = Arc::new(|| Ok("pass phrase".into())); + assert_eq!(load_private_key(&p, Some(&pp)).unwrap().algorithm().as_str(), "ssh-ed25519"); + assert!(generate_key_pair(&dir, "id_test", "ed25519", 0, "").is_err(), "must not overwrite"); + let plain = generate_key_pair(&dir, "id_plain", "ecdsa", 0, "").unwrap(); + assert!(load_private_key(&plain.to_string_lossy(), None).is_ok()); + assert!(generate_key_pair(&dir, "bad", "rsa", 1024, "").is_err()); + assert!(generate_key_pair(&dir, "../x", "ed25519", 0, "").is_err()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn totp_matches_rfc_vector() { + // RFC 6238 SHA-1 secret "12345678901234567890" is GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ in base32. + let secret = "gezd gnbv gy3t qojq gezd gnbv gy3t qojq"; + assert_eq!(totp_at(secret, 59).unwrap(), "287082"); + assert_eq!(totp_at(secret, 1111111109).unwrap(), "081804"); + assert!(totp_code("JBSWY3DPEHPK3PXP").is_ok()); + } + + #[test] + fn totp_prompt_detection() { + assert!(is_totp_prompt("Verification code: ")); + assert!(is_totp_prompt("Enter your 6-digit OTP")); + assert!(!is_totp_prompt("Password: ")); + } +} diff --git a/rust/core/src/clients/telnet.rs b/rust/core/src/clients/telnet.rs new file mode 100644 index 0000000..998b079 --- /dev/null +++ b/rust/core/src/clients/telnet.rs @@ -0,0 +1,433 @@ +//! Telnet client (port of Go `internal/telnetclient`): RFC 854/855 option negotiation (ECHO, SGA, +//! TTYPE, NAWS; everything else refused), IAC escaping, line-ending conversion, ISO 8859-1 output +//! encoding and BRK. Go read but never acted on `lineMode`, and neither do we. + +use std::sync::{Arc, Mutex}; + +use super::{Callbacks, DataFn, DebugFn, TcpConn}; +use crate::models::{LineEnding, Profile, TelnetConfig}; + +pub const IAC: u8 = 255; +pub const DONT: u8 = 254; +pub const DO: u8 = 253; +pub const WONT: u8 = 252; +pub const WILL: u8 = 251; +pub const SB: u8 = 250; +pub const SE: u8 = 240; +pub const BRK: u8 = 243; + +pub const OPT_ECHO: u8 = 1; +pub const OPT_SGA: u8 = 3; +pub const OPT_TERM_TYPE: u8 = 24; +pub const OPT_NAWS: u8 = 31; + +const SUB_IS: u8 = 0; +const SUB_SEND: u8 = 1; + +/// An unterminated IAC sequence longer than this is dropped rather than buffered forever. +const MAX_PENDING: usize = 8192; + +pub struct TelnetClient { + inner: Arc, +} + +struct Inner { + conn: Arc, + host: String, + port: i64, + connect_timeout: i64, + cfg: TelnetConfig, + encoding: String, + on_data: DataFn, + on_debug: Option, + state: Mutex, +} + +struct State { + local_echo: bool, + cols: u16, + rows: u16, +} + +impl TelnetClient { + /// `on_debug` receives negotiation messages ("received DO 31", "sending WILL 31", …), always, + /// as in Go; the profile's `negotiationDebug` is for the caller to honour. + pub fn new(profile: &Profile, cb: Callbacks, on_debug: Option) -> TelnetClient { + let cfg = profile.telnet.clone().unwrap_or_default(); + let encoding = if cfg.encoding.is_empty() { "utf-8".to_string() } else { cfg.encoding.clone() }; + let state = State { local_echo: cfg.local_echo, cols: 80, rows: 24 }; + TelnetClient { + inner: Arc::new(Inner { + conn: Arc::new(TcpConn::new(cb.on_error, cb.on_close)), + host: profile.host.clone(), + port: if profile.port == 0 { 23 } else { profile.port }, + connect_timeout: profile.connect_timeout, + cfg, + encoding, + on_data: cb.on_data, + on_debug, + state: Mutex::new(state), + }), + } + } + + pub fn connect(&self) -> crate::Result<()> { + let inner = &self.inner; + if inner.conn.is_connected() { + return Err("already connected".into()); + } + let (addr, res) = super::dial(&inner.host, inner.port, inner.connect_timeout); + let stream = res.map_err(|e| format!("Telnet connection to {addr} failed: {e}"))?; + let me = inner.clone(); + let mut pending = Vec::new(); + inner.conn.connect(stream, move |chunk| me.process(&mut pending, chunk)) + } + + /// Sends user input: line endings converted, encoded, literal 0xFF doubled. + pub fn write(&self, data: &[u8]) -> crate::Result<()> { + let converted = convert_line_ending(data, self.inner.cfg.line_ending); + let encoded = encode_outgoing(&converted, &self.inner.encoding); + self.inner.conn.write(&escape_iac(&encoded)) + } + + /// Records the size and sends a NAWS update (sent even if the server never asked for NAWS, as in Go). + pub fn resize(&self, cols: u16, rows: u16) -> crate::Result<()> { + if !self.inner.conn.is_connected() { + return Err("not connected".into()); + } + { + let mut st = self.inner.state.lock().unwrap(); + st.cols = cols; + st.rows = rows; + } + self.inner.send_naws(cols, rows) + } + + /// Sends IAC BRK. + pub fn send_break(&self) -> crate::Result<()> { + self.inner.conn.write(&[IAC, BRK]) + } + + pub fn close(&self) { + self.inner.conn.close(); + } + + pub fn is_connected(&self) -> bool { + self.inner.conn.is_connected() + } + + /// The negotiated local-echo state: starts at the profile's `localEcho`, cleared by the + /// server's WILL ECHO and set by WONT ECHO. Like Go, the client itself never echoes. + pub fn local_echo(&self) -> bool { + self.inner.state.lock().unwrap().local_echo + } +} + +impl Inner { + /// Splits a received chunk into data and IAC sequences. Unlike Go, a sequence cut off at the + /// end of a read is kept for the next read instead of dropped. + fn process(&self, pending: &mut Vec, chunk: &[u8]) { + pending.extend_from_slice(chunk); + let mut data = Vec::new(); + let mut i = 0; + while i < pending.len() { + if pending[i] != IAC { + data.push(pending[i]); + i += 1; + continue; + } + if !data.is_empty() { + (self.on_data)(&data); + data.clear(); + } + let consumed = self.handle_iac(&pending[i..]); + if consumed == 0 { + break; + } + i += consumed; + } + if !data.is_empty() { + (self.on_data)(&data); + } + pending.drain(..i); + if pending.len() > MAX_PENDING { + pending.clear(); + } + } + + /// Handles the IAC sequence at `data[0]`; returns bytes consumed, 0 if incomplete. + fn handle_iac(&self, data: &[u8]) -> usize { + if data.len() < 2 { + return 0; + } + match data[1] { + IAC => { + (self.on_data)(&[IAC]); + 2 + } + DO | DONT | WILL | WONT => { + let Some(&opt) = data.get(2) else { return 0 }; + match data[1] { + DO => self.handle_do(opt), + DONT => self.handle_dont(opt), + WILL => self.handle_will(opt), + _ => self.handle_wont(opt), + } + 3 + } + SB => match (2..data.len().saturating_sub(1)).find(|&i| data[i] == IAC && data[i + 1] == SE) { + Some(end) => { + self.process_subnegotiation(&data[2..end]); + end + 2 + } + None => 0, + }, + cmd => { + self.debug(format!("received IAC command: {cmd}")); + 2 + } + } + } + + fn handle_do(&self, opt: u8) { + self.debug(format!("received DO {opt}")); + match opt { + OPT_TERM_TYPE | OPT_SGA => self.negotiate(WILL, opt), + OPT_NAWS => { + self.negotiate(WILL, opt); + let (cols, rows) = { + let st = self.state.lock().unwrap(); + (st.cols, st.rows) + }; + let _ = self.send_naws(cols, rows); + } + _ => self.negotiate(WONT, opt), + } + } + + fn handle_dont(&self, opt: u8) { + self.debug(format!("received DONT {opt}")); + self.negotiate(WONT, opt); + } + + fn handle_will(&self, opt: u8) { + self.debug(format!("received WILL {opt}")); + match opt { + OPT_ECHO => { + self.negotiate(DO, opt); + self.state.lock().unwrap().local_echo = false; + } + OPT_SGA => self.negotiate(DO, opt), + _ => self.negotiate(DONT, opt), + } + } + + fn handle_wont(&self, opt: u8) { + self.debug(format!("received WONT {opt}")); + if opt == OPT_ECHO { + self.state.lock().unwrap().local_echo = true; + } + self.negotiate(DONT, opt); + } + + fn process_subnegotiation(&self, payload: &[u8]) { + let Some(&opt) = payload.first() else { return }; + self.debug(format!("sub-negotiation for option {opt}")); + if opt == OPT_TERM_TYPE && payload.get(1) == Some(&SUB_SEND) { + let tt = if self.cfg.terminal_type.is_empty() { "xterm-256color" } else { &self.cfg.terminal_type }; + self.debug(format!("sending terminal type: {tt}")); + let mut resp = vec![IAC, SB, OPT_TERM_TYPE, SUB_IS]; + resp.extend_from_slice(tt.to_uppercase().as_bytes()); + resp.extend_from_slice(&[IAC, SE]); + let _ = self.conn.write(&resp); + } + } + + fn negotiate(&self, cmd: u8, opt: u8) { + self.debug(format!("sending {} {opt}", command_name(cmd))); + let _ = self.conn.write(&[IAC, cmd, opt]); + } + + fn send_naws(&self, cols: u16, rows: u16) -> crate::Result<()> { + self.debug(format!("sending NAWS: {cols}x{rows}")); + let [c0, c1] = cols.to_be_bytes(); + let [r0, r1] = rows.to_be_bytes(); + self.conn.write(&[IAC, SB, OPT_NAWS, c0, c1, r0, r1, IAC, SE]) + } + + fn debug(&self, msg: String) { + if let Some(f) = &self.on_debug { + f(msg); + } + } +} + +fn command_name(cmd: u8) -> String { + match cmd { + WILL => "WILL".into(), + WONT => "WONT".into(), + DO => "DO".into(), + DONT => "DONT".into(), + _ => format!("CMD({cmd})"), + } +} + +/// Normalises CRLF/CR/LF to the configured ending (`None` leaves data untouched). +fn convert_line_ending(data: &[u8], le: LineEnding) -> Vec { + if le == LineEnding::None { + return data.to_vec(); + } + let mut out = Vec::with_capacity(data.len() + 8); + let mut i = 0; + while i < data.len() { + match data[i] { + b'\r' => { + if data.get(i + 1) == Some(&b'\n') { + i += 1; + } + out.extend_from_slice(le.bytes()); + } + b'\n' => out.extend_from_slice(le.bytes()), + b => out.push(b), + } + i += 1; + } + out +} + +/// Doubles literal 0xFF bytes. +fn escape_iac(data: &[u8]) -> Vec { + let mut out = Vec::with_capacity(data.len()); + for &b in data { + out.push(b); + if b == IAC { + out.push(IAC); + } + } + out +} + +/// UTF-8 → ISO 8859-1 for the Latin-1 encodings; everything else is sent as-is. Unlike Go this +/// runs before IAC escaping, so a Latin-1 'ÿ' (0xFF) is escaped correctly. +fn encode_outgoing(data: &[u8], encoding: &str) -> Vec { + if super::is_latin1(encoding) { super::utf8_to_latin1(data) } else { data.to_vec() } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + use std::time::Duration; + + #[test] + fn line_endings() { + let cases = [ + (LineEnding::Crlf, "a\nb\r\nc\rd", "a\r\nb\r\nc\r\nd"), + (LineEnding::Lf, "a\r\nb\rc\n", "a\nb\nc\n"), + (LineEnding::Cr, "a\r\nb\n", "a\rb\r"), + (LineEnding::None, "a\r\nb\n", "a\r\nb\n"), + ]; + for (le, input, want) in cases { + assert_eq!(convert_line_ending(input.as_bytes(), le), want.as_bytes(), "{le:?}"); + } + } + + #[test] + fn iac_escaping_and_latin1() { + assert_eq!(escape_iac(&[1, 0xff, 2]), vec![1, 0xff, 0xff, 2]); + assert_eq!(encode_outgoing("é".as_bytes(), "Latin-1"), vec![0xe9]); + assert_eq!(encode_outgoing("€".as_bytes(), "latin1"), "€".as_bytes()); // unencodable: unchanged + assert_eq!(encode_outgoing("é".as_bytes(), "utf-8"), "é".as_bytes()); + } + + fn read_n(s: &mut std::net::TcpStream, n: usize) -> Vec { + let mut buf = vec![0; n]; + s.read_exact(&mut buf).unwrap(); + buf + } + + #[test] + fn negotiates_and_frames_data() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let profile = Profile { host: "127.0.0.1".into(), port: listener.local_addr().unwrap().port() as i64, ..Profile::new(crate::models::Protocol::Telnet) }; + let (dtx, drx) = mpsc::channel::>(); + let (etx, erx) = mpsc::channel::(); + let (ctx, crx) = mpsc::channel::<()>(); + let debug = Arc::new(Mutex::new(Vec::::new())); + let dbg = debug.clone(); + let cb = Callbacks { + on_data: Arc::new(move |d| dtx.send(d.to_vec()).unwrap()), + on_error: Arc::new(move |e| etx.send(e).unwrap()), + on_close: Arc::new(move || ctx.send(()).unwrap()), + }; + let client = TelnetClient::new(&profile, cb, Some(Arc::new(move |m| dbg.lock().unwrap().push(m)))); + client.connect().unwrap(); + let (mut srv, _) = listener.accept().unwrap(); + srv.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + + srv.write_all(&[IAC, DO, OPT_TERM_TYPE, IAC, DO, OPT_NAWS, IAC, WILL, OPT_ECHO, IAC, WILL, OPT_SGA, IAC, DO, 99]).unwrap(); + let mut want = vec![IAC, WILL, OPT_TERM_TYPE, IAC, WILL, OPT_NAWS, IAC, SB, OPT_NAWS, 0, 80, 0, 24, IAC, SE]; + want.extend_from_slice(&[IAC, DO, OPT_ECHO, IAC, DO, OPT_SGA, IAC, WONT, 99]); + assert_eq!(read_n(&mut srv, want.len()), want); + assert!(!client.local_echo()); + + // Data with an escaped 0xFF, then a TTYPE SEND split across two writes. + srv.write_all(b"hi").unwrap(); + srv.write_all(&[IAC, IAC, IAC, SB, OPT_TERM_TYPE]).unwrap(); + std::thread::sleep(Duration::from_millis(50)); + srv.write_all(&[SUB_SEND, IAC, SE]).unwrap(); + let mut want = vec![IAC, SB, OPT_TERM_TYPE, SUB_IS]; + want.extend_from_slice(b"XTERM-256COLOR"); + want.extend_from_slice(&[IAC, SE]); + assert_eq!(read_n(&mut srv, want.len()), want); + let mut got = Vec::new(); + while got.len() < 3 { + got.extend(drx.recv_timeout(Duration::from_secs(5)).unwrap()); + } + assert_eq!(got, b"hi\xff"); + + srv.write_all(&[IAC, WONT, OPT_ECHO]).unwrap(); + assert_eq!(read_n(&mut srv, 3), [IAC, DONT, OPT_ECHO]); + assert!(client.local_echo()); + + client.resize(132, 50).unwrap(); + assert_eq!(read_n(&mut srv, 9), [IAC, SB, OPT_NAWS, 0, 132, 0, 50, IAC, SE]); + client.send_break().unwrap(); + assert_eq!(read_n(&mut srv, 2), [IAC, BRK]); + client.write(b"ls\n\xff").unwrap(); + assert_eq!(read_n(&mut srv, 6), b"ls\r\n\xff\xff"); + + let log = debug.lock().unwrap().clone(); + for m in ["received DO 24", "sending WILL 31", "sending NAWS: 80x24", "sending WONT 99", "sending terminal type: xterm-256color"] { + assert!(log.iter().any(|l| l == m), "missing debug {m:?} in {log:?}"); + } + + // Remote close: Go reports "read error: EOF" then closes, exactly once. + drop(srv); + assert_eq!(erx.recv_timeout(Duration::from_secs(5)).unwrap(), "read error: EOF"); + crx.recv_timeout(Duration::from_secs(5)).unwrap(); + client.close(); + assert!(crx.recv_timeout(Duration::from_millis(100)).is_err()); + assert!(client.write(b"x").is_err()); + } + + #[test] + fn local_close_fires_once_without_error() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let profile = Profile { host: "127.0.0.1".into(), port: listener.local_addr().unwrap().port() as i64, ..Profile::new(crate::models::Protocol::Telnet) }; + let (etx, erx) = mpsc::channel::(); + let (ctx, crx) = mpsc::channel::<()>(); + let cb = Callbacks { on_error: Arc::new(move |e| etx.send(e).unwrap()), on_close: Arc::new(move || ctx.send(()).unwrap()), ..Callbacks::noop() }; + let client = TelnetClient::new(&profile, cb, None); + client.connect().unwrap(); + assert!(client.connect().is_err()); + let _srv = listener.accept().unwrap(); + client.close(); + client.close(); + crx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(crx.recv_timeout(Duration::from_millis(100)).is_err()); + assert!(erx.recv_timeout(Duration::from_millis(100)).is_err()); + } +} diff --git a/rust/core/src/clients/vnc.rs b/rust/core/src/clients/vnc.rs new file mode 100644 index 0000000..9567966 --- /dev/null +++ b/rust/core/src/clients/vnc.rs @@ -0,0 +1,1074 @@ +//! Native VNC (RFB 3.3/3.7/3.8) client: None and VNC Authentication, Raw, CopyRect, Hextile, ZRLE +//! and Tight (zlib + JPEG), plus the DesktopSize, ExtendedDesktopSize and LastRect pseudo-encodings. +//! +//! One I/O thread owns the transport (TCP, or a caller-supplied stream such as an SSH tunnel). It +//! decodes into a private framebuffer and, after each FramebufferUpdate, copies the changed rows into +//! the shared `Arc>` the UI reads, so the lock is never held across network reads. +//! Outgoing input is queued and written whenever a read polls out, so the transport is never split. + +use std::io::{BufReader, ErrorKind, Read, Write}; +use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use flate2::{Decompress, FlushDecompress}; + +pub use super::irc::{Connector, ReadWrite}; +use super::{CloseFn, ErrorFn}; + +const READ_POLL: Duration = Duration::from_millis(50); +/// Largest desktop accepted from the server (64 Mpx = 256 MiB of RGBA; an 8K display is 33 Mpx). +const MAX_FB_PIXELS: usize = 64 << 20; +/// Largest single compressed rectangle (ZRLE) read from the server. +const MAX_ZRLE_BYTES: usize = 64 << 20; + +pub type FrameFn = Arc; +pub type ResizeFn = Arc; +pub type ClipboardFn = Arc; + +#[derive(Clone)] +pub struct Config { + pub host: String, + pub port: u16, + pub password: Option, + /// ClientInit's shared flag: leave other viewers connected. + pub shared: bool, + pub view_only: bool, + /// Tight JPEG quality, 0–9. + pub quality: u8, + /// Provides the transport instead of a direct TCP connect (e.g. an SSH direct-tcpip stream). + pub connector: Option, + pub connect_timeout: Duration, + /// Called after each FramebufferUpdate has been applied to the shared framebuffer. + pub on_frame: FrameFn, + pub on_resize: ResizeFn, + pub on_clipboard: ClipboardFn, + pub on_error: ErrorFn, + pub on_close: CloseFn, +} + +impl Default for Config { + fn default() -> Config { + Config { + host: String::new(), + port: 5900, + password: None, + shared: true, + view_only: false, + quality: 6, + connector: None, + connect_timeout: Duration::ZERO, + on_frame: Arc::new(|| {}), + on_resize: Arc::new(|_, _| {}), + on_clipboard: Arc::new(|_| {}), + on_error: Arc::new(|_| {}), + on_close: Arc::new(|| {}), + } + } +} + +/// The remote desktop as RGBA, row-major, `width * height * 4` bytes. The I/O thread sets `dirty` +/// after each update; the UI clears it once it has uploaded the texture. +#[derive(Clone, Debug, Default)] +pub struct Framebuffer { + pub width: u16, + pub height: u16, + pub rgba: Vec, + pub dirty: bool, +} + +impl Framebuffer { + fn new(width: u16, height: u16) -> Framebuffer { + let mut rgba = vec![0u8; width as usize * height as usize * 4]; + rgba.as_chunks_mut::<4>().0.iter_mut().for_each(|p| p[3] = 255); + Framebuffer { width, height, rgba, dirty: true } + } + + /// A framebuffer of a server-announced size, refusing sizes that would exhaust memory. + fn new_checked(width: u16, height: u16) -> crate::Result { + if width == 0 || height == 0 || width as usize * height as usize > MAX_FB_PIXELS { + return Err(format!("unsupported desktop size {width}x{height}").into()); + } + Ok(Framebuffer::new(width, height)) + } + + fn check(&self, x: u16, y: u16, w: u16, h: u16) -> crate::Result<()> { + if x as usize + w as usize > self.width as usize || y as usize + h as usize > self.height as usize { + return Err(format!("rectangle {w}x{h}+{x}+{y} outside the {}x{} framebuffer", self.width, self.height).into()); + } + Ok(()) + } + + /// Writes `w*h` pixels of `bpp` bytes each (R, G, B first; anything after is ignored). + fn put(&mut self, x: u16, y: u16, w: u16, h: u16, px: &[u8], bpp: usize) { + let (w, stride) = (w as usize, self.width as usize); + for row in 0..h as usize { + let dst = ((y as usize + row) * stride + x as usize) * 4; + for (d, s) in self.rgba[dst..dst + w * 4].as_chunks_mut::<4>().0.iter_mut().zip(px[row * w * bpp..].chunks_exact(bpp)) { + d[..3].copy_from_slice(&s[..3]); + d[3] = 255; + } + } + } + + fn fill(&mut self, x: u16, y: u16, w: u16, h: u16, rgb: &[u8]) { + let stride = self.width as usize; + for row in 0..h as usize { + let dst = ((y as usize + row) * stride + x as usize) * 4; + for d in self.rgba[dst..dst + w as usize * 4].as_chunks_mut::<4>().0 { + d[..3].copy_from_slice(&rgb[..3]); + d[3] = 255; + } + } + } +} + +struct State { + connected: bool, + closing: bool, + ready: Option>>, + name: String, +} + +pub struct Client { + cfg: Config, + fb: Arc>, + state: Mutex, + out: Mutex>>>, +} + +impl Client { + pub fn new(mut cfg: Config) -> Arc { + if cfg.connect_timeout.is_zero() { + cfg.connect_timeout = Duration::from_secs(30); + } + cfg.quality = cfg.quality.min(9); + Arc::new(Client { + cfg, + fb: Arc::new(Mutex::new(Framebuffer::default())), + state: Mutex::new(State { connected: false, closing: false, ready: None, name: String::new() }), + out: Mutex::new(None), + }) + } + + fn st(&self) -> MutexGuard<'_, State> { + self.state.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Connects and blocks until the handshake completes (ServerInit received), fails, or times out. + pub fn connect(self: &Arc) -> crate::Result<()> { + let (ready_tx, ready_rx) = mpsc::channel(); + let (out_tx, out_rx) = mpsc::channel(); + self.st().ready = Some(ready_tx); + *self.out.lock().unwrap_or_else(|e| e.into_inner()) = Some(out_tx); + + let client = self.clone(); + std::thread::spawn(move || client.io_thread(out_rx)); + + let timeout = self.cfg.connect_timeout; + match ready_rx.recv_timeout(timeout) { + Ok(Ok(())) => Ok(()), + Ok(Err(e)) => Err(e.into()), + Err(_) => { + self.close(); + Err(format!("VNC connection timed out after {timeout:?}").into()) + } + } + } + + /// Disconnects. Idempotent; `on_close` fires from the I/O thread. + pub fn close(&self) { + { + let mut st = self.st(); + if st.closing { + return; + } + st.closing = true; + } + // Dropping the sender makes the I/O thread's next poll end the connection. + *self.out.lock().unwrap_or_else(|e| e.into_inner()) = None; + } + + pub fn framebuffer(&self) -> Arc> { + self.fb.clone() + } + + pub fn desktop_name(&self) -> String { + self.st().name.clone() + } + + /// PointerEvent. `buttons` bit 0 is left, 1 middle, 2 right, 3/4 wheel up/down. + pub fn pointer(&self, x: u16, y: u16, buttons: u8) -> crate::Result<()> { + if self.cfg.view_only { + return Ok(()); + } + let mut m = vec![5, buttons]; + m.extend_from_slice(&x.to_be_bytes()); + m.extend_from_slice(&y.to_be_bytes()); + self.queue(m) + } + + /// KeyEvent with an X11 keysym (see `keysym_for`). + pub fn key(&self, keysym: u32, down: bool) -> crate::Result<()> { + if self.cfg.view_only { + return Ok(()); + } + let mut m = vec![4, down as u8, 0, 0]; + m.extend_from_slice(&keysym.to_be_bytes()); + self.queue(m) + } + + /// ClientCutText. RFB clipboard text is Latin-1; other characters become `?`. + pub fn send_clipboard(&self, text: &str) -> crate::Result<()> { + if self.cfg.view_only { + return Ok(()); + } + let bytes: Vec = text.chars().map(|c| u8::try_from(c).unwrap_or(b'?')).collect(); + let mut m = vec![6, 0, 0, 0]; + m.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); + m.extend_from_slice(&bytes); + self.queue(m) + } + + /// Asks for a non-incremental update of the whole screen. + pub fn request_full_update(&self) -> crate::Result<()> { + let (w, h) = { + let fb = self.fb.lock().unwrap_or_else(|e| e.into_inner()); + (fb.width, fb.height) + }; + self.queue(update_request(false, w, h)) + } + + fn queue(&self, msg: Vec) -> crate::Result<()> { + let guard = self.out.lock().unwrap_or_else(|e| e.into_inner()); + if guard.as_ref().is_some_and(|tx| tx.send(msg).is_ok()) { Ok(()) } else { Err("not connected".into()) } + } + + fn io_thread(self: Arc, out_rx: Receiver>) { + let result = self.open_transport().and_then(|io| self.run(io, out_rx)); + *self.out.lock().unwrap_or_else(|e| e.into_inner()) = None; + let (was_closing, was_connected, ready) = { + let mut st = self.st(); + let r = (st.closing, st.connected, st.ready.take()); + st.connected = false; + r + }; + if let Err(e) = result + && !was_closing + { + let msg = e.to_string(); + (self.cfg.on_error)(msg.clone()); + if let Some(ready) = ready { + let _ = ready.send(Err(msg)); + } + } + if was_connected { + (self.cfg.on_close)(); + } + } + + fn open_transport(&self) -> crate::Result> { + if let Some(connector) = &self.cfg.connector { + return connector(); + } + let (addr, tcp) = super::dial(&self.cfg.host, self.cfg.port as i64, self.cfg.connect_timeout.as_secs().max(1) as i64); + let tcp = tcp.map_err(|e| format!("dial tcp {addr}: {e}"))?; + tcp.set_read_timeout(Some(READ_POLL))?; + tcp.set_write_timeout(Some(self.cfg.connect_timeout))?; + let _ = tcp.set_nodelay(true); + Ok(Box::new(tcp)) + } + + fn run(&self, io: Box, out_rx: Receiver>) -> crate::Result<()> { + let mut r = BufReader::with_capacity(1 << 16, Conn { io, out_rx }); + let (w, h) = self.handshake(&mut r)?; + let mut local = Framebuffer::new_checked(w, h)?; + *self.fb.lock().unwrap_or_else(|e| e.into_inner()) = local.clone(); + (self.cfg.on_resize)(w, h); + { + let mut st = self.st(); + if st.closing { + return Ok(()); + } + st.connected = true; + if let Some(ready) = st.ready.take() { + let _ = ready.send(Ok(())); + } + } + let mut dec = Decoders::default(); + r.get_mut().send(&update_request(false, w, h))?; + loop { + match read_u8(&mut r)? { + 0 => { + let (y0, y1, resized) = read_update(&mut r, &mut local, &mut dec)?; + { + let mut fb = self.fb.lock().unwrap_or_else(|e| e.into_inner()); + if resized { + *fb = local.clone(); + } else if y0 < y1 { + let row = local.width as usize * 4; + fb.rgba[y0 * row..y1 * row].copy_from_slice(&local.rgba[y0 * row..y1 * row]); + } + fb.dirty = true; + } + if resized { + (self.cfg.on_resize)(local.width, local.height); + } + (self.cfg.on_frame)(); + r.get_mut().send(&update_request(!resized, local.width, local.height))?; + } + 1 => { + // SetColourMapEntries: unused with true colour. + let hdr = read_n(&mut r, 5)?; + read_n(&mut r, u16::from_be_bytes([hdr[3], hdr[4]]) as usize * 6)?; + } + 2 => {} // Bell + 3 => { + read_n(&mut r, 3)?; + let len = read_u32(&mut r)? as usize; + if len > 16 << 20 { + return Err("server clipboard text too long".into()); + } + let text: String = read_n(&mut r, len)?.iter().map(|&b| b as char).collect(); + (self.cfg.on_clipboard)(text); + } + t => return Err(format!("unsupported server message type {t}").into()), + } + } + } + + /// ProtocolVersion, security, ClientInit/ServerInit, SetPixelFormat and SetEncodings. + fn handshake(&self, r: &mut BufReader) -> crate::Result<(u16, u16)> { + let ver = read_n(r, 12)?; + let ver = String::from_utf8_lossy(&ver); + let minor = match ver.strip_prefix("RFB 003.").and_then(|s| s.trim_end().parse::().ok()) { + Some(m) => m, + None => return Err(format!("not a VNC server (got {:?})", ver.trim_end()).into()), + }; + // 3.889 is Apple's 3.8; anything between 3.3 and 3.7 is treated as 3.3, as RFB specifies. + let minor = if minor >= 8 { + 8 + } else if minor == 7 { + 7 + } else { + 3 + }; + r.get_mut().send(format!("RFB 003.00{minor}\n").as_bytes())?; + + let security = if minor == 3 { + match read_u32(r)? { + 0 => return Err(format!("VNC server refused the connection: {}", read_string(r)?).into()), + t => t, + } + } else { + let n = read_u8(r)? as usize; + if n == 0 { + return Err(format!("VNC server refused the connection: {}", read_string(r)?).into()); + } + let types = read_n(r, n)?; + let pick = if types.contains(&1) && (self.cfg.password.is_none() || !types.contains(&2)) { + 1 + } else if types.contains(&2) { + 2 + } else { + return Err(unsupported_security(&types).into()); + }; + r.get_mut().send(&[pick])?; + pick as u32 + }; + + match security { + 1 => {} + 2 => { + let password = self.cfg.password.as_deref().ok_or("the VNC server requires a password")?; + let challenge: [u8; 16] = read_n(r, 16)?.try_into().unwrap(); + r.get_mut().send(&vnc_auth_response(password, &challenge))?; + } + t => return Err(unsupported_security(&[t as u8]).into()), + } + if (minor == 8 || security == 2) && read_u32(r)? != 0 { + let reason = if minor == 8 { read_string(r)? } else { String::new() }; + return Err(if reason.is_empty() { + "VNC authentication failed (wrong password?)".into() + } else { + format!("VNC authentication failed: {reason}").into() + }); + } + + r.get_mut().send(&[self.cfg.shared as u8])?; + let init = read_n(r, 20)?; + let (w, h) = (u16::from_be_bytes([init[0], init[1]]), u16::from_be_bytes([init[2], init[3]])); + self.st().name = read_string(r)?; + + // 32bpp, depth 24, little-endian true colour with red in the low byte: pixels arrive as R,G,B,X. + let mut m = vec![0, 0, 0, 0, 32, 24, 0, 1, 0, 255, 0, 255, 0, 255, 0, 8, 16, 0, 0, 0]; + let mut encodings: Vec = vec![7, 16, 5, 1, 0, -223, -308, -224]; + encodings.push(-32 + self.cfg.quality as i32); // JPEG quality level + encodings.push(-256 + 2); // compression level 2, as the noVNC viewer used + m.extend_from_slice(&[2, 0]); + m.extend_from_slice(&(encodings.len() as u16).to_be_bytes()); + encodings.iter().for_each(|e| m.extend_from_slice(&e.to_be_bytes())); + r.get_mut().send(&m)?; + Ok((w, h)) + } +} + +fn unsupported_security(types: &[u8]) -> String { + if types.contains(&30) { + return "the VNC server only offers Apple Remote Desktop authentication, which isn't supported; enable \"VNC viewers may control screen with password\" in Screen Sharing settings".into(); + } + format!("the VNC server offers no supported security type (offered {types:?}; supported: None, VNC password)") +} + +/// VNC Authentication: DES-encrypt the challenge with the password (first 8 bytes, zero padded) +/// as the key, each key byte bit-reversed. +pub fn vnc_auth_response(password: &str, challenge: &[u8; 16]) -> [u8; 16] { + use des::cipher::{BlockCipherEncrypt, KeyInit}; + let mut key = [0u8; 8]; + for (k, b) in key.iter_mut().zip(password.bytes()) { + *k = b.reverse_bits(); + } + let cipher = des::Des::new(&key.into()); + let mut out = *challenge; + for chunk in out.as_chunks_mut::<8>().0 { + let mut block: [u8; 8] = *chunk; + let b = (&mut block).into(); + cipher.encrypt_block(b); + *chunk = block; + } + out +} + +fn update_request(incremental: bool, w: u16, h: u16) -> Vec { + let mut m = vec![3, incremental as u8, 0, 0, 0, 0]; + m.extend_from_slice(&w.to_be_bytes()); + m.extend_from_slice(&h.to_be_bytes()); + m +} + +/// The transport as seen by the decoder: reads that poll out flush the outgoing queue and retry, +/// and a dropped queue (`Client::close`) ends the connection. +struct Conn { + io: Box, + out_rx: Receiver>, +} + +impl Conn { + fn send(&mut self, data: &[u8]) -> std::io::Result<()> { + self.io.write_all(data)?; + self.io.flush() + } +} + +impl Read for Conn { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + loop { + match self.io.read(buf) { + Ok(0) => return Err(std::io::Error::new(ErrorKind::UnexpectedEof, "connection closed by server")), + Ok(n) => return Ok(n), + Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted) => {} + Err(e) => return Err(e), + } + loop { + match self.out_rx.try_recv() { + Ok(msg) => self.io.write_all(&msg)?, + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => return Err(std::io::Error::other("closed")), + } + } + self.io.flush()?; + } + } +} + +fn read_n(r: &mut impl Read, n: usize) -> crate::Result> { + let mut v = vec![0u8; n]; + r.read_exact(&mut v)?; + Ok(v) +} + +fn read_u8(r: &mut impl Read) -> crate::Result { + Ok(read_n(r, 1)?[0]) +} + +fn read_u16(r: &mut impl Read) -> crate::Result { + let b = read_n(r, 2)?; + Ok(u16::from_be_bytes([b[0], b[1]])) +} + +fn read_u32(r: &mut impl Read) -> crate::Result { + let b = read_n(r, 4)?; + Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) +} + +fn read_string(r: &mut impl Read) -> crate::Result { + let len = read_u32(r)? as usize; + if len > 1 << 20 { + return Err("server string too long".into()); + } + Ok(String::from_utf8_lossy(&read_n(r, len)?).into_owned()) +} + +/// Persistent zlib streams: ZRLE has one, Tight four. +struct Decoders { + zrle: Decompress, + tight: [Decompress; 4], + buf: Vec, +} + +impl Default for Decoders { + fn default() -> Decoders { + Decoders { zrle: Decompress::new(true), tight: std::array::from_fn(|_| Decompress::new(true)), buf: Vec::new() } + } +} + +/// Inflates one server chunk (which ends at a sync flush) into `out`. +fn inflate(z: &mut Decompress, mut input: &[u8], out: &mut Vec) -> crate::Result<()> { + out.clear(); + loop { + out.reserve(64 << 10); + let (i0, o0) = (z.total_in(), z.total_out()); + z.decompress_vec(input, out, FlushDecompress::Sync).map_err(|e| format!("zlib: {e}"))?; + input = &input[(z.total_in() - i0) as usize..]; + let stalled = z.total_in() == i0 && z.total_out() == o0; + if (input.is_empty() && out.len() < out.capacity()) || stalled { + return Ok(()); + } + } +} + +/// Reads one FramebufferUpdate (after the type byte) into `fb`. Returns the changed row range and +/// whether the desktop was resized. +fn read_update(r: &mut impl Read, fb: &mut Framebuffer, dec: &mut Decoders) -> crate::Result<(usize, usize, bool)> { + read_u8(r)?; + let n = read_u16(r)?; + let (mut y0, mut y1, mut resized) = (usize::MAX, 0, false); + for _ in 0..n { + let h = read_n(r, 12)?; + let be = |i: usize| u16::from_be_bytes([h[i], h[i + 1]]); + let (x, y, w, ht) = (be(0), be(2), be(4), be(6)); + let enc = i32::from_be_bytes([h[8], h[9], h[10], h[11]]); + match enc { + -224 => break, // LastRect + -223 | -308 => { + if enc == -308 { + let screens = read_n(r, 4)?[0] as usize; + read_n(r, screens * 16)?; + } + // ExtendedDesktopSize y is the status; non-zero answers a SetDesktopSize we never send. + if enc == -223 || y == 0 { + *fb = Framebuffer::new_checked(w, ht)?; + resized = true; + } + continue; + } + _ => {} + } + fb.check(x, y, w, ht)?; + match enc { + 0 => { + let px = read_n(r, w as usize * ht as usize * 4)?; + fb.put(x, y, w, ht, &px, 4); + } + 1 => { + let (sx, sy) = (read_u16(r)?, read_u16(r)?); + fb.check(sx, sy, w, ht)?; + let row = w as usize * 4; + let stride = fb.width as usize * 4; + let src: Vec = (0..ht as usize) + .flat_map(|i| { + let o = (sy as usize + i) * stride + sx as usize * 4; + fb.rgba[o..o + row].to_vec() + }) + .collect(); + fb.put(x, y, w, ht, &src, 4); + } + 5 => hextile(r, fb, x, y, w, ht)?, + 7 => tight(r, fb, dec, x, y, w, ht)?, + 16 => { + let len = read_u32(r)? as usize; + if len > MAX_ZRLE_BYTES { + return Err(format!("ZRLE rectangle of {len} bytes exceeds {MAX_ZRLE_BYTES}").into()); + } + let data = read_n(r, len)?; + inflate(&mut dec.zrle, &data, &mut dec.buf)?; + zrle(&dec.buf, fb, x, y, w, ht)?; + } + e => return Err(format!("unsupported encoding {e}").into()), + } + y0 = y0.min(y as usize); + y1 = y1.max(y as usize + ht as usize); + } + Ok((y0.min(y1), y1, resized)) +} + +fn hextile(r: &mut impl Read, fb: &mut Framebuffer, x: u16, y: u16, w: u16, h: u16) -> crate::Result<()> { + let (mut bg, mut fg) = ([0u8; 4], [0u8; 4]); + for ty in (y..y + h).step_by(16) { + for tx in (x..x + w).step_by(16) { + let (tw, th) = (16.min(x + w - tx), 16.min(y + h - ty)); + let sub = read_u8(r)?; + if sub & 1 != 0 { + let px = read_n(r, tw as usize * th as usize * 4)?; + fb.put(tx, ty, tw, th, &px, 4); + continue; + } + if sub & 2 != 0 { + bg.copy_from_slice(&read_n(r, 4)?); + } + if sub & 4 != 0 { + fg.copy_from_slice(&read_n(r, 4)?); + } + fb.fill(tx, ty, tw, th, &bg); + if sub & 8 != 0 { + for _ in 0..read_u8(r)? { + let colour = if sub & 16 != 0 { read_n(r, 4)? } else { fg.to_vec() }; + let b = read_n(r, 2)?; + let (sx, sy, sw, sh) = ((b[0] >> 4) as u16, (b[0] & 15) as u16, (b[1] >> 4) as u16 + 1, (b[1] & 15) as u16 + 1); + if sx + sw > tw || sy + sh > th { + return Err("hextile subrect outside its tile".into()); + } + fb.fill(tx + sx, ty + sy, sw, sh, &colour); + } + } + } + } + Ok(()) +} + +/// ZRLE: 64×64 tiles of 3-byte CPIXELs (R, G, B with our pixel format). +fn zrle(data: &[u8], fb: &mut Framebuffer, x: u16, y: u16, w: u16, h: u16) -> crate::Result<()> { + let mut p = 0usize; + let mut take = |n: usize| take(data, &mut p, n); + for ty in (y..y + h).step_by(64) { + for tx in (x..x + w).step_by(64) { + let (tw, th) = (64.min(x + w - tx), 64.min(y + h - ty)); + let count = tw as usize * th as usize; + let sub = take(1)?[0]; + let mut px = Vec::with_capacity(count * 3); + match sub { + 0 => px.extend_from_slice(take(count * 3)?), + 1 => fb.fill(tx, ty, tw, th, take(3)?), + 2..=16 => { + let palette = take(sub as usize * 3)?.to_vec(); + let bits = match sub { + 2 => 1, + 3..=4 => 2, + _ => 4, + }; + let row_bytes = (tw as usize * bits).div_ceil(8); + let packed = take(row_bytes * th as usize)?; + for row in packed.chunks_exact(row_bytes) { + for i in 0..tw as usize { + let bit = i * bits; + let idx = (row[bit / 8] >> (8 - bits - bit % 8)) as usize & ((1 << bits) - 1); + px.extend_from_slice(palette.get(idx * 3..idx * 3 + 3).ok_or("ZRLE palette index out of range")?); + } + } + } + 128 | 130..=255 => { + let palette = if sub == 128 { Vec::new() } else { take((sub as usize - 128) * 3)?.to_vec() }; + while px.len() < count * 3 { + let (colour, run) = if sub == 128 { + (take(3)?.to_vec(), Some(())) + } else { + let b = take(1)?[0]; + let i = (b & 127) as usize; + (palette.get(i * 3..i * 3 + 3).ok_or("ZRLE palette index out of range")?.to_vec(), (b & 128 != 0).then_some(())) + }; + let mut len = 1; + if run.is_some() { + loop { + let b = take(1)?[0]; + len += b as usize; + if b != 255 { + break; + } + } + } + if px.len() + len * 3 > count * 3 { + return Err("ZRLE run overflows its tile".into()); + } + (0..len).for_each(|_| px.extend_from_slice(&colour)); + } + } + s => return Err(format!("invalid ZRLE subencoding {s}").into()), + } + if sub != 1 { + fb.put(tx, ty, tw, th, &px, 3); + } + } + } + Ok(()) +} + +fn take<'a>(data: &'a [u8], p: &mut usize, n: usize) -> crate::Result<&'a [u8]> { + let s = data.get(*p..*p + n).ok_or("truncated ZRLE data")?; + *p += n; + Ok(s) +} + +fn compact_len(r: &mut impl Read) -> crate::Result { + let mut len = 0usize; + for i in 0..3 { + let b = read_u8(r)? as usize; + if i == 2 { + return Ok(len | b << 14); + } + len |= (b & 127) << (7 * i); + if b & 128 == 0 { + break; + } + } + Ok(len) +} + +/// Tight: fill, JPEG, or basic compression (copy/palette/gradient filters over four zlib streams). +fn tight(r: &mut impl Read, fb: &mut Framebuffer, dec: &mut Decoders, x: u16, y: u16, w: u16, h: u16) -> crate::Result<()> { + let ctl = read_u8(r)?; + for (i, z) in dec.tight.iter_mut().enumerate() { + if ctl & (1 << i) != 0 { + *z = Decompress::new(true); + } + } + let (wu, hu) = (w as usize, h as usize); + match ctl >> 4 { + 8 => { + let rgb = read_n(r, 3)?; + fb.fill(x, y, w, h, &rgb); + return Ok(()); + } + 9 => { + let len = compact_len(r)?; + let jpeg = read_n(r, len)?; + let img = image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg).map_err(|e| format!("Tight JPEG: {e}"))?.to_rgb8(); + if img.width() as usize != wu || img.height() as usize != hu { + return Err("Tight JPEG size doesn't match its rectangle".into()); + } + fb.put(x, y, w, h, img.as_raw(), 3); + return Ok(()); + } + c if c & 8 != 0 => return Err(format!("unsupported Tight compression type {c:#x}").into()), + _ => {} + } + let filter = if ctl & 0x40 != 0 { read_u8(r)? } else { 0 }; + let palette = if filter == 1 { + let n = read_u8(r)? as usize + 1; + read_n(r, n * 3)? + } else { + Vec::new() + }; + let size = match (filter, palette.len() / 3) { + (0 | 2, _) => wu * hu * 3, + (1, 2) => wu.div_ceil(8) * hu, + (1, _) => wu * hu, + (f, _) => return Err(format!("unsupported Tight filter {f}").into()), + }; + let data = if size < 12 { + read_n(r, size)? + } else { + let len = compact_len(r)?; + let compressed = read_n(r, len)?; + inflate(&mut dec.tight[(ctl >> 4 & 3) as usize], &compressed, &mut dec.buf)?; + std::mem::take(&mut dec.buf) + }; + if data.len() < size { + return Err("truncated Tight data".into()); + } + let px: Vec = match filter { + 1 => { + let mut px = Vec::with_capacity(wu * hu * 3); + let mut colour = |i: usize| -> crate::Result<()> { + px.extend_from_slice(palette.get(i * 3..i * 3 + 3).ok_or("Tight palette index out of range")?); + Ok(()) + }; + if palette.len() == 6 { + for row in data[..size].chunks_exact(wu.div_ceil(8)) { + for i in 0..wu { + colour((row[i / 8] >> (7 - i % 8)) as usize & 1)?; + } + } + } else { + data[..size].iter().try_for_each(|&i| colour(i as usize))?; + } + px + } + 2 => { + let mut px = vec![0u8; size]; + for j in 0..hu { + for i in 0..wu { + for c in 0..3 { + let at = |i: usize, j: usize| px[(j * wu + i) * 3 + c] as i32; + let left = if i > 0 { at(i - 1, j) } else { 0 }; + let up = if j > 0 { at(i, j - 1) } else { 0 }; + let ul = if i > 0 && j > 0 { at(i - 1, j - 1) } else { 0 }; + let pred = (left + up - ul).clamp(0, 255) as u8; + px[(j * wu + i) * 3 + c] = pred.wrapping_add(data[(j * wu + i) * 3 + c]); + } + } + } + px + } + _ => data, + }; + fb.put(x, y, w, h, &px, 3); + Ok(()) +} + +/// The X11 keysym for a key: a named key (DOM `KeyboardEvent.key` / egui names), otherwise the +/// typed character (Latin-1 maps directly, other Unicode to `0x01000000 | codepoint`). +pub fn keysym_for(name: &str, ch: Option) -> Option { + let named = match name { + "Enter" | "Return" => 0xff0d, + "Backspace" => 0xff08, + "Tab" => 0xff09, + "Escape" | "Esc" => 0xff1b, + "ArrowLeft" | "Left" => 0xff51, + "ArrowUp" | "Up" => 0xff52, + "ArrowRight" | "Right" => 0xff53, + "ArrowDown" | "Down" => 0xff54, + "Home" => 0xff50, + "End" => 0xff57, + "PageUp" => 0xff55, + "PageDown" => 0xff56, + "Insert" => 0xff63, + "Delete" => 0xffff, + "Shift" | "ShiftLeft" => 0xffe1, + "ShiftRight" => 0xffe2, + "Control" | "Ctrl" | "ControlLeft" => 0xffe3, + "ControlRight" => 0xffe4, + "Alt" | "AltLeft" => 0xffe9, + "AltRight" => 0xffea, + "AltGraph" => 0xfe03, + "Super" | "Meta" | "Command" | "OS" | "SuperLeft" | "MetaLeft" => 0xffeb, + "SuperRight" | "MetaRight" => 0xffec, + "CapsLock" => 0xffe5, + "Space" => 0x20, + _ => match name.strip_prefix('F').and_then(|n| n.parse::().ok()) { + Some(n @ 1..=12) => 0xffbe + n - 1, + _ => 0, + }, + }; + if named != 0 { + return Some(named); + } + let c = ch? as u32; + match c { + 0x20..=0x7e | 0xa0..=0xff => Some(c), + 0..=0x1f | 0x7f..=0x9f => None, + _ => Some(0x0100_0000 | c), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + + #[test] + fn des_response_matches_known_vector() { + // openssl enc -des-ecb -K 0e86ceceeef64e26 (bit-reversed "password") over "0123456789abcdef". + let got = vnc_auth_response("password", b"0123456789abcdef"); + let want = [0x56, 0x45, 0xab, 0xeb, 0x5f, 0x1e, 0x64, 0x75, 0xe8, 0xfe, 0xb1, 0x1b, 0xeb, 0x66, 0xea, 0x19]; + assert_eq!(got, want); + // Only the first 8 password bytes count. + assert_eq!(vnc_auth_response("password-too-long", b"0123456789abcdef"), want); + } + + #[test] + fn keysyms() { + assert_eq!(keysym_for("Enter", None), Some(0xff0d)); + assert_eq!(keysym_for("F1", None), Some(0xffbe)); + assert_eq!(keysym_for("F12", None), Some(0xffc9)); + assert_eq!(keysym_for("F13", None), None); + assert_eq!(keysym_for("Control", None), Some(0xffe3)); + assert_eq!(keysym_for("ArrowDown", None), Some(0xff54)); + assert_eq!(keysym_for("a", Some('a')), Some(0x61)); + assert_eq!(keysym_for("", Some('é')), Some(0xe9)); + assert_eq!(keysym_for("", Some('€')), Some(0x0100_20ac)); + assert_eq!(keysym_for("", Some('\u{1}')), None); + } + + #[test] + fn compact_lengths() { + assert_eq!(compact_len(&mut &[0x05][..]).unwrap(), 5); + assert_eq!(compact_len(&mut &[0x90, 0x4e][..]).unwrap(), 10000); + assert_eq!(compact_len(&mut &[0xff, 0xff, 0xff][..]).unwrap(), 4194303); + } + + fn zlib(data: &[u8]) -> Vec { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(data).unwrap(); + z.finish().unwrap() + } + + fn rect(m: &mut Vec, x: u16, y: u16, w: u16, h: u16, enc: i32) { + for v in [x, y, w, h] { + m.extend_from_slice(&v.to_be_bytes()); + } + m.extend_from_slice(&enc.to_be_bytes()); + } + + fn fake_server(listener: TcpListener, password: &'static str) -> std::thread::JoinHandle> { + std::thread::spawn(move || { + let (mut s, _) = listener.accept().unwrap(); + s.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let read = |s: &mut std::net::TcpStream, n: usize| { + let mut b = vec![0u8; n]; + s.read_exact(&mut b).unwrap(); + b + }; + s.write_all(b"RFB 003.008\n").unwrap(); + assert_eq!(read(&mut s, 12), b"RFB 003.008\n"); + s.write_all(&[1, 2]).unwrap(); + assert_eq!(read(&mut s, 1), [2]); + let challenge = *b"0123456789abcdef"; + s.write_all(&challenge).unwrap(); + if read(&mut s, 16) != vnc_auth_response(password, &challenge) { + s.write_all(&[0, 0, 0, 1, 0, 0, 0, 14]).unwrap(); + s.write_all(b"wrong password").unwrap(); + return Vec::new(); + } + s.write_all(&[0, 0, 0, 0]).unwrap(); + assert_eq!(read(&mut s, 1), [1]); // shared + let mut init = vec![0, 4, 0, 4]; + init.extend_from_slice(&[32, 24, 0, 1, 0, 255, 0, 255, 0, 255, 16, 8, 0, 0, 0, 0]); + init.extend_from_slice(&[0, 0, 0, 4]); + init.extend_from_slice(b"test"); + s.write_all(&init).unwrap(); + let pf = read(&mut s, 20); + assert_eq!(pf[0], 0); + let enc_hdr = read(&mut s, 4); + assert_eq!(enc_hdr[0], 2); + read(&mut s, u16::from_be_bytes([enc_hdr[2], enc_hdr[3]]) as usize * 4); + assert_eq!(read(&mut s, 10), update_request(false, 4, 4)); + + let mut m = vec![0, 0, 0, 5]; + rect(&mut m, 0, 0, 2, 2, 0); // Raw: red, green / blue, white + m.extend_from_slice(&[255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 255, 255, 255, 0]); + rect(&mut m, 2, 2, 2, 2, 1); // CopyRect from (0,0) + m.extend_from_slice(&[0, 0, 0, 0]); + rect(&mut m, 2, 0, 2, 2, 16); // ZRLE solid yellow + let z = zlib(&[1, 255, 255, 0]); + m.extend_from_slice(&(z.len() as u32).to_be_bytes()); + m.extend_from_slice(&z); + rect(&mut m, 0, 2, 1, 2, 5); // Hextile background cyan + m.extend_from_slice(&[2, 0, 255, 255, 0]); + rect(&mut m, 1, 2, 1, 2, 7); // Tight fill magenta + m.extend_from_slice(&[0x80, 255, 0, 255]); + s.write_all(&m).unwrap(); + assert_eq!(read(&mut s, 10), update_request(true, 4, 4)); + read(&mut s, 8) // the key event + }) + } + + #[test] + fn handshake_auth_and_decoding_against_fake_server() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = fake_server(listener, "secret"); + let (frame_tx, frame_rx) = mpsc::channel(); + let (close_tx, close_rx) = mpsc::channel(); + let client = Client::new(Config { + host: "127.0.0.1".into(), + port, + password: Some("secret".into()), + connect_timeout: Duration::from_secs(5), + on_frame: Arc::new(move || frame_tx.send(()).unwrap()), + on_close: Arc::new(move || close_tx.send(()).unwrap()), + ..Default::default() + }); + client.connect().unwrap(); + assert_eq!(client.desktop_name(), "test"); + frame_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + { + let fb = client.framebuffer(); + let fb = fb.lock().unwrap(); + assert_eq!((fb.width, fb.height), (4, 4)); + assert!(fb.dirty); + let px = |x: usize, y: usize| &fb.rgba[(y * 4 + x) * 4..(y * 4 + x) * 4 + 4]; + assert_eq!(px(0, 0), [255, 0, 0, 255]); + assert_eq!(px(1, 1), [255, 255, 255, 255]); + assert_eq!(px(2, 2), [255, 0, 0, 255]); // copied red + assert_eq!(px(3, 2), [0, 255, 0, 255]); // copied green + assert_eq!(px(2, 3), [0, 0, 255, 255]); // copied blue + assert_eq!(px(3, 1), [255, 255, 0, 255]); // ZRLE + assert_eq!(px(0, 3), [0, 255, 255, 255]); // Hextile + assert_eq!(px(1, 2), [255, 0, 255, 255]); // Tight + } + client.key(0xff0d, true).unwrap(); + assert_eq!(server.join().unwrap(), [4, 1, 0, 0, 0, 0, 0xff, 0x0d]); + client.close(); + client.close(); + close_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(close_rx.recv_timeout(Duration::from_millis(200)).is_err()); + } + + #[test] + fn wrong_password_reports_reason() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = fake_server(listener, "secret"); + let client = Client::new(Config { + host: "127.0.0.1".into(), + port, + password: Some("nope".into()), + connect_timeout: Duration::from_secs(5), + ..Default::default() + }); + let err = client.connect().unwrap_err().to_string(); + assert_eq!(err, "VNC authentication failed: wrong password"); + server.join().unwrap(); + } + + #[test] + fn zrle_and_tight_palettes_and_gradient() { + let px = |fb: &Framebuffer, x: usize| fb.rgba[x * 4..x * 4 + 3].to_vec(); + let (red, blue) = ([255u8, 0, 0], [0u8, 0, 255]); + + // ZRLE packed palette (1 bit/pixel): blue, red, blue. + let mut fb = Framebuffer::new(3, 1); + zrle(&[2, 255, 0, 0, 0, 0, 255, 0b1010_0000], &mut fb, 0, 0, 3, 1).unwrap(); + assert_eq!([px(&fb, 0), px(&fb, 1), px(&fb, 2)], [blue.to_vec(), red.to_vec(), blue.to_vec()]); + // ZRLE palette RLE: index 0 run of 2, then index 1 once. + zrle(&[130, 255, 0, 0, 0, 0, 255, 0x80, 1, 1], &mut fb, 0, 0, 3, 1).unwrap(); + assert_eq!([px(&fb, 0), px(&fb, 1), px(&fb, 2)], [red.to_vec(), red.to_vec(), blue.to_vec()]); + + // Tight two-colour palette, uncompressed (< 12 bytes): red, blue, red. + let mut dec = Decoders::default(); + let msg = [0x40, 1, 1, 255, 0, 0, 0, 0, 255, 0b0100_0000]; + tight(&mut &msg[..], &mut fb, &mut dec, 0, 0, 3, 1).unwrap(); + assert_eq!([px(&fb, 0), px(&fb, 1), px(&fb, 2)], [red.to_vec(), blue.to_vec(), red.to_vec()]); + + // Tight gradient filter over zlib stream 1: a flat 2x2 colour. + let mut fb = Framebuffer::new(2, 2); + let z = zlib(&[10, 20, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + let mut msg = vec![0x50, 2, z.len() as u8]; + msg.extend_from_slice(&z); + tight(&mut &msg[..], &mut fb, &mut dec, 0, 0, 2, 2).unwrap(); + assert!((0..4).all(|i| px(&fb, i) == [10, 20, 30])); + } + + #[test] + fn apple_only_security_is_explained() { + assert!(unsupported_security(&[30, 33]).contains("Apple Remote Desktop")); + } + + #[test] + fn server_sizes_and_rect_lengths_are_bounded() { + assert!(Framebuffer::new_checked(7680, 4320).is_ok()); + assert!(Framebuffer::new_checked(0, 10).is_err()); + assert!(Framebuffer::new_checked(u16::MAX, u16::MAX).is_err()); + // A DesktopSize pseudo-rect announcing a 65535x65535 desktop is refused before allocating. + let mut m = vec![0, 0, 1]; + rect(&mut m, 0, 0, u16::MAX, u16::MAX, -223); + let mut fb = Framebuffer::new(4, 4); + let mut dec = Decoders::default(); + assert!(read_update(&mut &m[..], &mut fb, &mut dec).unwrap_err().to_string().contains("unsupported desktop size")); + // A ZRLE rect claiming 4 GiB of data is refused before allocating. + let mut m = vec![0, 0, 1]; + rect(&mut m, 0, 0, 4, 4, 16); + m.extend_from_slice(&u32::MAX.to_be_bytes()); + assert!(read_update(&mut &m[..], &mut fb, &mut dec).unwrap_err().to_string().contains("exceeds")); + } +} diff --git a/rust/core/src/config.rs b/rust/core/src/config.rs new file mode 100644 index 0000000..d71166f --- /dev/null +++ b/rust/core/src/config.rs @@ -0,0 +1,127 @@ +//! Data directories, and the one-time move of Alchementary Link data into Nexus Terminal's. + +use std::path::{Path, PathBuf}; + +pub const APP_DIR: &str = "NexusTerminal"; +pub const LEGACY_APP_DIR: &str = "AlchementaryLink"; + +#[derive(Clone, Debug)] +pub struct AppConfig { + pub data_dir: PathBuf, + pub db_path: PathBuf, + pub vault_dir: PathBuf, + pub recordings_dir: PathBuf, + /// Set when this run copied data across from Alchementary Link. + pub migrated_from: Option, +} + +/// `~/Library/Application Support` on macOS, `%APPDATA%` on Windows, `$XDG_CONFIG_HOME` or +/// `~/.config` elsewhere — the same bases the Go app used. +pub fn base_dir() -> PathBuf { + let home = || std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from).unwrap_or_default(); + if cfg!(target_os = "macos") { + home().join("Library/Application Support") + } else if cfg!(windows) { + std::env::var_os("APPDATA").map(PathBuf::from).unwrap_or_else(|| home().join("AppData").join("Roaming")) + } else { + std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).unwrap_or_else(|| home().join(".config")) + } +} + +impl AppConfig { + pub fn in_dir(data_dir: PathBuf) -> AppConfig { + AppConfig { + db_path: data_dir.join("nexus.db"), + vault_dir: data_dir.join("vault"), + recordings_dir: data_dir.join("recordings"), + data_dir, + migrated_from: None, + } + } + + /// Resolves the data directory, migrating legacy data first, and creates the directories. + pub fn load() -> std::io::Result { + let base = base_dir(); + let mut cfg = AppConfig::in_dir(base.join(APP_DIR)); + cfg.migrated_from = migrate_legacy(&base.join(LEGACY_APP_DIR), &cfg)?; + for d in [&cfg.data_dir, &cfg.vault_dir] { + create_private_dir(d)?; + } + Ok(cfg) + } +} + +/// Creates `d` (and parents) readable only by the owner. +pub fn create_private_dir(d: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(d)?; + #[cfg(unix)] + std::fs::set_permissions(d, std::os::unix::fs::PermissionsExt::from_mode(0o700))?; + Ok(()) +} + +/// Copies (never moves) `AlchementaryLink/{link.db, vault/, recordings/}` into the Nexus +/// directory when Nexus has no database yet. A pre-rebrand `NexusTerminal/nexus.db` wins as-is. +pub fn migrate_legacy(legacy: &Path, cfg: &AppConfig) -> std::io::Result> { + let legacy_db = legacy.join("link.db"); + if cfg.db_path.exists() || !legacy_db.exists() { + return Ok(None); + } + create_private_dir(&cfg.data_dir)?; + // Fold the WAL into the main file so the copy is complete. + if let Ok(c) = rusqlite::Connection::open(&legacy_db) { + let _ = c.pragma_update(None, "wal_checkpoint", "TRUNCATE"); + } + std::fs::copy(&legacy_db, &cfg.db_path)?; + copy_dir(&legacy.join("vault"), &cfg.vault_dir)?; + copy_dir(&legacy.join("recordings"), &cfg.recordings_dir)?; + std::fs::write(cfg.data_dir.join("migrated-from"), legacy.to_string_lossy().as_bytes())?; + Ok(Some(legacy.to_path_buf())) +} + +fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> { + let Ok(rd) = std::fs::read_dir(from) else { return Ok(()) }; + create_private_dir(to)?; + for e in rd.flatten() { + if e.file_type()?.is_file() && !to.join(e.file_name()).exists() { + std::fs::copy(e.path(), to.join(e.file_name()))?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("nexus-cfg-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d + } + + #[test] + fn migrates_legacy_once_and_keeps_source() { + let base = tmp("mig"); + let legacy = base.join(LEGACY_APP_DIR); + std::fs::create_dir_all(legacy.join("vault")).unwrap(); + std::fs::write(legacy.join("link.db"), b"db").unwrap(); + std::fs::write(legacy.join("vault/vault.json"), b"{}").unwrap(); + let cfg = AppConfig::in_dir(base.join(APP_DIR)); + assert_eq!(migrate_legacy(&legacy, &cfg).unwrap(), Some(legacy.clone())); + assert_eq!(std::fs::read(&cfg.db_path).unwrap(), b"db"); + assert!(cfg.vault_dir.join("vault.json").exists()); + assert!(legacy.join("link.db").exists()); + // Second run: Nexus already has a database, nothing happens. + std::fs::write(legacy.join("link.db"), b"newer").unwrap(); + assert_eq!(migrate_legacy(&legacy, &cfg).unwrap(), None); + assert_eq!(std::fs::read(&cfg.db_path).unwrap(), b"db"); + std::fs::remove_dir_all(base).unwrap(); + } + + #[test] + fn no_legacy_no_migration() { + let base = tmp("none"); + let cfg = AppConfig::in_dir(base.join(APP_DIR)); + assert_eq!(migrate_legacy(&base.join(LEGACY_APP_DIR), &cfg).unwrap(), None); + } +} diff --git a/rust/core/src/exportimport.rs b/rust/core/src/exportimport.rs new file mode 100644 index 0000000..14f79b1 --- /dev/null +++ b/rust/core/src/exportimport.rs @@ -0,0 +1,90 @@ +//! Password-encrypted profile exports: `[version 1][salt 32][nonce 12][AES-256-GCM ciphertext]` +//! over a JSON bundle, with the vault's Argon2id parameters. `.link` files from Alchementary +//! Link are the same format. + +use crate::Result; +use crate::models::Profile; +use crate::secrets::{derive_key, random_bytes}; +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +const VERSION: u8 = 1; +const MAX_IMPORT_BYTES: u64 = 10 * 1024 * 1024; +pub const EXTENSION: &str = "nexus"; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Bundle { + version: i64, + exported_at: String, + #[serde(default)] + profiles: Vec, + count: usize, +} + +pub fn encrypt_and_export(profiles: &[Profile], password: &str, path: &Path) -> Result<()> { + if password.is_empty() { + return Err("exportimport: password cannot be empty".into()); + } + if profiles.is_empty() { + return Err("exportimport: no profiles to export".into()); + } + let bundle = Bundle { + version: 1, + exported_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + profiles: profiles.to_vec(), + count: profiles.len(), + }; + let plain = serde_json::to_vec_pretty(&bundle)?; + let salt = random_bytes::<32>(); + let nonce = random_bytes::<12>(); + let key = derive_key(password, &salt)?; + let ct = Aes256Gcm::new((&*key).into()).encrypt(Nonce::from_slice(&nonce), plain.as_slice()).map_err(|e| e.to_string())?; + let mut out = vec![VERSION]; + out.extend(salt); + out.extend(nonce); + out.extend(ct); + crate::secrets::write_private(path, &out) +} + +pub fn decrypt_and_import(path: &Path, password: &str) -> Result> { + if password.is_empty() { + return Err("exportimport: password cannot be empty".into()); + } + if std::fs::metadata(path)?.len() > MAX_IMPORT_BYTES { + return Err("exportimport: file is too large (max 10 MiB)".into()); + } + let data = std::fs::read(path)?; + if data.len() < 1 + 32 + 12 + 1 + 16 { + return Err("exportimport: file is too small or corrupt".into()); + } + if data[0] != VERSION { + return Err(format!("exportimport: unsupported format version {}", data[0]).into()); + } + let key = derive_key(password, &data[1..33])?; + let plain = Aes256Gcm::new((&*key).into()) + .decrypt(Nonce::from_slice(&data[33..45]), &data[45..]) + .map_err(|_| "exportimport: decryption failed — wrong password or corrupt file")?; + let bundle: Bundle = serde_json::from_slice(&plain).map_err(|e| format!("exportimport: invalid bundle JSON: {e}"))?; + Ok(bundle.profiles) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_go_export_and_round_trips() { + let fx = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/profiles.link"); + assert!(decrypt_and_import(&fx, "nope").is_err()); + let ps = decrypt_and_import(&fx, "export-pass").unwrap(); + assert_eq!(ps.len(), 6); + assert_eq!(ps[0].ssh.as_ref().unwrap().jump_hosts[0].port, 2222); + let out = std::env::temp_dir().join(format!("nexus-export-{}.nexus", std::process::id())); + encrypt_and_export(&ps, "pw", &out).unwrap(); + assert_eq!(decrypt_and_import(&out, "pw").unwrap(), ps); + std::fs::remove_file(out).unwrap(); + } +} diff --git a/rust/core/src/ircformat.rs b/rust/core/src/ircformat.rs new file mode 100644 index 0000000..681bd82 --- /dev/null +++ b/rust/core/src/ircformat.rs @@ -0,0 +1,461 @@ +//! mIRC formatting (^B ^] ^^ ^_ ^V ^Q ^O ^C fg,bg ^D hex) to styled spans, the nick colour hash +//! and the IRC event line layout. Ported from the Wails frontend's `irc.js`. +//! +//! Colours are xterm-256 palette indices: IRC colour codes are mapped through `IRC_COLOURS`. + +use crate::clients::irc::Event; + +/// IRC colours 00-98 mapped to the xterm 256-colour palette (modern IRC formatting spec). +/// Colour 99, and anything out of range, means the terminal default. +pub const IRC_COLOURS: [u8; 99] = [ + 15, 0, 4, 2, 9, 1, 5, 130, 11, 10, 6, 14, 12, 13, 8, 7, // + 52, 94, 100, 58, 22, 29, 23, 24, 17, 54, 53, 89, // + 88, 130, 142, 64, 28, 35, 30, 25, 18, 91, 90, 125, // + 124, 166, 184, 106, 34, 49, 37, 33, 19, 129, 127, 161, // + 196, 208, 226, 154, 46, 86, 51, 75, 21, 171, 201, 198, // + 203, 215, 227, 191, 83, 122, 87, 111, 63, 177, 207, 205, // + 217, 223, 229, 193, 157, 158, 159, 153, 147, 183, 219, 212, // + 16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231, +]; + +/// Palette indices used for nicks. +pub const NICK_COLOURS: [u8; 12] = [33, 39, 45, 69, 75, 81, 111, 141, 177, 207, 214, 220]; + +const GREY: u8 = 244; +const RED: u8 = 203; +const GREEN: u8 = 83; +const YELLOW: u8 = 221; +const CYAN: u8 = 81; +const MAGENTA: u8 = 177; + +/// Text style. `fg`/`bg` are xterm-256 indices; `fg_rgb`/`bg_rgb` come from ^D hex colours. +/// At most one of `fg`/`fg_rgb` (and of `bg`/`bg_rgb`) is set. `None` is the default colour. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Style { + pub fg: Option, + pub bg: Option, + pub fg_rgb: Option<[u8; 3]>, + pub bg_rgb: Option<[u8; 3]>, + pub bold: bool, + pub dim: bool, + pub italic: bool, + pub underline: bool, + pub reverse: bool, + pub strike: bool, + pub monospace: bool, +} + +impl Style { + fn fg(colour: u8) -> Style { + Style { fg: Some(colour), ..Style::default() } + } + fn set_fg(&mut self, colour: u8) { + self.fg = Some(colour); + self.fg_rgb = None; + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Span { + pub text: String, + pub style: Style, +} + +/// Spans under construction; `style` applies to the next text pushed. +#[derive(Default)] +struct Line { + spans: Vec, + style: Style, +} + +impl Line { + fn push(&mut self, text: &str) { + if text.is_empty() { + return; + } + match self.spans.last_mut() { + Some(last) if last.style == self.style => last.text.push_str(text), + _ => self.spans.push(Span { text: text.to_string(), style: self.style }), + } + } + + /// `text` in `style`, then back to the default style. + fn styled(&mut self, style: Style, text: &str) { + self.style = style; + self.push(text); + self.style = Style::default(); + } + + /// A bold, nick-coloured name layered on the current style, then reset. + fn nick(&mut self, nick: &str) { + let mut style = self.style; + style.bold = true; + style.set_fg(nick_colour(nick)); + self.styled(style, nick); + } + + /// Applies mIRC formatting codes in `value` on top of the current style. + fn mirc(&mut self, value: &str) { + let input: Vec = strip_terminal_escapes(value).chars().collect(); + // Toggle states start off regardless of the inherited style, as in irc.js. + let (mut bold, mut italic, mut strike, mut underline, mut reverse, mut mono) = (false, false, false, false, false, false); + let mut i = 0; + while i < input.len() { + let c = input[i]; + i += 1; + match c { + '\x02' => { + bold = !bold; + self.style.bold = bold; + if !bold { + self.style.dim = false; // SGR 22 clears both + } + } + '\x1d' => { + italic = !italic; + self.style.italic = italic; + } + '\x1e' => { + strike = !strike; + self.style.strike = strike; + } + '\x1f' => { + underline = !underline; + self.style.underline = underline; + } + '\x16' => { + reverse = !reverse; + self.style.reverse = reverse; + } + '\x11' => { + mono = !mono; + self.style.monospace = mono; + } + '\x0f' => { + self.style = Style::default(); + (bold, italic, strike, underline, reverse, mono) = (false, false, false, false, false, false); + } + '\x03' | '\x04' => { + let hex = c == '\x04'; + let read = |at: usize| if hex { read_hex(&input, at) } else { read_decimal(&input, at) }; + let Some((fg, len)) = read(i) else { + self.reset_colours(); + continue; + }; + i += len; + self.apply_colour(fg, false); + if input.get(i) != Some(&',') { + continue; + } + match read(i + 1) { + Some((bg, len)) => { + i += len + 1; + self.apply_colour(bg, true); + } + None => { + self.push(","); + i += 1; + } + } + } + c if (c as u32) < 0x20 || c as u32 == 0x7f => {} + c => self.push(c.encode_utf8(&mut [0; 4])), + } + } + } + + fn reset_colours(&mut self) { + (self.style.fg, self.style.bg, self.style.fg_rgb, self.style.bg_rgb) = (None, None, None, None); + } + + fn apply_colour(&mut self, colour: Colour, background: bool) { + let s = &mut self.style; + let (index, rgb) = if background { (&mut s.bg, &mut s.bg_rgb) } else { (&mut s.fg, &mut s.fg_rgb) }; + match colour { + Colour::Indexed(code) => { + *index = IRC_COLOURS.get(code as usize).copied(); + *rgb = None; + } + Colour::Rgb(value) => { + *rgb = Some(value); + *index = None; + } + } + } +} + +enum Colour { + Indexed(u8), + Rgb([u8; 3]), +} + +fn read_decimal(input: &[char], at: usize) -> Option<(Colour, usize)> { + let digits: String = input.iter().skip(at).take(2).take_while(|c| c.is_ascii_digit()).collect(); + if digits.is_empty() { + return None; + } + Some((Colour::Indexed(digits.parse().ok()?), digits.len())) +} + +fn read_hex(input: &[char], at: usize) -> Option<(Colour, usize)> { + let hex: String = input.get(at..at + 6)?.iter().collect(); + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok(); + Some((Colour::Rgb([byte(0)?, byte(2)?, byte(4)?]), 6)) +} + +/// Removes untrusted terminal sequences (OSC, CSI, other ESC pairs) and C1 controls, applying the +/// same four passes as irc.js. +fn strip_terminal_escapes(value: &str) -> String { + // OSC: ESC ] ... (BEL | ESC \), greedy like the JS regex. + let mut s = String::new(); + let mut rest = value; + while let Some(pos) = rest.find("\x1b]") { + let body = &rest[pos + 2..]; + let run_end = body.find('\x07'); + let end = match run_end { + Some(bel) => Some(bel + 1), + None => body.rfind("\x1b\\").map(|st| st + 2), + }; + match end { + Some(end) => { + s.push_str(&rest[..pos]); + rest = &body[end..]; + } + None => { + s.push_str(&rest[..pos + 2]); + rest = body; + } + } + } + s.push_str(rest); + + // CSI: ESC [ params intermediates final. + let chars: Vec = s.chars().collect(); + let mut csi = String::new(); + let mut i = 0; + while i < chars.len() { + if chars[i] == '\x1b' && chars.get(i + 1) == Some(&'[') { + let mut j = i + 2; + while chars.get(j).is_some_and(|c| ('0'..='?').contains(c)) { + j += 1; + } + while chars.get(j).is_some_and(|c| (' '..='/').contains(c)) { + j += 1; + } + if chars.get(j).is_some_and(|c| ('@'..='~').contains(c)) { + i = j + 1; + continue; + } + } + csi.push(chars[i]); + i += 1; + } + + // ESC plus any character but a line terminator. + let chars: Vec = csi.chars().collect(); + let mut out = String::new(); + let mut i = 0; + while i < chars.len() { + if chars[i] == '\x1b' && chars.get(i + 1).is_some_and(|c| !matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}')) { + i += 2; + continue; + } + out.push(chars[i]); + i += 1; + } + out.retain(|c| !('\u{80}'..='\u{9f}').contains(&c)); + out +} + +/// mIRC-formatted text as styled spans, starting from the default style. +pub fn format_text(value: &str) -> Vec { + let mut line = Line::default(); + line.mirc(value); + line.spans +} + +/// The text with all formatting removed. +pub fn plain_text(value: &str) -> String { + format_text(value).into_iter().map(|s| s.text).collect() +} + +/// The palette index for a nick (Java-style string hash over code points). +pub fn nick_colour(nick: &str) -> u8 { + let hash = nick.chars().fold(0u32, |h, c| h.wrapping_mul(31).wrapping_add(c as u32)); + NICK_COLOURS[hash as usize % NICK_COLOURS.len()] +} + +/// One chat line for an event: `[time] body`, laid out per event type as the Wails viewer did. +pub fn format_event(event: &Event, time: &str) -> Vec { + let from = plain_text(&event.from); + let subject = plain_text(&event.command); + let mut l = Line::default(); + l.styled(Style { dim: true, ..Style::fg(GREY) }, &format!("[{time}]")); + l.push(" "); + + let nick_or = |l: &mut Line, fallback: &str| { + if from.is_empty() { l.push(fallback) } else { l.nick(&from) } + }; + let suffix = |l: &mut Line| { + if !event.text.is_empty() { + l.push(" ("); + l.mirc(&event.text); + l.push(")"); + } + }; + let magenta_italic = Style { italic: true, ..Style::fg(MAGENTA) }; + + match event.kind.as_str() { + "message" => { + l.styled(Style::fg(GREY), "<"); + nick_or(&mut l, ""); + l.styled(Style::fg(GREY), ">"); + l.push(" "); + l.mirc(&event.text); + } + "action" => { + l.style = magenta_italic; + l.push("* "); + nick_or(&mut l, ""); + l.style = magenta_italic; + l.push(" "); + l.mirc(&event.text); + } + "notice" => { + l.style = Style::fg(YELLOW); + l.push("-"); + nick_or(&mut l, "notice"); + l.styled(Style::fg(YELLOW), "-"); + l.push(" "); + l.mirc(&event.text); + } + "join" => { + l.styled(Style::fg(GREEN), "-->"); + l.push(" "); + nick_or(&mut l, "user"); + l.push(" joined"); + } + "part" | "quit" => { + l.styled(Style::fg(RED), "<--"); + l.push(" "); + nick_or(&mut l, "user"); + l.push(if event.kind == "part" { " left" } else { " quit" }); + suffix(&mut l); + } + "kick" => { + l.styled(Style::fg(RED), "<--"); + l.push(" "); + l.styled(Style { bold: true, ..Style::default() }, if subject.is_empty() { "user" } else { &subject }); + l.push(" was kicked by "); + nick_or(&mut l, "server"); + suffix(&mut l); + } + "nick" => { + nick_or(&mut l, "user"); + l.push(" is now known as "); + l.styled(Style { bold: true, ..Style::default() }, &plain_text(&event.text)); + } + "topic" | "error" => { + let (colour, label) = if event.kind == "topic" { (CYAN, "topic:") } else { (RED, "error:") }; + l.styled(Style { bold: true, ..Style::fg(colour) }, label); + l.push(" "); + l.mirc(&event.text); + } + _ => { + l.style = Style::fg(CYAN); + l.mirc(&event.text); + } + } + l.spans +} + +#[cfg(test)] +mod tests { + use super::*; + + fn span(text: &str, style: Style) -> Span { + Span { text: text.into(), style } + } + + #[test] + fn toggles() { + let b = Style { bold: true, ..Style::default() }; + let i = Style { italic: true, ..Style::default() }; + let u = Style { underline: true, ..Style::default() }; + let d = Style::default(); + assert_eq!( + format_text("plain \x02bold\x02 \x1ditalic\x1d \x1funderlined\x1f"), + vec![span("plain ", d), span("bold", b), span(" ", d), span("italic", i), span(" ", d), span("underlined", u)] + ); + let all = format_text("\x16\x1e\x11x\x0fy"); + assert_eq!(all[0].style, Style { reverse: true, strike: true, monospace: true, ..Style::default() }); + assert_eq!(all[1], span("y", d)); + } + + #[test] + fn indexed_colours() { + assert_eq!(format_text("\x0304red\x03 default"), vec![span("red", Style::fg(9)), span(" default", Style::default())]); + assert_eq!( + format_text("\x0352extended \x0303,01matrix"), + vec![span("extended ", Style::fg(196)), span("matrix", Style { bg: Some(0), ..Style::fg(2) })] + ); + assert_eq!(format_text("\x0399default"), vec![span("default", Style::default())]); + } + + #[test] + fn hex_colours() { + assert_eq!( + format_text("\x04ff8800orange\x04 default"), + vec![span("orange", Style { fg_rgb: Some([255, 136, 0]), ..Style::default() }), span(" default", Style::default())] + ); + let s = format_text("\x0400ff00,0000FFx"); + assert_eq!(s[0].style, Style { fg_rgb: Some([0, 255, 0]), bg_rgb: Some([0, 0, 255]), ..Style::default() }); + } + + #[test] + fn colour_without_background_keeps_comma() { + assert_eq!(format_text("\x0304,comma"), vec![span(",comma", Style::fg(9))]); + } + + #[test] + fn plain_text_strips_formatting() { + assert_eq!(plain_text("\x02bold\x02 \x0304red\x03"), "bold red"); + } + + #[test] + fn untrusted_terminal_sequences_are_removed() { + assert_eq!(plain_text("before\x1b[31munsafe\x1b]0;title\x07after"), "beforeunsafeafter"); + assert_eq!(plain_text("a\x1b]8;;x\x1b\\b\u{9b}c\x1bZd\x01"), "abcd"); + } + + #[test] + fn nick_colour_matches_js_hash() { + // Hashes computed with the irc.js function under node. + assert_eq!(nick_colour(""), 33); + assert_eq!(nick_colour("bob"), NICK_COLOURS[97717 % 12]); + assert_eq!(nick_colour("alice"), NICK_COLOURS[92903040 % 12]); + } + + #[test] + fn event_lines() { + let message = + format_event(&Event { kind: "message".into(), from: "alice".into(), text: "hello \x02world\x02".into(), ..Default::default() }, "12:34:56"); + assert_eq!(message[0], span("[12:34:56]", Style { dim: true, ..Style::fg(GREY) })); + assert!(message.iter().any(|s| s.text == "world" && s.style.bold)); + let nick = message.iter().find(|s| s.text == "alice").unwrap(); + assert_eq!(nick.style, Style { bold: true, ..Style::fg(nick_colour("alice")) }); + let text: String = message.iter().map(|s| s.text.as_str()).collect(); + assert_eq!(text, "[12:34:56] hello world"); + + let action = format_event(&Event { kind: "action".into(), from: "bob".into(), text: "waves".into(), ..Default::default() }, "01:02:03"); + assert!(action.iter().any(|s| s.text.ends_with("waves") && s.style.italic && s.style.fg == Some(MAGENTA))); + + let kick = format_event(&Event { kind: "kick".into(), from: "op".into(), command: "eve".into(), text: "spam".into(), ..Default::default() }, "t"); + assert_eq!(kick.iter().map(|s| s.text.as_str()).collect::(), "[t] <-- eve was kicked by op (spam)"); + let system = format_event(&Event { kind: "system".into(), text: "hi".into(), ..Default::default() }, "t"); + assert_eq!(system.last().unwrap(), &span("hi", Style::fg(CYAN))); + } +} diff --git a/rust/core/src/keyfiles.rs b/rust/core/src/keyfiles.rs new file mode 100644 index 0000000..9735b15 --- /dev/null +++ b/rust/core/src/keyfiles.rs @@ -0,0 +1,130 @@ +//! SSH key file helpers that need no SSH crypto: spotting private keys, listing `~/.ssh`, and +//! sanitising key and upload file names. + +use crate::Result; +use std::io::Read; +use std::path::{Path, PathBuf}; + +const PRIVATE_KEY_MARKERS: [&str; 6] = + ["BEGIN OPENSSH PRIVATE KEY", "BEGIN RSA PRIVATE KEY", "BEGIN DSA PRIVATE KEY", "BEGIN EC PRIVATE KEY", "BEGIN ED25519 PRIVATE KEY", "BEGIN PRIVATE KEY"]; + +const IGNORED: [&str; 8] = + ["authorized_keys", "authorized_keys2", "config", "known_hosts", "known_hosts.old", "ssh_config", "ssh_known_hosts", "ssh_known_hosts2"]; + +/// Whether the first 512 bytes look like a private key. Unreadable or empty paths are `false` +/// (this is also Go's `FileIsPrivateKey`). +pub fn is_likely_private_key(path: &Path) -> bool { + let mut head = Vec::with_capacity(512); + let Ok(f) = std::fs::File::open(path) else { return false }; + if f.take(512).read_to_end(&mut head).is_err() || head.is_empty() { + return false; + } + head.starts_with(b"openssh-key-v1") || PRIVATE_KEY_MARKERS.iter().any(|m| head.windows(m.len()).any(|w| w == m.as_bytes())) +} + +/// Candidate private keys in `~/.ssh`, sorted. A missing home or `.ssh` gives an empty list. +pub fn list_ssh_keys() -> Result> { + match std::env::home_dir() { + Some(home) => list_ssh_keys_in(&home.join(".ssh")), + None => Ok(vec![]), + } +} + +pub fn list_ssh_keys_in(dir: &Path) -> Result> { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), + Err(e) => return Err(e.into()), + }; + let mut keys = Vec::new(); + for entry in entries { + let entry = entry?; + if entry.file_type()?.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + if IGNORED.contains(&name.as_str()) || name.ends_with(".pub") || name.ends_with(".old") { + continue; + } + let path = dir.join(&name); + if is_likely_private_key(&path) { + keys.push(path); + } + } + keys.sort(); + Ok(keys) +} + +/// Trimmed name of ASCII letters, digits, `-` and `_`. +pub fn validate_ssh_key_name(name: &str) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err("key name is required".into()); + } + if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { + return Err("key name contains invalid characters (use alphanumeric, hyphens, underscores)".into()); + } + Ok(name.into()) +} + +/// The base name of an uploaded file (either slash style), rejecting `.`, `..` and NULs. +pub fn safe_sftp_upload_name(file_name: &str) -> Result { + let name = file_name.trim().replace('\\', "/"); + if name.is_empty() { + return Err("invalid file name".into()); + } + // Go's path.Base: trailing slashes dropped; all slashes gives "/". + let trimmed = name.trim_end_matches('/'); + let base = if trimmed.is_empty() { "/" } else { trimmed.rsplit('/').next().unwrap_or(trimmed) }; + if matches!(base, "" | "." | ".." | "/") || base.contains('\0') { + return Err("invalid file name".into()); + } + Ok(base.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upload_names() { + assert_eq!(safe_sftp_upload_name("../report .txt").unwrap(), "report .txt"); + assert_eq!(safe_sftp_upload_name(r"C:\Users\me\a.txt").unwrap(), "a.txt"); + assert_eq!(safe_sftp_upload_name("dir/b.txt/").unwrap(), "b.txt"); + for bad in ["..", "folder/..", r"folder\..", "", " ", "///", ".", "a\0b"] { + assert!(safe_sftp_upload_name(bad).is_err(), "{bad:?}"); + } + } + + #[test] + fn key_names() { + assert_eq!(validate_ssh_key_name(" id_ed25519-work ").unwrap(), "id_ed25519-work"); + for bad in ["", " ", "a/b", "../x", "a b", "kéy"] { + assert!(validate_ssh_key_name(bad).is_err(), "{bad:?}"); + } + } + + #[test] + fn lists_private_keys() { + let dir = std::env::temp_dir().join(format!("nexus-keys-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("subdir")).unwrap(); + let key = "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n"; + for (name, body) in [ + ("id_ed25519", key), + ("id_rsa", "-----BEGIN RSA PRIVATE KEY-----\n"), + ("id_ed25519.pub", key), + ("config", key), + ("key.old", key), + ("notes.txt", "hello"), + ("empty", ""), + ] { + std::fs::write(dir.join(name), body).unwrap(); + } + let keys = list_ssh_keys_in(&dir).unwrap(); + assert_eq!(keys, vec![dir.join("id_ed25519"), dir.join("id_rsa")]); + assert!(!is_likely_private_key(Path::new(""))); + assert!(list_ssh_keys_in(&dir.join("missing")).unwrap().is_empty()); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/rust/core/src/lib.rs b/rust/core/src/lib.rs new file mode 100644 index 0000000..2a4ec6e --- /dev/null +++ b/rust/core/src/lib.rs @@ -0,0 +1,21 @@ +//! nexus-core: everything in Nexus Terminal that isn't drawing — storage, secrets, protocol +//! clients and the session manager. The egui app is one client of this crate. + +pub mod clients; +pub mod config; +pub mod exportimport; +pub mod ircformat; +pub mod keyfiles; +pub mod models; +pub mod recording; +pub mod secrets; +pub mod service; +pub mod sessions; +pub mod sshconfig; +pub mod storage; +pub mod terminalenv; +pub mod updater; +pub mod validation; + +pub type Error = Box; +pub type Result = std::result::Result; diff --git a/rust/core/src/models.rs b/rust/core/src/models.rs new file mode 100644 index 0000000..1892c4e --- /dev/null +++ b/rust/core/src/models.rs @@ -0,0 +1,912 @@ +//! Data model shared by storage, the session manager and the UI. Field names serialise exactly as +//! the Go structs did, so database JSON columns and `.link`/`.nexus` exports round-trip unchanged. + +use chrono::{DateTime, FixedOffset, Local}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::BTreeMap; + +pub const DEFAULT_SSH_PORT: u16 = 22; +pub const DEFAULT_TELNET_PORT: u16 = 23; +pub const DEFAULT_RAW_PORT: u16 = 23; +pub const DEFAULT_GITHUB_PORT: u16 = 443; +pub const DEFAULT_VNC_PORT: u16 = 5900; +pub const DEFAULT_IRC_PORT: u16 = 6667; +pub const DEFAULT_IRC_TLS_PORT: u16 = 6697; +pub const DEFAULT_CONNECT_TIMEOUT: i64 = 30; +pub const DEFAULT_SCROLLBACK: i64 = 10000; +pub const DEFAULT_FONT_SIZE: i64 = 14; +pub const DEFAULT_FONT_FAMILY: &str = "JetBrains Mono, Menlo, Monaco, Consolas, monospace"; +pub const DEFAULT_CURSOR_STYLE: &str = "block"; +pub const DEFAULT_TERMINAL_TYPE: &str = "xterm-256color"; +pub const DEFAULT_KEEPALIVE_INTERVAL: i64 = 30; +pub const DEFAULT_KEEPALIVE_COUNT_MAX: i64 = 3; + +pub type Time = DateTime; + +pub fn now() -> Time { + Local::now().fixed_offset() +} + +/// Go's zero `time.Time`, which is what a missing timestamp decodes to. +pub fn zero_time() -> Time { + DateTime::parse_from_rfc3339("0001-01-01T00:00:00Z").unwrap() +} + +/// Go writes nil slices and maps as `null`; treat that as empty. +fn nd<'de, D: Deserializer<'de>, T: Default + Deserialize<'de>>(d: D) -> Result { + Ok(Option::::deserialize(d)?.unwrap_or_default()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Protocol { + #[default] + Ssh, + Telnet, + Raw, + Github, + Vnc, + Irc, + Serial, + Local, +} + +impl Protocol { + pub const SAVED: [Protocol; 7] = [Protocol::Ssh, Protocol::Telnet, Protocol::Raw, Protocol::Github, Protocol::Vnc, Protocol::Irc, Protocol::Serial]; + pub fn id(self) -> &'static str { + match self { + Protocol::Ssh => "ssh", + Protocol::Telnet => "telnet", + Protocol::Raw => "raw", + Protocol::Github => "github", + Protocol::Vnc => "vnc", + Protocol::Irc => "irc", + Protocol::Serial => "serial", + Protocol::Local => "local", + } + } + pub fn from_id(s: &str) -> Option { + [Protocol::Local].into_iter().chain(Self::SAVED).find(|p| p.id() == s) + } + pub fn label(self) -> &'static str { + match self { + Protocol::Ssh => "SSH", + Protocol::Telnet => "Telnet", + Protocol::Raw => "RAW TCP", + Protocol::Github => "GitHub", + Protocol::Vnc => "VNC", + Protocol::Irc => "IRC", + Protocol::Serial => "Serial", + Protocol::Local => "Local", + } + } + /// Protocols whose sessions render in a terminal view. + pub fn is_terminal(self) -> bool { + matches!(self, Protocol::Ssh | Protocol::Telnet | Protocol::Serial | Protocol::Local) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum HostKeyPolicy { + #[serde(rename = "strict")] + Strict, + #[default] + #[serde(rename = "ask")] + Ask, + #[serde(rename = "accept-new")] + AcceptNew, + #[serde(rename = "insecure")] + Insecure, +} + +impl HostKeyPolicy { + pub const ALL: [HostKeyPolicy; 4] = [Self::Strict, Self::Ask, Self::AcceptNew, Self::Insecure]; + pub fn label(self) -> &'static str { + match self { + Self::Strict => "Strict (known hosts only)", + Self::Ask => "Ask on new or changed keys", + Self::AcceptNew => "Accept new, reject changed", + Self::Insecure => "Insecure (accept anything)", + } + } +} + +/// How a credential is obtained. Unknown strings (and the legacy "prompt") are kept as +/// ask-every-time, matching Go's normaliseSecretMode. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Default)] +pub enum SecretMode { + #[default] + #[serde(rename = "saved")] + Saved, + #[serde(rename = "session-only")] + SessionOnly, + #[serde(rename = "ask-every-time")] + AskEveryTime, +} + +impl<'de> Deserialize<'de> for SecretMode { + fn deserialize>(d: D) -> Result { + Ok(match Option::::deserialize(d)?.as_deref().unwrap_or("") { + "saved" | "" => SecretMode::Saved, + "session-only" => SecretMode::SessionOnly, + _ => SecretMode::AskEveryTime, + }) + } +} + +impl SecretMode { + pub const ALL: [SecretMode; 3] = [Self::Saved, Self::SessionOnly, Self::AskEveryTime]; + pub fn label(self) -> &'static str { + match self { + Self::Saved => "Save securely", + Self::SessionOnly => "Remember for this session", + Self::AskEveryTime => "Ask every time", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum LineEnding { + Lf, + Cr, + #[default] + Crlf, + None, +} + +impl LineEnding { + pub const ALL: [LineEnding; 4] = [Self::Crlf, Self::Lf, Self::Cr, Self::None]; + pub fn bytes(self) -> &'static [u8] { + match self { + Self::Lf => b"\n", + Self::Cr => b"\r", + Self::Crlf => b"\r\n", + Self::None => b"", + } + } + pub fn label(self) -> &'static str { + match self { + Self::Lf => "LF (\\n)", + Self::Cr => "CR (\\r)", + Self::Crlf => "CRLF (\\r\\n)", + Self::None => "None", + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Profile { + #[serde(default)] + pub id: String, + #[serde(default)] + pub display_name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub protocol: Protocol, + #[serde(default)] + pub host: String, + #[serde(default)] + pub port: i64, + #[serde(default)] + pub username: String, + #[serde(default, deserialize_with = "nd")] + pub tags: Vec, + #[serde(default)] + pub folder: String, + #[serde(default)] + pub environment: String, + #[serde(default)] + pub notes: String, + #[serde(default)] + pub favourite: bool, + #[serde(default)] + pub last_connected: Option